cloudscraper_rs/modules/performance/
mod.rs

1//! Performance monitoring utilities.
2//!
3//! Tracks latency and error trends, then surfaces alerts when thresholds are
4//! exceeded.
5
6use std::collections::{HashMap, VecDeque};
7use std::time::Duration;
8
9#[derive(Debug, Clone)]
10pub struct PerformanceConfig {
11    pub window: usize,
12    pub latency_threshold: Duration,
13    pub error_rate_threshold: f64,
14    pub min_samples: usize,
15}
16
17impl Default for PerformanceConfig {
18    fn default() -> Self {
19        Self {
20            window: 100,
21            latency_threshold: Duration::from_secs_f32(4.0),
22            error_rate_threshold: 0.25,
23            min_samples: 10,
24        }
25    }
26}
27
28#[derive(Debug, Clone)]
29pub struct PerformanceReport {
30    pub global_latency: Option<Duration>,
31    pub slow_domains: Vec<(String, Duration)>,
32    pub error_domains: Vec<(String, f64)>,
33    pub alerts: Vec<String>,
34}
35
36impl PerformanceReport {
37    fn empty() -> Self {
38        Self {
39            global_latency: None,
40            slow_domains: Vec::new(),
41            error_domains: Vec::new(),
42            alerts: Vec::new(),
43        }
44    }
45}
46
47#[derive(Debug)]
48struct DomainPerformance {
49    latencies: VecDeque<Duration>,
50    successes: usize,
51    failures: usize,
52    window: usize,
53}
54
55impl DomainPerformance {
56    fn new(window: usize) -> Self {
57        Self {
58            latencies: VecDeque::with_capacity(window),
59            successes: 0,
60            failures: 0,
61            window,
62        }
63    }
64
65    fn record(&mut self, latency: Duration, success: bool) {
66        if self.latencies.len() == self.window {
67            self.latencies.pop_front();
68        }
69        self.latencies.push_back(latency);
70        if success {
71            self.successes += 1;
72        } else {
73            self.failures += 1;
74        }
75    }
76
77    fn average_latency(&self) -> Option<Duration> {
78        if self.latencies.is_empty() {
79            return None;
80        }
81        let total = self.latencies.iter().map(|d| d.as_secs_f64()).sum::<f64>();
82        Some(Duration::from_secs_f64(total / self.latencies.len() as f64))
83    }
84
85    fn error_rate(&self) -> Option<f64> {
86        let total = self.successes + self.failures;
87        if total == 0 {
88            return None;
89        }
90        Some(self.failures as f64 / total as f64)
91    }
92}
93
94/// Observes per-domain performance with rolling statistics.
95#[derive(Debug)]
96pub struct PerformanceMonitor {
97    config: PerformanceConfig,
98    domains: HashMap<String, DomainPerformance>,
99    global_latencies: VecDeque<Duration>,
100}
101
102impl PerformanceMonitor {
103    pub fn new(config: PerformanceConfig) -> Self {
104        Self {
105            global_latencies: VecDeque::with_capacity(config.window),
106            domains: HashMap::new(),
107            config,
108        }
109    }
110
111    fn domain_mut(&mut self, domain: &str) -> &mut DomainPerformance {
112        self.domains
113            .entry(domain.to_string())
114            .or_insert_with(|| DomainPerformance::new(self.config.window))
115    }
116
117    /// Record a latency measurement and return an optional alert report.
118    pub fn record(
119        &mut self,
120        domain: &str,
121        latency: Duration,
122        success: bool,
123    ) -> Option<PerformanceReport> {
124        if self.global_latencies.len() == self.config.window {
125            self.global_latencies.pop_front();
126        }
127        self.global_latencies.push_back(latency);
128
129        let domain_state = self.domain_mut(domain);
130        domain_state.record(latency, success);
131
132        let should_report = domain_state.latencies.len() >= self.config.min_samples
133            || self.global_latencies.len() >= self.config.min_samples;
134        if !should_report {
135            return None;
136        }
137
138        let mut report = PerformanceReport::empty();
139        report.global_latency = self.global_latency();
140
141        for (domain_name, perf) in &self.domains {
142            if let Some(avg) = perf.average_latency()
143                && avg > self.config.latency_threshold
144            {
145                report.slow_domains.push((domain_name.clone(), avg));
146            }
147
148            if let Some(error_rate) = perf.error_rate()
149                && error_rate >= self.config.error_rate_threshold
150            {
151                report.error_domains.push((domain_name.clone(), error_rate));
152            }
153        }
154
155        if let Some(global) = report.global_latency
156            && global > self.config.latency_threshold
157        {
158            report.alerts.push(format!(
159                "Global latency {:.2}s exceeded threshold {:.2}s",
160                global.as_secs_f64(),
161                self.config.latency_threshold.as_secs_f64()
162            ));
163        }
164
165        for (domain, latency) in &report.slow_domains {
166            report.alerts.push(format!(
167                "Domain {} average latency {:.2}s exceeds threshold",
168                domain,
169                latency.as_secs_f64()
170            ));
171        }
172
173        for (domain, rate) in &report.error_domains {
174            report.alerts.push(format!(
175                "Domain {} error rate {:.1}% exceeds threshold",
176                domain,
177                rate * 100.0
178            ));
179        }
180
181        Some(report)
182    }
183
184    pub fn snapshot(&self) -> PerformanceReport {
185        let mut report = PerformanceReport::empty();
186        report.global_latency = self.global_latency();
187        for (domain, perf) in &self.domains {
188            if let Some(avg) = perf.average_latency()
189                && avg > self.config.latency_threshold
190            {
191                report.slow_domains.push((domain.clone(), avg));
192            }
193            if let Some(rate) = perf.error_rate()
194                && rate >= self.config.error_rate_threshold
195            {
196                report.error_domains.push((domain.clone(), rate));
197            }
198        }
199        report
200    }
201
202    fn global_latency(&self) -> Option<Duration> {
203        if self.global_latencies.is_empty() {
204            return None;
205        }
206        let total = self
207            .global_latencies
208            .iter()
209            .map(|d| d.as_secs_f64())
210            .sum::<f64>();
211        Some(Duration::from_secs_f64(
212            total / self.global_latencies.len() as f64,
213        ))
214    }
215}
216
217impl Default for PerformanceMonitor {
218    fn default() -> Self {
219        Self::new(PerformanceConfig::default())
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn emits_alert_for_high_latency() {
229        let mut monitor = PerformanceMonitor::new(PerformanceConfig {
230            latency_threshold: Duration::from_millis(200),
231            min_samples: 3,
232            ..Default::default()
233        });
234        for _ in 0..3 {
235            monitor.record("example.com", Duration::from_millis(500), true);
236        }
237        let report = monitor.snapshot();
238        assert!(!report.slow_domains.is_empty());
239    }
240}