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>
This commit is contained in:
@@ -1531,3 +1531,32 @@ gate enforces. The property is now asserted twice, once per package that owns a
|
||||
loss, recorded in both tests.
|
||||
Revisit if: the resolver gains feature participation, at which point `feed.go` and `web/extras.go` can leave
|
||||
too and the ceiling should be reconsidered downward rather than left as headroom.
|
||||
|
||||
## ADR-0086 — One log line per request, and error stops meaning "look at this"
|
||||
Date: 2026-08-03 · Status: accepted
|
||||
Decision: `cmd` configures exactly one logger behind `-log-level` (debug/info/warn/error) and `-log-format`
|
||||
(text/json), rejecting an unusable value at startup rather than falling back. `web.Logged` wraps the finished
|
||||
handler and writes one Info line per request — method, path, status, bytes, duration — which is the access
|
||||
log this engine did not have. Levels are fixed in `conventions.md`: **error** is something the engine could
|
||||
not do (a request or build step failed, startup aborting), **warn** is something it worked around while still
|
||||
serving, **info** is lifecycle and requests, **debug** is off by default.
|
||||
Why: `conventions.md` made importing `log` instead of `log/slog` a hard failure while never configuring
|
||||
slog — no level, no JSON, default handler. And nothing recorded requests at all, so a deployment of the
|
||||
binary alone (ADR-0010) produced no access log, which matters because offline log analysis is this project's
|
||||
answer to analytics: no counter on the read path, no third-party script.
|
||||
The re-levelling is the larger half. Counts were 38 error, 7 warn, 4 info, 0 debug; they are now 7, 40, 5, 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 "I want somebody to see this" means an operator cannot tell a broken build from a
|
||||
typo, which is the same failure as a warning nobody can act on. The seven that remain are the five requests
|
||||
that answer 500 and the two inside `fatal`.
|
||||
Consequence: the middleware is applied by `cmd` rather than inside `Handler`, so tests and the demo's coverage
|
||||
test stay quiet and logging is the operator's choice. Duration comes from `content.Now` because the clock is
|
||||
confined to one file and `verify.sh` enforces it by filename. The recorder deliberately does not forward
|
||||
`Flusher` or `ReaderFrom`: nothing here streams, so the only cost is an `io.Copy` fast path on static files,
|
||||
and implementing interfaces no caller needs is the speculation rule 6 forbids. A successful rebuild now logs
|
||||
too — it swapped silently before, which made the failures unreadable for want of anything to compare them to.
|
||||
No wrapper package: `log/slog` is the module, and a layer over it would be an abstraction with one caller.
|
||||
Only `serve` takes the flags; `check` and `new` are short-lived and print their own findings.
|
||||
Revisit if: request logging shows up in a profile, or an operator needs per-route levels — neither of which a
|
||||
two-flag configuration can express, and both of which would be evidence for a real logging design.
|
||||
|
||||
+7
-1
@@ -41,8 +41,9 @@ table owns.
|
||||
| `internal/web/extras.go` | the extras route: listing, one entry selected, or `?raw` bytes, all behind the bundle lookup |
|
||||
| `internal/web/asset.go` | files inside a bundle's own directory, looked up through the owning bundle so visibility can only ever inherit (ADR-0024) |
|
||||
| `internal/web/feed.go` | Atom for the site, a section or a tag, from dated bundles via one Query (ADR-0043) |
|
||||
| `internal/web/logging.go` | the access log: one Info line per request with method, path, status, bytes and duration, wrapped around the finished handler by `cmd` so tests stay quiet. Duration comes from `content.Now`, since the clock lives in one file (ADR-0086) |
|
||||
| `internal/web/web.go` | handler: `Snapshot` pairs the index with the theme that was current with it (ADR-0077); `serve` dispatches by kind, `serveBundle` answers the commonest one; listings, `/static/`, `/derived/`, degrade on failure. Mounts the exact paths features own, skipping any the engine already answers — a duplicate pattern would panic (ADR-0081). Since ADR-0085 it reserves only `/`: `/robots.txt` and `/sitemap.xml` are a feature's, so a clash *between* features is `wire.go`'s to settle |
|
||||
| `cmd/khosra/main.go` | flags (including `-poll`, zero to stop watching), wiring, startup, the derivative pass, and the one atomic swap a change goes through, theme and index together in `rebuilder` (ADR-0077). `main` dispatches subcommands, `runServe` assembles the server, `rebuilder` is used at startup and on every change alike |
|
||||
| `cmd/khosra/main.go` | flags (including `-poll`, zero to stop watching, and `-log-level`/`-log-format` which configure the one logger before anything can use it — ADR-0086), wiring, startup, the derivative pass, and the one atomic swap a change goes through, theme and index together in `rebuilder` (ADR-0077). `main` dispatches subcommands, `runServe` assembles the server, `rebuilder` is used at startup and on every change alike |
|
||||
| `cmd/khosra/check.go` | the `check` subcommand: parse, print, exit code. What counts as a finding lives in the feature |
|
||||
| `cmd/khosra/new.go` | the `new` subcommand: arguments in either order, then the feature does the writing |
|
||||
| `*_test.go` | table-driven, one file per source file — `shortcodes` has one each for icons, containers and the contents list; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404, plus benchmarks for the render path and the checker, unpublished visibility, listing shapes, scaffolding, extras, change detection, what a page can reach, the root listing, and the example site end to end — that last one in `cmd/khosra`, beside the wiring it proves (ADR-0072) |
|
||||
@@ -51,6 +52,11 @@ table owns.
|
||||
the file as text with the site's own settings and is dropped from the address, and `root/_headers.yaml`
|
||||
declares headers per exact path (ADR-0081). Paths the engine already answers are skipped and logged.
|
||||
|
||||
Every request produces one log line, and levels mean one thing each: **error** is something the engine could
|
||||
not do, **warn** is something it worked around while still serving — all of ADR-0029's category — **info** is
|
||||
lifecycle and requests, **debug** is off by default (ADR-0086, `conventions.md`). `-log-level` and
|
||||
`-log-format` (text or json) configure it.
|
||||
|
||||
A page carries only the CSS and JS its own shortcode calls or its `use:` list asked for, rendered once each from the theme's `assets:<name>` fragments, plus its own `styles`/`scripts` files — bundle-relative, anything climbing out dropped (ADR-0079, ADR-0080). The reference theme emits the stylesheets and **no `<script>` at all**, which `verify.sh` enforces; `examples/demo-site` redefines the `head` block to add the tag, so the one JavaScript exception is demonstrated by a site rather than built into the binary.
|
||||
|
||||
Serves a listing of everything at `/` (ADR-0050), a bundle at `/{section}/{slug}/` — the slug derived, or declared in frontmatter without moving the
|
||||
|
||||
+45
-39
@@ -6,27 +6,29 @@ Every top-level declaration in the engine, with its line. Read this before openi
|
||||
file: it answers "where does X live" and "what is in this package" without the bodies. What each
|
||||
file is *for* lives in `state.md`; why it is that way lives in `decisions.md`.
|
||||
|
||||
## cmd/khosra — 346 lines + 309 test
|
||||
## cmd/khosra — 393 lines + 341 test
|
||||
|
||||
check.go 45 · main.go 178 · new.go 42 · wire.go 81
|
||||
check.go 45 · main.go 225 · new.go 42 · wire.go 81
|
||||
|
||||
- check.go:16 func runCheck(args []string)
|
||||
- main.go:23 func main()
|
||||
- main.go:44 func runServe()
|
||||
- main.go:101 func pollInterval(dev bool, chosen time.Duration) time.Duration
|
||||
- main.go:117 func watching(fsys fs.FS, every time.Duration, rebuild func() int)
|
||||
- main.go:129 func rebuilder(fsys fs.FS, settings content.Settings, cache string, reveal bool,
|
||||
- main.go:161 func defaultCache() string
|
||||
- main.go:171 func fatal(msg string, err error)
|
||||
- main.go:24 func main()
|
||||
- main.go:45 func runServe()
|
||||
- main.go:101 func opened(dir, base string) (fs.FS, content.Settings)
|
||||
- main.go:122 func pollInterval(dev bool, chosen time.Duration) time.Duration
|
||||
- main.go:138 func watching(fsys fs.FS, every time.Duration, rebuild func() int)
|
||||
- main.go:150 func rebuilder(fsys fs.FS, settings content.Settings, cache string, reveal bool,
|
||||
- main.go:186 func defaultCache() string
|
||||
- main.go:196 func fatal(msg string, err error)
|
||||
- main.go:210 func logging(level, format string) error
|
||||
- new.go:12 func runNew(args []string)
|
||||
- wire.go:22 func theme(siteFS fs.FS, settings content.Settings) (*render.Renderer, error)
|
||||
- wire.go:35 func extenders(partial render.Partial) []goldmark.Extender
|
||||
- wire.go:61 func routes(siteFS fs.FS, settings content.Settings, site func() *content.Site) map[string]http.Handler
|
||||
- wire.go:72 func claim(out map[string]http.Handler, feature string, from map[string]http.Handler)
|
||||
|
||||
## internal/content — 1074 lines + 598 test
|
||||
## internal/content — 1076 lines + 598 test
|
||||
|
||||
clock.go 12 · content.go 481 · doc.go 5 · extras.go 92 · settings.go 59 · site.go 425
|
||||
clock.go 12 · content.go 483 · doc.go 5 · extras.go 92 · settings.go 59 · site.go 425
|
||||
|
||||
- clock.go:9 var now = time.Now
|
||||
- clock.go:12 func Now() time.Time { return now() }
|
||||
@@ -34,32 +36,32 @@ clock.go 12 · content.go 481 · doc.go 5 · extras.go 92 · settings.go 59 · s
|
||||
- content.go:27 type Bundle struct
|
||||
- content.go:77 func OpenSite(dir string) (fs.FS, error)
|
||||
- content.go:89 type Problem struct
|
||||
- content.go:101 func Scan(fsys fs.FS) ([]Bundle, error)
|
||||
- content.go:110 func ScanReport(fsys fs.FS) ([]Bundle, []Problem, error)
|
||||
- content.go:149 func Parse(name string, data []byte) (Bundle, error)
|
||||
- content.go:196 func (b Bundle) Published(at time.Time) bool
|
||||
- content.go:205 func (b Bundle) Assets() (string, bool)
|
||||
- content.go:215 func stringList(v any) []string
|
||||
- content.go:237 func asTime(v any) time.Time
|
||||
- content.go:253 func asInt(v any) int
|
||||
- content.go:271 func bundleFiles(v any, where string) []string
|
||||
- content.go:283 func terms(v any) []string
|
||||
- content.go:307 func TagSlug(tag string) string
|
||||
- content.go:316 func Normalise(s string) string { return norm.NFC.String(s) }
|
||||
- content.go:322 func splitName(name string) (key, lang string, ok bool)
|
||||
- content.go:344 func isLangTag(s string) bool
|
||||
- content.go:362 func isPartial(base string) bool
|
||||
- content.go:374 func skipDir(base string) bool
|
||||
- content.go:382 func splitFrontmatter(data []byte) (front, body []byte)
|
||||
- content.go:397 func trimLeadingFence(data []byte, fence string) ([]byte, bool)
|
||||
- content.go:414 func dropCollisions(all []Bundle) ([]Bundle, []Problem)
|
||||
- content.go:436 const PerPage = 10
|
||||
- content.go:442 func URL(key, lang string) string
|
||||
- content.go:455 func TagURL(section, slug, lang string, page int) string
|
||||
- content.go:465 const DerivedPrefix = "/derived/"
|
||||
- content.go:468 func DerivedURL(name string) string { return DerivedPrefix + name }
|
||||
- content.go:471 const TagsSegment = "tags"
|
||||
- content.go:475 func PageURL(key, lang string, page int) string
|
||||
- content.go:103 func Scan(fsys fs.FS) ([]Bundle, error)
|
||||
- content.go:112 func ScanReport(fsys fs.FS) ([]Bundle, []Problem, error)
|
||||
- content.go:151 func Parse(name string, data []byte) (Bundle, error)
|
||||
- content.go:198 func (b Bundle) Published(at time.Time) bool
|
||||
- content.go:207 func (b Bundle) Assets() (string, bool)
|
||||
- content.go:217 func stringList(v any) []string
|
||||
- content.go:239 func asTime(v any) time.Time
|
||||
- content.go:255 func asInt(v any) int
|
||||
- content.go:273 func bundleFiles(v any, where string) []string
|
||||
- content.go:285 func terms(v any) []string
|
||||
- content.go:309 func TagSlug(tag string) string
|
||||
- content.go:318 func Normalise(s string) string { return norm.NFC.String(s) }
|
||||
- content.go:324 func splitName(name string) (key, lang string, ok bool)
|
||||
- content.go:346 func isLangTag(s string) bool
|
||||
- content.go:364 func isPartial(base string) bool
|
||||
- content.go:376 func skipDir(base string) bool
|
||||
- content.go:384 func splitFrontmatter(data []byte) (front, body []byte)
|
||||
- content.go:399 func trimLeadingFence(data []byte, fence string) ([]byte, bool)
|
||||
- content.go:416 func dropCollisions(all []Bundle) ([]Bundle, []Problem)
|
||||
- content.go:438 const PerPage = 10
|
||||
- content.go:444 func URL(key, lang string) string
|
||||
- content.go:457 func TagURL(section, slug, lang string, page int) string
|
||||
- content.go:467 const DerivedPrefix = "/derived/"
|
||||
- content.go:470 func DerivedURL(name string) string { return DerivedPrefix + name }
|
||||
- content.go:473 const TagsSegment = "tags"
|
||||
- content.go:477 func PageURL(key, lang string, page int) string
|
||||
- extras.go:14 const ExtrasDir = "extras"
|
||||
- extras.go:17 type Entry struct
|
||||
- extras.go:33 func Extras(fsys fs.FS, b Bundle) []Entry
|
||||
@@ -367,9 +369,9 @@ chrome.go 115 · render.go 499 · view.go 192
|
||||
- view.go:167 type Picture struct
|
||||
- view.go:184 type Origin struct
|
||||
|
||||
## internal/web — 687 lines + 1324 test
|
||||
## internal/web — 745 lines + 1396 test
|
||||
|
||||
asset.go 58 · extras.go 93 · feed.go 125 · resolve.go 170 · web.go 241
|
||||
asset.go 58 · extras.go 93 · feed.go 125 · logging.go 58 · resolve.go 170 · web.go 241
|
||||
|
||||
- asset.go:22 func serveAsset(w http.ResponseWriter, req *http.Request, site *content.Site, siteFS fs.FS, res resolution) bool
|
||||
- extras.go:18 func serveExtras(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer,
|
||||
@@ -383,6 +385,10 @@ asset.go 58 · extras.go 93 · feed.go 125 · resolve.go 170 · web.go 241
|
||||
- feed.go:52 func serveFeed(w http.ResponseWriter, req *http.Request, site *content.Site, res resolution, settings content.Settings) bool
|
||||
- feed.go:102 func dated(all []content.Bundle) []content.Bundle
|
||||
- feed.go:113 func feedTitle(settings content.Settings, res resolution) string
|
||||
- logging.go:20 func Logged(h http.Handler) http.Handler
|
||||
- logging.go:43 type recorder struct
|
||||
- logging.go:49 func (r *recorder) WriteHeader(status int)
|
||||
- logging.go:54 func (r *recorder) Write(b []byte) (int, error)
|
||||
- resolve.go:12 type resolution struct
|
||||
- resolve.go:40 func resolve(path string, site *content.Site) (resolution, bool)
|
||||
- resolve.go:102 func cutLang(key string, site *content.Site) (lang, rest, redirect string)
|
||||
|
||||
Reference in New Issue
Block a user