every Tech Blog

株式会社エブリーのTech Blogです。

OpenTelemetry JS はページ遷移やタブクローズで失われる計測データを永続化なしでどのように減らしているのか

はじめに

デリッシュキッチンの鈴木です。

UX 体験向上のために Web フロントエンドのパフォーマンスを計測することもあるでしょう。その際に、計測結果をその都度サーバーへ送信すると、ネットワーク通信やシリアライズ処理が増え、画面描画やユーザー操作の体感に影響しやすくなります。これは避けなければなりません。 そこで実運用では、計測データをいったんメモリ上のバッファに溜め、一定間隔または一定件数でまとめて送信するバッチ送信が一般的です。しかしこの方式では、ページ遷移やタブクローズが起きた時点でバッファに未送信データが残っていると、送信開始前に失われたり、送信中の通信が中断されたりして欠損が起きる可能性があります。

さて、この問題にどう対処するべきなのでしょうか?今回は、ページ終了時に未送信データをできるだけ取りこぼさないために、パフォーマンス計測で使用される OpenTelemetry JS がどのように設計・実装して問題に対処しているかを、コードを手がかりに整理していきます。

課題: ページ遷移・クローズ時にデータが欠損する

送信完了前に通信が中断される問題

たとえばユーザーがボタンをクリックしてから画面遷移が完了するまでの時間(E2E レイテンシ)を計測する場合、まず正常系では次の流れになります。

  1. ユーザー操作(計測開始)
  2. 処理完了(計測終了 → データ確定)
  3. 計測データをメモリ上のバッファに保存する
  4. 一定間隔または一定件数で、バッファの内容をまとめて送信する

ここで問題になるのは、3 と 4 のあいだ、または 4 の途中にページ遷移やタブクローズが割り込むケースです。この時以下の問題が起こり得ます。

  • バッファに未送信データが残っていると欠損する
  • 送信中の通信がページ終了により中断されることがある

一般的な非同期通信(fetch / XMLHttpRequest)は、ページ終了に伴ってブラウザ側で中断されることがあります。その結果、バッファ内に残っていたデータや送信途中のデータが Collector に届かず、データ欠損が起きます。 以下は、欠損が起きる典型的な流れを、シーケンス図として表したものです。

Fig 1: バッチ送信における正常系と、ページ終了割り込みによる欠損パターン(シーケンス図)

※ 図中の Normal は正常系、残り 2 つはページ終了が割り込むことで欠損が起こり得るケースです。

OpenTelemetry の対策

この問題に対して OpenTelemetry JS は、Web 標準 API を活用した 2 つのアプローチを組み合わせ、タブクローズ時の送信成功率を高めています。

  • 検知とトリガー: ページ終了の直前に発火するイベントを検知し、バッファに残っているデータの送信をただちに開始する
  • 送信継続: ページ終了後も送信が完了しやすい Web API に委譲する

全体像をレイヤに分けると次のようになります。

Fig 2: OpenTelemetry JS による二段構えの送信設計(検知とトリガー/送信継続)

それでは、この 2 つの対策が具体的にどのコードで実現されているかを追っていきたいと思います。

実装詳細をコードで追う

フェーズ1: 【検知】ページライフサイクルイベントの監視

通常、スパン(計測データ)はパフォーマンスへの影響を抑えるため、一定数をバッファに溜めてからまとめて送信(バッチ処理)します。しかし、ページ終了時に通常の周期的な送信タイミングを待っていると、その前にページが破棄されてしまった場合にスパンが失われる可能性があります。 そこで BatchSpanProcessor は、ページが終了する兆候を示すイベントを監視し、発火したら forceFlush() を呼んで今ある分を即座に送る方針を取ります。

