Logging estruturado com slog e Zerolog

Logging estruturado com slog e Zerolog

Logging estruturado em Go com log/slog e Zerolog: handlers de texto e JSON, níveis, With e grupos, handler customizado que precisa sobreviver ao With, durações em nanossegundos no JSON, rotação com lumberjack e um benchmark medido, sem o atalho do io.Discard.
Golang

• • 17 min de leitura

Logs são a primeira e muitas vezes única fonte de informação quando algo dá errado em produção. Um sistema com logging bem estruturado permite diagnosticar problemas em segundos. Um sistema com fmt.Println espalhados pelo código torna o diagnóstico uma caçada demorada e frustrante.

A distinção fundamental é entre logging textual e logging estruturado. Logging textual produz strings legíveis por humanos mas difíceis de processar por máquinas. Logging estruturado produz documentos — tipicamente JSON — onde cada campo é um par chave-valor que sistemas como Elasticsearch, Datadog, Loki e CloudWatch podem indexar, filtrar e agregar.

O pacote slog: logging estruturado na biblioteca padrão

Go 1.21 introduziu log/slog — o primeiro logger estruturado na biblioteca padrão. Antes dele, cada projeto escolhia entre dezenas de bibliotecas externas sem padrão comum. O slog resolve isso com uma API elegante e extensível.

Conceitos fundamentais

package main

import (
    "context"
    "log/slog"
    "os"
)

func main() {
    // Logger padrão — TextHandler para terminal, JSONHandler para produção

    // TextHandler: saída legível por humanos
    loggerTexto := slog.New(slog.NewTextHandler(os.Stdout, nil))

    // JSONHandler: saída estruturada para sistemas de log
    loggerJSON := slog.New(slog.NewJSONHandler(os.Stdout, nil))

    // Definindo o logger padrão global
    slog.SetDefault(loggerJSON)

    // Níveis de log: Debug, Info, Warn, Error
    slog.Debug("debug desabilitado por padrão")
    slog.Info("aplicação iniciada")
    slog.Warn("configuração ausente, usando padrão")
    slog.Error("falha ao conectar", "erro", "connection refused")

    // Atributos chave-valor
    slog.Info("requisição recebida",
        "método", "GET",
        "caminho", "/usuarios",
        "ip", "192.168.1.1",
        "user_agent", "Mozilla/5.0",
    )

    // Com context — permite propagação de valores
    ctx := context.Background()
    slog.InfoContext(ctx, "operação concluída", "duração_ms", 142)

    // Usando logger não-global
    loggerTexto.Info("mensagem com logger específico")
    loggerJSON.Info("mensagem em JSON")
}

Saída do TextHandler, que usa o fuso local e milissegundos, e só põe aspas onde precisa:

time=2026-10-01T12:51:09.489-03:00 level=INFO msg="requisição recebida" método=GET caminho=/usuarios nome="Ana Lima"

Saída do JSONHandler, com a hora em nanossegundos:

{"time":"2026-10-01T12:51:09.489445603-03:00","level":"INFO","msg":"requisição recebida","método":"GET","caminho":"/usuarios","ip":"192.168.1.1","user_agent":"Mozilla/5.0"}

Configurando o slog

package main

import (
    "log/slog"
    "os"
)

func configurarLogger(ambiente string) *slog.Logger {
    var nivel slog.Level
    var handler slog.Handler

    switch ambiente {
    case "producao":
        nivel = slog.LevelInfo
        handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
            Level:     nivel,
            AddSource: true, // inclui arquivo e linha no log
            ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
                // Renomeia campos para compatibilidade com sistemas externos
                if a.Key == slog.TimeKey {
                    a.Key = "timestamp"
                }
                if a.Key == slog.LevelKey {
                    a.Key = "severity"
                }
                return a
            },
        })

    case "desenvolvimento":
        nivel = slog.LevelDebug
        handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
            Level:     nivel,
            AddSource: true,
        })

    default:
        nivel = slog.LevelWarn
        handler = slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
            Level: nivel,
        })
    }

    return slog.New(handler)
}

