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

GORM's default logger printed the fully interpolated SQL to standard
output on every statement that returned an error, including a plain
record-not-found. On /webhook/{uuid} and on the login form the
interpolated parameter is client-chosen and unbounded, so an
unauthenticated client sized the operator's log, one line per request,
at no level the operator could turn down.

Every gorm.Open in the service now installs internal/gormlog, a
gormlogger.Interface over the service's *slog.Logger. Its lines take
the level the operator set and the handler internal/logger selected; a
record-not-found is not logged as an error, since it is the expected
outcome on both of those paths and each handler already records its
own miss at DEBUG without the SQL; slow statements are kept at WARN
above the same 200ms threshold GORM used; and every value it emits is
spent through an encoded-byte budget.

Trace orders its cases exactly as GORM's own Trace orders them --
error-that-is-not-a-miss, then slow, then routine -- so a statement
that both missed and ran slow is still reported as slow. Ordering the
drop first would have made this adapter strictly less observant than
the IgnoreRecordNotFoundError option it was chosen over, on the two
lookups the issue is about, and a miss is the statement most likely to
be slow.

That budget is internal/middleware's truncateLogField, moved to a new
internal/logfield package now that a second writer needs it. The move
is unchanged logic. MaxAccessLogLineBytes bounds a GORM line too, and
internal/gormlog asserts each line against the constant directly.

The third gorm.Open, in the archive writer, was not named in the issue
and had the same default. All three sites are pinned independently:
internal/handlers covers the main and per-webhook databases,
internal/delivery covers the archive writer, whose type is unexported.
Reverting any one of the three to a bare &gorm.Config{} fails the
suite.

The flood test's per-line and volume assertions were vacuous, because
the replaced default logger wrote only to a buffer while everything
else went to the captured stdout. It now tees to stdout as GORM's real
default does, so a reverted call site lands in the same capture and
those assertions measure the whole writer set.

README: the ceiling now covers GORM, and the writers it does not cover
are re-derived by measuring rather than by reading. fx's console
logger and the Go runtime write to standard error. net/http's nil
ErrorLog is not a separate writer at all -- slog.SetDefault redirects
the log package's default logger into internal/logger's handler, so
those lines arrive on standard output at INFO. A handler panic reaches
that same path because chi's Recoverer crashes before writing, which
is filed as #187 and is also the widest line the service can write, at
2,772 bytes against the stated 2,560.
This commit is contained in:
2026-08-18 00:22:25 +00:00
parent b573959a26
commit f9d9a2c8d7
12 changed files with 1708 additions and 139 deletions

View File

@@ -1152,6 +1152,80 @@ that the rate is not bounded by the limits above on every route:
`/.well-known/healthcheck` and `/s/*` sit behind no limiter, so there
the multiplier is whatever the deployment will serve.
That figure is now the ceiling on a second writer 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 512-byte
encoded budget (`internal/logfield`). 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`, without the SQL.
Slow statements are kept — at `WARN`, above the same 200 ms threshold
GORM used, with the statement bounded — because that report is the one
thing GORM's logger gave an operator that nothing else here does, and
a statement that both missed and ran slow is still reported as slow.
The adapter orders those cases exactly as GORM's own `Trace` orders
them, so dropping the miss costs an operator no report that GORM's
`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;
`internal/gormlog/gormlog_test.go` asserts each line against
`MaxAccessLogLineBytes` directly rather than leaving it as arithmetic.
**What the ceiling does not cover.** It is a per-line bound on the
access log and on GORM's statement logging, not a bound on every line
this service writes. The exceptions are named here because a bound
that is true of one writer and silently false of another is worse than
no stated bound at all.
- Other `slog` calls that reach a client-chosen value — the
`MaxBodySize` rejection, the CSRF failure, the receiver rate-limit
rejection, and the two lookup misses above — still log the request
path or the submitted username untruncated.
- The `log` delivery target (`internal/delivery/target_log.go`) writes
the entire inbound event, headers and body, to the log. That is what
the target is for. Each line is bounded per event by the 1 MB
receiver body cap, and it costs nothing unless an authenticated
operator creates a target of that type.
- 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, measured at 2,772 bytes against the ceiling's 2,560. 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
connection's own address, unless the peer is listed in
@@ -1390,6 +1464,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