Files
bdeshiandClaude Opus 5 c3125e7ad2 log every request, and make error mean something again
Item 1 of the order of work, and two gaps rather than one polish item.

There was no access log. Nothing recorded that a request happened, and the
Dockerfile ships the binary alone with the site root mounted (ADR-0010), so a bare
deployment produced none at all — which mattered because offline log analysis is
this project's answer to analytics: no counter on the read path, no third-party
script. web.Logged now writes one Info line per request with method, path, status,
bytes and duration.

And slog was never configured. conventions.md makes importing `log` instead of
log/slog a hard failure while nothing ever set a level, a handler or a format.
Two flags now do, rejected at startup if unusable, because a logger quietly less
verbose than asked for hides exactly the lines somebody changed the flag to see.
JSON is the half that matters: it is what makes a log parseable.

The re-levelling was the larger half. Counts were 38 error, 7 warn, 4 info, 0
debug; they are now 7, 40, 6, 0. Almost every one of those errors was ADR-0029's
"logged, not fatal" category — a misspelled directive, an asset path climbing out
of its bundle, an unreadable picture — where the engine coped and the reader still
got a good page. Error used as "somebody should see this" means an operator cannot
tell a broken build from a typo. The seven that remain are the five requests that
answer 500 and the two inside fatal.

A successful rebuild now says so. It swapped silently before, so an operator could
see a failed rebuild and never a successful one, which leaves the failures with
nothing to be read against.

No wrapper package: log/slog is the module, and a layer over it would be an
abstraction with one caller. Duration comes from content.Now, since the clock is
confined to one file and verify.sh enforces it by filename. The recorder does not
forward Flusher or ReaderFrom — nothing here streams, so the cost is one io.Copy
fast path on static files, and implementing interfaces no caller needs is the
speculation rule 6 forbids.

Two things this change owed and paid. content.go's comment still said content
problems were "logged at error level", which the re-levelling made false. And the
new flags pushed runServe past the function-length advisory, so the site-opening
block became opened() — a warning that fires on correct code gets acted on, not
tolerated, and that is the whole reason the advisory exists.

Evidence, demo site, JSON: rebuilt bundles=31, then serving, then one request line
each for a 200, a 404 and robots.txt with real byte counts and durations. At
-log-level warn, request lines disappear. An invalid level exits with the reason.

12 files. Core 2913 → 2959 of 3400.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:35:29 +06:00

59 lines
2.1 KiB
Go

package web
import (
"log/slog"
"net/http"
"khosra/internal/content"
)
// Logged wraps a handler so every request produces one line.
//
// This is the access log the engine did not have. Nothing else recorded that a request happened, and the
// Dockerfile ships the binary alone with the site root mounted (ADR-0010) — so a bare deployment produced no
// access log at all, which made offline traffic analysis impossible rather than merely inconvenient. That
// matters because log analysis is this project's answer to analytics: no counter on the read path, no
// third-party script.
//
// Applied by `cmd` around the finished handler rather than inside Handler, so tests and the demo's coverage
// test stay quiet and so whether to log is the operator's decision rather than the engine's.
func Logged(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
started := content.Now()
rec := &recorder{ResponseWriter: w, status: http.StatusOK}
h.ServeHTTP(rec, req)
slog.Info("request",
"method", req.Method,
"path", req.URL.Path,
"status", rec.status,
"bytes", rec.bytes,
"took", content.Now().Sub(started))
})
}
// recorder remembers what the handler answered, because a ResponseWriter tells nobody.
//
// status starts at 200: a handler that writes without calling WriteHeader has sent one, and that is the
// common path here. Duration comes from content.Now, not time.Now — the clock lives in exactly one file and
// `verify.sh` enforces it by filename.
//
// It deliberately does not forward Flusher or ReaderFrom. Nothing in this engine streams or hijacks, so the
// only cost is losing an io.Copy fast path on static files; implementing optional interfaces that no caller
// needs would be the speculative kind of code rule 6 forbids.
type recorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *recorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
func (r *recorder) Write(b []byte) (int, error) {
n, err := r.ResponseWriter.Write(b)
r.bytes += n
return n, err
}