🧠FFVAcademy
📝

Logs Estruturados: JSON, correlation IDs e levels com propósito

14 min de leitura·+70 XP
Pré-requisitos (0/1)0%

Recomendamos completar os pré-requisitos antes de seguir, mas nada te impede de continuar.

Todo sistema em produção tem logs. Poucos têm logs que realmente ajudam numa crise às 3h da manhã. A diferença entre um log inútil e um que resolve um incidente em 5 minutos não é quantidade — é estrutura. Log estruturado não é modismo: é a base de qualquer sistema de observabilidade que funciona em escala.

Neste módulo você vai entender por que JSON logs substituíram texto livre, como correlation IDs ligam logs de diferentes serviços numa mesma requisição, o que cada nível de log realmente significa (e por que 90% dos times usa WARN errado), e o custo real de formatter síncrono em hot paths.

Onde isso entra no exame e na prática

No domínio de Observabilidade & SRE, logs estruturados são o pré-requisito para tudo mais: métricas derivadas de logs, correlação com traces, alertas baseados em padrões. Sem estrutura, logs são strings opacas. Com estrutura, são dados consultáveis.

💡
Nível alvo: Você deve conseguir implementar structured logging em qualquer serviço, injetar trace_id/span_id automaticamente, e configurar o pipeline de logs para não impactar latência de produção.

Texto livre vs JSON estruturado

Considere o mesmo evento em dois formatos:

bash
# ❌ Texto livre — o que a maioria tem
2024-01-15 14:23:11 ERROR Failed to process order 12345 for user 99 after 3 retries

# ✅ JSON estruturado — o que você quer ter
{
  "timestamp": "2024-01-15T14:23:11.847Z",
  "level": "error",
  "message": "Failed to process order after retries",
  "service": "order-service",
  "version": "1.4.2",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "order_id": "12345",
  "user_id": "99",
  "retry_count": 3,
  "error": "ConnectionTimeout",
  "error_detail": "payment-service:8080 unreachable after 5000ms",
  "duration_ms": 15234
}

Com JSON, você filtra em qualquer sistema de logs (Loki, Datadog, CloudWatch):

bash
# Loki LogQL — todos os erros de payment no último 1h
{service="order-service"} | json | level="error" | error_detail =~ "payment.*"

# Busca pelo trace_id específico (correlaciona com spans no Tempo)
{service=~".+"} | json | trace_id="4bf92f3577b34da6a3ce929d0e0e4736"

# Latência P99 derivada de logs (sem Prometheus)
{service="order-service"} | json | unwrap duration_ms | p99()

Correlation IDs: amarrando tudo

Uma requisição de checkout passa por 5 serviços. Sem um identificador comum, você tem 5 grupos de logs desconexos. Com trace_id, você tem a jornada completa.


  Cliente
    │
    ▼ POST /checkout
  ┌─────────────────────────────────────────────────────────┐
  │  API Gateway                                            │
  │  Gera trace_id: "4bf92f3577b34da6a3ce929d0e0e4736"     │
  │  Injeta header: traceparent: 00-4bf9...-00f0...-01      │
  └─────────────┬───────────────────────────────────────────┘
                │ HTTP + traceparent header
    ┌───────────▼───────────┐   ┌──────────────────────┐
    │  order-service        │──▶│  payment-service      │
    │  trace_id: 4bf9...    │   │  trace_id: 4bf9...    │
    │  span_id:  00f0...    │   │  span_id:  a3ce...    │
    └───────────────────────┘   └──────────────────────┘
                │
    ┌───────────▼───────────┐   ┌──────────────────────┐
    │  inventory-service    │   │  notification-service │
    │  trace_id: 4bf9...    │   │  trace_id: 4bf9...    │
    └───────────────────────┘   └──────────────────────┘

  Resultado: filtrar trace_id="4bf9..." retorna TODOS os logs
  de TODOS os serviços para essa requisição específica

