Observability¶
Client-side error tracking, breadcrumbs, sessions, and (light) performance tracing for the Expo app — iOS, Android, and web — using Sentry React Native (SDK ~6.x). Wired up alongside the backend Prometheus + OpenTelemetry stack so frontend and backend issues route through one Discord incident channel.
This page is the engineering reference for what we capture, how the DSN flows
in, the source-map pipeline, and the PII policy. For env-var details see
reference/env-vars.md.
What Sentry captures¶
| Signal | When it fires | Notes |
|---|---|---|
| Unhandled errors | Any unhandled JS exception in the React tree | Wrapped via Sentry.wrap(Layout) in app/_layout.tsx. |
| Manual exceptions | Sentry.captureException(err) in critical paths |
See "Adding captures" below. |
| Breadcrumbs — navigation | Every route change (pathname from expo-router) |
Effect lives in RootLayoutNav in app/_layout.tsx. |
| Breadcrumbs — HTTP | Every non-2xx response in utils/api.ts::handleResponse |
Logs status + path (no body). |
| Breadcrumbs — WebSocket | Chat WS close with code ≠ 1000 | Logs chat ID + close reason from services/chatService.ts. |
| Sessions | Auto, via enableAutoSessionTracking: true |
Powers Sentry's release-health dashboard (crash-free users / sessions). |
| Performance traces | 10% sample rate | Matches backend OTel sample rate so the two are statistically comparable. |
Source: frontend/utils/sentry.ts (initSentry) and the breadcrumb call
sites listed above.
How the DSN flows in¶
Sentry's DSN is a public identifier — it identifies the project to report to, not the customer. It's safe to bake into the JS bundle.
GCP Secret Manager Build process Bundle (production)
┌───────────────────────────┐ ┌──────────────────────────┐ ┌─────────────────────┐
│ tomoda-sentry-dsn │ ───gcloud──► │ build-with-secrets.sh │ ─────► │ process.env.EXPO_ │
│ tomoda-sentry-auth-token │ │ exports env, execs build │ bake │ PUBLIC_SENTRY_DSN │
└───────────────────────────┘ └──────────────────────────┘ └─────────────────────┘
GCP Secret Manager is the source of truth. The wrapper script frontend/scripts/build-with-secrets.sh pulls both values into env vars and execs the build (expo run:ios|android or expo export --platform web). No secret values touch disk; rotation in GCP SM takes effect on the next build automatically.
Local dev: leave EXPO_PUBLIC_SENTRY_DSN unset (or empty). initSentry() no-ops gracefully — Sentry.init is never called, Sentry.captureException is a silent function, and Sentry.wrap is a passthrough. The app runs fully without any Sentry account, which keeps new-contributor friction at zero.
Staging / prod: a gcloud auth login session that can secretAccessor on the two tomoda-sentry-* secrets is all the build needs. See Manual setup → Step 1 (Sentry) in the DevOps repo for the initial provisioning click path.
Source-map upload¶
Without source maps, every Sentry stack frame looks like index.android.bundle:1:347820 — useless. With them, Sentry symbolicates back to the original TypeScript line.
The pipeline differs slightly per platform:
Native (iOS + Android)¶
The @sentry/react-native/expo config plugin (registered in app.config.js) runs the upload step automatically during the native build. It picks up SENTRY_AUTH_TOKEN, SENTRY_ORG, and SENTRY_PROJECT from the environment — all set by build-with-secrets.sh before it execs expo run:*.
Web¶
The Expo web export does not auto-upload source maps. build-with-secrets.sh web runs the upload step explicitly via sentry-cli:
npx expo export --platform webproduces a minified bundle +.mapfiles indist/.npx sentry-cli releases new <release>registers the release (com.tomoda.app@<package.json version>).npx sentry-cli sourcemaps upload --release <release> --strip-prefix <frontend-dir> dist/uploads bundle + maps.npx sentry-cli releases finalize <release>marks the release ready to associate with events.
All three CLI calls run with SENTRY_AUTH_TOKEN / SENTRY_ORG / SENTRY_PROJECT already exported by the wrapper.
The auth token is a secret
SENTRY_AUTH_TOKEN grants write access to the Sentry project (uploads, releases). It is build-time only — never prefixed with EXPO_PUBLIC_, never committed, never logged. Stored in GCP SM as tomoda-sentry-auth-token; rotate via the Sentry UI + gcloud secrets versions add if leaked.
If the token is absent (or build-with-secrets.sh is bypassed and you run expo directly), the build still succeeds — you just get an artifact without source maps uploaded. Errors will still report, they'll just be hard to read until the next build with a valid token.
End-to-end traces (frontend → backend)¶
tracePropagationTargets in initSentry() lists the API origins (api.tomoda.life, api-dev.tomoda.life) that should receive W3C sentry-trace + baggage headers on every outgoing fetch. The backend's OTel pipeline (W3C TraceContext propagator) reads these headers and continues the trace, so a single trace ID in Tempo shows:
[frontend transaction "POST /api/v1/events"]
└─ [API handler span]
└─ [DB INSERT span]
└─ [GORM query]
To follow a trace from a Sentry event into Tempo:
- Open the Sentry issue, click the affected event.
- Trace section shows the trace ID — click the icon next to it to copy.
- In Grafana → Explore → Tempo → search by trace ID.
For this to work, both the frontend SDK and the backend OTel exporter need to sample the same trace ID. We use tracesSampleRate: 0.1 on both — the propagator passes the sample decision through, so once Sentry decides to sample, the backend inherits the decision.
Session Replay¶
Configured for error-only capture — replaysSessionSampleRate: 0, replaysOnErrorSampleRate: 1.0. Healthy sessions are never recorded (keeps Sentry's free-tier replay quota comfortable); any session where an exception fires gets a full replay attached to the issue.
In the Sentry UI, click into any Issue → Replays tab → watch the user's last 30s of interaction leading up to the crash. Privacy: text inputs are masked by default by the SDK; you can adjust per-input via data-sentry-mask / data-sentry-unmask attributes.
Adding a manual breadcrumb¶
Use breadcrumbs to leave a trail of what was happening before an error. Add them where the user is doing something interesting (state transition, optimistic UI update, retry, etc.):
import * as Sentry from '@sentry/react-native';
Sentry.addBreadcrumb({
category: 'event-creation', // your domain
level: 'info', // 'debug' | 'info' | 'warning' | 'error'
message: 'user submitted new event',
data: { event_id: id, has_location: !!loc }, // structured payload
});
Existing breadcrumb call sites — copy the pattern:
- Navigation:
app/_layout.tsx::RootLayoutNav(categorynavigation) - API failures:
utils/api.ts::handleResponse(categoryhttp) - WebSocket:
services/chatService.ts(categorywebsocket)
Capturing an exception¶
For errors that callers don't already throw to a screen, capture them explicitly so they appear in the Sentry Issues feed:
import * as Sentry from '@sentry/react-native';
try {
await refreshSomething();
} catch (err) {
Sentry.captureException(err);
// ... your existing recovery (toast, fallback, etc.)
}
When not to capture:
- Expected failures from third-party fetches (Klipy GIF search, link previews) — these fail often and aren't actionable.
- 4xx from input validation — the user sees the form error; Sentry doesn't need to know.
- Errors that already surface to the user via a thrown error reaching an error boundary — Sentry's auto-handler catches those.
When to capture (already wired in):
AuthContext::loadStoredAuth— silent AsyncStorage failure means the user is silently logged out.AuthContext::refreshAccessToken— silent refresh failure means an unexpected sign-out.AuthContext::refreshUser— silent failure means stale user data in memory.chatService::onmessageJSON parse — server sent malformed payload, always a bug.chatServicereconnect cap reached — covered indirectly via the WS-close breadcrumb + the error callback path.
PII policy¶
What we send to Sentry by default:
- User ID (UUID) — yes. Required to correlate reports across sessions.
- Email — yes. Already in many backend logs; consistent with existing privacy posture and useful for support flow ("the user emailing us about issue X is the same user reporting these crashes").
- IP address — yes (default Sentry behavior). Used for geo-tagging; can be scrubbed in the Sentry UI under Settings → Security & Privacy → Prevent storing of IP addresses.
- Stack traces — yes, with source maps attached so frames are readable.
What we do NOT send:
- Passwords, OAuth tokens, refresh tokens — never. Sentry's default scrubbers strip
Authorizationheaders; we also don't pass tokens into breadcrumbdatapayloads. - Message bodies (chat content, event descriptions) — never. Breadcrumbs only log metadata (status, path, IDs).
- Location coordinates — never directly. If a crash happens during a location-dependent flow, the breadcrumb trail might mention "user opened map," but no lat/lng is attached.
- Phone numbers — never.
If you add a new capture or breadcrumb, audit the data payload before
shipping. When in doubt, exclude.
Querying in Sentry UI¶
Common starting points:
| What you want | Where to look |
|---|---|
| Top crashing issues this week | Issues tab, sort by "Events" descending, filter is:unresolved age:-7d. |
| Crashes for a specific user | Issues tab, search user.email:foo@bar.com. |
| Release health (% crash-free) | Releases tab — driven by enableAutoSessionTracking. |
| Performance | Performance tab — sampled at 10%, so low-volume routes may have sparse data. |
| All events from one device | Issues → drill into any event → Sessions tab on the user's profile. |
Useful filters:
release:0.0.8— narrow to a specific app version (usesEXPO_PUBLIC_APP_VERSION).environment:prod— exclude dev / staging.dist:42— narrow to a specific EAS build number when you have version-collision noise.
Alert routing to Discord¶
Sentry alerts flow to the same Discord webhook the backend Prometheus/Alertmanager uses, giving us one unified incident channel.
This is configured in the Sentry UI, not in code. In the Sentry project's Settings → Integrations → Discord, add a webhook pointed at the existing Discord incidents channel. Then in Alerts → Create Alert Rule, route new issues / regressions to that webhook with whatever threshold you want (typical: "alert when an issue affects > N users in 1h").
Not configured here:
- The Discord webhook URL itself — lives in the DevOps repo's secret store, same one the backend Alertmanager uses.
- Alert thresholds, on-call rotation, escalation — set up in the Sentry UI.
If you need to set this up from scratch on a new Sentry project, file an issue tagging ops; it's a one-time UI walk-through.
Files of interest¶
frontend/utils/sentry.ts—initSentry()+wrapre-export.frontend/app/_layout.tsx— callsinitSentry()at module load, wraps the root component, emits navigation breadcrumbs.frontend/contexts/AuthContext.tsx—Sentry.setUser({ id, email })/Sentry.setUser(null)on auth state changes; captures for silent auth failures.frontend/utils/api.ts— HTTP failure breadcrumbs inhandleResponse.frontend/services/chatService.ts— WebSocket close breadcrumb + parse-error capture.frontend/app.config.js— Sentry Expo config plugin registration.frontend/eas.json—EXPO_PUBLIC_ENVin prod profile; DSN is added per environment.