Skip to main content

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}