Skip to main content

termivar_scanner/
metrics.rs

1//! In-memory scan measurement records.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** opt-in via `platform-models`.
6//! - **Execution:** host/library only; no repository runtime caller.
7//! - **Default `termivar scan`:** no.
8//! - **Support:** experimental data models.
9//!
10//! Ratios in this module describe recorded counters. A response/request ratio
11//! is not a request success rate, and no outcome is inferred from it.
12
13use std::collections::HashMap;
14use std::sync::atomic::{AtomicU64, Ordering};
15
16use serde::Serialize;
17
18/// Thread-safe counters and caller-recorded phase duration samples.
19#[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    /// Mean calculated from the recorded samples, or `None` when there are none.
75    pub fn phase_mean_duration_ms(&self, phase: u8) -> Option<f64> {
76        mean(self.phase_duration_samples(phase)?)
77    }
78
79    /// Recorded responses divided by recorded requests, expressed as percent.
80    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/// Snapshot of recorded counters and directly derived ratios.
122#[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/// Phase-specific raw measurements supplied by the caller.
164#[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    // A zero increment or saturation still performs the original successful RMW.
203    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}