1use 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
62pub 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}