Skip to main content

api_tools/server/axum/handlers/
prometheus.rs

1//! Prometheus metrics handler for Axum
2
3use crate::server::axum::response::ApiError;
4use metrics_exporter_prometheus::{Matcher, PrometheusBuilder, PrometheusHandle};
5
6/// Default buckets for the `http_requests_duration_seconds` histogram, in
7/// seconds. Suitable for typical HTTP API latency distributions.
8pub const DEFAULT_DURATION_BUCKETS: &[f64] = &[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0];
9
10/// Prometheus metrics handler for Axum
11pub struct PrometheusHandler {}
12
13impl PrometheusHandler {
14    /// Install the global Prometheus recorder using
15    /// [`DEFAULT_DURATION_BUCKETS`] for the request-duration histogram.
16    pub fn get_handle() -> Result<PrometheusHandle, ApiError> {
17        Self::get_handle_with_buckets(DEFAULT_DURATION_BUCKETS)
18    }
19
20    /// Install the global Prometheus recorder with custom histogram buckets
21    /// (in seconds) for `http_requests_duration_seconds`. Use this when the
22    /// default bucket distribution does not match your service's latency
23    /// profile.
24    pub fn get_handle_with_buckets(buckets: &[f64]) -> Result<PrometheusHandle, ApiError> {
25        PrometheusBuilder::new()
26            .set_buckets_for_metric(Matcher::Full("http_requests_duration_seconds".to_string()), buckets)
27            .map_err(|err| ApiError::InternalServerError(err.to_string()))?
28            .install_recorder()
29            .map_err(|err| ApiError::InternalServerError(err.to_string()))
30    }
31}
32
33#[cfg(test)]
34mod tests {
35    use super::*;
36
37    #[test]
38    fn default_duration_buckets_are_monotonically_increasing() {
39        assert!(!DEFAULT_DURATION_BUCKETS.is_empty());
40        for pair in DEFAULT_DURATION_BUCKETS.windows(2) {
41            assert!(pair[0] < pair[1], "buckets must be strictly increasing: {pair:?}");
42        }
43    }
44
45    /// Single combined test for the whole handler lifecycle. `install_recorder`
46    /// mutates a process-wide global, so we cannot run multiple tests against
47    /// it in parallel — tarpaulin / `cargo test` would race. Keeping the
48    /// scenario in one `#[test]` keeps the order deterministic without
49    /// pulling in `serial_test`.
50    #[test]
51    fn handler_install_recorder_lifecycle() {
52        // First successful install with custom buckets.
53        let handle =
54            PrometheusHandler::get_handle_with_buckets(&[0.001, 0.01, 0.1]).expect("first install should succeed");
55        // Sanity: rendering an empty registry yields a (possibly empty) string.
56        let _rendered = handle.render();
57
58        // Subsequent installs must fail because the global recorder is already
59        // set. Both flavours of the API exercise the same install path.
60        let err = PrometheusHandler::get_handle().expect_err("second install must fail");
61        assert!(matches!(err, ApiError::InternalServerError(_)));
62
63        let err = PrometheusHandler::get_handle_with_buckets(&[0.5, 1.0]).expect_err("third install must fail");
64        assert!(matches!(err, ApiError::InternalServerError(_)));
65    }
66}