diff --git a/Cargo.lock b/Cargo.lock index 279213138..2506235d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -359,12 +359,20 @@ name = "arc-util" version = "0.1.0" dependencies = [ "aho-corasick", + "anyhow", + "base64", + "chrono", "console 0.15.11", + "dirs", "regex", + "reqwest 0.12.28", "serde", "serde_json", "termimad", + "tokio", "toml", + "tracing", + "uuid", ] [[package]] diff --git a/crates/arc-util/Cargo.toml b/crates/arc-util/Cargo.toml index b533056d8..a2c746cd0 100644 --- a/crates/arc-util/Cargo.toml +++ b/crates/arc-util/Cargo.toml @@ -14,6 +14,15 @@ regex.workspace = true termimad.workspace = true aho-corasick.workspace = true serde_json.workspace = true +serde.workspace = true +reqwest.workspace = true +tokio.workspace = true +uuid.workspace = true +chrono.workspace = true +base64.workspace = true +dirs.workspace = true +tracing.workspace = true +anyhow.workspace = true [build-dependencies] toml = "0.8" diff --git a/crates/arc-util/src/lib.rs b/crates/arc-util/src/lib.rs index bab7da24d..330c64d81 100644 --- a/crates/arc-util/src/lib.rs +++ b/crates/arc-util/src/lib.rs @@ -1,3 +1,4 @@ pub mod check_report; pub mod redact; +pub mod telemetry; pub mod terminal; diff --git a/crates/arc-util/src/telemetry/anonymous_id.rs b/crates/arc-util/src/telemetry/anonymous_id.rs new file mode 100644 index 000000000..8fc4b0f4e --- /dev/null +++ b/crates/arc-util/src/telemetry/anonymous_id.rs @@ -0,0 +1,47 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::path::PathBuf; +use uuid::Uuid; + +fn anonymous_id_path() -> Result { + let home = dirs::home_dir().context("could not determine home directory")?; + Ok(home.join(".arc").join("anonymous_id")) +} + +pub fn load_or_create_anonymous_id() -> Result { + let path = anonymous_id_path()?; + + if let Ok(contents) = fs::read_to_string(&path) { + let id = contents.trim().to_string(); + if !id.is_empty() { + return Ok(id); + } + } + + let id = Uuid::new_v4().to_string(); + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; + } + + fs::write(&path, &id) + .with_context(|| format!("failed to write anonymous id to {}", path.display()))?; + + Ok(id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_or_create_returns_stable_id() { + // Calls the real function, which uses ~/.arc/anonymous_id + let id1 = load_or_create_anonymous_id().unwrap(); + let id2 = load_or_create_anonymous_id().unwrap(); + assert_eq!(id1, id2); + // Should be a valid UUID + Uuid::parse_str(&id1).unwrap(); + } +} diff --git a/crates/arc-util/src/telemetry/context.rs b/crates/arc-util/src/telemetry/context.rs new file mode 100644 index 000000000..f0ce2b5ab --- /dev/null +++ b/crates/arc-util/src/telemetry/context.rs @@ -0,0 +1,75 @@ +use serde_json::{json, Value}; + +pub fn build_context() -> Value { + json!({ + "os": { "name": std::env::consts::OS }, + "device": { "type": std::env::consts::ARCH }, + "locale": current_locale(), + "app": { "name": "arc", "version": env!("CARGO_PKG_VERSION") } + }) +} + +fn current_locale() -> String { + let lang = std::env::var("LANG").unwrap_or_default(); + parse_locale(&lang) +} + +fn parse_locale(lang: &str) -> String { + if lang.is_empty() || lang == "C" || lang == "POSIX" { + return "en-US".to_string(); + } + + // Strip encoding suffix (e.g. ".UTF-8") + let without_encoding = lang.split('.').next().unwrap_or(lang); + + // Convert underscore to hyphen (e.g. "en_US" -> "en-US") + without_encoding.replace('_', "-") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn context_has_expected_keys() { + let ctx = build_context(); + assert!(ctx.get("os").is_some()); + assert!(ctx["os"].get("name").is_some()); + assert!(ctx.get("device").is_some()); + assert!(ctx["device"].get("type").is_some()); + assert!(ctx.get("locale").is_some()); + assert!(ctx.get("app").is_some()); + assert_eq!(ctx["app"]["name"], "arc"); + assert!(ctx["app"].get("version").is_some()); + } + + #[test] + fn parse_locale_en_us_utf8() { + assert_eq!(parse_locale("en_US.UTF-8"), "en-US"); + } + + #[test] + fn parse_locale_c() { + assert_eq!(parse_locale("C"), "en-US"); + } + + #[test] + fn parse_locale_posix() { + assert_eq!(parse_locale("POSIX"), "en-US"); + } + + #[test] + fn parse_locale_empty() { + assert_eq!(parse_locale(""), "en-US"); + } + + #[test] + fn parse_locale_no_encoding() { + assert_eq!(parse_locale("fr_FR"), "fr-FR"); + } + + #[test] + fn parse_locale_with_encoding() { + assert_eq!(parse_locale("de_DE.ISO-8859-1"), "de-DE"); + } +} diff --git a/crates/arc-util/src/telemetry/event.rs b/crates/arc-util/src/telemetry/event.rs new file mode 100644 index 000000000..a04b7662d --- /dev/null +++ b/crates/arc-util/src/telemetry/event.rs @@ -0,0 +1,122 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Track { + #[serde(flatten)] + pub user: User, + pub event: String, + pub properties: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, + #[serde(rename = "messageId")] + pub message_id: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum User { + Both { + #[serde(rename = "userId")] + user_id: String, + #[serde(rename = "anonymousId")] + anonymous_id: String, + }, + UserId { + #[serde(rename = "userId")] + user_id: String, + }, + AnonymousId { + #[serde(rename = "anonymousId")] + anonymous_id: String, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn track_serialization_anonymous() { + let track = Track { + user: User::AnonymousId { + anonymous_id: "abc-123".to_string(), + }, + event: "Command Invoked".to_string(), + properties: json!({"command": "run"}), + context: Some(json!({"os": {"name": "macos"}})), + timestamp: Some("2025-01-01T00:00:00Z".to_string()), + message_id: "msg-1".to_string(), + }; + + let value = serde_json::to_value(&track).unwrap(); + assert_eq!(value["anonymousId"], "abc-123"); + assert_eq!(value["event"], "Command Invoked"); + assert_eq!(value["properties"]["command"], "run"); + assert_eq!(value["context"]["os"]["name"], "macos"); + assert_eq!(value["timestamp"], "2025-01-01T00:00:00Z"); + assert_eq!(value["messageId"], "msg-1"); + assert!(value.get("userId").is_none()); + } + + #[test] + fn track_serialization_user_id() { + let track = Track { + user: User::UserId { + user_id: "user-456".to_string(), + }, + event: "test".to_string(), + properties: json!({}), + context: None, + timestamp: None, + message_id: "msg-2".to_string(), + }; + + let value = serde_json::to_value(&track).unwrap(); + assert_eq!(value["userId"], "user-456"); + assert!(value.get("anonymousId").is_none()); + assert!(value.get("context").is_none()); + assert!(value.get("timestamp").is_none()); + } + + #[test] + fn track_serialization_both() { + let track = Track { + user: User::Both { + user_id: "user-456".to_string(), + anonymous_id: "abc-123".to_string(), + }, + event: "test".to_string(), + properties: json!({}), + context: None, + timestamp: None, + message_id: "msg-3".to_string(), + }; + + let value = serde_json::to_value(&track).unwrap(); + assert_eq!(value["userId"], "user-456"); + assert_eq!(value["anonymousId"], "abc-123"); + } + + #[test] + fn track_round_trip() { + let track = Track { + user: User::AnonymousId { + anonymous_id: "abc".to_string(), + }, + event: "test".to_string(), + properties: json!({"key": "value"}), + context: Some(json!({"app": {"name": "arc"}})), + timestamp: Some("2025-06-01T12:00:00Z".to_string()), + message_id: "msg-rt".to_string(), + }; + + let json_str = serde_json::to_string(&track).unwrap(); + let deserialized: Track = serde_json::from_str(&json_str).unwrap(); + assert_eq!(deserialized.event, "test"); + assert_eq!(deserialized.message_id, "msg-rt"); + } +} diff --git a/crates/arc-util/src/telemetry/mod.rs b/crates/arc-util/src/telemetry/mod.rs new file mode 100644 index 000000000..4b4075ad1 --- /dev/null +++ b/crates/arc-util/src/telemetry/mod.rs @@ -0,0 +1,154 @@ +pub mod anonymous_id; +pub mod context; +pub mod event; +pub mod sender; + +use anyhow::Result; +use chrono::Utc; +use serde_json::Value; +use uuid::Uuid; + +use event::{Track, User}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TelemetryLevel { + Off, + Errors, + All, +} + +pub struct Telemetry { + level: TelemetryLevel, + anonymous_id: String, + context: Value, +} + +impl Telemetry { + pub fn new() -> Result { + let level = telemetry_level(); + let anonymous_id = anonymous_id::load_or_create_anonymous_id()?; + let context = context::build_context(); + + Ok(Self { + level, + anonymous_id, + context, + }) + } + + pub fn level(&self) -> &TelemetryLevel { + &self.level + } + + pub fn should_track(&self, is_error: bool) -> bool { + match self.level { + TelemetryLevel::Off => false, + TelemetryLevel::Errors => is_error, + TelemetryLevel::All => true, + } + } + + pub fn build_track(&self, event: &str, properties: Value) -> Track { + Track { + user: User::AnonymousId { + anonymous_id: self.anonymous_id.clone(), + }, + event: event.to_string(), + properties, + context: Some(self.context.clone()), + timestamp: Some(Utc::now().to_rfc3339()), + message_id: Uuid::new_v4().to_string(), + } + } +} + +pub fn telemetry_level() -> TelemetryLevel { + match std::env::var("ARC_TELEMETRY").as_deref() { + Ok("off") => TelemetryLevel::Off, + Ok("errors") => TelemetryLevel::Errors, + Ok("all") => TelemetryLevel::All, + _ => { + if cfg!(debug_assertions) { + TelemetryLevel::Off + } else { + TelemetryLevel::All + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn telemetry_level_defaults_to_off_in_debug() { + // In test builds (debug_assertions=true), default is Off + std::env::remove_var("ARC_TELEMETRY"); + assert_eq!(telemetry_level(), TelemetryLevel::Off); + } + + #[test] + fn telemetry_level_parses_env_var() { + std::env::set_var("ARC_TELEMETRY", "all"); + assert_eq!(telemetry_level(), TelemetryLevel::All); + + std::env::set_var("ARC_TELEMETRY", "errors"); + assert_eq!(telemetry_level(), TelemetryLevel::Errors); + + std::env::set_var("ARC_TELEMETRY", "off"); + assert_eq!(telemetry_level(), TelemetryLevel::Off); + + // Clean up + std::env::remove_var("ARC_TELEMETRY"); + } + + #[test] + fn should_track_respects_level() { + let telemetry = Telemetry { + level: TelemetryLevel::Off, + anonymous_id: "test".to_string(), + context: json!({}), + }; + assert!(!telemetry.should_track(false)); + assert!(!telemetry.should_track(true)); + + let telemetry = Telemetry { + level: TelemetryLevel::Errors, + anonymous_id: "test".to_string(), + context: json!({}), + }; + assert!(!telemetry.should_track(false)); + assert!(telemetry.should_track(true)); + + let telemetry = Telemetry { + level: TelemetryLevel::All, + anonymous_id: "test".to_string(), + context: json!({}), + }; + assert!(telemetry.should_track(false)); + assert!(telemetry.should_track(true)); + } + + #[test] + fn build_track_populates_fields() { + let telemetry = Telemetry { + level: TelemetryLevel::All, + anonymous_id: "anon-123".to_string(), + context: json!({"app": {"name": "arc"}}), + }; + + let track = telemetry.build_track("Test Event", json!({"key": "value"})); + assert_eq!(track.event, "Test Event"); + assert_eq!(track.properties["key"], "value"); + assert!(track.context.is_some()); + assert!(track.timestamp.is_some()); + assert!(!track.message_id.is_empty()); + + match &track.user { + User::AnonymousId { anonymous_id } => assert_eq!(anonymous_id, "anon-123"), + _ => panic!("expected AnonymousId variant"), + } + } +} diff --git a/crates/arc-util/src/telemetry/sender.rs b/crates/arc-util/src/telemetry/sender.rs new file mode 100644 index 000000000..a37f105e8 --- /dev/null +++ b/crates/arc-util/src/telemetry/sender.rs @@ -0,0 +1,71 @@ +use base64::Engine; +use base64::engine::general_purpose::STANDARD; + +use super::event::Track; + +const SEGMENT_API_URL: &str = "https://api.segment.io/v1/track"; +const SEGMENT_WRITE_KEY: Option<&str> = option_env!("SEGMENT_WRITE_KEY"); + +/// Spawns a fire-and-forget tokio task to send a track event to Segment. +/// No-ops if the SEGMENT_WRITE_KEY was not set at compile time. +pub fn send(track: Track) { + let Some(write_key) = SEGMENT_WRITE_KEY else { + tracing::debug!("telemetry: no SEGMENT_WRITE_KEY, skipping send"); + return; + }; + + tokio::spawn(send_track(track, write_key)); +} + +async fn send_track(track: Track, write_key: &str) { + let auth = STANDARD.encode(format!("{write_key}:")); + + let result = reqwest::Client::new() + .post(SEGMENT_API_URL) + .header("Authorization", format!("Basic {auth}")) + .json(&track) + .send() + .await; + + match result { + Ok(resp) if !resp.status().is_success() => { + tracing::warn!( + status = %resp.status(), + "telemetry: segment API returned non-success status" + ); + } + Err(err) => { + tracing::warn!(%err, "telemetry: failed to send event to segment"); + } + Ok(_) => { + tracing::debug!("telemetry: event sent successfully"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::telemetry::event::User; + use serde_json::json; + + #[test] + fn send_noops_without_write_key() { + // SEGMENT_WRITE_KEY is not set at compile time in tests, + // so send() should return immediately without spawning. + let track = Track { + user: User::AnonymousId { + anonymous_id: "test".to_string(), + }, + event: "test".to_string(), + properties: json!({}), + context: None, + timestamp: None, + message_id: "msg-test".to_string(), + }; + + // This should not panic or require a tokio runtime + // because it returns before reaching tokio::spawn + send(track); + } +}