Skip to main content

lean_ctx/core/
telemetry_ledger.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Local append-only ledger of sent telemetry heartbeats.
3//!
4//! Every successful heartbeat is recorded as a JSON line in
5//! `<state_dir>/telemetry_heartbeats.jsonl`. This gives the user full
6//! transparency over what was sent and when — visible in the dashboard
7//! and via `lean-ctx telemetry history`.
8
9use serde::{Deserialize, Serialize};
10use std::path::PathBuf;
11
12use super::telemetry_aggregate::lock_telemetry_file;
13
14const MAX_LEDGER_BYTES: u64 = 1_048_576;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct HeartbeatRecord {
18    pub timestamp: String,
19    pub installation_id: String,
20    pub version: String,
21    pub os: String,
22    pub arch: String,
23    #[serde(default)]
24    pub schema_version: u16,
25    #[serde(default)]
26    pub event_names: Vec<String>,
27    #[serde(default)]
28    pub payload_hash: String,
29    #[serde(default)]
30    pub endpoint: String,
31    #[serde(default)]
32    pub status: String,
33}
34
35fn ledger_path() -> Result<PathBuf, String> {
36    crate::core::paths::state_dir().map(|d| d.join("telemetry_heartbeats.jsonl"))
37}
38
39pub(crate) fn append(record: &HeartbeatRecord) -> Result<(), String> {
40    let path = ledger_path()?;
41    if let Some(parent) = path.parent() {
42        std::fs::create_dir_all(parent).map_err(|e| format!("Cannot create state dir: {e}"))?;
43    }
44    let lock = open_lock(&path)?;
45    lock_telemetry_file(&lock, "ledger")?;
46    if read_paths(&path)?.iter().any(|existing| {
47        !record.payload_hash.is_empty() && existing.payload_hash == record.payload_hash
48    }) {
49        return Ok(());
50    }
51    let mut line =
52        serde_json::to_string(record).map_err(|e| format!("JSON serialization error: {e}"))?;
53    line.push('\n');
54
55    let current_len = std::fs::metadata(&path).map_or(0, |metadata| metadata.len());
56    if current_len.saturating_add(line.len() as u64) > MAX_LEDGER_BYTES {
57        rotate(&path)?;
58    }
59
60    use std::io::Write;
61    let mut options = std::fs::OpenOptions::new();
62    options.create(true).append(true);
63    #[cfg(unix)]
64    {
65        use std::os::unix::fs::OpenOptionsExt;
66        options.mode(0o600);
67    }
68    let file = options
69        .open(&path)
70        .map_err(|e| format!("Cannot open telemetry ledger: {e}"))?;
71    let mut writer = std::io::BufWriter::new(file);
72    writer
73        .write_all(line.as_bytes())
74        .map_err(|e| format!("Cannot write to telemetry ledger: {e}"))?;
75    writer
76        .flush()
77        .map_err(|e| format!("Cannot flush telemetry ledger: {e}"))?;
78    writer
79        .get_ref()
80        .sync_all()
81        .map_err(|e| format!("Cannot sync telemetry ledger: {e}"))?;
82    Ok(())
83}
84
85pub fn read_all() -> Vec<HeartbeatRecord> {
86    let Ok(path) = ledger_path() else {
87        return Vec::new();
88    };
89    read_paths(&path).unwrap_or_default()
90}
91
92pub(crate) fn latest_valid_version() -> Result<Option<String>, String> {
93    let path = ledger_path()?;
94    if let Some(parent) = path.parent() {
95        std::fs::create_dir_all(parent).map_err(|e| format!("Cannot create state dir: {e}"))?;
96    }
97    let lock = open_lock(&path)?;
98    lock_telemetry_file(&lock, "ledger")?;
99    Ok(read_paths(&path)?
100        .into_iter()
101        .rev()
102        .find_map(|record| (!record.version.is_empty()).then_some(record.version)))
103}
104
105pub fn purge_local() -> Result<(), String> {
106    let path = ledger_path()?;
107    if let Some(parent) = path.parent() {
108        std::fs::create_dir_all(parent).map_err(|e| format!("Cannot create state dir: {e}"))?;
109    }
110    let lock = open_lock(&path)?;
111    lock_telemetry_file(&lock, "ledger")?;
112    for candidate in [&path, &rotated_path(&path)] {
113        match std::fs::remove_file(candidate) {
114            Ok(()) => {}
115            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
116            Err(error) => return Err(format!("Cannot purge telemetry ledger: {error}")),
117        }
118    }
119    Ok(())
120}
121
122fn open_lock(path: &std::path::Path) -> Result<std::fs::File, String> {
123    std::fs::OpenOptions::new()
124        .create(true)
125        .truncate(false)
126        .read(true)
127        .write(true)
128        .open(path.with_extension("lock"))
129        .map_err(|e| format!("Cannot open telemetry ledger lock: {e}"))
130}
131
132fn rotated_path(path: &std::path::Path) -> PathBuf {
133    path.with_extension("jsonl.1")
134}
135
136fn rotate(path: &std::path::Path) -> Result<(), String> {
137    let rotated = rotated_path(path);
138    match std::fs::remove_file(&rotated) {
139        Ok(()) => {}
140        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
141        Err(error) => return Err(format!("Cannot replace rotated telemetry ledger: {error}")),
142    }
143    let length = std::fs::metadata(path).map_or(0, |metadata| metadata.len());
144    if length > MAX_LEDGER_BYTES {
145        use std::io::{Read, Seek};
146        let mut file = std::fs::File::open(path)
147            .map_err(|error| format!("Cannot open oversized telemetry ledger: {error}"))?;
148        file.seek(std::io::SeekFrom::End(-(MAX_LEDGER_BYTES as i64)))
149            .map_err(|error| format!("Cannot seek telemetry ledger: {error}"))?;
150        let mut tail = Vec::with_capacity(MAX_LEDGER_BYTES as usize);
151        file.read_to_end(&mut tail)
152            .map_err(|error| format!("Cannot read telemetry ledger tail: {error}"))?;
153        let start = tail
154            .iter()
155            .position(|byte| *byte == b'\n')
156            .map_or(tail.len(), |index| index + 1);
157        #[cfg(unix)]
158        let permissions = {
159            use std::os::unix::fs::PermissionsExt;
160            Some(std::fs::Permissions::from_mode(0o600))
161        };
162        #[cfg(not(unix))]
163        let permissions: Option<std::fs::Permissions> = None;
164        crate::core::atomic_fs::try_atomic_write(&rotated, &tail[start..], permissions.as_ref())
165            .map_err(|error| format!("Cannot write bounded telemetry ledger: {error}"))?;
166        std::fs::remove_file(path)
167            .map_err(|error| format!("Cannot replace oversized telemetry ledger: {error}"))?;
168        return Ok(());
169    }
170    match std::fs::rename(path, rotated) {
171        Ok(()) => Ok(()),
172        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
173        Err(error) => Err(format!("Cannot rotate telemetry ledger: {error}")),
174    }
175}
176
177fn read_paths(path: &std::path::Path) -> Result<Vec<HeartbeatRecord>, String> {
178    let mut records = Vec::new();
179    for candidate in [rotated_path(path), path.to_path_buf()] {
180        let content = match std::fs::read_to_string(candidate) {
181            Ok(content) => content,
182            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
183            Err(error) => return Err(format!("Cannot read telemetry ledger: {error}")),
184        };
185        records.extend(
186            content
187                .lines()
188                .filter_map(|line| serde_json::from_str(line).ok()),
189        );
190    }
191    Ok(records)
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use fs2::FileExt;
198
199    #[test]
200    fn contended_ledger_operations_return_without_mutating_records() {
201        let _iso = crate::core::data_dir::isolated_data_dir();
202        let path = ledger_path().unwrap();
203        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
204        let record = HeartbeatRecord {
205            timestamp: String::new(),
206            installation_id: String::new(),
207            version: "4".into(),
208            os: String::new(),
209            arch: String::new(),
210            schema_version: 2,
211            event_names: vec![],
212            payload_hash: "b".repeat(64),
213            endpoint: String::new(),
214            status: "success".into(),
215        };
216        append(&record).unwrap();
217        assert_eq!(latest_valid_version().unwrap().as_deref(), Some("4"));
218        let original = std::fs::read(&path).unwrap();
219        let blocker = open_lock(&path).unwrap();
220        blocker.lock_exclusive().unwrap();
221        let started = std::time::Instant::now();
222        assert!(append(&record).unwrap_err().contains("timed out"));
223        assert!(latest_valid_version().unwrap_err().contains("timed out"));
224        assert!(purge_local().unwrap_err().contains("timed out"));
225        assert!(started.elapsed() < std::time::Duration::from_secs(10));
226        assert_eq!(std::fs::read(&path).unwrap(), original);
227        drop(blocker);
228        append(&record).unwrap();
229        assert_eq!(latest_valid_version().unwrap().as_deref(), Some("4"));
230        purge_local().unwrap();
231        assert!(!path.exists());
232    }
233
234    #[test]
235    fn unreadable_ledger_is_not_an_empty_history() {
236        let _iso = crate::core::data_dir::isolated_data_dir();
237        let path = ledger_path().unwrap();
238        assert_eq!(latest_valid_version().unwrap(), None);
239        // A directory is unreadable as a ledger even under a privileged test user.
240        std::fs::create_dir_all(&path).unwrap();
241        assert!(
242            latest_valid_version()
243                .unwrap_err()
244                .contains("Cannot read telemetry ledger")
245        );
246    }
247
248    #[test]
249    fn append_and_read_roundtrip() {
250        let _iso = crate::core::data_dir::isolated_data_dir();
251        let record = HeartbeatRecord {
252            timestamp: "2026-07-30T23:00:00Z".to_string(),
253            installation_id: "test-uuid-1234".to_string(),
254            version: "3.9.13".to_string(),
255            os: "macos".to_string(),
256            arch: "aarch64".to_string(),
257            schema_version: 2,
258            event_names: vec!["heartbeat".to_string()],
259            payload_hash: "a".repeat(64),
260            endpoint: "https://api.leanctx.com/api/telemetry/v2/batch".to_string(),
261            status: "success".to_string(),
262        };
263        append(&record).unwrap();
264        append(&record).unwrap();
265        let all = read_all();
266        assert_eq!(all.len(), 1);
267        assert_eq!(all[0].installation_id, "test-uuid-1234");
268        assert_eq!(all[0].version, "3.9.13");
269        purge_local().expect("purge ledger");
270        assert!(read_all().is_empty());
271    }
272}