ADR-003: JSON-per-line on stderr, not logfmt or protobuf
Status: Accepted Date: 2026-04-21
Context
Once we committed to slog (ADR-002) and a Loki-backed stack (ADR-001), the on-wire line format still had a choice. slog ships both JSONHandler and TextHandler; third-party handlers offer logfmt, protobuf, MessagePack, and others. The format decision propagates through three surfaces:
- Loki's parser. LogQL has
| jsonand| logfmtbuilt in; anything else needs a custom parser. - Human emergency path. When Loki is unreachable, the only log access is
docker logs <container>on the VPS. Whatever comes out has to be grep-able by a half-awake operator at 3am. - Structure preservation. Nested fields (error with cause chain, redacted attrs, groups) need a format that can represent them without collapsing.
Decision
Every log line is a single-line JSON object on os.Stderr. The line is produced by slog.NewJSONHandler with the library's ReplaceAttr hook and wrapped by the message-scrubber decorator (see ADR-007). No other format is offered by the library.
Shape:
{"ts":"2026-04-21T14:02:11Z","level":"INFO","msg":"server starting","service":"distill","version":"0.1.4","commit":"abc1234","release_track":"stable"}
Consequences
- Loki parses it natively.
| jsonin LogQL gives queryable access to every field without a parser definition.{service="distill"} | json | err_category="upstream_timeout"works from day one. docker logs <container> | jq '...'works for emergency grepping. A human with no stack access can still pull useful structure out of a log file. Thejqrecipes in the playbook assume this shape.- Nested structure survives. Groups, error-cause chains, and redacted attrs all round-trip through JSON cleanly. logfmt would flatten or drop them; protobuf would require a schema registry.
- Line size is larger than logfmt. Keys repeat per line (
"service":"distill"on every event). We accept the bytes in exchange for the human readability and the free Loki parser. Compression on the Loki chunk side absorbs most of the cost. - One line per event is a hard rule. The docker json-file driver assumes newline-terminated records; a multi-line JSON payload would be split into separate entries. slog's JSONHandler guarantees single-line output.
- stderr, never stdout. stdout is reserved for program output (test harnesses, tools, piped CLIs). Mixing logs into stdout muddies the contract; stderr is universally "diagnostic byte stream".
Alternatives considered
- logfmt (
key=value key=value). Lighter on the wire, easier to scan visually. Rejected: escaping rules for values with spaces or quotes are fiddly, structured values (arrays, nested groups) collapse to strings that need re-parsing, and LogQL's| logfmtparser is more forgiving but less expressive than| json. The format survives casual eyeballing; it does not survive structured querying. - Protocol Buffers. Fastest to serialise, smallest on the wire, schema-enforced. Rejected: opaque to humans, requires a schema distribution story, and
docker logs <container>outputs bytes nobody can read without tooling. Operator ergonomics outweigh the serialisation win at our volume. - MessagePack / CBOR. Same tradeoff as protobuf without the schema story. Rejected for the same reason.
- Custom framed format. A bespoke format would let us optimise for exactly our fields. Rejected on "boring is better" grounds — JSON is the default every tool in the pipeline understands, and the cost of custom framing is a parser in Loki + a parser in every operator's head.
Supersedes: none Superseded by: