クライアント側のエラー収集を作った理由は単純です。ローカルでは再現しにくい、実ユーザーだけが遭遇する問題を知りたかったからです。レポーター自体は期待どおりに動き、失敗を捕まえて私に送ってくれました。
ところが、届くものをすべて「同じ種類の重要なエラー」と扱ってしまっていました。
外部の解析スクリプトが読み込めない。赤い通知。広告スクリプトがブロックされる。赤い通知。クローラーが Google Analytics を読み込めない。赤い通知。動画プレビューで play() を呼んだ直後、Promise が完了する前に停止される。赤い通知。発生元も有用なスタックも分からない Script error. が来る。これも赤い通知でした。
その同じ流れの中には、本当に対応すべき障害も混ざっていました。自分の URL が誤って https://example.comhttps://example.com/... の形になったケースと、ブラウザが /_next/static/chunks/... 配下の自分の Next.js ファイルを読み込めなかったケースです。
収集は機能していました。監視は機能していませんでした。
この違いに気づいてから、フロントエンドの可観測性に対する考え方が変わりました。ブラウザのエラーイベントは「何かが起きた」という観測結果です。それ自体は、まだ診断でも重要度でもインシデントでもありません。
最初の設計ミスは、「error」をそのまま「緊急」にしたことだった
初期の発想は、ほぼ次のようなものでした。
ブラウザが error を報告
↓
CLIENT ERROR を送信
↓
開発者が対応する
実際には、いくつもの別の問いが混ざっています。失敗したのは自分のコードか。現在のページは本当に壊れたのか。想定内のキャンセルか。ブラウザは発生元を特定できるだけの情報を持っているか。アプリは復旧したか。10件のメッセージは10件のインシデントなのか、それとも1件のインシデントが10個の症状を出しただけなのか。
こうした問いに答えられないうちは、1件のイベントを自動的にアラートへ昇格させるべきではありません。
初期の調査では、約18件のメッセージを並べて見たことがあります。大半は外部サービスや通常のライフサイクル由来のノイズでした。明らかに違ったのは2件です。誤って組み立てた自分の URL は確実なバグで、JavaScript チャンクの読み込み失敗はページに必要なコードが届かない可能性がありました。それでも当時のレポーターは、広告ブロックとほぼ同じ緊急度で扱っていました。
ここで「ブラウザエラーを全部集めること」と「本番監視を作ること」は別の仕事だと考えるようになりました。収集は証拠を残し、監視はその証拠を判断に圧縮します。
ブラウザには、すべての失敗を同じ意味で扱える単一のエラーチャネルはない
クライアント側の失敗は、それぞれ異なる意味を持って届きます。
window の error イベントは同期的なスクリプトエラーに使われ、リソースの読み込み失敗にも関係します。未処理の Promise の拒否は別経路で、ブラウザは unhandledrejection を発生させます。スクリプト、画像、メディアなどを読み込む要素も、それぞれ独自の error イベントを発生させます。さらに React や Next.js には、フレームワーク側のエラーバウンダリがあります。
window.error
→ 同期的な script error が外まで届いた可能性がある
unhandledrejection
→ reject された Promise がその時点で処理されていない
element error
→ resource を読み込めなかった、または利用できなかった
framework boundary
→ rendering や execution が error boundary に到達した
グローバルなフックが見ているのは境界に現れた症状です。完全な因果関係まで分かるとは限りません。
この違いを受け入れてからは、すべてのイベントをすぐに1つの汎用 Error と同じ重要度へ押し込むのをやめました。
最初に見るべきなのは「誰の失敗か」
最初に最も効いた分類は、失敗したコードやリソースが誰のものかを見ることでした。
/_next/static/chunks/app/... の失敗は、別オリジン上の広告 SDK が失敗したケースとは違います。自分の URL 生成処理が不正な URL を作ったケースは、解析リクエストがブロックされたケースとは違います。ブラウザ拡張由来のエラーも別のカテゴリです。
- 自分のアプリ:JavaScript、CSS、API、メディア、生成した URL;
- フレームワーク/ランタイム:アプリの実行経路に含まれる Next.js や React;
- 外部連携:解析、広告、ウィジェット、外部 SDK;
- 実行環境:ブラウザ拡張、クローラー、ネットワーク状態、プライバシー機能、ブラウザ固有の挙動。
外部サービスだから無視してよい、という意味ではありません。決済や認証の提供元は重要ですし、広告の障害は収益に影響することもあります。ただし「外部連携が不調」と「アプリがクラッシュ」は同じではありません。
すべてを同じ緊急チャネルへ送ると、そのチャネル自体が何を意味するのか分からなくなります。
2つの自分側の障害で、「対応可能なシグナル」の意味が分かった
不正 URL のケースは単純でした。
https://example.comhttps://example.com/resource
このケースでは AdBlock、VPN、ブラウザの仕様を疑う必要はありません。URL 自体が不正です。どこかで、すでに絶対 URL になっている値へオリジンをもう一度連結していました。
このイベントは、証拠が具体的で、リソースが自分のもので、調べるべきコード経路も自分で制御できるため、すぐ対応できました。
Next.js のチャンク読み込み失敗は別です:
Failed to load script:
/_next/static/chunks/9253.647385b4be0958e4.js
これも自分側のリソースで、ページを壊す可能性があります。ただしイベントだけでは原因は分かりません。古いクライアントが前のデプロイのアセットを要求した可能性もあれば、タイムアウト、リバースプロキシや CDN の障害、通信断、実際のファイル欠落も考えられます。
したがって正しい判断は「原因が分かった」ではなく、「重要度が高く、追加の情報を集めるべき」です。
原因に自信がなくても、重要度は高くなり得ます。
Script error. は手掛かりであって、スタックトレースではない
Error: Script error.
filename: unknown
line: 0
column: 0
見た目は深刻ですが、情報量はほぼありません。
ブラウザはクロスオリジンのスクリプトエラーについて、意図的に詳細を制限します。MDN でも、適切な CORS 設定がない場合は window.onerror から得られる情報が限られ、<script> の crossorigin の設定が取得できるエラー情報に直接影響すると説明されています。
そのため、情報の乏しい Script error. を自動的に「自分のアプリがクラッシュした」と解釈しません。自分のコード、外部コード、注入されたコード、あるいはクロスオリジン制限で詳細を取得できないエラーかもしれません。
イベント自体は残し、ページ、ビルド、ブラウザ、前後のイベントと関連付けます。ただし単発の 0:0 だけでは緊急通知しません。同じ特徴が特定のリリースやルート周辺でまとまり始めたら、そこで優先度を上げます。
「不明」は「無害」ではありません。しかし「重大」とも限りません。
AbortError は本物のエラーでも、正常なライフサイクルの一部になり得る
動画プレビューが、その違いを最も分かりやすく見せてくれました:
AbortError:
The play() request was interrupted by a call to pause()
HTMLMediaElement.play() は Promise を返し、その Promise は拒否されることがあります。メディアのライフサイクル処理は、保留中の再生を意図的に中断することもあります。MDN は load() が未完了の play() Promise を AbortError で中断することも明記しています。
プレビューの一覧では、ユーザーから見れば何も壊れていなくても簡単に起きます。要素がビューポートに入り、コードが play() を呼ぶ。ユーザーがスクロールし、要素が画面外へ出る。再生開始が完了する前に、コードが停止または別メディアへ切り替える。
Promise の拒否は本物です。しかしユーザー側のインシデントは存在しないことがあります。
通常は呼び出し元の近くで処理し、想定内のキャンセルと本当の再生失敗を分けるべきです。unhandledrejection は安全網としては有用ですが、正常なコンポーネントのライフサイクルを初めて解釈する場所には向きません。
外部サービスの障害には、アプリとは別の健全性モデルが必要
初期のログには解析や広告ドメインの失敗が大量にありました。プライバシー機能の強いブラウザ由来のものも、クローラー由来のものもあります。クローラーが Google Analytics を読み込めなかったケースなどは、緊急アラートとしての価値がほとんどありません。
分かるのは「1つのネットワークリクエストが失敗した」ということだけで、人間のユーザーがアプリを使えなかったかどうかはほぼ分かりません。
イベントを保存したこと自体は問題ではありません。自分の JavaScript チャンクの読み込み失敗と同じインシデント欄へ流したことが問題でした。
- アプリそのものがユーザーにとって壊れたのか。
- 外部連携は正常に動いているか?
ブロックされた広告は広告配信の指標に、解析スクリプトの失敗は解析カバレッジの指標にできます。主要機能がそれに依存していないなら、「フロントエンドがクラッシュした」という緊急通知にするべきではありません。
分けて集計すると、外部サービス側の問題も提供元、ブラウザ、地域ごとに見やすくなります。
navigator.onLine は文脈であって、到達可能性の証明ではない
ブラウザ自身がオンラインだと判断しているかも保存するようにしました。この情報は役に立ちますが、あくまで手掛かりです。
ネットワーク障害として分類されたイベントでも、同時に次の値が残っていました:
Online: true
矛盾ではありません。MDN も navigator.onLine はブラウザと OS のヒューリスティックに依存すると注意しています。LAN につながっていても自分のオリジンへ到達できるとは限りません。VPN、ファイアウォール、DNS、部分的なネットワーク障害もあります。
online === false
→ 環境側の問題を疑う強い材料
online === true
→ origin や resource に到達できたことの証明ではない
この小さな区別だけでも、監視システムが単なる手掛かりを確定診断として扱うのを防げます。
1つの根本障害が複数のブラウザイベントを生むことがある
収集情報を増やすと、別のノイズも見えてきました。1つのインシデントが複数のメッセージを生むのです。
チャンクが最初に resource.error を発生させ、その後ローダーが ChunkLoadError を投げ、React や Next.js がエラーバウンダリへ入り、復旧処理が再読み込みを予約することがあります。各層が別々にアラートすると、1回のユーザー操作が複数の本番障害に見えます。
5件のメッセージは心理的には5人のユーザーが影響を受けたように見えますが、実際には1つのセッションと1つのリソースから出ただけかもしれません。
メッセージ本文だけでの重複排除では足りません。必要なのはインシデントの相関です:
session
+ 短い時間窓
+ 正規化した error class
+ first-party resource
+ client build
+ route
生のイベントは残しますが、人に見せるのは最も情報量の多い表現にします。すでにエラーバウンダリから自分のスタックと正確なチャンク URL が取れているなら、その直前の一般的なリソースエラーで2通目の緊急通知を出す必要はありません。
アラートするのはインシデント。保存するのはイベント。
エラー文字列より、その周辺の情報の方が重要になった
後のテレメトリは、かなり構造化されました:
clientBuildId
resource URL
resourceResponseStatus
resourceTransferSize
resourceDurationMs
serviceWorkerVersion
serviceWorkerController
serviceWorkerState
chunkRecoveryScheduled
online
stack / component stack
メディアについては、メディアエラーコード、別手段で確認できる場合の HTTP ステータス、実際に返った Content-Type、そして HTTP 側の失敗かネットワーク配信側の失敗かも記録しました。
これで、単一の例外文字列だけでは答えられない質問ができます。特定のビルドから始まったのか。HTTP レスポンスは返ったのか。Service Worker がページを制御していたのか。復旧処理は実行されたのか。複数イベントが同じリソースを指しているのか。現在のルートは本当に使えなくなったのか。
Resource Timing API からは、リソースの所要時間、転送情報、環境が対応し許可されている場合はレスポンスステータスも得られます。ただし制限があります。クロスオリジンのタイミング情報は制限され、キャッシュ済みリソースでは transferSize: 0 になることがあり、responseStatus はすべてのブラウザで使えるわけではありません。だからこそ null と 0 は、勝手な推測で埋めずに意味のある状態として残します。
ChunkLoadError は症状であって、404検出器ではない
後のあるイベントで、チャンク障害の読み方が大きく変わりました。
ブラウザは Next.js のレイアウト用チャンクに ChunkLoadError を報告していましたが、拡張したテレメトリには次の情報もありました:
resourceResponseStatus: 200
resourceDurationMs: 170523
serviceWorkerState: activated
chunkRecoveryScheduled: true
記録された時間は約170秒でした。正確な根本原因が何であれ、少なくとも次の単純な決めつけは否定できます:
ChunkLoadError === server が 404 を返した
別のチャンク障害では、観測できるステータス自体がありませんでした。タイムアウトとして出たものもあれば、転送情報が残っていたものもあります。同じエラークラスでも、周辺の証拠は違います。
Next.js では特に重要です。/_next/static/ 配下のファイルは通常コンテンツハッシュを含み、不変アセットとして扱われます。現在の Next.js のセルフホスティング文書でも、この種のアセットに長期キャッシュヘッダーを付ける仕組みが説明されています。そのため ChunkLoadError には、デプロイのずれ、古いクライアント、ネットワーク、リバースプロキシ、CDN、キャッシュ、Service Worker、本当に欠けている成果物など複数の仮説があります。
アラート層に原因を作らせるべきではありません。調査に必要な証拠を残すことが役割です。
メディアエラーでも、同じ教訓を別の層から得た
あるケースでは、メディア要素が次のように報告しました:
MEDIA_ELEMENT_ERROR: Format error
これだけ見ると、コーデック非互換に見えます。
しかし一部のイベントで配信経路を追加確認すると、次の結果でした:
HTTP status: 410
Content-Type: text/html; charset=utf-8
failure kind: http
ブラウザは動画を要求したのに、返ってきたのは HTTP エラーを含む HTML でした。メディア要素は HTML を動画としてデコードできないため、表面上は「Format error」になります。役に立つ診断は配信層にありました。
失敗を最初に観測した層と、失敗を起こした層は同じとは限りません。
「コーデック障害」「ネットワーク停止」「キャッシュのバグ」「チャンク欠落」はすべて結論です。テレメトリはまず観測事実を記録するべきです。
ブラウザ障害を5つの軸で評価する
1. 所有者
自分のアプリ、フレームワーク/ランタイム、外部連携、実行環境のどれか。
2. ユーザー影響
現在のルート、レンダリング、認証、チャット、決済など主要フローが壊れたのか。それとも広告、解析、プリロード、プレビューなど任意の機能だけが失敗し、ページ自体は使えたのか。
3. 証拠の質
自分のコードのスタック、リソース URL、HTTP ステータス、ビルド ID、コンポーネントスタックがあるのか。それとも 0:0 の Script error. だけなのか。
4. 再現性と広がり
1つのセッションだけなのか、それとも同じ特徴が同じリリース後に複数ユーザー、ルート、ブラウザへ広がっているのか。
5. 復旧
アプリは自動復旧したか。チャンクの再読み込みは予約されたか。フォールバックは機能したか。ユーザーはまだ先へ進めない状態か。
first-party が明確
+ ユーザー影響が大きい
+ 証拠が強い
+ 複数 session で発生
+ recovery なし
= 緊急 incident
third-party
+ optional
+ 証拠が弱い
+ 単発
+ ユーザー影響なし
= metric または低優先度
ノイズを減らすときほど、安易に捨てない
ノイズがつらくなると、正規表現のブラックリストを大量に作って、邪魔なものを全部捨てたくなります。しかしそれは危険です。
すべての AbortError を抑制すると、本当に中断された API リクエストを隠すかもしれません。すべての Script error. を捨てると、特定ブラウザだけで増える問題を見逃すかもしれません。外部サービスの失敗を全部無視すれば、決済、認証、同意管理など重要な提供元の障害を隠す可能性があります。
ALERT
→ 対応が必要な強い incident
RETAIN / AGGREGATE
→ 保存して集計し、cluster 化したら alert
METRIC / SAMPLE
→ 期待される、または低影響の noise。傾向と例は残す
目標は、監視を静かにすることであって、盲目にすることではありません。
有用な分類器の本体は、高度なコードよりも方針にある
次のコードは本番環境からコピーしたものではありません。最初から持っておきたかった方針を短く表した例です:
function classifyClientEvent(event) {
const owner = classifyOwner(event);
if (isExpectedMediaCancellation(event)) {
return { severity: "metric", reason: "expected-cancellation" };
}
if (owner === "first-party" && breaksActiveRoute(event)) {
return { severity: "alert", reason: "first-party-user-impact" };
}
if (isActiveFirstPartyChunkFailure(event)) {
return { severity: "alert", reason: "application-chunk" };
}
if (owner === "third-party") {
return { severity: "aggregate", reason: "integration-health" };
}
if (isOpaqueScriptError(event)) {
return { severity: "aggregate", reason: "insufficient-evidence" };
}
return { severity: "aggregate", reason: "needs-correlation" };
}
難しいのは breaksActiveRoute() のような関数の中身です。例外名だけでは判定できず、ルートの文脈、リソースの所有元、エラーバウンダリの情報、ときには製品固有の知識まで必要です。
フィンガープリントはメッセージではなくインシデントを追う
文字列の完全一致は重複排除に向きません。圧縮後のスタック位置はビルドごとに変わり、チャンクのハッシュも変わります。URL には動的 ID が入り、ブラウザごとに文言も違います。
{
errorClass,
normalizedFirstPartyResource,
routeFamily,
clientBuild,
sessionId,
shortTimeBucket
}
アプリ全体のクラッシュなら、自分のコードの最上位スタックフレームが役立つことがあります。チャンク障害なら正規化したチャンクリソース、メディア配信障害なら HTTP の失敗クラスやメディアルートの方が、ブラウザ表層のエラーメッセージより有用です。
resource.error
→ ChunkLoadError
→ framework boundary
→ recovery scheduled
こうして、関連する4件の観測を「4回の緊急障害」ではなく「1件のインシデント」として扱えます。
緊急チャネルの役割は、もっと狭くていい
即時アラートは、現在のルートを壊す自分のランタイムエラー、実際の操作に影響する React/Next.js のエラーバウンダリ、現在必要な自分の JavaScript/CSS の読み込み失敗、複数セッションやビルドで繰り返す ChunkLoadError、ユーザーを先へ進めなくする主要 API/データ障害、不正な生成 URL のように自分側の明確な不変条件を破ったケースに絞ります。
単発で情報の乏しい Script error.、復旧に成功した自分のリソース障害、原因層がまだ分からないメディアエラー、まず集約して見るべきブラウザ固有の異常は、すぐ通知せず保持します。
既知の広告・解析リソース障害、想定内のメディア AbortError、クローラーだけで起きる外部サービス障害、強いオフライン兆候がある失敗、現在のルートに影響しない任意の先読みリソースは、通常メトリクスかサンプリング診断へ回します。
緊急アラートが表すべきなのは、ブラウザが吐いたメッセージ数ではなく、対応可能なユーザー影響です。
「エラー件数」よりインシデントを測る
- 1,000セッションあたりの自分側インシデント数;
- ビルド ID ごとの影響セッション数;
- ルートごとのエラーバウンダリ発生数;
- リソースとデプロイごとのチャンク障害;
- 提供元ごとの外部連携失敗率;
- 想定内キャンセルの件数。急増すれば異常として見えるようにする;
- 自動復旧の成功率;
- 一意なインシデント数と生イベント数を分ける。
「イベントが1件起きた」は、本番アラートのしきい値としてほとんど役に立ちません。「新しいビルドで同じ自分側インシデントが複数の独立セッションに広がり、しかも復旧に失敗している」なら、かなり意味のある運用シグナルです。
クライアント監視だけでは根本原因を証明できない
ブラウザのテレメトリには明確な限界があります。
HTTP ステータスが取れない理由は、API が対応していない、クロスオリジン制限がある、リクエストがキャンセルされた、あるいは別の観測ギャップがあるなど複数考えられます。Service Worker が有効だからといって、それが古いリソースを返した証拠にはなりません。デプロイ後の ChunkLoadError も、デプロイのずれを証明しません。online: true もオリジンへ到達できた証拠ではありません。
クライアント側テレメトリの役割は仮説を絞ることです。サーバーログ、リバースプロキシログ、デプロイのマニフェスト、キャッシュ状態、再現確認がまだ必要になることがあります。
可観測性を、際限のないユーザーデータ収集にしたくもありません。フィールドは、失敗の種類を見分けるのに役立つから存在するべきです。
良いテレメトリは、量が多いテレメトリではありません。失敗の種類をよりよく区別できるテレメトリです。
今のルール:イベントを集め、インシデントを調べ、影響に対してアラートする
最初は、レポーターが「何か壊れたか?」に答えれば十分だと思っていました。本番では広すぎる質問です。クローラーが解析へ到達できない、プライバシー機能が広告を止める、メディアの Promise が意図的にキャンセルされる、ユーザーの通信が切れる、外部 SDK が不調になる。どこかでは常に何かが起きます。
自分たちのものか?
ユーザーは機能を失ったか?
証拠はどれだけ強いか?
繰り返しているか?
アプリは recovery したか?
複数 event なのか、1つの incident なのか?
監視をこうした問いの周りに組み直してから、赤いメッセージの洪水がようやくエンジニアリングの道具になりました。
ブラウザエラーは観測です。インシデントは、複数の証拠を結び付けてユーザー影響を説明したものです。アラートは「人が今動くべきだ」という判断です。
この3つを、もう同じものとして扱いたくありません。