client-telemetry.ts13.1 KBView on GitHub
/**
 * Client telemetry — a fire-and-forget event pipe the server can join traces against.
 *
 * Why this exists: a user reported an email sent from the client that never reached Gmail, and
 * the investigation stalled for a day because the browser emitted NOTHING. Server logs had no
 * row to join to, so "the request never left the browser" and "the request left and was dropped"
 * were indistinguishable. The correlation id already travels on every tRPC request as
 * `X-Client-Request-Id` (see providers/query-provider.tsx); this module is the other half — it
 * records what the browser believed happened under that same id and ships it to the relay.
 *
 * Three hard rules, because this is diagnostic plumbing sitting in the path of real requests:
 *
 * 1. It can never throw into a caller. Every exported function is total: its body is wrapped,
 *    and a failure degrades to "no telemetry", never to a broken send.
 * 2. It can never recurse. The relay POST goes out on the raw `fetch`, not through the tRPC
 *    transport, and `CLIENT_TELEMETRY_PATH` is skipped by the tRPC capture layer.
 * 3. It can never carry content. `sanitizeMeta` admits only primitives and short string arrays,
 *    drops known content-bearing keys outright, and truncates what is left. A body, a recipient
 *    list or a subject cannot reach the wire even if a caller passes one by mistake.
 *
 * Volume (see the constants below): events are buffered and flushed on a 10s interval, early at
 * 25 buffered, and on pagehide/hidden with `keepalive` so the last events survive teardown. The
 * buffer is capped at 200 (oldest dropped) and the session at 2000 events, after which the
 * module goes quiet. Per-kind sampling is the CALLER's decision — the layers that can be chatty
 * (tRPC successes, thread-list churn) sample or throttle before they call in here; failures and
 * sends are never sampled. If the relay answers 404 the module disables itself for the session,
 * so shipping the client ahead of the server costs one request and nothing else.
 */

import { getRuntimeBackendUrl } from '@/lib/runtime-urls';

export const CLIENT_TELEMETRY_PATH = '/api/client-telemetry';

export interface ClientTelemetryEvent {
  ts: number;
  kind: string;
  name: string;
  correlationId?: string;
  durationMs?: number;
  status?: string;
  meta?: Record<string, unknown>;
  /**
   * Exempt from the session cap, and evicted last when the buffer overflows.
   *
   * The sampler already refuses to drop a send or a failure, but the caps below knew nothing
   * about that — so after 2,000 events a long-lived tab went silent for EVERYTHING, and buffer
   * overflow evicted oldest-first regardless of what it was. The event an incident needs is
   * exactly the one those two rules threw away.
   */
  priority?: boolean;
}

/** Periodic flush cadence while the tab is alive. */
const FLUSH_INTERVAL_MS = 10_000;
/** Flush early once this many events are buffered, so a burst does not wait out the interval. */
const FLUSH_AT_BUFFERED = 25;
/** Hard buffer ceiling. Past this the OLDEST events are dropped — the recent ones matter more. */
const MAX_BUFFERED = 200;
/** Whole-session ceiling. A wedged tab cannot turn into an unbounded uploader. */
const MAX_EVENTS_PER_SESSION = 2_000;

/** Meta is a flat bag of small scalars. These bounds are what makes that true on the wire. */
const MAX_META_KEYS = 12;
const MAX_STRING_LEN = 200;
const MAX_ARRAY_ITEMS = 12;
const MAX_NAME_LEN = 200;

/**
 * Keys that carry message content or identities. Dropped outright rather than truncated —
 * the first 200 characters of a subject line is still the subject line.
 */
const CONTENT_KEYS = new Set<string>([
  'address',
  'addresses',
  'bcc',
  'bccList',
  'body',
  'cc',
  'ccList',
  'content',
  'email',
  'emails',
  'from',
  'html',
  'htmlBody',
  'message',
  'messages',
  'preview',
  'processedHtml',
  'rawBody',
  'recipient',
  'recipients',
  'sender',
  'snippet',
  'subject',
  'text',
  'textBody',
  'to',
  'toList',
]);

let buffer: ClientTelemetryEvent[] = [];
let sessionEventCount = 0;
let droppedForCap = 0;
/** Set when the relay answers 404 — the endpoint is not deployed, so stop trying this session. */
let disabled = false;
let timer: ReturnType<typeof setInterval> | null = null;
let listenersInstalled = false;

