lib/tracy/src/context.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

   1 const std = @import("std");
   2 const pretty_json = @import("pretty").json;
   3 const capture_mod = @import("capture.zig");
   4 const report = @import("report.zig");
   5 const event = @import("event.zig");
   6 const record_mod = @import("record.zig");
   7 const transport = @import("transport.zig");
   8 
   9 pub const schema = "tracy.context/v0";
  10 pub const CaptureIntegrity = capture_mod.Integrity;
  11 
  12 pub const Group = enum {
  13     thread,
  14     cpu,
  15     reason,
  16     state,
  17     transition,
  18     none,
  19 
  20     pub fn fromName(text: []const u8) ?Group {
  21         if (std.mem.eql(u8, text, "thread")) return .thread;
  22         if (std.mem.eql(u8, text, "cpu")) return .cpu;
  23         if (std.mem.eql(u8, text, "reason")) return .reason;
  24         if (std.mem.eql(u8, text, "state")) return .state;
  25         if (std.mem.eql(u8, text, "transition")) return .transition;
  26         if (std.mem.eql(u8, text, "none")) return .none;
  27         return null;
  28     }
  29 
  30     fn tag(self: Group) []const u8 {
  31         return switch (self) {
  32             .thread => "thread",
  33             .cpu => "cpu",
  34             .reason => "reason",
  35             .state => "state",
  36             .transition => "transition",
  37             .none => "none",
  38         };
  39     }
  40 };
  41 
  42 pub const Sort = enum {
  43     running,
  44     blocked,
  45     ready,
  46     latency,
  47     switches,
  48     wakeups,
  49     migrations,
  50     last,
  51     thread,
  52     cpu,
  53     label,
  54 
  55     pub fn fromName(text: []const u8) ?Sort {
  56         if (std.mem.eql(u8, text, "running")) return .running;
  57         if (std.mem.eql(u8, text, "blocked")) return .blocked;
  58         if (std.mem.eql(u8, text, "ready")) return .ready;
  59         if (std.mem.eql(u8, text, "latency")) return .latency;
  60         if (std.mem.eql(u8, text, "switches")) return .switches;
  61         if (std.mem.eql(u8, text, "wakeups")) return .wakeups;
  62         if (std.mem.eql(u8, text, "migrations")) return .migrations;
  63         if (std.mem.eql(u8, text, "last")) return .last;
  64         if (std.mem.eql(u8, text, "thread")) return .thread;
  65         if (std.mem.eql(u8, text, "cpu")) return .cpu;
  66         if (std.mem.eql(u8, text, "label")) return .label;
  67         return null;
  68     }
  69 
  70     fn tag(self: Sort) []const u8 {
  71         return switch (self) {
  72             .running => "running",
  73             .blocked => "blocked",
  74             .ready => "ready",
  75             .latency => "latency",
  76             .switches => "switches",
  77             .wakeups => "wakeups",
  78             .migrations => "migrations",
  79             .last => "last",
  80             .thread => "thread",
  81             .cpu => "cpu",
  82             .label => "label",
  83         };
  84     }
  85 };
  86 
  87 pub const Options = struct {
  88     top: usize = 20,
  89     occurrences: usize = 80,
  90     group: Group = .thread,
  91     sort: Sort = .running,
  92     thread: ?u64 = null,
  93     cpu: ?u32 = null,
  94     since_ns: ?u64 = null,
  95     until_ns: ?u64 = null,
  96     match: ?[]const u8 = null,
  97     ignore_case: bool = false,
  98 };
  99 
 100 pub const Counters = struct {
 101     events: u64 = 0,
 102     switches: u64 = 0,
 103     wakeups: u64 = 0,
 104     thread_names: u64 = 0,
 105     ready_latency_samples: u64 = 0,
 106     redundant_wakeups: u64 = 0,
 107     ready_time_regressions: u64 = 0,
 108     filtered: u64 = 0,
 109     groups: u64 = 0,
 110     duration_ns: u64 = 0,
 111 };
 112 
 113 const ReasonState = struct {
 114     label: []const u8,
 115     threads: std.AutoHashMapUnmanaged(u64, void) = .{},
 116     cpus: std.AutoHashMapUnmanaged(u32, void) = .{},
 117     count: u64 = 0,
 118     blocked_ns: u64 = 0,
 119     ready_ns: u64 = 0,
 120     unknown_off_cpu_ns: u64 = 0,
 121     first_ns: u64 = 0,
 122     last_ns: u64 = 0,
 123 
 124     fn deinit(self: *ReasonState, allocator: std.mem.Allocator) void {
 125         self.threads.deinit(allocator);
 126         self.cpus.deinit(allocator);
 127         self.* = undefined;
 128     }
 129 };
 130 
 131 const TransitionState = struct {
 132     label: []const u8,
 133     prev_thread: u64 = 0,
 134     next_thread: u64 = 0,
 135     count: u64 = 0,
 136     first_ns: u64 = 0,
 137     last_ns: u64 = 0,
 138 
 139     fn deinit(self: *TransitionState, _: std.mem.Allocator) void {
 140         self.* = undefined;
 141     }
 142 };
 143 
 144 const CpuState = struct {
 145     id: u32,
 146     switches: u64 = 0,
 147     running_ns: u64 = 0,
 148     idle_ns: u64 = 0,
 149     current_thread: u64 = 0,
 150     current_since_ns: ?u64 = null,
 151     first_ns: u64 = 0,
 152     last_ns: u64 = 0,
 153 };
 154 
 155 const ThreadState = struct {
 156     id: u64,
 157     name: ?[]u8 = null,
 158     switch_ins: u64 = 0,
 159     switch_outs: u64 = 0,
 160     wakeups: u64 = 0,
 161     running_ns: u64 = 0,
 162     blocked_ns: u64 = 0,
 163     ready_ns: u64 = 0,
 164     ready_latencies: std.ArrayListUnmanaged(u64) = .empty,
 165     unknown_off_cpu_ns: u64 = 0,
 166     migrations: u64 = 0,
 167     running_since_ns: ?u64 = null,
 168     off_cpu_since_ns: ?u64 = null,
 169     ready_since_ns: ?u64 = null,
 170     last_cpu: ?u32 = null,
 171     off_cpu_reason: ?[]u8 = null,
 172     off_cpu_state: ?[]u8 = null,
 173     first_ns: u64 = 0,
 174     last_ns: u64 = 0,
 175 
 176     fn deinit(self: *ThreadState, allocator: std.mem.Allocator) void {
 177         if (self.name) |name| allocator.free(name);
 178         if (self.off_cpu_reason) |reason| allocator.free(reason);
 179         if (self.off_cpu_state) |state| allocator.free(state);
 180         self.ready_latencies.deinit(allocator);
 181         self.* = undefined;
 182     }
 183 
 184     fn displayName(self: ThreadState) []const u8 {
 185         return self.name orelse "";
 186     }
 187 };
 188 
 189 const OccurrenceKind = enum {
 190     context_switch,
 191     wakeup,
 192 
 193     fn tag(self: OccurrenceKind) []const u8 {
 194         return switch (self) {
 195             .context_switch => "switch",
 196             .wakeup => "wakeup",
 197         };
 198     }
 199 };
 200 
 201 const Occurrence = struct {
 202     kind: OccurrenceKind,
 203     seq: u64 = 0,
 204     time_ns: u64 = 0,
 205     source_thread: u64 = 0,
 206     cpu: ?u32 = null,
 207     prev_thread: u64 = 0,
 208     next_thread: u64 = 0,
 209     target_thread: u64 = 0,
 210     reason: ?[]u8 = null,
 211     state: ?[]u8 = null,
 212     previous_cstate: ?u32 = null,
 213     prev_priority: ?i64 = null,
 214     next_priority: ?i64 = null,
 215     priority_increment: ?i64 = null,
 216     ready_latency_ns: ?u64 = null,
 217 
 218     fn deinit(self: *Occurrence, allocator: std.mem.Allocator) void {
 219         if (self.reason) |reason| allocator.free(reason);
 220         if (self.state) |state| allocator.free(state);
 221         self.* = undefined;
 222     }
 223 };
 224 
 225 pub const Summary = struct {
 226     group: Group,
 227     label: []u8,
 228     count: u64 = 0,
 229     thread: ?u64 = null,
 230     cpu: ?u32 = null,
 231     prev_thread: u64 = 0,
 232     next_thread: u64 = 0,
 233     switch_ins: u64 = 0,
 234     switch_outs: u64 = 0,
 235     wakeups: u64 = 0,
 236     running_ns: u64 = 0,
 237     blocked_ns: u64 = 0,
 238     ready_ns: u64 = 0,
 239     ready_latency_samples: u64 = 0,
 240     ready_latency_mean_ns: u64 = 0,
 241     ready_latency_min_ns: u64 = 0,
 242     ready_latency_p50_ns: u64 = 0,
 243     ready_latency_p90_ns: u64 = 0,
 244     ready_latency_p99_ns: u64 = 0,
 245     ready_latency_max_ns: u64 = 0,
 246     unknown_off_cpu_ns: u64 = 0,
 247     idle_ns: u64 = 0,
 248     migrations: u64 = 0,
 249     first_ns: u64 = 0,
 250     last_ns: u64 = 0,
 251 
 252     pub fn deinit(self: *Summary, allocator: std.mem.Allocator) void {
 253         allocator.free(self.label);
 254         self.* = undefined;
 255     }
 256 };
 257 
 258 pub const Analyzer = struct {
 259     allocator: std.mem.Allocator,
 260     capture: capture_mod.Tracker = .{},
 261     threads: std.AutoHashMapUnmanaged(u64, ThreadState) = .{},
 262     cpus: std.AutoHashMapUnmanaged(u32, CpuState) = .{},
 263     reasons: std.StringHashMapUnmanaged(ReasonState) = .{},
 264     states: std.StringHashMapUnmanaged(ReasonState) = .{},
 265     transitions: std.StringHashMapUnmanaged(TransitionState) = .{},
 266     occurrences: std.ArrayListUnmanaged(Occurrence) = .empty,
 267     counters: Counters = .{},
 268     start_ns: ?u64 = null,
 269     end_ns: ?u64 = null,
 270 
 271     pub fn init(allocator: std.mem.Allocator) Analyzer {
 272         return .{ .allocator = allocator };
 273     }
 274 
 275     pub fn deinit(self: *Analyzer) void {
 276         var thread_iter = self.threads.valueIterator();
 277         while (thread_iter.next()) |thread_state| thread_state.deinit(self.allocator);
 278         self.threads.deinit(self.allocator);
 279         self.cpus.deinit(self.allocator);
 280         var reason_iter = self.reasons.iterator();
 281         while (reason_iter.next()) |entry| {
 282             self.allocator.free(entry.key_ptr.*);
 283             entry.value_ptr.deinit(self.allocator);
 284         }
 285         self.reasons.deinit(self.allocator);
 286         var state_iter = self.states.iterator();
 287         while (state_iter.next()) |entry| {
 288             self.allocator.free(entry.key_ptr.*);
 289             entry.value_ptr.deinit(self.allocator);
 290         }
 291         self.states.deinit(self.allocator);
 292         var transition_iter = self.transitions.iterator();
 293         while (transition_iter.next()) |entry| {
 294             self.allocator.free(entry.key_ptr.*);
 295             entry.value_ptr.deinit(self.allocator);
 296         }
 297         self.transitions.deinit(self.allocator);
 298         for (self.occurrences.items) |*occurrence| occurrence.deinit(self.allocator);
 299         self.occurrences.deinit(self.allocator);
 300         self.* = undefined;
 301     }
 302 
 303     pub fn ingestJsonlBytes(self: *Analyzer, bytes: []const u8) !void {
 304         var lines = std.mem.splitScalar(u8, bytes, '\n');
 305         while (lines.next()) |line| try self.ingestJsonLine(line);
 306     }
 307 
 308     pub fn ingestJsonLine(self: *Analyzer, line: []const u8) !void {
 309         const text = std.mem.trim(u8, line, " \t\r\n");
 310         if (text.len == 0) return;
 311         var parsed = try record_mod.parseLine(self.allocator, text);
 312         defer parsed.deinit();
 313         switch (parsed) {
 314             .event => |value| try self.ingest(value),
 315             .flight => |report_value| self.recordFlightReport(report_value),
 316         }
 317     }
 318 
 319     pub fn ingest(self: *Analyzer, parsed: event.Parsed) !void {
 320         self.capture.record(parsed);
 321         self.counters.events += 1;
 322         if (self.start_ns == null and parsed.time_ns != 0) self.start_ns = parsed.time_ns;
 323         if (parsed.time_ns != 0) self.end_ns = parsed.time_ns;
 324         switch (parsed.kind) {
 325             .start => {
 326                 if (parsed.time_ns != 0) self.start_ns = parsed.time_ns;
 327             },
 328             .stop => {
 329                 if (parsed.time_ns != 0) self.end_ns = parsed.time_ns;
 330             },
 331             .thread_name => try self.recordThreadName(parsed),
 332             .context_switch => try self.recordSwitch(parsed),
 333             .thread_wakeup => try self.recordWakeup(parsed),
 334             else => {},
 335         }
 336     }
 337 
 338     pub fn collectSummaries(self: *Analyzer, allocator: std.mem.Allocator, options: Options) !std.ArrayListUnmanaged(Summary) {
 339         var summaries: std.ArrayListUnmanaged(Summary) = .empty;
 340         errdefer deinitSummaries(allocator, &summaries);
 341         self.counters.filtered = 0;
 342         switch (options.group) {
 343             .thread => {
 344                 var iter = self.threads.valueIterator();
 345                 while (iter.next()) |thread_state| {
 346                     if (!self.threadMatches(thread_state.*, options)) {
 347                         self.counters.filtered += 1;
 348                         continue;
 349                     }
 350                     try appendSummary(
 351                         allocator,
 352                         &summaries,
 353                         try threadSummary(allocator, thread_state.*),
 354                     );
 355                 }
 356             },
 357             .cpu => {
 358                 var iter = self.cpus.valueIterator();
 359                 while (iter.next()) |cpu| {
 360                     if (options.cpu) |filter_cpu| if (cpu.id != filter_cpu) {
 361                         self.counters.filtered += 1;
 362                         continue;
 363                     };
 364                     try appendSummary(allocator, &summaries, try cpuSummary(allocator, cpu.*));
 365                 }
 366             },
 367             .reason => try self.collectReasonState(allocator, &summaries, self.reasons, .reason, options),
 368             .state => try self.collectReasonState(allocator, &summaries, self.states, .state, options),
 369             .transition => {
 370                 var iter = self.transitions.valueIterator();
 371                 while (iter.next()) |transition| {
 372                     if (!transitionMatches(transition.*, options)) {
 373                         self.counters.filtered += 1;
 374                         continue;
 375                     }
 376                     try appendSummary(
 377                         allocator,
 378                         &summaries,
 379                         try transitionSummary(allocator, transition.*),
 380                     );
 381                 }
 382             },
 383             .none => {
 384                 for (self.occurrences.items) |occurrence| {
 385                     if (!occurrenceMatches(occurrence, options)) {
 386                         self.counters.filtered += 1;
 387                         continue;
 388                     }
 389                     try appendSummary(
 390                         allocator,
 391                         &summaries,
 392                         try occurrenceSummary(allocator, occurrence),
 393                     );
 394                 }
 395             },
 396         }
 397         self.counters.groups = @intCast(summaries.items.len);
 398         self.counters.duration_ns = self.durationNs();
 399         sortSummaries(summaries.items, options.sort);
 400         return summaries;
 401     }
 402 
 403     pub fn collectOccurrences(self: *Analyzer, allocator: std.mem.Allocator, options: Options) !std.ArrayListUnmanaged(Occurrence) {
 404         var occurrences: std.ArrayListUnmanaged(Occurrence) = .empty;
 405         for (self.occurrences.items) |occurrence| {
 406             if (!occurrenceMatches(occurrence, options)) continue;
 407             try occurrences.append(allocator, occurrence);
 408         }
 409         sortOccurrences(occurrences.items, options.sort);
 410         return occurrences;
 411     }
 412 
 413     pub fn durationNs(self: Analyzer) u64 {
 414         const start_ns = self.start_ns orelse return 0;
 415         const end_ns = self.end_ns orelse return 0;
 416         if (end_ns <= start_ns) return 0;
 417         return end_ns - start_ns;
 418     }
 419 
 420     pub fn runningNs(self: Analyzer) u64 {
 421         var total: u64 = 0;
 422         var iter = self.threads.valueIterator();
 423         while (iter.next()) |thread_state| total +|= thread_state.running_ns;
 424         return total;
 425     }
 426 
 427     pub fn blockedNs(self: Analyzer) u64 {
 428         var total: u64 = 0;
 429         var iter = self.threads.valueIterator();
 430         while (iter.next()) |thread_state| total +|= thread_state.blocked_ns;
 431         return total;
 432     }
 433 
 434     pub fn readyNs(self: Analyzer) u64 {
 435         var total: u64 = 0;
 436         var iter = self.threads.valueIterator();
 437         while (iter.next()) |thread_state| total +|= thread_state.ready_ns;
 438         return total;
 439     }
 440 
 441     pub fn unknownOffCpuNs(self: Analyzer) u64 {
 442         var total: u64 = 0;
 443         var iter = self.threads.valueIterator();
 444         while (iter.next()) |thread_state| total +|= thread_state.unknown_off_cpu_ns;
 445         return total;
 446     }
 447 
 448     pub fn pendingReadyIntervals(self: Analyzer) u64 {
 449         var total: u64 = 0;
 450         var iter = self.threads.valueIterator();
 451         while (iter.next()) |thread_state| {
 452             if (thread_state.ready_since_ns != null) total +|= 1;
 453         }
 454         return total;
 455     }
 456 
 457     pub fn captureIntegrity(self: Analyzer) CaptureIntegrity {
 458         return self.capture.integrity(0);
 459     }
 460 
 461     pub fn readyLatencyEvidence(self: Analyzer) []const u8 {
 462         if (!std.mem.eql(u8, self.captureIntegrity().status, "complete")) return "partial";
 463         if (self.counters.ready_time_regressions != 0) return "partial";
 464         if (self.pendingReadyIntervals() != 0) return "partial";
 465         return "complete";
 466     }
 467 
 468     pub fn recordFlightReport(self: *Analyzer, report_value: transport.Report) void {
 469         self.capture.recordFlightReport(report_value);
 470     }
 471 
 472     fn recordThreadName(self: *Analyzer, parsed: event.Parsed) !void {
 473         const name = parsed.name orelse return;
 474         const thread_state = try self.threadState(parsed.thread);
 475         if (thread_state.name) |old| self.allocator.free(old);
 476         thread_state.name = try self.allocator.dupe(u8, name);
 477         self.counters.thread_names += 1;
 478     }
 479 
 480     fn recordSwitch(self: *Analyzer, parsed: event.Parsed) !void {
 481         self.counters.switches += 1;
 482         const occurrence_index = self.occurrences.items.len;
 483         var occurrence: Occurrence = .{
 484             .kind = .context_switch,
 485             .seq = parsed.seq,
 486             .time_ns = parsed.time_ns,
 487             .source_thread = parsed.thread,
 488             .cpu = parsed.cpu,
 489             .prev_thread = parsed.prev_thread,
 490             .next_thread = parsed.next_thread,
 491             .previous_cstate = parsed.previous_cstate,
 492             .prev_priority = parsed.prev_priority,
 493             .next_priority = parsed.next_priority,
 494         };
 495         errdefer occurrence.deinit(self.allocator);
 496         occurrence.reason = try dupeOptional(self.allocator, parsed.context_reason);
 497         occurrence.state = try dupeOptional(self.allocator, parsed.context_state);
 498         try self.recordOccurrence(&occurrence);
 499         try self.recordTransition(parsed);
 500         if (parsed.cpu) |cpu_id| try self.recordCpuSwitch(cpu_id, parsed);
 501         if (parsed.prev_thread != 0) try self.recordSwitchOut(parsed);
 502         if (parsed.next_thread != 0) {
 503             self.occurrences.items[occurrence_index].ready_latency_ns =
 504                 try self.recordSwitchIn(parsed);
 505         }
 506     }
 507 
 508     fn recordWakeup(self: *Analyzer, parsed: event.Parsed) !void {
 509         self.counters.wakeups += 1;
 510         const target_thread = if (parsed.target_thread != 0) parsed.target_thread else parsed.thread;
 511         var occurrence: Occurrence = .{
 512             .kind = .wakeup,
 513             .seq = parsed.seq,
 514             .time_ns = parsed.time_ns,
 515             .source_thread = parsed.thread,
 516             .cpu = parsed.cpu,
 517             .target_thread = target_thread,
 518             .priority_increment = parsed.value_i64,
 519         };
 520         errdefer occurrence.deinit(self.allocator);
 521         occurrence.reason = try dupeOptional(self.allocator, parsed.context_reason);
 522         try self.recordOccurrence(&occurrence);
 523         const target = try self.threadState(target_thread);
 524         noteTime(target, parsed.time_ns);
 525         target.wakeups += 1;
 526         if (parsed.cpu) |cpu| target.last_cpu = cpu;
 527         if (target.running_since_ns != null or target.ready_since_ns != null) {
 528             self.counters.redundant_wakeups +|= 1;
 529             return;
 530         }
 531         if (target.off_cpu_since_ns) |off_cpu_since| {
 532             if (parsed.time_ns >= off_cpu_since) {
 533                 const duration = parsed.time_ns - off_cpu_since;
 534                 target.blocked_ns +|= duration;
 535                 try self.addReasonDuration(&self.reasons, target.off_cpu_reason orelse parsed.context_reason, duration, 0, 0, target_thread, parsed.cpu, parsed.time_ns);
 536                 try self.addReasonDuration(&self.states, target.off_cpu_state, duration, 0, 0, target_thread, parsed.cpu, parsed.time_ns);
 537             }
 538             target.off_cpu_since_ns = null;
 539         }
 540         target.ready_since_ns = parsed.time_ns;
 541     }
 542 
 543     fn recordSwitchOut(self: *Analyzer, parsed: event.Parsed) !void {
 544         const thread_state = try self.threadState(parsed.prev_thread);
 545         noteTime(thread_state, parsed.time_ns);
 546         thread_state.switch_outs += 1;
 547         if (parsed.cpu) |cpu| thread_state.last_cpu = cpu;
 548         if (thread_state.running_since_ns) |running_since| {
 549             if (parsed.time_ns >= running_since) thread_state.running_ns +|= parsed.time_ns - running_since;
 550         }
 551         thread_state.running_since_ns = null;
 552         if (contextStateReady(parsed.context_state)) {
 553             thread_state.off_cpu_since_ns = null;
 554             thread_state.ready_since_ns = parsed.time_ns;
 555         } else {
 556             thread_state.off_cpu_since_ns = parsed.time_ns;
 557             thread_state.ready_since_ns = null;
 558         }
 559         try replaceOptional(self.allocator, &thread_state.off_cpu_reason, parsed.context_reason);
 560         try replaceOptional(self.allocator, &thread_state.off_cpu_state, parsed.context_state);
 561         try self.addReasonDuration(&self.reasons, parsed.context_reason, 0, 0, 1, parsed.prev_thread, parsed.cpu, parsed.time_ns);
 562         try self.addReasonDuration(&self.states, parsed.context_state, 0, 0, 1, parsed.prev_thread, parsed.cpu, parsed.time_ns);
 563     }
 564 
 565     fn recordSwitchIn(self: *Analyzer, parsed: event.Parsed) !?u64 {
 566         const thread_state = try self.threadState(parsed.next_thread);
 567         var ready_latency_ns: ?u64 = null;
 568         noteTime(thread_state, parsed.time_ns);
 569         thread_state.switch_ins += 1;
 570         if (parsed.cpu) |cpu| {
 571             if (thread_state.last_cpu) |last_cpu| {
 572                 if (last_cpu != cpu) thread_state.migrations += 1;
 573             }
 574             thread_state.last_cpu = cpu;
 575         }
 576         if (thread_state.ready_since_ns) |ready_since| {
 577             if (parsed.time_ns >= ready_since) {
 578                 const duration = parsed.time_ns - ready_since;
 579                 try thread_state.ready_latencies.append(self.allocator, duration);
 580                 thread_state.ready_ns +|= duration;
 581                 self.counters.ready_latency_samples +|= 1;
 582                 ready_latency_ns = duration;
 583                 try self.addReasonDuration(&self.reasons, thread_state.off_cpu_reason, 0, duration, 0, parsed.next_thread, parsed.cpu, parsed.time_ns);
 584                 try self.addReasonDuration(&self.states, thread_state.off_cpu_state, 0, duration, 0, parsed.next_thread, parsed.cpu, parsed.time_ns);
 585             } else {
 586                 self.counters.ready_time_regressions +|= 1;
 587             }
 588         } else if (thread_state.off_cpu_since_ns) |off_cpu_since| {
 589             if (parsed.time_ns >= off_cpu_since) {
 590                 const duration = parsed.time_ns - off_cpu_since;
 591                 thread_state.unknown_off_cpu_ns +|= duration;
 592                 try self.addUnknownDuration(&self.reasons, thread_state.off_cpu_reason, duration, parsed.next_thread, parsed.cpu, parsed.time_ns);
 593                 try self.addUnknownDuration(&self.states, thread_state.off_cpu_state, duration, parsed.next_thread, parsed.cpu, parsed.time_ns);
 594             }
 595         }
 596         thread_state.running_since_ns = parsed.time_ns;
 597         thread_state.off_cpu_since_ns = null;
 598         thread_state.ready_since_ns = null;
 599         clearOptional(self.allocator, &thread_state.off_cpu_reason);
 600         clearOptional(self.allocator, &thread_state.off_cpu_state);
 601         return ready_latency_ns;
 602     }
 603 
 604     fn recordCpuSwitch(self: *Analyzer, cpu_id: u32, parsed: event.Parsed) !void {
 605         const entry = try self.cpus.getOrPut(self.allocator, cpu_id);
 606         if (!entry.found_existing) entry.value_ptr.* = .{ .id = cpu_id };
 607         const cpu_state = entry.value_ptr;
 608         noteCpuTime(cpu_state, parsed.time_ns);
 609         if (cpu_state.current_since_ns) |current_since| {
 610             if (parsed.time_ns >= current_since) {
 611                 const duration = parsed.time_ns - current_since;
 612                 if (cpu_state.current_thread == 0) {
 613                     cpu_state.idle_ns +|= duration;
 614                 } else {
 615                     cpu_state.running_ns +|= duration;
 616                 }
 617             }
 618         }
 619         cpu_state.switches += 1;
 620         cpu_state.current_thread = parsed.next_thread;
 621         cpu_state.current_since_ns = parsed.time_ns;
 622     }
 623 
 624     fn recordTransition(self: *Analyzer, parsed: event.Parsed) !void {
 625         var label_buffer: [48]u8 = undefined;
 626         const label = std.fmt.bufPrint(
 627             &label_buffer,
 628             "{d}->{d}",
 629             .{ parsed.prev_thread, parsed.next_thread },
 630         ) catch unreachable;
 631         if (self.transitions.getPtr(label)) |transition| {
 632             transition.count += 1;
 633             noteRange(&transition.first_ns, &transition.last_ns, parsed.time_ns);
 634             return;
 635         }
 636         const owned_label = try self.allocator.dupe(u8, label);
 637         errdefer self.allocator.free(owned_label);
 638         const entry = try self.transitions.getOrPut(self.allocator, owned_label);
 639         std.debug.assert(!entry.found_existing);
 640         entry.key_ptr.* = owned_label;
 641         entry.value_ptr.* = .{
 642             .label = owned_label,
 643             .prev_thread = parsed.prev_thread,
 644             .next_thread = parsed.next_thread,
 645             .count = 1,
 646         };
 647         noteRange(&entry.value_ptr.first_ns, &entry.value_ptr.last_ns, parsed.time_ns);
 648     }
 649 
 650     fn recordOccurrence(self: *Analyzer, occurrence: *Occurrence) !void {
 651         try self.occurrences.append(self.allocator, occurrence.*);
 652         occurrence.reason = null;
 653         occurrence.state = null;
 654     }
 655 
 656     fn addReasonDuration(
 657         self: *Analyzer,
 658         map: *std.StringHashMapUnmanaged(ReasonState),
 659         label_text: ?[]const u8,
 660         blocked_ns: u64,
 661         ready_ns: u64,
 662         count: u64,
 663         thread_id: u64,
 664         cpu_id: ?u32,
 665         time_ns: u64,
 666     ) !void {
 667         const row = try self.reasonState(map, label_text);
 668         row.count += count;
 669         row.blocked_ns +|= blocked_ns;
 670         row.ready_ns +|= ready_ns;
 671         try noteReasonThreadCpu(self.allocator, row, thread_id, cpu_id);
 672         noteRange(&row.first_ns, &row.last_ns, time_ns);
 673     }
 674 
 675     fn addUnknownDuration(
 676         self: *Analyzer,
 677         map: *std.StringHashMapUnmanaged(ReasonState),
 678         label_text: ?[]const u8,
 679         duration: u64,
 680         thread_id: u64,
 681         cpu_id: ?u32,
 682         time_ns: u64,
 683     ) !void {
 684         const row = try self.reasonState(map, label_text);
 685         row.unknown_off_cpu_ns +|= duration;
 686         try noteReasonThreadCpu(self.allocator, row, thread_id, cpu_id);
 687         noteRange(&row.first_ns, &row.last_ns, time_ns);
 688     }
 689 
 690     fn reasonState(
 691         self: *Analyzer,
 692         map: *std.StringHashMapUnmanaged(ReasonState),
 693         label_text: ?[]const u8,
 694     ) !*ReasonState {
 695         const label = label_text orelse "<unknown>";
 696         if (map.getPtr(label)) |row| return row;
 697         const owned_label = try self.allocator.dupe(u8, label);
 698         errdefer self.allocator.free(owned_label);
 699         const entry = try map.getOrPut(self.allocator, owned_label);
 700         std.debug.assert(!entry.found_existing);
 701         entry.key_ptr.* = owned_label;
 702         entry.value_ptr.* = .{ .label = owned_label };
 703         return entry.value_ptr;
 704     }
 705 
 706     fn threadState(self: *Analyzer, id: u64) !*ThreadState {
 707         const entry = try self.threads.getOrPut(self.allocator, id);
 708         if (!entry.found_existing) entry.value_ptr.* = .{ .id = id };
 709         return entry.value_ptr;
 710     }
 711 
 712     fn collectReasonState(
 713         self: *Analyzer,
 714         allocator: std.mem.Allocator,
 715         summaries: *std.ArrayListUnmanaged(Summary),
 716         map: std.StringHashMapUnmanaged(ReasonState),
 717         group: Group,
 718         options: Options,
 719     ) !void {
 720         var iter = map.valueIterator();
 721         while (iter.next()) |row| {
 722             if (!reasonStateMatches(row.*, options)) {
 723                 self.counters.filtered += 1;
 724                 continue;
 725             }
 726             try appendSummary(
 727                 allocator,
 728                 summaries,
 729                 try reasonStateSummary(allocator, row.*, group),
 730             );
 731         }
 732     }
 733 
 734     fn threadMatches(self: Analyzer, thread_state: ThreadState, options: Options) bool {
 735         if (options.thread) |thread_filter| if (thread_state.id != thread_filter) return false;
 736         if (options.cpu) |cpu_filter| {
 737             const last_cpu = thread_state.last_cpu orelse return false;
 738             if (last_cpu != cpu_filter) return false;
 739         }
 740         if (options.match) |needle| {
 741             if (contains(thread_state.displayName(), needle, options.ignore_case)) return true;
 742             var buffer: [32]u8 = undefined;
 743             const thread_text = std.fmt.bufPrint(&buffer, "{d}", .{thread_state.id}) catch "";
 744             if (contains(thread_text, needle, options.ignore_case)) return true;
 745             return false;
 746         }
 747         _ = self;
 748         return true;
 749     }
 750 };
 751 
 752 pub fn deinitSummaries(allocator: std.mem.Allocator, summaries: *std.ArrayListUnmanaged(Summary)) void {
 753     for (summaries.items) |*summary| summary.deinit(allocator);
 754     summaries.deinit(allocator);
 755 }
 756 
 757 fn appendSummary(
 758     allocator: std.mem.Allocator,
 759     summaries: *std.ArrayListUnmanaged(Summary),
 760     summary_value: Summary,
 761 ) !void {
 762     var summary = summary_value;
 763     errdefer summary.deinit(allocator);
 764     try summaries.append(allocator, summary);
 765 }
 766 
 767 pub fn writeTextFromJsonlPath(
 768     allocator: std.mem.Allocator,
 769     path: []const u8,
 770     writer: *std.Io.Writer,
 771     options: Options,
 772 ) !void {
 773     return report.writeFromJsonlPath(Analyzer, writeText, allocator, path, writer, options);
 774 }
 775 
 776 pub fn writeJsonlFromJsonlPath(
 777     allocator: std.mem.Allocator,
 778     path: []const u8,
 779     writer: *std.Io.Writer,
 780     options: Options,
 781 ) !void {
 782     return report.writeFromJsonlPath(Analyzer, writeJsonl, allocator, path, writer, options);
 783 }
 784 
 785 pub fn ingestPath(analyzer: *Analyzer, path: []const u8) !void {
 786     return report.ingestJsonlPath(analyzer, path);
 787 }
 788 
 789 fn writeText(
 790     allocator: std.mem.Allocator,
 791     analyzer: *Analyzer,
 792     writer: *std.Io.Writer,
 793     options: Options,
 794 ) !void {
 795     var summaries = try analyzer.collectSummaries(allocator, options);
 796     defer deinitSummaries(allocator, &summaries);
 797     var occurrences = try analyzer.collectOccurrences(allocator, options);
 798     defer occurrences.deinit(allocator);
 799 
 800     try writer.print(
 801         "tracy context groups={d} switches={d} wakeups={d} threads={d} cpus={d} " ++
 802             "running_ns={d} blocked_ns={d} ready_ns={d} unknown_off_cpu_ns={d} " ++
 803             "ready_latency_samples={d} redundant_wakeups={d} " ++
 804             "ready_time_regressions={d} pending_ready_intervals={d} " ++
 805             "ready_latency_evidence={s} filtered={d} duration_ns={d} group={s} sort={s}\n",
 806         .{
 807             summaries.items.len,
 808             analyzer.counters.switches,
 809             analyzer.counters.wakeups,
 810             analyzer.threads.count(),
 811             analyzer.cpus.count(),
 812             analyzer.runningNs(),
 813             analyzer.blockedNs(),
 814             analyzer.readyNs(),
 815             analyzer.unknownOffCpuNs(),
 816             analyzer.counters.ready_latency_samples,
 817             analyzer.counters.redundant_wakeups,
 818             analyzer.counters.ready_time_regressions,
 819             analyzer.pendingReadyIntervals(),
 820             analyzer.readyLatencyEvidence(),
 821             analyzer.counters.filtered,
 822             analyzer.durationNs(),
 823             options.group.tag(),
 824             options.sort.tag(),
 825         },
 826     );
 827     try capture_mod.writeText(writer, analyzer.captureIntegrity());
 828     const summary_limit = @min(options.top, summaries.items.len);
 829     for (summaries.items[0..summary_limit]) |summary| {
 830         try writer.print("context group={s} label=", .{summary.group.tag()});
 831         try pretty_json.writeString(writer, summary.label);
 832         try writer.print(" count={d}", .{summary.count});
 833         try writeSummaryFieldsText(writer, summary);
 834         try writer.writeByte('\n');
 835     }
 836 
 837     const occurrence_limit = @min(options.occurrences, occurrences.items.len);
 838     for (occurrences.items[0..occurrence_limit]) |occurrence| {
 839         try writer.print("context-occurrence kind={s} time_ns={d} source_thread={d}", .{ occurrence.kind.tag(), occurrence.time_ns, occurrence.source_thread });
 840         try writeOccurrenceFieldsText(writer, occurrence);
 841         try writer.writeByte('\n');
 842     }
 843 }
 844 
 845 fn writeJsonl(
 846     allocator: std.mem.Allocator,
 847     analyzer: *Analyzer,
 848     writer: *std.Io.Writer,
 849     options: Options,
 850 ) !void {
 851     var summaries = try analyzer.collectSummaries(allocator, options);
 852     defer deinitSummaries(allocator, &summaries);
 853     var occurrences = try analyzer.collectOccurrences(allocator, options);
 854     defer occurrences.deinit(allocator);
 855 
 856     var summary_stream = pretty_json.Writer.init(writer, .minified);
 857     const summary_record = try summary_stream.object();
 858     try summary_record.field("schema", schema);
 859     try summary_record.field("kind", "summary");
 860     try summary_record.field("groups", summaries.items.len);
 861     try summary_record.field("switches", analyzer.counters.switches);
 862     try summary_record.field("wakeups", analyzer.counters.wakeups);
 863     try summary_record.field("threads", analyzer.threads.count());
 864     try summary_record.field("cpus", analyzer.cpus.count());
 865     try summary_record.field("running_ns", analyzer.runningNs());
 866     try summary_record.field("blocked_ns", analyzer.blockedNs());
 867     try summary_record.field("ready_ns", analyzer.readyNs());
 868     try summary_record.field("unknown_off_cpu_ns", analyzer.unknownOffCpuNs());
 869     try summary_record.field("ready_latency_samples", analyzer.counters.ready_latency_samples);
 870     try summary_record.field("redundant_wakeups", analyzer.counters.redundant_wakeups);
 871     try summary_record.field("ready_time_regressions", analyzer.counters.ready_time_regressions);
 872     try summary_record.field("pending_ready_intervals", analyzer.pendingReadyIntervals());
 873     try summary_record.field("filtered", analyzer.counters.filtered);
 874     try summary_record.field("duration_ns", analyzer.durationNs());
 875     try summary_record.field("group", options.group.tag());
 876     try summary_record.field("sort", options.sort.tag());
 877     try summary_record.field("ready_latency_evidence", analyzer.readyLatencyEvidence());
 878     try capture_mod.writeFields(summary_record, analyzer.captureIntegrity());
 879     try summary_record.endLine();
 880 
 881     const summary_limit = @min(options.top, summaries.items.len);
 882     for (summaries.items[0..summary_limit]) |summary| {
 883         var stream = pretty_json.Writer.init(writer, .minified);
 884         const object = try stream.object();
 885         try object.field("schema", schema);
 886         try object.field("kind", "group");
 887         try object.field("group", summary.group.tag());
 888         try object.field("label", summary.label);
 889         try object.field("count", summary.count);
 890         try writeSummaryFields(object, summary);
 891         try object.endLine();
 892     }
 893 
 894     const occurrence_limit = @min(options.occurrences, occurrences.items.len);
 895     for (occurrences.items[0..occurrence_limit]) |occurrence| {
 896         var stream = pretty_json.Writer.init(writer, .minified);
 897         const object = try stream.object();
 898         try object.field("schema", schema);
 899         try object.field("kind", occurrence.kind.tag());
 900         try object.field("time_ns", occurrence.time_ns);
 901         try object.field("source_thread", occurrence.source_thread);
 902         try writeOccurrenceFields(object, occurrence);
 903         try object.endLine();
 904     }
 905 }
 906 
 907 fn threadSummary(allocator: std.mem.Allocator, thread_state: ThreadState) !Summary {
 908     std.mem.sort(u64, thread_state.ready_latencies.items, {}, std.sort.asc(u64));
 909     const latency_samples: u64 = @intCast(thread_state.ready_latencies.items.len);
 910     var id_buffer: [20]u8 = undefined;
 911     const id = std.fmt.bufPrint(&id_buffer, "{d}", .{thread_state.id}) catch unreachable;
 912     const name = thread_state.name orelse "";
 913     const separator = if (name.len == 0) "" else " ";
 914     const label = try std.mem.concat(allocator, u8, &.{ "thread ", id, separator, name });
 915     return .{
 916         .group = .thread,
 917         .label = label,
 918         .count = thread_state.switch_ins + thread_state.switch_outs + thread_state.wakeups,
 919         .thread = thread_state.id,
 920         .cpu = thread_state.last_cpu,
 921         .switch_ins = thread_state.switch_ins,
 922         .switch_outs = thread_state.switch_outs,
 923         .wakeups = thread_state.wakeups,
 924         .running_ns = thread_state.running_ns,
 925         .blocked_ns = thread_state.blocked_ns,
 926         .ready_ns = thread_state.ready_ns,
 927         .ready_latency_samples = latency_samples,
 928         .ready_latency_mean_ns = if (latency_samples == 0)
 929             0
 930         else
 931             thread_state.ready_ns / latency_samples,
 932         .ready_latency_min_ns = readyLatencyPercentile(thread_state.ready_latencies.items, 0),
 933         .ready_latency_p50_ns = readyLatencyPercentile(thread_state.ready_latencies.items, 50),
 934         .ready_latency_p90_ns = readyLatencyPercentile(thread_state.ready_latencies.items, 90),
 935         .ready_latency_p99_ns = readyLatencyPercentile(thread_state.ready_latencies.items, 99),
 936         .ready_latency_max_ns = readyLatencyPercentile(thread_state.ready_latencies.items, 100),
 937         .unknown_off_cpu_ns = thread_state.unknown_off_cpu_ns,
 938         .migrations = thread_state.migrations,
 939         .first_ns = thread_state.first_ns,
 940         .last_ns = thread_state.last_ns,
 941     };
 942 }
 943 
 944 fn cpuSummary(allocator: std.mem.Allocator, cpu_state: CpuState) !Summary {
 945     const label = try std.fmt.allocPrint(allocator, "cpu {d}", .{cpu_state.id});
 946     return .{
 947         .group = .cpu,
 948         .label = label,
 949         .count = cpu_state.switches,
 950         .cpu = cpu_state.id,
 951         .running_ns = cpu_state.running_ns,
 952         .idle_ns = cpu_state.idle_ns,
 953         .first_ns = cpu_state.first_ns,
 954         .last_ns = cpu_state.last_ns,
 955     };
 956 }
 957 
 958 fn reasonStateSummary(allocator: std.mem.Allocator, row: ReasonState, group: Group) !Summary {
 959     return .{
 960         .group = group,
 961         .label = try allocator.dupe(u8, row.label),
 962         .count = row.count,
 963         .blocked_ns = row.blocked_ns,
 964         .ready_ns = row.ready_ns,
 965         .unknown_off_cpu_ns = row.unknown_off_cpu_ns,
 966         .first_ns = row.first_ns,
 967         .last_ns = row.last_ns,
 968     };
 969 }
 970 
 971 fn transitionSummary(allocator: std.mem.Allocator, transition: TransitionState) !Summary {
 972     return .{
 973         .group = .transition,
 974         .label = try allocator.dupe(u8, transition.label),
 975         .count = transition.count,
 976         .prev_thread = transition.prev_thread,
 977         .next_thread = transition.next_thread,
 978         .first_ns = transition.first_ns,
 979         .last_ns = transition.last_ns,
 980     };
 981 }
 982 
 983 fn occurrenceSummary(allocator: std.mem.Allocator, occurrence: Occurrence) !Summary {
 984     var label = std.Io.Writer.Allocating.init(allocator);
 985     defer label.deinit();
 986     try label.writer.print("{s} {d}", .{ occurrence.kind.tag(), occurrence.time_ns });
 987     var summary: Summary = .{
 988         .group = .none,
 989         .label = try allocator.dupe(u8, label.written()),
 990         .count = 1,
 991         .thread = occurrence.target_thread,
 992         .cpu = occurrence.cpu,
 993         .prev_thread = occurrence.prev_thread,
 994         .next_thread = occurrence.next_thread,
 995         .first_ns = occurrence.time_ns,
 996         .last_ns = occurrence.time_ns,
 997     };
 998     if (occurrence.ready_latency_ns) |latency_ns| {
 999         summary.ready_ns = latency_ns;
1000         summary.ready_latency_samples = 1;
1001         summary.ready_latency_mean_ns = latency_ns;
1002         summary.ready_latency_min_ns = latency_ns;
1003         summary.ready_latency_p50_ns = latency_ns;
1004         summary.ready_latency_p90_ns = latency_ns;
1005         summary.ready_latency_p99_ns = latency_ns;
1006         summary.ready_latency_max_ns = latency_ns;
1007     }
1008     return summary;
1009 }
1010 
1011 fn transitionMatches(transition: TransitionState, options: Options) bool {
1012     if (options.thread) |thread| {
1013         if (transition.prev_thread != thread and transition.next_thread != thread) return false;
1014     }
1015     if (!labelMatches(transition.label, options)) return false;
1016     return true;
1017 }
1018 
1019 fn occurrenceMatches(occurrence: Occurrence, options: Options) bool {
1020     if (options.thread) |thread| {
1021         if (occurrence.source_thread != thread and occurrence.prev_thread != thread and occurrence.next_thread != thread and occurrence.target_thread != thread) return false;
1022     }
1023     if (options.cpu) |cpu_filter| {
1024         const cpu = occurrence.cpu orelse return false;
1025         if (cpu != cpu_filter) return false;
1026     }
1027     if (options.since_ns) |since_ns| if (occurrence.time_ns < since_ns) return false;
1028     if (options.until_ns) |until_ns| if (occurrence.time_ns > until_ns) return false;
1029     if (options.match) |needle| {
1030         if (contains(occurrence.kind.tag(), needle, options.ignore_case)) return true;
1031         if (occurrence.reason) |reason| if (contains(reason, needle, options.ignore_case)) return true;
1032         if (occurrence.state) |state| if (contains(state, needle, options.ignore_case)) return true;
1033         return false;
1034     }
1035     return true;
1036 }
1037 
1038 fn labelMatches(label: []const u8, options: Options) bool {
1039     if (options.match) |needle| return contains(label, needle, options.ignore_case);
1040     return true;
1041 }
1042 
1043 fn reasonStateMatches(row: ReasonState, options: Options) bool {
1044     if (options.thread) |thread| {
1045         if (!row.threads.contains(thread)) return false;
1046     }
1047     if (options.cpu) |cpu| {
1048         if (!row.cpus.contains(cpu)) return false;
1049     }
1050     return labelMatches(row.label, options);
1051 }
1052 
1053 fn writeSummaryFieldsText(writer: *std.Io.Writer, summary: Summary) !void {
1054     if (summary.thread) |thread| try writer.print(" thread={d}", .{thread});
1055     if (summary.cpu) |cpu| try writer.print(" cpu={d}", .{cpu});
1056     if (summary.prev_thread != 0 or summary.next_thread != 0) try writer.print(" prev_thread={d} next_thread={d}", .{ summary.prev_thread, summary.next_thread });
1057     if (summary.switch_ins != 0) try writer.print(" switches_in={d}", .{summary.switch_ins});
1058     if (summary.switch_outs != 0) try writer.print(" switches_out={d}", .{summary.switch_outs});
1059     if (summary.wakeups != 0) try writer.print(" wakeups={d}", .{summary.wakeups});
1060     if (summary.running_ns != 0) try writer.print(" running_ns={d}", .{summary.running_ns});
1061     if (summary.blocked_ns != 0) try writer.print(" blocked_ns={d}", .{summary.blocked_ns});
1062     if (summary.ready_ns != 0) try writer.print(" ready_ns={d}", .{summary.ready_ns});
1063     try writeReadyLatencyText(writer, summary);
1064     if (summary.unknown_off_cpu_ns != 0) try writer.print(" unknown_off_cpu_ns={d}", .{summary.unknown_off_cpu_ns});
1065     if (summary.idle_ns != 0) try writer.print(" idle_ns={d}", .{summary.idle_ns});
1066     if (summary.migrations != 0) try writer.print(" migrations={d}", .{summary.migrations});
1067     if (summary.first_ns != 0) try writer.print(" first_ns={d}", .{summary.first_ns});
1068     if (summary.last_ns != 0) try writer.print(" last_ns={d}", .{summary.last_ns});
1069 }
1070 
1071 fn writeSummaryFields(object: pretty_json.Object, summary: Summary) !void {
1072     if (summary.thread) |thread| try object.field("thread", thread);
1073     if (summary.cpu) |cpu| try object.field("cpu", cpu);
1074     if (summary.prev_thread != 0 or summary.next_thread != 0) {
1075         try object.field("prev_thread", summary.prev_thread);
1076         try object.field("next_thread", summary.next_thread);
1077     }
1078     if (summary.switch_ins != 0) try object.field("switches_in", summary.switch_ins);
1079     if (summary.switch_outs != 0) try object.field("switches_out", summary.switch_outs);
1080     if (summary.wakeups != 0) try object.field("wakeups", summary.wakeups);
1081     if (summary.running_ns != 0) try object.field("running_ns", summary.running_ns);
1082     if (summary.blocked_ns != 0) try object.field("blocked_ns", summary.blocked_ns);
1083     if (summary.ready_ns != 0) try object.field("ready_ns", summary.ready_ns);
1084     try writeReadyLatencyFields(object, summary);
1085     if (summary.unknown_off_cpu_ns != 0) try object.field("unknown_off_cpu_ns", summary.unknown_off_cpu_ns);
1086     if (summary.idle_ns != 0) try object.field("idle_ns", summary.idle_ns);
1087     if (summary.migrations != 0) try object.field("migrations", summary.migrations);
1088     if (summary.first_ns != 0) try object.field("first_ns", summary.first_ns);
1089     if (summary.last_ns != 0) try object.field("last_ns", summary.last_ns);
1090 }
1091 
1092 fn writeReadyLatencyText(writer: *std.Io.Writer, summary: Summary) !void {
1093     if (summary.ready_latency_samples == 0) return;
1094     try writer.print(
1095         " ready_latency_samples={d} ready_latency_mean_ns={d} " ++
1096             "ready_latency_min_ns={d} ready_latency_p50_ns={d} " ++
1097             "ready_latency_p90_ns={d} ready_latency_p99_ns={d} " ++
1098             "ready_latency_max_ns={d}",
1099         .{
1100             summary.ready_latency_samples,
1101             summary.ready_latency_mean_ns,
1102             summary.ready_latency_min_ns,
1103             summary.ready_latency_p50_ns,
1104             summary.ready_latency_p90_ns,
1105             summary.ready_latency_p99_ns,
1106             summary.ready_latency_max_ns,
1107         },
1108     );
1109 }
1110 
1111 fn writeReadyLatencyFields(object: pretty_json.Object, summary: Summary) !void {
1112     if (summary.ready_latency_samples == 0) return;
1113     try object.field("ready_latency_samples", summary.ready_latency_samples);
1114     try object.field("ready_latency_mean_ns", summary.ready_latency_mean_ns);
1115     try object.field("ready_latency_min_ns", summary.ready_latency_min_ns);
1116     try object.field("ready_latency_p50_ns", summary.ready_latency_p50_ns);
1117     try object.field("ready_latency_p90_ns", summary.ready_latency_p90_ns);
1118     try object.field("ready_latency_p99_ns", summary.ready_latency_p99_ns);
1119     try object.field("ready_latency_max_ns", summary.ready_latency_max_ns);
1120 }
1121 
1122 fn writeOccurrenceFieldsText(writer: *std.Io.Writer, occurrence: Occurrence) !void {
1123     if (occurrence.cpu) |cpu| try writer.print(" cpu={d}", .{cpu});
1124     if (occurrence.prev_thread != 0) try writer.print(" prev_thread={d}", .{occurrence.prev_thread});
1125     if (occurrence.next_thread != 0) try writer.print(" next_thread={d}", .{occurrence.next_thread});
1126     if (occurrence.target_thread != 0) try writer.print(" target_thread={d}", .{occurrence.target_thread});
1127     if (occurrence.reason) |reason| {
1128         try writer.writeAll(" reason=");
1129         try pretty_json.writeString(writer, reason);
1130     }
1131     if (occurrence.state) |state| {
1132         try writer.writeAll(" state=");
1133         try pretty_json.writeString(writer, state);
1134     }
1135     if (occurrence.previous_cstate) |previous_cstate| try writer.print(" previous_cstate={d}", .{previous_cstate});
1136     if (occurrence.prev_priority) |prev_priority| try writer.print(" prev_priority={d}", .{prev_priority});
1137     if (occurrence.next_priority) |next_priority| try writer.print(" next_priority={d}", .{next_priority});
1138     if (occurrence.priority_increment) |priority_increment| try writer.print(" priority_increment={d}", .{priority_increment});
1139     if (occurrence.ready_latency_ns) |latency_ns| {
1140         try writer.print(" ready_latency_ns={d}", .{latency_ns});
1141     }
1142 }
1143 
1144 fn writeOccurrenceFields(object: pretty_json.Object, occurrence: Occurrence) !void {
1145     if (occurrence.cpu) |cpu| try object.field("cpu", cpu);
1146     if (occurrence.prev_thread != 0) try object.field("prev_thread", occurrence.prev_thread);
1147     if (occurrence.next_thread != 0) try object.field("next_thread", occurrence.next_thread);
1148     if (occurrence.target_thread != 0) try object.field("target_thread", occurrence.target_thread);
1149     if (occurrence.reason) |reason| try object.field("context_reason", reason);
1150     if (occurrence.state) |state| try object.field("context_state", state);
1151     if (occurrence.previous_cstate) |previous_cstate| try object.field("previous_cstate", previous_cstate);
1152     if (occurrence.prev_priority) |prev_priority| try object.field("prev_priority", prev_priority);
1153     if (occurrence.next_priority) |next_priority| try object.field("next_priority", next_priority);
1154     if (occurrence.priority_increment) |priority_increment| try object.field("priority_increment", priority_increment);
1155     if (occurrence.ready_latency_ns) |latency_ns| try object.field("ready_latency_ns", latency_ns);
1156 }
1157 
1158 fn noteTime(thread: *ThreadState, time_ns: u64) void {
1159     noteRange(&thread.first_ns, &thread.last_ns, time_ns);
1160 }
1161 
1162 fn noteCpuTime(cpu: *CpuState, time_ns: u64) void {
1163     noteRange(&cpu.first_ns, &cpu.last_ns, time_ns);
1164 }
1165 
1166 fn noteRange(first_ns: *u64, last_ns: *u64, time_ns: u64) void {
1167     if (time_ns == 0) return;
1168     if (first_ns.* == 0 or time_ns < first_ns.*) first_ns.* = time_ns;
1169     last_ns.* = @max(last_ns.*, time_ns);
1170 }
1171 
1172 fn noteReasonThreadCpu(allocator: std.mem.Allocator, row: *ReasonState, thread_id: u64, cpu_id: ?u32) !void {
1173     if (thread_id != 0) try row.threads.put(allocator, thread_id, {});
1174     if (cpu_id) |cpu| try row.cpus.put(allocator, cpu, {});
1175 }
1176 
1177 fn dupeOptional(allocator: std.mem.Allocator, text: ?[]const u8) !?[]u8 {
1178     const actual = text orelse return null;
1179     return try allocator.dupe(u8, actual);
1180 }
1181 
1182 fn replaceOptional(allocator: std.mem.Allocator, slot: *?[]u8, text: ?[]const u8) !void {
1183     clearOptional(allocator, slot);
1184     slot.* = try dupeOptional(allocator, text);
1185 }
1186 
1187 fn clearOptional(allocator: std.mem.Allocator, slot: *?[]u8) void {
1188     if (slot.*) |text| allocator.free(text);
1189     slot.* = null;
1190 }
1191 
1192 fn sortSummaries(items: []Summary, sort: Sort) void {
1193     std.mem.sort(Summary, items, sort, summaryLessThan);
1194 }
1195 
1196 fn summaryLessThan(sort: Sort, left: Summary, right: Summary) bool {
1197     return switch (sort) {
1198         .running => summaryRunningGreaterThan({}, left, right),
1199         .blocked => summaryBlockedGreaterThan({}, left, right),
1200         .ready => summaryReadyGreaterThan({}, left, right),
1201         .latency => summaryLatencyGreaterThan({}, left, right),
1202         .switches => summarySwitchesGreaterThan({}, left, right),
1203         .wakeups => summaryWakeupsGreaterThan({}, left, right),
1204         .migrations => summaryMigrationsGreaterThan({}, left, right),
1205         .last => summaryLastGreaterThan({}, left, right),
1206         .thread => summaryThreadLessThan({}, left, right),
1207         .cpu => summaryCpuLessThan({}, left, right),
1208         .label => summaryLabelLessThan({}, left, right),
1209     };
1210 }
1211 
1212 fn summaryRunningGreaterThan(_: void, left: Summary, right: Summary) bool {
1213     if (left.running_ns != right.running_ns) return left.running_ns > right.running_ns;
1214     return summarySwitchesGreaterThan({}, left, right);
1215 }
1216 
1217 fn summaryBlockedGreaterThan(_: void, left: Summary, right: Summary) bool {
1218     if (left.blocked_ns != right.blocked_ns) return left.blocked_ns > right.blocked_ns;
1219     return summarySwitchesGreaterThan({}, left, right);
1220 }
1221 
1222 fn summaryReadyGreaterThan(_: void, left: Summary, right: Summary) bool {
1223     if (left.ready_ns != right.ready_ns) return left.ready_ns > right.ready_ns;
1224     return summarySwitchesGreaterThan({}, left, right);
1225 }
1226 
1227 fn summaryLatencyGreaterThan(_: void, left: Summary, right: Summary) bool {
1228     if (left.ready_latency_max_ns != right.ready_latency_max_ns) {
1229         return left.ready_latency_max_ns > right.ready_latency_max_ns;
1230     }
1231     if (left.ready_latency_p99_ns != right.ready_latency_p99_ns) {
1232         return left.ready_latency_p99_ns > right.ready_latency_p99_ns;
1233     }
1234     return summarySwitchesGreaterThan({}, left, right);
1235 }
1236 
1237 fn summarySwitchesGreaterThan(_: void, left: Summary, right: Summary) bool {
1238     const left_switches = left.switch_ins + left.switch_outs + left.count;
1239     const right_switches = right.switch_ins + right.switch_outs + right.count;
1240     if (left_switches != right_switches) return left_switches > right_switches;
1241     return std.mem.lessThan(u8, left.label, right.label);
1242 }
1243 
1244 fn summaryWakeupsGreaterThan(_: void, left: Summary, right: Summary) bool {
1245     if (left.wakeups != right.wakeups) return left.wakeups > right.wakeups;
1246     return summarySwitchesGreaterThan({}, left, right);
1247 }
1248 
1249 fn summaryMigrationsGreaterThan(_: void, left: Summary, right: Summary) bool {
1250     if (left.migrations != right.migrations) return left.migrations > right.migrations;
1251     return summarySwitchesGreaterThan({}, left, right);
1252 }
1253 
1254 fn summaryLastGreaterThan(_: void, left: Summary, right: Summary) bool {
1255     if (left.last_ns != right.last_ns) return left.last_ns > right.last_ns;
1256     return summarySwitchesGreaterThan({}, left, right);
1257 }
1258 
1259 fn summaryThreadLessThan(_: void, left: Summary, right: Summary) bool {
1260     const left_thread = left.thread orelse 0;
1261     const right_thread = right.thread orelse 0;
1262     if (left_thread != right_thread) return left_thread < right_thread;
1263     return summarySwitchesGreaterThan({}, left, right);
1264 }
1265 
1266 fn summaryCpuLessThan(_: void, left: Summary, right: Summary) bool {
1267     const left_cpu = left.cpu orelse 0;
1268     const right_cpu = right.cpu orelse 0;
1269     if (left_cpu != right_cpu) return left_cpu < right_cpu;
1270     return summarySwitchesGreaterThan({}, left, right);
1271 }
1272 
1273 fn summaryLabelLessThan(_: void, left: Summary, right: Summary) bool {
1274     return std.mem.lessThan(u8, left.label, right.label);
1275 }
1276 
1277 fn sortOccurrences(items: []Occurrence, sort: Sort) void {
1278     switch (sort) {
1279         .latency => std.mem.sort(Occurrence, items, {}, occurrenceLatencyGreaterThan),
1280         .last => std.mem.sort(Occurrence, items, {}, occurrenceTimeGreaterThan),
1281         else => std.mem.sort(Occurrence, items, {}, occurrenceTimeLessThan),
1282     }
1283 }
1284 
1285 fn occurrenceLatencyGreaterThan(_: void, left: Occurrence, right: Occurrence) bool {
1286     const left_latency = left.ready_latency_ns orelse 0;
1287     const right_latency = right.ready_latency_ns orelse 0;
1288     if (left_latency != right_latency) return left_latency > right_latency;
1289     return occurrenceTimeLessThan({}, left, right);
1290 }
1291 
1292 fn occurrenceTimeLessThan(_: void, left: Occurrence, right: Occurrence) bool {
1293     if (left.time_ns != right.time_ns) return left.time_ns < right.time_ns;
1294     return left.seq < right.seq;
1295 }
1296 
1297 fn occurrenceTimeGreaterThan(_: void, left: Occurrence, right: Occurrence) bool {
1298     if (left.time_ns != right.time_ns) return left.time_ns > right.time_ns;
1299     return left.seq > right.seq;
1300 }
1301 
1302 fn contextStateReady(state: ?[]const u8) bool {
1303     const text = state orelse return false;
1304     inline for (.{ "ready", "runnable", "r", "r+" }) |ready| {
1305         if (asciiEqlIgnoreCase(text, ready)) return true;
1306     }
1307     return false;
1308 }
1309 
1310 fn readyLatencyPercentile(sorted: []const u64, percent: u64) u64 {
1311     if (sorted.len == 0) return 0;
1312     const rank: usize = @intCast((@as(u128, @min(percent, 100)) * sorted.len + 99) / 100);
1313     const index = @min(@max(rank, 1) - 1, sorted.len - 1);
1314     return sorted[index];
1315 }
1316 
1317 fn contains(haystack: []const u8, needle: []const u8, ignore_case: bool) bool {
1318     if (!ignore_case) return std.mem.indexOf(u8, haystack, needle) != null;
1319     if (needle.len == 0) return true;
1320     if (needle.len > haystack.len) return false;
1321     var index: usize = 0;
1322     while (index + needle.len <= haystack.len) : (index += 1) {
1323         if (asciiEqlIgnoreCase(haystack[index .. index + needle.len], needle)) return true;
1324     }
1325     return false;
1326 }
1327 
1328 fn asciiEqlIgnoreCase(left: []const u8, right: []const u8) bool {
1329     if (left.len != right.len) return false;
1330     for (left, right) |a, b| {
1331         if (std.ascii.toLower(a) != std.ascii.toLower(b)) return false;
1332     }
1333     return true;
1334 }
1335 
1336 test "context analyzer preserves preempted ready latency" {
1337     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
1338     defer trace.deinit();
1339     try (event.TraceEvent{ .seq = 1, .kind = .start, .time_ns = 90, .thread = 1, .name = "test" }).writeJsonLine(&trace.writer);
1340     try (event.TraceEvent{ .seq = 2, .kind = .thread_name, .time_ns = 95, .thread = 10, .name = "worker" }).writeJsonLine(&trace.writer);
1341     try (event.TraceEvent{ .seq = 3, .kind = .context_switch, .time_ns = 100, .thread = 1, .cpu = 0, .next_thread = 10 }).writeJsonLine(&trace.writer);
1342     try (event.TraceEvent{ .seq = 4, .kind = .context_switch, .time_ns = 150, .thread = 1, .cpu = 0, .prev_thread = 10, .next_thread = 11, .context_reason = "WrPreempted", .context_state = "Ready" }).writeJsonLine(&trace.writer);
1343     try (event.TraceEvent{ .seq = 5, .kind = .thread_wakeup, .time_ns = 180, .thread = 1, .target_thread = 10, .cpu = 0, .context_reason = "Unwait" }).writeJsonLine(&trace.writer);
1344     try (event.TraceEvent{ .seq = 6, .kind = .context_switch, .time_ns = 220, .thread = 1, .cpu = 1, .prev_thread = 11, .next_thread = 10, .context_reason = "WrYieldExecution", .context_state = "Ready" }).writeJsonLine(&trace.writer);
1345     try (event.TraceEvent{ .seq = 7, .kind = .stop, .time_ns = 260, .thread = 1 }).writeJsonLine(&trace.writer);
1346 
1347     var analyzer = Analyzer.init(std.testing.allocator);
1348     defer analyzer.deinit();
1349     try analyzer.ingestJsonlBytes(trace.written());
1350     try std.testing.expectEqual(@as(u64, 3), analyzer.counters.switches);
1351     try std.testing.expectEqual(@as(u64, 1), analyzer.counters.wakeups);
1352     try std.testing.expectEqual(@as(u64, 50), analyzer.threads.get(10).?.running_ns);
1353     try std.testing.expectEqual(@as(u64, 0), analyzer.threads.get(10).?.blocked_ns);
1354     try std.testing.expectEqual(@as(u64, 70), analyzer.threads.get(10).?.ready_ns);
1355     try std.testing.expectEqual(@as(u64, 1), analyzer.counters.ready_latency_samples);
1356     try std.testing.expectEqual(@as(u64, 1), analyzer.counters.redundant_wakeups);
1357     try std.testing.expectEqual(@as(u64, 1), analyzer.threads.get(10).?.migrations);
1358 
1359     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
1360     defer out.deinit();
1361     try writeText(std.testing.allocator, &analyzer, &out.writer, .{ .group = .thread, .sort = .running, .top = 4, .occurrences = 4 });
1362     const text = out.written();
1363     try expectContextContains(
1364         text,
1365         "ready_latency_samples=1 redundant_wakeups=1 ready_time_regressions=0 " ++
1366             "pending_ready_intervals=1 ready_latency_evidence=partial",
1367     );
1368     try expectContextContains(
1369         text,
1370         "context group=thread label=\"thread 10 worker\" count=4 thread=10 cpu=1 " ++
1371             "switches_in=2 switches_out=1 wakeups=1 running_ns=50 ready_ns=70 " ++
1372             "ready_latency_samples=1 ready_latency_mean_ns=70 ready_latency_min_ns=70 " ++
1373             "ready_latency_p50_ns=70 ready_latency_p90_ns=70 ready_latency_p99_ns=70 " ++
1374             "ready_latency_max_ns=70 migrations=1",
1375     );
1376     try expectContextContains(
1377         text,
1378         "context-occurrence kind=switch time_ns=220 source_thread=1 cpu=1 " ++
1379             "prev_thread=11 next_thread=10 reason=\"WrYieldExecution\" state=\"Ready\" " ++
1380             "ready_latency_ns=70",
1381     );
1382     try std.testing.expect(std.mem.indexOf(u8, text, "context-occurrence kind=switch time_ns=150 source_thread=1 cpu=0 prev_thread=10 next_thread=11 reason=\"WrPreempted\" state=\"Ready\"") != null);
1383 }
1384 
1385 test "context jsonl filters by cpu reason and thread" {
1386     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
1387     defer trace.deinit();
1388     try (event.TraceEvent{ .seq = 1, .kind = .context_switch, .time_ns = 100, .thread = 1, .cpu = 0, .next_thread = 10 }).writeJsonLine(&trace.writer);
1389     try (event.TraceEvent{ .seq = 2, .kind = .context_switch, .time_ns = 140, .thread = 1, .cpu = 0, .prev_thread = 10, .next_thread = 11, .context_reason = "WrMutex", .context_state = "Waiting" }).writeJsonLine(&trace.writer);
1390     try (event.TraceEvent{ .seq = 3, .kind = .thread_wakeup, .time_ns = 160, .thread = 1, .target_thread = 10, .cpu = 0 }).writeJsonLine(&trace.writer);
1391     try (event.TraceEvent{ .seq = 4, .kind = .context_switch, .time_ns = 180, .thread = 1, .cpu = 0, .prev_thread = 11, .next_thread = 10 }).writeJsonLine(&trace.writer);
1392 
1393     var analyzer = Analyzer.init(std.testing.allocator);
1394     defer analyzer.deinit();
1395     try analyzer.ingestJsonlBytes(trace.written());
1396 
1397     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
1398     defer out.deinit();
1399     try writeJsonl(std.testing.allocator, &analyzer, &out.writer, .{ .group = .reason, .cpu = 0, .thread = 10, .match = "wrmutex", .ignore_case = true });
1400     const text = out.written();
1401     try std.testing.expect(std.mem.indexOf(u8, text, "\"schema\":\"tracy.context/v0\"") != null);
1402     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"summary\",\"groups\":1") != null);
1403     try std.testing.expect(std.mem.indexOf(u8, text, "\"label\":\"WrMutex\"") != null);
1404     try std.testing.expect(std.mem.indexOf(u8, text, "\"blocked_ns\":20") != null);
1405 }
1406 
1407 test "context reports ready latency distributions and worst occurrences" {
1408     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
1409     defer trace.deinit();
1410     var seq: u64 = 1;
1411     try (event.TraceEvent{ .seq = seq, .kind = .start, .time_ns = 1 }).writeJsonLine(&trace.writer);
1412     seq += 1;
1413     var time_ns: u64 = 100;
1414     for ([_]u64{ 10, 20, 30, 40, 100 }) |latency_ns| {
1415         try appendReadyLatencySample(&trace.writer, &seq, 10, time_ns, latency_ns);
1416         time_ns += latency_ns + 10;
1417     }
1418     try appendReadyLatencySample(&trace.writer, &seq, 20, time_ns, 200);
1419     try (event.TraceEvent{ .seq = seq, .kind = .stop, .time_ns = time_ns + 210 })
1420         .writeJsonLine(&trace.writer);
1421 
1422     var analyzer = Analyzer.init(std.testing.allocator);
1423     defer analyzer.deinit();
1424     try analyzer.ingestJsonlBytes(trace.written());
1425     try std.testing.expectEqual(@as(u64, 6), analyzer.counters.ready_latency_samples);
1426     try std.testing.expectEqualStrings("complete", analyzer.readyLatencyEvidence());
1427 
1428     var rows = try analyzer.collectSummaries(std.testing.allocator, .{ .sort = .latency });
1429     defer deinitSummaries(std.testing.allocator, &rows);
1430     try std.testing.expectEqual(@as(?u64, 20), rows.items[0].thread);
1431     const worker = rows.items[1];
1432     try std.testing.expectEqual(@as(?u64, 10), worker.thread);
1433     try std.testing.expectEqual(@as(u64, 5), worker.ready_latency_samples);
1434     try std.testing.expectEqual(@as(u64, 40), worker.ready_latency_mean_ns);
1435     try std.testing.expectEqual(@as(u64, 10), worker.ready_latency_min_ns);
1436     try std.testing.expectEqual(@as(u64, 30), worker.ready_latency_p50_ns);
1437     try std.testing.expectEqual(@as(u64, 100), worker.ready_latency_p90_ns);
1438     try std.testing.expectEqual(@as(u64, 100), worker.ready_latency_p99_ns);
1439     try std.testing.expectEqual(@as(u64, 100), worker.ready_latency_max_ns);
1440 
1441     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
1442     defer out.deinit();
1443     try writeJsonl(
1444         std.testing.allocator,
1445         &analyzer,
1446         &out.writer,
1447         .{ .sort = .latency, .top = 3, .occurrences = 1 },
1448     );
1449     try expectContextContains(out.written(), "\"ready_latency_evidence\":\"complete\"");
1450     try expectContextContains(out.written(), "\"ready_latency_p99_ns\":100");
1451     try expectContextContains(out.written(), "\"ready_latency_ns\":200");
1452 }
1453 
1454 test "context retains flight reports and invalid ready evidence" {
1455     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
1456     defer trace.deinit();
1457     try (event.TraceEvent{ .seq = 1, .kind = .start, .time_ns = 10 })
1458         .writeJsonLine(&trace.writer);
1459     try (event.TraceEvent{
1460         .seq = 3,
1461         .kind = .thread_wakeup,
1462         .time_ns = 100,
1463         .target_thread = 10,
1464     }).writeJsonLine(&trace.writer);
1465     try (event.TraceEvent{
1466         .seq = 4,
1467         .kind = .context_switch,
1468         .time_ns = 90,
1469         .next_thread = 10,
1470     }).writeJsonLine(&trace.writer);
1471     try (event.TraceEvent{ .seq = 5, .kind = .stop, .time_ns = 110 })
1472         .writeJsonLine(&trace.writer);
1473     const flight_report = contextTestFlightReport();
1474     try flight_report.writeJsonl(&trace.writer);
1475 
1476     var analyzer = Analyzer.init(std.testing.allocator);
1477     defer analyzer.deinit();
1478     try analyzer.ingestJsonlBytes(trace.written());
1479     try std.testing.expectEqual(@as(u64, 1), analyzer.counters.ready_time_regressions);
1480     try std.testing.expectEqualStrings("partial", analyzer.readyLatencyEvidence());
1481     const integrity = analyzer.captureIntegrity();
1482     try std.testing.expectEqualStrings("sequence_gaps", integrity.status);
1483     try std.testing.expectEqualDeep(flight_report, integrity.flight_report.?);
1484 
1485     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
1486     defer out.deinit();
1487     try writeJsonl(std.testing.allocator, &analyzer, &out.writer, .{});
1488     try expectContextContains(out.written(), "\"ready_time_regressions\":1");
1489     try expectContextContains(out.written(), "\"flight_report\":{");
1490 }
1491 
1492 test "context releases ready latency evidence on allocation failure" {
1493     try std.testing.checkAllAllocationFailures(
1494         std.testing.allocator,
1495         analyzeReadyLatency,
1496         .{},
1497     );
1498 }
1499 
1500 fn analyzeReadyLatency(allocator: std.mem.Allocator) !void {
1501     const trace =
1502         "{\"v\":0,\"seq\":1,\"kind\":\"start\",\"time_ns\":10}\n" ++
1503         "{\"v\":0,\"seq\":2,\"kind\":\"context.switch\",\"time_ns\":20," ++
1504         "\"prev_thread\":7,\"next_thread\":8,\"context_reason\":\"preempted\"," ++
1505         "\"context_state\":\"Ready\"}\n" ++
1506         "{\"v\":0,\"seq\":3,\"kind\":\"context.switch\",\"time_ns\":40," ++
1507         "\"prev_thread\":8,\"next_thread\":7,\"context_reason\":\"waiting\"," ++
1508         "\"context_state\":\"Waiting\"}\n" ++
1509         "{\"v\":0,\"seq\":4,\"kind\":\"stop\",\"time_ns\":50}\n";
1510     var analyzer = Analyzer.init(allocator);
1511     defer analyzer.deinit();
1512     try analyzer.ingestJsonlBytes(trace);
1513     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
1514     defer out.deinit();
1515     try writeJsonl(allocator, &analyzer, &out.writer, .{ .sort = .latency });
1516 }
1517 
1518 fn appendReadyLatencySample(
1519     writer: *std.Io.Writer,
1520     seq: *u64,
1521     thread: u64,
1522     start_ns: u64,
1523     latency_ns: u64,
1524 ) !void {
1525     try (event.TraceEvent{
1526         .seq = seq.*,
1527         .kind = .thread_wakeup,
1528         .time_ns = start_ns,
1529         .thread = 99,
1530         .target_thread = thread,
1531         .cpu = 0,
1532     }).writeJsonLine(writer);
1533     seq.* += 1;
1534     try (event.TraceEvent{
1535         .seq = seq.*,
1536         .kind = .context_switch,
1537         .time_ns = start_ns + latency_ns,
1538         .thread = 99,
1539         .cpu = 0,
1540         .prev_thread = 99,
1541         .next_thread = thread,
1542     }).writeJsonLine(writer);
1543     seq.* += 1;
1544     try (event.TraceEvent{
1545         .seq = seq.*,
1546         .kind = .context_switch,
1547         .time_ns = start_ns + latency_ns + 1,
1548         .thread = 99,
1549         .cpu = 0,
1550         .prev_thread = thread,
1551         .next_thread = 99,
1552         .context_state = "Waiting",
1553     }).writeJsonLine(writer);
1554     seq.* += 1;
1555 }
1556 
1557 fn contextTestFlightReport() transport.Report {
1558     return .{
1559         .policy = .overwrite_oldest,
1560         .state = .accepting,
1561         .capacity_bytes = 64,
1562         .retained_bytes = 32,
1563         .event_capacity_bytes = 16,
1564         .writer_capacity_bytes = 8,
1565         .observed_events = 5,
1566         .stored_events = 5,
1567         .retained_events = 4,
1568         .overwritten_events = 1,
1569         .dropped_events = 0,
1570         .oversized_events = 0,
1571         .partial_event_bytes = 0,
1572         .discarding_oversized_event = false,
1573     };
1574 }
1575 
1576 fn expectContextContains(haystack: []const u8, needle: []const u8) !void {
1577     try std.testing.expect(std.mem.indexOf(u8, haystack, needle) != null);
1578 }