Skip to main content

roboticus_cli/cli/admin/misc/
local_metrics.rs

1pub async fn cmd_metrics(
2    url: &str,
3    kind: &str,
4    hours: Option<i64>,
5    json: bool,
6) -> Result<(), Box<dyn std::error::Error>> {
7    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
8    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
9    let c = RoboticusClient::new(url)?;
10
11    match kind {
12        "costs" => {
13            let data = c.get("/api/stats/costs").await.map_err(|e| {
14                RoboticusClient::check_connectivity_hint(&*e);
15                e
16            })?;
17            if json {
18                println!("{}", serde_json::to_string_pretty(&data)?);
19                return Ok(());
20            }
21            heading("Inference Costs");
22            let costs = data["costs"].as_array();
23            match costs {
24                Some(arr) if !arr.is_empty() => {
25                    let mut suppressed_zero_rows = 0usize;
26                    let filtered: Vec<&serde_json::Value> = arr
27                        .iter()
28                        .filter(|c| {
29                            let tin = c["tokens_in"].as_i64().unwrap_or(0);
30                            let tout = c["tokens_out"].as_i64().unwrap_or(0);
31                            let cost = c["cost"].as_f64().unwrap_or(0.0);
32                            let cached = c["cached"].as_bool().unwrap_or(false);
33                            let keep = cached || tin != 0 || tout != 0 || cost > 0.0;
34                            if !keep {
35                                suppressed_zero_rows += 1;
36                            }
37                            keep
38                        })
39                        .collect();
40                    if filtered.is_empty() {
41                        empty_state(
42                            "No billable/non-empty inference costs recorded (all recent rows were zero-token/no-cost events)",
43                        );
44                        if suppressed_zero_rows > 0 {
45                            kv("Suppressed Zero Rows", &suppressed_zero_rows.to_string());
46                        }
47                        return Ok(());
48                    }
49
50                    let widths = [20, 16, 10, 10, 10, 8];
51                    table_header(
52                        &[
53                            "Model",
54                            "Provider",
55                            "Tokens In",
56                            "Tokens Out",
57                            "Cost",
58                            "Cached",
59                        ],
60                        &widths,
61                    );
62
63                    let mut total_cost = 0.0f64;
64                    let mut total_in = 0i64;
65                    let mut total_out = 0i64;
66
67                    for c in &filtered {
68                        let model = truncate_id(c["model"].as_str().unwrap_or(""), 17);
69                        let provider = c["provider"].as_str().unwrap_or("").to_string();
70                        let tin = c["tokens_in"].as_i64().unwrap_or(0);
71                        let tout = c["tokens_out"].as_i64().unwrap_or(0);
72                        let cost = c["cost"].as_f64().unwrap_or(0.0);
73                        let cached = c["cached"].as_bool().unwrap_or(false);
74
75                        total_cost += cost;
76                        total_in += tin;
77                        total_out += tout;
78
79                        table_row(
80                            &[
81                                format!("{ACCENT}{model}{RESET}"),
82                                provider,
83                                tin.to_string(),
84                                tout.to_string(),
85                                format!("${cost:.4}"),
86                                if cached {
87                                    OK.to_string()
88                                } else {
89                                    format!("{DIM}-{RESET}")
90                                },
91                            ],
92                            &widths,
93                        );
94                    }
95                    table_separator(&widths);
96                    eprintln!();
97                    kv_accent("Total Cost", &format!("${total_cost:.4}"));
98                    kv("Total Tokens", &format!("{total_in} in / {total_out} out"));
99                    kv("Requests", &filtered.len().to_string());
100                    if suppressed_zero_rows > 0 {
101                        kv("Suppressed Zero Rows", &suppressed_zero_rows.to_string());
102                    }
103                    if !filtered.is_empty() {
104                        kv(
105                            "Avg Cost/Request",
106                            &format!("${:.4}", total_cost / filtered.len() as f64),
107                        );
108                    }
109                }
110                _ => empty_state("No inference costs recorded"),
111            }
112        }
113        "transactions" => {
114            let h = hours.unwrap_or(24);
115            let data = c
116                .get(&format!("/api/stats/transactions?hours={h}"))
117                .await
118                .map_err(|e| {
119                    RoboticusClient::check_connectivity_hint(&*e);
120                    e
121                })?;
122            if json {
123                println!("{}", serde_json::to_string_pretty(&data)?);
124                return Ok(());
125            }
126            heading(&format!("Transactions (last {h}h)"));
127            let txs = data["transactions"].as_array();
128            match txs {
129                Some(arr) if !arr.is_empty() => {
130                    let widths = [14, 12, 12, 20, 22];
131                    table_header(&["ID", "Type", "Amount", "Counterparty", "Time"], &widths);
132
133                    let mut total = 0.0f64;
134                    for t in arr {
135                        let id = truncate_id(t["id"].as_str().unwrap_or(""), 11);
136                        let tx_type = t["tx_type"].as_str().unwrap_or("").to_string();
137                        let amount = t["amount"].as_f64().unwrap_or(0.0);
138                        let currency = t["currency"].as_str().unwrap_or("USD");
139                        let counter = t["counterparty"].as_str().unwrap_or("-").to_string();
140                        let time = t["created_at"]
141                            .as_str()
142                            .map(|t| if t.len() > 19 { &t[..19] } else { t })
143                            .unwrap_or("")
144                            .to_string();
145
146                        total += amount;
147
148                        table_row(
149                            &[
150                                format!("{MONO}{id}{RESET}"),
151                                tx_type,
152                                format!("{amount:.2} {currency}"),
153                                counter,
154                                format!("{DIM}{time}{RESET}"),
155                            ],
156                            &widths,
157                        );
158                    }
159                    eprintln!();
160                    kv_accent("Total", &format!("{total:.2}"));
161                    kv("Count", &arr.len().to_string());
162                }
163                _ => empty_state("No transactions in this time window"),
164            }
165        }
166        "cache" => {
167            let data = c.get("/api/stats/cache").await.map_err(|e| {
168                RoboticusClient::check_connectivity_hint(&*e);
169                e
170            })?;
171            if json {
172                println!("{}", serde_json::to_string_pretty(&data)?);
173                return Ok(());
174            }
175            heading("Cache Statistics");
176            let hits = data["hits"].as_u64().unwrap_or(0);
177            let misses = data["misses"].as_u64().unwrap_or(0);
178            let entries = data["entries"].as_u64().unwrap_or(0);
179            let hit_rate = data["hit_rate"].as_f64().unwrap_or(0.0);
180
181            kv_accent("Entries", &entries.to_string());
182            kv("Hits", &hits.to_string());
183            kv("Misses", &misses.to_string());
184
185            let bar_width = 30;
186            let filled = (hit_rate * bar_width as f64 / 100.0) as usize;
187            let empty_part = bar_width - filled;
188            let bar = format!(
189                "{GREEN}{}{DIM}{}{RESET} {:.1}%",
190                "\u{2588}".repeat(filled),
191                "\u{2591}".repeat(empty_part),
192                hit_rate
193            );
194            kv("Hit Rate", &bar);
195        }
196        _ => {
197            return Err(
198                format!("unknown metric kind: {kind}. Use: costs, transactions, cache").into(),
199            );
200        }
201    }
202
203    eprintln!();
204    Ok(())
205}
206