use axum::extract::Request; use axum::http::Method; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use metrics::{counter, histogram}; use std::time::Instant; /// HTTP 请求指标中间件。 /// /// 记录两个 Prometheus 指标: /// - `http_requests_total` — 计数器,标签: method, path, status /// - `http_request_duration_seconds` — 直方图,标签: method, path, status pub async fn metrics_middleware(req: Request, next: Next) -> Response { let method = method_label(req.method()); let path = path_label(req.uri().path()); let start = Instant::now(); let resp = next.run(req).await; let elapsed = start.elapsed(); let status = resp.status().as_u16().to_string(); let labels = [ ("method", method.clone()), ("path", path.clone()), ("status", status.clone()), ]; counter!("http_requests_total", &labels).increment(1); histogram!("http_request_duration_seconds", &labels).record(elapsed.as_secs_f64()); resp } fn method_label(method: &Method) -> String { method.as_str().to_owned() } /// 归一化路径:将 UUID 段替换为 `:id`,避免高基数。 fn path_label(path: &str) -> String { let parts: Vec<&str> = path .split('/') .filter(|s| !s.is_empty()) .map(|s| if looks_like_uuid(s) { ":id" } else { s }) .collect(); if parts.is_empty() { "/".to_string() } else { format!("/{}", parts.join("/")) } } fn looks_like_uuid(s: &str) -> bool { s.len() == 36 && s.chars().filter(|c| *c == '-').count() == 4 && s.chars().all(|c| c.is_ascii_hexdigit() || c == '-') } /// 在独立端口启动 Prometheus exporter。 pub fn start_metrics_server(port: u16) { let builder = metrics_exporter_prometheus::PrometheusBuilder::new(); let recorder = builder.build_recorder(); let handle = recorder.handle(); if let Err(e) = metrics::set_global_recorder(recorder) { tracing::error!(error = %e, "Failed to install Prometheus recorder"); return; } tokio::spawn(async move { let app = axum::Router::new() .route( "/metrics", axum::routing::get(move || { let handle = handle.clone(); async move { let body = handle.render(); axum::response::IntoResponse::into_response(( [( axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4", )], body, )) } }), ) .fallback(|| async { axum::http::StatusCode::NOT_FOUND.into_response() as Response }); let addr = format!("0.0.0.0:{port}"); match tokio::net::TcpListener::bind(&addr).await { Ok(listener) => { tracing::info!(addr = %addr, "Prometheus metrics server listening"); if let Err(e) = axum::serve(listener, app).await { tracing::error!(error = %e, "Metrics server error"); } } Err(e) => { tracing::error!(error = %e, addr = %addr, "Failed to bind metrics server"); } } }); } #[cfg(test)] mod tests { use super::*; #[test] fn path_label_normalizes_uuids() { assert_eq!(path_label("/api/v1/users"), "/api/v1/users"); assert_eq!( path_label("/api/v1/users/01234567-89ab-cdef-0123-456789abcdef/posts"), "/api/v1/users/:id/posts" ); assert_eq!(path_label("/"), "/"); assert_eq!(path_label(""), "/"); } #[test] fn is_uuid_checks_format() { assert!(looks_like_uuid("01234567-89ab-cdef-0123-456789abcdef")); assert!(!looks_like_uuid("not-a-uuid")); assert!(!looks_like_uuid("short")); } }