23 Commits
Author SHA1 Message Date
clawbot d193c99cb7 Re-vendor the standard files from sneak/prompts dd4027b (closes #33)
check / check (push) Failing after 5s
REPO_POLICIES.md, .golangci.yml, the CI workflow and the scripts the
policy keeps identical across repositories are copies of the files at
that commit. .gitignore, .editorconfig and the new .dockerignore are the
canonical files followed by this repository's own entries. The lint
stage moves to the golangci-lint image the policy pins, in the same
commit as .golangci.yml. The format check leaves the lint stage and runs
on the host from script/check, which script/cibuild now runs after
script/bootstrap. The last Dockerfile stage runs script/bootstrap.
script/fmt runs goimports with go run at a pinned commit.

Model: opus-5-5
2026-10-06 12:49:32 +00:00
clawbot 16fc20b81c Take the console location from the record (closes #36)
check / check (push) Successful in 19s
ConsoleHandler found the file and line it prints by walking a fixed
number of stack frames up from itself, which only fit a record logged
through a slog.Logger and delivered through MultiplexHandler. It now
reads them from the record's PC, the call site slog stores, so a
ConsoleHandler used with slog.New and a record passed to Handle
directly print the right location too. A record whose PC is zero
prints ???:0 as before. The README drops its warning about the wrong
location and says how to set PC instead.

Model: opus-5-5
2026-10-06 14:34:48 +02:00
clawbot 9b3d7326ce Run the tests under the race detector, in Docker (closes #23)
check / check (push) Successful in 23s
script/test is now the standard script: it builds only the test stage
of the Dockerfile, without the build cache, so tests no longer run on
the host. That stage calls go test -race -cover directly, with a
verbose rerun on failure, on the Debian-based golang image, which has
the C toolchain -race needs.

The test stage no longer copies from the lint stage, so script/test
builds the tests alone. A new final stage copies one file from each of
the two stages; that is what makes a plain docker build, and so
script/cibuild, run both. The README entry for script/test and TODO.md
say what now runs.

Model: opus-5-5
2026-10-06 13:48:23 +02:00
clawbot 8e53530434 Run the linter only in Docker (closes #20)
check / check (push) Successful in 48s
script/lint now builds only the lint stage of the Dockerfile, without
the build cache, so every run executes the linter; the image is tagged
simplelog-lint. The lint stage calls golangci-lint directly, since make
lint is itself a docker build of that stage. script/cibuild and
script/docker also build without the cache, so their check steps always
run. script/fmt no longer runs golangci-lint --fix, and script/bootstrap
no longer installs it. golangci-lint config verify is left out, on the
owner's ruling. The README, TODO.md and the script/cibuild comment say
what now runs.

Model: opus-5-5
2026-10-06 09:41:32 +02:00
clawbot 151dd42b4b Return sink write errors from every handler (closes #22)
check / check (push) Successful in 35s
The console and JSON handlers threw away the error from their write to
stdout, so a lost log line looked delivered. They now return it,
wrapped. The webhook handler also returns an error for a status outside
2xx, and no longer follows redirects, which could resend the request
without the record. The multiplex handler passes the record to every
handler and returns their errors joined with errors.Join instead of
stopping at the first.

Both stdout handlers gain an unexported writer, nil meaning os.Stdout at
write time, so tests can supply a sink that fails. The README says what
each handler returns and that the slog.Logger methods discard a
handler's error.

Model: opus-5-5
2026-10-06 08:45:17 +02:00
clawbot b28c0dca0c Bring main's fixes into next (closes #16)
check / check (push) Failing after 2s
Lands the merge of main into next, so main is an ancestor of next.

Model: opus-5-5
2026-10-06 01:29:59 +02:00
sneak 2c3c35095b Bring main's fixes into next (closes #16)
check / check (push) Failing after 2s
check / check (pull_request) Failing after 2s
Merges main into next as a two-parent merge, so main becomes an ancestor
of next and the later merge of next into main does not conflict. This
brings in golangci-lint v2.12.2 with .golangci.yml, and the fix that
makes every handler emit slog attributes.

README.md and TODO.md conflicted. README.md keeps main's Attribute
output section, followed by next's Entrypoints section. TODO.md keeps
the completed steps from both sides, newest first. The other files
merged cleanly: the Makefile is still next's script/ shims, and the
Dockerfile has main's v2.12.2 lint image.

Model: opus-5-5
2026-10-05 23:22:45 +00:00
sneak 7f0cd5dd5f Merge pull request 'Emit slog attributes from every handler' (#21) from fix/handler-attrs into main
check / check (push) Successful in 40s
Reviewed-on: #21
2026-09-29 03:15:35 +02:00
sneak 86436449c5 Emit slog attributes from every handler (closes #19, closes #24)
check / check (push) Successful in 34s
check / check (pull_request) Successful in 32s
Handle never read the record's attributes, and WithAttrs and WithGroup
returned the receiver unchanged. The JSON and webhook handlers also marshaled
slog.Record itself, whose attributes are unexported.

Each handler now carries a handlerAttrs value, copied rather than mutated, so
sibling loggers cannot leak attributes into each other. JSON and webhook
output nest groups as objects; the console appends key=value pairs with dotted
group keys, quoted as slog.NewTextHandler quotes them, invalid UTF-8
included. Logged values are never written to. In JSON the record's own fields
win a key collision, and a repeated key keeps its last value unless both are
groups, which merge; the README documents both.

Deviation: DEL is quoted on the console; the stdlib leaves it bare.

Model: opus-5-5
2026-09-28 10:49:46 +00:00
sneak a5fdadba76 Add a failing test pinning the discarded slog attributes
Every handler drops slog attributes: Handle never reads the record's
attributes, and WithAttrs and WithGroup return the receiver unchanged. So
slog.Info("casting", "device", d) loses its field.

The test checks the bytes each handler writes, directly and through
MultiplexHandler: record attributes, WithAttrs, WithGroup, slog.Group nesting
and LogValuer resolution. It also pins what a fix must not break: logged values
are never modified, durations are nanoseconds in JSON and "3s" on the console,
and the record's own field names win a key collision. Console keys and values
are quoted as slog.NewTextHandler quotes them, invalid UTF-8 included.

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

Rule suppressed: paralleltest; the tests swap the process-wide os.Stdout.

Refs: #19

Model: opus-5-5
2026-09-28 10:49:07 +00:00
sneak 64c30e979d Merge pull request 'Update golangci-lint to v2.12.2 with canonical config' (#17) from golangci-v2.12.2 into main
check / check (push) Successful in 47s
Reviewed-on: #17
2026-08-10 15:40:04 +02:00
sneak 403ba4c42e build: update golangci-lint to v2.12.2 with canonical config
check / check (push) Successful in 29s
check / check (pull_request) Successful in 32s
Add the canonical .golangci.yml (v2 schema, all linters enabled with a
small documented disable list) and pin the Dockerfile lint stage to
golangci/golangci-lint:v2.12.2 by tag and digest, replacing the old
v1.64.8 digest-only pin.

Fix all findings surfaced by the v1 to v2 jump without changing any
exported signatures or behavior:

- add package and exported-symbol doc comments (revive)
- rename unused handler parameters to underscore (revive)
- check or explicitly discard error returns (errcheck, errchkjson)
- wrap errors with %w instead of %v (err113)
- use http.NewRequestWithContext instead of http.Post (noctx)
- replace fmt.Println with fmt.Fprintln(os.Stdout, ...) (forbidigo)
- name magic numbers as constants (mnd)
- add explicit slog.LevelDebug case (exhaustive)
- interface{} to any (modernize)
- move tests to the simplelog_test package (testpackage) and add
  t.Parallel() (paralleltest)
- move NewWebhookHandler above its methods (funcorder)
- whitespace, line-length, and blank-line fixes (wsl_v5, whitespace,
  nlreturn, lll, embeddedstructfieldcheck)
- nolint with justification for the intentional init/global design
  (gochecknoinits, gochecknoglobals) and interface-returning
  constructor (ireturn)
2026-08-07 17:10:03 +00:00
sneak ac3031a547 Add vendored REPO_POLICIES.md from prompts repo
check / check (push) Successful in 21s
2026-07-07 01:55:33 +02:00
sneak 5cb4f827d2 Adopt scripts-to-rule-them-all: script/ entrypoints, Makefile shims 2026-07-07 01:54:40 +02:00
sneak 6cb690bfb7 Add standard Workflow section to TODO.md
check / check (push) Successful in 23s
2026-07-06 21:06:43 +02:00
sneak 3bd6551c8e Merge branch 'TODO'
check / check (push) Successful in 46s
2026-07-06 20:51:17 +02:00
sneak 403d853d27 Add TODO.md 2026-07-06 20:35:50 +02:00
clawbotandclawbot 4abd40d8e2 fix: split Dockerfile with pinned images and add CI workflow (#14)
## Summary

Rewrites the Dockerfile to use sha256-pinned images and proper multi-stage build structure. Adds missing Makefile targets and a Gitea CI workflow.

## Changes

### Dockerfile
- **Lint stage**: `golangci/golangci-lint` v1.64.8 pinned by sha256 — runs `make fmt-check` + `make lint`
- **Test stage**: `golang` 1.22.12 pinned by sha256 — runs `make test` with dependency on lint stage
- Removed redundant final stage (this is a library with no binary to build)
- Both images pinned by digest with version+date comments

### Makefile
- Added `fmt-check` target: verifies `gofmt` compliance without modifying files
- Added `check` target: runs `fmt-check`, `lint`, `test` in sequence
- Added `hooks` target: installs a pre-commit hook that runs `make check`
- Separated `gofmt` check from `lint` target (was previously bundled)
- Changed default target from `test` to `check`

### CI
- Added `.gitea/workflows/check.yml`: runs `docker build .` on push to main and on PRs

## Verification

`docker build --progress plain .` passes — all stages complete successfully.

closes #9

<!-- session: agent:sdlc-manager:subagent:fffa0a5a-5127-4489-a2e0-314c5eaaed68 -->

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #14
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-02 21:06:53 +01:00
sneak 9121da9aae Merge pull request 'fix: JSONHandler deadlock from recursive log.Println (closes #3)' (#4) from clawbot/simplelog:fix/json-handler-deadlock into main
Reviewed-on: #4
2026-02-08 18:29:55 +01:00
sneak 74ce052b77 Merge branch 'main' into fix/json-handler-deadlock 2026-02-08 18:29:12 +01:00
sneak 1eef38a5fa Merge pull request 'test: add deadlock regression test for JSONHandler (issue #3)' (#7) from clawbot/simplelog:test/jsonhandler-deadlock into main
Reviewed-on: #7
2026-02-08 18:27:15 +01:00
clawbot 97a82e9b2c test: add deadlock regression test for JSONHandler
Reproduces issue #3 — JSONHandler.Handle() calling log.Println() causes
a deadlock when slog.SetDefault redirects log output back through slog.

This test hangs/fails on main and should pass once #4 is merged.
2026-02-08 09:21:08 -08:00
user 869b7ca4c3 fix: replace log.Println with fmt.Fprintln in JSONHandler to prevent deadlock 2026-02-08 09:15:17 -08:00
36 changed files with 3210 additions and 93 deletions
+78
View File
@@ -0,0 +1,78 @@
# .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 and coverage output.
/cmd/example/example
/*.test
/*.out
+15
View File
@@ -0,0 +1,15 @@
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
+9
View File
@@ -0,0 +1,9 @@
name: check
on: [push]
jobs:
check:
runs-on: ubuntu-latest
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: script/cibuild
+56 -1
View File
@@ -1,2 +1,57 @@
.aider* # 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 cmd/example/example
# aider's files, among them a config file that can hold an API key.
.aider*
+99
View File
@@ -0,0 +1,99 @@
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
+32 -35
View File
@@ -1,39 +1,36 @@
# First stage: Use the golangci-lint image to run the linter # Lint stage. The format check is not here: it runs on the host, in
FROM golangci/golangci-lint:latest as lint # script/fmt-check.
# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24
# Set the Current Working Directory inside the container FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
WORKDIR /app WORKDIR /src
COPY go.mod go.sum ./
# Copy the go.mod file and the rest of the application code RUN go mod download
COPY go.mod ./
COPY . . COPY . .
# Called directly: make lint is itself a docker build of this stage.
RUN golangci-lint run --config .golangci.yml ./...
# Run golangci-lint # Test stage: run full test suite under the race detector
RUN golangci-lint run # golang 1.22.12 (Debian-based; -race needs its C toolchain), 2025-02-04
FROM golang@sha256:1cf6c45ba39db9fd6db16922041d074a63c935556a05c5ccb62d181034df7f02 AS test
RUN sh -c 'test -z "$(gofmt -l .)"' WORKDIR /src
COPY go.mod go.sum ./
# Second stage: Use the official Golang image to run tests RUN go mod download
FROM golang:1.22 as test
# Set the Current Working Directory inside the container
WORKDIR /app
# Copy the go.mod file and the rest of the application code
COPY go.mod ./
COPY . . 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; }
# Run tests # Final stage: a development environment, set up by script/bootstrap.
RUN go test -v ./... # 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
# Final stage: Combine the linting and testing stages # VERSION build argument that script/docker and script/cibuild pass.
FROM golang:1.22 as final # golang 1.22.12 (2025-02-04)
FROM golang@sha256:1cf6c45ba39db9fd6db16922041d074a63c935556a05c5ccb62d181034df7f02
# Ensure that the linting stage succeeded COPY --from=lint /src/go.sum /dev/null
WORKDIR /app COPY --from=test /src/go.sum /dev/null
COPY --from=lint /app . WORKDIR /src
COPY --from=test /app . COPY script/ script/
COPY go.mod go.sum ./
# Set the final CMD to something minimal since we only needed to verify lint and tests during build RUN script/bootstrap
CMD ["echo", "Build and tests passed successfully!"] COPY . .
+21 -8
View File
@@ -1,17 +1,30 @@
.PHONY: test .PHONY: bootstrap setup test fmt fmt-check lint check docker hooks
default: test default: check
bootstrap:
@script/bootstrap
setup:
@script/setup
test: test:
@go test -v ./... @script/test
fmt: fmt:
goimports -l -w . @script/fmt
golangci-lint run --fix
fmt-check:
@script/fmt-check
lint: lint:
golangci-lint run @script/lint
sh -c 'test -z "$$(gofmt -l .)"'
check:
@script/check
docker: docker:
docker build --progress plain . @script/docker
hooks:
@script/install-precommit
+111
View File
@@ -18,6 +18,16 @@ 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 with `WithAttrs`, and those qualified by `WithGroup`.
`slog.Group` values nest, and `slog.LogValuer` values are resolved. In
json output attributes are object fields (groups become nested objects);
in console output they are appended as `key=value` pairs, with grouped
keys written as `group.key=value`. See
[Attribute output](#attribute-output) for the details worth knowing
- reports a record that could not be delivered as an error from the
handler's `Handle` method. See
[When delivery fails](#when-delivery-fails) for how to receive it
## Planned Features ## Planned Features
@@ -60,6 +70,107 @@ func main() {
} }
``` ```
## Attribute output
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 time they were attached. `slog.Group` values nest, and
`slog.LogValuer` values are resolved to the value they stand for. A few
behaviours are worth knowing before you rely on them.
**Your values are never modified.** Whatever you log is read and rendered,
never written to. A map or a slice you pass to `slog.Any` comes back from
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 webhook payloads emit a `slog.Duration` as a number of
nanoseconds, matching `slog.NewJSONHandler`, so a consumer can compare and
aggregate the field without parsing it. The console line emits the same
duration as `3s`, matching `slog.NewTextHandler`, because a person reads
it.
**The json payload is an object, with the consequences an object has.**
The record's own fields are named `Time`, `Level`, `Message` and `PC`, and
they own those names: an attribute keyed after one of them is dropped from
the json and webhook output. A key logged more than once keeps its last
value there for the same reason, with one exception: when both are
`slog.Group` values sharing a key, the two groups are merged into one
object holding the members of both, rather than the second replacing the
first. Neither the collision nor the merge applies to the console output,
which is a line of text: every pair appears, in order. If you need a field
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, an empty group is elided along with its key, a group with an
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
- `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, then
`go mod download`); 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 with goimports (writes); goimports runs
with `go run` at a pinned commit, so nothing needs to install it
- `script/fmt-check` — check formatting (read-only); fails if `gofmt -l`
reports files
- `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
[WTFPL](./LICENSE) [WTFPL](./LICENSE)
+679
View File
@@ -0,0 +1,679 @@
---
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.
+83
View File
@@ -0,0 +1,83 @@
# Workflow
* branch (from `main`)
* do the work in Next Step
* move Next Step to the top of Completed Steps
* move the top item of Future Steps into Next Step
* commit (`TODO.md` changes in the same commit as the work)
* merge to `main` if the branch is not protected, otherwise open a PR
* push
# Status
1.0+
Tagged v1.0.0 (2024-06-14) and 1.0.1 (2026-02-08). In post-1.0
maintenance; the library is referenced by the Go styleguide.
# Next Step
Restructure README.md into the standard sections: Description, Getting
Started, Rationale, Design, TODO, License, Author
# Completed Steps
* 2026-10-06: re-vendored the standard files from `sneak/prompts` at
commit `dd4027b`: `REPO_POLICIES.md`, the CI workflow, `.gitignore`,
and `.golangci.yml` together with the lint stage's new image, plus
the `.editorconfig` and `.dockerignore` this repo lacked, which
finishes the Makefile and policy-files step. `script/cibuild` now
bootstraps and runs `script/check` before building the image, the
format check runs only on the host, the last `Dockerfile` stage runs
`script/bootstrap`, and `script/fmt` runs goimports at a pinned commit
* 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
* Delete the old plain TODO file once this TODO.md lands
* Add .aider.* to .gitignore and remove the stray aider artifacts from
the working tree
* Pick one tag scheme before the next release (v1.0.0 vs 1.0.1 are
inconsistent)
* 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)
+285
View File
@@ -0,0 +1,285 @@
package simplelog
import (
"encoding/json"
"log/slog"
"strconv"
"strings"
"unicode"
"unicode/utf8"
)
// handlerAttrs is the attribute state every handler carries: the attributes
// accumulated by WithAttrs, plus the groups opened by WithGroup. Its methods
// never mutate the receiver, so handlers derived from a common parent stay
// independent of each other.
type handlerAttrs struct {
attrs []slog.Attr
groups []string
}
// withAttrs returns a copy carrying attrs in addition to those already held.
// The attributes are qualified by the groups that are open at the time they
// are attached, as the slog.Handler contract requires.
func (h handlerAttrs) withAttrs(attrs []slog.Attr) handlerAttrs {
qualified := qualifyAttrs(h.groups, attrs)
combined := make([]slog.Attr, 0, len(h.attrs)+len(qualified))
combined = append(combined, h.attrs...)
combined = append(combined, qualified...)
return handlerAttrs{attrs: combined, groups: h.groups}
}
// withGroup returns a copy with a further group open. An empty name is a no-op,
// per the slog.Handler contract.
func (h handlerAttrs) withGroup(name string) handlerAttrs {
if name == "" {
return h
}
groups := make([]string, 0, len(h.groups)+1)
groups = append(groups, h.groups...)
groups = append(groups, name)
return handlerAttrs{attrs: h.attrs, groups: groups}
}
// forRecord returns the accumulated attributes followed by the record's own,
// the latter qualified by any open groups.
func (h handlerAttrs) forRecord(record slog.Record) []slog.Attr {
own := qualifyAttrs(h.groups, recordAttrs(record))
all := make([]slog.Attr, 0, len(h.attrs)+len(own))
all = append(all, h.attrs...)
all = append(all, own...)
return all
}
// qualifyAttrs nests attrs inside the given open groups, innermost last.
func qualifyAttrs(groups []string, attrs []slog.Attr) []slog.Attr {
for i := len(groups) - 1; i >= 0; i-- {
attrs = []slog.Attr{{
Key: groups[i],
Value: slog.GroupValue(attrs...),
}}
}
return attrs
}
// recordAttrs collects the attributes a record carries. They live in
// unexported fields, so they are only reachable through Record.Attrs - which
// is why marshaling a slog.Record directly loses every one of them.
func recordAttrs(record slog.Record) []slog.Attr {
attrs := make([]slog.Attr, 0, record.NumAttrs())
record.Attrs(func(attr slog.Attr) bool {
attrs = append(attrs, attr)
return true
})
return attrs
}
// groupMap is a JSON object this package built for itself: the payload of a
// record, or one of the nested objects a slog.Group becomes.
//
// The distinct type is what keeps the handler from writing into data the
// caller still owns. A value handed to slog.Any goes into the payload by
// reference - copying every logged map and slice would be a real cost for no
// gain, since rendering only reads. The one place the handler writes into a
// value already in the payload is the group merge below, and a type assertion
// to groupMap can only succeed on a map this package allocated: a caller's
// map[string]any is a different type and never matches, however it was keyed.
// So the invariant holds by construction - nothing reachable from the caller
// is ever written to, only read.
type groupMap map[string]any
// recordToMap renders a record, with its handler's attributes, as the JSON
// object the JSON and webhook handlers emit. The record's own fields keep the
// names they have always had, and win a collision with an attribute key.
func recordToMap(record slog.Record, attrs handlerAttrs) groupMap {
fields := attrsToMap(attrs.forRecord(record))
fields["Time"] = record.Time
fields["Level"] = record.Level
fields["Message"] = record.Message
fields["PC"] = record.PC
return fields
}
// attrsToMap renders attributes as a JSON object in which groups are nested
// objects. A group named more than once is merged rather than duplicated;
// any other repeated key keeps the last value, as a JSON object must.
func attrsToMap(attrs []slog.Attr) groupMap {
fields := make(groupMap, len(attrs))
for _, attr := range attrs {
addAttrToMap(fields, attr)
}
return fields
}
func addAttrToMap(fields groupMap, attr slog.Attr) {
value := attr.Value.Resolve()
if attr.Key == "" && value.Any() == nil {
// An empty Attr is ignored, per the slog.Handler contract.
return
}
if value.Kind() == slog.KindGroup {
group := value.Group()
if len(group) == 0 {
// An empty group is elided, as is its key.
return
}
// A group with an empty key is inlined into its parent.
target := fields
if attr.Key != "" {
// Merge only into a group this package built. Anything else
// at this key - including a map the caller logged - is
// replaced rather than written into, so the caller's own
// data structure is never touched.
nested, ok := fields[attr.Key].(groupMap)
if !ok {
nested = make(groupMap, len(group))
fields[attr.Key] = nested
}
target = nested
}
for _, member := range group {
addAttrToMap(target, member)
}
return
}
fields[attr.Key] = jsonValue(value)
}
// jsonValue converts a resolved slog.Value into something encoding/json can
// render usefully. Values it cannot marshal - and errors, which marshal to an
// empty object - fall back to their slog string form, so a value is never
// rendered as an empty object.
//
// Values are returned as they were given, not copied: nothing here or in its
// callers writes to a value the caller supplied.
func jsonValue(value slog.Value) any {
switch value.Kind() {
case slog.KindString:
return value.String()
case slog.KindInt64:
return value.Int64()
case slog.KindUint64:
return value.Uint64()
case slog.KindFloat64:
return value.Float64()
case slog.KindBool:
return value.Bool()
case slog.KindDuration:
// Nanoseconds as a number, which is what slog.NewJSONHandler
// emits. A JSON consumer can then compare and aggregate the
// field; the "3s" form would have to be parsed first, and no
// common log pipeline knows how.
return value.Duration().Nanoseconds()
case slog.KindTime:
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:
// Anything a future Go release adds.
return jsonAnyValue(value)
}
}
func jsonAnyValue(value slog.Value) any {
held := value.Any()
if _, ok := held.(json.Marshaler); !ok {
if err, ok := held.(error); ok {
return err.Error()
}
}
_, err := json.Marshal(held)
if err != nil {
return value.String()
}
return held
}
// attrsToText renders attributes as the space separated key=value pairs the
// console handler appends to a log line. Groups become dotted key prefixes.
//
// Values take their slog string form, so a duration reads as "3s" here where
// the json output carries nanoseconds as a number. That is the same split the
// stdlib makes between slog.NewTextHandler and slog.NewJSONHandler: the
// console line is read by a person, the json line by a program.
func attrsToText(attrs []slog.Attr) string {
var out strings.Builder
for _, attr := range attrs {
appendAttrText(&out, "", attr)
}
return out.String()
}
func appendAttrText(out *strings.Builder, prefix string, attr slog.Attr) {
value := attr.Value.Resolve()
if attr.Key == "" && value.Any() == nil {
return
}
if value.Kind() == slog.KindGroup {
group := value.Group()
if len(group) == 0 {
return
}
nested := prefix
if attr.Key != "" {
nested = prefix + attr.Key + "."
}
for _, member := range group {
appendAttrText(out, nested, member)
}
return
}
out.WriteString(" ")
// The key is quoted on the same terms as the value, and as a whole
// including its group prefix, because that is the token a reader has to
// find the "=" in. slog.NewTextHandler quotes "prefix+key" the same way,
// so a key like "a=b" reads as "a=b"=v2 rather than the ambiguous
// a=b=v2, which parses as the key "a" with the value "b=v2".
out.WriteString(quoteIfNeeded(prefix + attr.Key))
out.WriteString("=")
out.WriteString(quoteIfNeeded(value.String()))
}
// quoteIfNeeded quotes a key or a value, as slog.NewTextHandler does, when
// leaving it bare would make the key=value pairs ambiguous or would write a
// 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 {
if text == "" {
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 {
if unicode.IsSpace(r) || !unicode.IsPrint(r) ||
r == utf8.RuneError || r == '"' || r == '=' {
return strconv.Quote(text)
}
}
return text
}
+912
View File
@@ -0,0 +1,912 @@
//nolint:paralleltest // tests swap the process-wide os.Stdout to read handler output
package simplelog_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"maps"
"net/http"
"net/http/httptest"
"os"
"reflect"
"slices"
"strings"
"testing"
"time"
"sneak.berlin/go/simplelog"
)
// These tests assert on the bytes the handlers actually emit, because that is
// where the defect lives: the handlers accept attributes and then throw them
// away, so nothing short of reading the output proves they survived.
// 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
// this is the only way to see their real output. os.Stdout is shared by the
// whole process, so a test that uses this must not run in parallel.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
original := os.Stdout
os.Stdout = w
collected := make(chan string, 1)
go func() {
var buf bytes.Buffer
_, _ = io.Copy(&buf, r)
collected <- buf.String()
}()
defer func() {
os.Stdout = original
_ = r.Close()
}()
fn()
os.Stdout = original
err = w.Close()
if err != nil {
t.Fatalf("close pipe writer: %v", err)
}
return <-collected
}
// testRecord builds an INFO record carrying the given attributes.
func testRecord(message string, attrs ...slog.Attr) slog.Record {
record := slog.NewRecord(time.Now(), slog.LevelInfo, message, 0)
record.AddAttrs(attrs...)
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.
func decodeLine(t *testing.T, output string) map[string]any {
t.Helper()
line := strings.TrimSpace(output)
if line == "" {
t.Fatal("handler emitted no output")
}
var decoded map[string]any
err := json.Unmarshal([]byte(line), &decoded)
if err != nil {
t.Fatalf("output is not valid JSON: %v\noutput: %s", err, line)
}
return decoded
}
// wantField asserts that a decoded JSON object has key with the given value.
func wantField(t *testing.T, decoded map[string]any, key string, want any) {
t.Helper()
got, ok := decoded[key]
if !ok {
t.Fatalf("field %q missing from output: %v", key, decoded)
}
if got != want {
t.Fatalf("field %q = %v, want %v", key, got, want)
}
}
// wantGroup asserts that a decoded JSON object has key holding a nested object.
func wantGroup(t *testing.T, decoded map[string]any, key string) map[string]any {
t.Helper()
got, ok := decoded[key]
if !ok {
t.Fatalf("group %q missing from output: %v", key, decoded)
}
group, ok := got.(map[string]any)
if !ok {
t.Fatalf("field %q = %v, want a nested object", key, got)
}
return group
}
// wantNoField asserts that a decoded JSON object does not carry key.
func wantNoField(t *testing.T, decoded map[string]any, key string) {
t.Helper()
if got, present := decoded[key]; present {
t.Fatalf("field %q unexpectedly present as %v: %v", key, got, decoded)
}
}
// wantUnchanged asserts that a value the caller still owns was not written to
// by the handler. Logging must read what it is handed, never modify it.
func wantUnchanged(t *testing.T, what string, got, want any) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Fatalf("handler mutated the caller's %s: got %v, want %v", what, got, want)
}
}
// wantContains asserts that console output contains a fragment.
func wantContains(t *testing.T, output, fragment string) {
t.Helper()
if !strings.Contains(output, fragment) {
t.Fatalf("output does not contain %q\noutput: %s", fragment, output)
}
}
// wantNotContains asserts that console output does not contain a fragment.
func wantNotContains(t *testing.T, output, fragment string) {
t.Helper()
if strings.Contains(output, fragment) {
t.Fatalf("output unexpectedly contains %q\noutput: %s", fragment, output)
}
}
// castTarget is a slog.LogValuer: the handler must resolve it rather than
// serialising the struct itself.
type castTarget struct {
id string
}
func (c castTarget) LogValue() slog.Value {
return slog.StringValue(c.id)
}
var _ slog.LogValuer = castTarget{}
// errConnectionRefused is the error the ResolvesValues tests log.
var errConnectionRefused = errors.New("connection refused")
func TestJSONHandlerEmitsRecordAttrs(t *testing.T) {
output := captureStdout(t, func() {
handler := simplelog.NewJSONHandler()
record := testRecord(
"casting",
slog.String("device", "livingroom"),
slog.String("file", "movie.mp4"),
slog.Int("attempt", 3),
)
handle(t, handler, record)
})
decoded := decodeLine(t, output)
wantField(t, decoded, "Message", "casting")
wantField(t, decoded, "device", "livingroom")
wantField(t, decoded, "file", "movie.mp4")
wantField(t, decoded, "attempt", float64(3))
}
func TestJSONHandlerWithAttrsAccumulates(t *testing.T) {
output := captureStdout(t, func() {
handler := simplelog.NewJSONHandler().
WithAttrs([]slog.Attr{slog.String("service", "cattbox")}).
WithAttrs([]slog.Attr{slog.String("component", "caster")})
record := testRecord("casting", slog.String("device", "livingroom"))
handle(t, handler, record)
})
decoded := decodeLine(t, output)
wantField(t, decoded, "service", "cattbox")
wantField(t, decoded, "component", "caster")
wantField(t, decoded, "device", "livingroom")
}
func TestJSONHandlerWithAttrsDoesNotMutateReceiver(t *testing.T) {
parent := simplelog.NewJSONHandler()
first := parent.WithAttrs([]slog.Attr{slog.String("worker", "first")})
second := parent.WithAttrs([]slog.Attr{slog.String("worker", "second")})
firstOutput := captureStdout(t, func() {
handle(t, first, testRecord("work"))
})
secondOutput := captureStdout(t, func() {
handle(t, second, testRecord("work"))
})
parentOutput := captureStdout(t, func() {
handle(t, parent, testRecord("work"))
})
wantField(t, decodeLine(t, firstOutput), "worker", "first")
wantField(t, decodeLine(t, secondOutput), "worker", "second")
if _, present := decodeLine(t, parentOutput)["worker"]; present {
t.Fatalf(
"parent handler leaked an attribute from a derived handler: %s",
parentOutput,
)
}
}
func TestJSONHandlerWithGroupNestsAttrs(t *testing.T) {
output := captureStdout(t, func() {
handler := simplelog.NewJSONHandler().
WithGroup("cast").
WithAttrs([]slog.Attr{slog.String("device", "livingroom")})
record := testRecord("casting", slog.String("file", "movie.mp4"))
handle(t, handler, record)
})
decoded := decodeLine(t, output)
wantField(t, decoded, "Message", "casting")
group := wantGroup(t, decoded, "cast")
wantField(t, group, "device", "livingroom")
wantField(t, group, "file", "movie.mp4")
}
func TestJSONHandlerResolvesValues(t *testing.T) {
output := captureStdout(t, func() {
handler := simplelog.NewJSONHandler()
record := testRecord(
"cast failed",
slog.Group("request", slog.Int("status", 502), slog.String("method", "POST")),
slog.Any("target", castTarget{id: "chromecast-7"}),
slog.Any("error", errConnectionRefused),
)
handle(t, handler, record)
})
decoded := decodeLine(t, output)
wantField(t, decoded, "target", "chromecast-7")
wantField(t, decoded, "error", "connection refused")
group := wantGroup(t, decoded, "request")
wantField(t, group, "status", float64(502))
wantField(t, group, "method", "POST")
}
func TestConsoleHandlerEmitsRecordAttrs(t *testing.T) {
output := captureStdout(t, func() {
handler := simplelog.NewConsoleHandler()
record := testRecord(
"casting",
slog.String("device", "livingroom"),
slog.String("file", "movie.mp4"),
slog.Int("attempt", 3),
)
handle(t, handler, record)
})
wantContains(t, output, "casting")
wantContains(t, output, "device=livingroom")
wantContains(t, output, "file=movie.mp4")
wantContains(t, output, "attempt=3")
}
func TestConsoleHandlerQuotesValuesNeedingIt(t *testing.T) {
output := captureStdout(t, func() {
handler := simplelog.NewConsoleHandler()
record := testRecord("casting", slog.String("file", "The Movie.mp4"))
handle(t, handler, record)
})
wantContains(t, output, `file="The Movie.mp4"`)
}
// TestConsoleHandlerQuotesKeysNeedingIt pins the key side of the same rule.
// A key is quoted on the same terms as a value, and as one token including
// its group prefix, so that the "=" separating the pair is always the first
// one outside quotes. Every case here was compared against
// slog.NewTextHandler, which renders each of them identically.
func TestConsoleHandlerQuotesKeysNeedingIt(t *testing.T) {
tests := []struct {
name string
build func() slog.Handler
attrs []slog.Attr
want string
notWant string
}{
{
name: "an ordinary key is left bare",
build: func() slog.Handler { return simplelog.NewConsoleHandler() },
attrs: []slog.Attr{slog.String("device", "livingroom")},
want: " device=livingroom",
notWant: `"device"`,
},
{
name: "a key containing an equals sign is quoted",
build: func() slog.Handler { return simplelog.NewConsoleHandler() },
attrs: []slog.Attr{slog.String("a=b", "v2")},
want: ` "a=b"=v2`,
notWant: " a=b=v2",
},
{
name: "a key containing a space is quoted",
build: func() slog.Handler { return simplelog.NewConsoleHandler() },
attrs: []slog.Attr{slog.String("my key", "v")},
want: ` "my key"=v`,
notWant: " my key=v",
},
{
name: "a key containing a quote is escaped",
build: func() slog.Handler { return simplelog.NewConsoleHandler() },
attrs: []slog.Attr{slog.String(`he"llo`, "v")},
want: ` "he\"llo"=v`,
notWant: ` he"llo=v`,
},
{
name: "a printable non-ascii key is left bare",
build: func() slog.Handler { return simplelog.NewConsoleHandler() },
attrs: []slog.Attr{slog.String("キー", "v")},
want: " キー=v",
notWant: `"キー"`,
},
{
name: "a group prefix is quoted together with its key",
build: func() slog.Handler { return simplelog.NewConsoleHandler() },
attrs: []slog.Attr{
slog.Group("grp", slog.String("a=b", "v")),
},
want: ` "grp.a=b"=v`,
notWant: " grp.a=b=v",
},
{
name: "a WithGroup prefix needing quotes is quoted with its key",
build: func() slog.Handler {
return simplelog.NewConsoleHandler().WithGroup("my grp")
},
attrs: []slog.Attr{slog.String("k", "v")},
want: ` "my grp.k"=v`,
notWant: " my grp.k=v",
},
{
name: "an empty key is quoted rather than left as a gap",
build: func() slog.Handler { return simplelog.NewConsoleHandler() },
attrs: []slog.Attr{slog.String("", "v")},
want: ` ""=v`,
notWant: " =v",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
output := captureStdout(t, func() {
handle(t, test.build(), testRecord("casting", test.attrs...))
})
wantContains(t, output, test.want)
wantNotContains(t, output, test.notWant)
})
}
}
// 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) {
output := captureStdout(t, func() {
handler := simplelog.NewConsoleHandler().
WithAttrs([]slog.Attr{slog.String("service", "cattbox")}).
WithAttrs([]slog.Attr{slog.String("component", "caster")})
record := testRecord("casting", slog.String("device", "livingroom"))
handle(t, handler, record)
})
wantContains(t, output, "service=cattbox")
wantContains(t, output, "component=caster")
wantContains(t, output, "device=livingroom")
}
func TestConsoleHandlerWithAttrsDoesNotMutateReceiver(t *testing.T) {
parent := simplelog.NewConsoleHandler()
first := parent.WithAttrs([]slog.Attr{slog.String("worker", "first")})
second := parent.WithAttrs([]slog.Attr{slog.String("worker", "second")})
firstOutput := captureStdout(t, func() {
handle(t, first, testRecord("work"))
})
secondOutput := captureStdout(t, func() {
handle(t, second, testRecord("work"))
})
parentOutput := captureStdout(t, func() {
handle(t, parent, testRecord("work"))
})
wantContains(t, firstOutput, "worker=first")
wantNotContains(t, firstOutput, "worker=second")
wantContains(t, secondOutput, "worker=second")
wantNotContains(t, secondOutput, "worker=first")
wantNotContains(t, parentOutput, "worker=")
}
func TestConsoleHandlerWithGroupQualifiesAttrs(t *testing.T) {
output := captureStdout(t, func() {
handler := simplelog.NewConsoleHandler().
WithGroup("cast").
WithAttrs([]slog.Attr{slog.String("device", "livingroom")})
record := testRecord("casting", slog.String("file", "movie.mp4"))
handle(t, handler, record)
})
wantContains(t, output, "cast.device=livingroom")
wantContains(t, output, "cast.file=movie.mp4")
}
func TestConsoleHandlerResolvesValues(t *testing.T) {
output := captureStdout(t, func() {
handler := simplelog.NewConsoleHandler()
record := testRecord(
"cast failed",
slog.Group("request", slog.Int("status", 502)),
slog.Any("target", castTarget{id: "chromecast-7"}),
slog.Any("error", errConnectionRefused),
)
handle(t, handler, record)
})
wantContains(t, output, "request.status=502")
wantContains(t, output, "target=chromecast-7")
wantContains(t, output, `error="connection refused"`)
}
func TestWebhookHandlerEmitsAttrs(t *testing.T) {
t.Parallel()
bodies := make(chan []byte, 1)
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read webhook body: %v", err)
}
bodies <- body
w.WriteHeader(http.StatusOK)
},
))
defer server.Close()
handler, err := simplelog.NewWebhookHandler(server.URL)
if err != nil {
t.Fatalf("NewWebhookHandler: %v", err)
}
withAttrs := handler.WithAttrs([]slog.Attr{slog.String("service", "cattbox")})
record := testRecord("casting", slog.String("device", "livingroom"))
handle(t, withAttrs, record)
var body []byte
select {
case body = <-bodies:
case <-time.After(5 * time.Second):
t.Fatal("webhook handler posted nothing")
}
decoded := decodeLine(t, string(body))
wantField(t, decoded, "service", "cattbox")
wantField(t, decoded, "device", "livingroom")
}
// TestMultiplexHandlerPassesAttrsThrough guards the composite handler that
// package init installs: attributes must survive the multiplex too. It is
// built while stdout is the capture pipe, which is not a terminal, so it
// writes through the JSON handler.
func TestMultiplexHandlerPassesAttrsThrough(t *testing.T) {
output := captureStdout(t, func() {
handler := simplelog.NewMultiplexHandler().
WithAttrs([]slog.Attr{slog.String("service", "cattbox")})
record := testRecord("casting", slog.String("device", "livingroom"))
handle(t, handler, record)
})
decoded := decodeLine(t, output)
wantField(t, decoded, "service", "cattbox")
wantField(t, decoded, "device", "livingroom")
}
// A logging library must read the values it is handed and never write to them.
// The dangerous shape is a group that shares a key with something the caller
// logged by reference: merging the group's members into whatever already sits
// at that key would reach straight back into the caller's own data structure.
// The next four tests hold every handler to that, through both the record path
// and the derived-logger path.
func TestJSONHandlerDoesNotMutateCallerMap(t *testing.T) {
caller := map[string]any{"mine": "untouched"}
want := maps.Clone(caller)
output := captureStdout(t, func() {
handler := simplelog.NewJSONHandler()
record := testRecord(
"casting",
slog.Any("g", caller),
slog.Group("g", slog.Int("injected", 1)),
)
handle(t, handler, record)
})
wantUnchanged(t, "map", caller, want)
// The later attribute wins the key outright, as any repeated key does.
group := wantGroup(t, decodeLine(t, output), "g")
wantField(t, group, "injected", float64(1))
wantNoField(t, group, "mine")
}
func TestJSONHandlerDoesNotMutateCallerMapThroughWithAttrs(t *testing.T) {
caller := map[string]any{"id": "req-1"}
want := maps.Clone(caller)
output := captureStdout(t, func() {
handler := simplelog.NewJSONHandler().
WithAttrs([]slog.Attr{slog.Any("req", caller)}).
WithGroup("req")
record := testRecord("cast failed", slog.Int("status", 502))
handle(t, handler, record)
})
wantUnchanged(t, "map", caller, want)
group := wantGroup(t, decodeLine(t, output), "req")
wantField(t, group, "status", float64(502))
wantNoField(t, group, "id")
}
// TestJSONHandlerDoesNotMutateCallerSlice is the slice form of the same
// hazard: a caller's slice is also handed over by reference, and appending
// into its spare capacity would be just as visible to the caller as writing
// into its map. The slice is built with room to spare so that an append within
// capacity cannot hide behind an unchanged length.
func TestJSONHandlerDoesNotMutateCallerSlice(t *testing.T) {
caller := make([]any, 2, 4)
caller[0] = "first"
caller[1] = "second"
backing := slices.Clone(caller[:cap(caller)])
output := captureStdout(t, func() {
handler := simplelog.NewJSONHandler()
record := testRecord(
"casting",
slog.Any("s", caller),
slog.Group("s", slog.Int("injected", 1)),
)
handle(t, handler, record)
})
wantUnchanged(t, "slice", caller, backing[:2])
wantUnchanged(t, "slice backing array", caller[:cap(caller)], backing)
group := wantGroup(t, decodeLine(t, output), "s")
wantField(t, group, "injected", float64(1))
}
func TestConsoleHandlerDoesNotMutateCallerMap(t *testing.T) {
caller := map[string]any{"mine": "untouched"}
want := maps.Clone(caller)
output := captureStdout(t, func() {
handler := simplelog.NewConsoleHandler()
record := testRecord(
"casting",
slog.Any("g", caller),
slog.Group("g", slog.Int("injected", 1)),
)
handle(t, handler, record)
})
wantUnchanged(t, "map", caller, want)
wantContains(t, output, "g.injected=1")
}
func TestWebhookHandlerDoesNotMutateCallerMap(t *testing.T) {
t.Parallel()
caller := map[string]any{"id": "req-1"}
want := maps.Clone(caller)
bodies := make(chan []byte, 1)
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read webhook body: %v", err)
}
bodies <- body
w.WriteHeader(http.StatusOK)
},
))
defer server.Close()
handler, err := simplelog.NewWebhookHandler(server.URL)
if err != nil {
t.Fatalf("NewWebhookHandler: %v", err)
}
derived := handler.
WithAttrs([]slog.Attr{slog.Any("req", caller)}).
WithGroup("req")
record := testRecord("cast failed", slog.Int("status", 502))
handle(t, derived, record)
var body []byte
select {
case body = <-bodies:
case <-time.After(5 * time.Second):
t.Fatal("webhook handler posted nothing")
}
wantUnchanged(t, "map", caller, want)
group := wantGroup(t, decodeLine(t, string(body)), "req")
wantField(t, group, "status", float64(502))
}
// TestJSONHandlerRendersDurationAsNanoseconds pins the json rendering of a
// duration to a number of nanoseconds, which is what slog.NewJSONHandler
// emits. A consumer that sums or compares the field needs a number; the "3s"
// form would silently arrive as a string.
func TestJSONHandlerRendersDurationAsNanoseconds(t *testing.T) {
output := captureStdout(t, func() {
handler := simplelog.NewJSONHandler()
record := testRecord("casting", slog.Duration("elapsed", 3*time.Second))
handle(t, handler, record)
})
decoded := decodeLine(t, output)
if _, isNumber := decoded["elapsed"].(float64); !isNumber {
t.Fatalf("field \"elapsed\" = %#v, want a json number", decoded["elapsed"])
}
wantField(t, decoded, "elapsed", float64((3 * time.Second).Nanoseconds()))
}
// TestConsoleHandlerRendersDurationReadably is the other half of that choice:
// the console line is read by a person, so it carries the same human-readable
// form slog.NewTextHandler uses.
func TestConsoleHandlerRendersDurationReadably(t *testing.T) {
output := captureStdout(t, func() {
handler := simplelog.NewConsoleHandler()
record := testRecord("casting", slog.Duration("elapsed", 3*time.Second))
handle(t, handler, record)
})
wantContains(t, output, "elapsed=3s")
}
// TestJSONHandlerContractEdgeCases pins the slog.Handler contract's four
// edge cases. Every case carries a control attribute as well, so a handler
// that emitted no attributes at all would fail rather than pass by omission.
//
// The empty group is attached through WithAttrs rather than to the record,
// because slog.Record.AddAttrs elides empty groups itself: routed through the
// record, the case would never reach the handler at all.
func TestJSONHandlerContractEdgeCases(t *testing.T) {
tests := []struct {
name string
build func() slog.Handler
attrs []slog.Attr
verify func(t *testing.T, decoded map[string]any)
}{
{
name: "empty attr is ignored",
build: func() slog.Handler { return simplelog.NewJSONHandler() },
attrs: []slog.Attr{{}, slog.String("kept", "yes")},
verify: func(t *testing.T, decoded map[string]any) {
t.Helper()
wantField(t, decoded, "kept", "yes")
wantNoField(t, decoded, "")
},
},
{
name: "empty group is elided along with its key",
build: func() slog.Handler {
return simplelog.NewJSONHandler().
WithAttrs([]slog.Attr{slog.Group("empty")})
},
attrs: []slog.Attr{slog.String("kept", "yes")},
verify: func(t *testing.T, decoded map[string]any) {
t.Helper()
wantField(t, decoded, "kept", "yes")
wantNoField(t, decoded, "empty")
},
},
{
name: "group with an empty key is inlined",
build: func() slog.Handler { return simplelog.NewJSONHandler() },
attrs: []slog.Attr{
slog.Group("", slog.String("inner", "yes")),
},
verify: func(t *testing.T, decoded map[string]any) {
t.Helper()
wantField(t, decoded, "inner", "yes")
wantNoField(t, decoded, "")
},
},
{
name: "WithGroup with an empty name is a no-op",
build: func() slog.Handler {
return simplelog.NewJSONHandler().WithGroup("")
},
attrs: []slog.Attr{slog.String("kept", "yes")},
verify: func(t *testing.T, decoded map[string]any) {
t.Helper()
wantField(t, decoded, "kept", "yes")
wantNoField(t, decoded, "")
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
output := captureStdout(t, func() {
handle(t, test.build(), testRecord("casting", test.attrs...))
})
test.verify(t, decodeLine(t, output))
})
}
}
// TestConsoleHandlerContractEdgeCases holds the console handler to the same
// four rules, since it renders attributes through its own code path.
func TestConsoleHandlerContractEdgeCases(t *testing.T) {
tests := []struct {
name string
build func() slog.Handler
attrs []slog.Attr
want string
notWant string
}{
{
name: "empty attr is ignored",
build: func() slog.Handler { return simplelog.NewConsoleHandler() },
attrs: []slog.Attr{{}, slog.String("kept", "yes")},
want: "kept=yes",
notWant: " =",
},
{
name: "empty group is elided along with its key",
build: func() slog.Handler {
return simplelog.NewConsoleHandler().
WithAttrs([]slog.Attr{slog.Group("empty")})
},
attrs: []slog.Attr{slog.String("kept", "yes")},
want: "kept=yes",
notWant: "empty=",
},
{
name: "group with an empty key is inlined",
build: func() slog.Handler { return simplelog.NewConsoleHandler() },
attrs: []slog.Attr{
slog.Group("", slog.String("inner", "yes")),
},
want: " inner=yes",
notWant: ".inner=",
},
{
name: "WithGroup with an empty name is a no-op",
build: func() slog.Handler {
return simplelog.NewConsoleHandler().WithGroup("")
},
attrs: []slog.Attr{slog.String("kept", "yes")},
want: " kept=yes",
notWant: ".kept=",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
output := captureStdout(t, func() {
handle(t, test.build(), testRecord("casting", test.attrs...))
})
wantContains(t, output, test.want)
wantNotContains(t, output, test.notWant)
})
}
}
// TestJSONHandlerRecordFieldsWinKeyCollision pins a caller-visible surprise:
// the json payload is an object, so the record's own fields own their names
// and an attribute keyed after one of them is dropped rather than emitted.
func TestJSONHandlerRecordFieldsWinKeyCollision(t *testing.T) {
output := captureStdout(t, func() {
handler := simplelog.NewJSONHandler()
record := testRecord(
"the real message",
slog.String("Time", "hijacked"),
slog.String("Level", "hijacked"),
slog.String("Message", "hijacked"),
slog.String("PC", "hijacked"),
slog.String("kept", "yes"),
)
handle(t, handler, record)
})
decoded := decodeLine(t, output)
wantField(t, decoded, "kept", "yes")
wantField(t, decoded, "Message", "the real message")
wantField(t, decoded, "Level", "INFO")
for _, reserved := range []string{"Time", "PC"} {
if decoded[reserved] == "hijacked" {
t.Fatalf("attribute overwrote the record's own %q field: %v", reserved, decoded)
}
}
}
// TestJSONHandlerDuplicateKeysKeepLast pins the other consequence of the
// payload being an object: a key logged twice collapses to its last value.
func TestJSONHandlerDuplicateKeysKeepLast(t *testing.T) {
output := captureStdout(t, func() {
handler := simplelog.NewJSONHandler()
record := testRecord(
"casting",
slog.String("device", "kitchen"),
slog.String("device", "livingroom"),
)
handle(t, handler, record)
})
wantField(t, decodeLine(t, output), "device", "livingroom")
}
// TestConsoleHandlerKeepsDuplicateKeys is the console counterpart: a line of
// text is not an object, so both pairs survive there.
func TestConsoleHandlerKeepsDuplicateKeys(t *testing.T) {
output := captureStdout(t, func() {
handler := simplelog.NewConsoleHandler()
record := testRecord(
"casting",
slog.String("device", "kitchen"),
slog.String("device", "livingroom"),
)
handle(t, handler, record)
})
wantContains(t, output, "device=kitchen")
wantContains(t, output, "device=livingroom")
}
+6 -2
View File
@@ -1,3 +1,5 @@
// Command example demonstrates logging through simplelog's default
// slog handler.
package main package main
import ( import (
@@ -6,13 +8,15 @@ import (
_ "sneak.berlin/go/simplelog" _ "sneak.berlin/go/simplelog"
) )
func main() { // attemptNumber is the example login attempt count logged below.
const attemptNumber = 3
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", 3), slog.Int("attempt", attemptNumber),
) )
slog.Warn( slog.Warn(
"Configuration mismatch", "Configuration mismatch",
+57 -14
View File
@@ -3,27 +3,42 @@ 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"
) )
type ConsoleHandler struct{} // ConsoleHandler writes human-readable, colored log lines to stdout.
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
}
// 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(
ctx context.Context, _ 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:
@@ -34,36 +49,64 @@ func (c *ConsoleHandler) Handle(
colorFunc = color.New(color.FgWhite).SprintfFunc() colorFunc = color.New(color.FgWhite).SprintfFunc()
} }
// Get the caller information // The file and line come from the record's PC, the call site; a record
_, file, line, ok := runtime.Caller(4) // without one prints a placeholder.
if !ok { file, line := "???", 0
file = "???"
line = 0 if record.PC != 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:%d: %s%s",
timestamp, timestamp,
record.Level, record.Level,
file, file,
line, line,
record.Message, record.Message,
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(
ctx context.Context, _ context.Context,
level slog.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 {
return c if len(attrs) == 0 {
return c
}
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 {
return c if name == "" {
return c
}
return &ConsoleHandler{out: c.out, attrs: c.attrs.withGroup(name)}
} }
+102
View File
@@ -0,0 +1,102 @@
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,6 +7,8 @@ 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"`
@@ -15,6 +17,8 @@ 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"
// Handler defines the interface for different log outputs. // ExtendedHandler defines the interface for different log outputs.
type ExtendedHandler interface { type ExtendedHandler interface {
slog.Handler slog.Handler
} }
+162
View File
@@ -0,0 +1,162 @@
package simplelog
import (
"bytes"
"context"
"errors"
"log/slog"
"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)
}
}
+46 -8
View File
@@ -3,30 +3,68 @@ package simplelog
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"log" "fmt"
"io"
"log/slog" "log/slog"
"os"
) )
type JSONHandler struct{} // JSONHandler writes each log record to stdout as a JSON document.
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
}
// NewJSONHandler returns a new JSONHandler.
func NewJSONHandler() *JSONHandler { func NewJSONHandler() *JSONHandler {
return &JSONHandler{} return &JSONHandler{}
} }
func (j *JSONHandler) Handle(ctx context.Context, record slog.Record) error { // Handle marshals the record, with its attributes, to one JSON object and
jsonData, _ := json.Marshal(record) // writes it to stdout. A failed write is returned as an error.
log.Println(string(jsonData)) func (j *JSONHandler) Handle(_ context.Context, record slog.Record) error {
jsonData, err := json.Marshal(recordToMap(record, j.attrs))
if err != nil {
return err
}
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
} }
func (j *JSONHandler) Enabled(ctx context.Context, level slog.Level) bool { // Enabled reports whether the handler processes records at the given
// 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 {
return j if len(attrs) == 0 {
return j
}
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 {
return j if name == "" {
return j
}
return &JSONHandler{out: j.out, attrs: j.attrs.withGroup(name)}
} }
+38
View File
@@ -0,0 +1,38 @@
package simplelog_test
import (
"log/slog"
"testing"
"time"
"sneak.berlin/go/simplelog"
)
// TestJSONHandlerDeadlock verifies that JSONHandler.Handle does not deadlock
// 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.
func TestJSONHandlerDeadlock(t *testing.T) {
t.Parallel()
handler := simplelog.NewJSONHandler()
// Set our handler as the default so log.Println routes through slog
logger := slog.New(handler)
slog.SetDefault(logger)
done := make(chan struct{})
go func() {
// This call deadlocks on unfixed code because Handle() calls
// log.Println() which re-enters slog → Handle() → log.Println() …
slog.Info("test message")
close(done)
}()
select {
case <-done:
// success
case <-time.After(5 * time.Second):
t.Fatal("JSONHandler.Handle deadlocked: timed out after 5 seconds")
}
}
+65
View File
@@ -0,0 +1,65 @@
#!/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.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
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
}
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).
# Nor is goimports: script/fmt runs it with `go run` at a pinned commit.
go mod download
echo "bootstrap complete"
}
main "$@"
Executable
+16
View File
@@ -0,0 +1,16 @@
#!/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 "$@"
Executable
+28
View File
@@ -0,0 +1,28 @@
#!/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 "$@"
Executable
+24
View File
@@ -0,0 +1,24 @@
#!/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 "$@"
Executable
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
# script/fmt: format all files (writes). goimports runs with `go run` at
# a pinned commit, so nothing installs it and every machine formats with
# the same version.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# goimports from golang.org/x/tools v0.51.0, 2026-10-02
GOIMPORTS="golang.org/x/tools/cmd/goimports@ea2f152dc35325e4b9b639583972375972350a84"
main() {
cd "$ROOT"
go run "$GOIMPORTS" -l -w .
}
main "$@"
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# script/fmt-check: check formatting (read-only). Fails and lists the
# offending files if gofmt would reformat anything.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "gofmt would reformat:"
echo "$unformatted"
exit 1
fi
}
main "$@"
+16
View File
@@ -0,0 +1,16 @@
#!/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 "$@"
Executable
+23
View File
@@ -0,0 +1,23 @@
#!/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
@@ -0,0 +1,19 @@
#!/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
@@ -0,0 +1,12 @@
#!/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 "$@"
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/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 "$@"
Executable
+19
View File
@@ -0,0 +1,19 @@
#!/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 "$@"
+47 -9
View File
@@ -1,8 +1,13 @@
// 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"
@@ -13,23 +18,31 @@ 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 (
webhookURL = os.Getenv("LOGGER_WEBHOOK_URL") ourCustomLogger *slog.Logger
ourCustomHandler slog.Handler
) )
var ourCustomLogger *slog.Logger //nolint:gochecknoinits // installs itself as slog default on import by design
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()) {
@@ -37,52 +50,72 @@ 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 {
if err := handler.Handle(ctx, record); err != nil { err := handler.Handle(ctx, record)
return err if err != nil {
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(
ctx context.Context, _ context.Context,
level slog.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
@@ -95,6 +128,7 @@ 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"`
} }
@@ -127,6 +161,10 @@ 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,
+2 -1
View File
@@ -1,8 +1,9 @@
package simplelog package simplelog_test
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.
} }
+15 -2
View File
@@ -1,3 +1,5 @@
// Command relp_log_trial emits sample log messages through simplelog's
// default slog handler for manual testing.
package main package main
import ( import (
@@ -6,11 +8,22 @@ 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("Attempting to connect to database", slog.String("host", "localhost"), slog.Int("port", 5432)) slog.Info(
"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("Failed to load module", slog.String("module", "finance"), slog.String("error", "module not found")) slog.Error(
"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"))
} }
+80 -12
View File
@@ -4,44 +4,112 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url" "net/url"
) )
// errWebhookStatus is returned when the webhook answers with a status
// outside 2xx.
var errWebhookStatus = errors.New("webhook did not accept the record")
// 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
} }
func (w *WebhookHandler) Enabled(ctx context.Context, level slog.Level) bool { // NewWebhookHandler returns a WebhookHandler that delivers records to
// 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{
// 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 {
return w if len(attrs) == 0 {
return w
}
return &WebhookHandler{
webhookURL: w.webhookURL,
client: w.client,
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 {
return w if name == "" {
} return w
}
func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) { return &WebhookHandler{
if _, err := url.ParseRequestURI(webhookURL); err != nil { webhookURL: w.webhookURL,
return nil, fmt.Errorf("invalid webhook URL: %v", err) client: w.client,
attrs: w.attrs.withGroup(name),
} }
return &WebhookHandler{webhookURL: webhookURL}, nil
} }
// Handle marshals the record, with its attributes, to one JSON object and
// POSTs it to the webhook URL. It returns an error when the request fails
// or the server answers with a status outside 2xx.
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(record) jsonData, err := json.Marshal(recordToMap(record, w.attrs))
if err != nil { if err != nil {
return fmt.Errorf("error marshaling event: %v", err) return fmt.Errorf("error marshaling event: %w", 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() }()
if response.StatusCode < http.StatusOK ||
response.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("%w: %s", errWebhookStatus, response.Status)
}
return nil return nil
} }