Skip to main content

lifeloop/telemetry/
claude.rs

1//! Claude Code lifecycle telemetry reader.
2//!
3//! Parses `~/.claude/projects/<slug>/<session>.jsonl` session logs and
4//! extracts the lifecycle kernel: latest prompt-token count, model
5//! name, and a compaction signal. No prompt or message bodies are
6//! retained — only the per-line `usage` aggregates.
7
8use std::fs;
9use std::io::{BufRead, BufReader};
10use std::path::{Path, PathBuf};
11
12use serde::Deserialize;
13use serde_json::Value;
14
15use super::{
16    EnvAlias, PressureObservation, TelemetryError, TelemetryResult, TokenUsage, compute_pct,
17    file_mtime_epoch_s, general_context_window, general_host_model, home_dir, is_recent,
18    read_file_bounded, read_file_to_string_bounded, resolve_env_string, resolve_env_u64,
19    string_key,
20};
21
22const ADAPTER_ID: &str = "claude";
23
24const CLAUDE_HOME_ALIASES: &[EnvAlias] = &[EnvAlias {
25    lifeloop: "LIFELOOP_CLAUDE_HOME",
26    ccd_compat: "CCD_CLAUDE_HOME",
27}];
28
29const CLAUDE_SESSION_ID_ALIASES: &[EnvAlias] = &[EnvAlias {
30    lifeloop: "LIFELOOP_CLAUDE_SESSION_ID",
31    ccd_compat: "CCD_CLAUDE_SESSION_ID",
32}];
33
34const CLAUDE_MODEL_ALIASES: &[EnvAlias] = &[EnvAlias {
35    lifeloop: "LIFELOOP_CLAUDE_MODEL",
36    ccd_compat: "CCD_CLAUDE_MODEL",
37}];
38
39const CLAUDE_CONTEXT_WINDOW_ALIASES: &[EnvAlias] = &[EnvAlias {
40    lifeloop: "LIFELOOP_CLAUDE_CONTEXT_WINDOW_TOKENS",
41    ccd_compat: "CCD_CLAUDE_CONTEXT_WINDOW_TOKENS",
42}];
43
44/// Probe the Claude Code session log for the given repo and return a
45/// neutral [`PressureObservation`] when one is recent enough to drive a
46/// `context.pressure_observed` event.
47///
48/// Returns `Ok(None)` when there is no fresh session log or when the log
49/// has no usage-bearing entries — this is the *expected* "telemetry
50/// unavailable" outcome and is not an error.
51pub fn current(repo_root: &Path) -> TelemetryResult<Option<PressureObservation>> {
52    let project_dir = claude_home()?
53        .join("projects")
54        .join(project_slug(repo_root));
55    if !project_dir.is_dir() {
56        return Ok(None);
57    }
58
59    let session_id_override = resolve_env_string(CLAUDE_SESSION_ID_ALIASES);
60    let session_log = match session_id_override.as_deref() {
61        Some(session_id) => {
62            let path = project_dir.join(format!("{session_id}.jsonl"));
63            path.is_file().then_some(path)
64        }
65        None => latest_session_log(&project_dir)?,
66    };
67    let Some(session_log) = session_log else {
68        return Ok(None);
69    };
70
71    let observed_at_epoch_s = match file_mtime_epoch_s(&session_log)? {
72        Some(epoch_s) => epoch_s,
73        None => return Ok(None),
74    };
75    if session_id_override.is_none() && !is_recent(observed_at_epoch_s)? {
76        return Ok(None);
77    }
78
79    let Some(metrics) = latest_session_metrics(&session_log)? else {
80        return Ok(None);
81    };
82
83    let context_window =
84        resolve_env_u64(CLAUDE_CONTEXT_WINDOW_ALIASES).or_else(general_context_window);
85    let compacted = session_was_compacted(&project_dir, &session_log, &metrics.session_id)?;
86
87    Ok(Some(PressureObservation {
88        adapter_id: ADAPTER_ID.into(),
89        adapter_version: None,
90        observed_at_epoch_s,
91        model_name: metrics
92            .model_name
93            .or_else(|| resolve_env_string(CLAUDE_MODEL_ALIASES))
94            .or_else(general_host_model),
95        total_tokens: Some(metrics.latest_prompt_tokens),
96        context_window_tokens: context_window,
97        context_used_pct: compute_pct(metrics.latest_prompt_tokens, context_window),
98        compaction_signal: compacted,
99        usage: TokenUsage {
100            input_tokens: metrics.input_tokens,
101            output_tokens: metrics.output_tokens,
102            cache_creation_input_tokens: metrics.cache_creation_input_tokens,
103            cache_read_input_tokens: metrics.cache_read_input_tokens,
104            blended_total_tokens: None,
105        },
106    }))
107}
108
109/// Parse a Claude Code session log byte slice directly. Useful for
110/// testing and for callers that already have the log in memory.
111///
112/// `observed_at_epoch_s` is the mtime the caller wants stamped on the
113/// resulting observation (typically the file mtime).
114pub fn parse_session_log(
115    bytes: &[u8],
116    observed_at_epoch_s: u64,
117    context_window_tokens: Option<u64>,
118) -> TelemetryResult<Option<PressureObservation>> {
119    let metrics = aggregate_session_metrics(bytes)?;
120    let Some(metrics) = metrics else {
121        return Ok(None);
122    };
123    Ok(Some(PressureObservation {
124        adapter_id: ADAPTER_ID.into(),
125        adapter_version: None,
126        observed_at_epoch_s,
127        model_name: metrics.model_name,
128        total_tokens: Some(metrics.latest_prompt_tokens),
129        context_window_tokens,
130        context_used_pct: compute_pct(metrics.latest_prompt_tokens, context_window_tokens),
131        compaction_signal: None,
132        usage: TokenUsage {
133            input_tokens: metrics.input_tokens,
134            output_tokens: metrics.output_tokens,
135            cache_creation_input_tokens: metrics.cache_creation_input_tokens,
136            cache_read_input_tokens: metrics.cache_read_input_tokens,
137            blended_total_tokens: None,
138        },
139    }))
140}
141
142fn claude_home() -> TelemetryResult<PathBuf> {
143    if let Some(path) = resolve_env_string(CLAUDE_HOME_ALIASES) {
144        return Ok(PathBuf::from(path));
145    }
146    Ok(home_dir()?.join(".claude"))
147}
148
149fn latest_session_log(project_dir: &Path) -> TelemetryResult<Option<PathBuf>> {
150    let mut newest: Option<(u64, PathBuf)> = None;
151    for entry in fs::read_dir(project_dir).map_err(TelemetryError::from)? {
152        let entry = entry.map_err(TelemetryError::from)?;
153        let path = entry.path();
154        let is_session_log = path
155            .file_name()
156            .and_then(|n| n.to_str())
157            .map(|n| n.ends_with(".jsonl"))
158            .unwrap_or(false);
159        if !is_session_log {
160            continue;
161        }
162        let mtime = match file_mtime_epoch_s(&path)? {
163            Some(m) => m,
164            None => continue,
165        };
166        match &newest {
167            Some((best, _)) if *best >= mtime => {}
168            _ => newest = Some((mtime, path)),
169        }
170    }
171    Ok(newest.map(|(_, p)| p))
172}
173
174fn latest_session_metrics(path: &Path) -> TelemetryResult<Option<ClaudeSessionMetrics>> {
175    let bytes = read_file_bounded(path, "Claude session log")?;
176    aggregate_session_metrics(&bytes)
177}
178
179fn aggregate_session_metrics(bytes: &[u8]) -> TelemetryResult<Option<ClaudeSessionMetrics>> {
180    let reader = BufReader::new(bytes);
181    let mut latest: Option<ClaudeSessionMetrics> = None;
182    for line in reader.lines() {
183        let line = line.map_err(TelemetryError::from)?;
184        let value: Value = match serde_json::from_str(&line) {
185            Ok(v) => v,
186            Err(_) => continue,
187        };
188        let model_name = string_key(
189            &value,
190            &["model", "model_name", "modelName", "display_name"],
191        );
192        let entry: ClaudeSessionEntry = match serde_json::from_value(value) {
193            Ok(e) => e,
194            Err(_) => continue,
195        };
196        let Some(message) = entry.message else {
197            continue;
198        };
199        let Some(usage) = message.usage else { continue };
200
201        let prompt_tokens = usage
202            .input_tokens
203            .saturating_add(usage.cache_creation_input_tokens)
204            .saturating_add(usage.cache_read_input_tokens);
205        if prompt_tokens == 0 && usage.output_tokens == 0 {
206            continue;
207        }
208        let Some(session_id) = entry.session_id else {
209            continue;
210        };
211
212        match &mut latest {
213            Some(m) => {
214                m.session_id = session_id;
215                m.latest_prompt_tokens = prompt_tokens;
216                m.input_tokens = m.input_tokens.saturating_add(usage.input_tokens);
217                m.output_tokens = m.output_tokens.saturating_add(usage.output_tokens);
218                m.cache_creation_input_tokens = m
219                    .cache_creation_input_tokens
220                    .saturating_add(usage.cache_creation_input_tokens);
221                m.cache_read_input_tokens = m
222                    .cache_read_input_tokens
223                    .saturating_add(usage.cache_read_input_tokens);
224                if model_name.is_some() {
225                    m.model_name = model_name;
226                }
227            }
228            None => {
229                latest = Some(ClaudeSessionMetrics {
230                    session_id,
231                    latest_prompt_tokens: prompt_tokens,
232                    input_tokens: usage.input_tokens,
233                    output_tokens: usage.output_tokens,
234                    cache_creation_input_tokens: usage.cache_creation_input_tokens,
235                    cache_read_input_tokens: usage.cache_read_input_tokens,
236                    model_name,
237                });
238            }
239        }
240    }
241    Ok(latest)
242}
243
244fn session_was_compacted(
245    project_dir: &Path,
246    session_log: &Path,
247    session_id: &str,
248) -> TelemetryResult<Option<bool>> {
249    let subagents_dir = project_dir.join(session_id).join("subagents");
250    if subagents_dir.is_dir() {
251        for entry in fs::read_dir(&subagents_dir).map_err(TelemetryError::from)? {
252            let entry = entry.map_err(TelemetryError::from)?;
253            let path = entry.path();
254            let is_acompact = path
255                .file_name()
256                .and_then(|n| n.to_str())
257                .map(|n| n.starts_with("agent-acompact-") && n.ends_with(".jsonl"))
258                .unwrap_or(false);
259            if is_acompact {
260                return Ok(Some(true));
261            }
262        }
263    }
264    let contents = read_file_to_string_bounded(session_log, "Claude session log")?;
265    if contents.contains("<command-name>/compact</command-name>") {
266        return Ok(Some(true));
267    }
268    Ok(None)
269}
270
271fn project_slug(repo_root: &Path) -> String {
272    repo_root
273        .canonicalize()
274        .unwrap_or_else(|_| repo_root.to_path_buf())
275        .to_string_lossy()
276        .replace('/', "-")
277}
278
279#[derive(Deserialize)]
280struct ClaudeSessionEntry {
281    #[serde(rename = "sessionId")]
282    session_id: Option<String>,
283    message: Option<ClaudeMessage>,
284}
285
286#[derive(Deserialize)]
287struct ClaudeMessage {
288    usage: Option<ClaudeUsage>,
289}
290
291#[derive(Deserialize)]
292struct ClaudeUsage {
293    #[serde(default)]
294    input_tokens: u64,
295    #[serde(default)]
296    output_tokens: u64,
297    #[serde(default)]
298    cache_creation_input_tokens: u64,
299    #[serde(default)]
300    cache_read_input_tokens: u64,
301}
302
303struct ClaudeSessionMetrics {
304    session_id: String,
305    latest_prompt_tokens: u64,
306    input_tokens: u64,
307    output_tokens: u64,
308    cache_creation_input_tokens: u64,
309    cache_read_input_tokens: u64,
310    model_name: Option<String>,
311}