UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

385 lines (354 loc) 9.94 kB
/** * @beignet/core/error-reporting * * Provider-neutral error reporting primitives for Beignet applications. */ import { type RedactionOptions, redactValue } from "../ports/redaction.js"; import { DEFAULT_ERROR_REPORTING_TIMEOUT_MS, errorReportingObserverTimeout, runErrorReportingOperation, } from "./internal.js"; export { DEFAULT_ERROR_REPORTING_TIMEOUT_MS, ErrorReportingTimeoutError, } from "./internal.js"; type MaybePromise<T> = T | Promise<T>; /** * JSON-compatible value accepted by error reporting context, tags, and extras. */ export type ErrorReportJsonValue = | null | boolean | number | string | readonly ErrorReportJsonValue[] | { readonly [key: string]: ErrorReportJsonValue }; /** * Error severity level shared by common reporting providers. */ export type ErrorReportLevel = "fatal" | "error" | "warning" | "info" | "debug"; /** * User or actor attached to a reported error. */ export type ErrorReportUser = { id?: string; email?: string; username?: string; ipAddress?: string; } & Record<string, ErrorReportJsonValue | undefined>; /** * Provider-neutral structured context attached to a reported error. */ export type ErrorReportContext = Record< string, ErrorReportJsonValue | undefined >; /** * Tags used for searching, grouping, and alert routing. */ export type ErrorReportTags = Record< string, string | number | boolean | null | undefined >; /** * Options accepted by exception and message capture calls. */ export type ErrorReportOptions = { level?: ErrorReportLevel; user?: ErrorReportUser | null; tags?: ErrorReportTags; contexts?: Record<string, ErrorReportContext | undefined>; extra?: Record<string, ErrorReportJsonValue | undefined>; fingerprint?: readonly string[]; mechanism?: string; handled?: boolean; requestId?: string; traceId?: string; spanId?: string; parentSpanId?: string; traceparent?: string; }; /** * Arguments accepted by `tryReportException(...)`. */ export interface TryReportExceptionOptions { /** Reporter that owns the capture. Omit it to make reporting a no-op. */ reporter?: ErrorReporterPort | ErrorReporterResolver; /** Original application or infrastructure error. */ error: unknown; /** Structured metadata attached to the report. */ reportOptions?: ErrorReportOptions; /** * Maximum time allowed for capture and, separately, the failure observer. * Set to `false` only when the reporting implementation is intentionally * unbounded. * * @default 1000 */ timeoutMs?: number | false; /** * Observer for reporter failures. Observer failures are also isolated. */ onReporterError?: (args: { error: unknown; reportingError: unknown; }) => MaybePromise<void>; } /** * Result returned by a reporting provider after capture. */ export type ErrorReportResult = { id?: string; }; /** * Flush options accepted by providers that buffer events. */ export type ErrorReporterFlushOptions = { timeoutMs?: number; }; /** * App-facing error reporting port. */ export type ErrorReporterPort = { captureException( error: unknown, options?: ErrorReportOptions, ): Promise<ErrorReportResult>; captureMessage( message: string, options?: ErrorReportOptions, ): Promise<ErrorReportResult>; setUser(user: ErrorReportUser | null): MaybePromise<void>; setTags(tags: ErrorReportTags): MaybePromise<void>; setContext( name: string, context: ErrorReportContext | null, ): MaybePromise<void>; flush(options?: ErrorReporterFlushOptions): Promise<boolean>; }; /** Lazy reporter resolver evaluated inside the best-effort capture deadline. */ export type ErrorReporterResolver = () => MaybePromise< ErrorReporterPort | undefined >; /** * Captured exception stored by `createMemoryErrorReporter(...)`. */ export type MemoryReportedException = { type: "exception"; error: unknown; options?: ErrorReportOptions; id: string; }; /** * Captured message stored by `createMemoryErrorReporter(...)`. */ export type MemoryReportedMessage = { type: "message"; message: string; options?: ErrorReportOptions; id: string; }; /** * Captured report stored by `createMemoryErrorReporter(...)`. */ export type MemoryErrorReport = MemoryReportedException | MemoryReportedMessage; /** * In-memory reporter state exposed for tests. */ export type MemoryErrorReporterPort = ErrorReporterPort & { reports: MemoryErrorReport[]; user: ErrorReportUser | null; tags: ErrorReportTags; contexts: Map<string, ErrorReportContext>; reset(): void; }; /** * Options accepted by `createMemoryErrorReporter(...)`. */ export type CreateMemoryErrorReporterOptions = { onCapture?: (report: MemoryErrorReport) => MaybePromise<void>; }; /** * Create a no-op reporter for apps that want to bind the port without sending * events. */ export function createNoopErrorReporter(): ErrorReporterPort { return { async captureException() { return {}; }, async captureMessage() { return {}; }, setUser() {}, setTags() {}, setContext() {}, async flush() { return true; }, }; } /** * Create an in-memory reporter for tests and local assertions. */ export function createMemoryErrorReporter( options: CreateMemoryErrorReporterOptions = {}, ): MemoryErrorReporterPort { let nextId = 1; const reports: MemoryErrorReport[] = []; const contexts = new Map<string, ErrorReportContext>(); const port: MemoryErrorReporterPort = { reports, user: null, tags: {}, contexts, async captureException(error, reportOptions) { const report: MemoryReportedException = { type: "exception", error, options: withAmbientState(port, reportOptions), id: String(nextId++), }; reports.push(report); await options.onCapture?.(report); return { id: report.id }; }, async captureMessage(message, reportOptions) { const report: MemoryReportedMessage = { type: "message", message, options: withAmbientState(port, reportOptions), id: String(nextId++), }; reports.push(report); await options.onCapture?.(report); return { id: report.id }; }, setUser(user) { port.user = user; }, setTags(tags) { port.tags = { ...port.tags, ...tags }; }, setContext(name, context) { if (context === null) { contexts.delete(name); return; } contexts.set(name, context); }, async flush() { return true; }, reset() { reports.length = 0; contexts.clear(); port.user = null; port.tags = {}; nextId = 1; }, }; return port; } /** * Report an exception through any `ErrorReporterPort`. */ export function reportException( reporter: ErrorReporterPort, error: unknown, options?: ErrorReportOptions, ): Promise<ErrorReportResult> { return reporter.captureException(error, options); } /** * Report a message through any `ErrorReporterPort`. */ export function reportMessage( reporter: ErrorReporterPort, message: string, options?: ErrorReportOptions, ): Promise<ErrorReportResult> { return reporter.captureMessage(message, options); } /** * Best-effort exception capture for runtime boundaries. * * Missing reporters, reporter failures, and reporter-failure observer errors * resolve to `undefined` so diagnostics cannot replace application behavior. */ export async function tryReportException( options: TryReportExceptionOptions, ): Promise<ErrorReportResult | undefined> { const reporterSource = options.reporter; if (!reporterSource) return undefined; const timeoutMs = options.timeoutMs ?? DEFAULT_ERROR_REPORTING_TIMEOUT_MS; try { return await runErrorReportingOperation(async () => { const reporter = typeof reporterSource === "function" ? await reporterSource() : reporterSource; if (!reporter) return undefined; return reporter.captureException(options.error, options.reportOptions); }, timeoutMs); } catch (reportingError) { try { if (options.onReporterError) { await runErrorReportingOperation( () => options.onReporterError?.({ error: options.error, reportingError, }), errorReportingObserverTimeout(timeoutMs), ); } } catch { // Reporter failure observers must not replace application behavior. } return undefined; } } /** * Redact structured error-report metadata with Beignet's shared sensitive-key * rules. The original exception is intentionally not part of this operation. */ export function redactErrorReportOptions( options: ErrorReportOptions, redactionOptions: RedactionOptions = {}, ): ErrorReportOptions { return { ...options, user: options.user ? redactValue(options.user, redactionOptions) : options.user, tags: options.tags ? redactValue(options.tags, redactionOptions) : options.tags, contexts: options.contexts ? redactValue(options.contexts, redactionOptions) : options.contexts, extra: options.extra ? redactValue(options.extra, redactionOptions) : options.extra, }; } function withAmbientState( port: MemoryErrorReporterPort, options: ErrorReportOptions | undefined, ): ErrorReportOptions | undefined { const contexts = port.contexts.size > 0 ? Object.fromEntries(port.contexts) : undefined; const hasTags = Object.keys(port.tags).length > 0; if (!port.user && !hasTags && !contexts) return options; return { ...options, user: options?.user ?? port.user ?? undefined, tags: hasTags ? { ...port.tags, ...options?.tags } : options?.tags, contexts: contexts || options?.contexts ? { ...contexts, ...options?.contexts } : undefined, }; }