Skip to main content

aster/
posthog.rs

1//! PostHog telemetry - fires once per session creation.
2
3#[cfg(feature = "telemetry-posthog")]
4use crate::config::get_enabled_extensions;
5use crate::config::paths::Paths;
6use crate::config::Config;
7#[cfg(feature = "telemetry-posthog")]
8use crate::session::session_manager::CURRENT_SCHEMA_VERSION;
9#[cfg(feature = "telemetry-posthog")]
10use crate::session::SessionManager;
11use chrono::{DateTime, Utc};
12use once_cell::sync::Lazy;
13use serde::{Deserialize, Serialize};
14use std::fs;
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::sync::Mutex;
17use uuid::Uuid;
18
19const POSTHOG_API_KEY: &str = "phc_RyX5CaY01VtZJCQyhSR5KFh6qimUy81YwxsEpotAftT";
20
21/// Config key for telemetry opt-out preference
22pub const TELEMETRY_ENABLED_KEY: &str = "ASTER_TELEMETRY_ENABLED";
23
24static TELEMETRY_DISABLED_BY_ENV: Lazy<AtomicBool> = Lazy::new(|| {
25    std::env::var("ASTER_TELEMETRY_OFF")
26        .map(|v| v == "1" || v.to_lowercase() == "true")
27        .unwrap_or(false)
28        .into()
29});
30
31/// Check if telemetry is enabled.
32///
33/// Returns false if:
34/// - ASTER_TELEMETRY_OFF environment variable is set to "1" or "true"
35/// - ASTER_TELEMETRY_ENABLED config value is set to false
36///
37/// Returns true otherwise (telemetry is opt-out, enabled by default)
38pub fn is_telemetry_enabled() -> bool {
39    if TELEMETRY_DISABLED_BY_ENV.load(Ordering::Relaxed) {
40        return false;
41    }
42
43    let config = Config::global();
44    config
45        .get_param::<bool>(TELEMETRY_ENABLED_KEY)
46        .unwrap_or(true)
47}
48
49// ============================================================================
50// Installation Tracking
51// ============================================================================
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54struct InstallationData {
55    installation_id: String,
56    first_seen: DateTime<Utc>,
57    session_count: u32,
58}
59
60impl Default for InstallationData {
61    fn default() -> Self {
62        Self {
63            installation_id: Uuid::new_v4().to_string(),
64            first_seen: Utc::now(),
65            session_count: 0,
66        }
67    }
68}
69
70fn installation_file_path() -> std::path::PathBuf {
71    Paths::state_dir().join("telemetry_installation.json")
72}
73
74fn load_or_create_installation() -> InstallationData {
75    let path = installation_file_path();
76
77    if let Ok(contents) = fs::read_to_string(&path) {
78        if let Ok(data) = serde_json::from_str::<InstallationData>(&contents) {
79            return data;
80        }
81    }
82
83    let data = InstallationData::default();
84    save_installation(&data);
85    data
86}
87
88fn save_installation(data: &InstallationData) {
89    let path = installation_file_path();
90
91    if let Some(parent) = path.parent() {
92        let _ = fs::create_dir_all(parent);
93    }
94
95    if let Ok(json) = serde_json::to_string_pretty(data) {
96        let _ = fs::write(path, json);
97    }
98}
99
100fn increment_session_count() -> InstallationData {
101    let mut data = load_or_create_installation();
102    data.session_count += 1;
103    save_installation(&data);
104    data
105}
106
107// ============================================================================
108// Platform Info
109// ============================================================================
110
111fn get_platform_version() -> Option<String> {
112    #[cfg(target_os = "macos")]
113    {
114        std::process::Command::new("sw_vers")
115            .arg("-productVersion")
116            .output()
117            .ok()
118            .and_then(|o| String::from_utf8(o.stdout).ok())
119            .map(|s| s.trim().to_string())
120    }
121    #[cfg(target_os = "linux")]
122    {
123        fs::read_to_string("/etc/os-release")
124            .ok()
125            .and_then(|content| {
126                content
127                    .lines()
128                    .find(|line| line.starts_with("VERSION_ID="))
129                    .map(|line| {
130                        line.trim_start_matches("VERSION_ID=")
131                            .trim_matches('"')
132                            .to_string()
133                    })
134            })
135    }
136    #[cfg(target_os = "windows")]
137    {
138        std::process::Command::new("cmd")
139            .args(["/C", "ver"])
140            .output()
141            .ok()
142            .and_then(|o| String::from_utf8(o.stdout).ok())
143            .map(|s| s.trim().to_string())
144    }
145    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
146    {
147        None
148    }
149}
150
151fn detect_install_method() -> String {
152    let exe_path = std::env::current_exe().ok();
153
154    if let Some(path) = exe_path {
155        let path_str = path.to_string_lossy().to_lowercase();
156
157        if path_str.contains("homebrew") || path_str.contains("/opt/homebrew") {
158            return "homebrew".to_string();
159        }
160        if path_str.contains(".cargo") {
161            return "cargo".to_string();
162        }
163        if path_str.contains("applications") || path_str.contains(".app") {
164            return "desktop".to_string();
165        }
166    }
167
168    if std::env::var("ASTER_DESKTOP").is_ok() {
169        return "desktop".to_string();
170    }
171
172    "binary".to_string()
173}
174
175fn is_dev_mode() -> bool {
176    cfg!(debug_assertions)
177}
178
179// ============================================================================
180// Session Context (set by CLI/Desktop at startup)
181// ============================================================================
182
183static SESSION_INTERFACE: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
184static SESSION_IS_RESUMED: AtomicBool = AtomicBool::new(false);
185
186pub fn set_session_context(interface: &str, is_resumed: bool) {
187    if let Ok(mut iface) = SESSION_INTERFACE.lock() {
188        *iface = Some(interface.to_string());
189    }
190    SESSION_IS_RESUMED.store(is_resumed, Ordering::Relaxed);
191}
192
193fn get_session_interface() -> String {
194    SESSION_INTERFACE
195        .lock()
196        .ok()
197        .and_then(|i| i.clone())
198        .unwrap_or_else(|| "unknown".to_string())
199}
200
201fn get_session_is_resumed() -> bool {
202    SESSION_IS_RESUMED.load(Ordering::Relaxed)
203}
204
205// ============================================================================
206// Telemetry Events
207// ============================================================================
208
209pub fn emit_session_started() {
210    if !is_telemetry_enabled() {
211        return;
212    }
213
214    let installation = increment_session_count();
215
216    tokio::spawn(async move {
217        let _ = send_session_event(&installation).await;
218    });
219}
220
221#[derive(Default, Clone)]
222pub struct ErrorContext {
223    pub component: Option<String>,
224    pub action: Option<String>,
225    pub error_message: Option<String>,
226}
227
228pub fn emit_error(error_type: &str, error_message: &str) {
229    emit_error_with_context(
230        error_type,
231        ErrorContext {
232            error_message: Some(error_message.to_string()),
233            ..Default::default()
234        },
235    );
236}
237
238pub fn emit_error_with_context(error_type: &str, context: ErrorContext) {
239    if !is_telemetry_enabled() {
240        return;
241    }
242
243    let installation = load_or_create_installation();
244    let error_type = error_type.to_string();
245
246    tokio::spawn(async move {
247        let _ = send_error_event(&installation, &error_type, context).await;
248    });
249}
250
251pub fn emit_custom_slash_command_used() {
252    if !is_telemetry_enabled() {
253        return;
254    }
255
256    let installation = load_or_create_installation();
257
258    tokio::spawn(async move {
259        let _ = send_custom_slash_command_event(&installation).await;
260    });
261}
262
263async fn send_error_event(
264    installation: &InstallationData,
265    error_type: &str,
266    context: ErrorContext,
267) -> Result<(), String> {
268    #[cfg(not(feature = "telemetry-posthog"))]
269    {
270        let _ = (installation, error_type, context);
271        return Ok(());
272    }
273
274    #[cfg(feature = "telemetry-posthog")]
275    {
276        let client = posthog_rs::client(POSTHOG_API_KEY).await;
277        let mut event = posthog_rs::Event::new("error", &installation.installation_id);
278
279        event.insert_prop("error_type", error_type).ok();
280        event
281            .insert_prop("error_category", classify_error(error_type))
282            .ok();
283        event.insert_prop("source", "backend").ok();
284        event.insert_prop("version", env!("CARGO_PKG_VERSION")).ok();
285        event.insert_prop("interface", get_session_interface()).ok();
286        event.insert_prop("os", std::env::consts::OS).ok();
287        event.insert_prop("arch", std::env::consts::ARCH).ok();
288
289        if let Some(component) = &context.component {
290            event.insert_prop("component", component.as_str()).ok();
291        }
292        if let Some(action) = &context.action {
293            event.insert_prop("action", action.as_str()).ok();
294        }
295        if let Some(error_message) = &context.error_message {
296            let sanitized = sanitize_string(error_message);
297            event.insert_prop("error_message", sanitized).ok();
298        }
299
300        if let Some(platform_version) = get_platform_version() {
301            event.insert_prop("platform_version", platform_version).ok();
302        }
303
304        let config = Config::global();
305        if let Ok(provider) = config.get_param::<String>("ASTER_PROVIDER") {
306            event.insert_prop("provider", provider).ok();
307        }
308        if let Ok(model) = config.get_param::<String>("ASTER_MODEL") {
309            event.insert_prop("model", model).ok();
310        }
311
312        client.capture(event).await.map_err(|e| format!("{:?}", e))
313    }
314}
315
316async fn send_custom_slash_command_event(installation: &InstallationData) -> Result<(), String> {
317    #[cfg(not(feature = "telemetry-posthog"))]
318    {
319        let _ = installation;
320        return Ok(());
321    }
322
323    #[cfg(feature = "telemetry-posthog")]
324    {
325        let client = posthog_rs::client(POSTHOG_API_KEY).await;
326        let mut event =
327            posthog_rs::Event::new("custom_slash_command_used", &installation.installation_id);
328
329        event.insert_prop("source", "backend").ok();
330        event.insert_prop("version", env!("CARGO_PKG_VERSION")).ok();
331        event.insert_prop("interface", get_session_interface()).ok();
332        event.insert_prop("os", std::env::consts::OS).ok();
333        event.insert_prop("arch", std::env::consts::ARCH).ok();
334
335        if let Some(platform_version) = get_platform_version() {
336            event.insert_prop("platform_version", platform_version).ok();
337        }
338
339        client.capture(event).await.map_err(|e| format!("{:?}", e))
340    }
341}
342
343async fn send_session_event(installation: &InstallationData) -> Result<(), String> {
344    #[cfg(not(feature = "telemetry-posthog"))]
345    {
346        let _ = installation;
347        return Ok(());
348    }
349
350    #[cfg(feature = "telemetry-posthog")]
351    {
352        let client = posthog_rs::client(POSTHOG_API_KEY).await;
353        let mut event = posthog_rs::Event::new("session_started", &installation.installation_id);
354
355        event.insert_prop("os", std::env::consts::OS).ok();
356        event.insert_prop("arch", std::env::consts::ARCH).ok();
357        event.insert_prop("version", env!("CARGO_PKG_VERSION")).ok();
358        event.insert_prop("is_dev", is_dev_mode()).ok();
359
360        if let Some(platform_version) = get_platform_version() {
361            event.insert_prop("platform_version", platform_version).ok();
362        }
363
364        event
365            .insert_prop("install_method", detect_install_method())
366            .ok();
367
368        event.insert_prop("interface", get_session_interface()).ok();
369
370        event
371            .insert_prop("is_resumed", get_session_is_resumed())
372            .ok();
373
374        event
375            .insert_prop("session_number", installation.session_count)
376            .ok();
377        let days_since_install = (Utc::now() - installation.first_seen).num_days();
378        event
379            .insert_prop("days_since_install", days_since_install)
380            .ok();
381
382        let config = Config::global();
383        if let Ok(provider) = config.get_param::<String>("ASTER_PROVIDER") {
384            event.insert_prop("provider", provider).ok();
385        }
386        if let Ok(model) = config.get_param::<String>("ASTER_MODEL") {
387            event.insert_prop("model", model).ok();
388        }
389
390        if let Ok(mode) = config.get_param::<String>("ASTER_MODE") {
391            event.insert_prop("setting_mode", mode).ok();
392        }
393        if let Ok(max_turns) = config.get_param::<i64>("ASTER_MAX_TURNS") {
394            event.insert_prop("setting_max_turns", max_turns).ok();
395        }
396
397        if let Ok(lead_model) = config.get_param::<String>("ASTER_LEAD_MODEL") {
398            event.insert_prop("setting_lead_model", lead_model).ok();
399        }
400        if let Ok(lead_provider) = config.get_param::<String>("ASTER_LEAD_PROVIDER") {
401            event
402                .insert_prop("setting_lead_provider", lead_provider)
403                .ok();
404        }
405        if let Ok(lead_turns) = config.get_param::<i64>("ASTER_LEAD_TURNS") {
406            event.insert_prop("setting_lead_turns", lead_turns).ok();
407        }
408        if let Ok(lead_failure_threshold) = config.get_param::<i64>("ASTER_LEAD_FAILURE_THRESHOLD")
409        {
410            event
411                .insert_prop("setting_lead_failure_threshold", lead_failure_threshold)
412                .ok();
413        }
414        if let Ok(lead_fallback_turns) = config.get_param::<i64>("ASTER_LEAD_FALLBACK_TURNS") {
415            event
416                .insert_prop("setting_lead_fallback_turns", lead_fallback_turns)
417                .ok();
418        }
419
420        let extensions = get_enabled_extensions();
421        event.insert_prop("extensions_count", extensions.len()).ok();
422        let extension_names: Vec<String> = extensions.iter().map(|e| e.name()).collect();
423        event.insert_prop("extensions", extension_names).ok();
424
425        event
426            .insert_prop("db_schema_version", CURRENT_SCHEMA_VERSION)
427            .ok();
428
429        if let Ok(insights) = SessionManager::get_insights().await {
430            event
431                .insert_prop("total_sessions", insights.total_sessions)
432                .ok();
433            event
434                .insert_prop("total_tokens", insights.total_tokens)
435                .ok();
436        }
437
438        client.capture(event).await.map_err(|e| format!("{:?}", e))
439    }
440}
441
442// ============================================================================
443// Error Classification
444// ============================================================================
445pub fn classify_error(error: &str) -> &'static str {
446    let error_lower = error.to_lowercase();
447
448    if error_lower.contains("network") || error_lower.contains("fetch") {
449        return "network_error";
450    }
451    if error_lower.contains("timeout") {
452        return "timeout";
453    }
454    if error_lower.contains("rate") && error_lower.contains("limit") {
455        return "rate_limit";
456    }
457    if error_lower.contains("auth")
458        || error_lower.contains("unauthorized")
459        || error_lower.contains("401")
460    {
461        return "auth_error";
462    }
463    if error_lower.contains("permission") || error_lower.contains("403") {
464        return "permission_error";
465    }
466    if error_lower.contains("not found") || error_lower.contains("404") {
467        return "not_found";
468    }
469    if error_lower.contains("provider") {
470        return "provider_error";
471    }
472    if error_lower.contains("config") {
473        return "config_error";
474    }
475    if error_lower.contains("extension") {
476        return "extension_error";
477    }
478    if error_lower.contains("database") || error_lower.contains("db") || error_lower.contains("sql")
479    {
480        return "database_error";
481    }
482    if error_lower.contains("migration") {
483        return "migration_error";
484    }
485    if error_lower.contains("render") || error_lower.contains("react") {
486        return "render_error";
487    }
488    if error_lower.contains("chunk") || error_lower.contains("module") {
489        return "module_error";
490    }
491
492    "unknown_error"
493}
494
495// ============================================================================
496// Privacy Sanitization
497// ============================================================================
498
499use regex::Regex;
500use std::sync::LazyLock;
501
502static SENSITIVE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
503    vec![
504        // File paths with usernames (Unix)
505        Regex::new(r"/Users/[^/\s]+").unwrap(),
506        Regex::new(r"/home/[^/\s]+").unwrap(),
507        // File paths with usernames (Windows)
508        Regex::new(r"(?i)C:\\Users\\[^\\\s]+").unwrap(),
509        // API keys and tokens (common patterns)
510        Regex::new(r"sk-[a-zA-Z0-9]{20,}").unwrap(),
511        Regex::new(r"pk-[a-zA-Z0-9]{20,}").unwrap(),
512        Regex::new(r"(?i)key[_-]?[a-zA-Z0-9]{16,}").unwrap(),
513        Regex::new(r"(?i)token[_-]?[a-zA-Z0-9]{16,}").unwrap(),
514        Regex::new(r"(?i)bearer\s+[a-zA-Z0-9._-]+").unwrap(),
515        // Email addresses
516        Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}").unwrap(),
517        // URLs with auth info
518        Regex::new(r"https?://[^:]+:[^@]+@").unwrap(),
519        // UUIDs (might be session/user IDs in error messages)
520        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}")
521            .unwrap(),
522    ]
523});
524
525fn sanitize_string(s: &str) -> String {
526    let mut result = s.to_string();
527    for pattern in SENSITIVE_PATTERNS.iter() {
528        result = pattern.replace_all(&result, "[REDACTED]").to_string();
529    }
530    result
531}
532
533fn sanitize_value(value: serde_json::Value) -> serde_json::Value {
534    match value {
535        serde_json::Value::String(s) => serde_json::Value::String(sanitize_string(&s)),
536        serde_json::Value::Array(arr) => {
537            serde_json::Value::Array(arr.into_iter().map(sanitize_value).collect())
538        }
539        serde_json::Value::Object(obj) => serde_json::Value::Object(
540            obj.into_iter()
541                .map(|(k, v)| (k, sanitize_value(v)))
542                .collect(),
543        ),
544        other => other,
545    }
546}
547
548// ============================================================================
549// Generic Event API (for frontend)
550// ============================================================================
551pub async fn emit_event(
552    event_name: &str,
553    properties: std::collections::HashMap<String, serde_json::Value>,
554) -> Result<(), String> {
555    if !is_telemetry_enabled() {
556        return Ok(());
557    }
558
559    #[cfg(not(feature = "telemetry-posthog"))]
560    {
561        let _ = (event_name, properties);
562        return Ok(());
563    }
564
565    #[cfg(feature = "telemetry-posthog")]
566    {
567        let mut properties = properties;
568        let installation = load_or_create_installation();
569        let client = posthog_rs::client(POSTHOG_API_KEY).await;
570        let mut event = posthog_rs::Event::new(event_name, &installation.installation_id);
571
572        event.insert_prop("os", std::env::consts::OS).ok();
573        event.insert_prop("arch", std::env::consts::ARCH).ok();
574        event.insert_prop("version", env!("CARGO_PKG_VERSION")).ok();
575        event.insert_prop("interface", "desktop").ok();
576        event.insert_prop("source", "ui").ok();
577
578        if let Some(platform_version) = get_platform_version() {
579            event.insert_prop("platform_version", platform_version).ok();
580        }
581
582        if event_name == "error_occurred" || event_name == "app_crashed" {
583            if let Some(serde_json::Value::String(error_type)) = properties.get("error_type") {
584                let classified = classify_error(error_type);
585                properties.insert(
586                    "error_category".to_string(),
587                    serde_json::Value::String(classified.to_string()),
588                );
589            }
590        }
591
592        for (key, value) in properties {
593            let key_lower = key.to_lowercase();
594            if key_lower.contains("key")
595                || key_lower.contains("token")
596                || key_lower.contains("secret")
597                || key_lower.contains("password")
598                || key_lower.contains("credential")
599            {
600                continue;
601            }
602            let sanitized_value = sanitize_value(value);
603            event.insert_prop(&key, sanitized_value).ok();
604        }
605
606        client.capture(event).await.map_err(|e| format!("{:?}", e))
607    }
608}