Skip to main content

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}