Skip to content

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

SurfaceSDKWired inCovers
Mobile product@sentry/react-nativeapps/mobile/src/lib/observability.ts + root _layout.tsxAndroid native + iOS native + Web PWA (RNW) — one SDK, parity by construction
DOM web app@sentry/reactadapter ready; bootstraps when apps/web is scaffoldedPublic cards + admin
Edge Functions@sentry/denosupabase/functions/_shared/observability.tsresponse.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/phone

Guarantees (non-negotiables baked in)

  • No-op until provisioned — no DSN ⇒ Sentry.init is skipped (mobile) and the SDK is never imported (EF). Dev, jest/Deno tests, and un-provisioned builds never phone home.
  • PII / GDPRsendDefaultPii: false + a scrubPii beforeSend/beforeBreadcrumb (mobile) and maskSensitiveData (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)

VarSurfaceNotes
EXPO_PUBLIC_SENTRY_DSNmobilePublic DSN, inlined at build. Blank = disabled.
EXPO_PUBLIC_ENVmobileEvent environment tag (development/uat/production).
EXPO_PUBLIC_SENTRY_TRACESmobileTrace sample rate 0..1. Default 0.
SENTRY_DSNEFSupabase project secret. Unset = disabled.
SENTRY_AUTH_TOKEN / SENTRY_ORG / SENTRY_PROJECTCI build onlySource-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) unconditionallygetSentryProperties() 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

  1. Report via @qrsetu/observability at the call site — never import a Sentry SDK outside the per-surface adapter file.
  2. Local dev — leave the DSN blank; captures log to the console via the no-op sink.
  3. Provision — create the Sentry project(s), set the DSN(s) as env/secrets per environment.
  4. Readable stack traces — set SENTRY_AUTH_TOKEN/SENTRY_ORG/SENTRY_PROJECT as CI secrets so release builds upload source maps (mobile: the @sentry/react-native/expo config plugin uploads on eas build / prebuilt release; a Metro-serializer wrapper + Hermes source-map upload is the enablement step done with the first release build).
  5. Promote the SENTRY_DSN secret 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-native mocked (…/observability.test.ts).
  • EFcaptureServerException no-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.