ADR-0002: Frontend Observability & Error Handling
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
Context
Section titled “Context”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.
Decision
Section titled “Decision”New package: @cosella/observability
Section titled “New package: @cosella/observability”| Concern | Decision |
|---|---|
| Location | packages/observability/ |
| Dependencies | @cosella/ui, @cosella/domain — nothing else internal |
| Auth coupling | Forbidden. No import of @cosella/auth. Apps inject user/workspace via getContext callback |
| React version | Peer dep on React 19; boundary uses class component (React error boundaries have no hook equivalent) |
| Telemetry adapter | ErrorReporter 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 |
Layered defense
Section titled “Layered defense”Six layers, each handling a distinct failure class. Consumers opt into as many as they need.
| Layer | Catches | Surface |
|---|---|---|
| 1. Global listeners | uncaught errors, unhandled rejections | registerGlobalErrorHandlers() — one call in each main.tsx |
| 2. Root boundary | last-resort render crashes | <RootErrorBoundary> — full-screen reload fallback |
| 3. Route boundary | per-route failures | <RouteErrorBoundary> — TanStack Router’s errorComponent |
| 4. Feature boundary | isolated widget failures | <FeatureErrorBoundary> — inline <Alert variant="danger"> |
| 5. Query error surface | fetch/mutation failures (suspense) | TanStack Query throwOnError on suspense queries only |
| 6. Electron main process | native crashes (render-process-gone) | main.ts listener — auto-relaunch or dialog per window (see below) |
Error policy (Amendment A)
Section titled “Error policy (Amendment A)”- Suspense queries throw to the nearest
<FeatureErrorBoundary>. Boundaries reset via<QueryErrorResetBoundary>from@tanstack/react-queryso retry actually re-fetches. - Mutations never throw to boundaries. They surface via
onError→toast.error(...)per the data-layer contract. - Global
throwOnError: trueon the QueryClient is prohibited. It defeats the boundary/toast split by forcing every mutation into the boundary graph. Enable per-query when needed.
Realtime error policy (Amendment B)
Section titled “Realtime error policy (Amendment B)”Realtime disconnects are connection states, not errors:
connected— normalreconnecting— transient; UI shows a badge/spinnerdegraded— 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 policy (Amendment C)
Section titled “Breadcrumbs policy (Amendment C)”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.
Electron main-process crashes (v1)
Section titled “Electron main-process crashes (v1)”Handled in apps/desktop/electron/main.ts, keyed by which window crashed:
| Window | Behaviour |
|---|---|
| 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 call | Never 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.
Reporter adapter
Section titled “Reporter adapter”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.
DSN handling
Section titled “DSN handling”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 theVITE_prefix.
Context injection
Section titled “Context injection”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, }; },});Fallback UI affordances
Section titled “Fallback UI affordances”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.
Testing requirements
Section titled “Testing requirements”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:
| Test | Verifies |
|---|---|
| Redaction | Reporter strips tokens (Bearer / OAuth) and email addresses from fixture stack traces before send |
| Rate limiter | Under a hot loop firing N errors in <1 s, the reporter caps sends at the configured budget and drops the rest |
| Restart guard threshold | Guard 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 shape | captureError 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.
Package boundaries
Section titled “Package boundaries”Adds pkg-observability to tooling/eslint-config/boundaries.js:
observability → ui, domain onlyobservability ↛ auth, api-client, realtime, permissions, testingAny consumer importing observability is fine (apps, other packages). Observability itself must remain leaf-like.
Consequences
Section titled “Consequences”Enforced
Section titled “Enforced”- No
throwOnError: trueat the QueryClient level in any app. - No
try/catchswallowing errors in slice hooks without a reporter call. - Mutations must define
onErrorhandlers 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.
Deferred (out of scope for this ADR)
Section titled “Deferred (out of scope for this ADR)”- 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.logfor now. - Server-side correlation — client error IDs are not yet linked to backend traces. Revisit when the API grows past
/healthand/calls.
Rollout
Section titled “Rollout”One PR per surface, in this order:
- This PR — package + dashboard reference implementation
- Desktop — first, because it is the most crash-sensitive surface (live calls)
- Admin
- Marketing (with
noopReporter) - Docs (with
noopReporter)
Each surface PR includes:
- Wiring
registerGlobalErrorHandlers - Root boundary at the app root
- Route boundaries via
errorComponenton file routes (or equivalent for Astro) - Feature boundaries wrapping any Suspense query
- Sentry DSN via env var (
VITE_SENTRY_DSN), off when unset
ADR location convention
Section titled “ADR location convention”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.