mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +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.
63 lines
2.4 KiB
TypeScript
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;
|
|
}
|