use std::time::Instant;
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::sync::OnceLock;
use http::{Method, StatusCode, Response, Uri, Request};
use tracing::Span;
#[cfg(feature = "prometheus-metrics")]
use prometheus::{IntCounterVec, HistogramVec, Opts, HistogramOpts};
#[derive(Clone)]
pub struct ObservabilityMiddleware<S> {
inner: S,
}
impl<S> ObservabilityMiddleware<S> {
pub fn new(inner: S) -> Self {
Self { inner }
}
}
#[derive(Clone, Copy, Default)]
pub struct ObservabilityLayer;
impl ObservabilityLayer {
pub fn new() -> Self {
Self
}
}
impl<S> tower::Layer<S> for ObservabilityLayer {
type Service = ObservabilityMiddleware<S>;
fn layer(&self, inner: S) -> Self::Service {
ObservabilityMiddleware::new(inner)
}
}
pub struct ResponseFuture<F> {
inner: Pin<Box<F>>,
start: Instant,
method: Method,
path: String,
span: Span,
}
impl<F, Res, E> Future for ResponseFuture<F>
where
F: Future<Output = Result<Res, E>>,
Res: HttpStatus,
E: std::fmt::Display,
{
type Output = F::Output;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
match self.inner.as_mut().poll(cx) {
Poll::Ready(Ok(response)) => {
let duration = self.start.elapsed();
let status = response.status();
record_http_metrics(
&self.method,
&self.path,
status,
duration.as_secs_f64(),
);
self.span.record("status", status.as_u16());
self.span.record("duration_ms", duration.as_millis());
self.span.in_scope(|| {
tracing::info!(
method = %self.method,
path = %self.path,
status = status.as_u16(),
duration_ms = duration.as_millis(),
"HTTP request completed"
);
});
Poll::Ready(Ok(response))
}
Poll::Ready(Err(e)) => {
let duration = self.start.elapsed();
self.span.record("status", 500u16);
self.span.record("duration_ms", duration.as_millis());
self.span.in_scope(|| {
tracing::error!(
method = %self.method,
path = %self.path,
duration_ms = duration.as_millis(),
error = %e,
"HTTP request failed"
);
});
Poll::Ready(Err(e))
}
Poll::Pending => Poll::Pending,
}
}
}
pub trait HttpStatus {
fn status(&self) -> StatusCode;
}
impl<B> HttpStatus for Response<B> {
fn status(&self) -> StatusCode {
Response::status(self)
}
}
impl<R, S> tower::Service<Request<R>> for ObservabilityMiddleware<S>
where
R: Send + 'static,
S: tower::Service<Request<R>> + Clone + Send + 'static,
S::Future: Send + 'static,
S::Response: HttpStatus,
S::Error: std::error::Error + Send + Sync,
{
type Response = S::Response;
type Error = S::Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<R>) -> Self::Future {
let method = req.method().clone();
let uri = req.uri().clone();
let path = extract_path_template(&uri);
#[cfg(feature = "otel-tracing")]
let parent_cx = {
struct HeaderExtractor<'a, B>(&'a Request<B>);
impl<B> opentelemetry::propagation::Extractor for HeaderExtractor<'_, B> {
fn get(&self, key: &str) -> Option<&str> {
self.0.headers().get(key).and_then(|v| v.to_str().ok())
}
fn keys(&self) -> Vec<&str> {
self.0.headers().keys().map(|k| k.as_str()).collect()
}
}
opentelemetry::global::get_text_map_propagator(|prop| {
prop.extract(&HeaderExtractor(&req))
})
};
let span = tracing::info_span!(
"http_request",
method = %method,
path = %path,
status = tracing::field::Empty,
duration_ms = tracing::field::Empty,
);
#[cfg(feature = "otel-tracing")]
{
use tracing_opentelemetry::OpenTelemetrySpanExt;
span.set_parent(parent_cx);
}
let start = Instant::now();
let mut inner = self.inner.clone();
Box::pin(async move {
let _enter = span.enter();
let response = inner.call(req).await?;
let duration = start.elapsed();
let status = response.status();
record_http_metrics(
&method,
&path,
status,
duration.as_secs_f64(),
);
span.record("status", status.as_u16());
span.record("duration_ms", duration.as_millis());
Ok(response)
})
}
}
fn extract_path_template(uri: &Uri) -> String {
let path = uri.path();
let normalized = path
.split('/')
.map(|segment| {
if segment.is_empty() {
return String::new(); }
if looks_like_id(segment) {
return ":id".to_string();
}
if segment.parse::<u64>().is_ok() {
return ":num".to_string();
}
segment.to_string()
})
.collect::<Vec<_>>()
.join("/");
if !normalized.starts_with('/') {
format!("/{}", normalized)
} else {
normalized
}
}
fn looks_like_id(s: &str) -> bool {
if s.len() == 36 && s.chars().filter(|&c| c == '-').count() == 4 {
return true;
}
if s.len() == 24 && s.chars().all(|c| c.is_ascii_hexdigit()) {
return true;
}
let hyphen_count = s.chars().filter(|&c| c == '-').count();
if hyphen_count > 0 && hyphen_count <= 3 {
let alphanumeric_part: String = s.chars().filter(|c| c.is_alphanumeric()).collect();
if alphanumeric_part.len() > 8 && alphanumeric_part.len() <= 24 {
return true;
}
}
if s.len() >= 10 && s.len() <= 32 && s.chars().all(|c| c.is_alphanumeric()) {
if s.chars().all(|c| c.is_ascii_digit()) {
return false;
}
let lower = s.to_lowercase();
if matches!(lower.as_str(), "api" | "v1" | "v2" | "admin" | "users" | "public" | "health" | "metrics") {
return false;
}
return true;
}
false
}
#[cfg(feature = "prometheus-metrics")]
fn record_http_metrics(method: &Method, path: &str, status: StatusCode, duration_secs: f64) {
let counter = HTTP_REQUESTS_TOTAL.get_or_init(|| {
let metric = IntCounterVec::new(
Opts::new("http_requests_total", "Total number of HTTP requests"),
&["method", "path", "status"]
).expect("Failed to create http_requests_total metric");
let _ = prometheus::register(Box::new(metric.clone()));
metric
});
let histogram = HTTP_REQUEST_DURATION_SECONDS.get_or_init(|| {
let metric = HistogramVec::new(
HistogramOpts::new("http_request_duration_seconds", "HTTP request latency in seconds")
.buckets(vec![0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0])
.into(),
&["method", "path"],
).expect("Failed to create http_request_duration_seconds metric");
let _ = prometheus::register(Box::new(metric.clone()));
metric
});
let _ = counter
.with_label_values(&[method.as_str(), path, &status.as_u16().to_string()])
.inc();
let _ = histogram
.with_label_values(&[method.as_str(), path])
.observe(duration_secs);
}
#[cfg(feature = "prometheus-metrics")]
static HTTP_REQUESTS_TOTAL: OnceLock<IntCounterVec> = OnceLock::new();
#[cfg(feature = "prometheus-metrics")]
static HTTP_REQUEST_DURATION_SECONDS: OnceLock<HistogramVec> = OnceLock::new();
#[cfg(not(feature = "prometheus-metrics"))]
fn record_http_metrics(method: &Method, path: &str, status: StatusCode, duration_secs: f64) {
tracing::debug!(
method = %method,
path = %path,
status = status.as_u16(),
duration_secs = duration_secs,
"HTTP request completed (metrics not recorded - enable prometheus-metrics feature)"
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_path_template() {
let uri = Uri::from_static("/api/users/123456789012/orders/abc123-def456");
let path = extract_path_template(&uri);
assert_eq!(path, "/api/users/:num/orders/:id");
}
#[test]
fn test_extract_path_with_query() {
let uri = Uri::from_static("/api/users?page=2&limit=10");
let path = extract_path_template(&uri);
assert_eq!(path, "/api/users");
}
#[test]
fn test_looks_like_id() {
assert!(looks_like_id("550e8400-e29b-41d4-a716-446655440000"));
assert!(looks_like_id("507f1f77bcf86cd799439011"));
assert!(looks_like_id("abc123def456"));
assert!(!looks_like_id("users"));
assert!(!looks_like_id("admin"));
}
}