Skip to main content

aptu_core/
metrics.rs

1// SPDX-FileCopyrightText: 2026 Aptu Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Fire-and-forget JSONL metrics logging.
5//!
6//! Appends AI usage statistics to a JSONL file when `APTU_METRICS_FILE` environment variable is set.
7//! Appends PR review context records to a JSONL file when `APTU_CONTEXT_FILE` environment variable is set.
8//! Failures are logged as warnings and never propagate to the caller.
9
10use std::fs::OpenOptions;
11use std::io::Write;
12
13use crate::history::AiStats;
14use serde::{Deserialize, Serialize};
15
16/// Append an AI statistics record to the metrics JSONL file.
17///
18/// Reads the `APTU_METRICS_FILE` environment variable. If not set, this is a no-op.
19/// If set, opens the file in append mode (creating it if necessary) and writes a single
20/// JSON line followed by a newline.
21///
22/// On any error (file I/O, serialization), logs a warning and returns normally.
23/// This function never fails the caller's operation.
24pub fn append_jsonl(stats: &AiStats) {
25    let Ok(path) = std::env::var("APTU_METRICS_FILE") else {
26        return; // Env var not set; no-op
27    };
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/// Record of PR review context decisions for explainability.
47///
48/// Captures all context assembly decisions (files, enrichments, budget drops, prompt size)
49/// for a single PR review operation. Written to JSONL when `APTU_CONTEXT_FILE` is set.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ReviewContextRecord {
52    /// Unique trace ID for correlating with AI stats.
53    pub trace_id: String,
54    /// Operation type (e.g., `pr_review`).
55    pub operation: String,
56    /// PR identifier (owner/repo#number).
57    pub pr: String,
58    /// Model used for analysis.
59    pub model: String,
60    /// GitHub actor (if available from environment).
61    pub github_actor: Option<String>,
62    /// Total number of files in the PR.
63    pub files_total: usize,
64    /// Number of files with a patch (non-empty diff).
65    pub files_with_patch: usize,
66    /// Number of files whose full content was truncated.
67    pub files_truncated: usize,
68    /// Total characters dropped from truncated files.
69    pub truncated_chars_dropped: usize,
70    /// Characters in AST context.
71    pub ast_context_chars: usize,
72    /// Characters in call graph context.
73    pub call_graph_chars: usize,
74    /// Characters in structural graph context (0 when graph feature is disabled).
75    pub graph_chars: usize,
76    /// Whether the structural graph was loaded from the on-disk cache.
77    pub graph_cache_hit: bool,
78    /// Number of dependency enrichments applied.
79    pub dep_enrichments_count: usize,
80    /// Total characters in dependency enrichments.
81    pub dep_enrichments_chars: usize,
82    /// Names of context items dropped due to budget (e.g., `call_graph`, `full_content`).
83    pub budget_drops: Vec<String>,
84    /// Whether the repository path was inferred from CWD.
85    pub cwd_inferred: bool,
86    /// Final assembled prompt character count.
87    pub prompt_chars_final: usize,
88    /// Finish reasons from the AI response.
89    pub finish_reasons: Vec<String>,
90    /// Maximum prompt character budget from review config.
91    pub max_prompt_chars: usize,
92}
93
94/// Append a PR review context record to the context JSONL file.
95///
96/// Reads the `APTU_CONTEXT_FILE` environment variable. If not set, this is a no-op.
97/// If set, opens the file in append mode (creating it if necessary) and writes a single
98/// JSON line followed by a newline.
99///
100/// On any error (file I/O, serialization), logs a warning and returns normally.
101/// This function never fails the caller's operation.
102///
103/// `APTU_CONTEXT_FILE` is validated to be non-empty when first encountered; an
104/// empty value is rejected with a warning so misconfiguration is visible rather
105/// than silently dropped.  Open/write errors are also warned and discarded so
106/// that a bad path never aborts the caller.
107pub fn write_context_jsonl(record: &ReviewContextRecord) {
108    let Ok(path) = std::env::var("APTU_CONTEXT_FILE") else {
109        return; // Env var not set; no-op
110    };
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        // Ensure APTU_METRICS_FILE is not set
182        // SAFETY: test-only; single-threaded test environment.
183        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        // Should not panic or error
204        append_jsonl(&stats);
205    }
206
207    #[test]
208    fn test_append_jsonl_warn_on_error() {
209        // Use an invalid path (directory that doesn't exist)
210        // SAFETY: test-only; single-threaded test environment.
211        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        // Should not panic; logs a warning internally
232        append_jsonl(&stats);
233
234        // SAFETY: test-only; single-threaded test environment.
235        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        // Ensure APTU_CONTEXT_FILE is not set
272        // SAFETY: test-only; single-threaded test environment.
273        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        // Should not panic or error
301        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}