lean_ctx/core/
telemetry_ledger.rs1use 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 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}