DEV Community

Cover image for Observabilidade serverless sem surpresa no bolso: logs, métricas e traces com CloudWatch + X-Ray do jeito certo
Diego Broetto
Diego Broetto

Posted on

Observabilidade serverless sem surpresa no bolso: logs, métricas e traces com CloudWatch + X-Ray do jeito certo

Tem uma fatura de CloudWatch que eu nunca vou esquecer.

Uma função Lambda simples, rodando há algumas semanas em produção. Sem grande volume. Sem nada de especial. Só que alguém tinha esquecido de configurar retenção de log, o X-Ray estava com sampling em 100%, e o log level estava em DEBUG porque "vou ajustar depois". A conta veio quase três vezes o esperado.

O pior não foi o valor. Foi saber que meu cliente tinha pagado para guardar lixo. Stack trace de erro que já estava resolvido. Trace de requisição que durou 12ms e nunca mais ninguém ia abrir. Log de evento completo com payload de cliente em texto plano num log group que ia existir pra sempre.

Observabilidade serverless não é caro por natureza. Fica caro quando você liga tudo no máximo e esquece. Este artigo é sobre não esquecer.


1.0 O problema específico do serverless

Em servidores tradicionais, você tem SSH. Tem processo rodando. Tem top, htop, journalctl. Quando algo dá errado, você entra na máquina e olha.

No Lambda, não tem nada disso. A função executa em milissegundos, o container some, e você ficou com o que colocou no log. Se não colocou nada útil, ou colocou tudo de forma errada, você está debugando no escuro.

Aí vem o impulso natural: loga tudo. Ativa X-Ray em todas as funções. Cria métrica para cada coisa que se move. Funciona. Mas o preço aparece no fim do mês.

A questão não é observar menos. É observar o que importa.


2.0 Os três pilares e o que cada um custa de verdade

Antes de falar de implementação, vale entender a tabela de preços do que você está usando.

CloudWatch Logs cobra em três momentos distintos: ingestão ($0,50/GB), armazenamento ($0,03/GB/mês) e consultas no Logs Insights ($0,005/GB escaneado). O armazenamento parece barato até você calcular que o padrão de retenção é "nunca expirar". Um log group de função Lambda com volume médio, sem retenção configurada, acumula sem parar.

CloudWatch Metrics tem métricas padrão do Lambda gratuitas. São muitas e cobrem bem o essencial: Duration, Errors, Throttles, ConcurrentExecutions, IteratorAge para Streams. O custo começa quando você cria métricas customizadas: $0,30 por métrica por mês. Soa pouco. Mas se você criar uma métrica por tenant_id com 500 tenants, são $150/mês só de métricas, sem incluir as chamadas de API para publicá-las.

X-Ray cobra $5,00 por 1 milhão de traces registrados e $0,50 por 1 milhão consultados. O sampling padrão da AWS é 1 requisição por segundo mais 5% do restante. Para a maioria das cargas, está OK. Para uma função processando 10 mil req/seg, 5% são 500 traces por segundo, 43 milhões por mês, $215 só de traces.

Esses números não são argumentos contra usar essas ferramentas. São argumentos para configurá-las de forma intencional.


3.0 Logs: estrutura antes de volume

O problema mais comum que vejo não é o volume de logs. É o formato.

Log em texto livre não serve em escala. Você não consegue filtrar, não consegue agregar, não consegue criar métricas a partir dele. Funciona para debug manual no console, mas quebra quando você precisa entender o que aconteceu às 3h da manhã em 200 invocações simultâneas.

JSON é o formato certo. O Lambda suporta nativamente desde 2023:

Globals:
  Function:
    LoggingConfig:
      LogFormat: JSON
      ApplicationLogLevel: INFO
      SystemLogLevel: WARN
Enter fullscreen mode Exit fullscreen mode

Com isso, cada log já sai estruturado com timestamp, level, requestId e a mensagem. Você ainda precisa estruturar o que coloca no log, mas o envelope já está pronto.

