diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 8c9fc966139a..947e2d334950 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -248,6 +248,8 @@ pub struct SessionSettings { } pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { + goose::posthog::set_session_context("cli", session_config.resume); + let config = Config::global(); let (saved_provider, saved_model_config) = if session_config.resume { diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index fd1b63eec346..e6f8e57474d4 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -105,6 +105,8 @@ async fn start_agent( State(state): State>, Json(payload): Json, ) -> Result, ErrorResponse> { + goose::posthog::set_session_context("desktop", false); + let StartAgentRequest { working_dir, recipe, @@ -197,6 +199,8 @@ async fn resume_agent( State(state): State>, Json(payload): Json, ) -> Result, ErrorResponse> { + goose::posthog::set_session_context("desktop", true); + let session = SessionManager::get_session(&payload.session_id, true) .await .map_err(|err| { diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index be04c7100dbe..ef4d5f69197b 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1221,7 +1221,8 @@ impl Agent { no_tools_called = false; } } - Err(ProviderError::ContextLengthExceeded(_error_msg)) => { + Err(ref provider_err @ ProviderError::ContextLengthExceeded(_)) => { + crate::posthog::emit_error(provider_err.telemetry_type()); yield AgentEvent::Message( Message::assistant().with_system_notification( SystemNotificationType::InlineMessage, @@ -1255,11 +1256,12 @@ impl Agent { } } } - Err(e) => { - error!("Error: {}", e); + Err(ref provider_err) => { + crate::posthog::emit_error(provider_err.telemetry_type()); + error!("Error: {}", provider_err); yield AgentEvent::Message( Message::assistant().with_text( - format!("Ran into this error: {e}.\n\nPlease retry if you think this is a transient or recoverable error.") + format!("Ran into this error: {provider_err}.\n\nPlease retry if you think this is a transient or recoverable error.") ) ); break; diff --git a/crates/goose/src/agents/tool_route_manager.rs b/crates/goose/src/agents/tool_route_manager.rs index 670806070210..363a5880e735 100644 --- a/crates/goose/src/agents/tool_route_manager.rs +++ b/crates/goose/src/agents/tool_route_manager.rs @@ -38,9 +38,9 @@ impl ToolRouteManager { pub async fn record_tool_requests(&self, requests: &[ToolRequest]) { let selector = self.router_tool_selector.lock().await.clone(); - if let Some(selector) = selector { - for request in requests { - if let Ok(tool_call) = &request.tool_call { + for request in requests { + if let Ok(tool_call) = &request.tool_call { + if let Some(ref selector) = selector { if let Err(e) = selector.record_tool_call(&tool_call.name).await { error!("Failed to record tool call: {}", e); } diff --git a/crates/goose/src/posthog.rs b/crates/goose/src/posthog.rs index 93bef449d0b3..54b5aa8f881c 100644 --- a/crates/goose/src/posthog.rs +++ b/crates/goose/src/posthog.rs @@ -1,9 +1,15 @@ //! PostHog telemetry - fires once per session creation. +use crate::config::paths::Paths; use crate::config::{get_enabled_extensions, Config}; use crate::session::SessionManager; +use chrono::{DateTime, Utc}; use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; +use std::fs; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; +use uuid::Uuid; const POSTHOG_API_KEY: &str = "phc_RyX5CaY01VtZJCQyhSR5KFh6qimUy81YwxsEpotAftT"; @@ -25,7 +31,6 @@ static TELEMETRY_DISABLED_BY_ENV: Lazy = Lazy::new(|| { /// /// Returns true otherwise (telemetry is opt-out, enabled by default) pub fn is_telemetry_enabled() -> bool { - // Environment variable takes precedence if TELEMETRY_DISABLED_BY_ENV.load(Ordering::Relaxed) { return false; } @@ -36,24 +41,242 @@ pub fn is_telemetry_enabled() -> bool { .unwrap_or(true) } +// ============================================================================ +// Installation Tracking +// ============================================================================ + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct InstallationData { + installation_id: String, + first_seen: DateTime, + session_count: u32, +} + +impl Default for InstallationData { + fn default() -> Self { + Self { + installation_id: Uuid::new_v4().to_string(), + first_seen: Utc::now(), + session_count: 0, + } + } +} + +fn installation_file_path() -> std::path::PathBuf { + Paths::state_dir().join("telemetry_installation.json") +} + +fn load_or_create_installation() -> InstallationData { + let path = installation_file_path(); + + if let Ok(contents) = fs::read_to_string(&path) { + if let Ok(data) = serde_json::from_str::(&contents) { + return data; + } + } + + let data = InstallationData::default(); + save_installation(&data); + data +} + +fn save_installation(data: &InstallationData) { + let path = installation_file_path(); + + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + + if let Ok(json) = serde_json::to_string_pretty(data) { + let _ = fs::write(path, json); + } +} + +fn increment_session_count() -> InstallationData { + let mut data = load_or_create_installation(); + data.session_count += 1; + save_installation(&data); + data +} + +// ============================================================================ +// Platform Info +// ============================================================================ + +fn get_platform_version() -> Option { + #[cfg(target_os = "macos")] + { + std::process::Command::new("sw_vers") + .arg("-productVersion") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + } + #[cfg(target_os = "linux")] + { + fs::read_to_string("/etc/os-release") + .ok() + .and_then(|content| { + content + .lines() + .find(|line| line.starts_with("VERSION_ID=")) + .map(|line| { + line.trim_start_matches("VERSION_ID=") + .trim_matches('"') + .to_string() + }) + }) + } + #[cfg(target_os = "windows")] + { + std::process::Command::new("cmd") + .args(["/C", "ver"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + None + } +} + +fn detect_install_method() -> String { + let exe_path = std::env::current_exe().ok(); + + if let Some(path) = exe_path { + let path_str = path.to_string_lossy().to_lowercase(); + + if path_str.contains("homebrew") || path_str.contains("/opt/homebrew") { + return "homebrew".to_string(); + } + if path_str.contains(".cargo") { + return "cargo".to_string(); + } + if path_str.contains("applications") || path_str.contains(".app") { + return "desktop".to_string(); + } + } + + if std::env::var("GOOSE_DESKTOP").is_ok() { + return "desktop".to_string(); + } + + "binary".to_string() +} + +// ============================================================================ +// Session Context (set by CLI/Desktop at startup) +// ============================================================================ + +static SESSION_INTERFACE: Lazy>> = Lazy::new(|| Mutex::new(None)); +static SESSION_IS_RESUMED: AtomicBool = AtomicBool::new(false); + +pub fn set_session_context(interface: &str, is_resumed: bool) { + if let Ok(mut iface) = SESSION_INTERFACE.lock() { + *iface = Some(interface.to_string()); + } + SESSION_IS_RESUMED.store(is_resumed, Ordering::Relaxed); +} + +fn get_session_interface() -> String { + SESSION_INTERFACE + .lock() + .ok() + .and_then(|i| i.clone()) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn get_session_is_resumed() -> bool { + SESSION_IS_RESUMED.load(Ordering::Relaxed) +} + +// ============================================================================ +// Telemetry Events +// ============================================================================ + pub fn emit_session_started() { if !is_telemetry_enabled() { return; } - tokio::spawn(async { - let _ = send_session_event().await; + let installation = increment_session_count(); + + tokio::spawn(async move { + let _ = send_session_event(&installation).await; + }); +} + +pub fn emit_error(error_type: &str) { + if !is_telemetry_enabled() { + return; + } + + let installation = load_or_create_installation(); + let error_type = error_type.to_string(); + + tokio::spawn(async move { + let _ = send_error_event(&installation, &error_type).await; }); } -async fn send_session_event() -> Result<(), String> { +async fn send_error_event(installation: &InstallationData, error_type: &str) -> Result<(), String> { + let client = posthog_rs::client(POSTHOG_API_KEY).await; + let mut event = posthog_rs::Event::new("error", &installation.installation_id); + + event.insert_prop("error_type", error_type).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(); + + if let Some(platform_version) = get_platform_version() { + event.insert_prop("platform_version", platform_version).ok(); + } + + let config = Config::global(); + if let Ok(provider) = config.get_param::("GOOSE_PROVIDER") { + event.insert_prop("provider", provider).ok(); + } + if let Ok(model) = config.get_param::("GOOSE_MODEL") { + event.insert_prop("model", model).ok(); + } + + client.capture(event).await.map_err(|e| format!("{:?}", e)) +} + +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", "goose_user"); + let mut event = posthog_rs::Event::new("session_started", &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(); + if let Some(platform_version) = get_platform_version() { + event.insert_prop("platform_version", platform_version).ok(); + } + + event + .insert_prop("install_method", detect_install_method()) + .ok(); + + 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(); + let config = Config::global(); if let Ok(provider) = config.get_param::("GOOSE_PROVIDER") { event.insert_prop("provider", provider).ok(); diff --git a/crates/goose/src/providers/errors.rs b/crates/goose/src/providers/errors.rs index b6ee4e7431fe..b72605d944aa 100644 --- a/crates/goose/src/providers/errors.rs +++ b/crates/goose/src/providers/errors.rs @@ -32,6 +32,21 @@ pub enum ProviderError { NotImplemented(String), } +impl ProviderError { + pub fn telemetry_type(&self) -> &'static str { + match self { + ProviderError::Authentication(_) => "auth", + ProviderError::ContextLengthExceeded(_) => "context_length", + ProviderError::RateLimitExceeded { .. } => "rate_limit", + ProviderError::ServerError(_) => "server", + ProviderError::RequestFailed(_) => "request", + ProviderError::ExecutionError(_) => "execution", + ProviderError::UsageError(_) => "usage", + ProviderError::NotImplemented(_) => "not_implemented", + } + } +} + impl From for ProviderError { fn from(error: anyhow::Error) -> Self { if let Some(reqwest_err) = error.downcast_ref::() { diff --git a/ui/desktop/src/components/TelemetryOptOutModal.tsx b/ui/desktop/src/components/TelemetryOptOutModal.tsx index f1dbf198dd2b..ed7d3b2323c3 100644 --- a/ui/desktop/src/components/TelemetryOptOutModal.tsx +++ b/ui/desktop/src/components/TelemetryOptOutModal.tsx @@ -113,15 +113,16 @@ export default function TelemetryOptOutModal(props: TelemetryOptOutModalProps) {

What we collect:

    -
  • Operating system and architecture
  • -
  • goose version
  • +
  • Operating system, version, and architecture
  • +
  • goose version and install method
  • Provider and model used
  • -
  • Number of extensions enabled
  • -
  • Session count and token usage (aggregated)
  • +
  • Extensions and tool usage counts (names only)
  • +
  • Session metrics (duration, interaction count, token usage)
  • +
  • Error types (e.g., "rate_limit", "auth" - no details)

- We never collect your conversations, code, or any personal data. You can change this - setting anytime in Settings → App. + We never collect your conversations, code, tool arguments, error messages, or any + personal data. You can change this setting anytime in Settings → App.