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
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct PressureObservation {
71 pub adapter_id: String,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub adapter_version: Option<String>,
77 pub observed_at_epoch_s: u64,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub model_name: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub total_tokens: Option<u64>,
86 #[serde(skip_serializing_if = "Option::is_none")]
88 pub context_window_tokens: Option<u64>,
89 #[serde(skip_serializing_if = "Option::is_none")]
92 pub context_used_pct: Option<u8>,
93 #[serde(skip_serializing_if = "Option::is_none")]
97 pub compaction_signal: Option<bool>,
98 #[serde(skip_serializing_if = "TokenUsage::is_empty", default)]
100 pub usage: TokenUsage,
101}
102
103#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(deny_unknown_fields)]
107pub struct TokenUsage {
108 #[serde(default, skip_serializing_if = "is_zero_u64")]
109 pub input_tokens: u64,
110 #[serde(default, skip_serializing_if = "is_zero_u64")]
111 pub output_tokens: u64,
112 #[serde(default, skip_serializing_if = "is_zero_u64")]
113 pub cache_creation_input_tokens: u64,
114 #[serde(default, skip_serializing_if = "is_zero_u64")]
115 pub cache_read_input_tokens: u64,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub blended_total_tokens: Option<u64>,
118}
119
120impl TokenUsage {
121 pub fn is_empty(&self) -> bool {
122 self.input_tokens == 0
123 && self.output_tokens == 0
124 && self.cache_creation_input_tokens == 0
125 && self.cache_read_input_tokens == 0
126 && self.blended_total_tokens.unwrap_or(0) == 0
127 }
128}
129
130fn is_zero_u64(value: &u64) -> bool {
131 *value == 0
132}
133
134#[derive(Debug)]
152pub enum TelemetryError {
153 Unavailable(String),
157 HookProtocol(String),
162 Internal(String),
164}
165
166impl TelemetryError {
167 pub fn failure_class(&self) -> &'static str {
169 match self {
170 Self::Unavailable(_) => "telemetry_unavailable",
171 Self::HookProtocol(_) => "hook_protocol_error",
172 Self::Internal(_) => "internal_error",
173 }
174 }
175}
176
177impl std::fmt::Display for TelemetryError {
178 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179 match self {
180 Self::Unavailable(msg) => write!(f, "telemetry_unavailable: {msg}"),
181 Self::HookProtocol(msg) => write!(f, "hook_protocol_error: {msg}"),
182 Self::Internal(msg) => write!(f, "internal_error: {msg}"),
183 }
184 }
185}
186
187impl std::error::Error for TelemetryError {}
188
189impl From<std::io::Error> for TelemetryError {
190 fn from(error: std::io::Error) -> Self {
191 match error.kind() {
192 std::io::ErrorKind::NotFound => Self::Unavailable(error.to_string()),
193 _ => Self::Internal(error.to_string()),
194 }
195 }
196}
197
198pub type TelemetryResult<T> = Result<T, TelemetryError>;
199
200#[derive(Debug, Clone, Copy)]
211pub struct EnvAlias {
212 pub lifeloop: &'static str,
213 pub ccd_compat: &'static str,
214}
215
216#[derive(Debug, Default)]
225pub struct EnvWarningSink {
226 inner: Mutex<EnvWarningInner>,
227}
228
229#[derive(Debug, Default)]
230struct EnvWarningInner {
231 seen: Vec<String>,
232 queued: Vec<EnvPrecedenceWarning>,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct EnvPrecedenceWarning {
240 pub lifeloop_key: &'static str,
241 pub ccd_compat_key: &'static str,
242}
243
244impl EnvWarningSink {
245 fn note(&self, alias: EnvAlias) {
246 let mut inner = self.inner.lock().expect("env warning sink poisoned");
247 if inner.seen.iter().any(|k| k == alias.lifeloop) {
248 return;
249 }
250 inner.seen.push(alias.lifeloop.to_string());
251 inner.queued.push(EnvPrecedenceWarning {
252 lifeloop_key: alias.lifeloop,
253 ccd_compat_key: alias.ccd_compat,
254 });
255 }
256
257 pub fn drain(&self) -> Vec<EnvPrecedenceWarning> {
261 let mut inner = self.inner.lock().expect("env warning sink poisoned");
262 std::mem::take(&mut inner.queued)
263 }
264
265 #[doc(hidden)]
268 pub fn reset_for_tests(&self) {
269 let mut inner = self.inner.lock().expect("env warning sink poisoned");
270 inner.seen.clear();
271 inner.queued.clear();
272 }
273}
274
275pub fn env_warning_sink() -> &'static EnvWarningSink {
279 use std::sync::OnceLock;
280 static SINK: OnceLock<EnvWarningSink> = OnceLock::new();
281 SINK.get_or_init(EnvWarningSink::default)
282}
283
284pub fn resolve_env_string(aliases: &[EnvAlias]) -> Option<String> {
290 resolve_env_string_with(aliases, &|name| std::env::var(name).ok())
291}
292
293pub fn resolve_env_string_with(
296 aliases: &[EnvAlias],
297 read: &dyn Fn(&str) -> Option<String>,
298) -> Option<String> {
299 let mut chosen: Option<String> = None;
300
301 for alias in aliases {
302 let lifeloop_value = read(alias.lifeloop)
303 .map(|v| v.trim().to_owned())
304 .filter(|v| !v.is_empty());
305 let ccd_value = read(alias.ccd_compat)
306 .map(|v| v.trim().to_owned())
307 .filter(|v| !v.is_empty());
308
309 if lifeloop_value.is_some() && ccd_value.is_some() {
310 env_warning_sink().note(*alias);
311 }
312
313 if chosen.is_none() {
314 chosen = lifeloop_value.or(ccd_value);
315 }
316 }
317
318 chosen
319}
320
321pub fn resolve_env_u64(aliases: &[EnvAlias]) -> Option<u64> {
323 resolve_env_string(aliases).and_then(|v| v.parse().ok())
324}
325
326pub const GENERAL_CONTEXT_WINDOW_ALIASES: &[EnvAlias] = &[EnvAlias {
331 lifeloop: "LIFELOOP_CONTEXT_WINDOW_TOKENS",
332 ccd_compat: "CCD_CONTEXT_WINDOW_TOKENS",
333}];
334
335pub const GENERAL_HOST_MODEL_ALIASES: &[EnvAlias] = &[EnvAlias {
338 lifeloop: "LIFELOOP_HOST_MODEL",
339 ccd_compat: "CCD_HOST_MODEL",
340}];
341
342pub fn general_context_window() -> Option<u64> {
343 resolve_env_u64(GENERAL_CONTEXT_WINDOW_ALIASES)
344}
345
346pub fn general_host_model() -> Option<String> {
347 resolve_env_string(GENERAL_HOST_MODEL_ALIASES)
348}
349
350pub fn file_mtime_epoch_s(path: &Path) -> TelemetryResult<Option<u64>> {
357 let metadata = match std::fs::metadata(path) {
358 Ok(m) => m,
359 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
360 Err(error) => return Err(TelemetryError::Internal(error.to_string())),
361 };
362 let modified = metadata
363 .modified()
364 .map_err(|e| TelemetryError::Internal(e.to_string()))?;
365 let epoch_s = modified
366 .duration_since(UNIX_EPOCH)
367 .map_err(|e| TelemetryError::Internal(format!("mtime before UNIX_EPOCH: {e}")))?
368 .as_secs();
369 Ok(Some(epoch_s))
370}
371
372pub const RECENT_ACTIVITY_SECS: u64 = 30 * 60;
376
377pub fn now_epoch_s() -> TelemetryResult<u64> {
378 SystemTime::now()
379 .duration_since(UNIX_EPOCH)
380 .map(|d| d.as_secs())
381 .map_err(|e| TelemetryError::Internal(format!("system clock before UNIX_EPOCH: {e}")))
382}
383
384pub fn is_recent(epoch_s: u64) -> TelemetryResult<bool> {
385 Ok(now_epoch_s()?.saturating_sub(epoch_s) <= RECENT_ACTIVITY_SECS)
386}
387
388pub fn home_dir() -> TelemetryResult<PathBuf> {
389 match std::env::var_os("HOME") {
390 Some(home) => Ok(PathBuf::from(home)),
391 None => Err(TelemetryError::Unavailable(
392 "HOME environment variable is not set".into(),
393 )),
394 }
395}
396
397pub fn compute_pct(total_tokens: u64, context_window: Option<u64>) -> Option<u8> {
398 let cw = context_window?;
399 if cw == 0 {
400 return None;
401 }
402 Some(((total_tokens.saturating_mul(100)) / cw).min(100) as u8)
403}
404
405pub fn string_key(value: &serde_json::Value, keys: &[&str]) -> Option<String> {
412 match value {
413 serde_json::Value::Object(map) => {
414 for key in keys {
415 if let Some(serde_json::Value::String(found)) = map.get(*key) {
416 return Some(found.clone());
417 }
418 }
419 for child in map.values() {
420 if let Some(found) = string_key(child, keys) {
421 return Some(found);
422 }
423 }
424 None
425 }
426 serde_json::Value::Array(items) => items.iter().find_map(|i| string_key(i, keys)),
427 _ => None,
428 }
429}
430
431pub fn number_key(value: &serde_json::Value, keys: &[&str]) -> Option<u64> {
435 match value {
436 serde_json::Value::Object(map) => {
437 for key in keys {
438 if let Some(found) = map.get(*key)
439 && let Some(number) = as_u64(found)
440 {
441 return Some(number);
442 }
443 }
444 for child in map.values() {
445 if let Some(found) = number_key(child, keys) {
446 return Some(found);
447 }
448 }
449 None
450 }
451 serde_json::Value::Array(items) => items.iter().find_map(|i| number_key(i, keys)),
452 _ => None,
453 }
454}
455
456pub fn as_u64(value: &serde_json::Value) -> Option<u64> {
459 match value {
460 serde_json::Value::Number(number) => number.as_u64(),
461 serde_json::Value::String(text) => text.parse().ok(),
462 _ => None,
463 }
464}
465
466#[cfg(test)]
471mod tests {
472 use super::*;
473 use std::sync::Mutex;
474
475 static ENV_SINK_LOCK: Mutex<()> = Mutex::new(());
480 use serde_json::json;
481
482 #[test]
483 fn as_u64_accepts_numbers_and_strings() {
484 assert_eq!(as_u64(&json!(42)), Some(42));
485 assert_eq!(as_u64(&json!("1024")), Some(1024));
486 assert_eq!(as_u64(&json!(-1)), None);
487 assert_eq!(as_u64(&json!(1.5)), None);
488 assert_eq!(as_u64(&json!("hello")), None);
489 }
490
491 #[test]
492 fn string_key_descends() {
493 let v = json!({"outer": {"inner": {"target": "found"}}});
494 assert_eq!(string_key(&v, &["target"]), Some("found".into()));
495 }
496
497 #[test]
498 fn number_key_descends() {
499 let v = json!({"usage": {"prompt_tokens": 200}});
500 assert_eq!(number_key(&v, &["prompt_tokens"]), Some(200));
501 }
502
503 #[test]
504 fn telemetry_error_failure_classes_are_stable() {
505 assert_eq!(
506 TelemetryError::Unavailable("x".into()).failure_class(),
507 "telemetry_unavailable"
508 );
509 assert_eq!(
510 TelemetryError::HookProtocol("x".into()).failure_class(),
511 "hook_protocol_error"
512 );
513 assert_eq!(
514 TelemetryError::Internal("x".into()).failure_class(),
515 "internal_error"
516 );
517 }
518
519 #[test]
520 fn pressure_observation_serializes_minimally() {
521 let obs = PressureObservation {
522 adapter_id: "claude".into(),
523 adapter_version: None,
524 observed_at_epoch_s: 100,
525 model_name: None,
526 total_tokens: Some(500),
527 context_window_tokens: Some(1000),
528 context_used_pct: Some(50),
529 compaction_signal: None,
530 usage: TokenUsage::default(),
531 };
532 let json = serde_json::to_value(&obs).unwrap();
533 assert_eq!(json["adapter_id"], "claude");
534 assert_eq!(json["observed_at_epoch_s"], 100);
535 assert_eq!(json["total_tokens"], 500);
536 assert!(json.get("adapter_version").is_none());
537 assert!(json.get("compaction_signal").is_none());
538 assert!(json.get("usage").is_none());
539 }
540
541 #[test]
542 fn resolve_env_string_with_lifeloop_winning() {
543 let _g = ENV_SINK_LOCK.lock().unwrap();
544 env_warning_sink().reset_for_tests();
545 let aliases = &[EnvAlias {
546 lifeloop: "LIFELOOP_TEST_X",
547 ccd_compat: "CCD_TEST_X",
548 }];
549 let read = |name: &str| -> Option<String> {
550 match name {
551 "LIFELOOP_TEST_X" => Some("ll".into()),
552 "CCD_TEST_X" => Some("ccd".into()),
553 _ => None,
554 }
555 };
556 assert_eq!(resolve_env_string_with(aliases, &read), Some("ll".into()));
557 let warnings = env_warning_sink().drain();
558 assert_eq!(warnings.len(), 1);
559 assert_eq!(warnings[0].lifeloop_key, "LIFELOOP_TEST_X");
560 assert_eq!(warnings[0].ccd_compat_key, "CCD_TEST_X");
561 }
562
563 #[test]
564 fn resolve_env_string_falls_back_to_ccd() {
565 let _g = ENV_SINK_LOCK.lock().unwrap();
566 env_warning_sink().reset_for_tests();
567 let aliases = &[EnvAlias {
568 lifeloop: "LIFELOOP_TEST_Y",
569 ccd_compat: "CCD_TEST_Y",
570 }];
571 let read = |name: &str| -> Option<String> {
572 match name {
573 "CCD_TEST_Y" => Some("ccd-only".into()),
574 _ => None,
575 }
576 };
577 assert_eq!(
578 resolve_env_string_with(aliases, &read),
579 Some("ccd-only".into())
580 );
581 assert!(env_warning_sink().drain().is_empty());
582 }
583
584 #[test]
585 fn warning_is_bounded_to_one_per_key() {
586 let _g = ENV_SINK_LOCK.lock().unwrap();
587 env_warning_sink().reset_for_tests();
588 let aliases = &[EnvAlias {
589 lifeloop: "LIFELOOP_TEST_Z",
590 ccd_compat: "CCD_TEST_Z",
591 }];
592 let read = |name: &str| -> Option<String> {
593 match name {
594 "LIFELOOP_TEST_Z" => Some("ll".into()),
595 "CCD_TEST_Z" => Some("ccd".into()),
596 _ => None,
597 }
598 };
599 for _ in 0..5 {
600 let _ = resolve_env_string_with(aliases, &read);
601 }
602 let warnings = env_warning_sink().drain();
603 assert_eq!(warnings.len(), 1);
604 for _ in 0..3 {
607 let _ = resolve_env_string_with(aliases, &read);
608 }
609 assert!(env_warning_sink().drain().is_empty());
610 }
611}