//! stats: usage statistics computed from the event journal. use super::*; use crate::events::{self, EventKind}; use crate::output::{print_json, Table}; use anyhow::Context; use serde::Serialize; use std::collections::BTreeMap; #[derive(Debug, Clone, Default, Serialize)] pub struct AgentStats { pub agent: String, pub launches: u64, pub total_duration_s: u64, pub avg_duration_s: u64, pub failures: u64, pub installs: u64, pub updates: u64, pub last_used: Option, } /// Cutoff instant for a period: 7d / 30d / 90d, or None for everything. pub fn cutoff(period: Option<&str>) -> Result>> { let days = match period { None | Some("all") => return Ok(None), Some("7d") => 7, Some("30d") => 30, Some("90d") => 90, Some(other) => bail!("invalid period '{other}' — expected 7d, 30d, 90d or all"), }; Ok(Some(chrono::Utc::now() - chrono::Duration::days(days))) } /// Aggregate journal events into per-agent statistics. pub fn aggregate(app: &App, agent: Option<&str>, period: Option<&str>) -> Result> { let cut = cutoff(period)?; let events = events::read_events(&app.events_dir(), 0); let mut map: BTreeMap = BTreeMap::new(); let mut dur_count: BTreeMap = BTreeMap::new(); for ev in events { if let Some(c) = cut { let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&ev.ts) else { continue; }; if ts.with_timezone(&chrono::Utc) < c { continue; } } let Some(name) = ev.agent else { continue; }; if let Some(a) = agent { if name != a { continue; } } let s = map.entry(name.clone()).or_default(); s.agent = name.clone(); match ev.kind { EventKind::Start | EventKind::Run => s.launches += 1, EventKind::Install => s.installs += 1, EventKind::Update => s.updates += 1, _ => {} } if let Some(d) = ev.duration_s { s.total_duration_s += d; *dur_count.entry(name.clone()).or_default() += 1; } if let Some(code) = ev.exit_code { if code != 0 { s.failures += 1; } } s.last_used = Some(ev.ts); } for (name, s) in map.iter_mut() { let n = dur_count.get(name).copied().unwrap_or(0); s.avg_duration_s = if n > 0 { s.total_duration_s / n } else { 0 }; } Ok(map.into_values().collect()) } /// Top 10 agents by launch count (issue #37). pub fn top(app: &App, period: Option<&str>) -> Result> { let mut rows = aggregate(app, None, period)?; rows.sort_by(|a, b| b.launches.cmp(&a.launches).then(b.total_duration_s.cmp(&a.total_duration_s))); rows.truncate(10); Ok(rows) } /// Markdown weekly/monthly report over the last N days (issue #37). pub fn report_md(app: &App, days: u32) -> String { let cut = chrono::Utc::now() - chrono::Duration::days(days as i64); let events = events::read_events(&app.events_dir(), 0); let mut total_launches = 0u64; let mut failures = 0u64; let mut changes = 0u64; let mut duration = 0u64; for ev in events { let ok = chrono::DateTime::parse_from_rfc3339(&ev.ts) .map(|t| t.with_timezone(&chrono::Utc) >= cut) .unwrap_or(false); if !ok { continue; } match ev.kind { EventKind::Start | EventKind::Run => total_launches += 1, EventKind::Install | EventKind::Update | EventKind::Uninstall => changes += 1, _ => {} } if let Some(d) = ev.duration_s { duration += d; } if let Some(c) = ev.exit_code { if c != 0 { failures += 1; } } } let sessions = crate::sessions::load(app).map(|s| s.sessions.len()).unwrap_or(0); let top_rows = top(app, None).unwrap_or_default(); // Issue #74: the report follows the active language. let md = if app.lang().is_en() { format!( "# agent-manager report — last {days} days\n\n- Launches: {total_launches}\n- Failures: {failures}\n- Changes (install/update/uninstall): {changes}\n- Cumulative duration: {}s\n- Recorded sessions: {sessions}\n", duration ) } else { format!( "# Rapport agent-manager — {days} derniers jours\n\n- Lancements : {total_launches}\n- Échecs : {failures}\n- Changements (install/update/uninstall) : {changes}\n- Durée cumulée : {}s\n- Sessions enregistrées : {sessions}\n", duration ) }; let mut md = md; if !top_rows.is_empty() { md.push_str(if app.lang().is_en() { "\n## Top agents\n\n| Agent | Launches | Duration | Failures |\n|---|---|---|---|\n" } else { "\n## Top agents\n\n| Agent | Lancements | Durée | Échecs |\n|---|---|---|---|\n" }); for r in &top_rows { md.push_str(&format!( "| {} | {} | {}s | {} |\n", r.agent, r.launches, r.total_duration_s, r.failures )); } } // Issue #49: cost section when usage was recorded. if let Ok(rows) = crate::costs::aggregate(app, Some(&format!("{days}d"))) { if !rows.is_empty() { let t = crate::costs::totals(&rows); md.push_str(&format!( "{}\n\n- {}: {}\n- {}: {} in / {} out\n- {}: ${:.4}\n", if app.lang().is_en() { "\n## Estimated costs" } else { "\n## Coûts estimés" }, if app.lang().is_en() { "Billed sessions" } else { "Sessions facturées" }, t.sessions, if app.lang().is_en() { "Tokens" } else { "Tokens" }, t.tokens_in, t.tokens_out, if app.lang().is_en() { "Estimated cost" } else { "Coût estimé" }, t.cost_usd )); } } md } pub fn run(app: &App, agent: Option<&str>, period: Option<&str>, costs: bool) -> Result { if costs { return run_costs(app, period); } let rows = aggregate(app, agent, period)?; if app.json() { print_json(&rows); return Ok(0); } let color = app.color(); let theme = app.theme(); if rows.is_empty() { app.log.info("no usage recorded yet — run or start an agent first"); return Ok(0); } let max_launches = rows.iter().map(|r| r.launches).max().unwrap_or(1); let mut table = Table::new(vec!["AGENT", "LAUNCHES", "DURATION", "AVG", "FAIL", "LAST"]); for r in &rows { table.row(vec![ r.agent.clone(), format!("{} {}", r.launches, bar(r.launches, max_launches, 8)), format_duration(r.total_duration_s), format_duration(r.avg_duration_s), if r.failures > 0 { crate::output::status_style("error", theme, color) } else { r.failures.to_string() }, r.last_used .as_deref() .map(|t| t[..t.len().min(10)].to_string()) .unwrap_or_else(|| "—".to_string()), ]); } print!("{}", table.render()); Ok(0) } /// am top : the most used agents (issue #37). pub fn run_top(app: &App, period: Option<&str>) -> Result { let rows = top(app, period)?; if app.json() { print_json(&rows); return Ok(0); } let color = app.color(); let theme = app.theme(); if rows.is_empty() { app.log.info("no usage recorded yet"); return Ok(0); } let max_launches = rows.iter().map(|r| r.launches).max().unwrap_or(1); let mut table = Table::new(vec!["AGENT", "LAUNCHES", "DURATION", "FAIL"]); for r in &rows { table.row(vec![ r.agent.clone(), format!("{} {}", r.launches, bar(r.launches, max_launches, 8)), format_duration(r.total_duration_s), if r.failures > 0 { crate::output::status_style("error", theme, color) } else { r.failures.to_string() }, ]); } print!("{}", table.render()); Ok(0) } /// am stats --costs : token usage and estimated spend (issue #49). pub fn run_costs(app: &App, period: Option<&str>) -> Result { let rows = crate::costs::aggregate(app, period)?; if app.json() { print_json(&rows); return Ok(0); } if rows.is_empty() { app.log .info("no cost recorded yet — agents exposing their usage (claude-code) are priced at session end"); return Ok(0); } let total = crate::costs::totals(&rows); let mut table = Table::new(vec!["AGENT", "SESSIONS", "TOKENS IN", "TOKENS OUT", "COST $"]); for r in &rows { table.row(vec![ r.agent.clone(), r.sessions.to_string(), r.tokens_in.to_string(), r.tokens_out.to_string(), format!("{:.4}", r.cost_usd), ]); } table.row(vec![ "TOTAL".to_string(), total.sessions.to_string(), total.tokens_in.to_string(), total.tokens_out.to_string(), format!("{:.4}", total.cost_usd), ]); print!("{}", table.render()); Ok(0) } /// am report : write the markdown digest (issue #37). pub fn run_report( app: &App, last_week: bool, last_month: bool, output: Option<&std::path::Path>, ) -> Result { let days: u32 = if last_week { 7 } else { 30 }; let md = report_md(app, days); let path = match output { Some(p) => p.to_path_buf(), None => { let stamp = chrono::Local::now().format("%Y%m%d"); app.events_dir().join(format!("report-{stamp}.md")) } }; if app.dry_run() { app.log.dry(format!("would write {}", path.display())); return Ok(0); } if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } std::fs::write(&path, md) .with_context(|| format!("cannot write {}", path.display()))?; app.log.success(&format!("report written to {}", path.display())); Ok(0) } /// ASCII bar: relative weight of v against max. fn bar(v: u64, max: u64, width: usize) -> String { if max == 0 { return String::new(); } let n = ((v * width as u64) / max).max(if v > 0 { 1 } else { 0 }) as usize; format!("{}{}", "█".repeat(n), "░".repeat(width.saturating_sub(n))) } fn format_duration(secs: u64) -> String { if secs == 0 { return "—".to_string(); } if secs < 60 { format!("{secs}s") } else if secs < 3600 { format!("{}m{:02}s", secs / 60, secs % 60) } else { format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60) } } #[cfg(test)] mod tests { use super::*; use clap::Parser; fn app_with_events(events_jsonl: &str) -> crate::app::App { // The TempDir guard would delete the directory when this helper // returns; leak it on purpose so the App keeps a live events dir. let guard = tempfile::tempdir().unwrap(); let dir = guard.path().to_path_buf(); std::mem::forget(guard); let cfg = dir.join("config.yaml"); std::fs::write( &cfg, concat!( "version: \"1.0\"\n", "settings:\n", " auto_install_deps: false\n", "agents: []\n", ), ) .unwrap(); let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); let mut app = crate::app::App::from_cli(cli).unwrap(); let mut p = app.paths.clone(); p.install_dir = dir.join("agents"); p.bin_dir = dir.join("agents").join("bin"); p.log_dir = dir.join("logs"); p.state_file = dir.join("state.json"); p.probe_cache_file = dir.join("probe-cache.json"); p.config_dir = Some(dir.join("config")); app.paths = p; std::fs::write(dir.join("events-test.jsonl"), events_jsonl).unwrap(); // Rename into the right month file so read_events picks it up. let month = chrono::Utc::now().format("%Y%m"); std::fs::rename( dir.join("events-test.jsonl"), dir.join(format!("events-{month}.jsonl")), ) .unwrap(); app } #[test] fn aggregates_counts_durations_and_failures() { let lines = [ r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":10}"#, r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"a","exit_code":0,"duration_s":30}"#, r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"b","exit_code":1,"duration_s":5}"#, r#"{"ts":"2026-08-03T11:00:00Z","kind":"install","agent":"a"}"#, ]; let app = app_with_events(&lines.join("\n")); let rows = aggregate(&app, None, None).unwrap(); let a = rows.iter().find(|r| r.agent == "a").unwrap(); assert_eq!(a.launches, 2); assert_eq!(a.total_duration_s, 40); assert_eq!(a.avg_duration_s, 20); assert_eq!(a.failures, 0); assert_eq!(a.installs, 1); let b = rows.iter().find(|r| r.agent == "b").unwrap(); assert_eq!(b.failures, 1); assert_eq!(b.launches, 1); } #[test] fn period_cuts_old_events() { let app = app_with_events( r#"{"ts":"2020-01-01T10:00:00Z","kind":"run","agent":"old","exit_code":0}"#, ); assert!(aggregate(&app, None, Some("30d")).unwrap().is_empty()); assert_eq!(aggregate(&app, None, None).unwrap().len(), 1); assert!(cutoff(Some("bad")).is_err()); } }