lib/tracy/src/fiber.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.fibers/v0";
   8 
   9 pub const Group = enum {
  10     fiber,
  11     thread,
  12     transition,
  13     none,
  14 
  15     pub fn fromName(text: []const u8) ?Group {
  16         if (std.mem.eql(u8, text, "fiber")) return .fiber;
  17         if (std.mem.eql(u8, text, "thread")) return .thread;
  18         if (std.mem.eql(u8, text, "transition")) return .transition;
  19         if (std.mem.eql(u8, text, "none")) return .none;
  20         return null;
  21     }
  22 
  23     fn tag(self: Group) []const u8 {
  24         return switch (self) {
  25             .fiber => "fiber",
  26             .thread => "thread",
  27             .transition => "transition",
  28             .none => "none",
  29         };
  30     }
  31 };
  32 
  33 pub const Sort = enum {
  34     running,
  35     enters,
  36     leaves,
  37     migrations,
  38     unmatched,
  39     last,
  40     fiber,
  41     thread,
  42     label,
  43 
  44     pub fn fromName(text: []const u8) ?Sort {
  45         if (std.mem.eql(u8, text, "running")) return .running;
  46         if (std.mem.eql(u8, text, "enters")) return .enters;
  47         if (std.mem.eql(u8, text, "leaves")) return .leaves;
  48         if (std.mem.eql(u8, text, "migrations")) return .migrations;
  49         if (std.mem.eql(u8, text, "unmatched")) return .unmatched;
  50         if (std.mem.eql(u8, text, "last")) return .last;
  51         if (std.mem.eql(u8, text, "fiber")) return .fiber;
  52         if (std.mem.eql(u8, text, "thread")) return .thread;
  53         if (std.mem.eql(u8, text, "label")) return .label;
  54         return null;
  55     }
  56 
  57     fn tag(self: Sort) []const u8 {
  58         return switch (self) {
  59             .running => "running",
  60             .enters => "enters",
  61             .leaves => "leaves",
  62             .migrations => "migrations",
  63             .unmatched => "unmatched",
  64             .last => "last",
  65             .fiber => "fiber",
  66             .thread => "thread",
  67             .label => "label",
  68         };
  69     }
  70 };
  71 
  72 pub const Options = struct {
  73     top: usize = 20,
  74     occurrences: usize = 80,
  75     group: Group = .fiber,
  76     sort: Sort = .running,
  77     fiber: ?u64 = null,
  78     thread: ?u64 = null,
  79     since_ns: ?u64 = null,
  80     until_ns: ?u64 = null,
  81     match: ?[]const u8 = null,
  82     ignore_case: bool = false,
  83 };
  84 
  85 pub const Counters = struct {
  86     events: u64 = 0,
  87     enters: u64 = 0,
  88     leaves: u64 = 0,
  89     names: u64 = 0,
  90     thread_names: u64 = 0,
  91     unmatched_leaves: u64 = 0,
  92     filtered: u64 = 0,
  93     groups: u64 = 0,
  94     active_fibers: u64 = 0,
  95     duration_ns: u64 = 0,
  96 };
  97 
  98 const FiberState = struct {
  99     id: u64,
 100     name: ?[]u8 = null,
 101     group_hint: ?i64 = null,
 102     enters: u64 = 0,
 103     leaves: u64 = 0,
 104     running_ns: u64 = 0,
 105     migrations: u64 = 0,
 106     unmatched_leaves: u64 = 0,
 107     active_since_ns: ?u64 = null,
 108     active_thread: ?u64 = null,
 109     last_thread: ?u64 = null,
 110     threads: std.AutoHashMapUnmanaged(u64, void) = .{},
 111     first_ns: u64 = 0,
 112     last_ns: u64 = 0,
 113 
 114     fn deinit(self: *FiberState, allocator: std.mem.Allocator) void {
 115         if (self.name) |name| allocator.free(name);
 116         self.threads.deinit(allocator);
 117         self.* = undefined;
 118     }
 119 
 120     fn displayName(self: FiberState) []const u8 {
 121         return self.name orelse "";
 122     }
 123 };
 124 
 125 const ThreadState = struct {
 126     id: u64,
 127     name: ?[]u8 = null,
 128     enters: u64 = 0,
 129     leaves: u64 = 0,
 130     running_ns: u64 = 0,
 131     unmatched_leaves: u64 = 0,
 132     current_fiber: u64 = 0,
 133     current_since_ns: ?u64 = null,
 134     first_ns: u64 = 0,
 135     last_ns: u64 = 0,
 136 
 137     fn deinit(self: *ThreadState, allocator: std.mem.Allocator) void {
 138         if (self.name) |name| allocator.free(name);
 139         self.* = undefined;
 140     }
 141 
 142     fn displayName(self: ThreadState) []const u8 {
 143         return self.name orelse "";
 144     }
 145 };
 146 
 147 const TransitionState = struct {
 148     label: []u8,
 149     from_thread: u64 = 0,
 150     to_thread: u64 = 0,
 151     fibers: std.AutoHashMapUnmanaged(u64, void) = .{},
 152     count: u64 = 0,
 153     first_ns: u64 = 0,
 154     last_ns: u64 = 0,
 155 
 156     fn deinit(self: *TransitionState, allocator: std.mem.Allocator) void {
 157         allocator.free(self.label);
 158         self.fibers.deinit(allocator);
 159         self.* = undefined;
 160     }
 161 };
 162 
 163 const OccurrenceKind = enum {
 164     name,
 165     enter,
 166     leave,
 167 
 168     fn tag(self: OccurrenceKind) []const u8 {
 169         return switch (self) {
 170             .name => "name",
 171             .enter => "enter",
 172             .leave => "leave",
 173         };
 174     }
 175 };
 176 
 177 const Occurrence = struct {
 178     kind: OccurrenceKind,
 179     seq: u64 = 0,
 180     time_ns: u64 = 0,
 181     fiber: u64 = 0,
 182     thread: u64 = 0,
 183     name: ?[]u8 = null,
 184     group_hint: ?i64 = null,
 185     duration_ns: u64 = 0,
 186     unmatched: bool = false,
 187 
 188     fn deinit(self: *Occurrence, allocator: std.mem.Allocator) void {
 189         if (self.name) |name| allocator.free(name);
 190         self.* = undefined;
 191     }
 192 };
 193 
 194 pub const Summary = struct {
 195     group: Group,
 196     label: []u8,
 197     count: u64 = 0,
 198     fiber: ?u64 = null,
 199     thread: ?u64 = null,
 200     from_thread: u64 = 0,
 201     to_thread: u64 = 0,
 202     group_hint: ?i64 = null,
 203     enters: u64 = 0,
 204     leaves: u64 = 0,
 205     running_ns: u64 = 0,
 206     migrations: u64 = 0,
 207     unmatched_leaves: u64 = 0,
 208     active: bool = false,
 209     first_ns: u64 = 0,
 210     last_ns: u64 = 0,
 211 
 212     pub fn deinit(self: *Summary, allocator: std.mem.Allocator) void {
 213         allocator.free(self.label);
 214         self.* = undefined;
 215     }
 216 };
 217 
 218 pub const Analyzer = struct {
 219     allocator: std.mem.Allocator,
 220     fibers: std.AutoHashMapUnmanaged(u64, FiberState) = .{},
 221     threads: std.AutoHashMapUnmanaged(u64, ThreadState) = .{},
 222     transitions: std.StringHashMapUnmanaged(TransitionState) = .{},
 223     occurrences: std.ArrayListUnmanaged(Occurrence) = .empty,
 224     counters: Counters = .{},
 225     start_ns: ?u64 = null,
 226     end_ns: ?u64 = null,
 227 
 228     pub fn init(allocator: std.mem.Allocator) Analyzer {
 229         return .{ .allocator = allocator };
 230     }
 231 
 232     pub fn deinit(self: *Analyzer) void {
 233         var fiber_iter = self.fibers.valueIterator();
 234         while (fiber_iter.next()) |fiber_state| fiber_state.deinit(self.allocator);
 235         self.fibers.deinit(self.allocator);
 236         var thread_iter = self.threads.valueIterator();
 237         while (thread_iter.next()) |thread_state| thread_state.deinit(self.allocator);
 238         self.threads.deinit(self.allocator);
 239         var transition_iter = self.transitions.iterator();
 240         while (transition_iter.next()) |entry| {
 241             self.allocator.free(entry.key_ptr.*);
 242             entry.value_ptr.deinit(self.allocator);
 243         }
 244         self.transitions.deinit(self.allocator);
 245         for (self.occurrences.items) |*occurrence| occurrence.deinit(self.allocator);
 246         self.occurrences.deinit(self.allocator);
 247         self.* = undefined;
 248     }
 249 
 250     pub fn ingestJsonlBytes(self: *Analyzer, bytes: []const u8) !void {
 251         var lines = std.mem.splitScalar(u8, bytes, '\n');
 252         while (lines.next()) |line| try self.ingestJsonLine(line);
 253     }
 254 
 255     pub fn ingestJsonLine(self: *Analyzer, line: []const u8) !void {
 256         const text = std.mem.trim(u8, line, " \t\r\n");
 257         if (text.len == 0) return;
 258         var parsed = (try record_mod.parseEventLine(self.allocator, text)) orelse return;
 259         defer parsed.deinit();
 260         try self.ingest(parsed);
 261     }
 262 
 263     pub fn ingest(self: *Analyzer, parsed: event.Parsed) !void {
 264         self.counters.events += 1;
 265         if (self.start_ns == null and parsed.time_ns != 0) self.start_ns = parsed.time_ns;
 266         if (parsed.time_ns != 0) self.end_ns = parsed.time_ns;
 267         switch (parsed.kind) {
 268             .start => {
 269                 if (parsed.time_ns != 0) self.start_ns = parsed.time_ns;
 270             },
 271             .stop => {
 272                 if (parsed.time_ns != 0) self.end_ns = parsed.time_ns;
 273             },
 274             .thread_name => try self.recordThreadName(parsed),
 275             .fiber_name => try self.recordFiberName(parsed),
 276             .fiber_enter => try self.recordEnter(parsed),
 277             .fiber_leave => try self.recordLeave(parsed),
 278             else => {},
 279         }
 280     }
 281 
 282     pub fn collectSummaries(self: *Analyzer, allocator: std.mem.Allocator, options: Options) !std.ArrayListUnmanaged(Summary) {
 283         var summaries: std.ArrayListUnmanaged(Summary) = .empty;
 284         errdefer deinitSummaries(allocator, &summaries);
 285         self.counters.filtered = 0;
 286         self.counters.active_fibers = self.activeCount();
 287         switch (options.group) {
 288             .fiber => {
 289                 var iter = self.fibers.valueIterator();
 290                 while (iter.next()) |fiber_state| {
 291                     if (!self.fiberMatches(fiber_state.*, options)) {
 292                         self.counters.filtered += 1;
 293                         continue;
 294                     }
 295                     try summaries.append(allocator, try fiberSummary(allocator, fiber_state.*, self.end_ns));
 296                 }
 297             },
 298             .thread => {
 299                 var iter = self.threads.valueIterator();
 300                 while (iter.next()) |thread_state| {
 301                     if (!threadMatches(thread_state.*, options)) {
 302                         self.counters.filtered += 1;
 303                         continue;
 304                     }
 305                     try summaries.append(allocator, try threadSummary(allocator, thread_state.*, self.end_ns));
 306                 }
 307             },
 308             .transition => {
 309                 var iter = self.transitions.valueIterator();
 310                 while (iter.next()) |transition| {
 311                     if (!transitionMatches(transition.*, options)) {
 312                         self.counters.filtered += 1;
 313                         continue;
 314                     }
 315                     try summaries.append(allocator, try transitionSummary(allocator, transition.*));
 316                 }
 317             },
 318             .none => {
 319                 for (self.occurrences.items) |occurrence| {
 320                     if (!occurrenceMatches(occurrence, options)) {
 321                         self.counters.filtered += 1;
 322                         continue;
 323                     }
 324                     try summaries.append(allocator, try occurrenceSummary(allocator, occurrence));
 325                 }
 326             },
 327         }
 328         self.counters.groups = @intCast(summaries.items.len);
 329         self.counters.duration_ns = self.durationNs();
 330         sortSummaries(summaries.items, options.sort);
 331         return summaries;
 332     }
 333 
 334     pub fn collectOccurrences(self: *Analyzer, allocator: std.mem.Allocator, options: Options) !std.ArrayListUnmanaged(Occurrence) {
 335         var occurrences: std.ArrayListUnmanaged(Occurrence) = .empty;
 336         for (self.occurrences.items) |occurrence| {
 337             if (!occurrenceMatches(occurrence, options)) continue;
 338             try occurrences.append(allocator, occurrence);
 339         }
 340         sortOccurrences(occurrences.items, options.sort);
 341         return occurrences;
 342     }
 343 
 344     pub fn durationNs(self: Analyzer) u64 {
 345         const start_ns = self.start_ns orelse return 0;
 346         const end_ns = self.end_ns orelse return 0;
 347         if (end_ns <= start_ns) return 0;
 348         return end_ns - start_ns;
 349     }
 350 
 351     pub fn runningNs(self: Analyzer) u64 {
 352         var total: u64 = 0;
 353         var iter = self.fibers.valueIterator();
 354         while (iter.next()) |fiber_state| total +|= effectiveFiberRunning(fiber_state.*, self.end_ns);
 355         return total;
 356     }
 357 
 358     fn recordThreadName(self: *Analyzer, parsed: event.Parsed) !void {
 359         const name = parsed.name orelse return;
 360         const thread_state = try self.threadState(parsed.thread);
 361         if (thread_state.name) |old| self.allocator.free(old);
 362         thread_state.name = try self.allocator.dupe(u8, name);
 363         self.counters.thread_names += 1;
 364     }
 365 
 366     fn recordFiberName(self: *Analyzer, parsed: event.Parsed) !void {
 367         const id = parsed.id;
 368         if (id == 0) return;
 369         const fiber_state = try self.fiberState(id);
 370         try self.setFiberName(fiber_state, parsed.name);
 371         fiber_state.group_hint = parsed.group_hint;
 372         noteRange(&fiber_state.first_ns, &fiber_state.last_ns, parsed.time_ns);
 373         self.counters.names += 1;
 374         try self.recordOccurrence(.{
 375             .kind = .name,
 376             .seq = parsed.seq,
 377             .time_ns = parsed.time_ns,
 378             .fiber = id,
 379             .thread = parsed.thread,
 380             .name = try dupeOptional(self.allocator, parsed.name),
 381             .group_hint = parsed.group_hint,
 382         });
 383     }
 384 
 385     fn recordEnter(self: *Analyzer, parsed: event.Parsed) !void {
 386         const id = parsed.id;
 387         if (id == 0) return;
 388         self.counters.enters += 1;
 389         const thread_state = try self.threadState(parsed.thread);
 390         closeCurrentThreadFiber(self, thread_state, parsed.time_ns) catch |err| return err;
 391         const fiber_state = try self.fiberState(id);
 392         if (parsed.name != null) try self.setFiberName(fiber_state, parsed.name);
 393         if (parsed.group_hint) |group_hint| fiber_state.group_hint = group_hint;
 394         if (fiber_state.active_since_ns) |active_since| {
 395             if (parsed.time_ns >= active_since) fiber_state.running_ns +|= parsed.time_ns - active_since;
 396         }
 397         if (fiber_state.last_thread) |last_thread| {
 398             if (last_thread != parsed.thread) {
 399                 fiber_state.migrations += 1;
 400                 try self.recordTransition(id, last_thread, parsed.thread, parsed.time_ns);
 401             }
 402         }
 403         fiber_state.enters += 1;
 404         fiber_state.active_since_ns = parsed.time_ns;
 405         fiber_state.active_thread = parsed.thread;
 406         fiber_state.last_thread = parsed.thread;
 407         try fiber_state.threads.put(self.allocator, parsed.thread, {});
 408         noteRange(&fiber_state.first_ns, &fiber_state.last_ns, parsed.time_ns);
 409         thread_state.enters += 1;
 410         thread_state.current_fiber = id;
 411         thread_state.current_since_ns = parsed.time_ns;
 412         noteRange(&thread_state.first_ns, &thread_state.last_ns, parsed.time_ns);
 413         try self.recordOccurrence(.{
 414             .kind = .enter,
 415             .seq = parsed.seq,
 416             .time_ns = parsed.time_ns,
 417             .fiber = id,
 418             .thread = parsed.thread,
 419             .name = try dupeOptional(self.allocator, fiber_state.name orelse parsed.name),
 420             .group_hint = fiber_state.group_hint,
 421         });
 422     }
 423 
 424     fn recordLeave(self: *Analyzer, parsed: event.Parsed) !void {
 425         self.counters.leaves += 1;
 426         const thread_state = try self.threadState(parsed.thread);
 427         const id = if (parsed.id != 0) parsed.id else thread_state.current_fiber;
 428         var unmatched = false;
 429         var duration: u64 = 0;
 430         if (id == 0) {
 431             unmatched = true;
 432             thread_state.unmatched_leaves += 1;
 433             self.counters.unmatched_leaves += 1;
 434         } else {
 435             const fiber_state = try self.fiberState(id);
 436             fiber_state.leaves += 1;
 437             noteRange(&fiber_state.first_ns, &fiber_state.last_ns, parsed.time_ns);
 438             if (fiber_state.active_since_ns) |active_since| {
 439                 if (fiber_state.active_thread == parsed.thread and parsed.time_ns >= active_since) {
 440                     duration = parsed.time_ns - active_since;
 441                     fiber_state.running_ns +|= duration;
 442                     fiber_state.active_since_ns = null;
 443                     fiber_state.active_thread = null;
 444                 } else {
 445                     unmatched = true;
 446                 }
 447             } else {
 448                 unmatched = true;
 449             }
 450             if (thread_state.current_fiber == id and thread_state.current_since_ns != null) {
 451                 const current_since = thread_state.current_since_ns.?;
 452                 if (parsed.time_ns >= current_since) thread_state.running_ns +|= parsed.time_ns - current_since;
 453                 thread_state.current_fiber = 0;
 454                 thread_state.current_since_ns = null;
 455             } else if (unmatched) {
 456                 thread_state.unmatched_leaves += 1;
 457             }
 458             if (unmatched) {
 459                 fiber_state.unmatched_leaves += 1;
 460                 self.counters.unmatched_leaves += 1;
 461             }
 462             thread_state.leaves += 1;
 463             noteRange(&thread_state.first_ns, &thread_state.last_ns, parsed.time_ns);
 464         }
 465         try self.recordOccurrence(.{
 466             .kind = .leave,
 467             .seq = parsed.seq,
 468             .time_ns = parsed.time_ns,
 469             .fiber = id,
 470             .thread = parsed.thread,
 471             .duration_ns = duration,
 472             .unmatched = unmatched,
 473         });
 474     }
 475 
 476     fn setFiberName(self: *Analyzer, fiber_state: *FiberState, name: ?[]const u8) !void {
 477         const actual = name orelse return;
 478         if (fiber_state.name) |old| self.allocator.free(old);
 479         fiber_state.name = try self.allocator.dupe(u8, actual);
 480     }
 481 
 482     fn fiberState(self: *Analyzer, id: u64) !*FiberState {
 483         const entry = try self.fibers.getOrPut(self.allocator, id);
 484         if (!entry.found_existing) entry.value_ptr.* = .{ .id = id };
 485         return entry.value_ptr;
 486     }
 487 
 488     fn threadState(self: *Analyzer, id: u64) !*ThreadState {
 489         const entry = try self.threads.getOrPut(self.allocator, id);
 490         if (!entry.found_existing) entry.value_ptr.* = .{ .id = id };
 491         return entry.value_ptr;
 492     }
 493 
 494     fn recordTransition(self: *Analyzer, fiber: u64, from_thread: u64, to_thread: u64, time_ns: u64) !void {
 495         var label_writer = std.Io.Writer.Allocating.init(self.allocator);
 496         defer label_writer.deinit();
 497         try label_writer.writer.print("thread {d}->{d}", .{ from_thread, to_thread });
 498         const label = label_writer.written();
 499         const entry = try self.transitions.getOrPut(self.allocator, label);
 500         if (!entry.found_existing) {
 501             const owned_label = try self.allocator.dupe(u8, label);
 502             entry.key_ptr.* = owned_label;
 503             entry.value_ptr.* = .{
 504                 .label = try self.allocator.dupe(u8, owned_label),
 505                 .from_thread = from_thread,
 506                 .to_thread = to_thread,
 507             };
 508         }
 509         entry.value_ptr.count += 1;
 510         try entry.value_ptr.fibers.put(self.allocator, fiber, {});
 511         noteRange(&entry.value_ptr.first_ns, &entry.value_ptr.last_ns, time_ns);
 512     }
 513 
 514     fn recordOccurrence(self: *Analyzer, occurrence: Occurrence) !void {
 515         try self.occurrences.append(self.allocator, occurrence);
 516     }
 517 
 518     pub fn activeCount(self: Analyzer) u64 {
 519         var count: u64 = 0;
 520         var iter = self.fibers.valueIterator();
 521         while (iter.next()) |fiber_state| {
 522             if (fiber_state.active_since_ns != null) count += 1;
 523         }
 524         return count;
 525     }
 526 
 527     fn fiberMatches(self: Analyzer, fiber_state: FiberState, options: Options) bool {
 528         if (options.fiber) |fiber_filter| if (fiber_state.id != fiber_filter) return false;
 529         if (options.thread) |thread_filter| if (!fiber_state.threads.contains(thread_filter)) return false;
 530         if (!rangeMatches(fiber_state.first_ns, fiber_state.last_ns, options)) return false;
 531         if (options.match) |needle| {
 532             if (contains(fiber_state.displayName(), needle, options.ignore_case)) return true;
 533             var buffer: [32]u8 = undefined;
 534             const id_text = std.fmt.bufPrint(&buffer, "{d}", .{fiber_state.id}) catch "";
 535             if (contains(id_text, needle, options.ignore_case)) return true;
 536             return false;
 537         }
 538         _ = self;
 539         return true;
 540     }
 541 };
 542 
 543 pub fn deinitSummaries(allocator: std.mem.Allocator, summaries: *std.ArrayListUnmanaged(Summary)) void {
 544     for (summaries.items) |*summary| summary.deinit(allocator);
 545     summaries.deinit(allocator);
 546 }
 547 
 548 pub fn writeTextFromJsonlPath(
 549     allocator: std.mem.Allocator,
 550     path: []const u8,
 551     writer: *std.Io.Writer,
 552     options: Options,
 553 ) !void {
 554     return report.writeFromJsonlPath(Analyzer, writeText, allocator, path, writer, options);
 555 }
 556 
 557 pub fn writeJsonlFromJsonlPath(
 558     allocator: std.mem.Allocator,
 559     path: []const u8,
 560     writer: *std.Io.Writer,
 561     options: Options,
 562 ) !void {
 563     return report.writeFromJsonlPath(Analyzer, writeJsonl, allocator, path, writer, options);
 564 }
 565 
 566 pub fn ingestPath(analyzer: *Analyzer, path: []const u8) !void {
 567     return report.ingestJsonlPath(analyzer, path);
 568 }
 569 
 570 fn writeText(
 571     allocator: std.mem.Allocator,
 572     analyzer: *Analyzer,
 573     writer: *std.Io.Writer,
 574     options: Options,
 575 ) !void {
 576     var summaries = try analyzer.collectSummaries(allocator, options);
 577     defer deinitSummaries(allocator, &summaries);
 578     var occurrences = try analyzer.collectOccurrences(allocator, options);
 579     defer occurrences.deinit(allocator);
 580 
 581     try writer.print(
 582         "tracy fibers groups={d} fibers={d} threads={d} enters={d} leaves={d} names={d} running_ns={d} migrations={d} unmatched_leaves={d} active={d} filtered={d} duration_ns={d} group={s} sort={s}\n",
 583         .{
 584             summaries.items.len,
 585             analyzer.fibers.count(),
 586             analyzer.threads.count(),
 587             analyzer.counters.enters,
 588             analyzer.counters.leaves,
 589             analyzer.counters.names,
 590             analyzer.runningNs(),
 591             totalMigrations(analyzer),
 592             analyzer.counters.unmatched_leaves,
 593             analyzer.counters.active_fibers,
 594             analyzer.counters.filtered,
 595             analyzer.durationNs(),
 596             options.group.tag(),
 597             options.sort.tag(),
 598         },
 599     );
 600     const summary_limit = @min(options.top, summaries.items.len);
 601     for (summaries.items[0..summary_limit]) |summary| {
 602         try writer.print("fiber group={s} label=", .{summary.group.tag()});
 603         try pretty_json.writeString(writer, summary.label);
 604         try writer.print(" count={d}", .{summary.count});
 605         try writeSummaryFieldsText(writer, summary);
 606         try writer.writeByte('\n');
 607     }
 608 
 609     const occurrence_limit = @min(options.occurrences, occurrences.items.len);
 610     for (occurrences.items[0..occurrence_limit]) |occurrence| {
 611         try writer.print("fiber-occurrence kind={s} time_ns={d} thread={d}", .{ occurrence.kind.tag(), occurrence.time_ns, occurrence.thread });
 612         try writeOccurrenceFieldsText(writer, occurrence);
 613         try writer.writeByte('\n');
 614     }
 615 }
 616 
 617 fn writeJsonl(
 618     allocator: std.mem.Allocator,
 619     analyzer: *Analyzer,
 620     writer: *std.Io.Writer,
 621     options: Options,
 622 ) !void {
 623     var summaries = try analyzer.collectSummaries(allocator, options);
 624     defer deinitSummaries(allocator, &summaries);
 625     var occurrences = try analyzer.collectOccurrences(allocator, options);
 626     defer occurrences.deinit(allocator);
 627 
 628     var summary_stream = pretty_json.Writer.init(writer, .minified);
 629     const summary_record = try summary_stream.object();
 630     try summary_record.field("schema", schema);
 631     try summary_record.field("kind", "summary");
 632     try summary_record.field("groups", summaries.items.len);
 633     try summary_record.field("fibers", analyzer.fibers.count());
 634     try summary_record.field("threads", analyzer.threads.count());
 635     try summary_record.field("enters", analyzer.counters.enters);
 636     try summary_record.field("leaves", analyzer.counters.leaves);
 637     try summary_record.field("names", analyzer.counters.names);
 638     try summary_record.field("running_ns", analyzer.runningNs());
 639     try summary_record.field("migrations", totalMigrations(analyzer));
 640     try summary_record.field("unmatched_leaves", analyzer.counters.unmatched_leaves);
 641     try summary_record.field("active", analyzer.counters.active_fibers);
 642     try summary_record.field("filtered", analyzer.counters.filtered);
 643     try summary_record.field("duration_ns", analyzer.durationNs());
 644     try summary_record.field("group", options.group.tag());
 645     try summary_record.field("sort", options.sort.tag());
 646     try summary_record.endLine();
 647 
 648     const summary_limit = @min(options.top, summaries.items.len);
 649     for (summaries.items[0..summary_limit]) |summary| {
 650         var stream = pretty_json.Writer.init(writer, .minified);
 651         const object = try stream.object();
 652         try object.field("schema", schema);
 653         try object.field("kind", "group");
 654         try object.field("group", summary.group.tag());
 655         try object.field("label", summary.label);
 656         try object.field("count", summary.count);
 657         try writeSummaryFields(object, summary);
 658         try object.endLine();
 659     }
 660 
 661     const occurrence_limit = @min(options.occurrences, occurrences.items.len);
 662     for (occurrences.items[0..occurrence_limit]) |occurrence| {
 663         var stream = pretty_json.Writer.init(writer, .minified);
 664         const object = try stream.object();
 665         try object.field("schema", schema);
 666         try object.field("kind", occurrence.kind.tag());
 667         try object.field("time_ns", occurrence.time_ns);
 668         try object.field("thread", occurrence.thread);
 669         try writeOccurrenceFields(object, occurrence);
 670         try object.endLine();
 671     }
 672 }
 673 
 674 fn closeCurrentThreadFiber(analyzer: *Analyzer, thread_state: *ThreadState, time_ns: u64) !void {
 675     if (thread_state.current_fiber == 0) return;
 676     if (thread_state.current_since_ns) |current_since| {
 677         if (time_ns >= current_since) thread_state.running_ns +|= time_ns - current_since;
 678     }
 679     const fiber_state = try analyzer.fiberState(thread_state.current_fiber);
 680     if (fiber_state.active_since_ns) |active_since| {
 681         if (time_ns >= active_since) fiber_state.running_ns +|= time_ns - active_since;
 682         fiber_state.active_since_ns = null;
 683         fiber_state.active_thread = null;
 684     }
 685     thread_state.current_fiber = 0;
 686     thread_state.current_since_ns = null;
 687 }
 688 
 689 fn fiberSummary(allocator: std.mem.Allocator, fiber_state: FiberState, end_ns: ?u64) !Summary {
 690     var label = std.Io.Writer.Allocating.init(allocator);
 691     defer label.deinit();
 692     try label.writer.print("fiber {d}", .{fiber_state.id});
 693     if (fiber_state.name) |name| {
 694         try label.writer.writeByte(' ');
 695         try label.writer.writeAll(name);
 696     }
 697     return .{
 698         .group = .fiber,
 699         .label = try allocator.dupe(u8, label.written()),
 700         .count = fiber_state.enters + fiber_state.leaves,
 701         .fiber = fiber_state.id,
 702         .thread = fiber_state.last_thread,
 703         .group_hint = fiber_state.group_hint,
 704         .enters = fiber_state.enters,
 705         .leaves = fiber_state.leaves,
 706         .running_ns = effectiveFiberRunning(fiber_state, end_ns),
 707         .migrations = fiber_state.migrations,
 708         .unmatched_leaves = fiber_state.unmatched_leaves,
 709         .active = fiber_state.active_since_ns != null,
 710         .first_ns = fiber_state.first_ns,
 711         .last_ns = fiber_state.last_ns,
 712     };
 713 }
 714 
 715 fn threadSummary(allocator: std.mem.Allocator, thread_state: ThreadState, end_ns: ?u64) !Summary {
 716     var label = std.Io.Writer.Allocating.init(allocator);
 717     defer label.deinit();
 718     try label.writer.print("thread {d}", .{thread_state.id});
 719     if (thread_state.name) |name| {
 720         try label.writer.writeByte(' ');
 721         try label.writer.writeAll(name);
 722     }
 723     return .{
 724         .group = .thread,
 725         .label = try allocator.dupe(u8, label.written()),
 726         .count = thread_state.enters + thread_state.leaves,
 727         .thread = thread_state.id,
 728         .enters = thread_state.enters,
 729         .leaves = thread_state.leaves,
 730         .running_ns = effectiveThreadRunning(thread_state, end_ns),
 731         .unmatched_leaves = thread_state.unmatched_leaves,
 732         .active = thread_state.current_fiber != 0,
 733         .first_ns = thread_state.first_ns,
 734         .last_ns = thread_state.last_ns,
 735     };
 736 }
 737 
 738 fn transitionSummary(allocator: std.mem.Allocator, transition: TransitionState) !Summary {
 739     return .{
 740         .group = .transition,
 741         .label = try allocator.dupe(u8, transition.label),
 742         .count = transition.count,
 743         .from_thread = transition.from_thread,
 744         .to_thread = transition.to_thread,
 745         .migrations = transition.count,
 746         .first_ns = transition.first_ns,
 747         .last_ns = transition.last_ns,
 748     };
 749 }
 750 
 751 fn occurrenceSummary(allocator: std.mem.Allocator, occurrence: Occurrence) !Summary {
 752     var label = std.Io.Writer.Allocating.init(allocator);
 753     defer label.deinit();
 754     try label.writer.print("{s} {d}", .{ occurrence.kind.tag(), occurrence.time_ns });
 755     return .{
 756         .group = .none,
 757         .label = try allocator.dupe(u8, label.written()),
 758         .count = 1,
 759         .fiber = if (occurrence.fiber == 0) null else occurrence.fiber,
 760         .thread = occurrence.thread,
 761         .group_hint = occurrence.group_hint,
 762         .running_ns = occurrence.duration_ns,
 763         .unmatched_leaves = if (occurrence.unmatched) 1 else 0,
 764         .first_ns = occurrence.time_ns,
 765         .last_ns = occurrence.time_ns,
 766     };
 767 }
 768 
 769 fn effectiveFiberRunning(fiber_state: FiberState, end_ns: ?u64) u64 {
 770     var total = fiber_state.running_ns;
 771     if (fiber_state.active_since_ns) |active_since| {
 772         if (end_ns) |end| {
 773             if (end >= active_since) total +|= end - active_since;
 774         }
 775     }
 776     return total;
 777 }
 778 
 779 fn effectiveThreadRunning(thread_state: ThreadState, end_ns: ?u64) u64 {
 780     var total = thread_state.running_ns;
 781     if (thread_state.current_since_ns) |current_since| {
 782         if (end_ns) |end| {
 783             if (end >= current_since) total +|= end - current_since;
 784         }
 785     }
 786     return total;
 787 }
 788 
 789 fn totalMigrations(analyzer: *Analyzer) u64 {
 790     var total: u64 = 0;
 791     var iter = analyzer.fibers.valueIterator();
 792     while (iter.next()) |fiber_state| total +|= fiber_state.migrations;
 793     return total;
 794 }
 795 
 796 fn threadMatches(thread_state: ThreadState, options: Options) bool {
 797     if (options.thread) |thread_filter| if (thread_state.id != thread_filter) return false;
 798     if (!rangeMatches(thread_state.first_ns, thread_state.last_ns, options)) return false;
 799     if (options.match) |needle| {
 800         if (contains(thread_state.displayName(), needle, options.ignore_case)) return true;
 801         var buffer: [32]u8 = undefined;
 802         const thread_text = std.fmt.bufPrint(&buffer, "{d}", .{thread_state.id}) catch "";
 803         if (contains(thread_text, needle, options.ignore_case)) return true;
 804         return false;
 805     }
 806     return true;
 807 }
 808 
 809 fn transitionMatches(transition: TransitionState, options: Options) bool {
 810     if (options.fiber) |fiber_filter| if (!transition.fibers.contains(fiber_filter)) return false;
 811     if (options.thread) |thread_filter| {
 812         if (transition.from_thread != thread_filter and transition.to_thread != thread_filter) return false;
 813     }
 814     if (!rangeMatches(transition.first_ns, transition.last_ns, options)) return false;
 815     if (options.match) |needle| return contains(transition.label, needle, options.ignore_case);
 816     return true;
 817 }
 818 
 819 fn occurrenceMatches(occurrence: Occurrence, options: Options) bool {
 820     if (options.fiber) |fiber_filter| if (occurrence.fiber != fiber_filter) return false;
 821     if (options.thread) |thread_filter| if (occurrence.thread != thread_filter) return false;
 822     if (options.since_ns) |since_ns| if (occurrence.time_ns < since_ns) return false;
 823     if (options.until_ns) |until_ns| if (occurrence.time_ns > until_ns) return false;
 824     if (options.match) |needle| {
 825         if (contains(occurrence.kind.tag(), needle, options.ignore_case)) return true;
 826         if (occurrence.name) |name| if (contains(name, needle, options.ignore_case)) return true;
 827         var buffer: [32]u8 = undefined;
 828         const fiber_text = std.fmt.bufPrint(&buffer, "{d}", .{occurrence.fiber}) catch "";
 829         if (contains(fiber_text, needle, options.ignore_case)) return true;
 830         return false;
 831     }
 832     return true;
 833 }
 834 
 835 fn rangeMatches(first_ns: u64, last_ns: u64, options: Options) bool {
 836     if (options.since_ns) |since_ns| if (last_ns != 0 and last_ns < since_ns) return false;
 837     if (options.until_ns) |until_ns| if (first_ns != 0 and first_ns > until_ns) return false;
 838     return true;
 839 }
 840 
 841 fn writeSummaryFieldsText(writer: *std.Io.Writer, summary: Summary) !void {
 842     if (summary.fiber) |fiber| try writer.print(" fiber={d}", .{fiber});
 843     if (summary.thread) |thread| try writer.print(" thread={d}", .{thread});
 844     if (summary.from_thread != 0 or summary.to_thread != 0) try writer.print(" from_thread={d} to_thread={d}", .{ summary.from_thread, summary.to_thread });
 845     if (summary.group_hint) |group_hint| try writer.print(" group_hint={d}", .{group_hint});
 846     if (summary.enters != 0) try writer.print(" enters={d}", .{summary.enters});
 847     if (summary.leaves != 0) try writer.print(" leaves={d}", .{summary.leaves});
 848     if (summary.running_ns != 0) try writer.print(" running_ns={d}", .{summary.running_ns});
 849     if (summary.migrations != 0) try writer.print(" migrations={d}", .{summary.migrations});
 850     if (summary.unmatched_leaves != 0) try writer.print(" unmatched_leaves={d}", .{summary.unmatched_leaves});
 851     if (summary.active) try writer.writeAll(" active=true");
 852     if (summary.first_ns != 0) try writer.print(" first_ns={d}", .{summary.first_ns});
 853     if (summary.last_ns != 0) try writer.print(" last_ns={d}", .{summary.last_ns});
 854 }
 855 
 856 fn writeSummaryFields(object: pretty_json.Object, summary: Summary) !void {
 857     if (summary.fiber) |fiber| try object.field("fiber", fiber);
 858     if (summary.thread) |thread| try object.field("thread", thread);
 859     if (summary.from_thread != 0 or summary.to_thread != 0) {
 860         try object.field("from_thread", summary.from_thread);
 861         try object.field("to_thread", summary.to_thread);
 862     }
 863     if (summary.group_hint) |group_hint| try object.field("group_hint", group_hint);
 864     if (summary.enters != 0) try object.field("enters", summary.enters);
 865     if (summary.leaves != 0) try object.field("leaves", summary.leaves);
 866     if (summary.running_ns != 0) try object.field("running_ns", summary.running_ns);
 867     if (summary.migrations != 0) try object.field("migrations", summary.migrations);
 868     if (summary.unmatched_leaves != 0) try object.field("unmatched_leaves", summary.unmatched_leaves);
 869     if (summary.active) try object.field("active", true);
 870     if (summary.first_ns != 0) try object.field("first_ns", summary.first_ns);
 871     if (summary.last_ns != 0) try object.field("last_ns", summary.last_ns);
 872 }
 873 
 874 fn writeOccurrenceFieldsText(writer: *std.Io.Writer, occurrence: Occurrence) !void {
 875     if (occurrence.fiber != 0) try writer.print(" fiber={d}", .{occurrence.fiber});
 876     if (occurrence.name) |name| {
 877         try writer.writeAll(" name=");
 878         try pretty_json.writeString(writer, name);
 879     }
 880     if (occurrence.group_hint) |group_hint| try writer.print(" group_hint={d}", .{group_hint});
 881     if (occurrence.duration_ns != 0) try writer.print(" duration_ns={d}", .{occurrence.duration_ns});
 882     if (occurrence.unmatched) try writer.writeAll(" unmatched=true");
 883 }
 884 
 885 fn writeOccurrenceFields(object: pretty_json.Object, occurrence: Occurrence) !void {
 886     if (occurrence.fiber != 0) try object.field("fiber", occurrence.fiber);
 887     if (occurrence.name) |name| try object.field("name", name);
 888     if (occurrence.group_hint) |group_hint| try object.field("group_hint", group_hint);
 889     if (occurrence.duration_ns != 0) try object.field("duration_ns", occurrence.duration_ns);
 890     if (occurrence.unmatched) try object.field("unmatched", true);
 891 }
 892 
 893 fn sortSummaries(items: []Summary, sort: Sort) void {
 894     std.mem.sort(Summary, items, sort, summaryLessThan);
 895 }
 896 
 897 fn summaryLessThan(sort: Sort, left: Summary, right: Summary) bool {
 898     return switch (sort) {
 899         .running => summaryRunningGreaterThan({}, left, right),
 900         .enters => summaryEntersGreaterThan({}, left, right),
 901         .leaves => summaryLeavesGreaterThan({}, left, right),
 902         .migrations => summaryMigrationsGreaterThan({}, left, right),
 903         .unmatched => summaryUnmatchedGreaterThan({}, left, right),
 904         .last => summaryLastGreaterThan({}, left, right),
 905         .fiber => summaryFiberLessThan({}, left, right),
 906         .thread => summaryThreadLessThan({}, left, right),
 907         .label => summaryLabelLessThan({}, left, right),
 908     };
 909 }
 910 
 911 fn summaryRunningGreaterThan(_: void, left: Summary, right: Summary) bool {
 912     if (left.running_ns != right.running_ns) return left.running_ns > right.running_ns;
 913     return summaryEntersGreaterThan({}, left, right);
 914 }
 915 
 916 fn summaryEntersGreaterThan(_: void, left: Summary, right: Summary) bool {
 917     if (left.enters != right.enters) return left.enters > right.enters;
 918     if (left.count != right.count) return left.count > right.count;
 919     return std.mem.lessThan(u8, left.label, right.label);
 920 }
 921 
 922 fn summaryLeavesGreaterThan(_: void, left: Summary, right: Summary) bool {
 923     if (left.leaves != right.leaves) return left.leaves > right.leaves;
 924     return summaryEntersGreaterThan({}, left, right);
 925 }
 926 
 927 fn summaryMigrationsGreaterThan(_: void, left: Summary, right: Summary) bool {
 928     if (left.migrations != right.migrations) return left.migrations > right.migrations;
 929     return summaryEntersGreaterThan({}, left, right);
 930 }
 931 
 932 fn summaryUnmatchedGreaterThan(_: void, left: Summary, right: Summary) bool {
 933     if (left.unmatched_leaves != right.unmatched_leaves) return left.unmatched_leaves > right.unmatched_leaves;
 934     return summaryEntersGreaterThan({}, left, right);
 935 }
 936 
 937 fn summaryLastGreaterThan(_: void, left: Summary, right: Summary) bool {
 938     if (left.last_ns != right.last_ns) return left.last_ns > right.last_ns;
 939     return summaryEntersGreaterThan({}, left, right);
 940 }
 941 
 942 fn summaryFiberLessThan(_: void, left: Summary, right: Summary) bool {
 943     const left_fiber = left.fiber orelse 0;
 944     const right_fiber = right.fiber orelse 0;
 945     if (left_fiber != right_fiber) return left_fiber < right_fiber;
 946     return summaryEntersGreaterThan({}, left, right);
 947 }
 948 
 949 fn summaryThreadLessThan(_: void, left: Summary, right: Summary) bool {
 950     const left_thread = left.thread orelse left.from_thread;
 951     const right_thread = right.thread orelse right.from_thread;
 952     if (left_thread != right_thread) return left_thread < right_thread;
 953     return summaryEntersGreaterThan({}, left, right);
 954 }
 955 
 956 fn summaryLabelLessThan(_: void, left: Summary, right: Summary) bool {
 957     return std.mem.lessThan(u8, left.label, right.label);
 958 }
 959 
 960 fn sortOccurrences(items: []Occurrence, sort: Sort) void {
 961     switch (sort) {
 962         .last => std.mem.sort(Occurrence, items, {}, occurrenceTimeGreaterThan),
 963         else => std.mem.sort(Occurrence, items, {}, occurrenceTimeLessThan),
 964     }
 965 }
 966 
 967 fn occurrenceTimeLessThan(_: void, left: Occurrence, right: Occurrence) bool {
 968     if (left.time_ns != right.time_ns) return left.time_ns < right.time_ns;
 969     return left.seq < right.seq;
 970 }
 971 
 972 fn occurrenceTimeGreaterThan(_: void, left: Occurrence, right: Occurrence) bool {
 973     if (left.time_ns != right.time_ns) return left.time_ns > right.time_ns;
 974     return left.seq > right.seq;
 975 }
 976 
 977 fn noteRange(first_ns: *u64, last_ns: *u64, time_ns: u64) void {
 978     if (time_ns == 0) return;
 979     if (first_ns.* == 0 or time_ns < first_ns.*) first_ns.* = time_ns;
 980     last_ns.* = @max(last_ns.*, time_ns);
 981 }
 982 
 983 fn dupeOptional(allocator: std.mem.Allocator, text: ?[]const u8) !?[]u8 {
 984     const actual = text orelse return null;
 985     return try allocator.dupe(u8, actual);
 986 }
 987 
 988 fn contains(haystack: []const u8, needle: []const u8, ignore_case: bool) bool {
 989     if (!ignore_case) return std.mem.indexOf(u8, haystack, needle) != null;
 990     if (needle.len == 0) return true;
 991     if (needle.len > haystack.len) return false;
 992     var index: usize = 0;
 993     while (index + needle.len <= haystack.len) : (index += 1) {
 994         if (asciiEqlIgnoreCase(haystack[index .. index + needle.len], needle)) return true;
 995     }
 996     return false;
 997 }
 998 
 999 fn asciiEqlIgnoreCase(left: []const u8, right: []const u8) bool {
1000     if (left.len != right.len) return false;
1001     for (left, right) |a, b| {
1002         if (std.ascii.toLower(a) != std.ascii.toLower(b)) return false;
1003     }
1004     return true;
1005 }
1006 
1007 test "fiber analyzer derives running time migrations and unmatched leaves" {
1008     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
1009     defer trace.deinit();
1010     try (event.TraceEvent{ .seq = 1, .kind = .start, .time_ns = 90, .thread = 1, .name = "test" }).writeJsonLine(&trace.writer);
1011     try (event.TraceEvent{ .seq = 2, .kind = .thread_name, .time_ns = 95, .thread = 100, .name = "worker-a" }).writeJsonLine(&trace.writer);
1012     try (event.TraceEvent{ .seq = 3, .kind = .thread_name, .time_ns = 96, .thread = 101, .name = "worker-b" }).writeJsonLine(&trace.writer);
1013     try (event.TraceEvent{ .seq = 4, .kind = .fiber_name, .time_ns = 100, .thread = 100, .id = 7, .name = "job.parse", .group_hint = 3 }).writeJsonLine(&trace.writer);
1014     try (event.TraceEvent{ .seq = 5, .kind = .fiber_enter, .time_ns = 110, .thread = 100, .id = 7, .name = "job.parse", .group_hint = 3 }).writeJsonLine(&trace.writer);
1015     try (event.TraceEvent{ .seq = 6, .kind = .fiber_leave, .time_ns = 150, .thread = 100, .id = 7 }).writeJsonLine(&trace.writer);
1016     try (event.TraceEvent{ .seq = 7, .kind = .fiber_enter, .time_ns = 180, .thread = 101, .id = 7 }).writeJsonLine(&trace.writer);
1017     try (event.TraceEvent{ .seq = 8, .kind = .fiber_leave, .time_ns = 230, .thread = 101, .id = 7 }).writeJsonLine(&trace.writer);
1018     try (event.TraceEvent{ .seq = 9, .kind = .fiber_leave, .time_ns = 240, .thread = 101, .id = 9 }).writeJsonLine(&trace.writer);
1019     try (event.TraceEvent{ .seq = 10, .kind = .stop, .time_ns = 260, .thread = 1 }).writeJsonLine(&trace.writer);
1020 
1021     var analyzer = Analyzer.init(std.testing.allocator);
1022     defer analyzer.deinit();
1023     try analyzer.ingestJsonlBytes(trace.written());
1024     try std.testing.expectEqual(@as(u64, 2), analyzer.counters.enters);
1025     try std.testing.expectEqual(@as(u64, 3), analyzer.counters.leaves);
1026     try std.testing.expectEqual(@as(u64, 1), analyzer.counters.unmatched_leaves);
1027     try std.testing.expectEqual(@as(u64, 90), analyzer.fibers.get(7).?.running_ns);
1028     try std.testing.expectEqual(@as(u64, 1), analyzer.fibers.get(7).?.migrations);
1029 
1030     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
1031     defer out.deinit();
1032     try writeText(std.testing.allocator, &analyzer, &out.writer, .{ .group = .fiber, .sort = .running, .top = 4, .occurrences = 8 });
1033     const text = out.written();
1034     try std.testing.expect(std.mem.indexOf(u8, text, "tracy fibers groups=2 fibers=2 threads=2 enters=2 leaves=3 names=1 running_ns=90 migrations=1 unmatched_leaves=1") != null);
1035     try std.testing.expect(std.mem.indexOf(u8, text, "fiber group=fiber label=\"fiber 7 job.parse\" count=4 fiber=7 thread=101 group_hint=3 enters=2 leaves=2 running_ns=90 migrations=1") != null);
1036     try std.testing.expect(std.mem.indexOf(u8, text, "fiber-occurrence kind=leave time_ns=240 thread=101 fiber=9 unmatched=true") != null);
1037 }
1038 
1039 test "fiber jsonl filters by thread fiber and name" {
1040     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
1041     defer trace.deinit();
1042     try (event.TraceEvent{ .seq = 1, .kind = .fiber_name, .time_ns = 100, .thread = 100, .id = 7, .name = "job.parse" }).writeJsonLine(&trace.writer);
1043     try (event.TraceEvent{ .seq = 2, .kind = .fiber_enter, .time_ns = 110, .thread = 100, .id = 7 }).writeJsonLine(&trace.writer);
1044     try (event.TraceEvent{ .seq = 3, .kind = .fiber_leave, .time_ns = 150, .thread = 100, .id = 7 }).writeJsonLine(&trace.writer);
1045     try (event.TraceEvent{ .seq = 4, .kind = .fiber_name, .time_ns = 160, .thread = 101, .id = 8, .name = "job.render" }).writeJsonLine(&trace.writer);
1046 
1047     var analyzer = Analyzer.init(std.testing.allocator);
1048     defer analyzer.deinit();
1049     try analyzer.ingestJsonlBytes(trace.written());
1050 
1051     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
1052     defer out.deinit();
1053     try writeJsonl(std.testing.allocator, &analyzer, &out.writer, .{ .group = .fiber, .fiber = 7, .thread = 100, .match = "PARSE", .ignore_case = true });
1054     const text = out.written();
1055     try std.testing.expect(std.mem.indexOf(u8, text, "\"schema\":\"tracy.fibers/v0\"") != null);
1056     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"summary\",\"groups\":1") != null);
1057     try std.testing.expect(std.mem.indexOf(u8, text, "\"label\":\"fiber 7 job.parse\"") != null);
1058     try std.testing.expect(std.mem.indexOf(u8, text, "\"running_ns\":40") != null);
1059     try std.testing.expect(std.mem.indexOf(u8, text, "job.render") == null);
1060 }