Skip to main content

memvid_cli/analytics/
queue.rs

1//! Local JSONL queue for analytics events
2//!
3//! Events are appended to a local file and periodically flushed
4//! to the server in batches.
5
6use serde::{Deserialize, Serialize};
7use std::fs::{self, OpenOptions};
8use std::io::{BufRead, BufReader, Write};
9use std::path::PathBuf;
10use std::sync::Mutex;
11
12/// Analytics event structure
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct AnalyticsEvent {
15    pub anon_id: String,
16    pub file_hash: String,
17    pub client: String,
18    pub command: String,
19    pub success: bool,
20    pub timestamp: String,
21    #[serde(default)]
22    pub file_created: bool,
23    #[serde(default)]
24    pub file_opened: bool,
25    /// User tier: "free", or plan name like "starter", "teams", "enterprise"
26    #[serde(default = "default_tier")]
27    pub user_tier: String,
28}
29
30fn default_tier() -> String {
31    "free".to_string()
32}
33
34/// Global queue file lock
35static QUEUE_LOCK: Mutex<()> = Mutex::new(());
36
37/// Get the analytics queue directory
38fn get_analytics_dir() -> Option<PathBuf> {
39    dirs::data_local_dir().map(|d| d.join("memvid").join("analytics"))
40}
41
42/// Get the queue file path
43fn get_queue_path() -> Option<PathBuf> {
44    get_analytics_dir().map(|d| d.join("queue.jsonl"))
45}
46
47/// Ensure analytics directory exists
48fn ensure_dir() -> Option<PathBuf> {
49    let dir = get_analytics_dir()?;
50    fs::create_dir_all(&dir).ok()?;
51    Some(dir)
52}
53
54/// Track an analytics event (append to local queue)
55/// This is fire-and-forget - errors are silently ignored
56pub fn track_event(event: AnalyticsEvent) {
57    if let Err(_e) = track_event_inner(event) {
58        // Silently ignore errors - analytics should never impact UX
59        #[cfg(debug_assertions)]
60        eprintln!("[analytics] Failed to queue event: {}", _e);
61    }
62}
63
64fn track_event_inner(event: AnalyticsEvent) -> std::io::Result<()> {
65    let _lock = QUEUE_LOCK.lock().map_err(|_| {
66        std::io::Error::new(std::io::ErrorKind::Other, "Failed to acquire queue lock")
67    })?;
68
69    let queue_path = get_queue_path()
70        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "Cannot find data dir"))?;
71
72    ensure_dir();
73
74    let mut file = OpenOptions::new()
75        .create(true)
76        .append(true)
77        .open(&queue_path)?;
78
79    let json = serde_json::to_string(&event)
80        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
81
82    writeln!(file, "{}", json)?;
83    Ok(())
84}
85
86/// Read all pending events from the queue
87pub fn read_pending_events() -> Vec<AnalyticsEvent> {
88    let _lock = match QUEUE_LOCK.lock() {
89        Ok(lock) => lock,
90        Err(_) => return vec![],
91    };
92
93    let queue_path = match get_queue_path() {
94        Some(p) => p,
95        None => return vec![],
96    };
97
98    if !queue_path.exists() {
99        return vec![];
100    }
101
102    let file = match fs::File::open(&queue_path) {
103        Ok(f) => f,
104        Err(_) => return vec![],
105    };
106
107    let reader = BufReader::new(file);
108    let mut events = Vec::new();
109
110    for line in reader.lines() {
111        if let Ok(line) = line {
112            if let Ok(event) = serde_json::from_str::<AnalyticsEvent>(&line) {
113                events.push(event);
114            }
115        }
116    }
117
118    events
119}
120
121/// Clear the queue after successful flush
122pub fn clear_queue() {
123    let _lock = match QUEUE_LOCK.lock() {
124        Ok(lock) => lock,
125        Err(_) => return,
126    };
127
128    if let Some(queue_path) = get_queue_path() {
129        let _ = fs::remove_file(&queue_path);
130    }
131}
132
133/// Get the number of pending events
134pub fn pending_count() -> usize {
135    read_pending_events().len()
136}
137
138/// Get the queue file size in bytes
139#[allow(dead_code)]
140pub fn queue_size_bytes() -> u64 {
141    get_queue_path()
142        .and_then(|p| fs::metadata(p).ok())
143        .map(|m| m.len())
144        .unwrap_or(0)
145}