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    /// Creates a `Report` instance with fake data for testing purposes.
106    ///
107    /// This constructor is only available with the `test-util` feature and allows
108    /// creating arbitrary test data without touching global state.
109    #[cfg(any(test, feature = "test-util"))]
110    #[must_use]
111    pub fn fake(events: Vec<EventMetrics>) -> Self {
112        Self {
113            events: events.into_boxed_slice(),
114        }
115    }
116}
117
118impl Display for Report {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        for event in &self.events {
121            writeln!(f, "{event}")?;
122        }
123
124        Ok(())
125    }
126}
127
128/// A human- and machine-readable report about observed occurrences of a single event.
129///
130/// Part of a collected [`Report`],
131#[derive(Debug)]
132pub struct EventMetrics {
133    name: EventName,
134
135    count: u64,
136    sum: Magnitude,
137
138    // 0 if there are no observations.
139    mean: Magnitude,
140
141    // None if the event was not configured to generate a histogram.
142    histogram: Option<Histogram>,
143}
144
145impl EventMetrics {
146    pub(crate) fn new(name: EventName, snapshot: ObservationBagSnapshot) -> Self {
147        let count = snapshot.count;
148        let sum = snapshot.sum;
149
150        #[expect(
151            clippy::arithmetic_side_effects,
152            reason = "NonZero protects against division by zero"
153        )]
154        #[expect(
155            clippy::integer_division,
156            reason = "we accept that we lose the remainder - 100% precision not required"
157        )]
158        let mean = Magnitude::try_from(count)
159            .ok()
160            .and_then(NonZero::new)
161            .map_or(0, |count| sum / count.get());
162
163        let histogram = if snapshot.bucket_magnitudes.is_empty() {
164            None
165        } else {
166            // We now need to synthesize the `Magnitude::MAX` bucket for the histogram.
167            // This is just "whatever is left after the configured buckets".
168            let plus_infinity_bucket_count = snapshot
169                .count
170                .saturating_sub(snapshot.bucket_counts.iter().sum::<u64>());
171
172            Some(Histogram {
173                magnitudes: snapshot.bucket_magnitudes,
174                counts: snapshot.bucket_counts,
175                plus_infinity_bucket_count,
176            })
177        };
178
179        Self {
180            name,
181            count,
182            sum,
183            mean,
184            histogram,
185        }
186    }
187
188    /// The name of the event associated with these metrics.
189    ///
190    /// # Example
191    ///
192    /// ```
193    /// use nm::{Event, Report};
194    ///
195    /// thread_local! {
196    ///     static HTTP_REQUESTS: Event = Event::builder()
197    ///         .name("http_requests")
198    ///         .build();
199    /// }
200    ///
201    /// HTTP_REQUESTS.with(|e| e.observe_once());
202    /// let report = Report::collect();
203    ///
204    /// for event in report.events() {
205    ///     println!("Event name: {}", event.name());
206    /// }
207    /// ```
208    #[must_use]
209    pub fn name(&self) -> &EventName {
210        &self.name
211    }
212
213    /// Total number of occurrences that have been observed.
214    ///
215    /// # Example
216    ///
217    /// ```
218    /// use nm::{Event, Report};
219    ///
220    /// thread_local! {
221    ///     static HTTP_REQUESTS: Event = Event::builder()
222    ///         .name("http_requests")
223    ///         .build();
224    /// }
225    ///
226    /// HTTP_REQUESTS.with(|e| e.observe_once());
227    /// HTTP_REQUESTS.with(|e| e.observe_once());
228    /// let report = Report::collect();
229    ///
230    /// for event in report.events() {
231    ///     println!("Total count: {}", event.count());
232    /// }
233    /// ```
234    #[must_use]
235    pub fn count(&self) -> u64 {
236        self.count
237    }
238
239    /// Sum of the magnitudes of all observed occurrences.
240    ///
241    /// # Example
242    ///
243    /// ```
244    /// use nm::{Event, Report};
245    ///
246    /// thread_local! {
247    ///     static SENT_BYTES: Event = Event::builder()
248    ///         .name("sent_bytes")
249    ///         .build();
250    /// }
251    ///
252    /// SENT_BYTES.with(|e| e.observe(1024));
253    /// SENT_BYTES.with(|e| e.observe(2048));
254    /// let report = Report::collect();
255    ///
256    /// for event in report.events() {
257    ///     println!("Total bytes: {}", event.sum());
258    /// }
259    /// ```
260    #[must_use]
261    pub fn sum(&self) -> Magnitude {
262        self.sum
263    }
264
265    /// Mean magnitude of all observed occurrences.
266    ///
267    /// If there are no observations, this will be zero.
268    ///
269    /// # Example
270    ///
271    /// ```
272    /// use nm::{Event, Report};
273    ///
274    /// thread_local! {
275    ///     static RESPONSE_TIME: Event = Event::builder()
276    ///         .name("response_time_ms")
277    ///         .build();
278    /// }
279    ///
280    /// RESPONSE_TIME.with(|e| e.observe(100));
281    /// RESPONSE_TIME.with(|e| e.observe(200));
282    /// let report = Report::collect();
283    ///
284    /// for event in report.events() {
285    ///     println!("Average response time: {}ms", event.mean());
286    /// }
287    /// ```
288    #[must_use]
289    pub fn mean(&self) -> Magnitude {
290        self.mean
291    }
292
293    /// The histogram of observed magnitudes (if configured).
294    ///
295    /// `None` if the event [was not configured to generate a histogram][1].
296    ///
297    /// # Example
298    ///
299    /// ```
300    /// use nm::{Event, Magnitude, Report};
301    ///
302    /// const RESPONSE_TIME_BUCKETS_MS: &[Magnitude] = &[10, 50, 100, 500];
303    ///
304    /// thread_local! {
305    ///     static HTTP_RESPONSE_TIME_MS: Event = Event::builder()
306    ///         .name("http_response_time_ms")
307    ///         .histogram(RESPONSE_TIME_BUCKETS_MS)
308    ///         .build();
309    /// }
310    ///
311    /// HTTP_RESPONSE_TIME_MS.with(|e| e.observe(75));
312    /// let report = Report::collect();
313    ///
314    /// for event in report.events() {
315    ///     if let Some(histogram) = event.histogram() {
316    ///         println!("Histogram for {}", event.name());
317    ///         for (bucket_upper_bound, count) in histogram.buckets() {
318    ///             println!("  ≤{}: {}", bucket_upper_bound, count);
319    ///         }
320    ///     }
321    /// }
322    /// ```
323    ///
324    /// [1]: crate::EventBuilder::histogram
325    #[must_use]
326    pub fn histogram(&self) -> Option<&Histogram> {
327        self.histogram.as_ref()
328    }
329
330    /// Creates an `EventMetrics` instance with fake data for testing purposes.
331    ///
332    /// This constructor is only available with the `test-util` feature and allows
333    /// creating arbitrary test data without touching global state.
334    #[cfg(any(test, feature = "test-util"))]
335    #[must_use]
336    pub fn fake(
337        name: impl Into<EventName>,
338        count: u64,
339        sum: Magnitude,
340        histogram: Option<Histogram>,
341    ) -> Self {
342        #[expect(
343            clippy::arithmetic_side_effects,
344            reason = "NonZero protects against division by zero"
345        )]
346        #[expect(
347            clippy::integer_division,
348            reason = "we accept that we lose the remainder - 100% precision not required"
349        )]
350        let mean = Magnitude::try_from(count)
351            .ok()
352            .and_then(NonZero::new)
353            .map_or(0, |count| sum / count.get());
354
355        Self {
356            name: name.into(),
357            count,
358            sum,
359            mean,
360            histogram,
361        }
362    }
363}
364
365impl Display for EventMetrics {
366    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
367        write!(f, "{}: ", self.name)?;
368
369        if self.count == 0 {
370            // If there is no recorded data, we just report a flat zero no questions asked.
371            writeln!(f, "0")?;
372            return Ok(());
373        }
374
375        #[expect(
376            clippy::cast_possible_wrap,
377            reason = "intentional wrap - crate policy is that values out of safe range may be mangled"
378        )]
379        let count_as_magnitude = self.count as Magnitude;
380
381        if count_as_magnitude == self.sum && self.histogram.is_none() {
382            // If we observe that only magnitude 1 events were recorded and there are no buckets,
383            // we treat this event as a bare counter and only emit the count.
384            //
385            // This is a heuristic: we might be wrong (e.g. 0 + 2 looks like 1 + 1) but given that
386            // this is a display for manual reading, we can afford to be wrong in some cases if it
387            // makes the typical case more readable.
388            writeln!(f, "{} (counter)", self.count)?;
389        } else {
390            writeln!(f, "{}; sum {}; mean {}", self.count, self.sum, self.mean)?;
391        }
392
393        // If there is no histogram to report (because there are no buckets defined), we are done.
394        if let Some(histogram) = &self.histogram {
395            writeln!(f, "{histogram}")?;
396        }
397
398        Ok(())
399    }
400}
401
402/// A histogram of observed event magnitudes.
403///
404/// A collected [`Report`] will contain a histogram
405/// for each event that was configured to generate one.
406#[derive(Debug)]
407pub struct Histogram {
408    /// Sorted, ascending.
409    ///
410    /// When iterating buckets, we always append a synthetic `Magnitude::MAX` bucket.
411    /// This is never included in the original magnitudes, always synthetic.
412    magnitudes: &'static [Magnitude],
413
414    counts: Box<[u64]>,
415
416    /// Occurrences that did not fit into any of the buckets.
417    /// We map these to a synthetic bucket with `Magnitude::MAX`.
418    plus_infinity_bucket_count: u64,
419}
420
421impl Histogram {
422    /// Iterates over the magnitudes of the histogram buckets, in ascending order.
423    ///
424    /// Each bucket counts the number of events that are less than or equal to the corresponding
425    /// magnitude. Each occurrence of an event is counted only once, in the first bucket that can
426    /// accept it.
427    ///
428    /// The last bucket always has the magnitude `Magnitude::MAX`, counting
429    /// occurrences that do not fit into any of the previous buckets.
430    pub fn magnitudes(&self) -> impl Iterator<Item = Magnitude> {
431        self.magnitudes
432            .iter()
433            .copied()
434            .chain(iter::once(Magnitude::MAX))
435    }
436
437    /// Iterates over the count of occurrences in each bucket,
438    /// including the last `Magnitude::MAX` bucket.
439    ///
440    /// Each bucket counts the number of events that are less than or equal to the corresponding
441    /// magnitude. Each occurrence of an event is counted only once, in the first bucket that can
442    /// accept it.
443    pub fn counts(&self) -> impl Iterator<Item = u64> {
444        self.counts
445            .iter()
446            .copied()
447            .chain(iter::once(self.plus_infinity_bucket_count))
448    }
449
450    /// Iterates over the histogram buckets as `(magnitude, count)` pairs,
451    /// in ascending order of magnitudes.
452    ///
453    /// Each bucket counts the number of events that are less than or equal to the corresponding
454    /// magnitude. Each occurrence of an event is counted only once, in the first bucket that can
455    /// accept it.
456    ///
457    /// The last bucket always has the magnitude `Magnitude::MAX`, counting
458    /// occurrences that do not fit into any of the previous buckets.
459    pub fn buckets(&self) -> impl Iterator<Item = (Magnitude, u64)> {
460        self.magnitudes().zip(self.counts())
461    }
462
463    /// Creates a `Histogram` instance with fake data for testing purposes.
464    ///
465    /// This constructor is only available with the `test-util` feature and allows
466    /// creating arbitrary test data without touching global state.
467    ///
468    /// The `magnitudes` slice must be sorted in ascending order. The `counts` slice
469    /// must have the same length as `magnitudes`. The `plus_infinity_count` is the
470    /// count for the synthetic `Magnitude::MAX` bucket.
471    #[cfg(any(test, feature = "test-util"))]
472    #[must_use]
473    pub fn fake(
474        magnitudes: &'static [Magnitude],
475        counts: Vec<u64>,
476        plus_infinity_count: u64,
477    ) -> Self {
478        Self {
479            magnitudes,
480            counts: counts.into_boxed_slice(),
481            plus_infinity_bucket_count: plus_infinity_count,
482        }
483    }
484}
485
486/// We auto-scale histogram bars when rendering the report. This is the number of characters
487/// that we use to represent the maximum bucket value in the histogram.
488///
489/// Histograms may be smaller than this, as well, because one character will never represent
490/// less than one event (so if the max value is 3, the histogram render will be 3 characters wide).
491///
492/// Due to aliasing effects (have to assign at least 1 item per character), the width may even
493/// be greater than this for histograms with very small bucket values. We are not after perfect
494/// rendering here, just a close enough approximation that is easy to read.
495const HISTOGRAM_BAR_WIDTH_CHARS: u64 = 50;
496
497/// Pre-allocated string of histogram bar characters to avoid allocation during rendering.
498/// We make this longer than the typical bar width to handle cases where aliasing causes
499/// the bar to exceed the target width.
500const HISTOGRAM_BAR_CHARS: &str =
501    "∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎";
502
503const HISTOGRAM_BAR_CHARS_LEN_BYTES: NonZero<usize> =
504    NonZero::new(HISTOGRAM_BAR_CHARS.len()).unwrap();
505
506/// Number of bytes per histogram bar character.
507/// The '∎' character is U+25A0 which encodes to 3 bytes in UTF-8.
508const BYTES_PER_HISTOGRAM_BAR_CHAR: NonZero<usize> = nz!(3);
509
510impl Display for Histogram {
511    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
512        let buckets = self.buckets().collect::<Vec<_>>();
513
514        // We measure the dynamic parts of the string to know how much padding to add.
515
516        // We write the observation counts here (both in measurement phase and when rendering).
517        let mut count_str = String::new();
518
519        // What is the widest event count string for any bucket? Affects padding.
520        let widest_count = buckets.iter().fold(0, |current, bucket| {
521            count_str.clear();
522            write!(&mut count_str, "{}", bucket.1)
523                .expect("we expect writing integer to String to be infallible");
524            cmp::max(current, count_str.len())
525        });
526
527        // We write the bucket upper bounds here (both in measurement phase and when rendering).
528        let mut upper_bound_str = String::new();
529
530        // What is the widest upper bound string for any bucket? Affects padding.
531        let widest_upper_bound = buckets.iter().fold(0, |current, bucket| {
532            upper_bound_str.clear();
533
534            if bucket.0 == Magnitude::MAX {
535                // We use "+inf" for the upper bound of the last bucket.
536                write!(&mut upper_bound_str, "+inf")
537                    .expect("we expect writing integer to String to be infallible");
538            } else {
539                write!(&mut upper_bound_str, "{}", bucket.0)
540                    .expect("we expect writing integer to String to be infallible");
541            }
542
543            cmp::max(current, upper_bound_str.len())
544        });
545
546        let histogram_scale = HistogramScale::new(self);
547
548        for (magnitude, count) in buckets {
549            upper_bound_str.clear();
550
551            if magnitude == Magnitude::MAX {
552                // We use "+inf" for the upper bound of the last bucket.
553                write!(&mut upper_bound_str, "+inf")?;
554            } else {
555                // Otherwise, we write the magnitude as is.
556                write!(&mut upper_bound_str, "{magnitude}")?;
557            }
558
559            let padding_needed = widest_upper_bound.saturating_sub(upper_bound_str.len());
560            for _ in 0..padding_needed {
561                upper_bound_str.insert(0, ' ');
562            }
563
564            count_str.clear();
565            write!(&mut count_str, "{count}")?;
566
567            let padding_needed = widest_count.saturating_sub(count_str.len());
568            for _ in 0..padding_needed {
569                count_str.insert(0, ' ');
570            }
571
572            write!(f, "value <= {upper_bound_str} [ {count_str} ]: ")?;
573            histogram_scale.write_bar(count, f)?;
574
575            writeln!(f)?;
576        }
577
578        Ok(())
579    }
580}
581
582/// Represent the auto-scaling logic of the histogram bars, identifying the step size for rendering.
583#[derive(Debug)]
584struct HistogramScale {
585    /// The number of events that each character in the histogram bar represents.
586    /// One character is rendered for each `count_per_char` events (rounded down).
587    count_per_char: NonZero<u64>,
588}
589
590impl HistogramScale {
591    fn new(snapshot: &Histogram) -> Self {
592        let max_count = snapshot
593            .counts()
594            .max()
595            .expect("a histogram always has at least one bucket by definition (+inf)");
596
597        // Each character in the histogram bar represents this many events for auto-scaling
598        // purposes. We use integers, so this can suffer from aliasing effects if there are
599        // not many events. That's fine - the relative sizes will still be fine and the numbers
600        // will give the ground truth even if the rendering is not perfect.
601        #[expect(
602            clippy::integer_division,
603            reason = "we accept the loss of precision here - the bar might not always reach 100% of desired width or even overshoot it"
604        )]
605        let count_per_char = NonZero::new(cmp::max(max_count / HISTOGRAM_BAR_WIDTH_CHARS, 1))
606            .expect("guarded by max()");
607
608        Self { count_per_char }
609    }
610
611    fn write_bar(&self, count: u64, f: &mut impl Write) -> fmt::Result {
612        let histogram_bar_width = count
613            .checked_div(self.count_per_char.get())
614            .expect("division by zero impossible - divisor is NonZero");
615
616        // Note: due to aliasing we can occasionally exceed HISTOGRAM_BAR_WIDTH_CHARS.
617        // This is fine - we are not looking for perfect rendering, just close enough.
618
619        let bar_width = usize::try_from(histogram_bar_width).expect("safe range");
620
621        let chars_in_constant = HISTOGRAM_BAR_CHARS_LEN_BYTES
622            .get()
623            .checked_div(BYTES_PER_HISTOGRAM_BAR_CHAR.get())
624            .expect("NonZero - cannot be zero");
625
626        let mut remaining = bar_width;
627
628        while remaining > 0 {
629            let chunk_size =
630                NonZero::new(remaining.min(chars_in_constant)).expect("guarded by loop condition");
631
632            // Calculate byte length directly: each ∎ character is BYTES_PER_HISTOGRAM_BAR_CHAR bytes in UTF-8.
633            let byte_end = chunk_size
634                .checked_mul(BYTES_PER_HISTOGRAM_BAR_CHAR)
635                .expect("we are seeking into a small constant value, overflow impossible");
636
637            #[expect(
638                clippy::string_slice,
639                reason = "safe slicing - ∎ characters have known UTF-8 encoding"
640            )]
641            f.write_str(&HISTOGRAM_BAR_CHARS[..byte_end.get()])?;
642
643            remaining = remaining
644                .checked_sub(chunk_size.get())
645                .expect("guarded by min() above");
646        }
647
648        Ok(())
649    }
650}
651
652#[cfg(test)]
653#[cfg_attr(coverage_nightly, coverage(off))]
654mod tests {
655    #![allow(clippy::indexing_slicing, reason = "panic is fine in tests")]
656
657    use super::*;
658
659    #[test]
660    fn histogram_properties_reflect_reality() {
661        let magnitudes = &[-5, 1, 10, 100];
662        let counts = &[66, 5, 3, 2];
663
664        let histogram = Histogram {
665            magnitudes,
666            counts: Vec::from(counts).into_boxed_slice(),
667            plus_infinity_bucket_count: 1,
668        };
669
670        assert_eq!(
671            histogram.magnitudes().collect::<Vec<_>>(),
672            magnitudes
673                .iter()
674                .copied()
675                .chain(iter::once(Magnitude::MAX))
676                .collect::<Vec<_>>()
677        );
678        assert_eq!(
679            histogram.counts().collect::<Vec<_>>(),
680            counts
681                .iter()
682                .copied()
683                .chain(iter::once(1))
684                .collect::<Vec<_>>()
685        );
686
687        let buckets: Vec<_> = histogram.buckets().collect();
688        assert_eq!(buckets.len(), 5);
689        assert_eq!(buckets[0], (-5, 66));
690        assert_eq!(buckets[1], (1, 5));
691        assert_eq!(buckets[2], (10, 3));
692        assert_eq!(buckets[3], (100, 2));
693        assert_eq!(buckets[4], (Magnitude::MAX, 1));
694    }
695
696    #[test]
697    fn histogram_display_contains_expected_information() {
698        let magnitudes = &[-5, 1, 10, 100];
699        let counts = &[666666, 5, 3, 2];
700
701        let histogram = Histogram {
702            magnitudes,
703            counts: Vec::from(counts).into_boxed_slice(),
704            plus_infinity_bucket_count: 1,
705        };
706
707        let mut output = String::new();
708        write!(&mut output, "{histogram}").unwrap();
709
710        println!("{output}");
711
712        // We expect each bucket to be displayed, except the MAX one should say "+inf"
713        // instead of the actual value (because the numeric value is too big for good UX).
714        // We check for the specific format we expect to see here (change test if we change format).
715        assert!(output.contains("value <=   -5 [ 666666 ]: "));
716        assert!(output.contains("value <=    1 [      5 ]: "));
717        assert!(output.contains("value <=   10 [      3 ]: "));
718        assert!(output.contains("value <=  100 [      2 ]: "));
719        assert!(output.contains("value <= +inf [      1 ]: "));
720
721        // We do not want to reproduce the auto-scaling logic here, so let's just ensure that
722        // the lines are not hilariously long (e.g. 66666 chars), as a basic sanity check.
723        // NB! Recall that String::len() counts BYTES and that the "boxes" we draw are non-ASCII
724        // characters that take up more than one byte each! So we leave some extra room with a
725        // x5 multiplier to give it some leeway - we just want to detect insane line lengths.
726        #[expect(clippy::cast_possible_truncation, reason = "safe range, tiny values")]
727        let max_acceptable_line_length = (HISTOGRAM_BAR_WIDTH_CHARS * 5) as usize;
728
729        for line in output.lines() {
730            assert!(
731                line.len() < max_acceptable_line_length,
732                "line is too long: {line}"
733            );
734        }
735    }
736
737    #[test]
738    fn event_properties_reflect_reality() {
739        let event_name = "test_event".to_string();
740        let count = 50;
741        let sum = Magnitude::from(1000);
742        let mean = Magnitude::from(20);
743
744        let histogram = Histogram {
745            magnitudes: &[1, 10, 100],
746            counts: vec![5, 3, 2].into_boxed_slice(),
747            plus_infinity_bucket_count: 1,
748        };
749
750        let event_metrics = EventMetrics {
751            name: event_name.clone().into(),
752            count,
753            sum,
754            mean,
755            histogram: Some(histogram),
756        };
757
758        assert_eq!(event_metrics.name(), &event_name);
759        assert_eq!(event_metrics.count(), count);
760        assert_eq!(event_metrics.sum(), sum);
761        assert_eq!(event_metrics.mean(), mean);
762        assert!(event_metrics.histogram().is_some());
763    }
764
765    #[test]
766    fn event_display_contains_expected_information() {
767        let event_name = "test_event".to_string();
768        let count = 50;
769        let sum = Magnitude::from(1000);
770        let mean = Magnitude::from(20);
771
772        let histogram = Histogram {
773            magnitudes: &[1, 10, 100],
774            counts: vec![5, 3, 2].into_boxed_slice(),
775            plus_infinity_bucket_count: 1,
776        };
777
778        let event_metrics = EventMetrics {
779            name: event_name.clone().into(),
780            count,
781            sum,
782            mean,
783            histogram: Some(histogram),
784        };
785
786        let mut output = String::new();
787        write!(&mut output, "{event_metrics}").unwrap();
788
789        println!("{output}");
790
791        // We expect the output to contain the event name and the metrics.
792        // We do not prescribe the exact format here, as it is not so critical.
793        assert!(output.contains(&event_name));
794        assert!(output.contains(&count.to_string()));
795        assert!(output.contains(&sum.to_string()));
796        assert!(output.contains(&mean.to_string()));
797        assert!(output.contains("value <= +inf [ 1 ]: "));
798    }
799
800    #[test]
801    fn report_properties_reflect_reality() {
802        let event1 = EventMetrics {
803            name: "event1".to_string().into(),
804            count: 10,
805            sum: Magnitude::from(100),
806            mean: Magnitude::from(10),
807            histogram: None,
808        };
809
810        let event2 = EventMetrics {
811            name: "event2".to_string().into(),
812            count: 5,
813            sum: Magnitude::from(50),
814            mean: Magnitude::from(10),
815            histogram: None,
816        };
817
818        let report = Report {
819            events: vec![event1, event2].into_boxed_slice(),
820        };
821
822        // This is very boring because the Report type is very boring.
823        let events = report.events().collect::<Vec<_>>();
824
825        assert_eq!(events.len(), 2);
826        assert_eq!(events[0].name(), "event1");
827        assert_eq!(events[1].name(), "event2");
828    }
829
830    #[test]
831    fn report_display_contains_expected_events() {
832        let event1 = EventMetrics {
833            name: "event1".to_string().into(),
834            count: 10,
835            sum: Magnitude::from(100),
836            mean: Magnitude::from(10),
837            histogram: None,
838        };
839
840        let event2 = EventMetrics {
841            name: "event2".to_string().into(),
842            count: 5,
843            sum: Magnitude::from(50),
844            mean: Magnitude::from(10),
845            histogram: None,
846        };
847
848        let report = Report {
849            events: vec![event1, event2].into_boxed_slice(),
850        };
851
852        let mut output = String::new();
853        write!(&mut output, "{report}").unwrap();
854
855        println!("{output}");
856
857        // We expect the output to contain both events.
858        assert!(output.contains("event1"));
859        assert!(output.contains("event2"));
860    }
861
862    #[test]
863    fn event_displayed_as_counter_if_unit_values_and_no_histogram() {
864        // The Display output should be heuristically detected as a "counter"
865        // and undergo simplified printing if the sum equals the count and if
866        // there is no histogram to report.
867
868        let counter = EventMetrics {
869            name: "test_event".to_string().into(),
870            count: 100,
871            sum: Magnitude::from(100),
872            mean: Magnitude::from(1),
873            histogram: None,
874        };
875
876        // sum != count - cannot be a counter.
877        let not_counter = EventMetrics {
878            name: "test_event".to_string().into(),
879            count: 100,
880            sum: Magnitude::from(200),
881            mean: Magnitude::from(2),
882            histogram: None,
883        };
884
885        // Has a histogram - cannot be a counter.
886        let also_not_counter = EventMetrics {
887            name: "test_event".to_string().into(),
888            count: 100,
889            sum: Magnitude::from(100),
890            mean: Magnitude::from(1),
891            histogram: Some(Histogram {
892                magnitudes: &[],
893                counts: Box::new([]),
894                plus_infinity_bucket_count: 100,
895            }),
896        };
897
898        // Neither condition is a match.
899        let still_not_counter = EventMetrics {
900            name: "test_event".to_string().into(),
901            count: 100,
902            sum: Magnitude::from(200),
903            mean: Magnitude::from(2),
904            histogram: Some(Histogram {
905                magnitudes: &[],
906                counts: Box::new([]),
907                plus_infinity_bucket_count: 200,
908            }),
909        };
910
911        let mut output = String::new();
912
913        write!(&mut output, "{counter}").unwrap();
914        assert!(output.contains("100 (counter)"));
915        output.clear();
916
917        write!(&mut output, "{not_counter}").unwrap();
918        assert!(output.contains("100; sum 200; mean 2"));
919        output.clear();
920
921        write!(&mut output, "{also_not_counter}").unwrap();
922        assert!(output.contains("100; sum 100; mean 1"));
923        output.clear();
924
925        write!(&mut output, "{still_not_counter}").unwrap();
926        assert!(output.contains("100; sum 200; mean 2"));
927    }
928
929    #[test]
930    fn histogram_scale_zero() {
931        // Everything is zero, so we render zero bar segments.
932        let histogram = Histogram {
933            magnitudes: &[1, 2, 3],
934            counts: Box::new([0, 0, 0]),
935            plus_infinity_bucket_count: 0,
936        };
937
938        let histogram_scale = HistogramScale::new(&histogram);
939
940        let mut output = String::new();
941        histogram_scale.write_bar(0, &mut output).unwrap();
942
943        assert_eq!(output, "");
944    }
945
946    #[test]
947    fn histogram_scale_small() {
948        // All the buckets have small values, so we do not reach 100% bar width.
949        let histogram = Histogram {
950            magnitudes: &[1, 2, 3],
951            counts: Box::new([1, 2, 3]),
952            plus_infinity_bucket_count: 0,
953        };
954
955        let histogram_scale = HistogramScale::new(&histogram);
956
957        let mut output = String::new();
958
959        histogram_scale.write_bar(0, &mut output).unwrap();
960        assert_eq!(output, "");
961        output.clear();
962
963        histogram_scale.write_bar(1, &mut output).unwrap();
964        assert_eq!(output, "∎");
965        output.clear();
966
967        histogram_scale.write_bar(2, &mut output).unwrap();
968        assert_eq!(output, "∎∎");
969        output.clear();
970
971        histogram_scale.write_bar(3, &mut output).unwrap();
972        assert_eq!(output, "∎∎∎");
973    }
974
975    #[test]
976    fn histogram_scale_just_over() {
977        // All the buckets values just a tiny bit over the desired width.
978        let histogram = Histogram {
979            magnitudes: &[1, 2, 3],
980            counts: Box::new([
981                HISTOGRAM_BAR_WIDTH_CHARS + 1,
982                HISTOGRAM_BAR_WIDTH_CHARS + 1,
983                HISTOGRAM_BAR_WIDTH_CHARS + 1,
984            ]),
985            plus_infinity_bucket_count: 0,
986        };
987
988        let histogram_scale = HistogramScale::new(&histogram);
989
990        let mut output = String::new();
991
992        histogram_scale
993            .write_bar(HISTOGRAM_BAR_WIDTH_CHARS + 1, &mut output)
994            .unwrap();
995        // We expect ∎ repeated HISTOGRAM_BAR_WIDTH_CHARS + 1 times.
996        assert_eq!(
997            output,
998            "∎".repeat(
999                usize::try_from(HISTOGRAM_BAR_WIDTH_CHARS + 1).expect("safe range, tiny value")
1000            )
1001        );
1002    }
1003
1004    #[test]
1005    fn histogram_scale_large_exact() {
1006        // The scale is large enough that we render long segments.
1007        // The numbers divide just right so the bar reaches the desired width.
1008        let histogram = Histogram {
1009            magnitudes: &[1, 2, 3],
1010            counts: Box::new([
1011                79,
1012                HISTOGRAM_BAR_WIDTH_CHARS * 100,
1013                HISTOGRAM_BAR_WIDTH_CHARS * 1000,
1014            ]),
1015            plus_infinity_bucket_count: 0,
1016        };
1017
1018        let histogram_scale = HistogramScale::new(&histogram);
1019
1020        let mut output = String::new();
1021
1022        histogram_scale.write_bar(0, &mut output).unwrap();
1023        assert_eq!(output, "");
1024        output.clear();
1025
1026        histogram_scale
1027            .write_bar(histogram_scale.count_per_char.get(), &mut output)
1028            .unwrap();
1029        assert_eq!(output, "∎");
1030        output.clear();
1031
1032        histogram_scale
1033            .write_bar(HISTOGRAM_BAR_WIDTH_CHARS * 1000, &mut output)
1034            .unwrap();
1035        assert_eq!(
1036            output,
1037            "∎".repeat(usize::try_from(HISTOGRAM_BAR_WIDTH_CHARS).expect("safe range, tiny value"))
1038        );
1039    }
1040
1041    #[test]
1042    fn histogram_scale_large_inexact() {
1043        // The scale is large enough that we render long segments.
1044        // The numbers divide with a remainder, so we do not reach 100% width.
1045        let histogram = Histogram {
1046            magnitudes: &[1, 2, 3],
1047            counts: Box::new([
1048                79,
1049                HISTOGRAM_BAR_WIDTH_CHARS * 100,
1050                HISTOGRAM_BAR_WIDTH_CHARS * 1000,
1051            ]),
1052            plus_infinity_bucket_count: 0,
1053        };
1054
1055        let histogram_scale = HistogramScale::new(&histogram);
1056
1057        let mut output = String::new();
1058        histogram_scale.write_bar(3, &mut output).unwrap();
1059
1060        let mut output = String::new();
1061
1062        histogram_scale.write_bar(0, &mut output).unwrap();
1063        assert_eq!(output, "");
1064        output.clear();
1065
1066        histogram_scale
1067            .write_bar(histogram_scale.count_per_char.get() - 1, &mut output)
1068            .unwrap();
1069        assert_eq!(output, "");
1070        output.clear();
1071
1072        histogram_scale
1073            .write_bar(histogram_scale.count_per_char.get(), &mut output)
1074            .unwrap();
1075        assert_eq!(output, "∎");
1076        output.clear();
1077
1078        histogram_scale
1079            .write_bar(HISTOGRAM_BAR_WIDTH_CHARS * 1000 - 1, &mut output)
1080            .unwrap();
1081        assert_eq!(
1082            output,
1083            "∎".repeat(
1084                usize::try_from(HISTOGRAM_BAR_WIDTH_CHARS).expect("safe range, tiny value") - 1
1085            )
1086        );
1087    }
1088
1089    #[test]
1090    fn histogram_char_byte_count_is_correct() {
1091        // Verify our assumption that ∎ is BYTES_PER_HISTOGRAM_BAR_CHAR bytes in UTF-8.
1092        assert_eq!("∎".len(), BYTES_PER_HISTOGRAM_BAR_CHAR.get());
1093
1094        // Verify that our constant string has the expected byte length.
1095        let expected_chars = HISTOGRAM_BAR_CHARS.chars().count();
1096        let expected_bytes = expected_chars * BYTES_PER_HISTOGRAM_BAR_CHAR.get();
1097        assert_eq!(HISTOGRAM_BAR_CHARS.len(), expected_bytes);
1098    }
1099
1100    #[test]
1101    fn event_metrics_display_zero_count_reports_flat_zero() {
1102        // This tests the "If there is no recorded data, we just report a flat zero
1103        // no questions asked." branch in the Display impl.
1104        let snapshot = ObservationBagSnapshot {
1105            count: 0,
1106            sum: 0,
1107            bucket_magnitudes: &[],
1108            bucket_counts: Box::new([]),
1109        };
1110
1111        let metrics = EventMetrics::new("zero_event".into(), snapshot);
1112
1113        let output = format!("{metrics}");
1114
1115        // The output should contain the event name followed by ": 0".
1116        assert!(output.contains("zero_event: 0"));
1117
1118        // It should be a short output (just the name and zero, plus newline).
1119        assert_eq!(output.trim(), "zero_event: 0");
1120    }
1121
1122    #[test]
1123    fn event_metrics_new_zero_count_empty_buckets() {
1124        let snapshot = ObservationBagSnapshot {
1125            count: 0,
1126            sum: 0,
1127            bucket_magnitudes: &[],
1128            bucket_counts: Box::new([]),
1129        };
1130
1131        let metrics = EventMetrics::new("empty_event".into(), snapshot);
1132
1133        assert_eq!(metrics.name(), "empty_event");
1134        assert_eq!(metrics.count(), 0);
1135        assert_eq!(metrics.sum(), 0);
1136        assert_eq!(metrics.mean(), 0);
1137        assert!(metrics.histogram().is_none());
1138    }
1139
1140    #[test]
1141    fn event_metrics_new_non_zero_count_empty_buckets() {
1142        let snapshot = ObservationBagSnapshot {
1143            count: 10,
1144            sum: 100,
1145            bucket_magnitudes: &[],
1146            bucket_counts: Box::new([]),
1147        };
1148
1149        let metrics = EventMetrics::new("sum_event".into(), snapshot);
1150
1151        assert_eq!(metrics.name(), "sum_event");
1152        assert_eq!(metrics.count(), 10);
1153        assert_eq!(metrics.sum(), 100);
1154        assert_eq!(metrics.mean(), 10); // 100 / 10 = 10
1155        assert!(metrics.histogram().is_none());
1156    }
1157
1158    #[test]
1159    fn event_metrics_new_non_zero_count_with_buckets() {
1160        // Create a snapshot with bucket data.
1161        // Buckets: [-10, 0, 10, 100]
1162        // Counts in buckets: [2, 3, 4, 5] = 14 total in buckets
1163        // Total count: 20 (so 6 are in +inf bucket)
1164        let snapshot = ObservationBagSnapshot {
1165            count: 20,
1166            sum: 500,
1167            bucket_magnitudes: &[-10, 0, 10, 100],
1168            bucket_counts: vec![2, 3, 4, 5].into_boxed_slice(),
1169        };
1170
1171        let metrics = EventMetrics::new("histogram_event".into(), snapshot);
1172
1173        assert_eq!(metrics.name(), "histogram_event");
1174        assert_eq!(metrics.count(), 20);
1175        assert_eq!(metrics.sum(), 500);
1176        assert_eq!(metrics.mean(), 25); // 500 / 20 = 25
1177
1178        let histogram = metrics.histogram().expect("histogram should be present");
1179
1180        // Verify bucket magnitudes include the synthetic +inf bucket.
1181        let magnitudes: Vec<_> = histogram.magnitudes().collect();
1182        assert_eq!(magnitudes, vec![-10, 0, 10, 100, Magnitude::MAX]);
1183
1184        // Verify bucket counts include the plus_infinity_bucket_count.
1185        // plus_infinity = 20 - (2+3+4+5) = 20 - 14 = 6
1186        let counts: Vec<_> = histogram.counts().collect();
1187        assert_eq!(counts, vec![2, 3, 4, 5, 6]);
1188
1189        // Verify buckets() returns correct pairs.
1190        let buckets: Vec<_> = histogram.buckets().collect();
1191        assert_eq!(buckets.len(), 5);
1192        assert_eq!(buckets[0], (-10, 2));
1193        assert_eq!(buckets[1], (0, 3));
1194        assert_eq!(buckets[2], (10, 4));
1195        assert_eq!(buckets[3], (100, 5));
1196        assert_eq!(buckets[4], (Magnitude::MAX, 6));
1197    }
1198
1199    #[test]
1200    fn event_metrics_fake_calculates_mean_correctly() {
1201        // Test that fake() correctly calculates mean as sum / count.
1202        let metrics = EventMetrics::fake("test_event", 10, 100, None);
1203
1204        assert_eq!(metrics.name(), "test_event");
1205        assert_eq!(metrics.count(), 10);
1206        assert_eq!(metrics.sum(), 100);
1207        assert_eq!(metrics.mean(), 10); // 100 / 10 = 10
1208        assert!(metrics.histogram().is_none());
1209    }
1210
1211    #[test]
1212    fn event_metrics_fake_calculates_mean_with_different_values() {
1213        // Test with different values to ensure division is correct.
1214        let metrics = EventMetrics::fake("test_event", 25, 500, None);
1215
1216        assert_eq!(metrics.mean(), 20); // 500 / 25 = 20
1217    }
1218
1219    #[test]
1220    fn event_metrics_fake_mean_zero_when_count_zero() {
1221        // Test that when count is 0, mean is 0 (not NaN or panic).
1222        let metrics = EventMetrics::fake("test_event", 0, 100, None);
1223
1224        assert_eq!(metrics.count(), 0);
1225        assert_eq!(metrics.sum(), 100);
1226        assert_eq!(metrics.mean(), 0);
1227    }
1228
1229    #[test]
1230    fn event_metrics_fake_mean_handles_integer_division() {
1231        // Test that integer division correctly truncates the remainder.
1232        // This test specifically catches mutations that replace / with % or *.
1233        // - If / were replaced with %, result would be 1 (10 % 3 = 1)
1234        // - If / were replaced with *, result would be 30 (10 * 3 = 30)
1235        let metrics = EventMetrics::fake("test_event", 3, 10, None);
1236
1237        assert_eq!(metrics.mean(), 3); // 10 / 3 = 3 (remainder discarded)
1238    }
1239}