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

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 at all, 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.

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.

README: the ceiling now covers GORM; the writers it does not cover are
named, including net/http's nil ErrorLog, fx's console logger and the
Go runtime, none of which carry a client-chosen value.
This commit is contained in:
2026-08-18 00:22:25 +00:00
parent 76725cffc4
commit ce36f430fb
11 changed files with 1371 additions and 139 deletions

View File

@@ -1113,6 +1113,55 @@ 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
at all: 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. 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.
- Three writers that do not go through `internal/logger` at all, all
of them on standard error. `net/http` builds its server with a nil
`ErrorLog`, so its own faults — a handler panic and its stack, a
superfluous `WriteHeader` — go to the `log` package's default
logger. `fx` prints the dependency graph and the lifecycle hooks
through its console logger at startup and shutdown. The Go runtime
writes a panic or a fatal error itself. None of the three carries a
client-chosen value at a client-chosen length: the three `panic`
calls in this service are invariant guards over constants and over
`crypto/rand`.
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
@@ -1351,6 +1400,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