func main() {
    logger := configurarLogger(os.Getenv("APP_ENV"))
    slog.SetDefault(logger)

    slog.Info("logger configurado", "ambiente", os.Getenv("APP_ENV"))
}

Logger com atributos fixos: With

With cria um novo logger filho que inclui atributos em todos os logs subsequentes — ideal para adicionar contexto de requisição, serviço ou componente:

package main

import (
    "log/slog"
    "os"
    "time"
)

func processarRequisicao(logger *slog.Logger, reqID, metodo, caminho string) {
    // Logger filho com contexto da requisição
    log := logger.With(
        "request_id", reqID,
        "método", metodo,
        "caminho", caminho,
    )

    inicio := time.Now()

    log.Info("requisição iniciada")

    // Simula processamento
    time.Sleep(50 * time.Millisecond)

    log.Info("buscando dados", "tabela", "usuarios")
    log.Info("requisição concluída",
        "status", 200,
        "duração_ms", time.Since(inicio).Milliseconds(),
    )
}

func main() {
    // Logger base com identificação do serviço
    base := slog.New(slog.NewJSONHandler(os.Stdout, nil)).With(
        "serviço", "api",
        "versao", "1.2.0",
        "ambiente", "producao",
    )

    processarRequisicao(base, "req-abc-123", "GET", "/usuarios/42")
    processarRequisicao(base, "req-def-456", "POST", "/pedidos")
}

Grupos: organizando atributos hierarquicamente

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

// slog.Group agrupa atributos sob uma chave neste log;
// logger.WithGroup("config") faria o mesmo em todos os logs seguintes
logger.Info("servidor iniciado",
    slog.Group("config",
        slog.String("host", "0.0.0.0"),
        slog.Int("port", 8080),
        slog.Duration("timeout", 30*time.Second),
    ),
    slog.Group("banco",
        slog.String("host", "localhost"),
        slog.Int("port", 5432),
        slog.Int("pool_size", 25),
    ),
)

Saída JSON:

{
  "level": "INFO",
  "msg": "servidor iniciado",
  "config": {"host": "0.0.0.0", "port": 8080, "timeout": 30000000000},
  "banco": {"host": "localhost", "port": 5432, "pool_size": 25}
}

O JSONHandler grava time.Duration como inteiro em nanossegundos: os 30 segundos viram 30000000000. Para um campo legível, vale registrar slog.Int64("timeout_ms", d.Milliseconds()) ou slog.String("timeout", d.String()).

Handler customizado: adicionando campos automáticos

package main

import (
    "context"
    "log/slog"
    "os"
)

// Chave de contexto para request ID
type chaveRequestID struct{}

// Handler que injeta automaticamente o request ID do context
type handlerComContext struct {
    slog.Handler
}

func (h *handlerComContext) Handle(ctx context.Context, r slog.Record) error {
    if reqID, ok := ctx.Value(chaveRequestID{}).(string); ok {
        r.AddAttrs(slog.String("request_id", reqID))
    }
    return h.Handler.Handle(ctx, r)
}

// WithAttrs e WithGroup precisam devolver o invólucro de novo. Os métodos
// herdados do handler embutido devolvem o handler interno, e todo logger
// criado com logger.With(...) perderia o request_id sem aviso
func (h *handlerComContext) WithAttrs(attrs []slog.Attr) slog.Handler {
    return &handlerComContext{Handler: h.Handler.WithAttrs(attrs)}
}

func (h *handlerComContext) WithGroup(nome string) slog.Handler {
    return &handlerComContext{Handler: h.Handler.WithGroup(nome)}
}

func novoLoggerComContext() *slog.Logger {
    handler := &handlerComContext{
        Handler: slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
            Level: slog.LevelDebug,
        }),
    }
    return slog.New(handler)
}

