1use std::fmt::{self, Display, Write};
2use std::num::NonZero;
3use std::{cmp, iter};
4
5use foldhash::{HashMap, HashMapExt};
6
7use crate::{EventName, GLOBAL_REGISTRY, Magnitude, ObservationBagSnapshot, Observations};
8
9#[derive(Debug)]
16pub struct Report {
17 events: Box<[EventMetrics]>,
19}
20
21impl Report {
22 #[must_use]
46 pub fn collect() -> Self {
47 let mut event_name_to_merged_snapshot = HashMap::new();
49
50 GLOBAL_REGISTRY.inspect(|observation_bags| {
51 for (event_name, observation_bag) in observation_bags {
52 let snapshot = observation_bag.snapshot();
53
54 event_name_to_merged_snapshot
56 .entry(event_name.clone())
57 .and_modify(|existing_snapshot: &mut ObservationBagSnapshot| {
58 existing_snapshot.merge_from(&snapshot);
59 })
60 .or_insert(snapshot);
61 }
62 });
63
64 let mut events = event_name_to_merged_snapshot
66 .into_iter()
67 .map(|(event_name, snapshot)| EventMetrics::new(event_name, snapshot))
68 .collect::<Vec<_>>();
69
70 events.sort_by_key(|event_metrics| event_metrics.name().clone());
72
73 Self {
74 events: events.into_boxed_slice(),
75 }
76 }
77
78 pub fn events(&self) -> impl Iterator<Item = &EventMetrics> {
101 self.events.iter()
102 }
103}
104
105impl Display for Report {
106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107 for event in &self.events {
108 writeln!(f, "{event}")?;
109 }
110
111 Ok(())
112 }
113}
114
115#[derive(Debug)]
119pub struct EventMetrics {
120 name: EventName,
121
122 count: u64,
123 sum: Magnitude,
124
125 mean: Magnitude,
127
128 histogram: Option<Histogram>,
130}
131
132impl EventMetrics {
133 pub(crate) fn new(name: EventName, snapshot: ObservationBagSnapshot) -> Self {
134 let count = snapshot.count;
135 let sum = snapshot.sum;
136
137 #[expect(
138 clippy::arithmetic_side_effects,
139 reason = "NonZero protects against division by zero"
140 )]
141 #[expect(
142 clippy::integer_division,
143 reason = "we accept that we lose the remainder - 100% precision not required"
144 )]
145 let mean = Magnitude::try_from(count)
146 .ok()
147 .and_then(NonZero::new)
148 .map_or(0, |count| sum / count.get());
149
150 let histogram = if snapshot.bucket_magnitudes.is_empty() {
151 None
152 } else {
153 let plus_infinity_bucket_count = snapshot
156 .count
157 .saturating_sub(snapshot.bucket_counts.iter().sum::<u64>());
158
159 Some(Histogram {
160 magnitudes: snapshot.bucket_magnitudes,
161 counts: snapshot.bucket_counts,
162 plus_infinity_bucket_count,
163 })
164 };
165
166 Self {
167 name,
168 count,
169 sum,
170 mean,
171 histogram,
172 }
173 }
174
175 #[must_use]
196 pub fn name(&self) -> &EventName {
197 &self.name
198 }
199
200 #[must_use]
222 pub fn count(&self) -> u64 {
223 self.count
224 }
225
226 #[must_use]
248 pub fn sum(&self) -> Magnitude {
249 self.sum
250 }
251
252 #[must_use]
276 pub fn mean(&self) -> Magnitude {
277 self.mean
278 }
279
280 #[must_use]
313 pub fn histogram(&self) -> Option<&Histogram> {
314 self.histogram.as_ref()
315 }
316}
317
318impl Display for EventMetrics {
319 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
320 write!(f, "{}: ", self.name)?;
321
322 if self.count == 0 {
323 writeln!(f, "0")?;
325 return Ok(());
326 }
327
328 #[expect(
329 clippy::cast_possible_wrap,
330 reason = "intentional wrap - crate policy is that values out of safe range may be mangled"
331 )]
332 let count_as_magnitude = self.count as Magnitude;
333
334 if count_as_magnitude == self.sum && self.histogram.is_none() {
335 writeln!(f, "{} (counter)", self.count)?;
342 } else {
343 writeln!(f, "{}; sum {}; mean {}", self.count, self.sum, self.mean)?;
344 }
345
346 if let Some(histogram) = &self.histogram {
348 writeln!(f, "{histogram}")?;
349 }
350
351 Ok(())
352 }
353}
354
355#[derive(Debug)]
360pub struct Histogram {
361 magnitudes: &'static [Magnitude],
366
367 counts: Box<[u64]>,
368
369 plus_infinity_bucket_count: u64,
372}
373
374impl Histogram {
375 pub fn magnitudes(&self) -> impl Iterator<Item = Magnitude> {
384 self.magnitudes
385 .iter()
386 .copied()
387 .chain(iter::once(Magnitude::MAX))
388 }
389
390 pub fn counts(&self) -> impl Iterator<Item = u64> {
397 self.counts
398 .iter()
399 .copied()
400 .chain(iter::once(self.plus_infinity_bucket_count))
401 }
402
403 pub fn buckets(&self) -> impl Iterator<Item = (Magnitude, u64)> {
413 self.magnitudes().zip(self.counts())
414 }
415}
416
417const HISTOGRAM_BAR_WIDTH_CHARS: u64 = 50;
423
424impl Display for Histogram {
425 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
426 let buckets = self.buckets().collect::<Vec<_>>();
427
428 let mut count_str = String::new();
432
433 let widest_count = buckets.iter().fold(0, |current, bucket| {
435 count_str.clear();
436 write!(&mut count_str, "{}", bucket.1)
437 .expect("we expect writing integer to String to be infallible");
438 cmp::max(current, count_str.len())
439 });
440
441 let mut upper_bound_str = String::new();
443
444 let widest_upper_bound = buckets.iter().fold(0, |current, bucket| {
446 upper_bound_str.clear();
447
448 if bucket.0 == Magnitude::MAX {
449 write!(&mut upper_bound_str, "+inf")
451 .expect("we expect writing integer to String to be infallible");
452 } else {
453 write!(&mut upper_bound_str, "{}", bucket.0)
454 .expect("we expect writing integer to String to be infallible");
455 }
456
457 cmp::max(current, upper_bound_str.len())
458 });
459
460 let histogram_scale = HistogramScale::new(self);
461
462 for (magnitude, count) in buckets {
463 upper_bound_str.clear();
464
465 if magnitude == Magnitude::MAX {
466 write!(&mut upper_bound_str, "+inf")?;
468 } else {
469 write!(&mut upper_bound_str, "{magnitude}")?;
471 }
472
473 let padding_needed = widest_upper_bound.saturating_sub(upper_bound_str.len());
474 for _ in 0..padding_needed {
475 upper_bound_str.insert(0, ' ');
476 }
477
478 count_str.clear();
479 write!(&mut count_str, "{count}")?;
480
481 let padding_needed = widest_count.saturating_sub(count_str.len());
482 for _ in 0..padding_needed {
483 count_str.insert(0, ' ');
484 }
485
486 write!(f, "value <= {upper_bound_str} [ {count_str} ]: ")?;
487 histogram_scale.write_bar(count, f)?;
488
489 writeln!(f)?;
490 }
491
492 Ok(())
493 }
494}
495
496#[derive(Debug)]
498struct HistogramScale {
499 count_per_char: u64,
502}
503
504impl HistogramScale {
505 fn new(snapshot: &Histogram) -> Self {
506 let max_count = snapshot
507 .counts()
508 .max()
509 .expect("a histogram always has at least one bucket by definition (+inf)");
510
511 #[expect(
516 clippy::integer_division,
517 reason = "we accept the loss of precision here - the bar might not always reach 100% of desired width"
518 )]
519 let count_per_char = cmp::max(max_count / HISTOGRAM_BAR_WIDTH_CHARS, 1);
520
521 Self { count_per_char }
522 }
523
524 fn write_bar(&self, count: u64, f: &mut impl Write) -> fmt::Result {
525 #[expect(
526 clippy::arithmetic_side_effects,
527 reason = "guarded by count_per_char being max'ed to at least 1 above"
528 )]
529 #[expect(
530 clippy::integer_division,
531 reason = "intentional loss of precision due to rounding down"
532 )]
533 let histogram_bar_width = count / self.count_per_char;
534
535 assert!(histogram_bar_width <= HISTOGRAM_BAR_WIDTH_CHARS);
537
538 for _ in 0..histogram_bar_width {
539 f.write_char('∎')?;
540 }
541
542 Ok(())
543 }
544}
545
546#[cfg(test)]
547mod tests {
548 #![allow(clippy::indexing_slicing, reason = "panic is fine in tests")]
549
550 use super::*;
551
552 #[test]
553 fn histogram_properties_reflect_reality() {
554 let magnitudes = &[-5, 1, 10, 100];
555 let counts = &[66, 5, 3, 2];
556
557 let histogram = Histogram {
558 magnitudes,
559 counts: Vec::from(counts).into_boxed_slice(),
560 plus_infinity_bucket_count: 1,
561 };
562
563 assert_eq!(
564 histogram.magnitudes().collect::<Vec<_>>(),
565 magnitudes
566 .iter()
567 .copied()
568 .chain(iter::once(Magnitude::MAX))
569 .collect::<Vec<_>>()
570 );
571 assert_eq!(
572 histogram.counts().collect::<Vec<_>>(),
573 counts
574 .iter()
575 .copied()
576 .chain(iter::once(1))
577 .collect::<Vec<_>>()
578 );
579
580 let buckets: Vec<_> = histogram.buckets().collect();
581 assert_eq!(buckets.len(), 5);
582 assert_eq!(buckets[0], (-5, 66));
583 assert_eq!(buckets[1], (1, 5));
584 assert_eq!(buckets[2], (10, 3));
585 assert_eq!(buckets[3], (100, 2));
586 assert_eq!(buckets[4], (Magnitude::MAX, 1));
587 }
588
589 #[test]
590 fn histogram_display_contains_expected_information() {
591 let magnitudes = &[-5, 1, 10, 100];
592 let counts = &[666666, 5, 3, 2];
593
594 let histogram = Histogram {
595 magnitudes,
596 counts: Vec::from(counts).into_boxed_slice(),
597 plus_infinity_bucket_count: 1,
598 };
599
600 let mut output = String::new();
601 write!(&mut output, "{histogram}").unwrap();
602
603 println!("{output}");
604
605 assert!(output.contains("value <= -5 [ 666666 ]: "));
609 assert!(output.contains("value <= 1 [ 5 ]: "));
610 assert!(output.contains("value <= 10 [ 3 ]: "));
611 assert!(output.contains("value <= 100 [ 2 ]: "));
612 assert!(output.contains("value <= +inf [ 1 ]: "));
613
614 #[expect(clippy::cast_possible_truncation, reason = "safe range, tiny values")]
620 let max_acceptable_line_length = (HISTOGRAM_BAR_WIDTH_CHARS * 5) as usize;
621
622 for line in output.lines() {
623 assert!(
624 line.len() < max_acceptable_line_length,
625 "line is too long: {line}"
626 );
627 }
628 }
629
630 #[test]
631 fn event_properties_reflect_reality() {
632 let event_name = "test_event".to_string();
633 let count = 50;
634 let sum = Magnitude::from(1000);
635 let mean = Magnitude::from(20);
636
637 let histogram = Histogram {
638 magnitudes: &[1, 10, 100],
639 counts: vec![5, 3, 2].into_boxed_slice(),
640 plus_infinity_bucket_count: 1,
641 };
642
643 let event_metrics = EventMetrics {
644 name: event_name.clone().into(),
645 count,
646 sum,
647 mean,
648 histogram: Some(histogram),
649 };
650
651 assert_eq!(event_metrics.name(), &event_name);
652 assert_eq!(event_metrics.count(), count);
653 assert_eq!(event_metrics.sum(), sum);
654 assert_eq!(event_metrics.mean(), mean);
655 assert!(event_metrics.histogram().is_some());
656 }
657
658 #[test]
659 fn event_display_contains_expected_information() {
660 let event_name = "test_event".to_string();
661 let count = 50;
662 let sum = Magnitude::from(1000);
663 let mean = Magnitude::from(20);
664
665 let histogram = Histogram {
666 magnitudes: &[1, 10, 100],
667 counts: vec![5, 3, 2].into_boxed_slice(),
668 plus_infinity_bucket_count: 1,
669 };
670
671 let event_metrics = EventMetrics {
672 name: event_name.clone().into(),
673 count,
674 sum,
675 mean,
676 histogram: Some(histogram),
677 };
678
679 let mut output = String::new();
680 write!(&mut output, "{event_metrics}").unwrap();
681
682 println!("{output}");
683
684 assert!(output.contains(&event_name));
687 assert!(output.contains(&count.to_string()));
688 assert!(output.contains(&sum.to_string()));
689 assert!(output.contains(&mean.to_string()));
690 assert!(output.contains("value <= +inf [ 1 ]: "));
691 }
692
693 #[test]
694 fn report_properties_reflect_reality() {
695 let event1 = EventMetrics {
696 name: "event1".to_string().into(),
697 count: 10,
698 sum: Magnitude::from(100),
699 mean: Magnitude::from(10),
700 histogram: None,
701 };
702
703 let event2 = EventMetrics {
704 name: "event2".to_string().into(),
705 count: 5,
706 sum: Magnitude::from(50),
707 mean: Magnitude::from(10),
708 histogram: None,
709 };
710
711 let report = Report {
712 events: vec![event1, event2].into_boxed_slice(),
713 };
714
715 let events = report.events().collect::<Vec<_>>();
717
718 assert_eq!(events.len(), 2);
719 assert_eq!(events[0].name(), "event1");
720 assert_eq!(events[1].name(), "event2");
721 }
722
723 #[test]
724 fn report_display_contains_expected_events() {
725 let event1 = EventMetrics {
726 name: "event1".to_string().into(),
727 count: 10,
728 sum: Magnitude::from(100),
729 mean: Magnitude::from(10),
730 histogram: None,
731 };
732
733 let event2 = EventMetrics {
734 name: "event2".to_string().into(),
735 count: 5,
736 sum: Magnitude::from(50),
737 mean: Magnitude::from(10),
738 histogram: None,
739 };
740
741 let report = Report {
742 events: vec![event1, event2].into_boxed_slice(),
743 };
744
745 let mut output = String::new();
746 write!(&mut output, "{report}").unwrap();
747
748 println!("{output}");
749
750 assert!(output.contains("event1"));
752 assert!(output.contains("event2"));
753 }
754
755 #[test]
756 fn event_displayed_as_counter_if_unit_values_and_no_histogram() {
757 let counter = EventMetrics {
762 name: "test_event".to_string().into(),
763 count: 100,
764 sum: Magnitude::from(100),
765 mean: Magnitude::from(1),
766 histogram: None,
767 };
768
769 let not_counter = EventMetrics {
771 name: "test_event".to_string().into(),
772 count: 100,
773 sum: Magnitude::from(200),
774 mean: Magnitude::from(2),
775 histogram: None,
776 };
777
778 let also_not_counter = EventMetrics {
780 name: "test_event".to_string().into(),
781 count: 100,
782 sum: Magnitude::from(100),
783 mean: Magnitude::from(1),
784 histogram: Some(Histogram {
785 magnitudes: &[],
786 counts: Box::new([]),
787 plus_infinity_bucket_count: 100,
788 }),
789 };
790
791 let still_not_counter = EventMetrics {
793 name: "test_event".to_string().into(),
794 count: 100,
795 sum: Magnitude::from(200),
796 mean: Magnitude::from(2),
797 histogram: Some(Histogram {
798 magnitudes: &[],
799 counts: Box::new([]),
800 plus_infinity_bucket_count: 200,
801 }),
802 };
803
804 let mut output = String::new();
805
806 write!(&mut output, "{counter}").unwrap();
807 assert!(output.contains("100 (counter)"));
808 output.clear();
809
810 write!(&mut output, "{not_counter}").unwrap();
811 assert!(output.contains("100; sum 200; mean 2"));
812 output.clear();
813
814 write!(&mut output, "{also_not_counter}").unwrap();
815 assert!(output.contains("100; sum 100; mean 1"));
816 output.clear();
817
818 write!(&mut output, "{still_not_counter}").unwrap();
819 assert!(output.contains("100; sum 200; mean 2"));
820 }
821
822 #[test]
823 fn histogram_scale_zero() {
824 let histogram = Histogram {
826 magnitudes: &[1, 2, 3],
827 counts: Box::new([0, 0, 0]),
828 plus_infinity_bucket_count: 0,
829 };
830
831 let histogram_scale = HistogramScale::new(&histogram);
832
833 let mut output = String::new();
834 histogram_scale.write_bar(0, &mut output).unwrap();
835
836 assert_eq!(output, "");
837 }
838
839 #[test]
840 fn histogram_scale_small() {
841 let histogram = Histogram {
843 magnitudes: &[1, 2, 3],
844 counts: Box::new([1, 2, 3]),
845 plus_infinity_bucket_count: 0,
846 };
847
848 let histogram_scale = HistogramScale::new(&histogram);
849
850 let mut output = String::new();
851
852 histogram_scale.write_bar(0, &mut output).unwrap();
853 assert_eq!(output, "");
854 output.clear();
855
856 histogram_scale.write_bar(1, &mut output).unwrap();
857 assert_eq!(output, "∎");
858 output.clear();
859
860 histogram_scale.write_bar(2, &mut output).unwrap();
861 assert_eq!(output, "∎∎");
862 output.clear();
863
864 histogram_scale.write_bar(3, &mut output).unwrap();
865 assert_eq!(output, "∎∎∎");
866 }
867
868 #[test]
869 fn histogram_scale_large_exact() {
870 let histogram = Histogram {
873 magnitudes: &[1, 2, 3],
874 counts: Box::new([
875 79,
876 HISTOGRAM_BAR_WIDTH_CHARS * 100,
877 HISTOGRAM_BAR_WIDTH_CHARS * 1000,
878 ]),
879 plus_infinity_bucket_count: 0,
880 };
881
882 let histogram_scale = HistogramScale::new(&histogram);
883
884 let mut output = String::new();
885
886 histogram_scale.write_bar(0, &mut output).unwrap();
887 assert_eq!(output, "");
888 output.clear();
889
890 histogram_scale
891 .write_bar(histogram_scale.count_per_char, &mut output)
892 .unwrap();
893 assert_eq!(output, "∎");
894 output.clear();
895
896 histogram_scale
897 .write_bar(HISTOGRAM_BAR_WIDTH_CHARS * 1000, &mut output)
898 .unwrap();
899 assert_eq!(
900 output,
901 "∎".repeat(usize::try_from(HISTOGRAM_BAR_WIDTH_CHARS).expect("safe range, tiny value"))
902 );
903 }
904
905 #[test]
906 fn histogram_scale_large_inexact() {
907 let histogram = Histogram {
910 magnitudes: &[1, 2, 3],
911 counts: Box::new([
912 79,
913 HISTOGRAM_BAR_WIDTH_CHARS * 100,
914 HISTOGRAM_BAR_WIDTH_CHARS * 1000,
915 ]),
916 plus_infinity_bucket_count: 0,
917 };
918
919 let histogram_scale = HistogramScale::new(&histogram);
920
921 let mut output = String::new();
922 histogram_scale.write_bar(3, &mut output).unwrap();
923
924 let mut output = String::new();
925
926 histogram_scale.write_bar(0, &mut output).unwrap();
927 assert_eq!(output, "");
928 output.clear();
929
930 histogram_scale
931 .write_bar(histogram_scale.count_per_char - 1, &mut output)
932 .unwrap();
933 assert_eq!(output, "");
934 output.clear();
935
936 histogram_scale
937 .write_bar(histogram_scale.count_per_char, &mut output)
938 .unwrap();
939 assert_eq!(output, "∎");
940 output.clear();
941
942 histogram_scale
943 .write_bar(HISTOGRAM_BAR_WIDTH_CHARS * 1000 - 1, &mut output)
944 .unwrap();
945 assert_eq!(
946 output,
947 "∎".repeat(
948 usize::try_from(HISTOGRAM_BAR_WIDTH_CHARS).expect("safe range, tiny value") - 1
949 )
950 );
951 }
952}