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 }