Decisions

ADR-002: Go stdlib `log/slog` as the library's base, not zap or zerolog

The observability library needs a structured logging core. Every Go service in the factory will import it, so the choice propagates everywhere and is expensive to reverse. Three things matter:

#002

ADR-002: Go stdlib log/slog as the library's base, not zap or zerolog

Status: Accepted Date: 2026-04-21

Context

The observability library needs a structured logging core. Every Go service in the factory will import it, so the choice propagates everywhere and is expensive to reverse. Three things matter:

  • Dependency footprint. Every transitive dependency we take on propagates to every service. Security scanning, licence review, and upgrade treadmill cost are all per-dependency.
  • API swap-out. Later phases may need a different backend (e.g. an OTLP-native logger). If the library's public surface matches a widely-implemented interface, backend swaps are a handler replacement, not a rewrite.
  • Performance. Logging runs on every request path. Allocation and throughput matter for the higher-traffic services, though Phase 1 services are nowhere near the ceiling.

The library's source is at gyrum-go pkg/observ/README.md (first shipped in gyrum-go commit 7b5006f).

Decision

The library uses Go's standard-library log/slog as its logging core. Every construction path (Logger(), LoggerFor, FromContext) returns or wraps a *slog.Logger. Custom behaviour (redaction, message scrubbing, build-info attachment) is implemented as slog.Handler decorators and a ReplaceAttr hook — see RedactAttr and msgRedactHandler in the library.

Third-party loggers are not used anywhere in pkg/observ.

Consequences

  • Zero external logging dependency. The library's go.mod lists no third-party logger. Every consuming service gets slog transitively from the standard library it already has.
  • Backend swap is a slog.Handler replacement. If Phase 6+ needs an OTLP-native backend, the handler is the seam. Public API of the library does not change. This is the specific reason we took slog: the Handler interface is the extension point.
  • Performance is acceptable, not best-in-class. Zerolog benchmarks 2×–3× faster than slog on hot loops. At Phase 1 traffic levels this is invisible. If we hit the ceiling, we swap the handler implementation (e.g. a faster JSON encoder) without changing the library's API. We have not profiled because nothing currently needs it.
  • Ecosystem momentum. slog is the default in the Go ecosystem from 1.21 onward. Libraries that take a logger increasingly take *slog.Logger or slog.Handler, so our logger composes with them natively.
  • Ergonomics tax. slog's slog.Attr-based API is more verbose than zap's zap.String(...) or zerolog's chained .Str(). We accept the verbosity — the library wraps the common cases (observ.Error(err).Category(...).Field(...).Log(ctx, msg)) so day-to-day call sites stay terse.

Alternatives considered

  • zerolog. ~2× faster than slog on write-through benchmarks, chainable API. Rejected: third-party dependency for every factory service, and the speed margin does not matter at our current traffic. The Handler seam in slog makes a later switch cheap if we ever need it, so committing to zerolog now buys nothing we can't also buy later.
  • zap. Popular (Uber-backed), broad ecosystem adoption, sugared + unsugared APIs. Rejected: third-party dependency, heavier public API surface to wrap, and the zap.Logger → zap.SugaredLogger split adds a teaching cost that our "one library, one way" stance does not reward.
  • logrus. The historical default. Rejected: slower than slog, allocation-heavy, and maintenance-mode upstream.
  • Roll our own. Would minimise dependencies but duplicate work that slog already handles correctly (handler chaining, level filtering, attribute grouping). Rejected on YAGNI grounds — we would reinvent slog.Handler.
  • hclog (HashiCorp's logger). Used in HashiCorp tooling, mature. Rejected: the same "third-party dependency for every service" argument as zap and zerolog, plus a smaller ecosystem than slog now has.

Supersedes: none Superseded by: