use std::collections::HashMap;
use std::io::IsTerminal;
use crate::replay;
#[derive(Debug, Clone, serde::Serialize)]
pub struct TraceAnalytics {
pub trace_count: usize,
pub latency: LatencyStats,
pub tokens: TokenStats,
pub anchors: AnchorStats,
pub errors: ErrorStats,
pub steps: StepFrequency,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct LatencyStats {
pub unit_count: usize,
pub p50_ms: u64,
pub p95_ms: u64,
pub p99_ms: u64,
pub mean_ms: u64,
pub min_ms: u64,
pub max_ms: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct TokenStats {
pub total_input: u64,
pub total_output: u64,
pub total: u64,
pub mean_input_per_unit: u64,
pub mean_output_per_unit: u64,
pub mean_total_per_unit: u64,
pub unit_count: usize,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct AnchorStats {
pub total_checks: usize,
pub total_passes: usize,
pub total_breaches: usize,
pub pass_rate: f64,
pub breach_rate: f64,
pub top_breaches: Vec<AnchorBreachEntry>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct AnchorBreachEntry {
pub anchor_name: String,
pub breach_count: usize,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ErrorStats {
pub total_steps: usize,
pub total_errors: usize,
pub total_retries: usize,
pub error_rate: f64,
pub retry_rate: f64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct StepFrequency {
pub unique_steps: usize,
pub top_steps: Vec<StepFreqEntry>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct StepFreqEntry {
pub step_name: String,
pub count: usize,
}
pub fn compute_analytics(traces: &[replay::ReplayTrace]) -> TraceAnalytics {
let mut durations: Vec<u64> = Vec::new();
let mut total_input: u64 = 0;
let mut total_output: u64 = 0;
let mut total_passes: usize = 0;
let mut total_breaches: usize = 0;
let mut total_steps: usize = 0;
let mut total_errors: usize = 0;
let mut total_retries: usize = 0;
let mut breach_counts: HashMap<String, usize> = HashMap::new();
let mut step_counts: HashMap<String, usize> = HashMap::new();
for trace in traces {
for unit in &trace.units {
durations.push(unit.duration_ms);
total_input += unit.total_input_tokens;
total_output += unit.total_output_tokens;
for step in &unit.steps {
total_steps += 1;
*step_counts.entry(step.name.clone()).or_insert(0) += 1;
if !step.success {
total_errors += 1;
}
if step.was_retried {
total_retries += 1;
}
for anchor in &step.anchor_results {
if anchor.passed {
total_passes += 1;
} else {
total_breaches += 1;
*breach_counts.entry(anchor.anchor_name.clone()).or_insert(0) += 1;
}
}
}
}
}
let latency = compute_latency(&durations);
let unit_count = durations.len();
let tokens = TokenStats {
total_input,
total_output,
total: total_input + total_output,
mean_input_per_unit: if unit_count > 0 { total_input / unit_count as u64 } else { 0 },
mean_output_per_unit: if unit_count > 0 { total_output / unit_count as u64 } else { 0 },
mean_total_per_unit: if unit_count > 0 { (total_input + total_output) / unit_count as u64 } else { 0 },
unit_count,
};
let total_checks = total_passes + total_breaches;
let anchors = AnchorStats {
total_checks,
total_passes,
total_breaches,
pass_rate: if total_checks > 0 { total_passes as f64 / total_checks as f64 } else { 1.0 },
breach_rate: if total_checks > 0 { total_breaches as f64 / total_checks as f64 } else { 0.0 },
top_breaches: top_breaches(&breach_counts, 10),
};
let errors = ErrorStats {
total_steps,
total_errors,
total_retries,
error_rate: if total_steps > 0 { total_errors as f64 / total_steps as f64 } else { 0.0 },
retry_rate: if total_steps > 0 { total_retries as f64 / total_steps as f64 } else { 0.0 },
};
let steps = compute_step_frequency(&step_counts, 10);
TraceAnalytics {
trace_count: traces.len(),
latency,
tokens,
anchors,
errors,
steps,
}
}
fn compute_latency(durations: &[u64]) -> LatencyStats {
if durations.is_empty() {
return LatencyStats {
unit_count: 0,
p50_ms: 0,
p95_ms: 0,
p99_ms: 0,
mean_ms: 0,
min_ms: 0,
max_ms: 0,
};
}
let mut sorted = durations.to_vec();
sorted.sort();
let n = sorted.len();
LatencyStats {
unit_count: n,
p50_ms: percentile(&sorted, 50.0),
p95_ms: percentile(&sorted, 95.0),
p99_ms: percentile(&sorted, 99.0),
mean_ms: sorted.iter().sum::<u64>() / n as u64,
min_ms: sorted[0],
max_ms: sorted[n - 1],
}
}
fn percentile(sorted: &[u64], pct: f64) -> u64 {
if sorted.is_empty() {
return 0;
}
let rank = (pct / 100.0 * sorted.len() as f64).ceil() as usize;
let idx = rank.min(sorted.len()).saturating_sub(1);
sorted[idx]
}
fn top_breaches(counts: &HashMap<String, usize>, limit: usize) -> Vec<AnchorBreachEntry> {
let mut entries: Vec<AnchorBreachEntry> = counts
.iter()
.map(|(name, &count)| AnchorBreachEntry {
anchor_name: name.clone(),
breach_count: count,
})
.collect();
entries.sort_by(|a, b| b.breach_count.cmp(&a.breach_count));
entries.truncate(limit);
entries
}
fn compute_step_frequency(counts: &HashMap<String, usize>, limit: usize) -> StepFrequency {
let mut entries: Vec<StepFreqEntry> = counts
.iter()
.map(|(name, &count)| StepFreqEntry {
step_name: name.clone(),
count,
})
.collect();
entries.sort_by(|a, b| b.count.cmp(&a.count));
let unique_steps = entries.len();
entries.truncate(limit);
StepFrequency {
unique_steps,
top_steps: entries,
}
}
pub fn run_stats(files: &[String], format: &str) -> i32 {
if files.is_empty() {
eprintln!("error: no trace files provided");
return 2;
}
let mut traces: Vec<replay::ReplayTrace> = Vec::new();
let mut errors = 0;
for path in files {
match std::fs::read_to_string(path) {
Ok(content) => {
match serde_json::from_str::<serde_json::Value>(&content) {
Ok(data) => {
traces.push(replay::parse_trace(&data));
}
Err(e) => {
eprintln!("error: failed to parse {}: {}", path, e);
errors += 1;
}
}
}
Err(e) => {
eprintln!("error: failed to read {}: {}", path, e);
errors += 1;
}
}
}
if traces.is_empty() {
eprintln!("error: no valid traces loaded ({} errors)", errors);
return 2;
}
let analytics = compute_analytics(&traces);
match format {
"json" => println!("{}", serde_json::to_string_pretty(&analytics).unwrap()),
"prometheus" => print!("{}", crate::trace_export::to_prometheus(&analytics)),
"csv" => print!("{}", crate::trace_export::to_csv(&analytics)),
_ => print_analytics(&analytics, errors),
}
0
}
fn print_analytics(a: &TraceAnalytics, load_errors: usize) {
let use_color = std::io::stdout().is_terminal();
let bold = if use_color { "\x1b[1m" } else { "" };
let cyan = if use_color { "\x1b[36m" } else { "" };
let yellow = if use_color { "\x1b[33m" } else { "" };
let red = if use_color { "\x1b[31m" } else { "" };
let green = if use_color { "\x1b[32m" } else { "" };
let reset = if use_color { "\x1b[0m" } else { "" };
println!("{}═══ AXON Trace Analytics ═══{}", bold, reset);
println!();
println!("{}Traces:{} {}", cyan, reset, a.trace_count);
if load_errors > 0 {
println!("{}Load errors:{} {}", red, reset, load_errors);
}
println!("{}Units:{} {}", cyan, reset, a.latency.unit_count);
println!("{}Steps:{} {}", cyan, reset, a.errors.total_steps);
println!();
println!("{}── Latency ──{}", bold, reset);
if a.latency.unit_count > 0 {
println!(" p50: {} ms", a.latency.p50_ms);
println!(" p95: {} ms", a.latency.p95_ms);
println!(" p99: {} ms", a.latency.p99_ms);
println!(" mean: {} ms", a.latency.mean_ms);
println!(" min: {} ms", a.latency.min_ms);
println!(" max: {} ms", a.latency.max_ms);
} else {
println!(" (no latency data)");
}
println!();
println!("{}── Tokens ──{}", bold, reset);
println!(" total input: {}", a.tokens.total_input);
println!(" total output: {}", a.tokens.total_output);
println!(" total: {}", a.tokens.total);
if a.tokens.unit_count > 0 {
println!(" mean/unit: {} in + {} out", a.tokens.mean_input_per_unit, a.tokens.mean_output_per_unit);
}
println!();
println!("{}── Anchors ──{}", bold, reset);
if a.anchors.total_checks > 0 {
println!(" checks: {}", a.anchors.total_checks);
println!(" {}passes: {}{} ({:.1}%)", green, a.anchors.total_passes, reset, a.anchors.pass_rate * 100.0);
println!(" {}breaches: {}{} ({:.1}%)", red, a.anchors.total_breaches, reset, a.anchors.breach_rate * 100.0);
if !a.anchors.top_breaches.is_empty() {
println!(" top breaches:");
for b in &a.anchors.top_breaches {
println!(" {}× {}{}{}", b.breach_count, yellow, b.anchor_name, reset);
}
}
} else {
println!(" (no anchor data)");
}
println!();
println!("{}── Errors ──{}", bold, reset);
println!(" errors: {} / {} steps ({:.1}%)", a.errors.total_errors, a.errors.total_steps, a.errors.error_rate * 100.0);
println!(" retries: {} ({:.1}%)", a.errors.total_retries, a.errors.retry_rate * 100.0);
println!();
println!("{}── Step Frequency ──{}", bold, reset);
println!(" unique steps: {}", a.steps.unique_steps);
if !a.steps.top_steps.is_empty() {
for s in &a.steps.top_steps {
println!(" {}× {}", s.count, s.step_name);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::replay::{ReplayTrace, TraceMeta, ReplayUnit, ReplayStep, AnchorEvent, ReplaySummary};
fn make_meta() -> TraceMeta {
TraceMeta {
source: "test.axon".into(),
backend: "anthropic".into(),
tool_mode: "stub".into(),
axon_version: "1.0.0".into(),
mode: "stub".into(),
}
}
fn make_step(name: &str, success: bool, retried: bool, anchors: Vec<AnchorEvent>) -> ReplayStep {
ReplayStep {
name: name.into(),
event_type: "step_complete".into(),
output: format!("{} output", name),
success,
anchor_results: anchors,
was_retried: retried,
}
}
fn make_unit(flow: &str, duration_ms: u64, input_tokens: u64, output_tokens: u64, steps: Vec<ReplayStep>) -> ReplayUnit {
ReplayUnit {
flow_name: flow.into(),
steps,
duration_ms,
total_input_tokens: input_tokens,
total_output_tokens: output_tokens,
anchor_breaches: 0,
}
}
fn make_trace(units: Vec<ReplayUnit>) -> ReplayTrace {
let total_steps = units.iter().map(|u| u.steps.len()).sum();
let total_input: u64 = units.iter().map(|u| u.total_input_tokens).sum();
let total_output: u64 = units.iter().map(|u| u.total_output_tokens).sum();
ReplayTrace {
meta: make_meta(),
units,
summary: ReplaySummary {
total_units: 0,
total_steps,
total_anchor_passes: 0,
total_anchor_breaches: 0,
total_retries: 0,
total_errors: 0,
total_input_tokens: total_input,
total_output_tokens: total_output,
},
}
}
fn anchor(name: &str, passed: bool) -> AnchorEvent {
AnchorEvent { anchor_name: name.into(), passed, detail: String::new() }
}
#[test]
fn percentile_basic() {
let data: Vec<u64> = (1..=10).map(|x| x * 10).collect();
assert_eq!(percentile(&data, 50.0), 50);
assert_eq!(percentile(&data, 95.0), 100);
assert_eq!(percentile(&data, 99.0), 100);
assert_eq!(percentile(&data, 0.0), 10); }
#[test]
fn percentile_single_value() {
assert_eq!(percentile(&[42], 50.0), 42);
assert_eq!(percentile(&[42], 99.0), 42);
}
#[test]
fn percentile_empty() {
assert_eq!(percentile(&[], 50.0), 0);
}
#[test]
fn latency_stats_computed() {
let t1 = make_trace(vec![
make_unit("F", 100, 0, 0, vec![make_step("S1", true, false, vec![])]),
make_unit("F", 200, 0, 0, vec![make_step("S2", true, false, vec![])]),
]);
let t2 = make_trace(vec![
make_unit("F", 150, 0, 0, vec![make_step("S1", true, false, vec![])]),
]);
let a = compute_analytics(&[t1, t2]);
assert_eq!(a.latency.unit_count, 3);
assert_eq!(a.latency.min_ms, 100);
assert_eq!(a.latency.max_ms, 200);
assert_eq!(a.latency.mean_ms, 150); }
#[test]
fn token_stats_aggregated() {
let t = make_trace(vec![
make_unit("F1", 0, 100, 50, vec![make_step("S", true, false, vec![])]),
make_unit("F2", 0, 200, 80, vec![make_step("S", true, false, vec![])]),
]);
let a = compute_analytics(&[t]);
assert_eq!(a.tokens.total_input, 300);
assert_eq!(a.tokens.total_output, 130);
assert_eq!(a.tokens.total, 430);
assert_eq!(a.tokens.mean_input_per_unit, 150);
assert_eq!(a.tokens.mean_output_per_unit, 65);
assert_eq!(a.tokens.unit_count, 2);
}
#[test]
fn anchor_stats_computed() {
let t = make_trace(vec![
make_unit("F", 0, 0, 0, vec![
make_step("S1", true, false, vec![
anchor("SafeOutput", true),
anchor("NoHallucination", false),
]),
make_step("S2", true, false, vec![
anchor("SafeOutput", true),
anchor("NoHallucination", false),
anchor("FactualOnly", false),
]),
]),
]);
let a = compute_analytics(&[t]);
assert_eq!(a.anchors.total_checks, 5);
assert_eq!(a.anchors.total_passes, 2);
assert_eq!(a.anchors.total_breaches, 3);
assert!((a.anchors.pass_rate - 0.4).abs() < 0.01);
assert!((a.anchors.breach_rate - 0.6).abs() < 0.01);
assert_eq!(a.anchors.top_breaches.len(), 2);
assert_eq!(a.anchors.top_breaches[0].anchor_name, "NoHallucination");
assert_eq!(a.anchors.top_breaches[0].breach_count, 2);
assert_eq!(a.anchors.top_breaches[1].anchor_name, "FactualOnly");
assert_eq!(a.anchors.top_breaches[1].breach_count, 1);
}
#[test]
fn error_and_retry_stats() {
let t = make_trace(vec![
make_unit("F", 0, 0, 0, vec![
make_step("S1", true, false, vec![]),
make_step("S2", false, true, vec![]), make_step("S3", true, true, vec![]), make_step("S4", false, false, vec![]), ]),
]);
let a = compute_analytics(&[t]);
assert_eq!(a.errors.total_steps, 4);
assert_eq!(a.errors.total_errors, 2);
assert_eq!(a.errors.total_retries, 2);
assert!((a.errors.error_rate - 0.5).abs() < 0.01);
assert!((a.errors.retry_rate - 0.5).abs() < 0.01);
}
#[test]
fn step_frequency_distribution() {
let t = make_trace(vec![
make_unit("F1", 0, 0, 0, vec![
make_step("Analyze", true, false, vec![]),
make_step("Summarize", true, false, vec![]),
]),
make_unit("F2", 0, 0, 0, vec![
make_step("Analyze", true, false, vec![]),
make_step("Generate", true, false, vec![]),
make_step("Analyze", true, false, vec![]),
]),
]);
let a = compute_analytics(&[t]);
assert_eq!(a.steps.unique_steps, 3);
assert_eq!(a.steps.top_steps[0].step_name, "Analyze");
assert_eq!(a.steps.top_steps[0].count, 3);
}
#[test]
fn empty_traces() {
let a = compute_analytics(&[]);
assert_eq!(a.trace_count, 0);
assert_eq!(a.latency.unit_count, 0);
assert_eq!(a.latency.p50_ms, 0);
assert_eq!(a.tokens.total, 0);
assert!((a.anchors.pass_rate - 1.0).abs() < 0.01); assert!((a.anchors.breach_rate - 0.0).abs() < 0.01);
}
#[test]
fn multiple_traces_aggregate() {
let t1 = make_trace(vec![
make_unit("F", 100, 50, 20, vec![make_step("A", true, false, vec![])]),
]);
let t2 = make_trace(vec![
make_unit("F", 200, 70, 30, vec![make_step("B", true, false, vec![])]),
]);
let a = compute_analytics(&[t1, t2]);
assert_eq!(a.trace_count, 2);
assert_eq!(a.latency.unit_count, 2);
assert_eq!(a.tokens.total_input, 120);
assert_eq!(a.tokens.total_output, 50);
assert_eq!(a.errors.total_steps, 2);
assert_eq!(a.steps.unique_steps, 2);
}
#[test]
fn no_anchor_data_defaults() {
let t = make_trace(vec![
make_unit("F", 100, 0, 0, vec![make_step("S", true, false, vec![])]),
]);
let a = compute_analytics(&[t]);
assert_eq!(a.anchors.total_checks, 0);
assert!((a.anchors.pass_rate - 1.0).abs() < 0.01);
assert!(a.anchors.top_breaches.is_empty());
}
#[test]
fn analytics_serializes_to_json() {
let t = make_trace(vec![
make_unit("F", 100, 50, 20, vec![
make_step("S", true, false, vec![anchor("Safe", true)]),
]),
]);
let a = compute_analytics(&[t]);
let json = serde_json::to_value(&a).unwrap();
assert_eq!(json["trace_count"], 1);
assert!(json["latency"]["p50_ms"].is_number());
assert!(json["tokens"]["total"].is_number());
assert!(json["anchors"]["pass_rate"].is_number());
assert!(json["errors"]["error_rate"].is_number());
assert!(json["steps"]["unique_steps"].is_number());
}
#[test]
fn run_stats_no_files_returns_error() {
assert_eq!(run_stats(&[], "text"), 2);
}
#[test]
fn run_stats_missing_file_returns_error() {
let files = vec!["nonexistent_trace_file.json".to_string()];
assert_eq!(run_stats(&files, "text"), 2);
}
#[test]
fn run_stats_valid_trace_json() {
let tmp = std::env::temp_dir().join("axon_stats_test.trace.json");
let data = serde_json::json!({
"_meta": { "source": "t.axon", "backend": "anthropic", "tool_mode": "stub", "axon_version": "1.0.0", "mode": "stub" },
"events": [
{ "event": "unit_start", "unit": "F", "step": "", "detail": "" },
{ "event": "step_complete", "unit": "F", "step": "S", "detail": "ok" },
{ "event": "unit_complete", "unit": "F", "step": "", "detail": "" },
]
});
std::fs::write(&tmp, serde_json::to_string(&data).unwrap()).unwrap();
let files = vec![tmp.to_str().unwrap().to_string()];
assert_eq!(run_stats(&files, "json"), 0);
let _ = std::fs::remove_file(tmp);
}
}