Go Structured Logging with Zap and Centralized Collection
Tutorials January 21, 2026 ยท 3 min read

Go Structured Logging with Zap and Centralized Collection

Master Go logging with uber-go/zap. Learn high-performance structured logging, context propagation, and centralized log shipping for Go applications.

Go's standard library logger is intentionally minimal. For production applications, you need something more powerful. Uber's zap is the go-to choice for high-performance structured logging in Go. This guide covers everything from basic setup to production-ready centralized logging.

Why Zap?

Zap is blazing fast and designed for performance-critical applications:

  • Zero allocation in hot paths
  • Structured logging with type safety
  • Leveled logging with sampling support
  • Multiple output formats (JSON, console)

Getting Started

go get go.uber.org/zap

Basic Usage

package main

import "go.uber.org/zap"

func main() {
    // Production logger (JSON output)
    logger, _ := zap.NewProduction()
    defer logger.Sync()

    logger.Info("Server starting",
        zap.String("host", "localhost"),
        zap.Int("port", 8080),
    )

    // Sugar logger (more convenient, slightly slower)
    sugar := logger.Sugar()
    sugar.Infow("Request processed",
        "method", "GET",
        "path", "/users",
        "duration_ms", 45,
    )
}

Custom Logger Configuration

func NewLogger(env string) (*zap.Logger, error) {
    var config zap.Config

    if env == "production" {
        config = zap.NewProductionConfig()
        config.EncoderConfig.TimeKey = "timestamp"
        config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
    } else {
        config = zap.NewDevelopmentConfig()
        config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
    }

    config.OutputPaths = []string{"stdout"}
    config.ErrorOutputPaths = []string{"stderr"}

    return config.Build()
}

Adding Context with Fields

// Create child loggers with context
func handleRequest(logger *zap.Logger, req *http.Request) {
    reqLogger := logger.With(
        zap.String("request_id", req.Header.Get("X-Request-ID")),
        zap.String("method", req.Method),
        zap.String("path", req.URL.Path),
    )

    reqLogger.Info("Request started")

    // All subsequent logs include the context
    reqLogger.Debug("Processing request")
    reqLogger.Info("Request completed", zap.Int("status", 200))
}

HTTP Middleware

func LoggingMiddleware(logger *zap.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()

            // Create request-scoped logger
            reqID := r.Header.Get("X-Request-ID")
            if reqID == "" {
                reqID = uuid.New().String()
            }

            reqLogger := logger.With(
                zap.String("request_id", reqID),
                zap.String("method", r.Method),
                zap.String("path", r.URL.Path),
                zap.String("remote_addr", r.RemoteAddr),
            )

            // Add logger to context
            ctx := context.WithValue(r.Context(), loggerKey, reqLogger)
            r = r.WithContext(ctx)

            // Wrap response writer to capture status
            ww := &responseWriter{ResponseWriter: w, status: 200}

            next.ServeHTTP(ww, r)

            reqLogger.Info("Request completed",
                zap.Int("status", ww.status),
                zap.Duration("duration", time.Since(start)),
            )
        })
    }
}

// Get logger from context
func LoggerFromContext(ctx context.Context) *zap.Logger {
    if logger, ok := ctx.Value(loggerKey).(*zap.Logger); ok {
        return logger
    }
    return zap.NewNop()
}

Error Logging

func processOrder(logger *zap.Logger, orderID string) error {
    logger.Info("Processing order", zap.String("order_id", orderID))

    if err := validateOrder(orderID); err != nil {
        logger.Error("Order validation failed",
            zap.String("order_id", orderID),
            zap.Error(err),
        )
        return err
    }

    // Log with stack trace for unexpected errors
    defer func() {
        if r := recover(); r != nil {
            logger.Error("Panic in order processing",
                zap.Any("panic", r),
                zap.Stack("stack"),
            )
        }
    }()

    return nil
}

Shipping Logs to Remote Services

Custom Writer for HTTP

type HTTPWriter struct {
    url    string
    apiKey string
    client *http.Client
    buffer chan []byte
}

func NewHTTPWriter(url, apiKey string) *HTTPWriter {
    w := &HTTPWriter{
        url:    url,
        apiKey: apiKey,
        client: &http.Client{Timeout: 5 * time.Second},
        buffer: make(chan []byte, 1000),
    }
    go w.flush()
    return w
}

func (w *HTTPWriter) Write(p []byte) (int, error) {
    select {
    case w.buffer <- append([]byte{}, p...):
        return len(p), nil
    default:
        return 0, errors.New("buffer full")
    }
}

func (w *HTTPWriter) flush() {
    for data := range w.buffer {
        req, _ := http.NewRequest("POST", w.url, bytes.NewReader(data))
        req.Header.Set("Authorization", "Bearer "+w.apiKey)
        req.Header.Set("Content-Type", "application/json")
        w.client.Do(req)
    }
}

// Use with zap
func NewRemoteLogger(url, apiKey string) *zap.Logger {
    writer := NewHTTPWriter(url, apiKey)

    core := zapcore.NewCore(
        zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
        zapcore.AddSync(writer),
        zap.InfoLevel,
    )

    return zap.New(core)
}

Using Syslog

import "log/syslog"

func NewSyslogLogger() (*zap.Logger, error) {
    writer, err := syslog.Dial("tcp", "logs.example.com:514",
        syslog.LOG_INFO|syslog.LOG_LOCAL0, "myapp")
    if err != nil {
        return nil, err
    }

    core := zapcore.NewCore(
        zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
        zapcore.AddSync(writer),
        zap.InfoLevel,
    )

    return zap.New(core), nil
}

Sampling for High-Volume Logs

config := zap.NewProductionConfig()
config.Sampling = &zap.SamplingConfig{
    Initial:    100,  // Log first 100 entries per second
    Thereafter: 10,   // Then log every 10th entry
}

logger, _ := config.Build()

Testing with Zap

import "go.uber.org/zap/zaptest"

func TestOrderProcessing(t *testing.T) {
    logger := zaptest.NewLogger(t)

    err := processOrder(logger, "order-123")
    assert.NoError(t, err)
}

// Or capture logs for assertions
func TestLogging(t *testing.T) {
    core, logs := observer.New(zap.InfoLevel)
    logger := zap.New(core)

    processOrder(logger, "order-123")

    assert.Equal(t, 1, logs.Len())
    assert.Equal(t, "Processing order", logs.All()[0].Message)
}

Production Best Practices

  1. Use structured fields - Never string concatenate log messages
  2. Add context early - Create child loggers with request context
  3. Sample high-volume logs - Protect against log storms
  4. Buffer remote writes - Don't block on network I/O
  5. Include stack traces for errors - Use zap.Stack() for panics

Conclusion

Zap provides the performance and features Go applications need for production logging. Combined with a centralized log management service like 401 Clicks, you'll have full visibility into your Go services with minimal overhead.

Start with the production config, add request context middleware, and ship your logs to a central service. Your debugging sessions will thank you.

A

Admin

Published on January 21, 2026