Skip to main content

backbone_observability/
middleware.rs

1//! Middleware for HTTP observability
2//!
3//! Provides Tower middleware for metrics and tracing.
4
5use 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// Global metrics when prometheus feature is enabled
14#[cfg(feature = "prometheus-metrics")]
15use prometheus::{IntCounterVec, HistogramVec, Opts, HistogramOpts};
16
17/// Metrics and tracing middleware for HTTP requests
18///
19/// This middleware:
20/// - Records HTTP request metrics (counter, histogram)
21/// - Adds distributed tracing to requests
22/// - Tracks request duration
23///
24/// # Example
25/// ```no_run
26/// use backbone_observability::ObservabilityLayer;
27/// use tower::ServiceBuilder;
28///
29/// let layer = ServiceBuilder::new()
30///     .layer(ObservabilityLayer::new());
31/// ```
32#[derive(Clone)]
33pub struct ObservabilityMiddleware<S> {
34    inner: S,
35}
36
37impl<S> ObservabilityMiddleware<S> {
38    /// Create a new observability middleware
39    pub fn new(inner: S) -> Self {
40        Self { inner }
41    }
42}
43
44/// Layer for creating observability middleware
45#[derive(Clone, Copy, Default)]
46pub struct ObservabilityLayer;
47
48impl ObservabilityLayer {
49    /// Create a new observability layer
50    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
63/// Response wrapper that records metrics
64///
65/// This wraps the inner service's Future and records metrics/tracing
66/// when the response completes.
67pub 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        // Poll the inner future
85        match self.inner.as_mut().poll(cx) {
86            Poll::Ready(Ok(response)) => {
87                let duration = self.start.elapsed();
88
89                // Extract status code from Response
90                let status = response.status();
91
92                // Record metrics
93                record_http_metrics(
94                    &self.method,
95                    &self.path,
96                    status,
97                    duration.as_secs_f64(),
98                );
99
100                // Complete the span
101                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                // Record error metrics
119                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
138// Helper trait to extract status from responses
139///
140/// This trait allows the middleware to extract HTTP status codes
141/// from different response types in the Tower ecosystem.
142pub trait HttpStatus {
143    /// Returns the HTTP status code for this response
144    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        // Extract W3C trace context from incoming headers (traceparent / tracestate)
175        #[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        // Create a span for this request
192        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        // Link span to the extracted parent trace context
201        #[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        // Clone the inner service for the request
210        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            // Record metrics after response is received
218            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            // Complete the span
229            span.record("status", status.as_u16());
230            span.record("duration_ms", duration.as_millis());
231
232            Ok(response)
233        })
234    }
235}
236
237/// Extract a clean path from the URI
238///
239/// Removes query parameters and normalizes the path for metrics.
240fn extract_path_template(uri: &Uri) -> String {
241    let path = uri.path();
242
243    // Normalize path segments
244    // Replace IDs with placeholders for better metric aggregation
245    let normalized = path
246        .split('/')
247        .map(|segment| {
248            if segment.is_empty() {
249                return String::new(); // Empty string for empty segments, join will handle the /
250            }
251            // Replace UUID-like segments with :id
252            if looks_like_id(segment) {
253                return ":id".to_string();
254            }
255            // Replace numeric segments with :num
256            if segment.parse::<u64>().is_ok() {
257                return ":num".to_string();
258            }
259            segment.to_string()
260        })
261        .collect::<Vec<_>>()
262        .join("/");
263
264    // Ensure path starts with /
265    if !normalized.starts_with('/') {
266        format!("/{}", normalized)
267    } else {
268        normalized
269    }
270}
271
272/// Check if a segment looks like an ID
273fn looks_like_id(s: &str) -> bool {
274    // Check for UUID-like patterns (36 chars with 4 hyphens)
275    if s.len() == 36 && s.chars().filter(|&c| c == '-').count() == 4 {
276        return true;
277    }
278    // Check for MongoDB ObjectId (24 hex chars)
279    if s.len() == 24 && s.chars().all(|c| c.is_ascii_hexdigit()) {
280        return true;
281    }
282    // Check for hyphenated alphanumeric strings (like abc123-def456)
283    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    // Check for alphanumeric strings that look like IDs (not common words, not purely numeric)
291    // Threshold lowered to catch strings like "abc123def456" (12 chars)
292    if s.len() >= 10 && s.len() <= 32 && s.chars().all(|c| c.is_alphanumeric()) {
293        // Exclude purely numeric strings (handled separately as :num)
294        if s.chars().all(|c| c.is_ascii_digit()) {
295            return false;
296        }
297        // Exclude common path segments
298        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/// Record HTTP request metrics
308#[cfg(feature = "prometheus-metrics")]
309fn record_http_metrics(method: &Method, path: &str, status: StatusCode, duration_secs: f64) {
310    // Get or initialize metrics
311    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    // Record metrics
332    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/// Global HTTP request counter metric
342#[cfg(feature = "prometheus-metrics")]
343static HTTP_REQUESTS_TOTAL: OnceLock<IntCounterVec> = OnceLock::new();
344
345/// Global HTTP request duration histogram metric
346#[cfg(feature = "prometheus-metrics")]
347static HTTP_REQUEST_DURATION_SECONDS: OnceLock<HistogramVec> = OnceLock::new();
348
349/// Stub for when prometheus-metrics is not enabled
350#[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        // UUID
382        assert!(looks_like_id("550e8400-e29b-41d4-a716-446655440000"));
383        // MongoDB ObjectId
384        assert!(looks_like_id("507f1f77bcf86cd799439011"));
385        // Alphanumeric string
386        assert!(looks_like_id("abc123def456"));
387        // Not an ID
388        assert!(!looks_like_id("users"));
389        assert!(!looks_like_id("admin"));
390    }
391}