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