O OpenTelemetry propaga isso automaticamente via W3C Trace Context (visto no módulo anterior). O que importa aqui é que o log exija que o trace_id seja injetado no contexto de logging— não passado manualmente a cada chamada de log.

Injeção automática de trace_id no logger

A forma correta: configurar o logger para ler o span ativo do contexto OTel a cada registro. Sem poluir chamadas individuais com parâmetros extras.

python
# Python: loguru + OpenTelemetry context bridge
import logging
import json
from opentelemetry import trace
from opentelemetry.trace import format_trace_id, format_span_id

class OtelJsonFormatter(logging.Formatter):
    """Injeta trace_id e span_id do contexto OTel ativo."""

    def format(self, record: logging.LogRecord) -> str:
        ctx = trace.get_current_span().get_span_context()

        log_entry = {
            "timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
            "level": record.levelname.lower(),
            "message": record.getMessage(),
            "logger": record.name,
            "module": record.module,
            "line": record.lineno,
        }

        # Injetar trace context se span ativo
        if ctx.is_valid:
            log_entry["trace_id"] = format_trace_id(ctx.trace_id)
            log_entry["span_id"] = format_span_id(ctx.span_id)
            log_entry["trace_flags"] = ctx.trace_flags

        # Campos extras passados via extra={}
        for key, value in record.__dict__.items():
            if key not in logging.LogRecord.__dict__ and not key.startswith("_"):
                log_entry[key] = value

        # Stacktrace se exception
        if record.exc_info:
            log_entry["exception"] = self.formatException(record.exc_info)

        return json.dumps(log_entry, default=str)


# Setup — uma vez no startup
handler = logging.StreamHandler()
handler.setFormatter(OtelJsonFormatter())
logging.getLogger().addHandler(handler)
logging.getLogger().setLevel(logging.INFO)

logger = logging.getLogger(__name__)

# Uso em qualquer lugar — trace_id é injetado automaticamente
def process_order(order_id: str, user_id: str):
    logger.info(
        "Processing order",
        extra={"order_id": order_id, "user_id": user_id}
    )
    # Output: {"timestamp":"...","level":"info","message":"Processing order",
    #          "trace_id":"4bf92f...","span_id":"00f067...","order_id":"123","user_id":"99"}
typescript
// Node.js: pino + OpenTelemetry (pino é 8× mais rápido que winston)
import pino from 'pino';
import { trace, context } from '@opentelemetry/api';

// Mixin lido a cada registro — injeta trace context
const otelMixin = () => {
  const span = trace.getActiveSpan();
  if (!span) return {};

  const ctx = span.spanContext();
  return {
    trace_id: ctx.traceId,
    span_id: ctx.spanId,
    trace_flags: ctx.traceFlags,
  };
};

const logger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  mixin: otelMixin,
  timestamp: pino.stdTimeFunctions.isoTime,
  // pino serializa em JSON por default — zero config adicional
});

export { logger };

// Uso
logger.info({ order_id: '123', user_id: '99' }, 'Processing order');
// Output: {"time":"2024-01-15T14:23:11.847Z","level":"info","msg":"Processing order",
//          "trace_id":"4bf92f...","span_id":"00f067...","order_id":"123","user_id":"99"}

Log levels: o que cada um realmente significa

A maioria dos times usa log levels de forma inconsistente. Aqui está a semântica correta — testada em ambientes de produção reais:

