lib/tracy/src/flame.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const pretty_json = @import("pretty").json;
  3 const event = @import("event.zig");
  4 const tree = @import("tree.zig");
  5 
  6 pub const schema = "tracy.flame/v0";
  7 
  8 pub const Sort = enum {
  9     time,
 10     name,
 11 
 12     pub fn fromName(text: []const u8) ?Sort {
 13         if (std.mem.eql(u8, text, "time")) return .time;
 14         if (std.mem.eql(u8, text, "name")) return .name;
 15         return null;
 16     }
 17 
 18     fn tag(self: Sort) []const u8 {
 19         return switch (self) {
 20             .time => "time",
 21             .name => "name",
 22         };
 23     }
 24 };
 25 
 26 pub const Options = struct {
 27     top: usize = 200,
 28     sort: Sort = .time,
 29     max_depth: usize = 64,
 30     min_ns: u64 = 0,
 31     thread: ?u64 = null,
 32     since_ns: ?u64 = null,
 33     until_ns: ?u64 = null,
 34     match: ?[]const u8 = null,
 35 };
 36 
 37 pub const Counters = struct {
 38     spans: u64 = 0,
 39     matched_spans: u64 = 0,
 40     open_spans: u64 = 0,
 41     superseded_spans: u64 = 0,
 42     invalid_duration_spans: u64 = 0,
 43     zero_duration_spans: u64 = 0,
 44     clipped_spans: u64 = 0,
 45     nodes: u64 = 0,
 46     max_depth: usize = 0,
 47     total_ns: u64 = 0,
 48     duration_ns: u64 = 0,
 49 };
 50 
 51 const Node = struct {
 52     name: []u8,
 53     file: ?[]u8 = null,
 54     function: ?[]u8 = null,
 55     line: u32 = 0,
 56     column: u32 = 0,
 57     total_ns: u64 = 0,
 58     spans: u64 = 0,
 59     threads: std.ArrayListUnmanaged(u64) = .empty,
 60     children: std.ArrayListUnmanaged(Node) = .empty,
 61 
 62     fn deinit(self: *Node, allocator: std.mem.Allocator) void {
 63         allocator.free(self.name);
 64         if (self.file) |file| allocator.free(file);
 65         if (self.function) |function| allocator.free(function);
 66         self.threads.deinit(allocator);
 67         for (self.children.items) |*child| child.deinit(allocator);
 68         self.children.deinit(allocator);
 69         self.* = undefined;
 70     }
 71 
 72     fn childNs(self: Node) u64 {
 73         var total: u64 = 0;
 74         for (self.children.items) |child| total +|= child.total_ns;
 75         return total;
 76     }
 77 
 78     fn selfNs(self: Node) u64 {
 79         const child_ns = self.childNs();
 80         if (self.total_ns <= child_ns) return 0;
 81         return self.total_ns - child_ns;
 82     }
 83 
 84     fn threadCount(self: Node) u64 {
 85         return @intCast(self.threads.items.len);
 86     }
 87 };
 88 
 89 const SpanRecord = struct {
 90     span: tree.Span,
 91     children: std.ArrayListUnmanaged(usize) = .empty,
 92 
 93     fn deinit(self: *SpanRecord, allocator: std.mem.Allocator) void {
 94         self.children.deinit(allocator);
 95         self.* = undefined;
 96     }
 97 };
 98 
 99 pub const Analyzer = struct {
100     allocator: std.mem.Allocator,
101     roots: std.ArrayListUnmanaged(Node) = .empty,
102     counters: Counters = .{},
103     evidence: ?tree.Evidence = null,
104 
105     pub fn init(allocator: std.mem.Allocator) Analyzer {
106         return .{ .allocator = allocator };
107     }
108 
109     pub fn deinit(self: *Analyzer) void {
110         for (self.roots.items) |*root| root.deinit(self.allocator);
111         self.roots.deinit(self.allocator);
112         self.* = undefined;
113     }
114 
115     pub fn ingestTree(self: *Analyzer, trace: *tree.Analyzer, options: Options) !void {
116         self.counters.duration_ns = trace.durationNs();
117         self.evidence = trace.evidence();
118         var spans = try trace.collectSpans(self.allocator);
119         defer spans.deinit(self.allocator);
120         self.counters.spans = @intCast(spans.items.len);
121 
122         var records: std.ArrayListUnmanaged(SpanRecord) = .empty;
123         defer {
124             for (records.items) |*record| record.deinit(self.allocator);
125             records.deinit(self.allocator);
126         }
127         try records.ensureTotalCapacity(self.allocator, spans.items.len);
128 
129         var ids: std.AutoHashMapUnmanaged(u64, usize) = .{};
130         defer ids.deinit(self.allocator);
131         try ids.ensureTotalCapacity(self.allocator, @intCast(spans.items.len));
132 
133         for (spans.items, 0..) |span, index| {
134             records.appendAssumeCapacity(.{ .span = span });
135             ids.putAssumeCapacity(span.id, index);
136         }
137 
138         var root_indices: std.ArrayListUnmanaged(usize) = .empty;
139         defer root_indices.deinit(self.allocator);
140         for (records.items, 0..) |record, index| {
141             if (record.span.parent_id) |parent_id| {
142                 if (ids.get(parent_id)) |parent_index| {
143                     try records.items[parent_index].children.append(self.allocator, index);
144                     continue;
145                 }
146             }
147             try root_indices.append(self.allocator, index);
148         }
149 
150         for (root_indices.items) |root_index| try self.recordSpan(records.items, &self.roots, root_index, options);
151         sortNodes(self.roots.items, options.sort);
152         self.counters.nodes = countNodes(self.roots.items);
153         self.counters.max_depth = maxDepth(self.roots.items, 0);
154         self.counters.total_ns = totalNs(self.roots.items);
155     }
156 
157     fn recordSpan(
158         self: *Analyzer,
159         records: []SpanRecord,
160         siblings: *std.ArrayListUnmanaged(Node),
161         index: usize,
162         options: Options,
163     ) !void {
164         const span = records[index].span;
165         var destination = siblings;
166         switch (span.state) {
167             .open => self.counters.open_spans += 1,
168             .superseded => self.counters.superseded_spans += 1,
169             .complete => if (!span.duration_valid) {
170                 self.counters.invalid_duration_spans += 1;
171             } else if (spanMatches(span, options)) {
172                 if (span.total_ns == 0) self.counters.zero_duration_spans += 1;
173                 destination = try self.recordMatchedSpan(siblings, span, options);
174             },
175         }
176         for (records[index].children.items) |child_index| {
177             try self.recordSpan(records, destination, child_index, options);
178         }
179     }
180 
181     fn recordMatchedSpan(
182         self: *Analyzer,
183         siblings: *std.ArrayListUnmanaged(Node),
184         span: tree.Span,
185         options: Options,
186     ) !*std.ArrayListUnmanaged(Node) {
187         var destination = siblings;
188         if (span.total_ns != 0) {
189             if (clippedDuration(span, options)) |duration| {
190                 const node = try self.nodeFor(siblings, span);
191                 node.total_ns +|= duration;
192                 node.spans += 1;
193                 try appendThread(self.allocator, &node.threads, span.thread);
194                 destination = &node.children;
195                 self.counters.matched_spans += 1;
196                 if (duration != span.total_ns) self.counters.clipped_spans += 1;
197             }
198         }
199         return destination;
200     }
201 
202     fn nodeFor(self: *Analyzer, siblings: *std.ArrayListUnmanaged(Node), span: tree.Span) !*Node {
203         for (siblings.items) |*node| {
204             if (sameSource(node.*, span)) return node;
205         }
206         try siblings.append(self.allocator, .{
207             .name = try self.allocator.dupe(u8, span.name),
208             .file = try dupeOptional(self.allocator, span.file),
209             .function = try dupeOptional(self.allocator, span.function),
210             .line = span.line,
211             .column = span.column,
212         });
213         return &siblings.items[siblings.items.len - 1];
214     }
215 };
216 
217 pub fn writeTextFromJsonlPath(
218     allocator: std.mem.Allocator,
219     path: []const u8,
220     writer: *std.Io.Writer,
221     options: Options,
222 ) !void {
223     var trace = tree.Analyzer.init(allocator);
224     defer trace.deinit();
225     try tree.ingestPath(&trace, path);
226     var analyzer = Analyzer.init(allocator);
227     defer analyzer.deinit();
228     try analyzer.ingestTree(&trace, options);
229     try writeText(&analyzer, writer, options);
230 }
231 
232 pub fn writeJsonlFromJsonlPath(
233     allocator: std.mem.Allocator,
234     path: []const u8,
235     writer: *std.Io.Writer,
236     options: Options,
237 ) !void {
238     var trace = tree.Analyzer.init(allocator);
239     defer trace.deinit();
240     try tree.ingestPath(&trace, path);
241     var analyzer = Analyzer.init(allocator);
242     defer analyzer.deinit();
243     try analyzer.ingestTree(&trace, options);
244     try writeJsonl(&analyzer, writer, options);
245 }
246 
247 pub fn writeFoldedFromJsonlPath(
248     allocator: std.mem.Allocator,
249     path: []const u8,
250     writer: *std.Io.Writer,
251     options: Options,
252 ) !void {
253     var trace = tree.Analyzer.init(allocator);
254     defer trace.deinit();
255     try tree.ingestPath(&trace, path);
256     var analyzer = Analyzer.init(allocator);
257     defer analyzer.deinit();
258     try analyzer.ingestTree(&trace, options);
259     try writeFolded(allocator, &analyzer, writer, options);
260 }
261 
262 fn writeText(analyzer: *Analyzer, writer: *std.Io.Writer, options: Options) !void {
263     try writer.print(
264         "tracy flame nodes={d} spans={d} matched_spans={d} open_spans={d} " ++
265             "superseded_spans={d} invalid_duration_spans={d} " ++
266             "zero_duration_spans={d} clipped_spans={d} total_ns={d} " ++
267             "duration_ns={d} max_depth={d} sort={s}\n",
268         .{
269             analyzer.counters.nodes,
270             analyzer.counters.spans,
271             analyzer.counters.matched_spans,
272             analyzer.counters.open_spans,
273             analyzer.counters.superseded_spans,
274             analyzer.counters.invalid_duration_spans,
275             analyzer.counters.zero_duration_spans,
276             analyzer.counters.clipped_spans,
277             analyzer.counters.total_ns,
278             analyzer.counters.duration_ns,
279             analyzer.counters.max_depth,
280             options.sort.tag(),
281         },
282     );
283     try analyzer.evidence.?.writeText(writer);
284     var shown: usize = 0;
285     try writeTextNodes(writer, analyzer.roots.items, 0, 0, options, &shown);
286 }
287 
288 fn writeJsonl(analyzer: *Analyzer, writer: *std.Io.Writer, options: Options) !void {
289     var stream = pretty_json.Writer.init(writer, .minified);
290     const object = try stream.object();
291     try object.field("schema", schema);
292     try object.field("kind", "summary");
293     try object.field("nodes", analyzer.counters.nodes);
294     try object.field("spans", analyzer.counters.spans);
295     try object.field("matched_spans", analyzer.counters.matched_spans);
296     try object.field("open_spans", analyzer.counters.open_spans);
297     try object.field("superseded_spans", analyzer.counters.superseded_spans);
298     try object.field("invalid_duration_spans", analyzer.counters.invalid_duration_spans);
299     try object.field("zero_duration_spans", analyzer.counters.zero_duration_spans);
300     try object.field("clipped_spans", analyzer.counters.clipped_spans);
301     try object.field("total_ns", analyzer.counters.total_ns);
302     try object.field("duration_ns", analyzer.counters.duration_ns);
303     try object.field("max_depth", analyzer.counters.max_depth);
304     try object.field("sort", options.sort.tag());
305     try analyzer.evidence.?.writeFields(object);
306     try object.endLine();
307     var shown: usize = 0;
308     var next_id: u64 = 1;
309     try writeJsonlNodes(writer, analyzer.roots.items, 0, 0, null, options, &shown, &next_id);
310 }
311 
312 fn writeFolded(allocator: std.mem.Allocator, analyzer: *Analyzer, writer: *std.Io.Writer, options: Options) !void {
313     var path: std.ArrayListUnmanaged(*const Node) = .empty;
314     defer path.deinit(allocator);
315     var shown: usize = 0;
316     for (analyzer.roots.items) |*root| {
317         try writeFoldedNode(allocator, writer, root, &path, 0, options, &shown);
318         if (shown >= options.top) return;
319     }
320 }
321 
322 fn writeTextNodes(
323     writer: *std.Io.Writer,
324     nodes: []const Node,
325     depth: usize,
326     base_offset_ns: u64,
327     options: Options,
328     shown: *usize,
329 ) !void {
330     var offset_ns = base_offset_ns;
331     for (nodes) |node| {
332         const current_offset_ns = offset_ns;
333         offset_ns +|= node.total_ns;
334         const visible = outputMatches(node, depth, options);
335         if (visible and shown.* < options.top) {
336             for (0..depth) |_| try writer.writeAll("  ");
337             try writer.print("flame depth={d} offset_ns={d} total_ns={d} self_ns={d} child_ns={d} spans={d} threads={d} children={d} name=", .{
338                 depth,
339                 current_offset_ns,
340                 node.total_ns,
341                 node.selfNs(),
342                 node.childNs(),
343                 node.spans,
344                 node.threadCount(),
345                 node.children.items.len,
346             });
347             try pretty_json.writeString(writer, node.name);
348             if (node.file) |file| try writer.print(" file={s}:{d}", .{ file, node.line });
349             if (node.function) |function| {
350                 try writer.writeAll(" function=");
351                 try pretty_json.writeString(writer, function);
352             }
353             try writer.writeByte('\n');
354             shown.* += 1;
355         }
356         if (shown.* >= options.top) return;
357         if (depth < options.max_depth) try writeTextNodes(writer, node.children.items, depth + 1, current_offset_ns, options, shown);
358         if (shown.* >= options.top) return;
359     }
360 }
361 
362 fn writeJsonlNodes(
363     writer: *std.Io.Writer,
364     nodes: []const Node,
365     depth: usize,
366     base_offset_ns: u64,
367     parent_id: ?u64,
368     options: Options,
369     shown: *usize,
370     next_id: *u64,
371 ) !void {
372     var offset_ns = base_offset_ns;
373     for (nodes) |node| {
374         const current_offset_ns = offset_ns;
375         offset_ns +|= node.total_ns;
376         var row_id: ?u64 = null;
377         const visible = outputMatches(node, depth, options);
378         if (visible and shown.* < options.top) {
379             const id = next_id.*;
380             next_id.* += 1;
381             row_id = id;
382             var stream = pretty_json.Writer.init(writer, .minified);
383             const object = try stream.object();
384             try object.field("schema", schema);
385             try object.field("kind", "node");
386             try object.field("id", id);
387             if (parent_id) |parent| try object.field("parent_id", parent);
388             try object.field("depth", depth);
389             try object.field("offset_ns", current_offset_ns);
390             try object.field("total_ns", node.total_ns);
391             try object.field("self_ns", node.selfNs());
392             try object.field("child_ns", node.childNs());
393             try object.field("spans", node.spans);
394             try object.field("threads", node.threadCount());
395             try object.field("children", node.children.items.len);
396             try object.field("name", node.name);
397             if (node.file) |file| {
398                 try object.field("file", file);
399                 try object.field("line", node.line);
400             }
401             if (node.function) |function| try object.field("function", function);
402             try object.endLine();
403             shown.* += 1;
404         }
405         if (shown.* >= options.top) return;
406         if (depth < options.max_depth) try writeJsonlNodes(writer, node.children.items, depth + 1, current_offset_ns, row_id orelse parent_id, options, shown, next_id);
407         if (shown.* >= options.top) return;
408     }
409 }
410 
411 fn writeFoldedNode(
412     allocator: std.mem.Allocator,
413     writer: *std.Io.Writer,
414     node: *const Node,
415     path: *std.ArrayListUnmanaged(*const Node),
416     depth: usize,
417     options: Options,
418     shown: *usize,
419 ) !void {
420     try path.append(allocator, node);
421     defer _ = path.pop();
422 
423     if (depth >= options.max_depth) {
424         try writeFoldedPath(writer, path.items, node.total_ns, options, shown);
425         return;
426     }
427 
428     const self_ns = node.selfNs();
429     if (self_ns != 0) try writeFoldedPath(writer, path.items, self_ns, options, shown);
430     if (shown.* >= options.top) return;
431     for (node.children.items) |*child| {
432         try writeFoldedNode(allocator, writer, child, path, depth + 1, options, shown);
433         if (shown.* >= options.top) return;
434     }
435 }
436 
437 fn writeFoldedPath(
438     writer: *std.Io.Writer,
439     path: []const *const Node,
440     value: u64,
441     options: Options,
442     shown: *usize,
443 ) !void {
444     if (value < options.min_ns) return;
445     if (!pathMatches(path, options)) return;
446     if (shown.* >= options.top) return;
447     for (path, 0..) |node, index| {
448         if (index != 0) try writer.writeByte(';');
449         try writeFoldedFrame(writer, node.name);
450     }
451     try writer.print(" {d}\n", .{value});
452     shown.* += 1;
453 }
454 
455 fn clippedDuration(span: tree.Span, options: Options) ?u64 {
456     var start_ns = span.start_ns;
457     var end_ns = span.end_ns;
458     if (options.since_ns) |since_ns| start_ns = @max(start_ns, since_ns);
459     if (options.until_ns) |until_ns| end_ns = @min(end_ns, until_ns);
460     if (end_ns <= start_ns) return null;
461     return end_ns - start_ns;
462 }
463 
464 fn spanMatches(span: tree.Span, options: Options) bool {
465     if (options.thread) |thread| {
466         if (span.thread != thread) return false;
467     }
468     return true;
469 }
470 
471 fn outputMatches(node: Node, depth: usize, options: Options) bool {
472     if (depth > options.max_depth) return false;
473     if (node.total_ns < options.min_ns) return false;
474     if (options.match) |needle| {
475         if (!nodeContains(node, needle)) return false;
476     }
477     return true;
478 }
479 
480 fn pathMatches(path: []const *const Node, options: Options) bool {
481     if (options.match) |needle| {
482         for (path) |node| {
483             if (nodeContains(node.*, needle)) return true;
484         }
485         return false;
486     }
487     return true;
488 }
489 
490 fn nodeContains(node: Node, needle: []const u8) bool {
491     if (contains(node.name, needle)) return true;
492     if (node.file) |file| if (contains(file, needle)) return true;
493     if (node.function) |function| if (contains(function, needle)) return true;
494     return false;
495 }
496 
497 fn contains(haystack: []const u8, needle: []const u8) bool {
498     return std.mem.indexOf(u8, haystack, needle) != null;
499 }
500 
501 fn sameSource(node: Node, span: tree.Span) bool {
502     if (!std.mem.eql(u8, node.name, span.name)) return false;
503     if (!optionalEql(node.file, span.file)) return false;
504     if (!optionalEql(node.function, span.function)) return false;
505     return node.line == span.line and node.column == span.column;
506 }
507 
508 fn optionalEql(left: ?[]const u8, right: ?[]const u8) bool {
509     if (left == null and right == null) return true;
510     if (left == null or right == null) return false;
511     return std.mem.eql(u8, left.?, right.?);
512 }
513 
514 fn appendThread(allocator: std.mem.Allocator, threads: *std.ArrayListUnmanaged(u64), thread: u64) !void {
515     for (threads.items) |existing| {
516         if (existing == thread) return;
517     }
518     try threads.append(allocator, thread);
519 }
520 
521 fn dupeOptional(allocator: std.mem.Allocator, text: ?[]const u8) !?[]u8 {
522     const actual = text orelse return null;
523     return try allocator.dupe(u8, actual);
524 }
525 
526 fn sortNodes(nodes: []Node, sort: Sort) void {
527     switch (sort) {
528         .time => std.mem.sort(Node, nodes, {}, nodeTimeGreaterThan),
529         .name => std.mem.sort(Node, nodes, {}, nodeNameLessThan),
530     }
531     for (nodes) |node| sortNodes(node.children.items, sort);
532 }
533 
534 fn nodeTimeGreaterThan(_: void, left: Node, right: Node) bool {
535     if (left.total_ns != right.total_ns) return left.total_ns > right.total_ns;
536     return nodeNameLessThan({}, left, right);
537 }
538 
539 fn nodeNameLessThan(_: void, left: Node, right: Node) bool {
540     const name_cmp = std.mem.order(u8, left.name, right.name);
541     if (name_cmp != .eq) return name_cmp == .lt;
542     const left_file = left.file orelse "";
543     const right_file = right.file orelse "";
544     const file_cmp = std.mem.order(u8, left_file, right_file);
545     if (file_cmp != .eq) return file_cmp == .lt;
546     return left.line < right.line;
547 }
548 
549 fn countNodes(nodes: []const Node) u64 {
550     var count: u64 = 0;
551     for (nodes) |node| count += 1 + countNodes(node.children.items);
552     return count;
553 }
554 
555 fn maxDepth(nodes: []const Node, depth: usize) usize {
556     var result: usize = 0;
557     for (nodes) |node| {
558         result = @max(result, depth);
559         result = @max(result, maxDepth(node.children.items, depth + 1));
560     }
561     return result;
562 }
563 
564 fn totalNs(nodes: []const Node) u64 {
565     var total: u64 = 0;
566     for (nodes) |node| total +|= node.total_ns;
567     return total;
568 }
569 
570 fn writeFoldedFrame(writer: *std.Io.Writer, text: []const u8) !void {
571     for (text) |byte| {
572         switch (byte) {
573             ';' => try writer.writeAll("\\;"),
574             '\\' => try writer.writeAll("\\\\"),
575             '\n' => try writer.writeAll("\\n"),
576             '\r' => try writer.writeAll("\\r"),
577             '\t' => try writer.writeAll("\\t"),
578             else => try writer.writeByte(byte),
579         }
580     }
581 }
582 
583 test "flame aggregates instrumentation zones into merged trees" {
584     var trace_bytes = std.Io.Writer.Allocating.init(std.testing.allocator);
585     defer trace_bytes.deinit();
586     try (event.TraceEvent{ .seq = 1, .kind = .start, .time_ns = 90, .thread = 1, .name = "test" }).writeJsonLine(&trace_bytes.writer);
587     try (event.TraceEvent{ .seq = 2, .kind = .zone_begin, .time_ns = 100, .thread = 1, .id = 1, .name = "root", .file = "root.zig", .line = 1 }).writeJsonLine(&trace_bytes.writer);
588     try (event.TraceEvent{ .seq = 3, .kind = .zone_begin, .time_ns = 120, .thread = 1, .id = 2, .name = "child", .file = "child.zig", .line = 2 }).writeJsonLine(&trace_bytes.writer);
589     try (event.TraceEvent{ .seq = 4, .kind = .zone_end, .time_ns = 150, .thread = 1, .id = 2 }).writeJsonLine(&trace_bytes.writer);
590     try (event.TraceEvent{ .seq = 5, .kind = .zone_end, .time_ns = 200, .thread = 1, .id = 1 }).writeJsonLine(&trace_bytes.writer);
591     try (event.TraceEvent{ .seq = 6, .kind = .zone_begin, .time_ns = 210, .thread = 2, .id = 3, .name = "root", .file = "root.zig", .line = 1 }).writeJsonLine(&trace_bytes.writer);
592     try (event.TraceEvent{ .seq = 7, .kind = .zone_begin, .time_ns = 220, .thread = 2, .id = 4, .name = "other", .file = "other.zig", .line = 3 }).writeJsonLine(&trace_bytes.writer);
593     try (event.TraceEvent{ .seq = 8, .kind = .zone_end, .time_ns = 260, .thread = 2, .id = 4 }).writeJsonLine(&trace_bytes.writer);
594     try (event.TraceEvent{ .seq = 9, .kind = .zone_end, .time_ns = 280, .thread = 2, .id = 3 }).writeJsonLine(&trace_bytes.writer);
595 
596     var trace = tree.Analyzer.init(std.testing.allocator);
597     defer trace.deinit();
598     try trace.ingestJsonlBytes(trace_bytes.written());
599 
600     var analyzer = Analyzer.init(std.testing.allocator);
601     defer analyzer.deinit();
602     try analyzer.ingestTree(&trace, .{});
603 
604     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
605     defer out.deinit();
606     try writeText(&analyzer, &out.writer, .{ .top = 8 });
607     const text = out.written();
608     try std.testing.expect(std.mem.indexOf(u8, text, "tracy flame nodes=3 spans=4 matched_spans=4") != null);
609     try std.testing.expect(std.mem.indexOf(u8, text, "flame depth=0 offset_ns=0 total_ns=170 self_ns=100 child_ns=70 spans=2 threads=2 children=2 name=\"root\"") != null);
610     try std.testing.expect(std.mem.indexOf(u8, text, "flame depth=1 offset_ns=0 total_ns=40 self_ns=40 child_ns=0 spans=1 threads=1 children=0 name=\"other\"") != null);
611     try std.testing.expect(std.mem.indexOf(u8, text, "flame depth=1 offset_ns=40 total_ns=30 self_ns=30 child_ns=0 spans=1 threads=1 children=0 name=\"child\"") != null);
612 }
613 
614 test "flame jsonl and folded output preserve clipped paths" {
615     var trace_bytes = std.Io.Writer.Allocating.init(std.testing.allocator);
616     defer trace_bytes.deinit();
617     try (event.TraceEvent{ .seq = 1, .kind = .zone_begin, .time_ns = 100, .thread = 7, .id = 1, .name = "root" }).writeJsonLine(&trace_bytes.writer);
618     try (event.TraceEvent{ .seq = 2, .kind = .zone_begin, .time_ns = 125, .thread = 7, .id = 2, .name = "child;one" }).writeJsonLine(&trace_bytes.writer);
619     try (event.TraceEvent{ .seq = 3, .kind = .zone_end, .time_ns = 175, .thread = 7, .id = 2 }).writeJsonLine(&trace_bytes.writer);
620     try (event.TraceEvent{ .seq = 4, .kind = .zone_end, .time_ns = 220, .thread = 7, .id = 1 }).writeJsonLine(&trace_bytes.writer);
621 
622     var trace = tree.Analyzer.init(std.testing.allocator);
623     defer trace.deinit();
624     try trace.ingestJsonlBytes(trace_bytes.written());
625 
626     var analyzer = Analyzer.init(std.testing.allocator);
627     defer analyzer.deinit();
628     const options = Options{ .top = 8, .since_ns = 110, .until_ns = 180 };
629     try analyzer.ingestTree(&trace, options);
630 
631     var jsonl = std.Io.Writer.Allocating.init(std.testing.allocator);
632     defer jsonl.deinit();
633     try writeJsonl(&analyzer, &jsonl.writer, options);
634     try std.testing.expect(std.mem.indexOf(u8, jsonl.written(), "\"schema\":\"tracy.flame/v0\"") != null);
635     try std.testing.expect(std.mem.indexOf(u8, jsonl.written(), "\"clipped_spans\":1") != null);
636     try std.testing.expect(std.mem.indexOf(u8, jsonl.written(), "\"name\":\"child;one\"") != null);
637 
638     var folded = std.Io.Writer.Allocating.init(std.testing.allocator);
639     defer folded.deinit();
640     try writeFolded(std.testing.allocator, &analyzer, &folded.writer, options);
641     const folded_text = folded.written();
642     try std.testing.expect(std.mem.indexOf(u8, folded_text, "root 20") != null);
643     try std.testing.expect(std.mem.indexOf(u8, folded_text, "root;child\\;one 50") != null);
644 }