---
url: https://docs.nemo12.com/ops/sentry.md
description: >-
  How Sentry is wired into the Workers and React apps, what it never sends, and
  the one-time setup the owner does by hand.
---

# Sentry error monitoring (SRC-1397)

Sentry tells us when something breaks in production, with a stack trace, before a parent has to
report it. Until the secrets below exist, **nothing is sent**: every piece of the integration is a
no-op without its DSN.

## What is wired

| Where | Package | Turned on by |
| --- | --- | --- |
| `workers/api`, `workers/foundry`, `workers/tool-plane` | `@sentry/cloudflare` via `withMonitoring()` | GitHub secret `SENTRY_DSN` (passed to the Worker as a var at deploy) |
| Every React app (admin, b21, coral, gull, learn, marlins, mentors, mm, partners, tutors, ui, web) | `@sentry/react` via `initSentry()` | GitHub secret `VITE_SENTRY_DSN` (baked in at build) |
| Source maps for the React apps | `@sentry/cli` in `.github/scripts/sentry-sourcemaps.sh` | GitHub secret `SENTRY_AUTH_TOKEN` (+ `SENTRY_ORG`, `SENTRY_PROJECT`) |

All shared code lives in `packages/sentry`:

* `scrub.ts`: the privacy scrubber, used by both sides (test: `scrub.test.ts`).
* `browser.ts`: `initSentry(app)`, called once from each app's `src/main.tsx`, and
  `reportReactError(error, componentStack)`, called from each app's `ErrorBoundary`. The SDK is a
  dynamic import behind a literal `import.meta.env.VITE_SENTRY_DSN`: without the DSN Vite drops it
  from the build entirely; with it, ~55 KB gzip load after the app has started, never in the entry
  chunk (test: `browser.test.ts` builds a one-line app with Vite and checks both).
* `worker.ts`: `withMonitoring(app, handler)` wraps each Worker's default export;
  `withMonitoringWorkflow` / `withMonitoringDurableObject` wrap the Workflow classes (`DictationForge`,
  `LessonForge`, `ItemForge`) and the Durable Object (`EventStage`). Without `SENTRY_DSN` the original
  handler or class runs untouched (checked per invocation, per construction for classes).

The release is the git commit SHA (`GITHUB_SHA`): `deploy.sh` passes `SENTRY_RELEASE` to Workers,
`deploy-app.sh` sets `VITE_SENTRY_RELEASE` for the React builds.

Settings: every `dataCollection` switch off (see below), traces sampled at 10%, **no session replay**.

## What gets reported

The SDK alone only sees exceptions that ESCAPE a handler, and most of ours never do. So:

* API: `app.onError` (every route exception, which Hono turns into a 500) calls `captureError`, and
  every `logEvent("error", ...)` line is sent as an event grouped by its event name (the cron and
  queue jobs catch per job and log). The reporter is wired in `workers/api/src/index.ts`.
* tool-plane: the HTTP and per-RPC catch blocks report anything that is not an expected `ToolError`.
* foundry: the cron drain and sweep catch blocks report; Workflow step failures are captured by the
  Workflow wrapper.
* React apps: React 19 hands an error an ErrorBoundary caught to `onCaughtError` (console only),
  never to `window.onerror`, so each `ErrorBoundary.componentDidCatch` calls `reportReactError`.

Every error-level log line counts against the Sentry quota (free plan: 5,000 errors a month), so a
noisy `logEvent("error")` is a bug to fix, not a line to keep.

## Privacy: what is never sent

Nemo12 serves children and families. Two layers:

1. **Not collected.** `PRIVACY_DEFAULTS.dataCollection` turns off every SDK collection switch:
   `userInfo` (which also makes the SDK send `infer_ip: "never"`, so Sentry stores no client IP for
   events or sessions), cookies, request/response headers, bodies, URL query params, AI prompts and
   outputs (`genAI`), DB query data, queue payloads, stack frame variables. Since @sentry/\* 11 this is
   the only switch the SDK reads: `sendDefaultPii` was removed and setting it does nothing, while
   the SDK's own defaults collect everything. `worker.test.ts` builds a real SDK client with our
   options and checks what it would send.
2. **Scrubbed.** Before anything leaves the browser or a Worker, `scrubEvent` (errors),
   `scrubSpan` (spans, which the SDK streams by default, so `beforeSendTransaction` never runs) and
   an integration that cleans the trace context (`baggage` header, envelope headers) remove or mask:
   * user identity: only an opaque `user.id` survives (no email, name, username, IP);
   * cookies and auth headers (`cookie`, `authorization`, `x-api-key`, client IP headers);
   * request bodies and query strings, everywhere (URLs, breadcrumbs, messages, span attributes);
   * email addresses in free text, also percent-encoded (`parent%40gmail.com`);
   * path segments that look like tokens (16+ url-safe characters with digits and letters): the
     calendar feed `/v1/calendar/feed/<token>.ics`, `/email/preferences/<token>`, `/e/u/<token>`;
   * span attributes for query, fragment, every request/response header, user agent, client address,
     `user.*` except the id, and AI prompts/outputs;
   * any field whose name looks personal (`email`, `name`, `phone`, `password`, `token`,
     `address`, `birth`...).

If you change the scrubber, extend `packages/sentry/scrub.test.ts` in the same commit.

## One-time setup (owner, by hand)

1. Go to <https://sentry.io/signup/> and create a free account (the Developer plan is free).

2. Create an organization if asked. Note its **slug** (Settings > General, e.g. `nemo12`).

3. Create a project: Projects > Create Project > platform **React** > name `nemo12`. One project
   for everything is fine: every event carries an `app` tag (`learn`, `api`, ...) to filter by.

4. Copy the **DSN**: Project Settings > Client Keys (DSN). It looks like
   `https://abc123@o123.ingest.sentry.io/456`.

5. Create an auth token for source maps: Settings > Developer Settings > Organization Tokens >
   Create New Token. Copy it once; Sentry will not show it again.

6. In GitHub open the repo > Settings > Secrets and variables > Actions > **New repository
   secret**, and add five secrets:

   | Name | Value |
   | --- | --- |
   | `SENTRY_DSN` | the DSN from step 4 (used by the Workers) |
   | `VITE_SENTRY_DSN` | the same DSN (used by the React apps) |
   | `SENTRY_AUTH_TOKEN` | the token from step 5 |
   | `SENTRY_ORG` | the organization slug from step 2 |
   | `SENTRY_PROJECT` | the project slug from step 3 (e.g. `nemo12`) |

7. **Required before the first deploy with the secrets**: in Sentry, Project Settings > Security &
   Privacy, turn on **Prevent Storing of IP Addresses** and **Data Scrubber** (with default
   scrubbers). Our SDK settings already send no IP; this is the second line, for a kids product it
   is not optional.

8. Nothing else. The next deploy of each app picks the secrets up. Do **not** also set
   `SENTRY_DSN` as a Cloudflare secret: the deploy passes it as a var and the two would clash.

nemo12.com sends a Content-Security-Policy (report-only today, `apps/web/worker/security.ts`) whose
`connect-src` allows `https://*.sentry.io`: a CSP `*.` wildcard matches any subdomain depth, so it
covers the regional ingest hosts (`o123.ingest.us.sentry.io`) that `*.ingest.sentry.io` would miss.

## Turning it off

Delete `SENTRY_DSN` and `VITE_SENTRY_DSN` from GitHub secrets and redeploy. Without them the code
does nothing.