LevelQuando usarResposta esperadaEm produção
TRACEPassos internos granulares (loop iteration, byte parsing)Nenhuma — só diagnóstico local❌ Nunca ligado
DEBUGValores de variáveis, decisões de branch, SQL geradoNenhuma — diagnóstico sob demanda⚠️ Sob demanda com sampling
INFOEvento de negócio significativo (order criada, user logado, payment aprovado)Nenhuma — operação normal✅ Sempre ligado
WARNSituação degradada mas recuperada (retry bem-sucedido, cache miss, rate limit atingido)Monitorar tendência✅ Sempre ligado + alertas de volume
ERROROperação falhou, dado pode estar perdido ou inconsistenteInvestigação imediata🚨 Alerta em qualquer ocorrência
FATAL/CRITICALSistema não consegue continuar (DB connection pool esgotado, config inválida)Restart + pager duty🚨🚨 PagerDuty imediato
⚠️
Anti-patterns comuns:
  • logger.error("User not found") — 404 é INFO, não ERROR. Erro do cliente ≠ erro do sistema.
  • logger.warn("Database query took 500ms") — se isso é normal no P99, é INFO. Se é anomalia, é ERROR com context.
  • logger.info("SELECT * FROM orders WHERE id=...") em loop — INFO deve ser eventos de negócio, não I/O interno. Use DEBUG.
  • Logar exceptions sem stack trace — sempre passe o objeto de exceção ao logger.

Campos obrigatórios vs opcionais

Defina um schema base que todos os serviços seguem. Campos extras são opcionais por serviço.

python
# Schema base obrigatório (todos os serviços)
REQUIRED_FIELDS = {
    "timestamp",    # ISO 8601 com milissegundos
    "level",        # error | warn | info | debug
    "message",      # descrição human-readable do evento
    "service",      # nome do serviço (ex: "order-service")
    "version",      # versão do binário (ex: "1.4.2")
    "trace_id",     # se OTel ativo (W3C format)
    "span_id",      # se OTel ativo
}

# Campos opcionais recomendados por contexto
CONTEXT_FIELDS = {
    # HTTP handler
    "http_method", "http_path", "http_status", "duration_ms",

    # Database
    "db_operation", "db_table", "db_duration_ms",

    # Queue/messaging
    "queue_name", "message_id", "consumer_group",

    # Negócio
    "user_id",      # ⚠️ Cuidado LGPD/GDPR — anonimizar se necessário
    "order_id", "payment_id", "tenant_id",

    # Error
    "error",        # tipo/classe do erro
    "error_detail", # mensagem de detalhe
    "retry_count",  # número de tentativas anteriores
}

Bibliotecas recomendadas por linguagem

LinguagemLib recomendadaPor quêAlternativa
PythonstructlogPipeline de processadores, contexto thread-local/async, OTel bridge nativologuru (ergonomia, async)
Node.js/TSpinoAsync, 8× mais rápido que winston, JSON por default, child loggerswinston (mais configurável)
Goslog (stdlib 1.21+)Padrão da linguagem, zero deps, structured por designzerolog (mais rápido)
Javalogback + logstash-encoderJSON encoder para Logback, padrão enterpriselog4j2 (async appender)
Rusttracing + tracing-subscriberIntegração nativa com tokio async, OTel bridgeslog
python
# structlog: o mais ergonômico em Python
import structlog
from opentelemetry import trace

# Configurar uma vez
structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,     # contexto async-safe
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.JSONRenderer(),
    ],
    wrapper_class=structlog.BoundLogger,
    context_class=dict,
    logger_factory=structlog.PrintLoggerFactory(),
)

logger = structlog.get_logger()

# Contexto de request — FastAPI middleware
@app.middleware("http")
async def logging_middleware(request: Request, call_next):
    span = trace.get_current_span()
    ctx = span.get_span_context()

    # Bind ao contexto da coroutine — disponível em TODOS os logs da request
    structlog.contextvars.bind_contextvars(
        trace_id=format(ctx.trace_id, "032x"),
        span_id=format(ctx.span_id, "016x"),
        method=request.method,
        path=request.url.path,
        request_id=request.headers.get("x-request-id"),
    )

    response = await call_next(request)

    logger.info(
        "request.completed",
        status=response.status_code,
    )
    # Output: {"event":"request.completed","level":"info","timestamp":"...",
    #          "trace_id":"...","method":"POST","path":"/checkout","status":200}

    structlog.contextvars.clear_contextvars()
    return response

