Você sabe exatamente onde a produção quebrou. O stack trace aponta para a linha 147 do invoice_service.py. A exceção é um KeyError em "customer_id". Você puxa o código, executa os testes, tudo passa. Você chama o endpoint manualmente com um payload de exemplo, funciona perfeitamente.
O bug é real. Os clientes estão encontrando ele. Você não consegue reproduzir na sua máquina.
Essa é a experiência padrão de debugar a partir de relatórios de erro. Stack traces te dizem onde. Eles não te dizem o que chegou lá. Sem as entradas exatas que dispararam a falha, você está reconstruindo uma cena de crime a partir de uma fotografia do contorno de giz.
O que replay de crash realmente significa
Replay de crash é a prática de capturar o estado completo de entrada que disparou uma falha em produção e reexecutar o caminho do código com aquele estado exato em um ambiente local. O objetivo é transformar “quebrou na linha 147” em “aqui está um caso de teste que faz a linha 147 quebrar toda vez.”
A maioria dos desenvolvedores já faz uma versão manual disso. Você lê o stack trace, adivinha qual requisição causou, tenta reconstruir o payload a partir dos logs e espera que seu banco de dados local tenha dados similares. Isso falha pelo mesmo motivo que a astrologia falha: você está correspondendo padrões sem informação suficiente.
A diferença entre adivinhar e reproduzir é serialização. Você deve capturar as entradas exatas da função, as respostas exatas do banco de dados e os valores de retorno exatos da API externa no momento da falha. Depois você os reinsere.
Como capturar entradas de produção para replay
O padrão efetivo mais simples é um decorador que intercepta os argumentos de uma função, os serializa para disco e os envia para algum lugar que você possa acessar depois. Quando a função quebra, você tem um snapshot congelado do mundo que o produziu.
Aqui está uma implementação Python funcional:
import json
import functools
import traceback
from pathlib import Path
from datetime import datetime, timezone
CAPTURE_DIR = Path("/var/crash-captures")
def capture_for_replay(func):
"""Decorator that captures inputs and outputs for replay debugging."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
capture = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"function": func.__qualname__,
"module": func.__module__,
"args": args,
"kwargs": kwargs,
"exception": None,
"traceback": None,
}
try:
result = func(*args, **kwargs)
capture["result"] = result
return result
except Exception as exc:
capture["exception"] = {
"type": type(exc).__name__,
"message": str(exc),
}
capture["traceback"] = traceback.format_exc()
# Write crash capture to disk
CAPTURE_DIR.mkdir(parents=True, exist_ok=True)
filename = (
f"{func.__name__}_"
f"{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.json"
)
capture_path = CAPTURE_DIR / filename
with open(capture_path, "w") as f:
json.dump(capture, f, default=str, indent=2)
raise # Re-raise so normal error handling continues
return wrapper
Aplique-o na função que está quebrando:
@capture_for_replay
def generate_invoice(customer_data: dict, line_items: list) -> dict:
customer_id = customer_data["customer_id"] # This is line 147
# ... rest of invoice logic
return {"invoice_id": "INV-123", "total": 0}
Quando KeyError: "customer_id" dispara em produção, você recebe um arquivo JSON que se parece com isso:
{
"timestamp": "2026-08-15T14:32:11+00:00",
"function": "generate_invoice",
"module": "billing.invoice_service",
"args": [
{},
[{"sku": "PRO-1", "price": 99.0}]
],
"kwargs": {},
"exception": {
"type": "KeyError",
"message": "'customer_id'"
},
"traceback": "..."
}
O primeiro argumento era um dicionário vazio. Esse é o bug inteiro. O chamador upstream passou {} em vez de um registro de cliente.
Você agora tem um caso de teste:
def test_generate_invoice_with_empty_customer():
with pytest.raises(KeyError, match="customer_id"):
generate_invoice({}, [{"sku": "PRO-1", "price": 99.0}])
Esse teste falha antes da correção e passa depois que você adiciona validação de entrada. Mais importante, você não precisou adivinhar. O crash te disse exatamente o que testar.
Por que a captura perde dependências que não consegue ver
Esse padrão captura argumentos de função, não estado global. Se generate_invoice lê de um banco de dados, chama uma API externa ou verifica uma variável de ambiente, esses valores não estão em args e kwargs. O replay só vai funcionar se seu ambiente local por acaso corresponder.
Você pode estender o decorador para capturar dependências externas explicitamente:
@capture_for_replay
def generate_invoice(
customer_data: dict,
line_items: list,
db_conn
) -> dict:
tax_rate = db_conn.execute(
"SELECT rate FROM tax_rates WHERE region = ?",
(customer_data["region"],)
).fetchone()[0]
# ...
Mas db_conn é um objeto de conexão. Você não consegue serializá-lo para JSON. O que você pode serializar é a consulta e o resultado. A abordagem mais princípiada é separar a lógica pura dos efeitos colaterais. Passe o resultado da consulta como argumento, não a conexão:
@capture_for_replay
def generate_invoice(
customer_data: dict,
line_items: list,
tax_rate: float
) -> dict:
# Pure function. All inputs are serializable.
total = sum(item["price"] for item in line_items)
total_with_tax = total * (1 + tax_rate)
return {
"invoice_id": "INV-123",
"total": round(total_with_tax, 2),
}
Isso é núcleo funcional, casca imperativa. Torna a captura trivial porque não há estado oculto. Toda entrada que importa está na lista de argumentos.
O problema da não-determinismo que você não consegue capturar
Mesmo com captura perfeita de entrada, alguns crashes não são reproduzíveis. Condições de corrida dependem do timing de threads. Corrupção de memória depende do estado do alocador. APIs externas retornam dados diferentes a cada chamada. Geradores de números aleatórios produzem sequências diferentes a menos que você os inicialize com uma semente.
Se seu crash é uma condição de corrida, reproduzir as mesmas entradas em um teste local single-threaded não vai dispará-lo. Você precisa do padrão de concorrência real, o que significa executar as threads originais, o que significa que você mudou de “replay” para “rastreamento distribuído mais teste de carga.” Essa é uma ferramenta diferente.
Para a maioria dos bugs de nível de aplicação, replay de entrada é suficiente. Para heisenbugs, não é. Saiba com qual você está lidando antes de passar três horas tentando reproduzir um problema de timing.
Tornando a captura operacional em produção
O decorador acima escreve no disco local. Em produção, você quer que essas capturas sejam enviadas para armazenamento de objetos ou seu rastreador de erros. A integração é simples: substitua a chamada open(capture_path, "w") por um upload para S3 ou um anexo à sua issue do Sentry.
A maioria das equipes deve começar com uma função. Escolha o serviço que quebra com mais frequência. Adicione o decorador. Espere pelo próximo crash. Quando ele chegar, você terá um payload JSON que transforma uma sessão de adivinhação de trinta minutos em um teste de cinco minutos.
Se você já está usando Sentry, o recurso Breadcrumbs captura parte desse contexto automaticamente. Para uma abordagem customizada, o padrão cabe em trinta linhas de Python e funciona em qualquer runtime que suporte decoradores ou middleware.
Capture um endpoint esta semana
Adicione captura ao seu endpoint com mais erros esta semana. Não todos os endpoints. Não todas as funções. Um. Quando ele quebrar, escreva o caso de teste a partir da captura antes de corrigir o bug. Execute o teste, veja ele falhar, aplique a correção, veja ele passar.
Esse loop, de crash de produção a caso de teste reproduzível, é o que separa o debug da arqueologia.