本番がどこでクラッシュしたかは正確にわかっている。スタックトレースは invoice_service.py の147行目を指している。例外は "customer_id" に対する KeyError だ。コードを引っ張り出してテストを実行すれば、すべて合格する。サンプルペイロードでエンドポイントを手動で叩いても、問題なく動く。

バグは本物だ。顧客が遭遇している。自分のマシンでは再現できない。

これが、エラーレポートからデバッグする際の標準的な体験だ。スタックトレースは「どこ」を教えてくれる。「何がそこに到達したか」は教えてくれない。クラッシュを引き起こした正確な入力なしに、チョークアウトラインの写真から犯罪現場を再構成しているようなものだ。

クラッシュリプレイが実際に何を意味するのか

クラッシュリプレイは、本番の障害を引き起こした完全な入力状態をキャプチャし、その正確な状態でローカル環境でコードパスを再実行する実践だ。目標は「147行目でクラッシュした」を「147行目を毎回クラッシュさせるテストケースはこれだ」に変えることだ。

ほとんどの開発者はすでにこれを手動で行っている。スタックトレースを読み、どのリクエストが原因か推測し、ログからペイロードを再構成しようとし、ローカルのデータベースに類似のデータがあることを祈る。これが占いと同じ理由で失敗する: 十分な情報なしにパターンを照合しているからだ。

推測とリプレイの違いはシリアライゼーションだ。正確な関数入力、正確なデータベースレスポンス、障害発生時の正確な外部APIの返り値をキャプチャしなければならない。そしてそれらを再投入する。

本番の入力をリプレイ用にキャプチャする方法

最も単純で効果的なパターンは、デコレーターで関数の引数を傍受し、ディスクにシリアライズし、後でアクセスできる場所に送信するものだ。関数がクラッシュしたとき、その時点で世界を生み出した凍結スナップショットが手に入る。

以下は動作する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):
    """リプレイデバッグ用に入出力をキャプチャするデコレーター。"""
    @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()

            # クラッシュキャプチャをディスクに書き込む
            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  # 通常のエラーハンドリングを継続するために再送出

    return wrapper

クラッシュしている関数に適用する。

@capture_for_replay
def generate_invoice(customer_data: dict, line_items: list) -> dict:
    customer_id = customer_data["customer_id"]  # これが147行目
    # ... 残りの請求ロジック
    return {"invoice_id": "INV-123", "total": 0}

本番で KeyError: "customer_id" が発生すると、以下のようなJSONファイルが得られる。

{
  "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": "..."
}

最初の引数は空の辞書だった。それがバグのすべてだ。上流の呼び出し元が顧客レコードではなく {} を渡していた。

これでテストケースができた。

def test_generate_invoice_with_empty_customer():
    with pytest.raises(KeyError, match="customer_id"):
        generate_invoice({}, [{"sku": "PRO-1", "price": 99.0}])

このテストは修正前に失敗し、入力検証を追加した後に合格する。さらに重要なのは、推測する必要がなかったことだ。クラッシュが正確に何をテストすべきか教えてくれた。

キャプチャが見えない依存関係を見逃す理由

このパターンは関数の引数をキャプチャするが、グローバル状態はキャプチャしない。generate_invoice がデータベースから読み込んだり、外部APIを呼び出したり、環境変数をチェックしたりする場合、それらの値は argskwargs にはない。ローカル環境がたまたま一致しない限り、リプレイは機能しない。

デコレーターを拡張して、外部の依存関係を明示的にキャプチャできる。

@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]
    # ...

しかし db_conn はコネクションオブジェクトだ。JSONにシリアライズすることはできない。シリアライズできるのはクエリと結果だ。より原則的なアプローチは、純粋なロジックと副作用を分離することだ。結果を引数として渡し、コネクションではなく。

@capture_for_replay
def generate_invoice(
    customer_data: dict,
    line_items: list,
    tax_rate: float
) -> dict:
    # 純粋関数。すべての入力がシリアライズ可能。
    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),
    }

これが functional core, imperative shell だ。重要な入力がすべて引数リストにあるため、キャプチャは些細なことになる。隠れた状態がない。

キャプチャでは消せない非決定論の問題

完璧な入力キャプチャがあっても、再現できないクラッシュもある。競合状態はスレッドのタイミングに依存する。メモリ破壊はアロケーターの状態に依存する。外部APIは毎回異なるデータを返す。乱数生成器はシードしない限り異なるシーケンスを生み出す。

クラッシュが競合状態なら、同じ入力をシングルスレッドのローカルテストでリプレイしても引き起こされない。元のスレッドを実行する必要があり、つまり「リプレイ」から「分散トレーシング+負荷テスト」に移行している。それは別のツールだ。

ほとんどのアプリケーションレベルのバグにとって、入力リプレイは十分だ。ハイゼンバグには不十分だ。3時間かけてタイミングの問題をリプレイしようとする前に、どちらを扱っているかを把握しておこう。

本番でのキャプチャを運用可能にする

上記のデコレーターはローカルディスクに書き込む。本番では、これらのキャプチャをオブジェクトストレージやエラートラッカーに送信したい。統合は簡単だ: open(capture_path, "w") の呼び出しをS3へのアップロードやSentryの問題への添付に置き換える。

ほとんどのチームは1つの関数から始めるべきだ。最も頻繁にクラッシュするサービスを選ぶ。デコレーターを追加する。次のクラッシュを待つ。到着したら、30分の推測セッションを5分のテスト作成に変えるJSONペイロードが手に入る。

すでにSentryを使っている場合、Breadcrumbs機能がこの文脈の一部を自動的にキャプチャする。カスタムアプローチの場合、このパターンは30行のPythonに収まり、デコレーターやミドルウェアをサポートするあらゆるランタイムで動作する。

今週1つのエンドポイントにキャプチャを追加する

今週、エラーが最も多いエンドポイントにキャプチャを追加する。すべてのエンドポイントではない。すべての関数ではない。1つだ。クラッシュしたら、バグを修正する前にキャプチャからテストケースを書く。テストを実行し、失敗するのを見る。修正を適用し、合格するのを見る。

本番のクラッシュから再現可能なテストケースに至るそのループが、デバッグを考古学と分かつものだ。