Performance: o custo real de logging

Logging mal implementado é uma das causas mais comuns de latência inexplicável em produção. Os culpados principais:

ProblemaImpactoSolução
Write síncrono em cada log+0.1–5ms por log em hot pathLogger assíncrono com buffer em memória + worker thread
JSON serialization a cada registro+0.05–0.5ms (Python: stdlib json lento)orjson (Python) ou pino (Node) — 5–10× mais rápido
String interpolation eagerCPU gasta formatando strings que podem ser filtradasLazy evaluation: log.debug("Val: %s", expensive_fn()) — só executa se DEBUG ativo
Lock global no handlerContention em multi-thread — degrada throughput linearHandler thread-safe sem lock global (pino, zerolog)
Stack trace em cada WARNStack trace serialization é 10–100× mais caro que mensagemStack trace apenas em ERROR+; WARN usa campos estruturados
python
# ❌ Eager evaluation — executa mesmo se DEBUG desligado
logger.debug(f"Cache keys: {json.dumps(cache.keys())}")  # dump() sempre executa

# ✅ Lazy — executa só se DEBUG ativo
logger.debug("Cache keys: %s", lambda: json.dumps(cache.keys()))

# ❌ Write síncrono em loop
for item in items:
    logger.info("Processing", extra={"item_id": item.id})  # I/O a cada iteração

# ✅ Agrupar ou usar rate limiting de logs
if len(items) > 100:
    logger.info("Processing batch", extra={"count": len(items)})
else:
    for item in items:
        logger.info("Processing", extra={"item_id": item.id})

# Configurar handler assíncrono (Python)
import logging.handlers
import queue

log_queue = queue.Queue(-1)  # unlimited
queue_handler = logging.handlers.QueueHandler(log_queue)
queue_listener = logging.handlers.QueueListener(
    log_queue,
    actual_handler,  # O handler real (StreamHandler, RotatingFileHandler)
    respect_handler_level=True,
)
queue_listener.start()  # Thread separada para I/O

Log sampling: quando você tem muito dado

Serviços de alto volume (1k+ req/s) não podem logar cada request ao nível INFO sem custo proibitivo. Sampling é a solução — mas precisa preservar logs de erros e traces completos.

📋 Serviço com 5.000 req/s — logging de cada request custa $2.000/mês em Datadog

Head sampling + full error logs

Amostrar 10% dos requests INFO reduz custo em 90%. Erros, WARNs e traces com anomalia são sempre enviados integralmente.

Alt: Logar tudo (sem sampling)Serviços < 100 req/s ou auditoria regulatória obrigatória

Alt: Sampling por trace_id (tail)Quando o Collector OTel controla sampling — logs e traces amostrados consistentemente

Alt: Drop total de INFO em produçãoAnti-pattern — você perde visibilidade de operações normais que precedem falhas

python
# Rate-limited logging: logar no máximo N vezes/segundo
import time
from collections import defaultdict

class RateLimitedLogger:
    def __init__(self, logger, max_per_second: int = 100):
        self._logger = logger
        self._max = max_per_second
        self._counts: dict[str, int] = defaultdict(int)
        self._window_start = time.monotonic()

    def info(self, event: str, **kwargs):
        now = time.monotonic()
        if now - self._window_start > 1.0:
            self._counts.clear()
            self._window_start = now

        if self._counts[event] < self._max:
            self._counts[event] += 1
            self._logger.info(event, **kwargs)
        # Dropped — nunca bloqueia o caller

