Skip to main content

spawn_db/
telemetry.rs

1//! Telemetry module for anonymous usage data collection.
2//!
3//! This module collects anonymous usage statistics to help improve spawn.
4//! It is designed to be:
5//! - **Non-blocking**: Telemetry is sent by a detached child process
6//! - **Privacy-respecting**: No personal data is collected
7//! - **Fail-silent**: Errors are silently ignored
8//!
9//! ## Opt-out
10//!
11//! Telemetry can be disabled by:
12//! 1. Setting the `DO_NOT_TRACK` environment variable (any value)
13//! 2. Setting `telemetry = false` in `spawn.toml`
14//!
15//! ## Debugging
16//!
17//! Set `SPAWN_DEBUG_TELEMETRY=1` to enable debug output for telemetry.
18
19use crate::commands::TelemetryInfo;
20use serde::{Deserialize, Serialize};
21use std::env;
22use std::io::{Read, Write};
23use std::process::{Command, Stdio};
24use std::time::Instant;
25
26/// Check if telemetry debug mode is enabled
27fn debug_enabled() -> bool {
28    env::var("SPAWN_DEBUG_TELEMETRY").is_ok()
29}
30
31/// Print debug message if SPAWN_DEBUG_TELEMETRY is set
32macro_rules! debug_telemetry {
33    ($($arg:tt)*) => {
34        if debug_enabled() {
35            eprintln!("[telemetry] {}", format!($($arg)*));
36        }
37    };
38}
39
40/// PostHog API key for spawn telemetry
41const POSTHOG_API_KEY: &str = "phc_yD13QBdCJSnbIjmkTcSf03dRhpLJdCMfTVRzD7XTFqd";
42
43/// PostHog API endpoint (EU Cloud)
44const POSTHOG_ENDPOINT: &str = "https://eu.i.posthog.com/batch/";
45
46/// Application version from Cargo.toml
47const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
48
49/// A telemetry event to be sent (serializable for IPC)
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct TelemetryEvent {
52    pub distinct_id: String,
53    pub command: String,
54    pub duration_ms: u64,
55    pub status: CommandStatus,
56    pub error_kind: Option<String>,
57    pub properties: Vec<(String, String)>,
58}
59
60/// Spawn a detached child process to send telemetry events
61fn spawn_telemetry_child(events: &[TelemetryEvent]) {
62    if events.is_empty() {
63        return;
64    }
65
66    // Get the current executable path
67    let exe_path = match env::current_exe() {
68        Ok(path) => path,
69        Err(e) => {
70            debug_telemetry!("failed to get current exe: {:?}", e);
71            return;
72        }
73    };
74
75    // Serialize the events to JSON
76    let json = match serde_json::to_string(events) {
77        Ok(j) => j,
78        Err(e) => {
79            debug_telemetry!("failed to serialize events: {:?}", e);
80            return;
81        }
82    };
83
84    // Build the command
85    let mut cmd = Command::new(&exe_path);
86    cmd.arg("--internal-telemetry")
87        .stdin(Stdio::piped())
88        .stdout(Stdio::null())
89        .stderr(Stdio::null());
90
91    // Platform-specific detachment
92    #[cfg(windows)]
93    {
94        use std::os::windows::process::CommandExt;
95        // DETACHED_PROCESS (0x8) | CREATE_NO_WINDOW (0x08000000)
96        cmd.creation_flags(0x08000008);
97    }
98
99    // Spawn the child
100    let mut child = match cmd.spawn() {
101        Ok(c) => c,
102        Err(e) => {
103            debug_telemetry!("failed to spawn telemetry child: {:?}", e);
104            return;
105        }
106    };
107
108    // Write JSON to stdin
109    if let Some(mut stdin) = child.stdin.take() {
110        if let Err(e) = stdin.write_all(json.as_bytes()) {
111            debug_telemetry!("failed to write to child stdin: {:?}", e);
112        }
113        // stdin is dropped here, closing the pipe
114    }
115
116    debug_telemetry!(
117        "spawned telemetry child process for {} event(s)",
118        events.len()
119    );
120    // Do NOT call child.wait() - let it run independently
121}
122
123/// Telemetry recorder for tracking command execution.
124///
125/// Use `TelemetryRecorder::new()` at the start of command execution,
126/// then call `finish()` when the command completes.
127pub struct TelemetryRecorder {
128    enabled: bool,
129    distinct_id: String,
130    command: String,
131    properties: Vec<(String, String)>,
132    start_time: Instant,
133}
134
135/// Status of command execution for telemetry
136#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
137pub enum CommandStatus {
138    Success,
139    Error,
140}
141
142impl std::fmt::Display for CommandStatus {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        match self {
145            CommandStatus::Success => write!(f, "success"),
146            CommandStatus::Error => write!(f, "error"),
147        }
148    }
149}
150
151impl TelemetryRecorder {
152    /// Create a new telemetry recorder with a pre-recorded start time.
153    ///
154    /// Use this when you need to start timing before you have all the
155    /// telemetry configuration (e.g., before loading config from disk).
156    ///
157    /// Checks opt-out settings in priority order:
158    /// 1. `DO_NOT_TRACK` env var -> Disable
159    /// 2. `telemetry_enabled = false` -> Disable
160    /// 3. Otherwise -> Enable
161    ///
162    /// If no `project_id` is provided, generates an ephemeral UUID for this session.
163    pub fn with_start_time(
164        project_id: Option<&str>,
165        telemetry_enabled: bool,
166        info: TelemetryInfo,
167        start_time: Instant,
168    ) -> Self {
169        // Check DO_NOT_TRACK env var first (highest priority)
170        let do_not_track = env::var("DO_NOT_TRACK").is_ok();
171
172        // Determine if telemetry is enabled
173        let enabled = !do_not_track && telemetry_enabled;
174
175        // Get or generate distinct_id
176        // Ephemeral IDs are prefixed with "e-" to distinguish them in analytics
177        let distinct_id = if enabled {
178            project_id
179                .map(|s| s.to_string())
180                .unwrap_or_else(|| format!("e-{}", uuid::Uuid::new_v4()))
181        } else {
182            String::new()
183        };
184
185        // Convert properties to owned strings
186        let properties = info
187            .properties
188            .into_iter()
189            .map(|(k, v)| (k.to_string(), v))
190            .collect();
191
192        Self {
193            enabled,
194            distinct_id,
195            command: info.label,
196            properties,
197            start_time,
198        }
199    }
200
201    /// Create a new telemetry recorder, starting the timer now.
202    ///
203    /// Checks opt-out settings in priority order:
204    /// 1. `DO_NOT_TRACK` env var -> Disable
205    /// 2. `telemetry_enabled = false` -> Disable
206    /// 3. Otherwise -> Enable
207    ///
208    /// If no `project_id` is provided, generates an ephemeral UUID for this session.
209    pub fn new(project_id: Option<&str>, telemetry_enabled: bool, info: TelemetryInfo) -> Self {
210        Self::with_start_time(project_id, telemetry_enabled, info, Instant::now())
211    }
212
213    /// Finish recording and spawn a detached child process to send telemetry.
214    ///
215    /// This method consumes the recorder and spawns a background process.
216    /// The main process can exit immediately without waiting.
217    pub fn finish(self, status: CommandStatus, error_kind: Option<&str>) {
218        debug_telemetry!("finish() called, enabled={}", self.enabled);
219        if !self.enabled {
220            return;
221        }
222
223        let event = TelemetryEvent {
224            distinct_id: self.distinct_id,
225            command: self.command,
226            duration_ms: self.start_time.elapsed().as_millis() as u64,
227            status,
228            error_kind: error_kind.map(|s| s.to_string()),
229            properties: self.properties,
230        };
231
232        debug_telemetry!(
233            "spawning child for event: command={}, distinct_id={}, duration_ms={}",
234            event.command,
235            event.distinct_id,
236            event.duration_ms
237        );
238
239        // Spawn detached child process to send the event
240        spawn_telemetry_child(&[event]);
241    }
242}
243
244/// Send multiple telemetry events via a detached child process.
245///
246/// This is useful when you have collected multiple events and want to
247/// send them all in a single child process.
248pub fn send_events(events: Vec<TelemetryEvent>) {
249    if events.is_empty() {
250        return;
251    }
252    debug_telemetry!("sending {} event(s)", events.len());
253    spawn_telemetry_child(&events);
254}
255
256/// Send telemetry events to PostHog using the batch API
257async fn send_events_to_posthog(events: &[TelemetryEvent]) -> Result<(), reqwest::Error> {
258    // Build batch payload
259    let batch: Vec<serde_json::Value> = events
260        .iter()
261        .map(|event| {
262            let mut props = serde_json::Map::new();
263            props.insert(
264                "distinct_id".to_string(),
265                serde_json::json!(event.distinct_id),
266            );
267            props.insert("app_version".to_string(), serde_json::json!(APP_VERSION));
268            props.insert(
269                "os_platform".to_string(),
270                serde_json::json!(std::env::consts::OS),
271            );
272            props.insert(
273                "os_arch".to_string(),
274                serde_json::json!(std::env::consts::ARCH),
275            );
276            props.insert("is_ci".to_string(), serde_json::json!(is_ci()));
277            props.insert("command".to_string(), serde_json::json!(event.command));
278            props.insert(
279                "duration_ms".to_string(),
280                serde_json::json!(event.duration_ms),
281            );
282            props.insert(
283                "status".to_string(),
284                serde_json::json!(event.status.to_string()),
285            );
286            props.insert("$lib".to_string(), serde_json::json!("spawn"));
287            props.insert("$lib_version".to_string(), serde_json::json!(APP_VERSION));
288            // Don't create person profiles for CLI telemetry
289            props.insert(
290                "$process_person_profile".to_string(),
291                serde_json::json!(false),
292            );
293
294            if let Some(ref kind) = event.error_kind {
295                props.insert("error_kind".to_string(), serde_json::json!(kind));
296            }
297
298            for (key, value) in &event.properties {
299                props.insert(key.clone(), serde_json::json!(value));
300            }
301
302            serde_json::json!({
303                "event": "command_completed",
304                "properties": props
305            })
306        })
307        .collect();
308
309    let payload = serde_json::json!({
310        "api_key": POSTHOG_API_KEY,
311        "batch": batch
312    });
313
314    debug_telemetry!("POST to {}", POSTHOG_ENDPOINT);
315    debug_telemetry!(
316        "payload: {}",
317        serde_json::to_string_pretty(&payload).unwrap_or_default()
318    );
319
320    let client = reqwest::Client::new();
321    let response = client
322        .post(POSTHOG_ENDPOINT)
323        .header("Content-Type", "application/json")
324        .json(&payload)
325        .send()
326        .await?;
327
328    let status = response.status();
329    let body = response.text().await.unwrap_or_default();
330
331    debug_telemetry!("response status: {}, body: {}", status, body);
332
333    Ok(())
334}
335
336/// Check if running in a CI environment
337fn is_ci() -> bool {
338    // Common CI environment variables
339    env::var("CI").is_ok()
340        || env::var("CONTINUOUS_INTEGRATION").is_ok()
341        || env::var("GITHUB_ACTIONS").is_ok()
342        || env::var("GITLAB_CI").is_ok()
343        || env::var("CIRCLECI").is_ok()
344        || env::var("TRAVIS").is_ok()
345        || env::var("JENKINS_URL").is_ok()
346        || env::var("BUILDKITE").is_ok()
347        || env::var("TEAMCITY_VERSION").is_ok()
348}
349
350/// Run the internal telemetry handler (called by child process).
351///
352/// This reads JSON-serialized TelemetryEvents from stdin and sends them to PostHog.
353/// This function is meant to be called when the binary is invoked with `--internal-telemetry`.
354pub fn run_internal_telemetry() {
355    // Read JSON from stdin
356    let mut input = String::new();
357    if let Err(e) = std::io::stdin().read_to_string(&mut input) {
358        debug_telemetry!("failed to read stdin: {:?}", e);
359        return;
360    }
361
362    // Parse the events
363    let events: Vec<TelemetryEvent> = match serde_json::from_str(&input) {
364        Ok(e) => e,
365        Err(e) => {
366            debug_telemetry!("failed to parse events JSON: {:?}", e);
367            return;
368        }
369    };
370
371    if events.is_empty() {
372        debug_telemetry!("no events to send");
373        return;
374    }
375
376    debug_telemetry!("child received {} event(s)", events.len());
377
378    // Create a minimal tokio runtime just for the HTTP call
379    let rt = match tokio::runtime::Builder::new_current_thread()
380        .enable_all()
381        .build()
382    {
383        Ok(rt) => rt,
384        Err(e) => {
385            debug_telemetry!("failed to create runtime: {:?}", e);
386            return;
387        }
388    };
389
390    // Send all events in a single batch request
391    let result = rt.block_on(send_events_to_posthog(&events));
392
393    match result {
394        Ok(()) => debug_telemetry!("successfully sent {} event(s)", events.len()),
395        Err(e) => debug_telemetry!("failed to send events: {:?}", e),
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use std::sync::Mutex;
403
404    // Mutex to serialize tests that modify environment variables
405    static ENV_MUTEX: Mutex<()> = Mutex::new(());
406
407    #[test]
408    fn test_do_not_track_disables_telemetry() {
409        let _guard = ENV_MUTEX.lock().unwrap();
410        env::set_var("DO_NOT_TRACK", "1");
411        let recorder = TelemetryRecorder::new(Some("test-id"), true, TelemetryInfo::new("test"));
412        assert!(!recorder.enabled);
413        env::remove_var("DO_NOT_TRACK");
414    }
415
416    #[test]
417    fn test_telemetry_enabled_false_disables() {
418        let _guard = ENV_MUTEX.lock().unwrap();
419        env::remove_var("DO_NOT_TRACK"); // Ensure clean state
420        let recorder = TelemetryRecorder::new(Some("test-id"), false, TelemetryInfo::new("test"));
421        assert!(!recorder.enabled);
422    }
423
424    #[test]
425    fn test_uses_project_id_as_distinct_id() {
426        let _guard = ENV_MUTEX.lock().unwrap();
427        env::remove_var("DO_NOT_TRACK"); // Ensure clean state
428        let recorder =
429            TelemetryRecorder::new(Some("my-project-123"), true, TelemetryInfo::new("test"));
430        assert!(recorder.enabled);
431        assert_eq!(recorder.distinct_id, "my-project-123");
432    }
433
434    #[test]
435    fn test_generates_ephemeral_id_without_project_id() {
436        let _guard = ENV_MUTEX.lock().unwrap();
437        env::remove_var("DO_NOT_TRACK"); // Ensure clean state
438        let recorder = TelemetryRecorder::new(None, true, TelemetryInfo::new("test"));
439        assert!(recorder.enabled);
440        // Should be prefixed with "e-" and contain a valid UUID
441        assert!(recorder.distinct_id.starts_with("e-"));
442        let uuid_part = recorder.distinct_id.strip_prefix("e-").unwrap();
443        assert!(uuid::Uuid::parse_str(uuid_part).is_ok());
444    }
445
446    #[test]
447    fn test_properties_are_stored() {
448        let _guard = ENV_MUTEX.lock().unwrap();
449        env::remove_var("DO_NOT_TRACK"); // Ensure clean state
450        let recorder = TelemetryRecorder::new(
451            Some("test-id"),
452            true,
453            TelemetryInfo::new("migration build")
454                .with_properties(vec![("opt_pinned", "true".to_string())]),
455        );
456        assert_eq!(recorder.command, "migration build");
457        assert_eq!(recorder.properties.len(), 1);
458        assert_eq!(
459            recorder.properties[0],
460            ("opt_pinned".to_string(), "true".to_string())
461        );
462    }
463}