@beignet/core
Version:
Core framework primitives for Beignet
306 lines (281 loc) • 8.36 kB
text/typescript
/**
* Error reporting hook utilities for @beignet/core/server
*/
import type { HttpContractConfig } from "../../contracts/index.js";
import type {
ErrorReporterPort,
ErrorReportOptions,
} from "../../error-reporting/index.js";
import {
DEFAULT_ERROR_REPORTING_TIMEOUT_MS,
tryReportException,
} from "../../error-reporting/index.js";
import {
errorReportingObserverTimeout,
runErrorReportingOperation,
} from "../../error-reporting/internal.js";
import { isAppError, SchemaValidationError } from "../../errors/index.js";
import {
IdempotencyConflictError,
IdempotencyInProgressError,
} from "../../idempotency/index.js";
import {
AuthUnauthorizedError,
EntitlementRequiredError,
GateAuthorizationError,
TenantRequiredError,
} from "../../ports/index.js";
import type {
HttpRequestLike,
ServerCaughtErrorHook,
ServerHook,
} from "../types.js";
type MaybePromise<T> = T | Promise<T>;
/**
* Minimal context shape used by `createErrorReportingHooks(...)`.
*/
export type ErrorReportingContext = {
ports?: {
errorReporter?: ErrorReporterPort;
};
requestId?: string;
traceId?: string;
spanId?: string;
parentSpanId?: string;
traceparent?: string;
actor?: {
type?: string;
id?: string;
};
tenant?: {
id?: string;
};
};
/**
* Arguments passed to error reporting hook callbacks.
*/
export type ErrorReportingHookArgs<Ctx> = Parameters<
ServerCaughtErrorHook<Ctx>
>[0];
/**
* Error reporter resolver accepted by `createErrorReportingHooks(...)`.
*/
export type ErrorReporterResolver<Ctx> = (
args: ErrorReportingHookArgs<Ctx>,
) => MaybePromise<ErrorReporterPort | undefined>;
/**
* Options accepted by `createErrorReportingHooks(...)`.
*/
export interface ErrorReportingHooksOptions<Ctx> {
/**
* Reporter to use instead of `ctx.ports.errorReporter`.
*/
reporter?: ErrorReporterPort | ErrorReporterResolver<Ctx>;
/**
* Decide whether a caught error should be captured. The default reports
* unexpected failures and skips expected catalog/auth/validation outcomes.
*/
shouldReport?: (args: ErrorReportingHookArgs<Ctx>) => MaybePromise<boolean>;
/**
* Additional capture options. These are merged over Beignet's request,
* actor, tenant, contract, and trace metadata.
*/
reportOptions?:
| ErrorReportOptions
| ((args: ErrorReportingHookArgs<Ctx>) => MaybePromise<ErrorReportOptions>);
/**
* Maximum time allowed for reporting preparation, capture, and failure
* observation. Each phase is bounded independently. Set to `false` only for
* intentionally unbounded reporters.
*
* @default 1000
*/
timeoutMs?: number | false;
/**
* Observer for reporter failures. Reporter failures are otherwise ignored so
* error reporting cannot change the HTTP response.
*/
onReporterError?: (args: {
error: unknown;
reportingError: unknown;
ctx?: Ctx;
req: HttpRequestLike;
contract: HttpContractConfig;
}) => MaybePromise<void>;
}
/**
* Create server hooks that capture unexpected HTTP request failures through
* `ctx.ports.errorReporter`.
*
* The hook observes `onCaughtError`, never maps responses, and ignores capture
* failures by default so reporting cannot affect request handling.
*/
export function createErrorReportingHooks<
Ctx extends ErrorReportingContext = ErrorReportingContext,
>(config: ErrorReportingHooksOptions<Ctx> = {}): ServerHook<Ctx> {
return {
name: "error-reporting",
async onCaughtError(args) {
const timeoutMs = config.timeoutMs ?? DEFAULT_ERROR_REPORTING_TIMEOUT_MS;
let prepared:
| { reporter: ErrorReporterPort; reportOptions: ErrorReportOptions }
| undefined;
try {
prepared = await runErrorReportingOperation(async () => {
const shouldReport = config.shouldReport
? await config.shouldReport(args)
: shouldReportServerError(args.err);
if (!shouldReport) return undefined;
const reporter = await resolveReporter(config.reporter, args);
if (!reporter) return undefined;
const baseOptions = createDefaultReportOptions(args);
const customOptions =
typeof config.reportOptions === "function"
? await config.reportOptions(args)
: config.reportOptions;
return {
reporter,
reportOptions: mergeReportOptions(baseOptions, customOptions),
};
}, timeoutMs);
} catch (reportingError) {
await notifyReporterError(config, args, reportingError, timeoutMs);
return;
}
if (!prepared) return;
await tryReportException({
reporter: prepared.reporter,
error: args.err,
reportOptions: prepared.reportOptions,
timeoutMs,
onReporterError: ({ reportingError }) =>
config.onReporterError?.({
error: args.err,
reportingError,
ctx: args.ctx,
req: args.req,
contract: args.contract,
}),
});
},
};
}
async function notifyReporterError<Ctx>(
config: ErrorReportingHooksOptions<Ctx>,
args: ErrorReportingHookArgs<Ctx>,
reportingError: unknown,
timeoutMs: number | false,
): Promise<void> {
if (!config.onReporterError) return;
try {
await runErrorReportingOperation(
() =>
config.onReporterError?.({
error: args.err,
reportingError,
ctx: args.ctx,
req: args.req,
contract: args.contract,
}),
errorReportingObserverTimeout(timeoutMs),
);
} catch {
// Reporting observers must not affect request handling.
}
}
/**
* Default filter for HTTP error reporting.
*/
export function shouldReportServerError(error: unknown): boolean {
if (isAppError(error)) return false;
if (error instanceof AuthUnauthorizedError) return false;
if (error instanceof TenantRequiredError) return false;
if (error instanceof IdempotencyConflictError) return false;
if (error instanceof IdempotencyInProgressError) return false;
if (error instanceof GateAuthorizationError) return false;
if (error instanceof EntitlementRequiredError) return false;
if (error instanceof SchemaValidationError) return false;
return true;
}
async function resolveReporter<Ctx extends ErrorReportingContext>(
reporter: ErrorReportingHooksOptions<Ctx>["reporter"],
args: ErrorReportingHookArgs<Ctx>,
): Promise<ErrorReporterPort | undefined> {
if (typeof reporter === "function") {
return reporter(args);
}
if (reporter) {
return reporter;
}
return args.ctx?.ports?.errorReporter;
}
function createDefaultReportOptions<Ctx extends ErrorReportingContext>(
args: ErrorReportingHookArgs<Ctx>,
): ErrorReportOptions {
const ctx = args.ctx;
const url = safeUrl(args.req.url);
const actor = ctx?.actor;
const tenant = ctx?.tenant;
return {
level: "error",
mechanism: "beignet.server",
handled: false,
requestId: ctx?.requestId,
traceId: ctx?.traceId,
spanId: ctx?.spanId,
parentSpanId: ctx?.parentSpanId,
traceparent: ctx?.traceparent,
user: actor?.type === "user" && actor.id ? { id: actor.id } : undefined,
tags: {
"beignet.kind": "http",
"http.method": args.req.method,
"http.route": args.contract.path,
"http.contract": args.contract.name,
},
contexts: {
request: {
method: args.req.method,
path: url?.pathname ?? args.contract.path,
route: args.contract.path,
contract: args.contract.name,
},
actor:
actor?.type || actor?.id
? {
type: actor.type,
id: actor.id,
}
: undefined,
tenant: tenant?.id ? { id: tenant.id } : undefined,
},
};
}
function mergeReportOptions(
base: ErrorReportOptions,
custom: ErrorReportOptions | undefined,
): ErrorReportOptions {
if (!custom) return base;
return {
...base,
...custom,
tags: {
...base.tags,
...custom.tags,
},
contexts: {
...base.contexts,
...custom.contexts,
},
extra: {
...base.extra,
...custom.extra,
},
};
}
function safeUrl(value: string): URL | undefined {
try {
return new URL(value);
} catch {
return undefined;
}
}