From cba1109444da94d4c41c91aa1cb7b17e081ea4ca Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:50:19 +0100 Subject: [PATCH] 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 --- app/(main)/[locale]/error.tsx | 2 + app/(main)/admin/[[...segments]]/page.tsx | 2 + app/(main)/admin/_pages/monitoring.tsx | 175 +++++++++++++ app/(main)/layout.tsx | 2 + app/api/admin/monitoring/route.ts | 30 +++ app/api/config/route.ts | 4 + app/api/errors/__tests__/route.test.ts | 89 +++++++ app/api/errors/route.ts | 70 +++++ app/global-error.tsx | 6 + components/admin/admin-shell.tsx | 2 + components/error/error-boundary.tsx | 5 + components/error/error-reporting-root.tsx | 30 +++ components/icons/index.ts | 1 + hooks/use-config.ts | 5 + instrumentation.node.ts | 46 ++-- instrumentation.ts | 14 + lib/__tests__/config-validation.test.ts | 15 ++ lib/__tests__/jmap-error-reporting.test.ts | 58 +++++ lib/__tests__/route-groups.test.ts | 21 ++ .../reachable-hosts-observability.test.ts | 21 ++ lib/admin/admin-nav.ts | 12 + lib/admin/audit.ts | 4 + lib/admin/outgoing-hosts-registry.ts | 3 +- lib/admin/reachable-hosts.ts | 2 + lib/admin/types.ts | 6 + lib/config-validation.ts | 8 + .../__tests__/aggregate.test.ts | 34 +++ lib/error-reporting/__tests__/client.test.ts | 120 +++++++++ .../__tests__/envelope.test.ts | 118 +++++++++ lib/error-reporting/__tests__/lite.test.ts | 21 ++ lib/error-reporting/__tests__/scrub.test.ts | 102 ++++++++ lib/error-reporting/__tests__/server.test.ts | 101 ++++++++ lib/error-reporting/aggregate.ts | 179 +++++++++++++ lib/error-reporting/client.ts | 244 ++++++++++++++++++ lib/error-reporting/debug-ids.ts | 47 ++++ lib/error-reporting/envelope.ts | 149 +++++++++++ lib/error-reporting/fingerprint.ts | 34 +++ lib/error-reporting/limiter.ts | 81 ++++++ lib/error-reporting/lite.ts | 22 ++ lib/error-reporting/scrub.ts | 126 +++++++++ lib/error-reporting/server.ts | 132 ++++++++++ lib/error-reporting/settings.ts | 53 ++++ lib/error-reporting/stack.ts | 57 ++++ lib/jmap/client.ts | 33 ++- lib/logger.ts | 31 +++ lib/route-groups.ts | 75 ++++++ lib/shared-store/server-state-allowlist.ts | 1 + lib/telemetry/state.ts | 11 +- stores/policy-store.ts | 6 + 49 files changed, 2384 insertions(+), 26 deletions(-) create mode 100644 app/(main)/admin/_pages/monitoring.tsx create mode 100644 app/api/admin/monitoring/route.ts create mode 100644 app/api/errors/__tests__/route.test.ts create mode 100644 app/api/errors/route.ts create mode 100644 components/error/error-reporting-root.tsx create mode 100644 lib/__tests__/jmap-error-reporting.test.ts create mode 100644 lib/__tests__/route-groups.test.ts create mode 100644 lib/admin/__tests__/reachable-hosts-observability.test.ts create mode 100644 lib/error-reporting/__tests__/aggregate.test.ts create mode 100644 lib/error-reporting/__tests__/client.test.ts create mode 100644 lib/error-reporting/__tests__/envelope.test.ts create mode 100644 lib/error-reporting/__tests__/lite.test.ts create mode 100644 lib/error-reporting/__tests__/scrub.test.ts create mode 100644 lib/error-reporting/__tests__/server.test.ts create mode 100644 lib/error-reporting/aggregate.ts create mode 100644 lib/error-reporting/client.ts create mode 100644 lib/error-reporting/debug-ids.ts create mode 100644 lib/error-reporting/envelope.ts create mode 100644 lib/error-reporting/fingerprint.ts create mode 100644 lib/error-reporting/limiter.ts create mode 100644 lib/error-reporting/lite.ts create mode 100644 lib/error-reporting/scrub.ts create mode 100644 lib/error-reporting/server.ts create mode 100644 lib/error-reporting/settings.ts create mode 100644 lib/error-reporting/stack.ts create mode 100644 lib/route-groups.ts diff --git a/app/(main)/[locale]/error.tsx b/app/(main)/[locale]/error.tsx index f25e2ad46..421c4be6c 100644 --- a/app/(main)/[locale]/error.tsx +++ b/app/(main)/[locale]/error.tsx @@ -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 ( diff --git a/app/(main)/admin/[[...segments]]/page.tsx b/app/(main)/admin/[[...segments]]/page.tsx index 4d71528bc..0c28af644 100644 --- a/app/(main)/admin/[[...segments]]/page.tsx +++ b/app/(main)/admin/[[...segments]]/page.tsx @@ -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 ? : ; case 'version': return ; case 'telemetry': return ; + case 'monitoring': return ; case 'logs': return ; } } diff --git a/app/(main)/admin/_pages/monitoring.tsx b/app/(main)/admin/_pages/monitoring.tsx new file mode 100644 index 000000000..268740b69 --- /dev/null +++ b/app/(main)/admin/_pages/monitoring.tsx @@ -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>({}); + const [state, setState] = useState(null); + const [edits, setEdits] = useState>({}); + 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 ( +
+ +
+ Loading +
+
+ ); + } + + const today = state?.errorsToday; + + return ( +
+ +
+ + + + + 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. What is sent + + + + + + + + {!state?.collecting ? ( + + 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. + + ) : !today || today.total === 0 ? ( +

No errors so far today (UTC).

+ ) : ( +
+

{today.total} errors today (UTC), most frequent first.

+
    + {today.top.map((entry) => ( +
  • +
    +
    {entry.name}{entry.code ? `: ${entry.code}` : ''}
    +
    + {entry.frames[0] ? `${entry.frames[0].path}:${entry.frames[0].line ?? ''}:${entry.frames[0].column ?? ''}` : 'no stack'} + {' · '}{entry.runtime}{' · '}{entry.version} +
    +
    + {entry.count} +
  • + ))} +
+
+ )} +
+
+ + setEdits({})} + note="Browsers pick it up on their next page load" + /> +
+ ); +} diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index 96f57ab7c..208656678 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -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({ {!themeColorConfigured && } + {children} diff --git a/app/api/admin/monitoring/route.ts b/app/api/admin/monitoring/route.ts new file mode 100644 index 000000000..a6f71b387 --- /dev/null +++ b/app/api/admin/monitoring/route.ts @@ -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 }); + } +} diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 6e8208834..de9521a46 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -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('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 diff --git a/app/api/errors/__tests__/route.test.ts b/app/api/errors/__tests__/route.test.ts new file mode 100644 index 000000000..b6bddfa02 --- /dev/null +++ b/app/api/errors/__tests__/route.test.ts @@ -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 = {}; +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); + }); +}); diff --git a/app/api/errors/route.ts b/app/api/errors/route.ts new file mode 100644 index 000000000..7e283f260 --- /dev/null +++ b/app/api/errors/route.ts @@ -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; + 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; + } 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 }); +} diff --git a/app/global-error.tsx b/app/global-error.tsx index b45178af5..3801a6475 100644 --- a/app/global-error.tsx +++ b/app/global-error.tsx @@ -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 ( diff --git a/components/admin/admin-shell.tsx b/components/admin/admin-shell.tsx index 8a5074ba3..3008828f8 100644 --- a/components/admin/admin-shell.tsx +++ b/components/admin/admin-shell.tsx @@ -7,6 +7,7 @@ import { ArrowLeft, ArrowUpCircle, Activity, + HeartRateMonitor, Building2, ChevronRight, KeyRound, @@ -59,6 +60,7 @@ const PAGE_ICONS: Record = { marketplace: Store, version: ArrowUpCircle, telemetry: Activity, + monitoring: HeartRateMonitor, logs: ScrollText, }; diff --git a/components/error/error-boundary.tsx b/components/error/error-boundary.tsx index cd088d3b5..b53e476b0 100644 --- a/components/error/error-boundary.tsx +++ b/components/error/error-boundary.tsx @@ -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); } diff --git a/components/error/error-reporting-root.tsx b/components/error/error-reporting-root.tsx new file mode 100644 index 000000000..9f74dfd44 --- /dev/null +++ b/components/error/error-reporting-root.tsx @@ -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; +} diff --git a/components/icons/index.ts b/components/icons/index.ts index a110750dd..b91c959b7 100644 --- a/components/icons/index.ts +++ b/components/icons/index.ts @@ -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, diff --git a/hooks/use-config.ts b/hooks/use-config.ts index 77329854a..d54a208d7 100644 --- a/hooks/use-config.ts +++ b/hooks/use-config.ts @@ -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 { .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; diff --git a/instrumentation.node.ts b/instrumentation.node.ts index 605628441..94f50b00b 100644 --- a/instrumentation.node.ts +++ b/instrumentation.node.ts @@ -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 { 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://:${port}/setup#token=${token}`); - console.info(" Token expires in 1 hour. Restart the container to reissue."); - console.info("=============================================================="); - console.info(""); + const open = `http://:${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) }); }); diff --git a/instrumentation.ts b/instrumentation.ts index 41630756b..a11419dd4 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -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); +}; diff --git a/lib/__tests__/config-validation.test.ts b/lib/__tests__/config-validation.test.ts index b2737ee7e..626e575ce 100644 --- a/lib/__tests__/config-validation.test.ts +++ b/lib/__tests__/config-validation.test.ts @@ -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); + }); +}); diff --git a/lib/__tests__/jmap-error-reporting.test.ts b/lib/__tests__/jmap-error-reporting.test.ts new file mode 100644 index 000000000..6749e7083 --- /dev/null +++ b/lib/__tests__/jmap-error-reporting.test.ts @@ -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]; +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 }, 'authenticatedFetch').mockResolvedValue(response); + const request = (calls: Call[]) => (client as unknown as { request: (c: Call[]) => Promise }).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' }]); + }); +}); diff --git a/lib/__tests__/route-groups.test.ts b/lib/__tests__/route-groups.test.ts new file mode 100644 index 000000000..fa54b744a --- /dev/null +++ b/lib/__tests__/route-groups.test.ts @@ -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); + }); +}); diff --git a/lib/admin/__tests__/reachable-hosts-observability.test.ts b/lib/admin/__tests__/reachable-hosts-observability.test.ts new file mode 100644 index 000000000..dd4ca025a --- /dev/null +++ b/lib/admin/__tests__/reachable-hosts-observability.test.ts @@ -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, env: Record = {}) { + 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']); + }); +}); diff --git a/lib/admin/admin-nav.ts b/lib/admin/admin-nav.ts index 5b336e453..8d3d938b5 100644 --- a/lib/admin/admin-nav.ts +++ b/lib/admin/admin-nav.ts @@ -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', diff --git a/lib/admin/audit.ts b/lib/admin/audit.ts index 0c4e2b6ed..572def9cd 100644 --- a/lib/admin/audit.ts +++ b/lib/admin/audit.ts @@ -89,6 +89,10 @@ export async function auditLog(action: string, detail: Record, 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. diff --git a/lib/admin/outgoing-hosts-registry.ts b/lib/admin/outgoing-hosts-registry.ts index 24fa214d7..178ae3401 100644 --- a/lib/admin/outgoing-hosts-registry.ts +++ b/lib/admin/outgoing-hosts-registry.ts @@ -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'] }, diff --git a/lib/admin/reachable-hosts.ts b/lib/admin/reachable-hosts.ts index cfae6020d..8d848815b 100644 --- a/lib/admin/reachable-hosts.ts +++ b/lib/admin/reachable-hosts.ts @@ -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. diff --git a/lib/admin/types.ts b/lib/admin/types.ts index fb35d3954..521fb40dc 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -363,6 +363,12 @@ export const CONFIG_ENV_MAP: Record 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; /** diff --git a/lib/error-reporting/__tests__/aggregate.test.ts b/lib/error-reporting/__tests__/aggregate.test.ts new file mode 100644 index 000000000..a9dfaf577 --- /dev/null +++ b/lib/error-reporting/__tests__/aggregate.test.ts @@ -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 = {}; + 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']); + }); +}); diff --git a/lib/error-reporting/__tests__/client.test.ts b/lib/error-reporting/__tests__/client.test.ts new file mode 100644 index 000000000..e4fc2709e --- /dev/null +++ b/lib/error-reporting/__tests__/client.test.ts @@ -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; + +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'], + ]); + }); +}); diff --git a/lib/error-reporting/__tests__/envelope.test.ts b/lib/error-reporting/__tests__/envelope.test.ts new file mode 100644 index 000000000..41d45af57 --- /dev/null +++ b/lib/error-reporting/__tests__/envelope.test.ts @@ -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)._debugIds; + delete (globalThis as Record)._sentryDebugIds; + resetDebugIdCache(); + }); + + it('maps each registered chunk to its id, from Turbopack and sentry-cli alike', () => { + (globalThis as Record)._debugIds = { + 'Error\n at https://mail.example.org/_next/static/chunks/1.js:1:10': 'bc67f7db-7f3b-7be2-6f94-966cd7825cc2', + }; + (globalThis as Record)._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); + }); +}); diff --git a/lib/error-reporting/__tests__/lite.test.ts b/lib/error-reporting/__tests__/lite.test.ts new file mode 100644 index 000000000..b73000005 --- /dev/null +++ b/lib/error-reporting/__tests__/lite.test.ts @@ -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 }); + }); +}); diff --git a/lib/error-reporting/__tests__/scrub.test.ts b/lib/error-reporting/__tests__/scrub.test.ts new file mode 100644 index 000000000..65be662c0 --- /dev/null +++ b/lib/error-reporting/__tests__/scrub.test.ts @@ -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'); + }); +}); diff --git a/lib/error-reporting/__tests__/server.test.ts b/lib/error-reporting/__tests__/server.test.ts new file mode 100644 index 000000000..e17afb52d --- /dev/null +++ b/lib/error-reporting/__tests__/server.test.ts @@ -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 = {}; +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; + +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']); + }); +}); diff --git a/lib/error-reporting/aggregate.ts b/lib/error-reporting/aggregate.ts new file mode 100644 index 000000000..5d933ce34 --- /dev/null +++ b/lib/error-reporting/aggregate.ts @@ -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>; + +export interface Recordable { + name: string; + frames: Frame[]; + code?: string; +} + +interface AggregateState { + pending: Aggregate; + timer: ReturnType | null; + flushing: Promise | 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 { + 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 { + 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]; +} diff --git a/lib/error-reporting/client.ts b/lib/error-reporting/client.ts new file mode 100644 index 000000000..c329d6b06 --- /dev/null +++ b/lib/error-reporting/client.ts @@ -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]; +} diff --git a/lib/error-reporting/debug-ids.ts b/lib/error-reporting/debug-ids.ts new file mode 100644 index 000000000..88ae156d9 --- /dev/null +++ b/lib/error-reporting/debug-ids.ts @@ -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] = ""`. 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 } | null = null; + +function registered(): Array<[string, string]> { + const g = globalThis as Record; + 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)) { + 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 { + const entries = registered(); + if (cache && cache.size === entries.length) return cache.ids; + const ids = new Map(); + 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; +} diff --git a/lib/error-reporting/envelope.ts b/lib/error-reporting/envelope.ts new file mode 100644 index 000000000..aaad77e1b --- /dev/null +++ b/lib/error-reporting/envelope.ts @@ -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://@host[:port][/prefix]/`, 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; + tags?: Record; +} + +/** The event payload. Frames go oldest first, as the format wants. */ +export function buildEvent(report: ErrorReport, ctx: EventContext): Record { + 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 { + 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(''); +} + +/** `+`, 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'; +} diff --git a/lib/error-reporting/fingerprint.ts b/lib/error-reporting/fingerprint.ts new file mode 100644 index 000000000..f57a1e537 --- /dev/null +++ b/lib/error-reporting/fingerprint.ts @@ -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('|')); +} diff --git a/lib/error-reporting/limiter.ts b/lib/error-reporting/limiter.ts new file mode 100644 index 000000000..69bbc332c --- /dev/null +++ b/lib/error-reporting/limiter.ts @@ -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 | null; + storageKey?: string; + now?: () => number; +} + +interface State { + total: number; + sent: Record; +} + +const MAX_REMEMBERED = 500; + +export class ReportLimiter { + private readonly options: Required> & { 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; + if (typeof parsed.total === 'number' && parsed.sent && typeof parsed.sent === 'object') { + this.state = { total: parsed.total, sent: parsed.sent as Record }; + } + } 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; + } +} diff --git a/lib/error-reporting/lite.ts b/lib/error-reporting/lite.ts new file mode 100644 index 000000000..f754f1cd5 --- /dev/null +++ b/lib/error-reporting/lite.ts @@ -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://@host/", "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).errorReporting : undefined; + if (!raw || typeof raw !== 'object') return null; + const input = raw as Record; + 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 }; +} diff --git a/lib/error-reporting/scrub.ts b/lib/error-reporting/scrub.ts new file mode 100644 index 000000000..809d633ef --- /dev/null +++ b/lib/error-reporting/scrub.ts @@ -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 - ": 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'; +} diff --git a/lib/error-reporting/server.ts b/lib/error-reporting/server.ts new file mode 100644 index 000000000..d94e728c3 --- /dev/null +++ b/lib/error-reporting/server.ts @@ -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 { + 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 { + 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 { + 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 | undefined, + stack: string | undefined, + skip = 2, +): Promise { + 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 { + await configManager.ensureLoaded(); + const { setErrorSink } = await import('@/lib/logger'); + setErrorSink((message, extra, stack) => { void captureLoggedError(message, extra, stack); }); +} diff --git a/lib/error-reporting/settings.ts b/lib/error-reporting/settings.ts new file mode 100644 index 000000000..c29a31470 --- /dev/null +++ b/lib/error-reporting/settings.ts @@ -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('errorReportingDsn', ''); + return { + dsn: parseDsn(raw) ? raw.trim() : '', + sampleRate: parseSampleRate(configManager.get('errorReportingSampleRate', '1')), + environment: parseEnvironment(configManager.get('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 { + await configManager.ensureLoaded(); + return !!errorReportingSettings().dsn; +} + +/** What /api/config hands the browser. */ +export async function browserErrorSettings(): Promise { + const settings = errorReportingSettings(); + return { ...settings, collect: await shouldCollectErrors() }; +} diff --git a/lib/error-reporting/stack.ts b/lib/error-reporting/stack.ts new file mode 100644 index 000000000..1c228eb99 --- /dev/null +++ b/lib/error-reporting/stack.ts @@ -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; + 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; +} diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 285bd9569..79cfb94a9 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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; } } diff --git a/lib/logger.ts b/lib/logger.ts index e704e87d1..b3c87994b 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -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 | 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): 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()) { diff --git a/lib/route-groups.ts b/lib/route-groups.ts new file mode 100644 index 000000000..e10e6c092 --- /dev/null +++ b/lib/route-groups.ts @@ -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:`, `page:`, `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; +} diff --git a/lib/shared-store/server-state-allowlist.ts b/lib/shared-store/server-state-allowlist.ts index 303984d17..2384bf964 100644 --- a/lib/shared-store/server-state-allowlist.ts +++ b/lib/shared-store/server-state-allowlist.ts @@ -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' }, diff --git a/lib/telemetry/state.ts b/lib/telemetry/state.ts index 9a82f2c2a..22a6e3645 100644 --- a/lib/telemetry/state.ts +++ b/lib/telemetry/state.ts @@ -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), }, }; diff --git a/stores/policy-store.ts b/stores/policy-store.ts index 6c0a6945a..b2e188573 100644 --- a/stores/policy-store.ts +++ b/stores/policy-store.ts @@ -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()((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 }); } },