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
5use mcp_usage_core::FreeReason;
6
7/// One counter slot per [`FreeReason`].
8const REASONS: usize = FreeReason::ALL.len();
9
10/// One counter slot per [`UnaccountedReason`].
11const UNACCOUNTED: usize = UnaccountedReason::ALL.len();
12
13/// Why a response the edge observed produced no accounting at all.
14///
15/// Four unrelated causes shared one counter, so an operator watching
16/// `mcp_usage_unrecognized_responses_total` climb could tell that responses
17/// were going unaccounted and nothing about why. They need opposite responses:
18/// a media type means the origin is misconfigured and revenue is being lost, a
19/// capture overflow means the capture bound is set too low for this traffic,
20/// and a task-id mismatch means the origin answered the wrong question.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum UnaccountedReason {
23    /// The origin declared a media type the meter does not read as MCP.
24    MediaType,
25    /// The body was read but was not a JSON-RPC response.
26    Unparseable,
27    /// A `tasks/get` was answered with a different task than it asked for.
28    TaskIdMismatch,
29    /// The captured body exceeded the configured capture bound, so the
30    /// terminal result was never seen.
31    Oversized,
32}
33
34impl UnaccountedReason {
35    /// Every reason, in a stable order.
36    ///
37    /// Callers index metric slots by position, so appending is safe and
38    /// reordering is not.
39    pub const ALL: [Self; 4] = [
40        Self::MediaType,
41        Self::Unparseable,
42        Self::TaskIdMismatch,
43        Self::Oversized,
44    ];
45
46    /// The stable wire name, written out so renaming a variant cannot silently
47    /// change what an operator's dashboard is keyed on.
48    #[must_use]
49    pub const fn as_str(self) -> &'static str {
50        match self {
51            Self::MediaType => "media_type",
52            Self::Unparseable => "unparseable",
53            Self::TaskIdMismatch => "task_id_mismatch",
54            Self::Oversized => "oversized",
55        }
56    }
57
58    /// Position in [`Self::ALL`], and so in the metric slots.
59    #[must_use]
60    pub const fn index(self) -> usize {
61        match self {
62            Self::MediaType => 0,
63            Self::Unparseable => 1,
64            Self::TaskIdMismatch => 2,
65            Self::Oversized => 3,
66        }
67    }
68}
69
70/// Process-local edge counters.
71#[derive(Debug, Default)]
72pub struct EdgeMetrics {
73    classified: AtomicU64,
74    rejected: AtomicU64,
75    unauthenticated: AtomicU64,
76    throttled: AtomicU64,
77    cache_hits: AtomicU64,
78    cache_misses: AtomicU64,
79    billed: AtomicU64,
80    billed_units: AtomicU64,
81    free: AtomicU64,
82    free_by_reason: [AtomicU64; REASONS],
83    duplicates: AtomicU64,
84    record_failures: AtomicU64,
85    deferred: AtomicU64,
86    unrecognized_responses: AtomicU64,
87    unrecognized_by_reason: [AtomicU64; UNACCOUNTED],
88}
89
90/// Point-in-time metric values.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
92pub struct MetricsSnapshot {
93    /// Successfully classified requests.
94    pub classified: u64,
95    /// Requests rejected for malformed or ambiguous MCP headers.
96    pub rejected: u64,
97    /// Requests refused for a missing, malformed, or unknown API key. Tracked
98    /// apart from `rejected` so credential stuffing is distinguishable from
99    /// clients that simply send bad protocol headers.
100    pub unauthenticated: u64,
101    /// Requests refused because the authentication-failure budget was spent.
102    pub throttled: u64,
103    /// Responses served from cache.
104    pub cache_hits: u64,
105    /// Cacheable requests not served from cache.
106    pub cache_misses: u64,
107    /// Delivered calls accepted by the recorder.
108    pub billed: u64,
109    /// Sum of delivered units accepted by the recorder.
110    pub billed_units: u64,
111    /// Exchanges classified as free.
112    pub free: u64,
113    /// Free exchanges split by reason, indexed by [`FreeReason::index`].
114    ///
115    /// The single `free` total cannot answer the question the enum exists for:
116    /// a spike in `missing_task_attribution` means durable-task charges are
117    /// being lost, and collapsed into one counter it is indistinguishable
118    /// from ordinary discovery traffic.
119    pub free_by_reason: [u64; REASONS],
120    /// Repeated once-only charges suppressed by the recorder.
121    pub duplicates: u64,
122    /// Local recorder failures.
123    pub record_failures: u64,
124    /// Terminal accounting parked because it could not finish without awaiting.
125    /// Only a durable task store produces these.
126    pub deferred: u64,
127    /// Bodies that ended without a parseable terminal response.
128    pub unrecognized_responses: u64,
129    /// Unaccounted responses split by cause, indexed by
130    /// [`UnaccountedReason::index`].
131    pub unrecognized_by_reason: [u64; UNACCOUNTED],
132}
133
134impl EdgeMetrics {
135    pub(crate) fn classified(&self) {
136        saturating_add(&self.classified, 1);
137    }
138    pub(crate) fn rejected(&self) {
139        saturating_add(&self.rejected, 1);
140    }
141    pub(crate) fn unauthenticated(&self) {
142        saturating_add(&self.unauthenticated, 1);
143    }
144    pub(crate) fn throttled(&self) {
145        saturating_add(&self.throttled, 1);
146    }
147    pub(crate) fn cache_hit(&self) {
148        saturating_add(&self.cache_hits, 1);
149    }
150    pub(crate) fn cache_miss(&self) {
151        saturating_add(&self.cache_misses, 1);
152    }
153    pub(crate) fn billed(&self, units: u64) {
154        saturating_add(&self.billed, 1);
155        saturating_add(&self.billed_units, units);
156    }
157    pub(crate) fn free(&self, reason: FreeReason) {
158        saturating_add(&self.free, 1);
159        saturating_add(&self.free_by_reason[reason.index()], 1);
160    }
161    pub(crate) fn duplicate(&self) {
162        saturating_add(&self.duplicates, 1);
163    }
164    pub(crate) fn record_failure(&self) {
165        saturating_add(&self.record_failures, 1);
166    }
167    pub(crate) fn deferred(&self) {
168        saturating_add(&self.deferred, 1);
169    }
170    pub(crate) fn unrecognized_response(&self, reason: UnaccountedReason) {
171        saturating_add(&self.unrecognized_responses, 1);
172        saturating_add(&self.unrecognized_by_reason[reason.index()], 1);
173    }
174
175    /// Read every counter using relaxed ordering.
176    #[must_use]
177    pub fn snapshot(&self) -> MetricsSnapshot {
178        MetricsSnapshot {
179            classified: self.classified.load(Ordering::Relaxed),
180            rejected: self.rejected.load(Ordering::Relaxed),
181            unauthenticated: self.unauthenticated.load(Ordering::Relaxed),
182            throttled: self.throttled.load(Ordering::Relaxed),
183            cache_hits: self.cache_hits.load(Ordering::Relaxed),
184            cache_misses: self.cache_misses.load(Ordering::Relaxed),
185            billed: self.billed.load(Ordering::Relaxed),
186            billed_units: self.billed_units.load(Ordering::Relaxed),
187            free: self.free.load(Ordering::Relaxed),
188            free_by_reason: std::array::from_fn(|slot| {
189                self.free_by_reason[slot].load(Ordering::Relaxed)
190            }),
191            duplicates: self.duplicates.load(Ordering::Relaxed),
192            record_failures: self.record_failures.load(Ordering::Relaxed),
193            deferred: self.deferred.load(Ordering::Relaxed),
194            unrecognized_responses: self.unrecognized_responses.load(Ordering::Relaxed),
195            unrecognized_by_reason: std::array::from_fn(|slot| {
196                self.unrecognized_by_reason[slot].load(Ordering::Relaxed)
197            }),
198        }
199    }
200
201    /// Render all counters in the Prometheus text exposition format.
202    ///
203    /// Metric names and help strings are fixed. The only label is `reason`, on
204    /// the two breakdowns, and its values come from the closed [`FreeReason`]
205    /// and [`UnaccountedReason`] enums. Nothing carries a tenant, customer,
206    /// method, or tool label, which keeps cardinality and privacy risk
207    /// bounded.
208    #[must_use]
209    pub fn render_prometheus(&self) -> String {
210        self.snapshot().render_prometheus()
211    }
212}
213
214impl MetricsSnapshot {
215    /// Render this snapshot in the Prometheus text exposition format.
216    #[must_use]
217    pub fn render_prometheus(self) -> String {
218        let mut output = String::with_capacity(3_000);
219        append_metric(
220            &mut output,
221            "mcp_usage_classified_total",
222            "Successfully classified MCP requests.",
223            self.classified,
224        );
225        append_metric(
226            &mut output,
227            "mcp_usage_rejected_total",
228            "MCP requests rejected for malformed or ambiguous headers.",
229            self.rejected,
230        );
231        append_metric(
232            &mut output,
233            "mcp_usage_unauthenticated_total",
234            "MCP requests refused for a missing, malformed, or unknown API key.",
235            self.unauthenticated,
236        );
237        append_metric(
238            &mut output,
239            "mcp_usage_throttled_total",
240            "MCP requests refused after too many failed authentication attempts.",
241            self.throttled,
242        );
243        append_metric(
244            &mut output,
245            "mcp_usage_cache_hits_total",
246            "MCP responses served from the authorization-aware cache.",
247            self.cache_hits,
248        );
249        append_metric(
250            &mut output,
251            "mcp_usage_cache_misses_total",
252            "Cacheable MCP requests not served from cache.",
253            self.cache_misses,
254        );
255        append_metric(
256            &mut output,
257            "mcp_usage_billable_deliveries_total",
258            "Delivered MCP results accepted by the usage recorder.",
259            self.billed,
260        );
261        append_metric(
262            &mut output,
263            "mcp_usage_recorded_units_total",
264            "Delivered MCP units accepted by the usage recorder.",
265            self.billed_units,
266        );
267        append_metric(
268            &mut output,
269            "mcp_usage_free_deliveries_total",
270            "MCP exchanges classified as free.",
271            self.free,
272        );
273        append_labelled_metric(
274            &mut output,
275            "mcp_usage_free_deliveries_by_reason_total",
276            "MCP exchanges classified as free, split by reason.",
277            "reason",
278            FreeReason::ALL
279                .iter()
280                .map(|reason| (reason.as_str(), self.free_by_reason[reason.index()])),
281        );
282        append_metric(
283            &mut output,
284            "mcp_usage_duplicates_total",
285            "Repeated once-only charges suppressed by the recorder.",
286            self.duplicates,
287        );
288        append_metric(
289            &mut output,
290            "mcp_usage_record_failures_total",
291            "Local recorder or attribution-store failures.",
292            self.record_failures,
293        );
294        append_metric(
295            &mut output,
296            "mcp_usage_deferred_completions_total",
297            "Terminal accounting parked to be completed outside the response body.",
298            self.deferred,
299        );
300        append_metric(
301            &mut output,
302            "mcp_usage_unrecognized_responses_total",
303            "Bodies that ended without a recognized terminal MCP response.",
304            self.unrecognized_responses,
305        );
306        append_labelled_metric(
307            &mut output,
308            "mcp_usage_unrecognized_responses_by_reason_total",
309            "Bodies that ended without a recognized terminal MCP response, split by cause.",
310            "reason",
311            UnaccountedReason::ALL
312                .iter()
313                .map(|reason| (reason.as_str(), self.unrecognized_by_reason[reason.index()])),
314        );
315        output
316    }
317}
318
319/// Write one metric family with a single label whose value set is a closed enum.
320///
321/// Every series is emitted on every scrape, including the zeroes, so a rate on
322/// a reason that has not fired yet still resolves instead of breaking the
323/// query. Label values come from [`FreeReason::as_str`], which is a fixed table
324/// of lowercase identifiers, so the series count is bounded and no escaping is
325/// required.
326fn append_labelled_metric<'a>(
327    output: &mut String,
328    name: &str,
329    help: &str,
330    label: &str,
331    series: impl Iterator<Item = (&'a str, u64)>,
332) {
333    output.push_str("# HELP ");
334    output.push_str(name);
335    output.push(' ');
336    output.push_str(help);
337    output.push('\n');
338    output.push_str("# TYPE ");
339    output.push_str(name);
340    output.push_str(" counter\n");
341    for (value, count) in series {
342        output.push_str(name);
343        output.push('{');
344        output.push_str(label);
345        output.push_str("=\"");
346        output.push_str(value);
347        output.push_str("\"} ");
348        output.push_str(&count.to_string());
349        output.push('\n');
350    }
351}
352
353fn append_metric(output: &mut String, name: &str, help: &str, value: u64) {
354    output.push_str("# HELP ");
355    output.push_str(name);
356    output.push(' ');
357    output.push_str(help);
358    output.push('\n');
359    output.push_str("# TYPE ");
360    output.push_str(name);
361    output.push_str(" counter\n");
362    output.push_str(name);
363    output.push(' ');
364    output.push_str(&value.to_string());
365    output.push('\n');
366}
367
368fn saturating_add(counter: &AtomicU64, increment: u64) {
369    let mut current = counter.load(Ordering::Relaxed);
370    loop {
371        let next = current.saturating_add(increment);
372        match counter.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) {
373            Ok(_) => break,
374            Err(observed) => current = observed,
375        }
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn every_unaccounted_reason_has_a_distinct_name_and_a_matching_index() {
385        let mut names = std::collections::HashSet::new();
386        for (slot, reason) in UnaccountedReason::ALL.into_iter().enumerate() {
387            assert_eq!(reason.index(), slot, "{reason:?} indexes the wrong slot");
388            assert!(names.insert(reason.as_str()), "{reason:?} reuses a name");
389        }
390        assert_eq!(names.len(), UNACCOUNTED);
391    }
392
393    #[test]
394    fn counters_saturate_instead_of_wrapping() {
395        let counter = AtomicU64::new(u64::MAX - 1);
396        saturating_add(&counter, 10);
397        assert_eq!(counter.load(Ordering::Relaxed), u64::MAX);
398    }
399
400    #[test]
401    fn prometheus_output_has_stable_low_cardinality_names() {
402        let metrics = EdgeMetrics::default();
403        metrics.classified();
404        metrics.billed(7);
405        let output = metrics.render_prometheus();
406        assert!(output.contains("mcp_usage_classified_total 1\n"));
407        assert!(output.contains("mcp_usage_recorded_units_total 7\n"));
408
409        // `reason` is the only label the edge ever emits, and it is bounded by
410        // the enum. Anything else appearing here is unbounded cardinality.
411        for line in output.lines().filter(|line| line.contains('{')) {
412            assert!(
413                line.starts_with("mcp_usage_free_deliveries_by_reason_total{reason=\"")
414                    || line
415                        .starts_with("mcp_usage_unrecognized_responses_by_reason_total{reason=\""),
416                "unexpected labelled series: {line}"
417            );
418        }
419    }
420
421    #[test]
422    fn free_verdicts_are_counted_against_their_own_reason() {
423        let metrics = EdgeMetrics::default();
424        metrics.free(FreeReason::Discovery);
425        metrics.free(FreeReason::Discovery);
426        metrics.free(FreeReason::MissingTaskAttribution);
427
428        let snapshot = metrics.snapshot();
429        assert_eq!(snapshot.free, 3);
430        assert_eq!(snapshot.free_by_reason[FreeReason::Discovery.index()], 2);
431        assert_eq!(
432            snapshot.free_by_reason[FreeReason::MissingTaskAttribution.index()],
433            1
434        );
435        assert_eq!(snapshot.free_by_reason.iter().sum::<u64>(), snapshot.free);
436
437        let output = snapshot.render_prometheus();
438        assert!(
439            output.contains("mcp_usage_free_deliveries_by_reason_total{reason=\"discovery\"} 2\n")
440        );
441        assert!(output.contains(
442            "mcp_usage_free_deliveries_by_reason_total{reason=\"missing_task_attribution\"} 1\n"
443        ));
444    }
445
446    #[test]
447    fn unaccounted_responses_are_counted_against_their_own_cause() {
448        let metrics = EdgeMetrics::default();
449        metrics.unrecognized_response(UnaccountedReason::MediaType);
450        metrics.unrecognized_response(UnaccountedReason::MediaType);
451        metrics.unrecognized_response(UnaccountedReason::Oversized);
452
453        let snapshot = metrics.snapshot();
454        assert_eq!(snapshot.unrecognized_responses, 3);
455        assert_eq!(
456            snapshot.unrecognized_by_reason[UnaccountedReason::MediaType.index()],
457            2
458        );
459        assert_eq!(
460            snapshot.unrecognized_by_reason[UnaccountedReason::Oversized.index()],
461            1
462        );
463        assert_eq!(
464            snapshot.unrecognized_by_reason.iter().sum::<u64>(),
465            snapshot.unrecognized_responses
466        );
467
468        let output = snapshot.render_prometheus();
469        assert!(output.contains(
470            "mcp_usage_unrecognized_responses_by_reason_total{reason=\"media_type\"} 2\n"
471        ));
472    }
473
474    #[test]
475    fn every_reason_is_scraped_even_before_it_fires() {
476        // A reason missing from the exposition until its first occurrence makes
477        // `rate(...)` on it fail exactly when an operator first goes looking.
478        let output = MetricsSnapshot::default().render_prometheus();
479        for reason in FreeReason::ALL {
480            let series = format!(
481                "mcp_usage_free_deliveries_by_reason_total{{reason=\"{}\"}} 0\n",
482                reason.as_str()
483            );
484            assert!(output.contains(&series), "missing zero series for {series}");
485        }
486        for reason in UnaccountedReason::ALL {
487            let series = format!(
488                "mcp_usage_unrecognized_responses_by_reason_total{{reason=\"{}\"}} 0\n",
489                reason.as_str()
490            );
491            assert!(output.contains(&series), "missing zero series for {series}");
492        }
493    }
494}