1use std::time::Instant;
6use std::future::Future;
7use std::pin::Pin;
8use std::task::{Context, Poll};
9use std::sync::OnceLock;
10use http::{Method, StatusCode, Response, Uri, Request};
11use tracing::Span;
12
13#[cfg(feature = "prometheus-metrics")]
15use prometheus::{IntCounterVec, HistogramVec, Opts, HistogramOpts};
16
17#[derive(Clone)]
33pub struct ObservabilityMiddleware<S> {
34 inner: S,
35}
36
37impl<S> ObservabilityMiddleware<S> {
38 pub fn new(inner: S) -> Self {
40 Self { inner }
41 }
42}
43
44#[derive(Clone, Copy, Default)]
46pub struct ObservabilityLayer;
47
48impl ObservabilityLayer {
49 pub fn new() -> Self {
51 Self
52 }
53}
54
55impl<S> tower::Layer<S> for ObservabilityLayer {
56 type Service = ObservabilityMiddleware<S>;
57
58 fn layer(&self, inner: S) -> Self::Service {
59 ObservabilityMiddleware::new(inner)
60 }
61}
62
63pub struct ResponseFuture<F> {
68 inner: Pin<Box<F>>,
69 start: Instant,
70 method: Method,
71 path: String,
72 span: Span,
73}
74
75impl<F, Res, E> Future for ResponseFuture<F>
76where
77 F: Future<Output = Result<Res, E>>,
78 Res: HttpStatus,
79 E: std::fmt::Display,
80{
81 type Output = F::Output;
82
83 fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
84 match self.inner.as_mut().poll(cx) {
86 Poll::Ready(Ok(response)) => {
87 let duration = self.start.elapsed();
88
89 let status = response.status();
91
92 record_http_metrics(
94 &self.method,
95 &self.path,
96 status,
97 duration.as_secs_f64(),
98 );
99
100 self.span.record("status", status.as_u16());
102 self.span.record("duration_ms", duration.as_millis());
103 self.span.in_scope(|| {
104 tracing::info!(
105 method = %self.method,
106 path = %self.path,
107 status = status.as_u16(),
108 duration_ms = duration.as_millis(),
109 "HTTP request completed"
110 );
111 });
112
113 Poll::Ready(Ok(response))
114 }
115 Poll::Ready(Err(e)) => {
116 let duration = self.start.elapsed();
117
118 self.span.record("status", 500u16);
120 self.span.record("duration_ms", duration.as_millis());
121 self.span.in_scope(|| {
122 tracing::error!(
123 method = %self.method,
124 path = %self.path,
125 duration_ms = duration.as_millis(),
126 error = %e,
127 "HTTP request failed"
128 );
129 });
130
131 Poll::Ready(Err(e))
132 }
133 Poll::Pending => Poll::Pending,
134 }
135 }
136}
137
138pub trait HttpStatus {
143 fn status(&self) -> StatusCode;
145}
146
147impl<B> HttpStatus for Response<B> {
148 fn status(&self) -> StatusCode {
149 Response::status(self)
150 }
151}
152
153impl<R, S> tower::Service<Request<R>> for ObservabilityMiddleware<S>
154where
155 R: Send + 'static,
156 S: tower::Service<Request<R>> + Clone + Send + 'static,
157 S::Future: Send + 'static,
158 S::Response: HttpStatus,
159 S::Error: std::error::Error + Send + Sync,
160{
161 type Response = S::Response;
162 type Error = S::Error;
163 type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
164
165 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
166 self.inner.poll_ready(cx)
167 }
168
169 fn call(&mut self, req: Request<R>) -> Self::Future {
170 let method = req.method().clone();
171 let uri = req.uri().clone();
172 let path = extract_path_template(&uri);
173
174 #[cfg(feature = "otel-tracing")]
176 let parent_cx = {
177 struct HeaderExtractor<'a, B>(&'a Request<B>);
178 impl<B> opentelemetry::propagation::Extractor for HeaderExtractor<'_, B> {
179 fn get(&self, key: &str) -> Option<&str> {
180 self.0.headers().get(key).and_then(|v| v.to_str().ok())
181 }
182 fn keys(&self) -> Vec<&str> {
183 self.0.headers().keys().map(|k| k.as_str()).collect()
184 }
185 }
186 opentelemetry::global::get_text_map_propagator(|prop| {
187 prop.extract(&HeaderExtractor(&req))
188 })
189 };
190
191 let span = tracing::info_span!(
193 "http_request",
194 method = %method,
195 path = %path,
196 status = tracing::field::Empty,
197 duration_ms = tracing::field::Empty,
198 );
199
200 #[cfg(feature = "otel-tracing")]
202 {
203 use tracing_opentelemetry::OpenTelemetrySpanExt;
204 span.set_parent(parent_cx);
205 }
206
207 let start = Instant::now();
208
209 let mut inner = self.inner.clone();
211
212 Box::pin(async move {
213 let _enter = span.enter();
214
215 let response = inner.call(req).await?;
216
217 let duration = start.elapsed();
219 let status = response.status();
220
221 record_http_metrics(
222 &method,
223 &path,
224 status,
225 duration.as_secs_f64(),
226 );
227
228 span.record("status", status.as_u16());
230 span.record("duration_ms", duration.as_millis());
231
232 Ok(response)
233 })
234 }
235}
236
237fn extract_path_template(uri: &Uri) -> String {
241 let path = uri.path();
242
243 let normalized = path
246 .split('/')
247 .map(|segment| {
248 if segment.is_empty() {
249 return String::new(); }
251 if looks_like_id(segment) {
253 return ":id".to_string();
254 }
255 if segment.parse::<u64>().is_ok() {
257 return ":num".to_string();
258 }
259 segment.to_string()
260 })
261 .collect::<Vec<_>>()
262 .join("/");
263
264 if !normalized.starts_with('/') {
266 format!("/{}", normalized)
267 } else {
268 normalized
269 }
270}
271
272fn looks_like_id(s: &str) -> bool {
274 if s.len() == 36 && s.chars().filter(|&c| c == '-').count() == 4 {
276 return true;
277 }
278 if s.len() == 24 && s.chars().all(|c| c.is_ascii_hexdigit()) {
280 return true;
281 }
282 let hyphen_count = s.chars().filter(|&c| c == '-').count();
284 if hyphen_count > 0 && hyphen_count <= 3 {
285 let alphanumeric_part: String = s.chars().filter(|c| c.is_alphanumeric()).collect();
286 if alphanumeric_part.len() > 8 && alphanumeric_part.len() <= 24 {
287 return true;
288 }
289 }
290 if s.len() >= 10 && s.len() <= 32 && s.chars().all(|c| c.is_alphanumeric()) {
293 if s.chars().all(|c| c.is_ascii_digit()) {
295 return false;
296 }
297 let lower = s.to_lowercase();
299 if matches!(lower.as_str(), "api" | "v1" | "v2" | "admin" | "users" | "public" | "health" | "metrics") {
300 return false;
301 }
302 return true;
303 }
304 false
305}
306
307#[cfg(feature = "prometheus-metrics")]
309fn record_http_metrics(method: &Method, path: &str, status: StatusCode, duration_secs: f64) {
310 let counter = HTTP_REQUESTS_TOTAL.get_or_init(|| {
312 let metric = IntCounterVec::new(
313 Opts::new("http_requests_total", "Total number of HTTP requests"),
314 &["method", "path", "status"]
315 ).expect("Failed to create http_requests_total metric");
316 let _ = prometheus::register(Box::new(metric.clone()));
317 metric
318 });
319
320 let histogram = HTTP_REQUEST_DURATION_SECONDS.get_or_init(|| {
321 let metric = HistogramVec::new(
322 HistogramOpts::new("http_request_duration_seconds", "HTTP request latency in seconds")
323 .buckets(vec![0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0])
324 .into(),
325 &["method", "path"],
326 ).expect("Failed to create http_request_duration_seconds metric");
327 let _ = prometheus::register(Box::new(metric.clone()));
328 metric
329 });
330
331 let _ = counter
333 .with_label_values(&[method.as_str(), path, &status.as_u16().to_string()])
334 .inc();
335
336 let _ = histogram
337 .with_label_values(&[method.as_str(), path])
338 .observe(duration_secs);
339}
340
341#[cfg(feature = "prometheus-metrics")]
343static HTTP_REQUESTS_TOTAL: OnceLock<IntCounterVec> = OnceLock::new();
344
345#[cfg(feature = "prometheus-metrics")]
347static HTTP_REQUEST_DURATION_SECONDS: OnceLock<HistogramVec> = OnceLock::new();
348
349#[cfg(not(feature = "prometheus-metrics"))]
351fn record_http_metrics(method: &Method, path: &str, status: StatusCode, duration_secs: f64) {
352 tracing::debug!(
353 method = %method,
354 path = %path,
355 status = status.as_u16(),
356 duration_secs = duration_secs,
357 "HTTP request completed (metrics not recorded - enable prometheus-metrics feature)"
358 );
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 #[test]
366 fn test_extract_path_template() {
367 let uri = Uri::from_static("/api/users/123456789012/orders/abc123-def456");
368 let path = extract_path_template(&uri);
369 assert_eq!(path, "/api/users/:num/orders/:id");
370 }
371
372 #[test]
373 fn test_extract_path_with_query() {
374 let uri = Uri::from_static("/api/users?page=2&limit=10");
375 let path = extract_path_template(&uri);
376 assert_eq!(path, "/api/users");
377 }
378
379 #[test]
380 fn test_looks_like_id() {
381 assert!(looks_like_id("550e8400-e29b-41d4-a716-446655440000"));
383 assert!(looks_like_id("507f1f77bcf86cd799439011"));
385 assert!(looks_like_id("abc123def456"));
387 assert!(!looks_like_id("users"));
389 assert!(!looks_like_id("admin"));
390 }
391}