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
This commit is contained in:
Linus Rath
2026-11-01 02:50:19 +01:00
parent d781e8762e
commit cba1109444
49 changed files with 2384 additions and 26 deletions
+2
View File
@@ -3,6 +3,7 @@
import { useEffect } from "react";
import { useTranslations } from "next-intl";
import { ErrorState } from "@/components/error/error-fallbacks";
import { captureException } from "@/lib/error-reporting/client";
/**
* Route-level error boundary for locale pages.
@@ -19,6 +20,7 @@ export default function LocaleError({
useEffect(() => {
console.error("Route error:", error);
captureException(error, "boundary", false);
}, [error]);
return (
@@ -20,6 +20,7 @@ import { MarketplaceDetailPage } from '../_pages/marketplace-detail';
import { ExtensionDetailPage } from '../_pages/extension-detail';
import { VersionPage } from '../_pages/version';
import { TelemetryPage } from '../_pages/telemetry';
import { MonitoringPage } from '../_pages/monitoring';
import { LogsPage } from '../_pages/logs';
/**
@@ -56,6 +57,7 @@ export default function AdminPage() {
case 'marketplace': return detail ? <MarketplaceDetailPage key={detail} slug={detail} /> : <MarketplacePage />;
case 'version': return <VersionPage />;
case 'telemetry': return <TelemetryPage />;
case 'monitoring': return <MonitoringPage />;
case 'logs': return <LogsPage />;
}
}
+175
View File
@@ -0,0 +1,175 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Loader2 } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { adminPageDef } from '@/lib/admin/admin-nav';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { SettingsSection, SectionNav } from '@/components/settings/settings-section';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { withEdit } from '@/lib/admin/edits';
import { useAdminFeedback } from '@/components/admin/feedback';
import { ConfigTextItem, ConfigNote } from '@/components/admin/config-items';
interface ConfigEntry {
value?: unknown;
source: 'admin' | 'env' | 'default';
}
interface ErrorEntry {
fingerprint: string;
name: string;
code?: string;
version: string;
runtime: 'browser' | 'server';
count: number;
frames: Array<{ path: string; line?: number; column?: number }>;
}
interface MonitoringState {
collecting: boolean;
day: string;
errorsToday: { total: number; top: ErrorEntry[] };
}
const PAGE = adminPageDef('monitoring');
const KEYS = ['errorReportingDsn', 'errorReportingSampleRate', 'errorReportingEnvironment'] as const;
const DOCS = 'https://bulwarkmail.org/docs/admin/monitoring';
export function MonitoringPage() {
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
const [state, setState] = useState<MonitoringState | null>(null);
const [edits, setEdits] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const setMessage = useAdminFeedback();
const load = useCallback(async () => {
const [configRes, stateRes] = await Promise.all([apiFetch('/api/admin/config'), apiFetch('/api/admin/monitoring')]);
if (configRes.ok) setConfig(await configRes.json());
if (stateRes.ok) setState(await stateRes.json());
setLoading(false);
}, []);
useEffect(() => { void load(); }, [load]);
function handleChange(key: string, value: unknown) {
setEdits((prev) => withEdit(prev, key, value, config[key]?.value));
setMessage(null);
}
function currentValue(key: string): string {
const value = key in edits ? edits[key] : config[key]?.value;
return typeof value === 'string' ? value : value === undefined || value === null ? '' : String(value);
}
async function handleSave() {
if (Object.keys(edits).length === 0) return;
setSaving(true);
setMessage(null);
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edits),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Saved. Browsers pick it up on their next page load.' });
setEdits({});
await load();
} else {
const data = await res.json().catch(() => ({}));
setMessage({ type: 'error', text: data.error || 'Failed to save' });
}
setSaving(false);
}
async function handleRevert(key: string) {
const res = await apiFetch('/api/admin/config', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
if (res.ok) {
setEdits((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
await load();
}
}
const hasEdits = KEYS.some((key) => key in edits);
useAdminUnsavedChanges('monitoring', hasEdits);
if (loading) {
return (
<div>
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
</div>
);
}
const today = state?.errorsToday;
return (
<div>
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
<div className="space-y-10">
<SectionNav />
<SettingsSection id="errors" title="Error reporting">
<ConfigNote>
Send errors from browsers and from this server to your own error tracker: Bugsink, GlitchTip or
Sentry. Off while the address is empty. Message text, addresses, file names, tokens and URLs are
removed before anything is sent. <a className="underline" href={DOCS} target="_blank" rel="noreferrer">What is sent</a>
</ConfigNote>
<ConfigTextItem label="Error reporting address (DSN)" configKey="errorReportingDsn" value={currentValue('errorReportingDsn')} source={config.errorReportingDsn?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://<key>@errors.example.com/1" wide />
<ConfigTextItem label="Sample rate" configKey="errorReportingSampleRate" value={currentValue('errorReportingSampleRate')} source={config.errorReportingSampleRate?.source} onChange={handleChange} onRevert={handleRevert} placeholder="1" />
<ConfigTextItem label="Environment" configKey="errorReportingEnvironment" value={currentValue('errorReportingEnvironment')} source={config.errorReportingEnvironment?.source} onChange={handleChange} onRevert={handleRevert} placeholder="production" />
</SettingsSection>
<SettingsSection id="errors-today" title="Errors today">
{!state?.collecting ? (
<ConfigNote>
Not counting errors. Counting starts when an error reporting address is set, the metrics
endpoint is on, or error counts are sent with the usage stats.
</ConfigNote>
) : !today || today.total === 0 ? (
<p className="text-sm text-muted-foreground">No errors so far today (UTC).</p>
) : (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{today.total} errors today (UTC), most frequent first.</p>
<ul className="divide-y divide-border rounded-md border border-border text-sm">
{today.top.map((entry) => (
<li key={entry.fingerprint} className="flex items-start justify-between gap-4 px-3 py-2">
<div className="min-w-0">
<div className="font-medium">{entry.name}{entry.code ? `: ${entry.code}` : ''}</div>
<div className="truncate font-mono text-xs text-muted-foreground">
{entry.frames[0] ? `${entry.frames[0].path}:${entry.frames[0].line ?? ''}:${entry.frames[0].column ?? ''}` : 'no stack'}
{' · '}{entry.runtime}{' · '}{entry.version}
</div>
</div>
<span className="shrink-0 tabular-nums">{entry.count}</span>
</li>
))}
</ul>
</div>
)}
</SettingsSection>
</div>
<AdminSaveBar
dirty={hasEdits}
saving={saving}
onSave={handleSave}
onDiscard={() => setEdits({})}
note="Browsers pick it up on their next page load"
/>
</div>
);
}
+2
View File
@@ -5,6 +5,7 @@ import { headers } from "next/headers";
import { getLocale, getTranslations } from "next-intl/server";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import { FaviconBadge } from "@/components/favicon-badge";
import { ErrorReportingRoot } from "@/components/error/error-reporting-root";
import { ThemeColorSync } from "@/components/theme-color-sync";
import { IosViewportHeal } from "@/components/ios-viewport-heal";
import { configManager } from "@/lib/admin/config-manager";
@@ -328,6 +329,7 @@ export default async function RootLayout({
<ServiceWorkerRegistration />
{!themeColorConfigured && <ThemeColorSync />}
<FaviconBadge />
<ErrorReportingRoot />
<IosViewportHeal />
{children}
</body>
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/admin/session';
import { logger } from '@/lib/logger';
import { readErrors, summarizeDay, utcDay } from '@/lib/error-reporting/aggregate';
import { shouldCollectErrors } from '@/lib/error-reporting/settings';
import { storedDataUnavailableResponse } from '@/lib/stored-data-errors';
export const dynamic = 'force-dynamic';
/**
* GET /api/admin/monitoring: what System > Monitoring shows besides the
* config keys (those come from /api/admin/config): whether errors are being
* counted and today's counts.
*/
export async function GET(request: NextRequest) {
try {
const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error;
const collecting = await shouldCollectErrors();
const today = utcDay();
const errorsToday = summarizeDay(await readErrors(), today);
return NextResponse.json({ collecting, day: today, errorsToday }, { headers: { 'Cache-Control': 'no-store' } });
} catch (err) {
logger.error('monitoring GET error', { error: err instanceof Error ? err.message : 'unknown' });
const unavailable = storedDataUnavailableResponse(err);
if (unavailable) return unavailable;
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+4
View File
@@ -12,6 +12,7 @@ import {
type BrandingOverrideKey,
} from '@/lib/admin/domain-branding';
import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
import { browserErrorSettings } from '@/lib/error-reporting/settings';
/**
* Runtime configuration endpoint
@@ -109,6 +110,9 @@ export async function GET(request: NextRequest) {
signLanguageUrl: httpUrlOrEmpty(branded<string>('signLanguageUrl', '')),
// Browser-side switches of lib/outgoing-hosts.ts read this (#903).
noThirdPartyTraffic: await isNoThirdPartyTrafficOn(),
// Error reporting (lib/error-reporting/client.ts): off unless a DSN is
// set or this server counts errors. The DSN is public by design.
errorReporting: await browserErrorSettings(),
},
{
// Runtime settings can change without new asset URLs. Prevent stale
+89
View File
@@ -0,0 +1,89 @@
// @vitest-environment node
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
const config: Record<string, unknown> = {};
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: async () => {},
get: (key: string, fallback: unknown) => (key in config ? config[key] : fallback),
},
}));
import { POST } from '../route';
import { flushErrors, readErrors, resetAggregateForTests, utcDay } from '@/lib/error-reporting/aggregate';
const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3';
const SUBJECT = 'Merger plans for Acme Q3';
let dir: string;
let ip = 0;
function post(body: unknown): NextRequest {
ip += 1;
return new NextRequest('http://localhost/api/errors', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': `203.0.113.${ip}` },
body: typeof body === 'string' ? body : JSON.stringify(body),
});
}
const report = {
name: 'TypeError',
message: `Cannot open "${SUBJECT}"`,
frames: [{ path: `https://mail.example.org/_next/static/chunks/1.js?subject=${encodeURIComponent(SUBJECT)}`, line: 2, column: 30, fn: 'Ab' }],
version: '1.20.0',
};
beforeEach(() => {
dir = mkdtempSync(path.join(tmpdir(), 'bulwark-errors-route-'));
process.env.TELEMETRY_DATA_DIR = dir;
for (const key of Object.keys(config)) delete config[key];
resetAggregateForTests();
});
afterEach(() => {
resetAggregateForTests();
delete process.env.TELEMETRY_DATA_DIR;
rmSync(dir, { recursive: true, force: true });
});
describe('POST /api/errors', () => {
it('keeps nothing while error collection is off', async () => {
const res = await POST(post(report));
expect(res.status).toBe(204);
await flushErrors();
expect(await readErrors()).toEqual({});
});
it('counts a report, scrubbed again, without its message', async () => {
config.errorReportingDsn = DSN;
expect((await POST(post(report))).status).toBe(204);
await flushErrors();
const stored = JSON.stringify(await readErrors());
expect(stored).not.toContain('Merger');
expect(stored).not.toContain('subject=');
const [entry] = Object.values((await readErrors())[utcDay()]);
expect(entry).toMatchObject({ name: 'TypeError', runtime: 'browser', version: '1.20.0', count: 1 });
expect(entry.frames).toEqual([{ path: '/_next/static/chunks/1.js', line: 2, column: 30, fn: 'Ab' }]);
});
it('ignores a name or code that is not an identifier or fixed code', async () => {
config.errorReportingDsn = DSN;
await POST(post({ ...report, name: SUBJECT, code: SUBJECT }));
await flushErrors();
const [entry] = Object.values((await readErrors())[utcDay()]);
expect(entry.name).toBe('Error');
expect(entry.code).toBeUndefined();
});
it('refuses bodies that are too large or not JSON objects', async () => {
config.errorReportingDsn = DSN;
expect((await POST(post('x'.repeat(17 * 1024)))).status).toBe(413);
expect((await POST(post('[1,2]'))).status).toBe(400);
expect((await POST(post('not json'))).status).toBe(400);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { getClientIP } from '@/lib/admin/session';
import { getStore } from '@/lib/shared-store';
import { recordError } from '@/lib/error-reporting/aggregate';
import { appVersion, isErrorCode } from '@/lib/error-reporting/envelope';
import { scrubName } from '@/lib/error-reporting/scrub';
import { shouldCollectErrors } from '@/lib/error-reporting/settings';
import { sanitizeFrames } from '@/lib/error-reporting/stack';
export const dynamic = 'force-dynamic';
/**
* POST /api/errors: a browser of this instance reports an error it caught
* (lib/error-reporting/client.ts), for the per-day error counts. Only while
* collection is on (a DSN, the metrics endpoint or error-count consent);
* otherwise 204 and nothing is kept.
*
* Same-origin only (the proxy's origin gate), at most 16 KB, 30 a minute per
* address. Nothing a browser sends is trusted: every field is scrubbed again
* and the fingerprint is computed here. The DSN gets the browser's own
* report; this route only counts.
*/
const MAX_BODY_BYTES = 16 * 1024;
const PER_MINUTE = 30;
const NO_STORE = { 'Cache-Control': 'no-store' };
export async function POST(request: NextRequest) {
if (!(await shouldCollectErrors())) return new NextResponse(null, { status: 204, headers: NO_STORE });
const length = Number(request.headers.get('content-length') ?? '0');
if (length > MAX_BODY_BYTES) return NextResponse.json({ error: 'too large' }, { status: 413, headers: NO_STORE });
const limit = await getStore().counters.reserve([
{ key: `error-reports:${getClientIP(request)}`, max: PER_MINUTE, windowMs: 60_000 },
]).catch(() => ({ ok: true }));
if (!limit.ok) return NextResponse.json({ error: 'rate limited' }, { status: 429, headers: NO_STORE });
let text: string;
try {
text = await request.text();
} catch {
return NextResponse.json({ error: 'bad request' }, { status: 400, headers: NO_STORE });
}
if (text.length > MAX_BODY_BYTES) return NextResponse.json({ error: 'too large' }, { status: 413, headers: NO_STORE });
let body: Record<string, unknown>;
try {
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object');
body = parsed as Record<string, unknown>;
} catch {
return NextResponse.json({ error: 'bad request' }, { status: 400, headers: NO_STORE });
}
const code = isErrorCode(body.code) ? body.code : undefined;
recordError(
{
name: scrubName(body.name),
frames: sanitizeFrames(body.frames),
...(code ? { code } : {}),
},
'browser',
// The version the tab runs (a tab left open across an update runs the
// old one, and its frames point into that); the server's when absent.
typeof body.version === 'string' && /^\d+\.\d+\.\d+(?:[-+][\w.-]{1,40})?$/.test(body.version) ? body.version : appVersion(),
);
return new NextResponse(null, { status: 204, headers: NO_STORE });
}
+6
View File
@@ -2,6 +2,8 @@
import { useEffect } from "react";
import { AlertTriangle, RefreshCw } from "@/components/icons";
import { fetchConfig } from "@/hooks/use-config";
import { captureException } from "@/lib/error-reporting/client";
import "./globals.css";
/**
@@ -25,6 +27,10 @@ export default function GlobalError({
}) {
useEffect(() => {
console.error("Global error:", error);
// Waits for the config when the app crashed before it arrived; sent only
// when error reporting is configured.
captureException(error, "global-error", false);
void fetchConfig().catch(() => undefined);
}, [error]);
return (
+2
View File
@@ -7,6 +7,7 @@ import {
ArrowLeft,
ArrowUpCircle,
Activity,
HeartRateMonitor,
Building2,
ChevronRight,
KeyRound,
@@ -59,6 +60,7 @@ const PAGE_ICONS: Record<AdminPageId, AppIcon> = {
marketplace: Store,
version: ArrowUpCircle,
telemetry: Activity,
monitoring: HeartRateMonitor,
logs: ScrollText,
};
+5
View File
@@ -3,6 +3,7 @@
import React, { Component, ReactNode } from "react";
import { useTranslations } from "next-intl";
import { debug } from "@/lib/debug";
import { captureException } from "@/lib/error-reporting/client";
export interface FallbackProps {
error: Error;
@@ -43,6 +44,10 @@ class ErrorBoundaryCore extends Component<
componentStack: errorInfo.componentStack,
});
// Off unless error reporting is configured (lib/error-reporting/client.ts).
// The component stack stays here: it names components, nothing else.
captureException(error, "boundary", false);
// Call optional error handler
this.props.onError?.(error, errorInfo);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
import { fetchConfig } from "@/hooks/use-config";
import { installGlobalHandlers, recordNavigation } from "@/lib/error-reporting/client";
/**
* Starts browser error reporting: window.onerror and unhandled rejections,
* and a breadcrumb per route change (the route's area only, never the URL).
* Whether anything is sent is decided by the config (/api/config, or Lite's
* policy.json); until it arrives, reports wait, and with reporting off they
* are dropped. Renders nothing.
*/
export function ErrorReportingRoot() {
const pathname = usePathname();
useEffect(() => {
installGlobalHandlers();
// Cached after the first fetch; this only makes sure it happens on pages
// that don't read the config themselves (admin, setup).
void fetchConfig().catch(() => undefined);
}, []);
useEffect(() => {
if (pathname) recordNavigation(pathname);
}, [pathname]);
return null;
}
+1
View File
@@ -122,6 +122,7 @@ export {
IconH1 as Heading1,
IconH2 as Heading2,
IconHeart as Heart,
IconHeartRateMonitor as HeartRateMonitor,
IconHelpCircle as HelpCircle,
IconHighlight as Highlighter,
IconHome as Home,
+5
View File
@@ -8,6 +8,7 @@ import { IS_LITE, IS_LITE_STALWART, LITE_CONFIG_PATH, withLiteBuildId } from '@/
import { applyLiteConfig, liteStalwartDefaults } from '@/lib/lite-config';
import { isConfigData } from '@/lib/config-validation';
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
import { configureErrorReporting, type ClientSettings } from '@/lib/error-reporting/client';
export interface ConfigData {
appName: string;
@@ -59,6 +60,8 @@ export interface ConfigData {
noThirdPartyTraffic: boolean;
/** Lite only: whether sign-out revokes the refresh token (#1388). The server build decides in its token route. */
oauthRevokeOnSignOut?: boolean;
/** Server build only; Lite reads it from policy.json (stores/policy-store.ts). */
errorReporting?: ClientSettings;
}
interface AppConfig extends ConfigData {
@@ -144,6 +147,8 @@ export async function fetchConfig(): Promise<ConfigData> {
.then((data) => {
configCache = data;
setNoThirdPartyTraffic(data.noThirdPartyTraffic === true);
// Lite configures error reporting from policy.json instead.
if (!IS_LITE) configureErrorReporting(data.errorReporting ?? null);
// Fetch admin policy alongside config (non-blocking)
usePolicyStore.getState().fetchPolicy();
return data;
+27 -19
View File
@@ -9,12 +9,13 @@ import { bootState, getStore, sharedStoreUrl } from "./lib/shared-store";
import { bootSharedMode } from "./lib/shared-store/boot";
import { importLocalData } from "./lib/shared-store/import";
import { exportSharedData } from "./lib/shared-store/export";
import { logger, isJsonLogFormat } from "./lib/logger";
const pkg = JSON.parse(
readFileSync(`${process.cwd()}/package.json`, "utf-8")
);
const current: string = pkg.version ?? "0.0.0";
console.info(`Bulwark Webmail v${current}`);
logger.info(`Bulwark Webmail v${current}`, { version: current });
// With REDIS_URL every replica shares one store (#1066). Without it,
// everything below runs exactly as it always has.
@@ -36,12 +37,12 @@ async function exportAndExit(): Promise<never> {
await store.ping(2000);
break;
} catch {
console.warn("Export: waiting for the shared store");
logger.warn("Export: waiting for the shared store");
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
const written = await exportSharedData();
console.info("Export from the shared store done; start without REDIS_URL on this volume now.", written);
logger.info("Export from the shared store done; start without REDIS_URL on this volume now.", { written });
process.exit(0);
}
@@ -57,18 +58,23 @@ migrateLegacyAdminLayout()
.then(() => migratePolicyUnifiedMailbox())
.then(() => (exportOnly ? exportAndExit() : shared ? bootShared() : loadAdmin()))
.then(async () => {
console.info("Admin dashboard initialized");
logger.info("Admin dashboard initialized");
// Errors routes catch and log, and errors Next reports (instrumentation.ts),
// go to the error tracker when one is configured. Off otherwise.
// https://bulwarkmail.org/docs/admin/monitoring
const { installServerErrorReporting } = await import("./lib/error-reporting/server");
await installServerErrorReporting();
// With a shared store, boot already made or loaded the secret.
if (!shared) {
try {
if (await ensureSessionSecret()) {
console.info(
logger.info(
"Generated a session secret in the admin state dir. Keep that dir on a " +
"persistent volume, or set SESSION_SECRET, so restarts don't sign everyone out.",
);
}
} catch (err) {
console.warn("Failed to generate a session secret:", err instanceof Error ? err.message : err);
logger.warn("Failed to generate a session secret", { error: err instanceof Error ? err.message : String(err) });
}
}
// If we're in bootstrap state (no JMAP_SERVER_URL env and no
@@ -79,20 +85,22 @@ migrateLegacyAdminLayout()
try {
const token = await ensureSetupToken();
const port = process.env.PORT || "3000";
console.info("");
console.info("==============================================================");
console.info(" SETUP REQUIRED");
console.info(` Token: ${token}`);
// In the fragment, so the token stays out of access logs and Referer.
console.info(` Open: http://<host>:${port}/setup#token=${token}`);
console.info(" Token expires in 1 hour. Restart the container to reissue.");
console.info("==============================================================");
console.info("");
const open = `http://<host>:${port}/setup#token=${token}`;
if (isJsonLogFormat()) {
logger.info("Setup required", { token, open, expiresIn: "1h" });
} else {
for (const line of [
"==============================================================",
" SETUP REQUIRED",
` Token: ${token}`,
` Open: ${open}`,
" Token expires in 1 hour. Restart the container to reissue.",
"==============================================================",
]) logger.info(line);
}
} catch (err) {
console.warn(
"Failed to issue setup token:",
err instanceof Error ? err.message : err,
);
logger.warn("Failed to issue setup token", { error: err instanceof Error ? err.message : String(err) });
}
}
})
@@ -116,5 +124,5 @@ migrateLegacyAdminLayout()
]);
})
.catch((err) => {
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
logger.warn("Admin dashboard init skipped", { error: err instanceof Error ? err.message : String(err) });
});
+14
View File
@@ -1,5 +1,19 @@
import type { Instrumentation } from "next";
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./instrumentation.node");
}
}
/**
* Errors Next caught while rendering a page or running a route handler,
* server action or the proxy. Counted, and sent to the error tracker when
* one is configured (lib/error-reporting/server.ts); nothing happens while
* error reporting is off. https://bulwarkmail.org/docs/admin/monitoring
*/
export const onRequestError: Instrumentation.onRequestError = async (error, _request, context) => {
if (process.env.NEXT_RUNTIME !== "nodejs") return;
const { captureRequestError } = await import("./lib/error-reporting/server");
await captureRequestError(error, context.routePath);
};
+15
View File
@@ -107,3 +107,18 @@ describe('server configuration validation', () => {
expect(isConfigData({ ...createConfig(), jmapServers: [entry] })).toBe(false);
});
});
describe('error reporting settings in the server configuration', () => {
const settings = { dsn: '', sampleRate: 1, environment: 'production', collect: false };
it('are optional, for Lite and servers before they existed', () => {
expect(isConfigData({ ...createConfig(), errorReporting: settings })).toBe(true);
expect(isConfigData(createConfig())).toBe(true);
});
it('are refused when malformed', () => {
expect(isConfigData({ ...createConfig(), errorReporting: { ...settings, sampleRate: '1' } })).toBe(false);
expect(isConfigData({ ...createConfig(), errorReporting: { ...settings, collect: 'yes' } })).toBe(false);
expect(isConfigData({ ...createConfig(), errorReporting: 'https://key@x.example/1' })).toBe(false);
});
});
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const reported = vi.hoisted(() => ({ calls: [] as string[][], failures: [] as unknown[] }));
vi.mock('@/lib/error-reporting/client', () => ({
recordJmapCall: (methods: string[]) => { reported.calls.push(methods); },
reportJmapFailure: (failure: unknown) => { reported.failures.push(failure); },
}));
import { JMAPClient } from '../jmap/client';
type Call = [string, Record<string, unknown>, string];
const SUBJECT = 'Merger plans for Acme Q3';
function clientAnswering(response: Response) {
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
Object.assign(client, { apiUrl: 'https://jmap.example.com/jmap/', accountId: 'me' });
vi.spyOn(client as unknown as { authenticatedFetch: () => Promise<Response> }, 'authenticatedFetch').mockResolvedValue(response);
const request = (calls: Call[]) => (client as unknown as { request: (c: Call[]) => Promise<unknown> }).request(calls);
return request;
}
beforeEach(() => {
reported.calls = [];
reported.failures = [];
});
describe('JMAP requests and error reporting', () => {
it('keeps the method names of each request as a breadcrumb', async () => {
const request = clientAnswering(new Response(JSON.stringify({ methodResponses: [] })));
await request([['Email/query', { filter: { subject: SUBJECT } }, '0'], ['Email/get', {}, '1']]);
expect(reported.calls).toEqual([['Email/query', 'Email/get']]);
expect(reported.failures).toEqual([]);
});
it('reports a failed request by its status only', async () => {
const request = clientAnswering(new Response(`{"detail":"${SUBJECT}"}`, { status: 503 }));
await expect(request([['Email/get', {}, '0']])).rejects.toThrow();
expect(reported.failures).toEqual([{ status: 503 }]);
});
it('does not report an expired token or the concurrency limit', async () => {
const request = clientAnswering(new Response('', { status: 401 }));
await expect(request([['Email/get', {}, '0']])).rejects.toThrow();
expect(reported.failures).toEqual([]);
});
it('reports method errors by method and type, but not the ones that only mean "sync again"', async () => {
const request = clientAnswering(new Response(JSON.stringify({
methodResponses: [
['error', { type: 'overQuota', description: SUBJECT }, 'a'],
['error', { type: 'cannotCalculateChanges' }, 'b'],
['Mailbox/get', { list: [] }, 'c'],
],
})));
await request([['Email/set', {}, 'a'], ['Email/changes', {}, 'b'], ['Mailbox/get', {}, 'c']]);
expect(reported.failures).toEqual([{ method: 'Email/set', type: 'overQuota' }]);
});
});
+21
View File
@@ -0,0 +1,21 @@
// @vitest-environment node
import { readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { API_GROUPS, PAGE_AREAS } from '@/lib/route-groups';
const ROOT = path.resolve(__dirname, '..', '..');
const dirs = (rel: string) => readdirSync(path.join(ROOT, rel)).filter((name) => statSync(path.join(ROOT, rel, name)).isDirectory());
// The groups are a fixed list so metric labels and report routes stay
// bounded; a new API folder or page area has to be added to it.
describe('route groups', () => {
it('list every API folder', () => {
expect([...API_GROUPS].sort()).toEqual(dirs('app/api').filter((d) => d !== '__tests__').sort());
});
it('list every page area', () => {
const areas = dirs('app/(main)/[locale]').filter((d) => !d.startsWith('[') && !d.startsWith('('));
for (const area of areas) expect(PAGE_AREAS).toContain(area);
});
});
@@ -0,0 +1,21 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { listReachableHosts, noThirdPartyCspSources } from '@/lib/admin/reachable-hosts';
const policy = { pushRelays: [], pushRelayUrl: '', defaultSidebarApps: [], features: { sidebarAppsEnabled: false, calendarEnabled: false } };
function hosts(config: Record<string, unknown>, env: Record<string, string | undefined> = {}) {
return listReachableHosts({ get: (key) => config[key], policy, env, buildRelayUrl: '' });
}
// No third-party traffic (#903) still allows the hosts the admin set: the
// error tracker is one of them, and the page's CSP must let browsers reach it.
describe('error reporting in the no-third-party list', () => {
it('is listed, without the key, only when a DSN is set', () => {
expect(hosts({}).filter((h) => h.kind === 'Error reporting')).toEqual([]);
const listed = hosts({ errorReportingDsn: 'https://6f0b9c79ed3a4315b76595bc2536ac40@errors.example.org/3' })
.filter((h) => h.kind === 'Error reporting');
expect(listed).toEqual([{ kind: 'Error reporting', host: 'errors.example.org', origin: 'https://errors.example.org', setIn: 'ERROR_REPORTING_DSN', side: 'both' }]);
expect(noThirdPartyCspSources(listed, false).connect).toEqual(['https://errors.example.org']);
});
});
+12
View File
@@ -27,6 +27,7 @@ export const ADMIN_PAGE_IDS = [
'marketplace',
'version',
'telemetry',
'monitoring',
'logs',
] as const;
@@ -243,6 +244,17 @@ export const ADMIN_PAGES: readonly AdminPageDef[] = [
{ id: 'payload', title: 'Payload preview', keywords: 'send now json' },
],
},
{
id: 'monitoring',
label: 'Monitoring',
description: 'Error reports, metrics, health checks and tracing for your own tools',
group: 'system',
keywords: 'monitoring errors sentry bugsink glitchtip dsn prometheus metrics health probes tracing opentelemetry otel',
sections: [
{ id: 'errors', title: 'Error reporting', keywords: 'dsn sentry bugsink glitchtip sample rate environment' },
{ id: 'errors-today', title: 'Errors today', keywords: 'count fingerprint top' },
],
},
{
id: 'logs',
label: 'Audit log',
+4
View File
@@ -89,6 +89,10 @@ export async function auditLog(action: string, detail: Record<string, unknown>,
ip,
};
// Also in the server log, so LOG_FORMAT=json shippers see admin actions.
// The audit file below stays the record the admin panel reads.
logger.info(`audit: ${action}`, { audit: entry });
try {
// One line per entry; the store rotates the local file (audit.log.3 →
// deleted, .2 → .3, .1 → .2, audit.log → .1) once it passes 10 MB.
+2 -1
View File
@@ -157,8 +157,9 @@ export const OUTGOING_HOSTS: readonly OutgoingHost[] = [
* has to be looked at again.
*/
export const NON_REQUEST_HOSTS: readonly { host: string; files: readonly string[]; why: string }[] = [
{ host: 'bulwarkmail.org', why: 'docs links, comments and the contact URL in User-Agent headers', files: ['app/(main)/admin/_pages/telemetry.tsx', 'app/api/fetch-ical/route.ts', 'app/api/translate/route.ts','components/admin/org/sections/recovery-section.tsx', 'instrumentation.node.ts', 'lib/telemetry/state.ts', 'lib/telemetry/types.ts'] },
{ host: 'bulwarkmail.org', why: 'docs links, comments and the contact URL in User-Agent headers', files: ['app/(main)/admin/_pages/telemetry.tsx', 'app/api/fetch-ical/route.ts', 'app/api/translate/route.ts','components/admin/org/sections/recovery-section.tsx', 'instrumentation.node.ts', 'lib/telemetry/state.ts', 'lib/telemetry/types.ts', 'app/(main)/admin/_pages/monitoring.tsx', 'lib/admin/types.ts', 'lib/error-reporting/client.ts', 'lib/error-reporting/lite.ts', 'lib/error-reporting/scrub.ts', 'instrumentation.ts'] },
{ host: 'bulwarkmail.com', why: 'JMAP capability URI', files: ['lib/jmap/client.ts'] },
{ host: 'develop.sentry.dev', why: 'link to the envelope format in a comment', files: ['lib/error-reporting/envelope.ts'] },
{ host: 'extensions.bulwarkmail.org', why: 'signed statement type URIs', files: ['lib/admin/directory-keys.ts'] },
{ host: 'www.w3.org', why: 'XML namespaces', files: ['components/ui/flag-icons.tsx', 'lib/favicon-badge.ts'] },
{ host: 'creativecommons.org', why: 'licence metadata inside an SVG', files: ['components/ui/flag-icons.tsx'] },
+2
View File
@@ -91,6 +91,8 @@ export function listReachableHosts({ get, policy, env, buildRelayUrl = DEFAULT_R
for (const app of policy.defaultSidebarApps ?? []) add('Sidebar app', app?.url, `User policy > Sidebar apps (${app?.name ?? ''})`, 'browser');
add('Document editor (WOPI)', get('wopiClientUrl'), 'WOPI_CLIENT_URL', 'both');
add('Translation (LibreTranslate)', env.LIBRETRANSLATE_URL, 'LIBRETRANSLATE_URL', 'server');
// Only when an admin set it; browsers and this server send error reports there.
add('Error reporting', get('errorReportingDsn'), 'ERROR_REPORTING_DSN', 'both');
for (const key of BRANDING_KEYS) add('Branding image', get(key), key, 'browser');
// User-directed: what users type in themselves stays allowed.
+6
View File
@@ -363,6 +363,12 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
settingsSyncEnabled: { envVar: 'SETTINGS_SYNC_ENABLED', type: 'boolean', defaultValue: false },
logFormat: { envVar: 'LOG_FORMAT', type: 'enum', defaultValue: 'text', enumValues: ['text', 'json'] },
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
// Error reporting to a Sentry-protocol tracker (Bugsink, GlitchTip,
// Sentry), off while empty. The DSN is public by design: browsers get it.
// https://bulwarkmail.org/docs/admin/monitoring
errorReportingDsn: { envVar: 'ERROR_REPORTING_DSN', fileEnvVar: 'ERROR_REPORTING_DSN_FILE', fileKey: 'errorReportingDsnFile', type: 'string', defaultValue: '' },
errorReportingSampleRate: { envVar: 'ERROR_REPORTING_SAMPLE_RATE', type: 'string', defaultValue: '1' },
errorReportingEnvironment: { envVar: 'ERROR_REPORTING_ENVIRONMENT', type: 'string', defaultValue: 'production' },
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', fileKey: "sessionSecretFile", type: 'string', defaultValue: '' },
extensionDirectoryUrl: { envVar: 'EXTENSION_DIRECTORY_URL', type: 'url', defaultValue: 'https://extensions.bulwarkmail.org' },
// Root keys (base64 raw Ed25519, comma-separated) a self-hosted extension
+8
View File
@@ -74,6 +74,14 @@ const configValidators = {
noThirdPartyTraffic: isBoolean,
// Lite only (lib/lite-config.ts); the server's response leaves it out.
oauthRevokeOnSignOut: (value): value is boolean | undefined => value === undefined || typeof value === 'boolean',
// Absent from Lite's config and from servers before 1.20.
errorReporting: (value): value is ConfigData['errorReporting'] => value === undefined || (
isRecord(value)
&& typeof value.dsn === 'string'
&& typeof value.sampleRate === 'number'
&& typeof value.environment === 'string'
&& typeof value.collect === 'boolean'
),
} satisfies Validators<ConfigData>;
/**
@@ -0,0 +1,34 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { bound, KEEP_DAYS, MAX_PER_DAY, merge, summarizeDay, type Aggregate, type AggregateEntry } from '../aggregate';
function entry(count: number): AggregateEntry {
return { name: 'Error', frames: [], version: '1.20.0', runtime: 'browser', count };
}
describe('error aggregate', () => {
it('adds counts from several replicas instead of overwriting them', () => {
const a: Aggregate = { '2026-11-01': { f1: entry(2) } };
const b: Aggregate = { '2026-11-01': { f1: entry(3), f2: entry(1) } };
expect(merge(a, b)['2026-11-01']).toEqual({ f1: entry(5), f2: entry(1) });
});
it('keeps the most frequent fingerprints per day and only the last days', () => {
const many: Record<string, AggregateEntry> = {};
for (let i = 0; i < MAX_PER_DAY + 50; i++) many[`f${i}`] = entry(i + 1);
const old = '2026-10-01';
const bounded = bound({ '2026-11-01': many, [old]: { x: entry(1) } }, '2026-11-01');
expect(Object.keys(bounded['2026-11-01'])).toHaveLength(MAX_PER_DAY);
expect(bounded['2026-11-01'].f0).toBeUndefined();
expect(bounded['2026-11-01'][`f${MAX_PER_DAY + 49}`]).toBeDefined();
expect(bounded[old]).toBeUndefined();
const lastKept = new Date(Date.parse('2026-11-01T00:00:00Z') - (KEEP_DAYS - 1) * 86_400_000).toISOString().slice(0, 10);
expect(Object.keys(bound({ [lastKept]: { x: entry(1) } }, '2026-11-01'))).toEqual([lastKept]);
});
it('summarizes a day, most frequent first', () => {
const summary = summarizeDay({ '2026-11-01': { a: entry(1), b: entry(5) } }, '2026-11-01');
expect(summary.total).toBe(6);
expect(summary.top.map((e) => e.fingerprint)).toEqual(['b', 'a']);
});
});
@@ -0,0 +1,120 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
captureException, configureErrorReporting, recordJmapCall, recordNavigation, reportJmapFailure,
resetErrorReportingForTests,
} from '../client';
const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3';
const SUBJECT = 'Merger plans for Acme Q3';
const ADDRESS = 'ceo.private@acme-corp.example';
const FOLDER = 'Acquisition Talks';
function errorWithStack(message: string): Error {
const error = new TypeError(message);
error.stack = `TypeError: ${message}\n at Ab (https://mail.example.org/_next/static/chunks/1.js:2:30)\n at https://mail.example.org/_next/static/chunks/2.js:4:5`;
return error;
}
let fetchSpy: ReturnType<typeof vi.fn>;
function calls(): Array<{ url: string; init: RequestInit }> {
return fetchSpy.mock.calls.map(([url, init]) => ({ url: String(url), init: init as RequestInit }));
}
beforeEach(() => {
resetErrorReportingForTests();
sessionStorage.clear();
fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
vi.stubGlobal('fetch', fetchSpy);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('browser error reporting', () => {
it('sends nothing while reporting is off', () => {
configureErrorReporting(null);
captureException(errorWithStack('boom'));
configureErrorReporting({ dsn: '', sampleRate: 1, environment: 'production', collect: false });
captureException(errorWithStack('boom again'));
expect(fetchSpy).not.toHaveBeenCalled();
});
it('holds reports until the config arrives, then sends them', () => {
captureException(errorWithStack('early'));
expect(fetchSpy).not.toHaveBeenCalled();
configureErrorReporting({ dsn: DSN, sampleRate: 1, environment: 'production', collect: false });
expect(calls().map((c) => c.url)).toEqual(['https://bugsink.example.org/api/3/envelope/?sentry_key=6f0b9c79ed3a4315b76595bc2536ac40&sentry_version=7']);
});
it('sends to the DSN without cookies or referrer, and with no mail content anywhere', () => {
configureErrorReporting({ dsn: DSN, sampleRate: 1, environment: 'staging', collect: true });
window.history.pushState({}, '', `/en/mail/folder/${encodeURIComponent(FOLDER)}/Mabc`);
recordNavigation(window.location.pathname);
recordJmapCall(['Email/query', 'Email/get']);
captureException(errorWithStack(`Failed to open "${SUBJECT}" from ${ADDRESS} at https://mail.example.org/download?name=${encodeURIComponent(SUBJECT)}`));
const [dsnCall, ownCall] = calls();
expect(dsnCall.init.credentials).toBe('omit');
expect(dsnCall.init.referrerPolicy).toBe('no-referrer');
expect(ownCall.url).toBe('/api/errors');
for (const { init } of [dsnCall, ownCall]) {
const body = String(init.body);
for (const planted of [SUBJECT, ADDRESS, FOLDER, encodeURIComponent(FOLDER), 'Merger', 'acme-corp', 'download?name']) {
expect(body).not.toContain(planted);
}
}
const event = JSON.parse(String(dsnCall.init.body).trim().split('\n')[2]);
expect(event.exception.values[0].value).toBe('Failed to open "…" from [email] at [url]');
expect(event.environment).toBe('staging');
expect(event.breadcrumbs.values.map((b: { message: string }) => b.message)).toEqual(['/mail/…', 'Email/query, Email/get']);
expect(event.tags.route).toBe('/mail/…');
});
it('sends each fingerprint once per session', () => {
configureErrorReporting({ dsn: DSN, sampleRate: 1, environment: 'production', collect: false });
captureException(errorWithStack('same'));
captureException(errorWithStack('same'));
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('stops at ten reports a minute', () => {
configureErrorReporting({ dsn: DSN, sampleRate: 1, environment: 'production', collect: false });
for (let i = 0; i < 15; i++) {
const error = new Error(`e${i}`);
error.stack = `Error\n at https://m.example/_next/static/chunks/${i}.js:1:1`;
captureException(error);
}
expect(fetchSpy).toHaveBeenCalledTimes(10);
});
it('applies the sample rate to the DSN only', () => {
vi.spyOn(Math, 'random').mockReturnValue(0.5);
configureErrorReporting({ dsn: DSN, sampleRate: 0.1, environment: 'production', collect: true });
captureException(errorWithStack('sampled out'));
expect(calls().map((c) => c.url)).toEqual(['/api/errors']);
});
it('ignores errors that come only from extensions or other scripts', () => {
configureErrorReporting({ dsn: DSN, sampleRate: 1, environment: 'production', collect: false });
const error = new Error('from an extension');
error.stack = 'Error\n at chrome-extension://abc/content.js:1:1';
captureException(error);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('reports a failed JMAP request by status or method and type, never the answer', () => {
configureErrorReporting({ dsn: DSN, sampleRate: 1, environment: 'production', collect: true });
reportJmapFailure({ status: 503 });
reportJmapFailure({ method: 'Email/set', type: 'overQuota' });
reportJmapFailure({ method: SUBJECT, type: ADDRESS });
const own = calls().filter((c) => c.url === '/api/errors').map((c) => JSON.parse(String(c.init.body)));
expect(own.map((r) => [r.name, r.code])).toEqual([
['JmapRequestError', 'http:503'],
['JmapMethodError', 'Email/set: overQuota'],
['JmapMethodError', 'Unknown/method: unknown'],
]);
});
});
@@ -0,0 +1,118 @@
// @vitest-environment node
import { afterEach, describe, expect, it } from 'vitest';
import { buildEnvelope, buildEvent, envelopeUrl, isErrorCode, parseDsn } from '../envelope';
import { debugIdsByPath, resetDebugIdCache } from '../debug-ids';
import { fingerprint } from '../fingerprint';
import { parseStack } from '../stack';
const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3';
describe('parseDsn', () => {
it('reads key, origin and project', () => {
expect(parseDsn(DSN)).toEqual({ origin: 'https://bugsink.example.org', pathPrefix: '', publicKey: '6f0b9c79ed3a4315b76595bc2536ac40', projectId: '3' });
expect(parseDsn('https://6f0b9c79ed3a4315b76595bc2536ac40@host.example:8443/sentry/12')?.pathPrefix).toBe('/sentry');
});
it('refuses what is not a DSN', () => {
for (const bad of ['', 'not a url', 'https://bugsink.example.org/3', 'ftp://6f0b9c79ed3a4315b76595bc2536ac40@x.example/3',
'https://6f0b9c79ed3a4315b76595bc2536ac40@x.example/abc', 'https://6f0b9c79ed3a4315b76595bc2536ac40:secret@x.example/3']) {
expect(parseDsn(bad), bad).toBeNull();
}
});
it('puts the key in the query, so a browser sends no custom header', () => {
expect(envelopeUrl(parseDsn(DSN)!)).toBe('https://bugsink.example.org/api/3/envelope/?sentry_key=6f0b9c79ed3a4315b76595bc2536ac40&sentry_version=7');
});
});
describe('stack parsing', () => {
it('reads V8, Firefox and Safari stacks, top frame first', () => {
const chrome = 'TypeError: x\n at Ab (https://mail.example.org/_next/static/chunks/1.js:2:30)\n at async https://mail.example.org/_next/static/chunks/2.js:4:5';
const firefox = 'Ab@https://mail.example.org/_next/static/chunks/1.js:2:30\n@https://mail.example.org/_next/static/chunks/2.js:4:5';
for (const stack of [chrome, firefox]) {
expect(parseStack(stack)).toEqual([
{ path: '/_next/static/chunks/1.js', line: 2, column: 30, fn: 'Ab' },
{ path: '/_next/static/chunks/2.js', line: 4, column: 5, fn: undefined },
]);
}
expect(parseStack(' at Object.handler (/app/.next/server/chunks/77.js:1:900)')[0]).toEqual({ path: '/.next/server/chunks/77.js', line: 1, column: 900, fn: 'Object.handler' });
});
});
describe('debug ids', () => {
afterEach(() => {
delete (globalThis as Record<string, unknown>)._debugIds;
delete (globalThis as Record<string, unknown>)._sentryDebugIds;
resetDebugIdCache();
});
it('maps each registered chunk to its id, from Turbopack and sentry-cli alike', () => {
(globalThis as Record<string, unknown>)._debugIds = {
'Error\n at https://mail.example.org/_next/static/chunks/1.js:1:10': 'bc67f7db-7f3b-7be2-6f94-966cd7825cc2',
};
(globalThis as Record<string, unknown>)._sentryDebugIds = {
'Error\n at https://mail.example.org/_next/static/chunks/2.js:1:10': 'c40c4654-00a8-5efe-b3a3-42818d0ccc15',
};
expect(Object.fromEntries(debugIdsByPath())).toEqual({
'/_next/static/chunks/1.js': 'bc67f7db-7f3b-7be2-6f94-966cd7825cc2',
'/_next/static/chunks/2.js': 'c40c4654-00a8-5efe-b3a3-42818d0ccc15',
});
});
});
describe('buildEvent', () => {
const frames = [
{ path: '/_next/static/chunks/1.js', line: 2, column: 30, fn: 'Ab' },
{ path: '/_next/static/chunks/2.js', line: 4, column: 5 },
];
const event = buildEvent(
{ name: 'TypeError', message: 'x is undefined', frames, handled: false, mechanism: 'onerror', route: '/mail/…' },
{
eventId: 'a'.repeat(32), timestamp: 1, release: '1.20.0+abc1234', environment: 'production', platform: 'javascript',
fingerprint: 'ffff0000ffff0000', debugIds: new Map([['/_next/static/chunks/1.js', 'bc67f7db-7f3b-7be2-6f94-966cd7825cc2']]),
},
);
it('names a source map image for each frame file with a debug id, under the frame path', () => {
const exception = (event.exception as { values: Array<{ stacktrace: { frames: Array<{ abs_path: string; lineno: number }> } }> }).values[0];
// Oldest first: the throwing frame is last.
expect(exception.stacktrace.frames.map((f) => f.abs_path)).toEqual(['/_next/static/chunks/2.js', '/_next/static/chunks/1.js']);
expect(event.debug_meta).toEqual({ images: [{ type: 'sourcemap', code_file: '/_next/static/chunks/1.js', debug_id: 'bc67f7db-7f3b-7be2-6f94-966cd7825cc2' }] });
});
it('groups by Bulwark\'s fingerprint and carries the release', () => {
expect(event.fingerprint).toEqual(['ffff0000ffff0000']);
expect(event.release).toBe('1.20.0+abc1234');
expect(event.tags).toEqual({ route: '/mail/…' });
});
it('wraps it in a three-line envelope', () => {
const lines = buildEnvelope(event).trim().split('\n');
expect(lines).toHaveLength(3);
expect(JSON.parse(lines[1])).toEqual({ type: 'event', content_type: 'application/json' });
expect(JSON.parse(lines[2]).event_id).toBe('a'.repeat(32));
});
});
describe('fingerprint', () => {
const frames = [{ path: '/_next/static/chunks/1.js', line: 2, column: 30 }];
it('differs by name, frame, version and code, and ignores frames outside the bundle', () => {
const base = fingerprint('TypeError', frames, '1.20.0');
expect(base).toMatch(/^[0-9a-f]{16}$/);
expect(fingerprint('TypeError', frames, '1.20.0')).toBe(base);
expect(fingerprint('RangeError', frames, '1.20.0')).not.toBe(base);
expect(fingerprint('TypeError', [{ ...frames[0], line: 3 }], '1.20.0')).not.toBe(base);
expect(fingerprint('TypeError', frames, '1.20.1')).not.toBe(base);
expect(fingerprint('TypeError', frames, '1.20.0', 'serverFail')).not.toBe(base);
expect(fingerprint('TypeError', [...frames, { path: '[other]', line: 1, column: 1 }], '1.20.0')).toBe(base);
});
it('accepts only fixed error codes', () => {
expect(isErrorCode('Email/set: overQuota')).toBe(true);
expect(isErrorCode('serverFail')).toBe(true);
expect(isErrorCode('http:503')).toBe(true);
expect(isErrorCode('Merger plans for Acme')).toBe(false);
expect(isErrorCode('Email/set: over quota now')).toBe(false);
});
});
@@ -0,0 +1,21 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { liteErrorReporting } from '../lite';
const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@errors.example.org/3';
// Bulwark Lite has no server: policy.json carries the error reporting address.
describe('Lite error reporting from policy.json', () => {
it('is off without a valid DSN', () => {
expect(liteErrorReporting({})).toBeNull();
expect(liteErrorReporting(null)).toBeNull();
expect(liteErrorReporting({ errorReporting: { dsn: 'not a dsn' } })).toBeNull();
});
it('reads the DSN, sample rate and environment, and never asks for server-side counting', () => {
expect(liteErrorReporting({ errorReporting: { dsn: DSN, sampleRate: 0.25, environment: 'lite-prod' } }))
.toEqual({ dsn: DSN, sampleRate: 0.25, environment: 'lite-prod', collect: false });
expect(liteErrorReporting({ errorReporting: { dsn: DSN, sampleRate: 7, environment: 'bad env!' } }))
.toEqual({ dsn: DSN, sampleRate: 1, environment: 'production', collect: false });
});
});
+102
View File
@@ -0,0 +1,102 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { scrubFramePath, scrubJmapErrorType, scrubMethodName, scrubName, scrubText } from '../scrub';
import { routeGroup, scrubRoutePath } from '@/lib/route-groups';
// Mail content planted where it could end up in an error: none of it may
// survive scrubbing.
const SUBJECT = 'Merger plans for Acme Q3';
const ADDRESS = 'ceo.private@acme-corp.example';
const FOLDER = 'Acquisition Talks';
const FILE = 'term-sheet-final.pdf';
const TOKEN = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.c2lnbmF0dXJlMTIzNDU2';
function expectClean(text: string) {
for (const planted of [SUBJECT, ADDRESS, FOLDER, FILE, TOKEN, 'Merger', 'acme-corp', 'Acquisition', 'term-sheet']) {
expect(text).not.toContain(planted);
}
}
describe('scrubText', () => {
it('removes a quoted subject, an address and a file name from a message', () => {
const out = scrubText(`Failed to move "${SUBJECT}" from ${ADDRESS} with attachment ${FILE}`);
expectClean(out);
expect(out).toBe('Failed to move "…" from [email] with attachment [file]');
});
it('removes text in every kind of quote, but keeps contractions', () => {
expect(scrubText(`can't open 'Merger' or “Merger” or ‘Merger’ or «Merger» or \`Merger\``))
.toBe("can't open '…' or “…” or ‘…’ or «…» or `…`");
});
it('removes URLs whole, with their query strings', () => {
const out = scrubText(`fetch https://mail.acme-corp.example/jmap/download/abc/${FILE}?token=${TOKEN} failed`);
expectClean(out);
expect(out).toBe('fetch [url] failed');
});
it('removes credentials, JWTs, key=value pairs and random-looking ids', () => {
expect(scrubText(`Authorization: Bearer ${TOKEN}`)).toBe('Authorization: Bearer [token]');
expect(scrubText(`token ${TOKEN} expired`)).toBe('token [token] expired');
expect(scrubText('cookie session=abc123def; theme=dark')).toBe('cookie session=[redacted]; theme=[redacted]');
expect(scrubText('blob Ma1b2c3d4e5f6a7b8c9d0e1f2a3b4 missing')).toBe('blob [id] missing');
});
it('keeps only the first line and drops a server answer after the status', () => {
expect(scrubText(`Request failed: 400 - {"description":"${SUBJECT}"}`)).toBe('Request failed: 400');
expect(scrubText(`Something broke\n${SUBJECT}\n${ADDRESS}`)).toBe('Something broke');
});
it('keeps engine messages readable', () => {
expect(scrubText('Cannot read properties of undefined (reading \'id\')')).toBe('Cannot read properties of undefined (reading \'…\')');
expect(scrubText('e.map is not a function')).toBe('e.map is not a function');
});
it('caps the length', () => {
expect(scrubText('x '.repeat(500)).length).toBeLessThanOrEqual(200);
});
});
describe('route paths in breadcrumbs and reports', () => {
it('keeps the area only, never folder names or ids', () => {
const path = `/de/mail/folder/${encodeURIComponent(FOLDER)}/Mabc123?q=${encodeURIComponent(SUBJECT)}`;
const out = scrubRoutePath(path);
expectClean(decodeURIComponent(out));
expect(out).toBe('/mail/…');
});
it('removes the base path and maps unknown areas and API routes to fixed names', () => {
expect(scrubRoutePath('/webmail/en/calendar', '/webmail')).toBe('/calendar');
expect(scrubRoutePath(`/en/${encodeURIComponent(FOLDER)}`)).toBe('/other');
expect(scrubRoutePath('/api/wopi/files/abc/contents')).toBe('/api/wopi/…');
expect(routeGroup(`/api/${FOLDER}`)).toBe('api:other');
expect(routeGroup('/_next/static/chunks/a.js')).toBe('static');
expect(routeGroup('/')).toBe('page:home');
});
});
describe('frame paths', () => {
it('drops the origin, base path, query and fragment', () => {
expect(scrubFramePath('https://mail.acme-corp.example/webmail/_next/static/chunks/12-ab.js?dpl=x#y')).toBe('/_next/static/chunks/12-ab.js');
expect(scrubFramePath('/home/alice/bulwark/.next/server/chunks/ssr/a.js')).toBe('/.next/server/chunks/ssr/a.js');
expect(scrubFramePath('/home/alice/bulwark/node_modules/next/dist/x.js')).toBe('node_modules/next/dist/x.js');
expect(scrubFramePath('node:internal/process/task_queues')).toBe('node:internal/process/task_queues');
});
it('turns anything else into [other]', () => {
expect(scrubFramePath('chrome-extension://abc/content.js')).toBe('[other]');
expect(scrubFramePath(`https://acme-corp.example/${FILE}`)).toBe('[other]');
expect(scrubFramePath('blob:https://x/1')).toBe('[other]');
});
});
describe('names', () => {
it('keeps identifiers only', () => {
expect(scrubName('TypeError')).toBe('TypeError');
expect(scrubName(SUBJECT)).toBe('Error');
expect(scrubMethodName('Email/get')).toBe('Email/get');
expect(scrubMethodName(SUBJECT)).toBe('Unknown/method');
expect(scrubJmapErrorType('overQuota')).toBe('overQuota');
expect(scrubJmapErrorType(ADDRESS)).toBe('unknown');
});
});
@@ -0,0 +1,101 @@
// @vitest-environment node
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const config: Record<string, unknown> = {};
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: async () => {},
get: (key: string, fallback: unknown) => (key in config ? config[key] : fallback),
},
}));
import { captureLoggedError, captureRequestError, installServerErrorReporting } from '../server';
import { flushErrors, readErrors, resetAggregateForTests, utcDay } from '../aggregate';
import { logger, setErrorSink } from '@/lib/logger';
const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3';
const SUBJECT = 'Merger plans for Acme Q3';
const ADDRESS = 'ceo.private@acme-corp.example';
let dir: string;
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
dir = mkdtempSync(path.join(tmpdir(), 'bulwark-errors-'));
process.env.TELEMETRY_DATA_DIR = dir;
for (const key of Object.keys(config)) delete config[key];
resetAggregateForTests();
fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
vi.stubGlobal('fetch', fetchSpy);
});
afterEach(() => {
setErrorSink(null);
resetAggregateForTests();
vi.unstubAllGlobals();
delete process.env.TELEMETRY_DATA_DIR;
rmSync(dir, { recursive: true, force: true });
});
function serverError(message: string): Error {
const error = new Error(message);
error.stack = `Error: ${message}\n at handler (/app/.next/server/chunks/77.js:1:900)\n at /app/node_modules/next/dist/server.js:5:6`;
return error;
}
describe('server error reporting', () => {
it('does nothing at all without a DSN or other reason to collect', async () => {
await captureRequestError(serverError('boom'), '/api/admin/config');
await flushErrors();
expect(fetchSpy).not.toHaveBeenCalled();
expect(existsSync(path.join(dir, 'errors.json'))).toBe(false);
});
it('sends a request error to the DSN with the route pattern and no mail content', async () => {
config.errorReportingDsn = DSN;
config.errorReportingEnvironment = 'beta';
await captureRequestError(serverError(`Could not file "${SUBJECT}" for ${ADDRESS}`), '/api/wopi/files/[fileId]');
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('https://bugsink.example.org/api/3/envelope/?sentry_key=6f0b9c79ed3a4315b76595bc2536ac40&sentry_version=7');
const body = String((init as RequestInit).body);
expect(body).not.toContain('Merger');
expect(body).not.toContain(ADDRESS);
const event = JSON.parse(body.trim().split('\n')[2]);
expect(event.platform).toBe('node');
expect(event.environment).toBe('beta');
expect(event.tags).toEqual({ runtime: 'server', route: '/api/wopi/files/[fileId]' });
expect(event.exception.values[0].stacktrace.frames.at(-1).abs_path).toBe('/.next/server/chunks/77.js');
});
it('counts the error per day', async () => {
config.errorReportingDsn = DSN;
await captureRequestError(serverError('one'));
await captureRequestError(serverError('one'));
await flushErrors();
const day = (await readErrors())[utcDay()];
expect(Object.values(day).map((e) => [e.name, e.runtime, e.count])).toEqual([['Error', 'server', 2]]);
});
it('reports logger.error calls with the caller\'s frames once installed', async () => {
config.errorReportingDsn = DSN;
await installServerErrorReporting();
logger.error('telemetry GET error', { error: `failed for ${ADDRESS}` });
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1));
const event = JSON.parse(String((fetchSpy.mock.calls[0][1] as RequestInit).body).trim().split('\n')[2]);
expect(event.exception.values[0].type).toBe('LoggedError');
expect(event.exception.values[0].value).toBe('telemetry GET error: failed for [email]');
});
it('drops the logger\'s own frames from a logged error', async () => {
config.errorReportingDsn = DSN;
const stack = 'Error\n at log (/app/.next/server/chunks/1.js:1:1)\n at Object.error (/app/.next/server/chunks/1.js:2:2)\n at GET (/app/.next/server/chunks/route.js:9:9)';
await captureLoggedError('x failed', undefined, stack);
const event = JSON.parse(String((fetchSpy.mock.calls[0][1] as RequestInit).body).trim().split('\n')[2]);
expect(event.exception.values[0].stacktrace.frames.map((f: { function: string }) => f.function)).toEqual(['GET']);
});
});
+179
View File
@@ -0,0 +1,179 @@
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];
}
+244
View File
@@ -0,0 +1,244 @@
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,
frames: 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);
}
/** 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];
}
+47
View File
@@ -0,0 +1,47 @@
import { parseStack } from './stack';
/**
* The debug ID of each loaded bundle file, by scrubbed path.
*
* Turbopack (`turbopack.debugIds`) and `sentry-cli sourcemaps inject` (the
* webpack build in Docker) start every chunk with a line that stores
* `globalThis[KEY][new Error().stack] = "<debug id>"`. The first frame of that
* stack is the chunk itself, so its path maps to the id. The error tracker
* finds the uploaded source map by that id alone.
*/
const KEYS = ['_debugIds', '_sentryDebugIds'] as const;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
let cache: { size: number; ids: Map<string, string> } | null = null;
function registered(): Array<[string, string]> {
const g = globalThis as Record<string, unknown>;
const out: Array<[string, string]> = [];
for (const key of KEYS) {
const map = g[key];
if (!map || typeof map !== 'object') continue;
for (const [stack, id] of Object.entries(map as Record<string, unknown>)) {
if (typeof id === 'string' && UUID.test(id)) out.push([stack, id.toLowerCase()]);
}
}
return out;
}
/** Path -> debug ID for every chunk loaded so far. Chunks load lazily, so this is rebuilt when more have registered. */
export function debugIdsByPath(): Map<string, string> {
const entries = registered();
if (cache && cache.size === entries.length) return cache.ids;
const ids = new Map<string, string>();
for (const [stack, id] of entries) {
const first = parseStack(stack).find((frame) => frame.path !== '[other]');
if (first) ids.set(first.path, id);
}
cache = { size: entries.length, ids };
return ids;
}
/** For tests. */
export function resetDebugIdCache(): void {
cache = null;
}
+149
View File
@@ -0,0 +1,149 @@
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';
}
+34
View File
@@ -0,0 +1,34 @@
import type { Frame } from './stack';
/** How many of the top frames make up a fingerprint. */
export const FINGERPRINT_FRAMES = 5;
/**
* 64-bit FNV-1a as 16 hex digits. Synchronous and the same in the browser and
* on the server; not for security, only for grouping.
*/
export function hash64(text: string): string {
let h1 = 0x811c9dc5;
let h2 = 0xcbf29ce4;
for (let i = 0; i < text.length; i++) {
const c = text.charCodeAt(i);
h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0;
h2 = Math.imul(h2 ^ c ^ (h1 >>> 7), 0x01000193) >>> 0;
}
return h1.toString(16).padStart(8, '0') + h2.toString(16).padStart(8, '0');
}
/**
* The fingerprint of an error: its name, the top frames (file without origin,
* line, column) and the Bulwark version. The same error in another release is
* another fingerprint, because its frames point into other chunks anyway.
* `code` is one of Bulwark's fixed error codes (a JMAP error type), for
* errors that are all raised from the same place.
*/
export function fingerprint(name: string, frames: readonly Frame[], version: string, code?: string): string {
const top = frames
.filter((frame) => frame.path !== '[other]')
.slice(0, FINGERPRINT_FRAMES)
.map((frame) => `${frame.path}:${frame.line ?? ''}:${frame.column ?? ''}`);
return hash64([name, code ?? '', ...top, version].join('|'));
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Keeps error reporting from flooding anyone: each fingerprint once per
* window, at most `perMinute` reports in any minute and `perSession` in all.
* The browser keeps its state in sessionStorage, so a page that crashes on
* every load doesn't start over each time.
*/
export interface LimiterOptions {
perMinute: number;
perSession: number;
/** How long a fingerprint stays "already sent"; Infinity for the whole session. */
dedupWindowMs: number;
/** Persist across page loads (sessionStorage); absent on the server. */
storage?: Pick<Storage, 'getItem' | 'setItem'> | null;
storageKey?: string;
now?: () => number;
}
interface State {
total: number;
sent: Record<string, number>;
}
const MAX_REMEMBERED = 500;
export class ReportLimiter {
private readonly options: Required<Omit<LimiterOptions, 'storage'>> & { storage: LimiterOptions['storage'] };
private recent: number[] = [];
private state: State = { total: 0, sent: {} };
constructor(options: LimiterOptions) {
this.options = { storageKey: 'bulwark.error-reporting', now: () => Date.now(), storage: null, ...options };
this.load();
}
private load(): void {
const { storage, storageKey } = this.options;
if (!storage) return;
try {
const raw = storage.getItem(storageKey);
if (!raw) return;
const parsed = JSON.parse(raw) as Partial<State>;
if (typeof parsed.total === 'number' && parsed.sent && typeof parsed.sent === 'object') {
this.state = { total: parsed.total, sent: parsed.sent as Record<string, number> };
}
} catch {
// Unreadable or blocked storage: start fresh, in memory.
}
}
private save(): void {
const { storage, storageKey } = this.options;
if (!storage) return;
try {
storage.setItem(storageKey, JSON.stringify(this.state));
} catch {
// Quota or privacy mode: the in-memory state still applies.
}
}
/** Whether a report with this fingerprint may go out now; counts it when it may. */
allow(fingerprint: string): boolean {
const now = this.options.now();
const last = this.state.sent[fingerprint];
if (last !== undefined && now - last < this.options.dedupWindowMs) return false;
if (this.state.total >= this.options.perSession) return false;
this.recent = this.recent.filter((at) => now - at < 60_000);
if (this.recent.length >= this.options.perMinute) return false;
this.recent.push(now);
this.state.total += 1;
this.state.sent[fingerprint] = now;
const keys = Object.keys(this.state.sent);
if (keys.length > MAX_REMEMBERED) {
for (const key of keys.sort((a, b) => this.state.sent[a] - this.state.sent[b]).slice(0, keys.length - MAX_REMEMBERED)) {
delete this.state.sent[key];
}
}
this.save();
return true;
}
}
+22
View File
@@ -0,0 +1,22 @@
import type { ClientSettings } from './client';
import { parseDsn } from './envelope';
/**
* Bulwark Lite has no server: error reporting is set in policy.json and
* browsers send straight to the DSN. There are no per-day counts.
*
* "errorReporting": { "dsn": "https://<key>@host/<project>", "sampleRate": 1, "environment": "production" }
*
* The DSN's host must also be in the CSP's connect-src of the Lite host.
* https://bulwarkmail.org/docs/lite/configuration
*/
export function liteErrorReporting(policy: unknown): ClientSettings | null {
const raw = policy && typeof policy === 'object' ? (policy as Record<string, unknown>).errorReporting : undefined;
if (!raw || typeof raw !== 'object') return null;
const input = raw as Record<string, unknown>;
const dsn = typeof input.dsn === 'string' && parseDsn(input.dsn) ? input.dsn.trim() : '';
if (!dsn) return null;
const rate = typeof input.sampleRate === 'number' && Number.isFinite(input.sampleRate) ? Math.min(1, Math.max(0, input.sampleRate)) : 1;
const environment = typeof input.environment === 'string' && /^[\w.-]{1,64}$/.test(input.environment) ? input.environment : 'production';
return { dsn, sampleRate: rate, environment, collect: false };
}
+126
View File
@@ -0,0 +1,126 @@
/**
* What an error report may carry, applied in the browser before sending and
* again on the server for everything a browser posts (lib/error-reporting).
*
* Mail content must never leave the browser in an error, not even to the
* admin's own error tracker: no message bodies, subjects, addresses, file
* names, tokens, cookies or URLs. Error messages are free text, so the rules
* remove whatever could be such content: quoted text, addresses, URLs, file
* names, credentials, long random-looking strings, key=value pairs, and
* everything after the first line. What stays is the shape of the message
* ("Cannot read properties of undefined (reading "…")").
*
* Documented at https://bulwarkmail.org/docs/admin/monitoring#what-is-removed
*/
export const MAX_MESSAGE_LENGTH = 200;
const REDACTED = '…';
// Quoted text: "…", '…', `…`, “…”, „…“, ‘…’, «…», ‹…›. A single quote only
// counts when it starts after a space or bracket, so "can't" stays a word.
const QUOTED: RegExp[] = [
/"[^"\n]*"/g,
/`[^`\n]*`/g,
/[“„][^”“\n]*[”“]/g,
/‘[^’\n]*’/g,
/«[^»\n]*»/g,
/‹[^›\n]*›/g,
/(^|[\s([{:,=])'[^'\n]*'(?=$|[\s)\]}.,:;!?])/g,
];
const EMAIL = /[^\s@<>"'`()[\]{},;:]+@[^\s@<>"'`()[\]{},;:]+\.[^\s@<>"'`()[\]{},;:]+/g;
const URL_LIKE = /\b[a-z][a-z0-9+.-]*:\/\/[^\s"'`<>()]*/gi;
const WWW = /\bwww\.[^\s"'`<>()]+/gi;
const AUTH = /\b(Bearer|Basic|Token)\s+[^\s,;]+/gi;
const JWT = /\beyJ[\w-]{5,}\.[\w-]{5,}\.[\w-]*/g;
const KEY_VALUE = /\b([A-Za-z_][\w.-]{0,40})=([^\s&;,]+)/g;
const FILE_NAME = /[^\s/\\"'`<>()]+\.(?:pdf|docx?|xlsx?|pptx?|odt|ods|odp|rtf|txt|csv|eml|msg|ics|vcf|zip|rar|7z|tar|gz|png|jpe?g|gif|webp|heic|svg|mp[34]|mov|wav|html?|xml|json|p7[msc]|asc|gpg|pgp|key|pem)\b/gi;
// Runs of letters and digits that look random: ids, hashes, keys, tokens.
const LONG_RUN = /[A-Za-z0-9+/_=-]{24,}/g;
const IPV4 = /\b\d{1,3}(?:\.\d{1,3}){3}\b/g;
const PHONE = /\+?\d[\d ()-]{7,}\d/g;
function looksRandom(run: string): boolean {
// Long identifiers in code are words glued together: keep them when they
// are only letters. Anything with digits or symbols mixed in goes.
return /\d/.test(run) || /[+/=_-]/.test(run) || run.length > 40;
}
/** Free text from an error, made safe to send. */
export function scrubText(input: unknown): string {
if (typeof input !== 'string' || input === '') return '';
// Only the first line: server answers, stack traces and multi-line content
// that ended up in a message all come after it.
let text = input.split(/\r?\n/, 1)[0];
// "Request failed: 400 - <server answer>": the answer may echo a subject.
text = text.replace(/^(.*?\b\d{3})\s+-\s.*$/, '$1');
text = text.replace(AUTH, '$1 [token]');
text = text.replace(JWT, '[token]');
text = text.replace(URL_LIKE, '[url]');
text = text.replace(WWW, '[url]');
text = text.replace(EMAIL, '[email]');
for (const quoted of QUOTED) {
text = text.replace(quoted, (match, lead?: string) => {
const prefix = typeof lead === 'string' ? lead : '';
const quote = match.slice(prefix.length, prefix.length + 1);
const close = match.slice(-1);
return `${prefix}${quote}${REDACTED}${close}`;
});
}
text = text.replace(FILE_NAME, '[file]');
text = text.replace(KEY_VALUE, '$1=[redacted]');
text = text.replace(LONG_RUN, (run) => (looksRandom(run) ? '[id]' : run));
text = text.replace(IPV4, '[ip]');
text = text.replace(PHONE, '[number]');
if (text.length > MAX_MESSAGE_LENGTH) text = `${text.slice(0, MAX_MESSAGE_LENGTH - 1)}…`;
return text;
}
/** An error name: a code identifier, or "Error". */
export function scrubName(input: unknown): string {
return typeof input === 'string' && /^[A-Za-z_$][\w$]{0,63}$/.test(input) ? input : 'Error';
}
/** A function name from a stack frame: identifiers and the usual decorations only. */
export function scrubFunctionName(input: unknown): string | undefined {
if (typeof input !== 'string' || input === '') return undefined;
const trimmed = input.trim().slice(0, 120);
return /^[\w$.<>[\] /#-]+$/.test(trimmed) && !/@/.test(trimmed) ? trimmed : '?';
}
/**
* The path of a stack frame's file, without origin, base path, query or
* fragment: `/_next/static/chunks/x.js` for the browser bundle,
* `/.next/server/...` for the server's, `node:...` for Node's own modules,
* `node_modules/...` for dependencies; `[other]` for anything else
* (extensions, plugin code, evaluated code).
*/
export function scrubFramePath(input: unknown): string {
if (typeof input !== 'string' || input === '') return '[other]';
const path = input.split(/[?#]/, 1)[0].replace(/\\/g, '/');
for (const marker of ['/_next/static/', '/.next/server/', '/.next/static/']) {
const at = path.indexOf(marker);
if (at >= 0) {
const rest = path.slice(at);
return /^[\w./()[\]@+~-]+$/.test(rest) ? rest.replace('/.next/static/', '/_next/static/') : '[other]';
}
}
if (/^node:[\w/.-]+$/.test(path)) return path;
const modules = path.lastIndexOf('/node_modules/');
if (modules >= 0) {
const rest = path.slice(modules + 1);
return /^[\w./@+~-]+$/.test(rest) ? rest : '[other]';
}
return '[other]';
}
/** A JMAP method name ("Email/get") or a fixed placeholder. */
export function scrubMethodName(input: unknown): string {
return typeof input === 'string' && /^[A-Z][A-Za-z]{0,40}\/[a-z][A-Za-z]{0,40}$/.test(input) ? input : 'Unknown/method';
}
/** A JMAP error type ("serverFail", "overQuota") or a fixed placeholder. */
export function scrubJmapErrorType(input: unknown): string {
return typeof input === 'string' && /^[a-z][A-Za-z]{1,40}$/.test(input) ? input : 'unknown';
}
+132
View File
@@ -0,0 +1,132 @@
import { configManager } from '@/lib/admin/config-manager';
import { recordError } from './aggregate';
import {
appVersion, buildEnvelope, buildEvent, envelopeUrl, newEventId, parseDsn, releaseName, type ErrorReport,
} from './envelope';
import { fingerprint } from './fingerprint';
import { ReportLimiter } from './limiter';
import { errorReportingSettings, shouldCollectErrors } from './settings';
import { scrubName, scrubText } from './scrub';
import { parseStack, type Frame } from './stack';
import { debugIdsByPath } from './debug-ids';
/**
* Error reporting on the server: errors Next hands to onRequestError
* (instrumentation.ts) and errors routes catch and log with logger.error.
* While collection is on (a DSN, the metrics endpoint or error-count
* consent, lib/error-reporting/settings.ts) every one is counted in the
* per-day aggregate; with a DSN it is also sent there, at most once an hour
* per fingerprint and 60 a minute. Sending never blocks or fails the request
* that had the error. With everything off nothing happens at all.
*/
const SEND_TIMEOUT_MS = 5000;
const ROUTE_PATTERN = /^\/[\w/[\]().@-]*$/;
interface ServerState {
limiter: ReportLimiter;
sending: number;
}
const KEY = Symbol.for('bulwark.error-reporting.server');
function state(): ServerState {
const g = globalThis as typeof globalThis & { [KEY]?: ServerState };
return (g[KEY] ??= {
limiter: new ReportLimiter({ perMinute: 60, perSession: Number.POSITIVE_INFINITY, dedupWindowMs: 60 * 60_000 }),
sending: 0,
});
}
async function deliver(report: ErrorReport): Promise<void> {
if (!(await shouldCollectErrors())) return;
const version = appVersion();
recordError(report, 'server', version);
const settings = errorReportingSettings();
const dsn = parseDsn(settings.dsn);
if (!dsn) return;
const print = fingerprint(report.name, report.frames, version, report.code);
const s = state();
if (s.sending > 4 || !s.limiter.allow(print)) return;
if (Math.random() >= settings.sampleRate) return;
const event = buildEvent(report, {
eventId: newEventId(),
timestamp: Date.now() / 1000,
release: releaseName(),
environment: settings.environment,
platform: 'node',
fingerprint: print,
debugIds: debugIdsByPath(),
tags: { runtime: 'server' },
});
s.sending++;
try {
await fetch(envelopeUrl(dsn), {
method: 'POST',
headers: { 'Content-Type': 'application/x-sentry-envelope' },
body: buildEnvelope(event),
redirect: 'manual',
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
});
} finally {
s.sending--;
}
}
/** Count `report` and send it to the DSN when one is set. Never rejects. */
export function handleServerReport(report: ErrorReport): Promise<void> {
return deliver(report).catch(() => undefined);
}
/** Next's onRequestError: an error a route, page or server action threw. */
export function captureRequestError(error: unknown, routePattern?: string): Promise<void> {
try {
const e = (error && typeof error === 'object' ? error : { message: String(error) }) as { name?: unknown; message?: unknown; stack?: unknown };
return handleServerReport({
name: scrubName(e.name),
message: scrubText(e.message),
frames: parseStack(e.stack),
handled: false,
mechanism: 'request',
// A route pattern from the code ("/api/admin/[id]"), never the request's URL.
...(typeof routePattern === 'string' && ROUTE_PATTERN.test(routePattern) ? { route: routePattern } : {}),
});
} catch {
return Promise.resolve();
}
}
/**
* logger.error: a route that caught an error and logged it. The frames are
* the logging call's, minus the logger's own `skip` frames.
*/
export function captureLoggedError(
message: string,
extra: Record<string, unknown> | undefined,
stack: string | undefined,
skip = 2,
): Promise<void> {
try {
const detail = typeof extra?.error === 'string' ? extra.error : extra?.error instanceof Error ? extra.error.message : '';
const frames: Frame[] = parseStack(stack).slice(skip);
return handleServerReport({
name: 'LoggedError',
message: scrubText(detail ? `${message}: ${detail}` : message),
frames,
handled: true,
mechanism: 'logger',
});
} catch {
return Promise.resolve();
}
}
/**
* Hook the logger up (instrumentation.node.ts). The logger only knows a
* function on globalThis, so it stays free of config code and safe for any
* bundle that imports it.
*/
export async function installServerErrorReporting(): Promise<void> {
await configManager.ensureLoaded();
const { setErrorSink } = await import('@/lib/logger');
setErrorSink((message, extra, stack) => { void captureLoggedError(message, extra, stack); });
}
+53
View File
@@ -0,0 +1,53 @@
import { configManager } from '@/lib/admin/config-manager';
import { parseDsn } from './envelope';
import type { ClientSettings } from './client';
/**
* The error-reporting settings as the server resolves them: admin config,
* then ERROR_REPORTING_DSN(_FILE) / ERROR_REPORTING_SAMPLE_RATE /
* ERROR_REPORTING_ENVIRONMENT, then off. Bulwark Lite reads the same three
* from policy.json (lib/error-reporting/lite.ts).
*/
export interface ErrorReportingSettings {
/** A valid DSN, or '' when none is set or the value doesn't parse. */
dsn: string;
sampleRate: number;
environment: string;
}
export function parseSampleRate(value: unknown): number {
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN;
if (!Number.isFinite(n)) return 1;
return Math.min(1, Math.max(0, n));
}
export function parseEnvironment(value: unknown): string {
const text = typeof value === 'string' ? value.trim() : '';
return /^[\w.-]{1,64}$/.test(text) ? text : 'production';
}
export function errorReportingSettings(): ErrorReportingSettings {
const raw = configManager.get<string>('errorReportingDsn', '');
return {
dsn: parseDsn(raw) ? raw.trim() : '',
sampleRate: parseSampleRate(configManager.get<string>('errorReportingSampleRate', '1')),
environment: parseEnvironment(configManager.get<string>('errorReportingEnvironment', 'production')),
};
}
/**
* Whether browsers should post their errors to /api/errors: when there is a
* DSN to report to, a metrics endpoint that counts them, or consent to send
* error counts with the telemetry heartbeat.
*/
export async function shouldCollectErrors(): Promise<boolean> {
await configManager.ensureLoaded();
return !!errorReportingSettings().dsn;
}
/** What /api/config hands the browser. */
export async function browserErrorSettings(): Promise<ClientSettings> {
const settings = errorReportingSettings();
return { ...settings, collect: await shouldCollectErrors() };
}
+57
View File
@@ -0,0 +1,57 @@
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;
}
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;
out.push({ path: scrubFramePath(frame.path), line, column, fn: scrubFunctionName(frame.fn) });
}
return out;
}
+32 -1
View File
@@ -1,3 +1,4 @@
import { recordJmapCall, reportJmapFailure } from "@/lib/error-reporting/client";
import { advertisesMailboxMetadata, bulwarkEntries, escapeMetadataPathSegment, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataPatchError, MetadataRefusedError, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata";
import { IncompleteDeleteError } from "./delete-error";
import { ArchiveEmailsError } from "./archive-error";
@@ -189,6 +190,29 @@ function mailboxIdsReplacement(
return { mailboxIds };
}
/**
* Statuses that are part of normal operation: an expired token (refreshed
* by the caller) and Stalwart's concurrency limit (retried above).
*/
const UNREPORTED_STATUSES = new Set([401, 429]);
/**
* Method errors that are answers, not failures: the client resyncs, pages
* again or asks again with the new state.
*/
const UNREPORTED_METHOD_ERRORS = new Set(['cannotCalculateChanges', 'anchorNotFound', 'stateMismatch', 'tooManyChanges']);
/** Report each `["error", {type}]` answer by method name and type (lib/error-reporting/client.ts). */
function reportMethodErrors(methodCalls: JMAPMethodCall[], response: JMAPResponse): void {
for (const [name, result, callId] of response?.methodResponses ?? []) {
if (name !== 'error') continue;
const type = (result as { type?: unknown } | undefined)?.type;
if (typeof type !== 'string' || UNREPORTED_METHOD_ERRORS.has(type)) continue;
const method = methodCalls.find((call) => call[2] === callId)?.[0] ?? 'Unknown/method';
reportJmapFailure({ method, type });
}
}
export class RateLimitError extends Error {
retryAfterMs: number;
constructor(retryAfterMs: number) {
@@ -2089,6 +2113,8 @@ export class JMAPClient implements IJMAPClient {
if (!this.apiUrl) {
throw new Error('Not connected. Call connect() first.');
}
// Error reports keep the method names of the last calls, nothing else.
recordJmapCall(methodCalls.map(([name]) => name));
const requestBody = {
using: using || (hasSubmissionMethod(methodCalls) ? [...SUBMISSION_USING] : ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"]),
@@ -2123,15 +2149,20 @@ export class JMAPClient implements IJMAPClient {
continue;
}
console.error('Request failed:', response.status, responseText);
// The status only: the answer may quote what was sent.
if (!UNREPORTED_STATUSES.has(response.status)) reportJmapFailure({ status: response.status });
throw new Error(`Request failed: ${response.status} - ${responseText.substring(0, 200)}`);
}
let parsed: JMAPResponse;
try {
return JSON.parse(responseText);
parsed = JSON.parse(responseText);
} catch {
console.error('Failed to parse response:', responseText);
throw new Error('Invalid JSON response from server');
}
reportMethodErrors(methodCalls, parsed);
return parsed;
}
}
+31
View File
@@ -40,7 +40,38 @@ function isJson(): boolean {
return configured === 'json';
}
/** Whether log lines go out as JSON (LOG_FORMAT=json or the admin setting). */
export function isJsonLogFormat(): boolean {
return isJson();
}
/**
* Gets every logger.error call, with the stack of the call. The server's
* error reporting sets it (lib/error-reporting/server.ts); without it errors
* are only logged.
*/
export type ErrorSink = (message: string, extra: Record<string, unknown> | undefined, stack: string | undefined) => void;
const SINK_KEY = Symbol.for('bulwark.logger.errorSink');
type GlobalWithSink = typeof globalThis & { [SINK_KEY]?: ErrorSink };
export function setErrorSink(sink: ErrorSink | null): void {
const target = globalThis as GlobalWithSink;
if (sink) target[SINK_KEY] = sink;
else delete target[SINK_KEY];
}
function log(level: LogLevel, message: string, extra?: Record<string, unknown>): void {
if (level === 'error') {
const sink = (globalThis as GlobalWithSink)[SINK_KEY];
if (sink) {
try {
// Frames: log, logger.error, then the caller.
sink(message, extra, new Error().stack);
} catch {
// reporting must not break logging
}
}
}
if (LEVELS[level] > getLevel()) return;
if (isJson()) {
+75
View File
@@ -0,0 +1,75 @@
/**
* A request path reduced to a fixed, small set of names: the area of the app
* or the API folder it belongs to. Error reports use it instead of the URL,
* and the request metrics use it as a label, so neither ever carries a folder
* name, a message id, a query string or anything else a user typed.
*
* lib/__tests__/route-groups.test.ts checks API_GROUPS against app/api and
* PAGE_AREAS against app/(main)/[locale].
*/
/** The folders under app/api. */
export const API_GROUPS: readonly string[] = [
'account', 'account-archive', 'admin', 'auth', 'bimi', 'caldav', 'calendar-agenda', 'config',
'connector', 'dev-jmap', 'errors', 'favicon', 'fetch-ical', 'health', 'og-image',
'plugin-approval-status', 'plugin-proxy', 'plugin-secrets', 'plugin-signing-pubkey', 'plugins',
'push', 'pwa-icon', 'pwa-screenshot', 'ready', 'settings', 'setup', 'system', 'translate',
'webdav', 'wopi',
];
/** The page areas under app/(main)/[locale], plus the pages outside it. */
export const PAGE_AREAS: readonly string[] = [
'apps', 'auth', 'calendar', 'contacts', 'files', 'login', 'mail', 'plugins', 'pro', 'settings',
'admin', 'setup', 'connector', 'protocol', 'plugin-sandbox', 'plugin-sandbox-privileged',
];
const API = new Set(API_GROUPS);
const AREAS = new Set(PAGE_AREAS);
const ASSET_FOLDERS = new Set(['branding', 'notification', 'demo', 'plugin-sandbox-assets']);
/** en, de, pt-BR, zh-TW, sr-Latn: what a locale prefix looks like. */
const LOCALE = /^[a-z]{2,3}(?:-[A-Za-z]{2,4})?$/;
interface Parsed {
group: string;
/** Path segments below the group's own. */
deeper: boolean;
}
function parse(rawPath: string, basePath: string): Parsed {
let path = rawPath.split(/[?#]/, 1)[0] || '/';
if (basePath && (path === basePath || path.startsWith(`${basePath}/`))) path = path.slice(basePath.length) || '/';
const segments = path.split('/').filter(Boolean);
if (segments.length === 0) return { group: 'page:home', deeper: false };
const [first, second] = segments;
if (first === '_next') {
return { group: second === 'static' ? 'static' : second === 'image' ? 'image' : 'other', deeper: false };
}
if (first === 'api') {
return { group: second && API.has(second) ? `api:${second}` : 'api:other', deeper: segments.length > 2 };
}
if ((segments.length === 1 && first.includes('.')) || ASSET_FOLDERS.has(first)) return { group: 'asset', deeper: false };
const rest = LOCALE.test(first) && !AREAS.has(first) ? segments.slice(1) : segments;
if (rest.length === 0) return { group: 'page:home', deeper: false };
return { group: AREAS.has(rest[0]) ? `page:${rest[0]}` : 'page:other', deeper: rest.length > 1 };
}
/**
* The group of a path: `api:<folder>`, `page:<area>`, `page:home`, `static`,
* `image`, `asset` or `other`. `basePath` is removed first.
*/
export function routeGroup(rawPath: string, basePath = ''): string {
return parse(rawPath, basePath).group;
}
/**
* A path for an error report: the group as a path, and `/…` when the path
* went deeper. `/de/mail/folder/Projects%20ACME/abc` -> `/mail/…`.
*/
export function scrubRoutePath(rawPath: string, basePath = ''): string {
const { group, deeper } = parse(rawPath, basePath);
const base = group === 'page:home' ? '/'
: group.startsWith('api:') ? `/api/${group.slice(4)}`
: group.startsWith('page:') ? `/${group.slice(5)}`
: `/${group}`;
return deeper ? `${base}/…` : base;
}
@@ -35,6 +35,7 @@ export const SERVER_STATE_ALLOWLIST: AllowedState[] = [
{ site: 'app/api/favicon/route.ts#cache', class: 'cache', why: 'sender icons from a public service, 14 days' },
{ site: 'app/api/favicon/route.ts#negativeCache', class: 'cache', why: 'sender icons that failed, 1 day' },
{ site: 'app/api/ready/route.ts#last', class: 'cache', why: 'the last store ping, 1 s, so probes can\'t flood the store' },
{ site: 'lib/error-reporting/debug-ids.ts#cache', class: 'per-process', why: 'debug ids of the bundle files this process loaded, read from globalThis' },
{ site: 'lib/admin/directory-keys.ts#keyringCache', class: 'cache', why: 'extension directory keys, 10 min; the anti-rollback record is in the store' },
{ site: 'lib/admin/paths.ts#cachedReadOnly', class: 'per-process', why: 'parsed ADMIN_CONFIG_READONLY env' },
{ site: 'lib/admin/plugin-approvals.ts#maxPackageBytes', class: 'per-process', why: 'a limit tests can lower' },
+6 -5
View File
@@ -13,15 +13,16 @@ export function getDir(): string {
}
/**
* Telemetry's documents (#1066): `state.json`, `logins.json` and the
* instance id `.telemetry-id`. Local mode: today's files in the telemetry
* dir. Shared mode: one copy for the whole deployment, so every replica
* reports as the same instance with the same counts.
* Telemetry's documents (#1066): `state.json`, `logins.json`, the instance
* id `.telemetry-id` and the per-day error counts `errors.json`
* (lib/error-reporting/aggregate.ts). Local mode: today's files in the
* telemetry dir. Shared mode: one copy for the whole deployment, so every
* replica reports as the same instance with the same counts.
*/
export const TELEMETRY_COLLECTION: CollectionOptions = {
local: {
dir: () => getDir(),
idOf: (relative) => (['state.json', 'logins.json', '.telemetry-id'].includes(relative) ? relative : null),
idOf: (relative) => (['state.json', 'logins.json', '.telemetry-id', 'errors.json'].includes(relative) ? relative : null),
},
};
+6
View File
@@ -4,6 +4,8 @@ import { DEFAULT_POLICY, DEFAULT_THEME_POLICY, POLICY_DOMAINS_HEADER, POLICY_SCO
import { apiFetch } from '@/lib/browser-navigation';
import { IS_LITE, IS_LITE_STALWART, LITE_POLICY_PATH, withLiteBuildId } from '@/lib/lite';
import { applyLitePolicy } from '@/lib/lite-config';
import { configureErrorReporting } from '@/lib/error-reporting/client';
import { liteErrorReporting } from '@/lib/error-reporting/lite';
import { resolvePolicy, type PolicySource, type PolicySubject } from '@/lib/admin/policy-scopes';
import { DEFAULT_ORGANISATION_POLICY, type OrganisationPolicy } from '@/lib/admin/organisation-policy';
@@ -115,11 +117,15 @@ export const usePolicyStore = create<PolicyState>()((set, get) => ({
const partial = res.headers?.get?.(POLICY_SCOPE_HEADER) === 'public';
const domainsHeader = res.headers?.get?.(POLICY_DOMAINS_HEADER);
const scopeDomains = typeof domainsHeader === 'string' ? domainsHeader.split(',').filter(Boolean) : null;
// Lite has no /api/config: error reporting is set in policy.json.
if (IS_LITE) configureErrorReporting(liteErrorReporting(data));
set({ ...resolved(IS_LITE ? applyLitePolicy(data) : data, get().subject), loaded: true, partial, scopeDomains });
} else {
if (IS_LITE) configureErrorReporting(null);
set({ ...(fallback.policy ? resolved(fallback.policy, get().subject) : {}), loaded: true });
}
} catch {
if (IS_LITE && request === policyRequest) configureErrorReporting(null);
if (request === policyRequest) set({ ...(fallback.policy ? resolved(fallback.policy, get().subject) : {}), loaded: true });
}
},