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 let total_commands: i64 = self.db.query_row(
182 "SELECT COUNT(*) FROM events WHERE event_type = 'command_executed'",
183 [],
184 |row| row.get(0),
185 )?;
186
187 let total_sessions: i64 = self.db.query_row(
188 "SELECT COUNT(*) FROM events WHERE event_type = 'session_started'",
189 [],
190 |row| row.get(0),
191 )?;
192
193 let total_errors: i64 = self.db.query_row(
194 "SELECT COUNT(*) FROM events WHERE event_type = 'error_occurred'",
195 [],
196 |row| row.get(0),
197 )?;
198
199 let mut stmt = self.db.prepare(
201 "SELECT json_extract(event_data, '$.command') as cmd, COUNT(*) as count
202 FROM events
203 WHERE event_type = 'command_executed'
204 GROUP BY cmd
205 ORDER BY count DESC
206 LIMIT 10"
207 )?;
208
209 let top_commands: Vec<(String, i64)> = stmt
210 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
211 .filter_map(Result::ok)
212 .collect();
213
214 let mut stmt = self.db.prepare(
216 "SELECT json_extract(event_data, '$.feature') as feature, COUNT(*) as count
217 FROM events
218 WHERE event_type = 'feature_used'
219 GROUP BY feature
220 ORDER BY count DESC
221 LIMIT 10"
222 )?;
223
224 let features_used: Vec<(String, i64)> = stmt
225 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
226 .filter_map(Result::ok)
227 .collect();
228
229 Ok(TelemetryStats {
230 total_commands: total_commands as usize,
231 total_sessions: total_sessions as usize,
232 total_errors: total_errors as usize,
233 top_commands,
234 features_used,
235 })
236 }
237
238 pub fn export_data(&self) -> Result<String> {
240 let mut stmt = self.db.prepare(
241 "SELECT timestamp, event_type, event_data FROM events ORDER BY timestamp"
242 )?;
243
244 let events: Vec<ExportedEvent> = stmt
245 .query_map([], |row| {
246 Ok(ExportedEvent {
247 timestamp: row.get(0)?,
248 event_type: row.get(1)?,
249 event_data: row.get(2)?,
250 })
251 })?
252 .filter_map(Result::ok)
253 .collect();
254
255 let export = TelemetryExport {
256 user_id: self.user_id.clone(),
257 exported_at: Utc::now().to_rfc3339(),
258 events,
259 };
260
261 Ok(serde_json::to_string_pretty(&export)?)
262 }
263
264 pub fn delete_all_data(&self) -> Result<()> {
266 self.db.execute("DELETE FROM events", [])?;
267 Ok(())
268 }
269
270 pub fn db_path() -> Result<PathBuf> {
272 get_telemetry_db_path()
273 }
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct TelemetryStats {
279 pub total_commands: usize,
280 pub total_sessions: usize,
281 pub total_errors: usize,
282 pub top_commands: Vec<(String, i64)>,
283 pub features_used: Vec<(String, i64)>,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
288struct ExportedEvent {
289 timestamp: String,
290 event_type: String,
291 event_data: String,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
296struct TelemetryExport {
297 user_id: String,
298 exported_at: String,
299 events: Vec<ExportedEvent>,
300}
301
302pub fn get_telemetry_db_path() -> Result<PathBuf> {
304 let data_dir = dirs::data_local_dir()
305 .context("Could not find local data directory")?;
306
307 let arct_dir = data_dir.join("arct");
308
309 if !arct_dir.exists() {
310 std::fs::create_dir_all(&arct_dir)
311 .with_context(|| format!("Failed to create data directory: {}", arct_dir.display()))?;
312 }
313
314 Ok(arct_dir.join("telemetry.db"))
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn test_telemetry_disabled() {
323 let telemetry = Telemetry::new(false).unwrap();
324
325 telemetry.record(TelemetryEvent::CommandExecuted {
327 command: "ls".to_string(),
328 success: true,
329 duration_ms: 100,
330 }).unwrap();
331
332 let stats = telemetry.get_stats().unwrap();
333 assert_eq!(stats.total_commands, 0);
334 }
335
336 #[test]
337 fn test_telemetry_enabled() {
338 let telemetry = Telemetry::new(true).unwrap();
339
340 telemetry.record(TelemetryEvent::CommandExecuted {
341 command: "ls".to_string(),
342 success: true,
343 duration_ms: 100,
344 }).unwrap();
345
346 let stats = telemetry.get_stats().unwrap();
347 assert!(stats.total_commands > 0);
348 }
349}