Logs Estruturados em Go com slog para Observabilidade e Alertas

Logs JSON consultáveis que se conectam a rastros.

Conteúdo da página

Logs são uma interface de depuração que você ainda pode usar quando o sistema está em chamas. O problema é que logs em texto puro envelhecem mal: assim que você precisa de filtragem, agregação e alertas, começa a analisar sentenças.

ambiente de trabalho com laptop e mascotes Go para melhor logamento

O logamento estruturado é o antídoto. Ele transforma cada linha de log em um pequeno evento com campos estáveis, permitindo que ferramentas pesquisem e agreguem de forma confiável. Para entender como os logs se conectam a métricas, painéis e alertas na stack mais ampla, veja o Guia de Observabilidade: Monitoramento, Métricas, Prometheus e Grafana.

O que é logamento estruturado e por que ele escala

Logamento estruturado é um logamento onde um registro não é apenas uma string, mas uma mensagem mais atributos de chave-valor tipados. A ideia é entediante da melhor forma possível: assim que os logs são legíveis por máquina, um incidente deixa de ser uma competição de grep.

Uma comparação rápida:

Texto puro (focado no humano, hostil às ferramentas)

falha ao cobrar cartão user=42 amount=19.99 ms=842 err=timeout

Estruturado (focado na ferramenta, ainda legível)

{"msg":"falha ao cobrar cartão","user_id":42,"amount":19.99,"duration_ms":842,"error":"timeout"}

Em produção, ajuda pensar nos logs como um fluxo de eventos emitido pelo processo, enquanto o roteamento e o armazenamento vivem fora da aplicação. Esse modelo mental leva você a escrever um evento por linha e manter os eventos fáceis de enviar e reprocessar.

Slog no Go como uma interface de logamento compartilhada

O Go tem o pacote log clássico desde sempre, mas serviços modernos precisam de níveis e campos. O pacote log/slog (Go 1.21 e posterior) traz o logamento estruturado para a biblioteca padrão e formaliza uma forma comum para registros de log: tempo, nível, mensagem e atributos. Para uma revisão compacta de linguagem e comandos junto com este guia, veja o Cheat Sheet do Go.

As partes principais do modelo são:

Registro

Um registro é o que aconteceu. Em termos de slog, ele contém tempo, nível, mensagem e um conjunto de atributos. Você cria registros via métodos como Info e Error, ou via Log quando quer fornecer o nível explicitamente.

Atributos

Atributos são os pares chave-valor que tornam os logs pesquisáveis. Se você registrar o mesmo conceito sob três chaves diferentes (user, userId, uid), terá três conjuntos de dados diferentes. Chaves consistentes é onde o valor real se esconde.

Handler (Processador)

Um handler é como os registros se tornam bytes. O TextHandler nativo escreve saída key=value, enquanto o JSONHandler escreve JSON delimitado por linha. Handlers também são onde a redação, renomeação de chaves e roteamento de saída tendem a acontecer.

Um recurso pouco valorizado é que o slog pode ficar na frente de código existente. Quando você define um logger slog padrão, as funções slog de nível superior o usam, e o pacote log clássico também pode ser redirecionado para ele. Isso torna a migração incremental possível.

Grupos

Grupos resolvem o problema de “cada subsistema usa id”. Você pode agrupar um conjunto de atributos para uma solicitação (request.method, request.path) ou namespacar um subsistema inteiro com WithGroup para que as chaves não colidam.

Uma configuração de slog moldada para produção

A configuração a seguir atinge os objetivos habituais. Os exemplos usam um pequeno pacote logx; para saber onde pacotes como esse geralmente vivem em um módulo real, veja Estrutura de Projeto Go: Práticas & Padrões.

  • um evento JSON por linha
  • logs escritos para stdout para coleta
  • metadados estáveis do serviço anexados uma vez
  • logamento consciente do contexto para IDs de solicitação e rastreamento
  • redação centralizada para chaves sensíveis
package logx

import (
	"log/slog"
	"os"
)

var level slog.LevelVar // padrão é INFO

func New() *slog.Logger {
	opts := &slog.HandlerOptions{
		Level:     &level, // pode ser alterado em tempo de execução
		AddSource: true,   // inclui arquivo e linha quando disponível
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			// Redação centralizada: consistente e difícil de contornar por acidente.
			switch a.Key {
			case "password", "token", "authorization", "api_key":
				return slog.String(a.Key, "[redacted]")
			}
			return a
		},
	}

	h := slog.NewJSONHandler(os.Stdout, opts)

	return slog.New(h).With(
		"service", os.Getenv("SERVICE_NAME"),
		"env", os.Getenv("ENV"),
		"version", os.Getenv("VERSION"),
	)
}

func SetLevel(l slog.Level) { level.Set(l) }

Um detalhe minúsculo com grandes consequências: o handler JSON nativo usa chaves padrão (time, level, msg, source). Quando seu backend de log espera um esquema diferente, ReplaceAttr é a válvula de alívio que permite normalizar chaves sem reescrever os locais de chamada.

O esquema importa mais que o logger

A maioria das falhas de “logamento estruturado” são falhas de esquema.

Campos essenciais que continuam pagando o aluguel

Todo backend de log armazenará um carimbo de data/hora, nível e mensagem. Na prática, um esquema de aplicativo útil geralmente adiciona um pequeno conjunto de campos estáveis:

  • service, env, version
  • component (ou subsystem)
  • event (um nome estável para a coisa que aconteceu)
  • request_id (quando uma solicitação existe)
  • trace_id e span_id (quando o rastreamento existe)
  • error (string) e error_kind (cesta estável)

