12k
All articles

JavaScriptのスタックトレースの読み方

JavaScriptのスタックトレースを読む方法: 最初に見るべきフレーム、asyncの抜け、圧縮コード、source map、Error.causeを解説。

OpenReplay Team
OpenReplay Team
JavaScriptのスタックトレースの読み方

JavaScriptのスタックトレースは時間を遡るように読みます。最上部のフレームが例外をスローした呼び出しであり、その下の各フレームがそこに至るまでの呼び出しです。

よくやりがちなのは、最初の行をざっと眺めて検索ボックスに貼り付け、あとは祈るというやり方です。それは最上部のフレームがReactやJSON.parseのものだったり、すべての関数がoという名前になっているバンドルのものだったりするまでは通用します。

この記事では1つのトレースを最初から最後まで追いながら、各セクションで読み方を1つずつ追加していきます。実際に開くべきフレームはどれか、asyncフレームは何を伝えているのか、minifyされたトレースはどう見えるのか、そしてError.causeの背後にあるスタックが出力したスタックに決して現れないのはなぜか、という点です。

要点

  • 最上部のフレームはエラーがスローされた場所であり、バグが持ち込まれた場所ではありません。自分が書いたファイルを指す最初のフレームが調査の起点です。
  • asyncと付いた非同期フレームは、各awaitが中断・再開した箇所からV8が再構築したものです。そのためawait、Promise.all()、Promise.any()はつなぎ合わされますが、素の.then()チェーンでは途切れが生じます。
  • V8はデフォルトで10フレームしか保持しません。この数は非標準のError.stackTraceLimitから変更できます。
  • new Error(message, { cause })は元のエラーを保持しますが、エンジンが2つのスタックをマージすることはありません。err.stackにはラッパー側だけが表示されます。

JavaScriptのスタックトレースはどのように見えるか

JavaScriptのスタックトレースは、1行目にエラー名とメッセージ、続いて呼び出しごとに1フレームずつ、新しいものから順に並びます。以下はこの記事で繰り返し取り上げるトレースです。カートのファイルをディスクから読み込み、パースして、アプリの残りの部分に渡しています。

SyntaxError: Unexpected token 'b', "{ bad json" is not valid JSON
    at JSON.parse (<anonymous>)
    at parseCart (/app/src/cart.js:5:15)
    at loadCart (/app/src/cart.js:10:20)
    at main (/app/src/main.js:6:16)
    at Object.<anonymous> (/app/src/main.js:12:1)

この順序でフレームを読めば連鎖は明快です。JSON.parseがスローし、parseCartがそれを呼び、loadCartがparseCartを呼び、以下同様に下へ続きます。最下部のフレームはその呼び出しの連鎖が始まった場所ですが、これは必ずしもプログラムのエントリポイントとは限りません。クリックハンドラ、タイマーのコールバック、あるいは決着したPromise経由で到達したコードは、そのコールバックから始まる新しい連鎖を持ちます。

1つのフレームからは4つの情報が得られます。関数名、ファイル、行、列です。minifyされたコードでは、ソースマップがない限り意味を持つのは列だけです。早めに知っておくべき注意点が1つあります。Error.prototype.stackは標準化されていません。どのエンジンも実装していますが、出力される文字列はそれぞれ微妙に異なり、フォーマットを固める TC39 での作業は未完了です。ここでの例はV8の形式で、Chrome、Edge、Nodeがこれに該当します。

最上部のフレームは通常あなたのコードではない

スタックトレースの最上部のフレームは、通常あなたのコードではありません。それは不正な値に気づいたライブラリ、フレームワーク、あるいは組み込み関数であり、つまり「何が壊れたか」は教えてくれますが、「なぜ壊れたか」は教えてくれません。上のトレースでは、at JSON.parse (<anonymous>)は、渡された文字列が有効なJSONではないと報告している組み込みパーサーです。そこに直すべきものは何もありません。

見るべきフレームは、自分が書いたファイルを指す最初のものです。それが/app/src/cart.js:5:15のparseCartです。その行を開くとJSON.parseの呼び出しがあり、不正な文字列が引数として到着したことがわかります。つまりその値は下のフレーム、10行目でファイルを読み込んだloadCartから来たのです。そこが本当の起点で、そこから明らかになる問いは、ファイルの内容はどこから来たのか、なぜ何も検証していなかったのかということです。

