Skip to main content

reifydb_profiler/
format.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::{cmp::Reverse, collections::HashMap, fmt::Write};
5
6use crate::{
7	category::{ALL_CATEGORIES, ProfilerCategory},
8	intern::DimInterner,
9	record::{AggregateRecord, DimIdx},
10	summary::ProfilerSummary,
11};
12
13pub fn summary(summary: &ProfilerSummary, top_n: usize) -> String {
14	let (totals, mut hot) = flow_aggregates(summary);
15	let mut out = summary_header(&totals);
16
17	if hot.is_empty() {
18		out.push_str(" hot=[]");
19		return out;
20	}
21
22	render_hot_rows(&mut out, summary, &mut hot, top_n);
23	out
24}
25
26#[inline]
27fn summary_header(totals: &FlowTotals) -> String {
28	format!(
29		"tick(proc={}us/{}c, apply_sum={}us/{}ops, lock_sum={}us)",
30		totals.process_wall_us,
31		totals.process_calls,
32		totals.apply_total_us,
33		totals.op_calls,
34		totals.lock_total_us,
35	)
36}
37
38#[inline]
39fn render_hot_rows(out: &mut String, summary: &ProfilerSummary, hot: &mut [(FlowKey, HotEntry)], top_n: usize) {
40	hot.sort_by(|a, b| b.1.apply_us.cmp(&a.1.apply_us));
41	out.push_str(" hot=[");
42	for (i, (key, entry)) in hot.iter().take(top_n).enumerate() {
43		if i > 0 {
44			out.push_str(", ");
45		}
46		let label = resolve_label(summary.interner.as_deref(), key.0);
47		let id = resolve_id(summary.interner.as_deref(), key.1);
48		let _ = write!(
49			out,
50			"{}@{}={}us/{}lk/{}c/{}in/{}out",
51			label, id, entry.apply_us, entry.lock_us, entry.calls, entry.input_rows, entry.output_rows,
52		);
53	}
54	out.push(']');
55}
56
57pub fn summary_table(summary: &ProfilerSummary, top_n: usize) -> String {
58	let mut out = String::new();
59	let _ = writeln!(out, "profile scope={} total={}", summary.scope_name, fmt_us(summary.total_duration_us));
60
61	for cat in ALL_CATEGORIES {
62		let cat_summary = summary.category(cat);
63		if cat_summary.calls == 0 {
64			continue;
65		}
66
67		match cat {
68			ProfilerCategory::Flow => {
69				let _ = writeln!(
70					out,
71					"  {}: {} calls, apply={}, lock={}",
72					category_label(cat),
73					cat_summary.calls,
74					fmt_us(cat_summary.total_us),
75					fmt_us(cat_summary.extras_sum[2]),
76				);
77				render_flow_rows(&mut out, summary, top_n);
78			}
79			_ => {
80				let _ = writeln!(
81					out,
82					"  {}: {} calls, total={}",
83					category_label(cat),
84					cat_summary.calls,
85					fmt_us(cat_summary.total_us),
86				);
87				render_non_flow_rows(&mut out, summary, cat, top_n);
88			}
89		}
90	}
91
92	out
93}
94
95pub fn aggregates_table(records: &[AggregateRecord], top_n: usize) -> String {
96	let mut out = String::new();
97	if records.is_empty() {
98		out.push_str("profile (accumulator) empty\n");
99		return out;
100	}
101	write_accumulator_header(&mut out, records);
102
103	for cat in ALL_CATEGORIES {
104		render_category(&mut out, records, cat, top_n);
105	}
106
107	out
108}
109
110#[inline]
111fn write_accumulator_header(out: &mut String, records: &[AggregateRecord]) {
112	let total_calls: u64 = records.iter().map(|r| r.calls).sum();
113	let total_us: u64 = records.iter().map(|r| r.total_us).sum();
114	let _ = writeln!(
115		out,
116		"profile (accumulator) {} records, {} calls, total={}",
117		records.len(),
118		total_calls,
119		fmt_us(total_us)
120	);
121}
122
123#[inline]
124fn render_category(out: &mut String, records: &[AggregateRecord], cat: ProfilerCategory, top_n: usize) {
125	let cat_records: Vec<&AggregateRecord> = records.iter().filter(|r| r.category == cat).collect();
126	if cat_records.is_empty() {
127		return;
128	}
129	let cat_calls: u64 = cat_records.iter().map(|r| r.calls).sum();
130	let cat_total: u64 = cat_records.iter().map(|r| r.total_us).sum();
131	let _ = writeln!(
132		out,
133		"  {}: {} records, {} calls, total={}",
134		category_label(cat),
135		cat_records.len(),
136		cat_calls,
137		fmt_us(cat_total)
138	);
139
140	let mut by_name: HashMap<&str, Vec<&AggregateRecord>> = HashMap::new();
141	for r in &cat_records {
142		by_name.entry(r.span_name.as_str()).or_default().push(*r);
143	}
144	let mut groups: Vec<(&str, Vec<&AggregateRecord>)> = by_name.into_iter().collect();
145	groups.sort_by_key(|(_, recs)| Reverse(recs.iter().map(|r| r.total_us).sum::<u64>()));
146
147	for (span_name, group) in groups {
148		render_group(out, span_name, group, top_n);
149	}
150}
151
152#[inline]
153fn render_group(out: &mut String, span_name: &str, mut group: Vec<&AggregateRecord>, top_n: usize) {
154	let group_total: u64 = group.iter().map(|r| r.total_us).sum();
155	let group_calls: u64 = group.iter().map(|r| r.calls).sum();
156
157	if group.len() == 1 && group[0].dimensions.is_empty() {
158		let r = group[0];
159		let p = r.histogram.percentiles();
160		let _ = writeln!(
161			out,
162			"    {}  total={} calls={} p50={} p75={} p90={} p95={} p99={}",
163			span_name,
164			fmt_us(r.total_us),
165			r.calls,
166			fmt_us(p.p50 as u64),
167			fmt_us(p.p75 as u64),
168			fmt_us(p.p90 as u64),
169			fmt_us(p.p95 as u64),
170			fmt_us(p.p99 as u64),
171		);
172		return;
173	}
174
175	let _ = writeln!(
176		out,
177		"    {} [{} ops, total={}, calls={}]",
178		span_name,
179		group.len(),
180		fmt_us(group_total),
181		group_calls,
182	);
183
184	group.sort_by(|a, b| b.total_us.cmp(&a.total_us));
185	group.truncate(top_n);
186
187	let labels: Vec<String> = group
188		.iter()
189		.map(|r| {
190			if r.dimensions.is_empty() {
191				"<no-dims>".to_string()
192			} else {
193				r.dimensions.join("@")
194			}
195		})
196		.collect();
197	let max_label_width = labels.iter().map(|s| s.len()).max().unwrap_or(0);
198
199	for (i, r) in group.iter().enumerate() {
200		let p = r.histogram.percentiles();
201		let _ = writeln!(
202			out,
203			"      {:<width$}  total={} calls={} p50={} p75={} p90={} p95={} p99={}",
204			labels[i],
205			fmt_us(r.total_us),
206			r.calls,
207			fmt_us(p.p50 as u64),
208			fmt_us(p.p75 as u64),
209			fmt_us(p.p90 as u64),
210			fmt_us(p.p95 as u64),
211			fmt_us(p.p99 as u64),
212			width = max_label_width,
213		);
214	}
215}
216
217pub fn fmt_us(us: u64) -> String {
218	if us < 1_000 {
219		format!("{}us", us)
220	} else if us < 1_000_000 {
221		format!("{:.1}ms", us as f64 / 1_000.0)
222	} else {
223		format!("{:.1}s", us as f64 / 1_000_000.0)
224	}
225}
226
227#[derive(Default)]
228struct FlowTotals {
229	apply_total_us: u64,
230	op_calls: u32,
231	process_wall_us: u64,
232	process_calls: u32,
233	lock_total_us: u64,
234}
235
236#[derive(Default, Clone)]
237struct HotEntry {
238	apply_us: u64,
239	lock_us: u64,
240	calls: u32,
241	input_rows: u64,
242	output_rows: u64,
243}
244
245type FlowKey = (DimIdx, DimIdx);
246
247fn flow_aggregates(summary: &ProfilerSummary) -> (FlowTotals, Vec<(FlowKey, HotEntry)>) {
248	let mut totals = FlowTotals::default();
249	let mut aggregates: HashMap<FlowKey, HotEntry> = HashMap::new();
250
251	for r in &summary.records {
252		if r.category_id != ProfilerCategory::Flow as u8 {
253			continue;
254		}
255		let is_apply = r.dim_indices[0] != 0 || r.dim_indices[1] != 0;
256		if is_apply {
257			totals.apply_total_us = totals.apply_total_us.saturating_add(r.duration_us as u64);
258			totals.op_calls = totals.op_calls.saturating_add(1);
259			totals.lock_total_us = totals.lock_total_us.saturating_add(r.extras[2]);
260			let entry = aggregates.entry((r.dim_indices[0], r.dim_indices[1])).or_default();
261			entry.apply_us = entry.apply_us.saturating_add(r.duration_us as u64);
262			entry.lock_us = entry.lock_us.saturating_add(r.extras[2]);
263			entry.calls = entry.calls.saturating_add(1);
264			entry.input_rows = entry.input_rows.saturating_add(r.extras[0]);
265			entry.output_rows = entry.output_rows.saturating_add(r.extras[1]);
266		} else {
267			totals.process_wall_us = totals.process_wall_us.saturating_add(r.duration_us as u64);
268			totals.process_calls = totals.process_calls.saturating_add(1);
269		}
270	}
271
272	(totals, aggregates.into_iter().collect())
273}
274
275fn render_flow_rows(out: &mut String, summary: &ProfilerSummary, top_n: usize) {
276	let (_, mut hot) = flow_aggregates(summary);
277	hot.sort_by(|a, b| b.1.apply_us.cmp(&a.1.apply_us));
278	hot.truncate(top_n);
279
280	if hot.is_empty() {
281		return;
282	}
283
284	let labels: Vec<String> = hot
285		.iter()
286		.map(|(key, _)| {
287			let label = resolve_label(summary.interner.as_deref(), key.0);
288			let id = resolve_id(summary.interner.as_deref(), key.1);
289			format!("{}@{}", label, id)
290		})
291		.collect();
292	let max_label_width = labels.iter().map(|s| s.len()).max().unwrap_or(0);
293
294	for (i, (_, entry)) in hot.iter().enumerate() {
295		let _ = writeln!(
296			out,
297			"    {:<width$}  apply={} calls={} lock={} io={}->{}",
298			labels[i],
299			fmt_us(entry.apply_us),
300			entry.calls,
301			fmt_us(entry.lock_us),
302			entry.input_rows,
303			entry.output_rows,
304			width = max_label_width,
305		);
306	}
307}
308
309fn render_non_flow_rows(out: &mut String, summary: &ProfilerSummary, cat: ProfilerCategory, top_n: usize) {
310	let mut agg: HashMap<u64, (u64, u64)> = HashMap::new();
311	for r in &summary.records {
312		if r.category_id != cat as u8 {
313			continue;
314		}
315		let entry = agg.entry(r.callsite_id).or_insert((0, 0));
316		entry.0 = entry.0.saturating_add(r.duration_us as u64);
317		entry.1 = entry.1.saturating_add(1);
318	}
319	let mut sorted: Vec<(u64, (u64, u64))> = agg.into_iter().collect();
320	sorted.sort_by(|a, b| b.1.0.cmp(&a.1.0));
321	sorted.truncate(top_n);
322
323	if sorted.is_empty() {
324		return;
325	}
326
327	let labels: Vec<String> = sorted.iter().map(|(callsite, _)| format!("span#{}", callsite)).collect();
328	let max_label_width = labels.iter().map(|s| s.len()).max().unwrap_or(0);
329
330	for (i, (_, (total, calls))) in sorted.iter().enumerate() {
331		let _ = writeln!(
332			out,
333			"    {:<width$}  total={} calls={}",
334			labels[i],
335			fmt_us(*total),
336			calls,
337			width = max_label_width,
338		);
339	}
340}
341
342fn resolve_label(interner: Option<&DimInterner>, idx: DimIdx) -> String {
343	let resolved = interner.and_then(|i| i.resolve(idx));
344	match resolved {
345		Some(s) if !s.is_empty() => s,
346		_ => "?".to_string(),
347	}
348}
349
350fn resolve_id(interner: Option<&DimInterner>, idx: DimIdx) -> String {
351	interner.and_then(|i| i.resolve(idx)).filter(|s| !s.is_empty()).unwrap_or_else(|| idx.to_string())
352}
353
354fn category_label(c: ProfilerCategory) -> &'static str {
355	match c {
356		ProfilerCategory::Query => "Query",
357		ProfilerCategory::Txn => "Txn",
358		ProfilerCategory::Storage => "Storage",
359		ProfilerCategory::Plan => "Plan",
360		ProfilerCategory::Cdc => "Cdc",
361		ProfilerCategory::Flow => "Flow",
362		ProfilerCategory::Subscription => "Subscription",
363		ProfilerCategory::Server => "Server",
364		ProfilerCategory::Wire => "Wire",
365		ProfilerCategory::Auth => "Auth",
366		ProfilerCategory::Catalog => "Catalog",
367		ProfilerCategory::Engine => "Engine",
368		ProfilerCategory::Mutate => "Mutate",
369		ProfilerCategory::Transport => "Transport",
370		ProfilerCategory::Task => "Task",
371		ProfilerCategory::Policy => "Policy",
372		ProfilerCategory::Ffi => "Ffi",
373		ProfilerCategory::Cache => "Cache",
374		ProfilerCategory::Shape => "Shape",
375		ProfilerCategory::Api => "Api",
376		ProfilerCategory::Actor => "Actor",
377	}
378}
379
380#[cfg(test)]
381mod tests {
382	use std::sync::Arc;
383
384	use super::*;
385	use crate::{
386		category::{CATEGORY_COUNT, ProfilerCategory},
387		intern::DimInterner,
388		percentile::PercentileHistogram,
389		record::{AggregateRecord, DIM_UNSET, MAX_EXTRAS, MinimalSpanRecord},
390		scope::ScopeId,
391		summary::CategorySummary,
392	};
393
394	fn empty_summary() -> ProfilerSummary {
395		ProfilerSummary {
396			scope_id: ScopeId(1),
397			scope_name: "x",
398			started_at_nanos: 0,
399			total_duration_us: 0,
400			records: Vec::new(),
401			per_category: [CategorySummary::default(); CATEGORY_COUNT],
402			interner: None,
403		}
404	}
405
406	fn summary_with(records: Vec<MinimalSpanRecord>, interner: Option<Arc<DimInterner>>) -> ProfilerSummary {
407		ProfilerSummary::from_records(ScopeId(1), "chaindex.batch_commit", 0, 12_345, records, interner)
408	}
409
410	#[test]
411	fn empty_summary_renders_hot_empty() {
412		let s = empty_summary();
413		assert!(summary(&s, 5).ends_with(" hot=[]"));
414	}
415
416	#[test]
417	fn summary_resolves_labels_when_interner_present() {
418		let interner = Arc::new(DimInterner::new());
419		let type_idx = interner.intern("map");
420		let id_idx = interner.intern("n1");
421
422		let apply_rec = MinimalSpanRecord::new(ProfilerCategory::Flow, 100, 500)
423			.with_dimensions([type_idx, id_idx])
424			.with_extras([10, 7, 50, 0]);
425		let s = summary_with(vec![apply_rec], Some(Arc::clone(&interner)));
426
427		let line = summary(&s, 5);
428		assert!(line.contains("map@n1=500us/50lk/1c/10in/7out"), "got {}", line);
429	}
430
431	#[test]
432	fn summary_falls_back_to_placeholder_when_interner_missing() {
433		let apply_rec = MinimalSpanRecord::new(ProfilerCategory::Flow, 100, 500)
434			.with_dimensions([42, 43])
435			.with_extras([1, 1, 1, 0]);
436		let s = summary_with(vec![apply_rec], None);
437
438		let line = summary(&s, 5);
439		assert!(line.contains("?@43=500us/1lk/1c/1in/1out"), "got {}", line);
440	}
441
442	#[test]
443	fn summary_separates_process_and_apply() {
444		let process_rec = MinimalSpanRecord::new(ProfilerCategory::Flow, 200, 1000);
445		let apply_rec = MinimalSpanRecord::new(ProfilerCategory::Flow, 100, 400)
446			.with_dimensions([1, 2])
447			.with_extras([5, 3, 25, 0]);
448		let s = summary_with(vec![process_rec, apply_rec], None);
449
450		let line = summary(&s, 5);
451		assert!(line.starts_with("tick(proc=1000us/1c, apply_sum=400us/1ops, lock_sum=25us)"), "got {}", line);
452	}
453
454	#[test]
455	fn summary_aggregates_repeated_apply_per_operator() {
456		let interner = Arc::new(DimInterner::new());
457		let t = interner.intern("filter");
458		let i = interner.intern("n2");
459
460		let recs = vec![
461			MinimalSpanRecord::new(ProfilerCategory::Flow, 100, 100)
462				.with_dimensions([t, i])
463				.with_extras([5, 3, 10, 0]),
464			MinimalSpanRecord::new(ProfilerCategory::Flow, 100, 200)
465				.with_dimensions([t, i])
466				.with_extras([7, 5, 15, 0]),
467		];
468		let s = summary_with(recs, Some(Arc::clone(&interner)));
469
470		let line = summary(&s, 5);
471		assert!(line.contains("filter@n2=300us/25lk/2c/12in/8out"), "got {}", line);
472	}
473
474	#[test]
475	fn summary_table_renders_multi_line_with_categories() {
476		let interner = Arc::new(DimInterner::new());
477		let map_t = interner.intern("map");
478		let map_id = interner.intern("n1");
479		let filter_t = interner.intern("filter");
480		let filter_id = interner.intern("n2");
481
482		let recs = vec![
483			MinimalSpanRecord::new(ProfilerCategory::Flow, 100, 5000)
484				.with_dimensions([map_t, map_id])
485				.with_extras([10, 7, 100, 0]),
486			MinimalSpanRecord::new(ProfilerCategory::Flow, 100, 3000)
487				.with_dimensions([filter_t, filter_id])
488				.with_extras([5, 3, 50, 0]),
489			MinimalSpanRecord::new(ProfilerCategory::Storage, 200, 1500),
490			MinimalSpanRecord::new(ProfilerCategory::Storage, 201, 600),
491		];
492		let s = summary_with(recs, Some(Arc::clone(&interner)));
493		let table = summary_table(&s, 5);
494
495		assert!(table.starts_with("profile scope=chaindex.batch_commit total="), "first line: {}", table);
496		assert!(table.contains("Flow: 2 calls, apply="), "flow header missing: {}", table);
497		assert!(table.contains("map@n1"), "map@n1 missing: {}", table);
498		assert!(table.contains("filter@n2"), "filter@n2 missing: {}", table);
499		assert!(table.contains("io=10->7"), "io rendering missing: {}", table);
500		assert!(table.contains("Storage: 2 calls, total="), "storage header missing: {}", table);
501		assert!(!table.contains('\u{2192}'), "unicode arrow leaked into ASCII output");
502	}
503
504	#[test]
505	fn summary_table_aligns_labels_within_category() {
506		let interner = Arc::new(DimInterner::new());
507		let short = interner.intern("a");
508		let long = interner.intern("very_long_type");
509		let short_id = interner.intern("1");
510		let long_id = interner.intern("z");
511
512		let recs = vec![
513			MinimalSpanRecord::new(ProfilerCategory::Flow, 100, 100)
514				.with_dimensions([short, short_id])
515				.with_extras([0, 0, 0, 0]),
516			MinimalSpanRecord::new(ProfilerCategory::Flow, 100, 200)
517				.with_dimensions([long, long_id])
518				.with_extras([0, 0, 0, 0]),
519		];
520		let s = summary_with(recs, Some(Arc::clone(&interner)));
521		let table = summary_table(&s, 5);
522
523		let lines: Vec<&str> = table.lines().collect();
524		let short_line = lines.iter().find(|l| l.contains("a@1 ")).expect("short label line");
525		let long_line = lines.iter().find(|l| l.contains("very_long_type@z")).expect("long label line");
526		let short_apply_pos = short_line.find("apply=").unwrap();
527		let long_apply_pos = long_line.find("apply=").unwrap();
528		assert_eq!(
529			short_apply_pos, long_apply_pos,
530			"apply= columns are not aligned:\n{}\n{}",
531			short_line, long_line
532		);
533	}
534
535	#[test]
536	fn fmt_us_unit_promotion() {
537		assert_eq!(fmt_us(0), "0us");
538		assert_eq!(fmt_us(500), "500us");
539		assert_eq!(fmt_us(1_500), "1.5ms");
540		assert_eq!(fmt_us(12_345), "12.3ms");
541		assert_eq!(fmt_us(1_500_000), "1.5s");
542	}
543
544	#[test]
545	fn aggregates_table_renders_per_category() {
546		let records = vec![
547			AggregateRecord {
548				category: ProfilerCategory::Flow,
549				span_name: "flow::engine::process_batch".to_string(),
550				dimensions: Vec::new(),
551				calls: 6,
552				total_us: 1_000,
553				histogram: PercentileHistogram::new(),
554				extras_sum: [0; MAX_EXTRAS],
555			},
556			AggregateRecord {
557				category: ProfilerCategory::Flow,
558				span_name: "flow::engine::apply".to_string(),
559				dimensions: vec!["map".to_string(), "n1".to_string()],
560				calls: 3,
561				total_us: 5_000,
562				histogram: PercentileHistogram::new(),
563				extras_sum: [0; MAX_EXTRAS],
564			},
565			AggregateRecord {
566				category: ProfilerCategory::Flow,
567				span_name: "flow::engine::apply".to_string(),
568				dimensions: vec!["filter".to_string(), "n2".to_string()],
569				calls: 2,
570				total_us: 3_000,
571				histogram: PercentileHistogram::new(),
572				extras_sum: [0; MAX_EXTRAS],
573			},
574			AggregateRecord {
575				category: ProfilerCategory::Storage,
576				span_name: "store::multi::write".to_string(),
577				dimensions: Vec::new(),
578				calls: 30,
579				total_us: 1_500,
580				histogram: PercentileHistogram::new(),
581				extras_sum: [0; MAX_EXTRAS],
582			},
583		];
584		let table = aggregates_table(&records, 10);
585		assert!(table.starts_with("profile (accumulator) 4 records, 41 calls, total="));
586		assert!(table.contains("Flow: 3 records, 11 calls, total="));
587		assert!(table.contains("flow::engine::apply [2 ops, total="));
588		assert!(table.contains("\n      map@n1  "), "expected nested map@n1 row, got:\n{}", table);
589		assert!(table.contains("\n      filter@n2  "), "expected nested filter@n2 row, got:\n{}", table);
590		assert!(table.contains("\n    flow::engine::process_batch  total="));
591		assert!(table.contains("Storage: 1 records, 30 calls, total="));
592		assert!(table.contains("\n    store::multi::write  total="));
593	}
594
595	#[test]
596	fn aggregates_table_groups_flow_apply_by_operator() {
597		let mk = |op: &str, total: u64| AggregateRecord {
598			category: ProfilerCategory::Flow,
599			span_name: "flow::engine::apply".to_string(),
600			dimensions: vec![op.to_string()],
601			calls: 1,
602			total_us: total,
603			histogram: PercentileHistogram::new(),
604			extras_sum: [0; MAX_EXTRAS],
605		};
606		let records = vec![mk("op_a", 4_000), mk("op_b", 3_000), mk("op_c", 2_000), mk("op_d", 1_000)];
607		let table = aggregates_table(&records, 2);
608		assert!(table.contains("flow::engine::apply [4 ops, total="));
609		assert!(table.contains("\n      op_a  "));
610		assert!(table.contains("\n      op_b  "));
611		assert!(!table.contains("\n      op_c  "), "op_c should be truncated by top_n=2: {}", table);
612		assert!(!table.contains("\n      op_d  "), "op_d should be truncated by top_n=2: {}", table);
613	}
614
615	#[test]
616	fn aggregates_table_single_no_dim_record_renders_inline() {
617		let records = vec![AggregateRecord {
618			category: ProfilerCategory::Flow,
619			span_name: "flow::engine::process_batch".to_string(),
620			dimensions: Vec::new(),
621			calls: 5,
622			total_us: 800,
623			histogram: PercentileHistogram::new(),
624			extras_sum: [0; MAX_EXTRAS],
625		}];
626		let table = aggregates_table(&records, 10);
627		assert!(!table.contains("[1 ops"), "single no-dim record must render inline, got:\n{}", table);
628		assert!(table.contains("\n    flow::engine::process_batch  total="));
629	}
630
631	#[test]
632	fn aggregates_table_handles_empty() {
633		let table = aggregates_table(&[], 10);
634		assert!(table.contains("empty"));
635	}
636
637	#[test]
638	fn summary_table_skips_empty_categories() {
639		let recs =
640			vec![MinimalSpanRecord::new(ProfilerCategory::Flow, 100, 0)
641				.with_dimensions([DIM_UNSET, DIM_UNSET])];
642		let s = summary_with(recs, None);
643		let table = summary_table(&s, 5);
644		assert!(table.contains("Flow:"));
645		assert!(!table.contains("Query:"));
646		assert!(!table.contains("Storage:"));
647	}
648}