import type { Context, Middleware } from "@wrnexus/core"; import { MetricsRegistry, type MetricPoint } from "./metrics.ts"; export interface MetricsMiddlewareOptions { registry?: MetricsRegistry; routeLabel?: (ctx: Context) => string; includePath?: boolean; } export function metricsMiddleware(options: MetricsMiddlewareOptions = {}): Middleware { const registry = options.registry ?? defaultMetrics; let active = 0; return async (ctx, next) => { const started = performance.now(); active++; registry.gauge("http.server.active_requests", active); try { const response = await next(); const route = options.routeLabel?.(ctx) ?? (options.includePath ? ctx.url.pathname : "unknown"); const labels = { method: ctx.req.method, status: response.status, route }; registry.increment("http.server.requests", 1, labels); registry.observe("http.server.duration_ms", performance.now() - started, labels); return response; } catch (error) { registry.increment("http.server.errors", 1, { method: ctx.req.method }); throw error; } finally { active--; registry.gauge("http.server.active_requests", active); } }; } export interface WebVitalRecord { name: "LCP" | "INP" | "CLS" | "FCP" | "TTFB"; value: number; rating?: "good" | "needs-improvement" | "poor"; route?: string; navigationType?: string; } export interface WebVitalsHandlerOptions { registry?: MetricsRegistry; onRecord?: (record: WebVitalRecord, request: Request) => void | Promise; maxBodyBytes?: number; } const VITAL_NAMES = new Set(["LCP", "INP", "CLS", "FCP", "TTFB"]); export function createWebVitalsHandler(options: WebVitalsHandlerOptions = {}) { const registry = options.registry ?? defaultMetrics; const maxBodyBytes = options.maxBodyBytes ?? 8 * 1024; return async (request: Request): Promise => { if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 }); if (request.headers.get("sec-fetch-site") === "cross-site") { return new Response("Forbidden", { status: 403 }); } const origin = request.headers.get("origin"); if (origin) { try { if (new URL(origin).origin !== new URL(request.url).origin) { return new Response("Forbidden", { status: 403 }); } } catch { return new Response("Forbidden", { status: 403 }); } } const length = Number(request.headers.get("content-length") ?? "0"); if (length > maxBodyBytes) return new Response("Payload Too Large", { status: 413 }); let body: unknown; try { const text = await request.text(); if (new TextEncoder().encode(text).byteLength > maxBodyBytes) { return new Response("Payload Too Large", { status: 413 }); } body = JSON.parse(text); } catch { return new Response("Invalid JSON", { status: 400 }); } if (!body || typeof body !== "object") return new Response("Invalid metric", { status: 400 }); const raw = body as Record; if ( !VITAL_NAMES.has(String(raw.name)) || typeof raw.value !== "number" || !Number.isFinite(raw.value) ) { return new Response("Invalid metric", { status: 400 }); } const record: WebVitalRecord = { name: raw.name as WebVitalRecord["name"], value: raw.value, ...(typeof raw.rating === "string" ? { rating: raw.rating as WebVitalRecord["rating"] } : {}), ...(typeof raw.route === "string" ? { route: raw.route.slice(0, 256) } : {}), ...(typeof raw.navigationType === "string" ? { navigationType: raw.navigationType.slice(0, 64) } : {}), }; registry.observe(`web.vitals.${record.name.toLowerCase()}`, record.value, { rating: record.rating ?? "unknown", route: record.route ?? "unknown", }); await options.onRecord?.(record, request); return new Response(null, { status: 204, headers: { "cache-control": "no-store" } }); }; } export interface MetricExporter { export(points: readonly MetricPoint[]): Promise | void; } export function createHttpMetricExporter( endpoint: string, options: { headers?: HeadersInit; fetch?: typeof fetch } = {}, ): MetricExporter { const send = options.fetch ?? fetch; return { async export(points) { const response = await send(endpoint, { method: "POST", headers: { "content-type": "application/json", ...Object.fromEntries(new Headers(options.headers)), }, body: JSON.stringify({ resourceMetrics: points }), }); if (!response.ok) throw new Error(`Metric export failed with ${response.status}.`); }, }; } export function createOtlpMetricExporter( endpoint: string, options: { headers?: HeadersInit; fetch?: typeof fetch; serviceName?: string } = {}, ): MetricExporter { const send = options.fetch ?? fetch; return { async export(points) { if (!points.length) return; const metrics = points.map((point) => { const attributes = Object.entries(point.labels).map(([key, value]) => ({ key, value: typeof value === "boolean" ? { boolValue: value } : typeof value === "number" ? { doubleValue: value } : { stringValue: value }, })); const timeUnixNano = String(BigInt(Math.trunc(point.timestamp)) * 1_000_000n); if (point.type === "histogram") { return { name: point.name, histogram: { aggregationTemporality: 2, dataPoints: [ { attributes, timeUnixNano, count: String(point.count ?? 0), sum: point.sum ?? 0, min: point.min, max: point.max, bucketCounts: [String(point.count ?? 0)], explicitBounds: [], }, ], }, }; } const kind = point.type === "counter" ? "sum" : "gauge"; return { name: point.name, [kind]: { ...(kind === "sum" ? { aggregationTemporality: 2, isMonotonic: true } : {}), dataPoints: [{ attributes, timeUnixNano, asDouble: point.value }], }, }; }); const response = await send(endpoint, { method: "POST", headers: { "content-type": "application/json", ...Object.fromEntries(new Headers(options.headers)), }, body: JSON.stringify({ resourceMetrics: [ { resource: { attributes: [ { key: "service.name", value: { stringValue: options.serviceName ?? "wrnexus" } }, ], }, scopeMetrics: [ { scope: { name: "@wrnexus/observability", version: "0.8.0" }, metrics, }, ], }, ], }), }); if (!response.ok) throw new Error(`OTLP metric export failed with ${response.status}.`); }, }; } export const defaultMetrics = new MetricsRegistry();