1use std::fmt::{self, Display, Write};
2use std::num::NonZero;
3use std::{cmp, iter};
4
5use foldhash::{HashMap, HashMapExt};
6use new_zealand::nz;
7
8use crate::{EventName, GLOBAL_REGISTRY, Magnitude, ObservationBagSnapshot, Observations};
9
10#[derive(Debug)]
17pub struct Report {
18 events: Box<[EventMetrics]>,
20}
21
22impl Report {
23 #[must_use]
47 pub fn collect() -> Self {
48 let mut event_name_to_merged_snapshot = HashMap::new();
50
51 GLOBAL_REGISTRY.inspect(|observation_bags| {
52 for (event_name, observation_bag) in observation_bags {
53 let snapshot = observation_bag.snapshot();
54
55 event_name_to_merged_snapshot
57 .entry(event_name.clone())
58 .and_modify(|existing_snapshot: &mut ObservationBagSnapshot| {
59 existing_snapshot.merge_from(&snapshot);
60 })
61 .or_insert(snapshot);
62 }
63 });
64
65 let mut events = event_name_to_merged_snapshot
67 .into_iter()
68 .map(|(event_name, snapshot)| EventMetrics::new(event_name, snapshot))
69 .collect::<Vec<_>>();
70
71 events.sort_by_key(|event_metrics| event_metrics.name().clone());
73
74 Self {
75 events: events.into_boxed_slice(),
76 }
77 }
78
79 pub fn events(&self) -> impl Iterator<Item = &EventMetrics> {
102 self.events.iter()
103 }
104}
105
106impl Display for Report {
107 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108 for event in &self.events {
109 writeln!(f, "{event}")?;
110 }
111
112 Ok(())
113 }
114}
115
116#[derive(Debug)]
120pub struct EventMetrics {
121 name: EventName,
122
123 count: u64,
124 sum: Magnitude,
125
126 mean: Magnitude,
128
129 histogram: Option<Histogram>,
131}
132
133impl EventMetrics {
134 pub(crate) fn new(name: EventName, snapshot: ObservationBagSnapshot) -> Self {
135 let count = snapshot.count;
136 let sum = snapshot.sum;
137
138 #[expect(
139 clippy::arithmetic_side_effects,
140 reason = "NonZero protects against division by zero"
141 )]
142 #[expect(
143 clippy::integer_division,
144 reason = "we accept that we lose the remainder - 100% precision not required"
145 )]
146 let mean = Magnitude::try_from(count)
147 .ok()
148 .and_then(NonZero::new)
149 .map_or(0, |count| sum / count.get());
150
151 let histogram = if snapshot.bucket_magnitudes.is_empty() {
152 None
153 } else {
154 let plus_infinity_bucket_count = snapshot
157 .count
158 .saturating_sub(snapshot.bucket_counts.iter().sum::<u64>());
159
160 Some(Histogram {
161 magnitudes: snapshot.bucket_magnitudes,
162 counts: snapshot.bucket_counts,
163 plus_infinity_bucket_count,
164 })
165 };
166
167 Self {
168 name,
169 count,
170 sum,
171 mean,
172 histogram,
173 }
174 }
175
176 #[must_use]
197 pub fn name(&self) -> &EventName {
198 &self.name
199 }
200
201 #[must_use]
223 pub fn count(&self) -> u64 {
224 self.count
225 }
226
227 #[must_use]
249 pub fn sum(&self) -> Magnitude {
250 self.sum
251 }
252
253 #[must_use]
277 pub fn mean(&self) -> Magnitude {
278 self.mean
279 }
280
281 #[must_use]
314 pub fn histogram(&self) -> Option<&Histogram> {
315 self.histogram.as_ref()
316 }
317}
318
319impl Display for EventMetrics {
320 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
321 write!(f, "{}: ", self.name)?;
322
323 if self.count == 0 {
324 writeln!(f, "0")?;
326 return Ok(());
327 }
328
329 #[expect(
330 clippy::cast_possible_wrap,
331 reason = "intentional wrap - crate policy is that values out of safe range may be mangled"
332 )]
333 let count_as_magnitude = self.count as Magnitude;
334
335 if count_as_magnitude == self.sum && self.histogram.is_none() {
336 writeln!(f, "{} (counter)", self.count)?;
343 } else {
344 writeln!(f, "{}; sum {}; mean {}", self.count, self.sum, self.mean)?;
345 }
346
347 if let Some(histogram) = &self.histogram {
349 writeln!(f, "{histogram}")?;
350 }
351
352 Ok(())
353 }
354}
355
356#[derive(Debug)]
361pub struct Histogram {
362 magnitudes: &'static [Magnitude],
367
368 counts: Box<[u64]>,
369
370 plus_infinity_bucket_count: u64,
373}
374
375impl Histogram {
376 pub fn magnitudes(&self) -> impl Iterator<Item = Magnitude> {
385 self.magnitudes
386 .iter()
387 .copied()
388 .chain(iter::once(Magnitude::MAX))
389 }
390
391 pub fn counts(&self) -> impl Iterator<Item = u64> {
398 self.counts
399 .iter()
400 .copied()
401 .chain(iter::once(self.plus_infinity_bucket_count))
402 }
403
404 pub fn buckets(&self) -> impl Iterator<Item = (Magnitude, u64)> {
414 self.magnitudes().zip(self.counts())
415 }
416}
417
418const HISTOGRAM_BAR_WIDTH_CHARS: u64 = 50;
428
429const HISTOGRAM_BAR_CHARS: &str =
433 "∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎";
434
435const HISTOGRAM_BAR_CHARS_LEN_BYTES: NonZero<usize> =
436 NonZero::new(HISTOGRAM_BAR_CHARS.len()).unwrap();
437
438const BYTES_PER_HISTOGRAM_BAR_CHAR: NonZero<usize> = nz!(3);
441
442impl Display for Histogram {
443 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
444 let buckets = self.buckets().collect::<Vec<_>>();
445
446 let mut count_str = String::new();
450
451 let widest_count = buckets.iter().fold(0, |current, bucket| {
453 count_str.clear();
454 write!(&mut count_str, "{}", bucket.1)
455 .expect("we expect writing integer to String to be infallible");
456 cmp::max(current, count_str.len())
457 });
458
459 let mut upper_bound_str = String::new();
461
462 let widest_upper_bound = buckets.iter().fold(0, |current, bucket| {
464 upper_bound_str.clear();
465
466 if bucket.0 == Magnitude::MAX {
467 write!(&mut upper_bound_str, "+inf")
469 .expect("we expect writing integer to String to be infallible");
470 } else {
471 write!(&mut upper_bound_str, "{}", bucket.0)
472 .expect("we expect writing integer to String to be infallible");
473 }
474
475 cmp::max(current, upper_bound_str.len())
476 });
477
478 let histogram_scale = HistogramScale::new(self);
479
480 for (magnitude, count) in buckets {
481 upper_bound_str.clear();
482
483 if magnitude == Magnitude::MAX {
484 write!(&mut upper_bound_str, "+inf")?;
486 } else {
487 write!(&mut upper_bound_str, "{magnitude}")?;
489 }
490
491 let padding_needed = widest_upper_bound.saturating_sub(upper_bound_str.len());
492 for _ in 0..padding_needed {
493 upper_bound_str.insert(0, ' ');
494 }
495
496 count_str.clear();
497 write!(&mut count_str, "{count}")?;
498
499 let padding_needed = widest_count.saturating_sub(count_str.len());
500 for _ in 0..padding_needed {
501 count_str.insert(0, ' ');
502 }
503
504 write!(f, "value <= {upper_bound_str} [ {count_str} ]: ")?;
505 histogram_scale.write_bar(count, f)?;
506
507 writeln!(f)?;
508 }
509
510 Ok(())
511 }
512}
513
514#[derive(Debug)]
516struct HistogramScale {
517 count_per_char: NonZero<u64>,
520}
521
522impl HistogramScale {
523 fn new(snapshot: &Histogram) -> Self {
524 let max_count = snapshot
525 .counts()
526 .max()
527 .expect("a histogram always has at least one bucket by definition (+inf)");
528
529 #[expect(
534 clippy::integer_division,
535 reason = "we accept the loss of precision here - the bar might not always reach 100% of desired width or even overshoot it"
536 )]
537 let count_per_char = NonZero::new(cmp::max(max_count / HISTOGRAM_BAR_WIDTH_CHARS, 1))
538 .expect("guarded by max()");
539
540 Self { count_per_char }
541 }
542
543 fn write_bar(&self, count: u64, f: &mut impl Write) -> fmt::Result {
544 let histogram_bar_width = count
545 .checked_div(self.count_per_char.get())
546 .expect("division by zero impossible - divisor is NonZero");
547
548 let bar_width = usize::try_from(histogram_bar_width).expect("safe range");
552
553 let chars_in_constant = HISTOGRAM_BAR_CHARS_LEN_BYTES
554 .get()
555 .checked_div(BYTES_PER_HISTOGRAM_BAR_CHAR.get())
556 .expect("NonZero - cannot be zero");
557
558 let mut remaining = bar_width;
559
560 while remaining > 0 {
561 let chunk_size =
562 NonZero::new(remaining.min(chars_in_constant)).expect("guarded by loop condition");
563
564 let byte_end = chunk_size
566 .checked_mul(BYTES_PER_HISTOGRAM_BAR_CHAR)
567 .expect("we are seeking into a small constant value, overflow impossible");
568
569 #[expect(
570 clippy::string_slice,
571 reason = "safe slicing - ∎ characters have known UTF-8 encoding"
572 )]
573 f.write_str(&HISTOGRAM_BAR_CHARS[..byte_end.get()])?;
574
575 remaining = remaining
576 .checked_sub(chunk_size.get())
577 .expect("guarded by min() above");
578 }
579
580 Ok(())
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 #![allow(clippy::indexing_slicing, reason = "panic is fine in tests")]
587
588 use super::*;
589
590 #[test]
591 fn histogram_properties_reflect_reality() {
592 let magnitudes = &[-5, 1, 10, 100];
593 let counts = &[66, 5, 3, 2];
594
595 let histogram = Histogram {
596 magnitudes,
597 counts: Vec::from(counts).into_boxed_slice(),
598 plus_infinity_bucket_count: 1,
599 };
600
601 assert_eq!(
602 histogram.magnitudes().collect::<Vec<_>>(),
603 magnitudes
604 .iter()
605 .copied()
606 .chain(iter::once(Magnitude::MAX))
607 .collect::<Vec<_>>()
608 );
609 assert_eq!(
610 histogram.counts().collect::<Vec<_>>(),
611 counts
612 .iter()
613 .copied()
614 .chain(iter::once(1))
615 .collect::<Vec<_>>()
616 );
617
618 let buckets: Vec<_> = histogram.buckets().collect();
619 assert_eq!(buckets.len(), 5);
620 assert_eq!(buckets[0], (-5, 66));
621 assert_eq!(buckets[1], (1, 5));
622 assert_eq!(buckets[2], (10, 3));
623 assert_eq!(buckets[3], (100, 2));
624 assert_eq!(buckets[4], (Magnitude::MAX, 1));
625 }
626
627 #[test]
628 fn histogram_display_contains_expected_information() {
629 let magnitudes = &[-5, 1, 10, 100];
630 let counts = &[666666, 5, 3, 2];
631
632 let histogram = Histogram {
633 magnitudes,
634 counts: Vec::from(counts).into_boxed_slice(),
635 plus_infinity_bucket_count: 1,
636 };
637
638 let mut output = String::new();
639 write!(&mut output, "{histogram}").unwrap();
640
641 println!("{output}");
642
643 assert!(output.contains("value <= -5 [ 666666 ]: "));
647 assert!(output.contains("value <= 1 [ 5 ]: "));
648 assert!(output.contains("value <= 10 [ 3 ]: "));
649 assert!(output.contains("value <= 100 [ 2 ]: "));
650 assert!(output.contains("value <= +inf [ 1 ]: "));
651
652 #[expect(clippy::cast_possible_truncation, reason = "safe range, tiny values")]
658 let max_acceptable_line_length = (HISTOGRAM_BAR_WIDTH_CHARS * 5) as usize;
659
660 for line in output.lines() {
661 assert!(
662 line.len() < max_acceptable_line_length,
663 "line is too long: {line}"
664 );
665 }
666 }
667
668 #[test]
669 fn event_properties_reflect_reality() {
670 let event_name = "test_event".to_string();
671 let count = 50;
672 let sum = Magnitude::from(1000);
673 let mean = Magnitude::from(20);
674
675 let histogram = Histogram {
676 magnitudes: &[1, 10, 100],
677 counts: vec![5, 3, 2].into_boxed_slice(),
678 plus_infinity_bucket_count: 1,
679 };
680
681 let event_metrics = EventMetrics {
682 name: event_name.clone().into(),
683 count,
684 sum,
685 mean,
686 histogram: Some(histogram),
687 };
688
689 assert_eq!(event_metrics.name(), &event_name);
690 assert_eq!(event_metrics.count(), count);
691 assert_eq!(event_metrics.sum(), sum);
692 assert_eq!(event_metrics.mean(), mean);
693 assert!(event_metrics.histogram().is_some());
694 }
695
696 #[test]
697 fn event_display_contains_expected_information() {
698 let event_name = "test_event".to_string();
699 let count = 50;
700 let sum = Magnitude::from(1000);
701 let mean = Magnitude::from(20);
702
703 let histogram = Histogram {
704 magnitudes: &[1, 10, 100],
705 counts: vec![5, 3, 2].into_boxed_slice(),
706 plus_infinity_bucket_count: 1,
707 };
708
709 let event_metrics = EventMetrics {
710 name: event_name.clone().into(),
711 count,
712 sum,
713 mean,
714 histogram: Some(histogram),
715 };
716
717 let mut output = String::new();
718 write!(&mut output, "{event_metrics}").unwrap();
719
720 println!("{output}");
721
722 assert!(output.contains(&event_name));
725 assert!(output.contains(&count.to_string()));
726 assert!(output.contains(&sum.to_string()));
727 assert!(output.contains(&mean.to_string()));
728 assert!(output.contains("value <= +inf [ 1 ]: "));
729 }
730
731 #[test]
732 fn report_properties_reflect_reality() {
733 let event1 = EventMetrics {
734 name: "event1".to_string().into(),
735 count: 10,
736 sum: Magnitude::from(100),
737 mean: Magnitude::from(10),
738 histogram: None,
739 };
740
741 let event2 = EventMetrics {
742 name: "event2".to_string().into(),
743 count: 5,
744 sum: Magnitude::from(50),
745 mean: Magnitude::from(10),
746 histogram: None,
747 };
748
749 let report = Report {
750 events: vec![event1, event2].into_boxed_slice(),
751 };
752
753 let events = report.events().collect::<Vec<_>>();
755
756 assert_eq!(events.len(), 2);
757 assert_eq!(events[0].name(), "event1");
758 assert_eq!(events[1].name(), "event2");
759 }
760
761 #[test]
762 fn report_display_contains_expected_events() {
763 let event1 = EventMetrics {
764 name: "event1".to_string().into(),
765 count: 10,
766 sum: Magnitude::from(100),
767 mean: Magnitude::from(10),
768 histogram: None,
769 };
770
771 let event2 = EventMetrics {
772 name: "event2".to_string().into(),
773 count: 5,
774 sum: Magnitude::from(50),
775 mean: Magnitude::from(10),
776 histogram: None,
777 };
778
779 let report = Report {
780 events: vec![event1, event2].into_boxed_slice(),
781 };
782
783 let mut output = String::new();
784 write!(&mut output, "{report}").unwrap();
785
786 println!("{output}");
787
788 assert!(output.contains("event1"));
790 assert!(output.contains("event2"));
791 }
792
793 #[test]
794 fn event_displayed_as_counter_if_unit_values_and_no_histogram() {
795 let counter = EventMetrics {
800 name: "test_event".to_string().into(),
801 count: 100,
802 sum: Magnitude::from(100),
803 mean: Magnitude::from(1),
804 histogram: None,
805 };
806
807 let not_counter = EventMetrics {
809 name: "test_event".to_string().into(),
810 count: 100,
811 sum: Magnitude::from(200),
812 mean: Magnitude::from(2),
813 histogram: None,
814 };
815
816 let also_not_counter = EventMetrics {
818 name: "test_event".to_string().into(),
819 count: 100,
820 sum: Magnitude::from(100),
821 mean: Magnitude::from(1),
822 histogram: Some(Histogram {
823 magnitudes: &[],
824 counts: Box::new([]),
825 plus_infinity_bucket_count: 100,
826 }),
827 };
828
829 let still_not_counter = EventMetrics {
831 name: "test_event".to_string().into(),
832 count: 100,
833 sum: Magnitude::from(200),
834 mean: Magnitude::from(2),
835 histogram: Some(Histogram {
836 magnitudes: &[],
837 counts: Box::new([]),
838 plus_infinity_bucket_count: 200,
839 }),
840 };
841
842 let mut output = String::new();
843
844 write!(&mut output, "{counter}").unwrap();
845 assert!(output.contains("100 (counter)"));
846 output.clear();
847
848 write!(&mut output, "{not_counter}").unwrap();
849 assert!(output.contains("100; sum 200; mean 2"));
850 output.clear();
851
852 write!(&mut output, "{also_not_counter}").unwrap();
853 assert!(output.contains("100; sum 100; mean 1"));
854 output.clear();
855
856 write!(&mut output, "{still_not_counter}").unwrap();
857 assert!(output.contains("100; sum 200; mean 2"));
858 }
859
860 #[test]
861 fn histogram_scale_zero() {
862 let histogram = Histogram {
864 magnitudes: &[1, 2, 3],
865 counts: Box::new([0, 0, 0]),
866 plus_infinity_bucket_count: 0,
867 };
868
869 let histogram_scale = HistogramScale::new(&histogram);
870
871 let mut output = String::new();
872 histogram_scale.write_bar(0, &mut output).unwrap();
873
874 assert_eq!(output, "");
875 }
876
877 #[test]
878 fn histogram_scale_small() {
879 let histogram = Histogram {
881 magnitudes: &[1, 2, 3],
882 counts: Box::new([1, 2, 3]),
883 plus_infinity_bucket_count: 0,
884 };
885
886 let histogram_scale = HistogramScale::new(&histogram);
887
888 let mut output = String::new();
889
890 histogram_scale.write_bar(0, &mut output).unwrap();
891 assert_eq!(output, "");
892 output.clear();
893
894 histogram_scale.write_bar(1, &mut output).unwrap();
895 assert_eq!(output, "∎");
896 output.clear();
897
898 histogram_scale.write_bar(2, &mut output).unwrap();
899 assert_eq!(output, "∎∎");
900 output.clear();
901
902 histogram_scale.write_bar(3, &mut output).unwrap();
903 assert_eq!(output, "∎∎∎");
904 }
905
906 #[test]
907 fn histogram_scale_just_over() {
908 let histogram = Histogram {
910 magnitudes: &[1, 2, 3],
911 counts: Box::new([
912 HISTOGRAM_BAR_WIDTH_CHARS + 1,
913 HISTOGRAM_BAR_WIDTH_CHARS + 1,
914 HISTOGRAM_BAR_WIDTH_CHARS + 1,
915 ]),
916 plus_infinity_bucket_count: 0,
917 };
918
919 let histogram_scale = HistogramScale::new(&histogram);
920
921 let mut output = String::new();
922
923 histogram_scale
924 .write_bar(HISTOGRAM_BAR_WIDTH_CHARS + 1, &mut output)
925 .unwrap();
926 assert_eq!(
928 output,
929 "∎".repeat(
930 usize::try_from(HISTOGRAM_BAR_WIDTH_CHARS + 1).expect("safe range, tiny value")
931 )
932 );
933 }
934
935 #[test]
936 fn histogram_scale_large_exact() {
937 let histogram = Histogram {
940 magnitudes: &[1, 2, 3],
941 counts: Box::new([
942 79,
943 HISTOGRAM_BAR_WIDTH_CHARS * 100,
944 HISTOGRAM_BAR_WIDTH_CHARS * 1000,
945 ]),
946 plus_infinity_bucket_count: 0,
947 };
948
949 let histogram_scale = HistogramScale::new(&histogram);
950
951 let mut output = String::new();
952
953 histogram_scale.write_bar(0, &mut output).unwrap();
954 assert_eq!(output, "");
955 output.clear();
956
957 histogram_scale
958 .write_bar(histogram_scale.count_per_char.get(), &mut output)
959 .unwrap();
960 assert_eq!(output, "∎");
961 output.clear();
962
963 histogram_scale
964 .write_bar(HISTOGRAM_BAR_WIDTH_CHARS * 1000, &mut output)
965 .unwrap();
966 assert_eq!(
967 output,
968 "∎".repeat(usize::try_from(HISTOGRAM_BAR_WIDTH_CHARS).expect("safe range, tiny value"))
969 );
970 }
971
972 #[test]
973 fn histogram_scale_large_inexact() {
974 let histogram = Histogram {
977 magnitudes: &[1, 2, 3],
978 counts: Box::new([
979 79,
980 HISTOGRAM_BAR_WIDTH_CHARS * 100,
981 HISTOGRAM_BAR_WIDTH_CHARS * 1000,
982 ]),
983 plus_infinity_bucket_count: 0,
984 };
985
986 let histogram_scale = HistogramScale::new(&histogram);
987
988 let mut output = String::new();
989 histogram_scale.write_bar(3, &mut output).unwrap();
990
991 let mut output = String::new();
992
993 histogram_scale.write_bar(0, &mut output).unwrap();
994 assert_eq!(output, "");
995 output.clear();
996
997 histogram_scale
998 .write_bar(histogram_scale.count_per_char.get() - 1, &mut output)
999 .unwrap();
1000 assert_eq!(output, "");
1001 output.clear();
1002
1003 histogram_scale
1004 .write_bar(histogram_scale.count_per_char.get(), &mut output)
1005 .unwrap();
1006 assert_eq!(output, "∎");
1007 output.clear();
1008
1009 histogram_scale
1010 .write_bar(HISTOGRAM_BAR_WIDTH_CHARS * 1000 - 1, &mut output)
1011 .unwrap();
1012 assert_eq!(
1013 output,
1014 "∎".repeat(
1015 usize::try_from(HISTOGRAM_BAR_WIDTH_CHARS).expect("safe range, tiny value") - 1
1016 )
1017 );
1018 }
1019
1020 #[test]
1021 fn histogram_char_byte_count_is_correct() {
1022 assert_eq!("∎".len(), BYTES_PER_HISTOGRAM_BAR_CHAR.get());
1024
1025 let expected_chars = HISTOGRAM_BAR_CHARS.chars().count();
1027 let expected_bytes = expected_chars * BYTES_PER_HISTOGRAM_BAR_CHAR.get();
1028 assert_eq!(HISTOGRAM_BAR_CHARS.len(), expected_bytes);
1029 }
1030}