今さらJavaScriptエラーの監視に向けて

JavaScriptのRuntimeエラーを捕まえたい

いろいろあって JavaScript エラーを監視したいと思い調べていたが、基本的な部分と各種ライブラリやフレームワーク固有の部分がごちゃごちゃしていたので頑張って整理してみた。

なお、例外や try-catch その他構文的な部分やライブラリ、フレームワーク固有の考え方の説明などはすべて割愛する。

今回確認したいのはコードを呼び出す側から呼び出し先で発生したエラーを捕捉できるかどうか

かつてwindow.onerrorがあった

いや今もあるんだけど。ブラウザの話ね。

ただこれ、window で動いているすべてのアプリケーションのエラーに対してまったく同じように反応するので、さまざまな 3rd party アプリがうごめく現代のブラウザ環境で使うのはかなり厳しい。特定のネイティブアプリ x 特定の 3rd party アプリの組み合わせでだけ発生するエラーとか、捕捉できても手の施しようがない。1

そこで window.onerror でごっそり拾うのではなく、狙ったコードのエラーだけを拾うことはできないか、改めて確認した。

まずJavaScriptエラーの発生箇所ごとの捕まえ方

  1. 通常の順次実行
  2. callback
  3. Promise
  4. async/await

1. 通常の順次実行

function sync () {
  Foo
}

function main () {
  try {
    sync()
  } catch (e) {
    console.error(['caught by main()', e])
  }
}

main()

結果

期待通り、呼び出し元で捕捉できる。ということは呼び出し元で監視システムへの送信の仕組みが整っていればエラーの発生状況は監視できる。

以降、基本的にはこの考え方で確認していく。

