Beginner

Structured Logging in Go with Zap

Set up uber-go/zap for high-performance structured logging in Go. Ship JSON logs to LogFlow with a custom WriteSyncer.

LogFlow TeamAugust 21, 20268 minGoZap

Zap is the fastest structured logging library for Go. It allocates zero bytes for common operations and outputs JSON by default — ideal for production logging.

This tutorial sets up Zap with JSON output and ships logs to LogFlow.

Prerequisites

  • Go 1.21+
  • A LogFlow account (free tier works)
  • Your API key from Settings → API Key

Step 1 — Install Zap

go get go.uber.org/zap

Step 2 — Create a Production Logger

package main

import (
    "go.uber.org/zap"
    "go.uber.org/zap/zapcore"
    "os"
    "time"
)

func NewLogger() (*zap.Logger, error) {
    config := zap.Config{
        Level:       zap.NewAtomicLevelAt(zap.InfoLevel),
        Development: false,
        Encoding:    "json",
        EncoderConfig: zapcore.EncoderConfig{
            TimeKey:        "timestamp",
            LevelKey:       "level",
            MessageKey:     "message",
            CallerKey:      "caller",
            StacktraceKey:  "stacktrace",
            EncodeLevel:    zapcore.LowercaseLevelEncoder,
            EncodeTime:     zapcore.TimeEncoderOfLayout("2006-01-02 15:04:05.000"),
            EncodeCaller:   zapcore.ShortCallerEncoder,
        },
        OutputPaths:      []string{"stdout"},
        ErrorOutputPaths: []string{"stderr"},
        InitialFields: map[string]interface{}{
            "service": os.Getenv("SERVICE_NAME"),
        },
    }

    return config.Build()
}

Output:

{"timestamp":"2026-08-21 14:23:11.456","level":"info","message":"server.started","caller":"main.go:42","service":"api","port":8080}

Step 3 — Use the Logger

func main() {
    logger, _ := NewLogger()
    defer logger.Sync()

    // Basic logging
    logger.Info("server.started", zap.Int("port", 8080))

    // With multiple fields
    logger.Info("request.handled",
        zap.String("method", "GET"),
        zap.String("path", "/api/users"),
        zap.Int("status", 200),
        zap.Duration("duration", 45*time.Millisecond),
    )

    // Error with context
    logger.Error("payment.failed",
        zap.String("order_id", "ORD-1234"),
        zap.Error(err),
    )
}

Sugared Logger for Convenience

Zap also offers a "sugared" logger with a more relaxed API (slightly slower but easier to use):

sugar := logger.Sugar()

sugar.Infow("request.handled",
    "method", "POST",
    "path", "/api/orders",
    "status", 201,
    "duration_ms", 123,
)

sugar.Errorf("failed to connect to %s: %v", host, err)

Step 4 — Add Request Context (HTTP Middleware)

Create middleware that adds request context to every log:

package middleware

import (
    "context"
    "net/http"
    "time"

    "github.com/google/uuid"
    "go.uber.org/zap"
)

type ctxKey string
const loggerKey ctxKey = "logger"

func RequestLogger(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) {
            start := time.Now()

            requestID := r.Header.Get("X-Request-ID")
            if requestID == "" {
                requestID = uuid.New().String()[:8]
            }

            // Create a child logger with request context
            reqLogger := base.With(
                zap.String("request_id", requestID),
                zap.String("method", r.Method),
                zap.String("path", r.URL.Path),
                zap.String("ip", r.RemoteAddr),
            )

            // Store logger in context
            ctx := context.WithValue(r.Context(), loggerKey, reqLogger)

            // Wrap response writer to capture status
            rw := &responseWriter{ResponseWriter: w, status: 200}
            next.ServeHTTP(rw, r.WithContext(ctx))

            reqLogger.Info("request.handled",
                zap.Int("status", rw.status),
                zap.Duration("duration", time.Since(start)),
            )
        })
    }
}

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

type responseWriter struct {
    http.ResponseWriter
    status int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.status = code
    rw.ResponseWriter.WriteHeader(code)
}

Usage in handlers:

func handleCreateOrder(w http.ResponseWriter, r *http.Request) {
    log := middleware.Log(r.Context())

    log.Info("order.creating", zap.String("user_id", userID))

    order, err := createOrder(r.Context(), req)
    if err != nil {
        log.Error("order.failed", zap.Error(err))
        http.Error(w, "failed", 500)
        return
    }

    log.Info("order.created", zap.String("order_id", order.ID))
}

Step 5 — Ship Logs to LogFlow

Create a custom WriteSyncer that sends logs to LogFlow:

package logflow

import (
    "bytes"
    "encoding/json"
    "net/http"
    "os"
    "sync"
    "time"
)

type Writer struct {
    apiKey string
    url    string
    client *http.Client
    mu     sync.Mutex
    buffer []json.RawMessage
}

func NewWriter() *Writer {
    w := &Writer{
        apiKey: os.Getenv("LOGFLOW_API_KEY"),
        url:    "https://api.getlogflow.com/v1/logs",
        client: &http.Client{Timeout: 5 * time.Second},
        buffer: make([]json.RawMessage, 0, 100),
    }

    // Flush every 2 seconds
    go func() {
        ticker := time.NewTicker(2 * time.Second)
        for range ticker.C {
            w.Flush()
        }
    }()

    return w
}

func (w *Writer) Write(p []byte) (int, error) {
    w.mu.Lock()
    w.buffer = append(w.buffer, append(json.RawMessage{}, p...))
    if len(w.buffer) >= 50 {
        w.mu.Unlock()
        w.Flush()
    } else {
        w.mu.Unlock()
    }
    return len(p), nil
}

func (w *Writer) Flush() {
    w.mu.Lock()
    if len(w.buffer) == 0 || w.apiKey == "" {
        w.mu.Unlock()
        return
    }
    batch := w.buffer
    w.buffer = make([]json.RawMessage, 0, 100)
    w.mu.Unlock()

    body, _ := json.Marshal(batch)
    req, _ := http.NewRequest("POST", w.url, bytes.NewReader(body))
    req.Header.Set("Authorization", "Bearer "+w.apiKey)
    req.Header.Set("Content-Type", "application/json")
    w.client.Do(req) // Fire and forget
}

func (w *Writer) Sync() error {
    w.Flush()
    return nil
}

Wire it into your logger:

import (
    "go.uber.org/zap/zapcore"
    "io"
    "os"
)

func NewLogger() *zap.Logger {
    encoder := zapcore.NewJSONEncoder(encoderConfig)

    // Write to both stdout and LogFlow
    logflowWriter := logflow.NewWriter()
    multiWriter := zapcore.NewMultiWriteSyncer(
        zapcore.AddSync(os.Stdout),
        zapcore.AddSync(logflowWriter),
    )

    core := zapcore.NewCore(encoder, multiWriter, zap.InfoLevel)
    return zap.New(core, zap.AddCaller())
}

Step 6 — Verify in LogFlow

Run your Go application and make some requests. Then search in LogFlow:

service:api level:info

You should see structured logs with all fields searchable.

Zap vs Zerolog vs Slog

Library Speed API Style Stdlib?
Zap Fastest Typed fields No
Zerolog Very fast Chained builder No
Slog Fast enough Typed fields Yes (Go 1.21+)

If you're starting a new project, also consider zerolog or Go's standard log/slog.

Next Steps

Start monitoring your logs today

Free plan available. No credit card required. Up and running in 2 minutes.

Get started free