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,
};
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,
})),
)
.await;
}
fn is_telemetry_enabled() -> bool {
if env::var("DO_NOT_TRACK").is_ok() {
return false;
}
match env::var("CLAUDE_UTILS_TELEMETRY") {
Ok(val) => {
let val = val.to_lowercase();
val != "0" && val != "false" && val != "no" && val != "off"
}
Err(_) => {
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 {
let id = Uuid::new_v4().to_string();
std::fs::create_dir_all(&config_dir)?;
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
}
}
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
}
}
};
}
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");
}
}