Files
webmail/lib/error-reporting/envelope.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

150 lines
5.4 KiB
TypeScript

import type { Frame } from './stack';
/**
* The Sentry envelope format, which Bugsink, GlitchTip and Sentry accept.
* Only what Bulwark sends: one event item per envelope.
* https://develop.sentry.dev/sdk/data-model/envelopes/
*/
export interface Dsn {
/** scheme://host[:port] */
origin: string;
/** Anything between the host and the project id, usually ''. */
pathPrefix: string;
publicKey: string;
projectId: string;
}
/** `https://<key>@host[:port][/prefix]/<project id>`, or null. */
export function parseDsn(raw: unknown): Dsn | null {
if (typeof raw !== 'string' || !raw.trim()) return null;
let url: URL;
try {
url = new URL(raw.trim());
} catch {
return null;
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
if (!url.username || url.password || url.search || url.hash) return null;
const parts = url.pathname.split('/').filter(Boolean);
const projectId = parts.pop();
if (!projectId || !/^\d+$/.test(projectId) || !/^[0-9a-f]{16,64}$/i.test(url.username)) return null;
return {
origin: `${url.protocol}//${url.host}`,
pathPrefix: parts.length ? `/${parts.join('/')}` : '',
publicKey: url.username,
projectId,
};
}
/** Where envelopes go. The key travels in the query, so a browser needs no custom header. */
export function envelopeUrl(dsn: Dsn): string {
return `${dsn.origin}${dsn.pathPrefix}/api/${dsn.projectId}/envelope/?sentry_key=${dsn.publicKey}&sentry_version=7`;
}
/** A fixed error code: `Email/set: overQuota`, `serverFail`, `http:503`. */
export const ERROR_CODE = /^(?:(?:[A-Z][A-Za-z]{0,40}\/[a-z][A-Za-z]{0,40}: )?[a-z][A-Za-z]{1,40}|http:\d{3})$/;
export function isErrorCode(value: unknown): value is string {
return typeof value === 'string' && ERROR_CODE.test(value);
}
export interface Breadcrumb {
/** Seconds since the epoch. */
timestamp: number;
category: 'navigation' | 'jmap';
/** A scrubbed route (`/mail/…`) or a JMAP method name. */
message: string;
}
/** An error, scrubbed, as both sides hand it to the sender. */
export interface ErrorReport {
name: string;
message: string;
frames: Frame[];
/** One of Bulwark's fixed codes, when the error has one. */
code?: string;
handled: boolean;
/** How it was caught: onerror, unhandledrejection, boundary, global-error, jmap, request, logger. */
mechanism: string;
breadcrumbs?: Breadcrumb[];
/** The scrubbed route it happened on. */
route?: string;
}
export interface EventContext {
eventId: string;
/** Seconds since the epoch. */
timestamp: number;
release: string;
environment: string;
platform: 'javascript' | 'node';
fingerprint: string;
/** Path -> debug id, for the frames' files. */
debugIds: Map<string, string>;
tags?: Record<string, string>;
}
/** The event payload. Frames go oldest first, as the format wants. */
export function buildEvent(report: ErrorReport, ctx: EventContext): Record<string, unknown> {
const frames = [...report.frames].reverse().map((frame) => ({
abs_path: frame.path,
filename: frame.path,
...(frame.fn ? { function: frame.fn } : {}),
...(frame.line !== undefined ? { lineno: frame.line } : {}),
...(frame.column !== undefined ? { colno: frame.column } : {}),
in_app: frame.path.startsWith('/_next/') || frame.path.startsWith('/.next/'),
}));
const images = [...new Set(report.frames.map((frame) => frame.path))]
.filter((path) => ctx.debugIds.has(path))
.map((path) => ({ type: 'sourcemap', code_file: path, debug_id: ctx.debugIds.get(path) }));
return {
event_id: ctx.eventId,
timestamp: ctx.timestamp,
platform: ctx.platform,
level: 'error',
release: ctx.release,
environment: ctx.environment,
// Bulwark's own grouping: the same key as the per-day counts.
fingerprint: [ctx.fingerprint],
exception: {
values: [{
type: report.name,
value: report.code ?? report.message,
mechanism: { type: report.mechanism, handled: report.handled },
...(frames.length ? { stacktrace: { frames } } : {}),
}],
},
...(report.breadcrumbs?.length
? { breadcrumbs: { values: report.breadcrumbs.map((b) => ({ timestamp: b.timestamp, category: b.category, message: b.message })) } }
: {}),
...(images.length ? { debug_meta: { images } } : {}),
tags: { ...(ctx.tags ?? {}), ...(report.route ? { route: report.route } : {}) },
sdk: { name: 'bulwark.error-reporting', version: '1' },
};
}
/** One event in an envelope. */
export function buildEnvelope(event: Record<string, unknown>): string {
const header = { event_id: event.event_id, sent_at: new Date().toISOString() };
return `${JSON.stringify(header)}\n${JSON.stringify({ type: 'event', content_type: 'application/json' })}\n${JSON.stringify(event)}\n`;
}
/** 32 hex digits, as event ids are. */
export function newEventId(): string {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
/** `<version>+<commit>`, the release the source maps were uploaded for. */
export function releaseName(): string {
const version = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
const commit = process.env.NEXT_PUBLIC_GIT_COMMIT;
return commit && commit !== 'unknown' ? `${version}+${commit}` : version;
}
export function appVersion(): string {
return process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
}