該当コード(BatchSpanProcessor.ts

private onInit(config?: BatchSpanProcessorBrowserConfig): void {
  if (
    config?.disableAutoFlushOnDocumentHide !== true &&
    typeof document !== 'undefined'
  ) {
    this._visibilityChangeListener = () => {
      if (document.visibilityState === 'hidden') {
        this.forceFlush().catch(error => {
          globalErrorHandler(error);
        });
      }
    };
    this._pageHideListener = () => {
      this.forceFlush().catch(error => {
        globalErrorHandler(error);
      });
    };
    document.addEventListener('visibilitychange', this._visibilityChangeListener);

    // use 'pagehide' event as a fallback for Safari; see
    // https://bugs.webkit.org/show_bug.cgi?id=116769
    document.addEventListener('pagehide', this._pageHideListener);
  }
}
  • visibilitychange: document.visibilityState === 'hidden' になった瞬間を検知
  • pagehide: Safari 向けのフォールバック(コメントにもある通り)

これらのイベントが発火すると forceFlush() が呼ばれ、バッファ内のスパンがエクスポート処理へ回されます。

該当コード(BatchSpanProcessorBase.ts

forceFlush(): Promise<void> {
  if (this._shutdownOnce.isCalled) {
    return this._shutdownOnce.promise;
  }
  return this._flushAll();
}

ここで重要なのは、forceFlush() 自体は非同期であり、送信完了までページの終了を止められるわけではない点です。JavaScript には送信が完了するまでページ遷移を確実に止めるための一般的な仕組みがありません。したがって、検知してただちに送信を開始しても、なお送信中にページが閉じてしまう可能性は残ります。その穴を埋めるのが次のフェーズです。

フェーズ2: 【送信継続】ページ終了後も通信を継続しやすい API の活用

ページ終了後も送信を完遂するには、ページの寿命とネットワークリクエストの寿命を切り離せる API が必要です。OpenTelemetry JS の Transport 層は、状況に応じて次の 2 つを使い分けます。

  • navigator.sendBeacon()(ヘッダ不要の場合)
  • fetch(..., { keepalive: true })(認証などでヘッダが必要な場合)

sendBeacon はページ終了時の送信継続に適した API ですが、リクエストにカスタム HTTP ヘッダー(例: Authorization)を付与できないという制約があります。そのため、認証等でヘッダーが必要なケースでは fetch を使う必要があり、ページ終了後も送信継続が期待できるよう keepalive: true を併用する設計になります。

選択肢A: navigator.sendBeacon()

sendBeacon は、ページアンロード時の送信を想定して設計された API です。ノンブロッキングで送信を開始でき、ページが閉じた後もブラウザが送信継続を試みます。ただし、sendBeacon の戻り値は送信完了を保証するものではなく、あくまでブラウザが送信処理の開始(キュー投入)を受け付けたかどうかの成否に近い点には注意が必要です。つまり、sendBeacon を使っても確実に届くわけではなく、ページ終了時の到達率を上げるためのベストエフォートな手段だと捉えるのが正確です。

該当コード(send-beacon-transport.ts

async send(data: Uint8Array): Promise<ExportResponse> {
  const blobType = (await this._params.headers())['Content-Type'];
  return new Promise<ExportResponse>(resolve => {
    if (
      navigator.sendBeacon(
        this._params.url,
        new Blob([data], { type: blobType })
      )
    ) {
      // no way to signal retry, treat everything as success
      diag.debug('SendBeacon success');
      resolve({ status: 'success' });
    } else {
      resolve({
        status: 'failure',
        error: new Error('SendBeacon failed'),
      });
    }
  });
}

選択肢B: fetchkeepalive: true

認証ヘッダーが必要な場合は fetch を使いますが、ポイントは keepalive: true を付けることです。これにより、ページの破棄後もリクエストが一定の条件で継続されることが期待できます。

該当コード(fetch-transport.ts

const isBrowserEnvironment = !!globalThis.location;
const url = new URL(this._parameters.url);
const response = await fetch(url.href, {
  method: 'POST',
  headers: await this._parameters.headers(),
  body: data,
  signal: abortController.signal,
  keepalive: isBrowserEnvironment,
  mode: isBrowserEnvironment
    ? globalThis.location?.origin === url.origin
      ? 'same-origin'
      : 'cors'
    : 'no-cors',
});

OpenTelemetry JS は、ブラウザ環境であることを検知した場合に自動で keepalive: true を付与するため、利用者側で特別な設定をしなくてもページ終了時に強い送信経路を取りやすい設計になっています。

なお keepalive(および sendBeacon)は、実装上おおむね 数十KB(典型的には約 64KiB 前後)の送信サイズ上限に当たりやすく、バッチが肥大化すると送信に失敗する可能性があります。したがって実運用では、ページ終了時のフラッシュ対象を未送信すべてにするのではなく、イベントを小さく保つ、分割する、重要度で間引くといった設計上の工夫も合わせて検討すると安全です。

まとめ: タブクローズ時のデータ損失はどこまで回避できるか

OpenTelemetry JS は、ブラウザ仕様の範囲内で ベストエフォートにデータ欠損を減らす設計を取っています。

  • 検知(BatchSpanProcessor): ページが非表示・終了に向かうイベントを検知し、即座に forceFlush() を起動する
  • 継続(Transport): sendBeacon または fetch(keepalive) を用い、ページ破棄後の通信継続をブラウザに委譲する

この二段構えにより、タブクローズ時のデータ到達率は現実的に大きく改善します。一方で、開発者が理解しておくべき限界もあります。

開発者が知っておくべきポイント

  • 設定不要で動く: 既定設定の範囲で、この仕組みは動作する
  • 完全な保証ではない: ブラウザのクラッシュ、ネットワーク断、OS 側の強制終了などでは失敗し得る
  • サイズ上限の影響がある: keepalive には送信サイズの上限があり、上限を超えると送信に失敗する可能性がある
  • 認証環境でも破綻しにくい: 認証ヘッダーが必要な場合は fetch(keepalive) が選択されるため、現代のブラウザでは一定の実用性が期待できる

以上が、OpenTelemetry JS がページ終了時に欠損しやすい計測データを守るために採用している設計の要点です。

おわりに

今回 Opentelemetry JS のコードを追ってみましたが、かなり SDK レベルで頑張ってくれている印象がありますね。 ただ、SDK だけで完全に永続性を担保することは難しいので、どうしても失いたくないデータがある場合は、ブラウザのストレージ(IndexedDB など)にいったん永続化し、Service Worker などを用いてバックグラウンドで再送する、といった設計も選択肢になります。OpenTelemetry JS の仕組みはあくまでベストエフォートであるため、要件に応じて永続化を組み合わせるとより堅牢になるでしょう。