Pipeline de logs: do serviço ao storage


  ┌─────────────────────────────────────────────────────────────────┐
  │  Serviços (qualquer linguagem)                                  │
  │  Emitem JSON para stdout                                        │
  │  ← padrão 12-factor app: log vai para stdout, infra coleta     │
  └──────┬──────────────┬──────────────────┬────────────────────────┘
         │              │                  │
         ▼              ▼                  ▼
  ┌────────────┐ ┌────────────┐ ┌────────────────────┐
  │  Fluent    │ │  Promtail  │ │  OTel Collector     │
  │  Bit/D     │ │  (Grafana) │ │  (logs pipeline)    │
  │ DaemonSet  │ │ DaemonSet  │ │  Deployment         │
  └─────┬──────┘ └─────┬──────┘ └────────┬────────────┘
        │              │                  │
        ▼              ▼                  ▼
  ┌─────────────────────────────────────────────────────┐
  │  Log Backend                                        │
  │                                                     │
  │  Self-hosted: Grafana Loki (labels + LogQL)         │
  │  Managed: Datadog Logs, CloudWatch Logs Insights    │
  │  Enterprise: Elastic/OpenSearch                     │
  │                                                     │
  │  Retenção:                                          │
  │  • ERROR/WARN: 90 dias                              │
  │  • INFO: 30 dias                                    │
  │  • DEBUG: 7 dias (se coletado)                      │
  └─────────────────────────────────────────────────────┘
💡
Loki labels vs fields: No Grafana Loki, labels são indexados (baixa cardinalidade: service, env, level). Campos dentro do JSON são consultados via parser (| json) sem index — mais flexíveis mas mais lentos. Nunca coloque user_id ou trace_id como label Loki — isso explode a cardinalidade do índice.
⚠️
Dados sensíveis em logs: LGPD/GDPR proíbem logar PII sem justificativa. Nunca logue passwords, tokens, CPF, cartão de crédito, email completo em INFO+. Use masking ou hashing antes de logar: "email": "f***@gmail.com","card": "**** **** **** 4242". Erros de compliance em logs são difíceis de reverter — logs são frequentemente arquivados por anos.

Q&A — perguntas típicas de entrevista SRE

P: Qual a diferença entre correlation ID, request ID e trace ID?

R: Request ID: identifica uma requisição HTTP individual (gerado no edge/gateway). Correlation ID: termo genérico para qualquer ID que correlaciona eventos — pode ser o request ID propagado. Trace ID: formato específico do OpenTelemetry (W3C Trace Context) — 128 bits hex — garante unicidade global e integra com Jaeger/Tempo. Use trace_id quando tiver OTel; correlation_id como fallback manual.

P: Por que logar para stdout e não diretamente para arquivo ou Elasticsearch?

R: 12-factor app: "Logs são streams de eventos". Logar para stdout desacopla o serviço de infraestrutura de logging. Em Kubernetes, kubelet já captura stdout. Você pode trocar o backend (Loki, Elasticsearch, Datadog) sem mudar código. Logar direto para Elastic cria dependência de runtime — se o Elastic cair, o serviço pode bloquear ou perder dados.

P: Como debugar em produção sem ligar DEBUG global (que gera volume absurdo)?

R: Dynamic log level por trace_id ou por flag de feature: o middleware verifica se o request tem header `X-Debug-Token: <secret>` e então eleva o nível para DEBUG apenas para aquela requisição/goroutine. Ou use tail-based sampling no Collector OTel: spans com erro trigam coleta de todos os logs daquele trace, independente do nível.

Take-aways:
  • JSON estruturado não é opcional em produção — é a base de toda observabilidade que escala.
  • trace_id/span_id devem ser injetados automaticamente via contexto OTel — não passados manualmente.
  • ERROR = alguém precisa investigar agora. WARN = situação degradada mas recuperada. INFO = evento de negócio.
  • Handler assíncrono + buffer é obrigatório em hot paths — logging síncrono adiciona latência de I/O ao caminho crítico.
  • Nunca coloque PII (CPF, email, cartão) em logs sem masking — compliance é mais difícil de reverter que um bug.
  • Em Loki/Elastic: labels têm baixa cardinalidade (service, env, level); campos JSON são filtragem sem índice.
🧩

Quiz rápido

4 perguntas · Acerte tudo e ganhe o badge 🎯 Gabarito

Continue lendo