この読む順序は一般化できます。node_modulesのパス、<anonymous>、nativeのフレームを下へ読み飛ばし、自分のファイルに行き当たったら、そこから値を供給したフレームを下方向へ追っていきます。

トレースが記録するのはプログラムが障害に至った経路であり、ユーザーがたどった経路ではありません。だからこそ、まったく同じフレームを持つ2つの報告が、一方は5分で直るものになり、もう一方は再現不能な幽霊になるのです。セッションリプレイはその欠けた半分を埋めます。フレームを読んで「どこで壊れたか」を把握し、セッションを見て「アプリがどうやってそれが壊れうる状態に至ったか」を確認するわけです。

非同期フレームとawait境界

loadCartをasyncにすると、トレースはawaitをまたいで広がり、再構築されたフレームにはasyncという接頭辞が付きます。

SyntaxError: Unexpected token 'b', "{ bad json" is not valid JSON
    at JSON.parse (<anonymous>)
    at parseCart (/app/src/cart.js:5:15)
    at async loadCart (/app/src/cart.js:10:20)
    at async main (/app/src/main.js:6:16)

async接頭辞の付いたフレームは、同期フレームと同じ方法で取得されたものではありません。V8はawaitの箇所からそれらを再構築しており、awaitは中断したまさにその場所から再開するため、これをコストなしで実現できます。

この再構築には限界があり、その限界こそトレースが薄くなる箇所です。つなぎ合わせが届くのはawaitのポイント、Promise.all()、Promise.any()だけで、それ以外はありません。そのため、awaitされずに返されたPromiseや.then()チェーンは、ちょうど呼び出し元のコンテキストがあったはずの場所に穴を残します。フレームが非同期境界で突然途切れている場合は、その上の層でawaitが抜けていないか探してください。有効にすべきフラグはありません。--async-stack-tracesはV8 v7.3以降デフォルトで有効なので、非同期フレームはChrome、メンテナンスされているすべてのNodeリリース、その他の現行V8ランタイムで表示されます。

フレームが欠ける、もう1つの理由は上限です。V8は10フレームを保持して残りを捨てます。Error.stackTraceLimitがその数のダイヤルです。新しい値は設定後に作成されたエラーに適用され、数値でないものやゼロ未満のものを指定するとフレームがまったく取得できなくなります。

if (process.env.NODE_ENV !== 'production') {
  Error.stackTraceLimit = Infinity;
}

これは開発環境に限定しましょう。深いトレースはメモリを消費し、重要なフレームをノイズに埋もれさせ、ファイルパスや関数名を、どこか別の場所に送られる可能性のあるログに押し込みます。このプロパティは非標準です。V8由来で、JavaScriptCoreが互換性のために模倣したものなので、設定してどこかが壊れることはありませんが、デフォルト値や細かな挙動はエンジンによって異なります。

minifyされたトレースはどう読むか

本番用バンドルに対しては、同じ障害が次のようなフレームを生成します。この形はバンドル出力の一例として捉え、特定のツールの正確なフォーマットとは考えないでください。

SyntaxError: Unexpected token 'b', "{ bad json" is not valid JSON
    at JSON.parse (<anonymous>)
    at o (/assets/index-4f1c8a2b.js:1:20874)
    at async s (/assets/index-4f1c8a2b.js:1:21036)

特徴は明白です。1文字の関数名、ファイル名は1つ、行は1、そして5桁か6桁の列番号。行1で列番号が巨大ということは、モジュールグラフ全体が1行に収まっているということであり、情報を持つ座標は列だけです。parseCartとloadCartはその列オフセットの中に依然として存在していますが、この文字列からは名前を知る手立てはありません。

名前を復元するには、ビルド時に生成され、トレースを解決できる場所にアップロードされた、ツールが到達可能なソースマップが必要です。ソースマップの仕組みについてのガイドで、フォーマットとビルド設定を解説しています。

Error.causeはスタックをマージしない

new Error(message, { cause: originalError })でエラーをラップすると、元のエラーオブジェクトはその型と自身のスタックとともに保持されますが、エンジンが2つのスタックをマージすることはありません。err.stackにはラッパー側だけが表示され、元のスタックはerr.cause.stackを通してのみ到達できます。

