Skip to content

ADR-0002: Frontend Observability & Error Handling

Accepted

ADR-0002: Frontend Observability & Error Handling

Section titled “ADR-0002: Frontend Observability & Error Handling”

Status: Accepted Date: 2026-07-15 Supersedes: None Related: ADR-0001: Monorepo Stack Selection

Cosella runs across five surfaces — dashboard, admin, marketing, docs, and an Electron desktop overlay used during live sales calls. A blank white page during a call is a revenue event, not a UX bug.

We need a system that:

  • Catches every uncaught render error, unhandled promise rejection, and native crash — no blank screens, ever.
  • Contains failures at the smallest useful boundary (a broken chart shouldn’t kill the shell).
  • Ships a themed fallback UI so the user always has a way forward.
  • Reports every error with enough context (workspace, route, breadcrumbs) to debug, without leaking PII.
  • Presents a single API across all five surfaces — one primitive, five consumers.
  • Handles Electron’s out-of-process crashes specially: the overlay window auto-relaunches silently; the main window uses the standard dialog flow; nothing blocks a live call with modal UI.
ConcernDecision
Locationpackages/observability/
Dependencies@cosella/ui, @cosella/domain — nothing else internal
Auth couplingForbidden. No import of @cosella/auth. Apps inject user/workspace via getContext callback
React versionPeer dep on React 19; boundary uses class component (React error boundaries have no hook equivalent)
Telemetry adapterErrorReporter interface. Ships with noopReporter. Sentry adapter is a thin wrapper in the same package
Browser SDK@sentry/react from the pnpm catalog, pinned to ^10.x (10.6+ current). ^9 is one major behind
Electron main SDK@sentry/electron not pinned in this PR. Verify current major + browser-SDK compatibility at the desktop PR

Six layers, each handling a distinct failure class. Consumers opt into as many as they need.

LayerCatchesSurface
1. Global listenersuncaught errors, unhandled rejectionsregisterGlobalErrorHandlers() — one call in each main.tsx
2. Root boundarylast-resort render crashes<RootErrorBoundary> — full-screen reload fallback
3. Route boundaryper-route failures<RouteErrorBoundary> — TanStack Router’s errorComponent
4. Feature boundaryisolated widget failures<FeatureErrorBoundary> — inline <Alert variant="danger">
5. Query error surfacefetch/mutation failures (suspense)TanStack Query throwOnError on suspense queries only
6. Electron main processnative crashes (render-process-gone)main.ts listener — auto-relaunch or dialog per window (see below)
  • Suspense queries throw to the nearest <FeatureErrorBoundary>. Boundaries reset via <QueryErrorResetBoundary> from @tanstack/react-query so retry actually re-fetches.
  • Mutations never throw to boundaries. They surface via onErrortoast.error(...) per the data-layer contract.
  • Global throwOnError: true on the QueryClient is prohibited. It defeats the boundary/toast split by forcing every mutation into the boundary graph. Enable per-query when needed.

Realtime disconnects are connection states, not errors:

  • connected — normal
  • reconnecting — transient; UI shows a badge/spinner
  • degraded — reconnect budget exhausted; UI shows an inline banner and pauses features that depend on live data

These states are rendered by the live-call UI. They are never thrown into an error boundary. Only terminal realtime failures (unrecoverable protocol errors, WsFactory throws) are reported as errors — and even then, they surface as an inline degraded state, not a full-screen fallback. The call keeps running.

Breadcrumbs are attached to every error report. What we record:

  • Route pathname + search
  • TanStack Query queryKey (the array only — not the resolved data)
  • Query state transitions (pending/success/error)
  • User-initiated navigations

What we never record:

  • Query response data
  • Mutation variables (may contain PII, deal amounts, contact info)
  • Transcript content, call audio metadata, or anything from the live-call state
  • Form field values
  • Auth tokens or session identifiers

Redact at breadcrumb-record time, not at send time. If a reporter ever receives a breadcrumb with disallowed content, that is a bug in the recorder, not the reporter.

Handled in apps/desktop/electron/main.ts, keyed by which window crashed:

WindowBehaviour
Overlay (frameless, always-on-top live-call)Silent auto-relaunch. Restart-loop guard: max 3 relaunches per 60 s. On limit hit, hide the overlay, show a tray notification, and degrade to non-overlay mode until user acts
Main window (dashboard shell inside desktop)Standard dialog: “Cosella crashed — reload or quit?”
Any window during an active callNever show a dialog. Fall through to silent-relaunch + tray notification, regardless of which window crashed

Active-call state comes from the renderer via IPC (window.cosella.setCallActive(bool)). Main tracks last known call state; if uncertain, assume active.

Guard-trip telemetry (required): every crash still reports one event per crash, and additionally a distinct overlay_restart_guard_tripped event is emitted once when the 3/60 s budget is exhausted. This separates “we’re seeing many crashes” from “we’ve now stopped trying to relaunch” on the dashboards.

