Skip to main content

solti_api/
metrics.rs

1//! # API Metrics
2//!
3//! HTTP and gRPC report through one [`ApiMetricsBackend`].
4//! Both transport builders use [`NoOpApiMetrics`] by default.
5//!
6//! ```text
7//! HTTP request ──┐
8//!                ├──► ApiMetricsBackend
9//! gRPC call ─────┘
10//! ```
11//!
12//! Route labels are bounded.
13//! HTTP uses matched route templates.
14//! gRPC uses full service paths.
15
16use std::sync::Arc;
17
18#[cfg(any(feature = "grpc", feature = "http"))]
19use std::time::{Duration, Instant};
20
21/// Transport that served an API request.
22///
23/// This closed set keeps the transport label bounded.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Transport {
26    /// Axum HTTP/JSON transport.
27    Http,
28    /// Tonic gRPC transport.
29    Grpc,
30}
31
32impl Transport {
33    /// Returns the stable lowercase metric label.
34    pub fn as_label(&self) -> &'static str {
35        match self {
36            Transport::Http => "http",
37            Transport::Grpc => "grpc",
38        }
39    }
40}
41
42/// Receives API request lifecycle metrics.
43///
44/// ## Labels
45///
46/// | Value       | HTTP                                  | gRPC                                      |
47/// |-------------|---------------------------------------|-------------------------------------------|
48/// | `method`    | Method such as `GET`                  | RPC name such as `CreateTask`             |
49/// | `path`      | Matched route template                | Full service and RPC path                 |
50/// | `status`    | Numeric HTTP status                   | Numeric tonic status code                 |
51/// | `transport` | [`Transport::Http`]                   | [`Transport::Grpc`]                       |
52///
53/// `record_request` is called after normal completion or stream termination.
54/// It is not called when a request future or stream is dropped first.
55/// The in-flight decrement still occurs on drop.
56pub trait ApiMetricsBackend: Send + Sync + std::fmt::Debug {
57    /// Records one completed request or terminated stream.
58    fn record_request(
59        &self,
60        _transport: Transport,
61        _method: &str,
62        _path: &str,
63        _status: u16,
64        _duration_ms: u64,
65    ) {
66    }
67
68    /// Adjusts the in-flight gauge.
69    ///
70    /// Entry uses `1`.
71    /// Completion, failure, cancellation, and drop use `-1`.
72    fn record_in_flight_delta(&self, _transport: Transport, _delta: i64) {}
73}
74
75/// Metrics backend that ignores every update.
76#[derive(Debug, Default)]
77pub struct NoOpApiMetrics;
78
79impl ApiMetricsBackend for NoOpApiMetrics {}
80
81/// Shared metrics backend handle.
82pub type ApiMetricsHandle = Arc<dyn ApiMetricsBackend>;
83
84/// Creates a shared no-op metrics backend.
85pub fn noop_api_metrics() -> ApiMetricsHandle {
86    Arc::new(NoOpApiMetrics)
87}
88
89/// Keeps the in-flight gauge balanced across every exit path.
90#[cfg(any(feature = "grpc", feature = "http"))]
91pub(crate) struct InFlightGuard {
92    metrics: ApiMetricsHandle,
93    transport: Transport,
94}
95
96#[cfg(any(feature = "grpc", feature = "http"))]
97impl InFlightGuard {
98    pub(crate) fn enter(metrics: &ApiMetricsHandle, transport: Transport) -> Self {
99        metrics.record_in_flight_delta(transport, 1);
100        Self {
101            metrics: Arc::clone(metrics),
102            transport,
103        }
104    }
105}
106
107#[cfg(any(feature = "grpc", feature = "http"))]
108impl Drop for InFlightGuard {
109    fn drop(&mut self) {
110        self.metrics.record_in_flight_delta(self.transport, -1);
111    }
112}
113
114#[cfg(any(feature = "grpc", feature = "http"))]
115pub(crate) struct RequestMetrics {
116    metrics: ApiMetricsHandle,
117    transport: Transport,
118    method: String,
119    path: String,
120    started_at: Instant,
121    in_flight: Option<InFlightGuard>,
122}
123
124#[cfg(any(feature = "grpc", feature = "http"))]
125impl RequestMetrics {
126    pub(crate) fn enter(
127        metrics: &ApiMetricsHandle,
128        transport: Transport,
129        method: impl Into<String>,
130        path: impl Into<String>,
131    ) -> Self {
132        Self {
133            metrics: Arc::clone(metrics),
134            transport,
135            method: method.into(),
136            path: path.into(),
137            started_at: Instant::now(),
138            in_flight: Some(InFlightGuard::enter(metrics, transport)),
139        }
140    }
141
142    pub(crate) fn complete(&mut self, status: u16) {
143        let Some(in_flight) = self.in_flight.take() else {
144            return;
145        };
146        let duration_ms = duration_millis(self.started_at.elapsed());
147        self.metrics.record_request(
148            self.transport,
149            &self.method,
150            &self.path,
151            status,
152            duration_ms,
153        );
154        drop(in_flight);
155    }
156}
157
158#[cfg(any(feature = "grpc", feature = "http"))]
159fn duration_millis(duration: Duration) -> u64 {
160    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
161}
162
163#[cfg(feature = "http")]
164#[derive(Debug, Clone, Copy)]
165pub(crate) struct StreamingResponse;
166
167#[cfg(feature = "http")]
168struct HttpMetricsStream {
169    inner: axum::body::BodyDataStream,
170    request: RequestMetrics,
171    status: u16,
172}
173
174#[cfg(feature = "http")]
175impl tokio_stream::Stream for HttpMetricsStream {
176    type Item = Result<axum::body::Bytes, axum::Error>;
177
178    fn poll_next(
179        mut self: std::pin::Pin<&mut Self>,
180        context: &mut std::task::Context<'_>,
181    ) -> std::task::Poll<Option<Self::Item>> {
182        match std::pin::Pin::new(&mut self.inner).poll_next(context) {
183            std::task::Poll::Ready(Some(Err(error))) => {
184                let status = self.status;
185                self.request.complete(status);
186                std::task::Poll::Ready(Some(Err(error)))
187            }
188            std::task::Poll::Ready(None) => {
189                let status = self.status;
190                self.request.complete(status);
191                std::task::Poll::Ready(None)
192            }
193            poll => poll,
194        }
195    }
196}
197
198/// Records HTTP request metrics around the next service.
199///
200/// Matched routes use their template.
201/// Unmatched requests use the stable `<unmatched>` label.
202#[cfg(feature = "http")]
203pub(crate) async fn http_metrics_middleware(
204    axum::extract::State(metrics): axum::extract::State<ApiMetricsHandle>,
205    request: axum::extract::Request,
206    next: axum::middleware::Next,
207) -> axum::response::Response {
208    let method = request.method().as_str().to_string();
209    let path = request
210        .extensions()
211        .get::<axum::extract::MatchedPath>()
212        .map(|mp| mp.as_str().to_string())
213        .unwrap_or_else(|| "<unmatched>".to_string());
214
215    let mut request_metrics = RequestMetrics::enter(&metrics, Transport::Http, method, path);
216    let response = next.run(request).await;
217    let status = response.status().as_u16();
218    if response.extensions().get::<StreamingResponse>().is_some() {
219        let (parts, body) = response.into_parts();
220        let stream = HttpMetricsStream {
221            inner: body.into_data_stream(),
222            request: request_metrics,
223            status,
224        };
225        axum::response::Response::from_parts(parts, axum::body::Body::from_stream(stream))
226    } else {
227        request_metrics.complete(status);
228        response
229    }
230}
231
232#[cfg(all(test, feature = "http"))]
233mod tests {
234    use std::sync::{
235        Mutex,
236        atomic::{AtomicI64, Ordering},
237    };
238
239    use axum::{
240        Router,
241        body::{Body, Bytes},
242        http::{Request, StatusCode},
243        middleware,
244        response::Response,
245        routing::get,
246    };
247    use http_body_util::BodyExt;
248    use tower::ServiceExt;
249
250    use super::*;
251
252    #[derive(Debug, Default)]
253    struct Probe {
254        paths: Mutex<Vec<String>>,
255        statuses: Mutex<Vec<u16>>,
256        in_flight: AtomicI64,
257    }
258
259    impl ApiMetricsBackend for Probe {
260        fn record_request(
261            &self,
262            _transport: Transport,
263            _method: &str,
264            path: &str,
265            status: u16,
266            _duration_ms: u64,
267        ) {
268            self.paths.lock().unwrap().push(path.to_string());
269            self.statuses.lock().unwrap().push(status);
270        }
271
272        fn record_in_flight_delta(&self, _transport: Transport, delta: i64) {
273            self.in_flight.fetch_add(delta, Ordering::SeqCst);
274        }
275    }
276
277    #[tokio::test]
278    async fn unmatched_routes_use_one_bounded_path_label() {
279        let probe = Arc::new(Probe::default());
280        let metrics: ApiMetricsHandle = probe.clone();
281        let app = Router::new()
282            .fallback(|| async { axum::http::StatusCode::NOT_FOUND })
283            .layer(middleware::from_fn_with_state(
284                metrics,
285                http_metrics_middleware,
286            ));
287
288        for path in ["/missing/one", "/missing/two"] {
289            app.clone()
290                .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
291                .await
292                .unwrap();
293        }
294
295        let paths = probe.paths.lock().unwrap();
296        assert_eq!(paths.len(), 2);
297        assert!(paths.iter().all(|path| path == "<unmatched>"));
298    }
299
300    #[tokio::test]
301    async fn cancellation_releases_http_in_flight_gauge() {
302        let probe = Arc::new(Probe::default());
303        let metrics: ApiMetricsHandle = probe.clone();
304        let app = Router::new()
305            .route(
306                "/pending",
307                get(|| async { std::future::pending::<axum::http::StatusCode>().await }),
308            )
309            .layer(middleware::from_fn_with_state(
310                metrics,
311                http_metrics_middleware,
312            ));
313
314        let request = Request::builder()
315            .uri("/pending")
316            .body(Body::empty())
317            .unwrap();
318        let task = tokio::spawn(app.oneshot(request));
319        for _ in 0..100 {
320            if probe.in_flight.load(Ordering::SeqCst) == 1 {
321                break;
322            }
323            tokio::task::yield_now().await;
324        }
325        assert_eq!(probe.in_flight.load(Ordering::SeqCst), 1);
326
327        task.abort();
328        let _ = task.await;
329        assert_eq!(probe.in_flight.load(Ordering::SeqCst), 0);
330    }
331
332    #[tokio::test]
333    async fn streaming_body_error_records_sent_status_once() {
334        async fn error_stream() -> Response {
335            let stream = tokio_stream::once(Err::<Bytes, std::io::Error>(std::io::Error::other(
336                "stream failed",
337            )));
338            let mut response = Response::builder()
339                .status(StatusCode::ACCEPTED)
340                .body(Body::from_stream(stream))
341                .unwrap();
342            response.extensions_mut().insert(StreamingResponse);
343            response
344        }
345
346        let probe = Arc::new(Probe::default());
347        let metrics: ApiMetricsHandle = probe.clone();
348        let app = Router::new().route("/stream", get(error_stream)).layer(
349            middleware::from_fn_with_state(metrics, http_metrics_middleware),
350        );
351
352        let response = app
353            .oneshot(
354                Request::builder()
355                    .uri("/stream")
356                    .body(Body::empty())
357                    .unwrap(),
358            )
359            .await
360            .unwrap();
361
362        assert_eq!(probe.in_flight.load(Ordering::SeqCst), 1);
363        assert!(probe.paths.lock().unwrap().is_empty());
364        assert!(response.into_body().collect().await.is_err());
365        assert_eq!(probe.in_flight.load(Ordering::SeqCst), 0);
366        assert_eq!(probe.paths.lock().unwrap().len(), 1);
367        assert_eq!(
368            probe.statuses.lock().unwrap().as_slice(),
369            &[StatusCode::ACCEPTED.as_u16()]
370        );
371    }
372}