mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 16:16:59 +00:00
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:
@@ -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 />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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) });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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('|'));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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); });
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user