solti_api/metrics.rs
1//! Metrics interface for the API layer (HTTP + gRPC).
2//!
3//! Implement [`ApiMetricsBackend`] to record per-request metrics.
4//! The default is [`NoOpApiMetrics`] - zero-cost when no handle is wired in.
5//!
6//! Wiring:
7//! - HTTP: apply [`http_metrics_middleware`] via [`axum::middleware::from_fn_with_state`]
8//! on the router returned by [`HttpApi::router`](crate::HttpApi::router).
9//! - gRPC: construct the service with [`SoltiApiService::new_with_metrics`](crate::SoltiApiService::new_with_metrics)
10//! or call [`build_grpc_server_with_metrics`](crate::build_grpc_server_with_metrics).
11
12use std::sync::Arc;
13
14/// Transport label value — bounded cardinality by construction.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Transport {
17 Http,
18 Grpc,
19}
20
21impl Transport {
22 pub fn as_label(&self) -> &'static str {
23 match self {
24 Transport::Http => "http",
25 Transport::Grpc => "grpc",
26 }
27 }
28}
29
30/// Metrics backend for the API layer.
31///
32/// ## Labels
33///
34/// - `transport`: `http` | `grpc`
35/// - `method`: HTTP method (`GET`, `POST`, ...) for HTTP, RPC method name (`SubmitTask`, ...) for gRPC
36/// - `path`: templated route (`/api/v1/tasks/{id}`) for HTTP via `MatchedPath`, full RPC path (`/solti.v1.SoltiApi/SubmitTask`) for gRPC
37/// - `status`: HTTP status code (200/404/500/...) for HTTP, gRPC code number for gRPC
38///
39/// Cardinality stays bounded because routes are a closed set per version and templated paths avoid per-resource-id explosion.
40pub trait ApiMetricsBackend: Send + Sync + std::fmt::Debug {
41 /// Record a completed request.
42 fn record_request(
43 &self,
44 _transport: Transport,
45 _method: &str,
46 _path: &str,
47 _status: u16,
48 _duration_ms: u64,
49 ) {
50 }
51
52 /// Adjust the in-flight gauge by `delta` (+1 on entry, -1 on exit).
53 fn record_in_flight_delta(&self, _transport: Transport, _delta: i64) {}
54}
55
56/// Zero-cost default implementation.
57#[derive(Debug, Default)]
58pub struct NoOpApiMetrics;
59
60impl ApiMetricsBackend for NoOpApiMetrics {}
61
62/// Shareable handle used throughout this crate.
63pub type ApiMetricsHandle = Arc<dyn ApiMetricsBackend>;
64
65/// Construct a no-op handle: convenient default.
66pub fn noop_api_metrics() -> ApiMetricsHandle {
67 Arc::new(NoOpApiMetrics)
68}
69
70/// Axum middleware that records per-request HTTP metrics.
71///
72/// Apply via `axum::middleware::from_fn_with_state(metrics, http_metrics_middleware)`.
73///
74/// Uses [`axum::extract::MatchedPath`] to capture the route **template**
75/// (e.g. `/api/v1/tasks/{id}`) instead of the raw URL — keeps `path` cardinality bounded.
76#[cfg(feature = "http")]
77pub async fn http_metrics_middleware(
78 axum::extract::State(metrics): axum::extract::State<ApiMetricsHandle>,
79 request: axum::extract::Request,
80 next: axum::middleware::Next,
81) -> axum::response::Response {
82 let method = request.method().as_str().to_string();
83 let path = request
84 .extensions()
85 .get::<axum::extract::MatchedPath>()
86 .map(|mp| mp.as_str().to_string())
87 .unwrap_or_else(|| request.uri().path().to_string());
88
89 metrics.record_in_flight_delta(Transport::Http, 1);
90 let start = std::time::Instant::now();
91 let response = next.run(request).await;
92 let duration_ms = start.elapsed().as_millis() as u64;
93 let status = response.status().as_u16();
94 metrics.record_request(Transport::Http, &method, &path, status, duration_ms);
95 metrics.record_in_flight_delta(Transport::Http, -1);
96 response
97}