snap_control/server/
metrics.rs1use std::{
17 future::Future,
18 pin::Pin,
19 task::{Context, Poll},
20 time::Instant,
21};
22
23use axum::{body::Body, extract::MatchedPath};
24use http::{Request, Response};
25use prometheus::{HistogramVec, IntCounterVec};
26use scion_sdk_observability::metrics::registry::MetricsRegistry;
27use tower::{BoxError, Layer, Service};
28
29const UNMATCHED_ROUTE: &str = "<unmatched>";
34
35#[derive(Clone)]
37pub struct PrometheusMiddlewareLayer {
38 metrics: Metrics,
39}
40
41impl PrometheusMiddlewareLayer {
42 pub fn new(metrics: Metrics) -> Self {
44 Self { metrics }
45 }
46}
47
48impl<S> Layer<S> for PrometheusMiddlewareLayer {
49 type Service = PrometheusMiddleware<S>;
50
51 fn layer(&self, inner: S) -> Self::Service {
52 PrometheusMiddleware::new(inner, self.metrics.clone())
53 }
54}
55
56#[derive(Clone)]
58pub struct PrometheusMiddleware<S> {
59 inner: S,
60 metrics: Metrics,
61}
62
63impl<S> PrometheusMiddleware<S> {
64 pub fn new(inner: S, metrics: Metrics) -> Self {
66 Self { inner, metrics }
67 }
68}
69
70impl<S> Service<Request<Body>> for PrometheusMiddleware<S>
71where
72 S: Service<Request<Body>, Response = Response<Body>> + Send + Clone + 'static,
73 S::Error: Into<BoxError>,
74 S::Future: Send + 'static,
75{
76 type Response = Response<Body>;
77 type Error = BoxError;
78 type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
79
80 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
81 self.inner.poll_ready(cx).map_err(Into::into)
82 }
83
84 fn call(&mut self, request: Request<Body>) -> Self::Future {
85 let rpc_method = request
89 .extensions()
90 .get::<MatchedPath>()
91 .map(|p| p.as_str().to_string())
92 .unwrap_or_else(|| UNMATCHED_ROUTE.to_string());
93 let metrics = self.metrics.clone();
94
95 metrics
97 .control_plane_started_total
98 .with_label_values(&[&rpc_method])
99 .inc();
100
101 let fut = self.inner.call(request);
102 let start = Instant::now();
103
104 Box::pin(async move {
105 let result = fut.await.map_err(Into::into)?;
106 let status = result.status().as_str().to_string();
107
108 metrics
110 .control_plane_handled_total
111 .with_label_values(&[&rpc_method, &status])
112 .inc();
113
114 let elapsed = start.elapsed().as_secs_f64();
116 metrics
117 .control_plane_latency_seconds
118 .with_label_values(&[&rpc_method, &status])
119 .observe(elapsed);
120
121 Ok(result)
122 })
123 }
124}
125
126#[derive(Debug, Clone)]
128pub struct Metrics {
129 pub control_plane_started_total: IntCounterVec,
131 pub control_plane_handled_total: IntCounterVec,
133 pub control_plane_latency_seconds: HistogramVec,
135}
136
137impl Metrics {
138 pub fn new(metrics_registry: &MetricsRegistry) -> Self {
140 Metrics {
141 control_plane_started_total: metrics_registry.int_counter_vec(
142 "control_plane_requests_started_total",
143 "Total number of control plane API requests started on the server.",
144 &["rpc_method"],
145 ),
146 control_plane_handled_total: metrics_registry.int_counter_vec(
147 "control_plane_requests_handled_total",
148 "Total number of control plane API requests handled on the server.",
149 &["rpc_method", "status"],
150 ),
151 control_plane_latency_seconds: metrics_registry.histogram_vec(
152 "control_plane_requests_latency_seconds",
153 "Latency of control plane API requests in seconds.",
154 vec![0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0],
155 &["rpc_method", "status"],
156 ),
157 }
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use axum::{Router, error_handling::HandleErrorLayer, routing::get};
164 use http::StatusCode;
165 use scion_sdk_observability::metrics::registry::MetricsRegistry;
166 use tower::{ServiceBuilder, ServiceExt};
167
168 use super::*;
169
170 fn test_app() -> (Router, Metrics) {
171 let metrics = Metrics::new(&MetricsRegistry::new());
172 let app = Router::new()
173 .route("/my-path/{id}", get(|| async { "ok" }))
174 .layer(
175 ServiceBuilder::new()
179 .layer(HandleErrorLayer::new(|_: BoxError| {
180 async move { StatusCode::INTERNAL_SERVER_ERROR }
181 }))
182 .layer(PrometheusMiddlewareLayer::new(metrics.clone())),
183 );
184 (app, metrics)
185 }
186
187 #[tokio::test]
188 async fn matched_route_uses_route_pattern_as_label() {
189 let (app, metrics) = test_app();
190
191 let response = app
192 .oneshot(
193 Request::builder()
194 .uri("/my-path/test")
195 .body(Body::empty())
196 .unwrap(),
197 )
198 .await
199 .unwrap();
200
201 assert_eq!(response.status(), StatusCode::OK);
202 assert_eq!(
203 metrics
204 .control_plane_started_total
205 .with_label_values(&["/my-path/{id}"])
206 .get(),
207 1
208 );
209 }
210
211 #[tokio::test]
212 async fn unmatched_route_collapses_to_single_label() {
213 let (app, metrics) = test_app();
214
215 for path in ["/secrets", "/private.key", "/.git/config"] {
216 let response = app
217 .clone()
218 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
219 .await
220 .unwrap();
221 assert_eq!(response.status(), StatusCode::NOT_FOUND);
222 }
223
224 assert_eq!(
225 metrics
226 .control_plane_started_total
227 .with_label_values(&[UNMATCHED_ROUTE])
228 .get(),
229 3
230 );
231 }
232}