Files
webmail/lib/error-reporting/server.ts
T
Linus Rath cba1109444 feat: report browser and server errors to an error tracker the admin sets
Admins had logs and nothing else: client errors only reached the
browser console. An admin can now set a Sentry-protocol address (DSN)
for Bugsink, GlitchTip or Sentry under System > Monitoring, or with
`ERROR_REPORTING_DSN`, `ERROR_REPORTING_DSN_FILE`,
`ERROR_REPORTING_SAMPLE_RATE` and `ERROR_REPORTING_ENVIRONMENT`.
Bulwark Lite reads the same from `errorReporting` in policy.json.
Everything stays off while no address is set.

- browsers report errors from the error boundaries, window.onerror,
  unhandled rejections and failed JMAP requests (method and error type
  only), with route and JMAP method breadcrumbs and the chunks' debug IDs
- the server reports what Next hands to onRequestError and what routes
  log with logger.error
- every report is scrubbed first: quoted text, addresses, URLs, file
  names, tokens and the rest after the first line are removed, and the
  server scrubs what browsers post to /api/errors again
- reports are deduplicated and rate-limited per session and process
- errors are counted per day for "errors today" on the admin page
- startup messages and audit entries go through the logger, so
  `LOG_FORMAT=json` covers them
2026-11-01 02:50:19 +01:00

133 lines
4.7 KiB
TypeScript

import { configManager } from '@/lib/admin/config-manager';
import { recordError } from './aggregate';
import {
appVersion, buildEnvelope, buildEvent, envelopeUrl, newEventId, parseDsn, releaseName, type ErrorReport,
} from './envelope';
import { fingerprint } from './fingerprint';
import { ReportLimiter } from './limiter';
import { errorReportingSettings, shouldCollectErrors } from './settings';
import { scrubName, scrubText } from './scrub';
import { parseStack, type Frame } from './stack';
import { debugIdsByPath } from './debug-ids';
/**
* Error reporting on the server: errors Next hands to onRequestError
* (instrumentation.ts) and errors routes catch and log with logger.error.
* While collection is on (a DSN, the metrics endpoint or error-count
* consent, lib/error-reporting/settings.ts) every one is counted in the
* per-day aggregate; with a DSN it is also sent there, at most once an hour
* per fingerprint and 60 a minute. Sending never blocks or fails the request
* that had the error. With everything off nothing happens at all.
*/
const SEND_TIMEOUT_MS = 5000;
const ROUTE_PATTERN = /^\/[\w/[\]().@-]*$/;
interface ServerState {
limiter: ReportLimiter;
sending: number;
}
const KEY = Symbol.for('bulwark.error-reporting.server');
function state(): ServerState {
const g = globalThis as typeof globalThis & { [KEY]?: ServerState };
return (g[KEY] ??= {
limiter: new ReportLimiter({ perMinute: 60, perSession: Number.POSITIVE_INFINITY, dedupWindowMs: 60 * 60_000 }),
sending: 0,
});
}
async function deliver(report: ErrorReport): Promise<void> {
if (!(await shouldCollectErrors())) return;
const version = appVersion();
recordError(report, 'server', version);
const settings = errorReportingSettings();
const dsn = parseDsn(settings.dsn);
if (!dsn) return;
const print = fingerprint(report.name, report.frames, version, report.code);
const s = state();
if (s.sending > 4 || !s.limiter.allow(print)) return;
if (Math.random() >= settings.sampleRate) return;
const event = buildEvent(report, {
eventId: newEventId(),
timestamp: Date.now() / 1000,
release: releaseName(),
environment: settings.environment,
platform: 'node',
fingerprint: print,
debugIds: debugIdsByPath(),
tags: { runtime: 'server' },
});
s.sending++;
try {
await fetch(envelopeUrl(dsn), {
method: 'POST',
headers: { 'Content-Type': 'application/x-sentry-envelope' },
body: buildEnvelope(event),
redirect: 'manual',
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
});
} finally {
s.sending--;
}
}
/** Count `report` and send it to the DSN when one is set. Never rejects. */
export function handleServerReport(report: ErrorReport): Promise<void> {
return deliver(report).catch(() => undefined);
}
/** Next's onRequestError: an error a route, page or server action threw. */
export function captureRequestError(error: unknown, routePattern?: string): Promise<void> {
try {
const e = (error && typeof error === 'object' ? error : { message: String(error) }) as { name?: unknown; message?: unknown; stack?: unknown };
return handleServerReport({
name: scrubName(e.name),
message: scrubText(e.message),
frames: parseStack(e.stack),
handled: false,
mechanism: 'request',
// A route pattern from the code ("/api/admin/[id]"), never the request's URL.
...(typeof routePattern === 'string' && ROUTE_PATTERN.test(routePattern) ? { route: routePattern } : {}),
});
} catch {
return Promise.resolve();
}
}
/**
* logger.error: a route that caught an error and logged it. The frames are
* the logging call's, minus the logger's own `skip` frames.
*/
export function captureLoggedError(
message: string,
extra: Record<string, unknown> | undefined,
stack: string | undefined,
skip = 2,
): Promise<void> {
try {
const detail = typeof extra?.error === 'string' ? extra.error : extra?.error instanceof Error ? extra.error.message : '';
const frames: Frame[] = parseStack(stack).slice(skip);
return handleServerReport({
name: 'LoggedError',
message: scrubText(detail ? `${message}: ${detail}` : message),
frames,
handled: true,
mechanism: 'logger',
});
} catch {
return Promise.resolve();
}
}
/**
* Hook the logger up (instrumentation.node.ts). The logger only knows a
* function on globalThis, so it stays free of config code and safe for any
* bundle that imports it.
*/
export async function installServerErrorReporting(): Promise<void> {
await configManager.ensureLoaded();
const { setErrorSink } = await import('@/lib/logger');
setErrorSink((message, extra, stack) => { void captureLoggedError(message, extra, stack); });
}