1use axum::extract::Request;
50use axum::middleware::Next;
51use axum::response::Response;
52use std::sync::atomic::{AtomicU64, Ordering};
53use std::time::Instant;
54
55use crate::log::LogLevel;
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub struct RequestId {
63 timestamp_secs: u64,
65 counter: u64,
67}
68
69impl RequestId {
70 pub fn to_hex(&self) -> String {
74 format!("{:08x}{:08x}", self.timestamp_secs, self.counter)
75 }
76
77 pub fn timestamp_secs(&self) -> u64 {
79 self.timestamp_secs
80 }
81
82 pub fn counter(&self) -> u64 {
84 self.counter
85 }
86}
87
88impl std::fmt::Display for RequestId {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 f.write_str(&self.to_hex())
91 }
92}
93
94static REQUEST_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
96
97pub fn generate_request_id() -> RequestId {
102 let counter = REQUEST_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
103 let timestamp_secs = std::time::SystemTime::now()
104 .duration_since(std::time::UNIX_EPOCH)
105 .map(|d| d.as_secs())
106 .unwrap_or(0);
107 RequestId {
108 timestamp_secs,
109 counter,
110 }
111}
112
113#[derive(Debug, Clone, Default)]
115pub struct LogConfig {
116 pub exclude_paths: Vec<String>,
120}
121
122impl LogConfig {
123 pub fn with_exclude_paths(mut self, paths: Vec<String>) -> Self {
125 self.exclude_paths = paths;
126 self
127 }
128
129 pub fn is_excluded(&self, path: &str) -> bool {
133 crate::middleware::auth::is_route_allowed(path, &self.exclude_paths)
134 }
135}
136
137pub fn log_level_for_status(status: u16) -> LogLevel {
146 match status {
147 400..=499 => LogLevel::Warn,
148 500..=599 => LogLevel::Error,
149 _ => LogLevel::Info,
150 }
151}
152
153pub fn format_request_log(
159 method: &str,
160 uri: &str,
161 status: u16,
162 duration_ms: u64,
163 request_id: &RequestId,
164) -> String {
165 format!(
166 "request_id={} method={} uri={} status={} duration_ms={}",
167 request_id.to_hex(),
168 method,
169 uri,
170 status,
171 duration_ms
172 )
173}
174
175pub async fn log_middleware(req: Request, next: Next) -> Response {
203 log_middleware_inner(req, next, &LogConfig::default()).await
204}
205
206pub async fn log_middleware_with_config(
208 axum::extract::State(config): axum::extract::State<LogConfig>,
209 req: Request,
210 next: Next,
211) -> Response {
212 log_middleware_inner(req, next, &config).await
213}
214
215async fn log_middleware_inner(req: Request, next: Next, config: &LogConfig) -> Response {
216 let method = req.method().clone();
218 let uri = req.uri().path().to_string();
219
220 let request_id = req
222 .extensions()
223 .get::<RequestId>()
224 .copied()
225 .unwrap_or_else(generate_request_id);
226
227 let start = Instant::now();
229
230 let mut req = req;
232 req.extensions_mut().insert(request_id);
233
234 let response = next.run(req).await;
236
237 let duration_ms = start.elapsed().as_millis() as u64;
239
240 if !config.is_excluded(&uri) {
242 let status = response.status().as_u16();
243 let level = log_level_for_status(status);
244 let request_id_hex = request_id.to_hex();
245
246 match level {
247 LogLevel::Debug => tracing::debug!(
248 request_id = %request_id_hex,
249 method = %method,
250 uri = %uri,
251 status = status,
252 duration_ms = duration_ms,
253 "request completed"
254 ),
255 LogLevel::Info => tracing::info!(
256 request_id = %request_id_hex,
257 method = %method,
258 uri = %uri,
259 status = status,
260 duration_ms = duration_ms,
261 "request completed"
262 ),
263 LogLevel::Warn => tracing::warn!(
264 request_id = %request_id_hex,
265 method = %method,
266 uri = %uri,
267 status = status,
268 duration_ms = duration_ms,
269 "request completed"
270 ),
271 LogLevel::Error => tracing::error!(
272 request_id = %request_id_hex,
273 method = %method,
274 uri = %uri,
275 status = status,
276 duration_ms = duration_ms,
277 "request completed"
278 ),
279 }
280 }
281
282 response
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use axum::body::Body;
289 use axum::http::StatusCode;
290 use axum::Router;
291 use http_body_util::BodyExt;
292 use tower::ServiceExt;
293
294 async fn read_body(resp: Response) -> String {
299 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
300 String::from_utf8(bytes.to_vec()).unwrap()
301 }
302
303 fn make_request(method: &str, uri: &str) -> Request {
304 Request::builder()
305 .method(method)
306 .uri(uri)
307 .body(Body::empty())
308 .unwrap()
309 }
310
311 fn build_app() -> Router {
313 Router::new()
314 .route(
315 "/ok",
316 axum::routing::get(|| async { axum::http::StatusCode::OK }),
317 )
318 .route(
319 "/notfound",
320 axum::routing::get(|| async { axum::http::StatusCode::NOT_FOUND }),
321 )
322 .route(
323 "/error",
324 axum::routing::get(|| async { axum::http::StatusCode::INTERNAL_SERVER_ERROR }),
325 )
326 .route("/body", axum::routing::get(|| async { "hello" }))
327 .layer(axum::middleware::from_fn(log_middleware))
328 }
329
330 #[test]
335 fn test_request_id_to_hex_is_16_chars() {
336 let id = RequestId {
337 timestamp_secs: 0x12345678,
338 counter: 0x9ABCDEF0,
339 };
340 let hex = id.to_hex();
341 assert_eq!(hex.len(), 16);
342 assert_eq!(hex, "123456789abcdef0");
343 }
344
345 #[test]
346 fn test_request_id_to_hex_zero() {
347 let id = RequestId {
348 timestamp_secs: 0,
349 counter: 0,
350 };
351 assert_eq!(id.to_hex(), "0000000000000000");
352 }
353
354 #[test]
355 fn test_request_id_to_hex_max() {
356 let id = RequestId {
357 timestamp_secs: u64::MAX,
358 counter: u64::MAX,
359 };
360 let hex = id.to_hex();
362 assert_eq!(hex.len(), 32); }
364
365 #[test]
366 fn test_request_id_display_matches_to_hex() {
367 let id = RequestId {
368 timestamp_secs: 0x12345678,
369 counter: 0x9ABCDEF0,
370 };
371 assert_eq!(format!("{}", id), id.to_hex());
372 }
373
374 #[test]
375 fn test_request_id_accessors() {
376 let id = RequestId {
377 timestamp_secs: 100,
378 counter: 200,
379 };
380 assert_eq!(id.timestamp_secs(), 100);
381 assert_eq!(id.counter(), 200);
382 }
383
384 #[test]
385 fn test_request_id_equality() {
386 let id1 = RequestId {
387 timestamp_secs: 1,
388 counter: 2,
389 };
390 let id2 = RequestId {
391 timestamp_secs: 1,
392 counter: 2,
393 };
394 let id3 = RequestId {
395 timestamp_secs: 1,
396 counter: 3,
397 };
398 assert_eq!(id1, id2);
399 assert_ne!(id1, id3);
400 }
401
402 #[test]
407 fn test_generate_request_id_returns_unique() {
408 let id1 = generate_request_id();
409 let id2 = generate_request_id();
410 assert_ne!(id1.counter(), id2.counter());
412 assert_eq!(id2.counter(), id1.counter() + 1);
413 }
414
415 #[test]
416 fn test_generate_request_id_hex_is_16_chars() {
417 let id = generate_request_id();
418 let hex = id.to_hex();
419 assert!(hex.len() >= 16);
422 }
423
424 #[test]
429 fn test_log_level_for_2xx_returns_info() {
430 assert_eq!(log_level_for_status(200), LogLevel::Info);
431 assert_eq!(log_level_for_status(201), LogLevel::Info);
432 assert_eq!(log_level_for_status(204), LogLevel::Info);
433 }
434
435 #[test]
436 fn test_log_level_for_3xx_returns_info() {
437 assert_eq!(log_level_for_status(301), LogLevel::Info);
438 assert_eq!(log_level_for_status(302), LogLevel::Info);
439 assert_eq!(log_level_for_status(304), LogLevel::Info);
440 }
441
442 #[test]
443 fn test_log_level_for_4xx_returns_warn() {
444 assert_eq!(log_level_for_status(400), LogLevel::Warn);
445 assert_eq!(log_level_for_status(401), LogLevel::Warn);
446 assert_eq!(log_level_for_status(403), LogLevel::Warn);
447 assert_eq!(log_level_for_status(404), LogLevel::Warn);
448 assert_eq!(log_level_for_status(422), LogLevel::Warn);
449 assert_eq!(log_level_for_status(499), LogLevel::Warn);
450 }
451
452 #[test]
453 fn test_log_level_for_5xx_returns_error() {
454 assert_eq!(log_level_for_status(500), LogLevel::Error);
455 assert_eq!(log_level_for_status(501), LogLevel::Error);
456 assert_eq!(log_level_for_status(502), LogLevel::Error);
457 assert_eq!(log_level_for_status(503), LogLevel::Error);
458 assert_eq!(log_level_for_status(599), LogLevel::Error);
459 }
460
461 #[test]
462 fn test_log_level_for_1xx_returns_info() {
463 assert_eq!(log_level_for_status(100), LogLevel::Info);
465 assert_eq!(log_level_for_status(101), LogLevel::Info);
466 }
467
468 #[test]
469 fn test_log_level_for_boundary() {
470 assert_eq!(log_level_for_status(399), LogLevel::Info);
472 assert_eq!(log_level_for_status(400), LogLevel::Warn);
473 assert_eq!(log_level_for_status(499), LogLevel::Warn);
474 assert_eq!(log_level_for_status(500), LogLevel::Error);
475 assert_eq!(log_level_for_status(599), LogLevel::Error);
476 assert_eq!(log_level_for_status(600), LogLevel::Info);
477 }
478
479 #[test]
484 fn test_format_request_log_basic() {
485 let request_id = RequestId {
486 timestamp_secs: 0x12345678,
487 counter: 0x9ABCDEF0,
488 };
489 let msg = format_request_log("GET", "/api/users", 200, 15, &request_id);
490 assert_eq!(
491 msg,
492 "request_id=123456789abcdef0 method=GET uri=/api/users status=200 duration_ms=15"
493 );
494 }
495
496 #[test]
497 fn test_format_request_log_post_method() {
498 let request_id = RequestId {
499 timestamp_secs: 0,
500 counter: 1,
501 };
502 let msg = format_request_log("POST", "/api/orders", 201, 42, &request_id);
503 assert_eq!(
504 msg,
505 "request_id=0000000000000001 method=POST uri=/api/orders status=201 duration_ms=42"
506 );
507 }
508
509 #[test]
510 fn test_format_request_log_error_status() {
511 let request_id = RequestId {
512 timestamp_secs: 0,
513 counter: 0,
514 };
515 let msg = format_request_log("GET", "/missing", 404, 5, &request_id);
516 assert_eq!(
517 msg,
518 "request_id=0000000000000000 method=GET uri=/missing status=404 duration_ms=5"
519 );
520 }
521
522 #[test]
523 fn test_format_request_log_with_query_string_in_uri() {
524 let request_id = RequestId {
526 timestamp_secs: 0,
527 counter: 0,
528 };
529 let msg = format_request_log("GET", "/api?foo=bar", 200, 1, &request_id);
530 assert!(msg.contains("uri=/api?foo=bar"));
531 }
532
533 #[test]
538 fn test_log_config_default_empty_exclude_paths() {
539 let config = LogConfig::default();
540 assert!(config.exclude_paths.is_empty());
541 }
542
543 #[test]
544 fn test_log_config_with_exclude_paths() {
545 let config = LogConfig::default().with_exclude_paths(vec!["/health".to_string()]);
546 assert_eq!(config.exclude_paths, vec!["/health".to_string()]);
547 }
548
549 #[test]
550 fn test_log_config_is_excluded_exact_match() {
551 let config = LogConfig::default().with_exclude_paths(vec!["/health".to_string()]);
552 assert!(config.is_excluded("/health"));
553 assert!(!config.is_excluded("/health/detail"));
554 assert!(!config.is_excluded("/api"));
555 }
556
557 #[test]
558 fn test_log_config_is_excluded_wildcard_match() {
559 let config = LogConfig::default().with_exclude_paths(vec!["/health/*".to_string()]);
560 assert!(config.is_excluded("/health/check"));
561 assert!(config.is_excluded("/health/deep/nested"));
562 assert!(!config.is_excluded("/health"));
563 assert!(!config.is_excluded("/api"));
564 }
565
566 #[test]
567 fn test_log_config_is_excluded_empty_list() {
568 let config = LogConfig::default();
569 assert!(!config.is_excluded("/any"));
570 }
571
572 #[test]
573 fn test_log_config_is_excluded_multiple_entries() {
574 let config = LogConfig::default()
575 .with_exclude_paths(vec!["/health".to_string(), "/metrics/*".to_string()]);
576 assert!(config.is_excluded("/health"));
577 assert!(config.is_excluded("/metrics/prometheus"));
578 assert!(!config.is_excluded("/api"));
579 }
580
581 #[tokio::test]
586 async fn test_log_middleware_returns_response_unchanged() {
587 let app = build_app();
589 let resp = app.oneshot(make_request("GET", "/body")).await.unwrap();
590 let body = read_body(resp).await;
591 assert_eq!(body, "hello");
592 }
593
594 #[tokio::test]
595 async fn test_log_middleware_returns_correct_status() {
596 let app = build_app();
597 let resp = app.oneshot(make_request("GET", "/ok")).await.unwrap();
598 assert_eq!(resp.status(), StatusCode::OK);
599 }
600
601 #[tokio::test]
602 async fn test_log_middleware_injects_request_id() {
603 let app = Router::new()
605 .route(
606 "/",
607 axum::routing::get(|req: Request| async move {
608 let request_id = req.extensions().get::<RequestId>().unwrap();
609 format!("request_id:{}", request_id.to_hex())
610 }),
611 )
612 .layer(axum::middleware::from_fn(log_middleware));
613
614 let resp = app.oneshot(make_request("GET", "/")).await.unwrap();
615 assert_eq!(resp.status(), StatusCode::OK);
616 let body = read_body(resp).await;
617 assert!(body.starts_with("request_id:"));
618 let hex = body.strip_prefix("request_id:").unwrap();
620 assert!(hex.len() >= 16);
621 }
622
623 #[tokio::test]
624 async fn test_log_middleware_generates_unique_request_ids() {
625 let app = Router::new()
627 .route(
628 "/",
629 axum::routing::get(|req: Request| async move {
630 let request_id = req.extensions().get::<RequestId>().unwrap();
631 request_id.to_hex()
632 }),
633 )
634 .layer(axum::middleware::from_fn(log_middleware));
635
636 let resp1 = app.clone().oneshot(make_request("GET", "/")).await.unwrap();
637 let hex1 = read_body(resp1).await;
638
639 let resp2 = app.oneshot(make_request("GET", "/")).await.unwrap();
640 let hex2 = read_body(resp2).await;
641
642 assert_ne!(hex1, hex2);
643 }
644
645 #[tokio::test]
646 async fn test_log_middleware_preserves_existing_request_id() {
647 let existing_id = RequestId {
649 timestamp_secs: 0xDEADBEEF,
650 counter: 0x12345678,
651 };
652 let app = Router::new()
653 .route(
654 "/",
655 axum::routing::get(|req: Request| async move {
656 let request_id = req.extensions().get::<RequestId>().unwrap();
657 request_id.to_hex()
658 }),
659 )
660 .layer(axum::middleware::from_fn(log_middleware))
661 .layer(
662 tower::ServiceBuilder::new().layer(tower::layer::layer_fn(move |service| {
663 tower::util::MapRequest::new(service, move |mut req: Request| {
664 req.extensions_mut().insert(existing_id);
665 req
666 })
667 })),
668 );
669
670 let resp = app.oneshot(make_request("GET", "/")).await.unwrap();
671 let body = read_body(resp).await;
672 assert_eq!(body, "deadbeef12345678");
673 }
674
675 #[tokio::test]
676 async fn test_log_middleware_records_2xx_status() {
677 let app = build_app();
679 let resp = app.oneshot(make_request("GET", "/ok")).await.unwrap();
680 assert_eq!(resp.status(), StatusCode::OK);
681 }
682
683 #[tokio::test]
684 async fn test_log_middleware_records_4xx_status() {
685 let app = build_app();
686 let resp = app.oneshot(make_request("GET", "/notfound")).await.unwrap();
687 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
688 }
689
690 #[tokio::test]
691 async fn test_log_middleware_records_5xx_status() {
692 let app = build_app();
693 let resp = app.oneshot(make_request("GET", "/error")).await.unwrap();
694 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
695 }
696
697 #[tokio::test]
698 async fn test_log_middleware_with_config_excludes_path() {
699 let config = LogConfig::default().with_exclude_paths(vec!["/health".to_string()]);
701 let app = Router::new()
702 .route("/health", axum::routing::get(|| async { "healthy" }))
703 .layer(axum::middleware::from_fn_with_state(
704 config,
705 log_middleware_with_config,
706 ));
707
708 let resp = app.oneshot(make_request("GET", "/health")).await.unwrap();
709 assert_eq!(resp.status(), StatusCode::OK);
710 let body = read_body(resp).await;
711 assert_eq!(body, "healthy");
712 }
713
714 #[tokio::test]
715 async fn test_log_middleware_with_config_wildcard_exclude() {
716 let config = LogConfig::default().with_exclude_paths(vec!["/metrics/*".to_string()]);
718 let app = Router::new()
719 .route(
720 "/metrics/prometheus",
721 axum::routing::get(|| async { "metrics" }),
722 )
723 .layer(axum::middleware::from_fn_with_state(
724 config,
725 log_middleware_with_config,
726 ));
727
728 let resp = app
729 .oneshot(make_request("GET", "/metrics/prometheus"))
730 .await
731 .unwrap();
732 assert_eq!(resp.status(), StatusCode::OK);
733 }
734
735 #[tokio::test]
736 async fn test_log_middleware_preserves_method_and_uri() {
737 let app = build_app();
740 let resp = app.oneshot(make_request("GET", "/ok")).await.unwrap();
741 assert_eq!(resp.status(), StatusCode::OK);
742 }
743
744 #[tokio::test]
745 async fn test_log_middleware_duration_is_non_negative() {
746 let app = build_app();
748 let start = std::time::Instant::now();
749 let resp = app.oneshot(make_request("GET", "/ok")).await.unwrap();
750 let elapsed = start.elapsed();
751 assert!(resp.status().is_success());
752 assert!(elapsed.as_millis() < 5000); }
755
756 #[tokio::test]
757 async fn test_log_middleware_handles_post_request() {
758 let app = Router::new()
759 .route(
760 "/submit",
761 axum::routing::post(|| async { axum::http::StatusCode::CREATED }),
762 )
763 .layer(axum::middleware::from_fn(log_middleware));
764
765 let req = Request::builder()
766 .method("POST")
767 .uri("/submit")
768 .body(Body::empty())
769 .unwrap();
770 let resp = app.oneshot(req).await.unwrap();
771 assert_eq!(resp.status(), StatusCode::CREATED);
772 }
773
774 #[tokio::test]
775 async fn test_log_middleware_chains_with_other_middleware() {
776 async fn add_header_middleware(req: Request, next: Next) -> Response {
778 let mut resp = next.run(req).await;
779 resp.headers_mut()
780 .insert("X-Custom", "value".parse().unwrap());
781 resp
782 }
783
784 let app = Router::new()
785 .route("/", axum::routing::get(|| async { "ok" }))
786 .layer(axum::middleware::from_fn(add_header_middleware))
787 .layer(axum::middleware::from_fn(log_middleware));
788
789 let resp = app.oneshot(make_request("GET", "/")).await.unwrap();
790 assert_eq!(resp.status(), StatusCode::OK);
791 assert_eq!(resp.headers().get("X-Custom").unwrap(), "value");
792 }
793
794 #[test]
799 fn test_php_apart_level_alignment() {
800 assert_eq!(log_level_for_status(200), LogLevel::Info);
804 assert_eq!(log_level_for_status(404), LogLevel::Warn);
805 assert_eq!(log_level_for_status(500), LogLevel::Error);
806 }
807
808 #[test]
809 fn test_php_think_logger_level_alignment() {
810 let levels = [
813 LogLevel::Debug,
814 LogLevel::Info,
815 LogLevel::Warn,
816 LogLevel::Error,
817 ];
818 assert_eq!(levels.len(), 4);
819 }
820
821 #[test]
822 fn test_request_id_format_aligns_with_w3c_span_id_length() {
823 let id = RequestId {
826 timestamp_secs: 0x12345678,
827 counter: 0x9ABCDEF0,
828 };
829 assert_eq!(id.to_hex().len(), 16);
830 }
831}