ADR-082 — Telemetry environment ladder (dev → staging → prod, isolated projects)
Status · Accepted Date · 2026-07-24 Related ADRs · ADR-067 (Sentry/GlitchTip error tracking — extended here), ADR-081 (self-hosted Umami analytics — extended here), ADR-070 / ADR-071 (docs vs app deploy split, the
stagingworkflow this retargets) Supersedes wording in · ADR-067 §"Local-dev posture" and ADR-081 §"Why env-var-gated" (both saidvite devis hard-silent — no longer true; see below)
Context
Two sibling ADRs already gate browser telemetry on env vars, not hostname: ADR-067 (Sentry SDK → self-hosted GlitchTip) and ADR-081 (self-hosted Umami). Both shipped with a binary posture: a deploy bakes the PUBLIC_* vars → telemetry is live; everything else (vite dev, vite preview, CI, forks) is silent.
That binary posture had two gaps once the product went to production on orrerylearn.com (VPS + Cloudflare) while the chipi.github.io/orrery gh-pages mirror kept deploying:
- The gh-pages deploy is not a "preview" — it is a real, publicly reachable environment. It was labelled
productionin its Sentry env tag and set no Umami at all, so its error traffic polluted the prod GlitchTip project and its usage was invisible. A shared prod dashboard that mixes two live environments cannot answer "is this error happening to real users?" - Local
vite devwas telemetry-blind. A crash or a UX dead-end an engineer hit while developing produced nothing — no error in any GlitchTip project, no event in any Umami site. The one environment where we iterate fastest was the one with no feedback loop.
Decision
Model telemetry as a three-rung environment ladder. Each rung reports to its own isolated GlitchTip project + Umami site, tagged by environment, so no rung's data ever contaminates another's.
| Rung | Where | GlitchTip project | Umami site | environment tag | How it's selected |
|---|---|---|---|---|---|
| prod | orrerylearn.com (VPS + CF) | 4 | prod site (4a25d8da…) | production | deploy-prod.yml bakes PUBLIC_SENTRY_DSN + PUBLIC_UMAMI_* from the prod GH environment |
| staging | chipi.github.io/orrery (gh-pages) | 6 | staging site (6e7ddfce…) | staging | staging.yml bakes them from the new staging GH environment |
| dev | maintainer's laptop, vite dev | 7 | dev site (1d2f214c…) | dev | fallback constants in sentry.ts / analytics.ts, reached only over the Tailscale homelab host |
Concretely:
preview.yml→staging.yml. The gh-pages deploy is renamed and declaresenvironment: staging(a new GH environment holding the staging-only DSN + Umami id), mirroringdeploy-prod.yml'senvironment: prod. Its Sentry env tag isstaging, not the old mislabeledproduction. (References updated inrelease.yml,deploy-docs.yml,docker-e2e.yml.)- Dev rung. In
vite devwith no deploy-injectedPUBLIC_*override,sentry.tsfalls back toDEV_SENTRY_DSN(GlitchTip project 7) andanalytics.tsfalls back toDEV_UMAMI_HOST/DEV_UMAMI_WEBSITE_ID(the dev Umami site). Both endpoints resolve only via the Tailscale MagicDNS hosthomelab(GlitchTip:8090, Umami:3001) — no fixed IP. - Worktree tag.
vite.config.tsinjects the current git branch as the__DEV_WORKTREE__define;sentry.tsadds it as aworktreetag on dev events only, so errors from parallel local worktrees/agents stay distinguishable in the dev project. It is never attached to staging/prod events.
Why this preserves fork-silence (the load-bearing property)
The binary posture's one real guarantee — a stranger who clones the repo sends nothing — is kept, now by construction rather than by a dev hard-block:
- Deploy rungs (prod/staging): the
PUBLIC_*values live in GH environment secrets a fork doesn't have. A fork's build bakes empty → SDK/tracker no-op. - Dev rung: the fallback DSN/host is the Tailscale-only name
homelab. Only a device on our tailnet resolves it. On a stranger's machinevite devresolves nothing, the transport fails, and no event leaves the browser. The DSN key + Umami id are public browser ids (they ship in the bundle by design — same as any Sentry DSN), so committing them leaks nothing.
So the guarantee moved from "dev is hard-blocked" to "dev's endpoint is unreachable off the tailnet." Same outcome for forks; a working feedback loop for the maintainer.
Consequences
vite devis no longer silent for the maintainer. Any doc that says "local dev is silent / dev sends nothing" is now only true for non-tailnet machines. Updated: README §Privacy,src/lib/analytics.tsmodule JSDoc,docs/guides/observability.md, ADR-067 / ADR-081 (amendment pointers to here).- New infra (homelab, owned by the infra repo): GlitchTip project 6 (
orrery-staging) + 7 (orrery-dev); Umami sites6e7ddfce…(staging) +1d2f214c…(dev); the GHstagingenvironment + its secrets. vite preview, screenshot pipeline, e2e stay silent — they are notdevand carry no bakedPUBLIC_*, so no rung resolves. The public prod dataset reflects real visitors only.- Reading a dev error requires being on the tailnet. Acceptable — dev telemetry is a maintainer convenience, not a product surface.
Not covered / out of scope
- Native mobile crash reporting (
@sentry/capacitor, #428) plugs into the same GlitchTip. Originally scoped out of the browser ladder; the 2026-07-24 amendment below folds mobile INTO the ladder as a platform segment. - Log shipping (ADR-068 Grafana Agent / VictoriaLogs) is a server-side concern, not browser telemetry, and is untouched here.
- The public-vhost hardening that hides the GlitchTip/Umami admin surfaces is the infra repo's responsibility (as in ADR-081).
Amendment — 2026-07-24 (mobile is a segment of the ladder, not a separate app)
#428 initially gave the Capacitor shells their own environment value (mobile-<platform>), which overloaded environment with the platform and so collided with using it for the tier — a native crash always read mobile-ios regardless of whether it came from a simulator or an App Store build. Decision: mobile is a segment of the same application, not a separate app, and rides the same tier ladder. Two independent dimensions:
| Dimension | Values | Meaning |
|---|---|---|
environment | dev / staging / prod | the tier — uniform for web AND native |
platform tag | web / ios / android | the segment — splits app from browser |
sentry.ts:environmentnow resolves the same way for web and native (PUBLIC_SENTRY_ENVIRONMENT→vite dev→dev→ elseprod); themobile-<platform>override is gone. Native still inits via@sentry/capacitor(+ the@sentry/sveltesibling) and carries theplatformtag. Result: prod project 4 holds{web, ios, android}events allenvironment=prod, filterable byplatform; staging project 6 likewise. Covered bysentry.native.test.ts.- Mobile build picks the tier by what it bakes (native builds are local, not CI). Two recipes — see
docs/guides/mobile-build-and-deploy.md §Ship:- Simulator / staging: export the staging DSN +
PUBLIC_SENTRY_ENVIRONMENT=staging+ the staging Umami id, thensync:mobile→ crashes land in staging project 6, pageviews in the staging Umami site. - Release / App Store: export the prod DSN +
=prod+ the prod Umami id → prod project 4. "What I bake at the end always goes to prod."
- Simulator / staging: export the staging DSN +
- Umami is identical per tier, device-agnostic (operator decision 2026-07-24). One Umami site per tier; no
platformdimension — Umami's own UA-based device breakdown already separates desktop / mobile. The app'scapacitor://localhostorigin is accepted (the sites don't restrict domain — verified by test ingest). The only requirement: the mobile build must bakePUBLIC_UMAMI_*(per tier) or the in-app analytics is silent —#428's recipe baked only the Sentry DSN. - Orthogonal to
STREAM_ORIGIN(the asset host): a build's telemetry tier and its asset origin are set independently at build time. ADR-083 then couples them behind a single runtime switch for internal mobile builds (staging↔prod for telemetry + assets together), while App Store release stays prod-locked.