Skip to content

Commit 95fbd9a

Browse files
pmaxhoganclaude
andauthored
feat(telemetry): preview exactly what a telemetry ping sends (#139)
## What / why Privacy-conscious users asked to see EXACTLY what data a telemetry ping sends before deciding whether to opt in (#34). This adds a "Preview data" link next to the telemetry toggle in Settings that opens a modal showing the pretty-printed JSON payload the next ping would carry. ## No-side-effect guarantee - New IPC command `preview_telemetry_ping` builds the payload through the SAME `resolve_payload` step the live ping path (`maybe_send_once`) uses - nothing is reimplemented, so preview can never drift from what would actually be sent. - No network call. - The `last_sent_at` delta checkpoint is never advanced, so the real next ping still aggregates the full un-checkpointed window. - The latency reservoir is only snapshotted (read-only), never reset - `reset()` is called exclusively from `maybe_send_once`'s post-send branch on a SUCCESSFUL send. - Works even while telemetry is currently disabled - that's the point, inspecting before opting in. - The only mutation possible is `ensure_install_id` minting a UUID v4 if one doesn't exist yet - the same idempotent one-time mint `get_telemetry_install_id` already performs. ## Test summary - Rust: 3 new unit tests on `resolve_payload` (the preview builder) - payload shape matches the SPEC s16 wire schema, preview works while telemetry is disabled, and preview does not advance `last_sent_at` or reset the latency reservoir. Full `cargo test -p driven-app`: 236 passed. - UI: new `telemetry-preview-modal.test.ts` (6 tests: hidden when closed, fetches + pretty-prints on open, loading/error states, re-fetches on reopen, close via button/overlay, copy-to-clipboard) plus a `settings-components.test.ts` mount test driving the real "Preview data" button through Settings.vue, asserting it works even when the toggle is off. Full `pnpm -C ui test`: 271+ passed. - Gates: `cargo fmt --check`, `cargo clippy --workspace --all-targets -D warnings`, `cargo check --workspace`, `pnpm -C ui lint`, `vue-tsc --noEmit` all clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QZQVP2tUuTLh8oL31D8heC --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 8ecced6 commit 95fbd9a

11 files changed

Lines changed: 735 additions & 37 deletions

File tree

src-tauri/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,9 @@ pub fn run() {
630630
telemetry::get_telemetry_enabled,
631631
telemetry::set_telemetry_enabled,
632632
telemetry::get_telemetry_install_id,
633+
// SPEC s16 telemetry preview: inspect the exact next-ping payload
634+
// without sending it (no network call, no side effects).
635+
telemetry::preview_telemetry_ping,
633636
// SPEC s11.4 activity (M7).
634637
commands::activity::query_activity,
635638
commands::activity::clear_activity_older_than,

src-tauri/src/telemetry.rs

Lines changed: 223 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@
2828
//! are pure functions the unit tests exercise directly; the production sink
2929
//! ([`HttpTelemetrySink`]) is the only part that touches the network and the
3030
//! tests never use it, so nothing here hits `driven.maxhogan.dev`.
31+
//!
32+
//! PREVIEW (SPEC s16 preview, #34): [`preview_telemetry_ping`] lets a
33+
//! privacy-conscious user inspect the EXACT next-ping payload from the
34+
//! Settings UI - even while telemetry is currently disabled - with NO network
35+
//! call and NO side effect (it never advances the delta checkpoint, never
36+
//! resets the latency reservoir). It shares [`resolve_payload`] with the live
37+
//! send path ([`maybe_send_once`]) so the two can never drift.
3138
3239
use std::time::Duration;
3340

@@ -614,17 +621,65 @@ fn delta_since_ms(now_ms: i64, last_sent_at: Option<i64>) -> i64 {
614621
}
615622
}
616623

624+
/// Resolve the EXACT wire payload a ping would carry RIGHT NOW - the install
625+
/// id (ensuring one exists, P1-3), the active channel, the DELTA event window
626+
/// `(last_sent_at, now]` (P2-3, capped at 24h), the coarse OS version, and a
627+
/// READ-ONLY latency snapshot - then hands them to [`build_payload`] (the
628+
/// single serialization path; nothing here hand-rolls JSON). Both the live
629+
/// send path ([`maybe_send_once`]) and the SPEC s16 preview command
630+
/// ([`preview_telemetry_ping`]) call this so the two can never drift: preview
631+
/// shows literally the same payload a real ping would build for this instant.
632+
///
633+
/// Side-effect note: `ensure_install_id` may WRITE a freshly-minted UUID v4 if
634+
/// none is stored yet (idempotent, stable thereafter - the same one-time mint
635+
/// `get_telemetry_install_id` already performs). Everything else here is
636+
/// read-only: `latency.snapshot()` does NOT drain the reservoir (only
637+
/// `reset()`, called by the caller after a SUCCESSFUL send, does), and
638+
/// `telemetry_events_since` is a pure aggregate query. So calling this to
639+
/// preview a payload advances no delta checkpoint and drops no latency
640+
/// samples - the actual next ping still aggregates the full window.
641+
async fn resolve_payload(
642+
state: &dyn StateRepo,
643+
version: String,
644+
now_ms: i64,
645+
last_sent_at: Option<i64>,
646+
latency: Option<&driven_core::telemetry::LatencyReservoir>,
647+
) -> CommandResult<TelemetryPayload> {
648+
let install_id = ensure_install_id(state).await?;
649+
let channel = read_channel(state)
650+
.await
651+
.unwrap_or_else(|_| "stable".to_string());
652+
let since_ms = delta_since_ms(now_ms, last_sent_at);
653+
let aggregate = state
654+
.telemetry_events_since(since_ms, now_ms)
655+
.await
656+
.map_err(CommandError::from)?;
657+
let os_version = coarse_os_version();
658+
// DESIGN s13: a READ-ONLY snapshot of the latency percentiles for this
659+
// window - never drains the reservoir (see doc comment above).
660+
let latency_pcts: LatencyP50P95 = latency.map(|r| r.snapshot().into()).unwrap_or_default();
661+
Ok(build_payload(
662+
install_id,
663+
now_ms,
664+
version,
665+
channel,
666+
os_version,
667+
aggregate,
668+
latency_pcts,
669+
))
670+
}
671+
617672
/// Gather + send ONE telemetry ping IF enabled (SPEC s16). Honors a disable
618673
/// IMMEDIATELY: it reads the pref at entry AND RE-READS it right before the send
619674
/// (P1-2), and also checks the optional `cancel` flag (flipped by
620675
/// `set_telemetry_enabled(false)`), so a toggle during the ensure-id / aggregate /
621-
/// build window aborts the send with NO network call. When enabled it ensures the
622-
/// install id, aggregates the DELTA window `(last_sent_at, now]` from the durable
623-
/// state (P2-3, capped at 24h), builds the payload, and sends it best-effort
624-
/// through `sink`. On a SUCCESSFUL send it records `last_sent_at = now` so the next
625-
/// ping reports only new events (restarts no longer double-count). Returns `true`
626-
/// if a send was attempted, `false` if telemetry was disabled / aborted (so tests
627-
/// can assert the no-network path).
676+
/// build window aborts the send with NO network call. When enabled it resolves
677+
/// the payload via [`resolve_payload`] (install id, DELTA window P2-3, latency
678+
/// snapshot) and sends it best-effort through `sink`. On a SUCCESSFUL send it
679+
/// records `last_sent_at = now` so the next ping reports only new events
680+
/// (restarts no longer double-count). Returns `true` if a send was attempted,
681+
/// `false` if telemetry was disabled / aborted (so tests can assert the
682+
/// no-network path).
628683
async fn maybe_send_once(
629684
state: &dyn StateRepo,
630685
version: String,
@@ -653,40 +708,13 @@ async fn maybe_send_once(
653708
tracing::debug!(target: TARGET, "telemetry cancelled before build; no ping sent");
654709
return false;
655710
}
656-
let install_id = match ensure_install_id(state).await {
657-
Ok(id) => id,
658-
Err(e) => {
659-
tracing::debug!(target: TARGET, error = %e, "telemetry: could not ensure install_id; skipping ping");
660-
return false;
661-
}
662-
};
663-
let channel = read_channel(state)
664-
.await
665-
.unwrap_or_else(|_| "stable".to_string());
666-
let since_ms = delta_since_ms(now_ms, prefs.last_sent_at);
667-
let aggregate = match state.telemetry_events_since(since_ms, now_ms).await {
668-
Ok(a) => a,
711+
let payload = match resolve_payload(state, version, now_ms, prefs.last_sent_at, latency).await {
712+
Ok(p) => p,
669713
Err(e) => {
670-
tracing::debug!(target: TARGET, error = %e, "telemetry: could not aggregate events; skipping ping");
714+
tracing::debug!(target: TARGET, error = %e, "telemetry: could not resolve payload; skipping ping");
671715
return false;
672716
}
673717
};
674-
let os_version = coarse_os_version();
675-
// DESIGN s13: a READ-ONLY snapshot of the latency percentiles for this
676-
// window. NOT reset here - only after a SUCCESSFUL send below, so a dropped
677-
// or aborted ping re-uses the same window's samples on the next attempt
678-
// (mirroring how the event-count aggregates re-send an un-checkpointed
679-
// window keyed on `last_sent_at`).
680-
let latency_pcts: LatencyP50P95 = latency.map(|r| r.snapshot().into()).unwrap_or_default();
681-
let payload = build_payload(
682-
install_id,
683-
now_ms,
684-
version,
685-
channel,
686-
os_version,
687-
aggregate,
688-
latency_pcts,
689-
);
690718

691719
// R3-P1-2 (SEND-ADMISSION GATE): acquire the shared gate, then do the final
692720
// cancel/pref re-check AND the network send WHILE HOLDING IT. The disable path
@@ -930,6 +958,47 @@ pub async fn get_telemetry_install_id(state: State<'_, AppState>) -> CommandResu
930958
ensure_install_id(state.state().as_ref()).await
931959
}
932960

961+
/// `preview_telemetry_ping()` - the telemetry preview feature: return the EXACT
962+
/// JSON payload the NEXT telemetry ping would send, WITHOUT sending it. No
963+
/// network call, and no side effect a real send has - the `last_sent_at` delta
964+
/// checkpoint is never advanced (so the real next ping still aggregates the
965+
/// full un-checkpointed window) and the latency reservoir is only snapshotted,
966+
/// never reset (see [`resolve_payload`]'s doc comment for the read-only
967+
/// guarantee).
968+
///
969+
/// Built through the SAME [`resolve_payload`] step the live ping path uses -
970+
/// nothing here reimplements the aggregation or serialization - so preview can
971+
/// never drift from what would actually be sent.
972+
///
973+
/// Available even when telemetry is currently DISABLED: that is the whole
974+
/// point of a preview - a privacy-conscious user inspects the payload BEFORE
975+
/// opting in, rather than having to enable it first to see what it looks like.
976+
///
977+
/// Returned as `serde_json::Value` (not the typed [`TelemetryPayload`]) so the
978+
/// webview renders literally the same JSON bytes a real send would POST,
979+
/// without a second TypeScript shape to keep in sync with the wire schema.
980+
#[tauri::command]
981+
pub async fn preview_telemetry_ping(
982+
app: AppHandle,
983+
state: State<'_, AppState>,
984+
) -> CommandResult<serde_json::Value> {
985+
let repo = state.state();
986+
let prefs = read_prefs(repo.as_ref()).await?;
987+
let version = app.package_info().version.to_string();
988+
let now_ms = driven_core::time::SystemClock.now_ms();
989+
let latency = state.telemetry_latency();
990+
let payload = resolve_payload(
991+
repo.as_ref(),
992+
version,
993+
now_ms,
994+
prefs.last_sent_at,
995+
Some(latency.as_ref()),
996+
)
997+
.await?;
998+
serde_json::to_value(&payload)
999+
.map_err(|e| CommandError::new(format!("could not serialize telemetry preview: {e}")))
1000+
}
1001+
9331002
#[cfg(test)]
9341003
mod tests {
9351004
use super::*;
@@ -1873,4 +1942,121 @@ mod tests {
18731942
"a bad custom CA must fail the telemetry send closed (no silent send)"
18741943
);
18751944
}
1945+
1946+
// -----------------------------------------------------------------------
1947+
// Telemetry preview (SPEC s16 preview): `resolve_payload` is exactly what
1948+
// `preview_telemetry_ping` calls (see its doc comment), so these tests
1949+
// exercise it directly rather than needing a live `AppHandle`.
1950+
// -----------------------------------------------------------------------
1951+
1952+
#[tokio::test]
1953+
async fn preview_resolves_the_same_shape_as_a_real_ping_without_sending() {
1954+
// The resolved payload has the exact SPEC s16 wire shape - same keys as
1955+
// a real ping - built via `resolve_payload` with NO `TelemetrySink`
1956+
// involved at all (no network seam, so there is nothing to fake-out).
1957+
let (repo, dir) = temp_repo().await;
1958+
let reservoir = driven_core::telemetry::LatencyReservoir::new(true);
1959+
let prefs = read_prefs(&repo).await.unwrap();
1960+
let payload = resolve_payload(
1961+
&repo,
1962+
"0.1.0".to_string(),
1963+
1_700_000_000_000,
1964+
prefs.last_sent_at,
1965+
Some(&reservoir),
1966+
)
1967+
.await
1968+
.unwrap();
1969+
assert!(
1970+
!payload.install_id.is_empty(),
1971+
"preview carries a real install_id"
1972+
);
1973+
assert_eq!(payload.version, "0.1.0");
1974+
let json = serde_json::to_value(&payload).unwrap();
1975+
let obj = json.as_object().unwrap();
1976+
let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
1977+
keys.sort_unstable();
1978+
assert_eq!(
1979+
keys,
1980+
vec![
1981+
"arch",
1982+
"channel",
1983+
"events_24h",
1984+
"install_id",
1985+
"latency_p50_p95_ms",
1986+
"os",
1987+
"os_version",
1988+
"ts",
1989+
"version",
1990+
],
1991+
"preview payload has the exact SPEC s16 wire shape"
1992+
);
1993+
cleanup(dir);
1994+
}
1995+
1996+
#[tokio::test]
1997+
async fn preview_works_when_telemetry_is_disabled() {
1998+
// SPEC s16 (telemetry preview): the whole point is letting a user
1999+
// inspect the payload BEFORE opting in, so `resolve_payload` must not
2000+
// gate on the enabled pref the way `maybe_send_once` does.
2001+
let (repo, dir) = temp_repo().await;
2002+
write_enabled(&repo, false).await.unwrap();
2003+
let prefs = read_prefs(&repo).await.unwrap();
2004+
assert!(!prefs.enabled);
2005+
let payload = resolve_payload(
2006+
&repo,
2007+
"0.1.0".to_string(),
2008+
1_700_000_000_000,
2009+
prefs.last_sent_at,
2010+
None,
2011+
)
2012+
.await
2013+
.unwrap();
2014+
assert!(
2015+
!payload.install_id.is_empty(),
2016+
"preview still builds a payload while telemetry is disabled"
2017+
);
2018+
cleanup(dir);
2019+
}
2020+
2021+
#[tokio::test]
2022+
async fn preview_does_not_advance_the_delta_checkpoint_or_reset_the_reservoir() {
2023+
// The no-side-effect guarantee (SPEC s16 preview): resolving a payload
2024+
// (what preview_telemetry_ping does) must leave `last_sent_at`
2025+
// untouched and must NOT drain the latency reservoir - only a
2026+
// SUCCESSFUL send (`maybe_send_once`'s post-send branch) may do
2027+
// either. Otherwise a preview would silently steal events/samples from
2028+
// the real next ping's window.
2029+
let (repo, dir) = temp_repo().await;
2030+
let reservoir = driven_core::telemetry::LatencyReservoir::new(true);
2031+
reservoir.record_scan_ms(42);
2032+
reservoir.record_upload_per_mb_ms(7);
2033+
let before = reservoir.snapshot();
2034+
assert!(!before.scan.is_empty());
2035+
2036+
let prefs_before = read_prefs(&repo).await.unwrap();
2037+
assert_eq!(prefs_before.last_sent_at, None, "fresh repo: never sent");
2038+
2039+
let now = 1_700_000_000_000i64;
2040+
let _payload = resolve_payload(
2041+
&repo,
2042+
"0.1.0".to_string(),
2043+
now,
2044+
prefs_before.last_sent_at,
2045+
Some(&reservoir),
2046+
)
2047+
.await
2048+
.unwrap();
2049+
2050+
let prefs_after = read_prefs(&repo).await.unwrap();
2051+
assert_eq!(
2052+
prefs_after.last_sent_at, None,
2053+
"preview must not advance the delta checkpoint"
2054+
);
2055+
let after = reservoir.snapshot();
2056+
assert_eq!(
2057+
after, before,
2058+
"preview must not reset/drain the latency reservoir"
2059+
);
2060+
cleanup(dir);
2061+
}
18762062
}

0 commit comments

Comments
 (0)