Files
webmail/lib/error-reporting/stack.ts
T
Linus Rath e580ed24da feat: send anonymous error counts with the usage stats when the admin also agrees to it
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.
2026-11-01 03:10:41 +01:00

63 lines
2.4 KiB
TypeScript

import { scrubFramePath, scrubFunctionName } from './scrub';
/** One stack frame, already scrubbed. */
export interface Frame {
/** The file as scrubFramePath leaves it: `/_next/static/chunks/x.js`. */
path: string;
line?: number;
column?: number;
fn?: string;
/** The debug ID of the file, when its bundle registered one (./debug-ids.ts). */
debugId?: string;
}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const MAX_FRAMES = 30;
// V8 (Chrome, Edge, Node): " at fn (file:1:2)", " at file:1:2",
// " at async fn (file:1:2)", " at new Foo (file:1:2)".
const V8 = /^\s*at (?:(.+?) \()?(.+?):(\d+):(\d+)\)?\s*$/;
// SpiderMonkey and JavaScriptCore (Firefox, Safari): "fn@file:1:2", "@file:1:2".
const GECKO = /^\s*([^@]*)@(.+?):(\d+):(\d+)\s*$/;
function number(value: string): number | undefined {
const n = Number(value);
return Number.isInteger(n) && n >= 0 && n < 10_000_000 ? n : undefined;
}
/** The frames of a stack, the frame that threw first, at most MAX_FRAMES. */
export function parseStack(stack: unknown): Frame[] {
if (typeof stack !== 'string') return [];
const frames: Frame[] = [];
for (const line of stack.split('\n')) {
const match = V8.exec(line) ?? GECKO.exec(line);
if (!match) continue;
const [, fn, file, lineNo, column] = match;
// "at fn (native)" and eval chains carry no file worth keeping.
frames.push({
path: scrubFramePath(file),
line: number(lineNo),
column: number(column),
fn: scrubFunctionName(fn?.replace(/^async /, '')),
});
if (frames.length >= MAX_FRAMES) break;
}
return frames;
}
/** Frames handed in from outside (a browser's report), checked and scrubbed again. */
export function sanitizeFrames(input: unknown): Frame[] {
if (!Array.isArray(input)) return [];
const out: Frame[] = [];
for (const raw of input.slice(0, MAX_FRAMES)) {
if (!raw || typeof raw !== 'object') continue;
const frame = raw as Record<string, unknown>;
const line = typeof frame.line === 'number' ? number(String(frame.line)) : undefined;
const column = typeof frame.column === 'number' ? number(String(frame.column)) : undefined;
const debugId = typeof frame.debugId === 'string' && UUID.test(frame.debugId) ? frame.debugId.toLowerCase() : undefined;
out.push({ path: scrubFramePath(frame.path), line, column, fn: scrubFunctionName(frame.fn), ...(debugId ? { debugId } : {}) });
}
return out;
}