lib/tracy/src/tree.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const pretty_json = @import("pretty").json;
  3 const sys = @import("sys");
  4 const capture_mod = @import("capture.zig");
  5 const event = @import("event.zig");
  6 const record_mod = @import("record.zig");
  7 const transport = @import("transport.zig");
  8 
  9 pub const schema = "tracy.tree/v0";
 10 pub const CaptureIntegrity = capture_mod.Integrity;
 11 
 12 pub const Options = struct {
 13     top: usize = 200,
 14     max_depth: usize = 16,
 15     min_duration_ns: u64 = 0,
 16     thread: ?u64 = null,
 17 };
 18 
 19 pub const Counters = struct {
 20     events: u64 = 0,
 21     started_zones: u64 = 0,
 22     completed_zones: u64 = 0,
 23     valid_duration_samples: u64 = 0,
 24     zone_timestamp_regressions: u64 = 0,
 25     open_zones: u64 = 0,
 26     unmatched_zone_ends: u64 = 0,
 27     duplicate_zone_begins: u64 = 0,
 28     out_of_order_zone_ends: u64 = 0,
 29     thread_names: u64 = 0,
 30     zone_texts: u64 = 0,
 31 };
 32 
 33 pub const SpanState = enum {
 34     open,
 35     complete,
 36     superseded,
 37 
 38     pub fn tag(self: SpanState) []const u8 {
 39         return @tagName(self);
 40     }
 41 };
 42 
 43 pub const Span = struct {
 44     id: u64,
 45     parent_id: ?u64 = null,
 46     name: []const u8,
 47     text: ?[]const u8 = null,
 48     file: ?[]const u8 = null,
 49     function: ?[]const u8 = null,
 50     line: u32 = 0,
 51     column: u32 = 0,
 52     thread: u64 = 0,
 53     depth: usize = 0,
 54     start_ns: u64 = 0,
 55     end_ns: u64 = 0,
 56     total_ns: u64 = 0,
 57     self_ns: u64 = 0,
 58     child_ns: u64 = 0,
 59     children: u64 = 0,
 60     value: ?u64 = null,
 61     color: ?u32 = null,
 62     state: SpanState,
 63     duration_valid: bool,
 64 };
 65 
 66 const Node = struct {
 67     id: u64,
 68     parent: ?usize = null,
 69     first_child: ?usize = null,
 70     last_child: ?usize = null,
 71     next_sibling: ?usize = null,
 72     name: []u8,
 73     text: ?[]u8 = null,
 74     file: ?[]u8 = null,
 75     function: ?[]u8 = null,
 76     line: u32 = 0,
 77     column: u32 = 0,
 78     thread: u64 = 0,
 79     start_ns: u64 = 0,
 80     end_ns: u64 = 0,
 81     child_ns: u64 = 0,
 82     child_count: u64 = 0,
 83     value: ?u64 = null,
 84     color: ?u32 = null,
 85     state: SpanState = .open,
 86 
 87     fn init(
 88         allocator: std.mem.Allocator,
 89         parsed: event.Parsed,
 90         parent: ?usize,
 91     ) !Node {
 92         var self: Node = .{
 93             .id = parsed.id,
 94             .parent = parent,
 95             .name = try allocator.dupe(u8, parsed.name orelse "<zone>"),
 96             .line = parsed.line,
 97             .column = parsed.column,
 98             .thread = parsed.thread,
 99             .start_ns = parsed.time_ns,
100             .color = parsed.color,
101         };
102         errdefer self.deinit(allocator);
103         self.file = try dupeOptional(allocator, parsed.file);
104         self.function = try dupeOptional(allocator, parsed.function);
105         return self;
106     }
107 
108     fn deinit(self: *Node, allocator: std.mem.Allocator) void {
109         allocator.free(self.name);
110         if (self.text) |text| allocator.free(text);
111         if (self.file) |file| allocator.free(file);
112         if (self.function) |function| allocator.free(function);
113         self.* = undefined;
114     }
115 
116     fn durationValid(self: Node) bool {
117         return self.state == .complete and self.end_ns >= self.start_ns;
118     }
119 
120     fn durationNs(self: Node) u64 {
121         if (!self.durationValid()) return 0;
122         return self.end_ns - self.start_ns;
123     }
124 
125     fn selfNs(self: Node) u64 {
126         const duration = self.durationNs();
127         if (duration <= self.child_ns) return 0;
128         return duration - self.child_ns;
129     }
130 };
131 
132 const ThreadState = struct {
133     id: u64,
134     name: ?[]u8 = null,
135     stack: std.ArrayListUnmanaged(usize) = .empty,
136     span_count: u64 = 0,
137     root_count: u64 = 0,
138     span_total_ns: u64 = 0,
139     root_total_ns: u64 = 0,
140 
141     fn deinit(self: *ThreadState, allocator: std.mem.Allocator) void {
142         if (self.name) |name| allocator.free(name);
143         self.stack.deinit(allocator);
144         self.* = undefined;
145     }
146 };
147 
148 const ThreadView = struct {
149     id: u64,
150     name: ?[]const u8,
151     span_count: u64,
152     root_count: u64,
153     span_total_ns: u64,
154     root_total_ns: u64,
155 };
156 
157 pub const Evidence = struct {
158     status: []const u8,
159     capture: CaptureIntegrity,
160     started_zones: u64,
161     completed_zones: u64,
162     valid_duration_samples: u64,
163     open_zones: u64,
164     unmatched_zone_ends: u64,
165     duplicate_zone_begins: u64,
166     out_of_order_zone_ends: u64,
167     zone_timestamp_regressions: u64,
168 
169     pub fn writeText(self: Evidence, writer: *std.Io.Writer) !void {
170         try writer.print(
171             "tracy tree_evidence={s} started_zones={d} completed_zones={d} " ++
172                 "valid_duration_samples={d} open_zones={d} " ++
173                 "unmatched_zone_ends={d} duplicate_zone_begins={d} " ++
174                 "out_of_order_zone_ends={d} zone_timestamp_regressions={d}\n",
175             .{
176                 self.status,
177                 self.started_zones,
178                 self.completed_zones,
179                 self.valid_duration_samples,
180                 self.open_zones,
181                 self.unmatched_zone_ends,
182                 self.duplicate_zone_begins,
183                 self.out_of_order_zone_ends,
184                 self.zone_timestamp_regressions,
185             },
186         );
187         try capture_mod.writeText(writer, self.capture);
188     }
189 
190     pub fn writeFields(self: Evidence, object: pretty_json.Object) !void {
191         const evidence = try object.object("tree_evidence");
192         try evidence.field("status", self.status);
193         try evidence.field("started_zones", self.started_zones);
194         try evidence.field("completed_zones", self.completed_zones);
195         try evidence.field("valid_duration_samples", self.valid_duration_samples);
196         try evidence.field("open_zones", self.open_zones);
197         try evidence.field("unmatched_zone_ends", self.unmatched_zone_ends);
198         try evidence.field("duplicate_zone_begins", self.duplicate_zone_begins);
199         try evidence.field("out_of_order_zone_ends", self.out_of_order_zone_ends);
200         try evidence.field("zone_timestamp_regressions", self.zone_timestamp_regressions);
201         try evidence.end();
202         try capture_mod.writeFields(object, self.capture);
203     }
204 };
205 
206 pub const Analyzer = struct {
207     allocator: std.mem.Allocator,
208     nodes: std.ArrayListUnmanaged(Node) = .empty,
209     active: std.AutoHashMapUnmanaged(u64, usize) = .{},
210     threads: std.AutoHashMapUnmanaged(u64, ThreadState) = .{},
211     capture: capture_mod.Tracker = .{},
212     counters: Counters = .{},
213     start_ns: ?u64 = null,
214     end_ns: ?u64 = null,
215 
216     pub fn init(allocator: std.mem.Allocator) Analyzer {
217         return .{ .allocator = allocator };
218     }
219 
220     pub fn deinit(self: *Analyzer) void {
221         for (self.nodes.items) |*node| node.deinit(self.allocator);
222         self.nodes.deinit(self.allocator);
223         self.active.deinit(self.allocator);
224         var thread_iter = self.threads.valueIterator();
225         while (thread_iter.next()) |thread| thread.deinit(self.allocator);
226         self.threads.deinit(self.allocator);
227         self.* = undefined;
228     }
229 
230     pub fn ingestJsonlBytes(self: *Analyzer, bytes: []const u8) !void {
231         var lines = std.mem.splitScalar(u8, bytes, '\n');
232         while (lines.next()) |line| try self.ingestJsonLine(line);
233         self.finishOpenCount();
234     }
235 
236     pub fn ingestJsonLine(self: *Analyzer, line: []const u8) !void {
237         const text = std.mem.trim(u8, line, " \t\r\n");
238         if (text.len == 0) return;
239         var parsed = try record_mod.parseLine(self.allocator, text);
240         defer parsed.deinit();
241         switch (parsed) {
242             .event => |value| try self.ingest(value),
243             .flight => |report_value| self.recordFlightReport(report_value),
244         }
245     }
246 
247     pub fn recordFlightReport(self: *Analyzer, report_value: transport.Report) void {
248         self.capture.recordFlightReport(report_value);
249     }
250 
251     pub fn ingest(self: *Analyzer, parsed: event.Parsed) !void {
252         self.capture.record(parsed);
253         self.counters.events += 1;
254         if (self.start_ns == null and parsed.time_ns != 0) self.start_ns = parsed.time_ns;
255         if (parsed.time_ns != 0) self.end_ns = parsed.time_ns;
256         switch (parsed.kind) {
257             .start => {
258                 if (parsed.time_ns != 0) self.start_ns = parsed.time_ns;
259             },
260             .stop => {
261                 if (parsed.time_ns != 0) self.end_ns = parsed.time_ns;
262             },
263             .zone_begin => try self.recordZoneBegin(parsed),
264             .zone_end => try self.recordZoneEnd(parsed),
265             .zone_text => try self.recordZoneText(parsed),
266             .zone_name => try self.recordZoneName(parsed),
267             .zone_color => self.recordZoneColor(parsed),
268             .zone_value => self.recordZoneValue(parsed),
269             .thread_name => try self.recordThreadName(parsed),
270             else => {},
271         }
272     }
273 
274     pub fn writeText(self: *Analyzer, writer: *std.Io.Writer, options: Options) !void {
275         self.finishOpenCount();
276         var threads = try self.collectThreads();
277         defer threads.deinit(self.allocator);
278         try writer.print(
279             "tracy tree spans={d} completed={d} open={d} threads={d} roots={d} unmatched_zone_ends={d} out_of_order_zone_ends={d} duration_ns={d}\n",
280             .{
281                 self.counters.started_zones,
282                 self.counters.completed_zones,
283                 self.counters.open_zones,
284                 threads.items.len,
285                 rootCount(threads.items),
286                 self.counters.unmatched_zone_ends,
287                 self.counters.out_of_order_zone_ends,
288                 self.durationNs(),
289             },
290         );
291         try self.evidence().writeText(writer);
292         var shown: usize = 0;
293         for (threads.items) |thread| {
294             if (options.thread) |wanted| {
295                 if (thread.id != wanted) continue;
296             }
297             try writer.print("thread id={d} name=", .{thread.id});
298             try pretty_json.writeString(writer, thread.name orelse "");
299             try writer.print(
300                 " spans={d} roots={d} span_total_ns={d} root_total_ns={d}\n",
301                 .{ thread.span_count, thread.root_count, thread.span_total_ns, thread.root_total_ns },
302             );
303             for (self.nodes.items, 0..) |node, index| {
304                 if (node.parent != null or node.thread != thread.id) continue;
305                 try self.writeNodeText(writer, index, 0, options, &shown);
306                 if (shown >= options.top) break;
307             }
308             if (shown >= options.top) break;
309         }
310     }
311 
312     pub fn writeJsonl(self: *Analyzer, writer: *std.Io.Writer, options: Options) !void {
313         self.finishOpenCount();
314         var threads = try self.collectThreads();
315         defer threads.deinit(self.allocator);
316         var summary_stream = pretty_json.Writer.init(writer, .minified);
317         const summary = try summary_stream.object();
318         try summary.field("schema", schema);
319         try summary.field("kind", "summary");
320         try summary.field("spans", self.counters.started_zones);
321         try summary.field("completed", self.counters.completed_zones);
322         try summary.field("open", self.counters.open_zones);
323         try summary.field("threads", threads.items.len);
324         try summary.field("roots", rootCount(threads.items));
325         try summary.field("unmatched_zone_ends", self.counters.unmatched_zone_ends);
326         try summary.field("out_of_order_zone_ends", self.counters.out_of_order_zone_ends);
327         try summary.field("duration_ns", self.durationNs());
328         try self.evidence().writeFields(summary);
329         try summary.endLine();
330         var shown: usize = 0;
331         for (threads.items) |thread| {
332             if (options.thread) |wanted| {
333                 if (thread.id != wanted) continue;
334             }
335             var stream = pretty_json.Writer.init(writer, .minified);
336             const object = try stream.object();
337             try object.field("schema", schema);
338             try object.field("kind", "thread");
339             try object.field("id", thread.id);
340             if (thread.name) |name| try object.field("name", name);
341             try object.field("spans", thread.span_count);
342             try object.field("roots", thread.root_count);
343             try object.field("span_total_ns", thread.span_total_ns);
344             try object.field("root_total_ns", thread.root_total_ns);
345             try object.endLine();
346             for (self.nodes.items, 0..) |node, index| {
347                 if (node.parent != null or node.thread != thread.id) continue;
348                 try self.writeNodeJsonl(writer, index, 0, options, &shown);
349                 if (shown >= options.top) break;
350             }
351             if (shown >= options.top) break;
352         }
353     }
354 
355     pub fn durationNs(self: Analyzer) u64 {
356         const start_ns = self.start_ns orelse return 0;
357         const end_ns = self.end_ns orelse return 0;
358         if (end_ns <= start_ns) return 0;
359         return end_ns - start_ns;
360     }
361 
362     pub fn evidence(self: *const Analyzer) Evidence {
363         const open_zones: u64 = @intCast(self.active.count());
364         const capture = self.capture.integrity(open_zones +| self.counters.unmatched_zone_ends);
365         const anomalies = self.counters.duplicate_zone_begins +|
366             self.counters.out_of_order_zone_ends +|
367             self.counters.zone_timestamp_regressions;
368         return .{
369             .status = if (std.mem.eql(u8, capture.status, "complete") and anomalies == 0)
370                 "complete"
371             else
372                 "partial",
373             .capture = capture,
374             .started_zones = self.counters.started_zones,
375             .completed_zones = self.counters.completed_zones,
376             .valid_duration_samples = self.counters.valid_duration_samples,
377             .open_zones = open_zones,
378             .unmatched_zone_ends = self.counters.unmatched_zone_ends,
379             .duplicate_zone_begins = self.counters.duplicate_zone_begins,
380             .out_of_order_zone_ends = self.counters.out_of_order_zone_ends,
381             .zone_timestamp_regressions = self.counters.zone_timestamp_regressions,
382         };
383     }
384 
385     pub fn collectSpans(self: *Analyzer, allocator: std.mem.Allocator) !std.ArrayListUnmanaged(Span) {
386         var spans: std.ArrayListUnmanaged(Span) = .empty;
387         try spans.ensureTotalCapacity(allocator, self.nodes.items.len);
388         for (self.nodes.items, 0..) |_, index| {
389             spans.appendAssumeCapacity(self.spanView(index));
390         }
391         return spans;
392     }
393 
394     fn recordZoneBegin(self: *Analyzer, parsed: event.Parsed) !void {
395         if (parsed.id == 0) return;
396         if (self.active.fetchRemove(parsed.id)) |removed| {
397             const old_index = removed.value;
398             const old_thread = self.nodes.items[old_index].thread;
399             self.nodes.items[old_index].state = .superseded;
400             if (self.threads.getPtr(old_thread)) |thread| {
401                 discardFromStack(thread, old_index);
402             }
403             self.counters.duplicate_zone_begins += 1;
404         }
405         const thread = try self.threadState(parsed.thread);
406         const parent = if (thread.stack.items.len == 0) null else thread.stack.items[thread.stack.items.len - 1];
407         const index = self.nodes.items.len;
408         var node = try Node.init(self.allocator, parsed, parent);
409         var node_owned = true;
410         errdefer if (node_owned) node.deinit(self.allocator);
411         try self.nodes.append(self.allocator, node);
412         node_owned = false;
413         if (parent) |parent_index| {
414             const parent_node = &self.nodes.items[parent_index];
415             if (parent_node.last_child) |last_child| {
416                 self.nodes.items[last_child].next_sibling = index;
417             } else {
418                 parent_node.first_child = index;
419             }
420             parent_node.last_child = index;
421             parent_node.child_count += 1;
422         } else {
423             thread.root_count += 1;
424         }
425         thread.span_count += 1;
426         try thread.stack.append(self.allocator, index);
427         try self.active.put(self.allocator, parsed.id, index);
428         self.counters.started_zones += 1;
429     }
430 
431     fn recordZoneEnd(self: *Analyzer, parsed: event.Parsed) !void {
432         const removed = self.active.fetchRemove(parsed.id) orelse {
433             self.counters.unmatched_zone_ends += 1;
434             return;
435         };
436         const index = removed.value;
437         const node = &self.nodes.items[index];
438         node.end_ns = parsed.time_ns;
439         node.state = .complete;
440         self.counters.completed_zones += 1;
441         if (node.durationValid()) {
442             self.counters.valid_duration_samples += 1;
443         } else {
444             self.counters.zone_timestamp_regressions += 1;
445         }
446         const duration = node.durationNs();
447         if (node.parent) |parent| self.nodes.items[parent].child_ns +|= duration;
448         const thread = try self.threadState(node.thread);
449         thread.span_total_ns +|= duration;
450         if (node.parent == null) thread.root_total_ns +|= duration;
451         self.removeFromStack(thread, index);
452     }
453 
454     fn recordZoneName(self: *Analyzer, parsed: event.Parsed) !void {
455         const name = parsed.name orelse return;
456         const index = self.active.get(parsed.id) orelse return;
457         const node = &self.nodes.items[index];
458         self.allocator.free(node.name);
459         node.name = try self.allocator.dupe(u8, name);
460     }
461 
462     fn recordZoneText(self: *Analyzer, parsed: event.Parsed) !void {
463         const text = parsed.text orelse return;
464         const index = self.active.get(parsed.id) orelse return;
465         const node = &self.nodes.items[index];
466         node.text = try appendText(self.allocator, node.text, text);
467         self.counters.zone_texts += 1;
468     }
469 
470     fn recordZoneColor(self: *Analyzer, parsed: event.Parsed) void {
471         const color = parsed.color orelse return;
472         const index = self.active.get(parsed.id) orelse return;
473         self.nodes.items[index].color = color;
474     }
475 
476     fn recordZoneValue(self: *Analyzer, parsed: event.Parsed) void {
477         const value = parsed.value_u64 orelse return;
478         const index = self.active.get(parsed.id) orelse return;
479         self.nodes.items[index].value = value;
480     }
481 
482     fn recordThreadName(self: *Analyzer, parsed: event.Parsed) !void {
483         const name = parsed.name orelse return;
484         const thread = try self.threadState(parsed.thread);
485         if (thread.name) |old| self.allocator.free(old);
486         thread.name = try self.allocator.dupe(u8, name);
487         self.counters.thread_names += 1;
488     }
489 
490     fn threadState(self: *Analyzer, id: u64) !*ThreadState {
491         const entry = try self.threads.getOrPut(self.allocator, id);
492         if (!entry.found_existing) entry.value_ptr.* = .{ .id = id };
493         return entry.value_ptr;
494     }
495 
496     fn removeFromStack(self: *Analyzer, thread: *ThreadState, index: usize) void {
497         if (thread.stack.items.len == 0) {
498             self.counters.out_of_order_zone_ends += 1;
499             return;
500         }
501         if (thread.stack.items[thread.stack.items.len - 1] == index) {
502             _ = thread.stack.pop();
503             return;
504         }
505         var cursor = thread.stack.items.len;
506         while (cursor > 0) {
507             cursor -= 1;
508             if (thread.stack.items[cursor] == index) {
509                 _ = thread.stack.orderedRemove(cursor);
510                 self.counters.out_of_order_zone_ends += 1;
511                 return;
512             }
513         }
514         self.counters.out_of_order_zone_ends += 1;
515     }
516 
517     fn discardFromStack(thread: *ThreadState, index: usize) void {
518         var cursor = thread.stack.items.len;
519         while (cursor > 0) {
520             cursor -= 1;
521             if (thread.stack.items[cursor] != index) continue;
522             _ = thread.stack.orderedRemove(cursor);
523             return;
524         }
525     }
526 
527     fn finishOpenCount(self: *Analyzer) void {
528         self.counters.open_zones = @intCast(self.active.count());
529     }
530 
531     fn collectThreads(self: *Analyzer) !std.ArrayListUnmanaged(ThreadView) {
532         var threads: std.ArrayListUnmanaged(ThreadView) = .empty;
533         var iter = self.threads.valueIterator();
534         while (iter.next()) |thread| {
535             if (thread.span_count == 0 and thread.name == null) continue;
536             try threads.append(self.allocator, .{
537                 .id = thread.id,
538                 .name = thread.name,
539                 .span_count = thread.span_count,
540                 .root_count = thread.root_count,
541                 .span_total_ns = thread.span_total_ns,
542                 .root_total_ns = thread.root_total_ns,
543             });
544         }
545         std.mem.sort(ThreadView, threads.items, {}, threadLessThan);
546         return threads;
547     }
548 
549     fn spanView(self: *Analyzer, index: usize) Span {
550         const node = self.nodes.items[index];
551         return .{
552             .id = node.id,
553             .parent_id = if (node.parent) |parent| self.nodes.items[parent].id else null,
554             .name = node.name,
555             .text = node.text,
556             .file = node.file,
557             .function = node.function,
558             .line = node.line,
559             .column = node.column,
560             .thread = node.thread,
561             .depth = self.depthOf(index),
562             .start_ns = node.start_ns,
563             .end_ns = node.end_ns,
564             .total_ns = node.durationNs(),
565             .self_ns = node.selfNs(),
566             .child_ns = node.child_ns,
567             .children = node.child_count,
568             .value = node.value,
569             .color = node.color,
570             .state = node.state,
571             .duration_valid = node.durationValid(),
572         };
573     }
574 
575     fn depthOf(self: Analyzer, index: usize) usize {
576         var depth: usize = 0;
577         var cursor = self.nodes.items[index].parent;
578         while (cursor) |parent| {
579             depth += 1;
580             cursor = self.nodes.items[parent].parent;
581         }
582         return depth;
583     }
584 
585     fn writeNodeText(
586         self: *Analyzer,
587         writer: *std.Io.Writer,
588         index: usize,
589         depth: usize,
590         options: Options,
591         shown: *usize,
592     ) !void {
593         if (shown.* >= options.top or depth > options.max_depth) return;
594         const node = self.nodes.items[index];
595         if (node.durationNs() < options.min_duration_ns) return;
596         for (0..depth) |_| try writer.writeAll("  ");
597         try writer.print(
598             "span id={d} depth={d} state={s} name=",
599             .{ node.id, depth, node.state.tag() },
600         );
601         try pretty_json.writeString(writer, node.name);
602         try writer.print(
603             " total_ns={d} self_ns={d} child_ns={d} start_ns={d} end_ns={d} " ++
604                 "children={d} duration_valid={}",
605             .{
606                 node.durationNs(),
607                 node.selfNs(),
608                 node.child_ns,
609                 node.start_ns,
610                 node.end_ns,
611                 node.child_count,
612                 node.durationValid(),
613             },
614         );
615         if (node.file) |file| try writer.print(" file={s}:{d}", .{ file, node.line });
616         if (node.function) |function| {
617             try writer.writeAll(" function=");
618             try pretty_json.writeString(writer, function);
619         }
620         if (node.text) |text| {
621             try writer.writeAll(" text=");
622             try pretty_json.writeString(writer, text);
623         }
624         if (node.value) |value| try writer.print(" value={d}", .{value});
625         if (node.color) |color| try writer.print(" color={d}", .{color});
626         try writer.writeByte('\n');
627         shown.* += 1;
628         var child = node.first_child;
629         while (child) |child_index| {
630             try self.writeNodeText(writer, child_index, depth + 1, options, shown);
631             if (shown.* >= options.top) return;
632             child = self.nodes.items[child_index].next_sibling;
633         }
634     }
635 
636     fn writeNodeJsonl(
637         self: *Analyzer,
638         writer: *std.Io.Writer,
639         index: usize,
640         depth: usize,
641         options: Options,
642         shown: *usize,
643     ) !void {
644         if (shown.* >= options.top or depth > options.max_depth) return;
645         const node = self.nodes.items[index];
646         if (node.durationNs() < options.min_duration_ns) return;
647         var stream = pretty_json.Writer.init(writer, .minified);
648         const object = try stream.object();
649         try object.field("schema", schema);
650         try object.field("kind", "span");
651         try object.field("id", node.id);
652         if (node.parent) |parent| try object.field("parent_id", self.nodes.items[parent].id);
653         try object.field("thread", node.thread);
654         try object.field("depth", depth);
655         try object.field("state", node.state.tag());
656         try object.field("duration_valid", node.durationValid());
657         try object.field("name", node.name);
658         if (self.threads.get(node.thread)) |thread| {
659             if (thread.name) |name| try object.field("thread_name", name);
660         }
661         try object.field("start_ns", node.start_ns);
662         try object.field("end_ns", node.end_ns);
663         try object.field("total_ns", node.durationNs());
664         try object.field("self_ns", node.selfNs());
665         try object.field("child_ns", node.child_ns);
666         try object.field("children", node.child_count);
667         if (node.file) |file| {
668             try object.field("file", file);
669             try object.field("line", node.line);
670         }
671         if (node.function) |function| try object.field("function", function);
672         if (node.text) |text| try object.field("text", text);
673         if (node.value) |value| try object.field("value", value);
674         if (node.color) |color| try object.field("color", color);
675         try object.endLine();
676         shown.* += 1;
677         var child = node.first_child;
678         while (child) |child_index| {
679             try self.writeNodeJsonl(writer, child_index, depth + 1, options, shown);
680             if (shown.* >= options.top) return;
681             child = self.nodes.items[child_index].next_sibling;
682         }
683     }
684 };
685 
686 pub fn writeTextFromJsonlPath(
687     allocator: std.mem.Allocator,
688     path: []const u8,
689     writer: *std.Io.Writer,
690     options: Options,
691 ) !void {
692     var analyzer = Analyzer.init(allocator);
693     defer analyzer.deinit();
694     try ingestPath(&analyzer, path);
695     try analyzer.writeText(writer, options);
696 }
697 
698 pub fn writeJsonlFromJsonlPath(
699     allocator: std.mem.Allocator,
700     path: []const u8,
701     writer: *std.Io.Writer,
702     options: Options,
703 ) !void {
704     var analyzer = Analyzer.init(allocator);
705     defer analyzer.deinit();
706     try ingestPath(&analyzer, path);
707     try analyzer.writeJsonl(writer, options);
708 }
709 
710 pub fn ingestPath(analyzer: *Analyzer, path: []const u8) !void {
711     var file = try sys.fs.cwd().openFile(sys.fs.debugIo(), path, .{});
712     defer file.close(sys.fs.debugIo());
713 
714     var buffer: [64 * 1024]u8 = undefined;
715     var reader = file.reader(sys.fs.debugIo(), &buffer);
716     while (true) {
717         const line = reader.interface.takeDelimiter('\n') catch |err| switch (err) {
718             error.ReadFailed => return reader.err.?,
719             else => return err,
720         };
721         const actual = line orelse break;
722         try analyzer.ingestJsonLine(actual);
723     }
724     analyzer.finishOpenCount();
725 }
726 
727 fn rootCount(threads: []const ThreadView) u64 {
728     var count: u64 = 0;
729     for (threads) |thread| count += thread.root_count;
730     return count;
731 }
732 
733 fn threadLessThan(_: void, left: ThreadView, right: ThreadView) bool {
734     return left.id < right.id;
735 }
736 
737 fn dupeOptional(allocator: std.mem.Allocator, text: ?[]const u8) !?[]u8 {
738     const actual = text orelse return null;
739     return try allocator.dupe(u8, actual);
740 }
741 
742 fn appendText(allocator: std.mem.Allocator, existing: ?[]u8, addition: []const u8) ![]u8 {
743     if (existing) |old| {
744         const joined = try allocator.alloc(u8, old.len + 1 + addition.len);
745         @memcpy(joined[0..old.len], old);
746         joined[old.len] = '\n';
747         @memcpy(joined[old.len + 1 ..], addition);
748         allocator.free(old);
749         return joined;
750     }
751     return try allocator.dupe(u8, addition);
752 }
753 
754 test "tree reconstructs nested spans and self time" {
755     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
756     defer trace.deinit();
757     try (event.TraceEvent{ .seq = 1, .kind = .start, .time_ns = 100, .thread = 1, .name = "test" }).writeJsonLine(&trace.writer);
758     try (event.TraceEvent{ .seq = 2, .kind = .thread_name, .time_ns = 105, .thread = 1, .name = "main" }).writeJsonLine(&trace.writer);
759     try (event.TraceEvent{ .seq = 3, .kind = .zone_begin, .time_ns = 110, .thread = 1, .id = 1, .name = "root", .file = "root.zig", .line = 7 }).writeJsonLine(&trace.writer);
760     try (event.TraceEvent{ .seq = 4, .kind = .zone_begin, .time_ns = 120, .thread = 1, .id = 2, .name = "child" }).writeJsonLine(&trace.writer);
761     try (event.TraceEvent{ .seq = 5, .kind = .zone_end, .time_ns = 150, .thread = 1, .id = 2 }).writeJsonLine(&trace.writer);
762     try (event.TraceEvent{ .seq = 6, .kind = .zone_end, .time_ns = 180, .thread = 1, .id = 1 }).writeJsonLine(&trace.writer);
763     try (event.TraceEvent{ .seq = 7, .kind = .stop, .time_ns = 190, .thread = 1 }).writeJsonLine(&trace.writer);
764 
765     var analyzer = Analyzer.init(std.testing.allocator);
766     defer analyzer.deinit();
767     try analyzer.ingestJsonlBytes(trace.written());
768 
769     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
770     defer out.deinit();
771     try analyzer.writeText(&out.writer, .{ .top = 8 });
772     const text = out.written();
773     try std.testing.expect(std.mem.indexOf(u8, text, "thread id=1 name=\"main\"") != null);
774     try std.testing.expect(std.mem.indexOf(u8, text, "span id=1 depth=0 state=complete name=\"root\" total_ns=70 self_ns=40 child_ns=30") != null);
775     try std.testing.expect(std.mem.indexOf(u8, text, "span id=2 depth=1 state=complete name=\"child\" total_ns=30 self_ns=30 child_ns=0") != null);
776 }
777 
778 test "tree jsonl emits parent ids and dynamic metadata" {
779     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
780     defer trace.deinit();
781     try (event.TraceEvent{ .seq = 1, .kind = .zone_begin, .time_ns = 100, .thread = 2, .id = 1, .name = "root" }).writeJsonLine(&trace.writer);
782     try (event.TraceEvent{ .seq = 2, .kind = .zone_begin, .time_ns = 120, .thread = 2, .id = 2, .name = "child" }).writeJsonLine(&trace.writer);
783     try (event.TraceEvent{ .seq = 3, .kind = .zone_name, .time_ns = 125, .thread = 2, .id = 2, .name = "renamed" }).writeJsonLine(&trace.writer);
784     try (event.TraceEvent{ .seq = 4, .kind = .zone_value, .time_ns = 126, .thread = 2, .id = 2, .value_u64 = 42 }).writeJsonLine(&trace.writer);
785     try (event.TraceEvent{ .seq = 5, .kind = .zone_color, .time_ns = 127, .thread = 2, .id = 2, .color = 0x112233 }).writeJsonLine(&trace.writer);
786     try (event.TraceEvent{ .seq = 6, .kind = .zone_text, .time_ns = 128, .thread = 2, .id = 2, .text = "first note" }).writeJsonLine(&trace.writer);
787     try (event.TraceEvent{ .seq = 7, .kind = .zone_text, .time_ns = 129, .thread = 2, .id = 2, .text = "second note" }).writeJsonLine(&trace.writer);
788     try (event.TraceEvent{ .seq = 8, .kind = .zone_end, .time_ns = 160, .thread = 2, .id = 2 }).writeJsonLine(&trace.writer);
789     try (event.TraceEvent{ .seq = 9, .kind = .zone_end, .time_ns = 200, .thread = 2, .id = 1 }).writeJsonLine(&trace.writer);
790 
791     var analyzer = Analyzer.init(std.testing.allocator);
792     defer analyzer.deinit();
793     try analyzer.ingestJsonlBytes(trace.written());
794 
795     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
796     defer out.deinit();
797     try analyzer.writeJsonl(&out.writer, .{ .top = 8 });
798     const text = out.written();
799     try std.testing.expect(std.mem.indexOf(u8, text, "\"schema\":\"tracy.tree/v0\"") != null);
800     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"thread\"") != null);
801     try std.testing.expect(std.mem.indexOf(u8, text, "\"parent_id\":1") != null);
802     try std.testing.expect(std.mem.indexOf(u8, text, "\"name\":\"renamed\"") != null);
803     try std.testing.expect(std.mem.indexOf(u8, text, "\"text\":\"first note\\nsecond note\"") != null);
804     try std.testing.expect(std.mem.indexOf(u8, text, "\"value\":42") != null);
805     try std.testing.expect(std.mem.indexOf(u8, text, "\"color\":1122867") != null);
806 
807     var text_out = std.Io.Writer.Allocating.init(std.testing.allocator);
808     defer text_out.deinit();
809     try analyzer.writeText(&text_out.writer, .{ .top = 8 });
810     try std.testing.expect(std.mem.indexOf(u8, text_out.written(), "text=\"first note\\nsecond note\"") != null);
811 }
812 
813 test "tree distinguishes lifecycle and duration evidence" {
814     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
815     defer trace.deinit();
816     try writeTreeIntegrityFixture(&trace.writer);
817 
818     var analyzer = Analyzer.init(std.testing.allocator);
819     defer analyzer.deinit();
820     try analyzer.ingestJsonlBytes(trace.written());
821     const evidence_value = analyzer.evidence();
822     try std.testing.expectEqualStrings("partial", evidence_value.status);
823     try std.testing.expectEqualStrings("complete", evidence_value.capture.status);
824     try std.testing.expectEqual(@as(u64, 6), evidence_value.started_zones);
825     try std.testing.expectEqual(@as(u64, 5), evidence_value.completed_zones);
826     try std.testing.expectEqual(@as(u64, 4), evidence_value.valid_duration_samples);
827     try std.testing.expectEqual(@as(u64, 1), evidence_value.duplicate_zone_begins);
828     try std.testing.expectEqual(@as(u64, 1), evidence_value.out_of_order_zone_ends);
829     try std.testing.expectEqual(@as(u64, 1), evidence_value.zone_timestamp_regressions);
830     try std.testing.expectEqual(
831         @as(u64, 2),
832         evidence_value.capture.flight_report.?.overwritten_events,
833     );
834 
835     var spans = try analyzer.collectSpans(std.testing.allocator);
836     defer spans.deinit(std.testing.allocator);
837     try expectTreeIntegritySpanStates(spans.items);
838 }
839 
840 fn writeTreeIntegrityFixture(writer: *std.Io.Writer) !void {
841     const rows = [_]event.TraceEvent{
842         .{ .seq = 1, .kind = .start, .time_ns = 80 },
843         .{ .seq = 2, .kind = .zone_begin, .time_ns = 100, .thread = 1, .id = 1 },
844         .{ .seq = 3, .kind = .zone_begin, .time_ns = 110, .thread = 1, .id = 2 },
845         .{ .seq = 4, .kind = .zone_end, .time_ns = 120, .thread = 1, .id = 1 },
846         .{ .seq = 5, .kind = .zone_end, .time_ns = 130, .thread = 1, .id = 2 },
847         .{ .seq = 6, .kind = .zone_begin, .time_ns = 140, .thread = 1, .id = 3 },
848         .{ .seq = 7, .kind = .zone_begin, .time_ns = 150, .thread = 1, .id = 3 },
849         .{ .seq = 8, .kind = .zone_end, .time_ns = 160, .thread = 1, .id = 3 },
850         .{ .seq = 9, .kind = .zone_begin, .time_ns = 180, .thread = 1, .id = 4 },
851         .{ .seq = 10, .kind = .zone_end, .time_ns = 170, .thread = 1, .id = 4 },
852         .{ .seq = 11, .kind = .zone_begin, .time_ns = 190, .thread = 1, .id = 5 },
853         .{ .seq = 12, .kind = .zone_end, .time_ns = 190, .thread = 1, .id = 5 },
854         .{ .seq = 13, .kind = .stop, .time_ns = 200 },
855     };
856     for (rows) |row| try row.writeJsonLine(writer);
857     try treeIntegrityFlightReport().writeJsonl(writer);
858 }
859 
860 fn treeIntegrityFlightReport() transport.Report {
861     return .{
862         .policy = .overwrite_oldest,
863         .state = .accepting,
864         .capacity_bytes = 64,
865         .retained_bytes = 32,
866         .event_capacity_bytes = 16,
867         .writer_capacity_bytes = 8,
868         .observed_events = 5,
869         .stored_events = 5,
870         .retained_events = 3,
871         .overwritten_events = 2,
872         .dropped_events = 0,
873         .oversized_events = 0,
874         .partial_event_bytes = 0,
875         .discarding_oversized_event = false,
876     };
877 }
878 
879 fn expectTreeIntegritySpanStates(spans: []const Span) !void {
880     var superseded: u64 = 0;
881     var invalid: u64 = 0;
882     var valid_zero: u64 = 0;
883     for (spans) |span| {
884         if (span.state == .superseded) superseded += 1;
885         if (span.state == .complete and !span.duration_valid) invalid += 1;
886         if (span.duration_valid and span.total_ns == 0) valid_zero += 1;
887     }
888     try std.testing.expectEqual(@as(u64, 1), superseded);
889     try std.testing.expectEqual(@as(u64, 1), invalid);
890     try std.testing.expectEqual(@as(u64, 1), valid_zero);
891 }
892 
893 test "tree releases integrity evidence on allocation failure" {
894     try std.testing.checkAllAllocationFailures(
895         std.testing.allocator,
896         analyzeTreeIntegrity,
897         .{},
898     );
899 }
900 
901 fn analyzeTreeIntegrity(allocator: std.mem.Allocator) !void {
902     const trace =
903         "{\"v\":0,\"seq\":1,\"kind\":\"start\",\"time_ns\":80}\n" ++
904         "{\"v\":0,\"seq\":2,\"kind\":\"zone.begin\",\"time_ns\":100," ++
905         "\"thread\":1,\"id\":1,\"name\":\"work\"}\n" ++
906         "{\"v\":0,\"seq\":3,\"kind\":\"zone.end\",\"time_ns\":90," ++
907         "\"thread\":1,\"id\":1}\n" ++
908         "{\"v\":0,\"seq\":4,\"kind\":\"stop\",\"time_ns\":110}\n";
909     var analyzer = Analyzer.init(allocator);
910     defer analyzer.deinit();
911     try analyzer.ingestJsonlBytes(trace);
912     var output = std.Io.Writer.Allocating.init(allocator);
913     defer output.deinit();
914     analyzer.writeJsonl(&output.writer, .{}) catch |err| switch (err) {
915         error.WriteFailed => return error.OutOfMemory,
916         else => return err,
917     };
918 }