rmcp_server_kit/
metrics.rs1use std::sync::Arc;
16
17use prometheus::{
18 Encoder, HistogramVec, IntCounterVec, Registry, TextEncoder, histogram_opts, opts,
19};
20
21use crate::error::McpxError;
22
23#[derive(Clone, Debug)]
25#[non_exhaustive]
26pub struct McpMetrics {
27 pub registry: Registry,
29 pub http_requests_total: IntCounterVec,
31 pub http_request_duration_seconds: HistogramVec,
33}
34
35impl McpMetrics {
36 pub fn new() -> Result<Self, McpxError> {
43 let registry = Registry::new();
44
45 let http_requests_total = IntCounterVec::new(
46 opts!("rmcp_server_kit_http_requests_total", "Total HTTP requests"),
47 &["method", "path", "status"],
48 )
49 .map_err(|e| McpxError::Metrics(e.to_string()))?;
50 registry
51 .register(Box::new(http_requests_total.clone()))
52 .map_err(|e| McpxError::Metrics(e.to_string()))?;
53
54 let http_request_duration_seconds = HistogramVec::new(
55 histogram_opts!(
56 "rmcp_server_kit_http_request_duration_seconds",
57 "HTTP request duration in seconds"
58 ),
59 &["method", "path"],
60 )
61 .map_err(|e| McpxError::Metrics(e.to_string()))?;
62 registry
63 .register(Box::new(http_request_duration_seconds.clone()))
64 .map_err(|e| McpxError::Metrics(e.to_string()))?;
65
66 Ok(Self {
67 registry,
68 http_requests_total,
69 http_request_duration_seconds,
70 })
71 }
72
73 #[must_use]
75 pub fn encode(&self) -> String {
76 let encoder = TextEncoder::new();
77 let metric_families = self.registry.gather();
78 let mut buf = Vec::new();
79 if let Err(e) = encoder.encode(&metric_families, &mut buf) {
80 tracing::warn!(error = %e, "prometheus encode failed");
81 return String::new();
82 }
83 String::from_utf8(buf).unwrap_or_default()
86 }
87}
88
89pub async fn serve_metrics(bind: String, metrics: Arc<McpMetrics>) -> Result<(), McpxError> {
96 let app = axum::Router::new().route(
97 "/metrics",
98 axum::routing::get(move || {
99 let m = Arc::clone(&metrics);
100 async move { m.encode() }
101 }),
102 );
103
104 let listener = tokio::net::TcpListener::bind(&bind)
105 .await
106 .map_err(|e| McpxError::Startup(format!("metrics bind {bind}: {e}")))?;
107 tracing::info!("metrics endpoint listening on http://{bind}/metrics");
108 axum::serve(listener, app)
109 .await
110 .map_err(|e| McpxError::Startup(format!("metrics serve: {e}")))?;
111 Ok(())
112}
113
114#[cfg(test)]
115mod tests {
116 #![allow(
117 clippy::unwrap_used,
118 clippy::expect_used,
119 clippy::panic,
120 clippy::indexing_slicing,
121 clippy::unwrap_in_result,
122 clippy::print_stdout,
123 clippy::print_stderr
124 )]
125 use super::*;
126
127 #[test]
128 fn new_creates_registry_with_counters() {
129 let m = McpMetrics::new().unwrap();
130 m.http_requests_total
132 .with_label_values(&["GET", "/test", "200"])
133 .inc();
134 m.http_request_duration_seconds
135 .with_label_values(&["GET", "/test"])
136 .observe(0.1);
137 assert_eq!(m.registry.gather().len(), 2);
138 }
139
140 #[test]
141 fn encode_empty_registry() {
142 let m = McpMetrics::new().unwrap();
143 let output = m.encode();
144 assert!(output.is_empty() || output.contains("rmcp_server_kit_"));
146 }
147
148 #[test]
149 fn counter_increment_shows_in_encode() {
150 let m = McpMetrics::new().unwrap();
151 m.http_requests_total
152 .with_label_values(&["GET", "/healthz", "200"])
153 .inc();
154 let output = m.encode();
155 assert!(output.contains("rmcp_server_kit_http_requests_total"));
156 assert!(output.contains("method=\"GET\""));
157 assert!(output.contains("path=\"/healthz\""));
158 assert!(output.contains("status=\"200\""));
159 assert!(output.contains(" 1")); }
161
162 #[test]
163 fn histogram_observe_shows_in_encode() {
164 let m = McpMetrics::new().unwrap();
165 m.http_request_duration_seconds
166 .with_label_values(&["POST", "/mcp"])
167 .observe(0.042);
168 let output = m.encode();
169 assert!(output.contains("rmcp_server_kit_http_request_duration_seconds"));
170 assert!(output.contains("method=\"POST\""));
171 assert!(output.contains("path=\"/mcp\""));
172 }
173
174 #[test]
175 fn multiple_increments_accumulate() {
176 let m = McpMetrics::new().unwrap();
177 let counter = m
178 .http_requests_total
179 .with_label_values(&["POST", "/mcp", "200"]);
180 counter.inc();
181 counter.inc();
182 counter.inc();
183 let output = m.encode();
184 assert!(output.contains(" 3")); }
186
187 #[test]
188 fn clone_shares_registry() {
189 let m = McpMetrics::new().unwrap();
190 let m2 = m.clone();
191 m.http_requests_total
192 .with_label_values(&["GET", "/test", "200"])
193 .inc();
194 let output = m2.encode();
196 assert!(output.contains(" 1"));
197 }
198}