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'ssrc/main.tsx, andreportReactError(error, componentStack), called from each app'sErrorBoundary. The SDK is a dynamic import behind a literalimport.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.tsbuilds a one-line app with Vite and checks both).worker.ts:withMonitoring(app, handler)wraps each Worker's default export;withMonitoringWorkflow/withMonitoringDurableObjectwrap the Workflow classes (DictationForge,LessonForge,ItemForge) and the Durable Object (EventStage). WithoutSENTRY_DSNthe 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) callscaptureError, and everylogEvent("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 inworkers/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 towindow.onerror, so eachErrorBoundary.componentDidCatchcallsreportReactError.
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:
- Not collected.
PRIVACY_DEFAULTS.dataCollectionturns off every SDK collection switch:userInfo(which also makes the SDK sendinfer_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:sendDefaultPiiwas removed and setting it does nothing, while the SDK's own defaults collect everything.worker.test.tsbuilds a real SDK client with our options and checks what it would send. - Scrubbed. Before anything leaves the browser or a Worker,
scrubEvent(errors),scrubSpan(spans, which the SDK streams by default, sobeforeSendTransactionnever runs) and an integration that cleans the trace context (baggageheader, envelope headers) remove or mask:- user identity: only an opaque
user.idsurvives (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...).
- user identity: only an opaque
If you change the scrubber, extend packages/sentry/scrub.test.ts in the same commit.
One-time setup (owner, by hand)
Go to https://sentry.io/signup/ and create a free account (the Developer plan is free).
Create an organization if asked. Note its slug (Settings > General, e.g.
nemo12).Create a project: Projects > Create Project > platform React > name
nemo12. One project for everything is fine: every event carries anapptag (learn,api, ...) to filter by.Copy the DSN: Project Settings > Client Keys (DSN). It looks like
https://abc123@o123.ingest.sentry.io/456.Create an auth token for source maps: Settings > Developer Settings > Organization Tokens > Create New Token. Copy it once; Sentry will not show it again.
In GitHub open the repo > Settings > Secrets and variables > Actions > New repository secret, and add five secrets:
Name Value SENTRY_DSNthe DSN from step 4 (used by the Workers) VITE_SENTRY_DSNthe same DSN (used by the React apps) SENTRY_AUTH_TOKENthe token from step 5 SENTRY_ORGthe organization slug from step 2 SENTRY_PROJECTthe project slug from step 3 (e.g. nemo12)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.
Nothing else. The next deploy of each app picks the secrets up. Do not also set
SENTRY_DSNas 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.