//! Structured HTTP request logging middleware using slog. //! //! Logs every incoming request with method, path, status code, //! response time, client IP, and authenticated user ID. use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform}; use futures::future::{LocalBoxFuture, Ready, ok}; use session::SessionExt; use slog::{error as slog_error, info as slog_info, warn as slog_warn}; 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 { log: slog::Logger, } impl RequestLogger { pub fn new(log: slog::Logger) -> Self { Self { log } } } 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), log: self.log.clone(), }) } } pub struct RequestLoggerMiddleware { service: Arc, log: slog::Logger, } impl Clone for RequestLoggerMiddleware { fn clone(&self) -> Self { Self { service: self.service.clone(), log: self.log.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 log = self.log.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 full_path = if query.is_empty() { path.clone() } else { format!("{}?{}", path, query) }; // Clone the Arc so it can be moved into the async block 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 log_message = format!( "HTTP request | method={} | path={} | status={} | duration_ms={} | remote={} | user_id={}", method, full_path, status_code, elapsed.as_millis(), remote, user_id_str ); match status_code { 200..=299 => slog_info!(&log, "{}", log_message), 400..=499 => slog_warn!(&log, "{}", log_message), _ => slog_error!(&log, "{}", log_message), } } Ok(res) }) } }