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
-
Documentação log/slog — Referência oficial do pacote. https://pkg.go.dev/log/slog
-
Go Blog: Structured Logging with slog — Introdução oficial ao slog. https://go.dev/blog/slog
-
rs/zerolog — Repositório e documentação do Zerolog. https://github.com/rs/zerolog
-
lumberjack — Rotação de logs para Go. https://pkg.go.dev/gopkg.in/natefinch/lumberjack.v2
-
OpenTelemetry Go — Integração de logs com traces e métricas. https://opentelemetry.io/docs/languages/go/
-
Elastic Common Schema — Convenção de campos para logs estruturados. https://www.elastic.co/guide/en/ecs/current/index.html
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.