mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
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
245 lines
8.8 KiB
TypeScript
245 lines
8.8 KiB
TypeScript
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
|
|
import { scrubRoutePath } from '@/lib/route-groups';
|
|
import { debugIdsByPath } from './debug-ids';
|
|
import {
|
|
appVersion, buildEnvelope, buildEvent, envelopeUrl, isErrorCode, newEventId, parseDsn, releaseName,
|
|
type Breadcrumb, type ErrorReport,
|
|
} from './envelope';
|
|
import { fingerprint } from './fingerprint';
|
|
import { ReportLimiter } from './limiter';
|
|
import { scrubJmapErrorType, scrubMethodName, scrubName, scrubText } from './scrub';
|
|
import { parseStack } from './stack';
|
|
|
|
/**
|
|
* Error reporting in the browser, off until the config names a DSN or asks
|
|
* for errors to be collected (lib/error-reporting/settings.ts). Captures what
|
|
* the error boundaries, window.onerror, unhandled rejections and failed JMAP
|
|
* requests hand it, scrubs it (./scrub.ts) and sends it to the DSN and/or
|
|
* this server's /api/errors. Nothing is sent before the config arrived, and
|
|
* nothing at all when both are off.
|
|
*
|
|
* https://bulwarkmail.org/docs/admin/monitoring
|
|
*/
|
|
|
|
export interface ClientSettings {
|
|
/** The error tracker's DSN; '' for none. */
|
|
dsn: string;
|
|
/** 0..1, applied to what goes to the DSN. */
|
|
sampleRate: number;
|
|
environment: string;
|
|
/** Also post errors to this server's /api/errors (the per-day counts). */
|
|
collect: boolean;
|
|
}
|
|
|
|
const MAX_PENDING = 10;
|
|
const MAX_BREADCRUMBS = 20;
|
|
|
|
interface ClientState {
|
|
/** undefined until configured; null when reporting is off. */
|
|
settings: ClientSettings | null | undefined;
|
|
pending: ErrorReport[];
|
|
breadcrumbs: Breadcrumb[];
|
|
limiter: ReportLimiter | null;
|
|
installed: boolean;
|
|
}
|
|
|
|
// On globalThis: the error boundary, the JMAP client and the root component
|
|
// may each end up with their own copy of this module.
|
|
const KEY = Symbol.for('bulwark.error-reporting.client');
|
|
function state(): ClientState {
|
|
const g = globalThis as typeof globalThis & { [KEY]?: ClientState };
|
|
return (g[KEY] ??= { settings: undefined, pending: [], breadcrumbs: [], limiter: null, installed: false });
|
|
}
|
|
|
|
function sessionStore(): Storage | null {
|
|
try {
|
|
return typeof sessionStorage === 'undefined' ? null : sessionStorage;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function limiter(): ReportLimiter {
|
|
const s = state();
|
|
return (s.limiter ??= new ReportLimiter({ perMinute: 10, perSession: 30, dedupWindowMs: Infinity, storage: sessionStore() }));
|
|
}
|
|
|
|
function basePath(): string {
|
|
return getPathPrefix();
|
|
}
|
|
|
|
function currentRoute(): string | undefined {
|
|
if (typeof location === 'undefined') return undefined;
|
|
return scrubRoutePath(location.pathname, basePath());
|
|
}
|
|
|
|
/** Apply the settings from /api/config (or Lite's policy.json); null switches reporting off. */
|
|
export function configureErrorReporting(settings: ClientSettings | null): void {
|
|
const s = state();
|
|
const on = settings && (parseDsn(settings.dsn) || settings.collect) ? settings : null;
|
|
s.settings = on;
|
|
const queued = s.pending;
|
|
s.pending = [];
|
|
if (on) for (const report of queued) deliver(report);
|
|
}
|
|
|
|
export function isErrorReportingOn(): boolean {
|
|
return !!state().settings;
|
|
}
|
|
|
|
/** A route change or a JMAP call, kept for the next report. */
|
|
export function addBreadcrumb(category: Breadcrumb['category'], message: string): void {
|
|
const s = state();
|
|
if (s.settings === null) return;
|
|
s.breadcrumbs.push({ timestamp: Date.now() / 1000, category, message });
|
|
if (s.breadcrumbs.length > MAX_BREADCRUMBS) s.breadcrumbs.shift();
|
|
}
|
|
|
|
/** A route change: only the scrubbed route is kept. */
|
|
export function recordNavigation(pathname: string): void {
|
|
addBreadcrumb('navigation', scrubRoutePath(pathname, basePath()));
|
|
}
|
|
|
|
/** A JMAP request: only the method names. */
|
|
export function recordJmapCall(methods: readonly string[]): void {
|
|
addBreadcrumb('jmap', methods.slice(0, 5).map(scrubMethodName).join(', '));
|
|
}
|
|
|
|
/** Turn anything thrown into a scrubbed report; null for what isn't worth one. */
|
|
export function reportFromError(error: unknown, mechanism: string, handled: boolean): ErrorReport | null {
|
|
let name = 'Error';
|
|
let message = '';
|
|
let stack: string | undefined;
|
|
if (error instanceof Error || (error && typeof error === 'object' && 'message' in error)) {
|
|
const e = error as { name?: unknown; message?: unknown; stack?: unknown };
|
|
name = scrubName(e.name);
|
|
message = scrubText(e.message);
|
|
stack = typeof e.stack === 'string' ? e.stack : undefined;
|
|
} else if (typeof error === 'string') {
|
|
name = mechanism === 'unhandledrejection' ? 'UnhandledRejection' : 'Error';
|
|
message = scrubText(error);
|
|
} else {
|
|
return null;
|
|
}
|
|
const frames = parseStack(stack);
|
|
// Every frame outside our bundles: a browser extension or injected script.
|
|
if (frames.length > 0 && frames.every((frame) => frame.path === '[other]')) return null;
|
|
return { name, message, frames, handled, mechanism, route: currentRoute() };
|
|
}
|
|
|
|
/** Report an error caught by a boundary, a global handler or a caller. */
|
|
export function captureException(error: unknown, mechanism = 'manual', handled = true): void {
|
|
try {
|
|
const report = reportFromError(error, mechanism, handled);
|
|
if (report) captureReport(report);
|
|
} catch {
|
|
// Reporting must never throw into the code that reported.
|
|
}
|
|
}
|
|
|
|
/** Report a JMAP request that failed: the method names and the status or error type only. */
|
|
export function reportJmapFailure(failure: { status: number } | { method: string; type: string }): void {
|
|
try {
|
|
const code = 'status' in failure
|
|
? `http:${Number.isInteger(failure.status) ? failure.status : 0}`
|
|
: `${scrubMethodName(failure.method)}: ${scrubJmapErrorType(failure.type)}`;
|
|
if (!isErrorCode(code)) return;
|
|
const name = 'status' in failure ? 'JmapRequestError' : 'JmapMethodError';
|
|
const frames = parseStack(new Error().stack).slice(1);
|
|
captureReport({
|
|
name,
|
|
message: code,
|
|
code,
|
|
frames,
|
|
handled: true,
|
|
mechanism: 'jmap',
|
|
route: currentRoute(),
|
|
});
|
|
} catch {
|
|
// never throws into the JMAP client
|
|
}
|
|
}
|
|
|
|
export function captureReport(report: ErrorReport): void {
|
|
const s = state();
|
|
if (s.settings === null) return;
|
|
const withCrumbs = { ...report, breadcrumbs: report.breadcrumbs ?? [...s.breadcrumbs] };
|
|
if (s.settings === undefined) {
|
|
if (s.pending.length < MAX_PENDING) s.pending.push(withCrumbs);
|
|
return;
|
|
}
|
|
deliver(withCrumbs);
|
|
}
|
|
|
|
function deliver(report: ErrorReport): void {
|
|
const s = state();
|
|
const settings = s.settings;
|
|
if (!settings) return;
|
|
const version = appVersion();
|
|
const print = fingerprint(report.name, report.frames, version, report.code);
|
|
if (!limiter().allow(print)) return;
|
|
|
|
const dsn = parseDsn(settings.dsn);
|
|
if (dsn && Math.random() < settings.sampleRate) {
|
|
const event = buildEvent(report, {
|
|
eventId: newEventId(),
|
|
timestamp: Date.now() / 1000,
|
|
release: releaseName(),
|
|
environment: settings.environment,
|
|
platform: 'javascript',
|
|
fingerprint: print,
|
|
debugIds: debugIdsByPath(),
|
|
tags: { runtime: 'browser', ...(process.env.NEXT_PUBLIC_BULWARK_LITE === '1' ? { lite: 'yes' } : {}) },
|
|
});
|
|
send(envelopeUrl(dsn), buildEnvelope(event), {
|
|
// text/plain keeps it a simple request: no preflight, no custom header.
|
|
headers: { 'Content-Type': 'text/plain;charset=UTF-8' },
|
|
credentials: 'omit',
|
|
// The page URL holds folder names and message ids.
|
|
referrerPolicy: 'no-referrer',
|
|
mode: 'cors',
|
|
});
|
|
}
|
|
if (settings.collect) {
|
|
send('/api/errors', JSON.stringify({
|
|
name: report.name,
|
|
message: report.message,
|
|
code: report.code,
|
|
frames: report.frames.slice(0, 10),
|
|
mechanism: report.mechanism,
|
|
handled: report.handled,
|
|
route: report.route,
|
|
version,
|
|
}), { headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin' }, true);
|
|
}
|
|
}
|
|
|
|
// A failed send is dropped quietly: it never turns into another report.
|
|
function send(url: string, body: string, init: RequestInit, sameOrigin = false): void {
|
|
const request = sameOrigin
|
|
? apiFetch(url, { method: 'POST', body, keepalive: true, ...init })
|
|
: fetch(url, { method: 'POST', body, keepalive: true, ...init });
|
|
void Promise.resolve(request).catch(() => undefined);
|
|
}
|
|
|
|
/** window.onerror and unhandled rejections, once. */
|
|
export function installGlobalHandlers(): void {
|
|
const s = state();
|
|
if (s.installed || typeof window === 'undefined') return;
|
|
s.installed = true;
|
|
window.addEventListener('error', (event: ErrorEvent) => {
|
|
// "Script error." from another origin carries nothing to report.
|
|
if (!event.error) return;
|
|
captureException(event.error, 'onerror', false);
|
|
});
|
|
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
|
|
captureException(event.reason, 'unhandledrejection', false);
|
|
});
|
|
}
|
|
|
|
/** For tests. */
|
|
export function resetErrorReportingForTests(): void {
|
|
const g = globalThis as typeof globalThis & { [KEY]?: ClientState };
|
|
delete g[KEY];
|
|
}
|