Skip to main content

sparrow/
telemetry.rs

1//! Telemetry — opt-in, off by default, content-free.
2//!
3//! Sparrow does not collect telemetry unless the user explicitly enables it
4//! via `sparrow telemetry enable`. This module provides the skeleton so the
5//! rest of the codebase can call `telemetry::event(..)` unconditionally
6//! without polluting the event stream when telemetry is off (the default).
7//!
8//! When enabled, the only data ever sent is:
9//!   - schema version
10//!   - sparrow version
11//!   - OS/arch
12//!   - event name (one of a closed enum, see `EventKind`)
13//!   - counters (latency_ms, success bool, tool name from a closed list)
14//!
15//! Never sent: prompts, tool outputs, file contents, file paths, provider
16//! responses, identifiers other than a locally-generated random install UUID
17//! the user can rotate with `sparrow telemetry rotate-id`.
18//!
19//! See PRIVACY.md for the full policy.
20
21use serde::{Deserialize, Serialize};
22use std::path::PathBuf;
23
24/// Closed enumeration of every event kind that can ever be sent.
25/// Adding a variant here is a deliberate policy decision and must be
26/// documented in PRIVACY.md.
27#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum EventKind {
30    /// CLI was invoked. No subcommand or args.
31    Startup,
32    /// A run reached the success path.
33    RunSuccess,
34    /// A run was aborted by the user (Ctrl+C, /stop).
35    RunCancelled,
36    /// A run hit a hard stop (budget, wall-clock, tokens).
37    RunHardStop,
38    /// A run failed with an unrecoverable error.
39    RunFailed,
40}
41
42/// Stored telemetry preference, read from `~/.sparrow/telemetry.toml`.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct TelemetryConfig {
45    /// Master switch. False until the user runs `sparrow telemetry enable`.
46    pub enabled: bool,
47    /// Random per-install id, regenerated by `sparrow telemetry rotate-id`.
48    pub install_id: String,
49}
50
51impl Default for TelemetryConfig {
52    fn default() -> Self {
53        Self {
54            enabled: false,
55            install_id: String::new(),
56        }
57    }
58}
59
60impl TelemetryConfig {
61    pub fn path(state_dir: &std::path::Path) -> PathBuf {
62        state_dir.join("telemetry.toml")
63    }
64
65    pub fn load(state_dir: &std::path::Path) -> Self {
66        let p = Self::path(state_dir);
67        if !p.exists() {
68            return Self::default();
69        }
70        std::fs::read_to_string(&p)
71            .ok()
72            .and_then(|s| toml::from_str(&s).ok())
73            .unwrap_or_default()
74    }
75
76    pub fn save(&self, state_dir: &std::path::Path) -> anyhow::Result<()> {
77        std::fs::create_dir_all(state_dir).ok();
78        let p = Self::path(state_dir);
79        std::fs::write(p, toml::to_string_pretty(self)?)?;
80        Ok(())
81    }
82}
83
84/// Fire a telemetry event. No-op unless telemetry is explicitly enabled.
85/// This function is intentionally infallible and side-effect-free in the
86/// default (off) state so callers do not need to guard at every call site.
87pub fn event(_state_dir: &std::path::Path, _kind: EventKind, _success: Option<bool>) {
88    // The default build ships with telemetry compiled in as a no-op.
89    // A future build flag (`--features telemetry-collector`) will route the
90    // closed-enum events to an opt-in endpoint documented in PRIVACY.md.
91    // For now the function exists so the rest of the codebase can call it
92    // without conditional compilation.
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn default_is_disabled() {
101        let cfg = TelemetryConfig::default();
102        assert!(!cfg.enabled);
103        assert!(cfg.install_id.is_empty());
104    }
105
106    #[test]
107    fn round_trip_through_tempdir() {
108        let dir = tempfile::tempdir().unwrap();
109        let cfg = TelemetryConfig {
110            enabled: false,
111            install_id: "abcdef".into(),
112        };
113        cfg.save(dir.path()).unwrap();
114        let back = TelemetryConfig::load(dir.path());
115        assert_eq!(back.install_id, "abcdef");
116        assert!(!back.enabled);
117    }
118
119    #[test]
120    fn event_is_noop_when_disabled() {
121        let dir = tempfile::tempdir().unwrap();
122        // Should not panic or fail — telemetry off by default.
123        event(dir.path(), EventKind::Startup, None);
124        event(dir.path(), EventKind::RunSuccess, Some(true));
125    }
126}