Logging estruturado com zap ou zerolog

1. Por que logging estruturado em Go?

O pacote log padrão do Go é suficiente para aplicações simples, mas apresenta limitações significativas em sistemas distribuídos e microsserviços. Logs em texto solto como "2024-01-15T10:30:00Z Usuário 42 fez login" são difíceis de parsear, filtrar e analisar em ferramentas modernas.

O logging estruturado resolve esses problemas ao produzir saída em formato JSON ou similar, permitindo:
- Busca e filtragem precisa em ferramentas como Elasticsearch, Datadog e Grafana Loki
- Campos contextuais: adicionar metadados como requestID, userID e latência sem concatenação manual
- Análise automatizada: alertas baseados em campos específicos, agregações e dashboards

// Exemplo da diferença entre logging tradicional e estruturado
// log padrão - difícil de parsear
log.Printf("Usuário %d fez login com IP %s", userID, ip)

// logging estruturado - fácil de processar
logger.Info("login realizado",
    zap.Int("userID", userID),
    zap.String("ip", ip),
)

2. Introdução ao zap: rápido e tipado

O zap, mantido pela Uber, é conhecido por sua performance excepcional e API tipada que minimiza alocações.

Instalação e configuração básica

import "go.uber.org/zap"

func main() {
    // Logger de produção - JSON, timestamp epoch
    logger, _ := zap.NewProduction()
    defer logger.Sync()

    // Logger de desenvolvimento - texto colorido, mais verboso
    devLogger, _ := zap.NewDevelopment()
    defer devLogger.Sync()

    logger.Info("servidor iniciado",
        zap.String("porta", ":8080"),
        zap.Int("workers", 10),
    )
}

Logs tipados vs SugaredLogger

// Logger tipado - máximo desempenho, sem alocações desnecessárias
logger.Info("processando requisição",
    zap.String("method", "POST"),
    zap.Float64("duration_ms", 45.2),
    zap.Int("status", 200),
)

// SugaredLogger - conveniência, similar ao fmt.Printf
sugar := logger.Sugar()
sugar.Infow("processando requisição",
    "method", "POST",
    "duration_ms", 45.2,
    "status", 200,
)

A diferença principal: zap.Logger é mais rápido e seguro em tipos, enquanto zap.SugaredLogger oferece flexibilidade com printf-style.

3. Introdução ao zerolog: minimalista e fluente

O zerolog oferece uma API fluente e elegante, com foco em simplicidade e zero alocações.

Instalação e configuração básica

import "github.com/rs/zerolog/log"

func main() {
    // Configuração global
    zerolog.TimeFieldFormat = time.RFC3339Nano

    // API fluente - encadeamento de métodos
    log.Info().
        Str("servico", "api").
        Int("porta", 8080).
        Bool("debug", true).
        Msg("servidor iniciado")

    // Níveis de log
    log.Debug().Msg("iniciando conexão com banco")
    log.Info().Msg("conexão estabelecida")
    log.Warn().Msg("latência acima do esperado")
    log.Error().Err(err).Msg("falha na consulta")
    // log.Fatal().Msg("erro crítico") // encerra o programa
}

Níveis de log completos

// Debug - informações detalhadas para desenvolvimento
log.Debug().Str("query", "SELECT * FROM users").Msg("executando consulta")

// Info - eventos normais do sistema
log.Info().Int("users", count).Msg("usuários ativos carregados")

// Warn - situações anormais mas não críticas
log.Warn().Float64("latencia_ms", 2500).Msg("requisição lenta")

// Error - falhas que precisam de atenção
log.Error().Err(err).
    Str("requestID", reqID).
    Msg("falha ao processar pagamento")

// Fatal - erro grave que encerra o programa
log.Fatal().Msg("configuração inválida do banco de dados")

4. Configurações avançadas de saída e formato

Saída para arquivo com rotação

import (
    "gopkg.in/natefinch/lumberjack.v2"
    "go.uber.org/zap/zapcore"
)

func configurarLoggerArquivo() *zap.Logger {
    writer := lumberjack.Logger{
        Filename:   "/var/log/app.log",
        MaxSize:    100, // megabytes
        MaxBackups: 3,
        MaxAge:     28, // dias
    }

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

    return zap.New(core)
}

Timestamp customizado e níveis dinâmicos

// zerolog: timestamp customizado
zerolog.TimeFieldFormat = "2006-01-02 15:04:05.000"
zerolog.SetGlobalLevel(zerolog.InfoLevel)

