Skip to content
Zever
Home

Logging

Log structured events through one interface and swap slog, zerolog, pretty, or noop by config.

2 min readEdit on GitHub

Every service logs through one interface, log.Logger. Swap the adapter and every line changes shape with no call-site changes: noop for tests, slog for the stdlib, zerolog for high-throughput JSON, pretty for local development. Resolve one logger per process and inject it.

Terminal window
LOG_ADAPTER=slog
Terminal window
LOG_ADAPTER=zerolog

The only option is min_level (minimum severity). Options.Validate() accepts the zero value, and the adapter choice is validated at resolve time. The container wires nothing, so register the adapter first:

main.go
import (
"github.com/zenta-dev/zever/adapters/log/slog"
"github.com/zenta-dev/zever/config"
"github.com/zenta-dev/zever/container"
)
slog.Register()
c := container.New(config.Default())
logger, err := c.Log()
if err != nil {
return err
}
handler := middleware.RequestLogger(logger)(mux)

Logger has no Close; logging probes as a no-op in c.Close(ctx). Full keys: log config reference.

Event has one typed method per kind, so slog and zerolog output stays queryable instead of a flat string.

handler.go
logger.Info().Str("user_id", userID).Dur("duration", elapsed).Msg("request handled")
logger.Error().Err(err).Msg("failed to process order")
if logger.Enabled(log.LevelDebug) {
logger.Debug().Any("payload", expensiveToCompute()).Send()
}
level=INFO msg="request handled" user_id=u_123 duration=12ms

Prefer Str/Int/Dur over Any for hot fields, because Any uses reflection. Reserve Any for dynamic payloads, and never pass request bodies, headers, or config maps through it. RequestLogger emits one line per request (method, path, status, duration_ms (fractional milliseconds), plus request_id when present); Recover logs panics with value and stack, then responds 500. Emit one line per failure, with no duplicates.

The API: Logger exposes Debug(), Info(), Warn(), Error(), Fatal(), With(), WithContext(ctx), Enabled(level), Sync(), and Name(). Event exposes Str, Int, Int64, Float64, Bool, Dur, Time, Err, AnErr(key, err), Any, Msg, Msgf(format, args...), and Send. Helpers: log.ContextWithLogger(ctx, l), log.FromContext(ctx, fallback), log.Open(adapter, opts), and middleware.RequestLogger(logger).

log.ContextWithLogger(ctx, logger) carries only the logger value, no I/O state. log.FromContext(ctx, fallback) returns it, or the fallback for contexts that carry none, so request_id and other attached fields survive the call chain.

Never pass raw config or headers into Any, which may print DSNs and keys. Use cfg.RedactedServices() and fixed field names. The same rule covers error text: name services and fields, never values. See Configuration.

  • One process, one adapter: slog for stdlib-only builds, zerolog for high-throughput JSON, pretty for local shells, noop for tests.
  • Buffered providers flush through their own Shutdown(ctx), which c.Close(ctx) probes. Do not exit before Close returns or trailing lines drop.
  • Shutdown never blocks past 5s per service (or the parent deadline). Keep log I/O fast and never format unbounded payloads.
No log output in tests

The resolved adapter is noop, correct for hermetic tests. Assert behavior, not lines, or resolve slog explicitly.

request_id is missing from lines

The context logger was never attached. Wrap the context with log.ContextWithLogger in middleware before handlers run.

Secrets in log files

A call passes raw config or headers into Any. Use cfg.RedactedServices() and fixed field names.

Expensive debug work on hot paths

Fields compute even when the level is off. Gate with logger.Enabled(log.LevelDebug).

Trailing lines lost at exit

The process exits before c.Close(ctx) finishes. Defer Close with a timeout parent and check the joined error.