3.1 Powertools Logger: o que ele faz que você não quer fazer na mão

O AWS Lambda Powertools tem um Logger que, além de JSON, injeta contexto da invocação automaticamente:

from aws_lambda_powertools import Logger

logger = Logger(service="pedidos")

@logger.inject_lambda_context(log_event=False)
def lambda_handler(event, context):
    logger.info("Pedido recebido", pedido_id=event["id"], valor=event["valor"])
Enter fullscreen mode Exit fullscreen mode

O que aparece no log:

{
  "level": "INFO",
  "message": "Pedido recebido",
  "pedido_id": "abc-123",
  "valor": 179.80,
  "cold_start": true,
  "function_name": "pedidos-criar-prod",
  "function_memory_size": 512,
  "function_arn": "arn:aws:lambda:...",
  "function_request_id": "...",
  "timestamp": "2025-07-14T10:23:41.123Z",
  "service": "pedidos"
}
Enter fullscreen mode Exit fullscreen mode

Exemplo no console:

cold_start está lá de graça. function_request_id está lá. Tudo que você precisaria escrever na mão já vem junto.

Um detalhe importante: log_event=False. Com True, o payload completo do evento vai para o log. Útil em desenvolvimento. Em produção, isso significa que dados do cliente vão para o CloudWatch em texto plano, e você vai pagar para armazenar informação que provavelmente não deveria estar lá. Controle isso por variável de ambiente:

@logger.inject_lambda_context(log_event=os.environ.get("LOG_EVENT") == "true")
Enter fullscreen mode Exit fullscreen mode

No template SAM:

Environment:
  Variables:
    LOG_EVENT: !If [IsDev, "true", "false"]
Enter fullscreen mode Exit fullscreen mode

3.2 append_keys: propagando contexto sem repetição

Dentro do handler, você frequentemente quer que o pedido_id apareça em todos os logs daquela invocação, não só no primeiro. O append_keys faz isso:

def lambda_handler(event, context):
    pedido_id = event["pathParameters"]["pedido_id"]
    logger.append_keys(pedido_id=pedido_id)

    # Daqui pra frente, todo log inclui pedido_id automaticamente
    pedido = buscar_pedido(pedido_id)
    logger.info("Pedido encontrado", status=pedido["status"])
Enter fullscreen mode Exit fullscreen mode

3.3 Retenção: configure no primeiro deploy, não depois

O padrão do Lambda é criar o log group automaticamente com retenção "Nunca expirar". Isso parece inofensivo até você perceber que é um bug, não uma feature.

Configure via CloudFormation/SAM para não depender de lembrar:

PedidosLogGroup:
  Type: AWS::Logs::LogGroup
  Properties:
    LogGroupName: !Sub "/aws/lambda/pedidos-criar-${Environment}"
    RetentionInDays: !Ref LogRetentionDays
Enter fullscreen mode Exit fullscreen mode

Com um parâmetro LogRetentionDays que varia por ambiente: 7 dias em dev, 30 em staging, 90 em prod. Ou o que compliance exigir, desde que não seja infinito.

3.4 Log level por ambiente

DEBUG em produção é a forma mais eficiente de dobrar sua conta de logs. Controle via Mappings no SAM:

Mappings:
  EnvConfig:
    dev:
      LogLevel: DEBUG
    staging:
      LogLevel: INFO
    prod:
      LogLevel: INFO
Enter fullscreen mode Exit fullscreen mode

4.0 Um debug real: dois bugs atrás de uma mensagem genérica

Isso aconteceu comigo enquanto preparava o projeto de exemplo deste artigo. Deixo aqui porque é um caso melhor do que qualquer exemplo que eu inventaria.

Depois do primeiro deploy bem-sucedido, testei o POST /pedidos e recebi:

{"error": "Internal Erro. Try again."}
Enter fullscreen mode Exit fullscreen mode

