ADR-067 — Sentry as client-side error-tracking layer (errors only, env-var-gated, no committed secrets)
Status · Accepted Date · 2026-05-22 Closes into · RFC-025 §4, §6 Related ADRs · ADR-016 (no client storage), ADR-029 (PWA + service worker), ADR-057 (locale cookie exception), ADR-051 (CC-BY ship-gate / citation discipline) Reuses · podcast_scraper RFC-081 §Layer-2 Sentry pattern (component-aware DSN,
sendDefaultPii: false,beforeSendscrub)
Context
Orrery ships ~50 KLOC of TypeScript / Svelte 5 to every browser. A bug that hydrates fine on Chromium-darwin but throws on Firefox-Android (or in a Paraglide-compiled locale we don't manually test) is currently invisible — the user closes the tab and we never know. The existing Umami analytics layer counts page views + a handful of custom events; it does not capture exceptions, console errors, or unhandled promise rejections. The hosted live site (chipi.github.io) and the eventual VPS-served build (per RFC-024) both need a way to surface client-side errors so the operator can triage them without waiting for a user to file an issue.
The decision is which SDK, with what config, configured how, given Orrery's load-bearing constraints:
- Privacy promise. The README's
## Privacysection commits to "no PII", "aggregate only", "zero analytics by default on forks." Whatever ships has to satisfy all three. - Static site, no server.
adapter-staticproduces a pre-rendered SPA. There is no server-side initialisation surface; only the client SDK can run. - No committed secrets. Per the operator's explicit direction during RFC-025 scoping: every credential — DSN included — lives behind an env var. The repo never contains a working DSN.
- Fork-friendliness. Self-hosters who clone Orrery and build for their own domain must get zero Sentry traffic by default. The Umami pattern is hostname-gated; the operator chose env-var-gated for Sentry (per RFC-025 §3 design question) so that forks that DO want their own Sentry can populate the env var without editing source.
Decision
Install @sentry/sveltekit. Initialise client-side only, via src/hooks.client.ts calling a thin src/lib/observability/sentry.ts module. The init reads PUBLIC_SENTRY_DSN from the build-time env (SvelteKit's $env/dynamic/public surface); when empty, the SDK is not invoked at all and no network request fires. When set, the SDK is initialised with tracesSampleRate: 0 (errors only), sendDefaultPii: false, and a beforeSend hook that strips URL query parameters, nulls request.headers + request.cookies, and sets user.ip_address to 0.0.0.0 (Sentry's discard sentinel). No hooks.server.ts integration (adapter-static has no server runtime). No source-map upload (deferred to RFC-025 §11). No session replay, no Web Vitals, no performance tracing.
What the SDK actually captures
- Unhandled JavaScript exceptions during route navigation, load functions, and client-side
+page.sveltelifecycle. - Unhandled promise rejections (Sentry's default
globalHandlersintegration catchesunhandledrejection). - Errors thrown inside Svelte effects + event handlers (caught by SvelteKit's
handleErrorhook, wrapped byhandleErrorWithSentry). - NOT performance traces, NOT route timings, NOT Web Vitals, NOT session replay. Each of those is explicitly disabled or simply not configured.
PII scrubbing — what beforeSend does to each event
beforeSend(event) {
// 1. URL — preserve path, strip query string + hash.
if (event.request?.url) {
try {
const u = new URL(event.request.url);
event.request.url = u.origin + u.pathname;
} catch { /* leave as-is, malformed URL */ }
}
// 2. Headers + cookies — null. The SDK sometimes attaches referer + a cookie
// dump even with sendDefaultPii: false; this is the belt to that suspenders.
event.request = event.request
? { ...event.request, headers: undefined, cookies: undefined }
: undefined;
// 3. IP — set to 0.0.0.0 which Sentry's server interprets as "discard".
event.user = { ...event.user, ip_address: '0.0.0.0' };
return event;
}beforeBreadcrumb(crumb) {
// ui.input breadcrumbs carry the typed value verbatim — drop entirely.
if (crumb.category === 'ui.input') return null;
// Navigation breadcrumbs carry full URLs; strip query.
if (crumb.category === 'navigation' && typeof crumb.data?.to === 'string') {
const to = crumb.data.to;
try { const u = new URL(to, location.origin); crumb.data = { ...crumb.data, to: u.pathname }; } catch {}
}
return crumb;
}Env-var gate (the fork-silent contract)
import { env } from '$env/dynamic/public';
import { dev } from '$app/environment';
export function initSentry() {
const dsn = env.PUBLIC_SENTRY_DSN;
if (!dsn) return; // Fork-silent default — no DSN, no init, no network.
if (dev) return; // Vite dev never reports.
Sentry.init({ dsn, /* …config… */ });
}The PUBLIC_ prefix is SvelteKit's signal that the var is exposed to the client bundle at build time. The Sentry DSN is public-by-design (it identifies the project, doesn't authenticate); inlining it into the bundle is correct and explicit.
Build wiring
.github/workflows/preview.yml (the GH Pages deploy workflow) gets one new env block on the build step:
env:
PUBLIC_SENTRY_DSN: ${{ secrets.SENTRY_DSN_WEB }}
PUBLIC_SENTRY_ENVIRONMENT: production
PUBLIC_SENTRY_RELEASE: gha-${{ github.sha }}The secret is set once in repo settings → Secrets → Actions by the operator. Forks that don't have the secret will have an empty env var → silent SDK → zero outbound traffic.
Rationale
@sentry/sveltekit rather than @sentry/browser: the framework-specific SDK provides the handleError hook integration, automatic source-map awareness, and the SvelteKit-compatible env-var loading. Adding @sentry/browser would require manual error boundary plumbing that the framework SDK already provides.
Errors-only (not perf): the user picked it during RFC-025 scoping. The reasoning: error volume is bounded by unique bugs in the bundle, not by visitor count, so it scales gracefully with site popularity. Performance tracing volume scales linearly with visitors and is much more likely to exhaust the Sentry free tier under viral load. We can add perf tracing later if a real-user performance regression motivates it.
sendDefaultPii: false + beforeSend is the standard belt-and-suspenders Sentry pattern. Sentry's SDK respects sendDefaultPii for the IP address + headers, but the beforeSend hook is the right place to enforce additional project-specific scrubbing (URL query stripping in our case, since Orrery's URL contract carries mission IDs + locale codes that aren't PII themselves but could become so if a future feature accepts user-typed search text in a query param).
Env-var-gated, not hostname-gated: the existing src/lib/analytics.ts uses a HOSTNAME_ALLOWLIST to ensure Umami only loads on chipi.github.io. We considered the same pattern for Sentry but chose env-var-gating instead. Reasoning: forks that DO want their own Sentry project should be able to populate PUBLIC_SENTRY_DSN in their own CI secrets without editing source code; hostname-gating would force them to modify analytics.ts-style allowlist files. Env-var-gating makes Sentry "BYO project" by construction. The trade-off is that a misconfigured CI could accidentally point a fork at the operator's project; mitigated by Sentry's project-side rate limits + the public-by-design DSN model.
No source-map upload in Slice 1: source-map upload requires SENTRY_AUTH_TOKEN (the genuinely secret one, not the public DSN), the @sentry/vite-plugin, and a release-tagging convention. Useful for production debugging but adds a moving piece. Land it as a follow-up RFC once the base SDK + scrubbing have bedded in and the operator has triaged at least one real error and felt the pain of minified stack traces.
Alternatives considered
- Hostname-gated like Umami.
analytics.ts:HOSTNAME_ALLOWLISTlists exactly one production hostname. Same pattern would work for Sentry. Rejected because env-var-gated makes BYO-Sentry forks possible without source edits. @sentry/browser+ manual hooks. Lower-level SDK, finer-grained control. Rejected —@sentry/sveltekitis the supported framework integration and itshandleErrorwrapping is non-trivial to replicate by hand.- Errors + 10% performance tracing. Adds Web Vitals + route-transition timing. Rejected during RFC-025 scoping; defer until a real-user perf regression motivates it. Adding it later is a one-line config change.
- Session replay (
@sentry/replay). Records user UI for error reproduction. Rejected explicitly — the Replay SDK captures DOM mutations + input events; the privacy posture would have to change materially to accommodate it. Out of bounds for Orrery. - No client error tracking; rely on user reports. Status quo. Rejected because users don't report errors — they close the tab. We've already seen this with the i18n cookie test that passed against vite preview but failed against nginx (caught in CI, not by a user). Production errors that ONLY fire on specific browser/locale combinations need an observation surface.
- A self-hosted error sink (e.g. GlitchTip on the VPS). Self-hosted Sentry-compatible service. Rejected — operator already has a Sentry org for podcast_scraper; doubling that infrastructure for Orrery alone doesn't pay back. Revisit if Sentry-Cloud free-tier limits become a problem.
- Hostname-allowlist + env-var-gate combined. Belt + suspenders. Rejected as over-engineered — one of the two gates is enough.
Consequences
Positive:
- Production JS errors on
chipi.github.io(and the future VPS) become visible without users reporting them. - Strict privacy:
sendDefaultPii: false+beforeSendscrub +tracesSampleRate: 0means no IP, no headers, no query params, no UI input — only error type, stack trace (minified pre-source-map-upload), and a path-only URL. - Fork-silent by default — the
PUBLIC_SENTRY_DSNenv var is empty in forks' CI, SDK no-ops, zero outbound traffic. - BYO-Sentry forks possible — set
PUBLIC_SENTRY_DSNin your own CI, your fork reports to your project. - Reuses the operator's existing Sentry org (podcast_scraper RFC-081) — one set of credentials, one billing surface.
Negative:
- Adds ~30 KB to the client bundle (
@sentry/sveltekitminified+gzipped). Acceptable; the bundle is ~700 KB total already (per the chunkSizeWarningLimit invite.config.ts). - Production minified stack traces are ugly until source-map upload lands (deferred). Errors are still triageable from the file:line + message, but resolving back to the original Svelte component requires manual sourcemap consultation.
- One more thing to maintain. ~150 LOC of new TypeScript + the GH Actions env block. Routine.
- A future contributor could write
Sentry.captureMessage('user typed: ' + input)and bypass the scrubber for the message argument. Documented in the operator guide as a "don't do this" and caught by code review.
Decision status
Accepted on 2026-05-22. Shipped in commit cc9bc3423 (Slice 1 of RFC-025). The base contract held — @sentry/sveltekit v10.53.1 SDK, env-var-gated init, errors only, sendDefaultPii: false, beforeSend scrub. One adjustment landed in implementation: @sentry/cli is a transitive dep under Sentry's own FSL-1.1-MIT source-available license (MIT-compatible after a 2-year competitive-use embargo), so the license allowlist in scripts/build-tech-bom.ts was extended to include FSL-1.1-MIT with reasoning in a comment.
Validated end-to-end:
PUBLIC_SENTRY_DSN= npm run buildsucceeds, fork-silent path (SDK no-op) confirmed by absence of DSN in bundle.PUBLIC_SENTRY_DSN=<test-DSN> npm run buildsucceeds, DSN inlined intobuild/_app/env.js, SDK code bundled intobuild/_app/immutable/entry/app.*.js(158 KB chunk).npm run typecheck: 0 errors / 0 warnings.npm run lint: clean.npm run test: 1141/1141 passing.npm run check-no-secrets:all: 8801 files clean.
No anticipated amendments at this scope; the source-map upload follow-up (RFC-025 §11) is its own RFC.
Amendment — 2026-05-29 (#263, prod-VPS wiring)
The first live prod DSN got provisioned when orrery's tailnet-only VPS deploy went green. Wiring landed in .github/workflows/deploy-prod.yml "Build static bundle" step:
env:
PUBLIC_SENTRY_DSN: ${{ secrets.PUBLIC_SENTRY_DSN }}
PUBLIC_SENTRY_ENVIRONMENT: production
PUBLIC_SENTRY_RELEASE: gha-${{ steps.sha.outputs.short }}PUBLIC_SENTRY_DSN is staged in the GH prod environment (not at the repo level — keeps it gated by the environment's protection rules). Empty/unset still no-ops per the original ADR-067 fork-silent design. The gh-pages preview deploy still reads from the never-set SENTRY_DSN_WEB repo secret — that's an inconsistency worth squashing in a follow-up, but not in #263's scope.