rmcp_server_kit/
metrics.rs1use std::sync::Arc;
16
17use prometheus::{
18 Encoder, HistogramOpts, HistogramVec, IntCounterVec, Registry, TextEncoder, opts,
19};
20
21use crate::error::RmcpServerKitError;
22
23const HTTP_DURATION_BUCKETS: &[f64] = &[
30 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0,
31];
32
33#[derive(Clone, Debug)]
35#[non_exhaustive]
36pub struct McpMetrics {
37 pub registry: Registry,
39 pub http_requests_total: IntCounterVec,
41 pub http_request_duration_seconds: HistogramVec,
43 pub rate_limited_total: IntCounterVec,
48}
49
50impl McpMetrics {
51 pub fn new() -> Result<Self, RmcpServerKitError> {
58 let registry = Registry::new();
59
60 let http_requests_total = IntCounterVec::new(
61 opts!("rmcp_server_kit_http_requests_total", "Total HTTP requests"),
62 &["method", "path", "status"],
63 )
64 .map_err(|e| RmcpServerKitError::Metrics(e.to_string()))?;
65 registry
66 .register(Box::new(http_requests_total.clone()))
67 .map_err(|e| RmcpServerKitError::Metrics(e.to_string()))?;
68
69 let http_request_duration_seconds = HistogramVec::new(
70 HistogramOpts::new(
71 "rmcp_server_kit_http_request_duration_seconds",
72 "HTTP request duration in seconds",
73 )
74 .buckets(HTTP_DURATION_BUCKETS.to_vec()),
75 &["method", "path"],
76 )
77 .map_err(|e| RmcpServerKitError::Metrics(e.to_string()))?;
78 registry
79 .register(Box::new(http_request_duration_seconds.clone()))
80 .map_err(|e| RmcpServerKitError::Metrics(e.to_string()))?;
81
82 let rate_limited_total = IntCounterVec::new(
83 opts!(
84 "rmcp_server_kit_rate_limited_total",
85 "Rate-limiter denials by limiter"
86 ),
87 &["limiter"],
88 )
89 .map_err(|e| RmcpServerKitError::Metrics(e.to_string()))?;
90 registry
91 .register(Box::new(rate_limited_total.clone()))
92 .map_err(|e| RmcpServerKitError::Metrics(e.to_string()))?;
93
94 Ok(Self {
95 registry,
96 http_requests_total,
97 http_request_duration_seconds,
98 rate_limited_total,
99 })
100 }
101
102 #[must_use]
109 pub fn encode(&self) -> String {
110 let encoder = TextEncoder::new();
111 let metric_families = self.registry.gather();
112 let mut buf = Vec::new();
113 if let Err(error) = encoder.encode(&metric_families, &mut buf) {
114 return encode_failure_body(&error);
115 }
116 String::from_utf8(buf).unwrap_or_default()
119 }
120}
121
122pub(crate) const ENCODE_FAILURE_MARKER: &str =
125 "# rmcp-server-kit: prometheus encoding failed - see server logs\n";
126
127fn encode_failure_body(error: &prometheus::Error) -> String {
135 tracing::error!(error = %error, "prometheus encoding failed; serving error marker body");
136 ENCODE_FAILURE_MARKER.to_owned()
137}
138
139pub(crate) fn record_rate_limit_deny(ext: &axum::http::Extensions, limiter: &str) {
148 if let Some(m) = ext.get::<Arc<McpMetrics>>() {
149 m.rate_limited_total.with_label_values(&[limiter]).inc();
150 }
151}
152
153pub async fn serve_metrics(
167 bind: String,
168 metrics: Arc<McpMetrics>,
169 shutdown: tokio_util::sync::CancellationToken,
170) -> Result<(), RmcpServerKitError> {
171 let app = axum::Router::new().route(
172 "/metrics",
173 axum::routing::get(move || {
174 let m = Arc::clone(&metrics);
175 async move { m.encode() }
176 }),
177 );
178
179 let listener = tokio::net::TcpListener::bind(&bind)
180 .await
181 .map_err(|e| RmcpServerKitError::Startup(format!("metrics bind {bind}: {e}")))?;
182 tracing::info!("metrics endpoint listening on http://{bind}/metrics");
183 axum::serve(listener, app)
184 .with_graceful_shutdown(async move { shutdown.cancelled().await })
185 .await
186 .map_err(|e| RmcpServerKitError::Startup(format!("metrics serve: {e}")))?;
187 Ok(())
188}
189
190#[cfg(test)]
191mod tests {
192 #![allow(
193 clippy::unwrap_used,
194 clippy::expect_used,
195 clippy::panic,
196 clippy::indexing_slicing,
197 clippy::unwrap_in_result,
198 clippy::print_stdout,
199 clippy::print_stderr,
200 reason = "test-only relaxations; production code uses ? and tracing"
201 )]
202 use super::*;
203
204 #[test]
205 fn encode_failure_returns_stable_non_empty_marker() {
206 let body = encode_failure_body(&prometheus::Error::Msg("encoder exploded".to_owned()));
211
212 assert!(
213 !body.is_empty(),
214 "a failed encode must never serve an empty body"
215 );
216 assert!(
217 body.contains("rmcp-server-kit: prometheus encoding failed"),
218 "marker body must be stable for scrapers/alerts: {body:?}"
219 );
220 assert_eq!(body, ENCODE_FAILURE_MARKER);
221 }
222
223 #[test]
224 fn new_creates_registry_with_counters() {
225 let m = McpMetrics::new().unwrap();
226 m.http_requests_total
228 .with_label_values(&["GET", "/test", "200"])
229 .inc();
230 m.http_request_duration_seconds
231 .with_label_values(&["GET", "/test"])
232 .observe(0.1);
233 assert_eq!(m.registry.gather().len(), 2);
234 }
235
236 #[test]
237 fn encode_empty_registry() {
238 let m = McpMetrics::new().unwrap();
239 let output = m.encode();
240 assert!(output.is_empty() || output.contains("rmcp_server_kit_"));
242 }
243
244 #[test]
245 fn counter_increment_shows_in_encode() {
246 let m = McpMetrics::new().unwrap();
247 m.http_requests_total
248 .with_label_values(&["GET", "/healthz", "200"])
249 .inc();
250 let output = m.encode();
251 assert!(output.contains("rmcp_server_kit_http_requests_total"));
252 assert!(output.contains("method=\"GET\""));
253 assert!(output.contains("path=\"/healthz\""));
254 assert!(output.contains("status=\"200\""));
255 assert!(output.contains(" 1")); }
257
258 #[test]
259 fn histogram_observe_shows_in_encode() {
260 let m = McpMetrics::new().unwrap();
261 m.http_request_duration_seconds
262 .with_label_values(&["POST", "/mcp"])
263 .observe(0.042);
264 let output = m.encode();
265 assert!(output.contains("rmcp_server_kit_http_request_duration_seconds"));
266 assert!(output.contains("method=\"POST\""));
267 assert!(output.contains("path=\"/mcp\""));
268 }
269
270 #[test]
271 fn multiple_increments_accumulate() {
272 let m = McpMetrics::new().unwrap();
273 let counter = m
274 .http_requests_total
275 .with_label_values(&["POST", "/mcp", "200"]);
276 counter.inc();
277 counter.inc();
278 counter.inc();
279 let output = m.encode();
280 assert!(output.contains(" 3")); }
282
283 #[test]
284 fn clone_shares_registry() {
285 let m = McpMetrics::new().unwrap();
286 let m2 = m.clone();
287 m.http_requests_total
288 .with_label_values(&["GET", "/test", "200"])
289 .inc();
290 let output = m2.encode();
292 assert!(output.contains(" 1"));
293 }
294
295 #[test]
296 fn rate_limited_counter_registers_and_encodes() {
297 let m = McpMetrics::new().unwrap();
298 m.rate_limited_total.with_label_values(&["tool"]).inc();
299 let output = m.encode();
300 assert!(output.contains("rmcp_server_kit_rate_limited_total"));
301 assert!(output.contains("limiter=\"tool\""));
302 assert!(output.contains(" 1"));
303 }
304
305 #[test]
306 fn record_rate_limit_deny_increments_via_extension() {
307 let m = Arc::new(McpMetrics::new().unwrap());
308 let mut ext = axum::http::Extensions::new();
309 ext.insert(Arc::clone(&m));
310 record_rate_limit_deny(&ext, "auth_pre");
311 record_rate_limit_deny(&ext, "auth_pre");
312 assert_eq!(
313 m.rate_limited_total.with_label_values(&["auth_pre"]).get(),
314 2
315 );
316 let empty = axum::http::Extensions::new();
318 record_rate_limit_deny(&empty, "auth_pre");
319 assert_eq!(
320 m.rate_limited_total.with_label_values(&["auth_pre"]).get(),
321 2
322 );
323 }
324
325 #[tokio::test]
331 async fn serve_metrics_releases_port_on_shutdown() {
332 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
335 let addr = probe.local_addr().unwrap();
336 drop(probe);
337
338 let metrics = Arc::new(McpMetrics::new().unwrap());
339 let shutdown = tokio_util::sync::CancellationToken::new();
340 let handle = tokio::spawn(serve_metrics(
341 addr.to_string(),
342 Arc::clone(&metrics),
343 shutdown.clone(),
344 ));
345
346 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
348 loop {
349 if tokio::net::TcpStream::connect(addr).await.is_ok() {
350 break;
351 }
352 assert!(
353 std::time::Instant::now() < deadline,
354 "metrics listener never accepted on {addr}"
355 );
356 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
357 }
358
359 shutdown.cancel();
361 let join = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
362 .await
363 .expect("serve_metrics did not return within timeout");
364 join.expect("join error")
365 .expect("serve_metrics returned Err");
366
367 let rebind = tokio::net::TcpListener::bind(addr)
369 .await
370 .expect("port not released after shutdown");
371 drop(rebind);
372 }
373}