Skip to main content

backbeat_cli/
inspect.rs

1// Copyright (c) 2026 Cameron Bytheway
2// SPDX-License-Identifier: MIT
3
4//! `backbeat inspect`: print a dump's envelope, schema registry, and per-shard record counts.
5//!
6//! Everything here is driven by the dump's embedded registry — there is no compiled-in knowledge of
7//! any event type. A record's `event_id` is matched against the registry to attribute it to an
8//! event and validate its size; unknown ids and size mismatches are reported rather than guessed.
9
10use anyhow::Result;
11use backbeat::{
12    record::RecordView,
13    ring::walk,
14    schema::{FieldRole, Phase},
15    wire::{DumpReader, OwnedSchema},
16};
17use bytes::Bytes;
18use std::{collections::BTreeMap, io::Write};
19
20/// Reads `bytes` as a dump and writes a human-readable summary to `out`.
21pub fn inspect(bytes: impl Into<Bytes>, out: &mut impl Write) -> Result<()> {
22    let bytes = bytes.into();
23    let total_len = bytes.len();
24    let reader = DumpReader::new(bytes).map_err(|e| anyhow::anyhow!("{e}"))?;
25    let schemas = reader.schemas().map_err(|e| anyhow::anyhow!("{e}"))?;
26    let intern = reader.intern_tables().map_err(|e| anyhow::anyhow!("{e}"))?;
27    let metas = reader.metas().map_err(|e| anyhow::anyhow!("{e}"))?;
28    let views = reader.views().map_err(|e| anyhow::anyhow!("{e}"))?;
29    let shards = reader.shards().map_err(|e| anyhow::anyhow!("{e}"))?;
30
31    // Index schemas by id for record attribution.
32    let by_id: BTreeMap<u64, &OwnedSchema> = schemas.iter().map(|s| (s.id.get(), s)).collect();
33
34    writeln!(out, "envelope")?;
35    writeln!(out, "  size:      {total_len} bytes")?;
36    writeln!(out, "  flags:     {:#06x}", reader.flags())?;
37    writeln!(out, "  sections:  {}", reader.section_count())?;
38
39    writeln!(out, "\nschema registry ({} events)", schemas.len())?;
40    for s in &schemas {
41        let phase = match s.phase {
42            Phase::Enter => "  [span enter]",
43            Phase::Exit => "  [span exit]",
44            _ => "",
45        };
46        writeln!(
47            out,
48            "  {:#018x}  {}  ({} bytes, {} fields){}{}",
49            s.id.get(),
50            s.qualified_name,
51            s.record_size,
52            s.fields.len(),
53            phase,
54            s.description
55                .as_deref()
56                .map(|d| format!("  — {d}"))
57                .unwrap_or_default()
58        )?;
59        for f in &s.fields {
60            // Role marker: * key, $ span id, ^ parent span id.
61            let marker = match f.role {
62                FieldRole::Key => "*",
63                FieldRole::SpanId => "$",
64                FieldRole::ParentSpanId => "^",
65                _ => " ",
66            };
67            writeln!(
68                out,
69                "      {}{}: {:?}{}{}",
70                marker,
71                f.name,
72                f.ty,
73                f.unit
74                    .as_deref()
75                    .map(|u| format!(" [{u}]"))
76                    .unwrap_or_default(),
77                f.sentinel
78                    .map(|s| format!(" (sentinel {s})"))
79                    .unwrap_or_default()
80            )?;
81        }
82    }
83    writeln!(out, "  (* = key, $ = span id, ^ = parent span id)")?;
84
85    // Instances: a single-process dump has one; a merged dump lists each process it contains.
86    writeln!(out, "\ninstances ({})", metas.len())?;
87    for m in &metas {
88        let n_intern: usize = intern
89            .iter()
90            .filter(|t| t.instance_id == m.instance_id)
91            .map(|t| t.entries.len())
92            .sum();
93        let host = if m.host.is_empty() {
94            "(no host)"
95        } else {
96            m.host.as_str()
97        };
98        writeln!(
99            out,
100            "  {:#018x}  {host}  ({n_intern} intern entries)",
101            m.instance_id
102        )?;
103    }
104
105    let total_intern: usize = intern.iter().map(|t| t.entries.len()).sum();
106    writeln!(
107        out,
108        "\nintern table: {total_intern} entries across {} instance(s)",
109        intern.len()
110    )?;
111
112    // Registered query-DDL view sets (consumer SQL, embedded verbatim). Show a one-line summary per
113    // set rather than the full text — `convert` writes the assembled DDL to a `.sql` sidecar.
114    if !views.is_empty() {
115        let total_lines: usize = views.iter().map(|v| v.lines().count()).sum();
116        writeln!(
117            out,
118            "\nview sets ({}, {total_lines} lines of DDL)",
119            views.len()
120        )?;
121        for (i, v) in views.iter().enumerate() {
122            writeln!(
123                out,
124                "  #{}: {} bytes, {} lines",
125                i + 1,
126                v.len(),
127                v.lines().count()
128            )?;
129        }
130    }
131
132    // Walk each shard, attributing records to events and counting them. The closure is walk's
133    // validator: it accepts (and counts) only records whose event_id is in the registry with a
134    // matching record_size, and rejects anything else so walk resynchronizes past torn data.
135    writeln!(out, "\nshards ({})", shards.len())?;
136    let mut total = 0usize;
137    let mut per_event: BTreeMap<String, usize> = BTreeMap::new();
138
139    for shard in &shards {
140        let mut count = 0usize;
141        walk(
142            &shard.region,
143            shard.head as usize,
144            shard.capacity as usize,
145            |payload| match RecordView::parse(&payload[..]) {
146                Some(rec) => match by_id.get(&rec.event_id.get()) {
147                    Some(schema) if rec.fields.len() == schema.record_size as usize => {
148                        *per_event.entry(schema.qualified_name.clone()).or_default() += 1;
149                        count += 1;
150                        true
151                    }
152                    _ => false,
153                },
154                None => false,
155            },
156        );
157        total += count;
158        writeln!(
159            out,
160            "  instance {:#018x}  shard {:>3}: head {:>10}  capacity {:>10}  {} records",
161            shard.instance_id, shard.shard_id, shard.head, shard.capacity, count
162        )?;
163    }
164
165    writeln!(out, "\nrecords: {total} total")?;
166    for (name, n) in &per_event {
167        writeln!(out, "  {name}: {n}")?;
168    }
169
170    Ok(())
171}