1use std::sync::Arc;
4
5pub const METRIC_TURNS_TOTAL: &str = "machi_turns_total";
7pub const METRIC_TURN_STEPS: &str = "machi_turn_steps";
9pub const METRIC_TURN_DURATION_MS: &str = "machi_turn_duration_ms";
11pub const METRIC_TOOL_CALLS_TOTAL: &str = "machi_tool_calls_total";
13pub const METRIC_TOOL_DURATION_MS: &str = "machi_tool_duration_ms";
15pub const METRIC_SAMPLE_DURATION_MS: &str = "machi_sample_duration_ms";
17pub const METRIC_TOKENS_TOTAL: &str = "machi_tokens_total";
19pub const METRIC_SPAWNS_TOTAL: &str = "machi_spawns_total";
21pub const METRIC_WORKFLOW_RUNS_TOTAL: &str = "machi_workflow_runs_total";
23pub const METRIC_WORKFLOW_AGENTS_TOTAL: &str = "machi_workflow_agents_total";
25pub const METRIC_COMPACTIONS_TOTAL: &str = "machi_compactions_total";
27
28#[must_use]
33pub fn required_metric_names() -> &'static [&'static str] {
34 &[
35 METRIC_TURNS_TOTAL,
36 METRIC_TURN_STEPS,
37 METRIC_TURN_DURATION_MS,
38 METRIC_TOOL_CALLS_TOTAL,
39 METRIC_TOOL_DURATION_MS,
40 METRIC_SAMPLE_DURATION_MS,
41 METRIC_TOKENS_TOTAL,
42 METRIC_SPAWNS_TOTAL,
43 METRIC_WORKFLOW_RUNS_TOTAL,
44 METRIC_WORKFLOW_AGENTS_TOTAL,
45 METRIC_COMPACTIONS_TOTAL,
46 ]
47}
48
49#[must_use]
51pub fn metric_catalogue_snapshot() -> String {
52 required_metric_names().join("\n")
53}
54
55pub fn emit_catalogue_smoke(metrics: &dyn MetricsSink) {
57 record_turn(metrics, "ok", 1, 1.0);
58 record_spawn(metrics, "ok");
59 record_tool_call(metrics, "smoke", "ok", 1.0);
60 record_sample(metrics, 1.0, 2, 3);
61 record_workflow_run(metrics, "completed");
62 record_workflow_agents(metrics, 1);
63 record_compaction(metrics, "max_messages", "ok");
64}
65
66pub trait MetricsSink: Send + Sync {
68 fn counter(&self, name: &str, value: u64, labels: &[(&str, &str)]);
70 fn histogram(&self, name: &str, value: f64, labels: &[(&str, &str)]);
72 fn gauge(&self, name: &str, value: f64, labels: &[(&str, &str)]);
74}
75
76#[derive(Debug, Default, Clone, Copy)]
78pub struct NoopMetrics;
79
80impl MetricsSink for NoopMetrics {
81 fn counter(&self, _name: &str, _value: u64, _labels: &[(&str, &str)]) {}
82 fn histogram(&self, _name: &str, _value: f64, _labels: &[(&str, &str)]) {}
83 fn gauge(&self, _name: &str, _value: f64, _labels: &[(&str, &str)]) {}
84}
85
86pub type SharedMetrics = Arc<dyn MetricsSink>;
88
89pub fn record_turn(metrics: &dyn MetricsSink, status: &str, steps: u64, duration_ms: f64) {
91 metrics.counter(METRIC_TURNS_TOTAL, 1, &[("status", status)]);
92 metrics.histogram(METRIC_TURN_STEPS, steps as f64, &[]);
93 metrics.histogram(METRIC_TURN_DURATION_MS, duration_ms, &[]);
94}
95
96pub fn record_spawn(metrics: &dyn MetricsSink, status: &str) {
98 metrics.counter(METRIC_SPAWNS_TOTAL, 1, &[("status", status)]);
99}
100
101pub fn record_tool_call(metrics: &dyn MetricsSink, tool: &str, status: &str, duration_ms: f64) {
103 metrics.counter(
104 METRIC_TOOL_CALLS_TOTAL,
105 1,
106 &[("tool", tool), ("status", status)],
107 );
108 metrics.histogram(METRIC_TOOL_DURATION_MS, duration_ms, &[("tool", tool)]);
109}
110
111pub fn record_sample(
113 metrics: &dyn MetricsSink,
114 duration_ms: f64,
115 input_tokens: u64,
116 output_tokens: u64,
117) {
118 metrics.histogram(METRIC_SAMPLE_DURATION_MS, duration_ms, &[]);
119 if input_tokens > 0 {
120 metrics.counter(METRIC_TOKENS_TOTAL, input_tokens, &[("direction", "input")]);
121 }
122 if output_tokens > 0 {
123 metrics.counter(
124 METRIC_TOKENS_TOTAL,
125 output_tokens,
126 &[("direction", "output")],
127 );
128 }
129}
130
131pub fn record_workflow_run(metrics: &dyn MetricsSink, outcome: &str) {
133 metrics.counter(METRIC_WORKFLOW_RUNS_TOTAL, 1, &[("outcome", outcome)]);
134}
135
136pub fn record_workflow_agents(metrics: &dyn MetricsSink, count: u64) {
138 if count > 0 {
139 metrics.counter(METRIC_WORKFLOW_AGENTS_TOTAL, count, &[]);
140 }
141}
142
143pub fn record_compaction(metrics: &dyn MetricsSink, strategy: &str, status: &str) {
145 metrics.counter(
146 METRIC_COMPACTIONS_TOTAL,
147 1,
148 &[("strategy", strategy), ("status", status)],
149 );
150}
151
152#[cfg(test)]
153mod tests {
154 use std::sync::Mutex;
155
156 use super::*;
157
158 const METRIC_CATALOGUE_GOLDEN: &str = "\
160machi_turns_total
161machi_turn_steps
162machi_turn_duration_ms
163machi_tool_calls_total
164machi_tool_duration_ms
165machi_sample_duration_ms
166machi_tokens_total
167machi_spawns_total
168machi_workflow_runs_total
169machi_workflow_agents_total
170machi_compactions_total";
171
172 #[test]
173 fn metric_catalogue_is_stable_and_prefixed() {
174 let names = required_metric_names();
175 assert!(names.len() >= 10, "expected full production catalogue");
176 let mut seen = std::collections::BTreeSet::new();
177 for name in names {
178 assert!(
179 name.starts_with("machi_"),
180 "metric {name} must start with machi_"
181 );
182 assert!(seen.insert(*name), "duplicate metric {name}");
183 }
184 }
185
186 #[test]
187 fn metric_catalogue_snapshot_matches_golden() {
188 assert_eq!(
189 metric_catalogue_snapshot(),
190 METRIC_CATALOGUE_GOLDEN,
191 "metric catalogue changed — update golden only with deliberate contract change"
192 );
193 }
194
195 #[test]
196 fn emit_catalogue_smoke_covers_all_names() {
197 let cap = Capture::default();
198 emit_catalogue_smoke(&cap);
199 let owned: Vec<String> = cap
200 .counters
201 .lock()
202 .expect("lock")
203 .iter()
204 .map(|(n, _)| n.clone())
205 .collect();
206 let names: std::collections::HashSet<&str> = owned.iter().map(String::as_str).collect();
207 for expected in [
209 METRIC_TURNS_TOTAL,
210 METRIC_SPAWNS_TOTAL,
211 METRIC_TOOL_CALLS_TOTAL,
212 METRIC_TOKENS_TOTAL,
213 METRIC_WORKFLOW_RUNS_TOTAL,
214 METRIC_WORKFLOW_AGENTS_TOTAL,
215 METRIC_COMPACTIONS_TOTAL,
216 ] {
217 assert!(names.contains(expected), "missing counter {expected}");
218 }
219 }
220
221 #[derive(Default)]
222 struct Capture {
223 counters: Mutex<Vec<(String, u64)>>,
224 }
225
226 impl MetricsSink for Capture {
227 fn counter(&self, name: &str, value: u64, _labels: &[(&str, &str)]) {
228 self.counters
229 .lock()
230 .expect("lock")
231 .push((name.to_owned(), value));
232 }
233 fn histogram(&self, _name: &str, _value: f64, _labels: &[(&str, &str)]) {}
234 fn gauge(&self, _name: &str, _value: f64, _labels: &[(&str, &str)]) {}
235 }
236
237 #[test]
238 fn record_helpers_emit_expected_names() {
239 let cap = Capture::default();
240 record_turn(&cap, "ok", 3, 12.0);
241 record_spawn(&cap, "ok");
242 record_tool_call(&cap, "calc", "ok", 1.0);
243 record_workflow_run(&cap, "completed");
244 let names: Vec<_> = cap
245 .counters
246 .lock()
247 .expect("lock")
248 .iter()
249 .map(|(n, _)| n.clone())
250 .collect();
251 assert!(names.iter().any(|n| n == METRIC_TURNS_TOTAL));
252 assert!(names.iter().any(|n| n == METRIC_SPAWNS_TOTAL));
253 assert!(names.iter().any(|n| n == METRIC_TOOL_CALLS_TOTAL));
254 assert!(names.iter().any(|n| n == METRIC_WORKFLOW_RUNS_TOTAL));
255 }
256}