Skip to main content

lifeloop/telemetry/
gemini.rs

1//! Gemini CLI lifecycle telemetry reader.
2//!
3//! Parses Gemini telemetry JSONL emitting `gemini_cli.api_response` and
4//! `gemini_cli.chat_compression` events. Extracts prompt-token totals,
5//! model name, the native context-window field, and a compaction
6//! signal.
7
8use std::collections::HashMap;
9use std::io::{BufRead, BufReader};
10use std::path::{Path, PathBuf};
11
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, is_recent,
17    number_key, read_file_bounded, resolve_env_string, resolve_env_u64, string_key,
18};
19
20const ADAPTER_ID: &str = "gemini";
21
22const GEMINI_TELEMETRY_PATH_ALIASES: &[EnvAlias] = &[
23    EnvAlias {
24        lifeloop: "LIFELOOP_GEMINI_TELEMETRY_PATH",
25        ccd_compat: "CCD_GEMINI_TELEMETRY_PATH",
26    },
27    EnvAlias {
28        lifeloop: "LIFELOOP_GEMINI_TELEMETRY_OUTFILE",
29        ccd_compat: "GEMINI_TELEMETRY_OUTFILE",
30    },
31];
32
33const GEMINI_MODEL_ALIASES: &[EnvAlias] = &[EnvAlias {
34    lifeloop: "LIFELOOP_GEMINI_MODEL",
35    ccd_compat: "CCD_GEMINI_MODEL",
36}];
37
38const GEMINI_CONTEXT_WINDOW_ALIASES: &[EnvAlias] = &[EnvAlias {
39    lifeloop: "LIFELOOP_GEMINI_CONTEXT_WINDOW_TOKENS",
40    ccd_compat: "CCD_GEMINI_CONTEXT_WINDOW_TOKENS",
41}];
42
43#[derive(Default)]
44struct GeminiSessionSnapshot {
45    latest_prompt_tokens: Option<u64>,
46    input_tokens: u64,
47    output_tokens: u64,
48    cache_read_input_tokens: u64,
49    context_window: Option<u64>,
50    compacted: bool,
51    model_name: Option<String>,
52}
53
54pub fn current(repo_root: &Path) -> TelemetryResult<Option<PressureObservation>> {
55    let Some((path, observed_at_epoch_s)) = telemetry_observation(repo_root)? else {
56        return Ok(None);
57    };
58    let bytes = read_file_bounded(&path, "Gemini telemetry log")?;
59    parse_telemetry_log(&bytes, observed_at_epoch_s)
60}
61
62/// Parse a Gemini telemetry log byte slice.
63pub fn parse_telemetry_log(
64    bytes: &[u8],
65    observed_at_epoch_s: u64,
66) -> TelemetryResult<Option<PressureObservation>> {
67    let reader = BufReader::new(bytes);
68    let mut sessions: HashMap<String, GeminiSessionSnapshot> = HashMap::new();
69    let mut latest_session_key = None;
70
71    for line in reader.lines() {
72        let line = line.map_err(TelemetryError::from)?;
73        if line.trim().is_empty() {
74            continue;
75        }
76        let value: Value = match serde_json::from_str(&line) {
77            Ok(v) => v,
78            Err(_) => continue,
79        };
80        let event_name = event_name(&value).unwrap_or_else(|| event_name_from_line(&line));
81        if event_name.is_empty() {
82            continue;
83        }
84        let session_key =
85            string_key(&value, &["session_id", "sessionId"]).unwrap_or_else(|| "global".to_owned());
86        latest_session_key = Some(session_key.clone());
87        let session = sessions.entry(session_key).or_default();
88        apply_event(session, &event_name, &value);
89    }
90
91    let Some(session_key) = latest_session_key else {
92        return Ok(None);
93    };
94    let Some(session) = sessions.remove(&session_key) else {
95        return Ok(None);
96    };
97    if session.latest_prompt_tokens.is_none() && !session.compacted {
98        return Ok(None);
99    }
100
101    Ok(Some(PressureObservation {
102        adapter_id: ADAPTER_ID.into(),
103        adapter_version: None,
104        observed_at_epoch_s,
105        model_name: session
106            .model_name
107            .or_else(|| resolve_env_string(GEMINI_MODEL_ALIASES))
108            .or_else(general_host_model),
109        total_tokens: session.latest_prompt_tokens,
110        context_window_tokens: session.context_window,
111        context_used_pct: session
112            .latest_prompt_tokens
113            .and_then(|t| compute_pct(t, session.context_window)),
114        compaction_signal: session.compacted.then_some(true),
115        usage: TokenUsage {
116            input_tokens: session.input_tokens,
117            output_tokens: session.output_tokens,
118            cache_creation_input_tokens: 0,
119            cache_read_input_tokens: session.cache_read_input_tokens,
120            blended_total_tokens: None,
121        },
122    }))
123}
124
125fn telemetry_observation(repo_root: &Path) -> TelemetryResult<Option<(PathBuf, u64)>> {
126    let Some(path) = telemetry_path(repo_root)? else {
127        return Ok(None);
128    };
129    let Some(observed_at_epoch_s) = file_mtime_epoch_s(&path)? else {
130        return Ok(None);
131    };
132    if resolve_env_string(GEMINI_TELEMETRY_PATH_ALIASES).is_none()
133        && !is_recent(observed_at_epoch_s)?
134    {
135        return Ok(None);
136    }
137    Ok(Some((path, observed_at_epoch_s)))
138}
139
140fn telemetry_path(repo_root: &Path) -> TelemetryResult<Option<PathBuf>> {
141    if let Some(path) = resolve_env_string(GEMINI_TELEMETRY_PATH_ALIASES) {
142        let path = PathBuf::from(path);
143        return Ok(path.is_file().then_some(path));
144    }
145    let candidates = [
146        repo_root.join(".gemini/telemetry.log"),
147        repo_root.join("telemetry.log"),
148        home_dir()?.join(".gemini/telemetry.log"),
149    ];
150    Ok(candidates.into_iter().find(|p| p.is_file()))
151}
152
153fn apply_event(session: &mut GeminiSessionSnapshot, event_name: &str, value: &Value) {
154    if event_name.contains("gemini_cli.api_response") {
155        apply_api_response(session, value);
156    }
157    if event_name.contains("gemini_cli.chat_compression") {
158        session.compacted = true;
159    }
160}
161
162fn apply_api_response(session: &mut GeminiSessionSnapshot, value: &Value) {
163    if let Some(prompt_tokens) = prompt_tokens(value) {
164        session.latest_prompt_tokens = Some(prompt_tokens);
165    }
166    if let Some(input_tokens) = input_tokens(value) {
167        session.input_tokens = session.input_tokens.saturating_add(input_tokens);
168    }
169    if let Some(output_tokens) = output_tokens(value) {
170        session.output_tokens = session.output_tokens.saturating_add(output_tokens);
171    }
172    if let Some(cache_read_tokens) = cache_read_input_tokens(value) {
173        session.cache_read_input_tokens = session
174            .cache_read_input_tokens
175            .saturating_add(cache_read_tokens);
176    }
177    session.context_window = number_key(
178        value,
179        &[
180            "model_context_window",
181            "context_window",
182            "contextWindow",
183            "limit_context",
184        ],
185    )
186    .or_else(|| resolve_env_u64(GEMINI_CONTEXT_WINDOW_ALIASES))
187    .or_else(general_context_window);
188    if let Some(model_name) = string_key(value, &["model", "model_name", "modelName", "model_id"]) {
189        session.model_name = Some(model_name);
190    }
191}
192
193fn event_name(value: &Value) -> Option<String> {
194    string_key(value, &["name", "event_name", "eventName"])
195}
196
197fn event_name_from_line(line: &str) -> String {
198    if line.contains("gemini_cli.api_response") {
199        "gemini_cli.api_response".to_owned()
200    } else if line.contains("gemini_cli.chat_compression") {
201        "gemini_cli.chat_compression".to_owned()
202    } else {
203        String::new()
204    }
205}
206
207fn prompt_tokens(value: &Value) -> Option<u64> {
208    let input_tokens = input_tokens(value).unwrap_or(0);
209    let cached_tokens = cache_read_input_tokens(value).unwrap_or(0);
210    let total = input_tokens.saturating_add(cached_tokens);
211    (total > 0).then_some(total)
212}
213
214fn input_tokens(value: &Value) -> Option<u64> {
215    number_key(
216        value,
217        &[
218            "input_token_count",
219            "inputTokens",
220            "prompt_tokens",
221            "promptTokenCount",
222        ],
223    )
224}
225
226fn output_tokens(value: &Value) -> Option<u64> {
227    number_key(
228        value,
229        &[
230            "output_token_count",
231            "outputTokens",
232            "completion_tokens",
233            "completionTokenCount",
234        ],
235    )
236}
237
238fn cache_read_input_tokens(value: &Value) -> Option<u64> {
239    number_key(
240        value,
241        &[
242            "cached_content_token_count",
243            "cacheReadInputTokens",
244            "cachedTokens",
245        ],
246    )
247}