function truncate(value: string): string {
  return value.length > MAX_STRING_LEN ? `${value.slice(0, MAX_STRING_LEN)}…` : value;
}

/** A scalar that is safe to ship as-is, or `undefined` if the value is not one. */
function scalarOrUndefined(value: unknown): string | number | boolean | null | undefined {
  if (value === null) return null;
  if (typeof value === 'string') return truncate(value);
  if (typeof value === 'boolean') return value;
  if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
  return undefined;
}

/**
 * Flatten meta to shippable scalars. Anything that is not a scalar or a short array of scalars
 * is dropped — no nested objects, so there is no path by which a message, a draft or a thread
 * object gets serialized wholesale.
 */
export function sanitizeMeta(meta: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
  if (!meta) return undefined;
  const out: Record<string, unknown> = {};
  let keys = 0;
  for (const [key, value] of Object.entries(meta)) {
    if (keys >= MAX_META_KEYS) break;
    if (CONTENT_KEYS.has(key)) continue;
    if (Array.isArray(value)) {
      const items: (string | number | boolean | null)[] = [];
      for (const item of value.slice(0, MAX_ARRAY_ITEMS)) {
        const scalar = scalarOrUndefined(item);
        if (scalar !== undefined) items.push(scalar);
      }
      out[key] = items;
      keys++;
      continue;
    }
    const scalar = scalarOrUndefined(value);
    if (scalar === undefined) continue;
    out[key] = scalar;
    keys++;
  }
  return out;
}

function sanitizeEvent(event: ClientTelemetryEvent): ClientTelemetryEvent {
  const sanitized: ClientTelemetryEvent = {
    ts: Number.isFinite(event.ts) ? event.ts : Date.now(),
    kind: String(event.kind).slice(0, MAX_NAME_LEN),
    name: String(event.name).slice(0, MAX_NAME_LEN),
  };
  if (typeof event.correlationId === 'string') {
    sanitized.correlationId = event.correlationId.slice(0, MAX_NAME_LEN);
  }
  if (typeof event.durationMs === 'number' && Number.isFinite(event.durationMs)) {
    sanitized.durationMs = Math.round(event.durationMs);
  }
  if (typeof event.status === 'string') {
    sanitized.status = event.status.slice(0, MAX_NAME_LEN);
  }
  // Carried onto the buffered copy because the eviction loop reads it off the BUFFER, not off
  // the caller's object. Dropping it here made the flag look unset to every eviction decision,
  // which is the exact failure the flag exists to prevent. The relay's zod schema has no
  // `priority` field and is not `.strict()`, so it is stripped server-side and never reaches Axiom.
  if (event.priority === true) sanitized.priority = true;
  const meta = sanitizeMeta(event.meta);
  if (meta && Object.keys(meta).length > 0) sanitized.meta = meta;
  return sanitized;
}

function endpointUrl(): string | null {
  try {
    const base = getRuntimeBackendUrl();
    if (typeof base !== 'string' || base.length === 0) return null;
    return base.replace(/\/+$/, '') + CLIENT_TELEMETRY_PATH;
  } catch {
    return null;
  }
}

/**
 * Trim the buffer to MAX_BUFFERED, evicting the oldest NON-priority events first; only if the
 * buffer is somehow all priority does it fall back to dropping the oldest outright.
 */
function enforceBufferCap(): void {
  if (buffer.length <= MAX_BUFFERED) return;
  let over = buffer.length - MAX_BUFFERED;
  for (let i = 0; i < buffer.length && over > 0; ) {
    if (buffer[i]?.priority) {
      i++;
      continue;
    }
    buffer.splice(i, 1);
    droppedForCap++;
    over--;
  }
  if (over > 0) {
    droppedForCap += over;
    buffer.splice(0, over);
  }
}

/**
 * Put a batch back after a flush that did not land.
 *
 * The buffer is handed to `fetch` and cleared in the same breath, so before this a transient
 * relay failure — a 502, a dropped connection — destroyed the evidence permanently, including
 * the abort and network-error events that are flushed immediately BECAUSE they are the ones an
 * incident needs. Restored at the FRONT, since these are the oldest events we hold, and then
 * re-capped so a relay that stays down cannot grow the buffer without bound.
 *
 * Only for failures worth retrying. A 4xx means the relay refused the batch's SHAPE, and
 * requeueing that just replays a rejection every ten seconds for the life of the tab.
 */
