1use std::{sync::OnceLock, time::Instant};
8
9use axum::{
10 Router,
11 extract::{MatchedPath, Request},
12 middleware::{self, Next},
13 response::Response,
14 routing::get,
15};
16use metrics_exporter_prometheus::{BuildError, Matcher, PrometheusBuilder, PrometheusHandle};
17use sqlx::PgPool;
18
19const DURATION_BUCKETS: [f64; 10] = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 10.0];
21
22static POOL: OnceLock<PgPool> = OnceLock::new();
24
25pub fn install(service: &str) -> Result<PrometheusHandle, BuildError> {
28 builder(service)?.install_recorder()
29}
30
31fn builder(service: &str) -> Result<PrometheusBuilder, BuildError> {
32 PrometheusBuilder::new()
33 .add_global_label("service", service)
34 .set_buckets_for_metric(
35 Matcher::Full("edda_http_request_duration_seconds".into()),
36 &DURATION_BUCKETS,
37 )
38}
39
40pub fn endpoint(handle: PrometheusHandle) -> Router {
42 Router::new().route(
43 "/metrics",
44 get(move || {
45 let handle = handle.clone();
46 async move {
47 measure_pool();
48 handle.run_upkeep();
49 handle.render()
50 }
51 }),
52 )
53}
54
55pub fn requests(router: Router) -> Router {
58 router.route_layer(middleware::from_fn(time))
59}
60
61async fn time(request: Request, next: Next) -> Response {
62 let started = Instant::now();
63 let method = request.method().to_string();
64 let route = request
65 .extensions()
66 .get::<MatchedPath>()
67 .map_or_else(String::new, |path| path.as_str().to_owned());
68 let response = next.run(request).await;
69 let status = response.status().as_u16().to_string();
70 metrics::counter!(
71 "edda_http_requests_total",
72 "method" => method.clone(),
73 "route" => route.clone(),
74 "status" => status,
75 )
76 .increment(1);
77 metrics::histogram!(
78 "edda_http_request_duration_seconds",
79 "method" => method,
80 "route" => route,
81 )
82 .record(started.elapsed().as_secs_f64());
83 response
84}
85
86pub fn refused(limit: &str) {
88 metrics::counter!("edda_limits_refused_total", "limit" => limit.to_owned()).increment(1);
89}
90
91pub fn watch_pool(pool: &PgPool) {
94 let _ = POOL.set(pool.clone());
95}
96
97fn measure_pool() {
98 let Some(pool) = POOL.get() else { return };
99 let idle = pool.num_idle();
100 let open = usize::try_from(pool.size()).unwrap_or(usize::MAX);
101 gauge_of("edda_db_connections", "idle", idle);
102 gauge_of("edda_db_connections", "busy", open.saturating_sub(idle));
103}
104
105fn gauge_of(name: &'static str, state: &'static str, count: usize) {
106 metrics::gauge!(name, "state" => state).set(as_f64(count));
107}
108
109pub fn as_f64(count: usize) -> f64 {
111 u32::try_from(count).map_or(f64::from(u32::MAX), f64::from)
112}
113
114#[cfg(test)]
116fn test_handle() -> PrometheusHandle {
117 static HANDLE: std::sync::Mutex<Option<PrometheusHandle>> = std::sync::Mutex::new(None);
118 HANDLE
119 .lock()
120 .unwrap()
121 .get_or_insert_with(|| builder("test").unwrap().install_recorder().unwrap())
122 .clone()
123}
124
125#[cfg(test)]
126mod tests {
127 use axum::{body::Body, http::StatusCode};
128 use http_body_util::BodyExt;
129 use sqlx::postgres::PgPoolOptions;
130 use tower::ServiceExt;
131
132 use super::*;
133
134 async fn read(endpoint: &Router) -> String {
135 let response = endpoint
136 .clone()
137 .oneshot(Request::get("/metrics").body(Body::empty()).unwrap())
138 .await
139 .unwrap();
140 assert_eq!(response.status(), StatusCode::OK);
141 let bytes = response.into_body().collect().await.unwrap().to_bytes();
142 String::from_utf8(bytes.to_vec()).unwrap()
143 }
144
145 #[tokio::test]
146 async fn requests_refusals_and_connections_are_measured() {
147 let endpoint = endpoint(test_handle());
148 let app = requests(Router::new().route("/characters/{id}", get(|| async { "ok" })));
149 for id in ["grog", "vex"] {
150 let request = Request::get(format!("/characters/{id}")).body(Body::empty());
151 app.clone().oneshot(request.unwrap()).await.unwrap();
152 }
153 refused("sign-ins");
154 watch_pool(
155 &PgPoolOptions::new()
156 .connect_lazy("postgres://postgres@127.0.0.1:1/nothing")
157 .unwrap(),
158 );
159
160 let measured = read(&endpoint).await;
161 let line = |start: &str| {
162 measured
163 .lines()
164 .find(|l| l.starts_with(start))
165 .map(str::to_owned)
166 };
167 let requests = line("edda_http_requests_total{").unwrap();
168 assert!(
169 requests.contains(r#"route="/characters/{id}""#),
170 "{requests}"
171 );
172 assert!(
173 requests.contains(r#"status="200""#) && requests.ends_with(" 2"),
174 "{requests}"
175 );
176 assert!(requests.contains(r#"service="test""#), "{requests}");
177 assert!(!measured.contains("grog"), "routes, not ids");
178 assert!(line("edda_http_request_duration_seconds_bucket{").is_some());
179 assert!(
180 line("edda_limits_refused_total{")
181 .unwrap()
182 .contains(r#"limit="sign-ins""#)
183 );
184 assert!(line("edda_db_connections{").is_some(), "{measured}");
185 }
186
187 #[test]
188 fn counts_become_gauge_values() {
189 assert!((as_f64(3) - 3.0).abs() < f64::EPSILON);
190 assert!((as_f64(usize::MAX) - f64::from(u32::MAX)).abs() < f64::EPSILON);
191 }
192}