client-telemetry-trpc.ts6.5 KBView on GitHub
/**
 * tRPC request capture — the client half of a joinable trace.
 *
 * `providers/query-provider.tsx` already mints a per-batch id and sends it as
 * `X-Client-Request-Id`. This wraps the fetch that carries it and records, under that same id,
 * what the browser observed: which procedures were in the batch, how long it took, the HTTP
 * status, and — the case that mattered — whether the request never produced a response at all.
 *
 * ABORTS AND NETWORK FAILURES ARE THE POINT. A send that is aborted by page teardown, or that
 * dies on the wire, leaves no server row and (before this) left no client row either, which is
 * exactly the hole that made "the email never arrived" undiagnosable. Those are captured at
 * 100%, never sampled, along with every non-2xx response and every batch containing a send.
 *
 * Sampling: routine 2xx responses are recorded at `SUCCESS_SAMPLE_RATE` (25%). The purpose of a
 * successful `settings.get` in the log is volume statistics, not diagnosis, and this transport
 * fires dozens of them per page load. Anything that could be the missing half of an incident —
 * a failure, an abort, a non-2xx, a send — bypasses the sampler.
 */

import { trpcProceduresInUrl } from '@/lib/trpc-batch-url';
import {
  CLIENT_TELEMETRY_PATH,
  flushClientTelemetry,
  recordTelemetryEvent,
  type ClientTelemetryEvent,
} from '@/lib/client-telemetry';

/** Fraction of successful (2xx) tRPC batches recorded. Failures ignore this entirely. */
const SUCCESS_SAMPLE_RATE = 0.25;

/**
 * Procedures whose every call is kept regardless of outcome. These are the ones an incident is
 * ever about: a mail that did or did not go out.
 */
// Verified against the real router: apps/server/src/trpc/routes/{mail,drafts}.ts.
// `mail.unsend` belongs here too — retracting a send is exactly the kind of event
// you want a record of when reconstructing what a user did.
const ALWAYS_CAPTURED_PROCEDURES = new Set<string>([
  'mail.send',
  'mail.unsend',
  'drafts.create',
  'drafts.send',
]);

export interface TrpcRequestContext {
  url: string;
  correlationId: string;
  signal?: AbortSignal | null;
}

function nowMs(): number {
  try {
    if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
      return performance.now();
    }
  } catch {
    // fall through
  }
  return Date.now();
}

function isAbort(error: unknown, signal: AbortSignal | null | undefined): boolean {
  if (signal && signal.aborted === true) return true;
  if (typeof error === 'object' && error !== null && 'name' in error) {
    const name: unknown = Reflect.get(error, 'name');
    if (name === 'AbortError' || name === 'TimeoutError') return true;
  }
  return false;
}

function errorMessage(error: unknown): string {
  if (error instanceof Error) return error.message;
  if (typeof error === 'string') return error;
  return 'unknown';
}

function shouldRecord(procedures: string[], keepRegardless: boolean): boolean {
  if (keepRegardless) return true;
  for (const procedure of procedures) {
    if (ALWAYS_CAPTURED_PROCEDURES.has(procedure)) return true;
  }
  return Math.random() < SUCCESS_SAMPLE_RATE;
}

/**
 * The events the sampler refuses to drop — a send, or anything that did not cleanly succeed.
 * Passing the same judgement to the buffer as `priority` makes the volume caps honour it too;
 * without it the sampler kept a send and the session cap threw it away anyway.
 */
function isPriorityCall(procedures: string[], keepRegardless: boolean): boolean {
  if (keepRegardless) return true;
  return procedures.some((procedure) => ALWAYS_CAPTURED_PROCEDURES.has(procedure));
}

function emit(event: ClientTelemetryEvent, priority: boolean): void {
  try {
    recordTelemetryEvent({ ...event, priority });
  } catch {
    // A diagnostic must never become the failure it was meant to explain.
  }
}

/** The only parts of a response this wrapper reads. `Response` satisfies it; so does a test stub. */
export interface TrackedResponse {
  status: number;
  headers: { get(name: string): string | null };
}

/**
 * Run `request` and record the outcome. Returns exactly what `request` returns and rethrows
 * exactly what it throws — the caller cannot tell this wrapper is here.
 */
export async function trackTrpcRequest<TResponse extends TrackedResponse>(
  context: TrpcRequestContext,
  request: () => Promise<TResponse>,
): Promise<TResponse> {
  // Never instrument the relay POST itself: that is the recursion this whole module must avoid.
  let procedures: string[] = [];
  let instrumented = true;
  let startedAt = 0;
  try {
    if (context.url.includes(CLIENT_TELEMETRY_PATH)) {
      instrumented = false;
    } else {
      procedures = trpcProceduresInUrl(context.url);
      startedAt = nowMs();
    }
  } catch {
    instrumented = false;
  }

  if (!instrumented) return request();

  const name = procedures.length > 0 ? procedures.join(',') : 'unknown';

  try {
    const response = await request();
    try {
      const ok = response.status >= 200 && response.status < 300;
      if (shouldRecord(procedures, !ok)) {
        emit({
          ts: Date.now(),
          kind: 'trpc',
          name,
          correlationId: context.correlationId,
          durationMs: nowMs() - startedAt,
          status: String(response.status),
          meta: {
            ok,
            procedureCount: procedures.length,
            traceId: response.headers.get('X-Trace-ID') ?? undefined,
            serverRequestId: response.headers.get('X-Request-ID') ?? undefined,
          },
        }, isPriorityCall(procedures, !ok));
      }
    } catch {
      // Header access or emission failed — the response still belongs to the caller.
    }
    return response;
  } catch (error) {
    // The invisible case. Always recorded, and always priority — this is the event that a
    // volume cap must never be allowed to swallow.
    emit({
      ts: Date.now(),
      kind: 'trpc',
      name,
      correlationId: context.correlationId,
      durationMs: nowMs() - startedAt,
      status: isAbort(error, context.signal) ? 'aborted' : 'network_error',
      meta: {
        ok: false,
        procedureCount: procedures.length,
        error: errorMessage(error),
      },
    }, true);
    // Ship it NOW, with keepalive. An abort is very often page teardown, and an event describing
    // the request that died is worthless if it dies in the buffer alongside it. Failures are rare
    // enough that a flush each is not a volume concern.
    try {
      flushClientTelemetry({ keepalive: true });
    } catch {
      // Total by contract; belt and braces.
    }
    throw error;
  }
}