lib/tracy/src/sample.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const pretty_json = @import("pretty").json;
  3 const report = @import("report.zig");
  4 const event = @import("event.zig");
  5 const record_mod = @import("record.zig");
  6 
  7 pub const schema = "tracy.samples/v0";
  8 
  9 pub const Group = enum {
 10     source,
 11     symbol,
 12     stack,
 13     thread,
 14     kind,
 15     none,
 16 
 17     pub fn fromName(text: []const u8) ?Group {
 18         if (std.mem.eql(u8, text, "source")) return .source;
 19         if (std.mem.eql(u8, text, "symbol")) return .symbol;
 20         if (std.mem.eql(u8, text, "stack")) return .stack;
 21         if (std.mem.eql(u8, text, "thread")) return .thread;
 22         if (std.mem.eql(u8, text, "kind")) return .kind;
 23         if (std.mem.eql(u8, text, "none")) return .none;
 24         return null;
 25     }
 26 
 27     fn tag(self: Group) []const u8 {
 28         return switch (self) {
 29             .source => "source",
 30             .symbol => "symbol",
 31             .stack => "stack",
 32             .thread => "thread",
 33             .kind => "kind",
 34             .none => "none",
 35         };
 36     }
 37 };
 38 
 39 pub const Sort = enum {
 40     samples,
 41     weight,
 42     last,
 43     first,
 44     thread,
 45     name,
 46     kind,
 47 
 48     pub fn fromName(text: []const u8) ?Sort {
 49         if (std.mem.eql(u8, text, "samples")) return .samples;
 50         if (std.mem.eql(u8, text, "weight")) return .weight;
 51         if (std.mem.eql(u8, text, "last")) return .last;
 52         if (std.mem.eql(u8, text, "first")) return .first;
 53         if (std.mem.eql(u8, text, "thread")) return .thread;
 54         if (std.mem.eql(u8, text, "name")) return .name;
 55         if (std.mem.eql(u8, text, "kind")) return .kind;
 56         return null;
 57     }
 58 
 59     fn tag(self: Sort) []const u8 {
 60         return switch (self) {
 61             .samples => "samples",
 62             .weight => "weight",
 63             .last => "last",
 64             .first => "first",
 65             .thread => "thread",
 66             .name => "name",
 67             .kind => "kind",
 68         };
 69     }
 70 };
 71 
 72 pub const Options = struct {
 73     top: usize = 20,
 74     occurrences: usize = 80,
 75     group: Group = .source,
 76     sort: Sort = .samples,
 77     min_weight: u64 = 0,
 78     thread: ?u64 = null,
 79     since_ns: ?u64 = null,
 80     until_ns: ?u64 = null,
 81     kind: ?[]const u8 = null,
 82     match: ?[]const u8 = null,
 83     ignore_case: bool = false,
 84 };
 85 
 86 pub const Counters = struct {
 87     events: u64 = 0,
 88     samples: u64 = 0,
 89     frames: u64 = 0,
 90     filtered: u64 = 0,
 91     groups: u64 = 0,
 92     duration_ns: u64 = 0,
 93 };
 94 
 95 const ThreadName = struct {
 96     name: []u8,
 97 
 98     fn deinit(self: *ThreadName, allocator: std.mem.Allocator) void {
 99         allocator.free(self.name);
100         self.* = undefined;
101     }
102 };
103 
104 const Frame = struct {
105     depth: u32 = 0,
106     name: ?[]u8 = null,
107     function: ?[]u8 = null,
108     file: ?[]u8 = null,
109     line: u32 = 0,
110     column: u32 = 0,
111     address: u64 = 0,
112 
113     fn deinit(self: *Frame, allocator: std.mem.Allocator) void {
114         if (self.name) |name| allocator.free(name);
115         if (self.function) |function| allocator.free(function);
116         if (self.file) |file| allocator.free(file);
117         self.* = undefined;
118     }
119 };
120 
121 const SourceView = struct {
122     name: ?[]const u8 = null,
123     function: ?[]const u8 = null,
124     file: ?[]const u8 = null,
125     line: u32 = 0,
126     column: u32 = 0,
127     address: u64 = 0,
128 };
129 
130 const Sample = struct {
131     id: u64 = 0,
132     seen: bool = false,
133     seq: u64 = 0,
134     time_ns: u64 = 0,
135     thread: u64 = 0,
136     name: ?[]u8 = null,
137     sample_kind: ?[]u8 = null,
138     function: ?[]u8 = null,
139     file: ?[]u8 = null,
140     line: u32 = 0,
141     column: u32 = 0,
142     address: u64 = 0,
143     weight: u64 = 1,
144     frames: std.ArrayListUnmanaged(Frame) = .empty,
145 
146     fn deinit(self: *Sample, allocator: std.mem.Allocator) void {
147         self.clearHeader(allocator);
148         for (self.frames.items) |*frame| frame.deinit(allocator);
149         self.frames.deinit(allocator);
150         self.* = undefined;
151     }
152 
153     fn clearHeader(self: *Sample, allocator: std.mem.Allocator) void {
154         if (self.name) |name| allocator.free(name);
155         if (self.sample_kind) |sample_kind| allocator.free(sample_kind);
156         if (self.function) |function| allocator.free(function);
157         if (self.file) |file| allocator.free(file);
158         self.name = null;
159         self.sample_kind = null;
160         self.function = null;
161         self.file = null;
162     }
163 
164     fn sortFrames(self: *Sample) void {
165         std.mem.sort(Frame, self.frames.items, {}, frameLessThan);
166     }
167 
168     fn kind(self: Sample) []const u8 {
169         return self.sample_kind orelse "manual";
170     }
171 
172     fn source(self: Sample) SourceView {
173         if (self.file != null or self.function != null or self.address != 0) {
174             return .{
175                 .name = self.name,
176                 .function = self.function,
177                 .file = self.file,
178                 .line = self.line,
179                 .column = self.column,
180                 .address = self.address,
181             };
182         }
183         if (self.frames.items.len != 0) {
184             const frame = self.frames.items[self.frames.items.len - 1];
185             return .{
186                 .name = frame.name,
187                 .function = frame.function,
188                 .file = frame.file,
189                 .line = frame.line,
190                 .column = frame.column,
191                 .address = frame.address,
192             };
193         }
194         return .{ .name = self.name, .address = self.address };
195     }
196 };
197 
198 pub const Summary = struct {
199     group: Group,
200     label: []u8,
201     count: u64 = 0,
202     weight: u64 = 0,
203     first_ns: u64 = 0,
204     last_ns: u64 = 0,
205     thread: ?u64 = null,
206     kind: ?[]const u8 = null,
207     name: ?[]const u8 = null,
208     file: ?[]const u8 = null,
209     function: ?[]const u8 = null,
210     line: u32 = 0,
211     column: u32 = 0,
212     address: u64 = 0,
213 
214     pub fn deinit(self: *Summary, allocator: std.mem.Allocator) void {
215         allocator.free(self.label);
216         self.* = undefined;
217     }
218 };
219 
220 const Occurrence = struct {
221     sample: *const Sample,
222 };
223 
224 pub const Analyzer = struct {
225     allocator: std.mem.Allocator,
226     samples: std.AutoHashMapUnmanaged(u64, Sample) = .{},
227     threads: std.AutoHashMapUnmanaged(u64, ThreadName) = .{},
228     counters: Counters = .{},
229     start_ns: ?u64 = null,
230     end_ns: ?u64 = null,
231     next_anonymous_id: u64 = 1,
232 
233     pub fn init(allocator: std.mem.Allocator) Analyzer {
234         return .{ .allocator = allocator };
235     }
236 
237     pub fn deinit(self: *Analyzer) void {
238         var sample_iter = self.samples.valueIterator();
239         while (sample_iter.next()) |sample| sample.deinit(self.allocator);
240         self.samples.deinit(self.allocator);
241         var thread_iter = self.threads.valueIterator();
242         while (thread_iter.next()) |thread| thread.deinit(self.allocator);
243         self.threads.deinit(self.allocator);
244         self.* = undefined;
245     }
246 
247     pub fn ingestJsonlBytes(self: *Analyzer, bytes: []const u8) !void {
248         var lines = std.mem.splitScalar(u8, bytes, '\n');
249         while (lines.next()) |line| try self.ingestJsonLine(line);
250     }
251 
252     pub fn ingestJsonLine(self: *Analyzer, line: []const u8) !void {
253         const text = std.mem.trim(u8, line, " \t\r\n");
254         if (text.len == 0) return;
255         var parsed = (try record_mod.parseEventLine(self.allocator, text)) orelse return;
256         defer parsed.deinit();
257         try self.ingest(parsed);
258     }
259 
260     pub fn ingest(self: *Analyzer, parsed: event.Parsed) !void {
261         self.counters.events += 1;
262         if (self.start_ns == null and parsed.time_ns != 0) self.start_ns = parsed.time_ns;
263         if (parsed.time_ns != 0) self.end_ns = parsed.time_ns;
264         switch (parsed.kind) {
265             .start => {
266                 if (parsed.time_ns != 0) self.start_ns = parsed.time_ns;
267             },
268             .stop => {
269                 if (parsed.time_ns != 0) self.end_ns = parsed.time_ns;
270             },
271             .thread_name => try self.recordThreadName(parsed),
272             .sample => try self.recordSample(parsed),
273             .sample_frame => try self.recordFrame(parsed),
274             else => {},
275         }
276     }
277 
278     pub fn collectSummaries(self: *Analyzer, allocator: std.mem.Allocator, options: Options) !std.ArrayListUnmanaged(Summary) {
279         self.prepareSamples();
280         var summaries: std.ArrayListUnmanaged(Summary) = .empty;
281         errdefer deinitSummaries(allocator, &summaries);
282         var groups: std.StringHashMapUnmanaged(usize) = .{};
283         defer groups.deinit(allocator);
284 
285         self.counters.filtered = 0;
286         var iter = self.samples.valueIterator();
287         while (iter.next()) |sample| {
288             if (!sample.seen) continue;
289             if (!self.sampleMatches(sample.*, options)) {
290                 self.counters.filtered += 1;
291                 continue;
292             }
293             const label = try self.groupLabel(allocator, sample.*, options.group);
294             errdefer allocator.free(label);
295             const group_entry = try groups.getOrPut(allocator, label);
296             var summary_index: usize = undefined;
297             if (group_entry.found_existing) {
298                 allocator.free(label);
299                 summary_index = group_entry.value_ptr.*;
300             } else {
301                 group_entry.key_ptr.* = label;
302                 summary_index = summaries.items.len;
303                 group_entry.value_ptr.* = summary_index;
304                 const src = sample.source();
305                 try summaries.append(allocator, .{
306                     .group = options.group,
307                     .label = label,
308                     .thread = if (options.group == .thread) sample.thread else null,
309                     .kind = if (options.group == .kind) sample.kind() else null,
310                     .name = src.name,
311                     .file = src.file,
312                     .function = src.function,
313                     .line = src.line,
314                     .column = src.column,
315                     .address = src.address,
316                 });
317             }
318             const summary = &summaries.items[summary_index];
319             summary.count += 1;
320             summary.weight +|= sample.weight;
321             if (summary.first_ns == 0 or sample.time_ns < summary.first_ns) summary.first_ns = sample.time_ns;
322             summary.last_ns = @max(summary.last_ns, sample.time_ns);
323             if (summary.kind == null) summary.kind = sample.kind();
324             if (summary.thread == null and options.group == .thread) summary.thread = sample.thread;
325         }
326 
327         self.counters.groups = @intCast(summaries.items.len);
328         self.counters.duration_ns = self.durationNs();
329         sortSummaries(summaries.items, options.sort);
330         return summaries;
331     }
332 
333     pub fn collectOccurrences(self: *Analyzer, allocator: std.mem.Allocator, options: Options) !std.ArrayListUnmanaged(Occurrence) {
334         self.prepareSamples();
335         var occurrences: std.ArrayListUnmanaged(Occurrence) = .empty;
336         var iter = self.samples.valueIterator();
337         while (iter.next()) |sample| {
338             if (!sample.seen) continue;
339             if (!self.sampleMatches(sample.*, options)) continue;
340             try occurrences.append(allocator, .{ .sample = sample });
341         }
342         sortOccurrences(occurrences.items, options.sort);
343         return occurrences;
344     }
345 
346     pub fn durationNs(self: Analyzer) u64 {
347         const start_ns = self.start_ns orelse return 0;
348         const end_ns = self.end_ns orelse return 0;
349         if (end_ns <= start_ns) return 0;
350         return end_ns - start_ns;
351     }
352 
353     fn recordThreadName(self: *Analyzer, parsed: event.Parsed) !void {
354         const name = parsed.name orelse return;
355         const entry = try self.threads.getOrPut(self.allocator, parsed.thread);
356         if (entry.found_existing) entry.value_ptr.deinit(self.allocator);
357         entry.value_ptr.* = .{ .name = try self.allocator.dupe(u8, name) };
358     }
359 
360     fn recordSample(self: *Analyzer, parsed: event.Parsed) !void {
361         const key = self.sampleKey(parsed);
362         const entry = try self.samples.getOrPut(self.allocator, key);
363         if (!entry.found_existing) {
364             entry.value_ptr.* = .{ .id = parsed.id };
365         } else {
366             entry.value_ptr.clearHeader(self.allocator);
367         }
368         entry.value_ptr.* = .{
369             .id = parsed.id,
370             .seen = true,
371             .seq = parsed.seq,
372             .time_ns = parsed.time_ns,
373             .thread = parsed.thread,
374             .name = try dupeOptional(self.allocator, parsed.name),
375             .sample_kind = try dupeOptional(self.allocator, parsed.sample_kind),
376             .function = try dupeOptional(self.allocator, parsed.function),
377             .file = try dupeOptional(self.allocator, parsed.file),
378             .line = parsed.line,
379             .column = parsed.column,
380             .address = parsed.address,
381             .weight = parsed.value_u64 orelse 1,
382             .frames = entry.value_ptr.frames,
383         };
384         self.counters.samples += 1;
385     }
386 
387     fn recordFrame(self: *Analyzer, parsed: event.Parsed) !void {
388         if (parsed.id == 0) return;
389         const entry = try self.samples.getOrPut(self.allocator, parsed.id);
390         if (!entry.found_existing) entry.value_ptr.* = .{ .id = parsed.id };
391         try entry.value_ptr.frames.append(self.allocator, .{
392             .depth = parsed.depth,
393             .name = try dupeOptional(self.allocator, parsed.name),
394             .function = try dupeOptional(self.allocator, parsed.function),
395             .file = try dupeOptional(self.allocator, parsed.file),
396             .line = parsed.line,
397             .column = parsed.column,
398             .address = parsed.address,
399         });
400         self.counters.frames += 1;
401     }
402 
403     fn sampleKey(self: *Analyzer, parsed: event.Parsed) u64 {
404         if (parsed.id != 0) return parsed.id;
405         const key = std.math.maxInt(u64) - self.next_anonymous_id;
406         self.next_anonymous_id +|= 1;
407         return key;
408     }
409 
410     fn prepareSamples(self: *Analyzer) void {
411         var iter = self.samples.valueIterator();
412         while (iter.next()) |sample| sample.sortFrames();
413     }
414 
415     fn sampleMatches(self: *Analyzer, sample: Sample, options: Options) bool {
416         if (options.thread) |thread| {
417             if (sample.thread != thread) return false;
418         }
419         if (options.since_ns) |since_ns| {
420             if (sample.time_ns < since_ns) return false;
421         }
422         if (options.until_ns) |until_ns| {
423             if (sample.time_ns > until_ns) return false;
424         }
425         if (sample.weight < options.min_weight) return false;
426         if (options.kind) |kind| {
427             if (!textEquals(sample.kind(), kind, options.ignore_case)) return false;
428         }
429         if (options.match) |needle| {
430             if (!self.sampleContains(sample, needle, options.ignore_case)) return false;
431         }
432         return true;
433     }
434 
435     fn sampleContains(self: *Analyzer, sample: Sample, needle: []const u8, ignore_case: bool) bool {
436         if (sample.name) |name| if (contains(name, needle, ignore_case)) return true;
437         if (contains(sample.kind(), needle, ignore_case)) return true;
438         if (sample.file) |file| if (contains(file, needle, ignore_case)) return true;
439         if (sample.function) |function| if (contains(function, needle, ignore_case)) return true;
440         if (self.threadName(sample.thread)) |thread_name| if (contains(thread_name, needle, ignore_case)) return true;
441         for (sample.frames.items) |frame| {
442             if (frame.name) |name| if (contains(name, needle, ignore_case)) return true;
443             if (frame.file) |file| if (contains(file, needle, ignore_case)) return true;
444             if (frame.function) |function| if (contains(function, needle, ignore_case)) return true;
445         }
446         return false;
447     }
448 
449     fn groupLabel(self: *Analyzer, allocator: std.mem.Allocator, sample: Sample, group: Group) ![]u8 {
450         var writer = std.Io.Writer.Allocating.init(allocator);
451         defer writer.deinit();
452         switch (group) {
453             .source => try writeSourceLabel(&writer.writer, sample),
454             .symbol => try writeSymbolLabel(&writer.writer, sample),
455             .stack => try writeStackLabel(&writer.writer, sample),
456             .thread => {
457                 try writer.writer.print("thread {d}", .{sample.thread});
458                 if (self.threadName(sample.thread)) |thread_name| {
459                     try writer.writer.writeAll(" ");
460                     try writer.writer.writeAll(thread_name);
461                 }
462             },
463             .kind => try writer.writer.writeAll(sample.kind()),
464             .none => try writer.writer.print("sample {d} {d}", .{ sample.id, sample.time_ns }),
465         }
466         return try allocator.dupe(u8, writer.written());
467     }
468 
469     fn threadName(self: *Analyzer, thread: u64) ?[]const u8 {
470         const entry = self.threads.get(thread) orelse return null;
471         return entry.name;
472     }
473 };
474 
475 pub fn deinitSummaries(allocator: std.mem.Allocator, summaries: *std.ArrayListUnmanaged(Summary)) void {
476     for (summaries.items) |*summary| summary.deinit(allocator);
477     summaries.deinit(allocator);
478 }
479 
480 pub fn writeTextFromJsonlPath(
481     allocator: std.mem.Allocator,
482     path: []const u8,
483     writer: *std.Io.Writer,
484     options: Options,
485 ) !void {
486     return report.writeFromJsonlPath(Analyzer, writeText, allocator, path, writer, options);
487 }
488 
489 pub fn writeJsonlFromJsonlPath(
490     allocator: std.mem.Allocator,
491     path: []const u8,
492     writer: *std.Io.Writer,
493     options: Options,
494 ) !void {
495     return report.writeFromJsonlPath(Analyzer, writeJsonl, allocator, path, writer, options);
496 }
497 
498 pub fn ingestPath(analyzer: *Analyzer, path: []const u8) !void {
499     return report.ingestJsonlPath(analyzer, path);
500 }
501 
502 fn writeText(
503     allocator: std.mem.Allocator,
504     analyzer: *Analyzer,
505     writer: *std.Io.Writer,
506     options: Options,
507 ) !void {
508     var summaries = try analyzer.collectSummaries(allocator, options);
509     defer deinitSummaries(allocator, &summaries);
510     var occurrences = try analyzer.collectOccurrences(allocator, options);
511     defer occurrences.deinit(allocator);
512 
513     try writer.print(
514         "tracy samples groups={d} samples={d} frames={d} filtered={d} duration_ns={d} group={s} sort={s}\n",
515         .{
516             summaries.items.len,
517             analyzer.counters.samples,
518             analyzer.counters.frames,
519             analyzer.counters.filtered,
520             analyzer.durationNs(),
521             options.group.tag(),
522             options.sort.tag(),
523         },
524     );
525     const summary_limit = @min(options.top, summaries.items.len);
526     for (summaries.items[0..summary_limit]) |summary| {
527         try writer.print("sample group={s} label=", .{summary.group.tag()});
528         try pretty_json.writeString(writer, summary.label);
529         try writer.print(" count={d} weight={d} first_ns={d} last_ns={d}", .{ summary.count, summary.weight, summary.first_ns, summary.last_ns });
530         if (summary.kind) |kind| {
531             try writer.writeAll(" kind=");
532             try pretty_json.writeString(writer, kind);
533         }
534         if (summary.thread) |thread| try writer.print(" thread={d}", .{thread});
535         if (summary.file) |file| try writer.print(" file={s}:{d}", .{ file, summary.line });
536         if (summary.function) |function| {
537             try writer.writeAll(" function=");
538             try pretty_json.writeString(writer, function);
539         }
540         if (summary.address != 0) try writer.print(" address={d}", .{summary.address});
541         try writer.writeByte('\n');
542     }
543 
544     const occurrence_limit = @min(options.occurrences, occurrences.items.len);
545     for (occurrences.items[0..occurrence_limit]) |occurrence| {
546         const sample = occurrence.sample;
547         try writer.print("sample-occurrence time_ns={d} thread={d}", .{ sample.time_ns, sample.thread });
548         if (sample.id != 0) try writer.print(" id={d}", .{sample.id});
549         if (sample.name) |name| {
550             try writer.writeAll(" name=");
551             try pretty_json.writeString(writer, name);
552         }
553         try writer.writeAll(" kind=");
554         try pretty_json.writeString(writer, sample.kind());
555         try writer.print(" weight={d}", .{sample.weight});
556         try writeSourceText(writer, sample.*);
557         if (sample.frames.items.len != 0) {
558             const stack = try stackLabel(allocator, sample.*);
559             defer allocator.free(stack);
560             try writer.writeAll(" stack=");
561             try pretty_json.writeString(writer, stack);
562         }
563         try writer.writeByte('\n');
564     }
565 }
566 
567 fn writeJsonl(
568     allocator: std.mem.Allocator,
569     analyzer: *Analyzer,
570     writer: *std.Io.Writer,
571     options: Options,
572 ) !void {
573     var summaries = try analyzer.collectSummaries(allocator, options);
574     defer deinitSummaries(allocator, &summaries);
575     var occurrences = try analyzer.collectOccurrences(allocator, options);
576     defer occurrences.deinit(allocator);
577 
578     var summary_stream = pretty_json.Writer.init(writer, .minified);
579     const summary_record = try summary_stream.object();
580     try summary_record.field("schema", schema);
581     try summary_record.field("kind", "summary");
582     try summary_record.field("groups", summaries.items.len);
583     try summary_record.field("samples", analyzer.counters.samples);
584     try summary_record.field("frames", analyzer.counters.frames);
585     try summary_record.field("filtered", analyzer.counters.filtered);
586     try summary_record.field("duration_ns", analyzer.durationNs());
587     try summary_record.field("group", options.group.tag());
588     try summary_record.field("sort", options.sort.tag());
589     try summary_record.endLine();
590 
591     const summary_limit = @min(options.top, summaries.items.len);
592     for (summaries.items[0..summary_limit]) |summary| {
593         var stream = pretty_json.Writer.init(writer, .minified);
594         const object = try stream.object();
595         try object.field("schema", schema);
596         try object.field("kind", "group");
597         try object.field("group", summary.group.tag());
598         try object.field("label", summary.label);
599         try object.field("count", summary.count);
600         try object.field("weight", summary.weight);
601         try object.field("first_ns", summary.first_ns);
602         try object.field("last_ns", summary.last_ns);
603         if (summary.kind) |kind| try object.field("sample_kind", kind);
604         if (summary.thread) |thread| try object.field("thread", thread);
605         if (summary.file) |file| {
606             try object.field("file", file);
607             try object.field("line", summary.line);
608         }
609         if (summary.function) |function| try object.field("function", function);
610         if (summary.address != 0) try object.field("address", summary.address);
611         try object.endLine();
612     }
613 
614     const occurrence_limit = @min(options.occurrences, occurrences.items.len);
615     for (occurrences.items[0..occurrence_limit]) |occurrence| {
616         const sample = occurrence.sample;
617         var stream = pretty_json.Writer.init(writer, .minified);
618         const object = try stream.object();
619         try object.field("schema", schema);
620         try object.field("kind", "sample");
621         try object.field("time_ns", sample.time_ns);
622         try object.field("thread", sample.thread);
623         if (sample.id != 0) try object.field("id", sample.id);
624         if (sample.name) |name| try object.field("name", name);
625         try object.field("sample_kind", sample.kind());
626         try object.field("weight", sample.weight);
627         try writeSourceFields(object, sample.*);
628         if (sample.frames.items.len != 0) {
629             const stack = try stackLabel(allocator, sample.*);
630             defer allocator.free(stack);
631             try object.field("stack", stack);
632         }
633         try object.endLine();
634     }
635 }
636 
637 fn writeSourceText(writer: *std.Io.Writer, sample: Sample) !void {
638     const src = sample.source();
639     if (src.file) |file| try writer.print(" file={s}:{d}", .{ file, src.line });
640     if (src.function) |function| {
641         try writer.writeAll(" function=");
642         try pretty_json.writeString(writer, function);
643     }
644     if (src.address != 0) try writer.print(" address={d}", .{src.address});
645 }
646 
647 fn writeSourceFields(object: pretty_json.Object, sample: Sample) !void {
648     const src = sample.source();
649     if (src.file) |file| {
650         try object.field("file", file);
651         try object.field("line", src.line);
652     }
653     if (src.function) |function| try object.field("function", function);
654     if (src.address != 0) try object.field("address", src.address);
655 }
656 
657 fn writeSourceLabel(writer: *std.Io.Writer, sample: Sample) !void {
658     const src = sample.source();
659     if (src.file) |file| {
660         try writer.print("{s}:{d}", .{ file, src.line });
661     } else {
662         try writeSymbolLabel(writer, sample);
663     }
664 }
665 
666 fn writeSymbolLabel(writer: *std.Io.Writer, sample: Sample) !void {
667     const src = sample.source();
668     if (src.function) |function| {
669         try writer.writeAll(function);
670     } else if (src.name) |name| {
671         try writer.writeAll(name);
672     } else if (sample.name) |name| {
673         try writer.writeAll(name);
674     } else if (src.address != 0) {
675         try writer.print("0x{x}", .{src.address});
676     } else {
677         try writer.writeAll("<sample>");
678     }
679 }
680 
681 fn writeStackLabel(writer: *std.Io.Writer, sample: Sample) !void {
682     if (sample.frames.items.len == 0) return try writeSymbolLabel(writer, sample);
683     for (sample.frames.items, 0..) |frame, index| {
684         if (index != 0) try writer.writeByte(';');
685         try writeFrameLabel(writer, frame);
686     }
687 }
688 
689 fn writeFrameLabel(writer: *std.Io.Writer, frame: Frame) !void {
690     if (frame.name) |name| {
691         try writer.writeAll(name);
692     } else if (frame.function) |function| {
693         try writer.writeAll(function);
694     } else if (frame.file) |file| {
695         try writer.print("{s}:{d}", .{ file, frame.line });
696     } else if (frame.address != 0) {
697         try writer.print("0x{x}", .{frame.address});
698     } else {
699         try writer.writeAll("<frame>");
700     }
701 }
702 
703 fn stackLabel(allocator: std.mem.Allocator, sample: Sample) ![]u8 {
704     var writer = std.Io.Writer.Allocating.init(allocator);
705     defer writer.deinit();
706     try writeStackLabel(&writer.writer, sample);
707     return try allocator.dupe(u8, writer.written());
708 }
709 
710 fn dupeOptional(allocator: std.mem.Allocator, text: ?[]const u8) !?[]u8 {
711     const actual = text orelse return null;
712     return try allocator.dupe(u8, actual);
713 }
714 
715 fn frameLessThan(_: void, left: Frame, right: Frame) bool {
716     if (left.depth != right.depth) return left.depth < right.depth;
717     return left.address < right.address;
718 }
719 
720 fn sortSummaries(items: []Summary, sort: Sort) void {
721     std.mem.sort(Summary, items, sort, summaryLessThan);
722 }
723 
724 fn summaryLessThan(sort: Sort, left: Summary, right: Summary) bool {
725     return switch (sort) {
726         .samples => summarySamplesGreaterThan({}, left, right),
727         .weight => summaryWeightGreaterThan({}, left, right),
728         .last => summaryLastGreaterThan({}, left, right),
729         .first => summaryFirstLessThan({}, left, right),
730         .thread => summaryThreadLessThan({}, left, right),
731         .name => summaryNameLessThan({}, left, right),
732         .kind => summaryKindLessThan({}, left, right),
733     };
734 }
735 
736 fn summarySamplesGreaterThan(_: void, left: Summary, right: Summary) bool {
737     if (left.count != right.count) return left.count > right.count;
738     if (left.weight != right.weight) return left.weight > right.weight;
739     return std.mem.lessThan(u8, left.label, right.label);
740 }
741 
742 fn summaryWeightGreaterThan(_: void, left: Summary, right: Summary) bool {
743     if (left.weight != right.weight) return left.weight > right.weight;
744     return summarySamplesGreaterThan({}, left, right);
745 }
746 
747 fn summaryLastGreaterThan(_: void, left: Summary, right: Summary) bool {
748     if (left.last_ns != right.last_ns) return left.last_ns > right.last_ns;
749     return summarySamplesGreaterThan({}, left, right);
750 }
751 
752 fn summaryFirstLessThan(_: void, left: Summary, right: Summary) bool {
753     if (left.first_ns != right.first_ns) return left.first_ns < right.first_ns;
754     return summarySamplesGreaterThan({}, left, right);
755 }
756 
757 fn summaryThreadLessThan(_: void, left: Summary, right: Summary) bool {
758     const left_thread = left.thread orelse 0;
759     const right_thread = right.thread orelse 0;
760     if (left_thread != right_thread) return left_thread < right_thread;
761     return summarySamplesGreaterThan({}, left, right);
762 }
763 
764 fn summaryNameLessThan(_: void, left: Summary, right: Summary) bool {
765     return std.mem.lessThan(u8, left.label, right.label);
766 }
767 
768 fn summaryKindLessThan(_: void, left: Summary, right: Summary) bool {
769     const left_kind = left.kind orelse "";
770     const right_kind = right.kind orelse "";
771     if (!std.mem.eql(u8, left_kind, right_kind)) return std.mem.lessThan(u8, left_kind, right_kind);
772     return summarySamplesGreaterThan({}, left, right);
773 }
774 
775 fn sortOccurrences(items: []Occurrence, sort: Sort) void {
776     switch (sort) {
777         .last, .weight => std.mem.sort(Occurrence, items, {}, occurrenceTimeGreaterThan),
778         else => std.mem.sort(Occurrence, items, {}, occurrenceTimeLessThan),
779     }
780 }
781 
782 fn occurrenceTimeLessThan(_: void, left: Occurrence, right: Occurrence) bool {
783     if (left.sample.time_ns != right.sample.time_ns) return left.sample.time_ns < right.sample.time_ns;
784     return left.sample.seq < right.sample.seq;
785 }
786 
787 fn occurrenceTimeGreaterThan(_: void, left: Occurrence, right: Occurrence) bool {
788     if (left.sample.time_ns != right.sample.time_ns) return left.sample.time_ns > right.sample.time_ns;
789     return left.sample.seq > right.sample.seq;
790 }
791 
792 fn contains(haystack: []const u8, needle: []const u8, ignore_case: bool) bool {
793     if (!ignore_case) return std.mem.indexOf(u8, haystack, needle) != null;
794     if (needle.len == 0) return true;
795     if (needle.len > haystack.len) return false;
796     var index: usize = 0;
797     while (index + needle.len <= haystack.len) : (index += 1) {
798         if (asciiEqlIgnoreCase(haystack[index .. index + needle.len], needle)) return true;
799     }
800     return false;
801 }
802 
803 fn textEquals(left: []const u8, right: []const u8, ignore_case: bool) bool {
804     if (!ignore_case) return std.mem.eql(u8, left, right);
805     return asciiEqlIgnoreCase(left, right);
806 }
807 
808 fn asciiEqlIgnoreCase(left: []const u8, right: []const u8) bool {
809     if (left.len != right.len) return false;
810     for (left, right) |a, b| {
811         if (std.ascii.toLower(a) != std.ascii.toLower(b)) return false;
812     }
813     return true;
814 }
815 
816 test "samples aggregate stacks and source locations" {
817     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
818     defer trace.deinit();
819     try (event.TraceEvent{ .seq = 1, .kind = .start, .time_ns = 90, .thread = 7, .name = "test" }).writeJsonLine(&trace.writer);
820     try (event.TraceEvent{ .seq = 2, .kind = .thread_name, .time_ns = 95, .thread = 7, .name = "worker" }).writeJsonLine(&trace.writer);
821     try (event.TraceEvent{ .seq = 3, .kind = .sample, .time_ns = 100, .thread = 7, .id = 1, .name = "tick", .function = "leaf", .file = "leaf.zig", .line = 9, .sample_kind = "cpu" }).writeJsonLine(&trace.writer);
822     try (event.TraceEvent{ .seq = 4, .kind = .sample_frame, .time_ns = 100, .thread = 7, .id = 1, .depth = 0, .name = "root", .function = "root", .file = "root.zig", .line = 1 }).writeJsonLine(&trace.writer);
823     try (event.TraceEvent{ .seq = 5, .kind = .sample_frame, .time_ns = 100, .thread = 7, .id = 1, .depth = 1, .name = "leaf", .function = "leaf", .file = "leaf.zig", .line = 9 }).writeJsonLine(&trace.writer);
824     try (event.TraceEvent{ .seq = 6, .kind = .sample, .time_ns = 120, .thread = 7, .id = 2, .name = "tick", .function = "leaf", .file = "leaf.zig", .line = 9, .value_u64 = 2, .sample_kind = "cpu" }).writeJsonLine(&trace.writer);
825     try (event.TraceEvent{ .seq = 7, .kind = .sample_frame, .time_ns = 120, .thread = 7, .id = 2, .depth = 0, .name = "root" }).writeJsonLine(&trace.writer);
826     try (event.TraceEvent{ .seq = 8, .kind = .sample_frame, .time_ns = 120, .thread = 7, .id = 2, .depth = 1, .name = "leaf" }).writeJsonLine(&trace.writer);
827     try (event.TraceEvent{ .seq = 9, .kind = .stop, .time_ns = 130, .thread = 7 }).writeJsonLine(&trace.writer);
828 
829     var analyzer = Analyzer.init(std.testing.allocator);
830     defer analyzer.deinit();
831     try analyzer.ingestJsonlBytes(trace.written());
832     try std.testing.expectEqual(@as(u64, 2), analyzer.counters.samples);
833     try std.testing.expectEqual(@as(u64, 4), analyzer.counters.frames);
834 
835     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
836     defer out.deinit();
837     try writeText(std.testing.allocator, &analyzer, &out.writer, .{ .group = .stack, .sort = .samples, .top = 4, .occurrences = 4 });
838     const text = out.written();
839     try std.testing.expect(std.mem.indexOf(u8, text, "tracy samples groups=1 samples=2 frames=4") != null);
840     try std.testing.expect(std.mem.indexOf(u8, text, "label=\"root;leaf\" count=2 weight=3") != null);
841     try std.testing.expect(std.mem.indexOf(u8, text, "sample-occurrence time_ns=100 thread=7 id=1 name=\"tick\" kind=\"cpu\" weight=1 file=leaf.zig:9 function=\"leaf\" stack=\"root;leaf\"") != null);
842 }
843 
844 test "samples jsonl filters by kind thread and match text" {
845     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
846     defer trace.deinit();
847     try (event.TraceEvent{ .seq = 1, .kind = .sample, .time_ns = 100, .thread = 1, .id = 1, .name = "idle", .function = "idle", .file = "idle.zig", .line = 1, .sample_kind = "wall" }).writeJsonLine(&trace.writer);
848     try (event.TraceEvent{ .seq = 2, .kind = .sample, .time_ns = 110, .thread = 2, .id = 2, .name = "parse", .function = "parse", .file = "parse.zig", .line = 7, .sample_kind = "cpu" }).writeJsonLine(&trace.writer);
849     try (event.TraceEvent{ .seq = 3, .kind = .sample_frame, .time_ns = 110, .thread = 2, .id = 2, .name = "parse" }).writeJsonLine(&trace.writer);
850 
851     var analyzer = Analyzer.init(std.testing.allocator);
852     defer analyzer.deinit();
853     try analyzer.ingestJsonlBytes(trace.written());
854 
855     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
856     defer out.deinit();
857     try writeJsonl(std.testing.allocator, &analyzer, &out.writer, .{ .group = .source, .kind = "cpu", .thread = 2, .match = "PARSE", .ignore_case = true });
858     const text = out.written();
859     try std.testing.expect(std.mem.indexOf(u8, text, "\"schema\":\"tracy.samples/v0\"") != null);
860     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"summary\",\"groups\":1") != null);
861     try std.testing.expect(std.mem.indexOf(u8, text, "\"label\":\"parse.zig:7\"") != null);
862     try std.testing.expect(std.mem.indexOf(u8, text, "\"idle\"") == null);
863 }