Route GORM's logger through slog and bound it (closes #178)
All checks were successful
check / check (push) Successful in 2m57s

This commit was merged in pull request #182.
This commit is contained in:
2026-08-18 07:17:43 +02:00
parent 563e834cf2
commit 0c64c411cc
11 changed files with 1447 additions and 21 deletions

View File

@@ -1141,10 +1141,11 @@ the figure has headroom. `internal/middleware/accesslog_test.go`
asserts it against 8 KB of client-chosen text in the path, in the
query, and in each of `User-Agent`, `Referer` and `X-Request-Id`,
including cases built from the characters the handlers escape, and
against the widest line the service can be made to write: a 5xx that
keeps its concrete path while all three header fields are also at their
budget. Every case runs through both handlers `internal/logger` can
select — the JSON one and the text one it installs on a tty — since the
against the widest access log line the service can be made to write: a
5xx that keeps its concrete path while all three header fields are also
at their budget. Every case runs through both handlers
`internal/logger` can select — the JSON one and the text one it
installs on a tty — since the
two do not escape alike and the ceiling is quoted unqualified. Measured
over a real connection, the widest line is 1,972 bytes.
@@ -1213,6 +1214,33 @@ against what the handlers really emit, over roughly 3,000 code points on
each, so an undercharged rune fails a test rather than quietly
falsifying the ceiling.
**It covers GORM's statement logging as well.** GORM's own default
logger printed the fully interpolated SQL — parameters and all — to
standard output on every statement that returned an error, including a
plain record-not-found, at a level no operator setting reached. Two of
this service's lookups miss by design on unauthenticated routes: the
entrypoint lookup behind `/webhook/{uuid}` and the user lookup behind
the login form, whose path segment and submitted username the client
picks outright. Every
`gorm.Open` in the service now installs the adapter in
`internal/gormlog` instead. It writes through the same `slog` logger as
everything else, so its lines take the level the operator set and the
handler `internal/logger` selected, and every value it emits is spent
through the same `internal/logfield` budget. A record-not-found is not
logged as an error: it is the expected outcome on both of those paths,
and each handler already records its own miss at `DEBUG` — bounded, per
the table above — without the SQL. Slow statements are kept, at `WARN`,
above the same 200 ms threshold GORM used and with the statement
bounded, because that report is the one thing GORM's logger gave an
operator that nothing else here does. The adapter orders its cases
exactly as GORM's own `Trace` orders them, so a statement that both
missed and ran slow is still reported as slow, and dropping the miss
costs an operator no report `IgnoreRecordNotFoundError` would have
kept. A GORM line spends at most two of those budgets — the statement
and the driver error — against a smaller fixed portion than the access
log's, and `internal/gormlog/gormlog_test.go` asserts each line against
`MaxAccessLogLineBytes` directly rather than leaving it as arithmetic.
What that ceiling does **not** cover, stated here so the figure is not
read as more than it is:
@@ -1236,16 +1264,39 @@ read as more than it is:
that type on a specific webhook, and each line it writes is bounded
per event by the 1 MB receiver body cap. Adding one is a decision to
spend log volume on that webhook's payloads.
- **GORM's default logger**, which prints the fully interpolated SQL to
stdout on every record-not-found — including the client-chosen path
on `/webhook/{uuid}` and the submitted username on the login form.
This one is not deliberate and not yet fixed; it does not go through
`internal/logger` at all, so no level the operator sets and no budget
above applies to it. Tracked at
<https://git.eeqj.de/sneak/webhooker/issues/178>. Until it is fixed,
an unauthenticated flood can still write text of its own choosing and
its own length to the operator's stdout, and the ceiling above
describes only the `slog` half of the picture.
- **Two writers that do not go through `internal/logger` at all**, both
on standard error. `fx` prints the dependency graph and the lifecycle
hooks through its default console logger at startup and shutdown —
nothing calls `fx.WithLogger`, and `fx.New` builds that logger over
`os.Stderr`. The Go runtime writes a panic or a fatal error itself; a
panic in a background worker rather than in a request handler is the
case that reaches it, since nothing recovers those. Neither carries a
client-chosen value at a client-chosen length: the five `panic` calls
in this service are invariant guards over constants and over
`crypto/rand`.
- **`net/http`'s own faults**, which are _not_ a separate writer.
`internal/server/http.go` builds its server with a nil `ErrorLog`, so
`net/http` falls back to the `log` package's default logger — and
`internal/logger` calls `slog.SetDefault`, which redirects that logger
into whichever handler it installed. Those lines therefore arrive on
standard output, shaped like every other line, at `INFO`. They are not
truncated and they are not bounded by the ceiling: a handler panic
arrives as one record carrying a whole goroutine stack, above the
ceiling's 2,560 bytes — measured at roughly 2,770 in one checkout. The
exact width is not an invariant, since it moves with the goroutine
number and with the source paths baked into the stack; that it exceeds
the ceiling does not move. The value is the runtime's, not a client's.
- **A handler panic reaches that path** rather than the one it looks
like it should. `internal/server/routes.go` installs chi's
`middleware.Recoverer` in front of every route, which is meant to
print the panic and its stack to standard error and answer 500. On the
Go version this service builds against it does neither: chi v1.5.5's
stack pretty-printer looks for a `panic(0x` frame that the runtime no
longer emits, walks past the end of its own slice, and panics before
writing a byte. That second panic escapes to `net/http`, which drops
the connection and reports it through the nil `ErrorLog` above.
Tracked separately in
<https://git.eeqj.de/sneak/webhooker/issues/187>.
Every limiter here — receiver, login, and password change — identifies
the client the same way, through one shared key function: the
@@ -1485,6 +1536,10 @@ webhooker/
│ │ └── webhook_db_manager.go # Per-webhook DB lifecycle manager
│ ├── globals/
│ │ └── globals.go # Build-time variables (appname, version, arch)
│ ├── gormlog/
│ │ └── gormlog.go # GORM's logger.Interface on top of slog, bounded
│ ├── logfield/
│ │ └── logfield.go # Encoded-byte budget for client-supplied log values
│ ├── delivery/
│ │ ├── engine.go # Event-driven delivery engine (channel + timer based)
│ │ ├── circuit_breaker.go # Per-target circuit breaker for http/slack targets with retries