//! Structured HTTP request logging middleware using tracing. //! //! Logs every incoming request with method, path, status code, //! response time, client IP, authenticated user ID, and trace_id. use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform}; use futures::future::{LocalBoxFuture, Ready, ok}; use session::SessionExt; use std::sync::Arc; use std::task::{Context, Poll}; use std::time::Instant; use uuid::Uuid; /// Default log format: `{method} {path} {status} {duration_ms}ms` pub struct RequestLogger { trace_id_header: String, } impl RequestLogger { pub fn new(trace_id_header: String) -> Self { Self { trace_id_header } } } impl Transform for RequestLogger where S: Service, Error = actix_web::Error> + 'static, S::Future: 'static, B: 'static, { type Response = ServiceResponse; type Error = actix_web::Error; type Transform = RequestLoggerMiddleware; type InitError = (); type Future = Ready>; fn new_transform(&self, service: S) -> Self::Future { ok(RequestLoggerMiddleware { service: Arc::new(service), trace_id_header: self.trace_id_header.clone(), }) } } pub struct RequestLoggerMiddleware { service: Arc, trace_id_header: String, } impl Clone for RequestLoggerMiddleware { fn clone(&self) -> Self { Self { service: self.service.clone(), trace_id_header: self.trace_id_header.clone(), } } } impl Service for RequestLoggerMiddleware 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 trace_id_header = self.trace_id_header.clone(); let method = req.method().to_string(); let path = req.path().to_string(); let query = req.query_string().to_string(); let remote = req .connection_info() .realip_remote_addr() .map(|s| s.to_string()) .unwrap_or_else(|| "unknown".to_string()); let user_id: Option = req.get_session().user(); let trace_id = Uuid::now_v7().to_string(); let full_path = if query.is_empty() { path.clone() } else { format!("{}?{}", path, query) }; let service = self.service.clone(); Box::pin(async move { let res = service.call(req).await?; let elapsed = started.elapsed(); let status = res.status(); let status_code = status.as_u16(); let is_health = path == "/health"; if !is_health { let user_id_str = user_id .map(|u: Uuid| u.to_string()) .unwrap_or_else(|| "-".to_string()); let duration_ms = elapsed.as_millis() as u64; let log_args = ( method = %method, path = %full_path, status = status_code, duration_ms = duration_ms, remote = %remote, user_id = %user_id_str, trace_id = %trace_id, ); match status_code { 200..=299 => { tracing::info!(log_args, "http_request"); } 400..=499 => { tracing::warn!(log_args, "http_request"); } _ => { tracing::error!(log_args, "http_request"); } } } Ok(res) }) } }