Skip to main content

common/
histogram_panel.rs

1//! Pure rendering of per-(side, op) latency distribution panels.
2//!
3//! The panel sits below the existing [`crate::observability::render_lines`]
4//! summary block and visualizes each active controller's most recent
5//! snapshot as a small ASCII histogram. Truncated to the densest 8 bands
6//! to fit beside the existing one-line-per-controller summary without
7//! overwhelming the terminal.
8
9use hdrhistogram::Histogram;
10
11/// One unit's data for the panel: its label, the snapshot, and the
12/// snapshot's covered duration (so the header line can name the
13/// window).
14pub struct PanelUnit<'a> {
15    pub label: &'a str,
16    pub histogram: &'a Histogram<u64>,
17    pub interval: std::time::Duration,
18}
19
20/// Number of histogram bands rendered per unit. Chosen to fit on screen
21/// alongside the existing one-line summary without overwhelming the
22/// progress display; smaller than the natural HDR bucket count.
23const ROWS_PER_UNIT: usize = 8;
24/// Max characters for the bar of the densest bucket.
25const BAR_WIDTH: usize = 24;
26
27const SEPARATOR: &str = "-----------------------";
28
29/// Render the distribution panel for the given units. Empty units (no
30/// samples) are skipped. Returns an empty string if every unit is
31/// empty.
32#[must_use]
33pub fn render_histogram_panel(units: &[PanelUnit]) -> String {
34    let visible: Vec<_> = units.iter().filter(|u| !u.histogram.is_empty()).collect();
35    if visible.is_empty() {
36        return String::new();
37    }
38    let mut out = String::new();
39    out.push('\n');
40    out.push_str(SEPARATOR);
41    for unit in &visible {
42        out.push('\n');
43        render_unit(&mut out, unit);
44    }
45    out
46}
47
48fn render_unit(out: &mut String, unit: &PanelUnit) {
49    let n = unit.histogram.len();
50    out.push_str(&format!(
51        "{label} distribution (last {secs:.1}s, n={n}):\n",
52        label = unit.label,
53        secs = unit.interval.as_secs_f64(),
54        n = n,
55    ));
56    // HDR's iter_log returns values bucketed at log-scale boundaries.
57    // We iterate, find the densest 8 contiguous bands centered on the
58    // mode, and render those.
59    let bands: Vec<(u64, u64)> = unit
60        .histogram
61        .iter_log(1, 2.0)
62        .map(|v| (v.value_iterated_to(), v.count_since_last_iteration()))
63        .filter(|&(_, c)| c > 0)
64        .collect();
65    if bands.is_empty() {
66        out.push_str("  (no samples)\n");
67        return;
68    }
69    let mode_idx = bands
70        .iter()
71        .enumerate()
72        .max_by_key(|(_, (_, c))| *c)
73        .map(|(i, _)| i)
74        .unwrap_or(0);
75    let half = ROWS_PER_UNIT / 2;
76    let lo = mode_idx.saturating_sub(half);
77    let hi = (lo + ROWS_PER_UNIT).min(bands.len());
78    let lo = hi.saturating_sub(ROWS_PER_UNIT);
79    let visible_bands = &bands[lo..hi];
80    let max_count = visible_bands.iter().map(|&(_, c)| c).max().unwrap_or(1);
81    for &(value, count) in visible_bands {
82        let bar_len = ((count * BAR_WIDTH as u64) / max_count) as usize;
83        let bar = "█".repeat(bar_len);
84        out.push_str(&format!(
85            "  {value:>8} {bar:<bar_width$} {count}\n",
86            value = format_micros(value),
87            bar = bar,
88            bar_width = BAR_WIDTH,
89            count = count,
90        ));
91    }
92}
93
94fn format_micros(v: u64) -> String {
95    if v < 1_000 {
96        format!("{v}µs")
97    } else if v < 1_000_000 {
98        format!("{:.1}ms", v as f64 / 1_000.0)
99    } else {
100        format!("{:.1}s", v as f64 / 1_000_000.0)
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    fn make_hist(samples: &[u64]) -> Histogram<u64> {
109        let mut h = Histogram::<u64>::new_with_bounds(1, 3_600_000_000, 3).unwrap();
110        for &v in samples {
111            h.record(v).unwrap();
112        }
113        h
114    }
115
116    #[test]
117    fn empty_units_produce_empty_output() {
118        let h = make_hist(&[]);
119        let units = [PanelUnit {
120            label: "src-stat",
121            histogram: &h,
122            interval: std::time::Duration::from_secs(1),
123        }];
124        assert_eq!(render_histogram_panel(&units), "");
125    }
126
127    #[test]
128    fn renders_per_unit_header_and_bands() {
129        // 100 samples clustered around 100µs / 200µs; render must include
130        // the unit's label, the "last 1.0s, n=100" header, and at least
131        // one bar character.
132        let mut samples = vec![100u64; 70];
133        samples.extend(vec![200u64; 30]);
134        let h = make_hist(&samples);
135        let units = [PanelUnit {
136            label: "src-stat",
137            histogram: &h,
138            interval: std::time::Duration::from_secs(1),
139        }];
140        let out = render_histogram_panel(&units);
141        assert!(out.contains("src-stat distribution"), "got: {out}");
142        assert!(out.contains("n=100"), "got: {out}");
143        assert!(
144            out.contains('█'),
145            "expected at least one bar block, got: {out}"
146        );
147    }
148
149    #[test]
150    fn empty_unit_is_skipped_among_active_units() {
151        let h_empty = make_hist(&[]);
152        let h_full = make_hist(&[100, 200, 300]);
153        let units = [
154            PanelUnit {
155                label: "idle",
156                histogram: &h_empty,
157                interval: std::time::Duration::from_secs(1),
158            },
159            PanelUnit {
160                label: "active",
161                histogram: &h_full,
162                interval: std::time::Duration::from_secs(1),
163            },
164        ];
165        let out = render_histogram_panel(&units);
166        assert!(!out.contains("idle"), "idle unit must be hidden: {out}");
167        assert!(out.contains("active"), "active unit must show: {out}");
168    }
169}