stats + log : observabilité du journal (issue #6) #21
+22
@@ -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
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user