2 Commits
Author SHA1 Message Date
sneak 430bd76230 Emit slog attributes from every handler (closes #19)
check / check (push) Successful in 31s
check / check (pull_request) Successful in 33s
The handlers took attributes and dropped them on the floor. Handle never
read record.Attrs, so the inline slog.Info("casting", "device", d) form
lost its fields; WithAttrs returned the receiver unchanged, so anything
attached to a derived logger vanished; and WithGroup did the same, so
grouping silently did nothing. The JSON and webhook handlers marshaled
the slog.Record value directly, which cannot work: a record keeps its
attributes in unexported fields, so encoding/json only ever saw Time,
Message, Level and PC.

The consequence was perverse. Converting log.Printf("[%s] Casting %s",
device, file) into structured attributes, as the Go styleguide asks,
left the output with strictly less information than before, and the
calling code reviewed as correct because it was correct.

A small shared attribute layer now holds the accumulated attributes and
open groups. It is copy-on-write, so two loggers derived from one parent
cannot leak attributes into each other, and it qualifies attributes by
the groups open at the time they were attached, per the slog.Handler
contract. Rendering follows each handler's format: the JSON and webhook
handlers emit attributes as object fields with groups as nested objects,
merging a group named twice rather than duplicating its key; the console
handler appends key=value pairs, groups flattened to dotted keys, with
key and value each quoted where leaving it bare would be ambiguous. The
key is quoted as one token, prefix included, so the "=" that separates
the pair is always the first one outside quotes: a key of "a=b" reads as
"a=b"=v rather than as a=b=v, which parses as the key "a" holding the
value "b=v". That is what slog.NewTextHandler does, and the console
rendering was compared against it key by key.

Values the caller logged go into the json payload by reference, because
rendering only reads them, which leaves the group merge as the one place
a value already in the payload is written to. It merges only into the
unexported groupMap type this package allocates for its own groups: a
caller's map[string]any is a different type and can never satisfy that
type assertion, so it is replaced rather than written into. The
invariant holds by construction - nothing reachable from the caller is
modified by logging it.

Values are resolved through slog.Value.Resolve, so LogValuer values are
reported as the value they stand for instead of as a struct, and errors
are reported as their message rather than as the empty object
encoding/json makes of them. Anything encoding/json cannot marshal falls
back to its slog string form rather than rendering as an empty object.

A duration is nanoseconds as a number in the json and webhook payloads,
matching slog.NewJSONHandler, so a consumer can compare and aggregate
the field without parsing it first. The console line keeps the readable
"3s" form, matching slog.NewTextHandler, because a person reads that
one.

The record's own fields keep the names they have always had - Time,
Level, Message, PC - and win a collision with an attribute key, so
existing consumers of the json output see no change beyond the added
fields. That, and a repeated key keeping its last value - except where
both are groups, which merge - are the two ways an attribute can go
missing from the json output; both are now written down in the README,
merge exception included, rather than left to be discovered.

All three handlers are covered, including WebhookHandler, which had the
same defect and is reached through the same MultiplexHandler.
2026-08-10 13:22:05 +00:00
sneak 3dbe6954d7 Add a failing test pinning the discarded slog attributes
Every handler in this package accepts slog attributes and then throws
them away: Handle never reads record.Attrs, and WithAttrs and WithGroup
return the receiver unchanged in ConsoleHandler, JSONHandler and
WebhookHandler alike. So slog.Info("casting", "device", d, "file", f)
emits the message and silently loses both fields, which makes properly
structured logging carry less information than the interpolated
log.Printf calls it replaces.

The test asserts on the bytes the handlers actually write - os.Stdout
for the console and JSON handlers, the posted body for the webhook
handler - rather than on internal state, because the output is where the
loss is observable. It covers record attributes, WithAttrs accumulation,
WithAttrs not mutating its receiver so sibling loggers cannot leak
attributes into each other, WithGroup qualification, slog.Group nesting,
and LogValuer resolution, for each handler and through MultiplexHandler.

It also pins the properties a fix must not get wrong on the way past. A
value the caller logged is read and never written to, even when a group
later claims the same key - for maps and for slices, through the record
path and the derived-logger path, in all three handlers. A duration is
a number of nanoseconds in the json payload and the readable "3s" form
on the console. A console key is quoted on the same terms as a console
value, and as one token including its group prefix, so that the "=" that
separates the pair is always the first one outside quotes: a key of
"a=b" reads as "a=b"=v rather than the ambiguous a=b=v. The four
slog.Handler contract edge cases hold: an empty Attr is ignored, an
empty group is elided with its key, a group with an empty key is
inlined, and WithGroup("") is a no-op. And the two rules that follow
from the json payload being an object hold too: the record's own field
names win a collision, and a repeated key keeps its last value - except
where both are groups, which merge - while the console line keeps both.

This commit adds only the test, and it fails. The fix follows.

Refs: #19
2026-08-10 13:21:32 +00:00
40 changed files with 332 additions and 2415 deletions
-83
View File
@@ -1,83 +0,0 @@
# .dockerignore does NOT use .gitignore semantics. Docker matches with
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
# `/` and an unprefixed pattern is anchored at the context root. Every
# depth-independent pattern therefore needs `**/`, or `config/.env` and
# `certs/server.key` still ship while this file reads as solved. Only
# genuinely root-anchored entries go unprefixed. Never transplant these
# into .gitignore, where `**/` is wrong.
#
# Matching is case-sensitive, so secrets use character ranges rather
# than an ALL-CAPS twin, which would still miss `Server.Key`.
#
# Extend with this repo's own host-built artifacts, written anchored:
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
# deletes the package directory from the context.
# .git is sent without its config. Without a VERSION build argument the
# stage that compiles runs `git describe --tags --always` on .git, which
# does not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there.
# Each submodule keeps a config with the same exposure in its git directory
# under .git/modules/, nested again for a submodule's own submodules, or in
# its own .git directory when it keeps one.
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
# `deploy/config`, `config/lib`) loses its whole git directory, because
# `**/.git/modules/**/config` also matches that segment's directory
# under .git/modules/. Go's version stamping then fails the build;
# nothing leaks. Name such a submodule without that segment:
# `git submodule add --name`.
**/.git/config
**/.git/modules/**/config
# Agent scratch: one full checkout of the repo per in-flight agent.
# Anchored because it occurs once where agents run at the repo root.
# KNOWN GAP: a repo running agents in subdirectories still ships
# `services/api/.claude/` and must add its own anchored entry.
.claude
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Re-include a committed template with a negation if the
# build needs one: `!docs/example.env`.
**/*.[eE][nN][vV]
**/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC]
# Private keys and the bundles carrying them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
**/*.[pP][eE][mM]
**/*.[kK][eE][yY]
**/*.[pP]12
**/*.[pP][fF][xX]
**/[iI][dD]_[rR][sS][aA]
**/[iI][dD]_[dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
**/[iI][dD]_[eE][dD]25519
**/[iI][dD]_[eE][dD]25519_[sS][kK]
# Dependencies: restored inside the image, never copied in.
**/node_modules
# OS metadata.
**/.DS_Store
**/Thumbs.db
# Editor state: never a build input, and it churns COPY.
**/*.swp
**/*.swo
**/*~
**/*.bak
**/.idea
**/.vscode
**/*.sublime-*
# This repository's host-built artifacts: the example program's binary,
# test binaries, coverage output, and the log yarn writes when an install
# fails.
/cmd/example/example
/*.test
/*.out
/yarn-error.log
# aider's files, among them a config file that can hold an API key.
**/.aider*
-15
View File
@@ -1,15 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[Makefile]
indent_style = tab
[*.go]
indent_style = tab
+7 -4
View File
@@ -1,9 +1,12 @@
name: check name: check
on: [push]
on:
push:
pull_request:
jobs: jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# actions/checkout v4.2.2, 2026-02-22 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - run: docker build .
- run: script/cibuild
+1 -56
View File
@@ -1,57 +1,2 @@
# OS
.DS_Store
Thumbs.db
# Editors
*.swp
*.swo
*~
*.bak
.idea/
.vscode/
*.sublime-*
# Agent scratch (worktrees of this repo, created and destroyed by
# in-flight tooling). Unanchored: .gitignore patterns already match at
# every depth, so no prefix is wanted here. This is not a .dockerignore
# entry and must not be given a `**/` prefix on the way into one.
.claude/
# Node
node_modules/
# Secrets. Unanchored like every entry above, so each matches at every
# depth. Matching is case-sensitive on Linux, so names use character
# ranges rather than a lowercase form that misses `Server.Key`.
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Only the templates `example.env` and `sample.env` are
# re-included below. A repository that commits any other template adds
# its own negation after these lines, for example `!.env.example`.
*.[eE][nN][vV]
.[eE][nN][vV].*
.[eE][nN][vV][rR][cC]
!example.env
!sample.env
# Private keys and the bundles carrying them.
*.[pP][eE][mM]
*.[kK][eE][yY]
*.[pP]12
*.[pP][fF][xX]
[iI][dD]_[rR][sS][aA]
[iI][dD]_[dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
[iI][dD]_[eE][dD]25519
[iI][dD]_[eE][dD]25519_[sS][kK]
# This repository's own entries. Go: logs, coverage output, test
# binaries, and the example program's binary.
*.log
*.out
*.test
cmd/example/example
# aider's files, among them a config file that can hold an API key.
.aider* .aider*
cmd/example/example
-99
View File
@@ -1,99 +0,0 @@
version: "2"
# Config schema uses the golangci-lint v2 layout (settings live under
# linters.settings, not top-level linters-settings) so that the
# thresholds below are actually applied by golangci-lint >= v2.
run:
timeout: 5m
modules-download-mode: readonly
linters:
default: all
enable:
# Successor to the deprecated gomodguard. Named explicitly, rather than
# left to `default: all`, because it carries the module policy below.
- gomodguard_v2
disable:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
- godot # Requires comments to end with periods
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
# Deprecated: the warning is attached to the old name, so it is
# silenced by disabling that name, not by enabling the successor.
- wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
depguard:
# Test-support code must not be compiled into the shipped binary. A
# test-support package exists to hand a test privileges the program
# itself must never have, so a file that is not a test must not import
# one. Test files, and the files inside a package whose directory name
# ends in `test`, are where that code belongs, and are exempt.
#
# The deny list below is the one part of this file a repository is
# expected to extend, and the only part it may. depguard matches an
# import path against a list of prefixes, so it cannot be told "any path
# whose last segment ends in test"; a repository's own test-support
# packages have to be named here one at a time, by full import path,
# under a module path that differs from repository to repository. Add
# them; change nothing else.
rules:
test-support:
list-mode: lax
files:
- "$all"
- "!$test"
- "!**/*test/**"
deny:
- pkg: net/http/httptest
desc: >-
Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary.
# Only decisions already recorded in the Go package defaults are
# listed here. Every entry matches the module path exactly.
gomodguard_v2:
blocked:
- module: github.com/rs/zerolog
recommendations:
- log/slog
reason: "Structured logging is stdlib log/slog."
# One entry per pre-fork module path, because the later releases
# are separate paths. A prefix match would be shorter but would
# also reach github.com/go-redis/redismock, the test double for
# the successor these entries recommend.
- module: github.com/go-redis/redis
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v7
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v8
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/sergi/go-diff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "No unified diff output; use go-udiff."
- module: github.com/hexops/gotextdiff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "Unmaintained fork; use go-udiff."
issues:
max-issues-per-linter: 0
max-same-issues: 0
-2
View File
@@ -1,2 +0,0 @@
node_modules/
yarn.lock
-4
View File
@@ -1,4 +0,0 @@
{
"tabWidth": 4,
"proseWrap": "always"
}
+11 -27
View File
@@ -1,36 +1,20 @@
# Lint stage. The format check is not here: it runs on the host, in # Lint stage: format check + golangci-lint
# script/fmt-check. # golangci-lint v1.64.8 (2025-02-18)
# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24 FROM golangci/golangci-lint@sha256:2987913e27f4eca9c8a39129d2c7bc1e74fbcf77f181e01cea607be437aa5cb8 AS lint
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
# Called directly: make lint is itself a docker build of this stage. RUN make fmt-check
RUN golangci-lint run --config .golangci.yml ./... RUN make lint
# Test stage: run full test suite under the race detector # Test stage: run full test suite
# golang 1.22.12 (Debian-based; -race needs its C toolchain), 2025-02-04
FROM golang@sha256:1cf6c45ba39db9fd6db16922041d074a63c935556a05c5ccb62d181034df7f02 AS test
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Called directly: make test is itself a docker build of this stage.
RUN go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 90s -race -v ./...; exit 1; }
# Final stage: a development environment, set up by script/bootstrap.
# The copies make a plain docker build run both stages above. No stage
# compiles a binary, since simplelog is a library, so none declares the
# VERSION build argument that script/docker and script/cibuild pass.
# golang 1.22.12 (2025-02-04) # golang 1.22.12 (2025-02-04)
FROM golang@sha256:1cf6c45ba39db9fd6db16922041d074a63c935556a05c5ccb62d181034df7f02 FROM golang@sha256:1cf6c45ba39db9fd6db16922041d074a63c935556a05c5ccb62d181034df7f02 AS test
# Depend on lint stage so both stages always run
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
WORKDIR /src WORKDIR /src
COPY script/ script/ COPY go.mod go.sum ./
COPY go.mod go.sum package.json yarn.lock ./ RUN go mod download
RUN script/bootstrap
COPY . . COPY . .
RUN make test
+13 -15
View File
@@ -1,30 +1,28 @@
.PHONY: bootstrap setup test fmt fmt-check lint check docker hooks .PHONY: test fmt fmt-check lint check docker hooks
default: check default: check
bootstrap:
@script/bootstrap
setup:
@script/setup
test: test:
@script/test @go test -v ./...
fmt: fmt:
@script/fmt goimports -l -w .
golangci-lint run --fix
fmt-check: fmt-check:
@script/fmt-check @test -z "$$(gofmt -l .)" || { echo "gofmt would reformat:"; gofmt -l .; exit 1; }
lint: lint:
@script/lint golangci-lint run
check: check: fmt-check lint test
@script/check
docker: docker:
@script/docker docker build --progress plain .
hooks: hooks:
@script/install-precommit @echo "Installing git hooks..."
@mkdir -p .git/hooks
@printf '#!/bin/sh\nmake check\n' > .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
@echo "Pre-commit hook installed."
+43 -108
View File
@@ -3,10 +3,11 @@
## Summary ## Summary
simplelog is an opinionated logging package designed to facilitate easy and simplelog is an opinionated logging package designed to facilitate easy and
structured logging in Go applications with an absolute minimum of boilerplate. structured logging in Go applications with an absolute minimum of
boilerplate.
The idea is that you can add a single import line which replaces the stdlib The idea is that you can add a single import line which replaces the
`log/slog` default handler, and solve the 90% case for logging. stdlib `log/slog` default handler, and solve the 90% case for logging.
## Current Status ## Current Status
@@ -17,15 +18,13 @@ Released v1.0.0 2024-06-14. Works as intended. No known bugs.
- if output is a tty, outputs pretty color logs - if output is a tty, outputs pretty color logs
- if output is not a tty, outputs json - if output is not a tty, outputs json
- supports delivering each log message via a webhook - supports delivering each log message via a webhook
- emits every `slog` attribute: those passed to a log call, those accumulated - emits every `slog` attribute: those passed to a log call, those
with `WithAttrs`, and those qualified by `WithGroup`. `slog.Group` values accumulated with `WithAttrs`, and those qualified by `WithGroup`.
nest, and `slog.LogValuer` values are resolved. In json output attributes are `slog.Group` values nest, and `slog.LogValuer` values are resolved. In
object fields (groups become nested objects); in console output they are json output attributes are object fields (groups become nested objects);
appended as `key=value` pairs, with grouped keys written as `group.key=value`. in console output they are appended as `key=value` pairs, with grouped
See [Attribute output](#attribute-output) for the details worth knowing keys written as `group.key=value`. See
- reports a record that could not be delivered as an error from the handler's [Attribute output](#attribute-output) for the details worth knowing
`Handle` method. See [When delivery fails](#when-delivery-fails) for how to
receive it
## Planned Features ## Planned Features
@@ -47,9 +46,9 @@ go get sneak.berlin/go/simplelog
## Usage ## Usage
Below is an example of how to use SimpleLog in a Go application. This example is Below is an example of how to use SimpleLog in a Go application. This
provided in the form of a `main.go` file, which demonstrates logging at various example is provided in the form of a `main.go` file, which demonstrates
levels using structured logging syntax. logging at various levels using structured logging syntax.
```go ```go
package main package main
@@ -71,103 +70,39 @@ func main() {
## Attribute output ## Attribute output
Attributes reach every handler: the ones passed to the log call, the ones Attributes reach every handler: the ones passed to the log call, the ones
accumulated with `WithAttrs`, and the ones qualified by the groups open at the accumulated with `WithAttrs`, and the ones qualified by the groups open at
time they were attached. `slog.Group` values nest, and `slog.LogValuer` values the time they were attached. `slog.Group` values nest, and
are resolved to the value they stand for. A few behaviours are worth knowing `slog.LogValuer` values are resolved to the value they stand for. A few
before you rely on them. behaviours are worth knowing before you rely on them.
**Your values are never modified.** Whatever you log is read and rendered, never **Your values are never modified.** Whatever you log is read and rendered,
written to. A map or a slice you pass to `slog.Any` comes back from the logger never written to. A map or a slice you pass to `slog.Any` comes back from
exactly as you handed it over, even when a group later uses the same key. the logger exactly as you handed it over, even when a group later uses the
same key.
**Durations are nanoseconds in json, and readable on the console.** The json and **Durations are nanoseconds in json, and readable on the console.** The
webhook payloads emit a `slog.Duration` as a number of nanoseconds, matching json and webhook payloads emit a `slog.Duration` as a number of
`slog.NewJSONHandler`, so a consumer can compare and aggregate the field without nanoseconds, matching `slog.NewJSONHandler`, so a consumer can compare and
parsing it. The console line emits the same duration as `3s`, matching aggregate the field without parsing it. The console line emits the same
`slog.NewTextHandler`, because a person reads it. duration as `3s`, matching `slog.NewTextHandler`, because a person reads
it.
**The json payload is an object, with the consequences an object has.** The **The json payload is an object, with the consequences an object has.**
record's own fields are named `Time`, `Level`, `Message` and `PC`, and they own The record's own fields are named `Time`, `Level`, `Message` and `PC`, and
those names: an attribute keyed after one of them is dropped from the json and they own those names: an attribute keyed after one of them is dropped from
webhook output. A key logged more than once keeps its last value there for the the json and webhook output. A key logged more than once keeps its last
same reason, with one exception: when both are `slog.Group` values sharing a value there for the same reason, with one exception: when both are
key, the two groups are merged into one object holding the members of both, `slog.Group` values sharing a key, the two groups are merged into one
rather than the second replacing the first. Neither the collision nor the merge object holding the members of both, rather than the second replacing the
applies to the console output, which is a line of text: every pair appears, in first. Neither the collision nor the merge applies to the console output,
order. If you need a field called `message`, pick a key that does not collide - which is a line of text: every pair appears, in order. If you need a field
the collision is silent. called `message`, pick a key that does not collide - the collision is
silent.
**Empty things follow the `slog.Handler` contract.** An empty `Attr` is ignored, **Empty things follow the `slog.Handler` contract.** An empty `Attr` is
an empty group is elided along with its key, a group with an empty key is ignored, an empty group is elided along with its key, a group with an
inlined into its parent, and `WithGroup("")` returns the handler unchanged. empty key is inlined into its parent, and `WithGroup("")` returns the
handler unchanged.
## When delivery fails
Every handler returns an error from `Handle` when it could not deliver the
record:
- `ConsoleHandler` and `JSONHandler` return the error from their write to
stdout, wrapped, so `errors.Is` still matches the original
- `WebhookHandler` returns an error when the request fails, and also when the
server answers with a status outside 2xx, a redirect included, since it does
not follow redirects. A request that has not finished after 5 seconds fails
with a timeout error, so a webhook server that never answers holds up a log
call for 5 seconds at most
- `MultiplexHandler`, which simplelog installs as the default, passes the record
to every handler it holds even after one of them fails, then returns all their
errors joined with `errors.Join` (nil if none failed)
`slog.Info`, `slog.Error` and the other `slog.Logger` methods throw that error
away. That is how `log/slog` works, and simplelog cannot change it. To find out
whether a record was delivered, build a `slog.Record` and pass it to the handler
yourself, for example `slog.Default().Handler().Handle(ctx, record)`, then check
the error it returns. Console output takes the file and line from the record's
`PC`, which you set with `runtime.Callers` as the `log/slog` documentation
shows. A record whose `PC` is zero prints `???:0` instead.
## Entrypoints
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call them.
The scripts are POSIX sh (not bash) so they run in minimal containers such as
alpine. We provide:
- `script/bootstrap` — install all dependencies (go if missing, goimports with
`go install` at a pinned commit unless the installed one already reports the
pinned version, then `go mod download`; node through nvm at a pinned version
if node is missing, yarn at a pinned version if it is missing, then the
`prettier` that `package.json` and `yarn.lock` pin); golangci-lint is not
installed, since it runs only in Docker
- `script/setup` — set up the repo for development after a fresh clone: runs
`script/bootstrap`, then `script/install-precommit`
- `script/projectname` — output the project name (our own extension); used by
`script/docker` for the image tag
- `script/test` — run the test suite under the race detector in Docker by
building only the `test` stage of the `Dockerfile`, without the build cache,
so every run tests; the image is tagged `simplelog-test`
- `script/lint` — run golangci-lint in Docker by building only the `lint` stage
of the `Dockerfile`, without the build cache, so every run lints; the image is
tagged `simplelog-lint`
- `script/fmt` — format all files (writes): Go with the goimports that
`script/bootstrap` installs, then every `*.md` file with `prettier`
- `script/fmt-check` — check formatting (read-only); fails if `gofmt -l` reports
files, or if `prettier` would change any `*.md` file
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
extension)
- `script/docker` — build the Docker image without the build cache, tagged via
`script/projectname` (byte-identical across repos); it also passes the output
of `git describe` as the `VERSION` build argument, which is ignored because
this `Dockerfile` does not declare it
- `script/cibuild` — what CI runs: `script/bootstrap`, then `script/check`, then
the same image build as `script/docker` (byte-identical across repos)
- `script/precommit` — run by the git pre-commit hook (our own extension); runs
a `go mod tidy` guard, then `script/check`
- `script/install-precommit` — installs the git pre-commit hook (our own
extension); `make hooks` shims to it
`make hooks` installs the pre-commit hook that runs `script/precommit`.
## License ## License
-679
View File
@@ -1,679 +0,0 @@
---
title: Repository Policies
last_modified: 2026-10-04
---
This document covers repository structure, tooling, and workflow standards. Code
style conventions are in separate documents:
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
(general, bash, Docker)
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
---
- Cross-project documentation (such as this file) must include
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
with the authoritative source as policies evolve.
- **ALL external references must be pinned by cryptographic hash.** This
includes Docker base images, Go modules, npm packages, GitHub Actions, and
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
`:3.21`, etc.) are server-mutable and therefore remote code execution
vulnerabilities. The ONLY acceptable way to reference an external dependency
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
etc. Instead, download a specific release archive from GitHub, verify its hash
(hardcoded in the Dockerfile or script), and only then install. Unverified
install scripts are arbitrary remote code execution. This is the single most
important rule in this document. Double-check every external reference in
every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets:
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Repos follow the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
pattern: the implementation of each Makefile target lives in an executable
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
`script/docker`), and the Makefile targets are thin shims that call them. The
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
minimal containers (e.g. alpine images have no bash); locate the repo root
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
for development after a fresh clone: runs `bootstrap`, then
`install-precommit`, plus any repo-specific initialization), `test`, and
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
with the version; the Gitea workflow calls it. **`script/cibuild` runs
`script/bootstrap` first**, because the workflow checks out the repo and runs
nothing else, while `script/fmt-check` runs the formatter on the host: on a
pristine checkout with nothing installed the run dies there, after the
containerised gates have passed. **The bootstrap alone is not enough**:
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
source nvm for the pinned node version before invoking it, exactly as
`script/bootstrap`'s own install step does. A runner carrying nothing but
docker and git then gets through `script/check`. Four further scripts are our
own extensions to the standard: `script/check` runs `script/test`,
`script/lint` and `script/fmt-check`; `script/precommit` is what the git
pre-commit hook runs, and it calls `script/check`; `script/install-precommit`
installs the git pre-commit hook (the `make hooks` target shims to it); and
`script/projectname` (literally that filename) simply outputs the project's
name. Scripts that need the name call `script/projectname` — e.g.
`script/docker` assembles its image tag from it — so those scripts stay
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g.
`go mod tidy` verification in Go repos) belong in `script/precommit`, not in
the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single
source of truth for how these operations are run.
- The Makefile is authoritative documentation for how the repo is used. Beyond
the required targets above, it should have targets for every common operation:
running a local development server (`make run`, `make dev`), re-initializing
or migrating the database (`make db-reset`, `make migrate`), building
artifacts (`make build`), generating code, seeding data, or anything else a
developer would do regularly. If someone checks out the repo and types
`make<tab>`, they should see every meaningful operation available. A new
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`, and it carries the repo's gates: a
`lint` phase and a `test` phase, with the final stage depending on both so the
image cannot be built unless they pass. For non-server repos the final stage
brings up a development environment; for server repos it is the runtime image.
The gate phases and the build stage start from their pinned base images and
install what those images lack either inline, as the canonical Go `Dockerfile`
below does for `git`, or by running `script/bootstrap`, as the `prompts`
repo's own `Dockerfile` does for its yarn packages. The development
environment stage installs development prerequisites by running
`script/bootstrap` rather than duplicating its installs inline. A stage that
runs `script/bootstrap` COPYs `script/` and the dependency manifests
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
no separate lint file. `script/lint` and `script/test` each build one phase
and nothing else:
```sh
docker build --no-cache --target lint -t "$(script/projectname)-lint" .
docker build --no-cache --target test -t "$(script/projectname)-test" .
```
**A stage that is not the last one in the file is built only when the final
stage's chain depends on it, or when `--target` names it.** That is why the
two gates are always invoked by name here, and why the final stage carries a
`COPY --from=` of a harmless file from each of them: without that edge a
plain `docker build .` builds the last stage alone and exits 0 having linted
and tested nothing.
**Every `docker build` in `script/` is tagged**, here and in
`script/cibuild` and `script/docker`. An untagged build leaves a dangling
image behind on every invocation, on every developer host and every CI
runner; a tagged one replaces the previous image.
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
themselves a `docker build` and would recurse into a daemon that does not
exist in a build step. Formatting is the exception and stays on the host:
`script/fmt` writes the working tree, and `script/fmt-check` is its
read-only twin.
**No lint verdict may come from a host invocation of the linter.** On a
shared host golangci-lint reads a result cache keyed on file content rather
than location, so a second checkout of the same content is served the first
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
exit non-zero with `parallel golangci-lint is running` — a status a caller
cannot tell from real findings. Both have produced wrong verdicts in this
org, in both directions. A container has its own cache, its own `TMPDIR` and
a digest-pinned binary, so neither is reachable.
- **Any build that runs checks is built with `--no-cache`.** Docker invalidates
a `COPY` layer only when the copied content changes, so on an unchanged tree
the check `RUN` is served from cache, nothing executes, and the build still
exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
`script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
four, and there is no fifth — `script/check` runs the two gate phases and
`script/fmt-check`, and builds no image of its own. A bare `docker build .` is
not evidence that anything ran: a sub-second build reporting success is a
cache hit, not a result. Never invalidate by pruning — `docker builder prune`
and friends destroy a build cache shared with every other build on the host.
When a check is added or changed, prove it works by planting a defect it must
catch and watching the run fail on it, then revert the defect. A green run
alone shows neither that the check ran nor that it covers what it should.
- **The gate phases are separate stages, and the build stage depends on both.**
The lint phase is based on the `golangci/golangci-lint` image (pinned by
hash), so lint failures surface in seconds rather than after a full compile,
and the test phase is based on the Debian Go image. The canonical Go repo
`Dockerfile`:
```dockerfile
# Lint phase
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN golangci-lint run --config .golangci.yml ./...
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
# image ships and the alpine one does not.
# golang:1.x, YYYY-MM-DD
FROM golang@sha256:... AS test
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 90s -race -v ./...; exit 1; }
# Build stage. Nothing is wanted from either phase above; the copies
# are what make BuildKit build them first, so this stage cannot run
# unless lint and test passed.
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# The VERSION build arg when one is given, otherwise
# `git describe --tags --always` on the .git in the build context. With
# .git present, a version that is still empty, dev or unknown fails the
# build: git is missing or could not read the checkout.
ARG VERSION
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \
exit 1 ;; \
esac; \
fi; \
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage, and the last one
FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint phase uses the `golangci/golangci-lint` image directly (it has
both Go and the linter), so nothing needs installing.
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
purpose is the ordering edge. BuildKit runs stages in parallel by default,
and a stage nothing depends on is not built at all, so without these two
lines a red gate would not fail the build.
- Keep the runtime stage last, and if you add a stage after it, give it the
same two copies. A plain `docker build .` builds the last stage's chain
and nothing else.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint phase must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
- If the project requires CGO or system libraries for linting, install them
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
has no `apk`, so install with `apt-get` under the Debian package name
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
lists in the same `RUN`, so the layer does not keep them:
```dockerfile
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/*
```
- `.dockerignore` lets `.git` into the build context. It keeps out every git
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
repository's own, each submodule's under `.git/modules/`, and that of a
submodule keeping its own `.git` directory. `git describe` does not need
them, and each can hold a credential: a password in a remote URL, or the
token the CI checkout step stores there. A submodule whose name has a
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
git directory to `**/.git/modules/**/config`, and Go's version stamping
then fails the build: give it a name without that segment
(`git submodule add --name`). The stage that compiles has `git` (the
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
takes the version from the `VERSION` build argument when one is given,
otherwise from `git describe --tags --always`. That gives the tag on a
tagged commit; on a later commit, the tag, the number of commits since it
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
tag is reachable. The stage that compiles also marks its working directory
safe for git (`git config --system --add safe.directory /src`): a context
sent as a tar stream keeps the sender's file owners, and git refuses a
checkout owned by another user, so the version would come out empty.
`ARG VERSION` has no default, and the build fails if the context carries
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` on push, and checks out the repo as its only other step.
That script bootstraps, runs the gate phases, and then builds the image, so a
successful run means every check passed; a bare `docker build .` does not
carry the same guarantee, because its gate phases may come from the cache. The
image build is uncached and so runs the gate phases a second time. That is the
price of the rule above, and it is worth paying: the image that ships is built
from a run of its own gates rather than from a cache entry. A separate
workflow limited to `main` by a `branches` list under `on: push` cannot be
checked by review: to try a change to it, add the feature branch to that list
and push, then remove the branch from the list again before merging. Keep any
job in it that publishes behind `if: github.ref_name == 'main'`, so the run
from the feature branch publishes nothing.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
two exceptions: four-space indents (except Go), and `proseWrap: always` for
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
testing is not possible in the repo, `script/precommit` may skip `script/test`
and run only `script/lint` and `script/fmt-check`. The hook is installed by
`script/install-precommit`; the Makefile must provide a `make hooks` target
that shims to it.
- All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
tests exist yet, add the most minimal test possible — e.g. importing the
module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 60 seconds. That is the hard cap, and a
suite that exceeds it fails. Under 20 seconds is the target. A suite between
20 and 60 seconds is still green, but the overage must be filed as an
improvement bug against that repo. Add a 90-second timeout to the test
invocation (`go test -timeout 90s`). The backstop deliberately sits above the
hard cap so that it catches a genuinely hung test rather than a merely slow
one.
- **The test command should use the conditional verbose rerun pattern.** Run
tests without `-v` (verbose) first. If tests fail, automatically rerun with
`-v` to show full output. This keeps CI logs and `docker build` output clean
on success (just package/suite summaries) while providing full diagnostic
detail on failure (every test case, every assertion). The command lives in the
`test` phase of the `Dockerfile`, since `script/test` builds that phase; the
Makefile form below is the same pattern for any repo-local invocation:
```makefile
test:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; exit 1; }
```
Go example:
```makefile
test:
@go test -count=1 -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
```
`-count=1` is required on both invocations: it defeats Go's test _result_
cache, so neither run can report a stored pass in place of running the
tests. It leaves the build cache alone, so it costs the runtime of the suite
and no recompilation.
That cache is Go's own, separate from Docker's layer cache. Go stores a
passing result in its cache directory (`GOCACHE`), and when the same tests
run again on unchanged code it prints that result, marked `(cached)`,
without running them. That matters on a developer's machine, where this
target runs and the directory lasts from one run to the next. The `test`
phase of the `Dockerfile` needs no `-count=1`: its base image holds no
result for this repo's tests and nothing before its `go test` step runs a
test, so there is nothing to replay. `--no-cache` (above) is what makes that
step run on an unchanged tree.
Python example:
```makefile
test:
@python -m pytest || \
{ echo "--- Rerunning with -v for details ---"; \
python -m pytest -v; exit 1; }
```
The `exit 1` ensures the target always fails after a rerun — the first run
already proved the tests are broken, so the build must not pass even if a
flaky test happens to succeed on the second attempt. The rerun exists solely
for diagnostic output.
- Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary
directories.
- `main` must always pass `make check`, no exceptions.
- Never commit secrets. `.env` files, credentials, API keys, and private keys
must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
setting up a new repo. These patterns are written to `.gitignore`'s own
semantics, in which an unanchored pattern already matches at every depth; they
are not a `.dockerignore` and must not be transplanted into one unmodified.
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
across unmodified leaves secrets in the build context.** Docker matches with
`moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so
`*` does not cross `/` and a pattern without a leading `**/` is anchored at
the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key`
therefore excludes only the copies at the repository root, while `config/.env`
and `certs/server.key` still reach the context and can land in an image layer
— which is more dangerous than a short file with no secret patterns at all,
because it reads as solved and stops anyone looking. Give every
depth-independent pattern the `**/` prefix and leave only genuinely
root-anchored entries unprefixed: `.claude`, and the repo's own host-built
binary, written `/myapp` and never `**/myapp`, which would also match
`cmd/myapp/` and delete the package directory from the context. Matching is
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`,
and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern
also catches something the build needs, re-include it with a negation
(`!docs/example.env`); deleting the pattern reopens the exposure for every
other file it covers. Fetch the standard `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend
it with the repo's own artifacts.
- **In-repo agent scratch belongs in both files, written to each file's own
semantics.** `.claude/` holds one worktree per in-flight agent — an entire
additional checkout of the repo — so under `COPY . .` the build context
inflates by a multiple of the repo and another session's unreviewed work can
be copied into an image layer. In `.gitignore` the entry is `.claude/`,
unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/`
prefix, because the prefixed form would also delete any nested directory of
that name from the build. Anchoring carries a known gap that the canonical
`.dockerignore` states in its own comment, since consuming repos receive the
file and not the tracker: the directory is created in the agent's working
directory, so a repo running agents in subdirectories still ships
`services/api/.claude/` and must add its own anchored entry there.
- **A plain `docker build .` of a clone stamps the version that
`git describe --tags --always` gives**, derived from the `.git` in the build
context as the canonical `Dockerfile` above shows. Without its failure check,
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
and the build would still exit 0. `script/docker` and `script/cibuild` pass
the version they compute on the host; it takes precedence. They do this
byte-identically across repos:
```sh
# Own line: a failing command substitution inside an argument does not
# trip `set -e`, so the inline form degrades to an empty constant.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$(script/projectname)" .
```
`--always` makes an untagged repo yield an abbreviated commit hash rather
than failing, and the `[ -n "$version" ]` line is the single place the
fallback is applied — a live check that fires on a build from an export with
no `.git` and on a repository with no commits yet. Do not fold it into the
substitution as `|| echo unknown`, which makes the guard unreachable. The
Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
the scripts stay byte-identical. One consequence for CI: the standard
checkout action clones shallow and fetches no tags, so a repo that embeds a
tag-derived version must set `fetch-depth: 0` on its checkout step.
- **Verify `.dockerignore` by enumerating the image, not by reading the
patterns.** Plant files at the root _and_ at least two directories deep, build
a probe image that does `COPY . .`, and list what actually landed
(`docker run --rm --entrypoint find IMAGE /app`). The `transferring context`
size is not a substitute: a nested secret is a few bytes, and BuildKit
transfers only the delta from the previous build.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`.
- Make all changes on a feature branch. You can do whatever you want on a
feature branch.
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
_NEVER_ be modified by an agent: fetch it from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it
byte-identical, so that no repo can quietly loosen its own linting. Linter
configuration changes are made to the canonical copy in the `prompts` repo and
reach consuming repos by re-vendoring; an agent may open a PR against
canonical, which only the user merges. One list is exempt from byte-identity,
because it cannot be written once for every repo: the `deny` list of the
`test-support` depguard rule, where a repo names its own test-support packages
by full import path. A repo adds entries there and changes nothing else, and a
re-vendor carries its entries forward. The canonical golangci-lint version is
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
image
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
directive must not name a newer Go minor version than the one golangci-lint
was built with, or golangci-lint refuses to lint it: this release lints
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
installs golangci-lint on the host. A repo sets the lint phase digest to the
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
the two prompted the change: the canonical copy can name linters that an older
golangci-lint rejects, and a newer golangci-lint can add linters that
`default: all` switches on until the canonical copy disables them.
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
`PATH` only, so on an already-provisioned machine the pin is inert and a
version bump is a silent no-op — while the Dockerfile, installing into a clean
image, gets the pinned version, so a local `make check` and `make docker` can
disagree about what the tool even is. The canonical form:
- compares the installed version against the pin over the **whole** version
token; a parser that stops at the first `-` reports `2.12.2` for a host
running `2.12.2-rc1` and skips the install;
- treats absent, non-zero, empty or unrecognised `--version` output as a
mismatch, so the failure direction is a redundant install and never a
skipped one;
- after installing, re-resolves the binary the way callers do — `hash -r`,
then through `PATH`, not through the directory the installer wrote to —
and fails naming the resolved path, since an install that a shadowing
binary hides succeeds while changing nothing any caller sees;
- is actually called, and prints the version on both success paths: a
function defined and never invoked has the same exit status and the same
empty output as one that worked.
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
A Go tool a repo needs on the host is installed with `go install` pinned to
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
a `go.mod` tool dependency or through a `tools.go` file, either of which
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).
- Use `yarn`, not `npm`.
- Write all dates as YYYY-MM-DD (ISO 8601).
- Simple projects should be configured with environment variables.
- Dockerized web services listen on port 8080 by default, overridable with
`PORT`.
- **HTTP/web services must be hardened for production internet exposure before
tagging 1.0.** This means full compliance with security best practices
including, without limitation, all of the following:
- **Security headers** on every response:
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
and `includeSubDomains`.
- `Content-Security-Policy` (CSP) with a restrictive default policy
(`default-src 'self'` as a baseline, tightened per-resource as
needed). Never use `unsafe-inline` or `unsafe-eval` unless
unavoidable, and document the reason.
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
Prefer the `frame-ancestors` CSP directive as the primary control.
- `X-Content-Type-Options: nosniff`.
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
- `Permissions-Policy` restricting access to browser features the
application does not use (camera, microphone, geolocation, etc.).
- **Request and response limits:**
- Maximum request body size enforced on all endpoints (e.g. Go
`http.MaxBytesReader`). Choose a sane default per-route; never accept
unbounded input.
- Maximum response body size where applicable (e.g. paginated APIs).
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
against slowloris attacks.
- `WriteTimeout` on the `http.Server`.
- `IdleTimeout` on the `http.Server`.
- Per-handler execution time limits via `context.WithTimeout` or
chi/stdlib `middleware.Timeout`.
- **Authentication and session security:**
- Rate limiting on password-based authentication endpoints. API keys are
high-entropy and not susceptible to brute force, so they are exempt.
- CSRF tokens on all state-mutating HTML forms. API endpoints
authenticated via `Authorization` header (Bearer token, API key) are
exempt because the browser does not attach these automatically.
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
MD5, or SHA.
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
`Strict`) attributes.
- **Reverse proxy awareness:**
- True client IP detection when behind a reverse proxy
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
forwarded headers only from a configured set of trusted proxy
addresses — never trust `X-Forwarded-For` unconditionally.
- **CORS:**
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
only for public, unauthenticated read-only APIs.
- **Error handling:**
- Internal errors must never leak stack traces, SQL queries, file paths,
or other implementation details to the client. Return generic error
messages in production; detailed errors only when `DEBUG` is enabled.
- **TLS:**
- Services never terminate TLS directly. They are always deployed behind
a TLS-terminating reverse proxy. The service itself listens on plain
HTTP. However, HSTS headers and `Secure` cookie flags must still be
set by the application so that the browser enforces HTTPS end-to-end.
This list is non-exhaustive. Apply defense-in-depth: if a standard security
hardening measure exists for HTTP services and is not listed here, it is
still expected. When in doubt, harden.
- `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block.
- **Entrypoints**: Opens by stating that the repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard (with that link), then documents each provided `script/`
entrypoint and its purpose.
- **Rationale**: Why does this exist?
- **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put
the todo list in the README so a new agent can pick up where the last one
left off.
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
`LICENSE` file in the repo root and a License section in the README.
- **Author**: [@sneak](https://sneak.berlin).
- First commit of a new repo should contain only `README.md`.
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
committing.
- Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
and language-specific config). Everything else goes in a subdirectory.
Canonical subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
body is a single call into `internal/` or `pkg/`, no project logic in
`cmd/`
- `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root)
- `internal/` — Go internal packages
- `internal/db/migrations/` — database migrations
- `pkg/` — Go library packages
- `share/` — systemd units, data files
- `static/` — static assets (images, fonts, etc.)
- `web/` — web frontend source
- When setting up a new repo, files from the `prompts` repo may be used as
templates. Fetch them from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
- New repos must contain at minimum:
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`)
- `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
is never committed under a file or directory named after one agent tool, such
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
+43 -73
View File
@@ -1,89 +1,59 @@
# Workflow # Workflow
- branch (from `main`) * branch (from `main`)
- do the work in Next Step * do the work in Next Step
- move Next Step to the top of Completed Steps * move Next Step to the top of Completed Steps
- move the top item of Future Steps into Next Step * move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work) * commit (`TODO.md` changes in the same commit as the work)
- merge to `main` if the branch is not protected, otherwise open a PR * merge to `main` if the branch is not protected, otherwise open a PR
- push * push
# Status # Status
1.0+ 1.0+
Tagged v1.0.0 (2024-06-14) and 1.0.1 (2026-02-08). In post-1.0 maintenance; the Tagged v1.0.0 (2024-06-14) and 1.0.1 (2026-02-08). In post-1.0
library is referenced by the Go styleguide. maintenance; the library is referenced by the Go styleguide.
# Next Step # Next Step
Restructure README.md into the standard sections: Description, Getting Started, Bring the Makefile up to policy in one commit: add fmt-check, check, and
Rationale, Design, TODO, License, Author hooks targets (test/lint/fmt/docker exist) and add the missing policy
files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig,
.dockerignore, and .gitea/workflows/check.yml running make check.
# Completed Steps # Completed Steps
- 2026-10-06: `make fmt` and `make fmt-check` also format Markdown, with * 2026-08-10: fixed every handler discarding slog attributes: console,
`prettier` at the standard settings, pinned through `package.json` and JSON and webhook handlers now emit record attributes, accumulate
`yarn.lock`; `script/bootstrap` installs node and yarn for it, and the WithAttrs without mutating the receiver, and honour WithGroup;
existing Markdown was formatted once slog.Group values nest and LogValuer values are resolved
- 2026-10-06: re-vendored the standard files from `sneak/prompts` at commit * 2026-02-08: fixed JSONHandler deadlock from recursive log.Println,
`dd4027b`: `REPO_POLICIES.md`, the CI workflow, `.gitignore`, and with regression test; tagged 1.0.1
`.golangci.yml` together with the lint stage's new image, plus the * 2024-06-14: 1.0 prep: lint and fmt enforced in Docker build, call
`.editorconfig` and `.dockerignore` this repo lacked, which finishes the stack depth fix so log locations report correctly, example script,
Makefile and policy-files step. `script/cibuild` now bootstraps and runs removed non-building RELP code; tagged v1.0.0
`script/check` before building the image, the format check runs only on the * 2024-05-22: module path moved to sneak.berlin/go/simplelog
host, the last `Dockerfile` stage runs `script/bootstrap`, which installs * 2024-05-14: initial library: slog-based console, JSON, webhook, and
goimports at a pinned commit RELP handlers, MultiplexHandler, caller file/line info, UTC ISO
- 2026-10-06: a webhook request now times out after 5 seconds, so a server that timestamps, level-colored output
never answers no longer stops every log call; the webhook handler also reads
each answer to the end so its connection is reused
- 2026-10-06: the console handler takes the file and line it prints from the
record's `PC` instead of counting stack frames, so they name the call site
whether the record came through a `slog.Logger` or straight to `Handle`
- 2026-10-06: the tests run under the race detector, in Docker: `script/test`
builds the `test` stage of the `Dockerfile`, which runs `go test -race`, and a
new final stage makes a plain `docker build` run both the `lint` and `test`
stages
- 2026-10-06: the linter runs only in Docker: `script/lint` builds the `lint`
stage of the `Dockerfile`, every `docker build` in `script/` runs without the
build cache, and `script/bootstrap` no longer installs golangci-lint
- 2026-10-06: every handler now returns a failed delivery from `Handle` instead
of discarding it: console and JSON return the stdout write error, the webhook
also fails on a non-2xx answer, and the multiplex delivers to every handler
and returns their errors joined
- 2026-08-10: fixed every handler discarding slog attributes: console, JSON and
webhook handlers now emit record attributes, accumulate WithAttrs without
mutating the receiver, and honour WithGroup; slog.Group values nest and
LogValuer values are resolved; console keys and values holding invalid UTF-8
are quoted
- 2026-08-07: added canonical `.golangci.yml` (v2 schema), pinned the
`Dockerfile` lint stage to golangci-lint v2.12.2 (tag+digest), and fixed all
findings the v1→v2 jump surfaced without changing any exported signatures or
behavior
* 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
shims, README Entrypoints section
- 2026-02-08: fixed JSONHandler deadlock from recursive log.Println, with
regression test; tagged 1.0.1
- 2024-06-14: 1.0 prep: lint and fmt enforced in Docker build, call stack depth
fix so log locations report correctly, example script, removed non-building
RELP code; tagged v1.0.0
- 2024-05-22: module path moved to sneak.berlin/go/simplelog
- 2024-05-14: initial library: slog-based console, JSON, webhook, and RELP
handlers, MultiplexHandler, caller file/line info, UTC ISO timestamps,
level-colored output
# Future Steps # Future Steps
- Delete the old plain TODO file once this TODO.md lands * Rewrite the Dockerfile to run make check with sha256-pinned base
- Add .aider.\* to .gitignore and remove the stray aider artifacts from the images (currently golangci/golangci-lint:latest and golang:1.22,
working tree unpinned, duplicating Makefile logic instead of calling it)
- Pick one tag scheme before the next release (v1.0.0 vs 1.0.1 are inconsistent) * Restructure README.md into the standard sections: Description,
- Tag v1.0.2, with the leading v, once the attribute fix lands, so consuming Getting Started, Rationale, Design, TODO, License, Author
repos can move off pseudo-version pins in one step * Delete the old plain TODO file once this TODO.md lands
- Fix RELP output to cache (from old TODO) * Add .aider.* to .gitignore and remove the stray aider artifacts from
- Re-add RELP delivery over TCP to remote rsyslog imrelp; removed 2024-06-14 the working tree
because it did not build (README planned feature) * Pick one tag scheme before the next release (v1.0.0 vs 1.0.1 are
- Add regex filtering for webhook logs (from old TODO) inconsistent)
- Better console output format (from old TODO) * Tag v1.0.2, with the leading v, once the attribute fix lands, so
consuming repos can move off pseudo-version pins in one step
* Fix RELP output to cache (from old TODO)
* Re-add RELP delivery over TCP to remote rsyslog imrelp; removed
2024-06-14 because it did not build (README planned feature)
* Add regex filtering for webhook logs (from old TODO)
* Better console output format (from old TODO)
+5 -35
View File
@@ -6,7 +6,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"unicode" "unicode"
"unicode/utf8"
) )
// handlerAttrs is the attribute state every handler carries: the attributes // handlerAttrs is the attribute state every handler carries: the attributes
@@ -26,7 +25,6 @@ func (h handlerAttrs) withAttrs(attrs []slog.Attr) handlerAttrs {
combined := make([]slog.Attr, 0, len(h.attrs)+len(qualified)) combined := make([]slog.Attr, 0, len(h.attrs)+len(qualified))
combined = append(combined, h.attrs...) combined = append(combined, h.attrs...)
combined = append(combined, qualified...) combined = append(combined, qualified...)
return handlerAttrs{attrs: combined, groups: h.groups} return handlerAttrs{attrs: combined, groups: h.groups}
} }
@@ -36,11 +34,9 @@ func (h handlerAttrs) withGroup(name string) handlerAttrs {
if name == "" { if name == "" {
return h return h
} }
groups := make([]string, 0, len(h.groups)+1) groups := make([]string, 0, len(h.groups)+1)
groups = append(groups, h.groups...) groups = append(groups, h.groups...)
groups = append(groups, name) groups = append(groups, name)
return handlerAttrs{attrs: h.attrs, groups: groups} return handlerAttrs{attrs: h.attrs, groups: groups}
} }
@@ -51,7 +47,6 @@ func (h handlerAttrs) forRecord(record slog.Record) []slog.Attr {
all := make([]slog.Attr, 0, len(h.attrs)+len(own)) all := make([]slog.Attr, 0, len(h.attrs)+len(own))
all = append(all, h.attrs...) all = append(all, h.attrs...)
all = append(all, own...) all = append(all, own...)
return all return all
} }
@@ -63,7 +58,6 @@ func qualifyAttrs(groups []string, attrs []slog.Attr) []slog.Attr {
Value: slog.GroupValue(attrs...), Value: slog.GroupValue(attrs...),
}} }}
} }
return attrs return attrs
} }
@@ -74,10 +68,8 @@ func recordAttrs(record slog.Record) []slog.Attr {
attrs := make([]slog.Attr, 0, record.NumAttrs()) attrs := make([]slog.Attr, 0, record.NumAttrs())
record.Attrs(func(attr slog.Attr) bool { record.Attrs(func(attr slog.Attr) bool {
attrs = append(attrs, attr) attrs = append(attrs, attr)
return true return true
}) })
return attrs return attrs
} }
@@ -104,7 +96,6 @@ func recordToMap(record slog.Record, attrs handlerAttrs) groupMap {
fields["Level"] = record.Level fields["Level"] = record.Level
fields["Message"] = record.Message fields["Message"] = record.Message
fields["PC"] = record.PC fields["PC"] = record.PC
return fields return fields
} }
@@ -116,7 +107,6 @@ func attrsToMap(attrs []slog.Attr) groupMap {
for _, attr := range attrs { for _, attr := range attrs {
addAttrToMap(fields, attr) addAttrToMap(fields, attr)
} }
return fields return fields
} }
@@ -145,14 +135,11 @@ func addAttrToMap(fields groupMap, attr slog.Attr) {
nested = make(groupMap, len(group)) nested = make(groupMap, len(group))
fields[attr.Key] = nested fields[attr.Key] = nested
} }
target = nested target = nested
} }
for _, member := range group { for _, member := range group {
addAttrToMap(target, member) addAttrToMap(target, member)
} }
return return
} }
@@ -186,12 +173,8 @@ func jsonValue(value slog.Value) any {
return value.Duration().Nanoseconds() return value.Duration().Nanoseconds()
case slog.KindTime: case slog.KindTime:
return value.Time() return value.Time()
case slog.KindAny, slog.KindGroup, slog.KindLogValuer:
// Only KindAny arrives here in practice: addAttrToMap renders
// groups itself and resolves every value first.
return jsonAnyValue(value)
default: default:
// Anything a future Go release adds. // KindAny, and anything a future Go release adds.
return jsonAnyValue(value) return jsonAnyValue(value)
} }
} }
@@ -203,12 +186,9 @@ func jsonAnyValue(value slog.Value) any {
return err.Error() return err.Error()
} }
} }
if _, err := json.Marshal(held); err != nil {
_, err := json.Marshal(held)
if err != nil {
return value.String() return value.String()
} }
return held return held
} }
@@ -224,7 +204,6 @@ func attrsToText(attrs []slog.Attr) string {
for _, attr := range attrs { for _, attr := range attrs {
appendAttrText(&out, "", attr) appendAttrText(&out, "", attr)
} }
return out.String() return out.String()
} }
@@ -239,16 +218,13 @@ func appendAttrText(out *strings.Builder, prefix string, attr slog.Attr) {
if len(group) == 0 { if len(group) == 0 {
return return
} }
nested := prefix nested := prefix
if attr.Key != "" { if attr.Key != "" {
nested = prefix + attr.Key + "." nested = prefix + attr.Key + "."
} }
for _, member := range group { for _, member := range group {
appendAttrText(out, nested, member) appendAttrText(out, nested, member)
} }
return return
} }
@@ -263,23 +239,17 @@ func appendAttrText(out *strings.Builder, prefix string, attr slog.Attr) {
out.WriteString(quoteIfNeeded(value.String())) out.WriteString(quoteIfNeeded(value.String()))
} }
// quoteIfNeeded quotes a key or a value, as slog.NewTextHandler does, when // quoteIfNeeded quotes a key or a value only when leaving it bare would make
// leaving it bare would make the key=value pairs ambiguous or would write a // the key=value pairs ambiguous, matching how the stdlib text handler reads.
// control character or invalid UTF-8 to the terminal. Unlike the stdlib, it
// also quotes DEL (0x7f), which is a control character too.
func quoteIfNeeded(text string) string { func quoteIfNeeded(text string) string {
if text == "" { if text == "" {
return `""` return `""`
} }
// Ranging over a string yields utf8.RuneError for each invalid byte;
// strconv.Quote then escapes that byte, as in "bad\xffkey".
for _, r := range text { for _, r := range text {
if unicode.IsSpace(r) || !unicode.IsPrint(r) || if unicode.IsSpace(r) || !unicode.IsPrint(r) ||
r == utf8.RuneError || r == '"' || r == '=' { r == '"' || r == '=' {
return strconv.Quote(text) return strconv.Quote(text)
} }
} }
return text return text
} }
+155 -162
View File
@@ -1,5 +1,4 @@
//nolint:paralleltest // tests swap the process-wide os.Stdout to read handler output package simplelog
package simplelog_test
import ( import (
"bytes" "bytes"
@@ -8,17 +7,13 @@ import (
"errors" "errors"
"io" "io"
"log/slog" "log/slog"
"maps"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"reflect" "reflect"
"slices"
"strings" "strings"
"testing" "testing"
"time" "time"
"sneak.berlin/go/simplelog"
) )
// These tests assert on the bytes the handlers actually emit, because that is // These tests assert on the bytes the handlers actually emit, because that is
@@ -27,8 +22,7 @@ import (
// captureStdout redirects os.Stdout for the duration of fn and returns what was // captureStdout redirects os.Stdout for the duration of fn and returns what was
// written to it. Both the console and the JSON handler write to os.Stdout, so // written to it. Both the console and the JSON handler write to os.Stdout, so
// this is the only way to see their real output. os.Stdout is shared by the // this is the only way to see their real output.
// whole process, so a test that uses this must not run in parallel.
func captureStdout(t *testing.T, fn func()) string { func captureStdout(t *testing.T, fn func()) string {
t.Helper() t.Helper()
@@ -41,10 +35,8 @@ func captureStdout(t *testing.T, fn func()) string {
os.Stdout = w os.Stdout = w
collected := make(chan string, 1) collected := make(chan string, 1)
go func() { go func() {
var buf bytes.Buffer var buf bytes.Buffer
_, _ = io.Copy(&buf, r) _, _ = io.Copy(&buf, r)
collected <- buf.String() collected <- buf.String()
}() }()
@@ -57,9 +49,7 @@ func captureStdout(t *testing.T, fn func()) string {
fn() fn()
os.Stdout = original os.Stdout = original
if err := w.Close(); err != nil {
err = w.Close()
if err != nil {
t.Fatalf("close pipe writer: %v", err) t.Fatalf("close pipe writer: %v", err)
} }
@@ -70,21 +60,9 @@ func captureStdout(t *testing.T, fn func()) string {
func testRecord(message string, attrs ...slog.Attr) slog.Record { func testRecord(message string, attrs ...slog.Attr) slog.Record {
record := slog.NewRecord(time.Now(), slog.LevelInfo, message, 0) record := slog.NewRecord(time.Now(), slog.LevelInfo, message, 0)
record.AddAttrs(attrs...) record.AddAttrs(attrs...)
return record return record
} }
// handle passes record to handler and fails the test if Handle returns an
// error.
func handle(t *testing.T, handler slog.Handler, record slog.Record) {
t.Helper()
err := handler.Handle(context.Background(), record)
if err != nil {
t.Fatalf("Handle: %v", err)
}
}
// decodeLine parses a single line of JSON handler output. // decodeLine parses a single line of JSON handler output.
func decodeLine(t *testing.T, output string) map[string]any { func decodeLine(t *testing.T, output string) map[string]any {
t.Helper() t.Helper()
@@ -95,12 +73,9 @@ func decodeLine(t *testing.T, output string) map[string]any {
} }
var decoded map[string]any var decoded map[string]any
if err := json.Unmarshal([]byte(line), &decoded); err != nil {
err := json.Unmarshal([]byte(line), &decoded)
if err != nil {
t.Fatalf("output is not valid JSON: %v\noutput: %s", err, line) t.Fatalf("output is not valid JSON: %v\noutput: %s", err, line)
} }
return decoded return decoded
} }
@@ -112,7 +87,6 @@ func wantField(t *testing.T, decoded map[string]any, key string, want any) {
if !ok { if !ok {
t.Fatalf("field %q missing from output: %v", key, decoded) t.Fatalf("field %q missing from output: %v", key, decoded)
} }
if got != want { if got != want {
t.Fatalf("field %q = %v, want %v", key, got, want) t.Fatalf("field %q = %v, want %v", key, got, want)
} }
@@ -126,12 +100,10 @@ func wantGroup(t *testing.T, decoded map[string]any, key string) map[string]any
if !ok { if !ok {
t.Fatalf("group %q missing from output: %v", key, decoded) t.Fatalf("group %q missing from output: %v", key, decoded)
} }
group, ok := got.(map[string]any) group, ok := got.(map[string]any)
if !ok { if !ok {
t.Fatalf("field %q = %v, want a nested object", key, got) t.Fatalf("field %q = %v, want a nested object", key, got)
} }
return group return group
} }
@@ -184,19 +156,18 @@ func (c castTarget) LogValue() slog.Value {
var _ slog.LogValuer = castTarget{} var _ slog.LogValuer = castTarget{}
// errConnectionRefused is the error the ResolvesValues tests log.
var errConnectionRefused = errors.New("connection refused")
func TestJSONHandlerEmitsRecordAttrs(t *testing.T) { func TestJSONHandlerEmitsRecordAttrs(t *testing.T) {
output := captureStdout(t, func() { output := captureStdout(t, func() {
handler := simplelog.NewJSONHandler() handler := NewJSONHandler()
record := testRecord( record := testRecord(
"casting", "casting",
slog.String("device", "livingroom"), slog.String("device", "livingroom"),
slog.String("file", "movie.mp4"), slog.String("file", "movie.mp4"),
slog.Int("attempt", 3), slog.Int("attempt", 3),
) )
handle(t, handler, record) if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
decoded := decodeLine(t, output) decoded := decodeLine(t, output)
@@ -208,11 +179,13 @@ func TestJSONHandlerEmitsRecordAttrs(t *testing.T) {
func TestJSONHandlerWithAttrsAccumulates(t *testing.T) { func TestJSONHandlerWithAttrsAccumulates(t *testing.T) {
output := captureStdout(t, func() { output := captureStdout(t, func() {
handler := simplelog.NewJSONHandler(). handler := NewJSONHandler().
WithAttrs([]slog.Attr{slog.String("service", "cattbox")}). WithAttrs([]slog.Attr{slog.String("service", "cattbox")}).
WithAttrs([]slog.Attr{slog.String("component", "caster")}) WithAttrs([]slog.Attr{slog.String("component", "caster")})
record := testRecord("casting", slog.String("device", "livingroom")) record := testRecord("casting", slog.String("device", "livingroom"))
handle(t, handler, record) if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
decoded := decodeLine(t, output) decoded := decodeLine(t, output)
@@ -222,38 +195,43 @@ func TestJSONHandlerWithAttrsAccumulates(t *testing.T) {
} }
func TestJSONHandlerWithAttrsDoesNotMutateReceiver(t *testing.T) { func TestJSONHandlerWithAttrsDoesNotMutateReceiver(t *testing.T) {
parent := simplelog.NewJSONHandler() parent := NewJSONHandler()
first := parent.WithAttrs([]slog.Attr{slog.String("worker", "first")}) first := parent.WithAttrs([]slog.Attr{slog.String("worker", "first")})
second := parent.WithAttrs([]slog.Attr{slog.String("worker", "second")}) second := parent.WithAttrs([]slog.Attr{slog.String("worker", "second")})
firstOutput := captureStdout(t, func() { firstOutput := captureStdout(t, func() {
handle(t, first, testRecord("work")) if err := first.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
secondOutput := captureStdout(t, func() { secondOutput := captureStdout(t, func() {
handle(t, second, testRecord("work")) if err := second.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
parentOutput := captureStdout(t, func() { parentOutput := captureStdout(t, func() {
handle(t, parent, testRecord("work")) if err := parent.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
wantField(t, decodeLine(t, firstOutput), "worker", "first") wantField(t, decodeLine(t, firstOutput), "worker", "first")
wantField(t, decodeLine(t, secondOutput), "worker", "second") wantField(t, decodeLine(t, secondOutput), "worker", "second")
if _, present := decodeLine(t, parentOutput)["worker"]; present { if _, present := decodeLine(t, parentOutput)["worker"]; present {
t.Fatalf( t.Fatalf("parent handler leaked an attribute from a derived handler: %s", parentOutput)
"parent handler leaked an attribute from a derived handler: %s",
parentOutput,
)
} }
} }
func TestJSONHandlerWithGroupNestsAttrs(t *testing.T) { func TestJSONHandlerWithGroupNestsAttrs(t *testing.T) {
output := captureStdout(t, func() { output := captureStdout(t, func() {
handler := simplelog.NewJSONHandler(). handler := NewJSONHandler().
WithGroup("cast"). WithGroup("cast").
WithAttrs([]slog.Attr{slog.String("device", "livingroom")}) WithAttrs([]slog.Attr{slog.String("device", "livingroom")})
record := testRecord("casting", slog.String("file", "movie.mp4")) record := testRecord("casting", slog.String("file", "movie.mp4"))
handle(t, handler, record) if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
decoded := decodeLine(t, output) decoded := decodeLine(t, output)
@@ -266,14 +244,16 @@ func TestJSONHandlerWithGroupNestsAttrs(t *testing.T) {
func TestJSONHandlerResolvesValues(t *testing.T) { func TestJSONHandlerResolvesValues(t *testing.T) {
output := captureStdout(t, func() { output := captureStdout(t, func() {
handler := simplelog.NewJSONHandler() handler := NewJSONHandler()
record := testRecord( record := testRecord(
"cast failed", "cast failed",
slog.Group("request", slog.Int("status", 502), slog.String("method", "POST")), slog.Group("request", slog.Int("status", 502), slog.String("method", "POST")),
slog.Any("target", castTarget{id: "chromecast-7"}), slog.Any("target", castTarget{id: "chromecast-7"}),
slog.Any("error", errConnectionRefused), slog.Any("error", errors.New("connection refused")),
) )
handle(t, handler, record) if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
decoded := decodeLine(t, output) decoded := decodeLine(t, output)
@@ -287,14 +267,16 @@ func TestJSONHandlerResolvesValues(t *testing.T) {
func TestConsoleHandlerEmitsRecordAttrs(t *testing.T) { func TestConsoleHandlerEmitsRecordAttrs(t *testing.T) {
output := captureStdout(t, func() { output := captureStdout(t, func() {
handler := simplelog.NewConsoleHandler() handler := NewConsoleHandler()
record := testRecord( record := testRecord(
"casting", "casting",
slog.String("device", "livingroom"), slog.String("device", "livingroom"),
slog.String("file", "movie.mp4"), slog.String("file", "movie.mp4"),
slog.Int("attempt", 3), slog.Int("attempt", 3),
) )
handle(t, handler, record) if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
wantContains(t, output, "casting") wantContains(t, output, "casting")
@@ -305,9 +287,11 @@ func TestConsoleHandlerEmitsRecordAttrs(t *testing.T) {
func TestConsoleHandlerQuotesValuesNeedingIt(t *testing.T) { func TestConsoleHandlerQuotesValuesNeedingIt(t *testing.T) {
output := captureStdout(t, func() { output := captureStdout(t, func() {
handler := simplelog.NewConsoleHandler() handler := NewConsoleHandler()
record := testRecord("casting", slog.String("file", "The Movie.mp4")) record := testRecord("casting", slog.String("file", "The Movie.mp4"))
handle(t, handler, record) if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
wantContains(t, output, `file="The Movie.mp4"`) wantContains(t, output, `file="The Movie.mp4"`)
@@ -328,42 +312,42 @@ func TestConsoleHandlerQuotesKeysNeedingIt(t *testing.T) {
}{ }{
{ {
name: "an ordinary key is left bare", name: "an ordinary key is left bare",
build: func() slog.Handler { return simplelog.NewConsoleHandler() }, build: func() slog.Handler { return NewConsoleHandler() },
attrs: []slog.Attr{slog.String("device", "livingroom")}, attrs: []slog.Attr{slog.String("device", "livingroom")},
want: " device=livingroom", want: " device=livingroom",
notWant: `"device"`, notWant: `"device"`,
}, },
{ {
name: "a key containing an equals sign is quoted", name: "a key containing an equals sign is quoted",
build: func() slog.Handler { return simplelog.NewConsoleHandler() }, build: func() slog.Handler { return NewConsoleHandler() },
attrs: []slog.Attr{slog.String("a=b", "v2")}, attrs: []slog.Attr{slog.String("a=b", "v2")},
want: ` "a=b"=v2`, want: ` "a=b"=v2`,
notWant: " a=b=v2", notWant: " a=b=v2",
}, },
{ {
name: "a key containing a space is quoted", name: "a key containing a space is quoted",
build: func() slog.Handler { return simplelog.NewConsoleHandler() }, build: func() slog.Handler { return NewConsoleHandler() },
attrs: []slog.Attr{slog.String("my key", "v")}, attrs: []slog.Attr{slog.String("my key", "v")},
want: ` "my key"=v`, want: ` "my key"=v`,
notWant: " my key=v", notWant: " my key=v",
}, },
{ {
name: "a key containing a quote is escaped", name: "a key containing a quote is escaped",
build: func() slog.Handler { return simplelog.NewConsoleHandler() }, build: func() slog.Handler { return NewConsoleHandler() },
attrs: []slog.Attr{slog.String(`he"llo`, "v")}, attrs: []slog.Attr{slog.String(`he"llo`, "v")},
want: ` "he\"llo"=v`, want: ` "he\"llo"=v`,
notWant: ` he"llo=v`, notWant: ` he"llo=v`,
}, },
{ {
name: "a printable non-ascii key is left bare", name: "a printable non-ascii key is left bare",
build: func() slog.Handler { return simplelog.NewConsoleHandler() }, build: func() slog.Handler { return NewConsoleHandler() },
attrs: []slog.Attr{slog.String("キー", "v")}, attrs: []slog.Attr{slog.String("キー", "v")},
want: " キー=v", want: " キー=v",
notWant: `"キー"`, notWant: `"キー"`,
}, },
{ {
name: "a group prefix is quoted together with its key", name: "a group prefix is quoted together with its key",
build: func() slog.Handler { return simplelog.NewConsoleHandler() }, build: func() slog.Handler { return NewConsoleHandler() },
attrs: []slog.Attr{ attrs: []slog.Attr{
slog.Group("grp", slog.String("a=b", "v")), slog.Group("grp", slog.String("a=b", "v")),
}, },
@@ -371,9 +355,9 @@ func TestConsoleHandlerQuotesKeysNeedingIt(t *testing.T) {
notWant: " grp.a=b=v", notWant: " grp.a=b=v",
}, },
{ {
name: "a WithGroup prefix needing quotes is quoted with its key", name: "a WithGroup prefix needing quotes quotes the whole key",
build: func() slog.Handler { build: func() slog.Handler {
return simplelog.NewConsoleHandler().WithGroup("my grp") return NewConsoleHandler().WithGroup("my grp")
}, },
attrs: []slog.Attr{slog.String("k", "v")}, attrs: []slog.Attr{slog.String("k", "v")},
want: ` "my grp.k"=v`, want: ` "my grp.k"=v`,
@@ -381,7 +365,7 @@ func TestConsoleHandlerQuotesKeysNeedingIt(t *testing.T) {
}, },
{ {
name: "an empty key is quoted rather than left as a gap", name: "an empty key is quoted rather than left as a gap",
build: func() slog.Handler { return simplelog.NewConsoleHandler() }, build: func() slog.Handler { return NewConsoleHandler() },
attrs: []slog.Attr{slog.String("", "v")}, attrs: []slog.Attr{slog.String("", "v")},
want: ` ""=v`, want: ` ""=v`,
notWant: " =v", notWant: " =v",
@@ -391,7 +375,11 @@ func TestConsoleHandlerQuotesKeysNeedingIt(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
output := captureStdout(t, func() { output := captureStdout(t, func() {
handle(t, test.build(), testRecord("casting", test.attrs...)) handler := test.build()
record := testRecord("casting", test.attrs...)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
wantContains(t, output, test.want) wantContains(t, output, test.want)
@@ -400,34 +388,15 @@ func TestConsoleHandlerQuotesKeysNeedingIt(t *testing.T) {
} }
} }
// TestConsoleHandlerQuotesInvalidUTF8 checks that a key or a value holding
// invalid UTF-8 is quoted with the bad byte escaped, as slog.NewTextHandler
// does, so the raw byte never reaches the terminal. Valid non-ASCII stays bare.
func TestConsoleHandlerQuotesInvalidUTF8(t *testing.T) {
output := captureStdout(t, func() {
handler := simplelog.NewConsoleHandler()
record := testRecord(
"casting",
slog.String("bad\xffkey", "v"),
slog.String("raw", "bad\xffvalue"),
slog.String("title", "Amélie"),
)
handle(t, handler, record)
})
wantContains(t, output, ` "bad\xffkey"=v`)
wantContains(t, output, ` raw="bad\xffvalue"`)
wantContains(t, output, " title=Amélie")
wantNotContains(t, output, "\xff")
}
func TestConsoleHandlerWithAttrsAccumulates(t *testing.T) { func TestConsoleHandlerWithAttrsAccumulates(t *testing.T) {
output := captureStdout(t, func() { output := captureStdout(t, func() {
handler := simplelog.NewConsoleHandler(). handler := NewConsoleHandler().
WithAttrs([]slog.Attr{slog.String("service", "cattbox")}). WithAttrs([]slog.Attr{slog.String("service", "cattbox")}).
WithAttrs([]slog.Attr{slog.String("component", "caster")}) WithAttrs([]slog.Attr{slog.String("component", "caster")})
record := testRecord("casting", slog.String("device", "livingroom")) record := testRecord("casting", slog.String("device", "livingroom"))
handle(t, handler, record) if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
wantContains(t, output, "service=cattbox") wantContains(t, output, "service=cattbox")
@@ -436,18 +405,24 @@ func TestConsoleHandlerWithAttrsAccumulates(t *testing.T) {
} }
func TestConsoleHandlerWithAttrsDoesNotMutateReceiver(t *testing.T) { func TestConsoleHandlerWithAttrsDoesNotMutateReceiver(t *testing.T) {
parent := simplelog.NewConsoleHandler() parent := NewConsoleHandler()
first := parent.WithAttrs([]slog.Attr{slog.String("worker", "first")}) first := parent.WithAttrs([]slog.Attr{slog.String("worker", "first")})
second := parent.WithAttrs([]slog.Attr{slog.String("worker", "second")}) second := parent.WithAttrs([]slog.Attr{slog.String("worker", "second")})
firstOutput := captureStdout(t, func() { firstOutput := captureStdout(t, func() {
handle(t, first, testRecord("work")) if err := first.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
secondOutput := captureStdout(t, func() { secondOutput := captureStdout(t, func() {
handle(t, second, testRecord("work")) if err := second.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
parentOutput := captureStdout(t, func() { parentOutput := captureStdout(t, func() {
handle(t, parent, testRecord("work")) if err := parent.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
wantContains(t, firstOutput, "worker=first") wantContains(t, firstOutput, "worker=first")
@@ -459,11 +434,13 @@ func TestConsoleHandlerWithAttrsDoesNotMutateReceiver(t *testing.T) {
func TestConsoleHandlerWithGroupQualifiesAttrs(t *testing.T) { func TestConsoleHandlerWithGroupQualifiesAttrs(t *testing.T) {
output := captureStdout(t, func() { output := captureStdout(t, func() {
handler := simplelog.NewConsoleHandler(). handler := NewConsoleHandler().
WithGroup("cast"). WithGroup("cast").
WithAttrs([]slog.Attr{slog.String("device", "livingroom")}) WithAttrs([]slog.Attr{slog.String("device", "livingroom")})
record := testRecord("casting", slog.String("file", "movie.mp4")) record := testRecord("casting", slog.String("file", "movie.mp4"))
handle(t, handler, record) if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
wantContains(t, output, "cast.device=livingroom") wantContains(t, output, "cast.device=livingroom")
@@ -472,14 +449,16 @@ func TestConsoleHandlerWithGroupQualifiesAttrs(t *testing.T) {
func TestConsoleHandlerResolvesValues(t *testing.T) { func TestConsoleHandlerResolvesValues(t *testing.T) {
output := captureStdout(t, func() { output := captureStdout(t, func() {
handler := simplelog.NewConsoleHandler() handler := NewConsoleHandler()
record := testRecord( record := testRecord(
"cast failed", "cast failed",
slog.Group("request", slog.Int("status", 502)), slog.Group("request", slog.Int("status", 502)),
slog.Any("target", castTarget{id: "chromecast-7"}), slog.Any("target", castTarget{id: "chromecast-7"}),
slog.Any("error", errConnectionRefused), slog.Any("error", errors.New("connection refused")),
) )
handle(t, handler, record) if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
wantContains(t, output, "request.status=502") wantContains(t, output, "request.status=502")
@@ -488,32 +467,29 @@ func TestConsoleHandlerResolvesValues(t *testing.T) {
} }
func TestWebhookHandlerEmitsAttrs(t *testing.T) { func TestWebhookHandlerEmitsAttrs(t *testing.T) {
t.Parallel()
bodies := make(chan []byte, 1) bodies := make(chan []byte, 1)
server := httptest.NewServer(http.HandlerFunc( server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
t.Errorf("read webhook body: %v", err) t.Errorf("read webhook body: %v", err)
} }
bodies <- body bodies <- body
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}, },
)) ))
defer server.Close() defer server.Close()
handler, err := simplelog.NewWebhookHandler(server.URL) handler, err := NewWebhookHandler(server.URL)
if err != nil { if err != nil {
t.Fatalf("NewWebhookHandler: %v", err) t.Fatalf("NewWebhookHandler: %v", err)
} }
withAttrs := handler.WithAttrs([]slog.Attr{slog.String("service", "cattbox")}) withAttrs := handler.WithAttrs([]slog.Attr{slog.String("service", "cattbox")})
record := testRecord("casting", slog.String("device", "livingroom")) record := testRecord("casting", slog.String("device", "livingroom"))
handle(t, withAttrs, record) if err := withAttrs.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
var body []byte var body []byte
select { select {
@@ -528,15 +504,15 @@ func TestWebhookHandlerEmitsAttrs(t *testing.T) {
} }
// TestMultiplexHandlerPassesAttrsThrough guards the composite handler that // TestMultiplexHandlerPassesAttrsThrough guards the composite handler that
// package init installs: attributes must survive the multiplex too. It is // package init installs: attributes must survive the multiplex too.
// built while stdout is the capture pipe, which is not a terminal, so it
// writes through the JSON handler.
func TestMultiplexHandlerPassesAttrsThrough(t *testing.T) { func TestMultiplexHandlerPassesAttrsThrough(t *testing.T) {
output := captureStdout(t, func() { output := captureStdout(t, func() {
handler := simplelog.NewMultiplexHandler(). handler := (&MultiplexHandler{handlers: []ExtendedHandler{NewJSONHandler()}}).
WithAttrs([]slog.Attr{slog.String("service", "cattbox")}) WithAttrs([]slog.Attr{slog.String("service", "cattbox")})
record := testRecord("casting", slog.String("device", "livingroom")) record := testRecord("casting", slog.String("device", "livingroom"))
handle(t, handler, record) if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
decoded := decodeLine(t, output) decoded := decodeLine(t, output)
@@ -553,19 +529,20 @@ func TestMultiplexHandlerPassesAttrsThrough(t *testing.T) {
func TestJSONHandlerDoesNotMutateCallerMap(t *testing.T) { func TestJSONHandlerDoesNotMutateCallerMap(t *testing.T) {
caller := map[string]any{"mine": "untouched"} caller := map[string]any{"mine": "untouched"}
want := maps.Clone(caller)
output := captureStdout(t, func() { output := captureStdout(t, func() {
handler := simplelog.NewJSONHandler() handler := NewJSONHandler()
record := testRecord( record := testRecord(
"casting", "casting",
slog.Any("g", caller), slog.Any("g", caller),
slog.Group("g", slog.Int("injected", 1)), slog.Group("g", slog.Int("injected", 1)),
) )
handle(t, handler, record) if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
wantUnchanged(t, "map", caller, want) wantUnchanged(t, "map", caller, map[string]any{"mine": "untouched"})
// The later attribute wins the key outright, as any repeated key does. // The later attribute wins the key outright, as any repeated key does.
group := wantGroup(t, decodeLine(t, output), "g") group := wantGroup(t, decodeLine(t, output), "g")
@@ -575,17 +552,18 @@ func TestJSONHandlerDoesNotMutateCallerMap(t *testing.T) {
func TestJSONHandlerDoesNotMutateCallerMapThroughWithAttrs(t *testing.T) { func TestJSONHandlerDoesNotMutateCallerMapThroughWithAttrs(t *testing.T) {
caller := map[string]any{"id": "req-1"} caller := map[string]any{"id": "req-1"}
want := maps.Clone(caller)
output := captureStdout(t, func() { output := captureStdout(t, func() {
handler := simplelog.NewJSONHandler(). handler := NewJSONHandler().
WithAttrs([]slog.Attr{slog.Any("req", caller)}). WithAttrs([]slog.Attr{slog.Any("req", caller)}).
WithGroup("req") WithGroup("req")
record := testRecord("cast failed", slog.Int("status", 502)) record := testRecord("cast failed", slog.Int("status", 502))
handle(t, handler, record) if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
wantUnchanged(t, "map", caller, want) wantUnchanged(t, "map", caller, map[string]any{"id": "req-1"})
group := wantGroup(t, decodeLine(t, output), "req") group := wantGroup(t, decodeLine(t, output), "req")
wantField(t, group, "status", float64(502)) wantField(t, group, "status", float64(502))
@@ -601,20 +579,26 @@ func TestJSONHandlerDoesNotMutateCallerSlice(t *testing.T) {
caller := make([]any, 2, 4) caller := make([]any, 2, 4)
caller[0] = "first" caller[0] = "first"
caller[1] = "second" caller[1] = "second"
backing := slices.Clone(caller[:cap(caller)])
output := captureStdout(t, func() { output := captureStdout(t, func() {
handler := simplelog.NewJSONHandler() handler := NewJSONHandler()
record := testRecord( record := testRecord(
"casting", "casting",
slog.Any("s", caller), slog.Any("s", caller),
slog.Group("s", slog.Int("injected", 1)), slog.Group("s", slog.Int("injected", 1)),
) )
handle(t, handler, record) if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
wantUnchanged(t, "slice", caller, backing[:2]) wantUnchanged(t, "slice", caller, []any{"first", "second"})
wantUnchanged(t, "slice backing array", caller[:cap(caller)], backing) wantUnchanged(
t,
"slice backing array",
caller[:cap(caller)],
[]any{"first", "second", nil, nil},
)
group := wantGroup(t, decodeLine(t, output), "s") group := wantGroup(t, decodeLine(t, output), "s")
wantField(t, group, "injected", float64(1)) wantField(t, group, "injected", float64(1))
@@ -622,45 +606,40 @@ func TestJSONHandlerDoesNotMutateCallerSlice(t *testing.T) {
func TestConsoleHandlerDoesNotMutateCallerMap(t *testing.T) { func TestConsoleHandlerDoesNotMutateCallerMap(t *testing.T) {
caller := map[string]any{"mine": "untouched"} caller := map[string]any{"mine": "untouched"}
want := maps.Clone(caller)
output := captureStdout(t, func() { output := captureStdout(t, func() {
handler := simplelog.NewConsoleHandler() handler := NewConsoleHandler()
record := testRecord( record := testRecord(
"casting", "casting",
slog.Any("g", caller), slog.Any("g", caller),
slog.Group("g", slog.Int("injected", 1)), slog.Group("g", slog.Int("injected", 1)),
) )
handle(t, handler, record) if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
wantUnchanged(t, "map", caller, want) wantUnchanged(t, "map", caller, map[string]any{"mine": "untouched"})
wantContains(t, output, "g.injected=1") wantContains(t, output, "g.injected=1")
} }
func TestWebhookHandlerDoesNotMutateCallerMap(t *testing.T) { func TestWebhookHandlerDoesNotMutateCallerMap(t *testing.T) {
t.Parallel()
caller := map[string]any{"id": "req-1"} caller := map[string]any{"id": "req-1"}
want := maps.Clone(caller)
bodies := make(chan []byte, 1) bodies := make(chan []byte, 1)
server := httptest.NewServer(http.HandlerFunc( server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
t.Errorf("read webhook body: %v", err) t.Errorf("read webhook body: %v", err)
} }
bodies <- body bodies <- body
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}, },
)) ))
defer server.Close() defer server.Close()
handler, err := simplelog.NewWebhookHandler(server.URL) handler, err := NewWebhookHandler(server.URL)
if err != nil { if err != nil {
t.Fatalf("NewWebhookHandler: %v", err) t.Fatalf("NewWebhookHandler: %v", err)
} }
@@ -669,7 +648,9 @@ func TestWebhookHandlerDoesNotMutateCallerMap(t *testing.T) {
WithAttrs([]slog.Attr{slog.Any("req", caller)}). WithAttrs([]slog.Attr{slog.Any("req", caller)}).
WithGroup("req") WithGroup("req")
record := testRecord("cast failed", slog.Int("status", 502)) record := testRecord("cast failed", slog.Int("status", 502))
handle(t, derived, record) if err := derived.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
var body []byte var body []byte
select { select {
@@ -678,7 +659,7 @@ func TestWebhookHandlerDoesNotMutateCallerMap(t *testing.T) {
t.Fatal("webhook handler posted nothing") t.Fatal("webhook handler posted nothing")
} }
wantUnchanged(t, "map", caller, want) wantUnchanged(t, "map", caller, map[string]any{"id": "req-1"})
group := wantGroup(t, decodeLine(t, string(body)), "req") group := wantGroup(t, decodeLine(t, string(body)), "req")
wantField(t, group, "status", float64(502)) wantField(t, group, "status", float64(502))
@@ -690,16 +671,17 @@ func TestWebhookHandlerDoesNotMutateCallerMap(t *testing.T) {
// form would silently arrive as a string. // form would silently arrive as a string.
func TestJSONHandlerRendersDurationAsNanoseconds(t *testing.T) { func TestJSONHandlerRendersDurationAsNanoseconds(t *testing.T) {
output := captureStdout(t, func() { output := captureStdout(t, func() {
handler := simplelog.NewJSONHandler() handler := NewJSONHandler()
record := testRecord("casting", slog.Duration("elapsed", 3*time.Second)) record := testRecord("casting", slog.Duration("elapsed", 3*time.Second))
handle(t, handler, record) if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
decoded := decodeLine(t, output) decoded := decodeLine(t, output)
if _, isNumber := decoded["elapsed"].(float64); !isNumber { if _, isNumber := decoded["elapsed"].(float64); !isNumber {
t.Fatalf("field \"elapsed\" = %#v, want a json number", decoded["elapsed"]) t.Fatalf("field \"elapsed\" = %#v, want a json number", decoded["elapsed"])
} }
wantField(t, decoded, "elapsed", float64((3 * time.Second).Nanoseconds())) wantField(t, decoded, "elapsed", float64((3 * time.Second).Nanoseconds()))
} }
@@ -708,9 +690,11 @@ func TestJSONHandlerRendersDurationAsNanoseconds(t *testing.T) {
// form slog.NewTextHandler uses. // form slog.NewTextHandler uses.
func TestConsoleHandlerRendersDurationReadably(t *testing.T) { func TestConsoleHandlerRendersDurationReadably(t *testing.T) {
output := captureStdout(t, func() { output := captureStdout(t, func() {
handler := simplelog.NewConsoleHandler() handler := NewConsoleHandler()
record := testRecord("casting", slog.Duration("elapsed", 3*time.Second)) record := testRecord("casting", slog.Duration("elapsed", 3*time.Second))
handle(t, handler, record) if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
wantContains(t, output, "elapsed=3s") wantContains(t, output, "elapsed=3s")
@@ -732,10 +716,9 @@ func TestJSONHandlerContractEdgeCases(t *testing.T) {
}{ }{
{ {
name: "empty attr is ignored", name: "empty attr is ignored",
build: func() slog.Handler { return simplelog.NewJSONHandler() }, build: func() slog.Handler { return NewJSONHandler() },
attrs: []slog.Attr{{}, slog.String("kept", "yes")}, attrs: []slog.Attr{{}, slog.String("kept", "yes")},
verify: func(t *testing.T, decoded map[string]any) { verify: func(t *testing.T, decoded map[string]any) {
t.Helper()
wantField(t, decoded, "kept", "yes") wantField(t, decoded, "kept", "yes")
wantNoField(t, decoded, "") wantNoField(t, decoded, "")
}, },
@@ -743,24 +726,22 @@ func TestJSONHandlerContractEdgeCases(t *testing.T) {
{ {
name: "empty group is elided along with its key", name: "empty group is elided along with its key",
build: func() slog.Handler { build: func() slog.Handler {
return simplelog.NewJSONHandler(). return NewJSONHandler().
WithAttrs([]slog.Attr{slog.Group("empty")}) WithAttrs([]slog.Attr{slog.Group("empty")})
}, },
attrs: []slog.Attr{slog.String("kept", "yes")}, attrs: []slog.Attr{slog.String("kept", "yes")},
verify: func(t *testing.T, decoded map[string]any) { verify: func(t *testing.T, decoded map[string]any) {
t.Helper()
wantField(t, decoded, "kept", "yes") wantField(t, decoded, "kept", "yes")
wantNoField(t, decoded, "empty") wantNoField(t, decoded, "empty")
}, },
}, },
{ {
name: "group with an empty key is inlined", name: "group with an empty key is inlined",
build: func() slog.Handler { return simplelog.NewJSONHandler() }, build: func() slog.Handler { return NewJSONHandler() },
attrs: []slog.Attr{ attrs: []slog.Attr{
slog.Group("", slog.String("inner", "yes")), slog.Group("", slog.String("inner", "yes")),
}, },
verify: func(t *testing.T, decoded map[string]any) { verify: func(t *testing.T, decoded map[string]any) {
t.Helper()
wantField(t, decoded, "inner", "yes") wantField(t, decoded, "inner", "yes")
wantNoField(t, decoded, "") wantNoField(t, decoded, "")
}, },
@@ -768,11 +749,10 @@ func TestJSONHandlerContractEdgeCases(t *testing.T) {
{ {
name: "WithGroup with an empty name is a no-op", name: "WithGroup with an empty name is a no-op",
build: func() slog.Handler { build: func() slog.Handler {
return simplelog.NewJSONHandler().WithGroup("") return NewJSONHandler().WithGroup("")
}, },
attrs: []slog.Attr{slog.String("kept", "yes")}, attrs: []slog.Attr{slog.String("kept", "yes")},
verify: func(t *testing.T, decoded map[string]any) { verify: func(t *testing.T, decoded map[string]any) {
t.Helper()
wantField(t, decoded, "kept", "yes") wantField(t, decoded, "kept", "yes")
wantNoField(t, decoded, "") wantNoField(t, decoded, "")
}, },
@@ -782,7 +762,11 @@ func TestJSONHandlerContractEdgeCases(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
output := captureStdout(t, func() { output := captureStdout(t, func() {
handle(t, test.build(), testRecord("casting", test.attrs...)) handler := test.build()
record := testRecord("casting", test.attrs...)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
test.verify(t, decodeLine(t, output)) test.verify(t, decodeLine(t, output))
@@ -802,7 +786,7 @@ func TestConsoleHandlerContractEdgeCases(t *testing.T) {
}{ }{
{ {
name: "empty attr is ignored", name: "empty attr is ignored",
build: func() slog.Handler { return simplelog.NewConsoleHandler() }, build: func() slog.Handler { return NewConsoleHandler() },
attrs: []slog.Attr{{}, slog.String("kept", "yes")}, attrs: []slog.Attr{{}, slog.String("kept", "yes")},
want: "kept=yes", want: "kept=yes",
notWant: " =", notWant: " =",
@@ -810,7 +794,7 @@ func TestConsoleHandlerContractEdgeCases(t *testing.T) {
{ {
name: "empty group is elided along with its key", name: "empty group is elided along with its key",
build: func() slog.Handler { build: func() slog.Handler {
return simplelog.NewConsoleHandler(). return NewConsoleHandler().
WithAttrs([]slog.Attr{slog.Group("empty")}) WithAttrs([]slog.Attr{slog.Group("empty")})
}, },
attrs: []slog.Attr{slog.String("kept", "yes")}, attrs: []slog.Attr{slog.String("kept", "yes")},
@@ -819,7 +803,7 @@ func TestConsoleHandlerContractEdgeCases(t *testing.T) {
}, },
{ {
name: "group with an empty key is inlined", name: "group with an empty key is inlined",
build: func() slog.Handler { return simplelog.NewConsoleHandler() }, build: func() slog.Handler { return NewConsoleHandler() },
attrs: []slog.Attr{ attrs: []slog.Attr{
slog.Group("", slog.String("inner", "yes")), slog.Group("", slog.String("inner", "yes")),
}, },
@@ -829,7 +813,7 @@ func TestConsoleHandlerContractEdgeCases(t *testing.T) {
{ {
name: "WithGroup with an empty name is a no-op", name: "WithGroup with an empty name is a no-op",
build: func() slog.Handler { build: func() slog.Handler {
return simplelog.NewConsoleHandler().WithGroup("") return NewConsoleHandler().WithGroup("")
}, },
attrs: []slog.Attr{slog.String("kept", "yes")}, attrs: []slog.Attr{slog.String("kept", "yes")},
want: " kept=yes", want: " kept=yes",
@@ -840,7 +824,11 @@ func TestConsoleHandlerContractEdgeCases(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
output := captureStdout(t, func() { output := captureStdout(t, func() {
handle(t, test.build(), testRecord("casting", test.attrs...)) handler := test.build()
record := testRecord("casting", test.attrs...)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
wantContains(t, output, test.want) wantContains(t, output, test.want)
@@ -854,7 +842,7 @@ func TestConsoleHandlerContractEdgeCases(t *testing.T) {
// and an attribute keyed after one of them is dropped rather than emitted. // and an attribute keyed after one of them is dropped rather than emitted.
func TestJSONHandlerRecordFieldsWinKeyCollision(t *testing.T) { func TestJSONHandlerRecordFieldsWinKeyCollision(t *testing.T) {
output := captureStdout(t, func() { output := captureStdout(t, func() {
handler := simplelog.NewJSONHandler() handler := NewJSONHandler()
record := testRecord( record := testRecord(
"the real message", "the real message",
slog.String("Time", "hijacked"), slog.String("Time", "hijacked"),
@@ -863,14 +851,15 @@ func TestJSONHandlerRecordFieldsWinKeyCollision(t *testing.T) {
slog.String("PC", "hijacked"), slog.String("PC", "hijacked"),
slog.String("kept", "yes"), slog.String("kept", "yes"),
) )
handle(t, handler, record) if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
decoded := decodeLine(t, output) decoded := decodeLine(t, output)
wantField(t, decoded, "kept", "yes") wantField(t, decoded, "kept", "yes")
wantField(t, decoded, "Message", "the real message") wantField(t, decoded, "Message", "the real message")
wantField(t, decoded, "Level", "INFO") wantField(t, decoded, "Level", "INFO")
for _, reserved := range []string{"Time", "PC"} { for _, reserved := range []string{"Time", "PC"} {
if decoded[reserved] == "hijacked" { if decoded[reserved] == "hijacked" {
t.Fatalf("attribute overwrote the record's own %q field: %v", reserved, decoded) t.Fatalf("attribute overwrote the record's own %q field: %v", reserved, decoded)
@@ -882,13 +871,15 @@ func TestJSONHandlerRecordFieldsWinKeyCollision(t *testing.T) {
// payload being an object: a key logged twice collapses to its last value. // payload being an object: a key logged twice collapses to its last value.
func TestJSONHandlerDuplicateKeysKeepLast(t *testing.T) { func TestJSONHandlerDuplicateKeysKeepLast(t *testing.T) {
output := captureStdout(t, func() { output := captureStdout(t, func() {
handler := simplelog.NewJSONHandler() handler := NewJSONHandler()
record := testRecord( record := testRecord(
"casting", "casting",
slog.String("device", "kitchen"), slog.String("device", "kitchen"),
slog.String("device", "livingroom"), slog.String("device", "livingroom"),
) )
handle(t, handler, record) if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
wantField(t, decodeLine(t, output), "device", "livingroom") wantField(t, decodeLine(t, output), "device", "livingroom")
@@ -898,13 +889,15 @@ func TestJSONHandlerDuplicateKeysKeepLast(t *testing.T) {
// text is not an object, so both pairs survive there. // text is not an object, so both pairs survive there.
func TestConsoleHandlerKeepsDuplicateKeys(t *testing.T) { func TestConsoleHandlerKeepsDuplicateKeys(t *testing.T) {
output := captureStdout(t, func() { output := captureStdout(t, func() {
handler := simplelog.NewConsoleHandler() handler := NewConsoleHandler()
record := testRecord( record := testRecord(
"casting", "casting",
slog.String("device", "kitchen"), slog.String("device", "kitchen"),
slog.String("device", "livingroom"), slog.String("device", "livingroom"),
) )
handle(t, handler, record) if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
}) })
wantContains(t, output, "device=kitchen") wantContains(t, output, "device=kitchen")
+2 -6
View File
@@ -1,5 +1,3 @@
// Command example demonstrates logging through simplelog's default
// slog handler.
package main package main
import ( import (
@@ -8,15 +6,13 @@ import (
_ "sneak.berlin/go/simplelog" _ "sneak.berlin/go/simplelog"
) )
// attemptNumber is the example login attempt count logged below.
const attemptNumber = 3
func main() { func main() {
// log structured data with slog as usual: // log structured data with slog as usual:
slog.Info( slog.Info(
"User login attempt", "User login attempt",
slog.String("user", "JohnDoe"), slog.String("user", "JohnDoe"),
slog.Int("attempt", attemptNumber), slog.Int("attempt", 3),
) )
slog.Warn( slog.Warn(
"Configuration mismatch", "Configuration mismatch",
+12 -46
View File
@@ -3,42 +3,29 @@ package simplelog
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"os"
"runtime" "runtime"
"time" "time"
"github.com/fatih/color" "github.com/fatih/color"
) )
// ConsoleHandler writes human-readable, colored log lines to stdout.
type ConsoleHandler struct { type ConsoleHandler struct {
// out is where records are written. Nil means os.Stdout, looked up on
// every write so that a reassigned os.Stdout is followed.
out io.Writer
attrs handlerAttrs attrs handlerAttrs
} }
// NewConsoleHandler returns a new ConsoleHandler.
func NewConsoleHandler() *ConsoleHandler { func NewConsoleHandler() *ConsoleHandler {
return &ConsoleHandler{} return &ConsoleHandler{}
} }
// Handle writes the record to stdout as a colored, timestamped line
// including the caller file and line, followed by the attributes as
// key=value pairs. A failed write is returned as an error.
func (c *ConsoleHandler) Handle( func (c *ConsoleHandler) Handle(
_ context.Context, ctx context.Context,
record slog.Record, record slog.Record,
) error { ) error {
timestamp := time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00") timestamp := time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00")
var colorFunc func(format string, a ...interface{}) string
var colorFunc func(format string, a ...any) string
switch record.Level { switch record.Level {
case slog.LevelDebug:
colorFunc = color.New(color.FgWhite).SprintfFunc()
case slog.LevelInfo: case slog.LevelInfo:
colorFunc = color.New(color.FgBlue).SprintfFunc() colorFunc = color.New(color.FgBlue).SprintfFunc()
case slog.LevelWarn: case slog.LevelWarn:
@@ -49,22 +36,13 @@ func (c *ConsoleHandler) Handle(
colorFunc = color.New(color.FgWhite).SprintfFunc() colorFunc = color.New(color.FgWhite).SprintfFunc()
} }
// The file and line come from the record's PC, the call site; a record // Get the caller information
// without one prints a placeholder. _, file, line, ok := runtime.Caller(4)
file, line := "???", 0 if !ok {
file = "???"
if record.PC != 0 { line = 0
frame, _ := runtime.CallersFrames([]uintptr{record.PC}).Next()
file, line = frame.File, frame.Line
} }
fmt.Println(
out := c.out
if out == nil {
out = os.Stdout
}
_, err := fmt.Fprintln(
out,
colorFunc( colorFunc(
"%s [%s] %s:%d: %s%s", "%s [%s] %s:%d: %s%s",
timestamp, timestamp,
@@ -75,38 +53,26 @@ func (c *ConsoleHandler) Handle(
attrsToText(c.attrs.forRecord(record)), attrsToText(c.attrs.forRecord(record)),
), ),
) )
if err != nil {
return fmt.Errorf("error writing log record: %w", err)
}
return nil return nil
} }
// Enabled reports whether the handler processes records at the given
// level; it always returns true.
func (c *ConsoleHandler) Enabled( func (c *ConsoleHandler) Enabled(
_ context.Context, ctx context.Context,
_ slog.Level, level slog.Level,
) bool { ) bool {
return true return true
} }
// WithAttrs returns a new handler that also emits attrs, qualified by the
// groups open now. The receiver is not modified.
func (c *ConsoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler { func (c *ConsoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 { if len(attrs) == 0 {
return c return c
} }
return &ConsoleHandler{attrs: c.attrs.withAttrs(attrs)}
return &ConsoleHandler{out: c.out, attrs: c.attrs.withAttrs(attrs)}
} }
// WithGroup returns a new handler that qualifies later attributes with
// the group name. An empty name returns the receiver.
func (c *ConsoleHandler) WithGroup(name string) slog.Handler { func (c *ConsoleHandler) WithGroup(name string) slog.Handler {
if name == "" { if name == "" {
return c return c
} }
return &ConsoleHandler{attrs: c.attrs.withGroup(name)}
return &ConsoleHandler{out: c.out, attrs: c.attrs.withGroup(name)}
} }
-102
View File
@@ -1,102 +0,0 @@
package simplelog
import (
"bytes"
"context"
"fmt"
"log/slog"
"runtime"
"strings"
"testing"
"time"
)
// These tests sit inside the package so they can read the console line
// from a buffer instead of from stdout.
// lineOf calls logCall and returns "file:line" for the line lineOf was
// called from. Each test writes its log call inside logCall on that same
// line, so the result is the location the console line must name.
func lineOf(logCall func()) string {
_, file, line, _ := runtime.Caller(1)
logCall()
return fmt.Sprintf("%s:%d", file, line)
}
// wantLocation fails the test unless the console line names location as
// where the record "casting" was logged.
func wantLocation(t *testing.T, output, location string) {
t.Helper()
if !strings.Contains(output, location+": casting") {
t.Fatalf("console line %q does not name %s", output, location)
}
}
// The default handler as simplelog installs it when stdout is a terminal:
// a MultiplexHandler holding a ConsoleHandler.
func TestConsoleHandlerNamesCallSiteThroughDefaultHandler(t *testing.T) {
t.Parallel()
var output bytes.Buffer
logger := slog.New(&MultiplexHandler{handlers: []ExtendedHandler{
&ConsoleHandler{out: &output},
}})
want := lineOf(func() { logger.Info("casting") })
wantLocation(t, output.String(), want)
}
func TestConsoleHandlerNamesCallSiteUsedWithSlogNew(t *testing.T) {
t.Parallel()
var output bytes.Buffer
logger := slog.New(&ConsoleHandler{out: &output})
want := lineOf(func() { logger.Info("casting") })
wantLocation(t, output.String(), want)
}
// The record is built as the log/slog package documentation shows for a
// function that logs on its caller's behalf: runtime.Callers supplies the
// PC.
func TestConsoleHandlerNamesCallSiteOfRecordPassedToHandle(t *testing.T) {
t.Parallel()
var (
output bytes.Buffer
pcs [1]uintptr
)
want := lineOf(func() { runtime.Callers(1, pcs[:]) })
record := slog.NewRecord(time.Now(), slog.LevelInfo, "casting", pcs[0])
err := (&ConsoleHandler{out: &output}).Handle(context.Background(), record)
if err != nil {
t.Fatalf("Handle: %v", err)
}
wantLocation(t, output.String(), want)
}
func TestConsoleHandlerPrintsPlaceholderWithoutPC(t *testing.T) {
t.Parallel()
var output bytes.Buffer
record := slog.NewRecord(time.Now(), slog.LevelInfo, "casting", 0)
err := (&ConsoleHandler{out: &output}).Handle(context.Background(), record)
if err != nil {
t.Fatalf("Handle: %v", err)
}
wantLocation(t, output.String(), "???:0")
}
-4
View File
@@ -7,8 +7,6 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// Event is a single structured log entry with a unique ID and
// timestamp.
type Event struct { type Event struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
@@ -17,8 +15,6 @@ type Event struct {
Data json.RawMessage `json:"data"` Data json.RawMessage `json:"data"`
} }
// NewEvent returns an Event with a fresh ID and the current UTC
// timestamp.
func NewEvent(level, message string, data json.RawMessage) Event { func NewEvent(level, message string, data json.RawMessage) Event {
return Event{ return Event{
ID: uuid.New(), ID: uuid.New(),
+1 -1
View File
@@ -2,7 +2,7 @@ package simplelog
import "log/slog" import "log/slog"
// ExtendedHandler defines the interface for different log outputs. // Handler defines the interface for different log outputs.
type ExtendedHandler interface { type ExtendedHandler interface {
slog.Handler slog.Handler
} }
-263
View File
@@ -1,263 +0,0 @@
package simplelog
import (
"bytes"
"context"
"errors"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// These tests sit inside the package so they can point a handler at a sink
// that fails, which callers outside the package cannot do.
// errSinkFailed is what failingWriter returns from every write.
var errSinkFailed = errors.New("sink failed")
// failingWriter is a sink whose every write fails.
type failingWriter struct{}
func (failingWriter) Write(_ []byte) (int, error) {
return 0, errSinkFailed
}
func errorTestRecord() slog.Record {
return slog.NewRecord(time.Now(), slog.LevelInfo, "casting", 0)
}
// The handler is derived with WithAttrs and WithGroup, so the test also
// fails if either of them drops the sink.
func TestJSONHandlerReturnsWriteError(t *testing.T) {
t.Parallel()
handler := (&JSONHandler{out: failingWriter{}}).
WithAttrs([]slog.Attr{slog.String("service", "cattbox")}).
WithGroup("cast")
err := handler.Handle(context.Background(), errorTestRecord())
if !errors.Is(err, errSinkFailed) {
t.Fatalf("Handle returned %v, want the sink's error", err)
}
}
func TestConsoleHandlerReturnsWriteError(t *testing.T) {
t.Parallel()
handler := (&ConsoleHandler{out: failingWriter{}}).
WithAttrs([]slog.Attr{slog.String("service", "cattbox")}).
WithGroup("cast")
err := handler.Handle(context.Background(), errorTestRecord())
if !errors.Is(err, errSinkFailed) {
t.Fatalf("Handle returned %v, want the sink's error", err)
}
}
// A failing handler must not stop the record reaching the handlers after
// it, and its error must still reach the caller.
func TestMultiplexHandlerDeliversPastFailingHandler(t *testing.T) {
t.Parallel()
var delivered bytes.Buffer
handler := &MultiplexHandler{handlers: []ExtendedHandler{
&JSONHandler{out: failingWriter{}},
&JSONHandler{out: &delivered},
}}
err := handler.Handle(context.Background(), errorTestRecord())
if !errors.Is(err, errSinkFailed) {
t.Fatalf("Handle returned %v, want the failing handler's error", err)
}
if !strings.Contains(delivered.String(), `"Message":"casting"`) {
t.Fatalf(
"second handler did not receive the record: %q",
delivered.String(),
)
}
}
// When more than one handler fails, the caller gets every failure, not
// only the first.
func TestMultiplexHandlerReturnsEveryFailure(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
},
))
defer server.Close()
webhook, err := NewWebhookHandler(server.URL)
if err != nil {
t.Fatalf("NewWebhookHandler: %v", err)
}
handler := &MultiplexHandler{handlers: []ExtendedHandler{
&JSONHandler{out: failingWriter{}},
webhook,
}}
err = handler.Handle(context.Background(), errorTestRecord())
if !errors.Is(err, errSinkFailed) {
t.Fatalf("Handle returned %v, want the JSON handler's error", err)
}
if !errors.Is(err, errWebhookStatus) {
t.Fatalf("Handle returned %v, want the webhook's error", err)
}
}
func TestWebhookHandlerReturnsErrorOnServerError(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
},
))
defer server.Close()
handler, err := NewWebhookHandler(server.URL)
if err != nil {
t.Fatalf("NewWebhookHandler: %v", err)
}
err = handler.Handle(context.Background(), errorTestRecord())
if !errors.Is(err, errWebhookStatus) {
t.Fatalf("Handle returned %v, want an error for the 500 answer", err)
}
}
// Following the redirect would resend the request as a GET without the
// record, and the GET is answered with 200, so only an error for the
// redirect itself tells the caller the record was lost.
func TestWebhookHandlerReturnsErrorOnRedirect(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/moved" {
http.Redirect(w, r, "/moved", http.StatusFound)
}
},
))
defer server.Close()
handler, err := NewWebhookHandler(server.URL)
if err != nil {
t.Fatalf("NewWebhookHandler: %v", err)
}
err = handler.Handle(context.Background(), errorTestRecord())
if !errors.Is(err, errWebhookStatus) {
t.Fatalf("Handle returned %v, want an error for the redirect", err)
}
}
// A server that accepts the request and never answers must not hold up
// the log call: Handle gives up after webhookTimeout and says why.
func TestWebhookHandlerTimesOutOnServerThatNeverAnswers(t *testing.T) {
t.Parallel()
testEnded := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {
<-testEnded
},
))
// server.Close waits for running requests, so the server's handler
// is released first.
defer func() {
close(testEnded)
server.Close()
}()
handler, err := NewWebhookHandler(server.URL)
if err != nil {
t.Fatalf("NewWebhookHandler: %v", err)
}
// Handle runs in a goroutine so that a lost timeout fails the test
// instead of hanging the test run.
handleErr := make(chan error, 1)
go func() {
handleErr <- handler.Handle(context.Background(), errorTestRecord())
}()
select {
case err = <-handleErr:
case <-time.After(webhookTimeout + time.Second):
t.Fatal("Handle did not return within webhookTimeout")
}
var netErr net.Error
if !errors.As(err, &netErr) || !netErr.Timeout() {
t.Fatalf("Handle returned %v, want a timeout error", err)
}
}
// A server that sends a 2xx status and then never finishes the answer
// must not hold up the log call either: reading the answer counts toward
// webhookTimeout, and Handle returns the read's error.
func TestWebhookHandlerTimesOutOnServerThatStallsTheAnswer(t *testing.T) {
t.Parallel()
testEnded := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
// Flush sends the status now; the answer stays unfinished
// until the test ends.
err := http.NewResponseController(w).Flush()
if err != nil {
t.Errorf("flush the status: %v", err)
}
<-testEnded
},
))
// server.Close waits for running requests, so the server's handler
// is released first.
defer func() {
close(testEnded)
server.Close()
}()
handler, err := NewWebhookHandler(server.URL)
if err != nil {
t.Fatalf("NewWebhookHandler: %v", err)
}
// Handle runs in a goroutine so that a lost timeout fails the test
// instead of hanging the test run.
handleErr := make(chan error, 1)
go func() {
handleErr <- handler.Handle(context.Background(), errorTestRecord())
}()
select {
case err = <-handleErr:
case <-time.After(webhookTimeout + time.Second):
t.Fatal("Handle did not return within webhookTimeout")
}
var netErr net.Error
if !errors.As(err, &netErr) || !netErr.Timeout() {
t.Fatalf("Handle returned %v, want a timeout error", err)
}
}
+6 -32
View File
@@ -4,67 +4,41 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"os" "os"
) )
// JSONHandler writes each log record to stdout as a JSON document.
type JSONHandler struct { type JSONHandler struct {
// out is where records are written. Nil means os.Stdout, looked up on
// every write so that a reassigned os.Stdout is followed.
out io.Writer
attrs handlerAttrs attrs handlerAttrs
} }
// NewJSONHandler returns a new JSONHandler.
func NewJSONHandler() *JSONHandler { func NewJSONHandler() *JSONHandler {
return &JSONHandler{} return &JSONHandler{}
} }
// Handle marshals the record, with its attributes, to one JSON object and func (j *JSONHandler) Handle(ctx context.Context, record slog.Record) error {
// writes it to stdout. A failed write is returned as an error.
func (j *JSONHandler) Handle(_ context.Context, record slog.Record) error {
jsonData, err := json.Marshal(recordToMap(record, j.attrs)) jsonData, err := json.Marshal(recordToMap(record, j.attrs))
if err != nil { if err != nil {
return err return fmt.Errorf("error marshaling log record: %w", err)
} }
fmt.Fprintln(os.Stdout, string(jsonData))
out := j.out
if out == nil {
out = os.Stdout
}
_, err = fmt.Fprintln(out, string(jsonData))
if err != nil {
return fmt.Errorf("error writing log record: %w", err)
}
return nil return nil
} }
// Enabled reports whether the handler processes records at the given func (j *JSONHandler) Enabled(ctx context.Context, level slog.Level) bool {
// level; it always returns true.
func (j *JSONHandler) Enabled(_ context.Context, _ slog.Level) bool {
return true return true
} }
// WithAttrs returns a new handler that also emits attrs, qualified by the
// groups open now. The receiver is not modified.
func (j *JSONHandler) WithAttrs(attrs []slog.Attr) slog.Handler { func (j *JSONHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 { if len(attrs) == 0 {
return j return j
} }
return &JSONHandler{attrs: j.attrs.withAttrs(attrs)}
return &JSONHandler{out: j.out, attrs: j.attrs.withAttrs(attrs)}
} }
// WithGroup returns a new handler that nests later attributes in an
// object named after the group. An empty name returns the receiver.
func (j *JSONHandler) WithGroup(name string) slog.Handler { func (j *JSONHandler) WithGroup(name string) slog.Handler {
if name == "" { if name == "" {
return j return j
} }
return &JSONHandler{attrs: j.attrs.withGroup(name)}
return &JSONHandler{out: j.out, attrs: j.attrs.withGroup(name)}
} }
+2 -7
View File
@@ -1,27 +1,22 @@
package simplelog_test package simplelog
import ( import (
"log/slog" "log/slog"
"testing" "testing"
"time" "time"
"sneak.berlin/go/simplelog"
) )
// TestJSONHandlerDeadlock verifies that JSONHandler.Handle does not deadlock // TestJSONHandlerDeadlock verifies that JSONHandler.Handle does not deadlock
// when the default slog handler routes log.Println back through slog. // when the default slog handler routes log.Println back through slog.
// On the unfixed code this test will hang (deadlock); with the fix it completes. // On the unfixed code this test will hang (deadlock); with the fix it completes.
func TestJSONHandlerDeadlock(t *testing.T) { func TestJSONHandlerDeadlock(t *testing.T) {
t.Parallel() handler := NewJSONHandler()
handler := simplelog.NewJSONHandler()
// Set our handler as the default so log.Println routes through slog // Set our handler as the default so log.Println routes through slog
logger := slog.New(handler) logger := slog.New(handler)
slog.SetDefault(logger) slog.SetDefault(logger)
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
// This call deadlocks on unfixed code because Handle() calls // This call deadlocks on unfixed code because Handle() calls
// log.Println() which re-enters slog → Handle() → log.Println() … // log.Println() which re-enters slog → Handle() → log.Println() …
-6
View File
@@ -1,6 +0,0 @@
{
"private": true,
"devDependencies": {
"prettier": "3.8.1"
}
}
-176
View File
@@ -1,176 +0,0 @@
#!/bin/sh
# script/bootstrap: install all dependencies needed to build and develop
# this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present. Node is
# used directly if installed; otherwise it is installed at a pinned
# version via nvm (installing nvm itself first, from a hash-verified
# release archive, never curl | sh).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-07-06
NODE_VERSION="22.17.0"
NVM_VERSION="0.40.3"
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
YARN_VERSION="1.22.22"
# goimports from golang.org/x/tools v0.30.0, 2025-02-10: the last release
# that Go 1.22, the Go of go.mod and the Dockerfile, can build.
GOIMPORTS_COMMIT="09747cdf594a7924dcecb506312be3bd6e437962"
GOIMPORTS_VERSION="v0.30.0"
PKGMGR=""
SUDO=""
detect_pkgmgr() {
[ -n "$PKGMGR" ] && return 0
if command -v nix-env >/dev/null 2>&1; then
PKGMGR="nix"
elif command -v apt-get >/dev/null 2>&1; then
PKGMGR="apt"
elif command -v brew >/dev/null 2>&1; then
PKGMGR="brew"
elif command -v apk >/dev/null 2>&1; then
PKGMGR="apk"
else
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
exit 1
fi
if [ "$PKGMGR" = "apt" ]; then
export DEBIAN_FRONTEND=noninteractive
if [ "$(id -u)" != "0" ]; then
SUDO="sudo"
fi
fi
}
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
pkg_install() {
detect_pkgmgr
case "$PKGMGR" in
nix) nix-env -iA "nixpkgs.$1" ;;
apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;;
esac
}
missing() {
! command -v "$1" >/dev/null 2>&1
}
# Print the golang.org/x/tools version that the goimports on PATH was
# built from, or nothing. goimports has no --version flag; `go version -m`
# prints the module versions Go records in every binary it builds.
goimports_version() {
bin="$(command -v goimports)" || return 0
go version -m "$bin" 2>/dev/null |
awk '$1 == "mod" && $2 == "golang.org/x/tools" { print $3 }'
}
# Install goimports at the pinned commit unless the one on PATH already
# reports the pinned version. go install writes to GOBIN, or to the bin
# directory of GOPATH when GOBIN is unset, which need not be on the
# caller's PATH; script/fmt puts that directory first on PATH the same
# way, so the check here sees the goimports that script/fmt runs.
ensure_goimports() {
gobin="$(go env GOBIN)"
PATH="${gobin:-$(go env GOPATH)/bin}:$PATH"
if [ "$(goimports_version)" != "$GOIMPORTS_VERSION" ]; then
go install "golang.org/x/tools/cmd/goimports@$GOIMPORTS_COMMIT"
hash -r
if [ "$(goimports_version)" != "$GOIMPORTS_VERSION" ]; then
echo "bootstrap: goimports on PATH is not $GOIMPORTS_VERSION" \
"after installing it: $(command -v goimports || echo none)" >&2
exit 1
fi
fi
echo "goimports $GOIMPORTS_VERSION at $(command -v goimports)"
}
# verify_sha256 <file> <expected-hash>
verify_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$1" | cut -d' ' -f1)"
else
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
fi
if [ "$actual" != "$2" ]; then
echo "bootstrap: sha256 mismatch for $1" >&2
echo " expected: $2" >&2
echo " actual: $actual" >&2
exit 1
fi
}
# nvm is a bash script; run a command in a bash with nvm loaded
nvm_sh() {
bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*"
}
ensure_nvm() {
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
# nvm prerequisites; nvm itself requires bash
if missing bash; then pkg_install bash bash bash bash; fi
if missing curl; then pkg_install curl curl curl curl; fi
if missing git; then pkg_install git git git git; fi
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/nvm.tar.gz" \
"https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz"
verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256"
mkdir -p "$HOME/.nvm"
tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
rm -rf "$tmp"
}
ensure_node() {
if ! missing node; then return 0; fi
ensure_nvm
nvm_sh "nvm install $NODE_VERSION"
}
ensure_yarn() {
if ! missing yarn; then return 0; fi
if ! missing corepack; then
corepack enable
corepack prepare "yarn@$YARN_VERSION" --activate
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
corepack prepare yarn@$YARN_VERSION --activate"
else
npm install -g "yarn@$YARN_VERSION"
fi
}
install_js_deps() {
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
yarn install --frozen-lockfile"
else
yarn install --frozen-lockfile
fi
}
main() {
cd "$ROOT"
if missing make; then pkg_install gnumake make make make; fi
if missing git; then pkg_install git git git git; fi
if missing go; then pkg_install go golang go go; fi
# golangci-lint is not installed: it runs only in docker (script/lint).
ensure_goimports
go mod download
ensure_node
ensure_yarn
install_js_deps
echo "bootstrap complete"
}
main "$@"
-16
View File
@@ -1,16 +0,0 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. test and lint are Docker
# phases; fmt-check is native, because a formatter writes the working
# tree. Must not modify any files.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"
-28
View File
@@ -1,28 +0,0 @@
#!/bin/sh
# script/cibuild: run the CI build. It bootstraps first: a CI runner
# checks out and runs this and nothing else, and script/fmt-check runs
# the formatter on the host, which a pristine checkout cannot do.
# --no-cache for the same reason as script/docker: the gate phases the
# final stage depends on are RUN steps, and a cached one is a check that
# did not run.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/check"
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. The VERSION build argument takes precedence over
# the version a build stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"
-24
View File
@@ -1,24 +0,0 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
# --no-cache because the gate phases the final stage depends on are RUN
# steps, and a cached one is a check that did not run.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. The VERSION build argument takes precedence over
# the version a build stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"
-37
View File
@@ -1,37 +0,0 @@
#!/bin/sh
# script/fmt: format all files (writes): Go with the goimports that
# script/bootstrap installs, then Markdown with prettier. It puts Go's bin
# directory first on PATH, as script/bootstrap does, so it runs that
# goimports even when the directory is not on the caller's PATH.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() {
cd "$ROOT"
gobin="$(go env GOBIN)"
PATH="${gobin:-$(go env GOPATH)/bin}:$PATH"
goimports -l -w .
run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always
}
main "$@"
-39
View File
@@ -1,39 +0,0 @@
#!/bin/sh
# script/fmt-check: check formatting (read-only). Fails and lists the
# offending files if gofmt would reformat any Go file; otherwise fails if
# prettier would reformat any Markdown file.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt-check: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() {
cd "$ROOT"
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "gofmt would reformat:"
echo "$unformatted"
exit 1
fi
run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
}
main "$@"
-16
View File
@@ -1,16 +0,0 @@
#!/bin/sh
# script/install-precommit: install the git pre-commit hook that runs
# script/precommit. Our own extension to scripts-to-rule-them-all.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
hook=".git/hooks/pre-commit"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
echo "pre-commit hook installed: runs script/precommit"
}
main "$@"
-23
View File
@@ -1,23 +0,0 @@
#!/bin/sh
# script/lint: run the linter. Linting is a phase of the Dockerfile and
# this builds that phase alone; the linter is never installed or run on
# a developer host, where a shared result cache and a host-global lock
# make its answer untrustworthy.
#
# The phase is not the last stage in the file, so it is built only when
# --target names it. --no-cache because a cached lint layer is a lint
# that did not run. The tag makes each build replace the previous image
# instead of leaving a dangling one behind.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build --no-cache \
--target lint \
-t "$("$SCRIPT_DIR/projectname")-lint" .
}
main "$@"
-19
View File
@@ -1,19 +0,0 @@
#!/bin/sh
# script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
go mod tidy
git diff --exit-code -- go.mod go.sum || {
echo "go mod tidy changed go.mod/go.sum; stage the changes and retry" >&2
exit 1
}
"$SCRIPT_DIR/check"
}
main "$@"
-12
View File
@@ -1,12 +0,0 @@
#!/bin/sh
# script/projectname: output the name of this project. Our own
# extension to scripts-to-rule-them-all. Other scripts that need the
# name (e.g. script/docker) call this, so they can stay identical
# across all repos.
set -eu
main() {
echo "simplelog"
}
main "$@"
-13
View File
@@ -1,13 +0,0 @@
#!/bin/sh
# script/setup: set up the repo for development after a fresh clone:
# installs dependencies and the git pre-commit hook.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/install-precommit"
}
main "$@"
-19
View File
@@ -1,19 +0,0 @@
#!/bin/sh
# script/test: run the test suite. Testing is a phase of the Dockerfile
# and this builds that phase alone, on the same terms as script/lint:
# --target because a phase that is not the last stage is built only when
# named, --no-cache because a cached test layer is a test that did not
# run, and a tag so each build replaces the previous image.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build --no-cache \
--target test \
-t "$("$SCRIPT_DIR/projectname")-test" .
}
main "$@"
+10 -48
View File
@@ -1,13 +1,8 @@
// Package simplelog installs a multiplexing slog handler as the process
// default on import. It logs human-readable colored output when stdout is
// a terminal, JSON otherwise, and can additionally POST each record to a
// webhook configured via the LOGGER_WEBHOOK_URL environment variable.
package simplelog package simplelog
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"log" "log"
"log/slog" "log/slog"
"os" "os"
@@ -18,31 +13,23 @@ import (
"github.com/mattn/go-isatty" "github.com/mattn/go-isatty"
) )
//nolint:gochecknoglobals // webhook destination is read once from the environment
var webhookURL = os.Getenv("LOGGER_WEBHOOK_URL")
//nolint:gochecknoglobals // package-level default logger state is the package's design
var ( var (
ourCustomLogger *slog.Logger webhookURL = os.Getenv("LOGGER_WEBHOOK_URL")
ourCustomHandler slog.Handler
) )
//nolint:gochecknoinits // installs itself as slog default on import by design var ourCustomLogger *slog.Logger
var ourCustomHandler slog.Handler
func init() { func init() {
ourCustomHandler = NewMultiplexHandler() ourCustomHandler = NewMultiplexHandler()
ourCustomLogger = slog.New(ourCustomHandler) ourCustomLogger = slog.New(ourCustomHandler)
slog.SetDefault(ourCustomLogger) slog.SetDefault(ourCustomLogger)
} }
// MultiplexHandler fans each log record out to a set of underlying
// handlers.
type MultiplexHandler struct { type MultiplexHandler struct {
handlers []ExtendedHandler handlers []ExtendedHandler
} }
// NewMultiplexHandler returns a handler that writes colored console
// output when stdout is a terminal and JSON otherwise, plus an optional
// webhook handler when LOGGER_WEBHOOK_URL is set.
func NewMultiplexHandler() slog.Handler { func NewMultiplexHandler() slog.Handler {
cl := &MultiplexHandler{} cl := &MultiplexHandler{}
if isatty.IsTerminal(os.Stdout.Fd()) { if isatty.IsTerminal(os.Stdout.Fd()) {
@@ -50,72 +37,52 @@ func NewMultiplexHandler() slog.Handler {
} else { } else {
cl.handlers = append(cl.handlers, NewJSONHandler()) cl.handlers = append(cl.handlers, NewJSONHandler())
} }
if webhookURL != "" { if webhookURL != "" {
handler, err := NewWebhookHandler(webhookURL) handler, err := NewWebhookHandler(webhookURL)
if err != nil { if err != nil {
log.Fatalf("Failed to initialize Webhook handler: %v", err) log.Fatalf("Failed to initialize Webhook handler: %v", err)
} }
cl.handlers = append(cl.handlers, handler) cl.handlers = append(cl.handlers, handler)
} }
return cl return cl
} }
// Handle forwards the record to every underlying handler, including the
// ones after a handler that fails. It returns the failures joined with
// errors.Join, or nil when every handler succeeded.
func (cl *MultiplexHandler) Handle( func (cl *MultiplexHandler) Handle(
ctx context.Context, ctx context.Context,
record slog.Record, record slog.Record,
) error { ) error {
var errs []error
for _, handler := range cl.handlers { for _, handler := range cl.handlers {
err := handler.Handle(ctx, record) if err := handler.Handle(ctx, record); err != nil {
if err != nil { return err
errs = append(errs, err)
} }
} }
return nil
}
return errors.Join(errs...)
}
// Enabled reports whether the handler processes records at the given
// level; it always returns true.
func (cl *MultiplexHandler) Enabled( func (cl *MultiplexHandler) Enabled(
_ context.Context, ctx context.Context,
_ slog.Level, level slog.Level,
) bool { ) bool {
// send us all events // send us all events
return true return true
} }
// WithAttrs returns a new MultiplexHandler whose underlying handlers
// each carry the given attributes.
func (cl *MultiplexHandler) WithAttrs(attrs []slog.Attr) slog.Handler { func (cl *MultiplexHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
newHandlers := make([]ExtendedHandler, len(cl.handlers)) newHandlers := make([]ExtendedHandler, len(cl.handlers))
for i, handler := range cl.handlers { for i, handler := range cl.handlers {
newHandlers[i] = handler.WithAttrs(attrs) newHandlers[i] = handler.WithAttrs(attrs)
} }
return &MultiplexHandler{handlers: newHandlers} return &MultiplexHandler{handlers: newHandlers}
} }
// WithGroup returns a new MultiplexHandler whose underlying handlers
// each use the given group name.
func (cl *MultiplexHandler) WithGroup(name string) slog.Handler { func (cl *MultiplexHandler) WithGroup(name string) slog.Handler {
newHandlers := make([]ExtendedHandler, len(cl.handlers)) newHandlers := make([]ExtendedHandler, len(cl.handlers))
for i, handler := range cl.handlers { for i, handler := range cl.handlers {
newHandlers[i] = handler.WithGroup(name) newHandlers[i] = handler.WithGroup(name)
} }
return &MultiplexHandler{handlers: newHandlers} return &MultiplexHandler{handlers: newHandlers}
} }
// ExtendedEvent describes an Event augmented with caller file and line
// information.
type ExtendedEvent interface { type ExtendedEvent interface {
GetID() uuid.UUID GetID() uuid.UUID
GetTimestamp() time.Time GetTimestamp() time.Time
@@ -128,7 +95,6 @@ type ExtendedEvent interface {
type extendedEvent struct { type extendedEvent struct {
Event Event
File string `json:"file"` File string `json:"file"`
Line int `json:"line"` Line int `json:"line"`
} }
@@ -161,10 +127,6 @@ func (e extendedEvent) GetLine() int {
return e.Line return e.Line
} }
// NewExtendedEvent wraps baseEvent with the caller file and line it was
// logged from.
//
//nolint:ireturn // returning the interface is this constructor's public API
func NewExtendedEvent(baseEvent Event, file string, line int) ExtendedEvent { func NewExtendedEvent(baseEvent Event, file string, line int) ExtendedEvent {
return extendedEvent{ return extendedEvent{
Event: baseEvent, Event: baseEvent,
+1 -2
View File
@@ -1,9 +1,8 @@
package simplelog_test package simplelog
import "testing" import "testing"
// TestCompile checks if the package compiles successfully. // TestCompile checks if the package compiles successfully.
func TestCompile(t *testing.T) { func TestCompile(t *testing.T) {
t.Parallel()
// This test ensures that the simplelog package compiles without error. // This test ensures that the simplelog package compiles without error.
} }
+2 -15
View File
@@ -1,5 +1,3 @@
// Command relp_log_trial emits sample log messages through simplelog's
// default slog handler for manual testing.
package main package main
import ( import (
@@ -8,22 +6,11 @@ import (
_ "sneak.berlin/go/simplelog" // Using underscore to only invoke init() _ "sneak.berlin/go/simplelog" // Using underscore to only invoke init()
) )
// examplePort is the sample database port logged below.
const examplePort = 5432
func main() { func main() {
// Send some test messages with structured data // Send some test messages with structured data
slog.Info("Starting the application", slog.String("status", "initialized")) slog.Info("Starting the application", slog.String("status", "initialized"))
slog.Info( slog.Info("Attempting to connect to database", slog.String("host", "localhost"), slog.Int("port", 5432))
"Attempting to connect to database",
slog.String("host", "localhost"),
slog.Int("port", examplePort),
)
slog.Warn("Using default configuration", slog.String("configuration", "default")) slog.Warn("Using default configuration", slog.String("configuration", "default"))
slog.Error( slog.Error("Failed to load module", slog.String("module", "finance"), slog.String("error", "module not found"))
"Failed to load module",
slog.String("module", "finance"),
slog.String("error", "module not found"),
)
slog.Info("Shutting down the application", slog.String("status", "stopped")) slog.Info("Shutting down the application", slog.String("status", "stopped"))
} }
+11 -84
View File
@@ -4,130 +4,57 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url" "net/url"
"time"
) )
// errWebhookStatus is returned when the webhook answers with a status
// outside 2xx.
var errWebhookStatus = errors.New("webhook did not accept the record")
// webhookTimeout bounds each webhook request, reading the answer
// included. Handle runs inside the log call, so a server that never
// answers would otherwise hold that call up for good.
const webhookTimeout = 5 * time.Second
// WebhookHandler POSTs each log record as JSON to a configured webhook
// URL.
type WebhookHandler struct { type WebhookHandler struct {
webhookURL string webhookURL string
client *http.Client
attrs handlerAttrs attrs handlerAttrs
} }
// NewWebhookHandler returns a WebhookHandler that delivers records to func (w *WebhookHandler) Enabled(ctx context.Context, level slog.Level) bool {
// the given URL, validating the URL first.
func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) {
_, err := url.ParseRequestURI(webhookURL)
if err != nil {
return nil, fmt.Errorf("invalid webhook URL: %w", err)
}
return &WebhookHandler{
webhookURL: webhookURL,
client: &http.Client{
Timeout: webhookTimeout,
// Following a redirect can resend the request as a GET
// without the record, so Handle gets the redirect answer
// itself and returns it as an error.
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
}, nil
}
// Enabled reports whether the handler processes records at the given
// level; it always returns true.
func (w *WebhookHandler) Enabled(_ context.Context, _ slog.Level) bool {
return true return true
} }
// WithAttrs returns a new handler that also emits attrs, qualified by the
// groups open now. The receiver is not modified.
func (w *WebhookHandler) WithAttrs(attrs []slog.Attr) slog.Handler { func (w *WebhookHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 { if len(attrs) == 0 {
return w return w
} }
return &WebhookHandler{ return &WebhookHandler{
webhookURL: w.webhookURL, webhookURL: w.webhookURL,
client: w.client,
attrs: w.attrs.withAttrs(attrs), attrs: w.attrs.withAttrs(attrs),
} }
} }
// WithGroup returns a new handler that nests later attributes in an
// object named after the group. An empty name returns the receiver.
func (w *WebhookHandler) WithGroup(name string) slog.Handler { func (w *WebhookHandler) WithGroup(name string) slog.Handler {
if name == "" { if name == "" {
return w return w
} }
return &WebhookHandler{ return &WebhookHandler{
webhookURL: w.webhookURL, webhookURL: w.webhookURL,
client: w.client,
attrs: w.attrs.withGroup(name), attrs: w.attrs.withGroup(name),
} }
} }
// Handle marshals the record, with its attributes, to one JSON object and func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) {
// POSTs it to the webhook URL. It returns an error when the request fails if _, err := url.ParseRequestURI(webhookURL); err != nil {
// or runs past webhookTimeout, or when the server answers with a status return nil, fmt.Errorf("invalid webhook URL: %v", err)
// outside 2xx. }
return &WebhookHandler{webhookURL: webhookURL}, nil
}
func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error { func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error {
jsonData, err := json.Marshal(recordToMap(record, w.attrs)) jsonData, err := json.Marshal(recordToMap(record, w.attrs))
if err != nil { if err != nil {
return fmt.Errorf("error marshaling event: %w", err) return fmt.Errorf("error marshaling event: %v", err)
} }
response, err := http.Post(w.webhookURL, "application/json", bytes.NewBuffer(jsonData))
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
w.webhookURL,
bytes.NewReader(jsonData),
)
if err != nil {
return fmt.Errorf("error creating webhook request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
response, err := w.client.Do(request)
if err != nil { if err != nil {
return err return err
} }
defer response.Body.Close()
defer func() { _ = response.Body.Close() }()
// The answer is read to the end so the client can reuse the
// connection for the next record. The read counts toward
// webhookTimeout, so a server that sends its status and then stalls
// fails here.
_, err = io.Copy(io.Discard, response.Body)
if err != nil {
return fmt.Errorf("error reading webhook answer: %w", err)
}
if response.StatusCode < http.StatusOK ||
response.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("%w: %s", errWebhookStatus, response.Status)
}
return nil return nil
} }
-8
View File
@@ -1,8 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
prettier@3.8.1:
version "3.8.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==