1use std::collections::HashMap;
2use std::io::Write as IoWrite;
3use std::path::{Path, PathBuf};
4use std::sync::Mutex;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7pub const ENDPOINT: &str = "https://codesynapse-telemetry.sohilladhani.workers.dev/v1/events";
8const SCHEMA_VERSION: u32 = 1;
9const MAX_QUEUE_BYTES: u64 = 256 * 1024;
10
11#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14pub struct TelemetryConfig {
15 pub enabled: bool,
16 pub machine_id: String,
17 pub consent_source: String,
18 pub updated_at: String,
19}
20
21#[derive(Debug)]
22pub struct TelemetryStatus {
23 pub enabled: bool,
24 pub decided_by: &'static str,
25 pub machine_id: Option<String>,
26 pub config_path: PathBuf,
27}
28
29#[derive(Default)]
32struct CountEntry {
33 calls: u64,
34 errors: u64,
35 saved_chars: u64,
36}
37
38#[derive(Default)]
39struct Inner {
40 counts: HashMap<(String, String), CountEntry>,
42 lifecycle: Vec<serde_json::Value>,
43}
44
45pub struct Telemetry {
48 global_dir: PathBuf,
49 inner: Mutex<Inner>,
50}
51
52impl Telemetry {
53 pub fn new(global_dir: PathBuf) -> Self {
54 Self {
55 global_dir,
56 inner: Mutex::new(Inner::default()),
57 }
58 }
59
60 pub fn status(&self) -> TelemetryStatus {
63 let config = self.read_config();
64 let machine_id = config.as_ref().map(|c| c.machine_id.clone());
65 let config_path = self.config_path();
66
67 if env_flag("DO_NOT_TRACK") {
68 return TelemetryStatus {
69 enabled: false,
70 decided_by: "DO_NOT_TRACK",
71 machine_id,
72 config_path,
73 };
74 }
75 if let Ok(v) = std::env::var("CODESYNAPSE_TELEMETRY") {
76 let on = !matches!(v.as_str(), "0" | "false" | "off");
77 return TelemetryStatus {
78 enabled: on,
79 decided_by: "CODESYNAPSE_TELEMETRY",
80 machine_id,
81 config_path,
82 };
83 }
84 match config {
85 Some(c) => TelemetryStatus {
86 enabled: c.enabled,
87 decided_by: "config",
88 machine_id: Some(c.machine_id),
89 config_path,
90 },
91 None => TelemetryStatus {
92 enabled: false,
93 decided_by: "default",
94 machine_id: None,
95 config_path,
96 },
97 }
98 }
99
100 pub fn is_enabled(&self) -> bool {
101 self.status().enabled
102 }
103
104 pub fn set_enabled(&self, enabled: bool) {
105 let existing = self.read_config();
106 let machine_id = existing
107 .as_ref()
108 .map(|c| c.machine_id.clone())
109 .unwrap_or_else(new_uuid);
110 let config = TelemetryConfig {
111 enabled,
112 machine_id,
113 consent_source: "cli".into(),
114 updated_at: utc_now_iso(),
115 };
116 self.write_config(&config);
117 if !enabled {
118 let _ = std::fs::remove_file(self.queue_path());
119 }
120 }
121
122 pub fn record_usage(&self, tool: &str, saved_chars: usize, ok: bool) {
125 if !self.is_enabled() {
126 return;
127 }
128 let day = utc_day();
129 let mut inner = self.inner.lock().unwrap();
130 let entry = inner.counts.entry((day, tool.to_string())).or_default();
131 entry.calls += 1;
132 if !ok {
133 entry.errors += 1;
134 }
135 entry.saved_chars += saved_chars as u64;
136 }
137
138 pub fn record_lifecycle(&self, event: &str, props: serde_json::Value) {
139 if !self.is_enabled() {
140 return;
141 }
142 let line = serde_json::json!({
143 "type": "lifecycle",
144 "event": event,
145 "ts": utc_now_iso(),
146 "props": props,
147 });
148 self.inner.lock().unwrap().lifecycle.push(line);
149 }
150
151 pub fn persist_sync(&self) {
154 if !self.is_enabled() {
155 return;
156 }
157 let mut inner = self.inner.lock().unwrap();
158 if inner.counts.is_empty() && inner.lifecycle.is_empty() {
159 return;
160 }
161
162 let mut lines: Vec<serde_json::Value> = Vec::new();
163
164 for ((day, tool), entry) in inner.counts.drain() {
165 lines.push(serde_json::json!({
166 "type": "rollup",
167 "day": day,
168 "tool": tool,
169 "calls": entry.calls,
170 "errors": entry.errors,
171 "saved_chars": entry.saved_chars,
172 }));
173 }
174 for ev in inner.lifecycle.drain(..) {
175 lines.push(ev);
176 }
177
178 let _ = self.append_to_queue(&lines);
179 }
180
181 pub fn flush_bg(&self) {
184 if !self.is_enabled() {
185 return;
186 }
187 let config = match self.read_config() {
188 Some(c) if c.enabled => c,
189 _ => return,
190 };
191 let queue_path = self.queue_path();
192 if !queue_path.exists() {
193 return;
194 }
195
196 let endpoint = std::env::var("CODESYNAPSE_TELEMETRY_ENDPOINT")
197 .unwrap_or_else(|_| ENDPOINT.to_string());
198
199 std::thread::spawn(move || {
200 let _ = do_flush(&queue_path, &config.machine_id, &endpoint);
201 });
202 }
203
204 fn config_path(&self) -> PathBuf {
207 self.global_dir.join("telemetry.json")
208 }
209
210 fn queue_path(&self) -> PathBuf {
211 self.global_dir.join("telemetry-queue.jsonl")
212 }
213
214 fn read_config(&self) -> Option<TelemetryConfig> {
215 let text = std::fs::read_to_string(self.config_path()).ok()?;
216 serde_json::from_str(&text).ok()
217 }
218
219 fn write_config(&self, config: &TelemetryConfig) {
220 let _ = std::fs::create_dir_all(&self.global_dir);
221 if let Ok(text) = serde_json::to_string_pretty(config) {
222 let _ = std::fs::write(self.config_path(), text + "\n");
223 }
224 }
225
226 fn append_to_queue(&self, lines: &[serde_json::Value]) -> std::io::Result<()> {
227 std::fs::create_dir_all(&self.global_dir)?;
228 let path = self.queue_path();
229
230 let payload = lines
232 .iter()
233 .filter_map(|l| serde_json::to_string(l).ok())
234 .collect::<Vec<_>>()
235 .join("\n")
236 + "\n";
237
238 let existing_len = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
239 if existing_len + payload.len() as u64 > MAX_QUEUE_BYTES {
240 let existing = std::fs::read_to_string(&path).unwrap_or_default();
241 let combined = existing + &payload;
242 let trimmed = trim_queue(&combined, MAX_QUEUE_BYTES as usize);
243 return std::fs::write(&path, trimmed);
244 }
245
246 let mut f = std::fs::OpenOptions::new()
247 .create(true)
248 .append(true)
249 .open(&path)?;
250 f.write_all(payload.as_bytes())?;
251 Ok(())
252 }
253}
254
255fn do_flush(queue_path: &Path, machine_id: &str, endpoint: &str) -> Option<()> {
258 let text = std::fs::read_to_string(queue_path).ok()?;
259 let today = utc_day();
260
261 let mut sendable: Vec<serde_json::Value> = Vec::new();
262 let mut keep: Vec<serde_json::Value> = Vec::new();
263
264 for line in text.lines() {
265 if line.trim().is_empty() {
266 continue;
267 }
268 let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
269 continue;
270 };
271 let is_lifecycle = v.get("type").and_then(|t| t.as_str()) == Some("lifecycle");
272 let day = v.get("day").and_then(|d| d.as_str()).unwrap_or("");
273
274 if is_lifecycle || (!day.is_empty() && day < today.as_str()) {
275 sendable.push(v);
276 } else {
277 keep.push(v);
278 }
279 }
280
281 if sendable.is_empty() {
282 return Some(());
283 }
284
285 let events: Vec<serde_json::Value> = sendable
286 .iter()
287 .map(|v| {
288 if v.get("type").and_then(|t| t.as_str()) == Some("rollup") {
289 let tool = v.get("tool").and_then(|t| t.as_str()).unwrap_or("");
290 let calls = v.get("calls").and_then(|c| c.as_u64()).unwrap_or(0);
291 let errors = v.get("errors").and_then(|e| e.as_u64()).unwrap_or(0);
292 let saved = v.get("saved_chars").and_then(|s| s.as_u64()).unwrap_or(0);
293 let day = v.get("day").and_then(|d| d.as_str()).unwrap_or("");
294 serde_json::json!({
295 "event": "usage_rollup",
296 "day": day,
297 "tool": tool,
298 "call_count": calls,
299 "error_count": errors,
300 "saved_chars_bucket": bucket_chars(saved),
301 })
302 } else {
303 let event = v.get("event").and_then(|e| e.as_str()).unwrap_or("unknown");
304 let ts = v.get("ts").and_then(|t| t.as_str()).unwrap_or("");
305 let props = v.get("props").cloned().unwrap_or(serde_json::json!({}));
306 serde_json::json!({
307 "event": event,
308 "ts": ts,
309 "props": props,
310 })
311 }
312 })
313 .collect();
314
315 let envelope = serde_json::json!({
316 "schema_version": SCHEMA_VERSION,
317 "machine_id": machine_id,
318 "codesynapse_version": env!("CARGO_PKG_VERSION"),
319 "os": std::env::consts::OS,
320 "arch": std::env::consts::ARCH,
321 "events": events,
322 });
323
324 let body = serde_json::to_string(&envelope).ok()?;
325 let result = ureq::post(endpoint)
326 .set("Content-Type", "application/json")
327 .timeout(std::time::Duration::from_secs(5))
328 .send_string(&body);
329
330 let _ = result;
333
334 let new_queue = keep
335 .iter()
336 .filter_map(|v| serde_json::to_string(v).ok())
337 .collect::<Vec<_>>()
338 .join("\n");
339
340 if new_queue.is_empty() {
341 let _ = std::fs::remove_file(queue_path);
342 } else {
343 let _ = std::fs::write(queue_path, new_queue + "\n");
344 }
345
346 Some(())
347}
348
349fn utc_day() -> String {
352 let secs = SystemTime::now()
353 .duration_since(UNIX_EPOCH)
354 .unwrap_or_default()
355 .as_secs();
356 let days = secs / 86400;
357 let z = days as i64 + 719468;
360 let era = if z >= 0 { z } else { z - 146096 } / 146097;
361 let doe = z - era * 146097;
362 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
363 let y = yoe + era * 400;
364 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
365 let mp = (5 * doy + 2) / 153;
366 let d = doy - (153 * mp + 2) / 5 + 1;
367 let m = if mp < 10 { mp + 3 } else { mp - 9 };
368 let y = if m <= 2 { y + 1 } else { y };
369 format!("{:04}-{:02}-{:02}", y, m, d)
370}
371
372fn utc_now_iso() -> String {
373 let secs = SystemTime::now()
374 .duration_since(UNIX_EPOCH)
375 .unwrap_or_default()
376 .as_secs();
377 let day = utc_day();
378 let h = (secs % 86400) / 3600;
379 let m = (secs % 3600) / 60;
380 let s = secs % 60;
381 format!("{}T{:02}:{:02}:{:02}Z", day, h, m, s)
382}
383
384fn new_uuid() -> String {
385 let mut bytes = [0u8; 16];
387 if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
388 use std::io::Read;
389 let _ = f.read_exact(&mut bytes);
390 } else {
391 let ts = SystemTime::now()
393 .duration_since(UNIX_EPOCH)
394 .unwrap_or_default()
395 .subsec_nanos();
396 let pid = std::process::id();
397 bytes[..4].copy_from_slice(&ts.to_le_bytes());
398 bytes[4..8].copy_from_slice(&pid.to_le_bytes());
399 }
400 bytes[6] = (bytes[6] & 0x0f) | 0x40;
402 bytes[8] = (bytes[8] & 0x3f) | 0x80;
403 format!(
404 "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
405 bytes[0], bytes[1], bytes[2], bytes[3],
406 bytes[4], bytes[5],
407 bytes[6], bytes[7],
408 bytes[8], bytes[9],
409 bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
410 )
411}
412
413fn env_flag(name: &str) -> bool {
414 !matches!(
415 std::env::var(name).ok().as_deref(),
416 Some("") | Some("0") | Some("false") | None
417 )
418}
419
420fn trim_queue(combined: &str, max_bytes: usize) -> String {
421 if combined.len() <= max_bytes {
422 return combined.to_string();
423 }
424 let excess = combined.len() - max_bytes;
425 let trimmed = &combined[excess..];
426 if let Some(pos) = trimmed.find('\n') {
428 trimmed[pos + 1..].to_string()
429 } else {
430 String::new()
431 }
432}
433
434pub fn bucket_chars(n: u64) -> &'static str {
435 if n < 1_000 {
436 "<1k"
437 } else if n < 10_000 {
438 "1k-10k"
439 } else if n < 100_000 {
440 "10k-100k"
441 } else {
442 "100k+"
443 }
444}
445
446pub fn bucket_count(n: usize) -> &'static str {
447 if n < 100 {
448 "<100"
449 } else if n < 1_000 {
450 "100-1k"
451 } else if n < 10_000 {
452 "1k-10k"
453 } else if n < 100_000 {
454 "10k-100k"
455 } else {
456 "100k+"
457 }
458}
459
460pub fn bucket_duration_ms(ms: u64) -> &'static str {
461 if ms < 10_000 {
462 "<10s"
463 } else if ms < 60_000 {
464 "10-60s"
465 } else if ms < 300_000 {
466 "1-5m"
467 } else {
468 "5m+"
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 #[test]
477 fn utc_day_format() {
478 let d = utc_day();
479 assert_eq!(d.len(), 10);
480 assert!(d.contains('-'));
481 }
482
483 #[test]
484 fn uuid_format() {
485 let u = new_uuid();
486 let parts: Vec<&str> = u.split('-').collect();
487 assert_eq!(parts.len(), 5);
488 assert_eq!(parts[0].len(), 8);
489 }
490
491 #[test]
492 fn bucket_chars_boundaries() {
493 assert_eq!(bucket_chars(0), "<1k");
494 assert_eq!(bucket_chars(999), "<1k");
495 assert_eq!(bucket_chars(1_000), "1k-10k");
496 assert_eq!(bucket_chars(100_000), "100k+");
497 }
498
499 #[test]
500 fn disabled_by_default_no_config() {
501 let dir = tempfile::tempdir().unwrap();
502 let t = Telemetry::new(dir.path().to_path_buf());
503 assert!(!t.is_enabled());
504 }
505
506 #[test]
507 fn set_enabled_writes_config() {
508 let dir = tempfile::tempdir().unwrap();
509 let t = Telemetry::new(dir.path().to_path_buf());
510 t.set_enabled(true);
511 assert!(t.is_enabled());
512 let config = t.read_config().unwrap();
513 assert!(config.enabled);
514 assert_eq!(config.consent_source, "cli");
515 }
516
517 #[test]
518 fn record_and_persist() {
519 let dir = tempfile::tempdir().unwrap();
520 let t = Telemetry::new(dir.path().to_path_buf());
521 t.set_enabled(true);
522 t.record_usage("codesynapse_context", 5000, true);
523 t.record_usage("codesynapse_context", 3000, false);
524 t.persist_sync();
525 let queue = std::fs::read_to_string(t.queue_path()).unwrap();
526 assert!(queue.contains("codesynapse_context"));
527 assert!(queue.contains("\"calls\":2"));
528 assert!(queue.contains("\"errors\":1"));
529 }
530
531 #[test]
532 fn disabled_no_record() {
533 let dir = tempfile::tempdir().unwrap();
534 let t = Telemetry::new(dir.path().to_path_buf());
535 t.record_usage("codesynapse_context", 5000, true);
537 t.persist_sync();
538 assert!(!t.queue_path().exists());
539 }
540}