Skip to main content

nntp_proxy/metrics/
types.rs

1//! Type-safe metrics types using the newtype pattern
2//!
3//! All metric values are wrapped in newtypes to prevent:
4//! - Mixing up different kinds of counts (commands vs errors vs articles)
5//! - Mixing up different time units (microseconds vs milliseconds)
6//! - Mixing up different rate types (bytes/sec vs commands/sec)
7//!
8//! This provides compile-time guarantees that we're not accidentally
9//! adding apples to oranges.
10
11use std::num::NonZeroU64;
12
13#[allow(clippy::cast_precision_loss)] // Metrics rates and percentages are derived display/monitoring values.
14const fn count_as_f64_for_rate(value: u64) -> f64 {
15    // Metrics rates and percentages are display/monitoring values. The source
16    // counters remain exact u64s; this conversion is only for derived ratios.
17    value as f64
18}
19
20// Bytes/sec is intentionally exposed as an integer metric derived from non-negative samples.
21#[allow(
22    clippy::cast_possible_truncation,
23    clippy::cast_precision_loss,
24    clippy::cast_sign_loss
25)]
26fn bytes_per_second_to_u64(bytes_delta: u64, seconds: f64) -> u64 {
27    // Bytes/sec is exposed as an integer metric. Truncating the fractional
28    // byte/sec component matches the previous API and avoids overstating rate.
29    // Callers pass positive elapsed durations, so the computed rate is non-negative.
30    (count_as_f64_for_rate(bytes_delta) / seconds) as u64
31}
32
33// ============================================================================
34// Macros to reduce boilerplate
35// ============================================================================
36
37/// Define a simple u64-based counter newtype with mutation operations.
38///
39/// Used for internal counting that needs `increment()` and `saturating_sub()`.
40/// For display-oriented types with unit strings, see `types::metrics::define_counter!`.
41macro_rules! counter_type {
42    ($name:ident) => {
43        #[derive(
44            Debug,
45            Clone,
46            Copy,
47            PartialEq,
48            Eq,
49            PartialOrd,
50            Ord,
51            Default,
52            serde::Serialize,
53            serde::Deserialize,
54        )]
55        pub struct $name(u64);
56
57        impl $name {
58            #[inline]
59            pub const fn new(value: u64) -> Self {
60                Self(value)
61            }
62
63            #[inline]
64            pub const fn get(self) -> u64 {
65                self.0
66            }
67
68            #[inline]
69            pub const fn increment(&mut self) {
70                self.0 += 1;
71            }
72
73            #[must_use]
74            #[inline]
75            pub const fn saturating_sub(self, other: Self) -> Self {
76                Self(self.0.saturating_sub(other.0))
77            }
78        }
79
80        impl std::fmt::Display for $name {
81            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82                write!(f, "{}", self.0)
83            }
84        }
85    };
86}
87
88/// Define a microseconds-based timing newtype that can average to milliseconds
89macro_rules! timing_type {
90    ($name:ident) => {
91        #[derive(
92            Debug,
93            Clone,
94            Copy,
95            PartialEq,
96            Eq,
97            PartialOrd,
98            Ord,
99            Default,
100            serde::Serialize,
101            serde::Deserialize,
102        )]
103        pub struct $name(u64);
104
105        impl $name {
106            #[inline]
107            pub const fn new(micros: u64) -> Self {
108                Self(micros)
109            }
110
111            #[inline]
112            pub const fn get(self) -> u64 {
113                self.0
114            }
115
116            #[inline]
117            pub const fn add(&mut self, other: Self) {
118                self.0 += other.0;
119            }
120
121            #[must_use]
122            pub fn average(total: Self, count: NonZeroU64) -> Milliseconds {
123                let avg_micros =
124                    count_as_f64_for_rate(total.0) / count_as_f64_for_rate(count.get());
125                Milliseconds::from_micros(avg_micros)
126            }
127        }
128    };
129}
130
131/// Define a f64-based rate/measurement newtype
132macro_rules! f64_type {
133    ($name:ident) => {
134        #[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, serde::Serialize, serde::Deserialize)]
135        pub struct $name(f64);
136
137        impl $name {
138            #[inline]
139            pub const fn new(value: f64) -> Self {
140                Self(value)
141            }
142
143            #[inline]
144            pub const fn get(self) -> f64 {
145                self.0
146            }
147        }
148    };
149}
150
151// ============================================================================
152// Backend Health Status (for metrics display)
153// ============================================================================
154
155/// Backend health status for metrics display (distinct from `health::HealthStatus`)
156///
157/// This 3-state enum is used for UI/metrics purposes, while `health::HealthStatus`
158/// is a binary Healthy/Unhealthy used for actual health checking.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
160pub enum BackendHealthStatus {
161    /// Backend is healthy and responding normally
162    #[default]
163    Healthy,
164    /// Backend is degraded (high error rate or slow)
165    Degraded,
166    /// Backend is down or unreachable
167    Down,
168}
169
170impl From<u8> for BackendHealthStatus {
171    fn from(value: u8) -> Self {
172        match value {
173            1 => Self::Degraded,
174            2 => Self::Down,
175            _ => Self::Healthy,
176        }
177    }
178}
179
180impl From<BackendHealthStatus> for u8 {
181    fn from(status: BackendHealthStatus) -> Self {
182        match status {
183            BackendHealthStatus::Healthy => 0,
184            BackendHealthStatus::Degraded => 1,
185            BackendHealthStatus::Down => 2,
186        }
187    }
188}
189
190// ============================================================================
191// Counts - Different types of things we count
192// ============================================================================
193
194counter_type!(CommandCount);
195counter_type!(FailureCount);
196
197/// Number of errors encountered
198#[derive(
199    Debug,
200    Clone,
201    Copy,
202    PartialEq,
203    Eq,
204    PartialOrd,
205    Ord,
206    Default,
207    serde::Serialize,
208    serde::Deserialize,
209)]
210pub struct ErrorCount(u64);
211
212impl ErrorCount {
213    #[inline]
214    #[must_use]
215    pub const fn new(count: u64) -> Self {
216        Self(count)
217    }
218
219    #[inline]
220    #[must_use]
221    pub const fn get(self) -> u64 {
222        self.0
223    }
224
225    #[inline]
226    pub const fn increment(&mut self) {
227        self.0 += 1;
228    }
229
230    #[inline]
231    pub const fn add(&mut self, other: Self) {
232        self.0 += other.0;
233    }
234
235    #[must_use]
236    #[inline]
237    pub const fn saturating_sub(self, other: Self) -> Self {
238        Self(self.0.saturating_sub(other.0))
239    }
240
241    #[must_use]
242    #[inline]
243    pub const fn is_zero(self) -> bool {
244        self.0 == 0
245    }
246}
247
248impl std::fmt::Display for ErrorCount {
249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        write!(f, "{}", self.0)
251    }
252}
253
254/// Number of articles retrieved
255#[derive(
256    Debug,
257    Clone,
258    Copy,
259    PartialEq,
260    Eq,
261    PartialOrd,
262    Ord,
263    Default,
264    serde::Serialize,
265    serde::Deserialize,
266)]
267pub struct ArticleCount(u64);
268
269impl ArticleCount {
270    #[inline]
271    #[must_use]
272    pub const fn new(count: u64) -> Self {
273        Self(count)
274    }
275
276    #[inline]
277    #[must_use]
278    pub const fn get(self) -> u64 {
279        self.0
280    }
281
282    #[inline]
283    pub const fn increment(&mut self) {
284        self.0 += 1;
285    }
286
287    /// Calculate average bytes per article
288    #[must_use]
289    pub const fn average_bytes(self, total_bytes: u64) -> Option<u64> {
290        total_bytes.checked_div(self.0)
291    }
292}
293
294impl std::fmt::Display for ArticleCount {
295    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296        write!(f, "{}", self.0)
297    }
298}
299
300/// Number of active connections (non-zero validated)
301#[derive(
302    Debug,
303    Clone,
304    Copy,
305    PartialEq,
306    Eq,
307    PartialOrd,
308    Ord,
309    Default,
310    serde::Serialize,
311    serde::Deserialize,
312)]
313pub struct ActiveConnections(usize);
314
315impl ActiveConnections {
316    #[inline]
317    #[must_use]
318    pub const fn new(count: usize) -> Self {
319        Self(count)
320    }
321
322    #[inline]
323    #[must_use]
324    pub const fn get(self) -> usize {
325        self.0
326    }
327}
328
329impl std::fmt::Display for ActiveConnections {
330    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331        write!(f, "{}", self.0)
332    }
333}
334
335// ============================================================================
336// Time measurements - Different units and types of timing
337// ============================================================================
338
339timing_type!(TtfbMicros);
340timing_type!(SendMicros);
341timing_type!(RecvMicros);
342
343/// Time in microseconds (for precision timing)
344#[derive(
345    Debug,
346    Clone,
347    Copy,
348    PartialEq,
349    Eq,
350    PartialOrd,
351    Ord,
352    Default,
353    serde::Serialize,
354    serde::Deserialize,
355)]
356pub struct Microseconds(u64);
357
358impl Microseconds {
359    #[inline]
360    #[must_use]
361    pub const fn new(micros: u64) -> Self {
362        Self(micros)
363    }
364
365    #[inline]
366    #[must_use]
367    pub const fn get(self) -> u64 {
368        self.0
369    }
370
371    #[inline]
372    pub const fn add(&mut self, other: Self) {
373        self.0 += other.0;
374    }
375
376    #[inline]
377    #[must_use]
378    pub fn as_millis_f64(self) -> f64 {
379        count_as_f64_for_rate(self.0) / 1000.0
380    }
381}
382
383f64_type!(Milliseconds);
384
385impl Milliseconds {
386    #[inline]
387    #[must_use]
388    pub fn from_micros(micros: f64) -> Self {
389        Self(micros / 1000.0)
390    }
391}
392
393f64_type!(OverheadMillis);
394
395impl OverheadMillis {
396    /// Calculate overhead from component times
397    #[must_use]
398    pub fn from_components(ttfb: Milliseconds, send: Milliseconds, recv: Milliseconds) -> Self {
399        Self(ttfb.0 - send.0 - recv.0)
400    }
401}
402
403// ============================================================================
404// Rates - Different types of throughput measurements
405// ============================================================================
406
407/// Bytes per second transfer rate
408#[derive(
409    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Default, serde::Serialize, serde::Deserialize,
410)]
411pub struct BytesPerSecond(u64);
412
413impl BytesPerSecond {
414    #[inline]
415    #[must_use]
416    pub const fn new(bps: u64) -> Self {
417        Self(bps)
418    }
419
420    #[inline]
421    #[must_use]
422    pub const fn get(self) -> u64 {
423        self.0
424    }
425
426    #[must_use]
427    pub fn from_delta(bytes_delta: u64, seconds: f64) -> Self {
428        if seconds > 0.0 {
429            Self(bytes_per_second_to_u64(bytes_delta, seconds))
430        } else {
431            Self(0)
432        }
433    }
434}
435
436f64_type!(CommandsPerSecond);
437
438impl CommandsPerSecond {
439    #[must_use]
440    pub fn from_delta(commands_delta: u64, seconds: f64) -> Self {
441        if seconds > 0.0 {
442            Self(count_as_f64_for_rate(commands_delta) / seconds)
443        } else {
444            Self(0.0)
445        }
446    }
447}
448
449f64_type!(ErrorRatePercent);
450
451impl ErrorRatePercent {
452    #[must_use]
453    pub fn from_counts(errors: ErrorCount, commands: CommandCount) -> Self {
454        if commands.get() > 0 {
455            Self(
456                (count_as_f64_for_rate(errors.get()) / count_as_f64_for_rate(commands.get()))
457                    * 100.0,
458            )
459        } else {
460            Self(0.0)
461        }
462    }
463
464    #[must_use]
465    pub fn from_raw_counts(errors: u64, commands: u64) -> Self {
466        if commands > 0 {
467            Self((count_as_f64_for_rate(errors) / count_as_f64_for_rate(commands)) * 100.0)
468        } else {
469            Self(0.0)
470        }
471    }
472
473    #[must_use]
474    pub fn is_high(self) -> bool {
475        self.0 > 5.0
476    }
477}
478
479#[cfg(test)]
480#[allow(clippy::float_cmp)] // These tests intentionally compare exact fixed outputs and zero cases.
481mod tests {
482    use super::*;
483
484    // CommandCount tests
485    #[test]
486    fn test_command_count_new() {
487        let count = CommandCount::new(42);
488        assert_eq!(count.get(), 42);
489    }
490
491    #[test]
492    fn test_command_count_default() {
493        let count = CommandCount::default();
494        assert_eq!(count.get(), 0);
495    }
496
497    #[test]
498    fn test_command_count_increment() {
499        let mut count = CommandCount::new(10);
500        count.increment();
501        assert_eq!(count.get(), 11);
502    }
503
504    #[test]
505    fn test_command_count_saturating_sub() {
506        let count1 = CommandCount::new(100);
507        let count2 = CommandCount::new(30);
508        let result = count1.saturating_sub(count2);
509        assert_eq!(result.get(), 70);
510    }
511
512    #[test]
513    fn test_command_count_saturating_sub_underflow() {
514        let count1 = CommandCount::new(10);
515        let count2 = CommandCount::new(30);
516        let result = count1.saturating_sub(count2);
517        assert_eq!(result.get(), 0); // Saturates at 0
518    }
519
520    #[test]
521    fn test_command_count_display() {
522        let count = CommandCount::new(1234);
523        assert_eq!(format!("{count}"), "1234");
524    }
525
526    // ErrorCount tests
527    #[test]
528    fn test_error_count_new() {
529        let count = ErrorCount::new(5);
530        assert_eq!(count.get(), 5);
531    }
532
533    #[test]
534    fn test_error_count_increment() {
535        let mut count = ErrorCount::new(0);
536        count.increment();
537        count.increment();
538        assert_eq!(count.get(), 2);
539    }
540
541    #[test]
542    fn test_error_count_is_zero() {
543        let zero = ErrorCount::new(0);
544        let nonzero = ErrorCount::new(1);
545
546        assert!(zero.is_zero());
547        assert!(!nonzero.is_zero());
548    }
549
550    // ArticleCount tests
551    #[test]
552    fn test_article_count_new() {
553        let count = ArticleCount::new(10);
554        assert_eq!(count.get(), 10);
555    }
556
557    #[test]
558    fn test_article_count_increment() {
559        let mut count = ArticleCount::new(5);
560        count.increment();
561        assert_eq!(count.get(), 6);
562    }
563
564    #[test]
565    fn test_article_count_average_bytes() {
566        let count = ArticleCount::new(10);
567        let avg = count.average_bytes(5000);
568        assert_eq!(avg, Some(500)); // 5000 / 10 = 500
569    }
570
571    #[test]
572    fn test_article_count_average_bytes_zero_articles() {
573        let count = ArticleCount::new(0);
574        let avg = count.average_bytes(1000);
575        assert_eq!(avg, None);
576    }
577
578    #[test]
579    fn test_article_count_average_bytes_zero_bytes() {
580        let count = ArticleCount::new(10);
581        let avg = count.average_bytes(0);
582        assert_eq!(avg, Some(0));
583    }
584
585    // ActiveConnections tests
586    #[test]
587    fn test_active_connections_new() {
588        let active = ActiveConnections::new(5);
589        assert_eq!(active.get(), 5);
590    }
591
592    #[test]
593    fn test_active_connections_default() {
594        let active = ActiveConnections::default();
595        assert_eq!(active.get(), 0);
596    }
597
598    #[test]
599    fn test_active_connections_display() {
600        let active = ActiveConnections::new(42);
601        assert_eq!(format!("{active}"), "42");
602    }
603
604    // Timing types tests
605    #[test]
606    fn test_ttfb_micros_new() {
607        let ttfb = TtfbMicros::new(1000);
608        assert_eq!(ttfb.get(), 1000);
609    }
610
611    #[test]
612    fn test_ttfb_micros_add() {
613        let mut ttfb = TtfbMicros::new(1000);
614        ttfb.add(TtfbMicros::new(500));
615        assert_eq!(ttfb.get(), 1500);
616    }
617
618    #[test]
619    fn test_ttfb_micros_average() {
620        let total = TtfbMicros::new(10000); // 10000 micros
621        let count = NonZeroU64::new(10).unwrap();
622        let avg = TtfbMicros::average(total, count);
623        assert!((avg.get() - 1.0).abs() < 0.01); // 1000 micros = 1.0 ms
624    }
625
626    #[test]
627    fn test_send_micros_average() {
628        let total = SendMicros::new(5000);
629        let count = NonZeroU64::new(10).unwrap();
630        let avg = SendMicros::average(total, count);
631        assert!((avg.get() - 0.5).abs() < 0.01); // 500 micros = 0.5 ms
632    }
633
634    #[test]
635    fn test_recv_micros_average() {
636        let total = RecvMicros::new(15000);
637        let count = NonZeroU64::new(10).unwrap();
638        let avg = RecvMicros::average(total, count);
639        assert!((avg.get() - 1.5).abs() < 0.01); // 1500 micros = 1.5 ms
640    }
641
642    // Microseconds tests
643    #[test]
644    fn test_microseconds_new() {
645        let micros = Microseconds::new(1000);
646        assert_eq!(micros.get(), 1000);
647    }
648
649    #[test]
650    fn test_microseconds_add() {
651        let mut micros = Microseconds::new(1000);
652        micros.add(Microseconds::new(500));
653        assert_eq!(micros.get(), 1500);
654    }
655
656    #[test]
657    fn test_microseconds_as_millis_f64() {
658        let micros = Microseconds::new(1500);
659        let millis = micros.as_millis_f64();
660        assert!((millis - 1.5).abs() < 0.01);
661    }
662
663    // Milliseconds tests
664    #[test]
665    fn test_milliseconds_new() {
666        let ms = Milliseconds::new(10.5);
667        assert!((ms.get() - 10.5).abs() < 0.01);
668    }
669
670    #[test]
671    fn test_milliseconds_from_micros() {
672        let ms = Milliseconds::from_micros(5000.0);
673        assert!((ms.get() - 5.0).abs() < 0.01);
674    }
675
676    // OverheadMillis tests
677    #[test]
678    fn test_overhead_millis_from_components() {
679        let ttfb = Milliseconds::new(10.0);
680        let send = Milliseconds::new(3.0);
681        let recv = Milliseconds::new(5.0);
682
683        let overhead = OverheadMillis::from_components(ttfb, send, recv);
684        assert!((overhead.get() - 2.0).abs() < 0.01); // 10 - 3 - 5 = 2
685    }
686
687    #[test]
688    fn test_overhead_millis_negative() {
689        // Edge case: send + recv > ttfb (shouldn't happen in practice)
690        let ttfb = Milliseconds::new(5.0);
691        let send = Milliseconds::new(3.0);
692        let recv = Milliseconds::new(4.0);
693
694        let overhead = OverheadMillis::from_components(ttfb, send, recv);
695        assert!((overhead.get() + 2.0).abs() < 0.01); // 5 - 3 - 4 = -2
696    }
697
698    // BytesPerSecond tests
699    #[test]
700    fn test_bytes_per_second_new() {
701        let bps = BytesPerSecond::new(1000);
702        assert_eq!(bps.get(), 1000);
703    }
704
705    #[test]
706    fn test_bytes_per_second_from_delta() {
707        let bps = BytesPerSecond::from_delta(1000, 2.0);
708        assert_eq!(bps.get(), 500); // 1000 bytes / 2 seconds = 500 bps
709    }
710
711    #[test]
712    fn test_bytes_per_second_from_delta_zero_time() {
713        let bps = BytesPerSecond::from_delta(1000, 0.0);
714        assert_eq!(bps.get(), 0); // Avoid division by zero
715    }
716
717    #[test]
718    fn test_bytes_per_second_default() {
719        let bps = BytesPerSecond::default();
720        assert_eq!(bps.get(), 0);
721    }
722
723    // CommandsPerSecond tests
724    #[test]
725    fn test_commands_per_second_new() {
726        let cps = CommandsPerSecond::new(10.5);
727        assert!((cps.get() - 10.5).abs() < 0.01);
728    }
729
730    #[test]
731    fn test_commands_per_second_from_delta() {
732        let cps = CommandsPerSecond::from_delta(100, 10.0);
733        assert!((cps.get() - 10.0).abs() < 0.01); // 100 / 10 = 10.0
734    }
735
736    #[test]
737    fn test_commands_per_second_from_delta_zero_time() {
738        let cps = CommandsPerSecond::from_delta(100, 0.0);
739        assert_eq!(cps.get(), 0.0);
740    }
741
742    // ErrorRatePercent tests
743    #[test]
744    fn test_error_rate_percent_from_counts() {
745        let errors = ErrorCount::new(5);
746        let commands = CommandCount::new(100);
747        let rate = ErrorRatePercent::from_counts(errors, commands);
748        assert!((rate.get() - 5.0).abs() < 0.01); // 5/100 = 5%
749    }
750
751    #[test]
752    fn test_error_rate_percent_from_counts_zero_commands() {
753        let errors = ErrorCount::new(10);
754        let commands = CommandCount::new(0);
755        let rate = ErrorRatePercent::from_counts(errors, commands);
756        assert_eq!(rate.get(), 0.0); // Avoid division by zero
757    }
758
759    #[test]
760    fn test_error_rate_percent_from_raw_counts() {
761        let rate = ErrorRatePercent::from_raw_counts(10, 100);
762        assert!((rate.get() - 10.0).abs() < 0.01); // 10/100 = 10%
763    }
764
765    #[test]
766    fn test_error_rate_percent_from_raw_counts_zero_commands() {
767        let rate = ErrorRatePercent::from_raw_counts(5, 0);
768        assert_eq!(rate.get(), 0.0);
769    }
770
771    #[test]
772    fn test_error_rate_percent_is_high() {
773        let low = ErrorRatePercent::new(3.0);
774        let threshold = ErrorRatePercent::new(5.0);
775        let high = ErrorRatePercent::new(10.0);
776
777        assert!(!low.is_high());
778        assert!(!threshold.is_high()); // 5.0 is NOT high (> 5.0)
779        assert!(high.is_high());
780    }
781
782    #[test]
783    fn test_error_rate_percent_is_high_edge_cases() {
784        let just_above = ErrorRatePercent::new(5.01);
785        let just_below = ErrorRatePercent::new(4.99);
786
787        assert!(just_above.is_high());
788        assert!(!just_below.is_high());
789    }
790
791    // Ordering tests
792    #[test]
793    fn test_command_count_ordering() {
794        let c1 = CommandCount::new(10);
795        let c2 = CommandCount::new(20);
796        let c3 = CommandCount::new(10);
797
798        assert!(c1 < c2);
799        assert!(c2 > c1);
800        assert_eq!(c1, c3);
801    }
802
803    #[test]
804    fn test_error_count_ordering() {
805        let e1 = ErrorCount::new(5);
806        let e2 = ErrorCount::new(10);
807
808        assert!(e1 < e2);
809        assert!(e2 > e1);
810    }
811
812    #[test]
813    fn test_article_count_ordering() {
814        let a1 = ArticleCount::new(100);
815        let a2 = ArticleCount::new(200);
816
817        assert!(a1 < a2);
818        assert!(a2 > a1);
819    }
820
821    #[test]
822    fn test_active_connections_ordering() {
823        let a1 = ActiveConnections::new(3);
824        let a2 = ActiveConnections::new(5);
825
826        assert!(a1 < a2);
827        assert!(a2 > a1);
828    }
829
830    // Clone and Copy tests
831    #[test]
832    fn test_types_are_copy() {
833        let count = CommandCount::new(42);
834        let copied = count; // Copy, not move
835        assert_eq!(count.get(), copied.get());
836    }
837}