func main() {
    logger := novoLoggerComContext()

    // Sem request ID no context
    ctx := context.Background()
    logger.InfoContext(ctx, "sem contexto")

    // Com request ID no context
    ctxComID := context.WithValue(ctx, chaveRequestID{}, "req-xyz-789")
    logger.InfoContext(ctxComID, "com request ID")
    logger.WarnContext(ctxComID, "aviso com request ID", "codigo", 429)
    logger.With("componente", "cache").InfoContext(ctxComID, "ainda com request ID")
}

Zerolog: performance extrema

O Zerolog está entre as bibliotecas de logging mais rápidas do ecossistema Go. A API encadeada escreve o JSON direto num buffer reaproveitado, sem mapas nem conversões para any, e um log abaixo do nível configurado retorna logo no início: no benchmark mais adiante, um Debug desabilitado custou menos de 5 ns, sem alocação nenhuma.

go get github.com/rs/zerolog
go get github.com/rs/zerolog/log
package main

import (
    "fmt"
    "os"
    "time"

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

func main() {
    // Configuração global
    zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
    zerolog.SetGlobalLevel(zerolog.InfoLevel)

    // Logger JSON para produção
    logger := zerolog.New(os.Stdout).
        With().
        Timestamp().
        Str("serviço", "api").
        Str("versao", "2.0.0").
        Logger()

    // Substituir o logger global
    log.Logger = logger

    // Logging básico
    log.Info().Msg("aplicação iniciada")
    log.Warn().
        Str("componente", "cache").
        Int("tamanho_atual", 950).
        Int("limite", 1000).
        Msg("cache próximo do limite")

    log.Error().
        Err(fmt.Errorf("conexão recusada")).
        Str("host", "banco.interno").
        Int("tentativas", 3).
        Msg("falha ao conectar ao banco")

    // Debug — zero alocações se nível for Info ou superior
    log.Debug().
        Str("query", "SELECT * FROM usuarios").
        Dur("duração", 45*time.Millisecond).
        Msg("query executada")
}

Zerolog: API completa de tipos

logger := zerolog.New(os.Stdout).With().Timestamp().Logger()

// Tipos disponíveis na API de encadeamento
logger.Info().
    Str("texto", "valor").
    Strs("lista", []string{"a", "b", "c"}).
    Int("inteiro", 42).
    Int64("int64", 9223372036854775807).
    Float64("float", 3.14159).
    Bool("booleano", true).
    Dur("duracao", 150*time.Millisecond).
    Time("momento", time.Now()).
    Err(err).                                    // campo "error"
    Interface("qualquer", meuStruct).            // qualquer tipo via JSON
    Dict("aninhado", zerolog.Dict().
        Str("chave", "valor").
        Int("numero", 1),
    ).
    Msg("log completo")

Zerolog com context

package main

import (
    "fmt"
    "net/http"
    "time"

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

func middlewareLoggingZerolog(prox http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        reqID := r.Header.Get("X-Request-ID")
        if reqID == "" {
            reqID = fmt.Sprintf("%d", time.Now().UnixNano())
        }

        // Cria logger filho com campos da requisição
        logger := log.With().
            Str("request_id", reqID).
            Str("método", r.Method).
            Str("caminho", r.URL.Path).
            Str("ip", r.RemoteAddr).
            Logger()

        // Injeta no context para uso nos handlers
        ctx := logger.WithContext(r.Context())

        inicio := time.Now()
        rw := novoResponseWriter(w) // invólucro que guarda status e bytes (não mostrado)
        prox.ServeHTTP(rw, r.WithContext(ctx))

        // Log final com status e duração
        logger.Info().
            Int("status", rw.status).
            Dur("duração", time.Since(inicio)).
            Int("bytes", rw.bytesEscritos).
            Msg("requisição concluída")
    })
}

func handler(w http.ResponseWriter, r *http.Request) {
    // Recupera o logger do context
    logger := zerolog.Ctx(r.Context())

    logger.Debug().Msg("processando handler")
    w.Write([]byte("ok"))
}

Zerolog: saída para desenvolvimento