Sem mais detalhes. E propositalmente sem mais detalhes o handler captura qualquer exceção genérica e devolve essa mensagem amigável de propósito, porque uma API não deveria vazar stack trace para quem está do outro lado. Isso é certo do ponto de vista de segurança. Também significa que, sem observabilidade decente no backend, você fica cego.

Fui direto para o CloudWatch Logs Insights:

filter level = "ERROR"
| stats count() as ocorrencias by tipo_erro, operacao
| sort ocorrencias desc
Enter fullscreen mode Exit fullscreen mode

E o log estruturado entregou na hora:

{
  "level": "ERROR",
  "operacao": "criar_pedido",
  "tipo_erro": "TypeError",
  "mensagem_erro": "Float types are not supported. Use Decimal types instead.",
  "pedido_id": null
}
Enter fullscreen mode Exit fullscreen mode

O DynamoDB, via boto3, não aceita float nativo do Python, só Decimal. O handler estava salvando valor (179.80) direto como veio do JSON. A função processar_pedido já fazia essa conversão certinho, só a criar_pedido tinha escapado. Corrigi convertendo para Decimal(str(valor)) apenas no item que vai para o DynamoDB, mantendo float no dict usado para log, métrica e resposta HTTP, não queria efeito colateral na serialização JSON por causa de um tipo que só o DynamoDB exige.

Testei de novo. Erro genérico de novo. Voltei no Insights, mesma query, e apareceu um segundo erro, completamente diferente do primeiro:

{
  "level": "ERROR",
  "operacao": "criar_pedido",
  "tipo_erro": "ClientError",
  "mensagem_erro": "User: .../pedidos-criar-dev is not authorized to perform: events:PutEvents on resource: arn:aws:events:us-east-1:...:event-bus/default",
  "pedido_id": "..."
}
Enter fullscreen mode Exit fullscreen mode

Esse foi mais sutil. O código tinha os.environ.get("EVENT_BUS_NAME", "default"), um fallback razoável à primeira vista. O problema é que a variável EVENT_BUS_NAME nunca tinha sido definida no template, então o fallback sempre disparava, e o código tentava publicar no event bus padrão da conta. A IAM policy, corretamente, só liberava PutEvents no bus customizado que o projeto realmente usa. Fallback silencioso mascarando uma variável de ambiente esquecida o tipo de bug que passa no code review porque parece defensivo.

O que faz esse caso valer a pena contar não é nenhum dos dois bugs isoladamente. É que sem tipo_erro e mensagem_erro como campos estruturados e pesquisáveis, eu estaria lendo texto corrido no console tentando adivinhar onde, entre duas invocações, a causa mudou completamente. Com log estruturado, foram duas queries idênticas, dois resultados diferentes, dois fixes de dez minutos cada.

É fácil ler a seção anterior sobre Powertools Logger e achar que pedido_id e cold_start automáticos são só conveniência. Na prática é a diferença entre filtrar por campo e grep em prosa.


5.0 Métricas: use o que é de graça antes de criar o que custa

Tem muita coisa útil antes de você precisar criar uma métrica customizada.

As métricas padrão do Lambda que mais uso no dia a dia:

  • Errors e ErrorRate: taxa de erro por função. Bom para alarme de SLA.
  • Duration com estatísticas p50, p90, p99: distribuição de latência. Nunca use só a média, ela esconde outliers. Uma função com p50 de 100ms e p99 de 8000ms está com problema que a média não mostra.
  • Throttles: qualquer valor acima de zero em produção merece atenção imediata.
  • ConcurrentExecutions: para detectar burst antes de virar throttle.
  • IteratorAge: se você tem função consumindo Kinesis ou DynamoDB Streams, isso é o lag do consumer. Subindo significa que você não está acompanhando o volume.

Todas gratuitas. Se essas cobrem o que você precisa, pare aqui.

5.1 Embedded Metric Format: métricas customizadas sem overhead de API

Quando você precisa de métrica customizada, o caminho certo é EMF, não put_metric_data.

