Skip to content

feat(telemetry): explicit consent CTA + Privacy pane + install ID rotation#2109

Merged
rgbkrk merged 21 commits intomainfrom
telemetry/desktop-ui
Apr 23, 2026
Merged

feat(telemetry): explicit consent CTA + Privacy pane + install ID rotation#2109
rgbkrk merged 21 commits intomainfrom
telemetry/desktop-ui

Conversation

@rgbkrk
Copy link
Copy Markdown
Member

@rgbkrk rgbkrk commented Apr 23, 2026

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_recorded flag, 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 passing
  • cargo test -p notebook --lib — 133 passing
  • cargo test -p runtimed --test tokio_mutex_lint — 1 passing
  • pnpm vp test run — 1078 passing, 3 skipped
  • pnpm --dir apps/notebook exec tsc --noEmit — clean
  • cargo xtask lint — clean

Spec + plan

  • Spec: docs/superpowers/specs/2026-04-23-telemetry-ui-and-docs-design.md
  • Plan: docs/superpowers/plans/2026-04-23-telemetry-desktop-implementation.md

How to preview the components

A new sub-app lives at apps/notebook/gallery/. It renders TelemetryDisclosureCard, 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 build emits dist/gallery/index.html too, so static hosting from a URL works later if we want it.

What the user sees

Onboarding page 2 now shows:

  1. The shared disclosure card — "One anonymous daily ping. Version, platform, architecture. No names, no paths, nothing about your notebooks." plus a "Read the full details" link to https://nteract.io/telemetry.
  2. A primary button: "You can count on me!"
  3. A secondary underlined link below: "Opt out of metrics, continue"

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_at markers), and the relative time since each source last pinged.

Architecture notes

  • Consent gate. should_send_full in crates/runtimed-client/src/telemetry.rs is the superset of should_send that also requires consent. try_send routes through it; should_send and blocking_gates stay unchanged so legacy callers compile.
  • Backfill. backfill_telemetry_consent_in_doc(&mut SettingsDoc) runs once in Daemon::new. If onboarding completed but consent was never recorded (pre-upgrade state), it flips the flag. Idempotent.
  • Rotation. rotate_install_id_in is a pure function in telemetry.rs; the rotate_install_id Tauri command wraps it and performs four sequential put_value writes to the settings doc.
  • Gallery. apps/notebook/gallery/ is a new Vite entry alongside onboarding/settings/upgrade/feedback. Its index.css imports ../settings/index.css wholesale so tokens, Tailwind config, and the cream theme stay in sync automatically. Any component used here must degrade gracefully without the Tauri shell — TelemetryDisclosureCard does (the optional onOpenLearnMore callback falls back to the <a href>); PrivacySection calls @tauri-apps/plugin-shell's open but 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:

Commit Task
dd52495a 1 — add telemetry_consent_recorded field + backfill helpers
58106f36 2 — gate heartbeats behind the flag
d9b1b8ab 3 — rotate_install_id_in helper
705ef72c 4 — rotate_install_id Tauri command
f3438b0d 5 — expose telemetry state + rotation to the frontend hook
71723507 6 — shared TelemetryDisclosureCard component
b073501b 7 — onboarding two-button CTA
ef7651bf 8 — Settings Privacy pane + onboarding link opener simplification
d734c7ee bonus — component gallery sub-app
7bcba476 9 — daemon startup backfill
d06d6502 10 — CLI surfaces consent_recorded
6bb1a04d 11 — slim docs/telemetry.md to a developer readme

Deviations from the plan

  • Shared disclosure card location. Plan said apps/notebook/src/components/TelemetryDisclosureCard.tsx but 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.
  • Link opener. Plan used @tauri-apps/plugin-shell directly. The notebook sub-apps don't initialize the NotebookHost registry that openUrl requires, so I kept the direct plugin-shell import in both onboarding and Settings — same pattern the feedback sub-app already uses.
  • "Erase my data" mailto dropped per explicit user request ("just provide the link"). The disclosure card's Learn more link points at the public telemetry page, which is the canonical place for erasure instructions.
  • Test location. Vitest config only picks up src/**/__tests__/**/*.test.tsx, so TelemetryDisclosureCard.test.tsx lives in src/components/__tests__/, not alongside the component as the plan sketch showed.

Test plan

  • Launch Vite gallery and click through each variant/state.
  • Back up ~/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 via runt config telemetry status.
  • Repeat with the opt-out path. Verify telemetry_enabled=false and telemetry_consent_recorded=true.
  • On a pre-existing (onboarded) install, verify runt config telemetry status shows consent_recorded: yes after first daemon restart under this branch.
  • Open Settings → Privacy, flip the switch, rotate the install ID. Confirm the new UUID appears instantly.
  • Click the Learn more link in either surface; system browser should open https://nteract.io/telemetry.

rgbkrk added 17 commits April 23, 2026 09:23
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.
@github-actions github-actions Bot added documentation Improvements or additions to documentation frontend Webview, React, TypeScript UI daemon runtimed daemon, kernel management, sync server labels Apr 23, 2026
rgbkrk added 3 commits April 23, 2026 10:36
`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.
@rgbkrk rgbkrk marked this pull request as ready for review April 23, 2026 18:09
@rgbkrk rgbkrk enabled auto-merge (squash) April 23, 2026 18:13
@rgbkrk rgbkrk disabled auto-merge April 23, 2026 18:31
@rgbkrk rgbkrk merged commit 2ed6840 into main Apr 23, 2026
12 of 13 checks passed
@rgbkrk rgbkrk deleted the telemetry/desktop-ui branch April 23, 2026 18:33
@blacksmith-sh
Copy link
Copy Markdown
Contributor

blacksmith-sh Bot commented Apr 23, 2026

Found 2 test failures on Blacksmith runners:

Failures

Test View Logs
TestAppendSource/test_append_source_basic View Logs
TestKernelLifecycle/test_interrupt_clears_queue_and_unblocks View Logs

Fix in Cursor

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

daemon runtimed daemon, kernel management, sync server documentation Improvements or additions to documentation frontend Webview, React, TypeScript UI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant