#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::Instant;
use axum::extract::MatchedPath;
use axum::http::{HeaderName, Method, Request, Response};
use pin_project_lite::pin_project;
use tower::{Layer, Service};
static X_REQUEST_ID: HeaderName = HeaderName::from_static("x-request-id");
pub const ACCESS_LOG_TARGET: &str = "autumn::access";
pub const UNMATCHED_ROUTE: &str = "_unmatched";
#[derive(Debug, Clone)]
pub struct ResponseRoute(pub String);
#[derive(Clone, Debug, Default)]
pub struct AccessLogEmitted(Arc<std::sync::atomic::AtomicBool>);
impl AccessLogEmitted {
fn mark(&self) {
self.0.store(true, std::sync::atomic::Ordering::Release);
}
fn is_marked(&self) -> bool {
self.0.load(std::sync::atomic::Ordering::Acquire)
}
}
#[derive(Clone, Debug)]
pub struct AccessLogLayer {
exclude: Arc<[String]>,
fallback: bool,
}
impl AccessLogLayer {
#[must_use]
pub fn new(exclude: Vec<String>) -> Self {
Self {
exclude: normalize_exclusions(exclude),
fallback: false,
}
}
#[must_use]
pub fn fallback(exclude: Vec<String>) -> Self {
Self {
exclude: normalize_exclusions(exclude),
fallback: true,
}
}
}
fn normalize_exclusions(exclude: Vec<String>) -> Arc<[String]> {
exclude
.into_iter()
.map(|prefix| {
let trimmed = prefix.trim_end_matches('/');
if trimmed.len() == prefix.len() {
prefix
} else {
trimmed.to_owned()
}
})
.filter(|prefix| !prefix.is_empty())
.collect()
}
impl<S> Layer<S> for AccessLogLayer {
type Service = AccessLogService<S>;
fn layer(&self, inner: S) -> Self::Service {
AccessLogService {
inner,
exclude: Arc::clone(&self.exclude),
fallback: self.fallback,
}
}
}
#[derive(Clone, Debug)]
pub struct AccessLogService<S> {
inner: S,
exclude: Arc<[String]>,
fallback: bool,
}
struct RequestMeta {
method: Method,
route: Option<MatchedPath>,
request_id: Option<crate::middleware::RequestId>,
}
impl<S, ReqBody, ResBody> Service<Request<ReqBody>> for AccessLogService<S>
where
S: Service<Request<ReqBody>, Response = Response<ResBody>>,
{
type Response = S::Response;
type Error = S::Error;
type Future = AccessLogFuture<S::Future>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
let mut req = req;
let meta = if is_excluded(req.uri().path(), &self.exclude) {
None
} else {
Some(RequestMeta {
method: req.method().clone(),
route: req.extensions().get::<MatchedPath>().cloned(),
request_id: req
.extensions()
.get::<crate::middleware::RequestId>()
.cloned(),
})
};
let sentinel = if self.fallback {
let sentinel = AccessLogEmitted::default();
req.extensions_mut().insert(sentinel.clone());
Some(sentinel)
} else {
req.extensions().get::<AccessLogEmitted>().cloned()
};
AccessLogFuture {
inner: self.inner.call(req),
meta,
start: crate::time::ambient_instant(),
fallback: self.fallback,
sentinel,
}
}
}
fn is_excluded(path: &str, exclude: &[String]) -> bool {
exclude.iter().any(|prefix| {
path.strip_prefix(prefix.as_str())
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
})
}
pin_project! {
pub struct AccessLogFuture<F> {
#[pin]
inner: F,
meta: Option<RequestMeta>,
start: Instant,
fallback: bool,
sentinel: Option<AccessLogEmitted>,
}
}
impl<F, ResBody, E> Future for AccessLogFuture<F>
where
F: Future<Output = Result<Response<ResBody>, E>>,
{
type Output = Result<Response<ResBody>, E>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.project();
match this.inner.poll(cx) {
Poll::Ready(Ok(response)) => {
let already_emitted = *this.fallback
&& this
.sentinel
.as_ref()
.is_some_and(AccessLogEmitted::is_marked);
if let Some(meta) = this.meta.take()
&& !already_emitted
{
let duration_ms = crate::time::ambient_instant()
.saturating_duration_since(*this.start)
.as_secs_f64()
* 1000.0;
let header_id = response
.headers()
.get(&X_REQUEST_ID)
.and_then(|value| value.to_str().ok());
let mut id_buf = [0u8; uuid::fmt::Hyphenated::LENGTH];
let request_id = meta
.request_id
.as_ref()
.map(|id| id.encode(&mut id_buf))
.or(header_id);
tracing::info!(
target: ACCESS_LOG_TARGET,
method = %meta.method,
route = meta
.route
.as_ref()
.map(MatchedPath::as_str)
.or_else(|| {
response
.extensions()
.get::<ResponseRoute>()
.map(|route| route.0.as_str())
})
.unwrap_or(UNMATCHED_ROUTE),
status = response.status().as_u16(),
duration_ms,
request_id,
"request served"
);
if !*this.fallback
&& let Some(sentinel) = this.sentinel.as_ref()
{
sentinel.mark();
}
}
Poll::Ready(Ok(response))
}
other => other,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[derive(Clone)]
struct Buf(Arc<Mutex<Vec<u8>>>);
impl std::io::Write for Buf {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
if let Ok(mut held) = self.0.lock() {
held.extend_from_slice(bytes);
}
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Buf {
type Writer = Self;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
fn exclude(prefixes: &[&str]) -> Arc<[String]> {
normalize_exclusions(prefixes.iter().map(|p| (*p).to_owned()).collect())
}
#[test]
fn excludes_exact_path_and_sub_segments() {
let prefixes = exclude(&["/health", "/actuator", "/static"]);
assert!(is_excluded("/health", &prefixes));
assert!(is_excluded("/actuator/health", &prefixes));
assert!(is_excluded("/static/css/app.css", &prefixes));
}
#[test]
fn does_not_exclude_lookalike_prefixes() {
let prefixes = exclude(&["/health", "/static"]);
assert!(!is_excluded("/healthz", &prefixes));
assert!(!is_excluded("/staticfiles", &prefixes));
assert!(!is_excluded("/users/1", &prefixes));
}
#[test]
fn trailing_slashes_in_config_are_tolerated() {
let prefixes = exclude(&["/actuator/"]);
assert!(is_excluded("/actuator", &prefixes));
assert!(is_excluded("/actuator/metrics", &prefixes));
}
#[test]
fn empty_or_slash_only_prefixes_exclude_nothing() {
assert!(!is_excluded("/users/1", &exclude(&[""])));
assert!(!is_excluded("/users/1", &exclude(&["/"])));
assert!(!is_excluded("/users/1", &[]));
}
#[tokio::test]
async fn a_response_route_names_the_log_line_when_the_request_had_none() {
use tower::ServiceExt as _;
let buf = Buf(Arc::new(Mutex::new(Vec::new())));
let subscriber = tracing_subscriber::fmt()
.with_writer(buf.clone())
.with_ansi(false)
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let service = AccessLogLayer::new(Vec::new()).layer(tower::service_fn(
|_request: Request<axum::body::Body>| async {
let mut response = Response::new(axum::body::Body::empty());
response
.extensions_mut()
.insert(ResponseRoute("/p/x".to_owned()));
Ok::<_, std::convert::Infallible>(response)
},
));
let request = Request::builder()
.uri("/p/x")
.body(axum::body::Body::empty())
.expect("request");
service.oneshot(request).await.expect("infallible");
let out = String::from_utf8(buf.0.lock().expect("lock").clone()).expect("utf8");
assert!(out.contains("/p/x"), "{out}");
assert!(!out.contains(UNMATCHED_ROUTE), "{out}");
}
#[tokio::test]
#[allow(
clippy::literal_string_with_formatting_args,
reason = "route templates use {placeholders}"
)]
async fn route_and_request_id_are_recorded_as_strings() {
use tower::ServiceExt as _;
let buf = Buf(Arc::new(Mutex::new(Vec::new())));
let subscriber = tracing_subscriber::fmt()
.with_writer(buf.clone())
.with_ansi(false)
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let router = axum::Router::new()
.route("/users/{id}", axum::routing::get(|| async { "ok" }))
.layer(AccessLogLayer::new(Vec::new()))
.layer(crate::middleware::RequestIdLayer::default());
let request = Request::builder()
.uri("/users/7")
.body(axum::body::Body::empty())
.expect("request");
let response = router.oneshot(request).await.expect("infallible");
let id = response
.headers()
.get(&X_REQUEST_ID)
.and_then(|value| value.to_str().ok())
.expect("x-request-id")
.to_owned();
let out = String::from_utf8(buf.0.lock().expect("lock").clone()).expect("utf8");
assert!(out.contains(r#"route="/users/{id}""#), "{out}");
assert!(out.contains(&format!(r#"request_id="{id}""#)), "{out}");
assert!(!out.contains("/users/7"), "{out}");
}
#[tokio::test]
async fn an_inbound_request_id_is_logged_as_sent() {
use tower::ServiceExt as _;
let buf = Buf(Arc::new(Mutex::new(Vec::new())));
let subscriber = tracing_subscriber::fmt()
.with_writer(buf.clone())
.with_ansi(false)
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let sent = "0E4F1C2A-9b3d-4E5F-8a6b-7C8D9E0F1A2B";
let service = AccessLogLayer::new(Vec::new()).layer(tower::service_fn(
|_request: Request<axum::body::Body>| async {
Ok::<_, std::convert::Infallible>(Response::new(axum::body::Body::empty()))
},
));
let mut request = Request::builder()
.uri("/nowhere")
.body(axum::body::Body::empty())
.expect("request");
request.extensions_mut().insert(
crate::middleware::RequestId::parse_inbound(sent).expect("well-formed inbound id"),
);
service.oneshot(request).await.expect("infallible");
let out = String::from_utf8(buf.0.lock().expect("lock").clone()).expect("utf8");
assert!(out.contains(&format!(r#"request_id="{sent}""#)), "{out}");
assert!(
out.contains(&format!(r#"route="{UNMATCHED_ROUTE}""#)),
"{out}"
);
}
#[tokio::test]
async fn the_fallback_takes_the_request_id_from_the_response_header() {
use tower::ServiceExt as _;
let buf = Buf(Arc::new(Mutex::new(Vec::new())));
let subscriber = tracing_subscriber::fmt()
.with_writer(buf.clone())
.with_ansi(false)
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let header_id = "0f8fad5b-d9cb-469f-a165-70867728950e";
let service = AccessLogLayer::fallback(Vec::new()).layer(tower::service_fn(
move |_request: Request<axum::body::Body>| async move {
let mut response = Response::new(axum::body::Body::empty());
response.headers_mut().insert(
X_REQUEST_ID.clone(),
axum::http::HeaderValue::from_static(header_id),
);
Ok::<_, std::convert::Infallible>(response)
},
));
let request = Request::builder()
.uri("/starting")
.body(axum::body::Body::empty())
.expect("request");
service.oneshot(request).await.expect("infallible");
let out = String::from_utf8(buf.0.lock().expect("lock").clone()).expect("utf8");
assert!(
out.contains(&format!(r#"request_id="{header_id}""#)),
"{out}"
);
}
}