mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
A second switch under System > Usage stats, "Also send anonymous error counts", or `BULWARK_TELEMETRY_ERRORS=on|off`. Off by default, off whenever the heartbeat is off or no third-party traffic is on, and switching the heartbeat off switches it off too. With it on the heartbeat becomes schema 2: the v1 fields unchanged plus `errors`, up to 50 fingerprints of the last complete UTC day with the error name, the top frames in Bulwark's bundles (path, line, column, debug ID), the version, a bucketed count, on how many of the last 7 days it occurred, and a message only when it is a fixed error code. Without the consent the heartbeat stays exactly schema 1. The admin preview shows the v2 payload as soon as the switch is flipped.
251 lines
9.1 KiB
TypeScript
251 lines
9.1 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,
|
|
// With debug IDs, so the counts can point at the uploaded source maps.
|
|
frames: withDebugIds(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);
|
|
}
|
|
|
|
function withDebugIds(frames: ErrorReport['frames']): ErrorReport['frames'] {
|
|
const ids = debugIdsByPath();
|
|
return frames.map((frame) => (ids.has(frame.path) ? { ...frame, debugId: ids.get(frame.path) } : frame));
|
|
}
|
|
|
|
/** 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];
|
|
}
|