1use 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}