Skip to content

Observability — Sentry (Errors, Traces, Logs, Metrics)

Tasuku sends four kinds of telemetry to Sentry: Issues (errors + warnings), Traces (sampled performance spans), Logs, and Metrics. Reporting is wired in exactly two files — one per runtime — behind a vendor-neutral facade, so the provider can be swapped without touching call sites.

No-op without a DSN. Local dev and tests have no SENTRY_DSN/PUBLIC_SENTRY_DSN, so the facade falls back to console and nothing leaves the machine.

flowchart LR
    APP["App code<br/>captureError / captureMessage<br/>countMetric / recordDistribution"]
    FACADE["src/lib/obs/report.ts<br/>vendor-neutral facade<br/>(redacts context, console fallback)"]
    SRV["sentry-server.ts<br/>@sentry/cloudflare<br/>(Worker)"]
    CLI["sentry-client.ts<br/>@sentry/browser<br/>(island, lazy-loaded)"]
    SENTRY["Sentry<br/>Issues · Traces · Logs · Metrics"]

    APP --> FACADE
    FACADE -->|Worker| SRV
    FACADE -->|browser| CLI
    SRV --> SENTRY
    CLI --> SENTRY
  • src/lib/obs/report.ts — the facade. Everything in the app calls captureError / captureMessage / countMetric / recordDistribution here; nothing else imports a Sentry SDK. Context objects pass through the same redact() guard as structured logs, so tokens/PII never reach the provider via our own call sites.
  • src/lib/obs/sentry-server.ts — the Worker adapter. withErrorMonitoring(handler) wraps the Worker (src/worker.ts) so unhandled fetch/scheduled exceptions and request traces are captured. Importing it registers the Sentry reporter.
  • src/lib/obs/sentry-client.ts — the browser adapter. Dynamically imported so the SDK lands in its own chunk, off the critical path.

Swapping providers = rewrite those two adapters. No other file changes.

SignalSentry productWhat we sendSampling
ExceptionsIssuesUnhandled fetch/scheduled errors; captureError100% (quota-capped)
MessagesIssuescaptureMessage, sent at warning100%
TracesTracesRequest spans; cron scan excluded10%
LogsLogsconsole.warn / console.error onlyn/a
MetricsMetricsapp.request count + app.request.duration ms, tagged {area, method, status}unsampled

captureError always reports at error/fatal. captureMessage is sent at warning (the SDK would otherwise default to info). A beforeSend floor drops any info/debug/ log event, so no informational events reach Issues — by policy (§9.3).

Traces — sampled, with the cron excluded

Section titled “Traces — sampled, with the cron excluded”

Traces sample at 10% to respect the free-tier span budget. The every-minute reminder Cron Trigger (scheduled()) would otherwise produce ~43k faas.cron transactions/month and dominate the quota, so a tracesSampler returns 0 for it (and 0.1 for everything else). Cron exception capture is unaffected — only the routine trace is dropped.

enableLogs + consoleLoggingIntegration({ levels: ['warn', 'error'] }) forward existing console.warn/console.error to Sentry Logs with no call-site changes. The app’s structured JSON event logs go through console.log (info level) and are deliberately not forwarded — they belong in Workers Logs, not Sentry (§9.3, same no-info policy as Issues).

app.request (counter) and app.request.duration (distribution, ms) are emitted once per request at the Worker, tagged with low-cardinality { area: api|web, method, status } — never the path (which carries ids/content). Metrics are unsampled, so they count accurately even though traces sample at 10%. Application Metrics are GA and on by default in the SDK; the explicit enableMetrics: true is belt-and-suspenders.

The app is privacy-strict, so both adapters strip personal data:

  • sendDefaultPii: false.
  • beforeSend removes the auto-attached request PII — cookies, the authorization header, query string, and event.user.
  • Facade context is run through redact() (the same guard that blanks token/secret/password/authorization/cookie/email and notes/title/query/ displayName).
  • tracePropagationTargets is same-origin only (/^\//) — the trace header never leaks to Clerk or Microsoft Graph.

When SENTRY_AUTH_TOKEN is present at build time, @sentry/vite-plugin emits hidden source maps (no sourceMappingURL, never served), uploads them under the release the SDKs report (__APP_VERSION__), then deletes them from dist so source never ships to users. Absent the token, the build is byte-identical and uploads nothing. This is opt-in per build — see the build-tool creds in Configuration & Secrets.

ValueKindWhereRead at
PUBLIC_SENTRY_DSNclient DSN.env / .dev.vars*build (baked into the browser bundle)
SENTRY_DSNserver DSNwrangler.jsonc varsrun time (env.SENTRY_DSN)
ENVIRONMENTenv tagwrangler.jsonc varsrun time
SENTRY_AUTH_TOKEN / SENTRY_ORG / SENTRY_PROJECTbuild-tool credsactive .dev.varsbuild (source-map upload)

The browser SDK POSTs to the regional ingest host, so the CSP connect-src allows https://*.ingest.{us,de}.sentry.io (src/lib/security/csp.ts). See Configuration & Secrets — Compile vs. Deploy for how these values are sourced and why a PUBLIC_* change requires a rebuild.

Everything above is tuned to stay inside the Sentry free plan:

  • Traces sampled at 10%; the cron transaction dropped entirely.
  • Logs + (forwarded) Issues limited to warning/error.
  • Metrics low-cardinality (no per-id tags) and unsampled but cheap.

Raise the sample rates if the quota allows.

scripts/sentry-smoke.mjs sends one of each signal straight to the ingest endpoint (no SDK, no deploy) to confirm a project is reachable:

Terminal window
node scripts/sentry-smoke.mjs "$PUBLIC_SENTRY_DSN"
# → Issues (warning + exception), Traces, Logs, Metrics

Real app telemetry only flows from the deployed Worker on real traffic — after a deploy, hit the URL a few times and check Explore → Metrics / Logs filtered to the right environment (~1 min to index).