1use std::sync::Arc;
17
18#[cfg(any(feature = "grpc", feature = "http"))]
19use std::time::{Duration, Instant};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Transport {
26 Http,
28 Grpc,
30}
31
32impl Transport {
33 pub fn as_label(&self) -> &'static str {
35 match self {
36 Transport::Http => "http",
37 Transport::Grpc => "grpc",
38 }
39 }
40}
41
42pub trait ApiMetricsBackend: Send + Sync + std::fmt::Debug {
57 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 fn record_in_flight_delta(&self, _transport: Transport, _delta: i64) {}
73}
74
75#[derive(Debug, Default)]
77pub struct NoOpApiMetrics;
78
79impl ApiMetricsBackend for NoOpApiMetrics {}
80
81pub type ApiMetricsHandle = Arc<dyn ApiMetricsBackend>;
83
84pub fn noop_api_metrics() -> ApiMetricsHandle {
86 Arc::new(NoOpApiMetrics)
87}
88
89#[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#[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}