sparrow-cli 0.6.2

A local-first Rust agent cockpit — route, run, replay, rewind
Documentation
//! Telemetry — opt-in, off by default, content-free.
//!
//! Sparrow does not collect telemetry unless the user explicitly enables it
//! via `sparrow telemetry enable`. This module provides the skeleton so the
//! rest of the codebase can call `telemetry::event(..)` unconditionally
//! without polluting the event stream when telemetry is off (the default).
//!
//! When enabled, the only data ever sent is:
//!   - schema version
//!   - sparrow version
//!   - OS/arch
//!   - event name (one of a closed enum, see `EventKind`)
//!   - counters (latency_ms, success bool, tool name from a closed list)
//!
//! Never sent: prompts, tool outputs, file contents, file paths, provider
//! responses, identifiers other than a locally-generated random install UUID
//! the user can rotate with `sparrow telemetry rotate-id`.
//!
//! See PRIVACY.md for the full policy.

use serde::{Deserialize, Serialize};
use std::path::PathBuf;

/// Closed enumeration of every event kind that can ever be sent.
/// Adding a variant here is a deliberate policy decision and must be
/// documented in PRIVACY.md.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventKind {
    /// CLI was invoked. No subcommand or args.
    Startup,
    /// A run reached the success path.
    RunSuccess,
    /// A run was aborted by the user (Ctrl+C, /stop).
    RunCancelled,
    /// A run hit a hard stop (budget, wall-clock, tokens).
    RunHardStop,
    /// A run failed with an unrecoverable error.
    RunFailed,
}

/// Stored telemetry preference, read from `~/.sparrow/telemetry.toml`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TelemetryConfig {
    /// Master switch. False until the user runs `sparrow telemetry enable`.
    pub enabled: bool,
    /// Random per-install id, regenerated by `sparrow telemetry rotate-id`.
    pub install_id: String,
}

impl Default for TelemetryConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            install_id: String::new(),
        }
    }
}

impl TelemetryConfig {
    pub fn path(state_dir: &std::path::Path) -> PathBuf {
        state_dir.join("telemetry.toml")
    }

    pub fn load(state_dir: &std::path::Path) -> Self {
        let p = Self::path(state_dir);
        if !p.exists() {
            return Self::default();
        }
        std::fs::read_to_string(&p)
            .ok()
            .and_then(|s| toml::from_str(&s).ok())
            .unwrap_or_default()
    }

    pub fn save(&self, state_dir: &std::path::Path) -> anyhow::Result<()> {
        std::fs::create_dir_all(state_dir).ok();
        let p = Self::path(state_dir);
        std::fs::write(p, toml::to_string_pretty(self)?)?;
        Ok(())
    }
}

/// Fire a telemetry event. No-op unless telemetry is explicitly enabled.
/// This function is intentionally infallible and side-effect-free in the
/// default (off) state so callers do not need to guard at every call site.
pub fn event(_state_dir: &std::path::Path, _kind: EventKind, _success: Option<bool>) {
    // The default build ships with telemetry compiled in as a no-op.
    // A future build flag (`--features telemetry-collector`) will route the
    // closed-enum events to an opt-in endpoint documented in PRIVACY.md.
    // For now the function exists so the rest of the codebase can call it
    // without conditional compilation.
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_is_disabled() {
        let cfg = TelemetryConfig::default();
        assert!(!cfg.enabled);
        assert!(cfg.install_id.is_empty());
    }

    #[test]
    fn round_trip_through_tempdir() {
        let dir = tempfile::tempdir().unwrap();
        let cfg = TelemetryConfig {
            enabled: false,
            install_id: "abcdef".into(),
        };
        cfg.save(dir.path()).unwrap();
        let back = TelemetryConfig::load(dir.path());
        assert_eq!(back.install_id, "abcdef");
        assert!(!back.enabled);
    }

    #[test]
    fn event_is_noop_when_disabled() {
        let dir = tempfile::tempdir().unwrap();
        // Should not panic or fail — telemetry off by default.
        event(dir.path(), EventKind::Startup, None);
        event(dir.path(), EventKind::RunSuccess, Some(true));
    }
}