Skip to main content

armature_analytics/
middleware.rs

1//! Analytics middleware for automatic request tracking
2
3use crate::{Analytics, ErrorRecord, RequestRecord};
4use armature_core::{Error, HttpRequest, HttpResponse, Middleware, Next};
5use async_trait::async_trait;
6use std::time::Instant;
7
8/// Middleware that automatically records analytics for all requests
9///
10/// # Example
11///
12/// ```rust,ignore
13/// use armature_analytics::{Analytics, AnalyticsMiddleware, AnalyticsConfig};
14/// use armature_core::Application;
15///
16/// let analytics = Analytics::new(AnalyticsConfig::default());
17///
18/// let app = Application::new(container, router)
19///     .middleware(AnalyticsMiddleware::new(analytics.clone()));
20/// ```
21#[derive(Clone)]
22pub struct AnalyticsMiddleware {
23    analytics: Analytics,
24}
25
26impl AnalyticsMiddleware {
27    /// Create a new analytics middleware
28    pub fn new(analytics: Analytics) -> Self {
29        Self { analytics }
30    }
31
32    /// Get a reference to the analytics instance
33    pub fn analytics(&self) -> &Analytics {
34        &self.analytics
35    }
36}
37
38/// Request context for tracking within handlers
39#[derive(Clone)]
40pub struct AnalyticsContext {
41    analytics: Analytics,
42    start_time: Instant,
43    method: String,
44    path: String,
45}
46
47impl AnalyticsContext {
48    /// Create a new analytics context
49    pub fn new(analytics: Analytics, method: impl Into<String>, path: impl Into<String>) -> Self {
50        Self {
51            analytics,
52            start_time: Instant::now(),
53            method: method.into(),
54            path: path.into(),
55        }
56    }
57
58    /// Get elapsed time since request start
59    pub fn elapsed(&self) -> std::time::Duration {
60        self.start_time.elapsed()
61    }
62
63    /// Complete the request tracking
64    pub fn complete(self, status: u16, response_size: Option<u64>) {
65        let record = RequestRecord::new(
66            &self.method,
67            normalize_path(&self.path),
68            status,
69            self.start_time.elapsed(),
70        )
71        .with_response_size(response_size.unwrap_or(0));
72
73        self.analytics.record_request(record);
74    }
75
76    /// Record an error during request processing
77    pub fn record_error(&self, error_type: &str, message: &str) {
78        let record = ErrorRecord::new(error_type, message)
79            .with_endpoint(format!("{} {}", self.method, self.path));
80
81        self.analytics.record_error(record);
82    }
83}
84
85/// Extract a client identifier from a request for per-client tracking.
86///
87/// Prefers, in order, the `x-client-id`, `x-forwarded-for` (first hop) and
88/// `x-real-ip` headers. Returns `None` when no identifying header is present.
89fn extract_client_id(req: &HttpRequest) -> Option<String> {
90    if let Some(id) = req.headers.get("x-client-id") {
91        return Some(id.to_owned());
92    }
93    if let Some(fwd) = req.headers.get("x-forwarded-for") {
94        // The first entry is the originating client.
95        if let Some(first) = fwd.split(',').next() {
96            let trimmed = first.trim();
97            if !trimmed.is_empty() {
98                return Some(trimmed.to_string());
99            }
100        }
101    }
102    if let Some(ip) = req.headers.get("x-real-ip") {
103        return Some(ip.to_owned());
104    }
105    None
106}
107
108#[async_trait]
109impl Middleware for AnalyticsMiddleware {
110    async fn handle(&self, req: HttpRequest, next: Next) -> Result<HttpResponse, Error> {
111        let config = self.analytics.config();
112
113        // When analytics is disabled, act as a transparent pass-through.
114        if !config.enabled {
115            return next(req).await;
116        }
117
118        let method = req.method_str().to_owned();
119
120        // Exclusion and sampling gate what we record, but never what we return.
121        // Evaluate them first, before any normalization or allocation, so that
122        // excluded/unsampled requests skip the expensive path-normalization and
123        // query/client-id work entirely.
124        //
125        // `path_only`, not `path`: the raw target still carries the query
126        // string. Keeping it would defeat both the exclusion prefixes and the
127        // `:id` normalization, and would make the endpoint key unique per query
128        // string — letting a caller flood the capped endpoint table.
129        let excluded = config.should_exclude(req.path_only());
130        if excluded || !config.should_sample() {
131            return next(req).await;
132        }
133
134        // Build the recording path (before `req` is consumed by `next`).
135        // Optionally fold query parameters into the tracked path.
136        let mut tracked_path = normalize_path(req.path_only());
137        if config.include_query_params && !req.query().is_empty() {
138            let mut pairs: Vec<(&str, &str)> = req.query().iter().collect();
139            pairs.sort_by(|a, b| a.0.cmp(b.0));
140            let query: Vec<String> = pairs.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
141            tracked_path = format!("{}?{}", tracked_path, query.join("&"));
142        }
143
144        // Capture the client id only when client tracking is enabled.
145        let client_id = if config.track_clients {
146            extract_client_id(&req)
147        } else {
148            None
149        };
150
151        let start = Instant::now();
152        let result = next(req).await;
153        let duration = start.elapsed();
154
155        match &result {
156            Ok(response) => {
157                let mut record =
158                    RequestRecord::new(method, tracked_path, response.status, duration)
159                        .with_response_size(response.body.len() as u64);
160                if let Some(cid) = client_id {
161                    record = record.with_client_id(cid);
162                }
163                self.analytics.record_request(record);
164            }
165            Err(err) => {
166                // A middleware-level failure never produced a response; record
167                // it as a 500 and capture the error for the error metrics.
168                let mut record = RequestRecord::new(&method, tracked_path.clone(), 500, duration);
169                if let Some(cid) = client_id {
170                    record = record.with_client_id(cid);
171                }
172                self.analytics.record_request(record);
173                self.analytics.record_error(
174                    ErrorRecord::new("middleware_error", err.to_string())
175                        .with_status(500)
176                        .with_endpoint(format!("{} {}", method, tracked_path)),
177                );
178            }
179        }
180
181        result
182    }
183}
184
185/// Helper to normalize request paths for aggregation
186///
187/// Converts paths like `/users/123/posts/456` to `/users/:id/posts/:id`
188pub fn normalize_path(path: &str) -> String {
189    // Write directly into a single pre-sized buffer, pushing either the
190    // borrowed segment or the `:id` placeholder, instead of allocating an
191    // intermediate `Vec<&str>`, a `Vec<String>` (one heap String per segment)
192    // and a joined String.
193    let mut out = String::with_capacity(path.len());
194    for (i, segment) in path.split('/').enumerate() {
195        if i > 0 {
196            out.push('/');
197        }
198        if segment.is_empty() {
199            // Preserve empty segments (leading/trailing/double slashes).
200        } else if is_likely_id(segment) {
201            out.push_str(":id");
202        } else {
203            out.push_str(segment);
204        }
205    }
206    out
207}
208
209/// Check if a path segment is likely an ID
210fn is_likely_id(segment: &str) -> bool {
211    // Check for UUID pattern
212    if segment.len() == 36 && segment.chars().filter(|c| *c == '-').count() == 4 {
213        return true;
214    }
215
216    // Check for numeric ID
217    if segment.chars().all(|c| c.is_ascii_digit()) && !segment.is_empty() {
218        return true;
219    }
220
221    // Check for hex IDs (like MongoDB ObjectId)
222    if segment.len() == 24 && segment.chars().all(|c| c.is_ascii_hexdigit()) {
223        return true;
224    }
225
226    false
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use crate::AnalyticsConfig;
233
234    #[test]
235    fn test_normalize_path() {
236        assert_eq!(normalize_path("/users/123/posts"), "/users/:id/posts");
237        assert_eq!(normalize_path("/api/v1/users"), "/api/v1/users");
238        assert_eq!(
239            normalize_path("/users/550e8400-e29b-41d4-a716-446655440000"),
240            "/users/:id"
241        );
242        assert_eq!(
243            normalize_path("/items/507f1f77bcf86cd799439011"),
244            "/items/:id"
245        );
246    }
247
248    #[test]
249    fn test_is_likely_id() {
250        assert!(is_likely_id("123"));
251        assert!(is_likely_id("550e8400-e29b-41d4-a716-446655440000"));
252        assert!(is_likely_id("507f1f77bcf86cd799439011"));
253        assert!(!is_likely_id("users"));
254        assert!(!is_likely_id("api"));
255    }
256
257    #[test]
258    fn test_analytics_context() {
259        let analytics = Analytics::new(AnalyticsConfig::default());
260        let ctx = AnalyticsContext::new(analytics.clone(), "GET", "/api/users");
261
262        std::thread::sleep(std::time::Duration::from_millis(10));
263
264        assert!(ctx.elapsed().as_millis() >= 10);
265    }
266
267    // Regression: AnalyticsMiddleware must implement armature_core::Middleware so
268    // that a request flowing through the chain automatically records analytics.
269    // Previously it implemented no trait and nothing was recorded unless the
270    // caller manually invoked record_*.
271    #[tokio::test]
272    async fn test_middleware_records_automatically() {
273        use std::future::Future;
274        use std::pin::Pin;
275
276        let analytics = Analytics::new(AnalyticsConfig::default());
277        let mw = AnalyticsMiddleware::new(analytics.clone());
278
279        let req = HttpRequest::new("GET", "/api/users/123".to_string());
280        let next: Next = Box::new(|_req: HttpRequest| {
281            Box::pin(async { Ok(HttpResponse::ok().with_body(b"hello".to_vec())) })
282                as Pin<Box<dyn Future<Output = Result<HttpResponse, Error>> + Send>>
283        });
284
285        let resp = mw.handle(req, next).await.unwrap();
286        assert_eq!(resp.status, 200);
287
288        let snapshot = analytics.snapshot();
289        assert_eq!(
290            snapshot.requests.total, 1,
291            "middleware must record the request"
292        );
293        assert_eq!(snapshot.requests.success, 1);
294        // Path must be normalized for aggregation.
295        assert_eq!(snapshot.endpoints.len(), 1);
296        assert_eq!(snapshot.endpoints[0].path, "/api/users/:id");
297        // Response size captured from the body.
298        assert_eq!(snapshot.throughput.total_bytes_transferred, 5);
299    }
300
301    #[tokio::test]
302    async fn test_middleware_respects_disabled_and_exclusions() {
303        use std::future::Future;
304        use std::pin::Pin;
305
306        // Disabled: nothing recorded, response still flows.
307        let analytics = Analytics::new(AnalyticsConfig::builder().enabled(false).build());
308        let mw = AnalyticsMiddleware::new(analytics.clone());
309        let req = HttpRequest::new("GET", "/api/x".to_string());
310        let next: Next = Box::new(|_req: HttpRequest| {
311            Box::pin(async { Ok(HttpResponse::ok()) })
312                as Pin<Box<dyn Future<Output = Result<HttpResponse, Error>> + Send>>
313        });
314        let resp = mw.handle(req, next).await.unwrap();
315        assert_eq!(resp.status, 200);
316        assert_eq!(analytics.snapshot().requests.total, 0);
317
318        // Excluded path: not recorded.
319        let analytics = Analytics::new(AnalyticsConfig::default());
320        let mw = AnalyticsMiddleware::new(analytics.clone());
321        let req = HttpRequest::new("GET", "/health".to_string());
322        let next: Next = Box::new(|_req: HttpRequest| {
323            Box::pin(async { Ok(HttpResponse::ok()) })
324                as Pin<Box<dyn Future<Output = Result<HttpResponse, Error>> + Send>>
325        });
326        mw.handle(req, next).await.unwrap();
327        assert_eq!(analytics.snapshot().requests.total, 0);
328    }
329
330    // Regression: `req.path` is the raw target, so a query string used to leak
331    // into the endpoint key (`/users/123?ref=x`), defeating normalization and
332    // giving an attacker unbounded distinct keys in the capped endpoint table.
333    #[tokio::test]
334    async fn test_middleware_strips_query_from_endpoint_key() {
335        use std::future::Future;
336        use std::pin::Pin;
337
338        let analytics = Analytics::new(AnalyticsConfig::default());
339        let mw = AnalyticsMiddleware::new(analytics.clone());
340
341        for query in ["?ref=x", "?ref=y", "?ref=z"] {
342            let req = HttpRequest::new("GET", format!("/users/123{}", query));
343            let next: Next = Box::new(|_req: HttpRequest| {
344                Box::pin(async { Ok(HttpResponse::ok()) })
345                    as Pin<Box<dyn Future<Output = Result<HttpResponse, Error>> + Send>>
346            });
347            mw.handle(req, next).await.unwrap();
348        }
349
350        let snapshot = analytics.snapshot();
351        assert_eq!(snapshot.endpoints.len(), 1, "query must not fork the key");
352        assert_eq!(snapshot.endpoints[0].path, "/users/:id");
353    }
354
355    // The exclusion prefixes are matched against the query-free path, so a
356    // query string cannot smuggle an excluded path back into the recording.
357    #[tokio::test]
358    async fn test_middleware_exclusion_ignores_query() {
359        use std::future::Future;
360        use std::pin::Pin;
361
362        let analytics = Analytics::new(AnalyticsConfig::default());
363        let mw = AnalyticsMiddleware::new(analytics.clone());
364        let req = HttpRequest::new("GET", "/health?cache-bust=1".to_string());
365        let next: Next = Box::new(|_req: HttpRequest| {
366            Box::pin(async { Ok(HttpResponse::ok()) })
367                as Pin<Box<dyn Future<Output = Result<HttpResponse, Error>> + Send>>
368        });
369        mw.handle(req, next).await.unwrap();
370        assert_eq!(analytics.snapshot().requests.total, 0);
371    }
372
373    // With `include_query_params`, the query is appended exactly once on top of
374    // the query-free normalized path — not doubled.
375    #[tokio::test]
376    async fn test_middleware_include_query_params_appends_once() {
377        use std::future::Future;
378        use std::pin::Pin;
379
380        let analytics = Analytics::new(
381            AnalyticsConfig::builder()
382                .include_query_params(true)
383                .build(),
384        );
385        let mw = AnalyticsMiddleware::new(analytics.clone());
386        let req = HttpRequest::new("GET", "/users/123?b=2&a=1".to_string());
387        let next: Next = Box::new(|_req: HttpRequest| {
388            Box::pin(async { Ok(HttpResponse::ok()) })
389                as Pin<Box<dyn Future<Output = Result<HttpResponse, Error>> + Send>>
390        });
391        mw.handle(req, next).await.unwrap();
392
393        let snapshot = analytics.snapshot();
394        assert_eq!(snapshot.endpoints.len(), 1);
395        assert_eq!(snapshot.endpoints[0].path, "/users/:id?a=1&b=2");
396    }
397}