230 lines
7.9 KiB
TypeScript
230 lines
7.9 KiB
TypeScript
import { createTracer, type Context, type Middleware } from "@wrnexus/core";
|
|
import { reportObservabilityFailure, type ObservabilityDiagnostic } from "./diagnostics.ts";
|
|
|
|
export interface TraceContext {
|
|
version: "00";
|
|
traceId: string;
|
|
spanId: string;
|
|
sampled: boolean;
|
|
}
|
|
|
|
export interface SpanRecord {
|
|
name: string;
|
|
traceId: string;
|
|
spanId: string;
|
|
parentSpanId?: string;
|
|
sampled: boolean;
|
|
startTime: number;
|
|
endTime: number;
|
|
durationMs: number;
|
|
status: "ok" | "error";
|
|
attributes: Record<string, string | number | boolean>;
|
|
error?: { name: string; message: string };
|
|
}
|
|
|
|
export interface SpanExporter {
|
|
export(spans: readonly SpanRecord[]): Promise<void> | void;
|
|
}
|
|
|
|
const TRACEPARENT = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
|
|
|
export function parseTraceparent(value: string | null | undefined): TraceContext | null {
|
|
if (!value) return null;
|
|
const match = TRACEPARENT.exec(value.trim().toLowerCase());
|
|
if (!match || /^0+$/.test(match[1]!) || /^0+$/.test(match[2]!)) return null;
|
|
return {
|
|
version: "00",
|
|
traceId: match[1]!,
|
|
spanId: match[2]!,
|
|
sampled: (Number.parseInt(match[3]!, 16) & 1) === 1,
|
|
};
|
|
}
|
|
|
|
export function formatTraceparent(context: TraceContext): string {
|
|
return `00-${context.traceId}-${context.spanId}-${context.sampled ? "01" : "00"}`;
|
|
}
|
|
|
|
function randomHex(bytes: number, random: (target: Uint8Array) => Uint8Array): string {
|
|
let value = "";
|
|
while (!value || /^0+$/.test(value)) {
|
|
value = Array.from(random(new Uint8Array(bytes)), (byte) =>
|
|
byte.toString(16).padStart(2, "0"),
|
|
).join("");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export interface TraceMiddlewareOptions {
|
|
serviceName?: string;
|
|
sampleRate?: number;
|
|
exporter?: SpanExporter;
|
|
onSpan?: (span: SpanRecord) => void | Promise<void>;
|
|
now?: () => number;
|
|
random?: (target: Uint8Array) => Uint8Array;
|
|
routeName?: (ctx: Context) => string;
|
|
serverTiming?: boolean;
|
|
onExportError?: (error: unknown, span: SpanRecord) => void | Promise<void>;
|
|
diagnostic?: ObservabilityDiagnostic;
|
|
}
|
|
|
|
export function traceMiddleware(options: TraceMiddlewareOptions = {}): Middleware {
|
|
const sampleRate = options.sampleRate ?? 1;
|
|
if (!Number.isFinite(sampleRate) || sampleRate < 0 || sampleRate > 1) {
|
|
throw new Error("Observability trace sampleRate must be between 0 and 1.");
|
|
}
|
|
const now = options.now ?? Date.now;
|
|
const random = options.random ?? ((target: Uint8Array) => crypto.getRandomValues(target));
|
|
return async (ctx, next) => {
|
|
const parent = parseTraceparent(ctx.req.headers.get("traceparent"));
|
|
const sampled = parent?.sampled ?? Math.random() < sampleRate;
|
|
const traceId = parent?.traceId ?? randomHex(16, random);
|
|
const spanId = randomHex(8, random);
|
|
const trace: TraceContext = { version: "00", traceId, spanId, sampled };
|
|
const started = now();
|
|
ctx.locals.traceId = traceId;
|
|
ctx.locals.spanId = spanId;
|
|
ctx.locals.traceparent = formatTraceparent(trace);
|
|
ctx.locals.requestId ??= traceId;
|
|
ctx.tracer ??= createTracer(now);
|
|
const frameworkSpan = ctx.tracer.startSpan("http.request", {
|
|
traceId,
|
|
spanId,
|
|
method: ctx.req.method,
|
|
path: ctx.url.pathname,
|
|
});
|
|
let response: Response | undefined;
|
|
let failure: unknown;
|
|
let failed = false;
|
|
try {
|
|
response = await next();
|
|
} catch (error) {
|
|
failure = error;
|
|
failed = true;
|
|
} finally {
|
|
const ended = now();
|
|
const span: SpanRecord = {
|
|
name: options.routeName?.(ctx) ?? `${ctx.req.method} ${ctx.url.pathname}`,
|
|
traceId,
|
|
spanId,
|
|
...(parent ? { parentSpanId: parent.spanId } : {}),
|
|
sampled,
|
|
startTime: started,
|
|
endTime: ended,
|
|
durationMs: ended - started,
|
|
status: failed || (response?.status ?? 500) >= 500 ? "error" : "ok",
|
|
attributes: {
|
|
"service.name": options.serviceName ?? "wrnexus",
|
|
"http.request.method": ctx.req.method,
|
|
"url.path": ctx.url.pathname,
|
|
"http.response.status_code": response?.status ?? 500,
|
|
},
|
|
...(failed
|
|
? {
|
|
error: {
|
|
name: failure instanceof Error ? failure.name : "Error",
|
|
message: failure instanceof Error ? failure.message : String(failure),
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
frameworkSpan.end(span.status, failure);
|
|
if (sampled) {
|
|
try {
|
|
await options.onSpan?.(span);
|
|
await options.exporter?.export([span]);
|
|
} catch (error) {
|
|
if (options.onExportError) await options.onExportError(error, span);
|
|
else reportObservabilityFailure("trace export failed", error, options.diagnostic);
|
|
}
|
|
}
|
|
}
|
|
if (failed) throw failure;
|
|
const headers = new Headers(response!.headers);
|
|
headers.set("traceparent", formatTraceparent(trace));
|
|
headers.set("x-request-id", String(ctx.locals.requestId));
|
|
if (options.serverTiming !== false) {
|
|
headers.append("server-timing", `trace;dur=${Math.max(0, now() - started).toFixed(2)}`);
|
|
}
|
|
return new Response(response!.body, {
|
|
status: response!.status,
|
|
statusText: response!.statusText,
|
|
headers,
|
|
});
|
|
};
|
|
}
|
|
|
|
function otlpValue(value: string | number | boolean) {
|
|
if (typeof value === "boolean") return { boolValue: value };
|
|
if (typeof value === "number")
|
|
return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value };
|
|
return { stringValue: value };
|
|
}
|
|
|
|
export function createOtlpTraceExporter(
|
|
endpoint: string,
|
|
options: { headers?: HeadersInit; fetch?: typeof fetch; serviceName?: string } = {},
|
|
): SpanExporter {
|
|
const send = options.fetch ?? fetch;
|
|
return {
|
|
async export(spans) {
|
|
if (!spans.length) return;
|
|
const body = {
|
|
resourceSpans: [
|
|
{
|
|
resource: {
|
|
attributes: [
|
|
{ key: "service.name", value: { stringValue: options.serviceName ?? "wrnexus" } },
|
|
],
|
|
},
|
|
scopeSpans: [
|
|
{
|
|
scope: { name: "@wrnexus/observability", version: "0.8.0" },
|
|
spans: spans.map((span) => ({
|
|
traceId: span.traceId,
|
|
spanId: span.spanId,
|
|
...(span.parentSpanId ? { parentSpanId: span.parentSpanId } : {}),
|
|
name: span.name,
|
|
kind: 2,
|
|
startTimeUnixNano: String(BigInt(Math.trunc(span.startTime)) * 1_000_000n),
|
|
endTimeUnixNano: String(BigInt(Math.trunc(span.endTime)) * 1_000_000n),
|
|
attributes: Object.entries(span.attributes).map(([key, value]) => ({
|
|
key,
|
|
value: otlpValue(value),
|
|
})),
|
|
status: { code: span.status === "error" ? 2 : 1 },
|
|
...(span.error
|
|
? {
|
|
events: [
|
|
{
|
|
timeUnixNano: String(BigInt(Math.trunc(span.endTime)) * 1_000_000n),
|
|
name: "exception",
|
|
attributes: [
|
|
{ key: "exception.type", value: { stringValue: span.error.name } },
|
|
{
|
|
key: "exception.message",
|
|
value: { stringValue: span.error.message },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}
|
|
: {}),
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const response = await send(endpoint, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...Object.fromEntries(new Headers(options.headers)),
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!response.ok) throw new Error(`OTLP trace export failed with ${response.status}.`);
|
|
},
|
|
};
|
|
}
|