diff --git a/Cargo.lock b/Cargo.lock index e36d91c55570..b83122bab331 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2977,7 +2977,7 @@ dependencies = [ [[package]] name = "goose" -version = "1.16.0" +version = "1.17.0" dependencies = [ "ahash", "anyhow", @@ -3063,7 +3063,7 @@ dependencies = [ [[package]] name = "goose-bench" -version = "1.16.0" +version = "1.17.0" dependencies = [ "anyhow", "async-trait", @@ -3086,7 +3086,7 @@ dependencies = [ [[package]] name = "goose-cli" -version = "1.16.0" +version = "1.17.0" dependencies = [ "agent-client-protocol", "anstream", @@ -3140,7 +3140,7 @@ dependencies = [ [[package]] name = "goose-mcp" -version = "1.16.0" +version = "1.17.0" dependencies = [ "anyhow", "async-trait", @@ -3205,7 +3205,7 @@ dependencies = [ [[package]] name = "goose-server" -version = "1.16.0" +version = "1.17.0" dependencies = [ "anyhow", "async-trait", @@ -3250,7 +3250,7 @@ dependencies = [ [[package]] name = "goose-test" -version = "1.16.0" +version = "1.17.0" dependencies = [ "clap", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 1704a8091725..e490b517b888 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "1.16.0" +version = "1.17.0" authors = ["Block "] license = "Apache-2.0" repository = "https://github.com/block/goose" diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 0468bdf1e2ae..e576c87d6a7e 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -397,6 +397,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::tunnel::start_tunnel, super::routes::tunnel::stop_tunnel, super::routes::tunnel::get_tunnel_status, + super::routes::telemetry::send_telemetry_event, ), components(schemas( super::routes::config_management::UpsertConfigQuery, @@ -529,6 +530,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::setup::SetupResponse, super::tunnel::TunnelInfo, super::tunnel::TunnelState, + super::routes::telemetry::TelemetryEventRequest, )) )] pub struct ApiDoc; diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index a79a8b9bf597..ec59ba387d1a 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -11,6 +11,7 @@ pub mod schedule; pub mod session; pub mod setup; pub mod status; +pub mod telemetry; pub mod tunnel; pub mod utils; @@ -31,6 +32,7 @@ pub fn configure(state: Arc, secret_key: String) -> Rout .merge(session::routes(state.clone())) .merge(schedule::routes(state.clone())) .merge(setup::routes(state.clone())) + .merge(telemetry::routes(state.clone())) .merge(tunnel::routes(state.clone())) .merge(mcp_ui_proxy::routes(secret_key)) } diff --git a/crates/goose-server/src/routes/telemetry.rs b/crates/goose-server/src/routes/telemetry.rs new file mode 100644 index 000000000000..2fde1a3cd29f --- /dev/null +++ b/crates/goose-server/src/routes/telemetry.rs @@ -0,0 +1,45 @@ +use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; +use goose::posthog::emit_event; +use serde::Deserialize; +use std::collections::HashMap; +use std::sync::Arc; +use utoipa::ToSchema; + +use crate::state::AppState; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct TelemetryEventRequest { + pub event_name: String, + #[serde(default)] + pub properties: HashMap, +} + +#[utoipa::path( + post, + path = "/telemetry/event", + request_body = TelemetryEventRequest, + responses( + (status = 202, description = "Event accepted for processing") + ) +)] +async fn send_telemetry_event( + State(_state): State>, + Json(request): Json, +) -> StatusCode { + let event_name = request.event_name; + let properties = request.properties; + + tokio::spawn(async move { + if let Err(e) = emit_event(&event_name, properties).await { + tracing::debug!("Failed to send telemetry event: {}", e); + } + }); + + StatusCode::ACCEPTED +} + +pub fn routes(state: Arc) -> Router { + Router::new() + .route("/telemetry/event", post(send_telemetry_event)) + .with_state(state) +} diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 6715929ba12e..a6a07c9e67a0 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -772,7 +772,25 @@ impl Agent { let slash_command_recipe = if message_text.trim().starts_with('/') { let command = message_text.split_whitespace().next(); - command.and_then(crate::slash_commands::resolve_slash_command) + + // Check if it's a builtin command first + let is_builtin = command + .map(|cmd| MANUAL_COMPACT_TRIGGERS.contains(&cmd)) + .unwrap_or(false); + + if is_builtin { + None + } else { + // Try to resolve as recipe command + let recipe = command.and_then(crate::slash_commands::resolve_slash_command); + + // Track non-builtin slash command usage (don't track command name for privacy) + if recipe.is_some() { + crate::posthog::emit_custom_slash_command_used(); + } + + recipe + } } else { None }; diff --git a/crates/goose/src/config/extensions.rs b/crates/goose/src/config/extensions.rs index 4ad4e84de300..a7bf586c8dec 100644 --- a/crates/goose/src/config/extensions.rs +++ b/crates/goose/src/config/extensions.rs @@ -66,7 +66,7 @@ fn get_extensions_map() -> IndexMap { bundled: Some(true), available_tools: Vec::new(), }, - enabled: true, + enabled: def.default_enabled, }, ); } diff --git a/crates/goose/src/posthog.rs b/crates/goose/src/posthog.rs index 54b5aa8f881c..73548312874d 100644 --- a/crates/goose/src/posthog.rs +++ b/crates/goose/src/posthog.rs @@ -2,6 +2,7 @@ use crate::config::paths::Paths; use crate::config::{get_enabled_extensions, Config}; +use crate::session::session_manager::CURRENT_SCHEMA_VERSION; use crate::session::SessionManager; use chrono::{DateTime, Utc}; use once_cell::sync::Lazy; @@ -167,6 +168,10 @@ fn detect_install_method() -> String { "binary".to_string() } +fn is_dev_mode() -> bool { + cfg!(debug_assertions) +} + // ============================================================================ // Session Context (set by CLI/Desktop at startup) // ============================================================================ @@ -209,7 +214,17 @@ pub fn emit_session_started() { }); } +#[derive(Default, Clone)] +pub struct ErrorContext { + pub component: Option, + pub action: Option, +} + pub fn emit_error(error_type: &str) { + emit_error_with_context(error_type, ErrorContext::default()); +} + +pub fn emit_error_with_context(error_type: &str, context: ErrorContext) { if !is_telemetry_enabled() { return; } @@ -218,20 +233,47 @@ pub fn emit_error(error_type: &str) { let error_type = error_type.to_string(); tokio::spawn(async move { - let _ = send_error_event(&installation, &error_type).await; + let _ = send_error_event(&installation, &error_type, context).await; }); } -async fn send_error_event(installation: &InstallationData, error_type: &str) -> Result<(), String> { +pub fn emit_custom_slash_command_used() { + if !is_telemetry_enabled() { + return; + } + + let installation = load_or_create_installation(); + + tokio::spawn(async move { + let _ = send_custom_slash_command_event(&installation).await; + }); +} + +async fn send_error_event( + installation: &InstallationData, + 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); 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(); + if let Some(component) = &context.component { + event.insert_prop("component", component.as_str()).ok(); + } + if let Some(action) = &context.action { + event.insert_prop("action", action.as_str()).ok(); + } + if let Some(platform_version) = get_platform_version() { event.insert_prop("platform_version", platform_version).ok(); } @@ -247,6 +289,24 @@ async fn send_error_event(installation: &InstallationData, error_type: &str) -> client.capture(event).await.map_err(|e| format!("{:?}", e)) } +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); + + 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(); + + if let Some(platform_version) = get_platform_version() { + event.insert_prop("platform_version", platform_version).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", &installation.installation_id); @@ -254,6 +314,7 @@ async fn send_session_event(installation: &InstallationData) -> Result<(), Strin 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(); if let Some(platform_version) = get_platform_version() { event.insert_prop("platform_version", platform_version).ok(); @@ -285,11 +346,49 @@ async fn send_session_event(installation: &InstallationData) -> Result<(), Strin event.insert_prop("model", model).ok(); } + if let Ok(mode) = config.get_param::("GOOSE_MODE") { + event.insert_prop("setting_mode", mode).ok(); + } + if let Ok(max_turns) = config.get_param::("GOOSE_MAX_TURNS") { + event.insert_prop("setting_max_turns", max_turns).ok(); + } + if let Ok(router_enabled) = config.get_param::("GOOSE_ENABLE_ROUTER") { + event + .insert_prop("setting_router_enabled", router_enabled) + .ok(); + } + + if let Ok(lead_model) = config.get_param::("GOOSE_LEAD_MODEL") { + event.insert_prop("setting_lead_model", lead_model).ok(); + } + if let Ok(lead_provider) = config.get_param::("GOOSE_LEAD_PROVIDER") { + event + .insert_prop("setting_lead_provider", lead_provider) + .ok(); + } + if let Ok(lead_turns) = config.get_param::("GOOSE_LEAD_TURNS") { + event.insert_prop("setting_lead_turns", lead_turns).ok(); + } + if let Ok(lead_failure_threshold) = config.get_param::("GOOSE_LEAD_FAILURE_THRESHOLD") { + event + .insert_prop("setting_lead_failure_threshold", lead_failure_threshold) + .ok(); + } + if let Ok(lead_fallback_turns) = config.get_param::("GOOSE_LEAD_FALLBACK_TURNS") { + event + .insert_prop("setting_lead_fallback_turns", lead_fallback_turns) + .ok(); + } + let extensions = get_enabled_extensions(); event.insert_prop("extensions_count", extensions.len()).ok(); let extension_names: Vec = extensions.iter().map(|e| e.name()).collect(); event.insert_prop("extensions", extension_names).ok(); + event + .insert_prop("db_schema_version", CURRENT_SCHEMA_VERSION) + .ok(); + if let Ok(insights) = SessionManager::get_insights().await { event .insert_prop("total_sessions", insights.total_sessions) @@ -301,3 +400,161 @@ async fn send_session_event(installation: &InstallationData) -> Result<(), Strin client.capture(event).await.map_err(|e| format!("{:?}", e)) } + +// ============================================================================ +// Error Classification +// ============================================================================ +pub fn classify_error(error: &str) -> &'static str { + let error_lower = error.to_lowercase(); + + if error_lower.contains("network") || error_lower.contains("fetch") { + return "network_error"; + } + if error_lower.contains("timeout") { + return "timeout"; + } + if error_lower.contains("rate") && error_lower.contains("limit") { + return "rate_limit"; + } + if error_lower.contains("auth") + || error_lower.contains("unauthorized") + || error_lower.contains("401") + { + return "auth_error"; + } + if error_lower.contains("permission") || error_lower.contains("403") { + return "permission_error"; + } + if error_lower.contains("not found") || error_lower.contains("404") { + return "not_found"; + } + if error_lower.contains("provider") { + return "provider_error"; + } + if error_lower.contains("config") { + return "config_error"; + } + if error_lower.contains("extension") { + return "extension_error"; + } + if error_lower.contains("database") || error_lower.contains("db") || error_lower.contains("sql") + { + return "database_error"; + } + if error_lower.contains("migration") { + return "migration_error"; + } + if error_lower.contains("render") || error_lower.contains("react") { + return "render_error"; + } + if error_lower.contains("chunk") || error_lower.contains("module") { + return "module_error"; + } + + "unknown_error" +} + +// ============================================================================ +// Privacy Sanitization +// ============================================================================ + +use regex::Regex; +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(), + ] +}); + +fn sanitize_string(s: &str) -> String { + let mut result = s.to_string(); + for pattern in SENSITIVE_PATTERNS.iter() { + result = pattern.replace_all(&result, "[REDACTED]").to_string(); + } + result +} + +fn sanitize_value(value: serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::String(s) => serde_json::Value::String(sanitize_string(&s)), + serde_json::Value::Array(arr) => { + serde_json::Value::Array(arr.into_iter().map(sanitize_value).collect()) + } + serde_json::Value::Object(obj) => serde_json::Value::Object( + obj.into_iter() + .map(|(k, v)| (k, sanitize_value(v))) + .collect(), + ), + other => other, + } +} + +// ============================================================================ +// Generic Event API (for frontend) +// ============================================================================ +pub async fn emit_event( + event_name: &str, + mut properties: std::collections::HashMap, +) -> Result<(), String> { + if !is_telemetry_enabled() { + return Ok(()); + } + + 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(); + + if let Some(platform_version) = get_platform_version() { + event.insert_prop("platform_version", platform_version).ok(); + } + + if event_name == "error_occurred" || event_name == "app_crashed" { + if let Some(serde_json::Value::String(error_type)) = properties.get("error_type") { + let classified = classify_error(error_type); + properties.insert( + "error_category".to_string(), + serde_json::Value::String(classified.to_string()), + ); + } + } + + 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(); + } + + client.capture(event).await.map_err(|e| format!("{:?}", e)) +} diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index d9c01b9700e2..e9ea8bc27d0b 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -18,6 +18,7 @@ use crate::config::paths::Paths; use crate::config::Config; use crate::conversation::message::Message; use crate::conversation::Conversation; +use crate::posthog; use crate::providers::create; use crate::recipe::Recipe; use crate::scheduler_trait::SchedulerTrait; @@ -694,6 +695,7 @@ impl Scheduler { } } +#[allow(clippy::too_many_lines)] async fn execute_job( job: ScheduledJob, jobs: Arc>, @@ -748,6 +750,19 @@ async fn execute_job( if let Some((_, job_def)) = jobs_guard.get_mut(job_id.as_str()) { job_def.current_session_id = Some(session.id.clone()); } + drop(jobs_guard); + + let start_time = std::time::Instant::now(); + tokio::spawn(async move { + let mut props = HashMap::new(); + props.insert( + "trigger".to_string(), + serde_json::Value::String("automated".to_string()), + ); + if let Err(e) = posthog::emit_event("schedule_job_started", props).await { + tracing::debug!("Failed to send schedule telemetry: {}", e); + } + }); let prompt_text = recipe .prompt @@ -799,6 +814,27 @@ async fn execute_job( .recipe(Some(recipe)) .apply() .await?; + + let duration_secs = start_time.elapsed().as_secs(); + tokio::spawn(async move { + let mut props = HashMap::new(); + props.insert( + "trigger".to_string(), + serde_json::Value::String("automated".to_string()), + ); + props.insert( + "status".to_string(), + serde_json::Value::String("completed".to_string()), + ); + props.insert( + "duration_seconds".to_string(), + serde_json::Value::Number(serde_json::Number::from(duration_secs)), + ); + if let Err(e) = posthog::emit_event("schedule_job_completed", props).await { + tracing::debug!("Failed to send schedule telemetry: {}", e); + } + }); + Ok(session.id) } diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 11ce210ff737..8a4641786bf6 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -19,7 +19,7 @@ use tokio::sync::OnceCell; use tracing::{info, warn}; use utoipa::ToSchema; -const CURRENT_SCHEMA_VERSION: i32 = 6; +pub const CURRENT_SCHEMA_VERSION: i32 = 6; pub const SESSIONS_FOLDER: &str = "sessions"; pub const DB_NAME: &str = "sessions.db"; diff --git a/ui/desktop/index.html b/ui/desktop/index.html index 32e2a9dbc54d..bf01dfc429c2 100644 --- a/ui/desktop/index.html +++ b/ui/desktop/index.html @@ -52,4 +52,4 @@
- \ No newline at end of file + diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 03c3c2553db3..1687760cd72c 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -10,7 +10,7 @@ "license": { "name": "Apache-2.0" }, - "version": "1.16.0" + "version": "1.17.0" }, "paths": { "/action-required/tool-confirmation": { @@ -2381,6 +2381,29 @@ } } }, + "/telemetry/event": { + "post": { + "tags": [ + "super::routes::telemetry" + ], + "operationId": "send_telemetry_event", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TelemetryEventRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Event accepted for processing" + } + } + } + }, "/tunnel/start": { "post": { "tags": [ @@ -5301,6 +5324,21 @@ "inlineMessage" ] }, + "TelemetryEventRequest": { + "type": "object", + "required": [ + "event_name" + ], + "properties": { + "event_name": { + "type": "string" + }, + "properties": { + "type": "object", + "additionalProperties": {} + } + } + }, "TextContent": { "type": "object", "required": [ diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 7317d4c01bf3..265176be3e71 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "goose-app", - "version": "1.16.0", + "version": "1.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "goose-app", - "version": "1.16.0", + "version": "1.17.0", "license": "Apache-2.0", "dependencies": { "@ai-sdk/openai": "^2.0.76", diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 065da8381d9a..14ae59b8dd21 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -1,7 +1,7 @@ { "name": "goose-app", "productName": "Goose", - "version": "1.16.0", + "version": "1.17.0", "description": "Goose App", "engines": { "node": "^22.17.1" diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 13ca9aaef7c1..084ef858250a 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -41,6 +41,13 @@ import { View, ViewOptions } from './utils/navigationUtils'; import { NoProviderOrModelError, useAgent } from './hooks/useAgent'; import { useNavigation } from './hooks/useNavigation'; import { errorMessage } from './utils/conversionUtils'; +import { usePageViewTracking } from './hooks/useAnalytics'; +import { trackOnboardingCompleted } from './utils/analytics'; + +function PageViewTracker() { + usePageViewTracking(); + return null; +} // Route Components const HubRouteWrapper = ({ isExtensionsLoading }: { isExtensionsLoading: boolean }) => { @@ -264,7 +271,8 @@ const WelcomeRoute = ({ onSelectProvider }: WelcomeRouteProps) => { navigate('/', { replace: true }); }} isOnboarding={true} - onProviderLaunched={() => { + onProviderLaunched={(model?: string) => { + trackOnboardingCompleted('other', model); onSelectProvider(); navigate('/', { replace: true }); }} @@ -609,6 +617,7 @@ export function AppInner() { return ( <> + diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index d7394952c99a..0ec5ba6b74cc 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; +import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -406,6 +406,15 @@ export const updateSessionUserRecipeValues = (options?: Options) => (options?.client ?? client).get({ url: '/status', ...options }); +export const sendTelemetryEvent = (options: Options) => (options.client ?? client).post({ + url: '/telemetry/event', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + /** * Start the tunnel */ diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 81b9987e32d7..f85a559dcc0e 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -887,6 +887,13 @@ export type SystemNotificationContent = { export type SystemNotificationType = 'thinkingMessage' | 'inlineMessage'; +export type TelemetryEventRequest = { + event_name: string; + properties?: { + [key: string]: unknown; + }; +}; + export type TextContent = { _meta?: { [key: string]: unknown; @@ -2888,6 +2895,20 @@ export type StatusResponses = { export type StatusResponse = StatusResponses[keyof StatusResponses]; +export type SendTelemetryEventData = { + body: TelemetryEventRequest; + path?: never; + query?: never; + url: '/telemetry/event'; +}; + +export type SendTelemetryEventResponses = { + /** + * Event accepted for processing + */ + 202: unknown; +}; + export type StartTunnelData = { body?: never; path?: never; diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 776a54da58ca..ec8d6d974b77 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -30,6 +30,13 @@ import { DiagnosticsModal } from './ui/DownloadDiagnostics'; import { Message } from '../api'; import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal'; import CreateEditRecipeModal from './recipes/CreateEditRecipeModal'; +import { + trackFileAttached, + trackVoiceDictation, + trackDiagnosticsOpened, + trackCreateRecipeOpened, + trackEditRecipeOpened, +} from '../utils/analytics'; interface QueuedMessage { id: string; @@ -243,6 +250,7 @@ export default function ChatInput({ estimatedSize, } = useWhisper({ onTranscription: (text) => { + trackVoiceDictation('transcribed'); // Append transcribed text to the current input const newValue = displayValue.trim() ? `${displayValue.trim()} ${text}` : text; setDisplayValue(newValue); @@ -250,6 +258,8 @@ export default function ChatInput({ textAreaRef.current?.focus(); }, onError: (error) => { + const errorType = error.name || 'DictationError'; + trackVoiceDictation('error', undefined, errorType); toastError({ title: 'Dictation Error', msg: error.message, @@ -1052,6 +1062,9 @@ export default function ChatInput({ try { const path = await window.electron.selectFileOrDirectory(); if (path) { + const isDirectory = !path.includes('.') || path.endsWith('/'); + trackFileAttached(isDirectory ? 'directory' : 'file'); + const newValue = displayValue.trim() ? `${displayValue.trim()} ${path}` : path; setDisplayValue(newValue); setValue(newValue); @@ -1283,8 +1296,10 @@ export default function ChatInput({ variant="outline" onClick={() => { if (isRecording) { + trackVoiceDictation('stop', Math.floor(recordingDuration)); stopRecording(); } else { + trackVoiceDictation('start'); startRecording(); } }} @@ -1548,8 +1563,10 @@ export default function ChatInput({ + ); @@ -61,6 +86,18 @@ export class ErrorBoundary extends React.Component< componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { // Send error to main process window.electron.logInfo(`[ERROR] ${error.toString()}\n${errorInfo.componentStack}`); + + const componentMatch = errorInfo.componentStack?.match(/^\s*at\s+(\w+)/); + const componentName = componentMatch ? componentMatch[1] : undefined; + + trackEvent({ + name: 'app_crashed', + properties: { + error_type: getErrorType(error), + component: componentName, + page: getCurrentPage(), + }, + }); } render() { diff --git a/ui/desktop/src/components/ProviderGuard.tsx b/ui/desktop/src/components/ProviderGuard.tsx index 7daf9cc70203..1e5a8275b4a9 100644 --- a/ui/desktop/src/components/ProviderGuard.tsx +++ b/ui/desktop/src/components/ProviderGuard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useMemo } from 'react'; +import { useEffect, useState, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useConfig } from './ConfigContext'; import { SetupModal } from './SetupModal'; @@ -11,6 +11,13 @@ import ApiKeyTester from './ApiKeyTester'; import { SwitchModelModal } from './settings/models/subcomponents/SwitchModelModal'; import { createNavigationHandler } from '../utils/navigationUtils'; import TelemetrySettings from './settings/app/TelemetrySettings'; +import { + trackOnboardingStarted, + trackOnboardingProviderSelected, + trackOnboardingCompleted, + trackOnboardingAbandoned, + trackOnboardingSetupFailed, +} from '../utils/analytics'; import { Goose, OpenRouter, Tetrate } from './icons'; @@ -29,6 +36,7 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG const [userInActiveSetup, setUserInActiveSetup] = useState(false); const [showSwitchModelModal, setShowSwitchModelModal] = useState(false); const [switchModelProvider, setSwitchModelProvider] = useState(null); + const onboardingTracked = useRef(false); const setView = useMemo(() => createNavigationHandler(navigate), [navigate]); @@ -49,12 +57,14 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG } | null>(null); const handleTetrateSetup = async () => { + trackOnboardingProviderSelected('tetrate'); try { const result = await startTetrateSetup(); if (result.success) { setSwitchModelProvider('tetrate'); setShowSwitchModelModal(true); } else { + trackOnboardingSetupFailed('tetrate', result.message); setTetrateSetupState({ show: true, title: 'Setup Failed', @@ -64,6 +74,7 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG } } catch (error) { console.error('Tetrate setup error:', error); + trackOnboardingSetupFailed('tetrate', 'unexpected_error'); setTetrateSetupState({ show: true, title: 'Setup Error', @@ -74,6 +85,7 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG }; const handleApiKeySuccess = async (provider: string, _model: string, apiKey: string) => { + trackOnboardingProviderSelected('api_key'); const keyName = `${provider.toUpperCase()}_API_KEY`; await upsert(keyName, apiKey, true); await upsert('GOOSE_PROVIDER', provider, false); @@ -82,7 +94,10 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG setShowSwitchModelModal(true); }; - const handleModelSelected = () => { + const handleModelSelected = (model: string) => { + if (switchModelProvider) { + trackOnboardingCompleted(switchModelProvider, model); + } setShowSwitchModelModal(false); setUserInActiveSetup(false); setShowFirstTimeSetup(false); @@ -95,12 +110,14 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG }; const handleOpenRouterSetup = async () => { + trackOnboardingProviderSelected('openrouter'); try { const result = await startOpenRouterSetup(); if (result.success) { setSwitchModelProvider('openrouter'); setShowSwitchModelModal(true); } else { + trackOnboardingSetupFailed('openrouter', result.message); setOpenRouterSetupState({ show: true, title: 'Setup Failed', @@ -110,6 +127,7 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG } } catch (error) { console.error('OpenRouter setup error:', error); + trackOnboardingSetupFailed('openrouter', 'unexpected_error'); setOpenRouterSetupState({ show: true, title: 'Setup Error', @@ -120,6 +138,7 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG }; const handleOllamaComplete = () => { + trackOnboardingCompleted('ollama'); setShowOllamaSetup(false); setShowFirstTimeSetup(false); setHasProvider(true); @@ -127,6 +146,7 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG }; const handleOllamaCancel = () => { + trackOnboardingAbandoned('ollama_setup'); setShowOllamaSetup(false); }; @@ -182,6 +202,13 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG checkProvider(); }, [read, didSelectProvider, userInActiveSetup]); + useEffect(() => { + if (!isChecking && !hasProvider && showFirstTimeSetup && !onboardingTracked.current) { + trackOnboardingStarted(); + onboardingTracked.current = true; + } + }, [isChecking, hasProvider, showFirstTimeSetup]); + if (isChecking) { return (
diff --git a/ui/desktop/src/components/TelemetryOptOutModal.tsx b/ui/desktop/src/components/TelemetryOptOutModal.tsx index ed7d3b2323c3..9f01df0703ac 100644 --- a/ui/desktop/src/components/TelemetryOptOutModal.tsx +++ b/ui/desktop/src/components/TelemetryOptOutModal.tsx @@ -5,6 +5,7 @@ import { Goose } from './icons/Goose'; import { TELEMETRY_UI_ENABLED } from '../updates'; import { toastService } from '../toasts'; import { useConfig } from './ConfigContext'; +import { trackTelemetryPreference } from '../utils/analytics'; const TELEMETRY_CONFIG_KEY = 'GOOSE_TELEMETRY_ENABLED'; @@ -54,6 +55,7 @@ export default function TelemetryOptOutModal(props: TelemetryOptOutModalProps) { setIsLoading(true); try { await upsert(TELEMETRY_CONFIG_KEY, enabled, false); + trackTelemetryPreference(enabled, 'modal'); setShowModal(false); onClose?.(); } catch (error) { diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx index c307f7e44ad6..b0720c3770cf 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx @@ -1,4 +1,4 @@ -import { useEffect, useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Tornado } from 'lucide-react'; import { all_goose_modes, ModeSelectionItem } from '../settings/mode/ModeSelectionItem'; import { useConfig } from '../ConfigContext'; @@ -8,6 +8,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '../ui/dropdown-menu'; +import { trackModeChanged } from '../../utils/analytics'; export const BottomMenuModeSelection = () => { const [gooseMode, setGooseMode] = useState('auto'); @@ -36,6 +37,7 @@ export const BottomMenuModeSelection = () => { try { await upsert('GOOSE_MODE', newMode, false); setGooseMode(newMode); + trackModeChanged(gooseMode, newMode); } catch (error) { console.error('Error updating goose mode:', error); throw new Error(`Failed to store new goose mode: ${newMode}`); diff --git a/ui/desktop/src/components/recipes/RecipesView.tsx b/ui/desktop/src/components/recipes/RecipesView.tsx index 3c278451df1b..544e5885a835 100644 --- a/ui/desktop/src/components/recipes/RecipesView.tsx +++ b/ui/desktop/src/components/recipes/RecipesView.tsx @@ -34,6 +34,14 @@ import { CronPicker } from '../schedule/CronPicker'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog'; import { SearchView } from '../conversation/SearchView'; import cronstrue from 'cronstrue'; +import { + trackRecipeDeleted, + trackRecipeStarted, + trackRecipeDeeplinkCopied, + trackRecipeScheduled, + trackRecipeSlashCommandSet, + getErrorType, +} from '../../utils/analytics'; export default function RecipesView() { const setView = useNavigation(); @@ -124,25 +132,34 @@ export default function RecipesView() { throwOnError: true, }); const session = newAgent.data; + trackRecipeStarted(true, undefined, false); setView('pair', { disableAnimation: true, resumeSessionId: session.id, }); } catch (error) { console.error('Failed to load recipe:', error); - setError(error instanceof Error ? error.message : 'Failed to load recipe'); + const errorMsg = error instanceof Error ? error.message : 'Failed to load recipe'; + trackRecipeStarted(false, getErrorType(error), false); + setError(errorMsg); } }; const handleStartRecipeChatInNewWindow = (recipeId: string) => { - window.electron.createChatWindow( - undefined, - window.appConfig.get('GOOSE_WORKING_DIR') as string, - undefined, - undefined, - 'pair', - recipeId - ); + try { + window.electron.createChatWindow( + undefined, + window.appConfig.get('GOOSE_WORKING_DIR') as string, + undefined, + undefined, + 'pair', + recipeId + ); + trackRecipeStarted(true, undefined, true); + } catch (error) { + console.error('Failed to open recipe in new window:', error); + trackRecipeStarted(false, getErrorType(error), true); + } }; const handleDeleteRecipe = async (recipeManifest: RecipeManifest) => { @@ -161,6 +178,7 @@ export default function RecipesView() { try { await deleteRecipe({ body: { id: recipeManifest.id } }); + trackRecipeDeleted(true); await loadSavedRecipes(); toastSuccess({ title: recipeManifest.recipe.title, @@ -168,7 +186,9 @@ export default function RecipesView() { }); } catch (err) { console.error('Failed to delete recipe:', err); - setError(err instanceof Error ? err.message : 'Failed to delete recipe'); + const errorMsg = err instanceof Error ? err.message : 'Failed to delete recipe'; + trackRecipeDeleted(false, getErrorType(err)); + setError(errorMsg); } }; @@ -189,12 +209,14 @@ export default function RecipesView() { try { const deeplink = await generateDeepLink(recipeManifest.recipe); await navigator.clipboard.writeText(deeplink); + trackRecipeDeeplinkCopied(true); toastSuccess({ title: 'Deeplink copied', msg: 'Recipe deeplink has been copied to clipboard', }); } catch (error) { console.error('Failed to copy deeplink:', error); + trackRecipeDeeplinkCopied(false, getErrorType(error)); toastSuccess({ title: 'Copy failed', msg: 'Failed to copy deeplink to clipboard', @@ -211,6 +233,8 @@ export default function RecipesView() { const handleSaveSchedule = async () => { if (!scheduleRecipeManifest) return; + const action = scheduleRecipeManifest.schedule_cron ? 'edit' : 'add'; + try { await scheduleRecipe({ body: { @@ -219,6 +243,7 @@ export default function RecipesView() { }, }); + trackRecipeScheduled(true, action); toastSuccess({ title: 'Schedule saved', msg: `Recipe will run ${getReadableCron(scheduleCron)}`, @@ -229,7 +254,9 @@ export default function RecipesView() { await loadSavedRecipes(); } catch (error) { console.error('Failed to save schedule:', error); - setError(error instanceof Error ? error.message : 'Failed to save schedule'); + const errorMsg = error instanceof Error ? error.message : 'Failed to save schedule'; + trackRecipeScheduled(false, action, getErrorType(error)); + setError(errorMsg); } }; @@ -244,6 +271,7 @@ export default function RecipesView() { }, }); + trackRecipeScheduled(true, 'remove'); toastSuccess({ title: 'Schedule removed', msg: 'Recipe will no longer run automatically', @@ -254,7 +282,9 @@ export default function RecipesView() { await loadSavedRecipes(); } catch (error) { console.error('Failed to remove schedule:', error); - setError(error instanceof Error ? error.message : 'Failed to remove schedule'); + const errorMsg = error instanceof Error ? error.message : 'Failed to remove schedule'; + trackRecipeScheduled(false, 'remove', getErrorType(error)); + setError(errorMsg); } }; @@ -267,6 +297,12 @@ export default function RecipesView() { const handleSaveSlashCommand = async () => { if (!slashCommandRecipeManifest) return; + const action = slashCommand + ? slashCommandRecipeManifest.slash_command + ? 'edit' + : 'add' + : 'remove'; + try { await setRecipeSlashCommand({ body: { @@ -275,6 +311,7 @@ export default function RecipesView() { }, }); + trackRecipeSlashCommandSet(true, action); toastSuccess({ title: 'Slash command saved', msg: slashCommand ? `Use /${slashCommand} to run this recipe` : 'Slash command removed', @@ -285,7 +322,9 @@ export default function RecipesView() { await loadSavedRecipes(); } catch (error) { console.error('Failed to save slash command:', error); - setError(error instanceof Error ? error.message : 'Failed to save slash command'); + const errorMsg = error instanceof Error ? error.message : 'Failed to save slash command'; + trackRecipeSlashCommandSet(false, action, getErrorType(error)); + setError(errorMsg); } }; @@ -300,6 +339,7 @@ export default function RecipesView() { }, }); + trackRecipeSlashCommandSet(true, 'remove'); toastSuccess({ title: 'Slash command removed', msg: 'Recipe slash command has been removed', @@ -310,7 +350,9 @@ export default function RecipesView() { await loadSavedRecipes(); } catch (error) { console.error('Failed to remove slash command:', error); - setError(error instanceof Error ? error.message : 'Failed to remove slash command'); + const errorMsg = error instanceof Error ? error.message : 'Failed to remove slash command'; + trackRecipeSlashCommandSet(false, 'remove', getErrorType(error)); + setError(errorMsg); } }; diff --git a/ui/desktop/src/components/schedule/ScheduleDetailView.tsx b/ui/desktop/src/components/schedule/ScheduleDetailView.tsx index cf61fb4d5cd3..cb27887c19b2 100644 --- a/ui/desktop/src/components/schedule/ScheduleDetailView.tsx +++ b/ui/desktop/src/components/schedule/ScheduleDetailView.tsx @@ -21,6 +21,7 @@ import { Loader2, Pause, Play, Edit, Square, Eye } from 'lucide-react'; import cronstrue from 'cronstrue'; import { formatToLocalDateWithTimezone } from '../../utils/date'; import { getSession, Session } from '../../api'; +import { trackScheduleRunNow, getErrorType } from '../../utils/analytics'; interface ScheduleSessionMeta { id: string; @@ -102,6 +103,7 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN setIsActionLoading(true); try { const newSessionId = await runScheduleNow(scheduleId); + trackScheduleRunNow(true); if (newSessionId === 'CANCELLED') { toastSuccess({ title: 'Job Cancelled', msg: 'The job was cancelled while starting up.' }); } else { @@ -110,9 +112,11 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN await fetchSessions(scheduleId); await fetchSchedule(scheduleId); } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Failed to trigger schedule'; + trackScheduleRunNow(false, getErrorType(err)); toastError({ title: 'Run Schedule Error', - msg: err instanceof Error ? err.message : 'Failed to trigger schedule', + msg: errorMsg, }); } finally { setIsActionLoading(false); @@ -132,9 +136,10 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN } await fetchSchedule(scheduleId); } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Operation failed'; toastError({ title: 'Pause/Unpause Error', - msg: err instanceof Error ? err.message : 'Operation failed', + msg: errorMsg, }); } finally { setIsActionLoading(false); @@ -149,9 +154,10 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN toastSuccess({ title: 'Job Killed', msg: result.message }); await fetchSchedule(scheduleId); } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Failed to kill job'; toastError({ title: 'Kill Job Error', - msg: err instanceof Error ? err.message : 'Failed to kill job', + msg: errorMsg, }); } finally { setIsActionLoading(false); @@ -175,9 +181,10 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN toastSuccess({ title: 'Job Inspection', msg: 'No detailed information available' }); } } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Failed to inspect job'; toastError({ title: 'Inspect Job Error', - msg: err instanceof Error ? err.message : 'Failed to inspect job', + msg: errorMsg, }); } finally { setIsActionLoading(false); @@ -193,9 +200,10 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN await fetchSchedule(scheduleId); setIsModalOpen(false); } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Failed to update schedule'; toastError({ title: 'Update Schedule Error', - msg: err instanceof Error ? err.message : 'Failed to update schedule', + msg: errorMsg, }); } finally { setIsActionLoading(false); diff --git a/ui/desktop/src/components/schedule/SchedulesView.tsx b/ui/desktop/src/components/schedule/SchedulesView.tsx index 818e0df2256a..c7b49b8f89a9 100644 --- a/ui/desktop/src/components/schedule/SchedulesView.tsx +++ b/ui/desktop/src/components/schedule/SchedulesView.tsx @@ -23,6 +23,7 @@ import cronstrue from 'cronstrue'; import { formatToLocalDateWithTimezone } from '../../utils/date'; import { MainPanelLayout } from '../Layout/MainPanelLayout'; import { ViewOptions } from '../../utils/navigationUtils'; +import { trackScheduleCreated, trackScheduleDeleted, getErrorType } from '../../utils/analytics'; interface SchedulesViewProps { onClose?: () => void; @@ -259,14 +260,23 @@ const SchedulesView: React.FC = ({ onClose: _onClose }) => { msg: `Successfully updated schedule "${editingSchedule.id}"`, }); } else { - await createSchedule(payload as NewSchedulePayload); + const newPayload = payload as NewSchedulePayload; + await createSchedule(newPayload); + const sourceType = pendingDeepLink ? 'deeplink' : 'file'; + trackScheduleCreated(sourceType, true); } await fetchSchedules(); setIsModalOpen(false); setEditingSchedule(null); } catch (error) { console.error('Failed to save schedule:', error); - setSubmitApiError(error instanceof Error ? error.message : 'Unknown error saving schedule.'); + const errorMsg = error instanceof Error ? error.message : 'Unknown error saving schedule.'; + setSubmitApiError(errorMsg); + + if (!editingSchedule) { + const sourceType = pendingDeepLink ? 'deeplink' : 'file'; + trackScheduleCreated(sourceType, false, getErrorType(error)); + } } finally { setIsSubmitting(false); } @@ -281,10 +291,13 @@ const SchedulesView: React.FC = ({ onClose: _onClose }) => { try { await deleteSchedule(id); + trackScheduleDeleted(true); await fetchSchedules(); } catch (error) { console.error(`Failed to delete schedule "${id}":`, error); - setApiError(error instanceof Error ? error.message : `Unknown error deleting "${id}".`); + const errorMsg = error instanceof Error ? error.message : `Unknown error deleting "${id}".`; + setApiError(errorMsg); + trackScheduleDeleted(false, getErrorType(error)); } finally { setActionsInProgress((prev) => { const newSet = new Set(prev); @@ -417,6 +430,10 @@ const SchedulesView: React.FC = ({ onClose: _onClose }) => { } }; + const handleNavigateToDetail = (id: string) => { + setViewingScheduleId(id); + }; + if (viewingScheduleId) { return ( = ({ onClose: _onClose }) => { { setEditingSchedule(schedule); setSubmitApiError(null); diff --git a/ui/desktop/src/components/settings/SettingsView.tsx b/ui/desktop/src/components/settings/SettingsView.tsx index 7c19f88c7227..e349e285ffed 100644 --- a/ui/desktop/src/components/settings/SettingsView.tsx +++ b/ui/desktop/src/components/settings/SettingsView.tsx @@ -9,9 +9,10 @@ import ConfigSettings from './config/ConfigSettings'; import { ExtensionConfig } from '../../api'; import { MainPanelLayout } from '../Layout/MainPanelLayout'; import { Bot, Share2, Monitor, MessageSquare } from 'lucide-react'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import ChatSettingsSection from './chat/ChatSettingsSection'; import { CONFIGURATION_ENABLED } from '../../updates'; +import { trackSettingsTabViewed } from '../../utils/analytics'; export type SettingsViewOptions = { deepLinkConfig?: ExtensionConfig; @@ -29,6 +30,12 @@ export default function SettingsView({ viewOptions: SettingsViewOptions; }) { const [activeTab, setActiveTab] = useState('models'); + const hasTrackedInitialTab = useRef(false); + + const handleTabChange = (tab: string) => { + setActiveTab(tab); + trackSettingsTabViewed(tab); + }; // Determine initial tab based on section prop useEffect(() => { @@ -52,6 +59,13 @@ export default function SettingsView({ } }, [viewOptions.section]); + useEffect(() => { + if (!hasTrackedInitialTab.current) { + trackSettingsTabViewed(activeTab); + hasTrackedInitialTab.current = true; + } + }, [activeTab]); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { @@ -79,7 +93,11 @@ export default function SettingsView({
- +
{ setShowPricing(checked); localStorage.setItem('show_pricing', String(checked)); + trackSettingToggled('cost_tracking', checked); // Trigger storage event for other components window.dispatchEvent(new CustomEvent('storage')); }; diff --git a/ui/desktop/src/components/settings/app/TelemetrySettings.tsx b/ui/desktop/src/components/settings/app/TelemetrySettings.tsx index 6f24a528c955..0ab743cd33d6 100644 --- a/ui/desktop/src/components/settings/app/TelemetrySettings.tsx +++ b/ui/desktop/src/components/settings/app/TelemetrySettings.tsx @@ -5,6 +5,10 @@ import { useConfig } from '../../ConfigContext'; import { TELEMETRY_UI_ENABLED } from '../../../updates'; import TelemetryOptOutModal from '../../TelemetryOptOutModal'; import { toastService } from '../../../toasts'; +import { + setTelemetryEnabled as setAnalyticsTelemetryEnabled, + trackTelemetryPreference, +} from '../../../utils/analytics'; const TELEMETRY_CONFIG_KEY = 'GOOSE_TELEMETRY_ENABLED'; @@ -42,6 +46,8 @@ export default function TelemetrySettings({ isWelcome = false }: TelemetrySettin try { await upsert(TELEMETRY_CONFIG_KEY, checked, false); setTelemetryEnabled(checked); + setAnalyticsTelemetryEnabled(checked); + trackTelemetryPreference(checked, isWelcome ? 'onboarding' : 'settings'); } catch (error) { console.error('Failed to update telemetry status:', error); toastService.error({ diff --git a/ui/desktop/src/components/settings/dictation/VoiceDictationToggle.tsx b/ui/desktop/src/components/settings/dictation/VoiceDictationToggle.tsx index 1cb5ffd239e6..57d59be4fb0a 100644 --- a/ui/desktop/src/components/settings/dictation/VoiceDictationToggle.tsx +++ b/ui/desktop/src/components/settings/dictation/VoiceDictationToggle.tsx @@ -8,6 +8,7 @@ import { import { useConfig } from '../../ConfigContext'; import { ProviderSelector } from './ProviderSelector'; import { VOICE_DICTATION_ELEVENLABS_ENABLED } from '../../../updates'; +import { trackSettingToggled } from '../../../utils/analytics'; export const VoiceDictationToggle = () => { const [settings, setSettings] = useState({ @@ -56,6 +57,7 @@ export const VoiceDictationToggle = () => { enabled, provider: settings.provider === null ? 'openai' : settings.provider, }); + trackSettingToggled('voice_dictation', enabled); }; const handleProviderChange = (provider: DictationProvider) => { diff --git a/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx index 172f0794928f..309dd3223f7c 100644 --- a/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx @@ -195,11 +195,19 @@ export default function ExtensionsSection({ }; const handleDeleteExtension = async (name: string) => { + // Capture the selected extension before closing the modal + const extensionToDelete = selectedExtension; + // Close the modal immediately handleModalClose(); try { - await deleteExtension({ name, removeFromConfig: removeExtension, sessionId: sessionId }); + await deleteExtension({ + name, + removeFromConfig: removeExtension, + sessionId: sessionId, + extensionConfig: extensionToDelete ?? undefined, + }); } catch (error) { console.error('Failed to delete extension:', error); // We don't reopen the modal on failure diff --git a/ui/desktop/src/components/settings/extensions/extension-manager.ts b/ui/desktop/src/components/settings/extensions/extension-manager.ts index 3da183fdca58..37b072894846 100644 --- a/ui/desktop/src/components/settings/extensions/extension-manager.ts +++ b/ui/desktop/src/components/settings/extensions/extension-manager.ts @@ -1,6 +1,17 @@ import type { ExtensionConfig } from '../../../api/types.gen'; import { toastService, ToastServiceOptions } from '../../../toasts'; import { addToAgent, removeFromAgent, sanitizeName } from './agent-api'; +import { + trackExtensionAdded, + trackExtensionEnabled, + trackExtensionDisabled, + trackExtensionDeleted, + getErrorType, +} from '../../../utils/analytics'; + +function isBuiltinExtension(config: ExtensionConfig): boolean { + return config.type === 'builtin'; +} interface ActivateExtensionProps { addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; @@ -58,17 +69,21 @@ export async function activateExtension({ extensionConfig, sessionId, }: ActivateExtensionProps): Promise { + const isBuiltin = isBuiltinExtension(extensionConfig); + try { // AddToAgent await addToAgent(extensionConfig, sessionId, true); } catch (error) { console.error('Failed to add extension to agent:', error); await addToConfig(extensionConfig.name, extensionConfig, false); + trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin); throw error; } try { await addToConfig(extensionConfig.name, extensionConfig, true); + trackExtensionAdded(extensionConfig.name, true, undefined, isBuiltin); } catch (error) { console.error('Failed to add extension to config:', error); // remove from Agent @@ -77,6 +92,7 @@ export async function activateExtension({ } catch (removeError) { console.error('Failed to remove extension from agent after config failure:', removeError); } + trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin); // Rethrow the error to inform the caller throw error; } @@ -252,13 +268,16 @@ export async function toggleExtension({ toastOptions = {}, sessionId, }: ToggleExtensionProps) { + const isBuiltin = isBuiltinExtension(extensionConfig); + // disabled to enabled if (toggle == 'toggleOn') { try { // add to agent with toast options await addToAgent(extensionConfig, sessionId, !toastOptions?.silent); } catch (error) { - console.error('Error adding extension to agent. Will try to toggle back off.'); + console.error('Error adding extension to agent. Attempting to toggle back off.'); + trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin); try { await toggleExtension({ toggle: 'toggleOff', @@ -276,8 +295,10 @@ export async function toggleExtension({ // update the config try { await addToConfig(extensionConfig.name, extensionConfig, true); + trackExtensionEnabled(extensionConfig.name, true, undefined, isBuiltin); } catch (error) { console.error('Failed to update config after enabling extension:', error); + trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin); // remove from agent try { await removeFromAgent(extensionConfig.name, sessionId, !toastOptions?.silent); @@ -300,8 +321,19 @@ export async function toggleExtension({ // update the config try { await addToConfig(extensionConfig.name, extensionConfig, false); + if (agentRemoveError) { + trackExtensionDisabled( + extensionConfig.name, + false, + getErrorType(agentRemoveError), + isBuiltin + ); + } else { + trackExtensionDisabled(extensionConfig.name, true, undefined, isBuiltin); + } } catch (error) { console.error('Error removing extension from config', extensionConfig.name, 'Error:', error); + trackExtensionDisabled(extensionConfig.name, false, getErrorType(error), isBuiltin); throw error; } @@ -316,13 +348,20 @@ interface DeleteExtensionProps { name: string; removeFromConfig: (name: string) => Promise; sessionId: string; + extensionConfig?: ExtensionConfig; } /** * Deletes an extension completely from both agent and config */ -export async function deleteExtension({ name, removeFromConfig, sessionId }: DeleteExtensionProps) { - // remove from agent +export async function deleteExtension({ + name, + removeFromConfig, + sessionId, + extensionConfig, +}: DeleteExtensionProps) { + const isBuiltin = extensionConfig ? isBuiltinExtension(extensionConfig) : false; + let agentRemoveError = null; try { await removeFromAgent(name, sessionId, true); @@ -333,16 +372,20 @@ export async function deleteExtension({ name, removeFromConfig, sessionId }: Del try { await removeFromConfig(name); + if (agentRemoveError) { + trackExtensionDeleted(name, false, getErrorType(agentRemoveError), isBuiltin); + } else { + trackExtensionDeleted(name, true, undefined, isBuiltin); + } } catch (error) { console.error( 'Failed to remove extension from config after removing from agent. Error:', error ); - // If we also had an agent remove error, log it but throw the config error as it's more critical + trackExtensionDeleted(name, false, getErrorType(error), isBuiltin); throw error; } - // If we had an error removing from agent but succeeded removing from config, still throw the original error if (agentRemoveError) { throw agentRemoveError; } diff --git a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx index 849c5ff14d4a..a4cfa00ca8df 100644 --- a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx +++ b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx @@ -19,6 +19,7 @@ import type { View } from '../../../../utils/navigationUtils'; import Model, { getProviderMetadata, fetchModelsForProviders } from '../modelInterface'; import { getPredefinedModelsFromEnv, shouldShowPredefinedModels } from '../predefinedModelsUtils'; import { ProviderType } from '../../../../api'; +import { trackModelChanged } from '../../../../utils/analytics'; const PREFERRED_MODEL_PATTERNS = [ /claude-sonnet-4/i, @@ -62,7 +63,7 @@ type SwitchModelModalProps = { sessionId: string | null; onClose: () => void; setView: (view: View) => void; - onModelSelected?: () => void; + onModelSelected?: (model: string) => void; initialProvider?: string | null; titleOverride?: string; }; @@ -144,8 +145,11 @@ export const SwitchModelModal = ({ } await changeModel(sessionId, modelObj); + + trackModelChanged(modelObj.provider || '', modelObj.name); + if (onModelSelected) { - onModelSelected(); + onModelSelected(modelObj.name); } onClose(); } diff --git a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx index fe5700259fe3..4221d2f775a7 100644 --- a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx +++ b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx @@ -59,7 +59,7 @@ function ProviderCards({ isOnboarding: boolean; refreshProviders?: () => void; setView?: (view: View) => void; - onModelSelected?: () => void; + onModelSelected?: (model?: string) => void; }) { const [configuringProvider, setConfiguringProvider] = useState(null); const [showCustomProviderModal, setShowCustomProviderModal] = useState(false); @@ -256,7 +256,7 @@ export default function ProviderGrid({ isOnboarding: boolean; refreshProviders?: () => void; setView?: (view: View) => void; - onModelSelected?: () => void; + onModelSelected?: (model?: string) => void; }) { return ( diff --git a/ui/desktop/src/components/settings/providers/ProviderSettingsPage.tsx b/ui/desktop/src/components/settings/providers/ProviderSettingsPage.tsx index 1c745a3fd3ba..15d874cdde94 100644 --- a/ui/desktop/src/components/settings/providers/ProviderSettingsPage.tsx +++ b/ui/desktop/src/components/settings/providers/ProviderSettingsPage.tsx @@ -10,7 +10,7 @@ import { createNavigationHandler } from '../../../utils/navigationUtils'; interface ProviderSettingsProps { onClose: () => void; isOnboarding: boolean; - onProviderLaunched?: () => void; + onProviderLaunched?: (model?: string) => void; } export default function ProviderSettings({ diff --git a/ui/desktop/src/components/settings/security/SecurityToggle.tsx b/ui/desktop/src/components/settings/security/SecurityToggle.tsx index 9bf80464bd60..531c076c6066 100644 --- a/ui/desktop/src/components/settings/security/SecurityToggle.tsx +++ b/ui/desktop/src/components/settings/security/SecurityToggle.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { Switch } from '../../ui/switch'; import { useConfig } from '../../ConfigContext'; +import { trackSettingToggled } from '../../../utils/analytics'; interface SecurityConfig { SECURITY_PROMPT_ENABLED?: boolean; @@ -23,6 +24,7 @@ export const SecurityToggle = () => { const handleToggle = async (enabled: boolean) => { await upsert('SECURITY_PROMPT_ENABLED', enabled, false); + trackSettingToggled('prompt_injection_detection', enabled); }; const handleThresholdChange = async (threshold: number) => { diff --git a/ui/desktop/src/components/settings/sessions/SessionSharingSection.tsx b/ui/desktop/src/components/settings/sessions/SessionSharingSection.tsx index a9df69ccb6fa..7cc5da1e37ea 100644 --- a/ui/desktop/src/components/settings/sessions/SessionSharingSection.tsx +++ b/ui/desktop/src/components/settings/sessions/SessionSharingSection.tsx @@ -4,6 +4,7 @@ import { Check, Lock, Loader2, AlertCircle } from 'lucide-react'; import { Switch } from '../../ui/switch'; import { Button } from '../../ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; +import { trackSettingToggled } from '../../../utils/analytics'; export default function SessionSharingSection() { const envBaseUrlShare = window.appConfig.get('GOOSE_BASE_URL_SHARE'); @@ -67,6 +68,7 @@ export default function SessionSharingSection() { setSessionSharingConfig((prev) => { const updated = { ...prev, enabled: !prev.enabled }; localStorage.setItem('session_sharing_config', JSON.stringify(updated)); + trackSettingToggled('session_sharing', updated.enabled); return updated; }); }; diff --git a/ui/desktop/src/hooks/useAnalytics.ts b/ui/desktop/src/hooks/useAnalytics.ts new file mode 100644 index 000000000000..27018f4ba29e --- /dev/null +++ b/ui/desktop/src/hooks/useAnalytics.ts @@ -0,0 +1,29 @@ +/** + * React hooks for frontend-specific analytics tracking. + * + * NOTE: The backend (posthog.rs) already tracks: + * - session_started (extensions, provider, model, tokens, etc.) + * - error (provider errors like rate_limit, auth, etc.) + * + * These frontend hooks focus on UI-specific events that the backend can't see: + * - Page views and navigation patterns + * - Onboarding funnel (where users drop off) + * - Frontend crashes/errors (React errors, unhandled rejections) + */ + +import { useEffect, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; +import { trackPageView } from '../utils/analytics'; + +export function usePageViewTracking(): void { + const location = useLocation(); + const previousPath = useRef(null); + + useEffect(() => { + const currentPath = location.pathname; + if (currentPath !== previousPath.current) { + trackPageView(currentPath, previousPath.current || undefined); + previousPath.current = currentPath; + } + }, [location.pathname]); +} diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index a7260e969da3..01555361b53e 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -212,6 +212,14 @@ export function useChatStream({ if (cached) { setSession(cached.session); updateMessages(cached.messages); + setTokenState({ + inputTokens: cached.session?.input_tokens ?? 0, + outputTokens: cached.session?.output_tokens ?? 0, + totalTokens: cached.session?.total_tokens ?? 0, + accumulatedInputTokens: cached.session?.accumulated_input_tokens ?? 0, + accumulatedOutputTokens: cached.session?.accumulated_output_tokens ?? 0, + accumulatedTotalTokens: cached.session?.accumulated_total_tokens ?? 0, + }); setChatState(ChatState.Idle); return; } @@ -241,6 +249,14 @@ export function useChatStream({ const session = response.data; setSession(session); updateMessages(session?.conversation || []); + setTokenState({ + inputTokens: session?.input_tokens ?? 0, + outputTokens: session?.output_tokens ?? 0, + totalTokens: session?.total_tokens ?? 0, + accumulatedInputTokens: session?.accumulated_input_tokens ?? 0, + accumulatedOutputTokens: session?.accumulated_output_tokens ?? 0, + accumulatedTotalTokens: session?.accumulated_total_tokens ?? 0, + }); setChatState(ChatState.Idle); onSessionLoaded?.(); } catch (error) { diff --git a/ui/desktop/src/renderer.tsx b/ui/desktop/src/renderer.tsx index d9191c1070e5..0aa41f58ca4d 100644 --- a/ui/desktop/src/renderer.tsx +++ b/ui/desktop/src/renderer.tsx @@ -4,9 +4,13 @@ import { ConfigProvider } from './components/ConfigContext'; import { ErrorBoundary } from './components/ErrorBoundary'; import SuspenseLoader from './suspense-loader'; import { client } from './api/client.gen'; +import { setTelemetryEnabled } from './utils/analytics'; +import { readConfig } from './api'; const App = lazy(() => import('./App')); +const TELEMETRY_CONFIG_KEY = 'GOOSE_TELEMETRY_ENABLED'; + (async () => { // Check if we're in the launcher view (doesn't need goosed connection) const isLauncher = window.location.hash === '#/launcher'; @@ -26,6 +30,16 @@ const App = lazy(() => import('./App')); 'X-Secret-Key': await window.electron.getSecretKey(), }, }); + + try { + const telemetryResponse = await readConfig({ + body: { key: TELEMETRY_CONFIG_KEY, is_secret: false }, + }); + const isTelemetryEnabled = telemetryResponse.data !== false; + setTelemetryEnabled(isTelemetryEnabled); + } catch (error) { + console.warn('[Analytics] Failed to initialize analytics:', error); + } } ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/ui/desktop/src/utils/analytics.ts b/ui/desktop/src/utils/analytics.ts new file mode 100644 index 000000000000..2b8540f6ff23 --- /dev/null +++ b/ui/desktop/src/utils/analytics.ts @@ -0,0 +1,566 @@ +/** + * Frontend Analytics Module + * + * Provides privacy-respecting analytics by routing events through the backend. + * The backend uses posthog-rs which handles the PostHog API correctly. + * + * What we track: + * - Feature usage (which features users interact with) + * - Screen/view navigation + * - Onboarding funnel completion + * - Error types (without sensitive details) + * + * What we never track: + * - Conversation content + * - Code or file contents + * - API keys or credentials + * - Tool arguments or outputs + * - Personal identifiable information + */ + +import { sendTelemetryEvent } from '../api'; + +let telemetryEnabled: boolean | null = null; + +export function setTelemetryEnabled(enabled: boolean): void { + telemetryEnabled = enabled; +} + +function canTrack(): boolean { + return telemetryEnabled === true; +} + +async function sendEvent( + eventName: string, + properties: Record = {} +): Promise { + if (!canTrack()) return; + + try { + await sendTelemetryEvent({ + body: { + event_name: eventName, + properties: properties as Record, + }, + }); + } catch (error) { + console.debug('[Analytics] Failed to send event:', error); + } +} + +// ============================================================================ +// Event Types +// ============================================================================ + +/** + * Frontend-specific analytics events. + * + * NOTE: The backend (posthog.rs) already tracks: + * - session_started (extensions, provider, model, tokens, session count, etc.) + * - error (provider errors like rate_limit, auth, etc.) + * + * Frontend events focus on what the backend can't see: + * - UI navigation patterns + * - Onboarding funnel (where users drop off during setup) + * - Frontend-only crashes (React errors, unhandled rejections) + */ +export type AnalyticsEvent = + | { name: 'page_view'; properties: { page: string; referrer?: string } } + | { name: 'onboarding_started'; properties: Record } + | { + name: 'onboarding_provider_selected'; + properties: { method: 'api_key' | 'openrouter' | 'tetrate' | 'ollama' | 'other' }; + } + | { + name: 'onboarding_completed'; + properties: { provider: string; model?: string; duration_seconds?: number }; + } + | { name: 'onboarding_abandoned'; properties: { step: string; duration_seconds?: number } } + | { + name: 'onboarding_setup_failed'; + properties: { provider: 'openrouter' | 'tetrate'; error_message?: string }; + } + | { + name: 'error_occurred'; + properties: { + error_type: string; + component?: string; + page?: string; + action?: string; + stack_summary?: string; + recoverable: boolean; + }; + } + | { name: 'app_crashed'; properties: { error_type: string; component?: string; page?: string } } + | { name: 'app_reloaded'; properties: { reason?: string } } + | { name: 'model_changed'; properties: { provider: string; model: string } } + | { name: 'settings_tab_viewed'; properties: { tab: string } } + | { name: 'setting_toggled'; properties: { setting: string; enabled: boolean } } + | { + name: 'telemetry_preference_set'; + properties: { enabled: boolean; location: 'settings' | 'onboarding' | 'modal' }; + } + | { + name: 'schedule_created'; + properties: { source_type: 'file' | 'deeplink'; success: boolean; error_details?: string }; + } + | { name: 'schedule_deleted'; properties: { success: boolean; error_details?: string } } + | { name: 'schedule_run_now'; properties: { success: boolean; error_details?: string } } + | { name: 'recipe_created'; properties: { success: boolean; error_details?: string } } + | { name: 'recipe_imported'; properties: { success: boolean; error_details?: string } } + | { name: 'recipe_edited'; properties: { success: boolean; error_details?: string } } + | { name: 'recipe_deleted'; properties: { success: boolean; error_details?: string } } + | { + name: 'recipe_started'; + properties: { success: boolean; error_details?: string; in_new_window?: boolean }; + } + | { name: 'recipe_deeplink_copied'; properties: { success: boolean; error_details?: string } } + | { + name: 'recipe_scheduled'; + properties: { success: boolean; error_details?: string; action: 'add' | 'edit' | 'remove' }; + } + | { + name: 'recipe_slash_command_set'; + properties: { success: boolean; error_details?: string; action: 'add' | 'edit' | 'remove' }; + } + | { + name: 'extension_added'; + properties: { + extension_name?: string; + is_builtin: boolean; + success: boolean; + error_details?: string; + }; + } + | { + name: 'extension_enabled'; + properties: { + extension_name?: string; + is_builtin: boolean; + success: boolean; + error_details?: string; + }; + } + | { + name: 'extension_disabled'; + properties: { + extension_name?: string; + is_builtin: boolean; + success: boolean; + error_details?: string; + }; + } + | { + name: 'extension_deleted'; + properties: { + extension_name?: string; + is_builtin: boolean; + success: boolean; + error_details?: string; + }; + } + // Chat input bar features + | { name: 'input_file_attached'; properties: { file_type: 'file' | 'directory' } } + | { + name: 'input_voice_dictation'; + properties: { + action: 'start' | 'stop' | 'transcribed' | 'error'; + duration_seconds?: number; + error_type?: string; + }; + } + | { name: 'input_mode_changed'; properties: { from_mode: string; to_mode: string } } + | { name: 'input_diagnostics_opened'; properties: Record } + | { name: 'input_create_recipe_opened'; properties: Record } + | { name: 'input_edit_recipe_opened'; properties: Record }; +// NOTE: slash_command_used is tracked by the backend (posthog.rs) with command_type info + +export function trackEvent(event: T): void { + sendEvent(event.name, event.properties); +} + +export function trackPageView(page: string, referrer?: string): void { + trackEvent({ + name: 'page_view', + properties: { page, referrer }, + }); +} + +export function trackError( + errorType: string, + options: { + component?: string; // React component name + page?: string; // Current route/page + action?: string; // What user was doing + stackSummary?: string; // Use getStackSummary() to generate + recoverable?: boolean; + } = {} +): void { + trackEvent({ + name: 'error_occurred', + properties: { + error_type: errorType, + component: options.component, + page: options.page, + action: options.action, + stack_summary: options.stackSummary, + recoverable: options.recoverable ?? false, + }, + }); +} + +export function trackErrorWithContext( + error: unknown, + context: { + component?: string; + page?: string; + action?: string; + recoverable?: boolean; + } = {} +): void { + trackError(getErrorType(error), { + ...context, + stackSummary: getStackSummary(error), + }); +} + +let onboardingStartTime: number | null = null; + +export function trackOnboardingStarted(): void { + onboardingStartTime = Date.now(); + trackEvent({ name: 'onboarding_started', properties: {} }); +} + +export function trackOnboardingProviderSelected( + method: 'api_key' | 'openrouter' | 'tetrate' | 'ollama' | 'other' +): void { + trackEvent({ + name: 'onboarding_provider_selected', + properties: { method }, + }); +} + +export function trackOnboardingCompleted(provider: string, model?: string): void { + const durationSeconds = onboardingStartTime + ? Math.round((Date.now() - onboardingStartTime) / 1000) + : undefined; + + trackEvent({ + name: 'onboarding_completed', + properties: { provider, model, duration_seconds: durationSeconds }, + }); + onboardingStartTime = null; +} + +export function trackOnboardingAbandoned(step: string): void { + const durationSeconds = onboardingStartTime + ? Math.round((Date.now() - onboardingStartTime) / 1000) + : undefined; + + trackEvent({ + name: 'onboarding_abandoned', + properties: { step, duration_seconds: durationSeconds }, + }); + onboardingStartTime = null; +} + +export function trackOnboardingSetupFailed( + provider: 'openrouter' | 'tetrate', + errorMessage?: string +): void { + trackEvent({ + name: 'onboarding_setup_failed', + properties: { provider, error_message: errorMessage }, + }); +} + +export function trackModelChanged(provider: string, model: string): void { + trackEvent({ + name: 'model_changed', + properties: { provider, model }, + }); +} + +export function trackSettingsTabViewed(tab: string): void { + trackEvent({ + name: 'settings_tab_viewed', + properties: { tab }, + }); +} + +export function trackSettingToggled(setting: string, enabled: boolean): void { + trackEvent({ + name: 'setting_toggled', + properties: { setting, enabled }, + }); +} + +export function trackTelemetryPreference( + enabled: boolean, + location: 'settings' | 'onboarding' | 'modal' +): void { + // Always send this event, even if telemetry is disabled + // This is the one exception - we need to know opt-out rates + sendEvent('telemetry_preference_set', { enabled, location }); +} + +export function getErrorType(error: unknown): string { + if (error instanceof Error) { + const name = error.name || 'Error'; + const message = error.message.split('\n')[0].slice(0, 200); + return `${name}: ${message}`; + } + return String(error).slice(0, 200); +} + +export function getStackSummary(error: unknown): string | undefined { + if (!(error instanceof Error) || !error.stack) { + return undefined; + } + + // Extract just the function/component names from the stack + // Skip error message, take top 4 frames + const lines = error.stack.split('\n').slice(1, 5); + const frames = lines + .map((line) => { + // Match function names like "at ComponentName" or "at Object.functionName" + const match = line.match(/at\s+([A-Za-z0-9_$.]+)/); + return match ? match[1] : null; + }) + .filter(Boolean); + + return frames.length > 0 ? frames.join(' > ') : undefined; +} + +// ============================================================================ +// Extension Tracking +// ============================================================================ + +// Only track names for builtin extensions (privacy protection for user-created extensions) +function getTrackableExtensionName(extensionName: string, isBuiltin: boolean): string | undefined { + return isBuiltin ? extensionName : undefined; +} + +export function trackExtensionAdded( + extensionName: string, + success: boolean, + errorDetails?: string, + isBuiltin: boolean = false +): void { + trackEvent({ + name: 'extension_added', + properties: { + extension_name: getTrackableExtensionName(extensionName, isBuiltin), + is_builtin: isBuiltin, + success, + error_details: errorDetails, + }, + }); +} + +export function trackExtensionEnabled( + extensionName: string, + success: boolean, + errorDetails?: string, + isBuiltin: boolean = false +): void { + trackEvent({ + name: 'extension_enabled', + properties: { + extension_name: getTrackableExtensionName(extensionName, isBuiltin), + is_builtin: isBuiltin, + success, + error_details: errorDetails, + }, + }); +} + +export function trackExtensionDisabled( + extensionName: string, + success: boolean, + errorDetails?: string, + isBuiltin: boolean = false +): void { + trackEvent({ + name: 'extension_disabled', + properties: { + extension_name: getTrackableExtensionName(extensionName, isBuiltin), + is_builtin: isBuiltin, + success, + error_details: errorDetails, + }, + }); +} + +export function trackExtensionDeleted( + extensionName: string, + success: boolean, + errorDetails?: string, + isBuiltin: boolean = false +): void { + trackEvent({ + name: 'extension_deleted', + properties: { + extension_name: getTrackableExtensionName(extensionName, isBuiltin), + is_builtin: isBuiltin, + success, + error_details: errorDetails, + }, + }); +} + +// ============================================================================ +// Schedule/Recipe Tracking +// ============================================================================ + +export function trackScheduleCreated( + sourceType: 'file' | 'deeplink', + success: boolean, + errorDetails?: string +): void { + trackEvent({ + name: 'schedule_created', + properties: { source_type: sourceType, success, error_details: errorDetails }, + }); +} + +export function trackScheduleDeleted(success: boolean, errorDetails?: string): void { + trackEvent({ + name: 'schedule_deleted', + properties: { success, error_details: errorDetails }, + }); +} + +export function trackScheduleRunNow(success: boolean, errorDetails?: string): void { + trackEvent({ + name: 'schedule_run_now', + properties: { success, error_details: errorDetails }, + }); +} + +// ============================================================================ +// Recipe Tracking +// ============================================================================ + +export function trackRecipeCreated(success: boolean, errorDetails?: string): void { + trackEvent({ + name: 'recipe_created', + properties: { success, error_details: errorDetails }, + }); +} + +export function trackRecipeImported(success: boolean, errorDetails?: string): void { + trackEvent({ + name: 'recipe_imported', + properties: { success, error_details: errorDetails }, + }); +} + +export function trackRecipeEdited(success: boolean, errorDetails?: string): void { + trackEvent({ + name: 'recipe_edited', + properties: { success, error_details: errorDetails }, + }); +} + +export function trackRecipeDeleted(success: boolean, errorDetails?: string): void { + trackEvent({ + name: 'recipe_deleted', + properties: { success, error_details: errorDetails }, + }); +} + +export function trackRecipeStarted( + success: boolean, + errorDetails?: string, + inNewWindow?: boolean +): void { + trackEvent({ + name: 'recipe_started', + properties: { success, error_details: errorDetails, in_new_window: inNewWindow }, + }); +} + +export function trackRecipeDeeplinkCopied(success: boolean, errorDetails?: string): void { + trackEvent({ + name: 'recipe_deeplink_copied', + properties: { success, error_details: errorDetails }, + }); +} + +export function trackRecipeScheduled( + success: boolean, + action: 'add' | 'edit' | 'remove', + errorDetails?: string +): void { + trackEvent({ + name: 'recipe_scheduled', + properties: { success, action, error_details: errorDetails }, + }); +} + +export function trackRecipeSlashCommandSet( + success: boolean, + action: 'add' | 'edit' | 'remove', + errorDetails?: string +): void { + trackEvent({ + name: 'recipe_slash_command_set', + properties: { success, action, error_details: errorDetails }, + }); +} + +// NOTE: slash_command_used is tracked by the backend (posthog.rs) with richer info: +// - command_type: "builtin" | "recipe" | "unknown" +// - command_name: only for builtin commands (e.g., "compact", "summarize") +// - success: true for builtin/recipe, false for unknown + +// ============================================================================ +// Chat Input Bar Feature Tracking +// ============================================================================ + +export function trackFileAttached(fileType: 'file' | 'directory'): void { + trackEvent({ + name: 'input_file_attached', + properties: { file_type: fileType }, + }); +} + +export function trackVoiceDictation( + action: 'start' | 'stop' | 'transcribed' | 'error', + durationSeconds?: number, + errorType?: string +): void { + trackEvent({ + name: 'input_voice_dictation', + properties: { action, duration_seconds: durationSeconds, error_type: errorType }, + }); +} + +export function trackModeChanged(fromMode: string, toMode: string): void { + trackEvent({ + name: 'input_mode_changed', + properties: { from_mode: fromMode, to_mode: toMode }, + }); +} + +export function trackDiagnosticsOpened(): void { + trackEvent({ + name: 'input_diagnostics_opened', + properties: {}, + }); +} + +export function trackCreateRecipeOpened(): void { + trackEvent({ + name: 'input_create_recipe_opened', + properties: {}, + }); +} + +export function trackEditRecipeOpened(): void { + trackEvent({ + name: 'input_edit_recipe_opened', + properties: {}, + }); +}