Skip to main content

machi_obs/
metrics.rs

1//! Stable metric series and a host-injected sink.
2
3use std::sync::Arc;
4
5/// Counter: completed turns by status (`ok`, `error`, `cancelled`).
6pub const METRIC_TURNS_TOTAL: &str = "machi_turns_total";
7/// Histogram: steps consumed per turn.
8pub const METRIC_TURN_STEPS: &str = "machi_turn_steps";
9/// Histogram: turn wall duration in milliseconds.
10pub const METRIC_TURN_DURATION_MS: &str = "machi_turn_duration_ms";
11/// Counter: tool calls by tool name and status.
12pub const METRIC_TOOL_CALLS_TOTAL: &str = "machi_tool_calls_total";
13/// Histogram: tool duration in milliseconds.
14pub const METRIC_TOOL_DURATION_MS: &str = "machi_tool_duration_ms";
15/// Histogram: LLM sample duration in milliseconds.
16pub const METRIC_SAMPLE_DURATION_MS: &str = "machi_sample_duration_ms";
17/// Counter: tokens by direction (`input`, `output`).
18pub const METRIC_TOKENS_TOTAL: &str = "machi_tokens_total";
19/// Counter: nested agent spawns by status.
20pub const METRIC_SPAWNS_TOTAL: &str = "machi_spawns_total";
21/// Counter: workflow runs by outcome.
22pub const METRIC_WORKFLOW_RUNS_TOTAL: &str = "machi_workflow_runs_total";
23/// Counter: workflow agent slots consumed.
24pub const METRIC_WORKFLOW_AGENTS_TOTAL: &str = "machi_workflow_agents_total";
25/// Counter: compaction passes by strategy/result.
26pub const METRIC_COMPACTIONS_TOTAL: &str = "machi_compactions_total";
27
28/// Required metric name catalogue (contract tests / CI snapshots).
29///
30/// **Rename = break:** CI asserts the exact ordered snapshot from
31/// [`metric_catalogue_snapshot`].
32#[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/// Exact newline-joined catalogue for CI golden comparison.
50#[must_use]
51pub fn metric_catalogue_snapshot() -> String {
52    required_metric_names().join("\n")
53}
54
55/// Emit every stable series once (for export / dashboard smoke).
56pub 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
66/// Host-provided metrics backend.
67pub trait MetricsSink: Send + Sync {
68    /// Increment a counter.
69    fn counter(&self, name: &str, value: u64, labels: &[(&str, &str)]);
70    /// Observe a histogram sample.
71    fn histogram(&self, name: &str, value: f64, labels: &[(&str, &str)]);
72    /// Set a gauge.
73    fn gauge(&self, name: &str, value: f64, labels: &[(&str, &str)]);
74}
75
76/// Discards all metrics (default for tests / offline).
77#[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
86/// Shared metrics handle.
87pub type SharedMetrics = Arc<dyn MetricsSink>;
88
89/// Record a completed turn.
90pub 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
96/// Record a nested spawn.
97pub fn record_spawn(metrics: &dyn MetricsSink, status: &str) {
98    metrics.counter(METRIC_SPAWNS_TOTAL, 1, &[("status", status)]);
99}
100
101/// Record one tool call.
102pub 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
111/// Record sample duration and tokens.
112pub 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
131/// Record workflow terminal outcome.
132pub fn record_workflow_run(metrics: &dyn MetricsSink, outcome: &str) {
133    metrics.counter(METRIC_WORKFLOW_RUNS_TOTAL, 1, &[("outcome", outcome)]);
134}
135
136/// Record workflow agent slot consumption.
137pub fn record_workflow_agents(metrics: &dyn MetricsSink, count: u64) {
138    if count > 0 {
139        metrics.counter(METRIC_WORKFLOW_AGENTS_TOTAL, count, &[]);
140    }
141}
142
143/// Record a compaction pass.
144pub 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    /// Golden snapshot — intentional fail on rename/reorder/add/remove.
159    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        // Histograms go elsewhere; counters cover the main series.
208        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}