Skip to main content

armature_analytics/
collector.rs

1//! Metrics collection and aggregation
2
3use crate::{
4    ClientRateLimitInfo, EndpointMetrics, ErrorMetrics, ErrorRecord, ErrorSummary, LatencyMetrics,
5    RateLimitEvent, RateLimitEventType, RateLimitMetrics, RequestMetrics, RequestRecord,
6    ThroughputMetrics,
7};
8use chrono::Utc;
9use dashmap::DashMap;
10use parking_lot::RwLock;
11use std::collections::{HashMap, VecDeque};
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::time::Instant;
14
15/// Thread-safe metrics collector
16pub struct MetricsCollector {
17    // Request counters
18    total_requests: AtomicU64,
19    success_requests: AtomicU64,
20    client_errors: AtomicU64,
21    server_errors: AtomicU64,
22    requests_by_method: DashMap<String, AtomicU64>,
23    requests_by_status: DashMap<u16, AtomicU64>,
24
25    // Latency tracking (totals/min/max stored in microseconds to preserve
26    // sub-millisecond resolution; sample deque keeps exact millisecond floats).
27    latency_samples: RwLock<VecDeque<f64>>,
28    total_latency_us: AtomicU64,
29    min_latency_us: AtomicU64,
30    max_latency_us: AtomicU64,
31
32    // Error tracking
33    total_errors: AtomicU64,
34    errors_by_type: DashMap<String, AtomicU64>,
35    errors_by_status: DashMap<u16, AtomicU64>,
36    recent_errors: RwLock<VecDeque<ErrorSummary>>,
37
38    // Rate limit tracking
39    rate_limit_checks: AtomicU64,
40    rate_limit_allowed: AtomicU64,
41    rate_limit_limited: AtomicU64,
42    rate_limited_clients: DashMap<String, ClientRateLimitInfo>,
43    // Running sum of per-event utilization percentages, used to compute a
44    // genuine average utilization rather than the allowed/total ratio.
45    rate_limit_utilization_sum: RwLock<f64>,
46
47    // Per-endpoint tracking
48    endpoint_metrics: DashMap<String, EndpointData>,
49
50    // Throughput tracking. Requests are downsampled into per-second buckets
51    // `(second, count)` rather than one entry per request. This bounds the
52    // structure to ~one bucket per second over the retention horizon
53    // (~3600 entries) regardless of RPS — instead of RPS*3600 timestamps —
54    // and bounds the peak-RPS computation to at most `throughput_window_secs`
55    // buckets instead of a full O(requests) scan under the write lock.
56    throughput_epoch: Instant,
57    request_buckets: RwLock<VecDeque<(u64, u64)>>,
58    total_response_bytes: AtomicU64,
59    peak_rps: RwLock<f64>,
60
61    // Configuration limits
62    max_latency_samples: usize,
63    max_recent_errors: usize,
64    max_endpoints: usize,
65    max_rate_limit_clients: usize,
66
67    // Configuration toggles / windows
68    enable_endpoint_metrics: bool,
69    enable_rate_limit_tracking: bool,
70    throughput_window_secs: u64,
71}
72
73struct EndpointData {
74    requests: AtomicU64,
75    errors: AtomicU64,
76    total_latency_us: AtomicU64,
77    latency_samples: RwLock<VecDeque<f64>>,
78}
79
80impl Default for EndpointData {
81    fn default() -> Self {
82        Self {
83            requests: AtomicU64::new(0),
84            errors: AtomicU64::new(0),
85            total_latency_us: AtomicU64::new(0),
86            latency_samples: RwLock::new(VecDeque::with_capacity(1000)),
87        }
88    }
89}
90
91impl MetricsCollector {
92    pub fn new() -> Self {
93        Self::with_limits(10_000, 100, 500, 1000)
94    }
95
96    pub fn with_limits(
97        max_latency_samples: usize,
98        max_recent_errors: usize,
99        max_endpoints: usize,
100        max_rate_limit_clients: usize,
101    ) -> Self {
102        Self {
103            total_requests: AtomicU64::new(0),
104            success_requests: AtomicU64::new(0),
105            client_errors: AtomicU64::new(0),
106            server_errors: AtomicU64::new(0),
107            requests_by_method: DashMap::new(),
108            requests_by_status: DashMap::new(),
109
110            latency_samples: RwLock::new(VecDeque::with_capacity(max_latency_samples)),
111            total_latency_us: AtomicU64::new(0),
112            min_latency_us: AtomicU64::new(u64::MAX),
113            max_latency_us: AtomicU64::new(0),
114
115            total_errors: AtomicU64::new(0),
116            errors_by_type: DashMap::new(),
117            errors_by_status: DashMap::new(),
118            recent_errors: RwLock::new(VecDeque::with_capacity(max_recent_errors)),
119
120            rate_limit_checks: AtomicU64::new(0),
121            rate_limit_allowed: AtomicU64::new(0),
122            rate_limit_limited: AtomicU64::new(0),
123            rate_limited_clients: DashMap::new(),
124            rate_limit_utilization_sum: RwLock::new(0.0),
125
126            endpoint_metrics: DashMap::new(),
127
128            throughput_epoch: Instant::now(),
129            request_buckets: RwLock::new(VecDeque::with_capacity(3601)),
130            total_response_bytes: AtomicU64::new(0),
131            peak_rps: RwLock::new(0.0),
132
133            max_latency_samples,
134            max_recent_errors,
135            max_endpoints,
136            max_rate_limit_clients,
137
138            enable_endpoint_metrics: true,
139            enable_rate_limit_tracking: true,
140            throughput_window_secs: 60,
141        }
142    }
143
144    /// Build a collector from a full [`crate::AnalyticsConfig`], honoring both
145    /// the capacity knobs and the behavioral toggles/windows.
146    pub fn from_config(config: &crate::AnalyticsConfig) -> Self {
147        let mut collector = Self::with_limits(
148            config.max_latency_samples,
149            config.max_recent_errors,
150            config.max_endpoints,
151            config.max_rate_limit_clients,
152        );
153        collector.enable_endpoint_metrics = config.enable_endpoint_metrics;
154        collector.enable_rate_limit_tracking = config.enable_rate_limit_tracking;
155        collector.throughput_window_secs = config.throughput_window_secs.max(1);
156        collector
157    }
158
159    /// Record a request
160    pub fn record_request(&self, record: RequestRecord) {
161        // Update counters
162        self.total_requests.fetch_add(1, Ordering::Relaxed);
163
164        if record.is_success() {
165            self.success_requests.fetch_add(1, Ordering::Relaxed);
166        } else if record.is_client_error() {
167            self.client_errors.fetch_add(1, Ordering::Relaxed);
168        } else if record.is_server_error() {
169            self.server_errors.fetch_add(1, Ordering::Relaxed);
170        }
171
172        // Update method counter
173        self.requests_by_method
174            .entry(record.method.clone())
175            .or_insert_with(|| AtomicU64::new(0))
176            .fetch_add(1, Ordering::Relaxed);
177
178        // Update status counter
179        self.requests_by_status
180            .entry(record.status)
181            .or_insert_with(|| AtomicU64::new(0))
182            .fetch_add(1, Ordering::Relaxed);
183
184        // Record latency
185        let latency_ms = record.duration.as_secs_f64() * 1000.0;
186        self.record_latency(latency_ms);
187
188        // Record response size
189        if let Some(size) = record.response_size {
190            self.total_response_bytes.fetch_add(size, Ordering::Relaxed);
191        }
192
193        // Record endpoint metrics (only when enabled). The cap must gate the
194        // insertion of *new* endpoint keys only — an already-tracked endpoint
195        // has to keep incrementing after the cap is reached, otherwise its
196        // counters freeze the moment the map fills up.
197        if self.enable_endpoint_metrics {
198            let endpoint_key = format!("{} {}", record.method, record.path);
199            let tracked = self.endpoint_metrics.contains_key(&endpoint_key)
200                || self.endpoint_metrics.len() < self.max_endpoints;
201            if tracked {
202                let endpoint = self.endpoint_metrics.entry(endpoint_key).or_default();
203
204                endpoint.requests.fetch_add(1, Ordering::Relaxed);
205                if !record.is_success() {
206                    endpoint.errors.fetch_add(1, Ordering::Relaxed);
207                }
208                endpoint
209                    .total_latency_us
210                    .fetch_add((latency_ms * 1000.0) as u64, Ordering::Relaxed);
211
212                let mut samples = endpoint.latency_samples.write();
213                if samples.len() >= 1000 {
214                    samples.pop_front();
215                }
216                samples.push_back(latency_ms);
217            }
218        }
219
220        // Record throughput into per-second buckets. Downsampling to
221        // `(second, count)` keeps the structure count-bounded (~one bucket per
222        // second over the one-hour horizon) even under sustained high RPS,
223        // where a per-request timestamp deque would grow to RPS*3600 entries.
224        // Retain up to one hour so `requests_last_hour` reflects real
225        // observations rather than a `requests_last_minute * 60` extrapolation.
226        //
227        // The slight exactness tradeoff: windows are now second-granular (each
228        // request is attributed to the 1-second bucket it fell in) rather than
229        // exact-instant. `requests_last_minute`/`requests_last_hour` sum bucket
230        // counts and remain correct to within sub-second boundary rounding.
231        let now = Instant::now();
232        let sec = now
233            .saturating_duration_since(self.throughput_epoch)
234            .as_secs();
235        let window = self.throughput_window_secs.max(1);
236
237        let mut buckets = self.request_buckets.write();
238
239        // Append to the current second's bucket, opening a new one on rollover.
240        match buckets.back_mut() {
241            Some((s, count)) if *s == sec => *count += 1,
242            _ => buckets.push_back((sec, 1)),
243        }
244
245        // Drop buckets older than the one-hour retention horizon.
246        let horizon = sec.saturating_sub(3600);
247        while let Some((s, _)) = buckets.front() {
248            if *s < horizon {
249                buckets.pop_front();
250            } else {
251                break;
252            }
253        }
254
255        // Peak RPS over the configured window. Summing only the buckets inside
256        // the window (at most `window` + 1 of them, since there is one bucket
257        // per second) keeps this O(window) rather than O(total requests) — the
258        // in-window count is maintained by the incremental bucket counts, so no
259        // full scan of individual requests is ever performed under the lock.
260        let window_start = sec.saturating_sub(window - 1);
261        let in_window: u64 = sum_buckets_since(&buckets, window_start);
262        drop(buckets);
263
264        let current_rps = in_window as f64 / window as f64;
265        let mut peak = self.peak_rps.write();
266        if current_rps > *peak {
267            *peak = current_rps;
268        }
269    }
270
271    fn record_latency(&self, latency_ms: f64) {
272        // Store in microseconds so sub-millisecond requests are not truncated
273        // to zero (which previously zeroed min/avg for fast endpoints).
274        let latency_us = (latency_ms * 1000.0) as u64;
275
276        // Update total latency
277        self.total_latency_us
278            .fetch_add(latency_us, Ordering::Relaxed);
279
280        // Update min
281        let mut current_min = self.min_latency_us.load(Ordering::Relaxed);
282        while latency_us < current_min {
283            match self.min_latency_us.compare_exchange_weak(
284                current_min,
285                latency_us,
286                Ordering::Relaxed,
287                Ordering::Relaxed,
288            ) {
289                Ok(_) => break,
290                Err(c) => current_min = c,
291            }
292        }
293
294        // Update max
295        let mut current_max = self.max_latency_us.load(Ordering::Relaxed);
296        while latency_us > current_max {
297            match self.max_latency_us.compare_exchange_weak(
298                current_max,
299                latency_us,
300                Ordering::Relaxed,
301                Ordering::Relaxed,
302            ) {
303                Ok(_) => break,
304                Err(c) => current_max = c,
305            }
306        }
307
308        // Add to samples
309        let mut samples = self.latency_samples.write();
310        if samples.len() >= self.max_latency_samples {
311            samples.pop_front();
312        }
313        samples.push_back(latency_ms);
314    }
315
316    /// Record a rate limit event
317    pub fn record_rate_limit(&self, event: RateLimitEvent) {
318        // Respect the rate-limit tracking toggle.
319        if !self.enable_rate_limit_tracking {
320            return;
321        }
322
323        self.rate_limit_checks.fetch_add(1, Ordering::Relaxed);
324
325        // Accumulate the event's utilization so `avg_utilization` reports the
326        // mean of how close clients were to their limits, not the allow ratio.
327        *self.rate_limit_utilization_sum.write() += event.utilization();
328
329        match event.event_type {
330            RateLimitEventType::Allowed => {
331                self.rate_limit_allowed.fetch_add(1, Ordering::Relaxed);
332            }
333            RateLimitEventType::Limited => {
334                self.rate_limit_limited.fetch_add(1, Ordering::Relaxed);
335
336                // Track limited client. The cap gates insertion of *new*
337                // clients only; an already-tracked client must keep counting.
338                let tracked = self.rate_limited_clients.contains_key(&event.client_id)
339                    || self.rate_limited_clients.len() < self.max_rate_limit_clients;
340                if tracked {
341                    self.rate_limited_clients
342                        .entry(event.client_id.clone())
343                        .and_modify(|info| {
344                            info.times_limited += 1;
345                            info.last_limited = Utc::now();
346                        })
347                        .or_insert_with(|| ClientRateLimitInfo {
348                            client_id: event.client_id,
349                            times_limited: 1,
350                            last_limited: Utc::now(),
351                        });
352                }
353            }
354            RateLimitEventType::Warning => {
355                // Just count as allowed
356                self.rate_limit_allowed.fetch_add(1, Ordering::Relaxed);
357            }
358        }
359    }
360
361    /// Record an error
362    pub fn record_error(&self, error: ErrorRecord) {
363        self.total_errors.fetch_add(1, Ordering::Relaxed);
364
365        // Update error type counter
366        self.errors_by_type
367            .entry(error.error_type.clone())
368            .or_insert_with(|| AtomicU64::new(0))
369            .fetch_add(1, Ordering::Relaxed);
370
371        // Update error status counter
372        if let Some(status) = error.status {
373            self.errors_by_status
374                .entry(status)
375                .or_insert_with(|| AtomicU64::new(0))
376                .fetch_add(1, Ordering::Relaxed);
377        }
378
379        // Add to recent errors
380        let mut recent = self.recent_errors.write();
381        if recent.len() >= self.max_recent_errors {
382            recent.pop_front();
383        }
384        recent.push_back(ErrorSummary {
385            error_type: error.error_type,
386            message: error.message,
387            count: 1,
388            last_seen: error.timestamp,
389        });
390    }
391
392    /// Get request metrics
393    pub fn request_metrics(&self) -> RequestMetrics {
394        let by_method: HashMap<String, u64> = self
395            .requests_by_method
396            .iter()
397            .map(|entry| (entry.key().clone(), entry.value().load(Ordering::Relaxed)))
398            .collect();
399
400        let by_status: HashMap<u16, u64> = self
401            .requests_by_status
402            .iter()
403            .map(|entry| (*entry.key(), entry.value().load(Ordering::Relaxed)))
404            .collect();
405
406        RequestMetrics {
407            total: self.total_requests.load(Ordering::Relaxed),
408            success: self.success_requests.load(Ordering::Relaxed),
409            client_errors: self.client_errors.load(Ordering::Relaxed),
410            server_errors: self.server_errors.load(Ordering::Relaxed),
411            by_method,
412            by_status,
413        }
414    }
415
416    /// Get latency metrics
417    pub fn latency_metrics(&self) -> LatencyMetrics {
418        let mut values: Vec<f64> = {
419            let samples = self.latency_samples.read();
420            samples.iter().copied().collect()
421        };
422        let total = self.total_requests.load(Ordering::Relaxed);
423
424        if values.is_empty() || total == 0 {
425            return LatencyMetrics::default();
426        }
427
428        let len = values.len();
429        let avg = self.total_latency_us.load(Ordering::Relaxed) as f64 / total as f64 / 1000.0;
430        let min = self.min_latency_us.load(Ordering::Relaxed);
431        let max = self.max_latency_us.load(Ordering::Relaxed);
432
433        // Use quickselect (O(n) per percentile, only partially reordering the
434        // buffer) instead of a full O(n log n) sort of the whole sample buffer
435        // on every snapshot — matching `endpoint_metrics`.
436        LatencyMetrics {
437            avg_ms: avg,
438            min_ms: if min == u64::MAX {
439                0.0
440            } else {
441                min as f64 / 1000.0
442            },
443            max_ms: max as f64 / 1000.0,
444            p50_ms: percentile_select(&mut values, 50.0),
445            p90_ms: percentile_select(&mut values, 90.0),
446            p95_ms: percentile_select(&mut values, 95.0),
447            p99_ms: percentile_select(&mut values, 99.0),
448            samples: len as u64,
449        }
450    }
451
452    /// Get error metrics
453    pub fn error_metrics(&self) -> ErrorMetrics {
454        let by_type: HashMap<String, u64> = self
455            .errors_by_type
456            .iter()
457            .map(|entry| (entry.key().clone(), entry.value().load(Ordering::Relaxed)))
458            .collect();
459
460        let by_status: HashMap<u16, u64> = self
461            .errors_by_status
462            .iter()
463            .map(|entry| (*entry.key(), entry.value().load(Ordering::Relaxed)))
464            .collect();
465
466        let recent: Vec<ErrorSummary> = self.recent_errors.read().iter().cloned().collect();
467
468        ErrorMetrics {
469            total: self.total_errors.load(Ordering::Relaxed),
470            by_type,
471            by_status,
472            recent,
473        }
474    }
475
476    /// Get rate limit metrics
477    pub fn rate_limit_metrics(&self) -> RateLimitMetrics {
478        let total_checks = self.rate_limit_checks.load(Ordering::Relaxed);
479        let allowed = self.rate_limit_allowed.load(Ordering::Relaxed);
480        let limited = self.rate_limit_limited.load(Ordering::Relaxed);
481
482        let mut top_limited: Vec<ClientRateLimitInfo> = self
483            .rate_limited_clients
484            .iter()
485            .map(|entry| entry.value().clone())
486            .collect();
487
488        top_limited.sort_by_key(|e| std::cmp::Reverse(e.times_limited));
489        top_limited.truncate(10);
490
491        // Mean of the per-event utilization percentages. The previous formula
492        // (allowed / total_checks) measured the allow ratio, not utilization.
493        let avg_utilization = if total_checks > 0 {
494            *self.rate_limit_utilization_sum.read() / total_checks as f64
495        } else {
496            0.0
497        };
498
499        RateLimitMetrics {
500            total_checks,
501            allowed,
502            limited,
503            unique_clients_limited: self.rate_limited_clients.len() as u64,
504            avg_utilization,
505            top_limited_clients: top_limited,
506        }
507    }
508
509    /// Get per-endpoint metrics
510    pub fn endpoint_metrics(&self) -> Vec<EndpointMetrics> {
511        self.endpoint_metrics
512            .iter()
513            .map(|entry| {
514                let key = entry.key();
515                let data = entry.value();
516                let requests = data.requests.load(Ordering::Relaxed);
517                let errors = data.errors.load(Ordering::Relaxed);
518                let total_latency_us = data.total_latency_us.load(Ordering::Relaxed);
519
520                // Only the p99 is needed here, so use quickselect (O(n)) instead
521                // of a full sort (O(n log n)) of every endpoint's sample buffer
522                // on each snapshot.
523                let mut values: Vec<f64> = data.latency_samples.read().iter().copied().collect();
524                let p99 = percentile_select(&mut values, 99.0);
525
526                let parts: Vec<&str> = key.splitn(2, ' ').collect();
527                let (method, path) = if parts.len() == 2 {
528                    (parts[0].to_string(), parts[1].to_string())
529                } else {
530                    ("".to_string(), key.clone())
531                };
532
533                EndpointMetrics {
534                    path,
535                    method,
536                    requests,
537                    errors,
538                    avg_latency_ms: if requests > 0 {
539                        total_latency_us as f64 / requests as f64 / 1000.0
540                    } else {
541                        0.0
542                    },
543                    p99_latency_ms: p99,
544                    error_rate: if requests > 0 {
545                        (errors as f64 / requests as f64) * 100.0
546                    } else {
547                        0.0
548                    },
549                }
550            })
551            .collect()
552    }
553
554    /// Get throughput metrics
555    pub fn throughput_metrics(&self) -> ThroughputMetrics {
556        let buckets = self.request_buckets.read();
557        let now = Instant::now();
558        let sec = now
559            .saturating_duration_since(self.throughput_epoch)
560            .as_secs();
561
562        // Sum the counts of all per-second buckets at or after `cutoff`. Buckets
563        // are stored in ascending second order, so a reverse `take_while` visits
564        // only the relevant tail.
565        let sum_since = |cutoff: u64| -> u64 { sum_buckets_since(&buckets, cutoff) };
566
567        // Second-granular windows (each request counted in its 1-second bucket).
568        let requests_last_minute = sum_since(sec.saturating_sub(59));
569        let requests_last_hour = sum_since(sec.saturating_sub(3599));
570
571        // Current RPS is measured over the configured throughput window.
572        let window = self.throughput_window_secs.max(1);
573        let requests_in_window = sum_since(sec.saturating_sub(window - 1));
574        let rps = requests_in_window as f64 / window as f64;
575
576        ThroughputMetrics {
577            requests_per_second: rps,
578            requests_last_minute,
579            requests_last_hour,
580            peak_rps: *self.peak_rps.read(),
581            avg_response_size: {
582                let total = self.total_requests.load(Ordering::Relaxed);
583                self.total_response_bytes
584                    .load(Ordering::Relaxed)
585                    .checked_div(total)
586                    .unwrap_or(0)
587            },
588            total_bytes_transferred: self.total_response_bytes.load(Ordering::Relaxed),
589        }
590    }
591
592    /// Reset all metrics
593    pub fn reset(&self) {
594        self.total_requests.store(0, Ordering::Relaxed);
595        self.success_requests.store(0, Ordering::Relaxed);
596        self.client_errors.store(0, Ordering::Relaxed);
597        self.server_errors.store(0, Ordering::Relaxed);
598        self.requests_by_method.clear();
599        self.requests_by_status.clear();
600
601        self.latency_samples.write().clear();
602        self.total_latency_us.store(0, Ordering::Relaxed);
603        self.min_latency_us.store(u64::MAX, Ordering::Relaxed);
604        self.max_latency_us.store(0, Ordering::Relaxed);
605
606        self.total_errors.store(0, Ordering::Relaxed);
607        self.errors_by_type.clear();
608        self.errors_by_status.clear();
609        self.recent_errors.write().clear();
610
611        self.rate_limit_checks.store(0, Ordering::Relaxed);
612        self.rate_limit_allowed.store(0, Ordering::Relaxed);
613        self.rate_limit_limited.store(0, Ordering::Relaxed);
614        self.rate_limited_clients.clear();
615        *self.rate_limit_utilization_sum.write() = 0.0;
616
617        self.endpoint_metrics.clear();
618
619        self.request_buckets.write().clear();
620        self.total_response_bytes.store(0, Ordering::Relaxed);
621        *self.peak_rps.write() = 0.0;
622    }
623}
624
625impl Default for MetricsCollector {
626    fn default() -> Self {
627        Self::new()
628    }
629}
630
631/// Calculate percentile from sorted array
632///
633/// Retained as the reference implementation used to validate `percentile_select`
634/// in tests; production snapshots use the O(n) quickselect variant below.
635#[cfg(test)]
636fn percentile(sorted: &[f64], pct: f64) -> f64 {
637    if sorted.is_empty() {
638        return 0.0;
639    }
640
641    let idx = ((pct / 100.0) * (sorted.len() - 1) as f64).round() as usize;
642    sorted[idx.min(sorted.len() - 1)]
643}
644
645/// Calculate a percentile from an *unsorted* slice using quickselect.
646///
647/// Equivalent result to `percentile` on the sorted slice, but runs in O(n)
648/// average time and only partially reorders `data`, avoiding a full sort when
649/// a single percentile is required.
650fn percentile_select(data: &mut [f64], pct: f64) -> f64 {
651    if data.is_empty() {
652        return 0.0;
653    }
654    let idx = (((pct / 100.0) * (data.len() - 1) as f64).round() as usize).min(data.len() - 1);
655    let (_, nth, _) = data.select_nth_unstable_by(idx, |a, b| {
656        a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
657    });
658    *nth
659}
660
661/// Sum the counts of all `(second, count)` buckets at or after `cutoff`.
662///
663/// Buckets are stored in ascending second order, so a reverse `take_while`
664/// visits only the relevant tail and stops as soon as it passes `cutoff`.
665fn sum_buckets_since(buckets: &std::collections::VecDeque<(u64, u64)>, cutoff: u64) -> u64 {
666    buckets
667        .iter()
668        .rev()
669        .take_while(|(s, _)| *s >= cutoff)
670        .map(|(_, c)| c)
671        .sum()
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677    use std::time::Duration;
678
679    #[test]
680    fn test_percentile_calculation() {
681        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
682        // Using nearest-rank method: idx = round((pct/100) * (n-1))
683        // 50th percentile: round(0.5 * 9) = round(4.5) = 5 -> data[5] = 6.0
684        assert_eq!(percentile(&data, 50.0), 6.0);
685        assert_eq!(percentile(&data, 90.0), 9.0);
686        assert_eq!(percentile(&data, 100.0), 10.0);
687    }
688
689    #[test]
690    fn test_collector_requests() {
691        let collector = MetricsCollector::new();
692
693        collector.record_request(RequestRecord::new(
694            "GET",
695            "/api/users",
696            200,
697            Duration::from_millis(50),
698        ));
699
700        collector.record_request(RequestRecord::new(
701            "POST",
702            "/api/users",
703            201,
704            Duration::from_millis(100),
705        ));
706
707        collector.record_request(RequestRecord::new(
708            "GET",
709            "/api/users/1",
710            404,
711            Duration::from_millis(10),
712        ));
713
714        let metrics = collector.request_metrics();
715        assert_eq!(metrics.total, 3);
716        assert_eq!(metrics.success, 2);
717        assert_eq!(metrics.client_errors, 1);
718    }
719
720    #[test]
721    fn test_percentile_select_matches_sorted() {
722        let mut data = vec![10.0, 2.0, 7.0, 1.0, 9.0, 3.0, 8.0, 4.0, 6.0, 5.0];
723        let mut sorted = data.clone();
724        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
725        for pct in [50.0, 90.0, 95.0, 99.0, 100.0] {
726            assert_eq!(percentile_select(&mut data, pct), percentile(&sorted, pct));
727        }
728    }
729
730    // Regression: once the endpoint cap is reached, an *already-tracked*
731    // endpoint must keep incrementing. The old code wrapped the whole update in
732    // `if len < max`, freezing every counter as soon as the map filled.
733    #[test]
734    fn test_endpoint_cap_does_not_freeze_existing() {
735        let collector = MetricsCollector::from_config(&crate::AnalyticsConfig {
736            max_endpoints: 1,
737            ..crate::AnalyticsConfig::default()
738        });
739
740        // First endpoint fills the map to its cap.
741        collector.record_request(RequestRecord::new(
742            "GET",
743            "/a",
744            200,
745            Duration::from_millis(5),
746        ));
747        // A second, different endpoint must be rejected (cap reached).
748        collector.record_request(RequestRecord::new(
749            "GET",
750            "/b",
751            200,
752            Duration::from_millis(5),
753        ));
754        // But the already-tracked endpoint must keep counting.
755        collector.record_request(RequestRecord::new(
756            "GET",
757            "/a",
758            200,
759            Duration::from_millis(5),
760        ));
761
762        let endpoints = collector.endpoint_metrics();
763        assert_eq!(endpoints.len(), 1);
764        let a = endpoints.iter().find(|e| e.path == "/a").unwrap();
765        assert_eq!(
766            a.requests, 2,
767            "existing endpoint must keep incrementing at cap"
768        );
769    }
770
771    // Regression: same freeze bug for the rate-limited client map.
772    #[test]
773    fn test_rate_limit_client_cap_does_not_freeze_existing() {
774        let collector = MetricsCollector::from_config(&crate::AnalyticsConfig {
775            max_rate_limit_clients: 1,
776            ..crate::AnalyticsConfig::default()
777        });
778
779        collector.record_rate_limit(RateLimitEvent::limited("c1", 100, 100, 60));
780        collector.record_rate_limit(RateLimitEvent::limited("c2", 100, 100, 60)); // rejected (cap)
781        collector.record_rate_limit(RateLimitEvent::limited("c1", 100, 100, 60)); // must count
782
783        let metrics = collector.rate_limit_metrics();
784        assert_eq!(metrics.unique_clients_limited, 1);
785        let c1 = metrics
786            .top_limited_clients
787            .iter()
788            .find(|c| c.client_id == "c1")
789            .unwrap();
790        assert_eq!(
791            c1.times_limited, 2,
792            "existing client must keep counting at cap"
793        );
794    }
795
796    // Regression: sub-millisecond latencies used to truncate to 0, zeroing the
797    // min and dragging the average to 0 for fast endpoints.
798    #[test]
799    fn test_submillisecond_latency_not_truncated() {
800        let collector = MetricsCollector::new();
801        collector.record_request(RequestRecord::new(
802            "GET",
803            "/fast",
804            200,
805            Duration::from_micros(400), // 0.4 ms
806        ));
807
808        let latency = collector.latency_metrics();
809        assert!(latency.avg_ms > 0.0, "avg latency must not truncate to 0");
810        assert!(latency.min_ms > 0.0, "min latency must not truncate to 0");
811        assert!((latency.avg_ms - 0.4).abs() < 0.05);
812    }
813
814    // Regression: avg_utilization used to report allowed/total, ignoring how
815    // close each client actually was to its limit.
816    #[test]
817    fn test_avg_utilization_uses_event_utilization() {
818        let collector = MetricsCollector::new();
819        // Two events at 100% and 50% utilization -> mean 75%.
820        collector.record_rate_limit(RateLimitEvent::limited("c1", 100, 100, 60)); // 100%
821        collector.record_rate_limit(RateLimitEvent::allowed("c2", 50, 100, 60)); // 50%
822
823        let metrics = collector.rate_limit_metrics();
824        assert!(
825            (metrics.avg_utilization - 75.0).abs() < 1e-6,
826            "avg_utilization should be mean of per-event utilization, got {}",
827            metrics.avg_utilization
828        );
829    }
830
831    // Regression: requests_last_hour was requests_last_minute * 60. A single
832    // request must therefore report 1, not 60.
833    #[test]
834    fn test_requests_last_hour_is_real_count() {
835        let collector = MetricsCollector::new();
836        collector.record_request(RequestRecord::new(
837            "GET",
838            "/x",
839            200,
840            Duration::from_millis(5),
841        ));
842        let throughput = collector.throughput_metrics();
843        assert_eq!(throughput.requests_last_hour, 1);
844        assert_eq!(throughput.requests_last_minute, 1);
845    }
846
847    // Config toggles must reach the collector: disabling endpoint metrics and
848    // rate-limit tracking must suppress the corresponding data.
849    #[test]
850    fn test_config_toggles_honored() {
851        let collector = MetricsCollector::from_config(&crate::AnalyticsConfig {
852            enable_endpoint_metrics: false,
853            enable_rate_limit_tracking: false,
854            ..crate::AnalyticsConfig::default()
855        });
856
857        collector.record_request(RequestRecord::new(
858            "GET",
859            "/x",
860            200,
861            Duration::from_millis(5),
862        ));
863        collector.record_rate_limit(RateLimitEvent::limited("c1", 100, 100, 60));
864
865        assert!(
866            collector.endpoint_metrics().is_empty(),
867            "endpoint metrics disabled -> no endpoints"
868        );
869        assert_eq!(
870            collector.rate_limit_metrics().total_checks,
871            0,
872            "rate limit tracking disabled -> no checks recorded"
873        );
874        // Core request counters still work.
875        assert_eq!(collector.request_metrics().total, 1);
876    }
877
878    // Regression (item 1): peak/current RPS must still be computed correctly
879    // after switching from a per-request timestamp scan to incremental
880    // per-second bucket counts. All requests recorded back-to-back land in the
881    // same 1-second bucket; over the default 60s window that is count/60 rps,
882    // and peak must equal the highest observed current rps.
883    #[test]
884    fn test_peak_rps_from_incremental_buckets() {
885        let collector = MetricsCollector::new(); // 60s throughput window
886        for _ in 0..120 {
887            collector.record_request(RequestRecord::new(
888                "GET",
889                "/x",
890                200,
891                Duration::from_millis(1),
892            ));
893        }
894        let t = collector.throughput_metrics();
895        assert_eq!(t.requests_last_minute, 120);
896        assert_eq!(t.requests_last_hour, 120);
897        // 120 requests over a 60s window = 2.0 rps.
898        assert!(
899            (t.requests_per_second - 2.0).abs() < 1e-9,
900            "current rps = {}",
901            t.requests_per_second
902        );
903        assert!((t.peak_rps - 2.0).abs() < 1e-9, "peak rps = {}", t.peak_rps);
904    }
905
906    // Regression (item 2): the throughput structure must be count-bounded. A
907    // per-request timestamp deque would hold one entry per request (10k here);
908    // per-second bucketing collapses same-second requests into a single
909    // `(second, count)` entry, so the deque stays tiny while totals are exact.
910    #[test]
911    fn test_request_buckets_are_count_bounded() {
912        let collector = MetricsCollector::new();
913        for _ in 0..10_000 {
914            collector.record_request(RequestRecord::new(
915                "GET",
916                "/x",
917                200,
918                Duration::from_millis(1),
919            ));
920        }
921        let buckets = collector.request_buckets.read();
922        assert!(
923            buckets.len() <= 2,
924            "10k same-second requests must downsample to <=2 buckets, got {}",
925            buckets.len()
926        );
927        let total: u64 = buckets.iter().map(|(_, c)| c).sum();
928        assert_eq!(total, 10_000, "bucket counts must preserve the exact total");
929    }
930}