O put_metric_data faz uma chamada de API para o CloudWatch a cada invocação. Além do custo das chamadas, é uma dependência extra que pode falhar e adiciona latência.

O EMF funciona diferente: você emite a métrica dentro do log JSON em um formato especial. O CloudWatch processa o log e cria a métrica automaticamente. Sem chamada de API extra, sem latência adicional.

Com o Powertools:

from aws_lambda_powertools import Metrics
from aws_lambda_powertools.metrics import MetricUnit

metrics = Metrics(namespace="MeuApp/Pedidos", service="pedidos")

@metrics.log_metrics(capture_cold_start_metric=True)
def lambda_handler(event, context):
    # Isso vira uma métrica CloudWatch via EMF — sem put_metric_data
    metrics.add_metric(name="PedidoProcessado", unit=MetricUnit.Count, value=1)
    metrics.add_dimension(name="Status", value="aprovado")
Enter fullscreen mode Exit fullscreen mode

capture_cold_start_metric=True emite uma métrica ColdStart automaticamente. De graça, sem código extra.

5.2 A armadilha das dimensões de alta cardinalidade

Métricas customizadas com dimensão de alta cardinalidade são uma das formas mais rápidas de explodir o custo do CloudWatch.

Exemplo: você tem 1000 clientes e quer ver quantos pedidos cada um fez.

# ERRADO — 1000 métricas × $0,30 = $300/mês
metrics.add_dimension(name="ClienteId", value=cliente_id)
metrics.add_metric(name="PedidoCliente", unit=MetricUnit.Count, value=1)
Enter fullscreen mode Exit fullscreen mode

Para esse caso, use Logs Insights:

filter mensagem = "Pedido processado"
| stats count() as total by cliente_id
| sort total desc
Enter fullscreen mode Exit fullscreen mode

A consulta custa por GB escaneado, mas você só roda quando precisa. Dimensões de métrica fazem sentido quando a cardinalidade é baixa: status, tipo, ambiente, região. Não por cliente, não por produto individual, não por request ID.


6.0 X-Ray: o que ele mostra que os logs não mostram

Os logs dizem que uma invocação levou 800ms. O X-Ray diz que desses 800ms, 12ms foram no seu código, 750ms foram esperando o DynamoDB responder, e os outros 38ms foram no cold start.

Essa diferença muda completamente onde você vai olhar para resolver o problema.

6.1 capture_method: subsegmentos sem boilerplate

Com o Powertools Tracer, você adiciona subsegmentos com um decorator:

from aws_lambda_powertools import Tracer

tracer = Tracer(service="pedidos")

@tracer.capture_lambda_handler(capture_response=False)
def lambda_handler(event, context):
    pedido = _parse_body(event)
    _salvar_pedido(pedido)
    _publicar_evento(pedido)

@tracer.capture_method
def _salvar_pedido(pedido):
    # O X-Ray vai mostrar exatamente quanto tempo este método levou
    table.put_item(Item=pedido)

@tracer.capture_method
def _publicar_evento(pedido):
    eventbridge.put_events(...)
Enter fullscreen mode Exit fullscreen mode

capture_response=False evita que o response completo vá para o trace, o que pode incluir dados sensíveis e aumentar custo de armazenamento.

6.2 put_annotation: filtrando traces que importam

Anotações no X-Ray permitem filtrar traces por valor de negócio:

tracer.put_annotation(key="status_pedido", value=status)
tracer.put_annotation(key="valor_acima_limite", value=valor > 500)
Enter fullscreen mode Exit fullscreen mode

No console do X-Ray, você consegue filtrar annotation.status_pedido = "rejeitado" e ver só os traces de pedidos rejeitados. Muito mais útil do que varrer logs para encontrar o mesmo padrão.

6.3 Sampling rules: a configuração que mais gente ignora

O padrão da AWS é 1 req/seg fixo mais 5% do restante. Para a maioria das funções está OK. O problema é quando você tem funções de alto volume.

