use std::fs::OpenOptions;
use std::io::Write;
use crate::history::AiStats;
use serde::{Deserialize, Serialize};
pub fn append_jsonl(stats: &AiStats) {
let Ok(path) = std::env::var("APTU_METRICS_FILE") else {
return; };
if let Err(e) = append_jsonl_impl(&path, stats) {
tracing::warn!("metrics: failed to append JSONL record: {}", e);
}
}
fn append_jsonl_impl(path: &str, stats: &AiStats) -> std::io::Result<()> {
let json_line = serde_json::to_string(stats)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let mut file = OpenOptions::new().append(true).create(true).open(path)?;
file.write_all(json_line.as_bytes())?;
file.write_all(b"\n")?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewContextRecord {
pub trace_id: String,
pub operation: String,
pub pr: String,
pub model: String,
pub github_actor: Option<String>,
pub files_total: usize,
pub files_with_patch: usize,
pub files_truncated: usize,
pub truncated_chars_dropped: usize,
pub ast_context_chars: usize,
pub call_graph_chars: usize,
pub dep_enrichments_count: usize,
pub dep_enrichments_chars: usize,
pub budget_drops: Vec<String>,
pub cwd_inferred: bool,
pub prompt_chars_final: usize,
pub finish_reasons: Vec<String>,
}
pub fn write_context_jsonl(record: &ReviewContextRecord) {
let Ok(path) = std::env::var("APTU_CONTEXT_FILE") else {
return; };
if path.is_empty() {
tracing::warn!("metrics: APTU_CONTEXT_FILE is set but empty; skipping context write");
return;
}
if let Err(e) = write_context_jsonl_impl(&path, record) {
tracing::warn!(
path = %path,
error = %e,
"metrics: failed to write context JSONL record"
);
}
}
fn write_context_jsonl_impl(path: &str, record: &ReviewContextRecord) -> std::io::Result<()> {
let json_line = serde_json::to_string(record)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let mut file = OpenOptions::new().append(true).create(true).open(path)?;
file.write_all(json_line.as_bytes())?;
file.write_all(b"\n")?;
if let Err(e) = file.flush() {
tracing::warn!("aptu: failed to flush context file: {}", e);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_append_jsonl_creates_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("metrics.jsonl");
let file_path_str = file_path.to_string_lossy().to_string();
let stats = AiStats {
provider: "test-provider".to_string(),
model: "test-model".to_string(),
input_tokens: 100,
output_tokens: 50,
duration_ms: 1000,
cost_usd: Some(0.01),
fallback_provider: None,
prompt_chars: 500,
cache_read_tokens: 0,
cache_write_tokens: 0,
trace_id: None,
};
append_jsonl_impl(&file_path_str, &stats).unwrap();
let content = fs::read_to_string(&file_path).unwrap();
assert!(content.contains("\"provider\":\"test-provider\""));
assert!(content.contains("\"model\":\"test-model\""));
assert!(content.contains("\"input_tokens\":100"));
assert!(content.contains("\"output_tokens\":50"));
assert!(content.ends_with('\n'));
}
#[test]
fn test_append_jsonl_noop_without_env() {
unsafe {
std::env::remove_var("APTU_METRICS_FILE");
}
let stats = AiStats {
provider: "test-provider".to_string(),
model: "test-model".to_string(),
input_tokens: 100,
output_tokens: 50,
duration_ms: 1000,
cost_usd: None,
fallback_provider: None,
prompt_chars: 500,
cache_read_tokens: 0,
cache_write_tokens: 0,
trace_id: None,
};
append_jsonl(&stats);
}
#[test]
fn test_append_jsonl_warn_on_error() {
unsafe {
std::env::set_var("APTU_METRICS_FILE", "/nonexistent/path/metrics.jsonl");
}
let stats = AiStats {
provider: "test-provider".to_string(),
model: "test-model".to_string(),
input_tokens: 100,
output_tokens: 50,
duration_ms: 1000,
cost_usd: None,
fallback_provider: None,
prompt_chars: 500,
cache_read_tokens: 0,
cache_write_tokens: 0,
trace_id: None,
};
append_jsonl(&stats);
unsafe {
std::env::remove_var("APTU_METRICS_FILE");
}
}
#[test]
fn test_append_jsonl_cache_tokens_in_record() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("metrics.jsonl");
let file_path_str = file_path.to_string_lossy().to_string();
let stats = AiStats {
provider: "anthropic".to_string(),
model: "claude-sonnet-4-6".to_string(),
input_tokens: 200,
output_tokens: 75,
duration_ms: 2000,
cost_usd: Some(0.02),
fallback_provider: None,
prompt_chars: 1000,
cache_read_tokens: 50,
cache_write_tokens: 25,
trace_id: None,
};
append_jsonl_impl(&file_path_str, &stats).unwrap();
let content = fs::read_to_string(&file_path).unwrap();
assert!(content.contains("\"cache_read_tokens\":50"));
assert!(content.contains("\"cache_write_tokens\":25"));
}
#[test]
fn test_write_context_jsonl_noop_without_env() {
unsafe {
std::env::remove_var("APTU_CONTEXT_FILE");
}
let record = ReviewContextRecord {
trace_id: "test-trace-id".to_string(),
operation: "pr_review".to_string(),
pr: "owner/repo#123".to_string(),
model: "test-model".to_string(),
github_actor: None,
files_total: 5,
files_with_patch: 4,
files_truncated: 0,
truncated_chars_dropped: 0,
ast_context_chars: 1000,
call_graph_chars: 2000,
dep_enrichments_count: 2,
dep_enrichments_chars: 500,
budget_drops: vec![],
cwd_inferred: false,
prompt_chars_final: 5000,
finish_reasons: vec!["stop".to_string()],
};
write_context_jsonl(&record);
}
#[test]
fn test_write_context_jsonl_creates_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("context.jsonl");
let file_path_str = file_path.to_string_lossy().to_string();
let record = ReviewContextRecord {
trace_id: "test-trace-id".to_string(),
operation: "pr_review".to_string(),
pr: "owner/repo#123".to_string(),
model: "test-model".to_string(),
github_actor: Some("test-actor".to_string()),
files_total: 5,
files_with_patch: 4,
files_truncated: 1,
truncated_chars_dropped: 500,
ast_context_chars: 1000,
call_graph_chars: 2000,
dep_enrichments_count: 2,
dep_enrichments_chars: 500,
budget_drops: vec!["call_graph".to_string()],
cwd_inferred: true,
prompt_chars_final: 5000,
finish_reasons: vec!["stop".to_string()],
};
write_context_jsonl_impl(&file_path_str, &record).unwrap();
let content = fs::read_to_string(&file_path).unwrap();
assert!(content.contains("\"trace_id\":\"test-trace-id\""));
assert!(content.contains("\"operation\":\"pr_review\""));
assert!(content.contains("\"pr\":\"owner/repo#123\""));
assert!(content.contains("\"files_total\":5"));
assert!(content.contains("\"files_with_patch\":4"));
assert!(content.contains("\"github_actor\":\"test-actor\""));
assert!(content.contains("\"budget_drops\":[\"call_graph\"]"));
assert!(content.contains("\"finish_reasons\":[\"stop\"]"));
assert!(content.ends_with('\n'));
}
}