// Console writer — saída colorida e legível para desenvolvimento
consoleWriter := zerolog.ConsoleWriter{
    Out:        os.Stdout,
    TimeFormat: "15:04:05",
    FormatLevel: func(i interface{}) string {
        switch i.(string) {
        case "debug":
            return "\033[36mDEBUG\033[0m"
        case "info":
            return "\033[32mINFO \033[0m"
        case "warn":
            return "\033[33mWARN \033[0m"
        case "error":
            return "\033[31mERROR\033[0m"
        }
        return i.(string)
    },
}

logger := zerolog.New(consoleWriter).With().Timestamp().Logger()

logger.Info().Str("componente", "servidor").Msg("iniciado")
logger.Warn().Int("conexoes", 98).Msg("conexões altas")
logger.Error().Err(errors.New("timeout")).Msg("operação falhou")

Múltiplos destinos

package main

import (
    "os"

    "github.com/rs/zerolog"
)

func criarLoggerMultiDestino(arquivoLog string) (zerolog.Logger, error) {
    arquivo, err := os.OpenFile(arquivoLog,
        os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
    if err != nil {
        return zerolog.Logger{}, err
    }

    // Escreve tanto no arquivo (JSON) quanto no terminal (texto)
    consoleWriter := zerolog.ConsoleWriter{Out: os.Stdout}
    multi := zerolog.MultiLevelWriter(consoleWriter, arquivo)

    return zerolog.New(multi).With().Timestamp().Logger(), nil
}

Rotação de logs com lumberjack

import "gopkg.in/natefinch/lumberjack.v2"

func criarLoggerComRotacao() zerolog.Logger {
    rotador := &lumberjack.Logger{
        Filename:   "/var/log/app/api.log",
        MaxSize:    100,   // MB por arquivo
        MaxBackups: 5,     // máximo de arquivos de backup
        MaxAge:     30,    // dias para manter arquivos antigos
        Compress:   true,  // comprime arquivos antigos
    }

    return zerolog.New(rotador).With().Timestamp().Logger()
}

Benchmark: slog vs zerolog vs log padrão

package benchmark_test

import (
    "io"
    "log"
    "log/slog"
    "testing"

    "github.com/rs/zerolog"
)

// Com hora, como o slog, para a comparação ser justa
var logger = zerolog.New(io.Discard).With().Timestamp().Logger()
var slogger = slog.New(slog.NewJSONHandler(io.Discard, nil))

// descarte joga fora o que recebe, mas o log não sabe disso: o pacote log
// reconhece o io.Discard e pula a formatação, o que mediria o nada
type descarte struct{}

func (descarte) Write(p []byte) (int, error) { return len(p), nil }

func BenchmarkLogPadrao(b *testing.B) {
    l := log.New(descarte{}, "", log.LstdFlags)
    for i := 0; i < b.N; i++ {
        l.Printf("mensagem %d", i)
    }
}

func BenchmarkSlog(b *testing.B) {
    for i := 0; i < b.N; i++ {
        slogger.Info("mensagem", "i", i, "componente", "benchmark")
    }
}

func BenchmarkZerolog(b *testing.B) {
    for i := 0; i < b.N; i++ {
        logger.Info().Int("i", i).Str("componente", "benchmark").Msg("mensagem")
    }
}

func BenchmarkZerologDesabilitado(b *testing.B) {
    l := zerolog.New(io.Discard).Level(zerolog.Disabled)
    for i := 0; i < b.N; i++ {
        l.Debug().Int("i", i).Msg("nunca escrito")
        // ~0 ns/op — zero alocações
    }
}

Resultados medidos com Go 1.27 e Zerolog 1.35, num Ryzen 7 3700X:

BenchmarkLogPadrao-16              4791052     256.2 ns/op    8 B/op    0 allocs/op
BenchmarkSlog-16                   1452924     861.3 ns/op    8 B/op    0 allocs/op
BenchmarkZerolog-16                4879773     242.8 ns/op    0 B/op    0 allocs/op
BenchmarkZerologDesabilitado-16  235440981     4.910 ns/op    0 B/op    0 allocs/op

Os números mudam de máquina para máquina, mas as proporções se mantêm: o Zerolog grava um log JSON em cerca de um terço do tempo do JSONHandler do slog, e um log desabilitado custa poucos nanossegundos nos dois. O log padrão aparece rápido, mas grava uma linha de texto sem estrutura, e não é comparável. Com log.SetOutput(io.Discard), como em muitos exemplos, ele marcou 12,5 ns: o pacote nem formata a mensagem quando sabe que ela será descartada. Para a maioria dos serviços, centenas de nanossegundos por log não fazem diferença; o custo só pesa quando se loga muito por requisição.

Quando usar cada um

Situação Recomendação
Projeto novo sem dependências extras log/slog
API com alta carga e latência crítica Zerolog
Biblioteca que outros projetos vão importar log/slog: quem importa escolhe o handler
CLI ou ferramenta simples log/slog com TextHandler
Microsserviço com OpenTelemetry log/slog, pela ponte otelslog do OpenTelemetry
Projeto legado com log padrão Migrar para log/slog gradualmente

Log estruturado troca a frase solta por campos que uma ferramenta consegue filtrar, e em Go isso já vem na biblioteca padrão com o log/slog. O Zerolog é a alternativa quando o custo por log importa, com cerca de um terço do tempo do JSONHandler. Nos dois, os cuidados são parecidos: o nível padrão esconde o Debug, uma duração vira nanossegundos no JSON, e um handler customizado precisa sobreviver ao With e ao WithGroup. E benchmark de logging só vale com uma saída que se comporte como a de verdade, porque o io.Discard faz o log padrão pular a formatação e medir o nada.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Na primeira versão do handlerComContext, só com o método Handle, os logs feitos direto pelo logger traziam o request_id. Mas um serviço criava log := logger.With("componente", "cache") e, a partir daí, nenhum log do cache tinha o request_id, mesmo recebendo o contexto certo. Por quê?

Ver resposta

✓ Resposta: A struct embute slog.Handler, e por isso herda todos os métodos da interface, inclusive WithAttrs e WithGroup. O logger.With chama o WithAttrs do handler para criar o handler do novo logger, e o método herdado é o do JSONHandler interno, que devolve um JSONHandler novo, sem o invólucro em volta. O logger derivado passa a escrever direto no JSONHandler, e o Handle customizado nunca mais é chamado. Num teste, o log direto saiu com "request_id":"req-1", e os logs depois de With e de WithGroup saíram sem ele, sem erro nenhum. A correção é implementar os dois métodos para embrulhar de novo o handler que o interno devolve, como o artigo passou a fazer; com eles, o log feito por logger.With("componente", "cache") manteve o request_id. A regra vale para qualquer tipo que embute uma interface e sobrescreve só parte dela: os métodos não sobrescritos ignoram o invólucro.

Exercício 2

Um colega roda o benchmark com o log padrão usando log.SetOutput(io.Discard) e conclui que ele é 60 vezes mais rápido que o slog: 12,5 ns contra 830 ns por log. Por que a conclusão está errada?

Ver resposta

✓ Resposta: O pacote log verifica se a saída é o io.Discard e, quando é, retorna sem formatar a mensagem, já que ela seria jogada fora de qualquer jeito. O benchmark mediu só essa verificação. Num teste, o mesmo Printf com io.Discard marcou 12,5 ns; com uma saída própria que também descarta os bytes, mas que o pacote não reconhece, marcou 256 ns, perto dos 243 ns do Zerolog com hora. Um benchmark precisa exercitar o caminho que roda em produção, e otimizações para casos especiais, como esta, são justamente o que ele não pode atingir por acidente. Mesmo corrigido, o log grava uma linha de texto sem campos, enquanto o slog e o Zerolog escrevem JSON: a comparação útil é entre os dois loggers estruturados.

Exercício 3

O painel de observabilidade mostra o campo timeout com o valor 30000000000, e alguém abre um chamado porque o serviço estaria configurado com um prazo de quase mil anos. O log vinha de slog.Duration("timeout", 30*time.Second) no JSONHandler. O que aconteceu, e como registrar durações?

Ver resposta

✓ Resposta: Um time.Duration é um inteiro de nanossegundos, e o JSONHandler grava o número cru, sem unidade: num teste, os 30 segundos saíram como "timeout":30000000000. O TextHandler, por outro lado, usa o método String e escreveria 30s, de modo que o mesmo código produz campos diferentes conforme o handler. O valor está correto, mas quem lê o painel não tem como saber a unidade. As saídas são incluir a unidade no nome e converter, como slog.Int64("timeout_ms", d.Milliseconds()), que mantém o campo numérico para filtros e gráficos, ou gravar o texto, com slog.String("timeout", d.String()), que é legível mas não se presta a comparações. O Zerolog tem uma convenção própria: Dur grava em milissegundos por padrão, controlado por zerolog.DurationFieldUnit. O importante é que a equipe escolha uma e a siga em todos os serviços.

Exercício 4

Ao copiar o exemplo de rotação de logs de uma versão antiga deste artigo, o go mod tidy falha com GitHub repository not found at https://github.com/natefinish/lumberjack. O nome do pacote parece certo à primeira vista. O que está errado, e por que esse tipo de erro é perigoso além de irritante?

Ver resposta

✓ Resposta: O autor do lumberjack é Nate Finch, e o caminho correto é gopkg.in/natefinch/lumberjack.v2, com finch; o exemplo antigo trazia natefinish. O gopkg.in redireciona para o GitHub, e o usuário natefinish não tem esse repositório, daí a mensagem. Aqui o erro é inofensivo, porque o pacote não existe e o build para. O perigo é o caso em que o nome errado existe: alguém pode registrar um repositório com um nome parecido com o de uma biblioteca popular e publicar código malicioso, à espera de quem digite errado. O Go oferece duas proteções: o go.sum fixa o conteúdo de cada versão já baixada, e o banco de checksums público, consultado por padrão, impede que uma versão publicada seja trocada. Nenhuma das duas impede que se escolha o pacote errado na primeira vez, e por isso vale conferir o caminho no pkg.go.dev antes de acrescentar uma dependência.

Exercício 5

Em desenvolvimento, alguém espalha slog.Debug pelo código para investigar um defeito, mas nenhuma das mensagens aparece. O programa usa slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))). O que falta, e como mudar o nível sem reiniciar o serviço?

