use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
pub mod claude;
pub mod codex;
pub mod gemini;
pub mod host;
pub mod opencode;
pub(crate) const MAX_TELEMETRY_LOG_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PressureObservation {
pub adapter_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub adapter_version: Option<String>,
pub observed_at_epoch_s: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_window_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_used_pct: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compaction_signal: Option<bool>,
#[serde(skip_serializing_if = "TokenUsage::is_empty", default)]
pub usage: TokenUsage,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TokenUsage {
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub input_tokens: u64,
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub output_tokens: u64,
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub cache_creation_input_tokens: u64,
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub cache_read_input_tokens: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blended_total_tokens: Option<u64>,
}
impl TokenUsage {
pub fn is_empty(&self) -> bool {
self.input_tokens == 0
&& self.output_tokens == 0
&& self.cache_creation_input_tokens == 0
&& self.cache_read_input_tokens == 0
&& self.blended_total_tokens.unwrap_or(0) == 0
}
}
fn is_zero_u64(value: &u64) -> bool {
*value == 0
}
#[derive(Debug)]
pub enum TelemetryError {
Unavailable(String),
HookProtocol(String),
Internal(String),
}
impl TelemetryError {
pub fn failure_class(&self) -> &'static str {
match self {
Self::Unavailable(_) => "telemetry_unavailable",
Self::HookProtocol(_) => "hook_protocol_error",
Self::Internal(_) => "internal_error",
}
}
}
impl std::fmt::Display for TelemetryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unavailable(msg) => write!(f, "telemetry_unavailable: {msg}"),
Self::HookProtocol(msg) => write!(f, "hook_protocol_error: {msg}"),
Self::Internal(msg) => write!(f, "internal_error: {msg}"),
}
}
}
impl std::error::Error for TelemetryError {}
impl From<std::io::Error> for TelemetryError {
fn from(error: std::io::Error) -> Self {
match error.kind() {
std::io::ErrorKind::NotFound => Self::Unavailable(error.to_string()),
_ => Self::Internal(error.to_string()),
}
}
}
pub type TelemetryResult<T> = Result<T, TelemetryError>;
pub(crate) fn read_file_bounded(path: &Path, label: &str) -> TelemetryResult<Vec<u8>> {
let metadata = path.metadata().map_err(TelemetryError::from)?;
if metadata.len() > MAX_TELEMETRY_LOG_BYTES {
return Err(TelemetryError::Unavailable(format!(
"{label} exceeds {MAX_TELEMETRY_LOG_BYTES} bytes: {}",
path.display()
)));
}
let file = std::fs::File::open(path).map_err(TelemetryError::from)?;
let mut bytes = Vec::new();
use std::io::Read;
file.take(MAX_TELEMETRY_LOG_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(TelemetryError::from)?;
if bytes.len() as u64 > MAX_TELEMETRY_LOG_BYTES {
return Err(TelemetryError::Unavailable(format!(
"{label} exceeds {MAX_TELEMETRY_LOG_BYTES} bytes: {}",
path.display()
)));
}
Ok(bytes)
}
pub(crate) fn read_file_to_string_bounded(path: &Path, label: &str) -> TelemetryResult<String> {
let bytes = read_file_bounded(path, label)?;
String::from_utf8(bytes).map_err(|err| {
TelemetryError::HookProtocol(format!("{label} is not UTF-8: {}: {err}", path.display()))
})
}
#[derive(Debug, Clone, Copy)]
pub struct EnvAlias {
pub lifeloop: &'static str,
pub ccd_compat: &'static str,
}
#[derive(Debug, Default)]
pub struct EnvWarningSink {
inner: Mutex<EnvWarningInner>,
}
#[derive(Debug, Default)]
struct EnvWarningInner {
seen: Vec<String>,
queued: Vec<EnvPrecedenceWarning>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvPrecedenceWarning {
pub lifeloop_key: &'static str,
pub ccd_compat_key: &'static str,
}
impl EnvWarningSink {
fn note(&self, alias: EnvAlias) {
let mut inner = self.inner.lock().expect("env warning sink poisoned");
if inner.seen.iter().any(|k| k == alias.lifeloop) {
return;
}
inner.seen.push(alias.lifeloop.to_string());
inner.queued.push(EnvPrecedenceWarning {
lifeloop_key: alias.lifeloop,
ccd_compat_key: alias.ccd_compat,
});
}
pub fn drain(&self) -> Vec<EnvPrecedenceWarning> {
let mut inner = self.inner.lock().expect("env warning sink poisoned");
std::mem::take(&mut inner.queued)
}
#[doc(hidden)]
pub fn reset_for_tests(&self) {
let mut inner = self.inner.lock().expect("env warning sink poisoned");
inner.seen.clear();
inner.queued.clear();
}
}
pub fn env_warning_sink() -> &'static EnvWarningSink {
use std::sync::OnceLock;
static SINK: OnceLock<EnvWarningSink> = OnceLock::new();
SINK.get_or_init(EnvWarningSink::default)
}
pub fn resolve_env_string(aliases: &[EnvAlias]) -> Option<String> {
resolve_env_string_with(aliases, &|name| std::env::var(name).ok())
}
pub fn resolve_env_string_with(
aliases: &[EnvAlias],
read: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
let mut chosen: Option<String> = None;
for alias in aliases {
let lifeloop_value = read(alias.lifeloop)
.map(|v| v.trim().to_owned())
.filter(|v| !v.is_empty());
let ccd_value = read(alias.ccd_compat)
.map(|v| v.trim().to_owned())
.filter(|v| !v.is_empty());
if lifeloop_value.is_some() && ccd_value.is_some() {
env_warning_sink().note(*alias);
}
if chosen.is_none() {
chosen = lifeloop_value.or(ccd_value);
}
}
chosen
}
pub fn resolve_env_u64(aliases: &[EnvAlias]) -> Option<u64> {
resolve_env_string(aliases).and_then(|v| v.parse().ok())
}
pub const GENERAL_CONTEXT_WINDOW_ALIASES: &[EnvAlias] = &[EnvAlias {
lifeloop: "LIFELOOP_CONTEXT_WINDOW_TOKENS",
ccd_compat: "CCD_CONTEXT_WINDOW_TOKENS",
}];
pub const GENERAL_HOST_MODEL_ALIASES: &[EnvAlias] = &[EnvAlias {
lifeloop: "LIFELOOP_HOST_MODEL",
ccd_compat: "CCD_HOST_MODEL",
}];
pub fn general_context_window() -> Option<u64> {
resolve_env_u64(GENERAL_CONTEXT_WINDOW_ALIASES)
}
pub fn general_host_model() -> Option<String> {
resolve_env_string(GENERAL_HOST_MODEL_ALIASES)
}
pub fn file_mtime_epoch_s(path: &Path) -> TelemetryResult<Option<u64>> {
let metadata = match std::fs::metadata(path) {
Ok(m) => m,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(TelemetryError::Internal(error.to_string())),
};
let modified = metadata
.modified()
.map_err(|e| TelemetryError::Internal(e.to_string()))?;
let epoch_s = modified
.duration_since(UNIX_EPOCH)
.map_err(|e| TelemetryError::Internal(format!("mtime before UNIX_EPOCH: {e}")))?
.as_secs();
Ok(Some(epoch_s))
}
pub const RECENT_ACTIVITY_SECS: u64 = 30 * 60;
pub fn now_epoch_s() -> TelemetryResult<u64> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.map_err(|e| TelemetryError::Internal(format!("system clock before UNIX_EPOCH: {e}")))
}
pub fn is_recent(epoch_s: u64) -> TelemetryResult<bool> {
Ok(now_epoch_s()?.saturating_sub(epoch_s) <= RECENT_ACTIVITY_SECS)
}
pub fn home_dir() -> TelemetryResult<PathBuf> {
match std::env::var_os("HOME") {
Some(home) => Ok(PathBuf::from(home)),
None => Err(TelemetryError::Unavailable(
"HOME environment variable is not set".into(),
)),
}
}
pub fn compute_pct(total_tokens: u64, context_window: Option<u64>) -> Option<u8> {
let cw = context_window?;
if cw == 0 {
return None;
}
Some(((total_tokens.saturating_mul(100)) / cw).min(100) as u8)
}
pub fn string_key(value: &serde_json::Value, keys: &[&str]) -> Option<String> {
match value {
serde_json::Value::Object(map) => {
for key in keys {
if let Some(serde_json::Value::String(found)) = map.get(*key) {
return Some(found.clone());
}
}
for child in map.values() {
if let Some(found) = string_key(child, keys) {
return Some(found);
}
}
None
}
serde_json::Value::Array(items) => items.iter().find_map(|i| string_key(i, keys)),
_ => None,
}
}
pub fn number_key(value: &serde_json::Value, keys: &[&str]) -> Option<u64> {
match value {
serde_json::Value::Object(map) => {
for key in keys {
if let Some(found) = map.get(*key)
&& let Some(number) = as_u64(found)
{
return Some(number);
}
}
for child in map.values() {
if let Some(found) = number_key(child, keys) {
return Some(found);
}
}
None
}
serde_json::Value::Array(items) => items.iter().find_map(|i| number_key(i, keys)),
_ => None,
}
}
pub fn as_u64(value: &serde_json::Value) -> Option<u64> {
match value {
serde_json::Value::Number(number) => number.as_u64(),
serde_json::Value::String(text) => text.parse().ok(),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_SINK_LOCK: Mutex<()> = Mutex::new(());
use serde_json::json;
#[test]
fn as_u64_accepts_numbers_and_strings() {
assert_eq!(as_u64(&json!(42)), Some(42));
assert_eq!(as_u64(&json!("1024")), Some(1024));
assert_eq!(as_u64(&json!(-1)), None);
assert_eq!(as_u64(&json!(1.5)), None);
assert_eq!(as_u64(&json!("hello")), None);
}
#[test]
fn string_key_descends() {
let v = json!({"outer": {"inner": {"target": "found"}}});
assert_eq!(string_key(&v, &["target"]), Some("found".into()));
}
#[test]
fn number_key_descends() {
let v = json!({"usage": {"prompt_tokens": 200}});
assert_eq!(number_key(&v, &["prompt_tokens"]), Some(200));
}
#[test]
fn telemetry_error_failure_classes_are_stable() {
assert_eq!(
TelemetryError::Unavailable("x".into()).failure_class(),
"telemetry_unavailable"
);
assert_eq!(
TelemetryError::HookProtocol("x".into()).failure_class(),
"hook_protocol_error"
);
assert_eq!(
TelemetryError::Internal("x".into()).failure_class(),
"internal_error"
);
}
#[test]
fn bounded_file_read_rejects_oversized_logs() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("huge.jsonl");
let file = std::fs::File::create(&path).expect("create file");
file.set_len(MAX_TELEMETRY_LOG_BYTES + 1)
.expect("sparse file length");
let err = read_file_bounded(&path, "test log").unwrap_err();
assert!(matches!(err, TelemetryError::Unavailable(_)));
}
#[test]
fn pressure_observation_serializes_minimally() {
let obs = PressureObservation {
adapter_id: "claude".into(),
adapter_version: None,
observed_at_epoch_s: 100,
model_name: None,
total_tokens: Some(500),
context_window_tokens: Some(1000),
context_used_pct: Some(50),
compaction_signal: None,
usage: TokenUsage::default(),
};
let json = serde_json::to_value(&obs).unwrap();
assert_eq!(json["adapter_id"], "claude");
assert_eq!(json["observed_at_epoch_s"], 100);
assert_eq!(json["total_tokens"], 500);
assert!(json.get("adapter_version").is_none());
assert!(json.get("compaction_signal").is_none());
assert!(json.get("usage").is_none());
}
#[test]
fn resolve_env_string_with_lifeloop_winning() {
let _g = ENV_SINK_LOCK.lock().unwrap();
env_warning_sink().reset_for_tests();
let aliases = &[EnvAlias {
lifeloop: "LIFELOOP_TEST_X",
ccd_compat: "CCD_TEST_X",
}];
let read = |name: &str| -> Option<String> {
match name {
"LIFELOOP_TEST_X" => Some("ll".into()),
"CCD_TEST_X" => Some("ccd".into()),
_ => None,
}
};
assert_eq!(resolve_env_string_with(aliases, &read), Some("ll".into()));
let warnings = env_warning_sink().drain();
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].lifeloop_key, "LIFELOOP_TEST_X");
assert_eq!(warnings[0].ccd_compat_key, "CCD_TEST_X");
}
#[test]
fn resolve_env_string_falls_back_to_ccd() {
let _g = ENV_SINK_LOCK.lock().unwrap();
env_warning_sink().reset_for_tests();
let aliases = &[EnvAlias {
lifeloop: "LIFELOOP_TEST_Y",
ccd_compat: "CCD_TEST_Y",
}];
let read = |name: &str| -> Option<String> {
match name {
"CCD_TEST_Y" => Some("ccd-only".into()),
_ => None,
}
};
assert_eq!(
resolve_env_string_with(aliases, &read),
Some("ccd-only".into())
);
assert!(env_warning_sink().drain().is_empty());
}
#[test]
fn warning_is_bounded_to_one_per_key() {
let _g = ENV_SINK_LOCK.lock().unwrap();
env_warning_sink().reset_for_tests();
let aliases = &[EnvAlias {
lifeloop: "LIFELOOP_TEST_Z",
ccd_compat: "CCD_TEST_Z",
}];
let read = |name: &str| -> Option<String> {
match name {
"LIFELOOP_TEST_Z" => Some("ll".into()),
"CCD_TEST_Z" => Some("ccd".into()),
_ => None,
}
};
for _ in 0..5 {
let _ = resolve_env_string_with(aliases, &read);
}
let warnings = env_warning_sink().drain();
assert_eq!(warnings.len(), 1);
for _ in 0..3 {
let _ = resolve_env_string_with(aliases, &read);
}
assert!(env_warning_sink().drain().is_empty());
}
}