94/100 Dias de Golang - Logging estruturado com log/slog
Table of Contents
#
Logging estruturado com log/slog
No dia 38 usamos o pacote log e no dia 39 o Zap. O log é simples, mas só imprime texto. O Zap faz logging estruturado e é muito rápido, mas é uma dependência externa. Desde o Go 1.21, a biblioteca padrão tem uma terceira opção que fica no meio do caminho: o pacote log/slog (o “s” é de structured).
#
Por que logging estruturado?
Compare esses dois logs:
2025/12/11 10:00:00 pagamento do pedido ped-981 aprovado em 230ms para o usuário 42
{"time":"2025-12-11T10:00:00Z","level":"INFO","msg":"pagamento aprovado","pedido_id":"ped-981","duracao_ms":230,"usuario_id":42}
Para uma pessoa, os dois são fáceis de ler. Mas imagine procurar todos os pagamentos de um usuário que demoraram mais de 1 segundo entre milhões de linhas. No primeiro formato é preciso escrever uma regex e torcer para que todo mundo tenha escrito a mensagem no mesmo formato. No segundo, cada informação é um campo com nome, e ferramentas como Grafana Loki, Elasticsearch, Datadog ou CloudWatch conseguem filtrar, agrupar e criar gráficos direto: usuario_id = 42 AND duracao_ms > 1000.
Logging estruturado é isso: em vez de montar uma frase, registramos uma mensagem fixa + pares de chave e valor.
#
O básico
O slog tem funções para cada nível de log: Debug, Info, Warn e Error. O primeiro argumento é a mensagem e o resto são pares chave/valor:
package main
import (
"errors"
"log/slog"
)
func main() {
slog.Info("servidor iniciado", "porta", 8080, "ambiente", "dev")
slog.Warn("uso de memória alto", "uso_mb", 512)
slog.Error("falha ao conectar no banco", "tentativa", 3, "erro", errors.New("connection refused"))
slog.Debug("isso não aparece")
}
Saída:
2025/12/11 10:00:00 INFO servidor iniciado porta=8080 ambiente=dev
2025/12/11 10:00:00 WARN uso de memória alto uso_mb=512
2025/12/11 10:00:00 ERROR falha ao conectar no banco tentativa=3 erro="connection refused"
Repare em duas coisas: a mensagem continua igual em todos os casos, e os dados variáveis viraram campos. E o Debug não apareceu, porque o nível padrão é Info.
#
Handlers: texto ou JSON
Quem decide como o log é escrito é o handler. O slog vem com dois:
package main
import (
"log/slog"
"os"
)
func main() {
text := slog.New(slog.NewTextHandler(os.Stdout, nil))
text.Info("usuário logado", "usuario_id", 42, "metodo", "senha")
opts := &slog.HandlerOptions{
Level: slog.LevelDebug,
AddSource: true,
}
json := slog.New(slog.NewJSONHandler(os.Stdout, opts))
json.Debug("consultando cache", "chave", "usuario:42")
json.Info("usuário logado", "usuario_id", 42, "metodo", "senha")
}
Saída:
time=2025-12-11T10:00:00.553-03:00 level=INFO msg="usuário logado" usuario_id=42 metodo=senha
{"time":"2025-12-11T10:00:00.553714078-03:00","level":"DEBUG","source":{"function":"main.main","file":"/home/victor/app/main.go","line":17},"msg":"consultando cache","chave":"usuario:42"}
{"time":"2025-12-11T10:00:00.553728134-03:00","level":"INFO","source":{"function":"main.main","file":"/home/victor/app/main.go","line":18},"msg":"usuário logado","usuario_id":42,"metodo":"senha"}
TextHandler: formatochave=valor, bom para ler no terminal durante o desenvolvimento;JSONHandler: uma linha de JSON por log, o formato que as ferramentas de observabilidade esperam em produção.
O HandlerOptions configura o handler. Aqui usamos Level: slog.LevelDebug para mostrar os logs de debug e AddSource: true para incluir o arquivo e a linha que geraram o log (como o log.Lshortfile do dia 38). Um padrão comum é escolher o handler pela variável de ambiente: TextHandler em desenvolvimento, JSONHandler em produção.
#
Atributos fixos com With
Muitas vezes vários logs compartilham as mesmas informações: o nome do serviço, a versão, o ID do pedido sendo processado… Em vez de repetir em todas as chamadas, o With cria um novo logger que já carrega esses atributos:
package main
import (
"context"
"log/slog"
"os"
"time"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// atributos que vão aparecer em todos os logs desse logger
logger = logger.With("servico", "api-pagamentos", "versao", "1.4.2")
pedidoLogger := logger.With("pedido_id", "ped-981")
pedidoLogger.Info("pagamento iniciado")
pedidoLogger.Info("pagamento aprovado", "duracao", 230*time.Millisecond)
logger.Info("requisição recebida",
slog.Group("http",
slog.String("metodo", "POST"),
slog.String("rota", "/pagamentos"),
slog.Int("status", 201),
),
)
logger.LogAttrs(context.Background(), slog.LevelInfo, "cache atualizado",
slog.Int("itens", 1500),
slog.Bool("parcial", false),
)
}
Saída:
{"time":"2025-12-11T10:00:00.631251206-03:00","level":"INFO","msg":"pagamento iniciado","servico":"api-pagamentos","versao":"1.4.2","pedido_id":"ped-981"}
{"time":"2025-12-11T10:00:00.631308823-03:00","level":"INFO","msg":"pagamento aprovado","servico":"api-pagamentos","versao":"1.4.2","pedido_id":"ped-981","duracao":230000000}
{"time":"2025-12-11T10:00:00.631311478-03:00","level":"INFO","msg":"requisição recebida","servico":"api-pagamentos","versao":"1.4.2","http":{"metodo":"POST","rota":"/pagamentos","status":201}}
{"time":"2025-12-11T10:00:00.631313612-03:00","level":"INFO","msg":"cache atualizado","servico":"api-pagamentos","versao":"1.4.2","itens":1500,"parcial":false}
Tem bastante coisa nesse exemplo:
Withnão altera o logger original, ele retorna um novo. OpedidoLoggertemservico,versaoepedido_id; ologgercontinua só com os dois primeiros.slog.Groupagrupa atributos relacionados. No JSON eles viram um objeto aninhado ("http":{...}); noTextHandlerviramhttp.metodo=POST http.rota=/pagamentos.time.Durationno JSON aparece em nanossegundos (230000000). Se preferir algo mais legível, logue"duracao_ms", d.Milliseconds().slog.String,slog.Int,slog.Bool… são os atributos tipados. Passar"chave", valoré mais curto, mas se você esquecer um valor (slog.Info("msg", "porta")), o slog imprime!BADKEY=porta. Com os atributos tipados esse erro nem compila. Ogo vettambém avisa sobre pares chave/valor quebrados.LogAttrsaceita só atributos tipados e é a forma mais eficiente de logar, porque evita alocações. Vale para trechos de código executados com muita frequência.
#
Escondendo dados sensíveis com LogValuer
Um problema clássico: alguém loga uma struct inteira e, junto, vai a senha do usuário para o sistema de logs. O slog tem a interface LogValuer, que permite a um tipo decidir como ele aparece no log:
package main
import (
"log/slog"
"os"
)
type Usuario struct {
ID int
Email string
Senha string
}
func (u Usuario) LogValue() slog.Value {
return slog.GroupValue(
slog.Int("id", u.ID),
slog.String("email", u.Email),
)
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
u := Usuario{ID: 42, Email: "victor@exemplo.com", Senha: "super-secreta"}
logger.Info("usuário criado", "usuario", u)
}
Saída:
{"time":"2025-12-11T10:00:00.713138315-03:00","level":"INFO","msg":"usuário criado","usuario":{"id":42,"email":"victor@exemplo.com"}}
A senha não aparece. E como a regra está no próprio tipo, ela vale para todos os lugares que logarem um Usuario, sem depender de cada pessoa lembrar de tirar a senha.
#
Context e request ID
Esse é, para mim, o recurso mais útil. Num servidor HTTP, cada requisição gera vários logs em funções diferentes: o handler, o serviço, o repositório… Para conseguir juntar todos os logs de uma mesma requisição, todos precisam ter o mesmo request ID.
Passar o logger como parâmetro para todas as funções é chato. Mas, como vimos no dia 89, o context já é passado para todas elas! Todas as funções de log têm uma versão que recebe context (InfoContext, ErrorContext…) e, com um handler customizado, podemos pegar o request ID do context automaticamente:
package main
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"os"
)
type chave string
const requestIDKey chave = "requestID"
// ContextHandler adiciona o request ID guardado no context em todos os logs
type ContextHandler struct {
slog.Handler
}
func (h ContextHandler) Handle(ctx context.Context, r slog.Record) error {
if id, ok := ctx.Value(requestIDKey).(string); ok {
r.AddAttrs(slog.String("request_id", id))
}
return h.Handler.Handle(ctx, r)
}
func (h ContextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return ContextHandler{h.Handler.WithAttrs(attrs)}
}
func (h ContextHandler) WithGroup(name string) slog.Handler {
return ContextHandler{h.Handler.WithGroup(name)}
}
func requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = "gerado-123"
}
ctx := context.WithValue(r.Context(), requestIDKey, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func buscarUsuario(ctx context.Context, id int) {
logger := slog.With("camada", "repositorio")
logger.InfoContext(ctx, "buscando usuário no banco", "usuario_id", id)
}
func usuarioHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
slog.InfoContext(ctx, "requisição recebida", "rota", r.URL.Path)
buscarUsuario(ctx, 42)
w.WriteHeader(http.StatusOK)
}
func main() {
handler := ContextHandler{slog.NewJSONHandler(os.Stdout, nil)}
slog.SetDefault(slog.New(handler))
mux := http.NewServeMux()
mux.HandleFunc("/usuarios/42", usuarioHandler)
app := requestIDMiddleware(mux)
// simulando duas requisições sem subir um servidor de verdade
req := httptest.NewRequest(http.MethodGet, "/usuarios/42", nil)
req.Header.Set("X-Request-ID", "abc-789")
app.ServeHTTP(httptest.NewRecorder(), req)
req = httptest.NewRequest(http.MethodGet, "/usuarios/42", nil)
app.ServeHTTP(httptest.NewRecorder(), req)
}
Saída:
{"time":"2025-12-11T10:00:00.078719467-03:00","level":"INFO","msg":"requisição recebida","rota":"/usuarios/42","request_id":"abc-789"}
{"time":"2025-12-11T10:00:00.07875859-03:00","level":"INFO","msg":"buscando usuário no banco","camada":"repositorio","usuario_id":42,"request_id":"abc-789"}
{"time":"2025-12-11T10:00:00.07876406-03:00","level":"INFO","msg":"requisição recebida","rota":"/usuarios/42","request_id":"gerado-123"}
{"time":"2025-12-11T10:00:00.078766164-03:00","level":"INFO","msg":"buscando usuário no banco","camada":"repositorio","usuario_id":42,"request_id":"gerado-123"}
Como funciona:
- O middleware lê o header
X-Request-ID(ou gera um ID, aqui fixo para simplificar; na vida real seria um UUID) e guarda no context da requisição; - As funções logam com
InfoContext(ctx, ...), passando o context adiante; - O
ContextHandler“embrulha” oJSONHandler. Antes de repassar cada log, ele procura o request ID no context e adiciona como atributo.
Nenhuma função precisou saber do request ID, e mesmo assim todos os logs de uma requisição podem ser encontrados com um único filtro.
Um detalhe importante: o ContextHandler precisa implementar WithAttrs e WithGroup. Como ele embute o slog.Handler, sem esses métodos seriam usados os do JSONHandler, que retornam um JSONHandler puro, e o nosso handler sairia da jogada. Testei sem eles: o log do buscarUsuario, que usa slog.With, saiu sem o request_id.
Para não subir um servidor de verdade, usei o httptest para simular as requisições: o NewRequest cria uma requisição e o NewRecorder faz o papel do ResponseWriter. É o mesmo pacote que usamos para testar handlers HTTP.
#
Logger padrão e nível dinâmico
O slog.SetDefault, usado no exemplo anterior, define o logger usado pelas funções do pacote (slog.Info, slog.Error…). E tem um bônus: ele também redireciona o pacote log antigo para o novo handler. Assim, bibliotecas que ainda usam log.Println passam a sair no mesmo formato que o resto da aplicação.
Outro recurso útil é o slog.LevelVar, que permite mudar o nível de log com a aplicação rodando. Por exemplo, ativar os logs de debug por alguns minutos para investigar um problema em produção, sem precisar fazer deploy:
package main
import (
"log"
"log/slog"
"os"
)
func main() {
nivel := new(slog.LevelVar) // começa em Info
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: nivel}))
slog.SetDefault(logger)
slog.Debug("debug 1: não aparece")
nivel.Set(slog.LevelDebug)
slog.Debug("debug 2: agora aparece")
log.Println("mensagem do pacote log antigo")
}
Saída:
time=2025-12-11T10:00:00.947-03:00 level=DEBUG msg="debug 2: agora aparece"
time=2025-12-11T10:00:00.947-03:00 level=INFO msg="mensagem do pacote log antigo"
O primeiro debug foi descartado, o segundo apareceu depois do nivel.Set, e o log.Println saiu formatado pelo TextHandler, com nível INFO. Na prática, o nivel.Set ficaria num endpoint administrativo ou seria disparado por um sinal do sistema operacional.
#
slog ou Zap?
- slog: está na biblioteca padrão, não adiciona dependências e cobre a maioria dos casos. Para projetos novos, é o meu ponto de partida;
- Zap (e outros como zerolog): ainda são mais rápidos e têm mais recursos prontos, como rotação de arquivos e amostragem de logs. Vale a pena quando o volume de logs é muito alto.
E dá para ter os dois: como o slog separa a API (o Logger) da escrita (o Handler), o próprio Zap fornece um handler para slog, o zapslog. O código da aplicação usa a API do slog e o Zap faz a escrita por baixo. Se um dia quiser trocar, muda só uma linha.