1use std::sync::atomic::{AtomicU64, Ordering};
4
5use mcp_usage_core::FreeReason;
6
7const REASONS: usize = FreeReason::ALL.len();
9
10const UNACCOUNTED: usize = UnaccountedReason::ALL.len();
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum UnaccountedReason {
23 MediaType,
25 Unparseable,
27 TaskIdMismatch,
29 Oversized,
32}
33
34impl UnaccountedReason {
35 pub const ALL: [Self; 4] = [
40 Self::MediaType,
41 Self::Unparseable,
42 Self::TaskIdMismatch,
43 Self::Oversized,
44 ];
45
46 #[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 #[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
92pub struct MetricsSnapshot {
93 pub classified: u64,
95 pub rejected: u64,
97 pub unauthenticated: u64,
101 pub throttled: u64,
103 pub cache_hits: u64,
105 pub cache_misses: u64,
107 pub billed: u64,
109 pub billed_units: u64,
111 pub free: u64,
113 pub free_by_reason: [u64; REASONS],
120 pub duplicates: u64,
122 pub record_failures: u64,
124 pub deferred: u64,
127 pub unrecognized_responses: u64,
129 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 #[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 #[must_use]
209 pub fn render_prometheus(&self) -> String {
210 self.snapshot().render_prometheus()
211 }
212}
213
214impl MetricsSnapshot {
215 #[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
319fn 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 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 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}