feat(telemetry): explicit consent CTA + Privacy pane + install ID rotation#2109
Merged
feat(telemetry): explicit consent CTA + Privacy pane + install ID rotation#2109
Conversation
Spec for publishing a trust-first /telemetry page at nteract.io, recasting the onboarding toggle as an affirmation, and adding a Settings -> Privacy pane. Source Serif 4 gets codified as --font-page-serif for future cream- style long-form pages.
Kyle prefers shipping the page as a single PR structured as separate commits for reviewability, rather than two PRs.
Mirrors the nteract.io rollout. Two PRs total, one per repo, each a sequence of reviewable commits.
…path - Onboarding replaces pre-checked toggle with two explicit buttons at the end of the flow: "You can count on me!" (primary) and "Opt out of metrics, continue" (secondary). That press is the consent event. - New `telemetry_consent_recorded` flag gates the heartbeat so nothing fires before the user has pressed one of the buttons, even though the default is enabled=true. - Page gains "Your rights" section, NumFOCUS sponsored-project note, and a privacy contact line. - New DELETE /v1/install/:id endpoint in nteract/telemetry for server- side erasure. Settings pane opens a mailto fallback in v1. - Rollout grows to three PRs, one per repo. Telemetry repo ships first.
Step-by-step plan for the desktop PR: new telemetry_consent_recorded gate, TelemetryDisclosureCard shared component, onboarding two-button CTA, Settings Privacy pane with install-ID rotation, and slimmed developer-only docs/telemetry.md.
Adds the consent-recorded flag that gates heartbeat emission on explicit user choice. Default false — a fresh install cannot emit a ping until the user presses one of the onboarding CTAs. - New field on SyncedSettings with documentation of the GDPR rationale - Default() sets it to false - Mirrored through SettingsDoc::new_with_defaults, merge_from_json, put_updates_from_json, and sync_client::get_all_from_doc so every read/write path in the settings stack carries the field - backfill_telemetry_consent helper to migrate existing users whose onboarding_completed is true; called once on daemon startup (wired up later in the plan's Task 9) - TypeScript binding regenerated so the frontend sees the field Plan task 1 of 12.
Adds should_send_full and blocking_gates_full as the consent-aware supersets of the existing functions. try_send now routes through should_send_full, so no heartbeat fires until the user has pressed one of the onboarding CTAs. The old should_send / blocking_gates are preserved unchanged to avoid cascading updates for callers that don't (yet) carry the consent flag. The _full variants are what the send path uses. Plan task 2 of 12.
Pure function that generates a fresh UUIDv4, assigns it to install_id, and clears the three per-source last_ping_at timestamps. Callers are responsible for persisting the mutated settings via the daemon sync client (Task 4 wires the Tauri command that does this). Clearing the markers alongside the ID prevents the 20-hour throttle from silently suppressing the first ping under the new ID. Rate limiting against rotation abuse happens at the Cloudflare edge, not here. Plan task 3 of 12.
Tauri command the Privacy pane invokes. Reads a fresh UUIDv4, connects to the daemon, and atomically writes the four affected fields (install_id + three last_ping_at markers) via separate put_value calls on a single client session. Returns the new ID so the UI renders the fresh value without a round-trip. Registered in the Tauri invoke_handler alongside complete_onboarding under a "Privacy / telemetry" group. Also adds the new telemetry_consent_recorded field to the file-based SyncedSettings builder in crates/notebook/src/settings.rs — the Rust compiler flagged the missing initializer on the production loader path (tests use ..defaults, so they were already fine). Plan task 4 of 12.
useSyncedSettings now surfaces telemetryEnabled, telemetryConsentRecorded, installId, the three last-ping timestamps, and a rotateInstallId action. Both the onboarding flow and the forthcoming Settings → Privacy pane consume these. - State reads on mount + mirror on settings:changed events (last-ping timestamps intentionally only fetched on mount; they move on the order of once per day) - bigint → number conversion matches the keep_alive_secs pattern already in the file — Option<u64> fields arrive as bigint for large values - rotateInstallId delegates to the Tauri command and clears local state on success so the UI updates immediately Plan task 5 of 12.
Single source of truth for the telemetry disclosure copy: a small "One anonymous daily ping" eyebrow, a one-sentence body that spells out exactly what's in the payload, and a link to the canonical page at https://nteract.io/telemetry for the full picture. Rendered in both onboarding (above the two consent buttons) and Settings → Privacy (alongside the revisit toggle) — same copy both places. Variances (like a trailing switch in the Settings case) come through the `footer` slot so the card itself stays neutral. Lives under /src/components/ (shared) so the settings sub-app and the onboarding sub-app both reach for it via @/components. Path alias matches the existing pattern for other cross-app components. The Learn more link takes an optional `onOpenLearnMore` callback so consumers can route the click through `openUrl` (host.externalLinks) rather than importing @tauri-apps/plugin-shell directly, per the repo's frontend architecture rule. Default falls back to the href so tests and non-Tauri hosts still work. Plan task 6 of 12.
Onboarding page 2 previously had a pre-checked toggle above a "Get
Started" button. That was a quiet opt-in. Replace with an explicit
two-button CTA: primary says "You can count on me!" — warm, inviting
framing — and secondary "Opt out of metrics, continue" stays one
click away, same visual hierarchy as a trailing prose link.
Both buttons:
- persist the choice to telemetry_enabled (true or false)
- flip telemetry_consent_recorded to true so the consent gate lets
heartbeats fire (when enabled)
- run through the same daemon-save → complete-onboarding path the
old handler used
The daemon-failure fallback (handleSkip) now also records consent —
as opted out — so a user who never reaches the CTA is never pinged.
Other changes:
- isSubmitting latch prevents double-fire if the user clicks twice
during the async save path
- Removed the local telemetryEnabled useState; the decision flows
through the handler argument instead, so there's no partially
applied state to manage
- Learn more link routes through openUrl() → host.externalLinks,
not @tauri-apps/plugin-shell directly (architecture rule)
Plan task 7 of 12.
Privacy lives between Appearance and New Notebooks in Settings. It
mirrors onboarding's disclosure card, adds a toggle for the running
daily ping, shows the install ID with a one-click rotate action, and
reports the last-ping times per source.
Deliberately scoped:
- No "Erase my data" mailto. If the user wants erasure they can
find the instructions on the public telemetry page the card links
to. Keeps the pane from sprouting prose.
- Rotate still asks `window.confirm` since it's a destructive action
on server-side data — old rows unlink but don't delete immediately.
Also swaps onboarding's Learn more link from openUrl (host-routed) to
@tauri-apps/plugin-shell's `open` directly, matching what feedback/
already does. Sub-apps don't initialize the host ref, so openUrl would
silently drop the click — direct shell access is both the established
pattern and the working one.
Plan task 8 of 12.
New apps/notebook/gallery/ sub-app renders the telemetry bits
(TelemetryDisclosureCard, the onboarding CTA block in all its states,
and the full Settings → Privacy section) in a standalone Vite entry
you can open in a browser. No Tauri, no daemon, no IPC. Side effects
are stubbed — install ID rotation uses crypto.randomUUID, switches
update local state only — so every variant is interactive without
spinning up the real desktop app.
Setup:
- apps/notebook/gallery/{index.html,main.tsx,App.tsx,index.css}
- Registered as a new input in vite.config.ts alongside settings,
onboarding, etc.
- index.css imports ../settings/index.css wholesale — same tokens,
same Tailwind config, plus @source globs for the shared src/
components and settings/sections/ the gallery pulls from.
Usage:
- `pnpm --dir apps/notebook dev` → http://localhost:5174/gallery/
- `pnpm --dir apps/notebook build` also emits dist/gallery/index.html
for static hosting if we want to surface it from a URL later.
Builds clean; tsc --noEmit passes.
At daemon startup, if the loaded SettingsDoc shows an install that already completed onboarding but never got a consent_recorded flag, flip it to true. Without this, the heartbeat gate from task 2 would suddenly drop every existing user's pings on first launch after the upgrade — no one gave consent, because the field didn't exist yet. Upgrade path was "toggle pre-checked and they clicked Get Started," and that intent carries over. Idempotent: next boot is a no-op. Fresh installs stay at false (the whole point — they haven't seen the new two-button CTA yet). New `backfill_telemetry_consent_in_doc(&mut SettingsDoc)` does the work on the Automerge doc directly, so the daemon doesn't have to materialize a SyncedSettings → mutate → round-trip-write dance. The existing value-type `backfill_telemetry_consent(&mut SyncedSettings)` is kept for callers that already hold the struct. Daemon::new runs it right after `load_or_create`, before any pool or runtime agent spin-up, so later readers already see the corrected state. Four new unit tests cover both helpers on both fresh-install and onboarded-upgrade paths, plus the idempotence check. Plan task 9 of 12.
`runt config telemetry status` now prints a Consent recorded: yes/no line between the master switch and the install ID, and its blocking- gates query uses blocking_gates_full so the "consent not recorded" reason appears alongside the existing dev-mode / CI / throttle gates. Keeps the CLI in parity with the GUI: anyone debugging "why aren't my pings sending" from a terminal sees the same information the Settings → Privacy pane does. Plan task 10 of 12.
…eract.io Every piece of user-facing telemetry information (what's collected, what's never collected, retention, your rights) lives at https://nteract.io/telemetry and is reachable from the new Learn More link in the onboarding disclosure card and the Settings → Privacy pane. This file now only covers what a developer working on the desktop codebase needs to know: where each piece lives, the consent-gate behavior, how install ID rotation works, and how to run the tests. Also drops the old "Opting out" section — the new CTAs make it obvious, and the public page explains it for curious users. Plan task 11 of 12.
`http://localhost:5174/gallery` (no trailing slash) was falling through to the SPA fallback, which serves the main notebook index.html — that entry wires up a Tauri transport and throws `Cannot read properties of undefined (reading 'metadata')` when loaded standalone in a browser. Production static hosts (Cloudflare Pages, nginx `try_files`) handle the redirect automatically; `vite dev` doesn't out of the box. Adds a small dev-only middleware that 301s `/<name>` to `/<name>/` for the five sub-app entries (onboarding, settings, feedback, upgrade, gallery). Query/hash are preserved. The build output is unaffected; this only runs under `pnpm dev`. Without this, anyone visiting `localhost:5174/gallery` hits the Tauri error screen with no hint about what they did wrong.
The settings CSS the gallery imports sets html/body/#root to height: 100%; overflow: hidden. That's right for a Tauri window — fixed chrome, no outer scroll — but locked the gallery page at viewport height, so anything past the first section fell below the fold. Undo the overflow:hidden for the gallery only. Main app / settings / onboarding in Tauri windows are unaffected. Also tighten the "Onboarding page 2" section description so the "submitting" state label in the selector is obvious: that's the ~200ms window while the daemon is persisting the user's choice, when both buttons are disabled and the primary reads "Setting up..."
Formerly the backfill in Daemon::new mutated the in-memory SettingsDoc and relied on persist_settings in sync_server.rs to write it to disk — but that only fires when a settings client connects and sends a sync message. A daemon that boots, runs briefly without any settings-window interaction, and exits would drop the backfill, and the next boot would backfill again (still correct, just wasteful). Now writes both the Automerge binary and the JSON mirror right after the flag flips. Idempotent backfill + idempotent save means subsequent boots are no-ops on disk, as intended. Also formats the subAppTrailingSlashRedirect call in vite.config.ts to match the project's prettier config.
Contributor
4 tasks
rgbkrk
added a commit
that referenced
this pull request
Apr 23, 2026
Cuts a clean minor bump across the 2.x product surface as a marker for the day's work: explicit telemetry consent (#2109), RuntimeLifecycle refactor phases 1-6, Python CI consolidation into the Build workflow, ErrorBoundary + WASM panic observability, and pipeline coherence guards. Everything's ready to shake out through nightlies and reach stable. 2.x → 2.3.0 (user-visible): - crates/{notebook,runt,runtimed,runtimed-client,runtimed-py} - crates/notebook/tauri.conf.json (was 2.1.3 — pre-existing drift) - python/{nteract,runtimed} Coordinated patch bumps alongside the release: - 0.2.1 → 0.2.2 workspace crates - 0.1.1 → 0.1.2 workspace crates - python/dx 2.0.1 → 2.0.2 - python/nteract-kernel-launcher 0.1.2 → 0.1.3 - python/prewarm 0.0.2 → 0.0.3 Not bumped: - crates/{automunge,runtime-doc} — still 0.1.0, haven't reached their first release cut yet, no reason to advance them as part of this marker - apps/notebook/package.json — internal workspace version, not published; movement here would be noise - python/gremlin — internal test-agent, stays at 0.0.0 Also refreshes Cargo.lock and rebuilds runtimed-wasm + sift-wasm + the five renderer plugins (markdown, plotly, vega, leaflet, sift). These regenerate with the new crate versions baked in; the deno shape test from #2104 verifies the committed WASM still emits the RuntimeState shape the frontend expects.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces the pre-checked telemetry toggle with an explicit two-button CTA at the end of onboarding, adds a Settings → Privacy pane so the decision is revisitable, gates every heartbeat behind a new
telemetry_consent_recordedflag, and ships an install-ID rotation primitive. Daemon startup backfills the flag for existing onboarded users so no one's pings get silently dropped across the upgrade.Also lands a lightweight
apps/notebook/gallery/sub-app so these components can be previewed in a browser without launching the desktop app.Marked draft while Codex finishes its review of the branch. All 12 plan tasks plus the gallery are implemented and every verification step in task 12 passes locally:
cargo test -p runtimed-client— 179 passingcargo test -p notebook --lib— 133 passingcargo test -p runtimed --test tokio_mutex_lint— 1 passingpnpm vp test run— 1078 passing, 3 skippedpnpm --dir apps/notebook exec tsc --noEmit— cleancargo xtask lint— cleanSpec + plan
docs/superpowers/specs/2026-04-23-telemetry-ui-and-docs-design.mddocs/superpowers/plans/2026-04-23-telemetry-desktop-implementation.mdHow to preview the components
A new sub-app lives at
apps/notebook/gallery/. It rendersTelemetryDisclosureCard, the onboarding CTA block (with a state selector for idle / no-env-selected / ready / submitting / setup-complete), and the full Settings → Privacy section against local state. No Tauri, no daemon, no IPC.pnpm --dir apps/notebook dev # → http://localhost:5174/gallery/Theme switcher at the top of the page toggles between light, dark, and system.
pnpm --dir apps/notebook buildemitsdist/gallery/index.htmltoo, so static hosting from a URL works later if we want it.What the user sees
Onboarding page 2 now shows:
Both buttons persist the telemetry choice, flip
telemetry_consent_recorded = true, complete onboarding, and close the window. Neither pings silently — both are explicit.Settings → Privacy adds an equivalent panel between Appearance and New Notebooks: same disclosure card, a trailing switch for toggling the daily ping, an install-ID viewer with a one-click rotate action (generates a fresh UUIDv4, clears the three
last_ping_atmarkers), and the relative time since each source last pinged.Architecture notes
should_send_fullincrates/runtimed-client/src/telemetry.rsis the superset ofshould_sendthat also requires consent.try_sendroutes through it;should_sendandblocking_gatesstay unchanged so legacy callers compile.backfill_telemetry_consent_in_doc(&mut SettingsDoc)runs once inDaemon::new. If onboarding completed but consent was never recorded (pre-upgrade state), it flips the flag. Idempotent.rotate_install_id_inis a pure function intelemetry.rs; therotate_install_idTauri command wraps it and performs four sequentialput_valuewrites to the settings doc.apps/notebook/gallery/is a new Vite entry alongside onboarding/settings/upgrade/feedback. Itsindex.cssimports../settings/index.csswholesale so tokens, Tailwind config, and the cream theme stay in sync automatically. Any component used here must degrade gracefully without the Tauri shell —TelemetryDisclosureCarddoes (the optionalonOpenLearnMorecallback falls back to the<a href>);PrivacySectioncalls@tauri-apps/plugin-shell'sopenbut wraps it in a.catch(() => {})so the rotate/learn-more links don't throw in the browser.Commit layout
Seventeen commits total, one per plan task plus five inherited docs commits:
dd52495atelemetry_consent_recordedfield + backfill helpers58106f36d9b1b8abrotate_install_id_inhelper705ef72crotate_install_idTauri commandf3438b0d71723507TelemetryDisclosureCardcomponentb073501bef7651bfd734c7ee7bcba476d06d65026bb1a04ddocs/telemetry.mdto a developer readmeDeviations from the plan
apps/notebook/src/components/TelemetryDisclosureCard.tsxbut imported it via@/components/.... The@/alias resolves to the repo-root/src/, so I put it at/src/components/TelemetryDisclosureCard.tsx(shared across sub-apps). Matches the import pattern the plan wrote.@tauri-apps/plugin-shelldirectly. The notebook sub-apps don't initialize theNotebookHostregistry thatopenUrlrequires, so I kept the direct plugin-shell import in both onboarding and Settings — same pattern the feedback sub-app already uses.src/**/__tests__/**/*.test.tsx, soTelemetryDisclosureCard.test.tsxlives insrc/components/__tests__/, not alongside the component as the plan sketch showed.Test plan
~/Library/Application Support/io.nteract.desktop/settings.json(or the Linux equivalent), delete it, run the app, walk through onboarding with the "You can count on me!" path. Verify telemetry_enabled=true and telemetry_consent_recorded=true afterward viarunt config telemetry status.runt config telemetry statusshowsconsent_recorded: yesafter first daemon restart under this branch.