1use std::collections::HashMap;
14use std::sync::atomic::{AtomicU64, Ordering};
15
16use serde::Serialize;
17
18#[derive(Debug)]
20pub struct MetricsCollector {
21 total_requests: AtomicU64,
22 total_responses: AtomicU64,
23 total_findings: AtomicU64,
24 total_bytes_sent: AtomicU64,
25 total_bytes_received: AtomicU64,
26 total_errors: AtomicU64,
27 phase_duration_samples_ms: HashMap<u8, Vec<u64>>,
28}
29
30impl MetricsCollector {
31 pub fn new() -> Self {
32 Self {
33 total_requests: AtomicU64::new(0),
34 total_responses: AtomicU64::new(0),
35 total_findings: AtomicU64::new(0),
36 total_bytes_sent: AtomicU64::new(0),
37 total_bytes_received: AtomicU64::new(0),
38 total_errors: AtomicU64::new(0),
39 phase_duration_samples_ms: HashMap::new(),
40 }
41 }
42
43 pub fn record_request(&self, bytes: u64) {
44 saturating_add(&self.total_requests, 1);
45 saturating_add(&self.total_bytes_sent, bytes);
46 }
47
48 pub fn record_response(&self, bytes: u64) {
49 saturating_add(&self.total_responses, 1);
50 saturating_add(&self.total_bytes_received, bytes);
51 }
52
53 pub fn record_finding(&self) {
54 saturating_add(&self.total_findings, 1);
55 }
56
57 pub fn record_error(&self) {
58 saturating_add(&self.total_errors, 1);
59 }
60
61 pub fn record_phase_duration(&mut self, phase: u8, duration_ms: u64) {
62 self.phase_duration_samples_ms
63 .entry(phase)
64 .or_default()
65 .push(duration_ms);
66 }
67
68 pub fn phase_duration_samples(&self, phase: u8) -> Option<&[u64]> {
69 self.phase_duration_samples_ms
70 .get(&phase)
71 .map(Vec::as_slice)
72 }
73
74 pub fn phase_mean_duration_ms(&self, phase: u8) -> Option<f64> {
76 mean(self.phase_duration_samples(phase)?)
77 }
78
79 pub fn response_request_ratio_percent(&self) -> Option<f64> {
81 ratio_percent(
82 self.total_responses.load(Ordering::Relaxed),
83 self.total_requests.load(Ordering::Relaxed),
84 )
85 }
86
87 pub fn findings_per_100_responses(&self) -> Option<f64> {
88 ratio_percent(
89 self.total_findings.load(Ordering::Relaxed),
90 self.total_responses.load(Ordering::Relaxed),
91 )
92 }
93
94 pub fn bytes_per_finding(&self) -> Option<f64> {
95 ratio(
96 self.total_bytes_received.load(Ordering::Relaxed),
97 self.total_findings.load(Ordering::Relaxed),
98 )
99 }
100
101 pub fn summary(&self) -> MetricsSummary {
102 MetricsSummary {
103 total_requests: self.total_requests.load(Ordering::Relaxed),
104 total_responses: self.total_responses.load(Ordering::Relaxed),
105 total_findings: self.total_findings.load(Ordering::Relaxed),
106 total_bytes_sent: self.total_bytes_sent.load(Ordering::Relaxed),
107 total_bytes_received: self.total_bytes_received.load(Ordering::Relaxed),
108 total_errors: self.total_errors.load(Ordering::Relaxed),
109 response_request_ratio_percent: self.response_request_ratio_percent(),
110 findings_per_100_responses: self.findings_per_100_responses(),
111 }
112 }
113}
114
115impl Default for MetricsCollector {
116 fn default() -> Self {
117 Self::new()
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Serialize)]
123pub struct MetricsSummary {
124 #[serde(rename = "requests")]
125 pub total_requests: u64,
126 #[serde(rename = "responses")]
127 pub total_responses: u64,
128 #[serde(rename = "findings")]
129 pub total_findings: u64,
130 #[serde(rename = "bytes_sent")]
131 pub total_bytes_sent: u64,
132 #[serde(rename = "bytes_received")]
133 pub total_bytes_received: u64,
134 #[serde(rename = "errors")]
135 pub total_errors: u64,
136 #[serde(with = "optional_finite_f64")]
137 pub response_request_ratio_percent: Option<f64>,
138 #[serde(with = "optional_finite_f64")]
139 pub findings_per_100_responses: Option<f64>,
140}
141
142impl MetricsSummary {
143 pub fn to_json(&self) -> Result<String, serde_json::Error> {
144 serde_json::to_string(self)
145 }
146}
147
148mod optional_finite_f64 {
149 use serde::Serializer;
150
151 pub fn serialize<S>(value: &Option<f64>, serializer: S) -> Result<S::Ok, S::Error>
152 where
153 S: Serializer,
154 {
155 match value {
156 Some(value) if value.is_finite() => serializer.serialize_some(value),
157 Some(_) => Err(serde::ser::Error::custom("metric ratio must be finite")),
158 None => serializer.serialize_none(),
159 }
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct PhaseMetrics {
166 pub phase_number: u8,
167 pub phase_name: String,
168 pub requests: u64,
169 pub responses: u64,
170 pub findings: u64,
171 pub duration_ms: u64,
172 pub response_time_samples_ms: Vec<u64>,
173}
174
175impl PhaseMetrics {
176 pub fn mean_response_time_ms(&self) -> Option<f64> {
177 mean(&self.response_time_samples_ms)
178 }
179
180 pub fn response_request_ratio_percent(&self) -> Option<f64> {
181 ratio_percent(self.responses, self.requests)
182 }
183}
184
185fn ratio(numerator: u64, denominator: u64) -> Option<f64> {
186 (denominator != 0).then_some(numerator as f64 / denominator as f64)
187}
188
189fn ratio_percent(numerator: u64, denominator: u64) -> Option<f64> {
190 ratio(numerator, denominator).map(|value| value * 100.0)
191}
192
193fn mean(samples: &[u64]) -> Option<f64> {
194 if samples.is_empty() {
195 return None;
196 }
197 let sum: u128 = samples.iter().map(|&sample| u128::from(sample)).sum();
198 Some(sum as f64 / samples.len() as f64)
199}
200
201fn saturating_add(counter: &AtomicU64, increment: u64) {
202 let mut current = counter.load(Ordering::Relaxed);
204 loop {
205 match counter.compare_exchange_weak(
206 current,
207 current.saturating_add(increment),
208 Ordering::Relaxed,
209 Ordering::Relaxed,
210 ) {
211 Ok(_) => return,
212 Err(observed) => current = observed,
213 }
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn atomic_saturating_metrics_add_preserves_zero_max_and_concurrent_updates() {
223 let counter = AtomicU64::new(0);
224 saturating_add(&counter, 0);
225 assert_eq!(counter.load(Ordering::Relaxed), 0);
226 saturating_add(&counter, 7);
227 assert_eq!(counter.load(Ordering::Relaxed), 7);
228 counter.store(u64::MAX - 3, Ordering::Relaxed);
229 saturating_add(&counter, 2);
230 assert_eq!(counter.load(Ordering::Relaxed), u64::MAX - 1);
231 saturating_add(&counter, 2);
232 saturating_add(&counter, 0);
233 saturating_add(&counter, u64::MAX);
234 assert_eq!(counter.load(Ordering::Relaxed), u64::MAX);
235
236 counter.store(0, Ordering::Relaxed);
237 std::thread::scope(|scope| {
238 for _ in 0..4 {
239 scope.spawn(|| {
240 for _ in 0..1000 {
241 saturating_add(&counter, 3);
242 }
243 });
244 }
245 });
246 assert_eq!(counter.load(Ordering::Relaxed), 12000);
247 }
248
249 #[test]
250 fn missing_denominators_produce_no_ratio() {
251 let collector = MetricsCollector::new();
252 assert_eq!(collector.response_request_ratio_percent(), None);
253 assert_eq!(collector.findings_per_100_responses(), None);
254 assert_eq!(collector.bytes_per_finding(), None);
255 }
256
257 #[test]
258 fn response_ratio_does_not_claim_success() {
259 let collector = MetricsCollector::new();
260 collector.record_request(10);
261 collector.record_request(20);
262 collector.record_response(30);
263 collector.record_error();
264
265 assert_eq!(collector.response_request_ratio_percent(), Some(50.0));
266 let json = collector.summary().to_json().expect("finite summary");
267 assert!(json.contains("response_request_ratio_percent"));
268 assert!(!json.contains("success_rate"));
269 }
270
271 #[test]
272 fn means_are_derived_from_raw_samples() {
273 let mut collector = MetricsCollector::new();
274 assert_eq!(collector.phase_mean_duration_ms(4), None);
275 collector.record_phase_duration(4, 10);
276 collector.record_phase_duration(4, 30);
277
278 assert_eq!(collector.phase_duration_samples(4), Some(&[10, 30][..]));
279 assert_eq!(collector.phase_mean_duration_ms(4), Some(20.0));
280 }
281
282 #[test]
283 fn phase_mean_uses_only_supplied_samples() {
284 let phase = PhaseMetrics {
285 phase_number: 1,
286 phase_name: "fixture".to_string(),
287 requests: 4,
288 responses: 3,
289 findings: 1,
290 duration_ms: 100,
291 response_time_samples_ms: vec![10, 20, 30],
292 };
293
294 assert_eq!(phase.mean_response_time_ms(), Some(20.0));
295 assert_eq!(phase.response_request_ratio_percent(), Some(75.0));
296 }
297
298 #[test]
299 fn counters_saturate_instead_of_wrapping() {
300 let collector = MetricsCollector::new();
301 collector.total_requests.store(u64::MAX, Ordering::Relaxed);
302 collector
303 .total_bytes_sent
304 .store(u64::MAX - 1, Ordering::Relaxed);
305
306 collector.record_request(10);
307
308 assert_eq!(collector.total_requests.load(Ordering::Relaxed), u64::MAX);
309 assert_eq!(collector.total_bytes_sent.load(Ordering::Relaxed), u64::MAX);
310 }
311
312 #[test]
313 fn summary_json_rejects_nonfinite_public_values() {
314 let summary = MetricsSummary {
315 total_requests: 0,
316 total_responses: 0,
317 total_findings: 0,
318 total_bytes_sent: 0,
319 total_bytes_received: 0,
320 total_errors: 0,
321 response_request_ratio_percent: Some(f64::NAN),
322 findings_per_100_responses: None,
323 };
324 assert!(summary.to_json().is_err());
325 }
326}