1use std::fs::OpenOptions;
11use std::io::Write;
12
13use crate::history::AiStats;
14use serde::{Deserialize, Serialize};
15
16pub fn append_jsonl(stats: &AiStats) {
25 let Ok(path) = std::env::var("APTU_METRICS_FILE") else {
26 return; };
28
29 if let Err(e) = append_jsonl_impl(&path, stats) {
30 tracing::warn!("metrics: failed to append JSONL record: {}", e);
31 }
32}
33
34fn append_jsonl_impl(path: &str, stats: &AiStats) -> std::io::Result<()> {
35 let json_line = serde_json::to_string(stats)
36 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
37
38 let mut file = OpenOptions::new().append(true).create(true).open(path)?;
39
40 file.write_all(json_line.as_bytes())?;
41 file.write_all(b"\n")?;
42
43 Ok(())
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ReviewContextRecord {
52 pub trace_id: String,
54 pub operation: String,
56 pub pr: String,
58 pub model: String,
60 pub github_actor: Option<String>,
62 pub files_total: usize,
64 pub files_with_patch: usize,
66 pub files_truncated: usize,
68 pub truncated_chars_dropped: usize,
70 pub ast_context_chars: usize,
72 pub call_graph_chars: usize,
74 pub graph_chars: usize,
76 pub graph_cache_hit: bool,
78 pub dep_enrichments_count: usize,
80 pub dep_enrichments_chars: usize,
82 pub budget_drops: Vec<String>,
84 pub cwd_inferred: bool,
86 pub prompt_chars_final: usize,
88 pub finish_reasons: Vec<String>,
90 pub max_prompt_chars: usize,
92}
93
94pub fn write_context_jsonl(record: &ReviewContextRecord) {
108 let Ok(path) = std::env::var("APTU_CONTEXT_FILE") else {
109 return; };
111
112 if path.is_empty() {
113 tracing::warn!("metrics: APTU_CONTEXT_FILE is set but empty; skipping context write");
114 return;
115 }
116
117 if let Err(e) = write_context_jsonl_impl(&path, record) {
118 tracing::warn!(
119 path = %path,
120 error = %e,
121 "metrics: failed to write context JSONL record"
122 );
123 }
124}
125
126fn write_context_jsonl_impl(path: &str, record: &ReviewContextRecord) -> std::io::Result<()> {
127 let json_line = serde_json::to_string(record)
128 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
129
130 let mut file = OpenOptions::new().append(true).create(true).open(path)?;
131
132 file.write_all(json_line.as_bytes())?;
133 file.write_all(b"\n")?;
134 if let Err(e) = file.flush() {
135 tracing::warn!("aptu: failed to flush context file: {}", e);
136 }
137
138 Ok(())
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use std::fs;
145 use tempfile::TempDir;
146
147 #[test]
148 fn test_append_jsonl_creates_file() {
149 let temp_dir = TempDir::new().unwrap();
150 let file_path = temp_dir.path().join("metrics.jsonl");
151 let file_path_str = file_path.to_string_lossy().into_owned();
152
153 let stats = AiStats {
154 provider: "test-provider".to_string(),
155 model: "test-model".to_string(),
156 input_tokens: 100,
157 output_tokens: 50,
158 duration_ms: 1000,
159 cost_usd: Some(0.01),
160 fallback_provider: None,
161 prompt_chars: 500,
162 cache_read_tokens: 0,
163 cache_write_tokens: 0,
164 effective_token_units: 0.0,
165 trace_id: None,
166 }
167 .with_computed_etu();
168
169 append_jsonl_impl(&file_path_str, &stats).unwrap();
170
171 let content = fs::read_to_string(&file_path).unwrap();
172 assert!(content.contains("\"provider\":\"test-provider\""));
173 assert!(content.contains("\"model\":\"test-model\""));
174 assert!(content.contains("\"input_tokens\":100"));
175 assert!(content.contains("\"output_tokens\":50"));
176 assert!(content.ends_with('\n'));
177 }
178
179 #[test]
180 fn test_append_jsonl_noop_without_env() {
181 unsafe {
184 std::env::remove_var("APTU_METRICS_FILE");
185 }
186
187 let stats = AiStats {
188 provider: "test-provider".to_string(),
189 model: "test-model".to_string(),
190 input_tokens: 100,
191 output_tokens: 50,
192 duration_ms: 1000,
193 cost_usd: None,
194 fallback_provider: None,
195 prompt_chars: 500,
196 cache_read_tokens: 0,
197 cache_write_tokens: 0,
198 effective_token_units: 0.0,
199 trace_id: None,
200 }
201 .with_computed_etu();
202
203 append_jsonl(&stats);
205 }
206
207 #[test]
208 fn test_append_jsonl_warn_on_error() {
209 unsafe {
212 std::env::set_var("APTU_METRICS_FILE", "/nonexistent/path/metrics.jsonl");
213 }
214
215 let stats = AiStats {
216 provider: "test-provider".to_string(),
217 model: "test-model".to_string(),
218 input_tokens: 100,
219 output_tokens: 50,
220 duration_ms: 1000,
221 cost_usd: None,
222 fallback_provider: None,
223 prompt_chars: 500,
224 cache_read_tokens: 0,
225 cache_write_tokens: 0,
226 effective_token_units: 0.0,
227 trace_id: None,
228 }
229 .with_computed_etu();
230
231 append_jsonl(&stats);
233
234 unsafe {
236 std::env::remove_var("APTU_METRICS_FILE");
237 }
238 }
239
240 #[test]
241 fn test_append_jsonl_cache_tokens_in_record() {
242 let temp_dir = TempDir::new().unwrap();
243 let file_path = temp_dir.path().join("metrics.jsonl");
244 let file_path_str = file_path.to_string_lossy().into_owned();
245
246 let stats = AiStats {
247 provider: "anthropic".to_string(),
248 model: "claude-sonnet-4-6".to_string(),
249 input_tokens: 200,
250 output_tokens: 75,
251 duration_ms: 2000,
252 cost_usd: Some(0.02),
253 fallback_provider: None,
254 prompt_chars: 1000,
255 cache_read_tokens: 50,
256 cache_write_tokens: 25,
257 effective_token_units: 0.0,
258 trace_id: None,
259 }
260 .with_computed_etu();
261
262 append_jsonl_impl(&file_path_str, &stats).unwrap();
263
264 let content = fs::read_to_string(&file_path).unwrap();
265 assert!(content.contains("\"cache_read_tokens\":50"));
266 assert!(content.contains("\"cache_write_tokens\":25"));
267 }
268
269 #[test]
270 fn test_write_context_jsonl_noop_without_env() {
271 unsafe {
274 std::env::remove_var("APTU_CONTEXT_FILE");
275 }
276
277 let record = ReviewContextRecord {
278 trace_id: "test-trace-id".to_string(),
279 operation: "pr_review".to_string(),
280 pr: "owner/repo#123".to_string(),
281 model: "test-model".to_string(),
282 github_actor: None,
283 files_total: 5,
284 files_with_patch: 4,
285 files_truncated: 0,
286 truncated_chars_dropped: 0,
287 ast_context_chars: 1000,
288 call_graph_chars: 2000,
289 graph_chars: 0,
290 graph_cache_hit: false,
291 dep_enrichments_count: 2,
292 dep_enrichments_chars: 500,
293 budget_drops: vec![],
294 cwd_inferred: false,
295 prompt_chars_final: 5000,
296 finish_reasons: vec!["stop".to_string()],
297 max_prompt_chars: 120_000,
298 };
299
300 write_context_jsonl(&record);
302 }
303
304 #[test]
305 fn test_write_context_jsonl_creates_file() {
306 let temp_dir = TempDir::new().unwrap();
307 let file_path = temp_dir.path().join("context.jsonl");
308 let file_path_str = file_path.to_string_lossy().into_owned();
309
310 let record = ReviewContextRecord {
311 trace_id: "test-trace-id".to_string(),
312 operation: "pr_review".to_string(),
313 pr: "owner/repo#123".to_string(),
314 model: "test-model".to_string(),
315 github_actor: Some("test-actor".to_string()),
316 files_total: 5,
317 files_with_patch: 4,
318 files_truncated: 1,
319 truncated_chars_dropped: 500,
320 ast_context_chars: 1000,
321 call_graph_chars: 2000,
322 graph_chars: 0,
323 graph_cache_hit: false,
324 dep_enrichments_count: 2,
325 dep_enrichments_chars: 500,
326 budget_drops: vec!["call_graph".to_string()],
327 cwd_inferred: true,
328 prompt_chars_final: 5000,
329 finish_reasons: vec!["stop".to_string()],
330 max_prompt_chars: 120_000,
331 };
332
333 write_context_jsonl_impl(&file_path_str, &record).unwrap();
334
335 let content = fs::read_to_string(&file_path).unwrap();
336 assert!(content.contains("\"trace_id\":\"test-trace-id\""));
337 assert!(content.contains("\"operation\":\"pr_review\""));
338 assert!(content.contains("\"pr\":\"owner/repo#123\""));
339 assert!(content.contains("\"files_total\":5"));
340 assert!(content.contains("\"files_with_patch\":4"));
341 assert!(content.contains("\"github_actor\":\"test-actor\""));
342 assert!(content.contains("\"budget_drops\":[\"call_graph\"]"));
343 assert!(content.contains("\"finish_reasons\":[\"stop\"]"));
344 assert!(content.ends_with('\n'));
345 }
346
347 #[test]
348 fn test_max_prompt_chars_recorded_in_context_record() {
349 let record = ReviewContextRecord {
350 trace_id: "test-trace".to_string(),
351 operation: "pr_review".to_string(),
352 pr: "owner/repo#1".to_string(),
353 model: "gpt-4".to_string(),
354 github_actor: None,
355 files_total: 5,
356 files_with_patch: 3,
357 files_truncated: 0,
358 truncated_chars_dropped: 0,
359 ast_context_chars: 100,
360 call_graph_chars: 50,
361 graph_chars: 0,
362 graph_cache_hit: false,
363 dep_enrichments_count: 2,
364 dep_enrichments_chars: 200,
365 budget_drops: vec![],
366 cwd_inferred: false,
367 prompt_chars_final: 5000,
368 finish_reasons: vec!["stop".to_string()],
369 max_prompt_chars: 120_000,
370 };
371
372 let json = serde_json::to_string(&record).expect("serialization failed");
373 assert!(
374 json.contains(r#""max_prompt_chars":120000"#),
375 "max_prompt_chars must be present in JSON with correct value, got: {}",
376 json,
377 );
378 }
379}