Skip to main content

lifeloop/telemetry/
codex.rs

1//! Codex CLI lifecycle telemetry reader.
2//!
3//! Parses Codex session JSONL emitting `event_msg` / `token_count`
4//! payloads and extracts the lifecycle kernel: latest prompt-token
5//! count, model name, and the natively reported context window.
6
7use std::fs;
8use std::io::{BufRead, BufReader};
9use std::path::{Path, PathBuf};
10
11use serde::Deserialize;
12use serde_json::Value;
13
14use super::{
15    EnvAlias, PressureObservation, TelemetryError, TelemetryResult, TokenUsage, compute_pct,
16    file_mtime_epoch_s, general_context_window, general_host_model, home_dir, resolve_env_string,
17    string_key,
18};
19
20const ADAPTER_ID: &str = "codex";
21
22const CODEX_HOME_ALIASES: &[EnvAlias] = &[EnvAlias {
23    lifeloop: "LIFELOOP_CODEX_HOME",
24    ccd_compat: "CODEX_HOME",
25}];
26
27const CODEX_THREAD_ID_ALIASES: &[EnvAlias] = &[EnvAlias {
28    lifeloop: "LIFELOOP_CODEX_THREAD_ID",
29    ccd_compat: "CODEX_THREAD_ID",
30}];
31
32const CODEX_MODEL_ALIASES: &[EnvAlias] = &[EnvAlias {
33    lifeloop: "LIFELOOP_CODEX_MODEL",
34    ccd_compat: "CCD_CODEX_MODEL",
35}];
36
37/// Probe the active Codex session log for the configured thread id.
38/// Returns `Ok(None)` when no thread id is configured or the session
39/// log has no `token_count` events yet.
40pub fn current() -> TelemetryResult<Option<PressureObservation>> {
41    let Some(thread_id) = resolve_env_string(CODEX_THREAD_ID_ALIASES) else {
42        return Ok(None);
43    };
44
45    let codex_home = codex_home()?;
46    let Some(session_path) = find_session_log(&codex_home.join("sessions"), &thread_id)? else {
47        return Ok(None);
48    };
49
50    let observed_at_epoch_s = match file_mtime_epoch_s(&session_path)? {
51        Some(epoch_s) => epoch_s,
52        None => return Ok(None),
53    };
54
55    let bytes = fs::read(&session_path).map_err(TelemetryError::from)?;
56    parse_session_log(&bytes, observed_at_epoch_s)
57}
58
59/// Parse a Codex session log byte slice. Public for tests and for
60/// callers that read the file themselves.
61pub fn parse_session_log(
62    bytes: &[u8],
63    observed_at_epoch_s: u64,
64) -> TelemetryResult<Option<PressureObservation>> {
65    let reader = BufReader::new(bytes);
66    let mut latest: Option<PressureObservation> = None;
67    let mut latest_model = resolve_env_string(CODEX_MODEL_ALIASES).or_else(general_host_model);
68
69    for line in reader.lines() {
70        let line = line.map_err(TelemetryError::from)?;
71        let value: Value = match serde_json::from_str(&line) {
72            Ok(v) => v,
73            Err(_) => continue,
74        };
75        if let Some(model_name) = string_key(
76            &value,
77            &[
78                "model",
79                "model_name",
80                "modelName",
81                "model_slug",
82                "modelSlug",
83            ],
84        ) {
85            latest_model = Some(model_name);
86        }
87        let entry: SessionEntry = match serde_json::from_value(value) {
88            Ok(e) => e,
89            Err(_) => continue,
90        };
91        if entry.entry_type != "event_msg" {
92            continue;
93        }
94        let Some(payload) = entry.payload else {
95            continue;
96        };
97        if payload.payload_type != "token_count" {
98            continue;
99        }
100
101        let native_window = match payload.info.model_context_window {
102            0 => None,
103            value => Some(value),
104        };
105        let context_window = native_window.or_else(general_context_window);
106
107        let total_tokens = payload
108            .info
109            .last_token_usage
110            .as_ref()
111            .map(|u| u.total_tokens)
112            .unwrap_or(payload.info.total_token_usage.total_tokens);
113
114        latest = Some(PressureObservation {
115            adapter_id: ADAPTER_ID.into(),
116            adapter_version: None,
117            observed_at_epoch_s,
118            model_name: latest_model.clone(),
119            total_tokens: Some(total_tokens),
120            context_window_tokens: context_window,
121            context_used_pct: compute_pct(total_tokens, context_window),
122            compaction_signal: None,
123            usage: TokenUsage {
124                blended_total_tokens: Some(payload.info.total_token_usage.total_tokens),
125                ..TokenUsage::default()
126            },
127        });
128    }
129
130    Ok(latest)
131}
132
133fn codex_home() -> TelemetryResult<PathBuf> {
134    if let Some(path) = resolve_env_string(CODEX_HOME_ALIASES) {
135        return Ok(PathBuf::from(path));
136    }
137    Ok(home_dir()?.join(".codex"))
138}
139
140fn find_session_log(root: &Path, thread_id: &str) -> TelemetryResult<Option<PathBuf>> {
141    if !root.is_dir() {
142        return Ok(None);
143    }
144    let mut stack = vec![root.to_path_buf()];
145    while let Some(dir) = stack.pop() {
146        for entry in fs::read_dir(&dir).map_err(TelemetryError::from)? {
147            let entry = entry.map_err(TelemetryError::from)?;
148            let path = entry.path();
149            if path.is_dir() {
150                stack.push(path);
151                continue;
152            }
153            let matches = path
154                .file_name()
155                .and_then(|n| n.to_str())
156                .map(|n| n.contains(thread_id) && n.ends_with(".jsonl"))
157                .unwrap_or(false);
158            if matches {
159                return Ok(Some(path));
160            }
161        }
162    }
163    Ok(None)
164}
165
166#[derive(Deserialize)]
167struct SessionEntry {
168    #[serde(rename = "type")]
169    entry_type: String,
170    payload: Option<TokenCountPayload>,
171}
172
173#[derive(Deserialize)]
174struct TokenCountPayload {
175    #[serde(rename = "type")]
176    payload_type: String,
177    info: TokenCountInfo,
178}
179
180#[derive(Deserialize)]
181struct TokenCountInfo {
182    total_token_usage: TotalTokenUsage,
183    last_token_usage: Option<TotalTokenUsage>,
184    #[serde(default)]
185    model_context_window: u64,
186}
187
188#[derive(Deserialize)]
189struct TotalTokenUsage {
190    total_tokens: u64,
191}