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