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        // If telemetry is disabled, return empty stats
182        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        // Most used commands
211        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        // Features used
226        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    /// Export all telemetry data as JSON
250    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    /// Delete all telemetry data
276    pub fn delete_all_data(&self) -> Result<()> {
277        self.db.execute("DELETE FROM events", [])?;
278        Ok(())
279    }
280
281    /// Get database path
282    pub fn db_path() -> Result<PathBuf> {
283        get_telemetry_db_path()
284    }
285}
286
287/// Telemetry statistics
288#[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/// Exported event
298#[derive(Debug, Clone, Serialize, Deserialize)]
299struct ExportedEvent {
300    timestamp: String,
301    event_type: String,
302    event_data: String,
303}
304
305/// Telemetry export
306#[derive(Debug, Clone, Serialize, Deserialize)]
307struct TelemetryExport {
308    user_id: String,
309    exported_at: String,
310    events: Vec<ExportedEvent>,
311}
312
313/// Get the telemetry database path
314pub 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        // Recording events should succeed but do nothing
337        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}