Decisions

ADR-008: One shared `observ` library, not per-service slog setup

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:

#008

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:

  1. Each service wires its own slog.JSONHandler, following written guidelines in a markdown file.
  2. 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_track env).
  • 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 sees observ.Error(...).Log(...) with no Category and 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/observ affects 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. LoggerFor for custom writers, SensitiveKeys extension for domain-specific keys, SensitiveValuePatterns for 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.go into 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/observ rather than replace it.

Supersedes: none Superseded by: