133 lines
4.7 KiB
TypeScript
133 lines
4.7 KiB
TypeScript
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<void>;
|
|
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<Response> => {
|
|
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<string, unknown>;
|
|
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> | 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 const defaultMetrics = new MetricsRegistry();
|