mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
Add more posthog analytics (privacy aware) (#6122)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -52,4 +52,4 @@
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/renderer.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -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
@@ -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={() =>
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user