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.modlists no third-party logger. Every consuming service gets slog transitively from the standard library it already has. - Backend swap is a
slog.Handlerreplacement. 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: theHandlerinterface 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.Loggerorslog.Handler, so our logger composes with them natively. - Ergonomics tax. slog's
slog.Attr-based API is more verbose than zap'szap.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
Handlerseam 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.SugaredLoggersplit 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: