1use std::path::{Path, PathBuf};
46use std::sync::Mutex;
47use std::time::{SystemTime, UNIX_EPOCH};
48
49use serde::{Deserialize, Serialize};
50
51pub mod claude;
52pub mod codex;
53pub mod gemini;
54pub mod host;
55pub mod opencode;
56
57pub(crate) const MAX_TELEMETRY_LOG_BYTES: u64 = 64 * 1024 * 1024;
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct PressureObservation {
73 pub adapter_id: String,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub adapter_version: Option<String>,
79 pub observed_at_epoch_s: u64,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub model_name: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub total_tokens: Option<u64>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub context_window_tokens: Option<u64>,
91 #[serde(skip_serializing_if = "Option::is_none")]
94 pub context_used_pct: Option<u8>,
95 #[serde(skip_serializing_if = "Option::is_none")]
99 pub compaction_signal: Option<bool>,
100 #[serde(skip_serializing_if = "TokenUsage::is_empty", default)]
102 pub usage: TokenUsage,
103}
104
105#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct TokenUsage {
110 #[serde(default, skip_serializing_if = "is_zero_u64")]
111 pub input_tokens: u64,
112 #[serde(default, skip_serializing_if = "is_zero_u64")]
113 pub output_tokens: u64,
114 #[serde(default, skip_serializing_if = "is_zero_u64")]
115 pub cache_creation_input_tokens: u64,
116 #[serde(default, skip_serializing_if = "is_zero_u64")]
117 pub cache_read_input_tokens: u64,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub blended_total_tokens: Option<u64>,
120}
121
122impl TokenUsage {
123 pub fn is_empty(&self) -> bool {
124 self.input_tokens == 0
125 && self.output_tokens == 0
126 && self.cache_creation_input_tokens == 0
127 && self.cache_read_input_tokens == 0
128 && self.blended_total_tokens.unwrap_or(0) == 0
129 }
130}
131
132fn is_zero_u64(value: &u64) -> bool {
133 *value == 0
134}
135
136#[derive(Debug)]
154pub enum TelemetryError {
155 Unavailable(String),
159 HookProtocol(String),
164 Internal(String),
166}
167
168impl TelemetryError {
169 pub fn failure_class(&self) -> &'static str {
171 match self {
172 Self::Unavailable(_) => "telemetry_unavailable",
173 Self::HookProtocol(_) => "hook_protocol_error",
174 Self::Internal(_) => "internal_error",
175 }
176 }
177}
178
179impl std::fmt::Display for TelemetryError {
180 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181 match self {
182 Self::Unavailable(msg) => write!(f, "telemetry_unavailable: {msg}"),
183 Self::HookProtocol(msg) => write!(f, "hook_protocol_error: {msg}"),
184 Self::Internal(msg) => write!(f, "internal_error: {msg}"),
185 }
186 }
187}
188
189impl std::error::Error for TelemetryError {}
190
191impl From<std::io::Error> for TelemetryError {
192 fn from(error: std::io::Error) -> Self {
193 match error.kind() {
194 std::io::ErrorKind::NotFound => Self::Unavailable(error.to_string()),
195 _ => Self::Internal(error.to_string()),
196 }
197 }
198}
199
200pub type TelemetryResult<T> = Result<T, TelemetryError>;
201
202pub(crate) fn read_file_bounded(path: &Path, label: &str) -> TelemetryResult<Vec<u8>> {
203 let metadata = path.metadata().map_err(TelemetryError::from)?;
204 if metadata.len() > MAX_TELEMETRY_LOG_BYTES {
205 return Err(TelemetryError::Unavailable(format!(
206 "{label} exceeds {MAX_TELEMETRY_LOG_BYTES} bytes: {}",
207 path.display()
208 )));
209 }
210 let file = std::fs::File::open(path).map_err(TelemetryError::from)?;
211 let mut bytes = Vec::new();
212 use std::io::Read;
213 file.take(MAX_TELEMETRY_LOG_BYTES + 1)
214 .read_to_end(&mut bytes)
215 .map_err(TelemetryError::from)?;
216 if bytes.len() as u64 > MAX_TELEMETRY_LOG_BYTES {
217 return Err(TelemetryError::Unavailable(format!(
218 "{label} exceeds {MAX_TELEMETRY_LOG_BYTES} bytes: {}",
219 path.display()
220 )));
221 }
222 Ok(bytes)
223}
224
225pub(crate) fn read_file_to_string_bounded(path: &Path, label: &str) -> TelemetryResult<String> {
226 let bytes = read_file_bounded(path, label)?;
227 String::from_utf8(bytes).map_err(|err| {
228 TelemetryError::HookProtocol(format!("{label} is not UTF-8: {}: {err}", path.display()))
229 })
230}
231
232#[derive(Debug, Clone, Copy)]
243pub struct EnvAlias {
244 pub lifeloop: &'static str,
245 pub ccd_compat: &'static str,
246}
247
248#[derive(Debug, Default)]
257pub struct EnvWarningSink {
258 inner: Mutex<EnvWarningInner>,
259}
260
261#[derive(Debug, Default)]
262struct EnvWarningInner {
263 seen: Vec<String>,
264 queued: Vec<EnvPrecedenceWarning>,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq)]
271pub struct EnvPrecedenceWarning {
272 pub lifeloop_key: &'static str,
273 pub ccd_compat_key: &'static str,
274}
275
276impl EnvWarningSink {
277 fn note(&self, alias: EnvAlias) {
278 let mut inner = self.inner.lock().expect("env warning sink poisoned");
279 if inner.seen.iter().any(|k| k == alias.lifeloop) {
280 return;
281 }
282 inner.seen.push(alias.lifeloop.to_string());
283 inner.queued.push(EnvPrecedenceWarning {
284 lifeloop_key: alias.lifeloop,
285 ccd_compat_key: alias.ccd_compat,
286 });
287 }
288
289 pub fn drain(&self) -> Vec<EnvPrecedenceWarning> {
293 let mut inner = self.inner.lock().expect("env warning sink poisoned");
294 std::mem::take(&mut inner.queued)
295 }
296
297 #[doc(hidden)]
300 pub fn reset_for_tests(&self) {
301 let mut inner = self.inner.lock().expect("env warning sink poisoned");
302 inner.seen.clear();
303 inner.queued.clear();
304 }
305}
306
307pub fn env_warning_sink() -> &'static EnvWarningSink {
311 use std::sync::OnceLock;
312 static SINK: OnceLock<EnvWarningSink> = OnceLock::new();
313 SINK.get_or_init(EnvWarningSink::default)
314}
315
316pub fn resolve_env_string(aliases: &[EnvAlias]) -> Option<String> {
322 resolve_env_string_with(aliases, &|name| std::env::var(name).ok())
323}
324
325pub fn resolve_env_string_with(
328 aliases: &[EnvAlias],
329 read: &dyn Fn(&str) -> Option<String>,
330) -> Option<String> {
331 let mut chosen: Option<String> = None;
332
333 for alias in aliases {
334 let lifeloop_value = read(alias.lifeloop)
335 .map(|v| v.trim().to_owned())
336 .filter(|v| !v.is_empty());
337 let ccd_value = read(alias.ccd_compat)
338 .map(|v| v.trim().to_owned())
339 .filter(|v| !v.is_empty());
340
341 if lifeloop_value.is_some() && ccd_value.is_some() {
342 env_warning_sink().note(*alias);
343 }
344
345 if chosen.is_none() {
346 chosen = lifeloop_value.or(ccd_value);
347 }
348 }
349
350 chosen
351}
352
353pub fn resolve_env_u64(aliases: &[EnvAlias]) -> Option<u64> {
355 resolve_env_string(aliases).and_then(|v| v.parse().ok())
356}
357
358pub const GENERAL_CONTEXT_WINDOW_ALIASES: &[EnvAlias] = &[EnvAlias {
363 lifeloop: "LIFELOOP_CONTEXT_WINDOW_TOKENS",
364 ccd_compat: "CCD_CONTEXT_WINDOW_TOKENS",
365}];
366
367pub const GENERAL_HOST_MODEL_ALIASES: &[EnvAlias] = &[EnvAlias {
370 lifeloop: "LIFELOOP_HOST_MODEL",
371 ccd_compat: "CCD_HOST_MODEL",
372}];
373
374pub fn general_context_window() -> Option<u64> {
375 resolve_env_u64(GENERAL_CONTEXT_WINDOW_ALIASES)
376}
377
378pub fn general_host_model() -> Option<String> {
379 resolve_env_string(GENERAL_HOST_MODEL_ALIASES)
380}
381
382pub fn file_mtime_epoch_s(path: &Path) -> TelemetryResult<Option<u64>> {
389 let metadata = match std::fs::metadata(path) {
390 Ok(m) => m,
391 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
392 Err(error) => return Err(TelemetryError::Internal(error.to_string())),
393 };
394 let modified = metadata
395 .modified()
396 .map_err(|e| TelemetryError::Internal(e.to_string()))?;
397 let epoch_s = modified
398 .duration_since(UNIX_EPOCH)
399 .map_err(|e| TelemetryError::Internal(format!("mtime before UNIX_EPOCH: {e}")))?
400 .as_secs();
401 Ok(Some(epoch_s))
402}
403
404pub const RECENT_ACTIVITY_SECS: u64 = 30 * 60;
408
409pub fn now_epoch_s() -> TelemetryResult<u64> {
410 SystemTime::now()
411 .duration_since(UNIX_EPOCH)
412 .map(|d| d.as_secs())
413 .map_err(|e| TelemetryError::Internal(format!("system clock before UNIX_EPOCH: {e}")))
414}
415
416pub fn is_recent(epoch_s: u64) -> TelemetryResult<bool> {
417 Ok(now_epoch_s()?.saturating_sub(epoch_s) <= RECENT_ACTIVITY_SECS)
418}
419
420pub fn home_dir() -> TelemetryResult<PathBuf> {
421 match std::env::var_os("HOME") {
422 Some(home) => Ok(PathBuf::from(home)),
423 None => Err(TelemetryError::Unavailable(
424 "HOME environment variable is not set".into(),
425 )),
426 }
427}
428
429pub fn compute_pct(total_tokens: u64, context_window: Option<u64>) -> Option<u8> {
430 let cw = context_window?;
431 if cw == 0 {
432 return None;
433 }
434 Some(((total_tokens.saturating_mul(100)) / cw).min(100) as u8)
435}
436
437pub fn string_key(value: &serde_json::Value, keys: &[&str]) -> Option<String> {
444 match value {
445 serde_json::Value::Object(map) => {
446 for key in keys {
447 if let Some(serde_json::Value::String(found)) = map.get(*key) {
448 return Some(found.clone());
449 }
450 }
451 for child in map.values() {
452 if let Some(found) = string_key(child, keys) {
453 return Some(found);
454 }
455 }
456 None
457 }
458 serde_json::Value::Array(items) => items.iter().find_map(|i| string_key(i, keys)),
459 _ => None,
460 }
461}
462
463pub fn number_key(value: &serde_json::Value, keys: &[&str]) -> Option<u64> {
467 match value {
468 serde_json::Value::Object(map) => {
469 for key in keys {
470 if let Some(found) = map.get(*key)
471 && let Some(number) = as_u64(found)
472 {
473 return Some(number);
474 }
475 }
476 for child in map.values() {
477 if let Some(found) = number_key(child, keys) {
478 return Some(found);
479 }
480 }
481 None
482 }
483 serde_json::Value::Array(items) => items.iter().find_map(|i| number_key(i, keys)),
484 _ => None,
485 }
486}
487
488pub fn as_u64(value: &serde_json::Value) -> Option<u64> {
491 match value {
492 serde_json::Value::Number(number) => number.as_u64(),
493 serde_json::Value::String(text) => text.parse().ok(),
494 _ => None,
495 }
496}
497
498#[cfg(test)]
503mod tests {
504 use super::*;
505 use std::sync::Mutex;
506
507 static ENV_SINK_LOCK: Mutex<()> = Mutex::new(());
512 use serde_json::json;
513
514 #[test]
515 fn as_u64_accepts_numbers_and_strings() {
516 assert_eq!(as_u64(&json!(42)), Some(42));
517 assert_eq!(as_u64(&json!("1024")), Some(1024));
518 assert_eq!(as_u64(&json!(-1)), None);
519 assert_eq!(as_u64(&json!(1.5)), None);
520 assert_eq!(as_u64(&json!("hello")), None);
521 }
522
523 #[test]
524 fn string_key_descends() {
525 let v = json!({"outer": {"inner": {"target": "found"}}});
526 assert_eq!(string_key(&v, &["target"]), Some("found".into()));
527 }
528
529 #[test]
530 fn number_key_descends() {
531 let v = json!({"usage": {"prompt_tokens": 200}});
532 assert_eq!(number_key(&v, &["prompt_tokens"]), Some(200));
533 }
534
535 #[test]
536 fn telemetry_error_failure_classes_are_stable() {
537 assert_eq!(
538 TelemetryError::Unavailable("x".into()).failure_class(),
539 "telemetry_unavailable"
540 );
541 assert_eq!(
542 TelemetryError::HookProtocol("x".into()).failure_class(),
543 "hook_protocol_error"
544 );
545 assert_eq!(
546 TelemetryError::Internal("x".into()).failure_class(),
547 "internal_error"
548 );
549 }
550
551 #[test]
552 fn bounded_file_read_rejects_oversized_logs() {
553 let dir = tempfile::tempdir().expect("tempdir");
554 let path = dir.path().join("huge.jsonl");
555 let file = std::fs::File::create(&path).expect("create file");
556 file.set_len(MAX_TELEMETRY_LOG_BYTES + 1)
557 .expect("sparse file length");
558
559 let err = read_file_bounded(&path, "test log").unwrap_err();
560 assert!(matches!(err, TelemetryError::Unavailable(_)));
561 }
562
563 #[test]
564 fn pressure_observation_serializes_minimally() {
565 let obs = PressureObservation {
566 adapter_id: "claude".into(),
567 adapter_version: None,
568 observed_at_epoch_s: 100,
569 model_name: None,
570 total_tokens: Some(500),
571 context_window_tokens: Some(1000),
572 context_used_pct: Some(50),
573 compaction_signal: None,
574 usage: TokenUsage::default(),
575 };
576 let json = serde_json::to_value(&obs).unwrap();
577 assert_eq!(json["adapter_id"], "claude");
578 assert_eq!(json["observed_at_epoch_s"], 100);
579 assert_eq!(json["total_tokens"], 500);
580 assert!(json.get("adapter_version").is_none());
581 assert!(json.get("compaction_signal").is_none());
582 assert!(json.get("usage").is_none());
583 }
584
585 #[test]
586 fn resolve_env_string_with_lifeloop_winning() {
587 let _g = ENV_SINK_LOCK.lock().unwrap();
588 env_warning_sink().reset_for_tests();
589 let aliases = &[EnvAlias {
590 lifeloop: "LIFELOOP_TEST_X",
591 ccd_compat: "CCD_TEST_X",
592 }];
593 let read = |name: &str| -> Option<String> {
594 match name {
595 "LIFELOOP_TEST_X" => Some("ll".into()),
596 "CCD_TEST_X" => Some("ccd".into()),
597 _ => None,
598 }
599 };
600 assert_eq!(resolve_env_string_with(aliases, &read), Some("ll".into()));
601 let warnings = env_warning_sink().drain();
602 assert_eq!(warnings.len(), 1);
603 assert_eq!(warnings[0].lifeloop_key, "LIFELOOP_TEST_X");
604 assert_eq!(warnings[0].ccd_compat_key, "CCD_TEST_X");
605 }
606
607 #[test]
608 fn resolve_env_string_falls_back_to_ccd() {
609 let _g = ENV_SINK_LOCK.lock().unwrap();
610 env_warning_sink().reset_for_tests();
611 let aliases = &[EnvAlias {
612 lifeloop: "LIFELOOP_TEST_Y",
613 ccd_compat: "CCD_TEST_Y",
614 }];
615 let read = |name: &str| -> Option<String> {
616 match name {
617 "CCD_TEST_Y" => Some("ccd-only".into()),
618 _ => None,
619 }
620 };
621 assert_eq!(
622 resolve_env_string_with(aliases, &read),
623 Some("ccd-only".into())
624 );
625 assert!(env_warning_sink().drain().is_empty());
626 }
627
628 #[test]
629 fn warning_is_bounded_to_one_per_key() {
630 let _g = ENV_SINK_LOCK.lock().unwrap();
631 env_warning_sink().reset_for_tests();
632 let aliases = &[EnvAlias {
633 lifeloop: "LIFELOOP_TEST_Z",
634 ccd_compat: "CCD_TEST_Z",
635 }];
636 let read = |name: &str| -> Option<String> {
637 match name {
638 "LIFELOOP_TEST_Z" => Some("ll".into()),
639 "CCD_TEST_Z" => Some("ccd".into()),
640 _ => None,
641 }
642 };
643 for _ in 0..5 {
644 let _ = resolve_env_string_with(aliases, &read);
645 }
646 let warnings = env_warning_sink().drain();
647 assert_eq!(warnings.len(), 1);
648 for _ in 0..3 {
651 let _ = resolve_env_string_with(aliases, &read);
652 }
653 assert!(env_warning_sink().drain().is_empty());
654 }
655}