Configure sampling rules por função ou por padrão de ARN:

{
  "RuleName": "HighVolumeFunction",
  "Priority": 1,
  "FixedRate": 0.01,
  "ReservoirSize": 1,
  "ServiceName": "pedidos-processar-*",
  "ResourceARN": "*"
}
Enter fullscreen mode Exit fullscreen mode

1% para função de alto volume que você já conhece bem é diferente de 5% para uma função nova que pode ter comportamento inesperado. Trate isso por função, não como configuração global.

Tem casos onde faz sentido desligar o X-Ray completamente: função de health check, processamento homogêneo de fila onde todos os traces são idênticos, função de baixa complexidade sem downstream calls. Nesses casos, logs estruturados com Duration no CloudWatch cobrem o que importa.


7.0 Alarmes: o que monitorar de verdade

Um dashboard sem alarme é decoração. Você precisa ser avisado quando algo sai do normal, não descobrir na próxima vez que abrir o console.

Três alarmes que eu considero mínimo obrigatório por função crítica:

Taxa de erro: monitore Errors, não ErrorRate, porque ErrorRate é percentual e pode mascarar volume real. Se você tem 0,1% de erro em 1 milhão de invocações, são 1000 erros. Coloque um alarme absoluto e um relativo.

Latência p99: não p50, não média. O p99 mostra o que seus usuários mais lentos estão experimentando. Um alarme em 80% do timeout configurado dá tempo de agir antes de começar a ter timeouts reais:

CriarPedidoDurationAlarm:
  Type: AWS::CloudWatch::Alarm
  Properties:
    MetricName: Duration
    ExtendedStatistic: p99
    Threshold: 24000  # 80% de 30s = 24000ms
    ComparisonOperator: GreaterThanThreshold
Enter fullscreen mode Exit fullscreen mode

Throttles: qualquer valor acima de zero em produção. Throttle significa que você está descartando tráfego. É uma emergência, não uma métrica para acompanhar.


8.0 Dashboard: visão consolidada, não por função

Um dashboard por função não serve. Quando algo dá errado em produção, você quer ver o sistema inteiro, não abrir 12 dashboards em abas diferentes.

Monte um dashboard por stack ou por domínio de negócio com:

  • Taxa de erro de todas as funções em um gráfico só
  • Latência p99 de todas as funções
  • Invocações totais por função
  • Throttles
  • Métricas de negócio via EMF (pedidos criados, processados, rejeitados)

O template SAM do projeto de exemplo gera esse dashboard via CloudFormation. Nada para configurar no console, versionado junto com a infraestrutura.


9.0 Checklist antes de ir para produção

[ ] LogFormat: JSON em todas as funções (template Globals)
[ ] ApplicationLogLevel: INFO em prod (nunca DEBUG)
[ ] log_event=False em prod (controle por variável de ambiente)
[ ] RetentionInDays configurado em todos os log groups
[ ] Métricas de Duration usando p99, não média
[ ] Sampling rule do X-Ray revisada para funções de alto volume
[ ] capture_response=False no Tracer em prod
[ ] Alarme em Errors (absoluto)
[ ] Alarme em Duration p99 (> 80% do timeout)
[ ] Alarme em Throttles (qualquer valor > 0)
[ ] Dashboard consolidado por stack
[ ] Budget Alert no Cost Explorer separando CloudWatch dos outros serviços
Enter fullscreen mode Exit fullscreen mode

O último item parece paranoia até deixar de ser.


10.0 Projeto de exemplo

O repositório que acompanha este artigo tem uma API de pedidos completa com quatro funções Lambda demonstrando cada conceito na prática:

  • criar_pedido: POST /pedidos com Logger, Tracer e Metrics configurados
  • consultar_pedido: GET /pedidos/{id} com anotações X-Ray e métricas de hit/miss
  • processar_pedido: consumidor EventBridge com métricas de negócio por dimensão de status
  • decidir_pedido: PATCH /pedidos/{id}/aprovar e /rejeitar, aprovação e rejeição manual de pedidos que ficaram em aguardando_aprovacao

