Skip to main content

batty_cli/team/
metrics_cmd.rs

1//! `batty metrics` — consolidated telemetry dashboard.
2//!
3//! Queries the SQLite telemetry database and prints a single-screen summary
4//! covering session totals, per-agent performance, cycle-time statistics,
5//! and review pipeline health. Handles missing/empty databases gracefully.
6
7use std::path::Path;
8
9use anyhow::{Context, Result};
10use rusqlite::Connection;
11
12use super::telemetry_db;
13
14/// Aggregated dashboard metrics produced by [`query_dashboard`].
15#[derive(Debug, Clone, Default, PartialEq)]
16pub struct DashboardMetrics {
17    // Session totals
18    pub total_tasks_completed: i64,
19    pub total_merges: i64,
20    pub total_events: i64,
21    pub sessions_count: i64,
22
23    // Cycle time
24    pub avg_cycle_time_secs: Option<f64>,
25    pub min_cycle_time_secs: Option<i64>,
26    pub max_cycle_time_secs: Option<i64>,
27
28    // Rates
29    pub completion_rate: Option<f64>,
30    pub failure_rate: Option<f64>,
31    pub merge_success_rate: Option<f64>,
32
33    // Review pipeline
34    pub auto_merge_count: i64,
35    pub manual_merge_count: i64,
36    pub auto_merge_rate: Option<f64>,
37    pub rework_count: i64,
38    pub rework_rate: Option<f64>,
39    pub avg_review_latency_secs: Option<f64>,
40
41    // Per-agent breakdown
42    pub agent_rows: Vec<AgentRow>,
43}
44
45/// Per-agent row in the dashboard.
46#[derive(Debug, Clone, PartialEq)]
47pub struct AgentRow {
48    pub role: String,
49    pub completions: i64,
50    pub failures: i64,
51    pub restarts: i64,
52    pub total_cycle_secs: i64,
53    pub idle_pct: Option<f64>,
54}
55
56/// Query the telemetry database and build the aggregated dashboard.
57pub fn query_dashboard(conn: &Connection) -> Result<DashboardMetrics> {
58    let mut m = DashboardMetrics::default();
59
60    // Session totals
61    let sessions = telemetry_db::query_session_summaries(conn)?;
62    m.sessions_count = sessions.len() as i64;
63    for s in &sessions {
64        m.total_tasks_completed += s.tasks_completed;
65        m.total_merges += s.total_merges;
66        m.total_events += s.total_events;
67    }
68
69    // Per-agent metrics
70    let agents = telemetry_db::query_agent_metrics(conn)?;
71    let mut total_completions: i64 = 0;
72    let mut total_failures: i64 = 0;
73    for a in &agents {
74        total_completions += a.completions;
75        total_failures += a.failures;
76        let total_polls = a.idle_polls + a.working_polls;
77        let idle_pct = if total_polls > 0 {
78            Some(a.idle_polls as f64 / total_polls as f64 * 100.0)
79        } else {
80            None
81        };
82        m.agent_rows.push(AgentRow {
83            role: a.role.clone(),
84            completions: a.completions,
85            failures: a.failures,
86            restarts: a.restarts,
87            total_cycle_secs: a.total_cycle_secs,
88            idle_pct,
89        });
90    }
91
92    // Rates
93    let total_outcomes = total_completions + total_failures;
94    if total_outcomes > 0 {
95        m.completion_rate = Some(total_completions as f64 / total_outcomes as f64 * 100.0);
96        m.failure_rate = Some(total_failures as f64 / total_outcomes as f64 * 100.0);
97    }
98    if m.total_tasks_completed > 0 {
99        m.merge_success_rate = Some(m.total_merges as f64 / m.total_tasks_completed as f64 * 100.0);
100    }
101
102    // Cycle time from task_metrics
103    let tasks = telemetry_db::query_task_metrics(conn)?;
104    let cycle_times: Vec<i64> = tasks
105        .iter()
106        .filter_map(|t| match (t.started_at, t.completed_at) {
107            (Some(s), Some(c)) if c > s => Some(c - s),
108            _ => None,
109        })
110        .collect();
111    if !cycle_times.is_empty() {
112        let sum: i64 = cycle_times.iter().sum();
113        m.avg_cycle_time_secs = Some(sum as f64 / cycle_times.len() as f64);
114        m.min_cycle_time_secs = cycle_times.iter().copied().min();
115        m.max_cycle_time_secs = cycle_times.iter().copied().max();
116    }
117
118    // Review pipeline
119    let review = telemetry_db::query_review_metrics(conn)?;
120    m.auto_merge_count = review.auto_merge_count;
121    m.manual_merge_count = review.manual_merge_count;
122    m.rework_count = review.rework_count;
123    m.avg_review_latency_secs = review.avg_review_latency_secs;
124
125    let total_merge = m.auto_merge_count + m.manual_merge_count;
126    if total_merge > 0 {
127        m.auto_merge_rate = Some(m.auto_merge_count as f64 / total_merge as f64 * 100.0);
128    }
129    let total_reviewed = total_merge + m.rework_count;
130    if total_reviewed > 0 {
131        m.rework_rate = Some(m.rework_count as f64 / total_reviewed as f64 * 100.0);
132    }
133
134    Ok(m)
135}
136
137/// Format a duration in seconds as a human-readable string.
138fn format_duration(secs: f64) -> String {
139    if secs < 60.0 {
140        format!("{:.0}s", secs)
141    } else if secs < 3600.0 {
142        let m = (secs / 60.0).floor();
143        let s = secs - m * 60.0;
144        format!("{:.0}m {:.0}s", m, s)
145    } else {
146        let h = (secs / 3600.0).floor();
147        let rem = secs - h * 3600.0;
148        let m = (rem / 60.0).floor();
149        format!("{:.0}h {:.0}m", h, m)
150    }
151}
152
153/// Format the dashboard for terminal display.
154pub fn format_dashboard(m: &DashboardMetrics) -> String {
155    let mut out = String::new();
156    let na = "n/a".to_string();
157
158    // Header
159    out.push_str("Telemetry Dashboard\n");
160    out.push_str(&"=".repeat(60));
161    out.push('\n');
162
163    // Session totals
164    out.push_str("\nSession Totals\n");
165    out.push_str(&"-".repeat(40));
166    out.push('\n');
167    out.push_str(&format!("  Sessions:        {}\n", m.sessions_count));
168    out.push_str(&format!("  Tasks Completed: {}\n", m.total_tasks_completed));
169    out.push_str(&format!("  Total Merges:    {}\n", m.total_merges));
170    out.push_str(&format!("  Total Events:    {}\n", m.total_events));
171
172    // Cycle time
173    out.push_str("\nCycle Time\n");
174    out.push_str(&"-".repeat(40));
175    out.push('\n');
176    let avg = m
177        .avg_cycle_time_secs
178        .map(format_duration)
179        .unwrap_or_else(|| na.clone());
180    let min = m
181        .min_cycle_time_secs
182        .map(|s| format_duration(s as f64))
183        .unwrap_or_else(|| na.clone());
184    let max = m
185        .max_cycle_time_secs
186        .map(|s| format_duration(s as f64))
187        .unwrap_or_else(|| na.clone());
188    out.push_str(&format!("  Average: {}\n", avg));
189    out.push_str(&format!("  Min:     {}\n", min));
190    out.push_str(&format!("  Max:     {}\n", max));
191
192    // Rates
193    out.push_str("\nRates\n");
194    out.push_str(&"-".repeat(40));
195    out.push('\n');
196    let cr = m
197        .completion_rate
198        .map(|r| format!("{:.0}%", r))
199        .unwrap_or_else(|| na.clone());
200    let fr = m
201        .failure_rate
202        .map(|r| format!("{:.0}%", r))
203        .unwrap_or_else(|| na.clone());
204    let mr = m
205        .merge_success_rate
206        .map(|r| format!("{:.0}%", r))
207        .unwrap_or_else(|| na.clone());
208    out.push_str(&format!("  Completion Rate:    {}\n", cr));
209    out.push_str(&format!("  Failure Rate:       {}\n", fr));
210    out.push_str(&format!("  Merge Success Rate: {}\n", mr));
211
212    // Review pipeline
213    out.push_str("\nReview Pipeline\n");
214    out.push_str(&"-".repeat(40));
215    out.push('\n');
216    let amr = m
217        .auto_merge_rate
218        .map(|r| format!("{:.0}%", r))
219        .unwrap_or_else(|| na.clone());
220    let rr = m
221        .rework_rate
222        .map(|r| format!("{:.0}%", r))
223        .unwrap_or_else(|| na.clone());
224    let latency = m
225        .avg_review_latency_secs
226        .map(format_duration)
227        .unwrap_or_else(|| na.clone());
228    out.push_str(&format!("  Auto-merge Rate: {}\n", amr));
229    out.push_str(&format!(
230        "  Auto: {}  Manual: {}  Rework: {}\n",
231        m.auto_merge_count, m.manual_merge_count, m.rework_count
232    ));
233    out.push_str(&format!("  Rework Rate:     {}\n", rr));
234    out.push_str(&format!("  Avg Review Latency: {}\n", latency));
235
236    // Per-agent table
237    if !m.agent_rows.is_empty() {
238        out.push_str("\nPer-Agent Breakdown\n");
239        out.push_str(&"-".repeat(60));
240        out.push('\n');
241        out.push_str(&format!(
242            "  {:<16} {:>6} {:>6} {:>6} {:>10} {:>8}\n",
243            "ROLE", "DONE", "FAIL", "RESTART", "CYCLE_S", "IDLE%"
244        ));
245        for a in &m.agent_rows {
246            let idle = a
247                .idle_pct
248                .map(|p| format!("{:.0}%", p))
249                .unwrap_or_else(|| "-".to_string());
250            out.push_str(&format!(
251                "  {:<16} {:>6} {:>6} {:>6} {:>10} {:>8}\n",
252                a.role, a.completions, a.failures, a.restarts, a.total_cycle_secs, idle
253            ));
254        }
255    }
256
257    out
258}
259
260/// Run the `batty metrics` command against the project root.
261///
262/// Opens the telemetry DB, queries the dashboard, and prints it.
263/// Returns gracefully when the DB is missing or empty.
264pub fn run(project_root: &Path) -> Result<()> {
265    let db_path = project_root.join(".batty").join("telemetry.db");
266    if !db_path.exists() {
267        println!("Telemetry Dashboard\n{}", "=".repeat(60));
268        println!("\nNo telemetry database found. Run `batty start` to begin collecting data.");
269        return Ok(());
270    }
271
272    let conn = telemetry_db::open(project_root).context("failed to open telemetry database")?;
273
274    let metrics = query_dashboard(&conn)?;
275    print!("{}", format_dashboard(&metrics));
276    Ok(())
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::team::events::TeamEvent;
283    use crate::team::telemetry_db;
284
285    fn setup_db_with_data() -> Connection {
286        let conn = telemetry_db::open_in_memory().unwrap();
287
288        // Create a session
289        let mut started = TeamEvent::daemon_started();
290        started.ts = 1000;
291        telemetry_db::insert_event(&conn, &started).unwrap();
292
293        // Assign and complete tasks
294        let mut a1 = TeamEvent::task_assigned("eng-1", "10");
295        a1.ts = 1100;
296        telemetry_db::insert_event(&conn, &a1).unwrap();
297
298        let mut c1 = TeamEvent::task_completed("eng-1", Some("10"));
299        c1.ts = 1400; // 300s cycle
300        telemetry_db::insert_event(&conn, &c1).unwrap();
301
302        let mut a2 = TeamEvent::task_assigned("eng-2", "20");
303        a2.ts = 1200;
304        telemetry_db::insert_event(&conn, &a2).unwrap();
305
306        let mut c2 = TeamEvent::task_completed("eng-2", Some("20"));
307        c2.ts = 1700; // 500s cycle
308        telemetry_db::insert_event(&conn, &c2).unwrap();
309
310        // Merge events
311        let mut m1 = TeamEvent::task_auto_merged("eng-1", "10", 0.9, 2, 30);
312        m1.ts = 1500;
313        telemetry_db::insert_event(&conn, &m1).unwrap();
314
315        let mut m2 = TeamEvent::task_manual_merged("20");
316        m2.ts = 1800;
317        telemetry_db::insert_event(&conn, &m2).unwrap();
318
319        // A failure
320        telemetry_db::insert_event(&conn, &TeamEvent::pane_death("eng-1")).unwrap();
321
322        conn
323    }
324
325    #[test]
326    fn metrics_with_data() {
327        let conn = setup_db_with_data();
328        let m = query_dashboard(&conn).unwrap();
329
330        assert_eq!(m.sessions_count, 1);
331        assert_eq!(m.total_tasks_completed, 2);
332        assert_eq!(m.total_merges, 2);
333        assert!(m.total_events > 0);
334
335        // Cycle times: 300s and 500s → avg 400
336        let avg = m.avg_cycle_time_secs.unwrap();
337        assert!((avg - 400.0).abs() < 0.01);
338        assert_eq!(m.min_cycle_time_secs, Some(300));
339        assert_eq!(m.max_cycle_time_secs, Some(500));
340
341        // Completion rate: 2 completions, 1 failure → 2/3 ≈ 66.7%
342        let cr = m.completion_rate.unwrap();
343        assert!((cr - 66.67).abs() < 1.0);
344
345        // Failure rate: 1/3 ≈ 33.3%
346        let fr = m.failure_rate.unwrap();
347        assert!((fr - 33.33).abs() < 1.0);
348
349        // Merge success rate: 2 merges / 2 completed → 100%
350        let mr = m.merge_success_rate.unwrap();
351        assert!((mr - 100.0).abs() < 0.01);
352
353        // Review pipeline
354        assert_eq!(m.auto_merge_count, 1);
355        assert_eq!(m.manual_merge_count, 1);
356        let amr = m.auto_merge_rate.unwrap();
357        assert!((amr - 50.0).abs() < 0.01);
358
359        // Agents present
360        assert_eq!(m.agent_rows.len(), 2);
361    }
362
363    #[test]
364    fn empty_db() {
365        let conn = telemetry_db::open_in_memory().unwrap();
366        let m = query_dashboard(&conn).unwrap();
367
368        assert_eq!(m, DashboardMetrics::default());
369        assert_eq!(m.sessions_count, 0);
370        assert_eq!(m.total_tasks_completed, 0);
371        assert!(m.avg_cycle_time_secs.is_none());
372        assert!(m.completion_rate.is_none());
373        assert!(m.failure_rate.is_none());
374        assert!(m.merge_success_rate.is_none());
375    }
376
377    #[test]
378    fn missing_db_shows_message() {
379        let tmp = tempfile::tempdir().unwrap();
380        // No .batty/ directory → no DB file
381        let result = run(tmp.path());
382        assert!(result.is_ok());
383    }
384
385    #[test]
386    fn rate_calculations() {
387        let conn = telemetry_db::open_in_memory().unwrap();
388
389        // 3 completions, 1 failure
390        let mut started = TeamEvent::daemon_started();
391        started.ts = 100;
392        telemetry_db::insert_event(&conn, &started).unwrap();
393
394        for i in 1..=3 {
395            let mut a = TeamEvent::task_assigned("eng-1", &i.to_string());
396            a.ts = 200 + i as u64 * 100;
397            telemetry_db::insert_event(&conn, &a).unwrap();
398
399            let mut c = TeamEvent::task_completed("eng-1", Some(&i.to_string()));
400            c.ts = 200 + i as u64 * 100 + 60;
401            telemetry_db::insert_event(&conn, &c).unwrap();
402
403            let mut m = TeamEvent::task_auto_merged("eng-1", &i.to_string(), 0.9, 2, 30);
404            m.ts = 200 + i as u64 * 100 + 120;
405            telemetry_db::insert_event(&conn, &m).unwrap();
406        }
407        telemetry_db::insert_event(&conn, &TeamEvent::pane_death("eng-1")).unwrap();
408
409        let m = query_dashboard(&conn).unwrap();
410
411        // completion_rate: 3 / (3+1) = 75%
412        let cr = m.completion_rate.unwrap();
413        assert!((cr - 75.0).abs() < 0.01);
414
415        // failure_rate: 1 / (3+1) = 25%
416        let fr = m.failure_rate.unwrap();
417        assert!((fr - 25.0).abs() < 0.01);
418
419        // merge_success_rate: 3 merges / 3 completed = 100%
420        let mr = m.merge_success_rate.unwrap();
421        assert!((mr - 100.0).abs() < 0.01);
422
423        // auto_merge_rate: 3 auto / (3+0) = 100%
424        let amr = m.auto_merge_rate.unwrap();
425        assert!((amr - 100.0).abs() < 0.01);
426
427        // cycle times: each task has 60s cycle → avg 60
428        let avg = m.avg_cycle_time_secs.unwrap();
429        assert!((avg - 60.0).abs() < 0.01);
430    }
431
432    #[test]
433    fn format_dashboard_renders_sections() {
434        let m = DashboardMetrics {
435            sessions_count: 2,
436            total_tasks_completed: 10,
437            total_merges: 8,
438            total_events: 50,
439            avg_cycle_time_secs: Some(300.0),
440            min_cycle_time_secs: Some(60),
441            max_cycle_time_secs: Some(900),
442            completion_rate: Some(90.0),
443            failure_rate: Some(10.0),
444            merge_success_rate: Some(80.0),
445            auto_merge_count: 6,
446            manual_merge_count: 2,
447            auto_merge_rate: Some(75.0),
448            rework_count: 1,
449            rework_rate: Some(11.0),
450            avg_review_latency_secs: Some(120.0),
451            agent_rows: vec![AgentRow {
452                role: "eng-1".to_string(),
453                completions: 5,
454                failures: 1,
455                restarts: 0,
456                total_cycle_secs: 1500,
457                idle_pct: Some(20.0),
458            }],
459        };
460
461        let text = format_dashboard(&m);
462
463        assert!(text.contains("Telemetry Dashboard"));
464        assert!(text.contains("Session Totals"));
465        assert!(text.contains("Sessions:        2"));
466        assert!(text.contains("Tasks Completed: 10"));
467        assert!(text.contains("Total Merges:    8"));
468
469        assert!(text.contains("Cycle Time"));
470        assert!(text.contains("Average: 5m 0s"));
471        assert!(text.contains("Min:     1m 0s"));
472        assert!(text.contains("Max:     15m 0s"));
473
474        assert!(text.contains("Rates"));
475        assert!(text.contains("Completion Rate:    90%"));
476        assert!(text.contains("Failure Rate:       10%"));
477        assert!(text.contains("Merge Success Rate: 80%"));
478
479        assert!(text.contains("Review Pipeline"));
480        assert!(text.contains("Auto-merge Rate: 75%"));
481        assert!(text.contains("Rework Rate:     11%"));
482
483        assert!(text.contains("Per-Agent Breakdown"));
484        assert!(text.contains("eng-1"));
485    }
486
487    #[test]
488    fn format_dashboard_empty_shows_na() {
489        let m = DashboardMetrics::default();
490        let text = format_dashboard(&m);
491
492        assert!(text.contains("n/a"));
493        assert!(text.contains("Sessions:        0"));
494        // No agent table when empty
495        assert!(!text.contains("Per-Agent Breakdown"));
496    }
497
498    #[test]
499    fn format_duration_works() {
500        assert_eq!(format_duration(30.0), "30s");
501        assert_eq!(format_duration(90.0), "1m 30s");
502        assert_eq!(format_duration(3661.0), "1h 1m");
503    }
504}