use std::time::Instant;
use axum::{
body::Body,
extract::Request,
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
};
use metrics::{counter, gauge, histogram};
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
pub mod names {
pub const HTTP_REQUEST_DURATION_SECONDS: &str = "http_request_duration_seconds";
pub const HTTP_REQUESTS_TOTAL: &str = "http_requests_total";
pub const HTTP_REQUESTS_IN_FLIGHT: &str = "http_requests_in_flight";
pub const BASELINE_UPLOADS_TOTAL: &str = "perfgate_baseline_uploads_total";
pub const BASELINE_DOWNLOADS_TOTAL: &str = "perfgate_baseline_downloads_total";
pub const STORAGE_OPERATIONS_TOTAL: &str = "perfgate_storage_operations_total";
}
pub fn setup_metrics_recorder() -> PrometheusHandle {
PrometheusBuilder::new()
.install_recorder()
.expect("failed to install Prometheus recorder")
}
pub async fn metrics_handler(
axum::extract::State(handle): axum::extract::State<PrometheusHandle>,
) -> impl IntoResponse {
let body = handle.render();
Response::builder()
.status(StatusCode::OK)
.header(
axum::http::header::CONTENT_TYPE,
"text/plain; version=0.0.4; charset=utf-8",
)
.body(Body::from(body))
.unwrap()
}
fn normalize_path(path: &str) -> String {
let segments: Vec<&str> = path.split('/').collect();
let mut normalized = Vec::with_capacity(segments.len());
let mut i = 0;
while i < segments.len() {
let seg = segments[i];
match seg {
"projects" => {
normalized.push("projects");
if i + 1 < segments.len() {
normalized.push(":project");
i += 1;
}
}
"baselines" => {
normalized.push("baselines");
if i + 1 < segments.len() && !segments[i + 1].is_empty() {
let next = segments[i + 1];
if next != "latest" && next != "versions" && next != "promote" {
normalized.push(":benchmark");
i += 1;
}
}
}
"versions" => {
normalized.push("versions");
if i + 1 < segments.len() {
normalized.push(":version");
i += 1;
}
}
"verdicts" => {
normalized.push("verdicts");
}
_ => {
normalized.push(seg);
}
}
i += 1;
}
normalized.join("/")
}
pub async fn metrics_middleware(request: Request, next: Next) -> Response {
let method = request.method().to_string();
let path = normalize_path(request.uri().path());
gauge!(names::HTTP_REQUESTS_IN_FLIGHT).increment(1);
let start = Instant::now();
let response = next.run(request).await;
let duration = start.elapsed().as_secs_f64();
let status = response.status().as_u16().to_string();
let labels = [
("method", method.clone()),
("path", path.clone()),
("status", status.clone()),
];
histogram!(names::HTTP_REQUEST_DURATION_SECONDS, &labels).record(duration);
counter!(names::HTTP_REQUESTS_TOTAL, &labels).increment(1);
gauge!(names::HTTP_REQUESTS_IN_FLIGHT).decrement(1);
response
}
pub fn record_baseline_upload(project: &str) {
counter!(names::BASELINE_UPLOADS_TOTAL, "project" => project.to_string()).increment(1);
counter!(names::STORAGE_OPERATIONS_TOTAL, "operation" => "upload").increment(1);
}
pub fn record_baseline_download(project: &str) {
counter!(names::BASELINE_DOWNLOADS_TOTAL, "project" => project.to_string()).increment(1);
counter!(names::STORAGE_OPERATIONS_TOTAL, "operation" => "download").increment(1);
}
pub fn record_storage_list() {
counter!(names::STORAGE_OPERATIONS_TOTAL, "operation" => "list").increment(1);
}
pub fn record_storage_delete() {
counter!(names::STORAGE_OPERATIONS_TOTAL, "operation" => "delete").increment(1);
}
pub fn record_storage_promote() {
counter!(names::STORAGE_OPERATIONS_TOTAL, "operation" => "promote").increment(1);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_path_health() {
assert_eq!(normalize_path("/health"), "/health");
}
#[test]
fn test_normalize_path_baselines_upload() {
assert_eq!(
normalize_path("/api/v1/projects/my-proj/baselines"),
"/api/v1/projects/:project/baselines"
);
}
#[test]
fn test_normalize_path_latest() {
assert_eq!(
normalize_path("/api/v1/projects/my-proj/baselines/my-bench/latest"),
"/api/v1/projects/:project/baselines/:benchmark/latest"
);
}
#[test]
fn test_normalize_path_version() {
assert_eq!(
normalize_path("/api/v1/projects/my-proj/baselines/my-bench/versions/v1"),
"/api/v1/projects/:project/baselines/:benchmark/versions/:version"
);
}
#[test]
fn test_normalize_path_promote() {
assert_eq!(
normalize_path("/api/v1/projects/my-proj/baselines/my-bench/promote"),
"/api/v1/projects/:project/baselines/:benchmark/promote"
);
}
#[test]
fn test_normalize_path_verdicts() {
assert_eq!(
normalize_path("/api/v1/projects/my-proj/verdicts"),
"/api/v1/projects/:project/verdicts"
);
}
#[test]
fn test_normalize_path_metrics() {
assert_eq!(normalize_path("/metrics"), "/metrics");
}
#[test]
fn test_normalize_path_root() {
assert_eq!(normalize_path("/"), "/");
}
}