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}