非同期スタックトレース: Error.stackが正しく表示しない理由
マイクロタスクキューが宿題を食べてしまった(そしてデバッグコンテキストも)
午前2時。PagerDutyのアラームが鳴り響いている。
ログを開くと、次のようなエラーが表示されます:
Error: Cannot read properties of undefined (reading 'id') at processTicksAndRejections (node:internal/process/task_queues:96:5)それだけです。関数名も、行番号も、ファイルパスも何もありません。ただの「processTicksAndRejections」です。
async JavaScriptへようこそ。ここでスタックトレースはでたらめで、行番号は関係ありません。
スタックトレースが壊れる理由
同期的なコードでは、コールスタックは美しい系譜です。AがBを呼び出し、BがCを呼び出します。Cがクラッシュしたとき、どうやってそこにたどり着いたのかを正確に見ることができます。
非同期コード(async/await)では、すべてのawaitキーワードが停止点となります。
awaitするたびに、あなたの関数はスタックから引き裂かれます。それはマイクロタスクキューというクライオジェニックフリーザーに保管されます。スタックは今や空(または他の処理を行っている)です。
Promiseが解決すると、あなたの関数は解凍されてスタックに戻されます。しかし履歴は失われています。
エンジンは500ミリ秒前に関数がawaitを呼び出したのは誰かまったく把握していません。ただ実行すべきタスクがあることを知っているだけです。
V8が試みた対処法
Node.jsは助けようと試みています。我々には以下の手段があります:
Error.captureStackTrace():スタックを作成時にキャプチャします。エラーが後で投げられると無意味です。--async-stack-traces:Node.jsがPromiseチェーンの「シャドウスタック」を保持するフラグです。- コスト:アプリケーションのパフォーマンスが30%低下します。
- 結果:役立ちますが、すぐにノイズが増えて使いづらくなります。
本質的な解決策:AsyncLocalStorage
本番環境で生き残るためには、スタックトレースではなく因果関係に注目してください。
実行スレッドがスタックとマイクロタスクキューの間をジャンプしても、コンテキスト(ユーザーID、リクエストID)を”スレッド”にアタッチする必要があります。
Node.jsにはこのための組み込みツールがあります:AsyncLocalStorage
import { AsyncLocalStorage } from 'async_hooks';
const context = new AsyncLocalStorage();
// 1. リクエストをラップcontext.run({ requestId: '123' }, () => { // 2. 深い非同期コードを呼び出す await processOrder();});
// 3. processOrderの内部:async function processOrder() { await db.query();
// 魔法!requestIdがまだ見える const { requestId } = context.getStore(); console.log(`[${requestId}] 注文の処理に失敗`);}中間で何層ものawaitが発生しても構いません。コンテキストは生存します。
本番運用の手順書
err.stackを信用しないでください。設計上不完全です。- 構造化ログを使用してください。
AsyncLocalStorageを使ってすべてのログラインにrequestIdをアタッチします。 - スタックではなくトレースしてください。OpenTelemetryを使用しましょう。これはサービス間の因果関係を可視化し、実際に気にすべき点を示します。
あなたのコードは非同期です。デバッグコンテキストはそうあってはいけません。