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