Skip to main content

snap_control/server/
metrics.rs

1// Copyright 2025 Anapaya Systems
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//   http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//! SNAP control plane API Prometheus middleware.
15
16use 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
29/// Label value used for requests that did not match any registered route.
30///
31/// This collapses probes against non-existent endpoints into a single time series instead of
32/// creating one series per unique (bogus) path.
33const UNMATCHED_ROUTE: &str = "<unmatched>";
34
35/// Prometheus middleware layer for tracking control plane API metrics.
36#[derive(Clone)]
37pub struct PrometheusMiddlewareLayer {
38    metrics: Metrics,
39}
40
41impl PrometheusMiddlewareLayer {
42    /// Create a new Prometheus middleware layer with the given metrics.
43    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/// Prometheus middleware for tracking control plane API metrics.
57#[derive(Clone)]
58pub struct PrometheusMiddleware<S> {
59    inner: S,
60    metrics: Metrics,
61}
62
63impl<S> PrometheusMiddleware<S> {
64    /// Create a new Prometheus middleware with the given service and metrics.
65    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        // Use the matched route pattern, this keeps the label cardinality bounded to the set of
86        // endpoints that actually exist. Requests that do not match any route have no `MatchedPath`
87        // extension and are bucketed under a single label instead of exploding the label space.
88        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        // Increment started metric
96        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            // Increment handled metric
109            metrics
110                .control_plane_handled_total
111                .with_label_values(&[&rpc_method, &status])
112                .inc();
113
114            // Observe latency
115            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/// SNAP control plane API metrics.
127#[derive(Debug, Clone)]
128pub struct Metrics {
129    /// Total number of control plane API requests started on the server.
130    pub control_plane_started_total: IntCounterVec,
131    /// Total number of control plane API requests handled on the server.
132    pub control_plane_handled_total: IntCounterVec,
133    /// Latency of control plane API requests in seconds.
134    pub control_plane_latency_seconds: HistogramVec,
135}
136
137impl Metrics {
138    /// Create new metrics instance with the given registry.
139    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                // Mirror the production stack: `HandleErrorLayer` absorbs the middleware's
176                // `BoxError` so the layered service satisfies `Router::layer`'s `Infallible`
177                // error bound.
178                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}