armature_analytics/
middleware.rs1use crate::{Analytics, ErrorRecord, RequestRecord};
4use std::time::Instant;
5
6#[derive(Clone)]
20pub struct AnalyticsMiddleware {
21 analytics: Analytics,
22}
23
24impl AnalyticsMiddleware {
25 pub fn new(analytics: Analytics) -> Self {
27 Self { analytics }
28 }
29
30 pub fn analytics(&self) -> &Analytics {
32 &self.analytics
33 }
34
35 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 if self.analytics.config().should_exclude(path) {
47 return;
48 }
49
50 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 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#[derive(Clone)]
91pub struct AnalyticsContext {
92 analytics: Analytics,
93 start_time: Instant,
94 method: String,
95 path: String,
96}
97
98impl AnalyticsContext {
99 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 pub fn elapsed(&self) -> std::time::Duration {
111 self.start_time.elapsed()
112 }
113
114 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 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#[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
144pub trait AnalyticsExt {
146 fn start_analytics(&self, analytics: &Analytics) -> AnalyticsContext;
148}
149
150pub 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 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
172fn is_likely_id(segment: &str) -> bool {
174 if segment.len() == 36 && segment.chars().filter(|c| *c == '-').count() == 4 {
176 return true;
177 }
178
179 if segment.chars().all(|c| c.is_ascii_digit()) && !segment.is_empty() {
181 return true;
182 }
183
184 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