export async function loadCart(path) {
  const raw = await readFile(path, 'utf8');
  try {
    return parseCart(raw);
  } catch (err) {
    throw new Error(`Cart file ${path} is not valid JSON`, { cause: err });
  }
}

呼び出し側はファイル名を含むメッセージを受け取るようになり、err.cause instanceof SyntaxErrorも依然として成り立ちます。得られないのはJSON.parseのフレームです。ラッパーのスタックはloadCart内のthrowから始まります。Error.causeはES2022で導入され、現行のブラウザとメンテナンスされているすべてのNodeリリース(Node 16.9.0以降)で動作します。列挙不可の自身のプロパティとして設定されるため、Object.keys()、for...in、そしてエラーに対する素朴なJSON.stringify()には現れません。

チェーンのどこまでをコンソールが出力するかはランタイムやコンソールによって異なるため、可搬性のある方法は自分で辿ることです。

function printChain(error) {
  let current = error;
  while (current instanceof Error) {
    console.error(current.stack);
    current = current.cause;
  }
}

これはラッパーのフレーム、続いてパーサーのフレームを、スローされた順に出力します。

手がかりを壊す2つの習慣

エラーをcatchして、causeを渡さずに新しいエラーをスローすると、元のトレースはプログラムから消えます。不正な値がどこから入ったかを教えてくれたはずのフレームはもうどこにも存在せず、どれだけログを検索しても戻ってきません。

catch (err) {
  throw new Error('Could not load cart');   // JSON.parse frame is gone
}

エラーをログ行に飲み込ませるのも、より静かに同じ損害をもたらします。

catch (err) {
  console.log('cart load failed');          // message, no stack, no type
  return [];
}

どちらもキーワード1つで問題なくなります。再スローするときは{ cause: err }を渡し、エラーについての文章ではなくerrそのものをログに出しましょう。

次にトレースが目の前に届いたときは、1行目から始めないでください。自分のファイル名が載っている最初のフレームまでスクロールし、その行を開いて、どんな値が誰から渡されたのかを問いましょう。フレームがasync境界や1文字の関数名で止まっているなら、それはつなぎ合わせの欠落かソースマップの不足を見ているのであって、物語の全体を見ているわけではありません。

FAQ

エラーハンドラが 'Script error.' だけを報告し、スタックが出ないのはなぜですか?

ブラウザはクロスオリジンのスクリプトがスローした例外の詳細をマスクするため、window.onerror には汎用的な 'Script error.' というテキストが渡され、有用なURL、行番号、スタックは得られません。本来のメッセージとフレームを取得するには、crossorigin属性をanonymousに設定してスクリプトを読み込み、それをホストしているサーバーが自分のオリジンを含むAccess-Control-Allow-Originヘッダーを返すようにしてください。主要な公開CDNはすでにそのヘッダーを送信しています。

Error.captureStackTrace はChromeとNode以外でも動作しますか?

もはやV8限定ではありません。Error.captureStackTrace はV8の非標準スタックトレースAPIの一部として登場しましたが、その後他のエンジンも追随しました。JavaScriptCoreは2023年12月11日にリリースされたSafari 17.2で、SpiderMonkeyは2025年4月29日にリリースされたFirefox 138で搭載しています。呼び出すと、渡したオブジェクトにスタック文字列が書き込まれます。まだ標準化されていないため、共有ライブラリのコードで使う際は typeof Error.captureStackTrace のチェックでガードしてください。

FirefoxのスタックトレースがなぜChromeと違って見えるのですか?

Error.prototype.stack はあらゆる仕様の外にあるため、各エンジンが好きな形式で出力し、内容も異なります。V8は各フレームを 'at' で始まる行に書きますが、Firefoxはそのような接頭辞のない functionName@file:line:column という形式を使います。スタック文字列は人間が読むための出力であり、パース可能なAPIではないと捉え、自作の正規表現だけに頼ってエラーのグルーピングを構築しないでください。

エラーをスローせずにスタックトレースを取得できますか?

できます。現行のほとんどのエンジンでは、スタックはErrorをスローしたときではなく構築したときに埋められるため、const { stack } = new Error() とすればthrowもcatchもなしにその場でコールスタックが得られます。最上部のフレームはそのエラーを作成した行で、通常のフレーム上限も引き続き適用されます。V8はError.stackTraceLimitを上げない限り10フレームしか保持しません。

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.