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    /// Number of dependency enrichments applied.
75    pub dep_enrichments_count: usize,
76    /// Total characters in dependency enrichments.
77    pub dep_enrichments_chars: usize,
78    /// Names of context items dropped due to budget (e.g., `call_graph`, `full_content`).
79    pub budget_drops: Vec<String>,
80    /// Whether the repository path was inferred from CWD.
81    pub cwd_inferred: bool,
82    /// Final assembled prompt character count.
83    pub prompt_chars_final: usize,
84    /// Finish reasons from the AI response.
85    pub finish_reasons: Vec<String>,
86}
87
88/// Append a PR review context record to the context JSONL file.
89///
90/// Reads the `APTU_CONTEXT_FILE` environment variable. If not set, this is a no-op.
91/// If set, opens the file in append mode (creating it if necessary) and writes a single
92/// JSON line followed by a newline.
93///
94/// On any error (file I/O, serialization), logs a warning and returns normally.
95/// This function never fails the caller's operation.
96///
97/// `APTU_CONTEXT_FILE` is validated to be non-empty when first encountered; an
98/// empty value is rejected with a warning so misconfiguration is visible rather
99/// than silently dropped.  Open/write errors are also warned and discarded so
100/// that a bad path never aborts the caller.
101pub fn write_context_jsonl(record: &ReviewContextRecord) {
102    let Ok(path) = std::env::var("APTU_CONTEXT_FILE") else {
103        return; // Env var not set; no-op
104    };
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        // Ensure APTU_METRICS_FILE is not set
176        // SAFETY: test-only; single-threaded test environment.
177        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        // Should not panic or error
198        append_jsonl(&stats);
199    }
200
201    #[test]
202    fn test_append_jsonl_warn_on_error() {
203        // Use an invalid path (directory that doesn't exist)
204        // SAFETY: test-only; single-threaded test environment.
205        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        // Should not panic; logs a warning internally
226        append_jsonl(&stats);
227
228        // SAFETY: test-only; single-threaded test environment.
229        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        // Ensure APTU_CONTEXT_FILE is not set
266        // SAFETY: test-only; single-threaded test environment.
267        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        // Should not panic or error
292        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}