use axum::extract::{MatchedPath, Request};
use axum::http::HeaderValue;
use axum::middleware::Next;
use axum::response::Response;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use crate::context::RequestContext;
#[derive(Debug, Clone)]
pub struct RequestLogConfig {
pub success_sample_rate: f64,
pub slow_request_threshold: Duration,
pub service_name: String,
pub environment: String,
pub request_level: String,
}
impl Default for RequestLogConfig {
fn default() -> Self {
Self {
success_sample_rate: 1.0,
slow_request_threshold: Duration::from_millis(250),
service_name: env!("CARGO_PKG_NAME").to_string(),
environment: "development".to_string(),
request_level: "info".to_string(),
}
}
}
static REQUEST_LOG_SEQUENCE: AtomicU64 = AtomicU64::new(0);
fn redact_path(path: &str) -> String {
if let Some((base, query)) = path.split_once('?') {
let redacted_query = query
.split('&')
.map(|param| {
if let Some((key, _)) = param.split_once('=') {
let lower = key.to_ascii_lowercase();
if lower.contains("password")
|| lower.contains("token")
|| lower.contains("secret")
|| lower.contains("key")
|| lower == "authorization"
|| lower == "cookie"
{
format!("{}=[REDACTED]", key)
} else {
param.to_string()
}
} else {
let lower = param.to_ascii_lowercase();
if lower.contains("password")
|| lower.contains("token")
|| lower.contains("secret")
{
"[REDACTED]".to_string()
} else {
param.to_string()
}
}
})
.collect::<Vec<_>>()
.join("&");
format!("{}?{}", base, redacted_query)
} else {
path.to_string()
}
}
fn error_details_for_status(status: u16) -> Option<(&'static str, &'static str)> {
match status {
400 => Some(("validation", "bad request")),
401 => Some(("authentication", "authentication required")),
403 => Some(("authorization", "forbidden")),
404 => Some(("not_found", "not found")),
409 => Some(("conflict", "conflict")),
429 => Some(("rate_limited", "rate limited")),
408 | 504 => Some(("timeout", "gateway timeout")),
502 => Some(("dependency", "bad gateway")),
503 => Some(("unavailable", "service unavailable")),
500 => Some(("internal", "internal server error")),
501 => Some(("not_impl", "not implemented")),
_ if (400..=499).contains(&status) => Some(("client_error", "client error")),
_ if (500..=599).contains(&status) => Some(("server_error", "server error")),
_ => None,
}
}
pub async fn logging_middleware(request: Request, next: Next) -> Response {
let method = request.method().clone();
let uri = request.uri().clone();
let raw_path = uri
.path_and_query()
.map(|pq| pq.to_string())
.unwrap_or_else(|| uri.path().to_string());
let path = redact_path(&raw_path);
let start = std::time::Instant::now();
let request_id = request
.extensions()
.get::<crate::core::error::CorrelationId>()
.map(ToString::to_string)
.or_else(|| {
request
.headers()
.get("x-request-id")
.and_then(|value| value.to_str().ok())
.filter(|value| {
!value.is_empty()
&& value.len() <= 128
&& value
.bytes()
.all(|byte| byte.is_ascii_graphic() && byte != b'"')
})
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| crate::core::error::CorrelationId::new().to_string());
let route = request
.extensions()
.get::<MatchedPath>()
.map(|matched| matched.as_str().to_owned())
.unwrap_or_else(|| uri.path().to_owned());
let context = request.extensions().get::<RequestContext>().cloned();
let log_config = request
.extensions()
.get::<RequestLogConfig>()
.cloned()
.unwrap_or_default();
let mut response = next.run(request).await;
let duration = start.elapsed();
let status = response.status().as_u16();
let should_log = status >= 400
|| duration >= log_config.slow_request_threshold
|| sample_success(log_config.success_sample_rate);
if should_log {
let outcome = request_outcome(status);
let error_details = error_details_for_status(status);
let duration_ms = duration.as_millis() as u64;
let duration_us = duration.as_micros() as u64;
let configured_level = log_config.request_level.to_ascii_lowercase();
let success_level = match configured_level.as_str() {
"trace" => tracing::Level::TRACE,
"debug" => tracing::Level::DEBUG,
"warn" => tracing::Level::WARN,
"error" => tracing::Level::ERROR,
_ => tracing::Level::INFO,
};
if let Some((error_code, error_message)) = error_details {
if status >= 500 {
tracing::error!(
service = %log_config.service_name,
service_version = env!("CARGO_PKG_VERSION"),
environment = %log_config.environment,
method = %method,
path = %path,
route = %route,
request_id = %request_id,
correlation_id = %request_id,
subject = context.as_ref().and_then(|value| value.subject.as_deref()).unwrap_or("anonymous"),
tenant_id = context.as_ref().and_then(|value| value.tenant_id.as_deref()).unwrap_or("-"),
instance_id = context.as_ref().and_then(|value| value.instance_id.as_deref()).unwrap_or("-"),
status = status,
duration_ms = duration_ms,
duration_us = duration_us,
outcome = outcome,
error_code = error_code,
error_message = error_message,
"request.completed"
);
} else {
tracing::warn!(
service = %log_config.service_name,
service_version = env!("CARGO_PKG_VERSION"),
environment = %log_config.environment,
method = %method,
path = %path,
route = %route,
request_id = %request_id,
correlation_id = %request_id,
subject = context.as_ref().and_then(|value| value.subject.as_deref()).unwrap_or("anonymous"),
tenant_id = context.as_ref().and_then(|value| value.tenant_id.as_deref()).unwrap_or("-"),
instance_id = context.as_ref().and_then(|value| value.instance_id.as_deref()).unwrap_or("-"),
status = status,
duration_ms = duration_ms,
duration_us = duration_us,
outcome = outcome,
error_code = error_code,
error_message = error_message,
"request.completed"
);
}
} else {
match success_level {
tracing::Level::TRACE => tracing::trace!(
service = %log_config.service_name,
service_version = env!("CARGO_PKG_VERSION"),
environment = %log_config.environment,
method = %method,
path = %path,
route = %route,
request_id = %request_id,
correlation_id = %request_id,
subject = context.as_ref().and_then(|value| value.subject.as_deref()).unwrap_or("anonymous"),
tenant_id = context.as_ref().and_then(|value| value.tenant_id.as_deref()).unwrap_or("-"),
instance_id = context.as_ref().and_then(|value| value.instance_id.as_deref()).unwrap_or("-"),
status = status,
duration_ms = duration_ms,
duration_us = duration_us,
outcome = outcome,
"request.completed"
),
tracing::Level::DEBUG => tracing::debug!(
service = %log_config.service_name,
service_version = env!("CARGO_PKG_VERSION"),
environment = %log_config.environment,
method = %method,
path = %path,
route = %route,
request_id = %request_id,
correlation_id = %request_id,
subject = context.as_ref().and_then(|value| value.subject.as_deref()).unwrap_or("anonymous"),
tenant_id = context.as_ref().and_then(|value| value.tenant_id.as_deref()).unwrap_or("-"),
instance_id = context.as_ref().and_then(|value| value.instance_id.as_deref()).unwrap_or("-"),
status = status,
duration_ms = duration_ms,
duration_us = duration_us,
outcome = outcome,
"request.completed"
),
tracing::Level::WARN => tracing::warn!(
service = %log_config.service_name,
service_version = env!("CARGO_PKG_VERSION"),
environment = %log_config.environment,
method = %method,
path = %path,
route = %route,
request_id = %request_id,
correlation_id = %request_id,
subject = context.as_ref().and_then(|value| value.subject.as_deref()).unwrap_or("anonymous"),
tenant_id = context.as_ref().and_then(|value| value.tenant_id.as_deref()).unwrap_or("-"),
instance_id = context.as_ref().and_then(|value| value.instance_id.as_deref()).unwrap_or("-"),
status = status,
duration_ms = duration_ms,
duration_us = duration_us,
outcome = outcome,
"request.completed"
),
tracing::Level::ERROR => tracing::error!(
service = %log_config.service_name,
service_version = env!("CARGO_PKG_VERSION"),
environment = %log_config.environment,
method = %method,
path = %path,
route = %route,
request_id = %request_id,
correlation_id = %request_id,
subject = context.as_ref().and_then(|value| value.subject.as_deref()).unwrap_or("anonymous"),
tenant_id = context.as_ref().and_then(|value| value.tenant_id.as_deref()).unwrap_or("-"),
instance_id = context.as_ref().and_then(|value| value.instance_id.as_deref()).unwrap_or("-"),
status = status,
duration_ms = duration_ms,
duration_us = duration_us,
outcome = outcome,
"request.completed"
),
_ => tracing::info!(
service = %log_config.service_name,
service_version = env!("CARGO_PKG_VERSION"),
environment = %log_config.environment,
method = %method,
path = %path,
route = %route,
request_id = %request_id,
correlation_id = %request_id,
subject = context.as_ref().and_then(|value| value.subject.as_deref()).unwrap_or("anonymous"),
tenant_id = context.as_ref().and_then(|value| value.tenant_id.as_deref()).unwrap_or("-"),
instance_id = context.as_ref().and_then(|value| value.instance_id.as_deref()).unwrap_or("-"),
status = status,
duration_ms = duration_ms,
duration_us = duration_us,
outcome = outcome,
"request.completed"
),
}
}
}
let header = HeaderValue::try_from(&request_id).expect("UUID is a valid header value");
response.headers_mut().insert("x-request-id", header);
response
}
fn sample_success(rate: f64) -> bool {
if rate >= 1.0 {
return true;
}
if rate <= 0.0 {
return false;
}
let sequence = REQUEST_LOG_SEQUENCE.fetch_add(1, Ordering::Relaxed);
(sequence % 10_000) < (rate * 10_000.0) as u64
}
fn request_outcome(status: u16) -> &'static str {
match status {
200..=399 => "success",
408 | 504 => "timeout",
400..=499 => "client_error",
502 => "dependency_error",
500..=599 => "server_error",
_ => "unknown",
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::Router;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::response::IntoResponse;
use axum::routing::get;
use tower::ServiceExt;
async fn handler() -> impl IntoResponse {
StatusCode::OK
}
#[tokio::test]
async fn test_logging_middleware_adds_request_id() {
let app = Router::new()
.route("/test", get(handler))
.layer(axum::middleware::from_fn(logging_middleware));
let request = Request::builder().uri("/test").body(Body::empty()).unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(response.headers().contains_key("x-request-id"));
}
#[tokio::test]
async fn test_logging_middleware_preserves_existing_request_id() {
let app = Router::new()
.route("/test", get(handler))
.layer(axum::middleware::from_fn(logging_middleware));
let request = Request::builder()
.uri("/test")
.header("x-request-id", "custom-id-123")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let request_id = response
.headers()
.get("x-request-id")
.unwrap()
.to_str()
.unwrap();
assert_eq!(request_id, "custom-id-123");
}
#[test]
fn test_request_outcome_classification() {
assert_eq!(request_outcome(200), "success");
assert_eq!(request_outcome(504), "timeout");
assert_eq!(request_outcome(502), "dependency_error");
assert_eq!(request_outcome(500), "server_error");
}
#[test]
fn test_sample_success_bounds() {
assert!(sample_success(1.0));
assert!(!sample_success(0.0));
}
#[test]
fn test_redact_path_removes_secrets() {
assert_eq!(
redact_path("/api/titles?password=secret123&token=abc"),
"/api/titles?password=[REDACTED]&token=[REDACTED]"
);
assert_eq!(
redact_path("/v1/search?key=mykey&filter=active"),
"/v1/search?key=[REDACTED]&filter=active"
);
assert_eq!(redact_path("/health"), "/health");
assert_eq!(
redact_path("/admin?authorization=Bearer+xyz"),
"/admin?authorization=[REDACTED]"
);
}
#[test]
fn test_error_details_for_status() {
assert_eq!(
error_details_for_status(401),
Some(("authentication", "authentication required"))
);
assert_eq!(
error_details_for_status(404),
Some(("not_found", "not found"))
);
assert_eq!(
error_details_for_status(500),
Some(("internal", "internal server error"))
);
assert_eq!(error_details_for_status(200), None);
}
#[test]
fn test_redact_path_preserves_non_secret() {
assert_eq!(
redact_path("/v1/titles/search?q=matrix&genre=Sci-Fi"),
"/v1/titles/search?q=matrix&genre=Sci-Fi"
);
}
#[test]
fn test_request_log_config_includes_request_level() {
let config = RequestLogConfig::default();
assert_eq!(config.request_level, "info");
let custom = RequestLogConfig {
request_level: "debug".to_string(),
..Default::default()
};
assert_eq!(custom.request_level, "debug");
}
#[test]
fn test_redact_path_handles_multiple_secrets_and_case() {
assert_eq!(
redact_path("/api?Password=foo&TOKEN=bar&secret=zzz&other=keep"),
"/api?Password=[REDACTED]&TOKEN=[REDACTED]&secret=[REDACTED]&other=keep"
);
}
#[tokio::test]
async fn test_logging_middleware_logs_success_with_method_path_status() {
let app = Router::new()
.route("/v1/titles/search", get(handler))
.layer(axum::middleware::from_fn(logging_middleware))
.layer(axum::Extension(RequestLogConfig {
success_sample_rate: 1.0,
slow_request_threshold: Duration::from_millis(250),
service_name: "test-service".to_string(),
environment: "test".to_string(),
request_level: "info".to_string(),
}));
let request = Request::builder()
.method("GET")
.uri("/v1/titles/search?q=matrix")
.header("x-request-id", "test-request-123")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("x-request-id").unwrap(),
"test-request-123"
);
assert_eq!(
redact_path("/v1/titles/search?q=matrix"),
"/v1/titles/search?q=matrix"
);
assert_eq!(error_details_for_status(200), None);
}
#[tokio::test]
async fn test_logging_middleware_logs_failure_with_error_details_and_secrets_excluded() {
async fn failing_handler() -> impl IntoResponse {
(StatusCode::NOT_FOUND, "not found")
}
let app = Router::new()
.route("/v1/titles/:id", get(failing_handler))
.layer(axum::middleware::from_fn(logging_middleware))
.layer(axum::Extension(RequestLogConfig {
success_sample_rate: 0.0,
slow_request_threshold: Duration::from_millis(1000),
service_name: "test-service".to_string(),
environment: "test".to_string(),
request_level: "info".to_string(),
}));
let request = Request::builder()
.method("GET")
.uri("/v1/titles/999?token=secret123&password=foo")
.header("x-request-id", "fail-request-456")
.header("Authorization", "Bearer super-secret-token")
.header("Cookie", "session=abc123")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(
response.headers().get("x-request-id").unwrap(),
"fail-request-456"
);
assert_eq!(
error_details_for_status(404),
Some(("not_found", "not found"))
);
assert_eq!(
redact_path("/v1/titles/999?token=secret123&password=foo"),
"/v1/titles/999?token=[REDACTED]&password=[REDACTED]"
);
}
}