import type { Context, Middleware } from "./context.ts"; export interface SpanRecord { name: string; startTime: number; endTime?: number; durationMs?: number; status?: "ok" | "error"; attributes: Record; error?: unknown; } export interface Tracer { startSpan(name: string, attributes?: SpanRecord["attributes"]): Span; records(): readonly SpanRecord[]; } export interface Span { setAttribute(name: string, value: string | number | boolean): void; end(status?: "ok" | "error", error?: unknown): SpanRecord; } export function createTracer(clock: () => number = () => performance.now()): Tracer { const spans: SpanRecord[] = []; return { startSpan(name, attributes = {}) { const record: SpanRecord = { name, startTime: clock(), attributes: { ...attributes } }; spans.push(record); let ended = false; return { setAttribute(key, value) { record.attributes[key] = value; }, end(status = "ok", error) { if (!ended) { ended = true; record.endTime = clock(); record.durationMs = record.endTime - record.startTime; record.status = status; record.error = error; } return record; }, }; }, records: () => spans, }; } export async function withSpan( tracer: Tracer, name: string, run: (span: Span) => T | Promise, attributes?: SpanRecord["attributes"], ): Promise { const span = tracer.startSpan(name, attributes); try { const result = await run(span); span.end("ok"); return result; } catch (error) { span.end("error", error); throw error; } } export interface TracingMiddlewareOptions { /** Include W3C Server-Timing response headers. Defaults to true. */ serverTiming?: boolean; /** Fraction of requests to trace, from 0 to 1. Defaults to 1. */ sampleRate?: number; /** Called after a traced response completes. */ onComplete?: (ctx: Context, records: readonly SpanRecord[]) => void | Promise; } export function tracingMiddleware( tracerFactory: (ctx: Context) => Tracer = () => createTracer(), options: TracingMiddlewareOptions = {}, ): Middleware { const sampleRate = Math.max(0, Math.min(1, options.sampleRate ?? 1)); return async (ctx, next) => { if (sampleRate === 0 || (sampleRate < 1 && Math.random() > sampleRate)) return next(); const tracer = tracerFactory(ctx); ctx.tracer = tracer; const response = await withSpan(tracer, "http.request", () => next(), { method: ctx.req.method, path: ctx.url.pathname, }); const records = tracer.records(); await options.onComplete?.(ctx, records); if (options.serverTiming === false) return response; const headers = new Headers(response.headers); const timings = records .filter((record) => record.durationMs !== undefined) .map( (record, index) => `wrn${index};dur=${record.durationMs!.toFixed(2)};desc="${record.name.replace(/"/g, "")}"`, ); if (timings.length) headers.set("server-timing", timings.join(", ")); return new Response(response.body, { status: response.status, statusText: response.statusText, headers, }); }; }