lib/tracy/src/message.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const pretty_json = @import("pretty").json;
  3 const sys = @import("sys");
  4 const event = @import("event.zig");
  5 const record_mod = @import("record.zig");
  6 
  7 pub const schema = "tracy.messages/v0";
  8 
  9 pub const Kind = enum {
 10     all,
 11     message,
 12     app_info,
 13 
 14     pub fn fromName(text: []const u8) ?Kind {
 15         if (std.mem.eql(u8, text, "all")) return .all;
 16         if (std.mem.eql(u8, text, "message")) return .message;
 17         if (std.mem.eql(u8, text, "app-info")) return .app_info;
 18         return null;
 19     }
 20 
 21     fn tag(self: Kind) []const u8 {
 22         return switch (self) {
 23             .all => "all",
 24             .message => "message",
 25             .app_info => "app-info",
 26         };
 27     }
 28 };
 29 
 30 pub const Group = enum {
 31     thread,
 32     text,
 33     kind,
 34     none,
 35 
 36     pub fn fromName(text: []const u8) ?Group {
 37         if (std.mem.eql(u8, text, "thread")) return .thread;
 38         if (std.mem.eql(u8, text, "text")) return .text;
 39         if (std.mem.eql(u8, text, "kind")) return .kind;
 40         if (std.mem.eql(u8, text, "none")) return .none;
 41         return null;
 42     }
 43 
 44     fn tag(self: Group) []const u8 {
 45         return switch (self) {
 46             .thread => "thread",
 47             .text => "text",
 48             .kind => "kind",
 49             .none => "none",
 50         };
 51     }
 52 };
 53 
 54 pub const Sort = enum {
 55     time,
 56     count,
 57     last,
 58     thread,
 59     text,
 60     kind,
 61 
 62     pub fn fromName(text: []const u8) ?Sort {
 63         if (std.mem.eql(u8, text, "time")) return .time;
 64         if (std.mem.eql(u8, text, "count")) return .count;
 65         if (std.mem.eql(u8, text, "last")) return .last;
 66         if (std.mem.eql(u8, text, "thread")) return .thread;
 67         if (std.mem.eql(u8, text, "text")) return .text;
 68         if (std.mem.eql(u8, text, "kind")) return .kind;
 69         return null;
 70     }
 71 
 72     fn tag(self: Sort) []const u8 {
 73         return switch (self) {
 74             .time => "time",
 75             .count => "count",
 76             .last => "last",
 77             .thread => "thread",
 78             .text => "text",
 79             .kind => "kind",
 80         };
 81     }
 82 };
 83 
 84 pub const Options = struct {
 85     top: usize = 20,
 86     occurrences: usize = 100,
 87     kind: Kind = .all,
 88     group: Group = .thread,
 89     sort: Sort = .time,
 90     thread: ?u64 = null,
 91     since_ns: ?u64 = null,
 92     until_ns: ?u64 = null,
 93     match: ?[]const u8 = null,
 94     ignore_case: bool = false,
 95 };
 96 
 97 pub const Counters = struct {
 98     events: u64 = 0,
 99     messages: u64 = 0,
100     app_infos: u64 = 0,
101     thread_names: u64 = 0,
102     filtered: u64 = 0,
103     groups: u64 = 0,
104     duration_ns: u64 = 0,
105 };
106 
107 const ThreadName = struct {
108     name: []u8,
109 
110     fn deinit(self: *ThreadName, allocator: std.mem.Allocator) void {
111         allocator.free(self.name);
112         self.* = undefined;
113     }
114 };
115 
116 const Entry = struct {
117     kind: Kind,
118     seq: u64 = 0,
119     time_ns: u64 = 0,
120     thread: u64 = 0,
121     thread_name: ?[]u8 = null,
122     text: []u8,
123     color: ?u32 = null,
124 
125     fn deinit(self: *Entry, allocator: std.mem.Allocator) void {
126         if (self.thread_name) |thread_name| allocator.free(thread_name);
127         allocator.free(self.text);
128         self.* = undefined;
129     }
130 };
131 
132 const GroupRow = struct {
133     group: Group,
134     label: []u8,
135     kind: ?Kind = null,
136     thread: ?u64 = null,
137     thread_name: ?[]u8 = null,
138     text: ?[]u8 = null,
139     count: u64 = 0,
140     first_ns: u64 = 0,
141     last_ns: u64 = 0,
142 
143     fn deinit(self: *GroupRow, allocator: std.mem.Allocator) void {
144         allocator.free(self.label);
145         if (self.thread_name) |thread_name| allocator.free(thread_name);
146         if (self.text) |text| allocator.free(text);
147         self.* = undefined;
148     }
149 };
150 
151 const GroupView = struct {
152     group: Group,
153     label: []const u8,
154     kind: ?Kind,
155     thread: ?u64,
156     thread_name: ?[]const u8,
157     text: ?[]const u8,
158     count: u64,
159     first_ns: u64,
160     last_ns: u64,
161 };
162 
163 pub const Analyzer = struct {
164     allocator: std.mem.Allocator,
165     groups: std.StringHashMapUnmanaged(GroupRow) = .{},
166     entries: std.ArrayListUnmanaged(Entry) = .empty,
167     threads: std.AutoHashMapUnmanaged(u64, ThreadName) = .{},
168     counters: Counters = .{},
169     start_ns: ?u64 = null,
170     end_ns: ?u64 = null,
171 
172     pub fn init(allocator: std.mem.Allocator) Analyzer {
173         return .{ .allocator = allocator };
174     }
175 
176     pub fn deinit(self: *Analyzer) void {
177         var group_iter = self.groups.iterator();
178         while (group_iter.next()) |entry| {
179             self.allocator.free(entry.key_ptr.*);
180             entry.value_ptr.deinit(self.allocator);
181         }
182         self.groups.deinit(self.allocator);
183         for (self.entries.items) |*entry| entry.deinit(self.allocator);
184         self.entries.deinit(self.allocator);
185         var thread_iter = self.threads.valueIterator();
186         while (thread_iter.next()) |thread| thread.deinit(self.allocator);
187         self.threads.deinit(self.allocator);
188         self.* = undefined;
189     }
190 
191     pub fn ingestJsonlBytes(self: *Analyzer, bytes: []const u8, options: Options) !void {
192         var lines = std.mem.splitScalar(u8, bytes, '\n');
193         while (lines.next()) |line| try self.ingestJsonLine(line, options);
194         self.finish(options);
195     }
196 
197     pub fn ingestJsonLine(self: *Analyzer, line: []const u8, options: Options) !void {
198         const text = std.mem.trim(u8, line, " \t\r\n");
199         if (text.len == 0) return;
200         var parsed = (try record_mod.parseEventLine(self.allocator, text)) orelse return;
201         defer parsed.deinit();
202         try self.ingest(parsed, options);
203     }
204 
205     pub fn ingest(self: *Analyzer, parsed: event.Parsed, options: Options) !void {
206         self.counters.events += 1;
207         if (self.start_ns == null and parsed.time_ns != 0) self.start_ns = parsed.time_ns;
208         if (parsed.time_ns != 0) self.end_ns = parsed.time_ns;
209         switch (parsed.kind) {
210             .thread_name => try self.recordThreadName(parsed),
211             .message => {
212                 self.counters.messages += 1;
213                 try self.recordEntry(.message, parsed, options);
214             },
215             .app_info => {
216                 self.counters.app_infos += 1;
217                 try self.recordEntry(.app_info, parsed, options);
218             },
219             else => {},
220         }
221     }
222 
223     pub fn finish(self: *Analyzer, options: Options) void {
224         self.counters.groups = @intCast(self.groups.count());
225         self.counters.duration_ns = self.durationNs();
226         sortEntries(self.entries.items, options.sort);
227     }
228 
229     fn recordThreadName(self: *Analyzer, parsed: event.Parsed) !void {
230         const name = parsed.name orelse return;
231         const entry = try self.threads.getOrPut(self.allocator, parsed.thread);
232         if (entry.found_existing) entry.value_ptr.deinit(self.allocator);
233         entry.value_ptr.* = .{ .name = try self.allocator.dupe(u8, name) };
234         self.counters.thread_names += 1;
235     }
236 
237     fn recordEntry(self: *Analyzer, kind: Kind, parsed: event.Parsed, options: Options) !void {
238         const text = parsed.text orelse "";
239         if (!entryMatches(kind, parsed, text, options)) {
240             self.counters.filtered += 1;
241             return;
242         }
243         const thread_name = self.threadName(parsed.thread);
244         var entry = Entry{
245             .kind = kind,
246             .seq = parsed.seq,
247             .time_ns = parsed.time_ns,
248             .thread = parsed.thread,
249             .thread_name = try dupeOptional(self.allocator, thread_name),
250             .text = try self.allocator.dupe(u8, text),
251             .color = parsed.color,
252         };
253         var owns_entry = true;
254         errdefer if (owns_entry) entry.deinit(self.allocator);
255         try self.recordGroup(entry, options);
256         try self.entries.append(self.allocator, entry);
257         owns_entry = false;
258     }
259 
260     fn recordGroup(self: *Analyzer, entry: Entry, options: Options) !void {
261         var key_writer = std.Io.Writer.Allocating.init(self.allocator);
262         defer key_writer.deinit();
263         try writeGroupKey(&key_writer.writer, entry, options.group);
264         const key_text = key_writer.written();
265         const group_entry = try self.groups.getOrPut(self.allocator, key_text);
266         if (!group_entry.found_existing) {
267             const owned_key = try self.allocator.dupe(u8, key_text);
268             group_entry.key_ptr.* = owned_key;
269             group_entry.value_ptr.* = try self.newGroup(entry, options.group);
270         }
271         const row = group_entry.value_ptr;
272         row.count += 1;
273         if (row.count == 1 or entry.time_ns < row.first_ns) row.first_ns = entry.time_ns;
274         if (entry.time_ns > row.last_ns) row.last_ns = entry.time_ns;
275     }
276 
277     fn newGroup(self: *Analyzer, entry: Entry, group: Group) !GroupRow {
278         const label = try groupLabel(self.allocator, entry, group);
279         errdefer self.allocator.free(label);
280         const thread_name = if (group == .thread) try dupeOptional(self.allocator, entry.thread_name) else null;
281         errdefer if (thread_name) |actual| self.allocator.free(actual);
282         const text = if (group == .text) try self.allocator.dupe(u8, entry.text) else null;
283         errdefer if (text) |actual| self.allocator.free(actual);
284         return .{
285             .group = group,
286             .label = label,
287             .kind = if (group == .kind) entry.kind else null,
288             .thread = if (group == .thread) entry.thread else null,
289             .thread_name = thread_name,
290             .text = text,
291         };
292     }
293 
294     fn collectGroups(self: *Analyzer, allocator: std.mem.Allocator, options: Options) !std.ArrayListUnmanaged(GroupView) {
295         var views: std.ArrayListUnmanaged(GroupView) = .empty;
296         var iter = self.groups.valueIterator();
297         while (iter.next()) |row| {
298             try views.append(allocator, .{
299                 .group = row.group,
300                 .label = row.label,
301                 .kind = row.kind,
302                 .thread = row.thread,
303                 .thread_name = row.thread_name,
304                 .text = row.text,
305                 .count = row.count,
306                 .first_ns = row.first_ns,
307                 .last_ns = row.last_ns,
308             });
309         }
310         sortGroups(views.items, options.sort);
311         return views;
312     }
313 
314     fn threadName(self: Analyzer, thread: u64) ?[]const u8 {
315         if (self.threads.get(thread)) |name| return name.name;
316         return null;
317     }
318 
319     fn durationNs(self: Analyzer) u64 {
320         const start_ns = self.start_ns orelse return 0;
321         const end_ns = self.end_ns orelse return 0;
322         if (end_ns <= start_ns) return 0;
323         return end_ns - start_ns;
324     }
325 };
326 
327 pub fn writeTextFromJsonlPath(
328     allocator: std.mem.Allocator,
329     path: []const u8,
330     writer: *std.Io.Writer,
331     options: Options,
332 ) !void {
333     var analyzer = Analyzer.init(allocator);
334     defer analyzer.deinit();
335     try ingestPath(&analyzer, path, options);
336     try writeText(allocator, &analyzer, writer, options);
337 }
338 
339 pub fn writeJsonlFromJsonlPath(
340     allocator: std.mem.Allocator,
341     path: []const u8,
342     writer: *std.Io.Writer,
343     options: Options,
344 ) !void {
345     var analyzer = Analyzer.init(allocator);
346     defer analyzer.deinit();
347     try ingestPath(&analyzer, path, options);
348     try writeJsonl(allocator, &analyzer, writer, options);
349 }
350 
351 fn ingestPath(analyzer: *Analyzer, path: []const u8, options: Options) !void {
352     var file = try sys.fs.cwd().openFile(sys.fs.debugIo(), path, .{});
353     defer file.close(sys.fs.debugIo());
354     var buffer: [64 * 1024]u8 = undefined;
355     var reader = file.reader(sys.fs.debugIo(), &buffer);
356     while (true) {
357         const line = reader.interface.takeDelimiter('\n') catch |err| switch (err) {
358             error.ReadFailed => return reader.err.?,
359             else => return err,
360         };
361         const actual = line orelse break;
362         try analyzer.ingestJsonLine(actual, options);
363     }
364     analyzer.finish(options);
365 }
366 
367 fn writeText(allocator: std.mem.Allocator, analyzer: *Analyzer, writer: *std.Io.Writer, options: Options) !void {
368     var groups = try analyzer.collectGroups(allocator, options);
369     defer groups.deinit(allocator);
370     try writer.print(
371         "tracy messages groups={d} entries={d} events={d} messages={d} app_infos={d} thread_names={d} filtered={d} duration_ns={d} kind={s} group={s} sort={s}",
372         .{
373             groups.items.len,
374             analyzer.entries.items.len,
375             analyzer.counters.events,
376             analyzer.counters.messages,
377             analyzer.counters.app_infos,
378             analyzer.counters.thread_names,
379             analyzer.counters.filtered,
380             analyzer.counters.duration_ns,
381             options.kind.tag(),
382             options.group.tag(),
383             options.sort.tag(),
384         },
385     );
386     if (options.thread) |thread| try writer.print(" thread={d}", .{thread});
387     if (options.since_ns) |since_ns| try writer.print(" since_ns={d}", .{since_ns});
388     if (options.until_ns) |until_ns| try writer.print(" until_ns={d}", .{until_ns});
389     if (options.match) |pattern| {
390         try writer.writeAll(" match=");
391         try pretty_json.writeString(writer, pattern);
392     }
393     try writer.writeByte('\n');
394     const group_limit = @min(options.top, groups.items.len);
395     for (groups.items[0..group_limit]) |group| {
396         try writer.print("message-group group={s} label=", .{group.group.tag()});
397         try pretty_json.writeString(writer, group.label);
398         try writer.print(" count={d} first_ns={d} last_ns={d}", .{ group.count, group.first_ns, group.last_ns });
399         if (group.kind) |kind| try writer.print(" kind={s}", .{kind.tag()});
400         if (group.thread) |thread| try writer.print(" thread={d}", .{thread});
401         if (group.thread_name) |thread_name| {
402             try writer.writeAll(" thread_name=");
403             try pretty_json.writeString(writer, thread_name);
404         }
405         if (group.text) |text| {
406             try writer.writeAll(" text=");
407             try pretty_json.writeString(writer, text);
408         }
409         try writer.writeByte('\n');
410     }
411     const occurrence_limit = @min(options.occurrences, analyzer.entries.items.len);
412     for (analyzer.entries.items[0..occurrence_limit]) |entry| {
413         try writer.print("message kind={s} seq={d} time_ns={d} thread={d}", .{ entry.kind.tag(), entry.seq, entry.time_ns, entry.thread });
414         if (entry.thread_name) |thread_name| {
415             try writer.writeAll(" thread_name=");
416             try pretty_json.writeString(writer, thread_name);
417         }
418         try writer.writeAll(" text=");
419         try pretty_json.writeString(writer, entry.text);
420         if (entry.color) |color| try writer.print(" color={d}", .{color});
421         try writer.writeByte('\n');
422     }
423 }
424 
425 fn writeJsonl(allocator: std.mem.Allocator, analyzer: *Analyzer, writer: *std.Io.Writer, options: Options) !void {
426     var groups = try analyzer.collectGroups(allocator, options);
427     defer groups.deinit(allocator);
428     var summary_stream = pretty_json.Writer.init(writer, .minified);
429     const summary = try summary_stream.object();
430     try summary.field("schema", schema);
431     try summary.field("kind", "summary");
432     try summary.field("groups", groups.items.len);
433     try summary.field("entries", analyzer.entries.items.len);
434     try summary.field("events", analyzer.counters.events);
435     try summary.field("messages", analyzer.counters.messages);
436     try summary.field("app_infos", analyzer.counters.app_infos);
437     try summary.field("thread_names", analyzer.counters.thread_names);
438     try summary.field("filtered", analyzer.counters.filtered);
439     try summary.field("duration_ns", analyzer.counters.duration_ns);
440     try summary.field("filter_kind", options.kind.tag());
441     try summary.field("group", options.group.tag());
442     try summary.field("sort", options.sort.tag());
443     if (options.thread) |thread| try summary.field("thread", thread);
444     if (options.since_ns) |since_ns| try summary.field("since_ns", since_ns);
445     if (options.until_ns) |until_ns| try summary.field("until_ns", until_ns);
446     if (options.match) |pattern| try summary.field("match", pattern);
447     try summary.endLine();
448     const group_limit = @min(options.top, groups.items.len);
449     for (groups.items[0..group_limit]) |group| {
450         var stream = pretty_json.Writer.init(writer, .minified);
451         const object = try stream.object();
452         try object.field("schema", schema);
453         try object.field("kind", "group");
454         try object.field("group", group.group.tag());
455         try object.field("label", group.label);
456         try object.field("count", group.count);
457         try object.field("first_ns", group.first_ns);
458         try object.field("last_ns", group.last_ns);
459         if (group.kind) |kind| try object.field("message_kind", kind.tag());
460         if (group.thread) |thread| try object.field("thread", thread);
461         if (group.thread_name) |name| try object.field("thread_name", name);
462         if (group.text) |text| try object.field("text", text);
463         try object.endLine();
464     }
465     const occurrence_limit = @min(options.occurrences, analyzer.entries.items.len);
466     for (analyzer.entries.items[0..occurrence_limit]) |entry| {
467         var stream = pretty_json.Writer.init(writer, .minified);
468         const object = try stream.object();
469         try object.field("schema", schema);
470         try object.field("kind", "message");
471         try object.field("message_kind", entry.kind.tag());
472         try object.field("seq", entry.seq);
473         try object.field("time_ns", entry.time_ns);
474         try object.field("thread", entry.thread);
475         if (entry.thread_name) |name| try object.field("thread_name", name);
476         try object.field("text", entry.text);
477         if (entry.color) |color| try object.field("color", color);
478         try object.endLine();
479     }
480 }
481 
482 fn entryMatches(kind: Kind, parsed: event.Parsed, text: []const u8, options: Options) bool {
483     switch (options.kind) {
484         .all => {},
485         .message => if (kind != .message) return false,
486         .app_info => if (kind != .app_info) return false,
487     }
488     if (options.thread) |thread| {
489         if (parsed.thread != thread) return false;
490     }
491     if (options.since_ns) |since_ns| {
492         if (parsed.time_ns < since_ns) return false;
493     }
494     if (options.until_ns) |until_ns| {
495         if (parsed.time_ns > until_ns) return false;
496     }
497     if (options.match) |pattern| {
498         if (!contains(text, pattern, options.ignore_case)) return false;
499     }
500     return true;
501 }
502 
503 fn writeGroupKey(writer: *std.Io.Writer, entry: Entry, group: Group) !void {
504     switch (group) {
505         .thread => try writer.print("thread:{d}", .{entry.thread}),
506         .text => try writer.writeAll(entry.text),
507         .kind => try writer.writeAll(entry.kind.tag()),
508         .none => try writer.print("message:{d}:{d}:{d}", .{ entry.time_ns, entry.seq, entry.thread }),
509     }
510 }
511 
512 fn groupLabel(allocator: std.mem.Allocator, entry: Entry, group: Group) ![]u8 {
513     var writer = std.Io.Writer.Allocating.init(allocator);
514     defer writer.deinit();
515     switch (group) {
516         .thread => {
517             if (entry.thread_name) |thread_name| {
518                 try writer.writer.print("{s} ({d})", .{ thread_name, entry.thread });
519             } else {
520                 try writer.writer.print("thread {d}", .{entry.thread});
521             }
522         },
523         .text => try writer.writer.writeAll(entry.text),
524         .kind => try writer.writer.writeAll(entry.kind.tag()),
525         .none => try writer.writer.print("{s}#{d}", .{ entry.kind.tag(), entry.seq }),
526     }
527     return try allocator.dupe(u8, writer.written());
528 }
529 
530 fn contains(haystack: []const u8, needle: []const u8, ignore_case: bool) bool {
531     if (!ignore_case) return std.mem.indexOf(u8, haystack, needle) != null;
532     if (needle.len == 0) return true;
533     if (needle.len > haystack.len) return false;
534     var index: usize = 0;
535     while (index + needle.len <= haystack.len) : (index += 1) {
536         if (asciiEqlIgnoreCase(haystack[index .. index + needle.len], needle)) return true;
537     }
538     return false;
539 }
540 
541 fn asciiEqlIgnoreCase(left: []const u8, right: []const u8) bool {
542     if (left.len != right.len) return false;
543     for (left, right) |a, b| {
544         if (std.ascii.toLower(a) != std.ascii.toLower(b)) return false;
545     }
546     return true;
547 }
548 
549 fn dupeOptional(allocator: std.mem.Allocator, text: ?[]const u8) !?[]u8 {
550     const actual = text orelse return null;
551     return try allocator.dupe(u8, actual);
552 }
553 
554 fn sortGroups(items: []GroupView, sort: Sort) void {
555     switch (sort) {
556         .time => std.mem.sort(GroupView, items, {}, groupTimeLessThan),
557         .count => std.mem.sort(GroupView, items, {}, groupCountGreaterThan),
558         .last => std.mem.sort(GroupView, items, {}, groupLastGreaterThan),
559         .thread => std.mem.sort(GroupView, items, {}, groupThreadLessThan),
560         .text => std.mem.sort(GroupView, items, {}, groupTextLessThan),
561         .kind => std.mem.sort(GroupView, items, {}, groupKindLessThan),
562     }
563 }
564 
565 fn sortEntries(items: []Entry, sort: Sort) void {
566     switch (sort) {
567         .time, .count, .last => std.mem.sort(Entry, items, {}, entryTimeLessThan),
568         .thread => std.mem.sort(Entry, items, {}, entryThreadLessThan),
569         .text => std.mem.sort(Entry, items, {}, entryTextLessThan),
570         .kind => std.mem.sort(Entry, items, {}, entryKindLessThan),
571     }
572 }
573 
574 fn groupTimeLessThan(_: void, left: GroupView, right: GroupView) bool {
575     if (left.first_ns != right.first_ns) return left.first_ns < right.first_ns;
576     return groupTextLessThan({}, left, right);
577 }
578 
579 fn groupCountGreaterThan(_: void, left: GroupView, right: GroupView) bool {
580     if (left.count != right.count) return left.count > right.count;
581     return groupTimeLessThan({}, left, right);
582 }
583 
584 fn groupLastGreaterThan(_: void, left: GroupView, right: GroupView) bool {
585     if (left.last_ns != right.last_ns) return left.last_ns > right.last_ns;
586     return groupTimeLessThan({}, left, right);
587 }
588 
589 fn groupThreadLessThan(_: void, left: GroupView, right: GroupView) bool {
590     const left_thread = left.thread orelse 0;
591     const right_thread = right.thread orelse 0;
592     if (left_thread != right_thread) return left_thread < right_thread;
593     return groupTextLessThan({}, left, right);
594 }
595 
596 fn groupTextLessThan(_: void, left: GroupView, right: GroupView) bool {
597     const cmp = std.mem.order(u8, left.label, right.label);
598     if (cmp != .eq) return cmp == .lt;
599     return left.first_ns < right.first_ns;
600 }
601 
602 fn groupKindLessThan(_: void, left: GroupView, right: GroupView) bool {
603     const left_kind = if (left.kind) |kind| kind.tag() else "";
604     const right_kind = if (right.kind) |kind| kind.tag() else "";
605     const cmp = std.mem.order(u8, left_kind, right_kind);
606     if (cmp != .eq) return cmp == .lt;
607     return groupTextLessThan({}, left, right);
608 }
609 
610 fn entryTimeLessThan(_: void, left: Entry, right: Entry) bool {
611     if (left.time_ns != right.time_ns) return left.time_ns < right.time_ns;
612     return left.seq < right.seq;
613 }
614 
615 fn entryThreadLessThan(_: void, left: Entry, right: Entry) bool {
616     if (left.thread != right.thread) return left.thread < right.thread;
617     return entryTimeLessThan({}, left, right);
618 }
619 
620 fn entryTextLessThan(_: void, left: Entry, right: Entry) bool {
621     const cmp = std.mem.order(u8, left.text, right.text);
622     if (cmp != .eq) return cmp == .lt;
623     return entryTimeLessThan({}, left, right);
624 }
625 
626 fn entryKindLessThan(_: void, left: Entry, right: Entry) bool {
627     const cmp = std.mem.order(u8, left.kind.tag(), right.kind.tag());
628     if (cmp != .eq) return cmp == .lt;
629     return entryTimeLessThan({}, left, right);
630 }
631 
632 test "messages groups text output by thread" {
633     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
634     defer trace.deinit();
635     try (event.TraceEvent{ .seq = 1, .kind = .start, .time_ns = 90, .thread = 1, .name = "test" }).writeJsonLine(&trace.writer);
636     try (event.TraceEvent{ .seq = 2, .kind = .thread_name, .time_ns = 95, .thread = 1, .name = "main" }).writeJsonLine(&trace.writer);
637     try (event.TraceEvent{ .seq = 3, .kind = .message, .time_ns = 100, .thread = 1, .text = "queue ready" }).writeJsonLine(&trace.writer);
638     try (event.TraceEvent{ .seq = 4, .kind = .message, .time_ns = 130, .thread = 1, .text = "queue drained" }).writeJsonLine(&trace.writer);
639     try (event.TraceEvent{ .seq = 5, .kind = .message, .time_ns = 140, .thread = 2, .text = "other" }).writeJsonLine(&trace.writer);
640     try (event.TraceEvent{ .seq = 6, .kind = .stop, .time_ns = 180, .thread = 1 }).writeJsonLine(&trace.writer);
641 
642     var analyzer = Analyzer.init(std.testing.allocator);
643     defer analyzer.deinit();
644     const options = Options{ .match = "queue", .group = .thread, .sort = .count, .ignore_case = true };
645     try analyzer.ingestJsonlBytes(trace.written(), options);
646 
647     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
648     defer out.deinit();
649     try writeText(std.testing.allocator, &analyzer, &out.writer, options);
650     const text = out.written();
651     try std.testing.expect(std.mem.indexOf(u8, text, "tracy messages groups=1 entries=2 events=6 messages=3 app_infos=0 thread_names=1 filtered=1") != null);
652     try std.testing.expect(std.mem.indexOf(u8, text, "message-group group=thread label=\"main (1)\" count=2 first_ns=100 last_ns=130 thread=1 thread_name=\"main\"") != null);
653     try std.testing.expect(std.mem.indexOf(u8, text, "message kind=message seq=3 time_ns=100 thread=1 thread_name=\"main\" text=\"queue ready\"") != null);
654 }
655 
656 test "messages jsonl groups app info by text in time window" {
657     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
658     defer trace.deinit();
659     try (event.TraceEvent{ .seq = 1, .kind = .app_info, .time_ns = 100, .thread = 1, .text = "build debug" }).writeJsonLine(&trace.writer);
660     try (event.TraceEvent{ .seq = 2, .kind = .app_info, .time_ns = 120, .thread = 1, .text = "build debug" }).writeJsonLine(&trace.writer);
661     try (event.TraceEvent{ .seq = 3, .kind = .message, .time_ns = 130, .thread = 1, .text = "skip" }).writeJsonLine(&trace.writer);
662     try (event.TraceEvent{ .seq = 4, .kind = .app_info, .time_ns = 200, .thread = 1, .text = "outside" }).writeJsonLine(&trace.writer);
663 
664     var analyzer = Analyzer.init(std.testing.allocator);
665     defer analyzer.deinit();
666     const options = Options{ .kind = .app_info, .group = .text, .sort = .count, .since_ns = 90, .until_ns = 150, .occurrences = 4 };
667     try analyzer.ingestJsonlBytes(trace.written(), options);
668 
669     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
670     defer out.deinit();
671     try writeJsonl(std.testing.allocator, &analyzer, &out.writer, options);
672     const text = out.written();
673     try std.testing.expect(std.mem.indexOf(u8, text, "\"schema\":\"tracy.messages/v0\"") != null);
674     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"summary\",\"groups\":1,\"entries\":2,\"events\":4,\"messages\":1,\"app_infos\":3") != null);
675     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"group\",\"group\":\"text\",\"label\":\"build debug\",\"count\":2") != null);
676     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"message\",\"message_kind\":\"app-info\",\"seq\":2") != null);
677 }