function requeueAfterFailedFlush(events: ClientTelemetryEvent[]): void {
  try {
    if (disabled || events.length === 0) return;
    buffer.unshift(...events);
    enforceBufferCap();
  } catch {
    // Best-effort by definition.
  }
}

/**
 * Ship whatever is buffered. Never awaits, never rejects — the caller (an interval, a pagehide
 * handler, a real request's completion path) must not be able to observe a telemetry failure.
 */
export function flushClientTelemetry(options: { keepalive?: boolean } = {}): void {
  try {
    if (buffer.length === 0) return;
    if (disabled) {
      buffer = [];
      return;
    }
    // No transport right now (SSR, a torn-down jsdom, a browser mid-teardown). Keep the events
    // buffered and try on the next flush — MAX_BUFFERED is what stops that growing without bound.
    if (typeof fetch !== 'function') return;
    const url = endpointUrl();
    if (!url) return;
    const events = buffer;
    buffer = [];
    // `droppedForCap` is part of the relay's envelope (see the server's
    // clientTelemetryBatchSchema): it is how "we are losing events" gets answered without
    // guessing which side lost them. Omitted entirely when nothing was dropped.
    const dropped = droppedForCap;
    droppedForCap = 0;
    const payload = JSON.stringify(dropped > 0 ? { events, droppedForCap: dropped } : { events });
    void fetch(url, {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: payload,
      keepalive: options.keepalive === true,
    })
      .then((res) => {
        // The relay may not be deployed yet. 404 means "never will be, this session" — go quiet
        // rather than posting into the void every 10 seconds for the life of the tab.
        if (res.status === 404) {
          disabled = true;
          return;
        }
        // 5xx and 429 are "try again"; any other 4xx is a verdict on this batch's shape, and
        // replaying it would only repeat the rejection.
        if (res.status >= 500 || res.status === 429) requeueAfterFailedFlush(events);
      })
      .catch(() => {
        // Offline, CORS, teardown mid-flight — keep the events for the next flush.
        requeueAfterFailedFlush(events);
      });
  } catch {
    // Unreachable in practice; the guarantee is what matters.
  }
}

function installListeners(): void {
  if (listenersInstalled) return;
  if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
  listenersInstalled = true;
  // pagehide fires on bfcache entry and on real unload, which `beforeunload` does not reliably do
  // on mobile Safari. visibilitychange catches the tab-switch that precedes most closes.
  window.addEventListener('pagehide', () => flushClientTelemetry({ keepalive: true }));
  window.addEventListener('visibilitychange', () => {
    if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
      flushClientTelemetry({ keepalive: true });
    }
  });
}

function ensureStarted(): void {
  installListeners();
  if (timer !== null) return;
  if (typeof setInterval !== 'function') return;
  timer = setInterval(() => flushClientTelemetry(), FLUSH_INTERVAL_MS);
  // Node/jest: do not hold the process open for a diagnostic timer.
  if (typeof timer === 'object' && timer !== null && 'unref' in timer) {
    const handle: { unref?: () => void } = timer;
    handle.unref?.();
  }
}

/** Buffer one event. Total: cannot throw, cannot block, cannot reach the network synchronously. */
export function recordTelemetryEvent(event: ClientTelemetryEvent): void {
  try {
    if (disabled) return;
    if (typeof window === 'undefined') return;
    // A priority event is never silenced by volume. Cedar is a mail client and tabs live for
    // days; routine chatter must not be able to spend the budget a send needs.
    if (!event.priority && sessionEventCount >= MAX_EVENTS_PER_SESSION) return;
    sessionEventCount++;
    buffer.push(sanitizeEvent(event));
    enforceBufferCap();
    ensureStarted();
    if (buffer.length >= FLUSH_AT_BUFFERED) flushClientTelemetry();
  } catch {
    // Never surface a diagnostic's failure to the code being diagnosed.
  }
}

/** Test seam: the events buffered but not yet shipped. */
export function peekClientTelemetryBufferForTests(): ClientTelemetryEvent[] {
  return buffer.slice();
}

/** Test seam: forget everything, including the 404 kill-switch and the flush timer. */
export function resetClientTelemetryForTests(): void {
  buffer = [];
  sessionEventCount = 0;
  droppedForCap = 0;
  disabled = false;
  if (timer !== null) {
    clearInterval(timer);
    timer = null;
  }
}