Appearance
Sentry (error & crash reporting)
Platform-wide error/crash reporting and diagnostics. This closes the former top deferred observability risk (CLAUDE.md). It sits alongside — not replacing — the always-on structured EF JSON logs and Cloudflare Web-Vitals.
Design principle: app code never touches a Sentry SDK. Everything reports through the @qrsetu/observability seam — a pure-leaf contract with a pluggable per-surface sink (the same pattern as @qrsetu/analytics). The heavy native SDK stays out of the shared dependency graph (ADR-0012 package purity) and out of every bundle until it is wired.
Topology
| Surface | SDK | Wired in | Covers |
|---|---|---|---|
| Mobile product | @sentry/react-native | apps/mobile/src/lib/observability.ts + root _layout.tsx | Android native + iOS native + Web PWA (RNW) — one SDK, parity by construction |
| DOM web app | @sentry/react | adapter ready; bootstraps when apps/web is scaffolded | Public cards + admin |
| Edge Functions | @sentry/deno | supabase/functions/_shared/observability.ts → response.ts err() | Server 5xx faults |
The seam (@qrsetu/observability)
Pure TS, zero deps. API: captureException(err, ctx), captureMessage(msg, ctx), setUser({ id }), addBreadcrumb({ message, data }), plus setObservabilitySink(sink) and the scrubPii / scrubString helpers. Until a sink is installed, capture logs in dev and no-ops in prod. Capture calls never throw — observability must never break the app.
ts
import { captureException, setUser } from '@qrsetu/observability';
captureException(err, { tags: { feature: 'onboarding' }, extra: { step: 'slug' } });
setUser({ id: profileId }); // opaque id only — never email/phoneGuarantees (non-negotiables baked in)
- No-op until provisioned — no DSN ⇒
Sentry.initis skipped (mobile) and the SDK is never imported (EF). Dev, jest/Deno tests, and un-provisioned builds never phone home. - PII / GDPR —
sendDefaultPii: false+ ascrubPiibeforeSend/beforeBreadcrumb(mobile) andmaskSensitiveData(EF). Emails, phone numbers, and sensitive-keyed values are redacted before send. The merchant's email/mobile (collected in onboarding) never leaves the device. Only 5xx server errors are captured; 4xx stay in the logs. - App-size budget — the mobile native SDK adds ~1–2 MB (arm64) / ~50–90 KB JS on the ~30–45 MB Hermes baseline — an approved trade-off (native crash/ANR/OOM capture is the highest-value mobile signal). Web/EF surfaces add zero app-size cost. See App-size policy and the mobile README budget note.
- Free-plan safe — tracing off by default (
tracesSampleRate: 0), Session Replay not enabled, environment-tagged events. Raise sampling deliberately only when profiling. - Performance — reporting is off the hot path (EF capture is fire-and-forget via
EdgeRuntime.waitUntil); init cost is a single guarded call.
Configuration (env)
| Var | Surface | Notes |
|---|---|---|
EXPO_PUBLIC_SENTRY_DSN | mobile | Public DSN, inlined at build. Blank = disabled. |
EXPO_PUBLIC_ENV | mobile | Event environment tag (development/uat/production). |
EXPO_PUBLIC_SENTRY_TRACES | mobile | Trace sample rate 0..1. Default 0. |
SENTRY_DSN | EF | Supabase project secret. Unset = disabled. |
SENTRY_AUTH_TOKEN / SENTRY_ORG / SENTRY_PROJECT | CI build only | Source-map upload; never runtime, never committed. |
All documented in .env.example. Recommended Sentry-org layout (all free-tier): separate projects per platform (qrsetu-mobile, qrsetu-web, qrsetu-edge), environment tag per project.
Source-map upload is switched OFF until Sentry is provisioned
app.json configures the plugin as ["@sentry/react-native/expo", { "disableAutoUpload": true }].
Why it is not merely unset: the config plugin injects the Upload Debug Symbols to Sentry Xcode build phase (and the Android equivalent) unconditionally — getSentryProperties() always returns a properties string, falling back to SENTRY_ORG/SENTRY_PROJECT env vars, so there is no "no org configured, skip it" path. Debug builds tolerate the resulting failure; Release builds do not, and a local iOS Release build died with:
error: sentry-cli - error: An organization ID or slug is required
(provide with --org, set SENTRY_ORG, or use an org-scoped auth token)
CommandError: Failed to build iOS project. "xcodebuild" exited with error code 65.Since no Sentry org exists yet, uploading is impossible and failing the build over it is pure obstruction. disableAutoUpload: true bakes export SENTRY_DISABLE_AUTO_UPLOAD=true into the generated native build phase on every prebuild, on every machine — deterministic, unlike a per-developer shell export. It disables upload only; runtime crash reporting is unaffected (that is gated separately by the DSN).
Reverse this when you provision Sentry — it is the step that makes traces readable
Without source maps, production stack traces point into minified Hermes bytecode and are close to useless. When the Sentry org exists: set organization/project in the plugin config, supply SENTRY_AUTH_TOKEN as a CI secret (never committed, never in app.json), and remove disableAutoUpload — or set it to false for local builds only via CI-vs-local config. Until then, treat unreadable release traces as expected, not as a Sentry bug.
For a one-off local Release build without touching config, the same flag works as an env var: SENTRY_DISABLE_AUTO_UPLOAD=true npx expo run:ios --device --configuration Release.
Developer workflow
- Report via
@qrsetu/observabilityat the call site — never import a Sentry SDK outside the per-surface adapter file. - Local dev — leave the DSN blank; captures log to the console via the no-op sink.
- Provision — create the Sentry project(s), set the DSN(s) as env/secrets per environment.
- Readable stack traces — set
SENTRY_AUTH_TOKEN/SENTRY_ORG/SENTRY_PROJECTas CI secrets so release builds upload source maps (mobile: the@sentry/react-native/expoconfig plugin uploads oneas build/ prebuilt release; a Metro-serializer wrapper + Hermes source-map upload is the enablement step done with the first release build). - Promote the
SENTRY_DSNsecret to both Supabase projects (Dev then Prod) per the promotion runbook — Dev/UAT does not self-sync.
Testing
- Package (
@qrsetu/observability) — scrub + routing + throw-safety, run from the mobile jest suite (apps/mobile/src/lib/__tests__/observability-contract.test.ts). - Mobile adapter — DSN gate + PII-safe init config + routing, with
@sentry/react-nativemocked (…/observability.test.ts). - EF —
captureServerExceptionno-op +err()5xx/4xx safety without a DSN (supabase/functions/_shared/tests/observability.test.ts). The real send/flush path is verified in the deployed smoke, not unit tests (hermetic: no network).
Parity status
Structural parity by construction — one @sentry/react-native wiring serves Android native, iOS native, and Web PWA (RNW). Verified: Web (RNW export bundles cleanly). Deferred: Android-native + iOS-PWA runtime verification lands with the next device build; DOM web-app adapter bootstraps with apps/web. Tracked under the observability QRS-### rows.
See Supabase · Cloudflare · Testing Strategy · Backend / Edge Functions.