lib/tracy/src/find.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.find/v0";
  7 
  8 pub const Group = enum {
  9     source,
 10     name,
 11     thread,
 12     parent,
 13     text,
 14     none,
 15 
 16     pub fn fromName(text: []const u8) ?Group {
 17         if (std.mem.eql(u8, text, "source")) return .source;
 18         if (std.mem.eql(u8, text, "name")) return .name;
 19         if (std.mem.eql(u8, text, "thread")) return .thread;
 20         if (std.mem.eql(u8, text, "parent")) return .parent;
 21         if (std.mem.eql(u8, text, "text")) return .text;
 22         if (std.mem.eql(u8, text, "none")) return .none;
 23         return null;
 24     }
 25 
 26     fn tag(self: Group) []const u8 {
 27         return switch (self) {
 28             .source => "source",
 29             .name => "name",
 30             .thread => "thread",
 31             .parent => "parent",
 32             .text => "text",
 33             .none => "none",
 34         };
 35     }
 36 };
 37 
 38 pub const Sort = enum {
 39     total,
 40     self,
 41     count,
 42     mean,
 43     max,
 44     name,
 45     time,
 46 
 47     pub fn fromName(text: []const u8) ?Sort {
 48         if (std.mem.eql(u8, text, "total")) return .total;
 49         if (std.mem.eql(u8, text, "self")) return .self;
 50         if (std.mem.eql(u8, text, "count")) return .count;
 51         if (std.mem.eql(u8, text, "mean")) return .mean;
 52         if (std.mem.eql(u8, text, "max")) return .max;
 53         if (std.mem.eql(u8, text, "name")) return .name;
 54         if (std.mem.eql(u8, text, "time")) return .time;
 55         return null;
 56     }
 57 
 58     fn tag(self: Sort) []const u8 {
 59         return switch (self) {
 60             .total => "total",
 61             .self => "self",
 62             .count => "count",
 63             .mean => "mean",
 64             .max => "max",
 65             .name => "name",
 66             .time => "time",
 67         };
 68     }
 69 };
 70 
 71 pub const Options = struct {
 72     pattern: []const u8,
 73     top: usize = 20,
 74     occurrences: usize = 20,
 75     group: Group = .source,
 76     sort: Sort = .total,
 77     thread: ?u64 = null,
 78     since_ns: ?u64 = null,
 79     until_ns: ?u64 = null,
 80     min_duration_ns: u64 = 0,
 81     ignore_case: bool = false,
 82 };
 83 
 84 pub const Counters = struct {
 85     spans: u64 = 0,
 86     matched_spans: u64 = 0,
 87     open_spans: u64 = 0,
 88     superseded_spans: u64 = 0,
 89     invalid_duration_spans: u64 = 0,
 90     zero_duration_spans: u64 = 0,
 91     filtered_spans: u64 = 0,
 92     clipped_spans: u64 = 0,
 93     groups: u64 = 0,
 94     duration_ns: u64 = 0,
 95 };
 96 
 97 const GroupRow = struct {
 98     group: Group,
 99     label: []u8,
100     name: ?[]u8 = null,
101     text: ?[]u8 = null,
102     file: ?[]u8 = null,
103     function: ?[]u8 = null,
104     line: u32 = 0,
105     column: u32 = 0,
106     thread: ?u64 = null,
107     parent_id: ?u64 = null,
108     count: u64 = 0,
109     total_ns: u64 = 0,
110     self_ns: u64 = 0,
111     min_ns: u64 = std.math.maxInt(u64),
112     max_ns: u64 = 0,
113     first_ns: u64 = 0,
114     last_ns: u64 = 0,
115     samples: std.ArrayListUnmanaged(u64) = .empty,
116     threads: std.ArrayListUnmanaged(u64) = .empty,
117 
118     fn deinit(self: *GroupRow, allocator: std.mem.Allocator) void {
119         allocator.free(self.label);
120         if (self.name) |name| allocator.free(name);
121         if (self.text) |text| allocator.free(text);
122         if (self.file) |file| allocator.free(file);
123         if (self.function) |function| allocator.free(function);
124         self.samples.deinit(allocator);
125         self.threads.deinit(allocator);
126         self.* = undefined;
127     }
128 
129     fn meanNs(self: GroupRow) u64 {
130         if (self.count == 0) return 0;
131         return self.total_ns / self.count;
132     }
133 
134     fn threadCount(self: GroupRow) u64 {
135         return @intCast(self.threads.items.len);
136     }
137 };
138 
139 const Occurrence = struct {
140     id: u64,
141     parent_id: ?u64 = null,
142     parent_name: ?[]u8 = null,
143     name: []u8,
144     text: ?[]u8 = null,
145     file: ?[]u8 = null,
146     function: ?[]u8 = null,
147     line: u32 = 0,
148     column: u32 = 0,
149     thread: u64 = 0,
150     depth: usize = 0,
151     start_ns: u64 = 0,
152     end_ns: u64 = 0,
153     duration_ns: u64 = 0,
154     self_ns: u64 = 0,
155     children: u64 = 0,
156 
157     fn deinit(self: *Occurrence, allocator: std.mem.Allocator) void {
158         if (self.parent_name) |parent_name| allocator.free(parent_name);
159         allocator.free(self.name);
160         if (self.text) |text| allocator.free(text);
161         if (self.file) |file| allocator.free(file);
162         if (self.function) |function| allocator.free(function);
163         self.* = undefined;
164     }
165 };
166 
167 const GroupView = struct {
168     group: Group,
169     label: []const u8,
170     name: ?[]const u8,
171     text: ?[]const u8,
172     file: ?[]const u8,
173     function: ?[]const u8,
174     line: u32,
175     column: u32,
176     thread: ?u64,
177     parent_id: ?u64,
178     count: u64,
179     total_ns: u64,
180     self_ns: u64,
181     mean_ns: u64,
182     min_ns: u64,
183     p50_ns: u64,
184     p90_ns: u64,
185     p99_ns: u64,
186     max_ns: u64,
187     first_ns: u64,
188     last_ns: u64,
189     threads: u64,
190 };
191 
192 pub const Analyzer = struct {
193     allocator: std.mem.Allocator,
194     groups: std.StringHashMapUnmanaged(GroupRow) = .{},
195     occurrences: std.ArrayListUnmanaged(Occurrence) = .empty,
196     counters: Counters = .{},
197     evidence: ?tree.Evidence = null,
198 
199     pub fn init(allocator: std.mem.Allocator) Analyzer {
200         return .{ .allocator = allocator };
201     }
202 
203     pub fn deinit(self: *Analyzer) void {
204         var iter = self.groups.iterator();
205         while (iter.next()) |entry| {
206             self.allocator.free(entry.key_ptr.*);
207             entry.value_ptr.deinit(self.allocator);
208         }
209         self.groups.deinit(self.allocator);
210         for (self.occurrences.items) |*occurrence| occurrence.deinit(self.allocator);
211         self.occurrences.deinit(self.allocator);
212         self.* = undefined;
213     }
214 
215     pub fn ingestTree(self: *Analyzer, trace: *tree.Analyzer, options: Options) !void {
216         self.counters.duration_ns = trace.durationNs();
217         self.evidence = trace.evidence();
218         var spans = try trace.collectSpans(self.allocator);
219         defer spans.deinit(self.allocator);
220         self.counters.spans = @intCast(spans.items.len);
221         for (spans.items) |span| {
222             switch (span.state) {
223                 .open => {
224                     self.counters.open_spans += 1;
225                     continue;
226                 },
227                 .superseded => {
228                     self.counters.superseded_spans += 1;
229                     continue;
230                 },
231                 .complete => {},
232             }
233             if (!span.duration_valid) {
234                 self.counters.invalid_duration_spans += 1;
235                 continue;
236             }
237             if (!spanMatches(span, options)) {
238                 self.counters.filtered_spans += 1;
239                 continue;
240             }
241             const duration = clippedDuration(span, options) orelse {
242                 if (span.total_ns == 0) self.counters.zero_duration_spans += 1;
243                 self.counters.filtered_spans += 1;
244                 continue;
245             };
246             if (duration < options.min_duration_ns) {
247                 self.counters.filtered_spans += 1;
248                 continue;
249             }
250             const parent = parentSpan(spans.items, span.parent_id);
251             try self.record(span, parent, duration, options);
252             self.counters.matched_spans += 1;
253             if (duration != span.total_ns) self.counters.clipped_spans += 1;
254         }
255         self.counters.groups = @intCast(self.groups.count());
256         sortOccurrences(self.occurrences.items, options.sort);
257     }
258 
259     fn record(self: *Analyzer, span: tree.Span, parent: ?tree.Span, duration: u64, options: Options) !void {
260         var key_writer = std.Io.Writer.Allocating.init(self.allocator);
261         defer key_writer.deinit();
262         try writeGroupKey(&key_writer.writer, span, parent, options);
263         const key_text = key_writer.written();
264         const entry = try self.groups.getOrPut(self.allocator, key_text);
265         if (!entry.found_existing) {
266             const owned_key = try self.allocator.dupe(u8, key_text);
267             entry.key_ptr.* = owned_key;
268             entry.value_ptr.* = try self.newGroup(span, parent, options);
269         }
270         const row = entry.value_ptr;
271         row.count += 1;
272         row.total_ns +|= duration;
273         row.self_ns +|= @min(span.self_ns, duration);
274         row.min_ns = @min(row.min_ns, duration);
275         row.max_ns = @max(row.max_ns, duration);
276         if (row.count == 1 or span.start_ns < row.first_ns) row.first_ns = span.start_ns;
277         if (span.end_ns > row.last_ns) row.last_ns = span.end_ns;
278         try row.samples.append(self.allocator, duration);
279         try appendThread(self.allocator, &row.threads, span.thread);
280         try self.occurrences.append(self.allocator, try occurrenceView(self.allocator, span, parent, duration));
281     }
282 
283     fn newGroup(self: *Analyzer, span: tree.Span, parent: ?tree.Span, options: Options) !GroupRow {
284         const label = try groupLabel(self.allocator, span, parent, options.group);
285         return .{
286             .group = options.group,
287             .label = label,
288             .name = try dupeOptional(self.allocator, span.name),
289             .text = try dupeOptional(self.allocator, span.text),
290             .file = try dupeOptional(self.allocator, span.file),
291             .function = try dupeOptional(self.allocator, span.function),
292             .line = span.line,
293             .column = span.column,
294             .thread = if (options.group == .thread) span.thread else null,
295             .parent_id = if (options.group == .parent) span.parent_id else null,
296         };
297     }
298 
299     fn collectGroups(self: *Analyzer, allocator: std.mem.Allocator, options: Options) !std.ArrayListUnmanaged(GroupView) {
300         var views: std.ArrayListUnmanaged(GroupView) = .empty;
301         var iter = self.groups.valueIterator();
302         while (iter.next()) |row| {
303             std.mem.sort(u64, row.samples.items, {}, u64LessThan);
304             try views.append(allocator, .{
305                 .group = row.group,
306                 .label = row.label,
307                 .name = row.name,
308                 .text = row.text,
309                 .file = row.file,
310                 .function = row.function,
311                 .line = row.line,
312                 .column = row.column,
313                 .thread = row.thread,
314                 .parent_id = row.parent_id,
315                 .count = row.count,
316                 .total_ns = row.total_ns,
317                 .self_ns = row.self_ns,
318                 .mean_ns = row.meanNs(),
319                 .min_ns = if (row.min_ns == std.math.maxInt(u64)) 0 else row.min_ns,
320                 .p50_ns = percentile(row.samples.items, 50),
321                 .p90_ns = percentile(row.samples.items, 90),
322                 .p99_ns = percentile(row.samples.items, 99),
323                 .max_ns = row.max_ns,
324                 .first_ns = row.first_ns,
325                 .last_ns = row.last_ns,
326                 .threads = row.threadCount(),
327             });
328         }
329         sortGroups(views.items, options.sort);
330         return views;
331     }
332 };
333 
334 pub fn writeTextFromJsonlPath(
335     allocator: std.mem.Allocator,
336     path: []const u8,
337     writer: *std.Io.Writer,
338     options: Options,
339 ) !void {
340     var trace = tree.Analyzer.init(allocator);
341     defer trace.deinit();
342     try tree.ingestPath(&trace, path);
343     var analyzer = Analyzer.init(allocator);
344     defer analyzer.deinit();
345     try analyzer.ingestTree(&trace, options);
346     try writeText(allocator, &analyzer, writer, options);
347 }
348 
349 pub fn writeJsonlFromJsonlPath(
350     allocator: std.mem.Allocator,
351     path: []const u8,
352     writer: *std.Io.Writer,
353     options: Options,
354 ) !void {
355     var trace = tree.Analyzer.init(allocator);
356     defer trace.deinit();
357     try tree.ingestPath(&trace, path);
358     var analyzer = Analyzer.init(allocator);
359     defer analyzer.deinit();
360     try analyzer.ingestTree(&trace, options);
361     try writeJsonl(allocator, &analyzer, writer, options);
362 }
363 
364 fn writeText(allocator: std.mem.Allocator, analyzer: *Analyzer, writer: *std.Io.Writer, options: Options) !void {
365     var groups = try analyzer.collectGroups(allocator, options);
366     defer groups.deinit(allocator);
367     try writer.writeAll("tracy find pattern=");
368     try pretty_json.writeString(writer, options.pattern);
369     try writer.print(
370         " groups={d} occurrences={d} spans={d} matched_spans={d} " ++
371             "open_spans={d} superseded_spans={d} invalid_duration_spans={d} " ++
372             "zero_duration_spans={d} filtered_spans={d} clipped_spans={d} " ++
373             "duration_ns={d} group={s} sort={s}\n",
374         .{
375             groups.items.len,
376             analyzer.occurrences.items.len,
377             analyzer.counters.spans,
378             analyzer.counters.matched_spans,
379             analyzer.counters.open_spans,
380             analyzer.counters.superseded_spans,
381             analyzer.counters.invalid_duration_spans,
382             analyzer.counters.zero_duration_spans,
383             analyzer.counters.filtered_spans,
384             analyzer.counters.clipped_spans,
385             analyzer.counters.duration_ns,
386             options.group.tag(),
387             options.sort.tag(),
388         },
389     );
390     try analyzer.evidence.?.writeText(writer);
391     const group_limit = @min(options.top, groups.items.len);
392     for (groups.items[0..group_limit]) |group| {
393         try writer.print("find-group group={s} label=", .{group.group.tag()});
394         try pretty_json.writeString(writer, group.label);
395         try writer.print(
396             " count={d} total_ns={d} self_ns={d} mean_ns={d} min_ns={d} p50_ns={d} p90_ns={d} p99_ns={d} max_ns={d} first_ns={d} last_ns={d} threads={d}",
397             .{
398                 group.count,
399                 group.total_ns,
400                 group.self_ns,
401                 group.mean_ns,
402                 group.min_ns,
403                 group.p50_ns,
404                 group.p90_ns,
405                 group.p99_ns,
406                 group.max_ns,
407                 group.first_ns,
408                 group.last_ns,
409                 group.threads,
410             },
411         );
412         try writeGroupMetadataText(writer, group);
413         try writer.writeByte('\n');
414     }
415     const occurrence_limit = @min(options.occurrences, analyzer.occurrences.items.len);
416     for (analyzer.occurrences.items[0..occurrence_limit]) |occurrence| {
417         try writeOccurrenceText(writer, occurrence);
418     }
419 }
420 
421 fn writeJsonl(allocator: std.mem.Allocator, analyzer: *Analyzer, writer: *std.Io.Writer, options: Options) !void {
422     var groups = try analyzer.collectGroups(allocator, options);
423     defer groups.deinit(allocator);
424     var summary_stream = pretty_json.Writer.init(writer, .minified);
425     const summary_record = try summary_stream.object();
426     try summary_record.field("schema", schema);
427     try summary_record.field("kind", "summary");
428     try summary_record.field("pattern", options.pattern);
429     try summary_record.field("groups", groups.items.len);
430     try summary_record.field("occurrences", analyzer.occurrences.items.len);
431     try summary_record.field("spans", analyzer.counters.spans);
432     try summary_record.field("matched_spans", analyzer.counters.matched_spans);
433     try summary_record.field("open_spans", analyzer.counters.open_spans);
434     try summary_record.field("superseded_spans", analyzer.counters.superseded_spans);
435     try summary_record.field("invalid_duration_spans", analyzer.counters.invalid_duration_spans);
436     try summary_record.field("zero_duration_spans", analyzer.counters.zero_duration_spans);
437     try summary_record.field("filtered_spans", analyzer.counters.filtered_spans);
438     try summary_record.field("clipped_spans", analyzer.counters.clipped_spans);
439     try summary_record.field("duration_ns", analyzer.counters.duration_ns);
440     try summary_record.field("group", options.group.tag());
441     try summary_record.field("sort", options.sort.tag());
442     try analyzer.evidence.?.writeFields(summary_record);
443     try summary_record.endLine();
444     const group_limit = @min(options.top, groups.items.len);
445     for (groups.items[0..group_limit]) |group| {
446         var stream = pretty_json.Writer.init(writer, .minified);
447         const object = try stream.object();
448         try object.field("schema", schema);
449         try object.field("kind", "group");
450         try object.field("group", group.group.tag());
451         try object.field("label", group.label);
452         try object.field("count", group.count);
453         try object.field("total_ns", group.total_ns);
454         try object.field("self_ns", group.self_ns);
455         try object.field("mean_ns", group.mean_ns);
456         try object.field("min_ns", group.min_ns);
457         try object.field("p50_ns", group.p50_ns);
458         try object.field("p90_ns", group.p90_ns);
459         try object.field("p99_ns", group.p99_ns);
460         try object.field("max_ns", group.max_ns);
461         try object.field("first_ns", group.first_ns);
462         try object.field("last_ns", group.last_ns);
463         try object.field("threads", group.threads);
464         try writeGroupMetadataFields(object, group);
465         try object.endLine();
466     }
467     const occurrence_limit = @min(options.occurrences, analyzer.occurrences.items.len);
468     for (analyzer.occurrences.items[0..occurrence_limit]) |occurrence| {
469         try writeOccurrenceJson(writer, occurrence);
470     }
471 }
472 
473 fn writeOccurrenceText(writer: *std.Io.Writer, occurrence: Occurrence) !void {
474     try writer.print(
475         "find-zone id={d} start_ns={d} end_ns={d} duration_ns={d} self_ns={d} thread={d} depth={d} children={d} name=",
476         .{ occurrence.id, occurrence.start_ns, occurrence.end_ns, occurrence.duration_ns, occurrence.self_ns, occurrence.thread, occurrence.depth, occurrence.children },
477     );
478     try pretty_json.writeString(writer, occurrence.name);
479     if (occurrence.parent_id) |parent_id| try writer.print(" parent_id={d}", .{parent_id});
480     if (occurrence.parent_name) |parent_name| {
481         try writer.writeAll(" parent=");
482         try pretty_json.writeString(writer, parent_name);
483     }
484     if (occurrence.text) |text| {
485         try writer.writeAll(" text=");
486         try pretty_json.writeString(writer, text);
487     }
488     if (occurrence.file) |file| try writer.print(" file={s}:{d}", .{ file, occurrence.line });
489     if (occurrence.function) |function| {
490         try writer.writeAll(" function=");
491         try pretty_json.writeString(writer, function);
492     }
493     try writer.writeByte('\n');
494 }
495 
496 fn writeOccurrenceJson(writer: *std.Io.Writer, occurrence: Occurrence) !void {
497     var stream = pretty_json.Writer.init(writer, .minified);
498     const object = try stream.object();
499     try object.field("schema", schema);
500     try object.field("kind", "zone");
501     try object.field("id", occurrence.id);
502     try object.field("start_ns", occurrence.start_ns);
503     try object.field("end_ns", occurrence.end_ns);
504     try object.field("duration_ns", occurrence.duration_ns);
505     try object.field("self_ns", occurrence.self_ns);
506     try object.field("thread", occurrence.thread);
507     try object.field("depth", occurrence.depth);
508     try object.field("children", occurrence.children);
509     try object.field("name", occurrence.name);
510     if (occurrence.parent_id) |parent_id| try object.field("parent_id", parent_id);
511     if (occurrence.parent_name) |parent_name| try object.field("parent_name", parent_name);
512     if (occurrence.text) |text| try object.field("text", text);
513     if (occurrence.file) |file| {
514         try object.field("file", file);
515         try object.field("line", occurrence.line);
516     }
517     if (occurrence.function) |function| try object.field("function", function);
518     try object.endLine();
519 }
520 
521 fn writeGroupMetadataText(writer: *std.Io.Writer, group: GroupView) !void {
522     if (group.thread) |thread| try writer.print(" thread={d}", .{thread});
523     if (group.parent_id) |parent_id| try writer.print(" parent_id={d}", .{parent_id});
524     if (group.name) |name| {
525         try writer.writeAll(" name=");
526         try pretty_json.writeString(writer, name);
527     }
528     if (group.text) |text| {
529         try writer.writeAll(" text=");
530         try pretty_json.writeString(writer, text);
531     }
532     if (group.file) |file| try writer.print(" file={s}:{d}", .{ file, group.line });
533     if (group.function) |function| {
534         try writer.writeAll(" function=");
535         try pretty_json.writeString(writer, function);
536     }
537 }
538 
539 fn writeGroupMetadataFields(object: pretty_json.Object, group: GroupView) !void {
540     if (group.thread) |thread| try object.field("thread", thread);
541     if (group.parent_id) |parent_id| try object.field("parent_id", parent_id);
542     if (group.name) |name| try object.field("name", name);
543     if (group.text) |text| try object.field("text", text);
544     if (group.file) |file| {
545         try object.field("file", file);
546         try object.field("line", group.line);
547     }
548     if (group.function) |function| try object.field("function", function);
549 }
550 
551 fn occurrenceView(allocator: std.mem.Allocator, span: tree.Span, parent: ?tree.Span, duration: u64) !Occurrence {
552     return .{
553         .id = span.id,
554         .parent_id = span.parent_id,
555         .parent_name = if (parent) |actual| try allocator.dupe(u8, actual.name) else null,
556         .name = try allocator.dupe(u8, span.name),
557         .text = try dupeOptional(allocator, span.text),
558         .file = try dupeOptional(allocator, span.file),
559         .function = try dupeOptional(allocator, span.function),
560         .line = span.line,
561         .column = span.column,
562         .thread = span.thread,
563         .depth = span.depth,
564         .start_ns = span.start_ns,
565         .end_ns = span.end_ns,
566         .duration_ns = duration,
567         .self_ns = @min(span.self_ns, duration),
568         .children = span.children,
569     };
570 }
571 
572 fn spanMatches(span: tree.Span, options: Options) bool {
573     if (options.thread) |thread| {
574         if (span.thread != thread) return false;
575     }
576     const pattern = options.pattern;
577     if (pattern.len == 0) return true;
578     if (contains(span.name, pattern, options.ignore_case)) return true;
579     if (span.text) |text| if (contains(text, pattern, options.ignore_case)) return true;
580     if (span.file) |file| if (contains(file, pattern, options.ignore_case)) return true;
581     if (span.function) |function| if (contains(function, pattern, options.ignore_case)) return true;
582     return false;
583 }
584 
585 fn clippedDuration(span: tree.Span, options: Options) ?u64 {
586     var start_ns = span.start_ns;
587     var end_ns = span.end_ns;
588     if (options.since_ns) |since_ns| start_ns = @max(start_ns, since_ns);
589     if (options.until_ns) |until_ns| end_ns = @min(end_ns, until_ns);
590     if (end_ns <= start_ns) return null;
591     return end_ns - start_ns;
592 }
593 
594 fn parentSpan(spans: []const tree.Span, parent_id: ?u64) ?tree.Span {
595     const id = parent_id orelse return null;
596     for (spans) |span| {
597         if (span.id == id) return span;
598     }
599     return null;
600 }
601 
602 fn writeGroupKey(writer: *std.Io.Writer, span: tree.Span, parent: ?tree.Span, options: Options) !void {
603     switch (options.group) {
604         .source => {
605             try writer.writeAll(span.name);
606             try writer.writeByte('\x1f');
607             if (span.file) |file| try writer.writeAll(file);
608             try writer.writeByte('\x1f');
609             if (span.function) |function| try writer.writeAll(function);
610             try writer.print("\x1f{d}:{d}", .{ span.line, span.column });
611         },
612         .name => try writer.writeAll(span.name),
613         .thread => try writer.print("thread:{d}", .{span.thread}),
614         .parent => {
615             if (parent) |actual| {
616                 try writer.print("parent:{d}:", .{actual.id});
617                 try writer.writeAll(actual.name);
618             } else {
619                 try writer.writeAll("parent:root");
620             }
621         },
622         .text => try writer.writeAll(span.text orelse ""),
623         .none => try writer.print("zone:{d}", .{span.id}),
624     }
625 }
626 
627 fn groupLabel(allocator: std.mem.Allocator, span: tree.Span, parent: ?tree.Span, group: Group) ![]u8 {
628     var writer = std.Io.Writer.Allocating.init(allocator);
629     defer writer.deinit();
630     switch (group) {
631         .source => try writer.writer.writeAll(span.name),
632         .name => try writer.writer.writeAll(span.name),
633         .thread => try writer.writer.print("thread {d}", .{span.thread}),
634         .parent => {
635             if (parent) |actual| {
636                 try writer.writer.writeAll(actual.name);
637             } else {
638                 try writer.writer.writeAll("<root>");
639             }
640         },
641         .text => try writer.writer.writeAll(span.text orelse ""),
642         .none => try writer.writer.print("{s}#{d}", .{ span.name, span.id }),
643     }
644     return try allocator.dupe(u8, writer.written());
645 }
646 
647 fn contains(haystack: []const u8, needle: []const u8, ignore_case: bool) bool {
648     if (!ignore_case) return std.mem.indexOf(u8, haystack, needle) != null;
649     if (needle.len == 0) return true;
650     if (needle.len > haystack.len) return false;
651     var index: usize = 0;
652     while (index + needle.len <= haystack.len) : (index += 1) {
653         if (asciiEqlIgnoreCase(haystack[index .. index + needle.len], needle)) return true;
654     }
655     return false;
656 }
657 
658 fn asciiEqlIgnoreCase(left: []const u8, right: []const u8) bool {
659     if (left.len != right.len) return false;
660     for (left, right) |a, b| {
661         if (std.ascii.toLower(a) != std.ascii.toLower(b)) return false;
662     }
663     return true;
664 }
665 
666 fn appendThread(allocator: std.mem.Allocator, threads: *std.ArrayListUnmanaged(u64), thread: u64) !void {
667     for (threads.items) |existing| {
668         if (existing == thread) return;
669     }
670     try threads.append(allocator, thread);
671 }
672 
673 fn dupeOptional(allocator: std.mem.Allocator, text: ?[]const u8) !?[]u8 {
674     const actual = text orelse return null;
675     return try allocator.dupe(u8, actual);
676 }
677 
678 fn percentile(sorted: []const u64, percent: usize) u64 {
679     if (sorted.len == 0) return 0;
680     const index = @min(sorted.len - 1, (sorted.len - 1) * percent / 100);
681     return sorted[index];
682 }
683 
684 fn sortGroups(items: []GroupView, sort: Sort) void {
685     switch (sort) {
686         .total => std.mem.sort(GroupView, items, {}, groupTotalGreaterThan),
687         .self => std.mem.sort(GroupView, items, {}, groupSelfGreaterThan),
688         .count => std.mem.sort(GroupView, items, {}, groupCountGreaterThan),
689         .mean => std.mem.sort(GroupView, items, {}, groupMeanGreaterThan),
690         .max => std.mem.sort(GroupView, items, {}, groupMaxGreaterThan),
691         .name => std.mem.sort(GroupView, items, {}, groupNameLessThan),
692         .time => std.mem.sort(GroupView, items, {}, groupTimeLessThan),
693     }
694 }
695 
696 fn sortOccurrences(items: []Occurrence, sort: Sort) void {
697     switch (sort) {
698         .time => std.mem.sort(Occurrence, items, {}, occurrenceTimeLessThan),
699         .name => std.mem.sort(Occurrence, items, {}, occurrenceNameLessThan),
700         else => std.mem.sort(Occurrence, items, {}, occurrenceDurationGreaterThan),
701     }
702 }
703 
704 fn groupTotalGreaterThan(_: void, left: GroupView, right: GroupView) bool {
705     if (left.total_ns != right.total_ns) return left.total_ns > right.total_ns;
706     return groupNameLessThan({}, left, right);
707 }
708 
709 fn groupSelfGreaterThan(_: void, left: GroupView, right: GroupView) bool {
710     if (left.self_ns != right.self_ns) return left.self_ns > right.self_ns;
711     return groupTotalGreaterThan({}, left, right);
712 }
713 
714 fn groupCountGreaterThan(_: void, left: GroupView, right: GroupView) bool {
715     if (left.count != right.count) return left.count > right.count;
716     return groupTotalGreaterThan({}, left, right);
717 }
718 
719 fn groupMeanGreaterThan(_: void, left: GroupView, right: GroupView) bool {
720     if (left.mean_ns != right.mean_ns) return left.mean_ns > right.mean_ns;
721     return groupTotalGreaterThan({}, left, right);
722 }
723 
724 fn groupMaxGreaterThan(_: void, left: GroupView, right: GroupView) bool {
725     if (left.max_ns != right.max_ns) return left.max_ns > right.max_ns;
726     return groupTotalGreaterThan({}, left, right);
727 }
728 
729 fn groupTimeLessThan(_: void, left: GroupView, right: GroupView) bool {
730     if (left.first_ns != right.first_ns) return left.first_ns < right.first_ns;
731     return groupNameLessThan({}, left, right);
732 }
733 
734 fn groupNameLessThan(_: void, left: GroupView, right: GroupView) bool {
735     const label_cmp = std.mem.order(u8, left.label, right.label);
736     if (label_cmp != .eq) return label_cmp == .lt;
737     return left.first_ns < right.first_ns;
738 }
739 
740 fn occurrenceDurationGreaterThan(_: void, left: Occurrence, right: Occurrence) bool {
741     if (left.duration_ns != right.duration_ns) return left.duration_ns > right.duration_ns;
742     return occurrenceTimeLessThan({}, left, right);
743 }
744 
745 fn occurrenceTimeLessThan(_: void, left: Occurrence, right: Occurrence) bool {
746     if (left.start_ns != right.start_ns) return left.start_ns < right.start_ns;
747     return left.id < right.id;
748 }
749 
750 fn occurrenceNameLessThan(_: void, left: Occurrence, right: Occurrence) bool {
751     const name_cmp = std.mem.order(u8, left.name, right.name);
752     if (name_cmp != .eq) return name_cmp == .lt;
753     return occurrenceTimeLessThan({}, left, right);
754 }
755 
756 fn u64LessThan(_: void, left: u64, right: u64) bool {
757     return left < right;
758 }
759 
760 test "find aggregates matched zones by source" {
761     var trace_bytes = std.Io.Writer.Allocating.init(std.testing.allocator);
762     defer trace_bytes.deinit();
763     try (event.TraceEvent{ .seq = 1, .kind = .zone_begin, .time_ns = 100, .thread = 1, .id = 1, .name = "root", .file = "root.zig", .line = 1 }).writeJsonLine(&trace_bytes.writer);
764     try (event.TraceEvent{ .seq = 2, .kind = .zone_begin, .time_ns = 120, .thread = 1, .id = 2, .name = "child phase", .file = "child.zig", .line = 2 }).writeJsonLine(&trace_bytes.writer);
765     try (event.TraceEvent{ .seq = 3, .kind = .zone_end, .time_ns = 150, .thread = 1, .id = 2 }).writeJsonLine(&trace_bytes.writer);
766     try (event.TraceEvent{ .seq = 4, .kind = .zone_end, .time_ns = 200, .thread = 1, .id = 1 }).writeJsonLine(&trace_bytes.writer);
767     try (event.TraceEvent{ .seq = 5, .kind = .zone_begin, .time_ns = 210, .thread = 2, .id = 3, .name = "Child phase", .file = "child.zig", .line = 2 }).writeJsonLine(&trace_bytes.writer);
768     try (event.TraceEvent{ .seq = 6, .kind = .zone_end, .time_ns = 260, .thread = 2, .id = 3 }).writeJsonLine(&trace_bytes.writer);
769 
770     var trace = tree.Analyzer.init(std.testing.allocator);
771     defer trace.deinit();
772     try trace.ingestJsonlBytes(trace_bytes.written());
773 
774     var analyzer = Analyzer.init(std.testing.allocator);
775     defer analyzer.deinit();
776     const options = Options{ .pattern = "child", .ignore_case = true };
777     try analyzer.ingestTree(&trace, options);
778 
779     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
780     defer out.deinit();
781     try writeText(std.testing.allocator, &analyzer, &out.writer, options);
782     const text = out.written();
783     try std.testing.expect(std.mem.indexOf(u8, text, "tracy find pattern=\"child\" groups=2 occurrences=2 spans=3 matched_spans=2") != null);
784     try std.testing.expect(std.mem.indexOf(u8, text, "find-group group=source label=\"Child phase\" count=1 total_ns=50") != null);
785     try std.testing.expect(std.mem.indexOf(u8, text, "find-group group=source label=\"child phase\" count=1 total_ns=30") != null);
786     try std.testing.expect(std.mem.indexOf(u8, text, "find-zone id=3 start_ns=210 end_ns=260 duration_ns=50 self_ns=50 thread=2") != null);
787 }
788 
789 test "find jsonl groups by parent and clips windowed matches" {
790     var trace_bytes = std.Io.Writer.Allocating.init(std.testing.allocator);
791     defer trace_bytes.deinit();
792     try (event.TraceEvent{ .seq = 1, .kind = .zone_begin, .time_ns = 100, .thread = 1, .id = 1, .name = "root" }).writeJsonLine(&trace_bytes.writer);
793     try (event.TraceEvent{ .seq = 2, .kind = .zone_begin, .time_ns = 120, .thread = 1, .id = 2, .name = "leaf" }).writeJsonLine(&trace_bytes.writer);
794     try (event.TraceEvent{ .seq = 3, .kind = .zone_text, .time_ns = 125, .thread = 1, .id = 2, .text = "queue wait" }).writeJsonLine(&trace_bytes.writer);
795     try (event.TraceEvent{ .seq = 4, .kind = .zone_end, .time_ns = 180, .thread = 1, .id = 2 }).writeJsonLine(&trace_bytes.writer);
796     try (event.TraceEvent{ .seq = 5, .kind = .zone_end, .time_ns = 220, .thread = 1, .id = 1 }).writeJsonLine(&trace_bytes.writer);
797 
798     var trace = tree.Analyzer.init(std.testing.allocator);
799     defer trace.deinit();
800     try trace.ingestJsonlBytes(trace_bytes.written());
801 
802     var analyzer = Analyzer.init(std.testing.allocator);
803     defer analyzer.deinit();
804     const options = Options{ .pattern = "queue", .group = .parent, .since_ns = 130, .until_ns = 170, .occurrences = 4 };
805     try analyzer.ingestTree(&trace, options);
806 
807     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
808     defer out.deinit();
809     try writeJsonl(std.testing.allocator, &analyzer, &out.writer, options);
810     const text = out.written();
811     try std.testing.expect(std.mem.indexOf(u8, text, "\"schema\":\"tracy.find/v0\"") != null);
812     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"summary\",\"pattern\":\"queue\",\"groups\":1,\"occurrences\":1") != null);
813     try std.testing.expect(std.mem.indexOf(u8, text, "\"group\":\"parent\",\"label\":\"root\"") != null);
814     try std.testing.expect(std.mem.indexOf(u8, text, "\"clipped_spans\":1") != null);
815     try std.testing.expect(std.mem.indexOf(u8, text, "\"duration_ns\":40") != null);
816     try std.testing.expect(std.mem.indexOf(u8, text, "\"text\":\"queue wait\"") != null);
817 }