Sabes exactamente dónde se cayó producción. El stack trace apunta a la línea 147 de invoice_service.py. La excepción es un KeyError en "customer_id". Extraes el código, ejecutas los tests, todo pasa. Golpeas el endpoint manualmente con un payload de muestra, funciona bien.
El bug es real. Los clientes lo están golpeando. No puedes hacer que ocurra en tu máquina.
Esta es la experiencia estándar de depurar a partir de reportes de error. Los stack traces te dicen dónde. No te dicen qué llegó allí. Sin las entradas exactas que desencadenaron la falla, estás reconstruyendo una escena del crimen a partir de una fotografía del contorno de tiza.
Qué significa realmente el crash replay
El crash replay es la práctica de capturar el estado de entrada completo que desencadenó una falla de producción y reejecutar el path de código con ese estado exacto en un entorno local. El objetivo es convertir “se cayó en la línea 147” en “aquí hay un test case que hace que la línea 147 se caiga cada vez”.
La mayoría de los desarrolladores ya hacen una versión manual de esto. Lees el stack trace, adivinas qué petición lo causó, intentas reconstruir el payload a partir de logs y esperas que tu base de datos local tenga datos similares. Esto falla por la misma razón que falla la astrología: estás emparejando patrones sin suficiente información.
La diferencia entre adivinar y reproducir es la serialización. Debes capturar las entradas exactas de la función, las respuestas exactas de la base de datos y los valores de retorno exactos de la API externa en el momento de la falla. Luego los vuelves a introducir.
Cómo capturar entradas de producción para reproducción
El patrón efectivo más simple es un decorator que intercepta los argumentos de una función, los serializa a disco y los envía a algún lugar donde puedas acceder más tarde. Cuando la función se cae, tienes una snapshot congelada del mundo que la produjo.
Aquí hay una implementación funcional en Python:
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
Aplícalo a la función que se está cayendo:
@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}
Cuando KeyError: "customer_id" se dispara en producción, obtienes un archivo JSON que se ve así:
{
"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": "..."
}
El primer argumento era un diccionario vacío. Ese es todo el bug. El llamador upstream pasó {} en lugar of a customer record.
Ahora tienes un test case:
def test_generate_invoice_with_empty_customer():
with pytest.raises(KeyError, match="customer_id"):
generate_invoice({}, [{"sku": "PRO-1", "price": 99.0}])
Este test falla antes del arreglo y pasa después de que agregues validación de entrada. Más importante aún, no tuviste que adivinar. El crash te dijo exactamente qué probar.
Por qué la captura pierde dependencias que no puede ver
Este patrón captura argumentos de función, no estado global. Si generate_invoice lee de una base de datos, llama a una API externa o comprueba una variable de entorno, esos valores no están en args y kwargs. La reproducción solo funcionará si tu entorno local coincide por casualidad.
Puedes extender el decorator para capturar dependencias externas explícitamente:
@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]
# ...
Pero db_conn es un objeto de conexión. No puedes serializarlo a JSON. Lo que puedes serializar es la query y el resultado. El enfoque más principado es separar la lógica pura de los side effects. Pasa el resultado de la query como argumento, no la conexión:
@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),
}
Esto es núcleo funcional, capa imperativa. Hace que la captura sea trivial porque no hay estado oculto. Cada entrada que importa está en la lista de argumentos.
El problema del no determinismo que no puedes capturar
Incluso con captura perfecta de entradas, algunos crashes no son reproducibles. Las race conditions dependen del timing de threads. La corrupción de memoria depende del estado del allocator. Las APIs externas devuelven datos diferentes en cada llamada. Los generadores de números aleatorios producen secuencias diferentes a menos que les des una seed.
Si tu crash es una race condition, reproducir las mismas entradas en un test local de un solo thread no lo desencadenará. Necesitas el patrón de concurrencia real, lo que significa ejecutar los threads originales, lo que significa que has pasado de “replay” a “distributed tracing más load testing.” Esa es una herramienta diferente.
Para la mayoría de los bugs a nivel de aplicación, el input replay es suficiente. Para los heisenbugs, no lo es. Sabes con cuál estás lidiando antes de pasar tres horas intentando reproducir un problema de timing.
Haciendo la captura operativa en producción
El decorator de arriba escribe en disco local. En producción, querrás que estas capturas se envíen a object storage o a tu rastreador de errores. La integración es directa: reemplaza la llamada open(capture_path, "w") con una subida a S3 o un adjunto a tu issue de Sentry.
La mayoría de los equipos deberían empezar con una función. Elige el servicio que se cae más a menudo. Agrega el decorator. Espera el siguiente crash. Cuando llegue, tendrás un payload JSON que convierte una sesión de adivinanza de treinta minutos en una escritura de test de cinco minutos.
Si ya estás usando Sentry, la función Breadcrumbs captura algo de este contexto automáticamente. Para un enfoque personalizado, el patrón cabe en treinta líneas de Python y funciona en cualquier runtime que soporte decorators o middleware.
Captura un endpoint esta semana
Agrega captura a tu endpoint con más errores esta semana. No cada endpoint. No cada función. Uno. Cuando se caiga, escribe el test case a partir de la captura antes de arreglar el bug. Ejecuta el test, míralo fallar, aplica el arreglo, míralo pasar.
Ese ciclo, desde el crash de producción hasta el test case reproducible, es lo que separa la depuración de la arqueología.