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