ADR-008: One shared observ library, not per-service slog setup
Status: Accepted Date: 2026-04-21
Context
Every Go service in the factory needs the same things: JSON log lines on stderr, build-identity fields on every line, redaction, a probe primitive, a consistent error-logging shape. There are two ways to deliver this:
- Each service wires its own
slog.JSONHandler, following written guidelines in a markdown file. - One shared library every service imports; the guidelines become code.
Option (1) was considered seriously before gyrum-go commit 7b5006f landed. We have four services in the factory today with more coming; the factory-model thesis is that services share substrate so a single improvement lifts all of them.
Decision
Every factory Go service uses github.com/gyrum-labs/gyrum-go/pkg/observ as its logging library. Services do not construct slog.JSONHandler directly. The library owns:
- Handler construction (JSON encoder, writer, level from env).
- Build-identity attachment (
SetBuildInfo,release_trackenv). - Redaction (three layers — see ADR-007).
- Probe primitive (see ADR-009).
- Error-log builder (
observ.Error(...).Category(...).Field(...).Log(...)). - Context-logger helpers (
WithLogger,FromContext).
Per-service handler customisation is permitted via LoggerFor(w, level, extras...) for tests and for services that need an additional writer (e.g. dual-output during migration), but the default Logger() path is shared. Extensions to the shared lists (SensitiveKeys, SensitiveValuePatterns) happen in the service's own init() — those extensions are additive, not replacements.
Consequences
- One upgrade lifts every service. Phase 1.5's value-pattern redaction (ADR-007) landed in one library PR; every service picked it up on the next
go get -u. A per-service world would have needed four PRs and four reviews for the same change. - Discipline is encoded in type signatures.
observ.Error(err).Category("upstream_timeout").Log(ctx, msg)makes the category a first-class step, not a remember-to-set-this-field convention. A reviewer seesobserv.Error(...).Log(...)with noCategoryand asks about it. - Factory-model assumption. The factory builds many small services that share one operational substrate. A per-service logging setup is four-plus copies of the same code; the drift is immediate and the cost of re-unifying scales with the drift. This ADR locks the shared substrate in.
- Library breakage blocks every service. A regression in
pkg/observaffects the whole fleet. The library's test coverage is correspondingly high (redaction in particular has adversarial fixtures), and the library goes through the usual gyrum-review-pr three-persona review before release. - Services depend on gyrum-go. One more module in
go.mod. Acceptable — the library has no third-party transitive deps (see ADR-002), so the only cost is the gyrum-go repo itself, which services depend on for middleware already. - Per-service needs still have a seam.
LoggerForfor custom writers,SensitiveKeysextension for domain-specific keys,SensitiveValuePatternsfor service-specific token formats. The library is not a monolith that shuts the door on service-specific needs — it is a shared default with named extension points.
Alternatives considered
- Guidelines-only, per-service slog setup. Each service copies a canonical
observ_init.gointo its tree. Rejected: drift across four-plus services is immediate (the first time one service picks up a new sensitive key and the others don't), and re-unifying after drift is more expensive than preventing it. A guideline is a wish; a library is a contract. - Vendored copy-paste. Ship the canonical file as a template copied into each repo at setup. Rejected for the same drift reason — the moment someone edits their local copy for a "quick fix", the contract is broken, and the "quick fix" is exactly the trigger that created it.
- Per-service libraries owned by each service team. Strongest local ownership. Rejected: the factory has very few services and very small teams. Team-scale ownership does not exist at the cardinality that would justify it; library ownership lives with the platform layer.
- Framework-level integration (inherit from a base service struct). Would bundle logging with HTTP, DB, and lifecycle. Rejected as scope creep — one primitive at a time. Phase 2's HTTP middleware will compose with
pkg/observrather than replace it.
Supersedes: none Superseded by: