claude-utils 0.2.0

Cross-platform companion toolkit for Anthropic's Claude Code CLI
Documentation
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::env;
use std::path::PathBuf;
use tokio::time::Duration;
use tracing::{debug, warn};
use uuid::Uuid;

use crate::Result;

const TELEMETRY_ENDPOINT: &str = "https://api.claude-utils.dev/v1/events";
const TELEMETRY_TIMEOUT: Duration = Duration::from_secs(2);

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TelemetryEvent {
    pub event_id: String,
    pub device_id: String,
    pub session_id: String,
    pub event_type: EventType,
    pub timestamp: i64,
    pub version: String,
    pub os: String,
    pub arch: String,
    pub installation_source: String,
    pub properties: Option<serde_json::Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventType {
    AppStarted,
    WatchModeEnabled,
    WatchModeDisabled,
    ClipboardImageDetected,
    ClipboardImageStaged,
    ClipboardTextDetected,
    McpRequestReceived,
    McpToolCalled,
    ErrorOccurred,
    AppStopped,
}

pub struct TelemetryClient {
    enabled: bool,
    device_id: String,
    session_id: String,
    client: reqwest::Client,
}

impl TelemetryClient {
    pub fn new() -> Result<Self> {
        let enabled = Self::is_telemetry_enabled();
        let device_id = Self::get_or_create_device_id()?;
        let session_id = Uuid::new_v4().to_string();

        let client = reqwest::Client::builder()
            .timeout(TELEMETRY_TIMEOUT)
            .build()?;

        debug!(
            "Telemetry initialized: enabled={}, device_id={}",
            enabled, device_id
        );

        Ok(Self {
            enabled,
            device_id,
            session_id,
            client,
        })
    }

    pub async fn track(&self, event_type: EventType, properties: Option<serde_json::Value>) {
        if !self.enabled {
            return;
        }

        let event = TelemetryEvent {
            event_id: Uuid::new_v4().to_string(),
            device_id: self.device_id.clone(),
            session_id: self.session_id.clone(),
            event_type,
            timestamp: Utc::now().timestamp(),
            version: env!("CARGO_PKG_VERSION").to_string(),
            os: env::consts::OS.to_string(),
            arch: env::consts::ARCH.to_string(),
            installation_source: Self::detect_installation_source(),
            properties,
        };

        // Fire and forget - don't block the app
        let client = self.client.clone();
        let endpoint = TELEMETRY_ENDPOINT.to_string();
        
        tokio::spawn(async move {
            match client.post(&endpoint).json(&event).send().await {
                Ok(response) => {
                    if !response.status().is_success() {
                        debug!("Telemetry request failed: {}", response.status());
                    }
                }
                Err(e) => {
                    debug!("Telemetry request error: {}", e);
                }
            }
        });
    }

    pub async fn track_error(&self, error_type: &str) {
        self.track(
            EventType::ErrorOccurred,
            Some(serde_json::json!({
                "error_type": error_type,
                // Never send actual error messages - just the type
            })),
        )
        .await;
    }

    fn is_telemetry_enabled() -> bool {
        // Respect DO_NOT_TRACK standard
        if env::var("DO_NOT_TRACK").is_ok() {
            return false;
        }

        // Check our specific env var
        match env::var("CLAUDE_UTILS_TELEMETRY") {
            Ok(val) => {
                let val = val.to_lowercase();
                val != "0" && val != "false" && val != "no" && val != "off"
            }
            Err(_) => {
                // Default to enabled, but check if it's first run
                if Self::is_first_run() {
                    Self::show_telemetry_notice();
                }
                true
            }
        }
    }

    fn is_first_run() -> bool {
        let config_dir = Self::get_config_dir();
        !config_dir.join(".device-id").exists()
    }

    fn show_telemetry_notice() {
        eprintln!(
            r#"
╭─────────────────────────────────────────────────────────╮
│                    Anonymous Analytics                   │
├─────────────────────────────────────────────────────────┤
│ claude-utils collects anonymous usage data to improve   │
│ the tool. No personal information is ever collected.    │
│                                                         │
│ To opt out, set: CLAUDE_UTILS_TELEMETRY=0              │
│                                                         │
│ Learn more: https://github.com/josharsh/claude-utils    │
╰─────────────────────────────────────────────────────────╯
"#
        );
    }

    fn get_or_create_device_id() -> Result<String> {
        let config_dir = Self::get_config_dir();
        let id_file = config_dir.join(".device-id");

        if let Ok(id) = std::fs::read_to_string(&id_file) {
            Ok(id.trim().to_string())
        } else {
            // Create new device ID
            let id = Uuid::new_v4().to_string();
            
            // Ensure directory exists
            std::fs::create_dir_all(&config_dir)?;
            
            // Write device ID
            std::fs::write(&id_file, &id)?;
            
            Ok(id)
        }
    }

    fn get_config_dir() -> PathBuf {
        dirs::config_dir()
            .unwrap_or_else(|| PathBuf::from("."))
            .join("claude-utils")
    }

    fn detect_installation_source() -> String {
        if env::var("HOMEBREW_PREFIX").is_ok() {
            "homebrew".to_string()
        } else if env::var("CARGO_HOME").is_ok() {
            "cargo".to_string()
        } else if std::env::current_exe()
            .ok()
            .and_then(|p| p.to_str().map(|s| s.to_string()))
            .map(|p| p.contains("brew"))
            .unwrap_or(false)
        {
            "homebrew".to_string()
        } else {
            "unknown".to_string()
        }
    }

    pub fn is_enabled(&self) -> bool {
        self.enabled
    }
}

// Global telemetry instance
lazy_static::lazy_static! {
    static ref TELEMETRY: Option<TelemetryClient> = {
        match TelemetryClient::new() {
            Ok(client) => Some(client),
            Err(e) => {
                warn!("Failed to initialize telemetry: {}", e);
                None
            }
        }
    };
}

// Convenience functions
pub async fn track(event_type: EventType, properties: Option<serde_json::Value>) {
    if let Some(client) = TELEMETRY.as_ref() {
        client.track(event_type, properties).await;
    }
}

pub async fn track_error(error_type: &str) {
    if let Some(client) = TELEMETRY.as_ref() {
        client.track_error(error_type).await;
    }
}

pub fn is_enabled() -> bool {
    TELEMETRY.as_ref().map(|c| c.is_enabled()).unwrap_or(false)
}

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

    #[test]
    fn test_telemetry_opt_out() {
        env::set_var("CLAUDE_UTILS_TELEMETRY", "0");
        assert!(!TelemetryClient::is_telemetry_enabled());
        env::remove_var("CLAUDE_UTILS_TELEMETRY");
    }

    #[test]
    fn test_do_not_track() {
        env::set_var("DO_NOT_TRACK", "1");
        assert!(!TelemetryClient::is_telemetry_enabled());
        env::remove_var("DO_NOT_TRACK");
    }
}