你確切知道生產環境在哪裡掛掉。Stack trace 指向 invoice_service.py 的第 147 行。例外是對 "customer_id"KeyError。你拉下程式碼,執行測試,全部通過。你用手動方式帶著範例 payload 命中端點,一切正常。

Bug 是真的。客戶正在觸發它。你卻無法在自己的機器上重現。

這就是根據錯誤報告進行除錯的標準體驗。Stack traces 告訴你位置。它們不會告訴你是什麼東西抵達了那裡。如果沒有觸發失敗的確切輸入,你就是從粉筆輪廓的照片來重建犯罪現場。

Crash replay 到底意味著什麼

Crash replay 是一種做法:擷取觸發生產環境失敗的完整輸入狀態,然後在本機環境中用那個確切狀態重新執行程式碼路徑。目標是把「它在第 147 行掛掉」變成「這是一個每次都能讓第 147 行掛掉的測試案例」。

大多數開發者已經在用手動版本做這件事。你讀 stack trace,猜是哪個請求導致的,試著從日誌重建 payload,並祈禱你的本機資料庫有類似的資料。這會失敗,理由跟占星術失敗的理由一樣:你在沒有足夠資訊的情況下比對模式。

猜測與重播之間的差別在於序列化。你必須在失敗發生的那一刻擷取確切的函式輸入、確切的資料庫回應,以及確切的外部 API 回傳值。然後把它們餵回去。

如何擷取生產環境輸入以進行重播

最簡單有效的模式是一個 decorator:攔截函式的引數、將它們序列化到磁碟,然後送到你稍後可以存取的地方。當函式 crash 時,你就擁有了一個產生 crash 的世界的凍結快照。

以下是一個可用的 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

把它套用到正在 crash 的函式上:

@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}

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

第一個引數是一個空字典。這就是全部的 bug。上游呼叫者傳了 {} 而不是客戶記錄。

你現在就有了一個測試案例:

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

這個測試在修復前會失敗,在你加入輸入驗證後會通過。更重要的是,你不必猜測。Crash 明確告訴你要測什麼。

為什麼擷取會漏掉它看不見的相依性

這個模式擷取的是函式引數,不是全域狀態。如果 generate_invoice 從資料庫讀取、呼叫外部 API 或檢查環境變數,這些值都不在 argskwargs 裡。只有當你的本機環境恰好吻合時,重播才會成功。

你可以擴充 decorator 來明確擷取外部相依性:

@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。你可以序列化的是查詢和結果。更有原則的做法是把純邏輯與 side effects 分開。把查詢結果當作引數傳入,而不是連線:

@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),
    }

這就是 functional core, imperative shell。它讓擷取變得微不足道,因為沒有隱藏狀態。每個重要的輸入都在引數清單中。

你無法用擷取解決的非確定性問題

即使有了完美的輸入擷取,有些 crash 還是無法重現。Race conditions 依賴執行緒時序。記憶體損壞依賴配置器狀態。外部 API 每次呼叫回傳不同資料。亂數產生器除非設定種子,否則會產生不同序列。

如果你的 crash 是 race condition,在本機單執行緒測試中重播相同輸入不會觸發它。你需要實際的並行模式,這意味著執行原始執行緒,也就是你已經從「重播」走向「distributed tracing 加負載測試」。那是另一種工具。

對於大多數應用層級的 bug,輸入重播就足夠了。對於 heisenbugs 則不然。在你花三小時試圖重現一個時序問題之前,先搞清楚你面對的是哪一種。

讓擷取在生產環境中運作

上面的 decorator 寫入本機磁碟。在生產環境中,你希望這些擷取被送到物件儲存空間或你的錯誤追蹤器。整合很直接:把 open(capture_path, "w") 的呼叫換成 S3 上傳或附加到你的 Sentry issue。

大多數團隊應該從一個函式開始。選最常 crash 的服務。加上 decorator。等待下一次 crash。當它發生時,你會得到一個 JSON payload,把三十分鐘的猜測過程變成五分鐘的測試撰寫。

如果你已經在使用 Sentry,Breadcrumbs 功能會自動擷取部分這類情境。如果是自訂方案,這個模式只要三十行 Python,並且可以在任何支援 decorator 或 middleware 的執行時期中運作。

本週就為一個端點加上擷取

本週就為錯誤率最高的端點加上擷取。不是每個端點。不是每個函式。一個就好。當它 crash 時,在修復 bug 之前先從擷取寫出測試案例。執行測試,看著它失敗,套用修復,看著它通過。

這個循環——從生產環境 crash 到可重現的測試案例——就是區分除錯與考古學的界線。