Ver resposta

✓ Resposta: Com as opções nil, o handler usa o nível padrão, Info, e descarta tudo o que está abaixo dele. Num teste, uma chamada a slog.Debug depois de logs de nível Info não produziu saída nenhuma. A correção imediata é passar &slog.HandlerOptions{Level: slog.LevelDebug}, como faz o configurarLogger do artigo para o ambiente de desenvolvimento. Para mudar o nível com o serviço rodando, o campo Level aceita um *slog.LevelVar em vez de um valor fixo: o handler consulta a variável a cada log, e uma chamada a nivel.Set(slog.LevelDebug), disparada por um endpoint administrativo ou por um sinal, liga o Debug na hora. O custo de deixar as chamadas no código é pequeno quando o nível está desligado, mas os argumentos continuam sendo avaliados: um slog.Debug("x", "dados", calcularAlgoCaro()) executa a função mesmo sem gravar nada.

Comentários

Mais em Golang

O ecossistema Go: comunidade, carreira e próximos passos
O ecossistema Go: comunidade, carreira e próximos passos

O ecossistema Go hoje: do Go 1.18 ao 1.27, com iteradores, omitzero…

Go Modules: go.mod, go.sum e gerenciamento de dependências
Go Modules: go.mod, go.sum e gerenciamento de dependências

Go Modules: go.mod e go.sum, go get e go mod tidy, versionamento semântico e o…

Domain-Driven Design aplicado a projetos Go
Domain-Driven Design aplicado a projetos Go

Domain-Driven Design em Go: linguagem compartilhada, entidades com eventos…