use std::fmt::Debug;
use data_privacy::{RedactedToString, RedactionEngine};
use http::uri::Authority;
use http_extensions::UriTemplateLabel;
use layered::{Layer, Service};
use ohno::ErrorExt;
use tick::Clock;
use tracing::{Level, event};
use crate::error_labels::collect_error_labels;
use crate::{HttpRequest, HttpResponse, RequestExt, RequestHandler, Result};
#[derive(Debug)]
pub struct Logging<T> {
inner: T,
clock: Clock,
redaction_engine: RedactionEngine,
}
impl Logging<()> {
#[must_use]
pub fn layer(clock: &Clock, redaction_engine: &RedactionEngine) -> LoggingLayer {
LoggingLayer {
clock: clock.clone(),
redaction_engine: redaction_engine.clone(),
}
}
}
#[derive(Debug)]
pub struct LoggingLayer {
clock: Clock,
redaction_engine: RedactionEngine,
}
impl<S> Layer<S> for LoggingLayer {
type Service = Logging<S>;
fn layer(&self, inner: S) -> Self::Service {
Logging {
inner,
clock: self.clock.clone(),
redaction_engine: self.redaction_engine.clone(),
}
}
}
impl<T: RequestHandler> Service<HttpRequest> for Logging<T> {
type Out = Result<HttpResponse>;
fn execute(&self, input: HttpRequest) -> impl Future<Output = Result<HttpResponse>> + Send {
let watch = self.clock.stopwatch();
let url = input.uri().clone();
let method = input.method().clone();
let template = input.uri_template_label().map(UriTemplateLabel::into_cow);
let redacted_path_and_query = redacted_path_and_query(&input, &self.redaction_engine);
async move {
match self.inner.execute(input).await {
Ok(response) => {
event!(
name: "http.response.complete",
Level::DEBUG,
http.request.method = method.as_str(),
server.address = url.authority().map(Authority::host),
server.port = url.port_u16(),
http.response.status_code = response.status().as_u16(),
network.protocol.version = ?response.version(),
url.scheme = url.scheme_str(),
url.path.template = template.as_deref(),
url.path.redacted = redacted_path_and_query,
http.client.request.duration = watch.elapsed().as_secs_f32(),
"HTTP response received successfully",
);
Ok(response)
}
Err(err) => {
event!(
name: "http.response.error",
Level::WARN,
http.request.method = method.as_str(),
server.address = url.authority().map(Authority::host),
error.type = %collect_error_labels(&err),
exception.message = err.message(),
url.scheme = url.scheme_str(),
url.path.template = template.as_deref(),
url.path.redacted = redacted_path_and_query,
http.client.request.duration = watch.elapsed().as_secs_f32(),
"HTTP response failed",
);
Err(err)
}
}
}
}
}
fn redacted_path_and_query(request: &HttpRequest, engine: &RedactionEngine) -> Option<String> {
match request.path_and_query() {
Some(path_and_query) => Some(path_and_query.to_redacted_string(engine)),
None => request
.uri()
.path_and_query()
.map(|v| templated_uri::PathAndQuery::from(v.clone()).to_redacted_string(engine)),
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use data_privacy::simple_redactor::{SimpleRedactor, SimpleRedactorMode};
use http::{Request, StatusCode};
use http_extensions::{FakeHandler, HttpBodyBuilder, HttpRequestBuilder};
use templated_uri::Uri;
use testing_aids::LogCapture;
use super::*;
use crate::handlers::Dispatch;
#[cfg_attr(miri, ignore)]
#[tokio::test]
async fn execute_logs_and_returns_successful_response() {
let capture = LogCapture::new();
let _guard = tracing::subscriber::set_default(capture.subscriber());
let clock = Clock::new_frozen();
let layer = Logging::layer(&clock, &RedactionEngine::default());
let handler = layer.layer(Dispatch::new_fake(FakeHandler::from(StatusCode::OK)));
let request = HttpRequestBuilder::new_fake()
.uri("https://example.com:123/path?query=value")
.build()
.unwrap();
let response = handler.execute(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let output = capture.output();
assert!(output.contains("DEBUG"), "expected DEBUG level, got:\n{output}");
capture.assert_contains("HTTP response received successfully");
capture.assert_contains("http.request.method=\"GET\"");
capture.assert_contains("server.address=\"example.com\"");
capture.assert_contains("server.port=123");
capture.assert_contains("http.response.status_code=200");
capture.assert_contains("network.protocol.version=HTTP/1.1");
capture.assert_contains("url.scheme=\"https\"");
capture.assert_contains("url.path.template=\"/path?query=value\"");
capture.assert_contains("url.path.redacted=\"\"");
}
#[cfg_attr(miri, ignore)]
#[tokio::test]
async fn execute_logs_and_propagates_inner_error() {
let capture = LogCapture::new();
let _guard = tracing::subscriber::set_default(capture.subscriber());
let clock = Clock::new_frozen();
let layer = Logging::layer(&clock, &RedactionEngine::default());
let handler = layer.layer(Dispatch::new_fake(FakeHandler::never_completes()));
let request = Request::get(http::Uri::from_static("/no-authority"))
.body(HttpBodyBuilder::new_fake().empty())
.unwrap();
let error = handler.execute(request).await.unwrap_err();
assert_eq!(collect_error_labels(&error), "uri_origin_missing");
let output = capture.output();
assert!(output.contains("WARN"), "expected WARN level, got:\n{output}");
capture.assert_contains("HTTP response failed");
capture.assert_contains("http.request.method=\"GET\"");
capture.assert_contains("error.type=uri_origin_missing");
capture.assert_contains("exception.message=\"request must have scheme and authority set\"");
capture.assert_contains("url.path.redacted=\"\"");
}
#[test]
fn redacted_path_and_query_when_templated_path_and_query_attached() {
let engine = RedactionEngine::builder()
.add_class_redactor(Uri::DATA_CLASS, SimpleRedactor::with_mode(SimpleRedactorMode::Passthrough))
.build();
let request = HttpRequestBuilder::new_fake()
.uri("https://example.com/path?query=value")
.build()
.unwrap();
let redacted = redacted_path_and_query(&request, &engine);
assert_eq!(redacted, Some("/path?query=value".to_string()));
}
#[test]
fn redacted_path_and_query_when_templated_path_and_query_not_attached() {
let engine = RedactionEngine::builder()
.add_class_redactor(Uri::DATA_CLASS, SimpleRedactor::with_mode(SimpleRedactorMode::Passthrough))
.build();
let request = Request::builder()
.uri("https://example.com/path?query=value")
.body(HttpBodyBuilder::new_fake().text("abc"))
.unwrap();
let redacted = redacted_path_and_query(&request, &engine);
assert_eq!(redacted, Some("/path?query=value".to_string()));
}
}