Skip to main content

krabka_bench_driver/
graph.rs

1//! Plotly HTML graph rendering for the benchmark report.
2//!
3//! This module turns the cross-run aggregates from [`crate::aggregate`] into one
4//! self-contained HTML page:
5//!
6//! - a grouped **bar chart per headline metric**, krabka against kafka across
7//!   every scenario cell, with run-to-run sample-stddev error bars; and
8//! - one **across-run-averaged time-series line chart** per
9//!   `(scenario, metric)`, which shows how the metric moved over the run for
10//!   each stack.
11//!
12//! The page loads `plotly.js` from the CDN and matches the version the `plotly`
13//! crate itself targets. It embeds each figure inline, so the whole report is
14//! a single `.html` file.
15
16use {
17    crate::{
18        aggregate::{
19            CellAgg, CellKey, ScalarMetric, StackAgg, TsSeries, aggregate_cells,
20            averaged_timeseries, scalar_metrics,
21        },
22        ids::TimeOffsetMs,
23        numeric::mebibytes_f64,
24        scenario::{RunOutput, Stack},
25    },
26    krabka_units::prelude::*,
27    plotly::{
28        Bar, Layout, Plot, Scatter,
29        common::{ErrorData, ErrorType, Line, Mode, Title},
30        layout::{Axis, BarMode},
31    },
32    std::{
33        collections::BTreeMap,
34        fmt::{Arguments, Write},
35    },
36};
37
38pub(super) fn push_fmt(output: &mut String, args: Arguments<'_>) {
39    // Writing formatted data to a `String` cannot fail.
40    let _ = output.write_fmt(args);
41}
42
43/// The plotly.js version that the `plotly` crate (0.14) renders against. Pin the
44/// same one, so the inline figures find a compatible global.
45///
46/// This is for the website fragment alone. The fragment is inlined into a page
47/// the site serves, that page is read online, and a second copy of a 4.5 MB
48/// bundle in every build of the site is not worth it. The standalone report
49/// carries the bundle instead: see [`wrap_page`].
50const PLOTLY_CDN: &str = "https://cdn.plot.ly/plotly-3.0.1.min.js";
51
52/// Renders every run into one self-contained HTML report with the title
53/// `title`.
54#[must_use]
55pub fn render_html(runs: &[RunOutput], title: &str) -> String {
56    let cells = aggregate_cells(runs);
57    let ts = averaged_timeseries(runs);
58
59    let mut body = String::new();
60    if cells.is_empty() {
61        body.push_str("<p>No runs to report.</p>\n");
62        return wrap_page(title, &body);
63    }
64
65    body.push_str("<h2>Summary — mean ± stddev across runs</h2>\n");
66    for m in scalar_metrics() {
67        let plot = bar_chart(&cells, &m);
68        body.push_str("<div class=\"plot\">");
69        body.push_str(&plot.to_inline_html(Some(&format!("bar-{}", m.key))));
70        body.push_str("</div>\n");
71    }
72
73    if !ts.is_empty() {
74        body.push_str("<h2>Over the run — averaged across runs</h2>\n");
75        body.push_str(&timeseries_charts(&ts));
76    }
77
78    wrap_page(title, &body)
79}
80
81/// One run together with the `runNN` tag parsed from its result filename.
82#[derive(Debug, Clone)]
83pub struct TaggedRun {
84    /// The `runNN` tag from the result filename.
85    pub tag: String,
86    /// The run the file held.
87    pub run: RunOutput,
88}
89
90/// Renders an HTML *fragment* with no `<html>` wrapper, for the Zola website to
91/// embed.
92///
93/// The fragment holds the averaged scalar summary, and then throughput, CPU,
94/// and memory over the run window for each scenario. Every individual run is a
95/// faint line, and the across-run mean is a bold line. `tagged` pairs each run
96/// with the `runNN` tag parsed from the result filename, so every per-run trace
97/// carries a name.
98#[must_use]
99pub fn render_web_fragment(tagged: &[TaggedRun]) -> String {
100    let runs: Vec<RunOutput> = tagged.iter().map(|t| t.run.clone()).collect();
101    let cells = aggregate_cells(&runs);
102    if cells.is_empty() {
103        return "<p>No benchmark runs available yet.</p>\n".to_owned();
104    }
105    let ts = averaged_timeseries(&runs);
106
107    let mut out = String::new();
108    push_fmt(
109        &mut out,
110        format_args!("<script src=\"{PLOTLY_CDN}\" charset=\"utf-8\"></script>\n"),
111    );
112
113    out.push_str(
114        "<h2 id=\"summary\">Summary — mean across runs (error bars = run-to-run stddev)</h2>\n",
115    );
116    for m in scalar_metrics() {
117        let plot = bar_chart(&cells, &m);
118        out.push_str("<div class=\"bench-plot\">");
119        out.push_str(&plot.to_inline_html(Some(&format!("bench-bar-{}", m.key))));
120        out.push_str("</div>\n");
121    }
122
123    out.push_str(
124        "<h2 id=\"per-run\">Per run &amp; averaged — throughput, CPU and memory over the run</h2>\n",
125    );
126    // Group runs into cells, preserving each run's tag for the faint per-run lines.
127    let mut by_cell: BTreeMap<CellKey, Vec<&TaggedRun>> = BTreeMap::new();
128    for t in tagged {
129        by_cell.entry(CellKey::of(&t.run)).or_default().push(t);
130    }
131    for (key, cell_runs) in &by_cell {
132        push_fmt(
133            &mut out,
134            format_args!("<h3>{}</h3>\n", escape(&key.label())),
135        );
136        for wm in &web_metrics() {
137            let plot = per_run_chart(key, cell_runs, wm, &ts);
138            let id = sanitize_id(&format!("bench-ts-{}-{}", key.label(), wm.avg_key));
139            out.push_str("<div class=\"bench-plot\">");
140            out.push_str(&plot.to_inline_html(Some(&id)));
141            out.push_str("</div>\n");
142        }
143    }
144    out
145}
146
147/// One point of a run's own time series.
148#[derive(Debug, Clone, Copy)]
149struct RunPoint {
150    t_offset_ms: TimeOffsetMs,
151    value: f64,
152}
153
154/// A website time-series metric. It holds the matching [`averaged_timeseries`]
155/// key for the bold mean line, a label, and a way to pull a run's
156/// [`RunPoint`]s for the faint per-run lines.
157struct WebMetric {
158    avg_key: &'static str,
159    label: &'static str,
160    per_run: fn(&RunOutput) -> Vec<RunPoint>,
161}
162
163fn web_metrics() -> Vec<WebMetric> {
164    vec![
165        WebMetric {
166            avg_key: "producer_msgs_per_sec",
167            label: "Throughput (producer msgs/s)",
168            per_run: |r| {
169                r.samples
170                    .iter()
171                    .map(|s| RunPoint {
172                        t_offset_ms: s.t_offset_ms,
173                        value: s.producer_rate.per_sec_f64(),
174                    })
175                    .collect()
176            },
177        },
178        WebMetric {
179            avg_key: "broker_cpu_cores",
180            label: "Broker CPU (cores)",
181            per_run: |r| {
182                r.broker_samples
183                    .iter()
184                    .map(|b| RunPoint {
185                        t_offset_ms: b.t_offset_ms,
186                        value: b.cpu_cores,
187                    })
188                    .collect()
189            },
190        },
191        WebMetric {
192            avg_key: "broker_mem_working_set_mb",
193            label: "Broker memory (MiB)",
194            per_run: |r| {
195                r.broker_samples
196                    .iter()
197                    .map(|b| RunPoint {
198                        t_offset_ms: b.t_offset_ms,
199                        value: mebibytes_f64(b.mem_working_set),
200                    })
201                    .collect()
202            },
203        },
204    ]
205}
206
207/// The bold mean line colour and the faint per-run line colour of one stack.
208struct StackColors {
209    mean: &'static str,
210    faint: &'static str,
211}
212
213fn stack_colors(s: Stack) -> StackColors {
214    match s {
215        Stack::Krabka => StackColors {
216            mean: "rgb(234,88,12)",
217            faint: "rgba(234,88,12,0.20)",
218        },
219        Stack::Kafka => StackColors {
220            mean: "rgb(37,99,235)",
221            faint: "rgba(37,99,235,0.20)",
222        },
223    }
224}
225
226/// One chart for `wm` in one cell. Every run is a faint line that the legend
227/// hides, and each stack also gets a bold across-run mean line.
228fn per_run_chart(key: &CellKey, cell_runs: &[&TaggedRun], wm: &WebMetric, ts: &[TsSeries]) -> Plot {
229    let mut plot = Plot::new();
230    for stack in [Stack::Krabka, Stack::Kafka] {
231        let StackColors {
232            mean: mean_color,
233            faint: faint_color,
234        } = stack_colors(stack);
235
236        for TaggedRun { tag, run } in cell_runs.iter().copied().filter(|t| t.run.stack == stack) {
237            let pts = (wm.per_run)(run);
238            if pts.is_empty() {
239                continue;
240            }
241            let x: Vec<f64> = pts
242                .iter()
243                .map(|p| p.t_offset_ms.as_time().secs_f64())
244                .collect();
245            let y: Vec<f64> = pts.iter().map(|p| p.value).collect();
246            plot.add_trace(
247                Scatter::new(x, y)
248                    .mode(Mode::Lines)
249                    .name(format!("{} {tag}", stack.as_str()))
250                    .legend_group(stack.as_str())
251                    .show_legend(false)
252                    .line(Line::new().color(faint_color).width(1.0)),
253            );
254        }
255
256        if let Some(series) = ts
257            .iter()
258            .find(|s| &s.key == key && s.stack == stack && s.metric == wm.avg_key)
259        {
260            let x: Vec<f64> = series
261                .points
262                .iter()
263                .map(|p| p.t_offset_ms.as_time().secs_f64())
264                .collect();
265            let y: Vec<f64> = series.points.iter().map(|p| p.mean).collect();
266            let n = series.points.iter().map(|p| p.n).max().unwrap_or(0);
267            plot.add_trace(
268                Scatter::new(x, y)
269                    .mode(Mode::Lines)
270                    .name(format!("{} (mean of {n})", stack.as_str()))
271                    .legend_group(stack.as_str())
272                    .line(Line::new().color(mean_color).width(3.0)),
273            );
274        }
275    }
276    plot.set_layout(
277        Layout::new()
278            .title(Title::with_text(format!("{} — {}", key.label(), wm.label)))
279            .height(360)
280            .x_axis(Axis::new().title(Title::with_text("seconds into run")))
281            .y_axis(Axis::new().title(Title::with_text(wm.label))),
282    );
283    plot
284}
285
286/// One grouped bar chart for metric `m`. Each cell gets a krabka bar and a kafka
287/// bar, and each bar carries the across-run sample-stddev as a symmetric error
288/// bar.
289fn bar_chart(cells: &[CellAgg], m: &ScalarMetric) -> Plot {
290    let labels: Vec<String> = cells.iter().map(|c| c.key.label()).collect();
291
292    let stack_series = |pick: fn(&CellAgg) -> &StackAgg| {
293        let mut means = Vec::with_capacity(cells.len());
294        let mut errs = Vec::with_capacity(cells.len());
295        for c in cells {
296            let s = pick(c).metrics.get(m.key).copied().unwrap_or_default();
297            means.push(s.mean);
298            errs.push(s.stddev);
299        }
300        (means, errs)
301    };
302    let (cy, ce) = stack_series(|c| &c.krabka);
303    let (ky, ke) = stack_series(|c| &c.kafka);
304
305    let mut plot = Plot::new();
306    plot.add_trace(
307        Bar::new(labels.clone(), cy)
308            .name("krabka")
309            .error_y(ErrorData::new(ErrorType::Data).array(ce)),
310    );
311    plot.add_trace(
312        Bar::new(labels, ky)
313            .name("kafka")
314            .error_y(ErrorData::new(ErrorType::Data).array(ke)),
315    );
316    plot.set_layout(
317        Layout::new()
318            .title(Title::with_text(format!("{} ({})", m.label, m.unit)))
319            .bar_mode(BarMode::Group)
320            .height(420)
321            .y_axis(Axis::new().title(Title::with_text(m.unit))),
322    );
323    plot
324}
325
326/// The `(cell, metric)` that one time-series chart plots.
327#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
328struct SeriesGroup<'a> {
329    key: &'a CellKey,
330    metric: &'static str,
331}
332
333/// One line chart per `(scenario, metric)`. Each chart holds a krabka line and a
334/// kafka line of the across-run-averaged value over the run.
335fn timeseries_charts(ts: &[TsSeries]) -> String {
336    let mut groups: BTreeMap<SeriesGroup<'_>, Vec<&TsSeries>> = BTreeMap::new();
337    for s in ts {
338        groups
339            .entry(SeriesGroup {
340                key: &s.key,
341                metric: s.metric,
342            })
343            .or_default()
344            .push(s);
345    }
346
347    let mut out = String::new();
348    for (&SeriesGroup { key, metric }, series) in &groups {
349        let label = ts_metric_label(metric);
350        let mut plot = Plot::new();
351        for s in series {
352            let x: Vec<f64> = s
353                .points
354                .iter()
355                .map(|p| p.t_offset_ms.as_time().secs_f64())
356                .collect();
357            let y: Vec<f64> = s.points.iter().map(|p| p.mean).collect();
358            plot.add_trace(Scatter::new(x, y).name(s.stack.as_str()).mode(Mode::Lines));
359        }
360        plot.set_layout(
361            Layout::new()
362                .title(Title::with_text(format!("{} — {label}", key.label())))
363                .height(340)
364                .x_axis(Axis::new().title(Title::with_text("seconds into run")))
365                .y_axis(Axis::new().title(Title::with_text(label))),
366        );
367        let id = sanitize_id(&format!("ts-{}-{metric}", key.label()));
368        out.push_str("<div class=\"plot\">");
369        out.push_str(&plot.to_inline_html(Some(&id)));
370        out.push_str("</div>\n");
371    }
372    out
373}
374
375/// The label a person reads for a time-series metric key. The keys are a closed
376/// set. See [`crate::aggregate`]. The fallback is unreachable in practice.
377fn ts_metric_label(key: &str) -> &'static str {
378    match key {
379        "producer_msgs_per_sec" => "Producer throughput (msgs/s)",
380        "consumer_msgs_per_sec" => "Consumer throughput (msgs/s)",
381        "producer_p99_ms" => "Producer ack p99 (ms)",
382        "consumer_e2e_p99_ms" => "Consumer e2e p99 (ms)",
383        "broker_cpu_cores" => "Broker CPU (cores)",
384        "broker_mem_working_set_mb" => "Broker working set (MiB)",
385        _ => "value",
386    }
387}
388
389/// Reduces an arbitrary string to a safe HTML id of alphanumerics and dashes.
390fn sanitize_id(s: &str) -> String {
391    s.chars()
392        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
393        .collect()
394}
395
396/// Wrap the rendered charts in a page that carries everything it needs.
397///
398/// The report is handed around as an artifact: attached to a run, opened weeks
399/// later, read on a machine behind a proxy. A `<script src>` to a CDN makes
400/// every chart in it depend on that host still serving that file, so an
401/// artifact that is otherwise complete opens with no plots at all.
402/// [`plotly::Plot::offline_js_sources`] returns the same plotly.js the crate
403/// renders against, as inline script tags. The page grows by about 6 MB and
404/// stops depending on anything.
405fn wrap_page(title: &str, body: &str) -> String {
406    let t = escape(title);
407    let scripts = Plot::offline_js_sources();
408    format!(
409        "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\
410<title>{t}</title>\n{scripts}\n\
411<style>body{{font-family:system-ui,sans-serif;margin:24px;max-width:1100px}}\
412h1{{font-size:1.6rem}}h2{{margin-top:2rem;border-bottom:1px solid #ddd;padding-bottom:4px}}\
413.plot{{margin:10px 0}}</style>\n</head>\n<body>\n<h1>{t}</h1>\n{body}\n</body>\n</html>\n"
414    )
415}
416
417/// Minimal HTML-text escaping for trusted scenario and metric labels.
418fn escape(s: &str) -> String {
419    s.replace('&', "&amp;")
420        .replace('<', "&lt;")
421        .replace('>', "&gt;")
422}
423
424#[cfg(test)]
425mod tests {
426
427    use {
428        super::*,
429        crate::{
430            ids::TimeOffsetMs,
431            scenario::{Sample, fixture::empty_run as run},
432        },
433    };
434
435    fn sample(t: u64, producer_rate: Frequency) -> Sample {
436        Sample {
437            t_offset_ms: TimeOffsetMs(t),
438            producer_rate,
439            consumer_rate: producer_rate * 0.9,
440            producer_p50: millis(1),
441            producer_p99: millis(4),
442            consumer_e2e_p99: millis(7),
443        }
444    }
445
446    #[test]
447    fn render_html_embeds_library_bars_and_timeseries() {
448        let runs = vec![
449            run()
450                .samples(vec![sample(0, per_sec(1000)), sample(2000, per_sec(1100))])
451                .p99(millis(5))
452                .call(),
453            run()
454                .stack(Stack::Kafka)
455                .producer_rate(per_sec(800))
456                .samples(vec![sample(0, per_sec(800)), sample(2000, per_sec(820))])
457                .p99(millis(7))
458                .call(),
459            run()
460                .scenario("fan-out")
461                .producer_rate(per_sec(500))
462                .p99(millis(9))
463                .call(),
464            run()
465                .scenario("fan-out")
466                .stack(Stack::Kafka)
467                .producer_rate(per_sec(400))
468                .p99(millis(11))
469                .call(),
470        ];
471        let html = render_html(&runs, "Krabka vs Strimzi");
472
473        // Valid page that embeds real figures, represents both scenarios and
474        // both stacks, and carries a headline bar metric plus a time-series
475        // chart.
476        for needle in [
477            "<html",
478            "Plotly.newPlot",
479            "Krabka vs Strimzi",
480            "small-msg",
481            "fan-out",
482            "krabka",
483            "kafka",
484            "Producer throughput",
485            "seconds into run",
486        ] {
487            assert2::assert!(html.contains(needle));
488        }
489        // The page carries plotly.js rather than a link to it, so it renders
490        // with no network. The bundle's own text mentions the CDN, so the check
491        // is on the `<script src>` tag and not on the host name.
492        assert2::assert!(!html.contains("<script src=\"https://cdn.plot.ly"));
493        assert2::assert!(html.len() > 1_000_000, "page is {} bytes", html.len());
494    }
495
496    #[test]
497    fn render_html_empty_runs_is_a_valid_page() {
498        let html = render_html(&[], "Empty");
499        assert2::assert!(html.contains("<html") && html.contains("Empty"));
500        assert2::assert!(html.contains("No runs"));
501    }
502
503    #[test]
504    fn web_fragment_has_per_run_and_mean_for_cpu_mem_throughput() {
505        use crate::scenario::BrokerSample;
506        let mk = |stack, prod: Frequency, cpu: f64, mem: ByteSize| {
507            let mut r = run()
508                .stack(stack)
509                .producer_rate(prod)
510                .samples(vec![sample(0, prod), sample(2000, prod * 1.1)])
511                .p99(millis(5))
512                .call();
513            r.broker_samples = vec![
514                BrokerSample {
515                    t_offset_ms: TimeOffsetMs(0),
516                    cpu_cores: cpu,
517                    mem_working_set: mem,
518                },
519                BrokerSample {
520                    t_offset_ms: TimeOffsetMs(2000),
521                    cpu_cores: cpu,
522                    mem_working_set: mem,
523                },
524            ];
525            r
526        };
527        let tagged = vec![
528            TaggedRun {
529                tag: "run01".to_owned(),
530                run: mk(Stack::Krabka, per_sec(1000), 2.0, mebibytes(300)),
531            },
532            TaggedRun {
533                tag: "run02".to_owned(),
534                run: mk(Stack::Krabka, per_sec(1200), 2.2, mebibytes(320)),
535            },
536            TaggedRun {
537                tag: "run01".to_owned(),
538                run: mk(Stack::Kafka, per_sec(800), 3.5, mebibytes(2000)),
539            },
540            TaggedRun {
541                tag: "run02".to_owned(),
542                run: mk(Stack::Kafka, per_sec(820), 3.6, mebibytes(2100)),
543            },
544        ];
545        let html = render_web_fragment(&tagged);
546        // The fragment is inlined into a page the site serves online, so it
547        // keeps the CDN reference the standalone report drops. It charts
548        // throughput/CPU/memory per run for both stacks, and labels the bold
549        // mean line with the run count ("mean of 2").
550        for needle in [
551            "<script src=\"https://cdn.plot.ly/plotly-3.0.1",
552            "Plotly.newPlot",
553            "Per run",
554            "small-msg",
555            "Throughput",
556            "Broker CPU",
557            "Broker memory",
558            "mean of 2",
559            "krabka",
560            "kafka",
561        ] {
562            assert2::assert!(html.contains(needle));
563        }
564    }
565
566    #[test]
567    fn web_fragment_empty_is_graceful() {
568        assert2::assert!(render_web_fragment(&[]).contains("No benchmark runs"));
569    }
570}