Observe o padrão: esses campos respondem a perguntas operacionais, não à curiosidade do desenvolvedor.

Convenções semânticas são um hack barato de consistência

Se você já usa OpenTelemetry, suas convenções semânticas fornecem um vocabulário padrão para atributos em todos os sinais de telemetria. Mesmo que você não exporte logs via OpenTelemetry, emprestar nomes de atributos reduz o imposto “como chamamos esse campo no serviço B”.

Alta cardinalidade e por que logs ficam caros

Alta cardinalidade significa “muitos valores únicos”. Está bem dentro de um payload JSON, mas torna-se doloroso quando um backend trata alguns campos como rótulos indexados ou chaves de fluxo. IDs de usuário, endereços IP, tokens de solicitação aleatórios e URLs completos tendem a explodir combinações.

O resultado prático é simples: mantenha rótulos e chaves de índice entediantes (service, environment, region), e mantenha campos de alta cardinalidade dentro do payload estruturado para filtragem no momento da consulta.

Correlação com IDs de solicitação e rastreamentos

A correlação é o ponto onde os logs deixam de ser apenas texto e começam a se comportar como telemetria.

Request ID como a chave de correlação de menor atrito

Um Request ID é a ponte mais simples entre uma solicitação entrante e tudo o que acontece por causa dela. Ele tende a funcionar mesmo sem rastreamento distribuído, e ainda é útil quando os rastros são amostrados.

Um padrão comum é anexar um logger por solicitação ao contexto:

package logx

import (
	"context"
	"log/slog"
)

type ctxKey struct{}

func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
	return context.WithValue(ctx, ctxKey{}, l)
}

func FromContext(ctx context.Context) *slog.Logger {
	if l, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok && l != nil {
		return l
	}
	return slog.Default()
}

Correlação de rastreamento com W3C Trace Context e OpenTelemetry

W3C Trace Context define uma maneira padrão de propagar identidade de rastro (para HTTP, via traceparent e tracestate). OpenTelemetry constrói sobre isso para que trace IDs e span IDs possam ser extraídos do contexto.

Este exemplo de middleware registra request_id e identificadores de rastro quando disponíveis:

package middleware

import (
	"crypto/rand"
	"encoding/hex"
	"net/http"

	"go.opentelemetry.io/otel/trace"
	"log/slog"

	"example.com/project/logx"
)

func requestID() string {
	var b [16]byte
	_, _ = rand.Read(b[:])
	return hex.EncodeToString(b[:])
}

func WithRequestLogger(base *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			rid := r.Header.Get("X-Request-Id")
			if rid == "" {
				rid = requestID()
			}

			l := base.With(
				"request_id", rid,
				"method", r.Method,
				"path", r.URL.Path,
			)

			if sc := trace.SpanContextFromContext(r.Context()); sc.IsValid() {
				l = l.With(
					"trace_id", sc.TraceID().String(),
					"span_id", sc.SpanID().String(),
				)
			}

			ctx := logx.WithLogger(r.Context(), l)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

Uma vez que os campos de correlação existem, a linha de log torna-se um índice para outros dados. A diferença em um incidente ao vivo não é sutil.

Transformando logs estruturados em sinais de monitoramento e alerta

Logs são ótimos para responder “o que aconteceu”. Alertas geralmente são sobre “com que frequência e quão grave”.

Uma abordagem prática é tratar certos eventos de log como contadores:

  • event=payment_failed
  • event=db_timeout
  • event=cache_miss

Muitas plataformas podem derivar métricas baseadas em logs contando registros correspondentes em uma janela. Logs estruturados tornam essa contagem resiliente, porque é baseada em um valor de campo em vez de uma correspondência de texto frágil. Quando você estiver pronto para visualizar e explorar esses sinais, Instalar e Usar Grafana no Ubuntu: Guia Completo percorre uma configuração completa do Grafana que você pode apontar para backends de log e métricas comuns.

É também onde os níveis de log começam a importar. Logs de Debug são frequentemente valiosos, mas também é onde custo e ruído se escondem. Usar um nível dinâmico (LevelVar) permite que o sistema permaneça silencioso por padrão, enquanto ainda permite detalhes direcionados quando necessário.

Pensamentos finais

Logamento estruturado no Go não é mais um debate de biblioteca. A parte interessante é se seus registros de log são consistentes, correlacionáveis e baratos de armazenar.

Quando seus logs transportam campos estáveis como event, request_id e trace_id, eles deixam de ser “strings que alguém escreveu” e começam a ser um conjunto de dados que você pode operar.

Notas

A equipe do Go introduziu log/slog no Go 1.21 e enfatizou que logs estruturados usam pares chave-valor para que possam ser analisados, filtrados, pesquisados e analisados de forma confiável, e também notou a motivação de fornecer um framework comum compartilhado em todo o ecossistema.

A documentação do pacote log/slog define o modelo de registro (tempo, nível, mensagem, pares chave-valor) e os handlers nativos (TextHandler para key=value e JSONHandler para JSON delimitado por linha), e documenta a integração SetDefault com o pacote log clássico.

Para correlação distribuída, a especificação W3C Trace Context padroniza a propagação de traceparent e tracestate, e o OpenTelemetry especifica que seu SpanContext está em conformidade com o W3C Trace Context e expõe TraceId e SpanId, tornando a correlação log-rastro direta quando um span está presente.

Para custo e desempenho de armazenamento de log, a documentação do Grafana Loki recomenda fortemente rótulos limitados e estáticos e alerta sobre rótulos de alta cardinalidade criando muitos fluxos e um índice enorme, o que é diretamente relevante ao decidir o que se torna um rótulo e o que permanece como um campo JSON não indexado.