lib/tracy/src/plot.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.plots/v0";
 10 pub const CaptureIntegrity = capture_mod.Integrity;
 11 
 12 pub const Sort = enum {
 13     samples,
 14     max,
 15     min,
 16     mean,
 17     range,
 18     last,
 19     name,
 20 
 21     pub fn fromName(text: []const u8) ?Sort {
 22         if (std.mem.eql(u8, text, "samples")) return .samples;
 23         if (std.mem.eql(u8, text, "max")) return .max;
 24         if (std.mem.eql(u8, text, "min")) return .min;
 25         if (std.mem.eql(u8, text, "mean")) return .mean;
 26         if (std.mem.eql(u8, text, "range")) return .range;
 27         if (std.mem.eql(u8, text, "last")) return .last;
 28         if (std.mem.eql(u8, text, "name")) return .name;
 29         return null;
 30     }
 31 
 32     fn tag(self: Sort) []const u8 {
 33         return switch (self) {
 34             .samples => "samples",
 35             .max => "max",
 36             .min => "min",
 37             .mean => "mean",
 38             .range => "range",
 39             .last => "last",
 40             .name => "name",
 41         };
 42     }
 43 };
 44 
 45 pub const Options = struct {
 46     top: usize = 20,
 47     sort: Sort = .samples,
 48     min_samples: u64 = 0,
 49     name: ?[]const u8 = null,
 50 };
 51 
 52 pub const Counters = struct {
 53     events: u64 = 0,
 54     samples: u64 = 0,
 55     series: u64 = 0,
 56     configurations: u64 = 0,
 57     configuration_conflicts: u64 = 0,
 58     ignored_missing_values: u64 = 0,
 59     ignored_nonfinite: u64 = 0,
 60 };
 61 
 62 pub const Summary = struct {
 63     name: []const u8,
 64     kind: ?[]const u8 = null,
 65     unit: ?[]const u8 = null,
 66     configured: bool = false,
 67     step: bool = false,
 68     fill: bool = false,
 69     color: ?u32 = null,
 70     configurations: u64 = 0,
 71     configuration_conflicts: u64 = 0,
 72     count: u64 = 0,
 73     min: f64 = 0,
 74     p50: f64 = 0,
 75     p90: f64 = 0,
 76     p99: f64 = 0,
 77     max: f64 = 0,
 78     mean: f64 = 0,
 79     first: f64 = 0,
 80     last: f64 = 0,
 81     delta: f64 = 0,
 82     range: f64 = 0,
 83     first_ns: u64 = 0,
 84     last_ns: u64 = 0,
 85     duration_ns: u64 = 0,
 86     threads: u64 = 0,
 87 };
 88 
 89 const Sample = struct {
 90     time_ns: u64,
 91     thread: u64,
 92     value: f64,
 93 };
 94 
 95 const Series = struct {
 96     name: []const u8,
 97     kind: ?[]u8 = null,
 98     unit: ?[]u8 = null,
 99     configured: bool = false,
100     step: bool = false,
101     fill: bool = false,
102     color: ?u32 = null,
103     configurations: u64 = 0,
104     configuration_conflicts: u64 = 0,
105     samples: std.ArrayListUnmanaged(Sample) = .empty,
106     threads: std.ArrayListUnmanaged(u64) = .empty,
107     sum: f64 = 0,
108     min: f64 = std.math.inf(f64),
109     max: f64 = -std.math.inf(f64),
110     first: f64 = 0,
111     last: f64 = 0,
112     first_ns: u64 = 0,
113     last_ns: u64 = 0,
114 
115     fn deinit(self: *Series, allocator: std.mem.Allocator) void {
116         if (self.kind) |kind| allocator.free(kind);
117         if (self.unit) |unit| allocator.free(unit);
118         self.samples.deinit(allocator);
119         self.threads.deinit(allocator);
120         self.* = undefined;
121     }
122 
123     fn count(self: Series) u64 {
124         return @intCast(self.samples.items.len);
125     }
126 
127     fn mean(self: Series) f64 {
128         if (self.samples.items.len == 0) return 0;
129         return self.sum / @as(f64, @floatFromInt(self.samples.items.len));
130     }
131 
132     fn range(self: Series) f64 {
133         if (self.samples.items.len == 0) return 0;
134         return self.max - self.min;
135     }
136 
137     fn delta(self: Series) f64 {
138         if (self.samples.items.len == 0) return 0;
139         return self.last - self.first;
140     }
141 
142     fn durationNs(self: Series) u64 {
143         if (self.last_ns <= self.first_ns) return 0;
144         return self.last_ns - self.first_ns;
145     }
146 };
147 
148 const Spike = struct {
149     name: []const u8,
150     index: u64,
151     time_ns: u64,
152     thread: u64,
153     value: f64,
154     delta: f64,
155     abs_delta: f64,
156 };
157 
158 pub const Analyzer = struct {
159     allocator: std.mem.Allocator,
160     capture: capture_mod.Tracker = .{},
161     series: std.StringHashMapUnmanaged(Series) = .{},
162     counters: Counters = .{},
163     start_ns: ?u64 = null,
164     end_ns: ?u64 = null,
165 
166     pub fn init(allocator: std.mem.Allocator) Analyzer {
167         return .{ .allocator = allocator };
168     }
169 
170     pub fn deinit(self: *Analyzer) void {
171         var iter = self.series.iterator();
172         while (iter.next()) |entry| {
173             self.allocator.free(entry.key_ptr.*);
174             entry.value_ptr.deinit(self.allocator);
175         }
176         self.series.deinit(self.allocator);
177         self.* = undefined;
178     }
179 
180     pub fn ingestJsonlBytes(self: *Analyzer, bytes: []const u8) !void {
181         var lines = std.mem.splitScalar(u8, bytes, '\n');
182         while (lines.next()) |line| try self.ingestJsonLine(line);
183     }
184 
185     pub fn ingestJsonLine(self: *Analyzer, line: []const u8) !void {
186         const text = std.mem.trim(u8, line, " \t\r\n");
187         if (text.len == 0) return;
188         var parsed = try record_mod.parseLine(self.allocator, text);
189         defer parsed.deinit();
190         switch (parsed) {
191             .event => |value| try self.ingest(value),
192             .flight => |report_value| self.recordFlightReport(report_value),
193         }
194     }
195 
196     pub fn ingest(self: *Analyzer, parsed: event.Parsed) !void {
197         self.capture.record(parsed);
198         self.counters.events += 1;
199         if (self.start_ns == null and parsed.time_ns != 0) self.start_ns = parsed.time_ns;
200         if (parsed.time_ns != 0) self.end_ns = parsed.time_ns;
201         switch (parsed.kind) {
202             .start => {
203                 if (parsed.time_ns != 0) self.start_ns = parsed.time_ns;
204             },
205             .stop => {
206                 if (parsed.time_ns != 0) self.end_ns = parsed.time_ns;
207             },
208             .plot_config => try self.recordConfig(parsed),
209             .plot => try self.recordPlot(parsed),
210             else => {},
211         }
212     }
213 
214     pub fn collectSummaries(self: *Analyzer, allocator: std.mem.Allocator, options: Options) !std.ArrayListUnmanaged(Summary) {
215         var rows: std.ArrayListUnmanaged(Summary) = .empty;
216         errdefer rows.deinit(allocator);
217         var iter = self.series.valueIterator();
218         while (iter.next()) |item| {
219             if (!seriesMatches(item.*, options)) continue;
220             try rows.append(allocator, try summaryView(allocator, item.*));
221         }
222         sortSummaries(rows.items, options.sort);
223         return rows;
224     }
225 
226     fn collectSpikes(self: *Analyzer, allocator: std.mem.Allocator, options: Options) !std.ArrayListUnmanaged(Spike) {
227         var rows: std.ArrayListUnmanaged(Spike) = .empty;
228         errdefer rows.deinit(allocator);
229         var iter = self.series.valueIterator();
230         while (iter.next()) |item| {
231             if (!seriesMatches(item.*, options)) continue;
232             if (item.samples.items.len < 2) continue;
233             for (item.samples.items[1..], 1..) |sample, index| {
234                 const previous = item.samples.items[index - 1];
235                 const delta = sample.value - previous.value;
236                 try rows.append(allocator, .{
237                     .name = item.name,
238                     .index = @intCast(index + 1),
239                     .time_ns = sample.time_ns,
240                     .thread = sample.thread,
241                     .value = sample.value,
242                     .delta = delta,
243                     .abs_delta = absF64(delta),
244                 });
245             }
246         }
247         std.mem.sort(Spike, rows.items, {}, spikeGreaterThan);
248         return rows;
249     }
250 
251     pub fn durationNs(self: Analyzer) u64 {
252         const start_ns = self.start_ns orelse return 0;
253         const end_ns = self.end_ns orelse return 0;
254         if (end_ns <= start_ns) return 0;
255         return end_ns - start_ns;
256     }
257 
258     pub fn captureIntegrity(self: *const Analyzer) CaptureIntegrity {
259         return self.capture.integrity(0);
260     }
261 
262     pub fn recordFlightReport(self: *Analyzer, report_value: transport.Report) void {
263         self.capture.recordFlightReport(report_value);
264     }
265 
266     fn recordConfig(self: *Analyzer, parsed: event.Parsed) !void {
267         const item = try self.seriesFor(parsed.name orelse "<plot>");
268         const unit = parsed.plot_unit orelse "number";
269         if (!item.configured) {
270             const owned_unit = try self.allocator.dupe(u8, unit);
271             item.unit = owned_unit;
272             item.configured = true;
273             item.step = parsed.plot_step;
274             item.fill = parsed.plot_fill;
275             item.color = parsed.color;
276             item.configurations += 1;
277             self.counters.configurations += 1;
278             return;
279         }
280         if (!configMatches(item.*, unit, parsed)) {
281             item.configuration_conflicts += 1;
282             self.counters.configuration_conflicts += 1;
283         }
284         item.configurations += 1;
285         self.counters.configurations += 1;
286     }
287 
288     fn recordPlot(self: *Analyzer, parsed: event.Parsed) !void {
289         const value = plotValue(parsed) orelse {
290             self.counters.ignored_missing_values += 1;
291             return;
292         };
293         if (!std.math.isFinite(value)) {
294             self.counters.ignored_nonfinite += 1;
295             return;
296         }
297         const item = try self.seriesFor(parsed.name orelse "<plot>");
298         if (parsed.plot_kind) |kind| try self.recordKind(item, kind);
299         if (item.samples.items.len == 0) {
300             item.first = value;
301             item.first_ns = parsed.time_ns;
302         }
303         try item.samples.append(self.allocator, .{
304             .time_ns = parsed.time_ns,
305             .thread = parsed.thread,
306             .value = value,
307         });
308         try appendThread(self.allocator, &item.threads, parsed.thread);
309         item.sum += value;
310         item.min = @min(item.min, value);
311         item.max = @max(item.max, value);
312         item.last = value;
313         item.last_ns = parsed.time_ns;
314         self.counters.samples += 1;
315     }
316 
317     fn recordKind(self: *Analyzer, item: *Series, kind: []const u8) !void {
318         if (item.kind) |existing| {
319             if (std.mem.eql(u8, existing, kind)) return;
320             const owned_kind = try self.allocator.dupe(u8, kind);
321             self.allocator.free(existing);
322             item.kind = owned_kind;
323             return;
324         }
325         item.kind = try self.allocator.dupe(u8, kind);
326     }
327 
328     fn seriesFor(self: *Analyzer, name: []const u8) !*Series {
329         const entry = try self.series.getOrPut(self.allocator, name);
330         if (!entry.found_existing) {
331             errdefer _ = self.series.fetchRemove(name);
332             const owned_name = try self.allocator.dupe(u8, name);
333             entry.key_ptr.* = owned_name;
334             entry.value_ptr.* = .{ .name = owned_name };
335             self.counters.series += 1;
336         }
337         return entry.value_ptr;
338     }
339 };
340 
341 pub fn writeTextFromJsonlPath(
342     allocator: std.mem.Allocator,
343     path: []const u8,
344     writer: *std.Io.Writer,
345     options: Options,
346 ) !void {
347     return report.writeFromJsonlPath(Analyzer, writeText, allocator, path, writer, options);
348 }
349 
350 pub fn writeJsonlFromJsonlPath(
351     allocator: std.mem.Allocator,
352     path: []const u8,
353     writer: *std.Io.Writer,
354     options: Options,
355 ) !void {
356     return report.writeFromJsonlPath(Analyzer, writeJsonl, allocator, path, writer, options);
357 }
358 
359 pub fn ingestPath(analyzer: *Analyzer, path: []const u8) !void {
360     return report.ingestJsonlPath(analyzer, path);
361 }
362 
363 fn writeText(
364     allocator: std.mem.Allocator,
365     analyzer: *Analyzer,
366     writer: *std.Io.Writer,
367     options: Options,
368 ) !void {
369     var summaries = try analyzer.collectSummaries(allocator, options);
370     defer summaries.deinit(allocator);
371     var spikes = try analyzer.collectSpikes(allocator, options);
372     defer spikes.deinit(allocator);
373 
374     try writer.print(
375         "tracy plots series={d} samples={d} configurations={d} " ++
376             "configuration_conflicts={d} ignored_missing_values={d} " ++
377             "ignored_nonfinite={d} duration_ns={d} sort={s}\n",
378         .{
379             summaries.items.len,
380             analyzer.counters.samples,
381             analyzer.counters.configurations,
382             analyzer.counters.configuration_conflicts,
383             analyzer.counters.ignored_missing_values,
384             analyzer.counters.ignored_nonfinite,
385             analyzer.durationNs(),
386             options.sort.tag(),
387         },
388     );
389     try capture_mod.writeText(writer, analyzer.captureIntegrity());
390     const summary_limit = @min(options.top, summaries.items.len);
391     for (summaries.items[0..summary_limit]) |summary| try writeSummaryText(writer, summary);
392     const spike_limit = @min(options.top, spikes.items.len);
393     for (spikes.items[0..spike_limit]) |spike| {
394         try writer.writeAll("plot-spike name=");
395         try pretty_json.writeString(writer, spike.name);
396         try writer.print(
397             " index={d} time_ns={d} thread={d} value={d} delta={d} abs_delta={d}\n",
398             .{ spike.index, spike.time_ns, spike.thread, spike.value, spike.delta, spike.abs_delta },
399         );
400     }
401 }
402 
403 fn writeJsonl(
404     allocator: std.mem.Allocator,
405     analyzer: *Analyzer,
406     writer: *std.Io.Writer,
407     options: Options,
408 ) !void {
409     var summaries = try analyzer.collectSummaries(allocator, options);
410     defer summaries.deinit(allocator);
411     var spikes = try analyzer.collectSpikes(allocator, options);
412     defer spikes.deinit(allocator);
413 
414     var summary_stream = pretty_json.Writer.init(writer, .minified);
415     const summary_record = try summary_stream.object();
416     try summary_record.field("schema", schema);
417     try summary_record.field("kind", "summary");
418     try summary_record.field("series", summaries.items.len);
419     try summary_record.field("samples", analyzer.counters.samples);
420     try summary_record.field("configurations", analyzer.counters.configurations);
421     try summary_record.field("configuration_conflicts", analyzer.counters.configuration_conflicts);
422     try summary_record.field("ignored_missing_values", analyzer.counters.ignored_missing_values);
423     try summary_record.field("ignored_nonfinite", analyzer.counters.ignored_nonfinite);
424     try summary_record.field("duration_ns", analyzer.durationNs());
425     try summary_record.field("sort", options.sort.tag());
426     try capture_mod.writeFields(summary_record, analyzer.captureIntegrity());
427     try summary_record.endLine();
428 
429     const summary_limit = @min(options.top, summaries.items.len);
430     for (summaries.items[0..summary_limit]) |summary| try writeSummaryJson(writer, summary);
431 
432     const spike_limit = @min(options.top, spikes.items.len);
433     for (spikes.items[0..spike_limit]) |spike| {
434         var stream = pretty_json.Writer.init(writer, .minified);
435         const object = try stream.object();
436         try object.field("schema", schema);
437         try object.field("kind", "spike");
438         try object.field("name", spike.name);
439         try object.field("index", spike.index);
440         try object.field("time_ns", spike.time_ns);
441         try object.field("thread", spike.thread);
442         try object.field("value", spike.value);
443         try object.field("delta", spike.delta);
444         try object.field("abs_delta", spike.abs_delta);
445         try object.endLine();
446     }
447 }
448 
449 fn writeSummaryText(writer: *std.Io.Writer, summary: Summary) !void {
450     try writer.writeAll("plot name=");
451     try pretty_json.writeString(writer, summary.name);
452     try writer.print(
453         " samples={d} min={d} p50={d} p90={d} p99={d} max={d} mean={d} " ++
454             "first={d} last={d} delta={d} range={d} first_ns={d} last_ns={d} " ++
455             "duration_ns={d} threads={d}",
456         .{
457             summary.count,   summary.min,         summary.p50,
458             summary.p90,     summary.p99,         summary.max,
459             summary.mean,    summary.first,       summary.last,
460             summary.delta,   summary.range,       summary.first_ns,
461             summary.last_ns, summary.duration_ns, summary.threads,
462         },
463     );
464     if (summary.kind) |kind| {
465         try writer.writeAll(" kind=");
466         try pretty_json.writeString(writer, kind);
467     }
468     try writeConfigText(writer, summary);
469     try writer.writeByte('\n');
470 }
471 
472 fn writeConfigText(writer: *std.Io.Writer, summary: Summary) !void {
473     try writer.print(
474         " configured={} configurations={d} configuration_conflicts={d} unit=",
475         .{ summary.configured, summary.configurations, summary.configuration_conflicts },
476     );
477     if (summary.unit) |unit| try pretty_json.writeString(writer, unit) else try writer.writeAll("none");
478     try writer.print(" step={} fill={} color=", .{ summary.step, summary.fill });
479     if (summary.color) |color| try writer.print("{d}", .{color}) else try writer.writeAll("none");
480 }
481 
482 fn writeSummaryJson(writer: *std.Io.Writer, summary: Summary) !void {
483     var stream = pretty_json.Writer.init(writer, .minified);
484     const object = try stream.object();
485     try object.field("schema", schema);
486     try object.field("kind", "plot");
487     try object.field("name", summary.name);
488     if (summary.kind) |kind| try object.field("plot_kind", kind);
489     try writeConfigFields(object, summary);
490     try object.field("samples", summary.count);
491     try object.field("min", summary.min);
492     try object.field("p50", summary.p50);
493     try object.field("p90", summary.p90);
494     try object.field("p99", summary.p99);
495     try object.field("max", summary.max);
496     try object.field("mean", summary.mean);
497     try object.field("first", summary.first);
498     try object.field("last", summary.last);
499     try object.field("delta", summary.delta);
500     try object.field("range", summary.range);
501     try object.field("first_ns", summary.first_ns);
502     try object.field("last_ns", summary.last_ns);
503     try object.field("duration_ns", summary.duration_ns);
504     try object.field("threads", summary.threads);
505     try object.endLine();
506 }
507 
508 fn writeConfigFields(object: pretty_json.Object, summary: Summary) !void {
509     try object.field("unit", summary.unit);
510     try object.field("configured", summary.configured);
511     try object.field("step", summary.step);
512     try object.field("fill", summary.fill);
513     try object.field("color", summary.color);
514     try object.field("configurations", summary.configurations);
515     try object.field("configuration_conflicts", summary.configuration_conflicts);
516 }
517 
518 fn seriesMatches(item: Series, options: Options) bool {
519     if (options.name) |wanted| {
520         if (!std.mem.eql(u8, wanted, item.name)) return false;
521     }
522     return item.count() >= options.min_samples;
523 }
524 
525 fn summaryView(allocator: std.mem.Allocator, item: Series) !Summary {
526     var values = try allocator.alloc(f64, item.samples.items.len);
527     defer allocator.free(values);
528     for (item.samples.items, 0..) |sample, index| values[index] = sample.value;
529     std.mem.sort(f64, values, {}, f64LessThan);
530     return .{
531         .name = item.name,
532         .kind = item.kind,
533         .unit = item.unit,
534         .configured = item.configured,
535         .step = item.step,
536         .fill = item.fill,
537         .color = item.color,
538         .configurations = item.configurations,
539         .configuration_conflicts = item.configuration_conflicts,
540         .count = item.count(),
541         .min = if (item.samples.items.len == 0) 0 else item.min,
542         .p50 = percentile(values, 50),
543         .p90 = percentile(values, 90),
544         .p99 = percentile(values, 99),
545         .max = if (item.samples.items.len == 0) 0 else item.max,
546         .mean = item.mean(),
547         .first = item.first,
548         .last = item.last,
549         .delta = item.delta(),
550         .range = item.range(),
551         .first_ns = item.first_ns,
552         .last_ns = item.last_ns,
553         .duration_ns = item.durationNs(),
554         .threads = @intCast(item.threads.items.len),
555     };
556 }
557 
558 fn configMatches(item: Series, unit: []const u8, parsed: event.Parsed) bool {
559     const existing_unit = item.unit orelse return false;
560     if (!std.mem.eql(u8, existing_unit, unit)) return false;
561     if (item.step != parsed.plot_step) return false;
562     if (item.fill != parsed.plot_fill) return false;
563     return item.color == parsed.color;
564 }
565 
566 fn percentile(sorted: []const f64, percent: u64) f64 {
567     if (sorted.len == 0) return 0;
568     const rank: usize = @intCast((@as(u128, percent) * sorted.len + 99) / 100);
569     const index = @min(@max(rank, 1) - 1, sorted.len - 1);
570     return sorted[index];
571 }
572 
573 fn plotValue(parsed: event.Parsed) ?f64 {
574     if (parsed.value_f64) |value| return value;
575     if (parsed.value_i64) |value| return @floatFromInt(value);
576     if (parsed.value_u64) |value| return @floatFromInt(value);
577     return null;
578 }
579 
580 fn appendThread(allocator: std.mem.Allocator, threads: *std.ArrayListUnmanaged(u64), thread: u64) !void {
581     for (threads.items) |existing| {
582         if (existing == thread) return;
583     }
584     try threads.append(allocator, thread);
585 }
586 
587 fn sortSummaries(items: []Summary, sort: Sort) void {
588     std.mem.sort(Summary, items, sort, summaryLessThan);
589 }
590 
591 fn summaryLessThan(sort: Sort, left: Summary, right: Summary) bool {
592     return switch (sort) {
593         .samples => summarySamplesGreaterThan({}, left, right),
594         .max => summaryMaxGreaterThan({}, left, right),
595         .min => summaryMinLessThan({}, left, right),
596         .mean => summaryMeanGreaterThan({}, left, right),
597         .range => summaryRangeGreaterThan({}, left, right),
598         .last => summaryLastGreaterThan({}, left, right),
599         .name => summaryNameLessThan({}, left, right),
600     };
601 }
602 
603 fn summarySamplesGreaterThan(_: void, left: Summary, right: Summary) bool {
604     if (left.count != right.count) return left.count > right.count;
605     return summaryNameLessThan({}, left, right);
606 }
607 
608 fn summaryMaxGreaterThan(_: void, left: Summary, right: Summary) bool {
609     if (left.max != right.max) return left.max > right.max;
610     return summaryNameLessThan({}, left, right);
611 }
612 
613 fn summaryMinLessThan(_: void, left: Summary, right: Summary) bool {
614     if (left.min != right.min) return left.min < right.min;
615     return summaryNameLessThan({}, left, right);
616 }
617 
618 fn summaryMeanGreaterThan(_: void, left: Summary, right: Summary) bool {
619     if (left.mean != right.mean) return left.mean > right.mean;
620     return summaryNameLessThan({}, left, right);
621 }
622 
623 fn summaryRangeGreaterThan(_: void, left: Summary, right: Summary) bool {
624     if (left.range != right.range) return left.range > right.range;
625     return summaryNameLessThan({}, left, right);
626 }
627 
628 fn summaryLastGreaterThan(_: void, left: Summary, right: Summary) bool {
629     if (left.last != right.last) return left.last > right.last;
630     return summaryNameLessThan({}, left, right);
631 }
632 
633 fn summaryNameLessThan(_: void, left: Summary, right: Summary) bool {
634     return std.mem.lessThan(u8, left.name, right.name);
635 }
636 
637 fn spikeGreaterThan(_: void, left: Spike, right: Spike) bool {
638     if (left.abs_delta != right.abs_delta) return left.abs_delta > right.abs_delta;
639     const name_cmp = std.mem.order(u8, left.name, right.name);
640     if (name_cmp != .eq) return name_cmp == .lt;
641     return left.index < right.index;
642 }
643 
644 fn f64LessThan(_: void, left: f64, right: f64) bool {
645     return left < right;
646 }
647 
648 fn absF64(value: f64) f64 {
649     if (value < 0) return -value;
650     return value;
651 }
652 
653 test "plots aggregate series percentiles and spikes" {
654     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
655     defer trace.deinit();
656     try (event.TraceEvent{ .seq = 1, .kind = .start, .time_ns = 90, .thread = 1, .name = "test" }).writeJsonLine(&trace.writer);
657     try (event.TraceEvent{ .seq = 2, .kind = .plot, .time_ns = 100, .thread = 1, .name = "latency.ns", .value_i64 = 10, .plot_kind = "int" }).writeJsonLine(&trace.writer);
658     try (event.TraceEvent{ .seq = 3, .kind = .plot, .time_ns = 110, .thread = 1, .name = "latency.ns", .value_i64 = 14, .plot_kind = "int" }).writeJsonLine(&trace.writer);
659     try (event.TraceEvent{ .seq = 4, .kind = .plot, .time_ns = 120, .thread = 2, .name = "queue.depth", .value_i64 = 3, .plot_kind = "int" }).writeJsonLine(&trace.writer);
660     try (event.TraceEvent{ .seq = 5, .kind = .plot, .time_ns = 130, .thread = 1, .name = "latency.ns", .value_i64 = 30, .plot_kind = "int" }).writeJsonLine(&trace.writer);
661     try (event.TraceEvent{ .seq = 6, .kind = .plot, .time_ns = 140, .thread = 1, .name = "latency.ns", .value_i64 = 22, .plot_kind = "int" }).writeJsonLine(&trace.writer);
662     try (event.TraceEvent{ .seq = 7, .kind = .plot, .time_ns = 150, .thread = 2, .name = "queue.depth", .value_i64 = 5, .plot_kind = "int" }).writeJsonLine(&trace.writer);
663     try (event.TraceEvent{ .seq = 8, .kind = .stop, .time_ns = 160, .thread = 1 }).writeJsonLine(&trace.writer);
664 
665     var analyzer = Analyzer.init(std.testing.allocator);
666     defer analyzer.deinit();
667     try analyzer.ingestJsonlBytes(trace.written());
668     try std.testing.expectEqual(@as(u64, 6), analyzer.counters.samples);
669     try std.testing.expectEqual(@as(u64, 2), analyzer.counters.series);
670 
671     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
672     defer out.deinit();
673     try writeText(std.testing.allocator, &analyzer, &out.writer, .{ .top = 4, .sort = .samples });
674     const text = out.written();
675     try std.testing.expect(std.mem.indexOf(u8, text, "tracy plots series=2 samples=6") != null);
676     try std.testing.expect(std.mem.indexOf(u8, text, "plot name=\"latency.ns\" samples=4 min=10") != null);
677     try std.testing.expect(std.mem.indexOf(u8, text, "p50=14") != null);
678     try std.testing.expect(std.mem.indexOf(u8, text, "p90=30") != null);
679     try std.testing.expect(std.mem.indexOf(u8, text, "mean=19") != null);
680     try std.testing.expect(std.mem.indexOf(u8, text, "delta=12 range=20") != null);
681     try std.testing.expect(std.mem.indexOf(u8, text, "plot-spike name=\"latency.ns\" index=3 time_ns=130 thread=1 value=30 delta=16 abs_delta=16") != null);
682 }
683 
684 test "plots jsonl filters series and emits spike rows" {
685     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
686     defer trace.deinit();
687     try (event.TraceEvent{ .seq = 1, .kind = .plot, .time_ns = 100, .thread = 1, .name = "latency.ns", .value_f64 = 1.5, .plot_kind = "float" }).writeJsonLine(&trace.writer);
688     try (event.TraceEvent{ .seq = 2, .kind = .plot, .time_ns = 110, .thread = 1, .name = "other", .value_i64 = 9, .plot_kind = "int" }).writeJsonLine(&trace.writer);
689     try (event.TraceEvent{ .seq = 3, .kind = .plot, .time_ns = 120, .thread = 1, .name = "latency.ns", .value_f64 = 5.5, .plot_kind = "float" }).writeJsonLine(&trace.writer);
690 
691     var analyzer = Analyzer.init(std.testing.allocator);
692     defer analyzer.deinit();
693     try analyzer.ingestJsonlBytes(trace.written());
694 
695     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
696     defer out.deinit();
697     try writeJsonl(std.testing.allocator, &analyzer, &out.writer, .{ .top = 2, .sort = .name, .min_samples = 2, .name = "latency.ns" });
698     const text = out.written();
699     try std.testing.expect(std.mem.indexOf(u8, text, "\"schema\":\"tracy.plots/v0\"") != null);
700     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"plot\"") != null);
701     try std.testing.expect(std.mem.indexOf(u8, text, "\"name\":\"latency.ns\"") != null);
702     try std.testing.expect(std.mem.indexOf(u8, text, "\"plot_kind\":\"float\"") != null);
703     try std.testing.expect(std.mem.indexOf(u8, text, "\"samples\":2") != null);
704     try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"spike\"") != null);
705     try std.testing.expect(std.mem.indexOf(u8, text, "\"delta\":4") != null);
706     try std.testing.expect(std.mem.indexOf(u8, text, "\"name\":\"other\"") == null);
707 }
708 
709 test "plots preserve first configuration and expose conflicts" {
710     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
711     defer trace.deinit();
712     try appendConfiguredTrace(&trace.writer);
713 
714     var analyzer = Analyzer.init(std.testing.allocator);
715     defer analyzer.deinit();
716     try analyzer.ingestJsonlBytes(trace.written());
717     try std.testing.expectEqual(@as(u64, 3), analyzer.counters.configurations);
718     try std.testing.expectEqual(@as(u64, 1), analyzer.counters.configuration_conflicts);
719 
720     var summaries = try analyzer.collectSummaries(std.testing.allocator, .{});
721     defer summaries.deinit(std.testing.allocator);
722     try std.testing.expectEqual(@as(usize, 1), summaries.items.len);
723     const summary = summaries.items[0];
724     try std.testing.expectEqualStrings("bytes", summary.unit.?);
725     try std.testing.expect(summary.configured);
726     try std.testing.expect(summary.step);
727     try std.testing.expect(summary.fill);
728     try std.testing.expectEqual(@as(?u32, 0x44aa88), summary.color);
729     try std.testing.expectEqual(@as(u64, 3), summary.configurations);
730     try std.testing.expectEqual(@as(u64, 1), summary.configuration_conflicts);
731 
732     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
733     defer out.deinit();
734     try writeJsonl(std.testing.allocator, &analyzer, &out.writer, .{});
735     const jsonl = out.written();
736     try std.testing.expect(std.mem.indexOf(u8, jsonl, "\"unit\":\"bytes\"") != null);
737     try std.testing.expect(std.mem.indexOf(u8, jsonl, "\"configured\":true") != null);
738     try std.testing.expect(std.mem.indexOf(u8, jsonl, "\"configuration_conflicts\":1") != null);
739 }
740 
741 test "plots retain flight reports and sequence integrity" {
742     var trace = std.Io.Writer.Allocating.init(std.testing.allocator);
743     defer trace.deinit();
744     try (event.TraceEvent{ .seq = 1, .kind = .start }).writeJsonLine(&trace.writer);
745     try (event.TraceEvent{ .seq = 3, .kind = .plot, .name = "load", .value_i64 = 1 })
746         .writeJsonLine(&trace.writer);
747     try (event.TraceEvent{ .seq = 4, .kind = .stop }).writeJsonLine(&trace.writer);
748     const flight_report = testFlightReport();
749     try flight_report.writeJsonl(&trace.writer);
750 
751     var analyzer = Analyzer.init(std.testing.allocator);
752     defer analyzer.deinit();
753     try analyzer.ingestJsonlBytes(trace.written());
754     const integrity = analyzer.captureIntegrity();
755     try std.testing.expectEqualStrings("sequence_gaps", integrity.status);
756     try std.testing.expectEqualDeep(flight_report, integrity.flight_report.?);
757 
758     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
759     defer out.deinit();
760     try writeJsonl(std.testing.allocator, &analyzer, &out.writer, .{});
761     try std.testing.expect(std.mem.indexOf(u8, out.written(), "\"flight_report\":{") != null);
762 }
763 
764 test "plots release configuration evidence on allocation failure" {
765     try std.testing.checkAllAllocationFailures(
766         std.testing.allocator,
767         analyzeConfiguredPlots,
768         .{},
769     );
770 }
771 
772 fn analyzeConfiguredPlots(allocator: std.mem.Allocator) !void {
773     var analyzer = Analyzer.init(allocator);
774     defer analyzer.deinit();
775     const trace =
776         "{\"v\":0,\"kind\":\"plot.config\",\"name\":\"heap\"," ++
777         "\"plot_unit\":\"bytes\",\"plot_step\":true}\n" ++
778         "{\"v\":0,\"kind\":\"plot\",\"name\":\"heap\",\"value_i64\":64," ++
779         "\"plot_kind\":\"int\"}\n";
780     try analyzer.ingestJsonlBytes(trace);
781     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
782     defer out.deinit();
783     try writeJsonl(allocator, &analyzer, &out.writer, .{});
784 }
785 
786 fn appendConfiguredTrace(writer: *std.Io.Writer) !void {
787     try (event.TraceEvent{ .seq = 1, .kind = .start }).writeJsonLine(writer);
788     const config = event.TraceEvent{
789         .seq = 2,
790         .kind = .plot_config,
791         .name = "heap",
792         .color = 0x44aa88,
793         .plot_unit = "bytes",
794         .plot_step = true,
795         .plot_fill = true,
796     };
797     try config.writeJsonLine(writer);
798     var repeated = config;
799     repeated.seq = 3;
800     try repeated.writeJsonLine(writer);
801     var conflicting = config;
802     conflicting.seq = 4;
803     conflicting.plot_unit = "nanoseconds";
804     try conflicting.writeJsonLine(writer);
805     try (event.TraceEvent{ .seq = 5, .kind = .plot, .name = "heap", .value_i64 = 64 })
806         .writeJsonLine(writer);
807     try (event.TraceEvent{ .seq = 6, .kind = .stop }).writeJsonLine(writer);
808 }
809 
810 fn testFlightReport() transport.Report {
811     return .{
812         .policy = .overwrite_oldest,
813         .state = .accepting,
814         .capacity_bytes = 64,
815         .retained_bytes = 32,
816         .event_capacity_bytes = 16,
817         .writer_capacity_bytes = 8,
818         .observed_events = 4,
819         .stored_events = 4,
820         .retained_events = 3,
821         .overwritten_events = 1,
822         .dropped_events = 0,
823         .oversized_events = 0,
824         .partial_event_bytes = 0,
825         .discarding_oversized_event = false,
826     };
827 }