lib/tracy/src/stats.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const pretty_json = @import("pretty").json;
3 const event = @import("event.zig");
4 const tree = @import("tree.zig");
5
6 pub const schema = "tracy.stats/v0";
7
8 pub const Sort = enum {
9 self,
10 total,
11 mean,
12 count,
13 threads,
14 name,
15
16 pub fn fromName(text: []const u8) ?Sort {
17 if (std.mem.eql(u8, text, "self")) return .self;
18 if (std.mem.eql(u8, text, "total")) return .total;
19 if (std.mem.eql(u8, text, "mean")) return .mean;
20 if (std.mem.eql(u8, text, "count")) return .count;
21 if (std.mem.eql(u8, text, "threads")) return .threads;
22 if (std.mem.eql(u8, text, "name")) return .name;
23 return null;
24 }
25
26 fn tag(self: Sort) []const u8 {
27 return switch (self) {
28 .self => "self",
29 .total => "total",
30 .mean => "mean",
31 .count => "count",
32 .threads => "threads",
33 .name => "name",
34 };
35 }
36 };
37
38 pub const Options = struct {
39 top: usize = 20,
40 sort: Sort = .self,
41 min_total_ns: u64 = 0,
42 min_self_ns: u64 = 0,
43 thread: ?u64 = null,
44 };
45
46 const Row = struct {
47 name: []u8,
48 file: ?[]u8 = null,
49 function: ?[]u8 = null,
50 line: u32 = 0,
51 column: u32 = 0,
52 count: u64 = 0,
53 total_ns: u64 = 0,
54 self_ns: u64 = 0,
55 min_ns: u64 = std.math.maxInt(u64),
56 max_ns: u64 = 0,
57 min_self_ns: u64 = std.math.maxInt(u64),
58 max_self_ns: u64 = 0,
59 max_depth: usize = 0,
60 threads: std.ArrayListUnmanaged(u64) = .empty,
61
62 fn deinit(self: *Row, allocator: std.mem.Allocator) void {
63 allocator.free(self.name);
64 if (self.file) |file| allocator.free(file);
65 if (self.function) |function| allocator.free(function);
66 self.threads.deinit(allocator);
67 self.* = undefined;
68 }
69
70 fn meanNs(self: Row) u64 {
71 if (self.count == 0) return 0;
72 return self.total_ns / self.count;
73 }
74
75 fn meanSelfNs(self: Row) u64 {
76 if (self.count == 0) return 0;
77 return self.self_ns / self.count;
78 }
79
80 fn threadCount(self: Row) u64 {
81 return @intCast(self.threads.items.len);
82 }
83 };
84
85 const View = struct {
86 name: []const u8,
87 file: ?[]const u8,
88 function: ?[]const u8,
89 line: u32,
90 column: u32,
91 count: u64,
92 total_ns: u64,
93 self_ns: u64,
94 min_ns: u64,
95 max_ns: u64,
96 min_self_ns: u64,
97 max_self_ns: u64,
98 mean_ns: u64,
99 mean_self_ns: u64,
100 max_depth: usize,
101 threads: u64,
102 };
103
104 const Analyzer = struct {
105 allocator: std.mem.Allocator,
106 rows: std.StringHashMapUnmanaged(Row) = .{},
107 spans: u64 = 0,
108 matched_spans: u64 = 0,
109 open_spans: u64 = 0,
110 superseded_spans: u64 = 0,
111 invalid_duration_spans: u64 = 0,
112 zero_spans: u64 = 0,
113 duration_ns: u64 = 0,
114 evidence: ?tree.Evidence = null,
115
116 fn init(allocator: std.mem.Allocator) Analyzer {
117 return .{ .allocator = allocator };
118 }
119
120 fn deinit(self: *Analyzer) void {
121 var iter = self.rows.iterator();
122 while (iter.next()) |entry| {
123 self.allocator.free(entry.key_ptr.*);
124 entry.value_ptr.deinit(self.allocator);
125 }
126 self.rows.deinit(self.allocator);
127 self.* = undefined;
128 }
129
130 fn ingestTree(self: *Analyzer, trace: *tree.Analyzer, options: Options) !void {
131 self.duration_ns = trace.durationNs();
132 self.evidence = trace.evidence();
133 var spans = try trace.collectSpans(self.allocator);
134 defer spans.deinit(self.allocator);
135 self.spans = @intCast(spans.items.len);
136 for (spans.items) |span| {
137 switch (span.state) {
138 .open => {
139 self.open_spans += 1;
140 continue;
141 },
142 .superseded => {
143 self.superseded_spans += 1;
144 continue;
145 },
146 .complete => {},
147 }
148 if (!span.duration_valid) {
149 self.invalid_duration_spans += 1;
150 continue;
151 }
152 if (span.total_ns == 0) {
153 self.zero_spans += 1;
154 continue;
155 }
156 if (options.thread) |thread| {
157 if (span.thread != thread) continue;
158 }
159 try self.record(span);
160 }
161 }
162
163 fn record(self: *Analyzer, span: tree.Span) !void {
164 var key_writer = std.Io.Writer.Allocating.init(self.allocator);
165 defer key_writer.deinit();
166 try writeKey(&key_writer.writer, span);
167 const key_text = key_writer.written();
168 const entry = try self.rows.getOrPut(self.allocator, key_text);
169 if (!entry.found_existing) {
170 const owned_key = try self.allocator.dupe(u8, key_text);
171 entry.key_ptr.* = owned_key;
172 entry.value_ptr.* = .{
173 .name = try self.allocator.dupe(u8, span.name),
174 .file = try dupeOptional(self.allocator, span.file),
175 .function = try dupeOptional(self.allocator, span.function),
176 .line = span.line,
177 .column = span.column,
178 };
179 }
180 const row = entry.value_ptr;
181 row.count += 1;
182 row.total_ns +|= span.total_ns;
183 row.self_ns +|= span.self_ns;
184 row.min_ns = @min(row.min_ns, span.total_ns);
185 row.max_ns = @max(row.max_ns, span.total_ns);
186 row.min_self_ns = @min(row.min_self_ns, span.self_ns);
187 row.max_self_ns = @max(row.max_self_ns, span.self_ns);
188 row.max_depth = @max(row.max_depth, span.depth);
189 try appendThread(self.allocator, &row.threads, span.thread);
190 self.matched_spans += 1;
191 }
192
193 fn collect(self: *Analyzer, options: Options) !std.ArrayListUnmanaged(View) {
194 var views: std.ArrayListUnmanaged(View) = .empty;
195 var iter = self.rows.valueIterator();
196 while (iter.next()) |row| {
197 if (row.total_ns < options.min_total_ns) continue;
198 if (row.self_ns < options.min_self_ns) continue;
199 try views.append(self.allocator, .{
200 .name = row.name,
201 .file = row.file,
202 .function = row.function,
203 .line = row.line,
204 .column = row.column,
205 .count = row.count,
206 .total_ns = row.total_ns,
207 .self_ns = row.self_ns,
208 .min_ns = if (row.min_ns == std.math.maxInt(u64)) 0 else row.min_ns,
209 .max_ns = row.max_ns,
210 .min_self_ns = if (row.min_self_ns == std.math.maxInt(u64)) 0 else row.min_self_ns,
211 .max_self_ns = row.max_self_ns,
212 .mean_ns = row.meanNs(),
213 .mean_self_ns = row.meanSelfNs(),
214 .max_depth = row.max_depth,
215 .threads = row.threadCount(),
216 });
217 }
218 sortViews(views.items, options.sort);
219 return views;
220 }
221 };
222
223 pub fn writeTextFromJsonlPath(
224 allocator: std.mem.Allocator,
225 path: []const u8,
226 writer: *std.Io.Writer,
227 options: Options,
228 ) !void {
229 var trace = tree.Analyzer.init(allocator);
230 defer trace.deinit();
231 try tree.ingestPath(&trace, path);
232 var analyzer = Analyzer.init(allocator);
233 defer analyzer.deinit();
234 try analyzer.ingestTree(&trace, options);
235 try writeText(allocator, &analyzer, writer, options);
236 }
237
238 pub fn writeJsonlFromJsonlPath(
239 allocator: std.mem.Allocator,
240 path: []const u8,
241 writer: *std.Io.Writer,
242 options: Options,
243 ) !void {
244 var trace = tree.Analyzer.init(allocator);
245 defer trace.deinit();
246 try tree.ingestPath(&trace, path);
247 var analyzer = Analyzer.init(allocator);
248 defer analyzer.deinit();
249 try analyzer.ingestTree(&trace, options);
250 try writeJsonl(allocator, &analyzer, writer, options);
251 }
252
253 fn writeText(
254 allocator: std.mem.Allocator,
255 analyzer: *Analyzer,
256 writer: *std.Io.Writer,
257 options: Options,
258 ) !void {
259 var views = try analyzer.collect(options);
260 defer views.deinit(allocator);
261 try writer.print(
262 "tracy stats rows={d} spans={d} matched_spans={d} open_spans={d} " ++
263 "superseded_spans={d} invalid_duration_spans={d} zero_spans={d} " ++
264 "duration_ns={d} sort={s}\n",
265 .{
266 views.items.len,
267 analyzer.spans,
268 analyzer.matched_spans,
269 analyzer.open_spans,
270 analyzer.superseded_spans,
271 analyzer.invalid_duration_spans,
272 analyzer.zero_spans,
273 analyzer.duration_ns,
274 options.sort.tag(),
275 },
276 );
277 try analyzer.evidence.?.writeText(writer);
278 const limit = @min(options.top, views.items.len);
279 for (views.items[0..limit]) |view| {
280 try writer.writeAll("stat name=");
281 try pretty_json.writeString(writer, view.name);
282 try writer.print(
283 " count={d} total_ns={d} self_ns={d} mean_ns={d} mean_self_ns={d} min_ns={d} max_ns={d} min_self_ns={d} max_self_ns={d} threads={d} max_depth={d}",
284 .{
285 view.count,
286 view.total_ns,
287 view.self_ns,
288 view.mean_ns,
289 view.mean_self_ns,
290 view.min_ns,
291 view.max_ns,
292 view.min_self_ns,
293 view.max_self_ns,
294 view.threads,
295 view.max_depth,
296 },
297 );
298 if (view.file) |file| try writer.print(" file={s}:{d}", .{ file, view.line });
299 if (view.function) |function| {
300 try writer.writeAll(" function=");
301 try pretty_json.writeString(writer, function);
302 }
303 try writer.writeByte('\n');
304 }
305 }
306
307 fn writeJsonl(
308 allocator: std.mem.Allocator,
309 analyzer: *Analyzer,
310 writer: *std.Io.Writer,
311 options: Options,
312 ) !void {
313 var views = try analyzer.collect(options);
314 defer views.deinit(allocator);
315 var summary_stream = pretty_json.Writer.init(writer, .minified);
316 const summary = try summary_stream.object();
317 try summary.field("schema", schema);
318 try summary.field("kind", "summary");
319 try summary.field("rows", views.items.len);
320 try summary.field("spans", analyzer.spans);
321 try summary.field("matched_spans", analyzer.matched_spans);
322 try summary.field("open_spans", analyzer.open_spans);
323 try summary.field("superseded_spans", analyzer.superseded_spans);
324 try summary.field("invalid_duration_spans", analyzer.invalid_duration_spans);
325 try summary.field("zero_spans", analyzer.zero_spans);
326 try summary.field("duration_ns", analyzer.duration_ns);
327 try summary.field("sort", options.sort.tag());
328 try analyzer.evidence.?.writeFields(summary);
329 try summary.endLine();
330 const limit = @min(options.top, views.items.len);
331 for (views.items[0..limit]) |view| {
332 var stream = pretty_json.Writer.init(writer, .minified);
333 const object = try stream.object();
334 try object.field("schema", schema);
335 try object.field("kind", "stat");
336 try object.field("name", view.name);
337 try object.field("count", view.count);
338 try object.field("total_ns", view.total_ns);
339 try object.field("self_ns", view.self_ns);
340 try object.field("mean_ns", view.mean_ns);
341 try object.field("mean_self_ns", view.mean_self_ns);
342 try object.field("min_ns", view.min_ns);
343 try object.field("max_ns", view.max_ns);
344 try object.field("min_self_ns", view.min_self_ns);
345 try object.field("max_self_ns", view.max_self_ns);
346 try object.field("threads", view.threads);
347 try object.field("max_depth", view.max_depth);
348 if (view.file) |file| {
349 try object.field("file", file);
350 try object.field("line", view.line);
351 }
352 if (view.function) |function| try object.field("function", function);
353 try object.endLine();
354 }
355 }
356
357 fn writeKey(writer: *std.Io.Writer, span: tree.Span) !void {
358 try writer.writeAll(span.name);
359 try writer.writeByte('\x1f');
360 if (span.file) |file| try writer.writeAll(file);
361 try writer.writeByte('\x1f');
362 if (span.function) |function| try writer.writeAll(function);
363 try writer.writeByte('\x1f');
364 try writer.print("{d}:{d}", .{ span.line, span.column });
365 }
366
367 fn appendThread(allocator: std.mem.Allocator, threads: *std.ArrayListUnmanaged(u64), thread: u64) !void {
368 for (threads.items) |existing| {
369 if (existing == thread) return;
370 }
371 try threads.append(allocator, thread);
372 }
373
374 fn sortViews(items: []View, sort: Sort) void {
375 switch (sort) {
376 .self => std.mem.sort(View, items, {}, viewSelfGreaterThan),
377 .total => std.mem.sort(View, items, {}, viewTotalGreaterThan),
378 .mean => std.mem.sort(View, items, {}, viewMeanGreaterThan),
379 .count => std.mem.sort(View, items, {}, viewCountGreaterThan),
380 .threads => std.mem.sort(View, items, {}, viewThreadsGreaterThan),
381 .name => std.mem.sort(View, items, {}, viewNameLessThan),
382 }
383 }
384
385 fn viewSelfGreaterThan(_: void, left: View, right: View) bool {
386 if (left.self_ns != right.self_ns) return left.self_ns > right.self_ns;
387 return viewTotalGreaterThan({}, left, right);
388 }
389
390 fn viewTotalGreaterThan(_: void, left: View, right: View) bool {
391 if (left.total_ns != right.total_ns) return left.total_ns > right.total_ns;
392 return viewNameLessThan({}, left, right);
393 }
394
395 fn viewMeanGreaterThan(_: void, left: View, right: View) bool {
396 if (left.mean_ns != right.mean_ns) return left.mean_ns > right.mean_ns;
397 return viewTotalGreaterThan({}, left, right);
398 }
399
400 fn viewCountGreaterThan(_: void, left: View, right: View) bool {
401 if (left.count != right.count) return left.count > right.count;
402 return viewTotalGreaterThan({}, left, right);
403 }
404
405 fn viewThreadsGreaterThan(_: void, left: View, right: View) bool {
406 if (left.threads != right.threads) return left.threads > right.threads;
407 return viewTotalGreaterThan({}, left, right);
408 }
409
410 fn viewNameLessThan(_: void, left: View, right: View) bool {
411 const name_cmp = std.mem.order(u8, left.name, right.name);
412 if (name_cmp != .eq) return name_cmp == .lt;
413 const left_file = left.file orelse "";
414 const right_file = right.file orelse "";
415 const file_cmp = std.mem.order(u8, left_file, right_file);
416 if (file_cmp != .eq) return file_cmp == .lt;
417 return left.line < right.line;
418 }
419
420 fn dupeOptional(allocator: std.mem.Allocator, text: ?[]const u8) !?[]u8 {
421 const actual = text orelse return null;
422 return try allocator.dupe(u8, actual);
423 }
424
425 test "stats aggregates inclusive and self time by source location" {
426 var trace_bytes = std.Io.Writer.Allocating.init(std.testing.allocator);
427 defer trace_bytes.deinit();
428 try (event.TraceEvent{ .seq = 1, .kind = .zone_begin, .time_ns = 100, .thread = 1, .id = 1, .name = "root", .file = "root.zig", .line = 1 }).writeJsonLine(&trace_bytes.writer);
429 try (event.TraceEvent{ .seq = 2, .kind = .zone_begin, .time_ns = 120, .thread = 1, .id = 2, .name = "child", .file = "child.zig", .line = 2 }).writeJsonLine(&trace_bytes.writer);
430 try (event.TraceEvent{ .seq = 3, .kind = .zone_end, .time_ns = 150, .thread = 1, .id = 2 }).writeJsonLine(&trace_bytes.writer);
431 try (event.TraceEvent{ .seq = 4, .kind = .zone_end, .time_ns = 200, .thread = 1, .id = 1 }).writeJsonLine(&trace_bytes.writer);
432 try (event.TraceEvent{ .seq = 5, .kind = .zone_begin, .time_ns = 210, .thread = 2, .id = 3, .name = "child", .file = "child.zig", .line = 2 }).writeJsonLine(&trace_bytes.writer);
433 try (event.TraceEvent{ .seq = 6, .kind = .zone_end, .time_ns = 260, .thread = 2, .id = 3 }).writeJsonLine(&trace_bytes.writer);
434
435 var trace = tree.Analyzer.init(std.testing.allocator);
436 defer trace.deinit();
437 try trace.ingestJsonlBytes(trace_bytes.written());
438
439 var analyzer = Analyzer.init(std.testing.allocator);
440 defer analyzer.deinit();
441 try analyzer.ingestTree(&trace, .{});
442
443 var out = std.Io.Writer.Allocating.init(std.testing.allocator);
444 defer out.deinit();
445 try writeText(std.testing.allocator, &analyzer, &out.writer, .{ .top = 4, .sort = .self });
446 const text = out.written();
447 try std.testing.expect(std.mem.indexOf(u8, text, "tracy stats rows=2 spans=3 matched_spans=3") != null);
448 try std.testing.expect(std.mem.indexOf(u8, text, "stat name=\"child\" count=2 total_ns=80 self_ns=80 mean_ns=40 mean_self_ns=40") != null);
449 try std.testing.expect(std.mem.indexOf(u8, text, "threads=2") != null);
450 try std.testing.expect(std.mem.indexOf(u8, text, "stat name=\"root\" count=1 total_ns=100 self_ns=70") != null);
451 }
452
453 test "stats jsonl emits filtered machine rows" {
454 var trace_bytes = std.Io.Writer.Allocating.init(std.testing.allocator);
455 defer trace_bytes.deinit();
456 try (event.TraceEvent{ .seq = 1, .kind = .zone_begin, .time_ns = 100, .thread = 7, .id = 1, .name = "phase", .file = "phase.zig", .line = 9 }).writeJsonLine(&trace_bytes.writer);
457 try (event.TraceEvent{ .seq = 2, .kind = .zone_end, .time_ns = 150, .thread = 7, .id = 1 }).writeJsonLine(&trace_bytes.writer);
458
459 var trace = tree.Analyzer.init(std.testing.allocator);
460 defer trace.deinit();
461 try trace.ingestJsonlBytes(trace_bytes.written());
462
463 var analyzer = Analyzer.init(std.testing.allocator);
464 defer analyzer.deinit();
465 try analyzer.ingestTree(&trace, .{ .thread = 7 });
466
467 var out = std.Io.Writer.Allocating.init(std.testing.allocator);
468 defer out.deinit();
469 try writeJsonl(std.testing.allocator, &analyzer, &out.writer, .{ .top = 2, .sort = .total, .min_total_ns = 10 });
470 const text = out.written();
471 try std.testing.expect(std.mem.indexOf(u8, text, "\"schema\":\"tracy.stats/v0\"") != null);
472 try std.testing.expect(std.mem.indexOf(u8, text, "\"kind\":\"stat\"") != null);
473 try std.testing.expect(std.mem.indexOf(u8, text, "\"name\":\"phase\"") != null);
474 try std.testing.expect(std.mem.indexOf(u8, text, "\"total_ns\":50") != null);
475 try std.testing.expect(std.mem.indexOf(u8, text, "\"threads\":1") != null);
476 }