Skip to main content

kaizen/shell/
metrics.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! `kaizen metrics` command.
3
4use crate::core::config;
5use crate::core::data_source::DataSource;
6use crate::metrics::{index, report};
7use crate::shell::cli::{maybe_refresh_store, open_workspace_read_store, workspace_path};
8use crate::shell::remote_pull::maybe_telemetry_pull;
9use crate::shell::scope;
10use crate::store::Store;
11use crate::sync::{ingest_ctx, smart};
12use anyhow::Result;
13use std::path::Path;
14
15/// Same output as `kaizen metrics` (human or pretty JSON when `json_out`).
16pub fn metrics_text(
17    workspace: Option<&Path>,
18    days: u32,
19    json_out: bool,
20    force: bool,
21    all_workspaces: bool,
22    refresh: bool,
23    source: DataSource,
24) -> Result<String> {
25    let roots = scope::resolve(workspace, all_workspaces)?;
26    let mut reports = Vec::new();
27    for workspace in &roots {
28        let cfg = config::load(workspace)?;
29        let store = open_workspace_read_store(workspace, refresh || source != DataSource::Local)?;
30        maybe_telemetry_pull(workspace, &store, &cfg, source, refresh)?;
31        maybe_refresh_store(workspace, &store, refresh)?;
32        if force {
33            let snapshot = index::ensure_indexed(&store, workspace, true)?;
34            maybe_enqueue_snapshot(&store, &cfg, workspace, &snapshot)?;
35        }
36        let ws_str = workspace.to_string_lossy().to_string();
37        if let Ok(mut report) = report::build_report(&store, &ws_str, days) {
38            if source != DataSource::Local
39                && let Ok(Some(agg)) =
40                    crate::shell::remote_observe::try_remote_event_agg(&store, &cfg, workspace)
41            {
42                report =
43                    crate::shell::remote_observe::apply_remote_to_metrics(report, &agg, source);
44            }
45            reports.push(if roots.len() == 1 {
46                report
47            } else {
48                decorate_metrics(workspace, report)
49            });
50        }
51    }
52    let metrics = merge_metrics(reports);
53    if json_out {
54        return Ok(serde_json::to_string_pretty(&metrics)?);
55    }
56    Ok(format_human(&metrics))
57}
58
59pub fn cmd_metrics(
60    workspace: Option<&Path>,
61    days: u32,
62    json_out: bool,
63    force: bool,
64    all_workspaces: bool,
65    refresh: bool,
66    source: DataSource,
67) -> Result<()> {
68    print!(
69        "{}",
70        metrics_text(
71            workspace,
72            days,
73            json_out,
74            force,
75            all_workspaces,
76            refresh,
77            source
78        )?
79    );
80    Ok(())
81}
82
83/// Same output as `kaizen metrics index`.
84pub fn metrics_index_text(workspace: Option<&Path>, force: bool) -> Result<String> {
85    let ws = workspace_path(workspace)?;
86    let cfg = config::load(&ws)?;
87    let db_path = crate::core::workspace::db_path(&ws)?;
88    let store = Store::open(&db_path)?;
89    let snapshot = index::ensure_indexed(&store, &ws, force)?;
90    maybe_enqueue_snapshot(&store, &cfg, &ws, &snapshot)?;
91    use std::fmt::Write;
92    let mut s = String::new();
93    writeln!(&mut s, "snapshot: {}", snapshot.id).unwrap();
94    writeln!(&mut s, "graph:    {}", snapshot.graph_path).unwrap();
95    Ok(s)
96}
97
98pub fn cmd_metrics_index(workspace: Option<&Path>, force: bool) -> Result<()> {
99    print!("{}", metrics_index_text(workspace, force)?);
100    Ok(())
101}
102
103pub fn metrics_quality_text(workspace: Option<&Path>, days: u32, json: bool) -> Result<String> {
104    let ws = workspace_path(workspace)?;
105    let store = open_workspace_read_store(&ws, false)?;
106    let end = now_ms();
107    let start = end.saturating_sub(days as u64 * 86_400_000);
108    let ws_str = ws.to_string_lossy().to_string();
109    let report = crate::metrics::quality::build_quality_report(&store, &ws_str, start, end)?;
110    if json {
111        return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?));
112    }
113    Ok(format_quality(&report))
114}
115
116pub fn cmd_metrics_quality(workspace: Option<&Path>, days: u32, json: bool) -> Result<()> {
117    print!("{}", metrics_quality_text(workspace, days, json)?);
118    Ok(())
119}
120
121fn format_quality(report: &crate::metrics::quality::CaptureQualityReport) -> String {
122    format!(
123        "Capture quality\n  events: {}\n  proxy_events: {}\n  trace_spans: {}\n  token_coverage: {}%\n  cost_coverage: {}%\n  latency_coverage: {}%\n  context_coverage: {}%\n  proxy_correlation: {}%\n  cache_read_tokens: {}\n  cache_creation_tokens: {}\n  orphan_spans: {}\n",
124        report.events_total,
125        report.proxy_events,
126        report.trace_spans_total,
127        report.token_coverage_pct,
128        report.cost_coverage_pct,
129        report.latency_coverage_pct,
130        report.context_coverage_pct,
131        report.proxy_correlation_pct,
132        report.cache_read_tokens,
133        report.cache_creation_tokens,
134        report.orphan_span_count,
135    )
136}
137
138fn now_ms() -> u64 {
139    std::time::SystemTime::now()
140        .duration_since(std::time::UNIX_EPOCH)
141        .map(|d| d.as_millis() as u64)
142        .unwrap_or(0)
143}
144
145fn decorate_metrics(
146    workspace: &Path,
147    mut metrics: crate::metrics::types::MetricsReport,
148) -> crate::metrics::types::MetricsReport {
149    for row in &mut metrics.hottest_files {
150        row.path = scope::decorate_path(workspace, &row.path);
151    }
152    for row in &mut metrics.most_changed_files {
153        row.path = scope::decorate_path(workspace, &row.path);
154    }
155    for row in &mut metrics.most_complex_files {
156        row.path = scope::decorate_path(workspace, &row.path);
157    }
158    for row in &mut metrics.highest_risk_files {
159        row.path = scope::decorate_path(workspace, &row.path);
160    }
161    for row in &mut metrics.agent_pain_hotspots {
162        row.path = scope::decorate_path(workspace, &row.path);
163    }
164    metrics
165}
166
167fn merge_metrics(
168    rows: Vec<crate::metrics::types::MetricsReport>,
169) -> crate::metrics::types::MetricsReport {
170    let mut out = crate::metrics::types::MetricsReport {
171        snapshot: None,
172        hottest_files: Vec::new(),
173        most_changed_files: Vec::new(),
174        most_complex_files: Vec::new(),
175        highest_risk_files: Vec::new(),
176        slowest_tools: Vec::new(),
177        highest_token_tools: Vec::new(),
178        highest_reasoning_tools: Vec::new(),
179        agent_pain_hotspots: Vec::new(),
180    };
181    for row in rows {
182        out.hottest_files.extend(row.hottest_files);
183        out.most_changed_files.extend(row.most_changed_files);
184        out.most_complex_files.extend(row.most_complex_files);
185        out.highest_risk_files.extend(row.highest_risk_files);
186        out.agent_pain_hotspots.extend(row.agent_pain_hotspots);
187        merge_tool_rows(&mut out.slowest_tools, row.slowest_tools);
188        merge_tool_rows(&mut out.highest_token_tools, row.highest_token_tools);
189        merge_tool_rows(
190            &mut out.highest_reasoning_tools,
191            row.highest_reasoning_tools,
192        );
193    }
194    trim_file_rows(&mut out.hottest_files);
195    trim_file_rows(&mut out.most_changed_files);
196    trim_file_rows(&mut out.most_complex_files);
197    trim_file_rows(&mut out.highest_risk_files);
198    trim_file_rows(&mut out.agent_pain_hotspots);
199    trim_tool_rows(&mut out.slowest_tools, |row| row.p95_ms.unwrap_or(0));
200    trim_tool_rows(&mut out.highest_token_tools, |row| row.total_tokens);
201    trim_tool_rows(&mut out.highest_reasoning_tools, |row| {
202        row.total_reasoning_tokens
203    });
204    out
205}
206
207fn merge_tool_rows(
208    target: &mut Vec<crate::metrics::types::RankedTool>,
209    rows: Vec<crate::metrics::types::RankedTool>,
210) {
211    for row in rows {
212        if let Some(existing) = target.iter_mut().find(|item| item.tool == row.tool) {
213            existing.calls += row.calls;
214            existing.total_tokens += row.total_tokens;
215            existing.total_reasoning_tokens += row.total_reasoning_tokens;
216            existing.p50_ms = existing.p50_ms.max(row.p50_ms);
217            existing.p95_ms = existing.p95_ms.max(row.p95_ms);
218            continue;
219        }
220        target.push(row);
221    }
222}
223
224fn trim_file_rows(rows: &mut Vec<crate::metrics::types::RankedFile>) {
225    rows.sort_by(|a, b| b.value.cmp(&a.value).then_with(|| a.path.cmp(&b.path)));
226    rows.truncate(10);
227}
228
229fn trim_tool_rows<F>(rows: &mut Vec<crate::metrics::types::RankedTool>, rank: F)
230where
231    F: Fn(&crate::metrics::types::RankedTool) -> u64,
232{
233    rows.sort_by(|a, b| rank(b).cmp(&rank(a)).then_with(|| a.tool.cmp(&b.tool)));
234    rows.truncate(10);
235}
236
237fn maybe_enqueue_snapshot(
238    store: &Store,
239    cfg: &crate::core::config::Config,
240    ws: &std::path::Path,
241    snapshot: &crate::metrics::types::RepoSnapshotRecord,
242) -> Result<()> {
243    let Some(ctx) = ingest_ctx(cfg, ws.to_path_buf()) else {
244        return Ok(());
245    };
246    let facts = store.file_facts_for_snapshot(&snapshot.id)?;
247    let edges = store.repo_edges_for_snapshot(&snapshot.id)?;
248    smart::enqueue_repo_snapshot(store, snapshot, &facts, &edges, &ctx)?;
249    smart::enqueue_workspace_fact_snapshot(store, ws, &ctx)
250}
251
252pub fn print_human(metrics: &crate::metrics::types::MetricsReport) {
253    print!("{}", format_human(metrics));
254}
255
256fn format_files(title: &str, rows: &[crate::metrics::types::RankedFile]) -> String {
257    use std::fmt::Write;
258    let mut s = String::new();
259    writeln!(&mut s, "{title}").unwrap();
260    if rows.is_empty() {
261        writeln!(&mut s, "  (none)").unwrap();
262        writeln!(&mut s).unwrap();
263        return s;
264    }
265    for row in rows.iter().take(5) {
266        writeln!(&mut s, "  {:>8}  {}", row.value, row.path).unwrap();
267    }
268    writeln!(&mut s).unwrap();
269    s
270}
271
272fn format_tools(title: &str, rows: &[crate::metrics::types::RankedTool]) -> String {
273    use std::fmt::Write;
274    let mut s = String::new();
275    writeln!(&mut s, "{title}").unwrap();
276    if rows.is_empty() {
277        writeln!(&mut s, "  (none)").unwrap();
278        writeln!(&mut s).unwrap();
279        return s;
280    }
281    for row in rows.iter().take(5) {
282        let p95 = row
283            .p95_ms
284            .map(|v| format!("{v}ms"))
285            .unwrap_or_else(|| "-".into());
286        writeln!(
287            &mut s,
288            "  {:<14} calls={} p95={} tok={} rtok={}",
289            row.tool, row.calls, p95, row.total_tokens, row.total_reasoning_tokens
290        )
291        .unwrap();
292    }
293    writeln!(&mut s).unwrap();
294    s
295}
296
297fn format_human(metrics: &crate::metrics::types::MetricsReport) -> String {
298    let mut out = String::new();
299    out.push_str(&format_files("Hottest files", &metrics.hottest_files));
300    out.push_str(&format_files("Most changed", &metrics.most_changed_files));
301    out.push_str(&format_files("Most complex", &metrics.most_complex_files));
302    out.push_str(&format_files("Highest risk", &metrics.highest_risk_files));
303    out.push_str(&format_tools("Slowest tools", &metrics.slowest_tools));
304    out.push_str(&format_tools(
305        "Highest token tools",
306        &metrics.highest_token_tools,
307    ));
308    out.push_str(&format_tools(
309        "Highest reasoning tools",
310        &metrics.highest_reasoning_tools,
311    ));
312    out.push_str(&format_files(
313        "Agent pain hotspots",
314        &metrics.agent_pain_hotspots,
315    ));
316    use std::fmt::Write;
317    let _ = writeln!(&mut out);
318    let _ = writeln!(&mut out, "Takeaway");
319    if let Some(f) = metrics.hottest_files.first() {
320        let _ = writeln!(
321            &mut out,
322            "  · Review {} first (heat {}); pair with `kaizen insights` for session context",
323            f.path, f.value
324        );
325    }
326    if let Some(t) = metrics.slowest_tools.first() {
327        let p95 = t
328            .p95_ms
329            .map(|v| format!("{v}ms"))
330            .unwrap_or_else(|| "n/a".into());
331        let _ = writeln!(
332            &mut out,
333            "  · Latency focus: {} p95 {} — tune or cache this tool path if it recurs",
334            t.tool, p95
335        );
336    }
337    if let Some(t) = metrics.highest_token_tools.first() {
338        let _ = writeln!(
339            &mut out,
340            "  · Token sink: {} ({} total tok) — compare week over week with `kaizen metrics --days 30`",
341            t.tool, t.total_tokens
342        );
343    }
344    let _ = writeln!(
345        &mut out,
346        "  · Next: `kaizen retro --days 7` for team-level bets"
347    );
348    out
349}