armature_analytics/
middleware.rs

1//! Analytics middleware for automatic request tracking
2
3use crate::{Analytics, ErrorRecord, RequestRecord};
4use std::time::Instant;
5
6/// Middleware that automatically records analytics for all requests
7///
8/// # Example
9///
10/// ```rust,ignore
11/// use armature_analytics::{Analytics, AnalyticsMiddleware, AnalyticsConfig};
12/// use armature_core::Application;
13///
14/// let analytics = Analytics::new(AnalyticsConfig::default());
15///
16/// let app = Application::new(container, router)
17///     .middleware(AnalyticsMiddleware::new(analytics.clone()));
18/// ```
19#[derive(Clone)]
20pub struct AnalyticsMiddleware {
21    analytics: Analytics,
22}
23
24impl AnalyticsMiddleware {
25    /// Create a new analytics middleware
26    pub fn new(analytics: Analytics) -> Self {
27        Self { analytics }
28    }
29
30    /// Get a reference to the analytics instance
31    pub fn analytics(&self) -> &Analytics {
32        &self.analytics
33    }
34
35    /// Record a request manually (for custom middleware implementations)
36    pub fn record_request(
37        &self,
38        method: &str,
39        path: &str,
40        status: u16,
41        start_time: Instant,
42        response_size: Option<u64>,
43        authenticated: bool,
44    ) {
45        // Check if path should be excluded
46        if self.analytics.config().should_exclude(path) {
47            return;
48        }
49
50        // Check sampling
51        if !self.analytics.config().should_sample() {
52            return;
53        }
54
55        let duration = start_time.elapsed();
56
57        let mut record = RequestRecord::new(method, path, status, duration)
58            .with_authenticated(authenticated);
59
60        if let Some(size) = response_size {
61            record = record.with_response_size(size);
62        }
63
64        self.analytics.record_request(record);
65    }
66
67    /// Record an error manually
68    pub fn record_error(
69        &self,
70        error_type: &str,
71        message: &str,
72        status: Option<u16>,
73        endpoint: Option<&str>,
74    ) {
75        let mut record = ErrorRecord::new(error_type, message);
76
77        if let Some(s) = status {
78            record = record.with_status(s);
79        }
80
81        if let Some(ep) = endpoint {
82            record = record.with_endpoint(ep);
83        }
84
85        self.analytics.record_error(record);
86    }
87}
88
89/// Request context for tracking within handlers
90#[derive(Clone)]
91pub struct AnalyticsContext {
92    analytics: Analytics,
93    start_time: Instant,
94    method: String,
95    path: String,
96}
97
98impl AnalyticsContext {
99    /// Create a new analytics context
100    pub fn new(analytics: Analytics, method: impl Into<String>, path: impl Into<String>) -> Self {
101        Self {
102            analytics,
103            start_time: Instant::now(),
104            method: method.into(),
105            path: path.into(),
106        }
107    }
108
109    /// Get elapsed time since request start
110    pub fn elapsed(&self) -> std::time::Duration {
111        self.start_time.elapsed()
112    }
113
114    /// Complete the request tracking
115    pub fn complete(self, status: u16, response_size: Option<u64>) {
116        let record = RequestRecord::new(&self.method, &self.path, status, self.start_time.elapsed())
117            .with_response_size(response_size.unwrap_or(0));
118
119        self.analytics.record_request(record);
120    }
121
122    /// Record an error during request processing
123    pub fn record_error(&self, error_type: &str, message: &str) {
124        let record = ErrorRecord::new(error_type, message)
125            .with_endpoint(format!("{} {}", self.method, self.path));
126
127        self.analytics.record_error(record);
128    }
129}
130
131/// Handler wrapper that automatically tracks analytics
132#[allow(dead_code)]
133pub struct TrackedHandler<F> {
134    handler: F,
135    analytics: Analytics,
136}
137
138impl<F> TrackedHandler<F> {
139    pub fn new(handler: F, analytics: Analytics) -> Self {
140        Self { handler, analytics }
141    }
142}
143
144/// Extension trait for adding analytics to requests
145pub trait AnalyticsExt {
146    /// Start tracking analytics for this request
147    fn start_analytics(&self, analytics: &Analytics) -> AnalyticsContext;
148}
149
150/// Helper to normalize request paths for aggregation
151///
152/// Converts paths like `/users/123/posts/456` to `/users/:id/posts/:id`
153pub fn normalize_path(path: &str) -> String {
154    let segments: Vec<&str> = path.split('/').collect();
155    let normalized: Vec<String> = segments
156        .into_iter()
157        .map(|segment| {
158            // Check if segment looks like an ID
159            if segment.is_empty() {
160                String::new()
161            } else if is_likely_id(segment) {
162                ":id".to_string()
163            } else {
164                segment.to_string()
165            }
166        })
167        .collect();
168
169    normalized.join("/")
170}
171
172/// Check if a path segment is likely an ID
173fn is_likely_id(segment: &str) -> bool {
174    // Check for UUID pattern
175    if segment.len() == 36 && segment.chars().filter(|c| *c == '-').count() == 4 {
176        return true;
177    }
178
179    // Check for numeric ID
180    if segment.chars().all(|c| c.is_ascii_digit()) && !segment.is_empty() {
181        return true;
182    }
183
184    // Check for hex IDs (like MongoDB ObjectId)
185    if segment.len() == 24 && segment.chars().all(|c| c.is_ascii_hexdigit()) {
186        return true;
187    }
188
189    false
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::AnalyticsConfig;
196
197    #[test]
198    fn test_normalize_path() {
199        assert_eq!(normalize_path("/users/123/posts"), "/users/:id/posts");
200        assert_eq!(normalize_path("/api/v1/users"), "/api/v1/users");
201        assert_eq!(
202            normalize_path("/users/550e8400-e29b-41d4-a716-446655440000"),
203            "/users/:id"
204        );
205        assert_eq!(
206            normalize_path("/items/507f1f77bcf86cd799439011"),
207            "/items/:id"
208        );
209    }
210
211    #[test]
212    fn test_is_likely_id() {
213        assert!(is_likely_id("123"));
214        assert!(is_likely_id("550e8400-e29b-41d4-a716-446655440000"));
215        assert!(is_likely_id("507f1f77bcf86cd799439011"));
216        assert!(!is_likely_id("users"));
217        assert!(!is_likely_id("api"));
218    }
219
220    #[test]
221    fn test_analytics_context() {
222        let analytics = Analytics::new(AnalyticsConfig::default());
223        let ctx = AnalyticsContext::new(analytics.clone(), "GET", "/api/users");
224
225        std::thread::sleep(std::time::Duration::from_millis(10));
226
227        assert!(ctx.elapsed().as_millis() >= 10);
228    }
229}
230