1#[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
21pub 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
31pub 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#[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
107fn 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
179static 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
205pub 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
442pub 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
495use regex::Regex;
500use std::sync::LazyLock;
501
502static SENSITIVE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
503 vec![
504 Regex::new(r"/Users/[^/\s]+").unwrap(),
506 Regex::new(r"/home/[^/\s]+").unwrap(),
507 Regex::new(r"(?i)C:\\Users\\[^\\\s]+").unwrap(),
509 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 Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}").unwrap(),
517 Regex::new(r"https?://[^:]+:[^@]+@").unwrap(),
519 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
548pub 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}