Skip to main content

nntp_proxy/metrics/
backend_stats.rs

1//! Backend statistics snapshot type
2
3use super::types::{
4    ActiveConnections, ArticleCount, BackendHealthStatus, CommandCount, ErrorCount,
5    ErrorRatePercent, FailureCount, OverheadMillis, RecvMicros, SendMicros, TtfbMicros,
6};
7use crate::types::{
8    ArticleBytesTotal, BackendId, BytesReceived, BytesSent, TimingMeasurementCount,
9};
10
11/// Statistics for a single backend (cloneable snapshot)
12#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
13pub struct BackendStats {
14    pub backend_id: BackendId,
15    #[serde(skip, default)]
16    pub active_connections: ActiveConnections,
17    pub total_commands: CommandCount,
18    pub bytes_sent: BytesSent,
19    pub bytes_received: BytesReceived,
20    pub errors: ErrorCount,
21    pub errors_4xx: ErrorCount,
22    pub errors_5xx: ErrorCount,
23    pub article_bytes_total: ArticleBytesTotal,
24    pub article_count: ArticleCount,
25    pub ttfb_micros_total: TtfbMicros,
26    pub ttfb_count: TimingMeasurementCount,
27    pub send_micros_total: SendMicros,
28    pub recv_micros_total: RecvMicros,
29    pub connection_failures: FailureCount,
30    #[serde(skip, default)]
31    pub health_status: BackendHealthStatus,
32}
33
34impl Default for BackendStats {
35    fn default() -> Self {
36        Self {
37            backend_id: BackendId::from_index(0),
38            active_connections: ActiveConnections::default(),
39            total_commands: CommandCount::default(),
40            bytes_sent: BytesSent::ZERO,
41            bytes_received: BytesReceived::ZERO,
42            errors: ErrorCount::default(),
43            errors_4xx: ErrorCount::default(),
44            errors_5xx: ErrorCount::default(),
45            article_bytes_total: ArticleBytesTotal::ZERO,
46            article_count: ArticleCount::default(),
47            ttfb_micros_total: TtfbMicros::default(),
48            ttfb_count: TimingMeasurementCount::ZERO,
49            send_micros_total: SendMicros::default(),
50            recv_micros_total: RecvMicros::default(),
51            connection_failures: FailureCount::default(),
52            health_status: BackendHealthStatus::Healthy,
53        }
54    }
55}
56
57impl BackendStats {
58    /// Calculate average article size
59    #[must_use]
60    #[inline]
61    pub const fn average_article_size(&self) -> Option<u64> {
62        self.article_count
63            .average_bytes(self.article_bytes_total.get())
64    }
65
66    /// Calculate average time to first byte in milliseconds
67    #[must_use]
68    #[inline]
69    pub fn average_ttfb_ms(&self) -> Option<f64> {
70        std::num::NonZeroU64::new(self.ttfb_count.get())
71            .map(|count| TtfbMicros::average(self.ttfb_micros_total, count).get())
72    }
73
74    /// Calculate average send time in milliseconds
75    #[must_use]
76    #[inline]
77    pub fn average_send_ms(&self) -> Option<f64> {
78        std::num::NonZeroU64::new(self.ttfb_count.get())
79            .map(|count| SendMicros::average(self.send_micros_total, count).get())
80    }
81
82    /// Calculate average receive time in milliseconds
83    #[must_use]
84    #[inline]
85    pub fn average_recv_ms(&self) -> Option<f64> {
86        std::num::NonZeroU64::new(self.ttfb_count.get())
87            .map(|count| RecvMicros::average(self.recv_micros_total, count).get())
88    }
89
90    /// Calculate async overhead in milliseconds
91    #[must_use]
92    #[inline]
93    pub fn average_overhead_ms(&self) -> Option<f64> {
94        std::num::NonZeroU64::new(self.ttfb_count.get()).map(|count| {
95            let ttfb = TtfbMicros::average(self.ttfb_micros_total, count);
96            let send = SendMicros::average(self.send_micros_total, count);
97            let recv = RecvMicros::average(self.recv_micros_total, count);
98            OverheadMillis::from_components(ttfb, send, recv).get()
99        })
100    }
101
102    /// Calculate error rate as a percentage
103    #[must_use]
104    #[inline]
105    pub fn error_rate_percent(&self) -> f64 {
106        ErrorRatePercent::from_counts(self.errors, self.total_commands).get()
107    }
108
109    /// Check if backend has high error rate (> 5%)
110    #[must_use]
111    #[inline]
112    pub fn has_high_error_rate(&self) -> bool {
113        ErrorRatePercent::from_counts(self.errors, self.total_commands).is_high()
114    }
115
116    /// Check if backend is healthy
117    #[must_use]
118    #[inline]
119    pub fn is_healthy(&self) -> bool {
120        self.health_status == BackendHealthStatus::Healthy
121    }
122
123    /// Check if backend is degraded
124    #[must_use]
125    #[inline]
126    pub fn is_degraded(&self) -> bool {
127        self.health_status == BackendHealthStatus::Degraded
128    }
129
130    /// Check if backend is down
131    #[must_use]
132    #[inline]
133    pub fn is_down(&self) -> bool {
134        self.health_status == BackendHealthStatus::Down
135    }
136
137    /// Get total bytes transferred
138    #[must_use]
139    #[inline]
140    pub const fn total_bytes(&self) -> u64 {
141        self.bytes_sent
142            .as_u64()
143            .saturating_add(self.bytes_received.as_u64())
144    }
145
146    /// Check if backend has any activity
147    #[must_use]
148    #[inline]
149    pub const fn has_activity(&self) -> bool {
150        self.total_commands.get() > 0
151    }
152}
153
154#[cfg(test)]
155#[allow(clippy::float_cmp)] // These tests assert exact zero/default values from deterministic fixtures.
156mod tests {
157    use super::*;
158    use crate::types::BackendId;
159
160    fn create_test_stats() -> BackendStats {
161        BackendStats {
162            backend_id: BackendId::from_index(0),
163            total_commands: CommandCount::new(100),
164            errors: ErrorCount::new(5),
165            bytes_sent: BytesSent::new(1000),
166            bytes_received: BytesReceived::new(2000),
167            article_count: ArticleCount::new(10),
168            article_bytes_total: ArticleBytesTotal::new(5000),
169            ttfb_micros_total: TtfbMicros::new(10000),
170            ttfb_count: TimingMeasurementCount::new(10),
171            send_micros_total: SendMicros::new(3000),
172            recv_micros_total: RecvMicros::new(5000),
173            health_status: BackendHealthStatus::Healthy,
174            ..Default::default()
175        }
176    }
177
178    #[test]
179    fn test_backend_stats_default() {
180        let stats = BackendStats::default();
181        assert_eq!(stats.backend_id, BackendId::from_index(0));
182        assert_eq!(stats.total_commands.get(), 0);
183        assert_eq!(stats.errors.get(), 0);
184        assert_eq!(stats.bytes_sent.as_u64(), 0);
185        assert_eq!(stats.bytes_received.as_u64(), 0);
186        assert_eq!(stats.health_status, BackendHealthStatus::Healthy);
187    }
188
189    #[test]
190    fn test_average_article_size() {
191        let stats = create_test_stats();
192        let avg = stats.average_article_size();
193        assert_eq!(avg, Some(500)); // 5000 / 10
194    }
195
196    #[test]
197    fn test_average_article_size_zero_articles() {
198        let stats = BackendStats {
199            article_count: ArticleCount::new(0),
200            article_bytes_total: ArticleBytesTotal::new(1000),
201            ..Default::default()
202        };
203        assert_eq!(stats.average_article_size(), None);
204    }
205
206    #[test]
207    fn test_average_article_size_zero_bytes() {
208        let stats = BackendStats {
209            article_count: ArticleCount::new(10),
210            article_bytes_total: ArticleBytesTotal::new(0),
211            ..Default::default()
212        };
213        assert_eq!(stats.average_article_size(), Some(0));
214    }
215
216    #[test]
217    fn test_average_ttfb_ms() {
218        let stats = create_test_stats();
219        let avg = stats.average_ttfb_ms();
220        assert!(avg.is_some());
221        // 10000 micros / 10 measurements = 1000 micros = 1.0 ms
222        assert!((avg.unwrap() - 1.0).abs() < 0.01);
223    }
224
225    #[test]
226    fn test_average_ttfb_ms_zero_count() {
227        let stats = BackendStats {
228            ttfb_micros_total: TtfbMicros::new(1000),
229            ttfb_count: TimingMeasurementCount::new(0),
230            ..Default::default()
231        };
232        assert_eq!(stats.average_ttfb_ms(), None);
233    }
234
235    #[test]
236    fn test_average_send_ms() {
237        let stats = create_test_stats();
238        let avg = stats.average_send_ms();
239        assert!(avg.is_some());
240        // 3000 micros / 10 = 300 micros = 0.3 ms
241        assert!((avg.unwrap() - 0.3).abs() < 0.01);
242    }
243
244    #[test]
245    fn test_average_send_ms_zero_count() {
246        let stats = BackendStats {
247            send_micros_total: SendMicros::new(1000),
248            ttfb_count: TimingMeasurementCount::new(0),
249            ..Default::default()
250        };
251        assert_eq!(stats.average_send_ms(), None);
252    }
253
254    #[test]
255    fn test_average_recv_ms() {
256        let stats = create_test_stats();
257        let avg = stats.average_recv_ms();
258        assert!(avg.is_some());
259        // 5000 micros / 10 = 500 micros = 0.5 ms
260        assert!((avg.unwrap() - 0.5).abs() < 0.01);
261    }
262
263    #[test]
264    fn test_average_recv_ms_zero_count() {
265        let stats = BackendStats {
266            recv_micros_total: RecvMicros::new(1000),
267            ttfb_count: TimingMeasurementCount::new(0),
268            ..Default::default()
269        };
270        assert_eq!(stats.average_recv_ms(), None);
271    }
272
273    #[test]
274    fn test_average_overhead_ms() {
275        let stats = create_test_stats();
276        let overhead = stats.average_overhead_ms();
277        assert!(overhead.is_some());
278        // ttfb(1.0) - send(0.3) - recv(0.5) = 0.2 ms overhead
279        assert!((overhead.unwrap() - 0.2).abs() < 0.01);
280    }
281
282    #[test]
283    fn test_average_overhead_ms_zero_count() {
284        let stats = BackendStats::default();
285        assert_eq!(stats.average_overhead_ms(), None);
286    }
287
288    #[test]
289    fn test_error_rate_percent() {
290        let stats = create_test_stats();
291        let rate = stats.error_rate_percent();
292        assert!((rate - 5.0).abs() < 0.01); // 5/100 = 5%
293    }
294
295    #[test]
296    fn test_error_rate_percent_zero_commands() {
297        let stats = BackendStats {
298            total_commands: CommandCount::new(0),
299            errors: ErrorCount::new(10),
300            ..Default::default()
301        };
302        assert_eq!(stats.error_rate_percent(), 0.0);
303    }
304
305    #[test]
306    fn test_error_rate_percent_no_errors() {
307        let stats = BackendStats {
308            total_commands: CommandCount::new(100),
309            errors: ErrorCount::new(0),
310            ..Default::default()
311        };
312        assert_eq!(stats.error_rate_percent(), 0.0);
313    }
314
315    #[test]
316    fn test_has_high_error_rate() {
317        // 10% error rate - should be high
318        let stats = BackendStats {
319            total_commands: CommandCount::new(100),
320            errors: ErrorCount::new(10),
321            ..Default::default()
322        };
323        assert!(stats.has_high_error_rate());
324
325        // 5% error rate - exactly at threshold, not high
326        let stats_threshold = BackendStats {
327            total_commands: CommandCount::new(100),
328            errors: ErrorCount::new(5),
329            ..Default::default()
330        };
331        assert!(!stats_threshold.has_high_error_rate());
332
333        // 3% error rate - should not be high
334        let stats_low = BackendStats {
335            total_commands: CommandCount::new(100),
336            errors: ErrorCount::new(3),
337            ..Default::default()
338        };
339        assert!(!stats_low.has_high_error_rate());
340    }
341
342    #[test]
343    fn test_is_healthy() {
344        let stats = BackendStats {
345            health_status: BackendHealthStatus::Healthy,
346            ..Default::default()
347        };
348        assert!(stats.is_healthy());
349        assert!(!stats.is_degraded());
350        assert!(!stats.is_down());
351    }
352
353    #[test]
354    fn test_is_degraded() {
355        let stats = BackendStats {
356            health_status: BackendHealthStatus::Degraded,
357            ..Default::default()
358        };
359        assert!(!stats.is_healthy());
360        assert!(stats.is_degraded());
361        assert!(!stats.is_down());
362    }
363
364    #[test]
365    fn test_is_down() {
366        let stats = BackendStats {
367            health_status: BackendHealthStatus::Down,
368            ..Default::default()
369        };
370        assert!(!stats.is_healthy());
371        assert!(!stats.is_degraded());
372        assert!(stats.is_down());
373    }
374
375    #[test]
376    fn test_total_bytes() {
377        let stats = create_test_stats();
378        assert_eq!(stats.total_bytes(), 3000); // 1000 + 2000
379    }
380
381    #[test]
382    fn test_total_bytes_zero() {
383        let stats = BackendStats::default();
384        assert_eq!(stats.total_bytes(), 0);
385    }
386
387    #[test]
388    fn test_total_bytes_saturating() {
389        let stats = BackendStats {
390            bytes_sent: BytesSent::new(u64::MAX),
391            bytes_received: BytesReceived::new(1),
392            ..Default::default()
393        };
394        assert_eq!(stats.total_bytes(), u64::MAX);
395    }
396
397    #[test]
398    fn test_has_activity() {
399        let stats = BackendStats {
400            total_commands: CommandCount::new(1),
401            ..Default::default()
402        };
403        assert!(stats.has_activity());
404    }
405
406    #[test]
407    fn test_has_activity_none() {
408        let stats = BackendStats::default();
409        assert!(!stats.has_activity());
410    }
411
412    #[test]
413    fn test_backend_stats_clone() {
414        let stats = create_test_stats();
415        let cloned = stats.clone();
416
417        assert_eq!(stats.backend_id, cloned.backend_id);
418        assert_eq!(stats.total_commands.get(), cloned.total_commands.get());
419        assert_eq!(stats.total_bytes(), cloned.total_bytes());
420        assert_eq!(stats.health_status, cloned.health_status);
421    }
422}