你確切知道生產環境在哪裡掛掉。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 或檢查環境變數,這些值都不在 args 和 kwargs 裡。只有當你的本機環境恰好吻合時,重播才會成功。
你可以擴充 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 到可重現的測試案例——就是區分除錯與考古學的界線。