1use {
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 let _ = output.write_fmt(args);
41}
42
43const PLOTLY_CDN: &str = "https://cdn.plot.ly/plotly-3.0.1.min.js";
51
52#[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(×eries_charts(&ts));
76 }
77
78 wrap_page(title, &body)
79}
80
81#[derive(Debug, Clone)]
83pub struct TaggedRun {
84 pub tag: String,
86 pub run: RunOutput,
88}
89
90#[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 & averaged — throughput, CPU and memory over the run</h2>\n",
125 );
126 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#[derive(Debug, Clone, Copy)]
149struct RunPoint {
150 t_offset_ms: TimeOffsetMs,
151 value: f64,
152}
153
154struct 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
207struct 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
226fn 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
286fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
328struct SeriesGroup<'a> {
329 key: &'a CellKey,
330 metric: &'static str,
331}
332
333fn 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
375fn 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
389fn sanitize_id(s: &str) -> String {
391 s.chars()
392 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
393 .collect()
394}
395
396fn 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
417fn escape(s: &str) -> String {
419 s.replace('&', "&")
420 .replace('<', "<")
421 .replace('>', ">")
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 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 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 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}