arct_telemetry/
lib.rs

1//! Privacy-first telemetry for Arc Academy Terminal
2//!
3//! This module provides opt-in, local telemetry that helps users understand
4//! their learning patterns while respecting privacy.
5//!
6//! Privacy guarantees:
7//! - Opt-in only (disabled by default)
8//! - All data stored locally (never sent anywhere)
9//! - No sensitive information collected (no command arguments, no file paths)
10//! - Users can export and delete their data at any time
11//! - Anonymous user IDs
12
13use anyhow::{Context, Result};
14use chrono::Utc;
15use rusqlite::{params, Connection};
16use serde::{Deserialize, Serialize};
17use std::path::PathBuf;
18use uuid::Uuid;
19
20/// Telemetry event types
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(tag = "type", rename_all = "snake_case")]
23pub enum TelemetryEvent {
24    /// Application session started
25    SessionStarted {
26        session_id: String,
27        version: String,
28        os: String,
29        arch: String,
30    },
31
32    /// Application session ended
33    SessionEnded {
34        session_id: String,
35        duration_ms: u64,
36    },
37
38    /// Command executed (command name only, no arguments)
39    CommandExecuted {
40        command: String,
41        success: bool,
42        duration_ms: u64,
43    },
44
45    /// Feature used
46    FeatureUsed {
47        feature: String,
48        context: Option<String>,
49    },
50
51    /// Error occurred
52    ErrorOccurred {
53        error_type: String,
54        context: String,
55    },
56
57    /// Theme changed
58    ThemeChanged {
59        from: String,
60        to: String,
61    },
62
63    /// Configuration changed
64    ConfigChanged {
65        key: String,
66    },
67}
68
69/// Telemetry service
70pub struct Telemetry {
71    enabled: bool,
72    user_id: String,
73    db: Connection,
74}
75
76impl Telemetry {
77    /// Create a new telemetry instance
78    pub fn new(enabled: bool) -> Result<Self> {
79        let db_path = get_telemetry_db_path()?;
80
81        // Ensure parent directory exists
82        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        // Initialize database schema
89        Self::init_db(&db)?;
90
91        // Load or create user ID
92        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    /// Initialize database schema
102    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    /// Load or create anonymous user ID
128    fn load_or_create_user_id(db: &Connection) -> Result<String> {
129        // Try to load existing user ID
130        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        // Create new anonymous user ID
143        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    /// Record a telemetry event
153    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    /// Get telemetry statistics
180    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        // Most used commands
200        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        // Features used
215        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    /// Export all telemetry data as JSON
239    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    /// Delete all telemetry data
265    pub fn delete_all_data(&self) -> Result<()> {
266        self.db.execute("DELETE FROM events", [])?;
267        Ok(())
268    }
269
270    /// Get database path
271    pub fn db_path() -> Result<PathBuf> {
272        get_telemetry_db_path()
273    }
274}
275
276/// Telemetry statistics
277#[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/// Exported event
287#[derive(Debug, Clone, Serialize, Deserialize)]
288struct ExportedEvent {
289    timestamp: String,
290    event_type: String,
291    event_data: String,
292}
293
294/// Telemetry export
295#[derive(Debug, Clone, Serialize, Deserialize)]
296struct TelemetryExport {
297    user_id: String,
298    exported_at: String,
299    events: Vec<ExportedEvent>,
300}
301
302/// Get the telemetry database path
303pub 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        // Recording events should succeed but do nothing
326        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}