nm/
reports.rs

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/// A human- and machine-readable report about observed occurrences of events.
10///
11/// For human-readable output, use the `Display` trait implementation. This is intended
12/// for writing to a terminal and uses only the basic ASCII character set.
13///
14/// For machine-readable output, inspect report contents via the provided methods.
15#[derive(Debug)]
16pub struct Report {
17    // Sorted by event name, ascending.
18    events: Box<[EventMetrics]>,
19}
20
21impl Report {
22    /// Generates a report by collecting all metrics for all events.
23    ///
24    /// # Example
25    ///
26    /// ```
27    /// use nm::{Event, Report};
28    ///
29    /// thread_local! {
30    ///     static TEST_EVENT: Event = Event::builder()
31    ///         .name("test_event")
32    ///         .build();
33    /// }
34    ///
35    /// // Observe some events first
36    /// TEST_EVENT.with(|e| e.observe_once());
37    ///
38    /// let report = Report::collect();
39    /// println!("{}", report);
40    /// ```
41    ///
42    /// # Panics
43    ///
44    /// Panics if the same event is registered on different threads with a different configuration.
45    #[must_use]
46    pub fn collect() -> Self {
47        // We must first collect all observations from all threads and merge them per-event.
48        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                // Merge the snapshot into the existing one for this event name.
55                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        // Now that we have the data set, we can form the report.
65        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        // Sort the events by name.
71        events.sort_by_key(|event_metrics| event_metrics.name().clone());
72
73        Self {
74            events: events.into_boxed_slice(),
75        }
76    }
77
78    /// Iterates through all the events in the report, allowing access to their metrics.
79    ///
80    /// # Example
81    ///
82    /// ```
83    /// use nm::{Event, Report};
84    ///
85    /// thread_local! {
86    ///     static TEST_EVENT: Event = Event::builder()
87    ///         .name("test_event")
88    ///         .build();
89    /// }
90    ///
91    /// // Observe some events first
92    /// TEST_EVENT.with(|e| e.observe_once());
93    ///
94    /// let report = Report::collect();
95    ///
96    /// for event in report.events() {
97    ///     println!("Event: {}, Count: {}", event.name(), event.count());
98    /// }
99    /// ```
100    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/// A human- and machine-readable report about observed occurrences of a single event.
116///
117/// Part of a collected [`Report`],
118#[derive(Debug)]
119pub struct EventMetrics {
120    name: EventName,
121
122    count: u64,
123    sum: Magnitude,
124
125    // 0 if there are no observations.
126    mean: Magnitude,
127
128    // None if the event was not configured to generate a histogram.
129    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            // We now need to synthesize the `Magnitude::MAX` bucket for the histogram.
154            // This is just "whatever is left after the configured buckets".
155            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    /// The name of the event associated with these metrics.
176    ///
177    /// # Example
178    ///
179    /// ```
180    /// use nm::{Event, Report};
181    ///
182    /// thread_local! {
183    ///     static HTTP_REQUESTS: Event = Event::builder()
184    ///         .name("http_requests")
185    ///         .build();
186    /// }
187    ///
188    /// HTTP_REQUESTS.with(|e| e.observe_once());
189    /// let report = Report::collect();
190    ///
191    /// for event in report.events() {
192    ///     println!("Event name: {}", event.name());
193    /// }
194    /// ```
195    #[must_use]
196    pub fn name(&self) -> &EventName {
197        &self.name
198    }
199
200    /// Total number of occurrences that have been observed.
201    ///
202    /// # Example
203    ///
204    /// ```
205    /// use nm::{Event, Report};
206    ///
207    /// thread_local! {
208    ///     static HTTP_REQUESTS: Event = Event::builder()
209    ///         .name("http_requests")
210    ///         .build();
211    /// }
212    ///
213    /// HTTP_REQUESTS.with(|e| e.observe_once());
214    /// HTTP_REQUESTS.with(|e| e.observe_once());
215    /// let report = Report::collect();
216    ///
217    /// for event in report.events() {
218    ///     println!("Total count: {}", event.count());
219    /// }
220    /// ```
221    #[must_use]
222    pub fn count(&self) -> u64 {
223        self.count
224    }
225
226    /// Sum of the magnitudes of all observed occurrences.
227    ///
228    /// # Example
229    ///
230    /// ```
231    /// use nm::{Event, Report};
232    ///
233    /// thread_local! {
234    ///     static SENT_BYTES: Event = Event::builder()
235    ///         .name("sent_bytes")
236    ///         .build();
237    /// }
238    ///
239    /// SENT_BYTES.with(|e| e.observe(1024));
240    /// SENT_BYTES.with(|e| e.observe(2048));
241    /// let report = Report::collect();
242    ///
243    /// for event in report.events() {
244    ///     println!("Total bytes: {}", event.sum());
245    /// }
246    /// ```
247    #[must_use]
248    pub fn sum(&self) -> Magnitude {
249        self.sum
250    }
251
252    /// Mean magnitude of all observed occurrences.
253    ///
254    /// If there are no observations, this will be zero.
255    ///
256    /// # Example
257    ///
258    /// ```
259    /// use nm::{Event, Report};
260    ///
261    /// thread_local! {
262    ///     static RESPONSE_TIME: Event = Event::builder()
263    ///         .name("response_time_ms")
264    ///         .build();
265    /// }
266    ///
267    /// RESPONSE_TIME.with(|e| e.observe(100));
268    /// RESPONSE_TIME.with(|e| e.observe(200));
269    /// let report = Report::collect();
270    ///
271    /// for event in report.events() {
272    ///     println!("Average response time: {}ms", event.mean());
273    /// }
274    /// ```
275    #[must_use]
276    pub fn mean(&self) -> Magnitude {
277        self.mean
278    }
279
280    /// The histogram of observed magnitudes (if configured).
281    ///
282    /// `None` if the event [was not configured to generate a histogram][1].
283    ///
284    /// # Example
285    ///
286    /// ```
287    /// use nm::{Event, Magnitude, Report};
288    ///
289    /// const RESPONSE_TIME_BUCKETS_MS: &[Magnitude] = &[10, 50, 100, 500];
290    ///
291    /// thread_local! {
292    ///     static HTTP_RESPONSE_TIME_MS: Event = Event::builder()
293    ///         .name("http_response_time_ms")
294    ///         .histogram(RESPONSE_TIME_BUCKETS_MS)
295    ///         .build();
296    /// }
297    ///
298    /// HTTP_RESPONSE_TIME_MS.with(|e| e.observe(75));
299    /// let report = Report::collect();
300    ///
301    /// for event in report.events() {
302    ///     if let Some(histogram) = event.histogram() {
303    ///         println!("Histogram for {}", event.name());
304    ///         for (bucket_upper_bound, count) in histogram.buckets() {
305    ///             println!("  ≤{}: {}", bucket_upper_bound, count);
306    ///         }
307    ///     }
308    /// }
309    /// ```
310    ///
311    /// [1]: crate::EventBuilder::histogram
312    #[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            // If there is no recorded data, we just report a flat zero no questions asked.
324            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            // If we observe that only magnitude 1 events were recorded and there are no buckets,
336            // we treat this event as a bare counter and only emit the count.
337            //
338            // This is a heuristic: we might be wrong (e.g. 0 + 2 looks like 1 + 1) but given that
339            // this is a display for manual reading, we can afford to be wrong in some cases if it
340            // makes the typical case more readable.
341            writeln!(f, "{} (counter)", self.count)?;
342        } else {
343            writeln!(f, "{}; sum {}; mean {}", self.count, self.sum, self.mean)?;
344        }
345
346        // If there is no histogram to report (because there are no buckets defined), we are done.
347        if let Some(histogram) = &self.histogram {
348            writeln!(f, "{histogram}")?;
349        }
350
351        Ok(())
352    }
353}
354
355/// A histogram of observed event magnitudes.
356///
357/// A collected [`Report`] will contain a histogram
358/// for each event that was configured to generate one.
359#[derive(Debug)]
360pub struct Histogram {
361    /// Sorted, ascending.
362    ///
363    /// When iterating buckets, we always append a synthetic `Magnitude::MAX` bucket.
364    /// This is never included in the original magnitudes, always synthetic.
365    magnitudes: &'static [Magnitude],
366
367    counts: Box<[u64]>,
368
369    /// Occurrences that did not fit into any of the buckets.
370    /// We map these to a synthetic bucket with `Magnitude::MAX`.
371    plus_infinity_bucket_count: u64,
372}
373
374impl Histogram {
375    /// Iterates over the magnitudes of the histogram buckets, in ascending order.
376    ///
377    /// Each bucket counts the number of events that are less than or equal to the corresponding
378    /// magnitude. Each occurrence of an event is counted only once, in the first bucket that can
379    /// accept it.
380    ///
381    /// The last bucket always has the magnitude `Magnitude::MAX`, counting
382    /// occurrences that do not fit into any of the previous buckets.
383    pub fn magnitudes(&self) -> impl Iterator<Item = Magnitude> {
384        self.magnitudes
385            .iter()
386            .copied()
387            .chain(iter::once(Magnitude::MAX))
388    }
389
390    /// Iterates over the count of occurrences in each bucket,
391    /// including the last `Magnitude::MAX` bucket.
392    ///
393    /// Each bucket counts the number of events that are less than or equal to the corresponding
394    /// magnitude. Each occurrence of an event is counted only once, in the first bucket that can
395    /// accept it.
396    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    /// Iterates over the histogram buckets as `(magnitude, count)` pairs,
404    /// in ascending order of magnitudes.
405    ///
406    /// Each bucket counts the number of events that are less than or equal to the corresponding
407    /// magnitude. Each occurrence of an event is counted only once, in the first bucket that can
408    /// accept it.
409    ///
410    /// The last bucket always has the magnitude `Magnitude::MAX`, counting
411    /// occurrences that do not fit into any of the previous buckets.
412    pub fn buckets(&self) -> impl Iterator<Item = (Magnitude, u64)> {
413        self.magnitudes().zip(self.counts())
414    }
415}
416
417/// We auto-scale histogram bars when rendering the report. This is the number of characters
418/// that we use to represent the maximum bucket value in the histogram.
419///
420/// Histograms may be smaller than this, as well, because one character will never represent
421/// less than one event (so if the max value is 3, the histogram render will be 3 characters wide).
422const 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        // We measure the dynamic parts of the string to know how much padding to add.
429
430        // We write the observation counts here (both in measurement phase and when rendering).
431        let mut count_str = String::new();
432
433        // What is the widest event count string for any bucket? Affects padding.
434        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        // We write the bucket upper bounds here (both in measurement phase and when rendering).
442        let mut upper_bound_str = String::new();
443
444        // What is the widest upper bound string for any bucket? Affects padding.
445        let widest_upper_bound = buckets.iter().fold(0, |current, bucket| {
446            upper_bound_str.clear();
447
448            if bucket.0 == Magnitude::MAX {
449                // We use "+inf" for the upper bound of the last bucket.
450                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                // We use "+inf" for the upper bound of the last bucket.
467                write!(&mut upper_bound_str, "+inf")?;
468            } else {
469                // Otherwise, we write the magnitude as is.
470                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/// Represent the auto-scaling logic of the histogram bars, identifying the step size for rendering.
497#[derive(Debug)]
498struct HistogramScale {
499    /// The number of events that each character in the histogram bar represents.
500    /// One character is rendered for each `count_per_char` events (rounded down).
501    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        // Each character in the histogram bar represents this many events for auto-scaling
512        // purposes. We use integers, so this can suffer from aliasing effects if there are
513        // not many events. That's fine - the relative sizes will still be fine and the numbers
514        // will give the ground truth even if the rendering is not perfect.
515        #[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        // Sanity check.
536        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        // We expect each bucket to be displayed, except the MAX one should say "+inf"
606        // instead of the actual value (because the numeric value is too big for good UX).
607        // We check for the specific format we expect to see here (change test if we change format).
608        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        // We do not want to reproduce the auto-scaling logic here, so let's just ensure that
615        // the lines are not hilariously long (e.g. 66666 chars), as a basic sanity check.
616        // NB! Recall that String::len() counts BYTES and that the "boxes" we draw are non-ASCII
617        // characters that take up more than one byte each! So we leave some extra room with a
618        // x5 multiplier to give it some leeway - we just want to detect insane line lengths.
619        #[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        // We expect the output to contain the event name and the metrics.
685        // We do not prescribe the exact format here, as it is not so critical.
686        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        // This is very boring because the Report type is very boring.
716        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        // We expect the output to contain both events.
751        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        // The Display output should be heuristically detected as a "counter"
758        // and undergo simplified printing if the sum equals the count and if
759        // there is no histogram to report.
760
761        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        // sum != count - cannot be a counter.
770        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        // Has a histogram - cannot be a counter.
779        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        // Neither condition is a match.
792        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        // Everything is zero, so we render zero bar segments.
825        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        // All the buckets have small values, so we do not reach 100% bar width.
842        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        // The scale is large enough that we render long segments.
871        // The numbers divide just right so the bar reaches the desired width.
872        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        // The scale is large enough that we render long segments.
908        // The numbers divide with a remainder, so we do not reach 100% width.
909        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}