ブラウザの可観測性
今回は、ブラウザの可観測性について書いてみたい。
筆者はこのブログに、訪問者が体感する性能を直接収集するコードを組み込みながら、慣れ親しんだと信じていたブラウザ側の知識が思ったより浅かったことに気づいた。DevToolsのPerformanceパネルを開き、Lighthouseを実行することは長くやってきた。しかし実際のユーザーが経験したことを継続的に収集しようとすると、問いが変わる。ブラウザがいつどんな値を作り、その値が何を含み、どんな条件ではなぜ作られないのかまで知らなければならない。
この知識は一つの文書にまとまっていない。Performance Timeline、Navigation Timing、Resource Timing、Paint Timing、Event Timingのように複数の仕様に散らばっており、その上にWeb Vitalsが独自の計算ルールを載せている。SPAの画面遷移、bfcacheによるページ状態の復元、バックグラウンドタブ、iframeまで入ってくると、同じページを見ていてもツールごとに違う数字が出る。(筆者も値が予想と違うとき、サイトが遅いのか測定ルールのせいなのかを切り分けるところで最も長く迷った)
そこでこの記事は、ブラウザが残した出来事、ユーザーが体感した経験、実際のユーザー集団の分布という三段階で観測の範囲を広げていく。その合間に、このブログが実際に収集している値と、逆に観測を増やさないことにした決定も併せて置いてみる。ブラウザの可観測性の深さは、APIをどれだけ多く使うかでは決まらない。原材料と指標と分布を区別し、その過程で漏れたユーザーや歪んだ条件まで記録するとき、観測値は判断に使える情報になる。
ブラウザが残す信号
ウェブページが開かれると、ブラウザは内部で複数の種類のPerformanceEntryを作る。W3CのPerformance Timelineは、これらの項目を一つの時間軸で扱える共通基盤だ。
重要なのは、開発者がストップウォッチのように開始と終了を自分で打刻しなくてよいという点だ。ブラウザはドキュメントのナビゲーション、リソース要求、ペイント、ユーザー入力といった出来事をすでに知っている。フロントエンド観測の出発点は、この内部記録を読むことだ。
| 観測対象 | 代表的なentry | 答えられる問い |
|---|---|---|
| ドキュメントのナビゲーション | navigation |
DNS、接続、TLS、応答、DOM処理のどこで時間がかかったのか |
| 画像・スクリプト・CSS | resource |
どのリソースが遅れ、キャッシュと転送サイズはどうだったのか |
| 画面表示 | paint, largest-contentful-paint |
最初の画面と主要コンテンツはいつ見えたのか |
| レイアウトの変化 | layout-shift |
ユーザーが見る画面はいつ、何のせいで動いたのか |
| ユーザー入力 | event |
入力の後、ブラウザが次の画面を描くまでどこで遅延したのか |
| 長いレンダリング作業 | long-animation-frame |
一つのフレームの中でどのスクリプトとレンダリング段階が時間を使ったのか |
| アプリケーション区間 | mark, measure |
サービスが定義した処理はどれだけかかったのか |
この表を見ると、ブラウザ観測が単なるページ読み込み時間の測定ではないことがわかる。ネットワーク、メインスレッド、レンダリングパイプライン、ユーザー入力を同じ時間軸の上に載せられる。
しかし、それぞれのentryは完成した結論ではない。ブラウザが提供する原材料に近い。
性能entryの収集
この原材料をリアルタイムで受け取る標準インターフェースがPerformanceObserverだ。
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
sendPerformanceEntry(entry)
}
})
observer.observe({ type: 'resource', buffered: true })コードは短いが、ここにはいくつかの重要な条件が隠れている。
第一に、MDNのobserve() のドキュメントが説明するように、bufferedはtypeと一緒に使わなければならない。複数の種類を一度に受け取るentryTypesとは併用できない。初期スクリプトの実行前に作られたLCP候補やリソース記録を受け取る必要があるなら、この違いがデータ欠落を分ける。
第二に、ブラウザがサポートしないentryは静かに無視されることがある。したがって収集側はPerformanceObserver.supportedEntryTypesを確認しなければならない。最新のChromeで見える値がすべてのSafariとFirefoxからも届くと仮定すると、ダッシュボードの空白区間を性能問題と誤解することになる。
第三に、entryにはバッファの上限がある。観測コードの開始が遅かったり、リソースを非常に多く要求するアプリケーションでは、過去の項目がバッファから押し出されることがある。「エラーがなかった」と「エラーを受け取れなかった」が違うように、「entryがない」と「entryが生成されなかった」も区別しなければならない。
観測コード自体もメインスレッドで実行される。コールバックの中で大きなオブジェクトをシリアライズし、即座にネットワーク要求を送れば、ユーザー体験を測るコードがユーザー体験を悪化させかねない。収集と送信を分離し、必要な属性だけを正規化し、バッチングとサンプリングを設計しなければならない理由だ。
ネットワーク時間の構成
ページが遅いという話を、真っ先にネットワークの問題に翻訳してしまうことが多い。しかしdurationだけを見ても原因は区別しにくい。
PerformanceNavigationTimingとPerformanceResourceTimingには、リクエスト前後の複数の境界が入っている。DNSルックアップ、TCP接続、TLSネゴシエーション、リクエスト送信、最初のバイト、応答完了を分けて見られる。ブラウザがService Workerを経由したのか、転送されたサイズとデコードされたサイズがどれだけ違うのか、リソースがレンダリングをブロックしたのかも確認できる。
この区分は対応を変える。
domainLookupEnd - domainLookupStartが大きければDNSの層を見る。connectEnd - connectStartが大きければ接続とTLSを見る。responseStart - requestStartが大きければサーバー処理とネットワーク往復を併せて疑う。responseEnd - responseStartが大きければ応答サイズと転送速度を見る。transferSizeが0ならキャッシュ再利用の可能性を確認しつつ、cross-originの公開制限やブラウザ実装の条件も併せて見る。
ただし、cross-originリソースは既定では詳細区間が隠される。W3CのResource Timing仕様が説明するように、提供側サーバーがTiming-Allow-Origin応答ヘッダーで許可して初めて一部の詳細値が公開される。CDNや外部画像が遅いように見えても、ブラウザの中では区間が0に見えることがあるのだ。
だからRUMデータにおける0は、常に速いという意味ではない。権限のせいで見えない値かもしれない。
Web Vitalsの解釈
Web Vitalsに含まれるLCP、INP、CLSは、ブラウザが単に一度記録したタイムスタンプではない。複数のentryとページのライフサイクルを解釈して作った、ユーザー中心の指標だ。
LCPは、画面に見える主要コンテンツの候補が変わるたびに更新される。INPはページの生存期間中のクリック、タップ、キーボード操作を観察した後、最も遅い値に近い一つを代表値として選ぶ。インタラクションが50回を超えると極端な値の一部を除外するが、ほとんどのページでは最も遅いインタラクションがINPになる。CLSはすべての移動を無限に足し合わせるのではなく、一定の時間間隔でまとめられたセッションウィンドウのうち最大の値を選ぶ。
自前で計算することはできるが、ページが非表示になったり復元されたりする時点まで含めて境界条件を合わせるのは難しい。Googleのweb-vitalsライブラリは、ブラウザのentryをそのまま渡す道具ではなく、標準APIの上でこうしたライフサイクルと指標ごとの計算ルールを適用する実装だ。
ここから一段深く入ると、スコアだけでは足りないという問題が生じる。LCPが4秒だという事実はわかったのに、どの要素とリソースがその値を作ったのかわからなければ、修正すべき箇所を見つけられない。web-vitals/attributionビルドは、LCPの要素、INPのイベント対象と処理区間、CLSに寄与した要素のように、原因に近い情報を追加する。
つまり、観測は次の三段階で深まっていく。
- 指標を収集する。
- 遅いユーザーと環境の分布を見つける。
- 指標を生み出した要素、スクリプト、リクエストまで帰属させる。
第一段階だけではレポートが生まれ、第三段階まで行って初めて修正可能な情報が生まれる。
ラボと実際のユーザー
LighthouseとDevToolsは、統制された条件で同じページを繰り返し測定するのに向いている。コードをデプロイする前にリグレッションを捕まえたり、特定のプロファイルを深く分析するときに有用だ。しかし、ユーザーが実際にどんなデバイスとネットワークでどんな操作をしたのかは見せてくれない。
RUM(Real User Monitoring)は、実際の訪問者のブラウザで値を収集する。GoogleのWeb Vitalsドキュメントは、Core Web Vitalsをページ訪問の75パーセンタイルで評価し、モバイルとデスクトップを分けて見るよう勧めている。平均値一つでは、遅いユーザーの集団が消されてしまいかねないからだ。
特にINPは、実際の入力がなければ計算できない。ユーザーのいないLighthouseでは、INPの代わりにTBTを代理指標として使う。両者は関連しているが、同じ値ではない。
Chrome UX Report(CrUX)もRUMだが、サービス内部のRUMとは性格が違う。CrUX APIは、Chromeユーザーの集計されたfield dataをページまたはorigin単位で提供する。業界の基準と比較するにはよいが、特定のリリースやユーザーフロー、アプリケーションの状態を併せて見ることはできない。
逆に、自前のRUMは望む文脈を付けられるが、標本の偏りと実装ミスを自分で引き受けなければならない。広告ブロッカーが収集リクエストを遮ったり、同意していないユーザーを除外したり、特定のブラウザがAPIをサポートしていなければ、観測されるユーザーは全体のユーザーと違うものになる。
筆者のブログがその選択の小さな例だ。このブログではWebVitalsReporterというクライアントコンポーネント一つがweb-vitalsを動的に読み込み、LCP、INP、CLS、FCP、TTFBを測定して、GA4にweb_vitalsという単一のイベントとして送っている。指標名はevent_labelで区別し、同じページの生存期間で更新された値を重複集計しないようmetric.idも一緒に載せる。GA4イベントのvalueは整数なので、CLSは1000を掛けて丸める。専用の収集サーバーなしに、すでに運用していたGA4に載せた構成だ。
正直に書いておくと、この構成で筆者がこれまで確認したのは、値が送信されるという事実までだ。専用のRUM製品なら標準で出してくれるp75の分布や遅いページのランキングをGA4で見るには、探索レポートを別途組まなければならないのだが、その作業はまだしていない。つまりこの層は有効でデータは積み上がっているが、それを読む側は空いている。そして上記の偏りもそのまま引き継ぐ。広告ブロッカーがGAリクエストを遮れば、その訪問者は筆者の分布から漏れる。
二つのうちどちらかが正解なのではなく、問いが違う。
- デプロイ前にこの変更は遅くなったのか: lab data
- 実際のユーザーの遅い区間はどこか: 自前のRUM
- 公開ウェブでこのoriginはどの水準か: CrUX
自前のRUMを選ぶと、次の問いは何を一回のページ経験として数えるのかになる。この境界が決まって初めて、収集する値と文脈も一貫して設計できる。
曖昧になったページの境界
伝統的なnavigationでは、ブラウザがドキュメントの始まりと終わりを知っている。SPAのclient-side navigationでは、URLと画面が変わってもドキュメントは新しく作られない。ブラウザの視点では、一つの長いページの生存期間として残りやすい。
この問題を解決しようと、各RUMツールとフレームワークは独自のヒューリスティックを使ってきた。しかし実装ごとに「新しい画面」の定義が違い、比較しにくかった。ChromeチームはSoft Navigations APIを通じて、ユーザー入力、URLの変化、画面の更新を束ね、ブラウザがsoft navigationを直接認識する方向を推し進めてきた。
このAPIは2026年8月に出たChrome 151から標準で提供される。web-vitalsライブラリも6.0からreportSoftNavsオプションでsoft navigation単位の指標報告をサポートし始めた。ただし今のところChromium系でしか動かず、FirefoxとSafariには対応する実装がないため、既存のroute計測を即座に置き換えることはできない。筆者のブログもNext.jsのclient-side navigationで記事一覧から記事へ入るのだが、収集コードが5.x基盤だった間は、その遷移が別のページ経験として捕捉されていなかった。この記事を書きながら6.2.1に上げてreportSoftNavsを有効にした後、プロダクションにヘッドレスChromeをつないでGA4へ出ていくリクエストをそのまま開いてみた。一つのセッションでこんな値が出ていった。
| 指標 | 値 | navigationType |
|---|---|---|
| TTFB | 798ms | navigate |
| FCP | 1680ms | navigate |
| LCP | 1680ms | navigate |
| FCP | 542ms | soft-navigation |
| TTFB | 0ms | soft-navigation |
意図どおり、一覧から記事へ入った遷移が別の経験として捕捉された。だが同じ表は、オプション一つでは終わらない理由も見せている。**soft navigationのTTFBは0だ。**サーバーにリクエストしたことがないのだから当然の値だが、この0が最初の読み込みの798msと同じ場所に積まれると、TTFBの平均はコードを直さなくても静かに下がっていく。だから指標ごとに付いてくるnavigationTypeをイベントパラメータとして一緒に送るよう直さなければならなかった。観測を一つ増やせば、それを区別する次元も一緒に増える。
測り直しながらわかったことがあと二つある。一つは、ブラウザがsoft navigationを認めるにはユーザー入力がなければならないという点だ。スクリプトからclick()を呼んだときはURLが変わり画面が更新されたのにsoft-navigation entryが生成されず、実際のマウス入力を送って初めて捕捉された。もう一つは、このブログでその遷移が起きる場所が思ったより狭いということだ。一覧とヘッダーのリンクはNextのLinkなのでclient-side navigationだが、記事本文の中の内部リンクはマークダウンが作る普通のaタグなので、フルページロードだ。同じサイトの中でも、ある移動はsoft navigationで、ある移動はそうではない。
この事例が示すより重要な事実は、SPAの性能測定が単なるライブラリ設定の問題ではなく、ページの境界を誰が定義するのかという問題だという点だ。
ページの境界を決めれば、route、metric id、sessionの文脈をどの単位で保存するかも決められる。ではその問いをRUMのデータモデルに移してみよう。
RUMのデータモデル
navigator.sendBeacon()で値を送るコードは長くない。難しいのは、後で答えたい問いを失わずに、コストとcardinalityを制御することだ。
最低限、次の文脈を併せて考えることになる。
| 文脈 | 必要な理由 |
|---|---|
| ページとroute | 遅い画面を区別する |
| releaseとcommit | リグレッションが入ったデプロイを見つける |
| navigation type | 新規ナビゲーション、リロード、bfcache復元を区別する |
| deviceとconnection | 環境による分布の違いを見る |
| metric id | 同じページの生存期間で更新された値を重複集計しない |
| sessionとtrace id | 行動、エラー、サーバーリクエストをつなぐ |
| visibility state | バックグラウンドタブで歪んだ値をふるい落とす |
ここにDOMセレクター全体、URL全体、ユーザーIDをむやみに付ければ、分析は楽になるように見えるが、コストと個人情報のリスクが大きくなる。動的なURLはcardinalityを爆発させ、セレクターやネットワークのボディには個人情報が混ざりうる。
観測データは多ければ多いほどよいわけではない。後で下す決定につながらない属性は、収集しないほうがましだ。
収集と保存の選択肢
ブラウザ観測を最初から自分で作る必要はない。
web-vitalsはCore Web Vitalsの計算とattributionを提供する。- Boomerangは歴史あるオープンソースのRUM収集器で、多数の性能プラグインとbeacon送信方式を提供する。
- Grafana Faro Web SDKは、性能、エラー、ログ、traceをブラウザで収集し、バックエンドの観測とつなぐ。
- OpenTelemetry JavaScriptはブラウザのtraceを作れるが、公式ドキュメントはブラウザのclient instrumentationをまだexperimentalと表示している。
ツールを選ぶときは、機能の数より先に所有する範囲を見るべきだ。SDKだけを使うのか、収集endpointとストレージも運用するのか、個人情報の削除と保持ポリシーまで自分で責任を持つのかによって選択は変わる。
SaaSを使えば運用の負担は減り、オープンソースのスタックを自分で運用すればデータの経路とコストモデルをより細かく制御できる。どちらもタダではない。
79KBを断念した決定
コストの話が出たついでに、筆者がこのブログで実際に下した決定を一つ書き留めておく。このブログのエラー計測(Sentry)はサーバー専用だ。筆者はブラウザでしか起きないエラーが見えない状態がずっと気にかかっていて、クライアント計測を有効にし、clean buildを基準にクライアントJSのgzip合計を比較してみた。
| 構成 | client JS (gzip) | 増加分 |
|---|---|---|
| Sentry未適用 | 181.6 KB | 基準 |
| サーバー専用 (現在) | 182.3 KB | +0.7 KB |
サーバー専用 + エラーフォールバックUIでcaptureExceptionを呼ぶ |
186.0 KB | +4.4 KB |
| クライアント + サーバー | 260.4 KB | +78.8 KB |
サーバー計測は事実上タダなのに、ブラウザ計測は78.8KBを要求した。バンドル最適化オプションも試したが数値はそのままで、クライアントのコストを減らす唯一の方法は、ブラウザの初期化ファイルをそもそも置かないことだった。三行目が4.4KBを占める理由も同じ構造だ。ブラウザSDKがなければ、エラーフォールバックUIのcaptureExceptionは何もしないno-opなのに、SDKのコードはバンドルに載る。だから呼び出し自体を外した。
この測定には欠点もある。静的成果物全体を合算した値なので、訪問者一人が実際にダウンロードする量とは違うし、最適化オプションを有効にしたのに1バイトも減らなかったことは、そのオプションが効いていなかったという合図かもしれない。だから正確に書くなら、「ブラウザ観測は79KBだ」ではなく「筆者の設定ではそれ以下に下げられなかった」が正しい。
それでも決定は明確だった。このブログにおいて読み込み性能はそのままユーザー体験であり、検索露出の前提でもあるのに、78.8KBのコストを払うのは筆者ではなく訪問者だ。ユーザーがコストを払う観測なら、その観測がユーザーに何を返すのかを問わなければならず、個人ブログのクライアントエラー計測に対する筆者の答えは「十分に返せていない」だった。前の節で、ユーザー体験を測るコードがユーザー体験を悪化させうると書いたが、その原則をツール導入の単位に広げるとこうなる。観測をより緻密にすることが、常に正しい選択とは限らない。
問いの参入障壁
ここまで見たAPIとツールを実際の問いにつなげるとき、AIが役に立つ地点は三つある。
第一に、現象から仕様へ向かう経路を絞ってくれる。「LCPが二回届く」「cross-originリソースの区間がすべて0だ」「SPAの遷移後に値が更新されない」といった現象を、関連するAPIと条件につなげられる。
第二に、異なるツールの結果を同じ時間軸へ翻訳する作業を助けてくれる。DevToolsのtrace、RUMのevent、Sentryのspan、サーバーログが指す時刻と識別子が違うとき、比較する候補を素早く作れる。
第三に、収集されたデータから分布と異常区間を探索できる。単純な平均の代わりに、ブラウザ、route、release、deviceごとの違いを提案し、次のクエリを作るコストが下がる。
しかし、この過程は候補を作る仕事であって、根拠の代わりになる仕事ではない。収集されなかったユーザーはデータの中におらず、定義を誤ったmetricは、どれだけ精緻に分析しても誤った結論を出す。個人情報を送ってよいのか、観測コードのコストをユーザーが負担してよいのかも、技術文書だけでは決められない。
AIはブラウザをよりよく観測させる新しいセンサーというより、既存のセンサーの取扱説明書を読み、問いを立てるコストを下げる道具に近い。
観測は問いから始まる
まとめると、ブラウザはネットワーク、レンダリング、入力、レイアウトの変化をすでに詳細に記録している。PerformanceObserverはその記録を読む出発点であり、Web Vitalsはそれをユーザー体験の言葉に解釈した指標であり、RUMは実際のユーザー環境でその分布を継続的に収集する体系だ。
三つの段階は似て見えるが、答える問いが違う。entryはブラウザで何が起きたのかを教え、Web Vitalsはユーザーが何を体感したのかを圧縮し、RUMはその経験が誰にどれだけ繰り返されるのかを見せてくれる。
複数の仕様を読みツールを組み合わせる敷居は下がったが、簡単に収集できるようになったことと、正しく解釈できるようになったことは別の問題だ。どのユーザーが漏れ、どんな条件で値が歪むのかまで説明できるとき、観測データはようやく判断に使える情報になる。この記事を読んでいる読者の方々も、いま見ている性能の数字が、誰の経験をどんなルールで要約した値なのか、一度振り返ってみてほしい。
次の記事であるシステムの可観測性では、このブラウザのデータをエラー、trace、profile、サーバーログとどうつなげられるのかを見ていこうと思う。ユーザーの画面から始まった一回のリクエストが、システムの中でどこまでつながったのかを追いかける話だ。