@beignet/core
Version:
Core framework primitives for Beignet
500 lines (459 loc) • 14.2 kB
text/typescript
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,
};
}