lib/tracy/src/timeline.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 const tree = @import("tree.zig");
   7 
   8 pub const schema = "tracy.timeline/v0";
   9 
  10 pub const Kind = enum {
  11     all,
  12     zone,
  13     message,
  14     app_info,
  15     frame,
  16     plot,
  17     alloc,
  18     free,
  19     thread,
  20     fiber,
  21     gpu,
  22     sample,
  23     context,
  24     lock,
  25     system,
  26     metadata,
  27 
  28     pub fn fromName(text: []const u8) ?Kind {
  29         if (std.mem.eql(u8, text, "all")) return .all;
  30         if (std.mem.eql(u8, text, "zone")) return .zone;
  31         if (std.mem.eql(u8, text, "message")) return .message;
  32         if (std.mem.eql(u8, text, "app-info")) return .app_info;
  33         if (std.mem.eql(u8, text, "frame")) return .frame;
  34         if (std.mem.eql(u8, text, "plot")) return .plot;
  35         if (std.mem.eql(u8, text, "alloc")) return .alloc;
  36         if (std.mem.eql(u8, text, "free")) return .free;
  37         if (std.mem.eql(u8, text, "thread")) return .thread;
  38         if (std.mem.eql(u8, text, "fiber")) return .fiber;
  39         if (std.mem.eql(u8, text, "gpu")) return .gpu;
  40         if (std.mem.eql(u8, text, "sample")) return .sample;
  41         if (std.mem.eql(u8, text, "context")) return .context;
  42         if (std.mem.eql(u8, text, "lock")) return .lock;
  43         if (std.mem.eql(u8, text, "system")) return .system;
  44         if (std.mem.eql(u8, text, "metadata")) return .metadata;
  45         return null;
  46     }
  47 
  48     fn tag(self: Kind) []const u8 {
  49         return switch (self) {
  50             .all => "all",
  51             .zone => "zone",
  52             .message => "message",
  53             .app_info => "app-info",
  54             .frame => "frame",
  55             .plot => "plot",
  56             .alloc => "alloc",
  57             .free => "free",
  58             .thread => "thread",
  59             .fiber => "fiber",
  60             .gpu => "gpu",
  61             .sample => "sample",
  62             .context => "context",
  63             .lock => "lock",
  64             .system => "system",
  65             .metadata => "metadata",
  66         };
  67     }
  68 };
  69 
  70 pub const Sort = enum {
  71     time,
  72     duration,
  73 
  74     pub fn fromName(text: []const u8) ?Sort {
  75         if (std.mem.eql(u8, text, "time")) return .time;
  76         if (std.mem.eql(u8, text, "duration")) return .duration;
  77         return null;
  78     }
  79 
  80     fn tag(self: Sort) []const u8 {
  81         return switch (self) {
  82             .time => "time",
  83             .duration => "duration",
  84         };
  85     }
  86 };
  87 
  88 pub const Options = struct {
  89     top: usize = 100,
  90     kind: Kind = .all,
  91     sort: Sort = .time,
  92     thread: ?u64 = null,
  93     since_ns: ?u64 = null,
  94     until_ns: ?u64 = null,
  95     min_duration_ns: u64 = 0,
  96     match: ?[]const u8 = null,
  97 };
  98 
  99 pub const Counters = struct {
 100     events: u64 = 0,
 101     instants: u64 = 0,
 102     metadata: u64 = 0,
 103 };
 104 
 105 const Instant = struct {
 106     kind: Kind,
 107     event: []const u8,
 108     time_ns: u64,
 109     thread: u64,
 110     id: u64 = 0,
 111     name: ?[]u8 = null,
 112     text: ?[]u8 = null,
 113     file: ?[]u8 = null,
 114     function: ?[]u8 = null,
 115     line: u32 = 0,
 116     column: u32 = 0,
 117     color: ?u32 = null,
 118     value_u64: ?u64 = null,
 119     value_i64: ?i64 = null,
 120     value_f64: ?f64 = null,
 121     address: u64 = 0,
 122     size: u64 = 0,
 123     depth: u32 = 0,
 124     cpu: ?u32 = null,
 125     prev_thread: u64 = 0,
 126     next_thread: u64 = 0,
 127     target_thread: u64 = 0,
 128     pid: u64 = 0,
 129     previous_cstate: ?u32 = null,
 130     prev_priority: ?i64 = null,
 131     next_priority: ?i64 = null,
 132     group_hint: ?i64 = null,
 133     gpu_context: ?u32 = null,
 134     gpu_query: ?u32 = null,
 135     gpu_time: ?i64 = null,
 136     gpu_period: ?f64 = null,
 137     gpu_cpu_delta_ns: ?i64 = null,
 138     gpu_context_type: ?[]u8 = null,
 139     gpu_has_calibration: bool = false,
 140     gpu_serial: bool = false,
 141     gpu_annotation_id: ?i64 = null,
 142     cpu_package: ?u32 = null,
 143     cpu_die: ?u32 = null,
 144     cpu_core: ?u32 = null,
 145     power_delta_uj: ?u64 = null,
 146     hw_sample_kind: ?[]u8 = null,
 147     plot_kind: ?[]u8 = null,
 148     plot_unit: ?[]u8 = null,
 149     plot_step: bool = false,
 150     plot_fill: bool = false,
 151     lock_kind: ?[]u8 = null,
 152     sample_kind: ?[]u8 = null,
 153     context_reason: ?[]u8 = null,
 154     context_state: ?[]u8 = null,
 155 
 156     fn deinit(self: *Instant, allocator: std.mem.Allocator) void {
 157         if (self.name) |name| allocator.free(name);
 158         if (self.text) |text| allocator.free(text);
 159         if (self.file) |file| allocator.free(file);
 160         if (self.function) |function| allocator.free(function);
 161         if (self.plot_kind) |plot_kind| allocator.free(plot_kind);
 162         if (self.plot_unit) |plot_unit| allocator.free(plot_unit);
 163         if (self.lock_kind) |lock_kind| allocator.free(lock_kind);
 164         if (self.sample_kind) |sample_kind| allocator.free(sample_kind);
 165         if (self.gpu_context_type) |gpu_context_type| allocator.free(gpu_context_type);
 166         if (self.hw_sample_kind) |hw_sample_kind| allocator.free(hw_sample_kind);
 167         if (self.context_reason) |context_reason| allocator.free(context_reason);
 168         if (self.context_state) |context_state| allocator.free(context_state);
 169         self.* = undefined;
 170     }
 171 };
 172 
 173 const Row = struct {
 174     kind: Kind,
 175     event: []const u8,
 176     time_ns: u64,
 177     end_ns: u64 = 0,
 178     duration_ns: u64 = 0,
 179     thread: u64 = 0,
 180     depth: usize = 0,
 181     id: u64 = 0,
 182     state: tree.SpanState = .complete,
 183     duration_valid: bool = false,
 184     name: ?[]const u8 = null,
 185     text: ?[]const u8 = null,
 186     file: ?[]const u8 = null,
 187     function: ?[]const u8 = null,
 188     line: u32 = 0,
 189     column: u32 = 0,
 190     color: ?u32 = null,
 191     value_u64: ?u64 = null,
 192     value_i64: ?i64 = null,
 193     value_f64: ?f64 = null,
 194     address: u64 = 0,
 195     size: u64 = 0,
 196     sample_depth: u32 = 0,
 197     cpu: ?u32 = null,
 198     prev_thread: u64 = 0,
 199     next_thread: u64 = 0,
 200     target_thread: u64 = 0,
 201     pid: u64 = 0,
 202     previous_cstate: ?u32 = null,
 203     prev_priority: ?i64 = null,
 204     next_priority: ?i64 = null,
 205     group_hint: ?i64 = null,
 206     gpu_context: ?u32 = null,
 207     gpu_query: ?u32 = null,
 208     gpu_time: ?i64 = null,
 209     gpu_period: ?f64 = null,
 210     gpu_cpu_delta_ns: ?i64 = null,
 211     gpu_context_type: ?[]const u8 = null,
 212     gpu_has_calibration: bool = false,
 213     gpu_serial: bool = false,
 214     gpu_annotation_id: ?i64 = null,
 215     cpu_package: ?u32 = null,
 216     cpu_die: ?u32 = null,
 217     cpu_core: ?u32 = null,
 218     power_delta_uj: ?u64 = null,
 219     hw_sample_kind: ?[]const u8 = null,
 220     plot_kind: ?[]const u8 = null,
 221     plot_unit: ?[]const u8 = null,
 222     plot_step: bool = false,
 223     plot_fill: bool = false,
 224     lock_kind: ?[]const u8 = null,
 225     sample_kind: ?[]const u8 = null,
 226     context_reason: ?[]const u8 = null,
 227     context_state: ?[]const u8 = null,
 228 };
 229 
 230 pub const Analyzer = struct {
 231     allocator: std.mem.Allocator,
 232     zones: tree.Analyzer,
 233     instants: std.ArrayListUnmanaged(Instant) = .empty,
 234     counters: Counters = .{},
 235     start_ns: ?u64 = null,
 236     end_ns: ?u64 = null,
 237 
 238     pub fn init(allocator: std.mem.Allocator) Analyzer {
 239         return .{
 240             .allocator = allocator,
 241             .zones = tree.Analyzer.init(allocator),
 242         };
 243     }
 244 
 245     pub fn deinit(self: *Analyzer) void {
 246         for (self.instants.items) |*instant| instant.deinit(self.allocator);
 247         self.instants.deinit(self.allocator);
 248         self.zones.deinit();
 249         self.* = undefined;
 250     }
 251 
 252     pub fn ingestJsonlBytes(self: *Analyzer, bytes: []const u8) !void {
 253         var lines = std.mem.splitScalar(u8, bytes, '\n');
 254         while (lines.next()) |line| try self.ingestJsonLine(line);
 255     }
 256 
 257     pub fn ingestJsonLine(self: *Analyzer, line: []const u8) !void {
 258         const text = std.mem.trim(u8, line, " \t\r\n");
 259         if (text.len == 0) return;
 260         var parsed = try record_mod.parseLine(self.allocator, text);
 261         defer parsed.deinit();
 262         switch (parsed) {
 263             .event => |value| try self.ingest(value),
 264             .flight => |report_value| self.zones.recordFlightReport(report_value),
 265         }
 266     }
 267 
 268     pub fn ingest(self: *Analyzer, parsed: event.Parsed) !void {
 269         self.counters.events += 1;
 270         if (self.start_ns == null and parsed.time_ns != 0) self.start_ns = parsed.time_ns;
 271         if (parsed.time_ns != 0) self.end_ns = parsed.time_ns;
 272         try self.zones.ingest(parsed);
 273         switch (parsed.kind) {
 274             .message => try self.recordInstant(.message, parsed),
 275             .app_info => try self.recordInstant(.app_info, parsed),
 276             .frame => try self.recordInstant(.frame, parsed),
 277             .plot_config, .plot => try self.recordInstant(.plot, parsed),
 278             .alloc => try self.recordInstant(.alloc, parsed),
 279             .free => try self.recordInstant(.free, parsed),
 280             .thread_name => try self.recordInstant(.thread, parsed),
 281             .fiber_name, .fiber_enter, .fiber_leave => try self.recordInstant(.fiber, parsed),
 282             .gpu_context,
 283             .gpu_context_name,
 284             .gpu_zone_begin,
 285             .gpu_zone_end,
 286             .gpu_time,
 287             .gpu_calibration,
 288             .gpu_time_sync,
 289             .gpu_annotation,
 290             .gpu_annotation_name,
 291             => try self.recordInstant(.gpu, parsed),
 292             .sample, .sample_frame => try self.recordInstant(.sample, parsed),
 293             .context_switch, .thread_wakeup => try self.recordInstant(.context, parsed),
 294             .lock_announce,
 295             .lock_terminate,
 296             .lock_wait,
 297             .lock_obtain,
 298             .lock_release,
 299             .lock_shared_wait,
 300             .lock_shared_obtain,
 301             .lock_shared_release,
 302             .lock_mark,
 303             .lock_name,
 304             => try self.recordInstant(.lock, parsed),
 305             .thread_context,
 306             .thread_pid,
 307             .cpu_topology,
 308             .sys_time,
 309             .sys_power,
 310             .hw_sample,
 311             => try self.recordInstant(.system, parsed),
 312             .zone_text, .zone_name, .zone_color, .zone_value => try self.recordInstant(.metadata, parsed),
 313             else => {},
 314         }
 315     }
 316 
 317     pub fn collectRows(self: *Analyzer, allocator: std.mem.Allocator, options: Options) !std.ArrayListUnmanaged(Row) {
 318         var rows: std.ArrayListUnmanaged(Row) = .empty;
 319         var spans = try self.zones.collectSpans(allocator);
 320         defer spans.deinit(allocator);
 321         for (spans.items) |span| {
 322             const end_ns = if (span.state == .open)
 323                 self.end_ns orelse span.start_ns
 324             else
 325                 span.end_ns;
 326             const row = Row{
 327                 .kind = .zone,
 328                 .event = "zone",
 329                 .time_ns = span.start_ns,
 330                 .end_ns = end_ns,
 331                 .duration_ns = if (span.state == .open and end_ns >= span.start_ns)
 332                     end_ns - span.start_ns
 333                 else
 334                     span.total_ns,
 335                 .thread = span.thread,
 336                 .depth = span.depth,
 337                 .id = span.id,
 338                 .state = span.state,
 339                 .duration_valid = span.duration_valid,
 340                 .name = span.name,
 341                 .text = span.text,
 342                 .file = span.file,
 343                 .function = span.function,
 344                 .line = span.line,
 345                 .column = span.column,
 346                 .color = span.color,
 347                 .value_u64 = span.value,
 348             };
 349             if (!rowMatches(row, options)) continue;
 350             try rows.append(allocator, row);
 351         }
 352         for (self.instants.items) |instant| {
 353             const row = instantRow(instant);
 354             if (!rowMatches(row, options)) continue;
 355             try rows.append(allocator, row);
 356         }
 357         sortRows(rows.items, options.sort);
 358         return rows;
 359     }
 360 
 361     pub fn durationNs(self: Analyzer) u64 {
 362         const start_ns = self.start_ns orelse return 0;
 363         const end_ns = self.end_ns orelse return 0;
 364         if (end_ns <= start_ns) return 0;
 365         return end_ns - start_ns;
 366     }
 367 
 368     fn recordInstant(self: *Analyzer, kind: Kind, parsed: event.Parsed) !void {
 369         try self.instants.append(self.allocator, .{
 370             .kind = kind,
 371             .event = parsed.kind.tag(),
 372             .time_ns = parsed.time_ns,
 373             .thread = parsed.thread,
 374             .id = parsed.id,
 375             .name = try dupeOptional(self.allocator, parsed.name),
 376             .text = try dupeOptional(self.allocator, parsed.text),
 377             .file = try dupeOptional(self.allocator, parsed.file),
 378             .function = try dupeOptional(self.allocator, parsed.function),
 379             .line = parsed.line,
 380             .column = parsed.column,
 381             .color = parsed.color,
 382             .value_u64 = parsed.value_u64,
 383             .value_i64 = parsed.value_i64,
 384             .value_f64 = parsed.value_f64,
 385             .address = parsed.address,
 386             .size = parsed.size,
 387             .depth = parsed.depth,
 388             .cpu = parsed.cpu,
 389             .prev_thread = parsed.prev_thread,
 390             .next_thread = parsed.next_thread,
 391             .target_thread = parsed.target_thread,
 392             .pid = parsed.pid,
 393             .previous_cstate = parsed.previous_cstate,
 394             .prev_priority = parsed.prev_priority,
 395             .next_priority = parsed.next_priority,
 396             .group_hint = parsed.group_hint,
 397             .gpu_context = parsed.gpu_context,
 398             .gpu_query = parsed.gpu_query,
 399             .gpu_time = parsed.gpu_time,
 400             .gpu_period = parsed.gpu_period,
 401             .gpu_cpu_delta_ns = parsed.gpu_cpu_delta_ns,
 402             .gpu_context_type = try dupeOptional(self.allocator, parsed.gpu_context_type),
 403             .gpu_has_calibration = parsed.gpu_has_calibration,
 404             .gpu_serial = parsed.gpu_serial,
 405             .gpu_annotation_id = parsed.gpu_annotation_id,
 406             .cpu_package = parsed.cpu_package,
 407             .cpu_die = parsed.cpu_die,
 408             .cpu_core = parsed.cpu_core,
 409             .power_delta_uj = parsed.power_delta_uj,
 410             .hw_sample_kind = try dupeOptional(self.allocator, parsed.hw_sample_kind),
 411             .plot_kind = try dupeOptional(self.allocator, parsed.plot_kind),
 412             .plot_unit = try dupeOptional(self.allocator, parsed.plot_unit),
 413             .plot_step = parsed.plot_step,
 414             .plot_fill = parsed.plot_fill,
 415             .lock_kind = try dupeOptional(self.allocator, parsed.lock_kind),
 416             .sample_kind = try dupeOptional(self.allocator, parsed.sample_kind),
 417             .context_reason = try dupeOptional(self.allocator, parsed.context_reason),
 418             .context_state = try dupeOptional(self.allocator, parsed.context_state),
 419         });
 420         self.counters.instants += 1;
 421         if (kind == .metadata) self.counters.metadata += 1;
 422     }
 423 };
 424 
 425 pub fn writeTextFromJsonlPath(
 426     allocator: std.mem.Allocator,
 427     path: []const u8,
 428     writer: *std.Io.Writer,
 429     options: Options,
 430 ) !void {
 431     return report.writeFromJsonlPath(Analyzer, writeText, allocator, path, writer, options);
 432 }
 433 
 434 pub fn writeJsonlFromJsonlPath(
 435     allocator: std.mem.Allocator,
 436     path: []const u8,
 437     writer: *std.Io.Writer,
 438     options: Options,
 439 ) !void {
 440     return report.writeFromJsonlPath(Analyzer, writeJsonl, allocator, path, writer, options);
 441 }
 442 
 443 pub fn ingestPath(analyzer: *Analyzer, path: []const u8) !void {
 444     return report.ingestJsonlPath(analyzer, path);
 445 }
 446 
 447 fn writeText(
 448     allocator: std.mem.Allocator,
 449     analyzer: *Analyzer,
 450     writer: *std.Io.Writer,
 451     options: Options,
 452 ) !void {
 453     var rows = try analyzer.collectRows(allocator, options);
 454     defer rows.deinit(allocator);
 455     try writeTextSummary(analyzer, writer, options, rows.items.len);
 456     try analyzer.zones.evidence().writeText(writer);
 457     const limit = @min(options.top, rows.items.len);
 458     for (rows.items[0..limit]) |row| try writeTextRow(writer, row);
 459 }
 460 
 461 fn writeTextSummary(
 462     analyzer: *Analyzer,
 463     writer: *std.Io.Writer,
 464     options: Options,
 465     rows: usize,
 466 ) !void {
 467     try writer.print(
 468         "tracy timeline rows={d} events={d} zones={d} completed_zones={d} " ++
 469             "open_zones={d} instants={d} metadata={d} duration_ns={d} " ++
 470             "kind={s} sort={s}\n",
 471         .{
 472             rows,
 473             analyzer.counters.events,
 474             analyzer.zones.counters.started_zones,
 475             analyzer.zones.counters.completed_zones,
 476             analyzer.zones.active.count(),
 477             analyzer.counters.instants,
 478             analyzer.counters.metadata,
 479             analyzer.durationNs(),
 480             options.kind.tag(),
 481             options.sort.tag(),
 482         },
 483     );
 484 }
 485 
 486 fn writeTextRow(writer: *std.Io.Writer, row: Row) !void {
 487     try writer.print(
 488         "timeline kind={s} event={s} time_ns={d}",
 489         .{ row.kind.tag(), row.event, row.time_ns },
 490     );
 491     if (row.kind == .zone) {
 492         try writer.print(
 493             " end_ns={d} duration_ns={d} state={s} depth={d}",
 494             .{ row.end_ns, row.duration_ns, row.state.tag(), row.depth },
 495         );
 496     }
 497     try writer.print(" thread={d}", .{row.thread});
 498     if (row.id != 0) try writer.print(" id={d}", .{row.id});
 499     if (row.name) |name| {
 500         try writer.writeAll(" name=");
 501         try pretty_json.writeString(writer, name);
 502     }
 503     if (row.text) |text| {
 504         try writer.writeAll(" text=");
 505         try pretty_json.writeString(writer, text);
 506     }
 507     if (row.file) |file| try writer.print(" file={s}:{d}", .{ file, row.line });
 508     if (row.function) |function| {
 509         try writer.writeAll(" function=");
 510         try pretty_json.writeString(writer, function);
 511     }
 512     try writeValueText(writer, row);
 513     try writeTextDetails(writer, row);
 514     try writer.writeByte('\n');
 515 }
 516 
 517 fn writeTextDetails(writer: *std.Io.Writer, row: Row) !void {
 518     if (row.color) |color| try writer.print(" color={d}", .{color});
 519     if (row.address != 0) try writer.print(" address={d}", .{row.address});
 520     if (row.size != 0) try writer.print(" size={d}", .{row.size});
 521     if (std.mem.eql(u8, row.event, "sample.frame")) {
 522         try writer.print(" depth={d}", .{row.sample_depth});
 523     }
 524     if (row.cpu) |cpu| try writer.print(" cpu={d}", .{cpu});
 525     if (row.prev_thread != 0) try writer.print(" prev_thread={d}", .{row.prev_thread});
 526     if (row.next_thread != 0) try writer.print(" next_thread={d}", .{row.next_thread});
 527     if (row.target_thread != 0) try writer.print(" target_thread={d}", .{row.target_thread});
 528     if (row.pid != 0) try writer.print(" pid={d}", .{row.pid});
 529     if (row.previous_cstate) |value| try writer.print(" previous_cstate={d}", .{value});
 530     if (row.prev_priority) |value| try writer.print(" prev_priority={d}", .{value});
 531     if (row.next_priority) |value| try writer.print(" next_priority={d}", .{value});
 532     if (row.group_hint) |value| try writer.print(" group_hint={d}", .{value});
 533     try writeGpuFieldsText(writer, row);
 534     try writeSystemFieldsText(writer, row);
 535     if (row.plot_kind) |plot_kind| {
 536         try writer.writeAll(" plot_kind=");
 537         try pretty_json.writeString(writer, plot_kind);
 538     }
 539     try writePlotFieldsText(writer, row);
 540     try writeTextLabels(writer, row);
 541     if (row.kind == .zone) try writer.print(" duration_valid={}", .{row.duration_valid});
 542 }
 543 
 544 fn writeTextLabels(writer: *std.Io.Writer, row: Row) !void {
 545     if (row.lock_kind) |value| {
 546         try writer.writeAll(" lock_kind=");
 547         try pretty_json.writeString(writer, value);
 548     }
 549     if (row.sample_kind) |value| {
 550         try writer.writeAll(" sample_kind=");
 551         try pretty_json.writeString(writer, value);
 552     }
 553     if (row.context_reason) |value| {
 554         try writer.writeAll(" context_reason=");
 555         try pretty_json.writeString(writer, value);
 556     }
 557     if (row.context_state) |value| {
 558         try writer.writeAll(" context_state=");
 559         try pretty_json.writeString(writer, value);
 560     }
 561 }
 562 
 563 fn writeJsonl(
 564     allocator: std.mem.Allocator,
 565     analyzer: *Analyzer,
 566     writer: *std.Io.Writer,
 567     options: Options,
 568 ) !void {
 569     var rows = try analyzer.collectRows(allocator, options);
 570     defer rows.deinit(allocator);
 571     try writeJsonSummary(analyzer, writer, options, rows.items.len);
 572     const limit = @min(options.top, rows.items.len);
 573     for (rows.items[0..limit]) |row| try writeJsonRow(writer, row);
 574 }
 575 
 576 fn writeJsonSummary(
 577     analyzer: *Analyzer,
 578     writer: *std.Io.Writer,
 579     options: Options,
 580     rows: usize,
 581 ) !void {
 582     var stream = pretty_json.Writer.init(writer, .minified);
 583     const object = try stream.object();
 584     try object.field("schema", schema);
 585     try object.field("kind", "summary");
 586     try object.field("rows", rows);
 587     try object.field("events", analyzer.counters.events);
 588     try object.field("zones", analyzer.zones.counters.started_zones);
 589     try object.field("completed_zones", analyzer.zones.counters.completed_zones);
 590     try object.field("open_zones", analyzer.zones.active.count());
 591     try object.field("instants", analyzer.counters.instants);
 592     try object.field("metadata", analyzer.counters.metadata);
 593     try object.field("duration_ns", analyzer.durationNs());
 594     try object.field("filter_kind", options.kind.tag());
 595     try object.field("sort", options.sort.tag());
 596     try analyzer.zones.evidence().writeFields(object);
 597     try object.endLine();
 598 }
 599 
 600 fn writeJsonRow(writer: *std.Io.Writer, row: Row) !void {
 601     var stream = pretty_json.Writer.init(writer, .minified);
 602     const object = try stream.object();
 603     try object.field("schema", schema);
 604     try object.field("kind", row.kind.tag());
 605     try object.field("event", row.event);
 606     try object.field("time_ns", row.time_ns);
 607     try object.field("thread", row.thread);
 608     if (row.kind == .zone) {
 609         try object.field("end_ns", row.end_ns);
 610         try object.field("duration_ns", row.duration_ns);
 611         try object.field("state", row.state.tag());
 612         try object.field("duration_valid", row.duration_valid);
 613         try object.field("depth", row.depth);
 614     }
 615     if (row.id != 0) try object.field("id", row.id);
 616     if (row.name) |name| try object.field("name", name);
 617     if (row.text) |value| try object.field("text", value);
 618     if (row.file) |value| {
 619         try object.field("file", value);
 620         try object.field("line", row.line);
 621     }
 622     if (row.function) |value| try object.field("function", value);
 623     try writeValueField(object, row);
 624     try writeFields(object, row);
 625     try object.endLine();
 626 }
 627 
 628 fn writeFields(object: pretty_json.Object, row: Row) !void {
 629     if (row.color) |value| try object.field("color", value);
 630     if (row.address != 0) try object.field("address", row.address);
 631     if (row.size != 0) try object.field("size", row.size);
 632     if (std.mem.eql(u8, row.event, "sample.frame")) try object.field("depth", row.sample_depth);
 633     if (row.cpu) |value| try object.field("cpu", value);
 634     if (row.prev_thread != 0) try object.field("prev_thread", row.prev_thread);
 635     if (row.next_thread != 0) try object.field("next_thread", row.next_thread);
 636     if (row.target_thread != 0) try object.field("target_thread", row.target_thread);
 637     if (row.pid != 0) try object.field("pid", row.pid);
 638     if (row.previous_cstate) |value| try object.field("previous_cstate", value);
 639     if (row.prev_priority) |value| try object.field("prev_priority", value);
 640     if (row.next_priority) |value| try object.field("next_priority", value);
 641     if (row.group_hint) |value| try object.field("group_hint", value);
 642     try writeGpuFields(object, row);
 643     try writeSystemFields(object, row);
 644     if (row.plot_kind) |value| try object.field("plot_kind", value);
 645     try writePlotFields(object, row);
 646     try writeLabels(object, row);
 647 }
 648 
 649 fn writeLabels(object: pretty_json.Object, row: Row) !void {
 650     if (row.lock_kind) |value| try object.field("lock_kind", value);
 651     if (row.sample_kind) |value| try object.field("sample_kind", value);
 652     if (row.context_reason) |value| try object.field("context_reason", value);
 653     if (row.context_state) |value| try object.field("context_state", value);
 654 }
 655 
 656 fn rowMatches(row: Row, options: Options) bool {
 657     if (options.kind != .all and row.kind != options.kind) return false;
 658     if (options.thread) |thread| {
 659         if (row.thread != thread) return false;
 660     }
 661     if (row.kind == .zone and row.duration_ns < options.min_duration_ns) return false;
 662     if (options.since_ns) |since_ns| {
 663         const row_end = if (row.kind == .zone) row.end_ns else row.time_ns;
 664         if (row_end < since_ns) return false;
 665     }
 666     if (options.until_ns) |until_ns| {
 667         if (row.time_ns > until_ns) return false;
 668     }
 669     if (options.match) |needle| {
 670         if (!rowContains(row, needle)) return false;
 671     }
 672     return true;
 673 }
 674 
 675 fn rowContains(row: Row, needle: []const u8) bool {
 676     if (contains(row.kind.tag(), needle)) return true;
 677     if (contains(row.event, needle)) return true;
 678     if (row.name) |name| if (contains(name, needle)) return true;
 679     if (row.text) |text| if (contains(text, needle)) return true;
 680     if (row.file) |file| if (contains(file, needle)) return true;
 681     if (row.function) |function| if (contains(function, needle)) return true;
 682     if (row.plot_kind) |plot_kind| if (contains(plot_kind, needle)) return true;
 683     if (row.plot_unit) |plot_unit| if (contains(plot_unit, needle)) return true;
 684     if (row.lock_kind) |lock_kind| if (contains(lock_kind, needle)) return true;
 685     if (row.sample_kind) |sample_kind| if (contains(sample_kind, needle)) return true;
 686     if (row.gpu_context_type) |context_type| if (contains(context_type, needle)) return true;
 687     if (row.hw_sample_kind) |sample_kind| if (contains(sample_kind, needle)) return true;
 688     if (row.context_reason) |context_reason| if (contains(context_reason, needle)) return true;
 689     if (row.context_state) |context_state| if (contains(context_state, needle)) return true;
 690     return false;
 691 }
 692 
 693 fn contains(haystack: []const u8, needle: []const u8) bool {
 694     return std.mem.indexOf(u8, haystack, needle) != null;
 695 }
 696 
 697 fn instantRow(instant: Instant) Row {
 698     return .{
 699         .kind = instant.kind,
 700         .event = instant.event,
 701         .time_ns = instant.time_ns,
 702         .thread = instant.thread,
 703         .id = instant.id,
 704         .name = instant.name,
 705         .text = instant.text,
 706         .file = instant.file,
 707         .function = instant.function,
 708         .line = instant.line,
 709         .column = instant.column,
 710         .color = instant.color,
 711         .value_u64 = instant.value_u64,
 712         .value_i64 = instant.value_i64,
 713         .value_f64 = instant.value_f64,
 714         .address = instant.address,
 715         .size = instant.size,
 716         .sample_depth = instant.depth,
 717         .cpu = instant.cpu,
 718         .prev_thread = instant.prev_thread,
 719         .next_thread = instant.next_thread,
 720         .target_thread = instant.target_thread,
 721         .pid = instant.pid,
 722         .previous_cstate = instant.previous_cstate,
 723         .prev_priority = instant.prev_priority,
 724         .next_priority = instant.next_priority,
 725         .group_hint = instant.group_hint,
 726         .gpu_context = instant.gpu_context,
 727         .gpu_query = instant.gpu_query,
 728         .gpu_time = instant.gpu_time,
 729         .gpu_period = instant.gpu_period,
 730         .gpu_cpu_delta_ns = instant.gpu_cpu_delta_ns,
 731         .gpu_context_type = instant.gpu_context_type,
 732         .gpu_has_calibration = instant.gpu_has_calibration,
 733         .gpu_serial = instant.gpu_serial,
 734         .gpu_annotation_id = instant.gpu_annotation_id,
 735         .cpu_package = instant.cpu_package,
 736         .cpu_die = instant.cpu_die,
 737         .cpu_core = instant.cpu_core,
 738         .power_delta_uj = instant.power_delta_uj,
 739         .hw_sample_kind = instant.hw_sample_kind,
 740         .plot_kind = instant.plot_kind,
 741         .plot_unit = instant.plot_unit,
 742         .plot_step = instant.plot_step,
 743         .plot_fill = instant.plot_fill,
 744         .lock_kind = instant.lock_kind,
 745         .sample_kind = instant.sample_kind,
 746         .context_reason = instant.context_reason,
 747         .context_state = instant.context_state,
 748     };
 749 }
 750 
 751 fn sortRows(items: []Row, sort: Sort) void {
 752     std.mem.sort(Row, items, sort, rowLessThan);
 753 }
 754 
 755 fn rowLessThan(sort: Sort, left: Row, right: Row) bool {
 756     return switch (sort) {
 757         .time => rowTimeLessThan({}, left, right),
 758         .duration => rowDurationGreaterThan({}, left, right),
 759     };
 760 }
 761 
 762 fn rowTimeLessThan(_: void, left: Row, right: Row) bool {
 763     if (left.time_ns != right.time_ns) return left.time_ns < right.time_ns;
 764     if (left.duration_ns != right.duration_ns) return left.duration_ns > right.duration_ns;
 765     return std.mem.lessThan(u8, left.event, right.event);
 766 }
 767 
 768 fn rowDurationGreaterThan(_: void, left: Row, right: Row) bool {
 769     if (left.duration_ns != right.duration_ns) return left.duration_ns > right.duration_ns;
 770     return rowTimeLessThan({}, left, right);
 771 }
 772 
 773 fn dupeOptional(allocator: std.mem.Allocator, text: ?[]const u8) !?[]u8 {
 774     const actual = text orelse return null;
 775     return try allocator.dupe(u8, actual);
 776 }
 777 
 778 fn writeValueText(writer: *std.Io.Writer, row: Row) !void {
 779     if (row.value_f64) |value| {
 780         try writer.print(" value={d}", .{value});
 781     } else if (row.value_i64) |value| {
 782         try writer.print(" value={d}", .{value});
 783     } else if (row.value_u64) |value| {
 784         try writer.print(" value={d}", .{value});
 785     }
 786 }
 787 
 788 fn writeValueField(object: pretty_json.Object, row: Row) !void {
 789     if (row.value_f64) |value| {
 790         try object.field("value", value);
 791     } else if (row.value_i64) |value| {
 792         try object.field("value", value);
 793     } else if (row.value_u64) |value| {
 794         try object.field("value", value);
 795     }
 796 }
 797 
 798 fn writePlotFieldsText(writer: *std.Io.Writer, row: Row) !void {
 799     if (row.plot_unit) |plot_unit| {
 800         try writer.writeAll(" plot_unit=");
 801         try pretty_json.writeString(writer, plot_unit);
 802     }
 803     if (std.mem.eql(u8, row.event, "plot.config")) {
 804         try writer.print(" plot_step={} plot_fill={}", .{ row.plot_step, row.plot_fill });
 805     }
 806 }
 807 
 808 fn writePlotFields(object: pretty_json.Object, row: Row) !void {
 809     if (row.plot_unit) |plot_unit| try object.field("plot_unit", plot_unit);
 810     if (std.mem.eql(u8, row.event, "plot.config")) {
 811         try object.field("plot_step", row.plot_step);
 812         try object.field("plot_fill", row.plot_fill);
 813     }
 814 }
 815 
 816 fn writeGpuFieldsText(writer: *std.Io.Writer, row: Row) !void {
 817     if (row.gpu_context) |context| try writer.print(" gpu_context={d}", .{context});
 818     if (row.gpu_query) |query| try writer.print(" gpu_query={d}", .{query});
 819     if (row.gpu_time) |gpu_time| try writer.print(" gpu_time={d}", .{gpu_time});
 820     if (row.gpu_period) |period| try writer.print(" gpu_period={d}", .{period});
 821     if (row.gpu_cpu_delta_ns) |delta| try writer.print(" gpu_cpu_delta_ns={d}", .{delta});
 822     if (row.gpu_context_type) |context_type| {
 823         try writer.writeAll(" gpu_context_type=");
 824         try pretty_json.writeString(writer, context_type);
 825     }
 826     if (row.gpu_has_calibration) try writer.writeAll(" gpu_has_calibration=true");
 827     if (row.gpu_serial) try writer.writeAll(" gpu_serial=true");
 828     if (row.gpu_annotation_id) |annotation_id| try writer.print(" gpu_annotation_id={d}", .{annotation_id});
 829 }
 830 
 831 fn writeGpuFields(object: pretty_json.Object, row: Row) !void {
 832     if (row.gpu_context) |context| try object.field("gpu_context", context);
 833     if (row.gpu_query) |query| try object.field("gpu_query", query);
 834     if (row.gpu_time) |gpu_time| try object.field("gpu_time", gpu_time);
 835     if (row.gpu_period) |period| try object.field("gpu_period", period);
 836     if (row.gpu_cpu_delta_ns) |delta| try object.field("gpu_cpu_delta_ns", delta);
 837     if (row.gpu_context_type) |context_type| try object.field("gpu_context_type", context_type);
 838     if (row.gpu_has_calibration) try object.field("gpu_has_calibration", true);
 839     if (row.gpu_serial) try object.field("gpu_serial", true);
 840     if (row.gpu_annotation_id) |annotation_id| try object.field("gpu_annotation_id", annotation_id);
 841 }
 842 
 843 fn writeSystemFieldsText(writer: *std.Io.Writer, row: Row) !void {
 844     if (row.cpu_package) |package| try writer.print(" cpu_package={d}", .{package});
 845     if (row.cpu_die) |die| try writer.print(" cpu_die={d}", .{die});
 846     if (row.cpu_core) |core| try writer.print(" cpu_core={d}", .{core});
 847     if (row.power_delta_uj) |delta| try writer.print(" power_delta_uj={d}", .{delta});
 848     if (row.hw_sample_kind) |kind| {
 849         try writer.writeAll(" hw_sample_kind=");
 850         try pretty_json.writeString(writer, kind);
 851     }
 852 }
 853 
 854 fn writeSystemFields(object: pretty_json.Object, row: Row) !void {
 855     if (row.cpu_package) |package| try object.field("cpu_package", package);
 856     if (row.cpu_die) |die| try object.field("cpu_die", die);
 857     if (row.cpu_core) |core| try object.field("cpu_core", core);
 858     if (row.power_delta_uj) |delta| try object.field("power_delta_uj", delta);
 859     if (row.hw_sample_kind) |kind| try object.field("hw_sample_kind", kind);
 860 }
 861 
 862 test "timeline combines spans and instant events by time" {
 863     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
 864     defer trace.deinit();
 865     try (event.TraceEvent{ .seq = 1, .kind = .start, .time_ns = 90, .thread = 1, .name = "test" }).writeJsonLine(&trace.writer);
 866     try (event.TraceEvent{ .seq = 2, .kind = .thread_name, .time_ns = 95, .thread = 1, .name = "main" }).writeJsonLine(&trace.writer);
 867     try (event.TraceEvent{ .seq = 3, .kind = .zone_begin, .time_ns = 100, .thread = 1, .id = 1, .name = "root", .file = "root.zig", .line = 7 }).writeJsonLine(&trace.writer);
 868     try (event.TraceEvent{ .seq = 4, .kind = .zone_begin, .time_ns = 120, .thread = 1, .id = 2, .name = "child" }).writeJsonLine(&trace.writer);
 869     try (event.TraceEvent{ .seq = 5, .kind = .message, .time_ns = 125, .thread = 1, .text = "inside child" }).writeJsonLine(&trace.writer);
 870     try (event.TraceEvent{ .seq = 6, .kind = .zone_text, .time_ns = 130, .thread = 1, .id = 2, .text = "child detail" }).writeJsonLine(&trace.writer);
 871     try (event.TraceEvent{ .seq = 7, .kind = .zone_end, .time_ns = 150, .thread = 1, .id = 2 }).writeJsonLine(&trace.writer);
 872     try (event.TraceEvent{ .seq = 8, .kind = .plot, .time_ns = 155, .thread = 1, .name = "latency.ns", .value_i64 = 30, .plot_kind = "int" }).writeJsonLine(&trace.writer);
 873     try (event.TraceEvent{ .seq = 9, .kind = .alloc, .time_ns = 170, .thread = 1, .name = "arena", .address = 4096, .size = 64 }).writeJsonLine(&trace.writer);
 874     try (event.TraceEvent{ .seq = 10, .kind = .zone_end, .time_ns = 210, .thread = 1, .id = 1 }).writeJsonLine(&trace.writer);
 875     try (event.TraceEvent{ .seq = 11, .kind = .stop, .time_ns = 220, .thread = 1 }).writeJsonLine(&trace.writer);
 876 
 877     var analyzer = Analyzer.init(std.testing.allocator);
 878     defer analyzer.deinit();
 879     try analyzer.ingestJsonlBytes(trace.written());
 880 
 881     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
 882     defer out.deinit();
 883     try writeText(std.testing.allocator, &analyzer, &out.writer, .{ .top = 8, .sort = .time });
 884     const text = out.written();
 885     try std.testing.expect(std.mem.indexOf(u8, text, "tracy timeline rows=7 events=11 zones=2 completed_zones=2") != null);
 886     try std.testing.expect(std.mem.indexOf(u8, text, "timeline kind=zone event=zone time_ns=100 end_ns=210 duration_ns=110 state=complete depth=0 thread=1 id=1 name=\"root\"") != null);
 887     try std.testing.expect(std.mem.indexOf(u8, text, "timeline kind=zone event=zone time_ns=120 end_ns=150 duration_ns=30 state=complete depth=1 thread=1 id=2 name=\"child\" text=\"child detail\"") != null);
 888     try std.testing.expect(std.mem.indexOf(u8, text, "timeline kind=message event=message time_ns=125 thread=1 text=\"inside child\"") != null);
 889     try std.testing.expect(std.mem.indexOf(u8, text, "timeline kind=plot event=plot time_ns=155 thread=1 name=\"latency.ns\" value=30 plot_kind=\"int\"") != null);
 890     try std.testing.expect(std.mem.indexOf(u8, text, "timeline kind=alloc event=alloc time_ns=170 thread=1 name=\"arena\" address=4096 size=64") != null);
 891 }
 892 
 893 test "timeline jsonl filters by window kind and match text" {
 894     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
 895     defer trace.deinit();
 896     try (event.TraceEvent{ .seq = 1, .kind = .zone_begin, .time_ns = 100, .thread = 1, .id = 1, .name = "root" }).writeJsonLine(&trace.writer);
 897     try (event.TraceEvent{ .seq = 2, .kind = .message, .time_ns = 120, .thread = 1, .text = "queue ready" }).writeJsonLine(&trace.writer);
 898     try (event.TraceEvent{ .seq = 3, .kind = .message, .time_ns = 180, .thread = 2, .text = "other thread" }).writeJsonLine(&trace.writer);
 899     try (event.TraceEvent{ .seq = 4, .kind = .zone_text, .time_ns = 190, .thread = 1, .id = 1, .text = "root detail" }).writeJsonLine(&trace.writer);
 900     try (event.TraceEvent{ .seq = 5, .kind = .zone_end, .time_ns = 240, .thread = 1, .id = 1 }).writeJsonLine(&trace.writer);
 901 
 902     var analyzer = Analyzer.init(std.testing.allocator);
 903     defer analyzer.deinit();
 904     try analyzer.ingestJsonlBytes(trace.written());
 905 
 906     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
 907     defer out.deinit();
 908     try writeJsonl(std.testing.allocator, &analyzer, &out.writer, .{ .top = 4, .kind = .message, .since_ns = 110, .until_ns = 140, .match = "queue" });
 909     const text = out.written();
 910     try std.testing.expect(std.mem.indexOf(u8, text, "\"schema\":\"tracy.timeline/v0\"") != null);
 911     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"summary\",\"rows\":1") != null);
 912     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"message\"") != null);
 913     try std.testing.expect(std.mem.indexOf(u8, text, "\"text\":\"queue ready\"") != null);
 914     try std.testing.expect(std.mem.indexOf(u8, text, "\"other thread\"") == null);
 915 }
 916 
 917 test "timeline includes sample instants and frames" {
 918     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
 919     defer trace.deinit();
 920     try (event.TraceEvent{ .seq = 1, .kind = .sample, .time_ns = 100, .thread = 1, .id = 9, .name = "tick", .sample_kind = "cpu", .file = "hot.zig", .line = 4 }).writeJsonLine(&trace.writer);
 921     try (event.TraceEvent{ .seq = 2, .kind = .sample_frame, .time_ns = 100, .thread = 1, .id = 9, .depth = 1, .name = "hot", .sample_kind = "cpu" }).writeJsonLine(&trace.writer);
 922 
 923     var analyzer = Analyzer.init(std.testing.allocator);
 924     defer analyzer.deinit();
 925     try analyzer.ingestJsonlBytes(trace.written());
 926 
 927     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
 928     defer out.deinit();
 929     try writeText(std.testing.allocator, &analyzer, &out.writer, .{ .top = 4, .kind = .sample });
 930     const text = out.written();
 931     try std.testing.expect(std.mem.indexOf(u8, text, "tracy timeline rows=2 events=2") != null);
 932     try std.testing.expect(std.mem.indexOf(u8, text, "timeline kind=sample event=sample time_ns=100 thread=1 id=9 name=\"tick\" file=hot.zig:4 sample_kind=\"cpu\"") != null);
 933     try std.testing.expect(std.mem.indexOf(u8, text, "timeline kind=sample event=sample.frame time_ns=100 thread=1 id=9 name=\"hot\" depth=1 sample_kind=\"cpu\"") != null);
 934 }
 935 
 936 test "timeline includes context switches and wakeups" {
 937     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
 938     defer trace.deinit();
 939     try (event.TraceEvent{ .seq = 1, .kind = .context_switch, .time_ns = 100, .thread = 1, .cpu = 0, .prev_thread = 10, .next_thread = 11, .context_reason = "WrPreempted", .context_state = "Ready" }).writeJsonLine(&trace.writer);
 940     try (event.TraceEvent{ .seq = 2, .kind = .thread_wakeup, .time_ns = 120, .thread = 1, .target_thread = 10, .cpu = 0, .context_reason = "Unwait" }).writeJsonLine(&trace.writer);
 941 
 942     var analyzer = Analyzer.init(std.testing.allocator);
 943     defer analyzer.deinit();
 944     try analyzer.ingestJsonlBytes(trace.written());
 945 
 946     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
 947     defer out.deinit();
 948     try writeText(std.testing.allocator, &analyzer, &out.writer, .{ .top = 4, .kind = .context });
 949     const text = out.written();
 950     try std.testing.expect(std.mem.indexOf(u8, text, "tracy timeline rows=2 events=2") != null);
 951     try std.testing.expect(std.mem.indexOf(u8, text, "timeline kind=context event=context.switch time_ns=100 thread=1 cpu=0 prev_thread=10 next_thread=11 context_reason=\"WrPreempted\" context_state=\"Ready\"") != null);
 952     try std.testing.expect(std.mem.indexOf(u8, text, "timeline kind=context event=thread.wakeup time_ns=120 thread=1 cpu=0 target_thread=10 context_reason=\"Unwait\"") != null);
 953 }
 954 
 955 test "timeline includes fiber instants" {
 956     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
 957     defer trace.deinit();
 958     try (event.TraceEvent{ .seq = 1, .kind = .fiber_name, .time_ns = 90, .thread = 10, .id = 7, .name = "job.parse", .group_hint = 3 }).writeJsonLine(&trace.writer);
 959     try (event.TraceEvent{ .seq = 2, .kind = .fiber_enter, .time_ns = 100, .thread = 10, .id = 7, .name = "job.parse", .group_hint = 3 }).writeJsonLine(&trace.writer);
 960     try (event.TraceEvent{ .seq = 3, .kind = .fiber_leave, .time_ns = 140, .thread = 10, .id = 7 }).writeJsonLine(&trace.writer);
 961 
 962     var analyzer = Analyzer.init(std.testing.allocator);
 963     defer analyzer.deinit();
 964     try analyzer.ingestJsonlBytes(trace.written());
 965 
 966     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
 967     defer out.deinit();
 968     try writeText(std.testing.allocator, &analyzer, &out.writer, .{ .top = 4, .kind = .fiber });
 969     const text = out.written();
 970     try std.testing.expect(std.mem.indexOf(u8, text, "tracy timeline rows=3 events=3") != null);
 971     try std.testing.expect(std.mem.indexOf(u8, text, "timeline kind=fiber event=fiber.name time_ns=90 thread=10 id=7 name=\"job.parse\" group_hint=3") != null);
 972     try std.testing.expect(std.mem.indexOf(u8, text, "timeline kind=fiber event=fiber.enter time_ns=100 thread=10 id=7 name=\"job.parse\" group_hint=3") != null);
 973     try std.testing.expect(std.mem.indexOf(u8, text, "timeline kind=fiber event=fiber.leave time_ns=140 thread=10 id=7") != null);
 974 }
 975 
 976 test "timeline includes gpu instants" {
 977     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
 978     defer trace.deinit();
 979     try (event.TraceEvent{ .seq = 1, .kind = .gpu_context, .time_ns = 90, .thread = 10, .gpu_context = 2, .name = "render", .gpu_period = 1.0, .gpu_context_type = "vulkan" }).writeJsonLine(&trace.writer);
 980     try (event.TraceEvent{ .seq = 2, .kind = .gpu_zone_begin, .time_ns = 100, .thread = 10, .gpu_context = 2, .gpu_query = 11, .name = "draw" }).writeJsonLine(&trace.writer);
 981     try (event.TraceEvent{ .seq = 3, .kind = .gpu_time, .time_ns = 140, .thread = 10, .gpu_context = 2, .gpu_query = 11, .gpu_time = 55 }).writeJsonLine(&trace.writer);
 982 
 983     var analyzer = Analyzer.init(std.testing.allocator);
 984     defer analyzer.deinit();
 985     try analyzer.ingestJsonlBytes(trace.written());
 986 
 987     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
 988     defer out.deinit();
 989     try writeText(std.testing.allocator, &analyzer, &out.writer, .{ .top = 4, .kind = .gpu });
 990     const text = out.written();
 991     try std.testing.expect(std.mem.indexOf(u8, text, "tracy timeline rows=3 events=3") != null);
 992     try std.testing.expect(std.mem.indexOf(u8, text, "timeline kind=gpu event=gpu.context time_ns=90 thread=10 name=\"render\" gpu_context=2 gpu_period=1 gpu_context_type=\"vulkan\"") != null);
 993     try std.testing.expect(std.mem.indexOf(u8, text, "timeline kind=gpu event=gpu.zone.begin time_ns=100 thread=10 name=\"draw\" gpu_context=2 gpu_query=11") != null);
 994     try std.testing.expect(std.mem.indexOf(u8, text, "timeline kind=gpu event=gpu.time time_ns=140 thread=10 gpu_context=2 gpu_query=11 gpu_time=55") != null);
 995 }
 996 
 997 test "timeline includes system instants" {
 998     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
 999     defer trace.deinit();
1000     try (event.TraceEvent{ .seq = 1, .kind = .thread_pid, .time_ns = 90, .thread = 42, .pid = 900 }).writeJsonLine(&trace.writer);
1001     try (event.TraceEvent{ .seq = 2, .kind = .cpu_topology, .time_ns = 100, .thread = 1, .cpu = 7, .cpu_package = 1, .cpu_die = 0, .cpu_core = 3 }).writeJsonLine(&trace.writer);
1002     try (event.TraceEvent{ .seq = 3, .kind = .hw_sample, .time_ns = 110, .thread = 42, .cpu = 7, .address = 0xabc, .hw_sample_kind = "cache-miss" }).writeJsonLine(&trace.writer);
1003 
1004     var analyzer = Analyzer.init(std.testing.allocator);
1005     defer analyzer.deinit();
1006     try analyzer.ingestJsonlBytes(trace.written());
1007 
1008     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
1009     defer out.deinit();
1010     try writeText(std.testing.allocator, &analyzer, &out.writer, .{ .top = 4, .kind = .system });
1011     const text = out.written();
1012     try std.testing.expect(std.mem.indexOf(u8, text, "tracy timeline rows=3 events=3") != null);
1013     try std.testing.expect(std.mem.indexOf(u8, text, "timeline kind=system event=thread.pid time_ns=90 thread=42 pid=900") != null);
1014     try std.testing.expect(std.mem.indexOf(u8, text, "timeline kind=system event=cpu.topology time_ns=100 thread=1 cpu=7 cpu_package=1 cpu_die=0 cpu_core=3") != null);
1015     try std.testing.expect(std.mem.indexOf(u8, text, "timeline kind=system event=hw.sample time_ns=110 thread=42 address=2748 cpu=7 hw_sample_kind=\"cache-miss\"") != null);
1016 }
1017 
1018 test "timeline preserves plot configuration metadata" {
1019     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
1020     defer trace.deinit();
1021     try (event.TraceEvent{
1022         .seq = 1,
1023         .kind = .plot_config,
1024         .time_ns = 100,
1025         .thread = 1,
1026         .name = "heap.live",
1027         .color = 7,
1028         .plot_unit = "bytes",
1029         .plot_step = true,
1030         .plot_fill = true,
1031     }).writeJsonLine(&trace.writer);
1032 
1033     var analyzer = Analyzer.init(std.testing.allocator);
1034     defer analyzer.deinit();
1035     try analyzer.ingestJsonlBytes(trace.written());
1036 
1037     var text_out = std.Io.Writer.Allocating.init(std.testing.allocator);
1038     defer text_out.deinit();
1039     try writeText(std.testing.allocator, &analyzer, &text_out.writer, .{ .kind = .plot });
1040     const text_metadata =
1041         "name=\"heap.live\" color=7 plot_unit=\"bytes\" plot_step=true plot_fill=true";
1042     try std.testing.expect(std.mem.indexOf(u8, text_out.written(), text_metadata) != null);
1043 
1044     var json_out = std.Io.Writer.Allocating.init(std.testing.allocator);
1045     defer json_out.deinit();
1046     try writeJsonl(std.testing.allocator, &analyzer, &json_out.writer, .{ .kind = .plot });
1047     const json = json_out.written();
1048     try std.testing.expect(std.mem.indexOf(u8, json, "\"event\":\"plot.config\"") != null);
1049     try std.testing.expect(std.mem.indexOf(u8, json, "\"plot_unit\":\"bytes\"") != null);
1050     try std.testing.expect(std.mem.indexOf(u8, json, "\"plot_step\":true") != null);
1051     try std.testing.expect(std.mem.indexOf(u8, json, "\"plot_fill\":true") != null);
1052 }