diff --git a/Cargo.lock b/Cargo.lock index 4f486578..664095c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -750,7 +750,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tower", "tower-layer", @@ -786,7 +786,7 @@ dependencies = [ "serde_path_to_error", "serde_urlencoded", "sha1", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tokio-tungstenite", "tower", @@ -810,7 +810,7 @@ dependencies = [ "mime", "pin-project-lite", "rustversion", - "sync_wrapper 1.0.2", + "sync_wrapper", "tower-layer", "tower-service", "tracing", @@ -829,7 +829,7 @@ dependencies = [ "http-body-util", "mime", "pin-project-lite", - "sync_wrapper 1.0.2", + "sync_wrapper", "tower-layer", "tower-service", "tracing", @@ -4322,7 +4322,6 @@ dependencies = [ "opentelemetry_sdk", "pastey", "pctx_code_mode", - "posthog-rs", "pulldown-cmark", "rand 0.8.5", "rayon", @@ -4573,7 +4572,7 @@ dependencies = [ "url", "utoipa 4.2.3", "uuid", - "winreg 0.55.0", + "winreg", "wiremock", ] @@ -5006,7 +5005,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.2", - "system-configuration 0.7.0", + "system-configuration", "tokio", "tower-service", "tracing", @@ -7334,25 +7333,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "posthog-rs" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42508f04262bd31d4865363a2f665d56e337a8ddbc1de02c006697c489312a" -dependencies = [ - "chrono", - "derive_builder", - "regex", - "reqwest 0.11.27", - "semver", - "serde", - "serde_json", - "sha1", - "tokio", - "tracing", - "uuid", -] - [[package]] name = "potential_utf" version = "0.1.4" @@ -7994,49 +7974,6 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" -[[package]] -name = "reqwest" -version = "0.11.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" -dependencies = [ - "async-compression", - "base64 0.21.7", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper-rustls 0.24.2", - "ipnet", - "js-sys", - "log", - "mime", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls 0.21.12", - "rustls-pemfile", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 0.1.2", - "system-configuration 0.5.1", - "tokio", - "tokio-rustls 0.24.1", - "tokio-util", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots 0.25.4", - "winreg 0.50.0", -] - [[package]] name = "reqwest" version = "0.12.28" @@ -8070,7 +8007,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tokio-rustls 0.26.4", "tokio-util", @@ -8119,7 +8056,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tokio-rustls 0.26.4", "tokio-util", @@ -8458,15 +8395,6 @@ dependencies = [ "security-framework 3.5.1", ] -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", -] - [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -10124,12 +10052,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - [[package]] name = "sync_wrapper" version = "1.0.2" @@ -10212,17 +10134,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "system-configuration" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "system-configuration-sys 0.5.0", -] - [[package]] name = "system-configuration" version = "0.7.0" @@ -10231,17 +10142,7 @@ checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags 2.10.0", "core-foundation 0.9.4", - "system-configuration-sys 0.6.0", -] - -[[package]] -name = "system-configuration-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" -dependencies = [ - "core-foundation-sys", - "libc", + "system-configuration-sys", ] [[package]] @@ -10788,7 +10689,7 @@ dependencies = [ "percent-encoding", "pin-project", "socket2 0.6.2", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tokio-stream", "tower", @@ -10819,7 +10720,7 @@ dependencies = [ "indexmap 2.13.0", "pin-project-lite", "slab", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tokio-util", "tower-layer", @@ -11447,7 +11348,6 @@ checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" dependencies = [ "getrandom 0.3.4", "js-sys", - "serde_core", "wasm-bindgen", ] @@ -11739,12 +11639,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "0.25.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" - [[package]] name = "webpki-roots" version = "0.26.11" @@ -12377,16 +12271,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - [[package]] name = "winreg" version = "0.55.0" diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index a0fe7f82..770dc465 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -122,7 +122,6 @@ schemars = { workspace = true, features = [ "derive", ] } insta = "1.43.2" -posthog-rs = "0.4.3" shellexpand = { workspace = true } indexmap = "2.12.0" ignore = { workspace = true } diff --git a/crates/goose/src/posthog.rs b/crates/goose/src/posthog.rs index a1449a9a..1c4693c0 100644 --- a/crates/goose/src/posthog.rs +++ b/crates/goose/src/posthog.rs @@ -1,5 +1,3 @@ -//! PostHog telemetry - fires once per session creation. - use crate::config::paths::Paths; use crate::config::{get_enabled_extensions, Config}; use crate::session::session_manager::CURRENT_SCHEMA_VERSION; @@ -9,12 +7,14 @@ use crate::subprocess::SubprocessExt; use chrono::{DateTime, Utc}; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::fs; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; use uuid::Uuid; const POSTHOG_API_KEY: &str = "phc_RyX5CaY01VtZJCQyhSR5KFh6qimUy81YwxsEpotAftT"; +const POSTHOG_CAPTURE_URL: &str = "https://us.i.posthog.com/capture/"; /// Config key for telemetry opt-out preference pub const TELEMETRY_ENABLED_KEY: &str = "GOOSE_TELEMETRY_ENABLED"; @@ -31,7 +31,6 @@ static TELEMETRY_DISABLED_BY_ENV: Lazy = Lazy::new(|| { /// Returns Some(true) if telemetry is enabled, Some(false) if disabled, /// or None if the user hasn't made a choice yet. pub fn get_telemetry_choice() -> Option { - // If disabled by env var, treat as explicit choice to disable if TELEMETRY_DISABLED_BY_ENV.load(Ordering::Relaxed) { return Some(false); } @@ -52,6 +51,44 @@ pub fn is_telemetry_enabled() -> bool { get_telemetry_choice().unwrap_or(false) } +// ============================================================================ +// PostHog HTTP API +// ============================================================================ + +#[derive(Serialize)] +struct CaptureEvent { + api_key: &'static str, + event: String, + distinct_id: String, + properties: HashMap, + timestamp: Option, +} + +async fn posthog_capture( + event_name: &str, + distinct_id: &str, + properties: HashMap, +) -> Result<(), String> { + let payload = CaptureEvent { + api_key: POSTHOG_API_KEY, + event: event_name.to_string(), + distinct_id: distinct_id.to_string(), + properties, + timestamp: Some(Utc::now().to_rfc3339()), + }; + + let client = reqwest::Client::new(); + client + .post(POSTHOG_CAPTURE_URL) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await + .map_err(|e| format!("{e}"))?; + + Ok(()) +} + // ============================================================================ // Installation Tracking // ============================================================================ @@ -209,6 +246,18 @@ fn get_session_is_resumed() -> bool { SESSION_IS_RESUMED.load(Ordering::Relaxed) } +// ============================================================================ +// Property Helpers +// ============================================================================ + +fn insert( + props: &mut HashMap, + key: &str, + val: impl Into, +) { + props.insert(key.to_string(), val.into()); +} + // ============================================================================ // Telemetry Events // ============================================================================ @@ -281,151 +330,148 @@ async fn send_error_event( error_type: &str, context: ErrorContext, ) -> Result<(), String> { - let client = posthog_rs::client(POSTHOG_API_KEY).await; - let mut event = posthog_rs::Event::new("error", &installation.installation_id); + let mut props = HashMap::new(); - event.insert_prop("error_type", error_type).ok(); - event - .insert_prop("error_category", classify_error(error_type)) - .ok(); - event.insert_prop("source", "backend").ok(); - event.insert_prop("version", env!("CARGO_PKG_VERSION")).ok(); - event.insert_prop("interface", get_session_interface()).ok(); - event.insert_prop("os", std::env::consts::OS).ok(); - event.insert_prop("arch", std::env::consts::ARCH).ok(); + insert(&mut props, "error_type", error_type); + insert(&mut props, "error_category", classify_error(error_type)); + insert(&mut props, "source", "backend"); + insert(&mut props, "version", env!("CARGO_PKG_VERSION")); + insert(&mut props, "interface", get_session_interface()); + insert(&mut props, "os", std::env::consts::OS); + insert(&mut props, "arch", std::env::consts::ARCH); if let Some(component) = &context.component { - event.insert_prop("component", component.as_str()).ok(); + insert(&mut props, "component", component.as_str()); } if let Some(action) = &context.action { - event.insert_prop("action", action.as_str()).ok(); + insert(&mut props, "action", action.as_str()); } if let Some(error_message) = &context.error_message { - let sanitized = sanitize_string(error_message); - event.insert_prop("error_message", sanitized).ok(); + insert(&mut props, "error_message", sanitize_string(error_message)); } if let Some(platform_version) = get_platform_version() { - event.insert_prop("platform_version", platform_version).ok(); + insert(&mut props, "platform_version", platform_version); } let config = Config::global(); if let Ok(provider) = config.get_param::("GOOSE_PROVIDER") { - event.insert_prop("provider", provider).ok(); + insert(&mut props, "provider", provider); } if let Ok(model) = config.get_param::("GOOSE_MODEL") { - event.insert_prop("model", model).ok(); + insert(&mut props, "model", model); } - client.capture(event).await.map_err(|e| format!("{:?}", e)) + posthog_capture("error", &installation.installation_id, props).await } async fn send_custom_slash_command_event(installation: &InstallationData) -> Result<(), String> { - let client = posthog_rs::client(POSTHOG_API_KEY).await; - let mut event = - posthog_rs::Event::new("custom_slash_command_used", &installation.installation_id); + let mut props = HashMap::new(); - event.insert_prop("source", "backend").ok(); - event.insert_prop("version", env!("CARGO_PKG_VERSION")).ok(); - event.insert_prop("interface", get_session_interface()).ok(); - event.insert_prop("os", std::env::consts::OS).ok(); - event.insert_prop("arch", std::env::consts::ARCH).ok(); + insert(&mut props, "source", "backend"); + insert(&mut props, "version", env!("CARGO_PKG_VERSION")); + insert(&mut props, "interface", get_session_interface()); + insert(&mut props, "os", std::env::consts::OS); + insert(&mut props, "arch", std::env::consts::ARCH); if let Some(platform_version) = get_platform_version() { - event.insert_prop("platform_version", platform_version).ok(); + insert(&mut props, "platform_version", platform_version); } - client.capture(event).await.map_err(|e| format!("{:?}", e)) + posthog_capture( + "custom_slash_command_used", + &installation.installation_id, + props, + ) + .await } async fn send_session_event(installation: &InstallationData) -> Result<(), String> { - let client = posthog_rs::client(POSTHOG_API_KEY).await; - let mut event = posthog_rs::Event::new("session_started", &installation.installation_id); + let mut props = HashMap::new(); - event.insert_prop("os", std::env::consts::OS).ok(); - event.insert_prop("arch", std::env::consts::ARCH).ok(); - event.insert_prop("version", env!("CARGO_PKG_VERSION")).ok(); - event.insert_prop("is_dev", is_dev_mode()).ok(); + insert(&mut props, "os", std::env::consts::OS); + insert(&mut props, "arch", std::env::consts::ARCH); + insert(&mut props, "version", env!("CARGO_PKG_VERSION")); + insert(&mut props, "is_dev", is_dev_mode()); if let Some(platform_version) = get_platform_version() { - event.insert_prop("platform_version", platform_version).ok(); + insert(&mut props, "platform_version", platform_version); } - event - .insert_prop("install_method", detect_install_method()) - .ok(); + insert(&mut props, "install_method", detect_install_method()); + insert(&mut props, "interface", get_session_interface()); + insert(&mut props, "is_resumed", get_session_is_resumed()); + insert(&mut props, "session_number", installation.session_count); - event.insert_prop("interface", get_session_interface()).ok(); - - event - .insert_prop("is_resumed", get_session_is_resumed()) - .ok(); - - event - .insert_prop("session_number", installation.session_count) - .ok(); let days_since_install = (Utc::now() - installation.first_seen).num_days(); - event - .insert_prop("days_since_install", days_since_install) - .ok(); + insert(&mut props, "days_since_install", days_since_install); let config = Config::global(); if let Ok(provider) = config.get_param::("GOOSE_PROVIDER") { - event.insert_prop("provider", provider).ok(); + insert(&mut props, "provider", provider); } if let Ok(model) = config.get_param::("GOOSE_MODEL") { - event.insert_prop("model", model).ok(); + insert(&mut props, "model", model); } if let Ok(mode) = config.get_param::("GOOSE_MODE") { - event.insert_prop("setting_mode", mode).ok(); + insert(&mut props, "setting_mode", mode); } if let Ok(max_turns) = config.get_param::("GOOSE_MAX_TURNS") { - event.insert_prop("setting_max_turns", max_turns).ok(); + insert(&mut props, "setting_max_turns", max_turns); } if let Ok(lead_model) = config.get_param::("GOOSE_LEAD_MODEL") { - event.insert_prop("setting_lead_model", lead_model).ok(); + insert(&mut props, "setting_lead_model", lead_model); } if let Ok(lead_provider) = config.get_param::("GOOSE_LEAD_PROVIDER") { - event - .insert_prop("setting_lead_provider", lead_provider) - .ok(); + insert(&mut props, "setting_lead_provider", lead_provider); } if let Ok(lead_turns) = config.get_param::("GOOSE_LEAD_TURNS") { - event.insert_prop("setting_lead_turns", lead_turns).ok(); + insert(&mut props, "setting_lead_turns", lead_turns); } if let Ok(lead_failure_threshold) = config.get_param::("GOOSE_LEAD_FAILURE_THRESHOLD") { - event - .insert_prop("setting_lead_failure_threshold", lead_failure_threshold) - .ok(); + insert( + &mut props, + "setting_lead_failure_threshold", + lead_failure_threshold, + ); } if let Ok(lead_fallback_turns) = config.get_param::("GOOSE_LEAD_FALLBACK_TURNS") { - event - .insert_prop("setting_lead_fallback_turns", lead_fallback_turns) - .ok(); + insert( + &mut props, + "setting_lead_fallback_turns", + lead_fallback_turns, + ); } let extensions = get_enabled_extensions(); - event.insert_prop("extensions_count", extensions.len()).ok(); + insert(&mut props, "extensions_count", extensions.len() as u64); let extension_names: Vec = extensions.iter().map(|e| e.name()).collect(); - event.insert_prop("extensions", extension_names).ok(); + insert( + &mut props, + "extensions", + serde_json::Value::Array( + extension_names + .into_iter() + .map(serde_json::Value::String) + .collect(), + ), + ); - event - .insert_prop("db_schema_version", CURRENT_SCHEMA_VERSION) - .ok(); + insert( + &mut props, + "db_schema_version", + CURRENT_SCHEMA_VERSION as u64, + ); let session_manager = SessionManager::instance(); if let Ok(insights) = session_manager.get_insights().await { - event - .insert_prop("total_sessions", insights.total_sessions) - .ok(); - event - .insert_prop("total_tokens", insights.total_tokens) - .ok(); + insert(&mut props, "total_sessions", insights.total_sessions as u64); + insert(&mut props, "total_tokens", insights.total_tokens as u64); } - client.capture(event).await.map_err(|e| format!("{:?}", e)) + posthog_capture("session_started", &installation.installation_id, props).await } // ============================================================================ @@ -490,22 +536,16 @@ use std::sync::LazyLock; static SENSITIVE_PATTERNS: LazyLock> = LazyLock::new(|| { vec![ - // File paths with usernames (Unix) Regex::new(r"/Users/[^/\s]+").unwrap(), Regex::new(r"/home/[^/\s]+").unwrap(), - // File paths with usernames (Windows) Regex::new(r"(?i)C:\\Users\\[^\\\s]+").unwrap(), - // API keys and tokens (common patterns) Regex::new(r"sk-[a-zA-Z0-9]{20,}").unwrap(), Regex::new(r"pk-[a-zA-Z0-9]{20,}").unwrap(), Regex::new(r"(?i)key[_-]?[a-zA-Z0-9]{16,}").unwrap(), Regex::new(r"(?i)token[_-]?[a-zA-Z0-9]{16,}").unwrap(), Regex::new(r"(?i)bearer\s+[a-zA-Z0-9._-]+").unwrap(), - // Email addresses Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}").unwrap(), - // URLs with auth info Regex::new(r"https?://[^:]+:[^@]+@").unwrap(), - // UUIDs (might be session/user IDs in error messages) Regex::new(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") .unwrap(), ] @@ -539,7 +579,7 @@ fn sanitize_value(value: serde_json::Value) -> serde_json::Value { // ============================================================================ pub async fn emit_event( event_name: &str, - mut properties: std::collections::HashMap, + mut properties: HashMap, ) -> Result<(), String> { if !is_telemetry_enabled() { return Ok(()); @@ -551,17 +591,15 @@ pub async fn emit_event( #[allow(unreachable_code)] let installation = load_or_create_installation(); - let client = posthog_rs::client(POSTHOG_API_KEY).await; - let mut event = posthog_rs::Event::new(event_name, &installation.installation_id); - event.insert_prop("os", std::env::consts::OS).ok(); - event.insert_prop("arch", std::env::consts::ARCH).ok(); - event.insert_prop("version", env!("CARGO_PKG_VERSION")).ok(); - event.insert_prop("interface", "desktop").ok(); - event.insert_prop("source", "ui").ok(); + insert(&mut properties, "os", std::env::consts::OS); + insert(&mut properties, "arch", std::env::consts::ARCH); + insert(&mut properties, "version", env!("CARGO_PKG_VERSION")); + insert(&mut properties, "interface", "desktop"); + insert(&mut properties, "source", "ui"); if let Some(platform_version) = get_platform_version() { - event.insert_prop("platform_version", platform_version).ok(); + insert(&mut properties, "platform_version", platform_version); } if event_name == "error_occurred" || event_name == "app_crashed" { @@ -574,19 +612,18 @@ pub async fn emit_event( } } - for (key, value) in properties { - let key_lower = key.to_lowercase(); - if key_lower.contains("key") - || key_lower.contains("token") - || key_lower.contains("secret") - || key_lower.contains("password") - || key_lower.contains("credential") - { - continue; - } - let sanitized_value = sanitize_value(value); - event.insert_prop(&key, sanitized_value).ok(); - } + let sanitized: HashMap = properties + .into_iter() + .filter(|(key, _)| { + let key_lower = key.to_lowercase(); + !key_lower.contains("key") + && !key_lower.contains("token") + && !key_lower.contains("secret") + && !key_lower.contains("password") + && !key_lower.contains("credential") + }) + .map(|(k, v)| (k, sanitize_value(v))) + .collect(); - client.capture(event).await.map_err(|e| format!("{:?}", e)) + posthog_capture(event_name, &installation.installation_id, sanitized).await }