Set up zerolog in a Go application for fast, structured JSON logging. Ship logs to a centralized platform and add request context with middleware.
Go's standard library log package writes plain text with no structure, no levels, and no fields. The newer slog package (Go 1.21+) is better but still basic. For production workloads, zerolog is the fastest structured logger in the Go ecosystem — zero allocation on common paths means no GC pressure even under high throughput.
This tutorial sets up zerolog in a Go HTTP service with request middleware, error tracking, and log shipping.
go get github.com/rs/zerolog
package main
import (
"os"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func initLogger() {
// Production: JSON output
// Development: pretty console output
if os.Getenv("ENV") == "development" {
log.Logger = log.Output(zerolog.ConsoleWriter{
Out: os.Stdout,
TimeFormat: time.RFC3339,
})
} else {
zerolog.TimeFieldFormat = time.RFC3339Nano
log.Logger = zerolog.New(os.Stdout).
With().
Timestamp().
Str("service", "my-api").
Logger()
}
// Set global log level
level := os.Getenv("LOG_LEVEL")
switch level {
case "debug":
zerolog.SetGlobalLevel(zerolog.DebugLevel)
case "warn":
zerolog.SetGlobalLevel(zerolog.WarnLevel)
case "error":
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
default:
zerolog.SetGlobalLevel(zerolog.InfoLevel)
}
}
Production output:
{"level":"info","service":"my-api","time":"2026-08-19T09:23:15.442Z","message":"Server started","port":8080}
Log every request with method, path, status code, and duration:
package middleware
import (
"net/http"
"time"
"github.com/rs/zerolog/log"
)
func RequestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Wrap ResponseWriter to capture status code
wrapped := &statusWriter{ResponseWriter: w, statusCode: http.StatusOK}
// Pass trace ID from header or generate one
traceID := r.Header.Get("X-Trace-ID")
if traceID == "" {
traceID = generateID()
}
w.Header().Set("X-Trace-ID", traceID)
// Add logger with request context to the request
logger := log.With().
Str("traceId", traceID).
Str("method", r.Method).
Str("path", r.URL.Path).
Logger()
// Store logger in context
ctx := logger.WithContext(r.Context())
next.ServeHTTP(wrapped, r.WithContext(ctx))
duration := time.Since(start)
logger.Info().
Int("status", wrapped.statusCode).
Dur("durationMs", duration).
Str("clientIp", r.RemoteAddr).
Msg("Request completed")
})
}
type statusWriter struct {
http.ResponseWriter
statusCode int
}
func (w *statusWriter) WriteHeader(code int) {
w.statusCode = code
w.ResponseWriter.WriteHeader(code)
}
The middleware stores a logger in the request context. Use it in handlers to automatically include trace ID and request info:
func CreateOrderHandler(w http.ResponseWriter, r *http.Request) {
logger := zerolog.Ctx(r.Context())
var order Order
if err := json.NewDecoder(r.Body).Decode(&order); err != nil {
logger.Warn().Err(err).Msg("Invalid order payload")
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
logger.Info().
Str("orderId", order.ID).
Float64("total", order.Total).
Str("currency", order.Currency).
Msg("Order created")
// Process order...
if err := processPayment(order); err != nil {
logger.Error().
Err(err).
Str("orderId", order.ID).
Str("provider", "stripe").
Msg("Payment processing failed")
http.Error(w, "Payment failed", http.StatusInternalServerError)
return
}
logger.Info().
Str("orderId", order.ID).
Msg("Order completed successfully")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(order)
}
Every log from this handler automatically includes the traceId, method, and path set by the middleware.
Create a writer that sends logs to LogFlow's API:
package logshipper
import (
"bytes"
"encoding/json"
"net/http"
"sync"
"time"
)
type LogFlowWriter struct {
apiKey string
endpoint string
buffer []map[string]interface{}
mu sync.Mutex
client *http.Client
}
func NewLogFlowWriter(apiKey string) *LogFlowWriter {
w := &LogFlowWriter{
apiKey: apiKey,
endpoint: "https://api.getlogflow.com/v1/logs",
client: &http.Client{Timeout: 5 * time.Second},
}
go w.flushLoop()
return w
}
func (w *LogFlowWriter) Write(p []byte) (n int, err error) {
var entry map[string]interface{}
if err := json.Unmarshal(p, &entry); err != nil {
return 0, err
}
w.mu.Lock()
w.buffer = append(w.buffer, entry)
w.mu.Unlock()
return len(p), nil
}
func (w *LogFlowWriter) flushLoop() {
ticker := time.NewTicker(1 * time.Second)
for range ticker.C {
w.flush()
}
}
func (w *LogFlowWriter) flush() {
w.mu.Lock()
if len(w.buffer) == 0 {
w.mu.Unlock()
return
}
batch := w.buffer
w.buffer = nil
w.mu.Unlock()
payload := make([]map[string]interface{}, len(batch))
for i, entry := range batch {
payload[i] = map[string]interface{}{
"level": entry["level"],
"message": entry["message"],
"service": entry["service"],
"attributes": entry,
}
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", w.endpoint, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+w.apiKey)
req.Header.Set("Content-Type", "application/json")
w.client.Do(req)
}
Wire it into zerolog with a multi-writer:
func initLogger() {
consoleWriter := os.Stdout
logflowWriter := logshipper.NewLogFlowWriter(os.Getenv("LOGFLOW_API_KEY"))
multi := zerolog.MultiLevelWriter(consoleWriter, logflowWriter)
log.Logger = zerolog.New(multi).
With().
Timestamp().
Str("service", "my-api").
Logger()
}
If changing the application code isn't feasible, pipe the process output to a forwarder like Vector or Fluent Bit that sends JSON lines to LogFlow's HTTP endpoint.
Catch panics and log them instead of crashing silently:
func RecoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
logger := zerolog.Ctx(r.Context())
logger.Error().
Interface("panic", rec).
Bytes("stack", debug.Stack()).
Msg("Panic recovered")
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func main() {
initLogger()
mux := http.NewServeMux()
mux.HandleFunc("POST /orders", CreateOrderHandler)
mux.HandleFunc("GET /health", HealthHandler)
handler := middleware.RequestLogger(
RecoveryMiddleware(mux),
)
log.Info().Int("port", 8080).Msg("Server started")
http.ListenAndServe(":8080", handler)
}
| Logger | Allocations per log | JSON output | Structured fields |
|---|---|---|---|
log (stdlib) |
1-2 | No | No |
slog (Go 1.21+) |
0-1 | Yes | Yes |
zap |
0 | Yes | Yes |
zerolog |
0 | Yes | Yes |
Zerolog and zap both achieve zero allocations. Zerolog's API is more fluent (log.Info().Str("key", "val").Msg("text")), while zap uses a field-list style (zap.Info("text", zap.String("key", "val"))). Both are excellent choices. This tutorial uses zerolog for its chainable API.
Free plan available. No credit card required. Up and running in 2 minutes.
Get started freeHow to Ship Vercel and Next.js Logs to a Log Management Platform
Vercel logs disappear after 1 hour. Set up persistent logging for Next.js with a log drain or SDK.
Java Logging with SLF4J and Logback: From Setup to Production
Set up structured JSON logging in Java with SLF4J, Logback, and MDC for request tracing.
How to Monitor Cron Jobs and Catch Silent Failures
Cron jobs fail silently by default. Set up logging and silence detection to catch failures before users do.