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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user