1use crate::{Analytics, ErrorRecord, RequestRecord};
4use armature_core::{Error, HttpRequest, HttpResponse, Middleware, Next};
5use async_trait::async_trait;
6use std::time::Instant;
7
8#[derive(Clone)]
22pub struct AnalyticsMiddleware {
23 analytics: Analytics,
24}
25
26impl AnalyticsMiddleware {
27 pub fn new(analytics: Analytics) -> Self {
29 Self { analytics }
30 }
31
32 pub fn analytics(&self) -> &Analytics {
34 &self.analytics
35 }
36}
37
38#[derive(Clone)]
40pub struct AnalyticsContext {
41 analytics: Analytics,
42 start_time: Instant,
43 method: String,
44 path: String,
45}
46
47impl AnalyticsContext {
48 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 pub fn elapsed(&self) -> std::time::Duration {
60 self.start_time.elapsed()
61 }
62
63 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 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
85fn 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 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 if !config.enabled {
115 return next(req).await;
116 }
117
118 let method = req.method_str().to_owned();
119
120 let excluded = config.should_exclude(req.path_only());
130 if excluded || !config.should_sample() {
131 return next(req).await;
132 }
133
134 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 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 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
185pub fn normalize_path(path: &str) -> String {
189 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 } else if is_likely_id(segment) {
201 out.push_str(":id");
202 } else {
203 out.push_str(segment);
204 }
205 }
206 out
207}
208
209fn is_likely_id(segment: &str) -> bool {
211 if segment.len() == 36 && segment.chars().filter(|c| *c == '-').count() == 4 {
213 return true;
214 }
215
216 if segment.chars().all(|c| c.is_ascii_digit()) && !segment.is_empty() {
218 return true;
219 }
220
221 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 #[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 assert_eq!(snapshot.endpoints.len(), 1);
296 assert_eq!(snapshot.endpoints[0].path, "/api/users/:id");
297 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 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 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 #[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 #[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 #[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}