|
28 | 28 | //! are pure functions the unit tests exercise directly; the production sink |
29 | 29 | //! ([`HttpTelemetrySink`]) is the only part that touches the network and the |
30 | 30 | //! 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. |
31 | 38 |
|
32 | 39 | use std::time::Duration; |
33 | 40 |
|
@@ -614,17 +621,65 @@ fn delta_since_ms(now_ms: i64, last_sent_at: Option<i64>) -> i64 { |
614 | 621 | } |
615 | 622 | } |
616 | 623 |
|
| 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 | + |
617 | 672 | /// Gather + send ONE telemetry ping IF enabled (SPEC s16). Honors a disable |
618 | 673 | /// IMMEDIATELY: it reads the pref at entry AND RE-READS it right before the send |
619 | 674 | /// (P1-2), and also checks the optional `cancel` flag (flipped by |
620 | 675 | /// `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). |
628 | 683 | async fn maybe_send_once( |
629 | 684 | state: &dyn StateRepo, |
630 | 685 | version: String, |
@@ -653,40 +708,13 @@ async fn maybe_send_once( |
653 | 708 | tracing::debug!(target: TARGET, "telemetry cancelled before build; no ping sent"); |
654 | 709 | return false; |
655 | 710 | } |
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, |
669 | 713 | 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"); |
671 | 715 | return false; |
672 | 716 | } |
673 | 717 | }; |
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 | | - ); |
690 | 718 |
|
691 | 719 | // R3-P1-2 (SEND-ADMISSION GATE): acquire the shared gate, then do the final |
692 | 720 | // 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 |
930 | 958 | ensure_install_id(state.state().as_ref()).await |
931 | 959 | } |
932 | 960 |
|
| 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 | + |
933 | 1002 | #[cfg(test)] |
934 | 1003 | mod tests { |
935 | 1004 | use super::*; |
@@ -1873,4 +1942,121 @@ mod tests { |
1873 | 1942 | "a bad custom CA must fail the telemetry send closed (no silent send)" |
1874 | 1943 | ); |
1875 | 1944 | } |
| 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 | + } |
1876 | 2062 | } |
0 commit comments