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 }