Skip to main content

mcp_usage_tower/
metrics.rs

1//! Low-cardinality atomic metrics for the edge hot path.
2
3use std::sync::atomic::{AtomicU64, Ordering};
4
5/// Process-local edge counters.
6#[derive(Debug, Default)]
7pub struct EdgeMetrics {
8    classified: AtomicU64,
9    rejected: AtomicU64,
10    unauthenticated: AtomicU64,
11    throttled: AtomicU64,
12    cache_hits: AtomicU64,
13    cache_misses: AtomicU64,
14    billed: AtomicU64,
15    billed_units: AtomicU64,
16    free: AtomicU64,
17    duplicates: AtomicU64,
18    record_failures: AtomicU64,
19    deferred: AtomicU64,
20    unrecognized_responses: AtomicU64,
21}
22
23/// Point-in-time metric values.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub struct MetricsSnapshot {
26    /// Successfully classified requests.
27    pub classified: u64,
28    /// Requests rejected for malformed or ambiguous MCP headers.
29    pub rejected: u64,
30    /// Requests refused for a missing, malformed, or unknown API key. Tracked
31    /// apart from `rejected` so credential stuffing is distinguishable from
32    /// clients that simply send bad protocol headers.
33    pub unauthenticated: u64,
34    /// Requests refused because the authentication-failure budget was spent.
35    pub throttled: u64,
36    /// Responses served from cache.
37    pub cache_hits: u64,
38    /// Cacheable requests not served from cache.
39    pub cache_misses: u64,
40    /// Delivered calls accepted by the recorder.
41    pub billed: u64,
42    /// Sum of delivered units accepted by the recorder.
43    pub billed_units: u64,
44    /// Exchanges classified as free.
45    pub free: u64,
46    /// Repeated once-only charges suppressed by the recorder.
47    pub duplicates: u64,
48    /// Local recorder failures.
49    pub record_failures: u64,
50    /// Terminal accounting parked because it could not finish without awaiting.
51    /// Only a durable task store produces these.
52    pub deferred: u64,
53    /// Bodies that ended without a parseable terminal response.
54    pub unrecognized_responses: u64,
55}
56
57impl EdgeMetrics {
58    pub(crate) fn classified(&self) {
59        saturating_add(&self.classified, 1);
60    }
61    pub(crate) fn rejected(&self) {
62        saturating_add(&self.rejected, 1);
63    }
64    pub(crate) fn unauthenticated(&self) {
65        saturating_add(&self.unauthenticated, 1);
66    }
67    pub(crate) fn throttled(&self) {
68        saturating_add(&self.throttled, 1);
69    }
70    pub(crate) fn cache_hit(&self) {
71        saturating_add(&self.cache_hits, 1);
72    }
73    pub(crate) fn cache_miss(&self) {
74        saturating_add(&self.cache_misses, 1);
75    }
76    pub(crate) fn billed(&self, units: u64) {
77        saturating_add(&self.billed, 1);
78        saturating_add(&self.billed_units, units);
79    }
80    pub(crate) fn free(&self) {
81        saturating_add(&self.free, 1);
82    }
83    pub(crate) fn duplicate(&self) {
84        saturating_add(&self.duplicates, 1);
85    }
86    pub(crate) fn record_failure(&self) {
87        saturating_add(&self.record_failures, 1);
88    }
89    pub(crate) fn deferred(&self) {
90        saturating_add(&self.deferred, 1);
91    }
92    pub(crate) fn unrecognized_response(&self) {
93        saturating_add(&self.unrecognized_responses, 1);
94    }
95
96    /// Read every counter using relaxed ordering.
97    #[must_use]
98    pub fn snapshot(&self) -> MetricsSnapshot {
99        MetricsSnapshot {
100            classified: self.classified.load(Ordering::Relaxed),
101            rejected: self.rejected.load(Ordering::Relaxed),
102            unauthenticated: self.unauthenticated.load(Ordering::Relaxed),
103            throttled: self.throttled.load(Ordering::Relaxed),
104            cache_hits: self.cache_hits.load(Ordering::Relaxed),
105            cache_misses: self.cache_misses.load(Ordering::Relaxed),
106            billed: self.billed.load(Ordering::Relaxed),
107            billed_units: self.billed_units.load(Ordering::Relaxed),
108            free: self.free.load(Ordering::Relaxed),
109            duplicates: self.duplicates.load(Ordering::Relaxed),
110            record_failures: self.record_failures.load(Ordering::Relaxed),
111            deferred: self.deferred.load(Ordering::Relaxed),
112            unrecognized_responses: self.unrecognized_responses.load(Ordering::Relaxed),
113        }
114    }
115
116    /// Render all counters in the Prometheus text exposition format.
117    ///
118    /// Metric names and help strings are fixed and carry no tenant, customer,
119    /// method, or tool labels, which keeps cardinality and privacy risk bounded.
120    #[must_use]
121    pub fn render_prometheus(&self) -> String {
122        self.snapshot().render_prometheus()
123    }
124}
125
126impl MetricsSnapshot {
127    /// Render this snapshot in the Prometheus text exposition format.
128    #[must_use]
129    pub fn render_prometheus(self) -> String {
130        let mut output = String::with_capacity(1_600);
131        append_metric(
132            &mut output,
133            "mcp_usage_classified_total",
134            "Successfully classified MCP requests.",
135            self.classified,
136        );
137        append_metric(
138            &mut output,
139            "mcp_usage_rejected_total",
140            "MCP requests rejected for malformed or ambiguous headers.",
141            self.rejected,
142        );
143        append_metric(
144            &mut output,
145            "mcp_usage_unauthenticated_total",
146            "MCP requests refused for a missing, malformed, or unknown API key.",
147            self.unauthenticated,
148        );
149        append_metric(
150            &mut output,
151            "mcp_usage_throttled_total",
152            "MCP requests refused after too many failed authentication attempts.",
153            self.throttled,
154        );
155        append_metric(
156            &mut output,
157            "mcp_usage_cache_hits_total",
158            "MCP responses served from the authorization-aware cache.",
159            self.cache_hits,
160        );
161        append_metric(
162            &mut output,
163            "mcp_usage_cache_misses_total",
164            "Cacheable MCP requests not served from cache.",
165            self.cache_misses,
166        );
167        append_metric(
168            &mut output,
169            "mcp_usage_billable_deliveries_total",
170            "Delivered MCP results accepted by the usage recorder.",
171            self.billed,
172        );
173        append_metric(
174            &mut output,
175            "mcp_usage_recorded_units_total",
176            "Delivered MCP units accepted by the usage recorder.",
177            self.billed_units,
178        );
179        append_metric(
180            &mut output,
181            "mcp_usage_free_deliveries_total",
182            "MCP exchanges classified as free.",
183            self.free,
184        );
185        append_metric(
186            &mut output,
187            "mcp_usage_duplicates_total",
188            "Repeated once-only charges suppressed by the recorder.",
189            self.duplicates,
190        );
191        append_metric(
192            &mut output,
193            "mcp_usage_record_failures_total",
194            "Local recorder or attribution-store failures.",
195            self.record_failures,
196        );
197        append_metric(
198            &mut output,
199            "mcp_usage_deferred_completions_total",
200            "Terminal accounting parked to be completed outside the response body.",
201            self.deferred,
202        );
203        append_metric(
204            &mut output,
205            "mcp_usage_unrecognized_responses_total",
206            "Bodies that ended without a recognized terminal MCP response.",
207            self.unrecognized_responses,
208        );
209        output
210    }
211}
212
213fn append_metric(output: &mut String, name: &str, help: &str, value: u64) {
214    output.push_str("# HELP ");
215    output.push_str(name);
216    output.push(' ');
217    output.push_str(help);
218    output.push('\n');
219    output.push_str("# TYPE ");
220    output.push_str(name);
221    output.push_str(" counter\n");
222    output.push_str(name);
223    output.push(' ');
224    output.push_str(&value.to_string());
225    output.push('\n');
226}
227
228fn saturating_add(counter: &AtomicU64, increment: u64) {
229    let mut current = counter.load(Ordering::Relaxed);
230    loop {
231        let next = current.saturating_add(increment);
232        match counter.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) {
233            Ok(_) => break,
234            Err(observed) => current = observed,
235        }
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn counters_saturate_instead_of_wrapping() {
245        let counter = AtomicU64::new(u64::MAX - 1);
246        saturating_add(&counter, 10);
247        assert_eq!(counter.load(Ordering::Relaxed), u64::MAX);
248    }
249
250    #[test]
251    fn prometheus_output_has_stable_low_cardinality_names() {
252        let metrics = EdgeMetrics::default();
253        metrics.classified();
254        metrics.billed(7);
255        let output = metrics.render_prometheus();
256        assert!(output.contains("mcp_usage_classified_total 1\n"));
257        assert!(output.contains("mcp_usage_recorded_units_total 7\n"));
258        assert!(!output.contains('{'));
259    }
260}