//! Actix-web metrics middleware: counts requests and measures latency. //! //! Registers metrics into a shared atomic counter exposed as structured fields //! on every request. No external metrics endpoint — logs are the export path. use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform}; use futures::future::{LocalBoxFuture, Ready, ok}; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, RwLock}; use std::task::{Context, Poll}; use std::time::Instant; /// HTTP metrics collected by this middleware. #[derive(Debug, Default)] pub struct HttpMetrics { /// Total number of requests processed. pub request_count: AtomicU64, /// Sum of all request durations in milliseconds. pub total_duration_ms: AtomicU64, /// Number of 2xx responses. pub status_2xx: AtomicU64, /// Number of 4xx responses. pub status_4xx: AtomicU64, /// Number of 5xx responses. pub status_5xx: AtomicU64, /// Per-endpoint request counters. Key format: "GET /api/room/{id}" or "POST /api/git/commit" pub endpoint_counts: RwLock>, } impl HttpMetrics { /// Creates a new instance with all counters initialised to zero. pub fn new() -> Self { Self::default() } /// Increment the counter for a specific HTTP endpoint (method + path). pub fn incr_endpoint(&self, method: &str, path: &str) { let key = format!("{} {}", method, path); let mut map = self .endpoint_counts .write() .unwrap_or_else(|e| e.into_inner()); let counter = map.entry(key).or_insert_with(|| AtomicU64::new(0)); counter.fetch_add(1, Ordering::Relaxed); } /// Returns a snapshot of all current counter values. pub fn snapshot(&self) -> HashMap { let mut m = HashMap::new(); m.insert( "http_requests_total".into(), serde_json::json!(self.request_count.load(Ordering::Relaxed)), ); m.insert( "http_request_duration_ms_total".into(), serde_json::json!(self.total_duration_ms.load(Ordering::Relaxed)), ); m.insert( "http_requests_2xx".into(), serde_json::json!(self.status_2xx.load(Ordering::Relaxed)), ); m.insert( "http_requests_4xx".into(), serde_json::json!(self.status_4xx.load(Ordering::Relaxed)), ); m.insert( "http_requests_5xx".into(), serde_json::json!(self.status_5xx.load(Ordering::Relaxed)), ); // Per-endpoint counters let map = self .endpoint_counts .read() .unwrap_or_else(|e| e.into_inner()); for (key, counter) in map.iter() { // Sanitize key for use as metric name: replace spaces and slashes with underscores let sanitized = key.replace([' ', '/'], "_").to_lowercase(); let metric_key = format!("http_endpoint_{}", sanitized); m.insert( metric_key, serde_json::json!(counter.load(Ordering::Relaxed)), ); } m } } /// Actix-web middleware that collects per-request metrics and exposes them /// via structured fields on every log line. pub struct MetricsMiddleware { metrics: Arc, } impl MetricsMiddleware { /// Constructs a new `MetricsMiddleware` wrapping the shared `HttpMetrics`. pub fn new(metrics: Arc) -> Self { Self { metrics } } } impl Transform for MetricsMiddleware where S: Service, Error = actix_web::Error> + 'static, S::Future: 'static, B: 'static, { type Response = ServiceResponse; type Error = actix_web::Error; type Transform = MetricsMiddlewareService; type InitError = (); type Future = Ready>; fn new_transform(&self, service: S) -> Self::Future { ok(MetricsMiddlewareService { service: Arc::new(service), metrics: self.metrics.clone(), }) } } pub struct MetricsMiddlewareService { service: Arc, metrics: Arc, } impl Clone for MetricsMiddlewareService { fn clone(&self) -> Self { Self { service: self.service.clone(), metrics: self.metrics.clone(), } } } impl Service for MetricsMiddlewareService where S: Service, Error = actix_web::Error> + 'static, S::Future: 'static, B: 'static, { type Response = ServiceResponse; type Error = actix_web::Error; type Future = LocalBoxFuture<'static, Result>; fn poll_ready(&self, cx: &mut Context<'_>) -> Poll> { self.service.poll_ready(cx) } fn call(&self, req: ServiceRequest) -> Self::Future { let started = Instant::now(); let service = self.service.clone(); let metrics = self.metrics.clone(); let method = req.method().as_str().to_string(); let path = req.path().to_string(); Box::pin(async move { let res = service.call(req).await?; let elapsed_ms = started.elapsed().as_millis() as u64; let status_code = res.status().as_u16(); // Update counters atomically. metrics.request_count.fetch_add(1, Ordering::Relaxed); metrics .total_duration_ms .fetch_add(elapsed_ms, Ordering::Relaxed); metrics.incr_endpoint(&method, &path); match status_code { 200..=299 => { metrics.status_2xx.fetch_add(1, Ordering::Relaxed); } 400..=499 => { metrics.status_4xx.fetch_add(1, Ordering::Relaxed); } 500..=599 => { metrics.status_5xx.fetch_add(1, Ordering::Relaxed); } _ => {} } Ok(res) }) } }