// zap: nível atômico para alteração em runtime
atom := zap.NewAtomicLevel()
atom.SetLevel(zap.DebugLevel)

logger := zap.New(zapcore.NewCore(
    zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
    os.Stdout,
    atom,
))

5. Adicionando contexto e campos dinâmicos

Injeção de campos comuns via With()

// zap
logger := zap.L().With(
    zap.String("service", "payment-api"),
    zap.String("environment", "production"),
)

// zerolog
zerolog := log.With().
    Str("service", "payment-api").
    Str("environment", "production").
    Logger()

Contexto de requisição HTTP

func middlewareLogContext(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        requestID := uuid.New().String()
        ctx := context.WithValue(r.Context(), "requestID", requestID)

        // zap
        log := zap.L().With(zap.String("requestID", requestID))
        ctx = context.WithValue(ctx, "logger", log)

        // zerolog
        zlog := zerolog.Ctx(r.Context()).With().
            Str("requestID", requestID).
            Logger()
        ctx = zlog.WithContext(ctx)

        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

6. Integração com HTTP handlers e middleware

Middleware de logging completo

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

        // Capturar status code
        wrapper := &responseWriter{ResponseWriter: w, statusCode: 200}

        next.ServeHTTP(wrapper, r)

        duration := time.Since(start)

        // Log estruturado da requisição
        log.Info().
            Str("method", r.Method).
            Str("path", r.URL.Path).
            Int("status", wrapper.statusCode).
            Float64("duration_ms", float64(duration.Microseconds())/1000).
            Str("user_agent", r.UserAgent()).
            Msg("request completed")
    })
}

type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

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

Testando logs com httptest

func TestHandlerLogs(t *testing.T) {
    var buf bytes.Buffer
    logger := zerolog.New(&buf)

    handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        logger.Info().Str("path", r.URL.Path).Msg("request received")
        w.WriteHeader(http.StatusOK)
    })

    req := httptest.NewRequest("GET", "/api/users", nil)
    handler.ServeHTTP(httptest.NewRecorder(), req)

    // Assert no log gerado
    var logEntry map[string]interface{}
    json.Unmarshal(buf.Bytes(), &logEntry)

    assert.Equal(t, "/api/users", logEntry["path"])
    assert.Equal(t, "request received", logEntry["message"])
}

7. Comparação prática: zap vs zerolog

Benchmark de performance

Biblioteca Alocações/op Tempo/op
zap.Logger 0 ~200ns
zerolog 0 ~300ns
logrus 5 ~800ns
log padrão 3 ~500ns

Quando usar cada um

Use zap quando:
- Performance é crítica (alta throughput)
- Precisa de tipagem forte para evitar erros
- Trabalha com times grandes que exigem consistência

Use zerolog quando:
- Prefere API fluente e expressiva
- Precisa de um logger leve e minimalista
- Deseja integração fácil com context.Context

8. Boas práticas e armadilhas comuns

Rate limiting em loops de alta frequência

// zap: sampling automático
logger, _ := zap.Config{
    Sampling: &zap.SamplingConfig{
        Initial:    100, // loga as primeiras 100 entradas
        Thereafter: 100, // depois loga 1 a cada 100
    },
}.Build()

// zerolog: implementação manual com sampler
sampler := &zerolog.BasicSampler{N: 10}
sampled := log.Sample(sampler)

Segurança concorrente

Ambas as bibliotecas são thread-safe por padrão. Não é necessário sincronização adicional.

// Seguro para uso em múltiplas goroutines
go func() {
    log.Info().Msg("goroutine 1")
}()
go func() {
    log.Info().Msg("goroutine 2")
}()

Evitando fmt.Sprintf em logs estruturados

// ERRADO - perde a estrutura JSON
log.Info(fmt.Sprintf("usuário %d acessou recurso %s", userID, resource))

// CERTO - preserva campos estruturados
log.Info().
    Int("userID", userID).
    Str("resource", resource).
    Msg("acesso a recurso")

Logging em testes com bytes.Buffer

func TestLogging(t *testing.T) {
    var buf bytes.Buffer
    logger := zerolog.New(&buf)

    logger.Info().Str("test", "value").Msg("test message")

    var result map[string]interface{}
    json.Unmarshal(buf.Bytes(), &result)

    assert.Equal(t, "test message", result["message"])
    assert.Equal(t, "value", result["test"])
}

Referências