packages/observability/src/reporter.ts
export interface ErrorReporter {
captureError(error: unknown, context: ErrorContext): void;
captureMessage(message: string, level: 'info' | 'warn' | 'error'): void;
addBreadcrumb(breadcrumb: Breadcrumb): void;
flush(timeoutMs?: number): Promise<void>;
}
  • Default: noopReporter — used by marketing, docs, tests, and any environment without a DSN.
  • Sentry: createSentryReporter({ dsn, environment, release }) — wraps @sentry/react (browser) and @sentry/electron (main process). Same interface both sides.
  • Adding another sink (PostHog, self-hosted) later means implementing ErrorReporter — no touches to call sites.

Configured per app via VITE_SENTRY_DSN. When unset, the reporter falls back to noopReporter and the app boots normally.

Sentry DSNs are public client keys — they identify the project, not authenticate a client. Safe to bundle. That said, VITE_* env vars in Vite are inlined into the client bundle at build time and are never a place for secrets — this variable happens to be safe by design, not by isolation. Any future observability env var (server-side auth tokens, private tunnels) must not use the VITE_ prefix.

registerGlobalErrorHandlers takes an app-provided getContext() closure. This is where auth/workspace/plan data enters observability without observability importing @cosella/auth:

registerGlobalErrorHandlers({
reporter: sentry,
getContext: () => {
const session = getSession();
const workspace = getCurrentWorkspace();
return {
userId: session?.user.id,
workspaceId: workspace?.id,
plan: workspace?.plan,
appVersion: __APP_VERSION__,
route: window.location.pathname,
};
},
});

Feature-level fallback is <Alert variant="danger"> with a “Retry” action button that calls the boundary reset function. Small, in-place, no chrome.

Route and root fallbacks are richer cards and must retain:

  • A themed icon + heading (“Something went wrong”)
  • A one-line human-readable summary of what failed
  • A “Try again” button (calls resetError)
  • The error ID (client-generated UUID, same one attached to the Sentry event)
  • A “Copy error ID” affordance for support tickets — one click writes the ID to the clipboard and toasts a confirmation

Error ID visibility is non-negotiable: without it, support tickets that say “the app crashed” cannot be correlated to the reported event.

Observability is business logic — not a visual primitive — so the CLAUDE.md/AGENTS.md rule applies: every function needs a test. Minimum tests required in this PR:

TestVerifies
RedactionReporter strips tokens (Bearer / OAuth) and email addresses from fixture stack traces before send
Rate limiterUnder a hot loop firing N errors in <1 s, the reporter caps sends at the configured budget and drops the rest
Restart guard thresholdGuard trips at 3 crashes in 60 s — not 2, not 4. Emits the overlay_restart_guard_tripped event once (deferred to the desktop PR since Electron main-process code lives there)
Boundary reset<ErrorBoundary> renders fallback on child throw, calls onError, and re-renders children after resetError()
Reporter context shapecaptureError receives the ErrorContext shape defined by the interface; extra keys are permitted, required keys are not omitted

Stories ship in addition for the fallback components (RootErrorBoundary, RouteErrorBoundary, FeatureErrorBoundary) — thrown-child stories with reset interaction. Stories do not replace tests.

Adds pkg-observability to tooling/eslint-config/boundaries.js:

observability → ui, domain only
observability ↛ auth, api-client, realtime, permissions, testing

Any consumer importing observability is fine (apps, other packages). Observability itself must remain leaf-like.

  • No throwOnError: true at the QueryClient level in any app.
  • No try/catch swallowing errors in slice hooks without a reporter call.
  • Mutations must define onError handlers that toast — the linter will not catch this; PR review does.
  • Breadcrumb recorders (query listener, route listener) live in the observability package; apps do not roll their own.
  • Reporter selection lives in each app’s main.tsx. There is no globally shared Sentry singleton.
  • Every app grows by ~40 KB (Sentry browser SDK) except marketing/docs which use noopReporter.
  • Every render error now blocks a network round-trip for context enrichment before the fallback UI paints. Mitigated by making the enrichment async and painting the fallback immediately.
  • Retry-loop guards in Electron add state to main.ts — we accept this in exchange for silent overlay recovery.
  • Session replay — big scope, dep weight, needs consent flow. Revisit when Sentry replay pricing is clear or a self-hosted option is picked.
  • i18n on fallback copy — English-only until the product ships in a second locale.
  • Log ingestion pipeline — errors go to Sentry; general logs are still console.log for now.
  • Server-side correlation — client error IDs are not yet linked to backend traces. Revisit when the API grows past /health and /calls.

One PR per surface, in this order:

  1. This PR — package + dashboard reference implementation
  2. Desktop — first, because it is the most crash-sensitive surface (live calls)
  3. Admin
  4. Marketing (with noopReporter)
  5. Docs (with noopReporter)

Each surface PR includes:

  • Wiring registerGlobalErrorHandlers
  • Root boundary at the app root
  • Route boundaries via errorComponent on file routes (or equivalent for Astro)
  • Feature boundaries wrapping any Suspense query
  • Sentry DSN via env var (VITE_SENTRY_DSN), off when unset

All ADRs live at apps/docs/src/content/docs/engineering/adr-NNNN.mdx (MDX, Starlight-rendered on the internal docs site). This ADR file lives there. Do not create docs/engineering/adr/ at the repo root — that would silently fork ADR discovery. CLAUDE.md and AGENTS.md both reference this location.