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

180 lines
5.8 KiB
TypeScript

import { logger } from '@/lib/logger';
import { telemetryDocs } from '@/lib/telemetry/state';
import { isErrorCode } from './envelope';
import { fingerprint as fingerprintOf } from './fingerprint';
import type { Frame } from './stack';
/**
* Errors counted per UTC day: fingerprint -> name, top frames, version and
* how often. The admin's "errors today", the metrics endpoint and the
* telemetry error counts read it. Only what scrubbing leaves goes in.
*
* Bounded: MAX_PER_DAY fingerprints per day (the rarest go first) and
* KEEP_DAYS days. Counts gather in memory and are added to `errors.json` in
* telemetry's document collection every FLUSH_MS, so several replicas add up
* (an update, not an overwrite).
*/
export const MAX_PER_DAY = 200;
export const KEEP_DAYS = 8;
export const MAX_FRAMES_KEPT = 5;
const FLUSH_MS = 60_000;
export const ERRORS_DOC = 'errors.json';
export interface AggregateEntry {
name: string;
frames: Frame[];
version: string;
/** A fixed error code when the error has one. */
code?: string;
runtime: 'browser' | 'server';
count: number;
}
/** day (YYYY-MM-DD) -> fingerprint -> entry */
export type Aggregate = Record<string, Record<string, AggregateEntry>>;
export interface Recordable {
name: string;
frames: Frame[];
code?: string;
}
interface AggregateState {
pending: Aggregate;
timer: ReturnType<typeof setInterval> | null;
flushing: Promise<void> | null;
}
const KEY = Symbol.for('bulwark.error-reporting.aggregate');
function state(): AggregateState {
const g = globalThis as typeof globalThis & { [KEY]?: AggregateState };
return (g[KEY] ??= { pending: {}, timer: null, flushing: null });
}
export function utcDay(at = Date.now()): string {
return new Date(at).toISOString().slice(0, 10);
}
/** Keep the newest KEEP_DAYS days and the MAX_PER_DAY most frequent per day. */
export function bound(agg: Aggregate, today = utcDay()): Aggregate {
const cutoff = utcDay(Date.parse(`${today}T00:00:00Z`) - (KEEP_DAYS - 1) * 86_400_000);
const out: Aggregate = {};
for (const [day, entries] of Object.entries(agg)) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || day < cutoff) continue;
const kept = Object.entries(entries)
.sort(([, a], [, b]) => b.count - a.count)
.slice(0, MAX_PER_DAY);
out[day] = Object.fromEntries(kept);
}
return out;
}
/** Add `delta`'s counts to `base`. */
export function merge(base: Aggregate, delta: Aggregate): Aggregate {
const out: Aggregate = {};
for (const [day, entries] of Object.entries(base)) out[day] = { ...entries };
for (const [day, entries] of Object.entries(delta)) {
const target = (out[day] ??= {});
for (const [print, entry] of Object.entries(entries)) {
const existing = target[print];
target[print] = existing ? { ...existing, count: existing.count + entry.count } : { ...entry };
}
}
return out;
}
function parseStored(data: Buffer | null): Aggregate {
if (!data) return {};
try {
const parsed = JSON.parse(data.toString('utf8')) as unknown;
return parsed && typeof parsed === 'object' ? (parsed as Aggregate) : {};
} catch {
return {};
}
}
/** Count one error. Returns its fingerprint. */
export function recordError(report: Recordable, runtime: 'browser' | 'server', version: string, at = Date.now()): string {
const print = fingerprintOf(report.name, report.frames, version, report.code);
const s = state();
const day = utcDay(at);
const entries = (s.pending[day] ??= {});
const existing = entries[print];
if (existing) {
existing.count += 1;
} else if (Object.keys(entries).length < MAX_PER_DAY) {
entries[print] = {
name: report.name,
frames: report.frames.slice(0, MAX_FRAMES_KEPT),
version,
...(isErrorCode(report.code) ? { code: report.code } : {}),
runtime,
count: 1,
};
}
startTimer();
return print;
}
function startTimer(): void {
const s = state();
if (s.timer) return;
s.timer = setInterval(() => { void flushErrors(); }, FLUSH_MS);
s.timer.unref?.();
}
/** Add what this process counted since the last flush to the stored aggregate. */
export function flushErrors(): Promise<void> {
const s = state();
if (s.flushing) return s.flushing;
const delta = s.pending;
if (Object.keys(delta).length === 0) return Promise.resolve();
s.pending = {};
s.flushing = telemetryDocs()
.update(ERRORS_DOC, (current) => JSON.stringify(bound(merge(parseStored(current), delta))))
.then(() => undefined)
.catch((error) => {
// Keep the counts for the next try.
s.pending = merge(delta, s.pending);
logger.warn('error counts not stored', { error: error instanceof Error ? error.message : String(error) });
})
.finally(() => { s.flushing = null; });
return s.flushing;
}
/** The stored aggregate with this process's unflushed counts added. */
export async function readErrors(): Promise<Aggregate> {
let stored: Aggregate = {};
try {
stored = parseStored((await telemetryDocs().get(ERRORS_DOC))?.data ?? null);
} catch (error) {
logger.warn('error counts not readable', { error: error instanceof Error ? error.message : String(error) });
}
return bound(merge(stored, state().pending));
}
export interface DaySummary {
total: number;
top: Array<{ fingerprint: string } & AggregateEntry>;
}
export function summarizeDay(agg: Aggregate, day = utcDay(), limit = 20): DaySummary {
const entries = Object.entries(agg[day] ?? {});
return {
total: entries.reduce((sum, [, e]) => sum + e.count, 0),
top: entries
.sort(([, a], [, b]) => b.count - a.count)
.slice(0, limit)
.map(([fingerprint, e]) => ({ fingerprint, ...e })),
};
}
/** For tests. */
export function resetAggregateForTests(): void {
const s = state();
if (s.timer) clearInterval(s.timer);
const g = globalThis as typeof globalThis & { [KEY]?: AggregateState };
delete g[KEY];
}