A quarta função existe porque a classificação automática por valor deixava um estado sem saída: pedidos na faixa intermediária ficavam parados em aguardando_aprovacao sem nenhum jeito de sair dali. As duas rotas aprovar e rejeitar são atendidas pela mesma Lambda, com a ação derivada do path da requisição. Menos uma função duplicada, menos um cold start a mais para gerenciar.

Regra de negócio (classificação automática por valor):

Valor do pedido Status resultante
≤ R$ 500,00 aprovado (automático)
R$ 500,01 – R$ 5.000,00 aguardando_aprovacao (precisa de /aprovar ou /rejeitar)
> R$ 5.000,00 rejeitado (automático)

Endpoints:

Método Path Função Notas
POST /pedidos criar_pedido Publica PedidoCriado no EventBridge
GET /pedidos/{id} consultar_pedido 404 se não existir
PATCH /pedidos/{id}/aprovar decidir_pedido 409 se o pedido não estiver em aguardando_aprovacao
PATCH /pedidos/{id}/rejeitar decidir_pedido 409 se o pedido não estiver em aguardando_aprovacao

A transição de status usa ConditionExpression no update_item do DynamoDB o mesmo padrão de idempotência do processar_pedido para garantir que a decisão só é aplicada uma vez, mesmo com requisições concorrentes. Quando a condição falha, uma leitura simples desambigua entre "pedido não existe" (404) e "pedido já está em outro status" (409, com o status atual na mensagem).

A observabilidade segue o mesmo padrão das outras funções: métrica EMF PedidoDecididoManualmente com dimensão Decisao (aprovado/rejeitado), anotação X-Ray decisao_manual, e logs estruturados com pedido_id, acao e novo_status. O dashboard ganhou um widget novo para essas decisões, e o widget "Pedidos por Status" foi corrigido para incluir a série aguardando_aprovacao faltava no dashboard original, o que por si só é um bom lembrete de revisar dashboard quando o domínio de negócio ganha um status novo.

O template SAM inclui log groups com retenção, alarmes em p99 e throttles, dashboard CloudWatch e Mappings de configuração por ambiente.

Para rodar:

sam build --use-container
sam deploy --config-env dev
Enter fullscreen mode Exit fullscreen mode

As queries prontas para o Logs Insights estão em monitoring/insights-queries.md no repositório.

O que este exemplo ainda não cobre, para ser honesto: os alarmes de erro e duração hoje existem só para criar_pedido, decidir_pedido e consultar_pedido ainda não têm réplica. Também não há notificação (SNS ou e-mail) quando um pedido entra em aguardando_aprovacao hoje isso só é visível consultando a API ou o dashboard. E não existem testes automatizados. Nenhum desses pontos invalida o que foi demonstrado sobre observabilidade, mas vale deixar claro que é um projeto de exemplo, não um sistema pronto para produção.

🔗 Repositório com os exemplos: https://github.com/diegobroetto/observability-demo


11.0 Conclusão

Observabilidade serverless cara geralmente é observabilidade descuidada. DEBUG em produção, retenção infinita, X-Ray sem sampling rule em função de alto volume, métrica customizada com dimensão de alta cardinalidade qualquer um desses sozinho não parece grande coisa. Junto viram uma fatura que você não esperava.

O Powertools for Lambda resolve 80% do trabalho sem boilerplate. O resto é configuração intencional no template: retenção por ambiente, log level por ambiente, sampling por tipo de função.

E configure os alarmes antes de precisar deles. O dashboard você abre quando quer. O alarme te acorda quando você precisa.

Top comments (1)

Collapse
 
thiagosagara profile image
Thiago Sagara

Muito bom Broetto,

Sinceramente serveless é meu calcanhar de aquiles, e seus post tem ajudado bastante.

Powertools for lambda com nosso querido @leandrodamascena como principal contribuidor.