Skip to main content

aster_forge_actix_observability/
lib.rs

1//! Actix Web observability endpoints for Aster services.
2//!
3//! This crate owns route-level observability glue that is specific to Actix Web, such as the
4//! Prometheus text exposition endpoint. Metrics recording traits and concrete backend state remain
5//! in `aster_forge_metrics`; product route modules can call these helpers without carrying
6//! backend-specific `#[cfg]` blocks.
7#![cfg_attr(
8    not(test),
9    deny(
10        clippy::unwrap_used,
11        clippy::unreachable,
12        clippy::expect_used,
13        clippy::panic,
14        clippy::unimplemented,
15        clippy::todo
16    )
17)]
18
19use actix_web::Scope;
20
21/// Adds a Prometheus `/metrics` endpoint to an Actix route scope when enabled.
22///
23/// Without the `prometheus` feature this returns the input scope unchanged. Products should call
24/// this unconditionally from their route assembly and let Cargo features decide whether the
25/// endpoint exists.
26pub fn configure_prometheus_route(scope: Scope) -> Scope {
27    #[cfg(feature = "prometheus")]
28    {
29        scope.route("/metrics", actix_web::web::get().to(prometheus_metrics))
30    }
31
32    #[cfg(not(feature = "prometheus"))]
33    {
34        scope
35    }
36}
37
38#[cfg(feature = "prometheus")]
39async fn prometheus_metrics() -> actix_web::HttpResponse {
40    if !aster_forge_metrics::prometheus::is_initialized() {
41        tracing::debug!("metrics probe failed because metrics are not initialized");
42        return actix_web::HttpResponse::ServiceUnavailable()
43            .body("metrics registry is not initialized");
44    }
45
46    match aster_forge_metrics::prometheus::export_metrics() {
47        Ok(body) => {
48            tracing::debug!(bytes = body.len(), "metrics probe exported metrics");
49            actix_web::HttpResponse::Ok()
50                .content_type("text/plain; version=0.0.4; charset=utf-8")
51                .body(body)
52        }
53        Err(error) => {
54            tracing::debug!(error = %error, "metrics probe export failed");
55            actix_web::HttpResponse::ServiceUnavailable().body(error)
56        }
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use actix_web::{App, http::StatusCode, test, web};
63
64    use super::configure_prometheus_route;
65
66    #[cfg(not(feature = "prometheus"))]
67    #[actix_web::test]
68    async fn prometheus_route_is_not_registered_without_feature() {
69        use actix_web::HttpResponse;
70
71        let app = test::init_service(App::new().service(configure_prometheus_route(
72            web::scope("").route("/healthz", web::get().to(HttpResponse::Ok)),
73        )))
74        .await;
75
76        let health =
77            test::call_service(&app, test::TestRequest::get().uri("/healthz").to_request()).await;
78        assert_eq!(health.status(), StatusCode::OK);
79
80        let metrics =
81            test::call_service(&app, test::TestRequest::get().uri("/metrics").to_request()).await;
82        assert_eq!(metrics.status(), StatusCode::NOT_FOUND);
83    }
84
85    #[cfg(feature = "prometheus")]
86    #[actix_web::test]
87    async fn prometheus_route_exports_text_after_registry_init() {
88        aster_forge_metrics::prometheus::init_metrics()
89            .expect("metrics registry should initialize");
90        let recorder = aster_forge_metrics::prometheus::PrometheusMetricsRecorder;
91        aster_forge_metrics::MetricsRecorder::record_http_request(
92            &recorder, "GET", "/healthz", 200, 0.01,
93        );
94        let app =
95            test::init_service(App::new().service(configure_prometheus_route(web::scope(""))))
96                .await;
97
98        let response =
99            test::call_service(&app, test::TestRequest::get().uri("/metrics").to_request()).await;
100        assert_eq!(response.status(), StatusCode::OK);
101
102        let body = test::read_body(response).await;
103        let body = std::str::from_utf8(&body).expect("metrics body should be utf-8");
104        assert!(body.contains("http_requests_total"));
105        assert!(body.contains("route=\"/healthz\""));
106        assert!(body.contains("process_uptime_seconds"));
107    }
108}