Pular para o conteúdo principal

victorstein.dev

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: formato chave=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:

  • With não altera o logger original, ele retorna um novo. O pedidoLogger tem servico, versao e pedido_id; o logger continua só com os dois primeiros.
  • slog.Group agrupa atributos relacionados. No JSON eles viram um objeto aninhado ("http":{...}); no TextHandler viram http.metodo=POST http.rota=/pagamentos.
  • time.Duration no 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. O go vet também avisa sobre pares chave/valor quebrados.
  • LogAttrs aceita 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:

  1. 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;
  2. As funções logam com InfoContext(ctx, ...), passando o context adiante;
  3. O ContextHandler “embrulha” o JSONHandler. 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.

# Referências

  1. Documentação log/slog
  2. Structured Logging with slog
  3. A Guide to Writing slog Handlers