nm/
reports.rs

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