Add more posthog analytics (privacy aware) (#6122)

This commit is contained in:
Zane
2025-12-16 08:59:14 -08:00
committed by GitHub
parent 036f4f74d9
commit 8a91f15731
34 changed files with 1339 additions and 48 deletions
+2
View File
@@ -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;
+2
View File
@@ -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<crate::state::AppState>, 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))
}
@@ -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<String, serde_json::Value>,
}
#[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<Arc<AppState>>,
Json(request): Json<TelemetryEventRequest>,
) -> 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<AppState>) -> Router {
Router::new()
.route("/telemetry/event", post(send_telemetry_event))
.with_state(state)
}
+19 -1
View File
@@ -781,7 +781,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
};
+259 -2
View File
@@ -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<String>,
pub action: Option<String>,
}
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::<String>("GOOSE_MODE") {
event.insert_prop("setting_mode", mode).ok();
}
if let Ok(max_turns) = config.get_param::<i64>("GOOSE_MAX_TURNS") {
event.insert_prop("setting_max_turns", max_turns).ok();
}
if let Ok(router_enabled) = config.get_param::<bool>("GOOSE_ENABLE_ROUTER") {
event
.insert_prop("setting_router_enabled", router_enabled)
.ok();
}
if let Ok(lead_model) = config.get_param::<String>("GOOSE_LEAD_MODEL") {
event.insert_prop("setting_lead_model", lead_model).ok();
}
if let Ok(lead_provider) = config.get_param::<String>("GOOSE_LEAD_PROVIDER") {
event
.insert_prop("setting_lead_provider", lead_provider)
.ok();
}
if let Ok(lead_turns) = config.get_param::<i64>("GOOSE_LEAD_TURNS") {
event.insert_prop("setting_lead_turns", lead_turns).ok();
}
if let Ok(lead_failure_threshold) = config.get_param::<i64>("GOOSE_LEAD_FAILURE_THRESHOLD") {
event
.insert_prop("setting_lead_failure_threshold", lead_failure_threshold)
.ok();
}
if let Ok(lead_fallback_turns) = config.get_param::<i64>("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<String> = 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<Vec<Regex>> = 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<String, serde_json::Value>,
) -> 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))
}
+36
View File
@@ -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<Mutex<JobsMap>>,
@@ -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)
}
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -52,4 +52,4 @@
<div id="root"></div>
<script type="module" src="./src/renderer.tsx"></script>
</body>
</html>
</html>
+38
View File
@@ -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": [
+10 -1
View File
@@ -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 (
<>
<PageViewTracker />
<ToastContainer
aria-label="Toast notifications"
toastClassName={() =>
+10 -1
View File
@@ -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<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
@@ -406,6 +406,15 @@ export const updateSessionUserRecipeValues = <ThrowOnError extends boolean = fal
export const status = <ThrowOnError extends boolean = false>(options?: Options<StatusData, ThrowOnError>) => (options?.client ?? client).get<StatusResponses, unknown, ThrowOnError>({ url: '/status', ...options });
export const sendTelemetryEvent = <ThrowOnError extends boolean = false>(options: Options<SendTelemetryEventData, ThrowOnError>) => (options.client ?? client).post<SendTelemetryEventResponses, unknown, ThrowOnError>({
url: '/telemetry/event',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Start the tunnel
*/
+21
View File
@@ -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;
+21 -1
View File
@@ -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({
<Button
onClick={() => {
if (recipe) {
trackEditRecipeOpened();
setShowEditRecipeModal(true);
} else {
trackCreateRecipeOpened();
setShowCreateRecipeModal(true);
}
}}
@@ -1572,7 +1589,10 @@ export default function ChatInput({
<TooltipTrigger asChild>
<Button
type="button"
onClick={() => setDiagnosticsOpen(true)}
onClick={() => {
trackDiagnosticsOpened();
setDiagnosticsOpen(true);
}}
variant="ghost"
size="sm"
className="flex items-center justify-center text-text-default/70 hover:text-text-default text-xs cursor-pointer transition-colors"
+38 -1
View File
@@ -2,10 +2,21 @@ import React from 'react';
import { Button } from './ui/button';
import { AlertTriangle } from 'lucide-react';
import { errorMessage } from '../utils/conversionUtils';
import { trackErrorWithContext, trackEvent, getErrorType } from '../utils/analytics';
function getCurrentPage(): string {
return window.location.hash.replace('#', '') || '/';
}
// Capture unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
window.electron.logInfo(`[UNHANDLED REJECTION] ${event.reason}`);
trackErrorWithContext(event.reason, {
component: 'global',
page: getCurrentPage(),
action: 'async_operation',
recoverable: false,
});
});
// Capture global errors
@@ -13,9 +24,23 @@ window.addEventListener('error', (event) => {
window.electron.logInfo(
`[GLOBAL ERROR] ${event.message} at ${event.filename}:${event.lineno}:${event.colno}`
);
trackErrorWithContext(event.error || event.message, {
component: event.filename ? event.filename.split('/').pop() : 'unknown',
page: getCurrentPage(),
action: 'script_execution',
recoverable: false,
});
});
export function ErrorUI({ error }: { error: string }) {
const handleReload = () => {
trackEvent({
name: 'app_reloaded',
properties: { reason: 'error_recovery' },
});
window.electron.reloadApp();
};
return (
<div className="fixed inset-0 w-full h-full flex flex-col items-center justify-center gap-6 bg-background">
<div className="flex flex-col items-center gap-4 max-w-[600px] text-center px-6">
@@ -39,7 +64,7 @@ export function ErrorUI({ error }: { error: string }) {
{error}
</pre>
<Button onClick={() => window.electron.reloadApp()}>Reload</Button>
<Button onClick={handleReload}>Reload</Button>
</div>
</div>
);
@@ -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() {
+29 -2
View File
@@ -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<string | null>(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 (
<div className="h-screen w-full bg-background-default flex items-center justify-center">
@@ -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) {
@@ -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}`);
@@ -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);
}
};
@@ -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<ScheduleDetailViewProps> = ({ 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<ScheduleDetailViewProps> = ({ 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<ScheduleDetailViewProps> = ({ 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<ScheduleDetailViewProps> = ({ 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<ScheduleDetailViewProps> = ({ 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<ScheduleDetailViewProps> = ({ 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);
@@ -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<SchedulesViewProps> = ({ 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<SchedulesViewProps> = ({ 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<SchedulesViewProps> = ({ onClose: _onClose }) => {
}
};
const handleNavigateToDetail = (id: string) => {
setViewingScheduleId(id);
};
if (viewingScheduleId) {
return (
<ScheduleDetailView
@@ -492,7 +509,7 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
<ScheduleCard
key={job.id}
job={job}
onNavigateToDetail={setViewingScheduleId}
onNavigateToDetail={handleNavigateToDetail}
onEdit={(schedule) => {
setEditingSchedule(schedule);
setSubmitApiError(null);
@@ -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({
</div>
<div className="flex-1 min-h-0 relative px-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="h-full flex flex-col"
>
<div className="px-1">
<TabsList className="w-full mb-2 justify-start">
<TabsTrigger
@@ -13,6 +13,7 @@ import ThemeSelector from '../../GooseSidebar/ThemeSelector';
import BlockLogoBlack from './icons/block-lockup_black.png';
import BlockLogoWhite from './icons/block-lockup_white.png';
import TelemetrySettings from './TelemetrySettings';
import { trackSettingToggled } from '../../../utils/analytics';
interface AppSettingsSectionProps {
scrollToSection?: string;
@@ -170,6 +171,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
const success = await window.electron.setMenuBarIcon(newState);
if (success) {
setMenuBarIconEnabled(newState);
trackSettingToggled('menu_bar_icon', newState);
}
};
@@ -194,6 +196,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
const success = await window.electron.setDockIcon(newState);
if (success) {
setDockIconEnabled(newState);
trackSettingToggled('dock_icon', newState);
}
};
@@ -202,12 +205,14 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
const success = await window.electron.setWakelock(newState);
if (success) {
setWakelockEnabled(newState);
trackSettingToggled('prevent_sleep', newState);
}
};
const handleShowPricingToggle = (checked: boolean) => {
setShowPricing(checked);
localStorage.setItem('show_pricing', String(checked));
trackSettingToggled('cost_tracking', checked);
// Trigger storage event for other components
window.dispatchEvent(new CustomEvent('storage'));
};
@@ -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({
@@ -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<DictationSettings>({
@@ -56,6 +57,7 @@ export const VoiceDictationToggle = () => {
enabled,
provider: settings.provider === null ? 'openai' : settings.provider,
});
trackSettingToggled('voice_dictation', enabled);
};
const handleProviderChange = (provider: DictationProvider) => {
@@ -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
@@ -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<void>;
@@ -58,17 +69,21 @@ export async function activateExtension({
extensionConfig,
sessionId,
}: ActivateExtensionProps): Promise<void> {
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<void>;
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;
}
@@ -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();
}
@@ -59,7 +59,7 @@ function ProviderCards({
isOnboarding: boolean;
refreshProviders?: () => void;
setView?: (view: View) => void;
onModelSelected?: () => void;
onModelSelected?: (model?: string) => void;
}) {
const [configuringProvider, setConfiguringProvider] = useState<ProviderDetails | null>(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 (
<GridLayout>
@@ -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({
@@ -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) => {
@@ -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;
});
};
+29
View File
@@ -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<string | null>(null);
useEffect(() => {
const currentPath = location.pathname;
if (currentPath !== previousPath.current) {
trackPageView(currentPath, previousPath.current || undefined);
previousPath.current = currentPath;
}
}, [location.pathname]);
}
+14
View File
@@ -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(
+566
View File
@@ -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<string, unknown> = {}
): Promise<void> {
if (!canTrack()) return;
try {
await sendTelemetryEvent({
body: {
event_name: eventName,
properties: properties as Record<string, unknown>,
},
});
} 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<string, never> }
| {
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<string, never> }
| { name: 'input_create_recipe_opened'; properties: Record<string, never> }
| { name: 'input_edit_recipe_opened'; properties: Record<string, never> };
// NOTE: slash_command_used is tracked by the backend (posthog.rs) with command_type info
export function trackEvent<T extends AnalyticsEvent>(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: {},
});
}