Back to docs
Recipe / Observability

Go zap structured logging

Wire uber-go/zap into a Meridian-backed Go service so every request emits structured JSON, ships to your aggregator, and stays cheap under load. Zero allocations on the hot path, type-safe fields, and a logger your future self can grep.

1. Install and bootstrap the production logger

Zap ships two presets: NewProduction (JSON, info+, sampled) and NewDevelopment (console, debug+). Pick production for anything past localhost; the sampler caps duplicate log floods at 100/sec.

go get go.uber.org/zap

// main.go
logger, err := zap.NewProduction()
if err != nil { panic(err) }
defer logger.Sync()

logger.Info("meridian.boot",
  zap.String("region", "swc"),
  zap.Int("port", 8080),
)

2. Attach a request-scoped logger via middleware

Stuff a child logger into context.Context with the request id baked in. Every downstream call pulls the same logger, so trace id, user id, and route stay consistent across the whole chain without you re-passing fields.

func WithLogger(base *zap.Logger) func(http.Handler) http.Handler {
  return func(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
      rid := r.Header.Get("X-Request-Id")
      log := base.With(zap.String("rid", rid), zap.String("path", r.URL.Path))
      ctx := context.WithValue(r.Context(), "log", log)
      next.ServeHTTP(w, r.WithContext(ctx))
    })
  }
}

3. Sample, redact, and ship to Meridian

Point zap at stdout, let the platform tail and forward to your aggregator. Use zap.Namespace to scope token fields and a redactor hook to scrub Authorization headers before they leave the box. The sampler keeps cost predictable when a hot path goes loud.

  • Always defer logger.Sync() in main.
  • Never use Sugar() on hot paths — typed fields are 4–10x faster.
  • Treat Fatal as a real fatal: it calls os.Exit(1).