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 freeLogging in Python with LogFlow
Add production-ready structured logging to any Python application and ship logs to LogFlow.
Structured Logging in Flask
Add production-ready structured logging to any Flask app in under 10 minutes.
Structured Logging in Laravel
Add production-ready structured logging to Laravel and ship logs to LogFlow.