Skip to main content

aster_forge_observability/actix/
mod.rs

1//! Injectable Prometheus resource. Products own its path and authentication middleware.
2
3#[cfg(feature = "prometheus")]
4use actix_web::{HttpRequest, HttpResponse, Resource, http::header, web};
5
6/// This resource does not read global state. Missing injection returns 503;
7/// products can wrap the resource so authentication happens before this handler.
8#[cfg(feature = "prometheus")]
9pub fn prometheus_resource(path: &str) -> Resource {
10    web::resource(path).route(web::get().to(prometheus_metrics))
11}
12
13#[cfg(feature = "prometheus")]
14async fn prometheus_metrics(request: HttpRequest) -> HttpResponse {
15    let Some(provider) = request.app_data::<web::Data<aster_forge_metrics::MetricsProvider>>()
16    else {
17        return HttpResponse::ServiceUnavailable()
18            .insert_header((header::CACHE_CONTROL, "no-store"))
19            .body("metrics provider is unavailable");
20    };
21    match provider
22        .export_prometheus(std::time::Duration::from_secs(5))
23        .await
24    {
25        Ok(body) => HttpResponse::Ok()
26            .insert_header((header::CACHE_CONTROL, "no-store"))
27            .content_type("text/plain; version=0.0.4; charset=utf-8")
28            .body(body),
29        Err(_) => HttpResponse::ServiceUnavailable()
30            .insert_header((header::CACHE_CONTROL, "no-store"))
31            .body("metrics export is unavailable"),
32    }
33}
34
35#[cfg(all(test, feature = "prometheus"))]
36mod tests {
37    use super::*;
38    use actix_web::{App, http::StatusCode, test};
39    use aster_forge_metrics::{MetricDescriptor, MetricsProvider, ProductCounter};
40
41    #[actix_web::test]
42    async fn explicit_instance_injection_preserves_status_format_and_no_store() {
43        let app = test::init_service(App::new().service(prometheus_resource("/metrics"))).await;
44        let response =
45            test::call_service(&app, test::TestRequest::get().uri("/metrics").to_request()).await;
46        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
47        assert_eq!(
48            response.headers().get(header::CACHE_CONTROL).unwrap(),
49            "no-store"
50        );
51
52        let provider = MetricsProvider::builder()
53            .with_prometheus()
54            .unwrap()
55            .build()
56            .unwrap();
57        let handle = provider
58            .register_batch(&[MetricDescriptor::counter(
59                "test",
60                "requests_total",
61                "Requests.",
62                &[],
63            )])
64            .unwrap()
65            .remove(0);
66        ProductCounter::from_handle(handle).unwrap().inc(&[], 2);
67        let app = test::init_service(
68            App::new()
69                .app_data(web::Data::new(provider.clone()))
70                .service(prometheus_resource("/custom")),
71        )
72        .await;
73        let response =
74            test::call_service(&app, test::TestRequest::get().uri("/custom").to_request()).await;
75        assert_eq!(response.status(), StatusCode::OK);
76        assert_eq!(
77            response.headers().get(header::CONTENT_TYPE).unwrap(),
78            "text/plain; version=0.0.4; charset=utf-8"
79        );
80        assert_eq!(
81            response.headers().get(header::CACHE_CONTROL).unwrap(),
82            "no-store"
83        );
84        assert!(
85            String::from_utf8(test::read_body(response).await.to_vec())
86                .unwrap()
87                .contains("test_requests_total 2")
88        );
89        provider
90            .shutdown(std::time::Duration::from_secs(1))
91            .await
92            .unwrap();
93        let response =
94            test::call_service(&app, test::TestRequest::get().uri("/custom").to_request()).await;
95        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
96    }
97}