1use anyhow::{Context, Result};
14use chrono::Utc;
15use rusqlite::{params, Connection};
16use serde::{Deserialize, Serialize};
17use std::path::PathBuf;
18use uuid::Uuid;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(tag = "type", rename_all = "snake_case")]
23pub enum TelemetryEvent {
24 SessionStarted {
26 session_id: String,
27 version: String,
28 os: String,
29 arch: String,
30 },
31
32 SessionEnded {
34 session_id: String,
35 duration_ms: u64,
36 },
37
38 CommandExecuted {
40 command: String,
41 success: bool,
42 duration_ms: u64,
43 },
44
45 FeatureUsed {
47 feature: String,
48 context: Option<String>,
49 },
50
51 ErrorOccurred {
53 error_type: String,
54 context: String,
55 },
56
57 ThemeChanged {
59 from: String,
60 to: String,
61 },
62
63 ConfigChanged {
65 key: String,
66 },
67}
68
69pub struct Telemetry {
71 enabled: bool,
72 user_id: String,
73 db: Connection,
74}
75
76impl Telemetry {
77 pub fn new(enabled: bool) -> Result<Self> {
79 let db_path = get_telemetry_db_path()?;
80
81 if let Some(parent) = db_path.parent() {
83 std::fs::create_dir_all(parent)?;
84 }
85
86 let db = Connection::open(&db_path)?;
87
88 Self::init_db(&db)?;
90
91 let user_id = Self::load_or_create_user_id(&db)?;
93
94 Ok(Self {
95 enabled,
96 user_id,
97 db,
98 })
99 }
100
101 fn init_db(db: &Connection) -> Result<()> {
103 db.execute_batch(
104 r#"
105 CREATE TABLE IF NOT EXISTS metadata (
106 key TEXT PRIMARY KEY,
107 value TEXT NOT NULL
108 );
109
110 CREATE TABLE IF NOT EXISTS events (
111 id INTEGER PRIMARY KEY AUTOINCREMENT,
112 timestamp TEXT NOT NULL,
113 user_id TEXT NOT NULL,
114 event_type TEXT NOT NULL,
115 event_data TEXT NOT NULL
116 );
117
118 CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp);
119 CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
120 CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id);
121 "#,
122 )?;
123
124 Ok(())
125 }
126
127 fn load_or_create_user_id(db: &Connection) -> Result<String> {
129 let existing: Option<String> = db
131 .query_row(
132 "SELECT value FROM metadata WHERE key = 'user_id'",
133 [],
134 |row| row.get(0),
135 )
136 .ok();
137
138 if let Some(user_id) = existing {
139 return Ok(user_id);
140 }
141
142 let user_id = Uuid::new_v4().to_string();
144 db.execute(
145 "INSERT INTO metadata (key, value) VALUES ('user_id', ?1)",
146 params![&user_id],
147 )?;
148
149 Ok(user_id)
150 }
151
152 pub fn record(&self, event: TelemetryEvent) -> Result<()> {
154 if !self.enabled {
155 return Ok(());
156 }
157
158 let timestamp = Utc::now().to_rfc3339();
159 let event_type = match &event {
160 TelemetryEvent::SessionStarted { .. } => "session_started",
161 TelemetryEvent::SessionEnded { .. } => "session_ended",
162 TelemetryEvent::CommandExecuted { .. } => "command_executed",
163 TelemetryEvent::FeatureUsed { .. } => "feature_used",
164 TelemetryEvent::ErrorOccurred { .. } => "error_occurred",
165 TelemetryEvent::ThemeChanged { .. } => "theme_changed",
166 TelemetryEvent::ConfigChanged { .. } => "config_changed",
167 };
168
169 let event_data = serde_json::to_string(&event)?;
170
171 self.db.execute(
172 "INSERT INTO events (timestamp, user_id, event_type, event_data) VALUES (?1, ?2, ?3, ?4)",
173 params![timestamp, &self.user_id, event_type, event_data],
174 )?;
175
176 Ok(())
177 }
178
179 pub fn get_stats(&self) -> Result<TelemetryStats> {
181 if !self.enabled {
183 return Ok(TelemetryStats {
184 total_commands: 0,
185 total_sessions: 0,
186 total_errors: 0,
187 top_commands: vec![],
188 features_used: vec![],
189 });
190 }
191
192 let total_commands: i64 = self.db.query_row(
193 "SELECT COUNT(*) FROM events WHERE event_type = 'command_executed'",
194 [],
195 |row| row.get(0),
196 )?;
197
198 let total_sessions: i64 = self.db.query_row(
199 "SELECT COUNT(*) FROM events WHERE event_type = 'session_started'",
200 [],
201 |row| row.get(0),
202 )?;
203
204 let total_errors: i64 = self.db.query_row(
205 "SELECT COUNT(*) FROM events WHERE event_type = 'error_occurred'",
206 [],
207 |row| row.get(0),
208 )?;
209
210 let mut stmt = self.db.prepare(
212 "SELECT json_extract(event_data, '$.command') as cmd, COUNT(*) as count
213 FROM events
214 WHERE event_type = 'command_executed'
215 GROUP BY cmd
216 ORDER BY count DESC
217 LIMIT 10"
218 )?;
219
220 let top_commands: Vec<(String, i64)> = stmt
221 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
222 .filter_map(Result::ok)
223 .collect();
224
225 let mut stmt = self.db.prepare(
227 "SELECT json_extract(event_data, '$.feature') as feature, COUNT(*) as count
228 FROM events
229 WHERE event_type = 'feature_used'
230 GROUP BY feature
231 ORDER BY count DESC
232 LIMIT 10"
233 )?;
234
235 let features_used: Vec<(String, i64)> = stmt
236 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
237 .filter_map(Result::ok)
238 .collect();
239
240 Ok(TelemetryStats {
241 total_commands: total_commands as usize,
242 total_sessions: total_sessions as usize,
243 total_errors: total_errors as usize,
244 top_commands,
245 features_used,
246 })
247 }
248
249 pub fn export_data(&self) -> Result<String> {
251 let mut stmt = self.db.prepare(
252 "SELECT timestamp, event_type, event_data FROM events ORDER BY timestamp"
253 )?;
254
255 let events: Vec<ExportedEvent> = stmt
256 .query_map([], |row| {
257 Ok(ExportedEvent {
258 timestamp: row.get(0)?,
259 event_type: row.get(1)?,
260 event_data: row.get(2)?,
261 })
262 })?
263 .filter_map(Result::ok)
264 .collect();
265
266 let export = TelemetryExport {
267 user_id: self.user_id.clone(),
268 exported_at: Utc::now().to_rfc3339(),
269 events,
270 };
271
272 Ok(serde_json::to_string_pretty(&export)?)
273 }
274
275 pub fn delete_all_data(&self) -> Result<()> {
277 self.db.execute("DELETE FROM events", [])?;
278 Ok(())
279 }
280
281 pub fn db_path() -> Result<PathBuf> {
283 get_telemetry_db_path()
284 }
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct TelemetryStats {
290 pub total_commands: usize,
291 pub total_sessions: usize,
292 pub total_errors: usize,
293 pub top_commands: Vec<(String, i64)>,
294 pub features_used: Vec<(String, i64)>,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
299struct ExportedEvent {
300 timestamp: String,
301 event_type: String,
302 event_data: String,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
307struct TelemetryExport {
308 user_id: String,
309 exported_at: String,
310 events: Vec<ExportedEvent>,
311}
312
313pub fn get_telemetry_db_path() -> Result<PathBuf> {
315 let data_dir = dirs::data_local_dir()
316 .context("Could not find local data directory")?;
317
318 let arct_dir = data_dir.join("arct");
319
320 if !arct_dir.exists() {
321 std::fs::create_dir_all(&arct_dir)
322 .with_context(|| format!("Failed to create data directory: {}", arct_dir.display()))?;
323 }
324
325 Ok(arct_dir.join("telemetry.db"))
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
333 fn test_telemetry_disabled() {
334 let telemetry = Telemetry::new(false).unwrap();
335
336 telemetry.record(TelemetryEvent::CommandExecuted {
338 command: "ls".to_string(),
339 success: true,
340 duration_ms: 100,
341 }).unwrap();
342
343 let stats = telemetry.get_stats().unwrap();
344 assert_eq!(stats.total_commands, 0);
345 }
346
347 #[test]
348 fn test_telemetry_enabled() {
349 let telemetry = Telemetry::new(true).unwrap();
350
351 telemetry.record(TelemetryEvent::CommandExecuted {
352 command: "ls".to_string(),
353 success: true,
354 duration_ms: 100,
355 }).unwrap();
356
357 let stats = telemetry.get_stats().unwrap();
358 assert!(stats.total_commands > 0);
359 }
360}