[
  'caught by main()',
  ReferenceError: Foo is not defined
  ..

2. callback

function callback () {
  setTimeout(() => {
    Foo
  }, 0)
}

function main () {
  try {
    callback()
  } catch (e) {
    console.error(['caught by main()', e])
  }
}

main()

結果

main() の try-catch では捕捉できない。JavaSctipt のホスト環境そのものまで捕捉できないので、他になんらかのプログラムが同時に動いていたら、それらとエラーは混ざってしまうことを意味する。

    Foo
    ^

ReferenceError: Foo is not defined

3. Promise

async function async () {
  return new Promise((resolv, reject) => {
    Foo
  })
}

function main () {
  try {
    async()
  } catch (e) {
    console.error(['caught by main()', e])
  }
}

main()

結果

Promise も callback なので捕捉できない。

4. async/await

async function async () {
  return new Promise((resolv, reject) => {
    Foo
  })
}

async function main () {
  try {
    await async()
  } catch (e) {
    console.error(['caught by main()', e])
  }
}

main()

結果

捕捉できる。

[
  'caught by main()',
  ReferenceError: Foo is not defined
  ..

ここまでのまとめ

発生箇所捕捉可否
通常の順次実行YES
callbackNO
PromiseNO
async/awaitYES

そりゃそうだ。

callback, Promise は callback の中で try-catch すればそこでは捕捉できるのだが、ここで捕捉してしまうと今度は「その捕捉したエラーを呼び出し元にどう伝えるか?」が課題になる。

  1. 意図通りに捕捉できないエラーは他のプログラムのエラーと混ざってしまう
  2. 実行箇所で捕捉したエラーを呼び出し元に適切に伝えられない場合はエラーそのものが隠蔽されてしまう

ブラウザ上で動くプログラムのエラーを監視するのが難しいのはこのためだ。現在のブラウザ上では自分が書いたプログラム以外のコードが大量に動いている。(GTM, GA はじめ各種効果計測用の何か、など)

だから自分が作って自分で呼び出したプログラムのエラーだけ拾いたい場合は上の原則を原則を踏まえたうえでもう一工夫が必要になる。

外から捕捉できないエラーをどうにか中から外に伝える方法

まぁ

error handler関数を与える

callback に対して外から try-catch はできないので、

  1. callback の中に try-catch を埋める
  2. その try-catch で捕捉したエラーを外から与えられた error handler 関数に渡す

これで中のエラーを外に伝えることができる。

function async (err) {
  setTimeout(() => {
    try {
      Foo
    } catch (e) {
      err(e)
    }
  }, 0)
}

function main () {
  try {
    async((err) => {
      console.error(['handled by callback', err])
    })
  } catch (e) {
    console.error(['caught by main()', e])
  }
}

main()

結果

古式ゆかしき Node.js みが出てきたけど、これで一応捕捉できる。

[
  'handled by callback',
  ReferenceError: Foo is not defined
  ..

呼び出し側の try-catch では捕捉できていないが、与えた callback の方で捕捉できている。

とは言え、実際にはどこでどんな風に捕捉できないエラーの発生源が隠れているかをすべて把握するのはかなり難しいのと、階層が深くなっていった先でどうするのか問題がある。

DOM Eventで伝える

ブラウザで考えるなら DOM Event でエラーを伝播させる方法がある。

  1. コードを実行するDOMを特定する
  2. その DOM のなんらかのイベントでコードを実行
  3. 同じ DOM で error イベントを捕捉
<html>
  <body>
    <button id="button">ボタン</button>
    <script>
      const b = document.getElementById('button')
      b.addEventListener('click', (e) => {
        click(e.target)
      })
      b.addEventListener('error', (e) => {
        console.error(['caught by DOM Event', e.detail])
      })

      function click (ele) {
        async(ele)
      }

      function async (ele) {
        setTimeout(() => {
          try {
            Foo
          } catch (e) {
            ele.dispatchEvent(new CustomEvent('error', { detail: e }))
          }
        }, 0)
      }
    </script>
  </body>
</html>

結果

[
  "caught by DOM Event",
  {
    message: "Foo is not defined,
    stack: "ReferenceError: Foo is not defined\n
    ..
    "
  }
  ..
]

なんとか stack 含めて把握できる。この情報を監視システムに送ればイケる。

実際には非同期処理の多くは async/await で処理できるだろうし、event loop と timer で処理するようなもの以外はここまでの工夫は要らないかもしれない。

フレームワークの状況

Stimulusは半自動でapplicationレベルでエラーハンドラが用意されている

Stimulus: A modest JavaScript framework for the HTML you already have.

Stimulus はコンポーネントファーストではないし、JSX のような変換もない。全部ただの HTML でただの JavaScript である。

  1. HTML 断片を用意
  2. そこに Controller を bind
  3. Controller からは Stimulus Application を辿れる
  4. application 全体が try-catch の中で実行されている
  5. Application に handleError メソッドがある

Stimulus Handbook

ということで、イベントハンドラが通常の try-catch でエラーの捕捉が可能な順次実行か async/await で実行されていれば、Stimulus アプリケーションでは自分で try-catch で囲んだりしなくてもアプリケーション全体のエラーを捕捉できる

class Kontroller extends Controller {
  ..
}

const app = Application.start()
app.register('k', Kontroller)

app.handleError = (error, message, detail) => {
  console.error(..)
}

そしてどうにか Controller までエラーを運んでくれば、そこからはそんなに大変じゃない。すぐに application が見える。

  loop () {
    // model の中の callback の中に対して error handler を与えて、
    // Application レベルで処理
    model.start((err) => {
      this.application.handleError(e)
    })
  }

Vue.js

Production Error Code Reference | Vue.js

import { createApp } from 'vue'

const app = createApp(<component>)
app.config.errorHandler = function (error) {
  console.error('Vue: ' + error.stack)
}
app.mount(<element>)

こんな感じ。拾えるエラーの制限は通常の JavaScript と同様。

component 単位でも Composition API, Options API それぞれで error handler がセットできる。

あるいは $el$refs を利用して DOM 側へアクセスできるので、そこで上の DOM 経由の方法を使うこともできる。

React.js

アプリケーション全体のエラーハンドラ(19以降)

React は長らく ErrorBoundary の話題ばかりだったけど、React 19 ( 18時点では Canary ) にて標準で3パターンのエラーハンドラを登録できる。

createRoot – React

const root = createRoot(
  <rootElement>,
  {
    onCaughtError (e) {
      console.error(['in onCaughtError', e])
    },
    onUncaughtError (e) {
      console.error(['in onUncaughtError', e])
    },
    onRecoverableError (e) {
      console.error(['in onRecoverableError', e])
    }
  }
)
root.render(<component />)

React の error handler は細かく分かれているが、これは前述の ErrorBoundary の関係。onCaughtError は Error Boundary が catch したうえで投げているもの。

renderそのものをtry-catch

React の Functional Component は関数で定義されていてコンポーネントのレンダーは関数の実行と解釈できるので、以下のようにも書ける。

function Child () {
  return (
    ..
  )
}

function RootComponent () {
  try {
    return <Child />
  } catch (e) {
    ..
  }
}

onErrorのpropertyにエラーハンドラを与える

function onError (e) {
  .. // <- やりたいことを書く
}

const root = createRoot(<rootElement>)
root.render(<component onError={onError} />)

これで props down させておくと、好きなタイミングで onError を呼べる。Promise や callback の処理に対してこれを与えればどこからでも監視システムと接続可能。React は function を props で投げつけるスタイルなので、これも普通っちゃ普通だし、いちばん最初の古式ゆかしき Node.js みを感じる仕立てと同じになる。

DOMも外から与えれば同じようにイケんこともない

React はもともと DOM と疎結合なので Vue ほどには楽に DOM 要素にタッチできないが、エラーハンドラと同様に props で渡すことはできる。

できるけどエラーハンドラを渡す方がよほど簡単に目的を実現できると思う。

まとめ

  1. JavaScript で Runtime エラーを捕捉する方法は try-catch が基本なのだが、callback に対しては無力で、案外拾えないケースが多い(すべて async/await しかないならなんとかなるかも)
  2. これに対して外からエラーハンドラを与える方法など、なんとか中のエラーを外(呼び出し側)に伝える方法はある
  3. 監視システムに繋ぐ仕組みは基本的にこの呼び出し側にある。Viewライブラリやフレームワークはその仕組みを用意してくれているが、2 が分かっていないと適切に狙ったエラーの情報だけを取得するのは難しい
  4. ユーザーに通知するアプリケーション上のエラー状態についてはできるだけ throw / try-catch を使わずに Result 型などで実現しておくと、監視関係のコードが際立つのでよいと思う2(名前も例外の Error とは明確に異なる呼び方をするのがよさそう。)

これらを原則としたうえで、エラーが監視可能かどうかを常に確認しながら書くのが大事なんだろうけど、こういうのに適した Lint ルールってないのかな? それとも歯を食いしばって頑張るしかないんだろうか。

  1. 特に toC 領域、マーケ領域に近づけば近づくほど難しい。toB で閉じたアプリケーションであれば恐らくそんなに問題にならないだろう。 

  2. ただし React は ErrorBoundary という考え方が先に確立されてしまったので話がややこしくなりそう。 

More