CI-Spellbook reports browser crashes to Sentry. It is
opt-out: on any build configured with a DSN, reporting is on by default,
and the user turns it off with the Share crash reports toggle in the app
store (or CI_TELEMETRY=off on the container directly).
Two statements are both true here and easy to conflate:
- Configured with a DSN → reporting is on unless the user turns it off. That is the shipped appliance: release images carry a DSN.
- No DSN → nothing initialises, and no Sentry code is even present in the
bundle you are served, so the app cannot make an outbound connection. That is
a source checkout,
npm run dev, or any build made withoutVITE_SENTRY_DSN. It is the absence of configuration, not a privacy setting.
So "opt-out" describes what a user of the appliance experiences. It does not mean a DSN-less build quietly reports — it cannot.
One caveat is specific to this repo and is not cosmetic: Spellbook is a static
bundle with no server of its own, so on a host that serves nothing but files
there is no process to answer the consent check, and reporting stays off
there even with a DSN baked in. The opt-out default therefore holds for the
container image and for npm run dev, and does not hold on a bare static
host. See
The browser consent check
for the full picture.
This follows the env contract established by CI-Server (the reference implementation) and shared with CI-Planning and CI-Import-Tools.
Three controls, in precedence order — the first that applies wins:
| # | Control | Effect |
|---|---|---|
| 1 | CI_LOCAL_ONLY=true on the container |
Local-only mode means zero phone-home. Beats everything, including a baked-in DSN. |
| 2 | Share crash reports toggle in the app store, or CI_TELEMETRY=off |
The marketplace toggle sets exactly CI_TELEMETRY on this container. Also false, 0, no, disabled. |
| 3 | Build without VITE_SENTRY_DSN |
Not a user-facing setting — with no DSN there is no SDK in the bundle to disable. |
docker run -d -p 5500:5500 -e CI_TELEMETRY=off \
ghcr.io/companionintelligence/ci-spellbook:latestNeither 1 nor 2 needs a rebuild: the bundle asks GET /api/config/telemetry on
every page load, so an operator can revoke reporting on an image whose DSN was
fixed at build time.
Spellbook is a client for an appliance that holds the user's digital life, so reporting is deliberately narrower than a Sentry default install:
| Setting | Value | Why |
|---|---|---|
sendDefaultPii |
false |
No IPs, cookies, or request bodies. |
tracesSampleRate |
0.1 by default |
Override with VITE_SENTRY_TRACES_SAMPLE_RATE. |
| Session replay | 0.1 of sessions, 100% of error sessions |
Override with the VITE_SENTRY_REPLAYS_* variables. |
| Profiling | not enabled in the browser | Backend profiling is controlled separately where supported. |
beforeSend |
scrubber | Redacts credentials and home paths, drops cookies/body/query string. |
beforeBreadcrumb |
scrubber | Scrubs at record time, so an unsent crumb never sits in the scope with a query string in it. |
Two kill switches disable reporting even when a DSN is baked into an image, so an operator can turn it off without a rebuild:
| Variable | Effect |
|---|---|
CI_TELEMETRY=off |
Explicit opt-out (also false, 0, no, disabled). |
CI_LOCAL_ONLY=true |
Local-only mode means zero phone-home. See local-only-mode. |
CI_LOCAL_ONLY takes precedence over CI_TELEMETRY, which takes precedence
over the DSN.
src/lib/telemetry.js implements the same rules as CI-Server's
common/telemetry/scrubEvent.ts and its Python twins. The shared redaction
cases live in src/lib/telemetryScrubFixtures.js, a cross-repo copy of
CI-Server's scrubFixtures.ts — change every copy together; nothing can
assert that automatically across repos.
- Bearer tokens,
api_key=/password:/token:style pairs, Tailscaletskey-…, and credentials in connection URLs →[Filtered] - Home directories (
/Users/<name>,/home/<name>,C:\Users\<name>) →~, since they leak the account name. Windows patterns are applied before the macOS one, or/Users/<name>matches insideC:/Users/<name>and leaves a strandedC:~/… - Object and header keys matching
password|secret|token|authorization|cookie|jwt|api[-_ ]?key|dsn|pepper|private_key|signature→[Filtered]. The hyphenated form matters:x-api-keyis a real header name, and the SDK ships request headers even with PII disabled — resolvingsendDefaultPii: falsegives an IP-oriented deny list applied to span attributes, not toevent.request.headers. The regex is the only guard there is - Object and header keys matching the anchored identity set
(?:^|[-_])user$|(?:^|[-_])owner$|username|email|forwarded|^remote-→[Filtered]. These name a person rather than carrying a credential, so no value-level rule can ever catch them — a username is just a word. Behind CI-Hub's Traefik forward-auth the proxy injectsX-CI-Hub-User,-Timestampand-Signatureon every authenticated request, and the SDK captures the headers that arrived, so they reach the event with no reference in our code at all. Anchored on purpose:user-agent,user_idandowner_idare diagnostics worth keeping, and a naive/user|owner/would eat all three - Request cookies and body dropped; query strings removed from
request.query_string, fromrequest.url, and from thereferer/referrer/locationheaders.httpContextIntegrationfillsRefererfromdocument.referrer, and the browser's default referrer policy keeps the full same-origin previous URL, query string and all - Breadcrumbs, which are the biggest leak surface in a browser: the default
breadcrumbsIntegrationrecords fetch/xhr crumbs carryingdata.urlwith the query string, and console crumbs carrying the joinedmessageplus the rawdata.arguments. Every crumb'smessageis scrubbed and the wholedataobject goes through the value walk — not each value in turn, or the crumb's own keys are never key-checked and a baredata.tokenships intact — withurl/from/tothen treated as URLs - Stack frame
vars(captured locals) dropped, and home directories collapsed infilename/abs_path. Paths are only home-collapsed and never redacted further — Sentry groups issues onfilename, so redacting the body would destroy grouping server_name(the machine hostname) removed — a hostname identifies the household the way a home path does- Strings over 8 KB truncated, so a stray blob can't smuggle content out inside an exception message
- Chunk-load and
ResizeObservernoise dropped entirely
A DSN baked into a bundle at build time cannot be revoked afterwards, so the
kill switches would not reach the browser on their own — and the CI-Marketplace
manifest's "Share crash reports" toggle sets exactly CI_TELEMETRY. Before
calling Sentry.init, the app asks GET /api/config/telemetry whether
reporting is permitted, and fails closed if that endpoint is missing,
unreachable or malformed.
Spellbook has no server of its own. npm run serve is
python3 -m http.server, and the shipped artefact is a directory of static
files. So there is nothing to add a controller to, and the endpoint is provided
by whatever is serving the files:
| How it's served | Endpoint | Is the opt-out default honoured? |
|---|---|---|
| GHCR image (nginx) — how the Hub/marketplace runs it | nginx/40-telemetry-config.sh resolves the environment at container start and writes /api/config/telemetry; nginx/default.conf serves it no-store |
Yes — on by default with a DSN, and both kill switches reach the browser |
npm run dev / npm run preview |
Vite middleware in vite.config.js, from process.env |
Yes — same rule, resolved from process.env |
Cloudflare Pages (the main deploy) |
none | No — the check 404s (a real 404: 404.html is in the build, which also turns off Pages' single-page-app fallback; before it was, Pages answered this path with the home page and a 200, which the bundle also rejects), so reporting stays off even with a DSN |
npm run serve, or any bare static host |
none | No — same 404, same result |
The last two rows are where this repo genuinely departs from the fleet's
opt-out posture, and the gap is stated plainly rather than glossed: on a bare
static host there is no process to read CI_TELEMETRY, so the switch cannot be
honoured there at all. Given that, the design keeps failing closed — a 404
is treated exactly like a "no", so those deployments report nothing. Reporting
"because we could not ask" would mean sending crashes an operator has no way to
switch off, which is worse than the default being wrong in one direction.
The Cloudflare Pages deploy is still built without a DSN, so it has no Sentry
code in it either way and the distinction remains theoretical. Note that wiring
the DSN into docker-publish.yml did not change this: Pages builds are run
by the Cloudflare GitHub App from the dashboard, not by any workflow in this
repo, so the GHCR image and the Pages deploy are now deliberately different in
this one respect. Before the Pages build may carry a DSN it needs a Function at
/api/config/telemetry — otherwise it would ship an unswitchable reporter.
The shell script and the Vite middleware are two implementations of one rule.
src/lib/telemetryConsentEndpoint.test.js runs both over the same env matrix
and asserts they agree.
With VITE_SENTRY_DSN unset, the whole init path folds to dead code and the SDK
is tree-shaken out entirely. Measured on this repo (Vite 5 / Rollup):
| Build | dist/assets/*.js |
gzip | captureException in output |
|---|---|---|---|
| no DSN | 387,579 bytes | 125.71 kB | absent |
VITE_SENTRY_DSN=… |
479,780 bytes | 156.52 kB | present |
That is +92,201 bytes raw / +30.8 kB gzip for reporting, and zero bytes without it.
It holds because of the exact shape in src/lib/telemetry.js:
const DSN = import.meta.env.VITE_SENTRY_DSN // module scope, NO optional chaining
…
if (!DSN) { … return }import.meta.env.X?.trim() folds under Rollup but not under rolldown-vite,
where it silently keeps the entire SDK in a DSN-less bundle. Keep this shape.
captureException call sites are also dropped, because initialized can only
be assigned inside that dead branch (measured: a call site costs 6 bytes, not
92 kB). That analysis is subtler than the if (!DSN) one, so
npm run assert:no-sentry-without-dsn builds with no DSN and greps the output;
CI runs it on every PR. If a bundler upgrade ever stops proving it, that job
fails loudly instead of the app silently shipping the SDK.
One project per marketplace app id, matching the fleet convention
(ci-memory, ci-import-tools, …): Spellbook reports into ci-spellbook
in the companion-intelligence org.
The project does not exist yet — the org sets allowMemberProjectCreation: false, so an owner has to create it. Every event carries a
component: ci-spellbook tag regardless, so pointing this at a shared project
and filtering by component: also works.
sentry issue list companion-intelligence/ci-spellbook --query "is:unresolved"
The DSN is a build-time value — Vite bakes VITE_* into the bundle, so it
must be present when npm run build runs, not when the container starts.
# local
VITE_SENTRY_DSN=https://<key>@o<org>.ingest.us.sentry.io/<id> npm run build
# image
docker build \
--build-arg VITE_SENTRY_DSN=https://<key>@o<org>.ingest.us.sentry.io/<id> \
--build-arg VITE_SENTRY_RELEASE=ci-spellbook@1.1.0 \
--build-arg VITE_CI_ENVIRONMENT=production \
-t ci-spellbook .Once a DSN is baked in, reporting is on for that image without any further step — see Turning it off for how a user or operator switches it back off, no rebuild required.
npm run test:unit # gate + scrubber + entrypoint parity
npm run assert:no-sentry-without-dsn # proves a DSN-less bundle has no SDK
# the consent endpoint, as the image actually serves it
docker run --rm -d --name sb -p 5500:5500 -e CI_TELEMETRY=off ci-spellbook
curl -s localhost:5500/api/config/telemetry # {"enabled":false,"reason":"opt-out"}To confirm reporting is genuinely inert, load the app with the browser devtools
network tab filtered to sentry.io — with no DSN, or with either kill switch
set, there is no request at all.
| Path | Role |
|---|---|
src/lib/telemetrySettings.js |
The gate: kill switches, precedence, consent payload |
src/lib/telemetry.js |
Scrubber + Sentry.init + captureException |
src/lib/telemetryScrubFixtures.js |
Shared redaction cases (cross-repo copy) |
src/main.jsx |
Calls initTelemetry() before the root render |
src/hooks/useShelf.js |
Reports shelf-content load failures, which are caught and so never reach the global handler |
nginx/40-telemetry-config.sh |
Writes runtime consent at container start |
nginx/default.conf |
Serves /api/config/telemetry |
vite.config.js |
Same endpoint for dev / preview |
scripts/assert-bundle-without-dsn.mjs |
CI guard for the bundle guarantee |
- Source map upload.
docker-publish.ymlnow passes--build-arg VITE_SENTRY_DSNfrom the repositorySENTRY_DSNsecret, so the GHCR image finally carries a DSN and the opt-out default has something to act on. It still does not runsentry sourcemaps upload, so browser stack traces arrive minified. CI-Hub'sdesktop-release.ymlhas the pattern to copy; it needs aSENTRY_AUTH_TOKENsecret, which this repo does not have yet. - Cloudflare Pages consent. See the table above: the Pages deploy has no endpoint, so it must stay DSN-less until it grows a Function. This is the one place the opt-out default does not reach, and it is the reason it must stay DSN-less rather than merely being expected to.
contexts.culture. Sentry's defaultCultureContextintegration attaches the browser locale, calendar and timezone to every event. It is not user-identifying on its own and CI-Server ships it too, so this repo does not unilaterally diverge — but it is a fleet-wide decision worth revisiting for a local-first product.