Skip to main content

armature_analytics/
config.rs

1//! Analytics configuration
2
3use serde::{Deserialize, Serialize};
4
5/// Configuration for the analytics module
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct AnalyticsConfig {
8    /// Enable analytics collection
9    pub enabled: bool,
10    /// Maximum number of latency samples to keep for percentile calculation
11    pub max_latency_samples: usize,
12    /// Maximum number of recent errors to keep
13    pub max_recent_errors: usize,
14    /// Time window for throughput calculation (in seconds)
15    pub throughput_window_secs: u64,
16    /// Enable per-endpoint metrics
17    pub enable_endpoint_metrics: bool,
18    /// Maximum number of endpoints to track
19    pub max_endpoints: usize,
20    /// Enable rate limit tracking
21    pub enable_rate_limit_tracking: bool,
22    /// Paths to exclude from analytics
23    pub exclude_paths: Vec<String>,
24    /// Whether to include query parameters in path tracking
25    pub include_query_params: bool,
26    /// Sampling rate (0.0 to 1.0, 1.0 = 100% of requests)
27    pub sampling_rate: f64,
28    /// Enable client identification tracking
29    pub track_clients: bool,
30    /// Maximum number of unique clients to track for rate limits
31    pub max_rate_limit_clients: usize,
32}
33
34impl Default for AnalyticsConfig {
35    fn default() -> Self {
36        Self {
37            enabled: true,
38            max_latency_samples: 10_000,
39            max_recent_errors: 100,
40            throughput_window_secs: 60,
41            enable_endpoint_metrics: true,
42            max_endpoints: 500,
43            enable_rate_limit_tracking: true,
44            exclude_paths: vec![
45                "/health".to_string(),
46                "/healthz".to_string(),
47                "/ready".to_string(),
48                "/metrics".to_string(),
49            ],
50            include_query_params: false,
51            sampling_rate: 1.0,
52            track_clients: true,
53            max_rate_limit_clients: 1000,
54        }
55    }
56}
57
58impl AnalyticsConfig {
59    /// Create a new configuration builder
60    pub fn builder() -> AnalyticsConfigBuilder {
61        AnalyticsConfigBuilder::default()
62    }
63
64    /// Create configuration for development (verbose tracking)
65    pub fn development() -> Self {
66        Self {
67            enabled: true,
68            max_latency_samples: 50_000,
69            max_recent_errors: 500,
70            throughput_window_secs: 60,
71            enable_endpoint_metrics: true,
72            max_endpoints: 1000,
73            enable_rate_limit_tracking: true,
74            exclude_paths: vec![],
75            include_query_params: true,
76            sampling_rate: 1.0,
77            track_clients: true,
78            max_rate_limit_clients: 5000,
79        }
80    }
81
82    /// Create configuration for production (optimized)
83    pub fn production() -> Self {
84        Self {
85            enabled: true,
86            max_latency_samples: 10_000,
87            max_recent_errors: 100,
88            throughput_window_secs: 60,
89            enable_endpoint_metrics: true,
90            max_endpoints: 500,
91            enable_rate_limit_tracking: true,
92            exclude_paths: vec![
93                "/health".to_string(),
94                "/healthz".to_string(),
95                "/ready".to_string(),
96                "/metrics".to_string(),
97                "/favicon.ico".to_string(),
98            ],
99            include_query_params: false,
100            sampling_rate: 1.0,
101            track_clients: true,
102            max_rate_limit_clients: 1000,
103        }
104    }
105
106    /// Create minimal configuration (low overhead)
107    pub fn minimal() -> Self {
108        Self {
109            enabled: true,
110            max_latency_samples: 1_000,
111            max_recent_errors: 20,
112            throughput_window_secs: 60,
113            enable_endpoint_metrics: false,
114            max_endpoints: 100,
115            enable_rate_limit_tracking: false,
116            exclude_paths: vec![
117                "/health".to_string(),
118                "/healthz".to_string(),
119                "/ready".to_string(),
120                "/metrics".to_string(),
121            ],
122            include_query_params: false,
123            sampling_rate: 0.1, // 10% sampling
124            track_clients: false,
125            max_rate_limit_clients: 100,
126        }
127    }
128
129    /// Check if a path should be excluded
130    pub fn should_exclude(&self, path: &str) -> bool {
131        self.exclude_paths.iter().any(|p| path.starts_with(p))
132    }
133
134    /// Check if this request should be sampled
135    pub fn should_sample(&self) -> bool {
136        if self.sampling_rate >= 1.0 {
137            return true;
138        }
139        if self.sampling_rate <= 0.0 {
140            return false;
141        }
142        rand_float() < self.sampling_rate
143    }
144}
145
146/// Uniform random float generator in `[0.0, 1.0)`.
147///
148/// Backed by a per-thread `xorshift64*` PRNG seeded from a high-resolution
149/// clock and a monotonic counter. The previous implementation derived the
150/// value from `subsec_nanos() % 1000`, which is neither uniform (nanosecond
151/// timers are quantized on most platforms) nor well distributed, so sampling
152/// decisions were badly biased. This produces 53 bits of uniform entropy.
153fn rand_float() -> f64 {
154    use std::cell::Cell;
155    use std::sync::atomic::{AtomicU64, Ordering};
156    use std::time::{SystemTime, UNIX_EPOCH};
157
158    // Distinct, ever-changing contribution to each thread's seed so that
159    // threads spawned within the same clock tick do not share a stream.
160    static SEED_COUNTER: AtomicU64 = AtomicU64::new(0);
161
162    thread_local! {
163        static STATE: Cell<u64> = Cell::new({
164            let nanos = SystemTime::now()
165                .duration_since(UNIX_EPOCH)
166                .map(|d| d.as_nanos() as u64)
167                .unwrap_or(0);
168            let counter = SEED_COUNTER.fetch_add(1, Ordering::Relaxed);
169            // Mix the clock and the counter; force a non-zero state.
170            let mut s = nanos
171                ^ counter.wrapping_mul(0x9E37_79B9_7F4A_7C15)
172                ^ 0xD1B5_4A32_D192_ED03;
173            if s == 0 {
174                s = 0x9E37_79B9_7F4A_7C15;
175            }
176            s
177        });
178    }
179
180    STATE.with(|state| {
181        let mut x = state.get();
182        // xorshift64*
183        x ^= x >> 12;
184        x ^= x << 25;
185        x ^= x >> 27;
186        state.set(x);
187        let v = x.wrapping_mul(0x2545_F491_4F6C_DD1D);
188        // Top 53 bits -> uniform f64 in [0, 1).
189        ((v >> 11) as f64) / ((1u64 << 53) as f64)
190    })
191}
192
193/// Builder for AnalyticsConfig
194#[derive(Default)]
195pub struct AnalyticsConfigBuilder {
196    config: AnalyticsConfig,
197}
198
199impl AnalyticsConfigBuilder {
200    pub fn enabled(mut self, enabled: bool) -> Self {
201        self.config.enabled = enabled;
202        self
203    }
204
205    pub fn max_latency_samples(mut self, max: usize) -> Self {
206        self.config.max_latency_samples = max;
207        self
208    }
209
210    pub fn max_recent_errors(mut self, max: usize) -> Self {
211        self.config.max_recent_errors = max;
212        self
213    }
214
215    pub fn throughput_window(mut self, secs: u64) -> Self {
216        self.config.throughput_window_secs = secs;
217        self
218    }
219
220    pub fn enable_endpoint_metrics(mut self, enabled: bool) -> Self {
221        self.config.enable_endpoint_metrics = enabled;
222        self
223    }
224
225    pub fn max_endpoints(mut self, max: usize) -> Self {
226        self.config.max_endpoints = max;
227        self
228    }
229
230    pub fn enable_rate_limit_tracking(mut self, enabled: bool) -> Self {
231        self.config.enable_rate_limit_tracking = enabled;
232        self
233    }
234
235    pub fn exclude_path(mut self, path: impl Into<String>) -> Self {
236        self.config.exclude_paths.push(path.into());
237        self
238    }
239
240    pub fn exclude_paths(mut self, paths: Vec<String>) -> Self {
241        self.config.exclude_paths = paths;
242        self
243    }
244
245    pub fn include_query_params(mut self, include: bool) -> Self {
246        self.config.include_query_params = include;
247        self
248    }
249
250    pub fn sampling_rate(mut self, rate: f64) -> Self {
251        self.config.sampling_rate = rate.clamp(0.0, 1.0);
252        self
253    }
254
255    pub fn track_clients(mut self, track: bool) -> Self {
256        self.config.track_clients = track;
257        self
258    }
259
260    pub fn max_rate_limit_clients(mut self, max: usize) -> Self {
261        self.config.max_rate_limit_clients = max;
262        self
263    }
264
265    pub fn build(self) -> AnalyticsConfig {
266        self.config
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn test_default_config() {
276        let config = AnalyticsConfig::default();
277        assert!(config.enabled);
278        assert_eq!(config.sampling_rate, 1.0);
279    }
280
281    #[test]
282    fn test_exclude_paths() {
283        let config = AnalyticsConfig::default();
284        assert!(config.should_exclude("/health"));
285        assert!(config.should_exclude("/healthz"));
286        assert!(!config.should_exclude("/api/users"));
287    }
288
289    #[test]
290    fn test_builder() {
291        let config = AnalyticsConfig::builder()
292            .enabled(true)
293            .sampling_rate(0.5)
294            .max_latency_samples(5000)
295            .exclude_path("/internal")
296            .build();
297
298        assert!(config.enabled);
299        assert_eq!(config.sampling_rate, 0.5);
300        assert_eq!(config.max_latency_samples, 5000);
301        assert!(config.should_exclude("/internal"));
302    }
303
304    // Regression: the old rand_float derived from `subsec_nanos() % 1000` was
305    // heavily biased in a tight loop, so a 50% sampling rate did not sample
306    // anywhere near half of requests. This asserts rough uniformity.
307    #[test]
308    fn test_sampling_is_roughly_uniform() {
309        let config = AnalyticsConfig::builder().sampling_rate(0.5).build();
310        let n = 20_000;
311        let sampled = (0..n).filter(|_| config.should_sample()).count();
312        let ratio = sampled as f64 / n as f64;
313        assert!(
314            (0.45..=0.55).contains(&ratio),
315            "expected ~50% sampling, got {:.3}",
316            ratio
317        );
318    }
319
320    #[test]
321    fn test_rand_float_in_range_and_varies() {
322        let mut seen = std::collections::HashSet::new();
323        for _ in 0..1000 {
324            let v = rand_float();
325            assert!((0.0..1.0).contains(&v));
326            seen.insert(v.to_bits());
327        }
328        // A biased/quantized generator would collapse to a handful of values.
329        assert!(
330            seen.len() > 500,
331            "rand_float not varied enough: {}",
332            seen.len()
333        );
334    }
335}