use std::sync::Arc;
use std::time::Instant;
use axum::{
body::Body,
extract::{MatchedPath, State},
http::Request,
middleware::Next,
response::{IntoResponse, Response},
};
use acme_proxy_jobs::metrics::Metrics;
use acme_proxy_jobs::metrics::split_matched_path;
pub async fn record_request(
State(metrics): State<Arc<Metrics>>,
request: Request<Body>,
next: Next,
) -> Response {
let matched = request
.extensions()
.get::<MatchedPath>()
.map(|matched| matched.as_str().to_string());
let started = Instant::now();
let response = next.run(request).await;
let elapsed = started.elapsed();
let (profile, route) = split_matched_path(matched.as_deref());
metrics.record_request(&profile, &route, response.status().as_u16(), elapsed);
response.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use acme_proxy_jobs::metrics::ROUTE_UNMATCHED;
use axum::{Router, http::StatusCode, middleware, routing::get};
use tower::ServiceExt;
async fn app(metrics: Arc<Metrics>) -> Router {
let inner = Router::new()
.route("/order/{id}", get(|| async { "ok" }))
.route("/boom", get(|| async { StatusCode::INTERNAL_SERVER_ERROR }));
Router::new()
.route("/health", get(|| async { "ok" }))
.nest(
&format!("{}/le", acme_proxy_core::routes::PROFILE_PREFIX),
inner,
)
.layer(middleware::from_fn_with_state(metrics, record_request))
}
async fn registry() -> Arc<Metrics> {
Arc::new(Metrics::new(Arc::new(
acme_proxy_store::db::Database::connect_in_memory()
.await
.unwrap(),
)))
}
#[tokio::test]
async fn two_ids_on_one_route_are_one_series() {
let metrics = registry().await;
let app = app(metrics.clone()).await;
for id in ["aaaa-1111", "bbbb-2222", "cccc-3333"] {
let request = Request::get(format!("/profile/le/order/{id}"))
.body(Body::empty())
.unwrap();
app.clone().oneshot(request).await.unwrap();
}
let rendered = metrics.render();
assert!(
rendered.contains(
"acme_proxy_requests_total{role=\"acme,admin,worker\",profile=\"le\",route=\"/order/{id}\",status=\"200\"} 3\n"
),
"{rendered}"
);
assert!(!rendered.contains("aaaa-1111"), "{rendered}");
}
#[tokio::test]
async fn root_routes_and_statuses_are_labelled() {
let metrics = registry().await;
let app = app(metrics.clone()).await;
app.clone()
.oneshot(Request::get("/health").body(Body::empty()).unwrap())
.await
.unwrap();
app.clone()
.oneshot(
Request::get("/profile/le/boom")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let rendered = metrics.render();
assert!(
rendered.contains(
"acme_proxy_requests_total{role=\"acme,admin,worker\",profile=\"none\",route=\"/health\",status=\"200\"} 1\n"
),
"{rendered}"
);
assert!(
rendered.contains(
"acme_proxy_requests_total{role=\"acme,admin,worker\",profile=\"le\",route=\"/boom\",status=\"500\"} 1\n"
),
"{rendered}"
);
}
#[tokio::test]
async fn unmatched_paths_collapse_to_one_series() {
let metrics = registry().await;
let app = app(metrics.clone()).await;
for path in ["/nope", "/also-nope", "/../etc/passwd"] {
let request = Request::get(path).body(Body::empty()).unwrap();
app.clone().oneshot(request).await.unwrap();
}
let rendered = metrics.render();
assert!(
rendered.contains(&format!(
"acme_proxy_requests_total{{role=\"acme,admin,worker\",profile=\"none\",\
route=\"{ROUTE_UNMATCHED}\",status=\"404\"}} 3\n"
)),
"{rendered}"
);
assert!(!rendered.contains("also-nope"), "{rendered}");
}
}