Skip to main content

backbeat_cli/
trace.rs

1// Copyright (c) 2026 Cameron Bytheway
2// SPDX-License-Identifier: MIT
3
4//! `backbeat` → Chrome / Perfetto trace JSON.
5//!
6//! Spans become **async** events (`ph:"b"`/`"e"`, paired by `id`), not duration events (`B`/`E`):
7//! backbeat spans carry explicit ids, can overlap, may be emitted across different shards (thread
8//! migration), and may be orphaned by ring eviction — all of which violate the per-thread LIFO
9//! stack contract that `B`/`E` assume. Async events pair by explicit `id`, tolerate overlap, and
10//! need no nesting. Plain (non-span) events become instants (`ph:"i"`).
11//!
12//! Pairing is by `(instance_id, span_id)` so merged multi-process dumps never collide. Orphans (the
13//! other half evicted from the ring) are surfaced rather than dropped: an enter with no exit gets a
14//! synthetic close at the trace's max timestamp; an exit with no enter becomes a zero-width instant.
15//!
16//! The output is read by `chrome://tracing`, [Perfetto](https://ui.perfetto.dev/), and any
17//! Trace-Event-Format consumer.
18
19use crate::model::Loaded;
20use anyhow::{Context, Result};
21use backbeat::schema::{FieldType, Phase};
22use serde_json::{json, Value as Json};
23use std::{collections::HashMap, fs::File, io::Write, path::Path};
24
25/// Writes the loaded dumps to `output` as Chrome Trace Event Format JSON. Returns the event count.
26pub fn to_trace(dumps: &[Loaded], output: &Path) -> Result<usize> {
27    let events = build_events(dumps);
28    let doc = json!({
29        "displayTimeUnit": "ns",
30        "traceEvents": events,
31    });
32    let mut file =
33        File::create(output).with_context(|| format!("creating {}", output.display()))?;
34    serde_json::to_writer(&mut file, &doc).context("writing trace JSON")?;
35    file.flush().ok();
36    Ok(doc["traceEvents"].as_array().map_or(0, |a| a.len()))
37}
38
39/// A record flattened with the context needed to render it, independent of which dump it came from.
40struct Flat<'a> {
41    instance_id: u64,
42    shard_id: u32,
43    ts_nanos: u64,
44    phase: Phase,
45    span_id: Option<u64>,
46    name: &'a str,
47    /// Decoded `(field_name, json_value)` for every field (args object).
48    args: Vec<(&'a str, Json)>,
49}
50
51/// Builds the `traceEvents` array from all dumps.
52fn build_events(dumps: &[Loaded]) -> Vec<Json> {
53    // Flatten every record across every dump, decoding fields via that dump's schema + intern table.
54    let mut flat: Vec<Flat> = Vec::new();
55    let mut max_ts = 0u64;
56    for d in dumps {
57        for r in &d.records {
58            let s = &d.schemas[r.schema_idx];
59            max_ts = max_ts.max(r.ts_nanos);
60            let span_id = s
61                .span_id()
62                .and_then(|f| read_u64(&r.fields, f.offset as usize));
63            let args = s
64                .fields
65                .iter()
66                .map(|f| (f.name.as_str(), decode_json(f, &r.fields, &d.intern)))
67                .collect();
68            flat.push(Flat {
69                instance_id: d.instance_id,
70                shard_id: r.shard_id,
71                ts_nanos: r.ts_nanos,
72                phase: s.phase,
73                span_id,
74                name: &s.qualified_name,
75                args,
76            });
77        }
78    }
79
80    // Which (instance_id, span_id) keys have an enter / an exit, so we can detect orphans.
81    let mut has_enter: HashMap<(u64, u64), bool> = HashMap::new();
82    let mut has_exit: HashMap<(u64, u64), bool> = HashMap::new();
83    for f in &flat {
84        if let Some(sid) = f.span_id {
85            match f.phase {
86                Phase::Enter => *has_enter.entry((f.instance_id, sid)).or_default() = true,
87                Phase::Exit => *has_exit.entry((f.instance_id, sid)).or_default() = true,
88                _ => {}
89            }
90        }
91    }
92
93    let mut events = Vec::with_capacity(flat.len());
94    for f in &flat {
95        let cat = f.name.split("::").next().unwrap_or("");
96        let args: serde_json::Map<String, Json> = f
97            .args
98            .iter()
99            .map(|(k, v)| (k.to_string(), v.clone()))
100            .collect();
101        // pid = process (instance), tid = shard/core. Pairing is by `id`, so tid is free to show
102        // per-core activity (a span's begin and end may even land on different tids after migration).
103        let pid = f.instance_id;
104        let tid = f.shard_id;
105
106        match (f.phase, f.span_id) {
107            (Phase::Enter, Some(sid)) => {
108                events.push(async_event("b", f, sid, cat, pid, tid, args.clone()));
109                // Orphaned enter (exit evicted): synthesize a close at the trace's end.
110                if !has_exit
111                    .get(&(f.instance_id, sid))
112                    .copied()
113                    .unwrap_or(false)
114                {
115                    let mut a = args.clone();
116                    a.insert("backbeat_open".to_string(), json!(true));
117                    events.push(async_event_at("e", f, sid, cat, pid, tid, max_ts, a));
118                }
119            }
120            (Phase::Exit, Some(sid)) => {
121                // Orphaned exit (enter evicted): a zero-width instant marks where it closed.
122                if !has_enter
123                    .get(&(f.instance_id, sid))
124                    .copied()
125                    .unwrap_or(false)
126                {
127                    let mut a = args.clone();
128                    a.insert("backbeat_orphan_exit".to_string(), json!(true));
129                    events.push(instant_event(f, cat, pid, tid, a));
130                } else {
131                    events.push(async_event("e", f, sid, cat, pid, tid, args));
132                }
133            }
134            // A plain event: an instant. If it carries a parent_span_id it still renders as an
135            // instant (Chrome has no "point attached to a span"); the parent link is in args.
136            _ => events.push(instant_event(f, cat, pid, tid, args)),
137        }
138    }
139    events
140}
141
142/// An async-event (`b`/`e`) at the record's own timestamp.
143fn async_event(
144    ph: &str,
145    f: &Flat,
146    sid: u64,
147    cat: &str,
148    pid: u64,
149    tid: u32,
150    args: serde_json::Map<String, Json>,
151) -> Json {
152    async_event_at(ph, f, sid, cat, pid, tid, f.ts_nanos, args)
153}
154
155/// An async-event at an explicit timestamp (used for synthetic orphan closes).
156#[allow(clippy::too_many_arguments)]
157fn async_event_at(
158    ph: &str,
159    f: &Flat,
160    sid: u64,
161    cat: &str,
162    pid: u64,
163    tid: u32,
164    ts_nanos: u64,
165    args: serde_json::Map<String, Json>,
166) -> Json {
167    json!({
168        "ph": ph,
169        "name": f.name,
170        "cat": cat,
171        "id": format!("{sid:#018x}"),
172        "pid": pid,
173        "tid": tid,
174        "ts": ts_micros(ts_nanos),
175        "args": args,
176    })
177}
178
179/// An instant event (`ph:"i"`) at the record's timestamp.
180fn instant_event(
181    f: &Flat,
182    cat: &str,
183    pid: u64,
184    tid: u32,
185    args: serde_json::Map<String, Json>,
186) -> Json {
187    json!({
188        "ph": "i",
189        "name": f.name,
190        "cat": cat,
191        "pid": pid,
192        "tid": tid,
193        "ts": ts_micros(f.ts_nanos),
194        "s": "t", // thread-scoped instant
195        "args": args,
196    })
197}
198
199/// Trace Event Format `ts` is microseconds (fractional ok). We keep nanosecond precision via the
200/// fraction and set `displayTimeUnit: "ns"` on the document.
201fn ts_micros(ts_nanos: u64) -> f64 {
202    ts_nanos as f64 / 1000.0
203}
204
205/// Reads a little-endian `u64` at `offset`, or `None` if out of range.
206fn read_u64(bytes: &[u8], offset: usize) -> Option<u64> {
207    bytes
208        .get(offset..offset + 8)
209        .map(|s| u64::from_le_bytes(s.try_into().unwrap()))
210}
211
212/// Decodes a field to a JSON value using only its schema descriptor (mirrors the Parquet decoder).
213fn decode_json(
214    field: &backbeat::wire::OwnedField,
215    bytes: &[u8],
216    intern: &HashMap<u32, String>,
217) -> Json {
218    let start = field.offset as usize;
219    let Some(slice) = bytes.get(start..start + field.width as usize) else {
220        return Json::Null;
221    };
222    // `field.width` is read from the (possibly corrupt or foreign) dump and is not guaranteed to
223    // match the natural width of `field.ty`, so every fixed-width read goes through a fallible
224    // conversion: a mismatch renders as `null` rather than panicking the tool. This mirrors the
225    // Parquet decoder, which is already fallible throughout.
226    match field.ty {
227        FieldType::U8 => le::<1>(slice).map_or(Json::Null, |b| json!(b[0])),
228        FieldType::U16 => le::<2>(slice).map_or(Json::Null, |b| json!(u16::from_le_bytes(b))),
229        FieldType::U32 => le::<4>(slice).map_or(Json::Null, |b| json!(u32::from_le_bytes(b))),
230        FieldType::U64 => le::<8>(slice).map_or(Json::Null, |b| json!(u64::from_le_bytes(b))),
231        FieldType::I8 => le::<1>(slice).map_or(Json::Null, |b| json!(b[0] as i8)),
232        FieldType::I16 => le::<2>(slice).map_or(Json::Null, |b| json!(i16::from_le_bytes(b))),
233        FieldType::I32 => le::<4>(slice).map_or(Json::Null, |b| json!(i32::from_le_bytes(b))),
234        FieldType::I64 => le::<8>(slice).map_or(Json::Null, |b| json!(i64::from_le_bytes(b))),
235        FieldType::Bool => slice.first().map_or(Json::Null, |b| json!(*b != 0)),
236        FieldType::Bytes => json!(hex(slice)),
237        FieldType::Enum { repr } => {
238            let Some(raw_bytes) = slice.get(..repr as usize) else {
239                return Json::Null;
240            };
241            let mut buf = [0u8; 8];
242            buf[..repr as usize].copy_from_slice(raw_bytes);
243            let raw = u64::from_le_bytes(buf);
244            match field.enum_labels.iter().find(|l| l.value == raw) {
245                Some(l) => json!(l.label),
246                None => json!(raw),
247            }
248        }
249        FieldType::Interned { .. } => match le::<4>(slice) {
250            Some(b) => {
251                let id = u32::from_le_bytes(b);
252                match intern.get(&id) {
253                    Some(s) => json!(s),
254                    None => json!(format!("#{id}")),
255                }
256            }
257            None => Json::Null,
258        },
259        _ => json!(hex(slice)),
260    }
261}
262
263/// Reads exactly `N` little-endian bytes from the front of `slice`, or `None` if the field's
264/// declared width is too small to hold the type. Guards against a corrupt/foreign dump whose
265/// `field.width` disagrees with `field.ty`.
266fn le<const N: usize>(slice: &[u8]) -> Option<[u8; N]> {
267    slice.get(..N)?.try_into().ok()
268}
269
270fn hex(bytes: &[u8]) -> String {
271    let mut s = String::with_capacity(bytes.len() * 2);
272    for b in bytes {
273        s.push_str(&format!("{b:02x}"));
274    }
275    s
276}