stats + log : observabilité du journal (issue #6) #21

Merged
bruno merged 1 commits from feat/stats-log into main 2026-08-16 22:30:55 -04:00
8 changed files with 534 additions and 1 deletions
+22
View File
@@ -112,6 +112,28 @@ pub enum Command {
#[arg(long, value_name = "ID")] #[arg(long, value_name = "ID")]
show: Option<String>, show: Option<String>,
}, },
/// Show usage statistics computed from the event journal
Stats {
/// Agent name or alias (omit for the global view)
agent: Option<String>,
/// Only events of the last period (7d, 30d, 90d, all)
#[arg(long, value_name = "PERIOD")]
period: Option<String>,
},
/// Read the event journal: everything am did
Log {
/// Only events of this agent
agent: Option<String>,
/// Only these event kinds (comma separated: start,stop,run,install,...)
#[arg(long, value_name = "KINDS")]
kind: Option<String>,
/// Only events at or after this date (YYYY-MM-DD or RFC 3339)
#[arg(long, value_name = "DATE")]
since: Option<String>,
/// Maximum number of events (default: 50)
#[arg(long, value_name = "N", default_value = "50")]
limit: usize,
},
/// Install an agent and its dependencies /// Install an agent and its dependencies
Install { Install {
/// Agent name or alias /// Agent name or alias
+120
View File
@@ -0,0 +1,120 @@
//! log: a filtered view of the event journal — everything am did.
use super::*;
use crate::events::{self, Event};
use crate::output::print_json;
use serde::Serialize;
/// Filter journal events: agent, kind list, since date, and a max count.
/// Returns events in reverse chronological order (most recent first).
pub fn filter_events(
app: &App,
agent: Option<&str>,
kinds: Option<&str>,
since: Option<&str>,
limit: usize,
) -> Result<Vec<Event>> {
let mut events = events::read_events(&app.events_dir(), 0);
if let Some(a) = agent {
events.retain(|e| e.agent.as_deref() == Some(a));
}
if let Some(list) = kinds {
let wanted: Vec<&str> = list.split(',').map(|s| s.trim()).collect();
events.retain(|e| wanted.iter().any(|w| e.kind.as_str() == *w));
}
if let Some(s) = since {
let cut = parse_since(s)?;
events.retain(|e| {
chrono::DateTime::parse_from_rfc3339(&e.ts)
.map(|t| t >= cut)
.unwrap_or(false)
});
}
events.reverse(); // most recent first
events.truncate(limit);
Ok(events)
}
fn parse_since(s: &str) -> Result<chrono::DateTime<chrono::FixedOffset>> {
if let Ok(t) = chrono::DateTime::parse_from_rfc3339(s) {
return Ok(t);
}
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
.map_err(|_| anyhow!("invalid date '{s}' — expected YYYY-MM-DD or an RFC 3339 timestamp"))?
.and_hms_opt(0, 0, 0)
.map(|d| chrono::DateTime::from_naive_utc_and_offset(d, chrono::FixedOffset::east_opt(0).unwrap()))
.ok_or_else(|| anyhow!("invalid date '{s}'"))
}
#[derive(Serialize)]
struct LogRow {
ts: String,
kind: String,
agent: Option<String>,
pid: Option<u32>,
exit_code: Option<i32>,
duration_s: Option<u64>,
session: Option<String>,
}
impl From<&Event> for LogRow {
fn from(e: &Event) -> Self {
LogRow {
ts: e.ts.clone(),
kind: e.kind.as_str().to_string(),
agent: e.agent.clone(),
pid: e.pid,
exit_code: e.exit_code,
duration_s: e.duration_s,
session: e.session.clone(),
}
}
}
pub fn run(
app: &App,
agent: Option<&str>,
kinds: Option<&str>,
since: Option<&str>,
limit: usize,
) -> Result<i32> {
let events = filter_events(app, agent, kinds, since, limit)?;
if app.json() {
let rows: Vec<LogRow> = events.iter().map(LogRow::from).collect();
print_json(&rows);
return Ok(0);
}
for e in &events {
let mut parts = vec![e.ts.clone(), e.kind.as_str().to_string()];
if let Some(a) = &e.agent {
parts.push(a.clone());
}
if let Some(p) = e.pid {
parts.push(format!("pid={p}"));
}
if let Some(c) = e.exit_code {
parts.push(format!("exit={c}"));
}
if let Some(d) = e.duration_s {
parts.push(format!("dur={d}s"));
}
println!("{}", parts.join(" "));
}
if events.is_empty() {
app.log.info("no matching events in the journal");
}
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_since_accepts_date_and_timestamp() {
let d = parse_since("2026-08-01").unwrap();
assert_eq!(d.format("%Y-%m-%d").to_string(), "2026-08-01");
assert!(parse_since("2026-08-01T00:00:00Z").is_ok());
assert!(parse_since("hier").is_err());
}
}
+9
View File
@@ -8,10 +8,12 @@ pub mod help_cmd;
pub mod info_cmd; pub mod info_cmd;
pub mod install_cmd; pub mod install_cmd;
pub mod list_cmd; pub mod list_cmd;
pub mod log_cmd;
pub mod run_cmd; pub mod run_cmd;
pub mod search_cmd; pub mod search_cmd;
pub mod self_uninstall; pub mod self_uninstall;
pub mod sessions_cmd; pub mod sessions_cmd;
pub mod stats_cmd;
pub mod self_update; pub mod self_update;
pub mod status_cmd; pub mod status_cmd;
pub mod uninstall_cmd; pub mod uninstall_cmd;
@@ -49,6 +51,13 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
timeout, timeout,
} => run_cmd::restart(app, start, *force, *timeout), } => run_cmd::restart(app, start, *force, *timeout),
Command::Status { agent } => status_cmd::run(app, agent.as_deref()), Command::Status { agent } => status_cmd::run(app, agent.as_deref()),
Command::Stats { agent, period } => stats_cmd::run(app, agent.as_deref(), period.as_deref()),
Command::Log {
agent,
kind,
since,
limit,
} => log_cmd::run(app, agent.as_deref(), kind.as_deref(), since.as_deref(), *limit),
Command::Sessions { Command::Sessions {
agent, agent,
project, project,
+212
View File
@@ -0,0 +1,212 @@
//! stats: usage statistics computed from the event journal.
use super::*;
use crate::events::{self, EventKind};
use crate::output::{print_json, Table};
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<String>,
}
/// Cutoff instant for a period: 7d / 30d / 90d, or None for everything.
pub fn cutoff(period: Option<&str>) -> Result<Option<chrono::DateTime<chrono::Utc>>> {
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<Vec<AgentStats>> {
let cut = cutoff(period)?;
let events = events::read_events(&app.events_dir(), 0);
let mut map: BTreeMap<String, AgentStats> = BTreeMap::new();
let mut dur_count: BTreeMap<String, u64> = 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())
}
pub fn run(app: &App, agent: Option<&str>, period: Option<&str>) -> Result<i32> {
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)
}
/// 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());
}
}
+18
View File
@@ -32,6 +32,24 @@ pub enum EventKind {
Shell, Shell,
} }
impl EventKind {
pub fn as_str(&self) -> &'static str {
match self {
EventKind::Start => "start",
EventKind::Stop => "stop",
EventKind::Run => "run",
EventKind::Restart => "restart",
EventKind::Install => "install",
EventKind::Update => "update",
EventKind::Uninstall => "uninstall",
EventKind::Doctor => "doctor",
EventKind::Config => "config",
EventKind::Repl => "repl",
EventKind::Shell => "shell",
}
}
}
/// One journal entry. Optional fields are omitted from the JSON line. /// One journal entry. Optional fields are omitted from the JSON line.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Event { pub struct Event {
+40
View File
@@ -198,6 +198,46 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "Show one session with its log excerpt.", code: "sessions --show 20260815_143926_a1b2c3" }, HelpExample { desc: "Show one session with its log excerpt.", code: "sessions --show 20260815_143926_a1b2c3" },
], ],
}, },
HelpSpec {
name: "stats",
category: "Commands",
usage: "stats {flags} [agent]",
about: "Show usage statistics computed from the event journal.",
search_terms: &["usage", "metrics", "duration"],
flags: &[
HelpFlag { short: "", long: "--period", value: "PERIOD", desc: "Only events of the last period (7d, 30d, 90d, all)" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias (omit for the global view)" },
],
io: None,
examples: &[
HelpExample { desc: "Global usage statistics.", code: "stats" },
HelpExample { desc: "One agent over the last month.", code: "stats claude-code --period 30d" },
],
},
HelpSpec {
name: "log",
category: "Commands",
usage: "log {flags} [agent]",
about: "Read the event journal: everything am did.",
search_terms: &["journal", "events", "history"],
flags: &[
HelpFlag { short: "", long: "--kind", value: "KINDS", desc: "Only these event kinds (comma separated)" },
HelpFlag { short: "", long: "--since", value: "DATE", desc: "Only events at or after this date (YYYY-MM-DD or RFC 3339)" },
HelpFlag { short: "", long: "--limit", value: "N", desc: "Maximum number of events (default: 50)" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Only events of this agent" },
],
io: None,
examples: &[
HelpExample { desc: "The last 50 events.", code: "log" },
HelpExample { desc: "Starts of one agent since a date.", code: "log claude-code --kind start --since 2026-08-01" },
],
},
HelpSpec { HelpSpec {
name: "search", name: "search",
category: "Commands", category: "Commands",
+3 -1
View File
@@ -61,6 +61,8 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("list", "list installed agents"), ("list", "list installed agents"),
("status", "show agent state"), ("status", "show agent state"),
("sessions", "list agent sessions"), ("sessions", "list agent sessions"),
("stats", "usage statistics"),
("log", "read the event journal"),
("search", "search the catalog"), ("search", "search the catalog"),
("info", "agent details"), ("info", "agent details"),
("install", "install an agent"), ("install", "install an agent"),
@@ -137,7 +139,7 @@ impl AmCompleter {
Self { Self {
theme: std::cell::Cell::new(crate::theme::default_theme()), theme: std::cell::Cell::new(crate::theme::default_theme()),
commands: vec![ commands: vec![
"list", "status", "sessions", "search", "info", "install", "uninstall", "update", "list", "status", "sessions", "stats", "log", "search", "info", "install", "uninstall", "update",
"start", "stop", "restart", "run", "doctor", "config", "completion", "start", "stop", "restart", "run", "doctor", "config", "completion",
"self-update", "self-uninstall", "export", "import", "shell", "theme", "self-update", "self-uninstall", "export", "import", "shell", "theme",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
+110
View File
@@ -0,0 +1,110 @@
//! stats + log: aggregation and filtering over the event journal.
mod common;
use agent_manager::app::App;
use agent_manager::commands::log_cmd;
use agent_manager::commands::stats_cmd;
use agent_manager::events;
fn app_with_events(tag: &str, lines: &[&str]) -> App {
let app = common::test_app(tag, &["stats"]);
let month = chrono::Utc::now().format("%Y%m").to_string();
let path = app.events_dir().join(format!("events-{month}.jsonl"));
let mut text = String::new();
for l in lines {
text.push_str(l);
text.push('\n');
}
std::fs::create_dir_all(app.events_dir()).unwrap();
std::fs::write(&path, text).unwrap();
app
}
#[test]
fn stats_aggregate_counts_everything() {
let app = app_with_events(
"agg",
&[
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"}"#,
r#"{"ts":"2026-08-03T12:00:00Z","kind":"update","agent":"a"}"#,
],
);
let rows = stats_cmd::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);
assert_eq!(a.updates, 1);
let b = rows.iter().find(|r| r.agent == "b").unwrap();
assert_eq!(b.failures, 1);
assert_eq!(b.launches, 1);
}
#[test]
fn stats_period_cuts_old_events() {
let app = app_with_events(
"period",
&[r#"{"ts":"2020-01-01T10:00:00Z","kind":"run","agent":"old","exit_code":0}"#],
);
assert!(stats_cmd::aggregate(&app, None, Some("30d")).unwrap().is_empty());
assert_eq!(stats_cmd::aggregate(&app, None, None).unwrap().len(), 1);
assert!(stats_cmd::aggregate(&app, None, Some("7d")).unwrap().is_empty());
}
#[test]
fn log_filters_by_agent_kind_and_limit() {
let app = app_with_events(
"logf",
&[
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0}"#,
r#"{"ts":"2026-08-01T10:00:01Z","kind":"start","agent":"a","pid":42}"#,
r#"{"ts":"2026-08-01T10:00:02Z","kind":"run","agent":"b","exit_code":1}"#,
],
);
let all = log_cmd::filter_events(&app, None, None, None, 50).unwrap();
assert_eq!(all.len(), 3);
assert_eq!(all[0].agent.as_deref(), Some("b"), "most recent first");
let only_a = log_cmd::filter_events(&app, Some("a"), None, None, 50).unwrap();
assert_eq!(only_a.len(), 2);
let starts = log_cmd::filter_events(&app, None, Some("start"), None, 50).unwrap();
assert_eq!(starts.len(), 1);
assert_eq!(starts[0].pid, Some(42));
let limited = log_cmd::filter_events(&app, None, None, None, 1).unwrap();
assert_eq!(limited.len(), 1);
let since = log_cmd::filter_events(&app, None, None, Some("2026-09-01"), 50).unwrap();
assert!(since.is_empty());
}
#[test]
fn stats_command_runs() {
let app = app_with_events(
"cmd",
&[r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"a","exit_code":0}"#],
);
let code = stats_cmd::run(&app, None, None).unwrap();
assert_eq!(code, 0);
}
#[test]
fn journal_reads_back_through_events_api() {
let app = app_with_events("api", &[]);
let mut log = events::EventLog::new(app.events_dir());
log.append(
&events::Event::now(events::EventKind::Run)
.with_agent("x")
.with_exit_code(0),
)
.unwrap();
let all = events::read_events(&app.events_dir(), 0);
assert_eq!(all.len(), 1);
}