UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

500 lines (459 loc) 14.2 kB
import { BEIGNET_ERROR_OWNER_HEADER, type HttpContractConfig, } from "../contracts/index.js"; import type { AnyPorts } from "../ports/index.js"; import { redactValue } from "../ports/redaction.js"; import { type ProviderInstrumentationEventInput, type ProviderInstrumentationPort, resolveProviderInstrumentationPort, } from "../providers/instrumentation.js"; import { createTraceContext, parseTraceparent, resolveTracingPort, type TraceContext, type TracingPort, } from "../tracing/index.js"; import type { HttpRequestLike, HttpResponseLike, ServerHook } from "./http.js"; import { clearActiveRequestContext, enterActiveRequestContext, readContextActor, readContextTenant, } from "./request-context.js"; /** * Options for the server-owned request instrumentation pipeline. * * The server resolves request IDs and W3C trace context before user hooks and * context creation run, writes them to response headers, and records request * and error events into the resolved provider instrumentation port * (`ports.instrumentation`, then `ports.devtools`) after responses are sent. * * Pass `instrumentation: false` to `createServer(...)` to disable headers and * event recording entirely. */ export interface ServerInstrumentationOptions<Ctx = unknown> { /** * Request/response header used for the request correlation ID. * * Pass `false` to avoid reading or writing a request ID header. * * @default "x-request-id" */ requestIdHeader?: string | false; /** * W3C trace context header used to correlate events with distributed * traces. * * Pass `false` to avoid reading or writing a trace context header. * * @default "traceparent" */ traceContextHeader?: string | false; /** * Request path prefixes that should not enter ambient correlation or record * events. Response headers are still written. * * Defaults to the devtools dashboard prefix so its polling traffic does not * fill the event timeline. * * @default ["/api/devtools"] */ ignorePaths?: readonly string[]; /** * Apply a custom redactor to events produced by the server. Sink-level * redaction (such as the devtools redactor) still runs when events are * stored. */ redact?: ( event: ProviderInstrumentationEventInput, ) => ProviderInstrumentationEventInput; /** * Decide whether to capture a completed request event. */ shouldCapture?: (args: { req: HttpRequestLike; ctx?: Ctx; contract: HttpContractConfig; response: HttpResponseLike; error?: unknown; }) => boolean; } /** * Correlation values resolved by the server for one request or service * context. */ export interface RequestCorrelation { /** * Request correlation ID. */ requestId: string; /** * W3C trace context. */ trace: TraceContext; } /** * Internal runtime created by `createServer(...)` from its `instrumentation` * option. */ export interface ServerInstrumentationRuntime<Ctx> { /** * Resolve the instrumentation sink once final ports are known. */ attachPorts(ports: AnyPorts): void; /** * Resolve (and cache per request) the request ID and trace context. */ prepareRequest(req: HttpRequestLike): RequestCorrelation; /** * Create fresh correlation values for a service context. */ createServiceCorrelation(): RequestCorrelation; /** * Pipeline hook installed before user hooks, when instrumentation is * enabled. */ hook?: ServerHook<Ctx, AnyPorts>; } type TraceContextFields = { traceId?: string; spanId?: string; parentSpanId?: string; traceparent?: string; tracestate?: string; }; function getContextRequestId(ctx: unknown): string | undefined { if (!ctx || typeof ctx !== "object") return undefined; const requestId = (ctx as { requestId?: unknown }).requestId; return typeof requestId === "string" ? requestId : undefined; } function getContextTraceContext(ctx: unknown): TraceContextFields | undefined { if (!ctx || typeof ctx !== "object") return undefined; const context = ctx as TraceContextFields; if ( !context.traceId && !context.spanId && !context.parentSpanId && !context.traceparent && !context.tracestate ) { return undefined; } return { traceId: context.traceId, spanId: context.spanId, parentSpanId: context.parentSpanId, traceparent: context.traceparent, tracestate: context.tracestate, }; } function getErrorMessage(error: unknown): string { if (error instanceof Error) return error.message; if (typeof error === "string") return error; return "Unknown error"; } function getErrorStack(error: unknown): string | undefined { return error instanceof Error ? error.stack : undefined; } function createRequestId(): string { if (typeof crypto !== "undefined" && "randomUUID" in crypto) { return crypto.randomUUID(); } return `${Date.now()}-${Math.random().toString(16).slice(2)}`; } function getPathname(req: HttpRequestLike): string { try { return new URL(req.url).pathname; } catch { return req.url; } } function isIgnoredPath( pathname: string, ignorePaths: readonly string[], ): boolean { return ignorePaths.some((ignorePath) => { const normalized = ignorePath.replace(/\/+$/, ""); return pathname === normalized || pathname.startsWith(`${normalized}/`); }); } function requestHeadersToRecord(headers: Headers): Record<string, string> { const record: Record<string, string> = {}; headers.forEach((value, key) => { record[key] = value; }); return record; } function getResponseHeader( headers: Record<string, string> | undefined, name: string, ): string | undefined { if (!headers) return undefined; const direct = headers[name]; if (direct !== undefined) return direct; const normalized = name.toLowerCase(); const entry = Object.entries(headers).find( ([key]) => key.toLowerCase() === normalized, ); return entry?.[1]; } function getResponseOwner( response: HttpResponseLike, ): "route" | "framework" | "transport" | "unknown" { return getResponseHeader(response.headers, BEIGNET_ERROR_OWNER_HEADER) === "framework" ? "framework" : "route"; } function isWatcherEnabled( port: ProviderInstrumentationPort, name: string, ): boolean { return port.isWatcherEnabled?.(name) ?? true; } /** * Create the server-owned instrumentation runtime for `createServer(...)`. * * Correlation values (request ID and trace context) are always resolved so * context factories receive stable `requestId`/`trace` arguments, even when * instrumentation is disabled. Headers and event recording only run when * instrumentation is enabled. */ export function createServerInstrumentation<Ctx>( options: ServerInstrumentationOptions<Ctx> | false | undefined, ): ServerInstrumentationRuntime<Ctx> { const enabled = options !== false; const resolvedOptions = options === false || options === undefined ? {} : options; const requestIdHeader = resolvedOptions.requestIdHeader ?? "x-request-id"; const traceContextHeader = resolvedOptions.traceContextHeader ?? "traceparent"; const ignorePaths = resolvedOptions.ignorePaths ?? ["/api/devtools"]; let port: ProviderInstrumentationPort | undefined; let tracing: TracingPort | undefined; const correlations = new WeakMap<HttpRequestLike, RequestCorrelation>(); const prepareRequest = (req: HttpRequestLike): RequestCorrelation => { const cached = correlations.get(req); if (cached) return cached; const headerRequestId = requestIdHeader === false ? undefined : (req.headers.get(requestIdHeader) ?? undefined); const headerTraceparent = traceContextHeader === false ? undefined : (req.headers.get(traceContextHeader) ?? undefined); const parsedTraceparent = parseTraceparent(headerTraceparent); const activeTrace = tracing?.current(); const correlation: RequestCorrelation = { requestId: headerRequestId ?? createRequestId(), trace: activeTrace ?? createTraceContext({ traceparent: parsedTraceparent?.traceparent, tracestate: parsedTraceparent ? (req.headers.get("tracestate") ?? undefined) : undefined, }), }; correlations.set(req, correlation); return correlation; }; const resolveRequestId = (args: { req: HttpRequestLike; ctx?: unknown; }): string => getContextRequestId(args.ctx) ?? prepareRequest(args.req).requestId; const resolveTraceContext = (args: { req: HttpRequestLike; ctx?: unknown; }): TraceContext => { const activeTrace = tracing?.current(); if (activeTrace) return activeTrace; const contextTrace = getContextTraceContext(args.ctx); if (contextTrace) { return createTraceContext(contextTrace); } return prepareRequest(args.req).trace; }; const record = (event: ProviderInstrumentationEventInput) => { if (!port) return; let prepared = redactValue(event); if (resolvedOptions.redact) { try { prepared = resolvedOptions.redact(prepared); } catch (error) { try { port.record({ type: "error", message: "Server instrumentation redactor failed", owner: "framework", details: { message: getErrorMessage(error), }, }); } catch { // Instrumentation sinks must never affect responses. } return; } } try { port.record(prepared); } catch { // Instrumentation sinks must never affect responses. } }; const enterAmbientContext = (args: { req: HttpRequestLike; ctx?: unknown; }) => { if (isIgnoredPath(getPathname(args.req), ignorePaths)) return; const trace = resolveTraceContext(args); enterActiveRequestContext({ requestId: resolveRequestId(args), traceId: trace.traceId, spanId: trace.spanId, parentSpanId: trace.parentSpanId, traceparent: trace.traceparent, tracestate: trace.tracestate, actor: readContextActor(args.ctx), tenant: readContextTenant(args.ctx), }); }; const hook: ServerHook<Ctx, AnyPorts> = { name: "beignet.instrumentation", onRequest: ({ req }) => { enterAmbientContext({ req }); return undefined; }, beforeHandle: ({ req, ctx }) => { // Re-enter with context values so app-owned overrides win for ambient // correlation inheritance. enterAmbientContext({ req, ctx }); return undefined; }, beforeSend: ({ req, ctx, response }) => { if (requestIdHeader === false && traceContextHeader === false) { return undefined; } const requestId = resolveRequestId({ req, ctx }); const trace = resolveTraceContext({ req, ctx }); return { ...response, headers: { ...response.headers, ...(requestIdHeader === false ? {} : { [requestIdHeader]: requestId }), ...(traceContextHeader === false ? {} : { [traceContextHeader]: trace.traceparent }), }, }; }, afterSend: ({ req, ctx, contract, response, error, durationMs, stages, }) => { try { if (!port) return; const path = getPathname(req); if (isIgnoredPath(path, ignorePaths)) return; const shouldCaptureRequest = isWatcherEnabled(port, "requests"); const shouldCaptureError = Boolean(error) && isWatcherEnabled(port, "errors"); if (!shouldCaptureRequest && !shouldCaptureError) return; if ( resolvedOptions.shouldCapture && !resolvedOptions.shouldCapture({ req, ctx: ctx as Ctx | undefined, contract, response, error, }) ) { return; } const requestId = resolveRequestId({ req, ctx }); const trace = resolveTraceContext({ req, ctx }); const responseOwner = getResponseOwner(response); if (shouldCaptureRequest) { record({ type: "request", requestId, traceId: trace.traceId, spanId: trace.spanId, parentSpanId: trace.parentSpanId, traceparent: trace.traceparent, tracestate: trace.tracestate, method: req.method, path, contractName: contract.name, responseOwner, status: response.status, durationMs, stages, details: { headers: requestHeadersToRecord(req.headers), route: { contractName: contract.name, method: req.method, path, }, response: { owner: responseOwner, status: response.status, }, hookPhases: [ "onRequest", "beforeHandle", "beforeSend", "afterSend", ], }, }); } if (error && shouldCaptureError) { record({ type: "error", requestId, traceId: trace.traceId, spanId: trace.spanId, parentSpanId: trace.parentSpanId, traceparent: trace.traceparent, tracestate: trace.tracestate, message: getErrorMessage(error), stack: getErrorStack(error), contractName: contract.name, owner: responseOwner === "framework" ? "framework" : "route", }); } } finally { clearActiveRequestContext(); } }, }; return { attachPorts(ports) { tracing = resolveTracingPort(ports); if (!enabled) return; port = resolveProviderInstrumentationPort(ports); }, prepareRequest, createServiceCorrelation: () => ({ requestId: createRequestId(), trace: tracing?.current() ?? createTraceContext(), }), hook: enabled ? hook : undefined, }; }