複数のアナリティクス イベントをデバウンスしてバッチ処理する

ほとんどの分析、テレメトリ、Core Web Vitals の分析データは、ユーザーへの応答よりも優先度が低いため、ユーザーがページを離れるまで送信を延期しても問題ありません。ただし、リアルタイムの更新を配信する必要がある場合は、タイムリーさが重要になります。

ビーコンを最小限に抑えながら、リアルタイムの分析情報の更新を提供する最適な方法は、activateAfter 構成オプションを指定して fetchLater() を使用することです。これにより、特定の時間枠内で発生するすべての分析イベントを効果的にデバウンスして 1 つのビーコンにバッチ処理できます。このビーコンは、タイムアウトが期限切れになる前にユーザーがページを離れた場合でも確実に送信されます。

導入方法

  1. リクエストをスケジュールする: 関連する分析データが利用可能になったらすぐに、データ ペイロードを指定して fetchLater() を呼び出し、activateAfter 値を渡します。これにより、指定した時間が経過した後、またはユーザーがページを離れた場合に、データが送信されるようにキューに登録されます。

  2. 複数のイベントをまとめてバッチ処理する: activateAfter タイムアウトが切れる前に新しいアナリティクス イベントが発生した場合は、以前にスケジュールされたリクエストを中止し、1 つのペイロードに完全なイベントキューを含めて fetchLater() を再度呼び出します(同じ activateAfter 値を使用)。

  3. タイムアウトの期限が切れたときにイベントキューをリセットする: スケジュール設定されたビーコンが正常に送信された後(つまり、fetchLater() の結果の activated 値が true の場合)に新しいアナリティクス イベントが発生した場合は、イベントキューをリセットします。

  4. 残りの処理はブラウザに任せる: activateAfter タイムアウトが切れる前にユーザーがページを離れたり、タブを閉じたりした場合でも、ブラウザは最新の fetchLater() 呼び出しからペイロードを確実に送信します。

コード例

このコードは、ページ上のすべての load イベントと click イベントをトラッキングし、10 秒のタイムアウト内に発生したすべてのイベントをバッチ処理します。

// Replace with your analytics endpoint.
const ANALYTICS_ENDPOINT = '/path/to/analytics/endpoint';

// Replace with a time window of your choice. All analytics events that
// occur within this time window will be batched together.
const BATCH_WINDOW = 10 * 1000;

// The maximum number of events to batch. Pick a number that is unlikely
// to overflow the fetchLater() quota for the page.
const MAX_QUEUE_SIZE = 100;

const eventQueue = [];
let fetchLaterResult;
let fetchLaterController;

function trackEvent(eventData) {
  // If the previously queued beacon has already been sent, or if the
  // max queue size has been met, reset the queue.
  if (fetchLaterResult?.activated || eventQueue.length > MAX_QUEUE_SIZE) {
    fetchLaterController = null;
    fetchLaterResult = null;
    eventQueue.length = 0;
  }

  eventQueue.push(eventData);

  // Abort any pending beacons before creating a new one.
  if (fetchLaterController) {
    fetchLaterController.abort();
  }
  fetchLaterController = new AbortController();

  // Schedule a fetch for the events to be sent when the batch window expires.
  // IMPORTANT: wrap the call in a try/catch to handle quota errors.
  try {
    fetchLaterResult = fetchLater(ANALYTICS_ENDPOINT, {
      method: 'POST',
      headers: {'content-type': 'application/json'},
      body: JSON.stringify(eventQueue),
      signal: fetchLaterController.signal,
      activateAfter: BATCH_WINDOW,
    });
  } catch (error) {
    // Handle errors as needed.
  }
}

// Track page loads.
window.addEventListener('load', () => {
  trackEvent({type: 'page_load'});
});

// Track click events.
window.addEventListener('click', (event) => {
  trackEvent({type: 'click', target: serializeElement(event.target)});
});

ベスト プラクティス

  • 推奨: fetchLater() を使用し、activateAfter オプションを設定して、互いに近接して発生する複数のアナリティクス イベントをバッチ処理します。
  • 新しいアナリティクス イベントが発生した場合に、AbortController を使用して保留中のフェッチをキャンセルする。
  • 推奨: イベントをバッチ処理する際は、バッチ処理するイベントが多すぎて fetchLater() の割り当て(現在はオリジンあたり約 64 KB)を超えないようにしてください。
  • fetchLater() の呼び出しを try/catch でラップして、割り当てエラーを処理する。
  • globalThis で fetchLater() の存在を検出する機能を実装し、API をサポートしていないブラウザ用のフォールバック戦略を実装します。
  • リクエスト本文に ReadableStream オブジェクトを使用しないでください。エラーが発生します。

ブラウザのサポートとフォールバック戦略

そのため、通常はフォールバック戦略が必要です。

ただし、この API の信頼性とパフォーマンスの向上を考慮すると、ブラウザがサポートしている場合は fetchLater() を使用する必要があります。

fetchLater() ポリフィル

次の最小限の fetchLater() ポリフィルを使用します。これは、サポートされていないブラウザで API を可能な限り忠実に実装します。

このポリフィルで注目すべき動作の違いは、ユーザーがページを離れたときにペイロードを送信するのではなく、ページの visibilityState が「hidden」に変わるたびにペイロードを送信することです。これは、現在広く利用されている最も信頼性の高いセッション終了シグナルです。

globalThis.fetchLater ??= function fetchLater(url, init = {}) {
  let timeoutHandle;
  let activated = false;

  function sendNow() {
    if (!(init.signal && init.signal.aborted)) {
      // Use fetch keepalive if the browser supports it or if custom fetch
      // parameters are specified (e.g. custom headers or methods).
      // Otherwise fall back to `navigator.sendBeacon()`.
      if (
        'keepalive' in Request.prototype ||
        init.method !== 'POST' ||
        init.headers
      ) {
        fetch(url, Object.assign({}, init, {keepalive: true}));
        activated = true;
      } else {
        activated = navigator.sendBeacon(url, init.body);
      }
    }
    destroy();
  }

  function destroy() {
    document.removeEventListener('visibilitychange', sendNow);
    clearTimeout(timeoutHandle);
  }

  if (document.visibilityState === 'hidden') {
    // If the beacon was created while the page is already hidden, send data
    // ASAP but wait until the next microtask to allow all sync code to run.
    queueMicrotask(sendNow);
  } else {
    document.addEventListener('visibilitychange', sendNow);

    if (typeof init.activateAfter === 'number' && init.activateAfter >= 0) {
      timeoutHandle = setTimeout(sendNow, init.activateAfter);
    }
  }

  if (init.signal) {
    init.signal.addEventListener('abort', destroy);
  }

  return {
    get activated() {
      return activated;
    },
  };
};