Cómo agrupar y procesar por lotes varios eventos de estadísticas

La mayoría de los datos de estadísticas, telemetría y Core Web Vitals tienen una prioridad más baja que responder al usuario, por lo que puedes posponer su envío hasta que el usuario abandone la página. La excepción a esto es si necesitas entregar actualizaciones en tiempo real, en cuyo caso la puntualidad es importante.

La forma óptima de proporcionar actualizaciones de estadísticas en tiempo real y, al mismo tiempo, minimizar las balizas, es usar fetchLater() con la opción de configuración activateAfter. Esto te permite agrupar en lotes y eliminar de manera eficaz los eventos de Analytics que se producen dentro de un período determinado en una sola baliza, que se envía de forma confiable incluso si el usuario abandona la página antes de que venza el tiempo de espera.

Cómo implementarlo

  1. Programa la solicitud: En cuanto haya datos de estadísticas relevantes disponibles, llama a fetchLater() con tu carga útil de datos y pasa un valor activateAfter. Esto pone en cola los datos que se enviarán después de que transcurra ese período o si el usuario abandona la página antes.

  2. Agrupa varios eventos en un lote: Si se produce un evento de Analytics nuevo antes de que venza el tiempo de espera de activateAfter, anula la solicitud programada anteriormente y vuelve a llamar a fetchLater() (con el mismo valor de activateAfter) con la cola de eventos completa en una sola carga útil.

  3. Restablece la cola de eventos cuando venza el tiempo de espera: Si se produce un nuevo evento de estadísticas después de que se haya enviado correctamente la baliza programada (es decir, el valor activated del resultado fetchLater() es true), restablece la cola de eventos.

  4. Deja que el navegador se encargue del resto: Si el usuario sale de la página o cierra la pestaña antes de que venza el tiempo de espera de activateAfter, el navegador seguirá enviando de forma confiable la carga útil de tu llamada a fetchLater() más reciente.

Ejemplo de código

Este código hace un seguimiento de todos los eventos load y click de una página y agrupa todos los eventos que ocurren dentro de un tiempo de espera de 10 segundos.

// 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)});
});

Prácticas recomendadas

  • SÍ uses fetchLater() con la opción activateAfter configurada para agrupar en lotes varios eventos de Analytics que ocurren en un intervalo de tiempo breve.
  • SÍ usa un AbortController para cancelar la recuperación pendiente en los casos en que se produzca un nuevo evento de Analytics.
  • SÍ verifica que no agrupes demasiados eventos en un lote y superes la cuota de fetchLater() (actualmente, alrededor de 64 KB por origen).
  • SÍ une las llamadas a fetchLater() en un try/catch para controlar los errores de cuota.
  • SÍ detecta la presencia de fetchLater() en globalThis y, además, implementa una estrategia de resguardo para los navegadores que no admiten la API.
  • NO uses un objeto ReadableStream para el cuerpo de la solicitud, ya que se producirá un error.

Compatibilidad con navegadores y estrategias de resguardo

Por lo tanto, suele ser necesaria una estrategia de resguardo.

Sin embargo, dadas las mejoras en la confiabilidad y el rendimiento de esta API, se debe usar fetchLater() si el navegador la admite.

Polyfill de fetchLater()

Usa el siguiente polyfill mínimo de fetchLater(), que implementa la API lo más fielmente posible en los navegadores que no la admiten.

La única diferencia notable de comportamiento con este polyfill es que, en lugar de enviar la carga útil cuando el usuario abandona la página, la envía cada vez que el visibilityState de la página cambia a "oculto", ya que esta es la señal de fin de sesión más confiable que está ampliamente disponible en la actualidad.

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;
    },
  };
};