How-To: Set Up Structured Logging in Go with zerolog (and why it’s the best for prod/Kubernetes)
Updated: 2025-08-24
Why zerolog (the no‑nonsense take)
- Blazing fast, low allocation: JSON logs with minimal overhead vs. fmt/reflect-heavy loggers.
- Native structured fields: You don’t string-concatenate context; you attach key/value pairs.
- Production-first: Emits clean JSON to stdout (perfect for Fluent Bit/Loki/Elastic) with an easy env toggle for pretty logs locally.
- Tiny API surface: Fewer knobs, fewer footguns. Add fields, set level, ship.
- Easily composable: Context-aware loggers, hooks, sampling, caller info, error stacks.
- Great in containers: Deterministic JSON lines → lower ingest cost, easier querying, better SLOs.
Install
go mod init example.com/zerolog-demo
go get github.com/rs/zerolog@latest
go get github.com/rs/zerolog/log@latest
# Optional (stack traces)
go get github.com/pkg/errors@latest
Minimal setup (main.go) — JSON by default, pretty in dev
package main
import (
"net/http"
"os"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func main() {
// ISO8601 timestamps
zerolog.TimeFieldFormat = time.RFC3339
// LOG_LEVEL=debug|info|warn|error
if lvl, err := zerolog.ParseLevel(os.Getenv("LOG_LEVEL")); err == nil {
zerolog.SetGlobalLevel(lvl)
} else {
zerolog.SetGlobalLevel(zerolog.InfoLevel)
}
// Pretty console for local dev: LOG_PRETTY=1 go run .
if os.Getenv("LOG_PRETTY") == "1" {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
}
// Attach global fields once
log.Logger = log.With().
Str("service", "zerolog-demo").
Str("version", os.Getenv("SERVICE_VERSION")).
Logger()
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
log.Info().Str("path", r.URL.Path).Msg("ok")
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok\n"))
})
addr := ":8080"
log.Info().Str("addr", addr).Msg("starting_http_server")
if err := http.ListenAndServe(addr, nil); err != nil {
log.Fatal().Err(err).Msg("server_exit")
}
}
HTTP middleware with request IDs + duration
// middleware.go
package main
import (
"net/http"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
type statusWriter struct{ http.ResponseWriter; status int }
func (w *statusWriter) WriteHeader(code int){ w.status = code; w.ResponseWriter.WriteHeader(code) }
func withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
reqID := r.Header.Get("X-Request-ID")
if reqID == "" { reqID = uuid.NewString() }
sw := &statusWriter{ResponseWriter: w, status: 200}
w.Header().Set("X-Request-ID", reqID)
logger := log.With().
Str("request_id", reqID).
Str("method", r.Method).
Str("path", r.URL.Path).
Str("user_agent", r.UserAgent()).
Logger()
r = r.WithContext(logger.WithContext(r.Context()))
logger.Info().Msg("request_start")
next.ServeHTTP(sw, r)
logger.Info().Int("status", sw.status).
Dur("duration", time.Since(start)).
Msg("request_end")
})
}
Add caller info, sampling, and error stacks
package main
import (
"errors"
"os"
"time"
pkgerrors "github.com/pkg/errors"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func advancedSetup() {
zerolog.TimeFieldFormat = time.RFC3339
// Caller annotation (+1 to skip this frame)
log.Logger = zerolog.New(os.Stdout).
With().Timestamp().Caller().Logger()
// Sample noisy info logs to reduce volume
logSampled := log.Sample(&zerolog.BasicSampler{N: 10})
logSampled.Info().Msg("this info log is sampled (1/10)")
// Stack traces via github.com/pkg/errors
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
err := pkgerrors.WithStack(errors.New("boom"))
log.Error().Stack().Err(err).Msg("something exploded")
}
Dockerfile (static, tiny, rootless)
# syntax=docker/dockerfile:1
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o app
FROM gcr.io/distroless/base-debian12
WORKDIR /app
COPY --from=build /src/app ./app
USER 65532:65532
ENV LOG_LEVEL=info SERVICE_VERSION=1.0.0
EXPOSE 8080
ENTRYPOINT ["./app"]
Kubernetes Deployment (stdout JSON, perfect for Loki/Fluent Bit)
apiVersion: apps/v1
kind: Deployment
metadata:
name: zerolog-demo
namespace: apps
spec:
replicas: 2
selector: { matchLabels: { app: zerolog-demo } }
template:
metadata:
labels: { app: zerolog-demo }
spec:
containers:
- name: app
image: gitlab.local:5050/local/zerolog-demo:1.0.0
env:
- name: LOG_LEVEL
value: info
- name: SERVICE_VERSION
valueFrom:
fieldRef: { fieldPath: metadata.labels['pod-template-hash'] }
ports: [{ containerPort: 8080 }]
readinessProbe:
httpGet: { path: /healthz, port: 8080 }
resources:
requests: { cpu: "50m", memory: "64Mi" }
limits: { cpu: "250m", memory: "256Mi" }
Fluent Bit JSON parser (optional)
[PARSER]
Name zerolog
Format json
Time_Key time
Time_Format %Y-%m-%dT%H:%M:%S%z
Decode_Field_As escaped message do_next
Decode_Field_As json message
Ops playbook
- Keep prod logs as JSON; use pretty console only in dev.
- Always include request_id to correlate traces and metrics.
- Sample high-volume info logs; never sample errors.
- For long-running jobs, log at start/end with duration and outcome.
- Set global level via env so you can flip to debug without redeploy.
Security considerations
- Treat logs as sensitive data (they often contain IDs, paths, occasionally errors). Limit access and retention.
- Don’t log secrets or tokens; mask or hash sensitive values.
- If exporting to a third party, enforce TLS and principal-of-least-privilege for log ingestion.
Why zerolog beats the usual suspects (my spicy take)
- vs. stdlib `log`: faster, structured, machine-parseable JSON instead of string soup.
- vs. logrus: leaner, faster, no reflection overhead; mature but slower project.
- vs. zap: zap is also fast and excellent; zerolog’s API is even smaller and its zero-allocation design is brutally efficient for HTTP services. If you need sugared logging or a bigger ecosystem, zap is great—otherwise zerolog is the sleek race car.
Meme idea
printf("ok") = vibes. zerolog = incident solved.
Taylor Swift
“It’s me, hi, I’m the problem, it’s me.” — Anti‑Hero
Comments
Post a Comment