1use std::sync::atomic::{AtomicU64, Ordering};
4
5#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub struct MetricsSnapshot {
26 pub classified: u64,
28 pub rejected: u64,
30 pub unauthenticated: u64,
34 pub throttled: u64,
36 pub cache_hits: u64,
38 pub cache_misses: u64,
40 pub billed: u64,
42 pub billed_units: u64,
44 pub free: u64,
46 pub duplicates: u64,
48 pub record_failures: u64,
50 pub deferred: u64,
53 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 #[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 #[must_use]
121 pub fn render_prometheus(&self) -> String {
122 self.snapshot().render_prometheus()
123 }
124}
125
126impl MetricsSnapshot {
127 #[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}