Skip to content

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 gracefullySentry.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:

  1. npx expo export --platform web produces a minified bundle + .map files in dist/.
  2. npx sentry-cli releases new <release> registers the release (com.tomoda.app@<package.json version>).
  3. npx sentry-cli sourcemaps upload --release <release> --strip-prefix <frontend-dir> dist/ uploads bundle + maps.
  4. 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:

  1. Open the Sentry issue, click the affected event.
  2. Trace section shows the trace ID — click the icon next to it to copy.
  3. 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 (category navigation)
  • API failures: utils/api.ts::handleResponse (category http)
  • WebSocket: services/chatService.ts (category websocket)

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::onmessage JSON parse — server sent malformed payload, always a bug.
  • chatService reconnect 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 Authorization headers; we also don't pass tokens into breadcrumb data payloads.
  • 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 (uses EXPO_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