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 dep_enrichments_count: usize,
76 pub dep_enrichments_chars: usize,
78 pub budget_drops: Vec<String>,
80 pub cwd_inferred: bool,
82 pub prompt_chars_final: usize,
84 pub finish_reasons: Vec<String>,
86}
87
88pub fn write_context_jsonl(record: &ReviewContextRecord) {
102 let Ok(path) = std::env::var("APTU_CONTEXT_FILE") else {
103 return; };
105
106 if path.is_empty() {
107 tracing::warn!("metrics: APTU_CONTEXT_FILE is set but empty; skipping context write");
108 return;
109 }
110
111 if let Err(e) = write_context_jsonl_impl(&path, record) {
112 tracing::warn!(
113 path = %path,
114 error = %e,
115 "metrics: failed to write context JSONL record"
116 );
117 }
118}
119
120fn write_context_jsonl_impl(path: &str, record: &ReviewContextRecord) -> std::io::Result<()> {
121 let json_line = serde_json::to_string(record)
122 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
123
124 let mut file = OpenOptions::new().append(true).create(true).open(path)?;
125
126 file.write_all(json_line.as_bytes())?;
127 file.write_all(b"\n")?;
128 if let Err(e) = file.flush() {
129 tracing::warn!("aptu: failed to flush context file: {}", e);
130 }
131
132 Ok(())
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use std::fs;
139 use tempfile::TempDir;
140
141 #[test]
142 fn test_append_jsonl_creates_file() {
143 let temp_dir = TempDir::new().unwrap();
144 let file_path = temp_dir.path().join("metrics.jsonl");
145 let file_path_str = file_path.to_string_lossy().to_string();
146
147 let stats = AiStats {
148 provider: "test-provider".to_string(),
149 model: "test-model".to_string(),
150 input_tokens: 100,
151 output_tokens: 50,
152 duration_ms: 1000,
153 cost_usd: Some(0.01),
154 fallback_provider: None,
155 prompt_chars: 500,
156 cache_read_tokens: 0,
157 cache_write_tokens: 0,
158 effective_token_units: 0.0,
159 trace_id: None,
160 }
161 .with_computed_etu();
162
163 append_jsonl_impl(&file_path_str, &stats).unwrap();
164
165 let content = fs::read_to_string(&file_path).unwrap();
166 assert!(content.contains("\"provider\":\"test-provider\""));
167 assert!(content.contains("\"model\":\"test-model\""));
168 assert!(content.contains("\"input_tokens\":100"));
169 assert!(content.contains("\"output_tokens\":50"));
170 assert!(content.ends_with('\n'));
171 }
172
173 #[test]
174 fn test_append_jsonl_noop_without_env() {
175 unsafe {
178 std::env::remove_var("APTU_METRICS_FILE");
179 }
180
181 let stats = AiStats {
182 provider: "test-provider".to_string(),
183 model: "test-model".to_string(),
184 input_tokens: 100,
185 output_tokens: 50,
186 duration_ms: 1000,
187 cost_usd: None,
188 fallback_provider: None,
189 prompt_chars: 500,
190 cache_read_tokens: 0,
191 cache_write_tokens: 0,
192 effective_token_units: 0.0,
193 trace_id: None,
194 }
195 .with_computed_etu();
196
197 append_jsonl(&stats);
199 }
200
201 #[test]
202 fn test_append_jsonl_warn_on_error() {
203 unsafe {
206 std::env::set_var("APTU_METRICS_FILE", "/nonexistent/path/metrics.jsonl");
207 }
208
209 let stats = AiStats {
210 provider: "test-provider".to_string(),
211 model: "test-model".to_string(),
212 input_tokens: 100,
213 output_tokens: 50,
214 duration_ms: 1000,
215 cost_usd: None,
216 fallback_provider: None,
217 prompt_chars: 500,
218 cache_read_tokens: 0,
219 cache_write_tokens: 0,
220 effective_token_units: 0.0,
221 trace_id: None,
222 }
223 .with_computed_etu();
224
225 append_jsonl(&stats);
227
228 unsafe {
230 std::env::remove_var("APTU_METRICS_FILE");
231 }
232 }
233
234 #[test]
235 fn test_append_jsonl_cache_tokens_in_record() {
236 let temp_dir = TempDir::new().unwrap();
237 let file_path = temp_dir.path().join("metrics.jsonl");
238 let file_path_str = file_path.to_string_lossy().to_string();
239
240 let stats = AiStats {
241 provider: "anthropic".to_string(),
242 model: "claude-sonnet-4-6".to_string(),
243 input_tokens: 200,
244 output_tokens: 75,
245 duration_ms: 2000,
246 cost_usd: Some(0.02),
247 fallback_provider: None,
248 prompt_chars: 1000,
249 cache_read_tokens: 50,
250 cache_write_tokens: 25,
251 effective_token_units: 0.0,
252 trace_id: None,
253 }
254 .with_computed_etu();
255
256 append_jsonl_impl(&file_path_str, &stats).unwrap();
257
258 let content = fs::read_to_string(&file_path).unwrap();
259 assert!(content.contains("\"cache_read_tokens\":50"));
260 assert!(content.contains("\"cache_write_tokens\":25"));
261 }
262
263 #[test]
264 fn test_write_context_jsonl_noop_without_env() {
265 unsafe {
268 std::env::remove_var("APTU_CONTEXT_FILE");
269 }
270
271 let record = ReviewContextRecord {
272 trace_id: "test-trace-id".to_string(),
273 operation: "pr_review".to_string(),
274 pr: "owner/repo#123".to_string(),
275 model: "test-model".to_string(),
276 github_actor: None,
277 files_total: 5,
278 files_with_patch: 4,
279 files_truncated: 0,
280 truncated_chars_dropped: 0,
281 ast_context_chars: 1000,
282 call_graph_chars: 2000,
283 dep_enrichments_count: 2,
284 dep_enrichments_chars: 500,
285 budget_drops: vec![],
286 cwd_inferred: false,
287 prompt_chars_final: 5000,
288 finish_reasons: vec!["stop".to_string()],
289 };
290
291 write_context_jsonl(&record);
293 }
294
295 #[test]
296 fn test_write_context_jsonl_creates_file() {
297 let temp_dir = TempDir::new().unwrap();
298 let file_path = temp_dir.path().join("context.jsonl");
299 let file_path_str = file_path.to_string_lossy().to_string();
300
301 let record = ReviewContextRecord {
302 trace_id: "test-trace-id".to_string(),
303 operation: "pr_review".to_string(),
304 pr: "owner/repo#123".to_string(),
305 model: "test-model".to_string(),
306 github_actor: Some("test-actor".to_string()),
307 files_total: 5,
308 files_with_patch: 4,
309 files_truncated: 1,
310 truncated_chars_dropped: 500,
311 ast_context_chars: 1000,
312 call_graph_chars: 2000,
313 dep_enrichments_count: 2,
314 dep_enrichments_chars: 500,
315 budget_drops: vec!["call_graph".to_string()],
316 cwd_inferred: true,
317 prompt_chars_final: 5000,
318 finish_reasons: vec!["stop".to_string()],
319 };
320
321 write_context_jsonl_impl(&file_path_str, &record).unwrap();
322
323 let content = fs::read_to_string(&file_path).unwrap();
324 assert!(content.contains("\"trace_id\":\"test-trace-id\""));
325 assert!(content.contains("\"operation\":\"pr_review\""));
326 assert!(content.contains("\"pr\":\"owner/repo#123\""));
327 assert!(content.contains("\"files_total\":5"));
328 assert!(content.contains("\"files_with_patch\":4"));
329 assert!(content.contains("\"github_actor\":\"test-actor\""));
330 assert!(content.contains("\"budget_drops\":[\"call_graph\"]"));
331 assert!(content.contains("\"finish_reasons\":[\"stop\"]"));
332 assert!(content.ends_with('\n'));
333 }
334}