Vous savez exactement où la production a planté. La stack trace pointe vers la ligne 147 de invoice_service.py. L’exception est un KeyError sur "customer_id". Vous récupérez le code, exécutez les tests, tout passe. Vous frappez l’endpoint manuellement avec un payload d’exemple, ça marche bien.
Le bug est réel. Les clients le rencontrent. Vous ne pouvez pas le reproduire sur votre machine.
C’est l’expérience standard du débogage à partir de rapports d’erreurs. Les stack traces vous disent où. Elles ne vous disent pas ce qui est arrivé là. Sans les entrées exactes qui ont déclenché l’échec, vous reconstruisez une scène de crime à partir d’une photo du contour de craie.
Ce que signifie réellement le replay de crash
Le replay de crash est la pratique consistant à capturer l’état d’entrée complet qui a déclenché une défaillance en production et à réexécuter le chemin de code avec cet état exact dans un environnement local. L’objectif est de transformer « ça a planté à la ligne 147 » en « voici un cas de test qui fait planter la ligne 147 à chaque fois. »
La plupart des développeurs font déjà une version manuelle de ceci. Vous lisez la stack trace, devinez quelle requête l’a causée, essayez de reconstruire le payload depuis les logs, et espérez que votre base de données locale a des données similaires. Ça échoue pour la même raison que l’astrologie échoue : vous faites correspondre des patterns sans assez d’informations.
La différence entre deviner et rejouer, c’est la sérialisation. Vous devez capturer les entrées de fonction exactes, les réponses de base de données exactes, et les valeurs de retour d’API externe exactes au moment de la défaillance. Puis vous les réintroduisez.
Comment capturer les entrées de production pour le replay
Le pattern efficace le plus simple est un décorateur qui intercepte les arguments d’une fonction, les sérialise sur disque, et les envoie quelque part où vous pourrez y accéder plus tard. Quand la fonction plante, vous avez un instantané figé du monde qui l’a produite.
Voici une implémentation Python fonctionnelle :
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
Appliquez-le à la fonction qui plante :
@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}
Quand KeyError: "customer_id" se déclenche en production, vous obtenez un fichier JSON qui ressemble à ceci :
{
"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": "..."
}
Le premier argument était un dictionnaire vide. C’est tout le bug. L’appelant en amont a passé {} au lieu d’un enregistrement client.
Vous avez maintenant un cas de test :
def test_generate_invoice_with_empty_customer():
with pytest.raises(KeyError, match="customer_id"):
generate_invoice({}, [{"sku": "PRO-1", "price": 99.0}])
Ce test échoue avant la correction et passe après que vous ayez ajouté la validation des entrées. Plus important, vous n’avez pas eu à deviner. Le crash vous a dit exactement quoi tester.
Pourquoi la capture manque les dépendances qu’elle ne peut pas voir
Ce pattern capture les arguments de fonction, pas l’état global. Si generate_invoice lit depuis une base de données, appelle une API externe, ou vérifie une variable d’environnement, ces valeurs ne sont pas dans args et kwargs. Le replay ne fonctionnera que si votre environnement local correspond par hasard.
Vous pouvez étendre le décorateur pour capturer explicitement des dépendances externes :
@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]
# ...
Mais db_conn est un objet de connexion. Vous ne pouvez pas le sérialiser en JSON. Ce que vous pouvez sérialiser, c’est la requête et le résultat. L’approche plus rigoureuse est de séparer la logique pure des effets de bord. Passez le résultat de la requête comme argument, pas la connexion :
@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),
}
C’est du fonctionnel au coeur, impératif en coquille. Ça rend la capture triviale parce qu’il n’y a pas d’état caché. Chaque entrée qui compte est dans la liste d’arguments.
Le problème de non-déterminisme que vous ne pouvez pas éliminer par capture
Même avec une capture d’entrée parfaite, certains crashes ne sont pas reproductibles. Les race conditions dépendent du timing des threads. La corruption mémoire dépend de l’état de l’allocateur. Les API externes retournent des données différentes à chaque appel. Les générateurs de nombres aléatoires produisent des séquences différentes à moins que vous ne les initialisiez avec une graine.
Si votre crash est une race condition, rejouer les mêmes entrées dans un test local mono-thread ne le déclenchera pas. Vous avez besoin du pattern de concurrence réel, ce qui signifie exécuter les threads originaux, ce qui signifie que vous êtes passés du « replay » au « distributed tracing plus load testing. » C’est un autre outil.
Pour la plupart des bugs au niveau application, le replay d’entrées suffit. Pour les heisenbugs, ce n’est pas le cas. Sachez avec lequel vous avez affaire avant de passer trois heures à essayer de rejouer un problème de timing.
Rendre la capture opérationnelle en production
Le décorateur ci-dessus écrit sur le disque local. En production, vous voulez que ces captures soient envoyées vers un stockage objet ou votre outil de suivi d’erreurs. L’intégration est simple : remplacez l’appel open(capture_path, "w") par un upload S3 ou une pièce jointe à votre ticket Sentry.
La plupart des équipes devraient commencer par une fonction. Choisissez le service qui plante le plus souvent. Ajoutez le décorateur. Attendez le prochain crash. Quand il arrive, vous aurez un payload JSON qui transforme une session de devinage de trente minutes en une écriture de test de cinq minutes.
Si vous utilisez déjà Sentry, la fonctionnalité Breadcrumbs capture une partie de ce contexte automatiquement. Pour une approche personnalisée, le pattern tient en trente lignes de Python et fonctionne dans n’importe quel runtime qui supporte les décorateurs ou le middleware.
Capturez un endpoint cette semaine
Ajoutez la capture à votre endpoint avec le plus d’erreurs cette semaine. Pas chaque endpoint. Pas chaque fonction. Un seul. Quand il plante, écrivez le cas de test à partir de la capture avant de corriger le bug. Exécutez le test, regardez-le échouer, appliquez la correction, regardez-le passer.
Cette boucle, du crash de production au cas de test reproductible, est ce qui sépare le débogage de l’archéologie.