Theme
SD MILIEU

2021-10-5

非同期処理内部で生成されたErrorのスタックトレースは使いづらい事が多いという話

const syncThrow = () => {
  throw new Error('error')
}

const secondFunc = () => {
  syncThrow()
}

const firstFunc = () => {
  secondFunc()
}

firstFunc()

上のような同期処理の場合は以下のようにエラーが発生するまでの経路がわかりやすいスタックトレースが生成される。

Error: error
    at syncThrow (/path/to/program/main.js:2:9)
    at secondFunc (/path/to/program/main.js:6:3)
    at firstFunc (/path/to/program/main.js:10:3)
    at Object.<anonymous> (/path/to/program/main.js:13:1)

これが、次のように setTimeoutfetch など、非同期処理の内部で生成されたErrorの場合以下のようになる。

const asyncThrow = () => {
  setTimeout(() => {
    throw new Error('error')
  }, 100)
}

const secondFunc = () => {
  asyncThrow()
}

const firstFunc = () => {
  secondFunc()
}

firstFunc()
Error: error
    at Timeout._onTimeout (/path/to/program/main.js:3:11)

Errorが生成された行はわかるので、 asyncThrow でErrorが生成された事まではわかるが、当該 asyncThrow がどういう経路で呼ばれたものかはわからない。非同期処理は現在スタックに積まれている処理が終わってから実行されるので、ソースコードの見た目通りのスタックトレースにならないのだ。

ならどうすればいいかと言うと、非同期処理を呼ぶ直前でErrorを生成し、それのスタックトレース情報を追加してやればいい。

const asyncThrow = () => {
  const syncStack = (new Error('setTimeoutが呼ばれるまでのスタックトレース')).stack

  setTimeout(() => {
    const error = new Error('error')
    error.stack = `${error.stack}\n${syncStack}`
    throw error
  }, 100)
}

const secondFunc = () => {
  asyncThrow()
}

const firstFunc = () => {
  secondFunc()
}

firstFunc()
Error: error
    at Timeout._onTimeout (/path/to/program/main.js:5:19)
    at listOnTimeout (node:internal/timers:564:17)
    at process.processTimers (node:internal/timers:507:7)
Error: setTimeoutが呼ばれるまでのスタックトレース
    at asyncThrow (/path/to/program/main.js:2:22)
    at secondFunc (/path/to/program/main.js:12:3)
    at firstFunc (/path/to/program/main.js:16:3)
    at Object.<anonymous> (/path/to/program/main.js:19:1)

スタックトレースの見た目が独特になるのでチームメンバーへの周知が必要にはなるが、これで非同期処理が呼ばれるまでのスタックトレースを出力することが可能になる。

実際には、axios等を使う非同期通信処理で問題になることが多いと思われるので、axios等をラップしたモジュールを用意し、そのモジュール内で前述の処理を書いてやればいいかと思う。