lib/tracy/src/source.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const pretty_json = @import("pretty").json;
  3 const sys = @import("sys");
  4 const event = @import("event.zig");
  5 const tree = @import("tree.zig");
  6 
  7 pub const schema = "tracy.source/v0";
  8 
  9 const max_source_bytes = 8 * 1024 * 1024;
 10 
 11 pub const Sort = enum {
 12     self,
 13     total,
 14     mean,
 15     count,
 16     file,
 17     name,
 18 
 19     pub fn fromName(text: []const u8) ?Sort {
 20         if (std.mem.eql(u8, text, "self")) return .self;
 21         if (std.mem.eql(u8, text, "total")) return .total;
 22         if (std.mem.eql(u8, text, "mean")) return .mean;
 23         if (std.mem.eql(u8, text, "count")) return .count;
 24         if (std.mem.eql(u8, text, "file")) return .file;
 25         if (std.mem.eql(u8, text, "name")) return .name;
 26         return null;
 27     }
 28 
 29     fn tag(self: Sort) []const u8 {
 30         return switch (self) {
 31             .self => "self",
 32             .total => "total",
 33             .mean => "mean",
 34             .count => "count",
 35             .file => "file",
 36             .name => "name",
 37         };
 38     }
 39 };
 40 
 41 pub const Options = struct {
 42     top: usize = 20,
 43     sort: Sort = .self,
 44     min_total_ns: u64 = 0,
 45     min_self_ns: u64 = 0,
 46     thread: ?u64 = null,
 47     match: ?[]const u8 = null,
 48     root: ?[]const u8 = null,
 49     context: usize = 2,
 50     context_back: usize = 2,
 51     ignore_case: bool = false,
 52 };
 53 
 54 pub const Counters = struct {
 55     spans: u64 = 0,
 56     matched_spans: u64 = 0,
 57     open_spans: u64 = 0,
 58     superseded_spans: u64 = 0,
 59     invalid_duration_spans: u64 = 0,
 60     zero_spans: u64 = 0,
 61     no_file_spans: u64 = 0,
 62     filtered_spans: u64 = 0,
 63     rows: u64 = 0,
 64     duration_ns: u64 = 0,
 65 };
 66 
 67 const Row = struct {
 68     name: []u8,
 69     file: []u8,
 70     function: ?[]u8 = null,
 71     line: u32 = 0,
 72     column: u32 = 0,
 73     count: u64 = 0,
 74     total_ns: u64 = 0,
 75     self_ns: u64 = 0,
 76     min_ns: u64 = std.math.maxInt(u64),
 77     max_ns: u64 = 0,
 78     min_self_ns: u64 = std.math.maxInt(u64),
 79     max_self_ns: u64 = 0,
 80     max_depth: usize = 0,
 81     threads: std.ArrayListUnmanaged(u64) = .empty,
 82 
 83     fn deinit(self: *Row, allocator: std.mem.Allocator) void {
 84         allocator.free(self.name);
 85         allocator.free(self.file);
 86         if (self.function) |function| allocator.free(function);
 87         self.threads.deinit(allocator);
 88         self.* = undefined;
 89     }
 90 
 91     fn meanNs(self: Row) u64 {
 92         if (self.count == 0) return 0;
 93         return self.total_ns / self.count;
 94     }
 95 
 96     fn meanSelfNs(self: Row) u64 {
 97         if (self.count == 0) return 0;
 98         return self.self_ns / self.count;
 99     }
100 
101     fn threadCount(self: Row) u64 {
102         return @intCast(self.threads.items.len);
103     }
104 };
105 
106 const View = struct {
107     name: []const u8,
108     file: []const u8,
109     function: ?[]const u8,
110     line: u32,
111     column: u32,
112     count: u64,
113     total_ns: u64,
114     self_ns: u64,
115     min_ns: u64,
116     max_ns: u64,
117     min_self_ns: u64,
118     max_self_ns: u64,
119     mean_ns: u64,
120     mean_self_ns: u64,
121     max_depth: usize,
122     threads: u64,
123 };
124 
125 const SourceState = enum {
126     found,
127     missing,
128     out_of_range,
129     invalid_line,
130 
131     fn tag(self: SourceState) []const u8 {
132         return switch (self) {
133             .found => "found",
134             .missing => "missing",
135             .out_of_range => "out-of-range",
136             .invalid_line => "invalid-line",
137         };
138     }
139 };
140 
141 const SourceContext = struct {
142     path: []u8,
143     state: SourceState,
144     excerpt: []u8,
145     total_lines: u32 = 0,
146 
147     fn deinit(self: *SourceContext, allocator: std.mem.Allocator) void {
148         allocator.free(self.path);
149         allocator.free(self.excerpt);
150         self.* = undefined;
151     }
152 };
153 
154 const LineScan = struct {
155     total_lines: u32 = 0,
156     target_found: bool = false,
157 };
158 
159 pub const Analyzer = struct {
160     allocator: std.mem.Allocator,
161     rows: std.StringHashMapUnmanaged(Row) = .{},
162     counters: Counters = .{},
163     evidence: ?tree.Evidence = null,
164 
165     pub fn init(allocator: std.mem.Allocator) Analyzer {
166         return .{ .allocator = allocator };
167     }
168 
169     pub fn deinit(self: *Analyzer) void {
170         var iter = self.rows.iterator();
171         while (iter.next()) |entry| {
172             self.allocator.free(entry.key_ptr.*);
173             entry.value_ptr.deinit(self.allocator);
174         }
175         self.rows.deinit(self.allocator);
176         self.* = undefined;
177     }
178 
179     pub fn ingestTree(self: *Analyzer, trace: *tree.Analyzer, options: Options) !void {
180         self.counters.duration_ns = trace.durationNs();
181         self.evidence = trace.evidence();
182         var spans = try trace.collectSpans(self.allocator);
183         defer spans.deinit(self.allocator);
184         self.counters.spans = @intCast(spans.items.len);
185         for (spans.items) |span| {
186             switch (span.state) {
187                 .open => {
188                     self.counters.open_spans += 1;
189                     continue;
190                 },
191                 .superseded => {
192                     self.counters.superseded_spans += 1;
193                     continue;
194                 },
195                 .complete => {},
196             }
197             if (!span.duration_valid) {
198                 self.counters.invalid_duration_spans += 1;
199                 continue;
200             }
201             if (span.total_ns == 0) {
202                 self.counters.zero_spans += 1;
203                 continue;
204             }
205             if (options.thread) |thread| {
206                 if (span.thread != thread) {
207                     self.counters.filtered_spans += 1;
208                     continue;
209                 }
210             }
211             if (!spanMatches(span, options)) {
212                 self.counters.filtered_spans += 1;
213                 continue;
214             }
215             const file = span.file orelse {
216                 self.counters.no_file_spans += 1;
217                 continue;
218             };
219             if (file.len == 0) {
220                 self.counters.no_file_spans += 1;
221                 continue;
222             }
223             try self.record(span, file);
224             self.counters.matched_spans += 1;
225         }
226         self.counters.rows = @intCast(self.rows.count());
227     }
228 
229     fn record(self: *Analyzer, span: tree.Span, file: []const u8) !void {
230         var key_writer = std.Io.Writer.Allocating.init(self.allocator);
231         defer key_writer.deinit();
232         try key_writer.writer.writeAll(file);
233         try key_writer.writer.writeByte('\x1f');
234         if (span.function) |function| try key_writer.writer.writeAll(function);
235         try key_writer.writer.print("\x1f{d}:{d}", .{ span.line, span.column });
236         const key_text = key_writer.written();
237         const entry = try self.rows.getOrPut(self.allocator, key_text);
238         if (!entry.found_existing) {
239             const owned_key = try self.allocator.dupe(u8, key_text);
240             entry.key_ptr.* = owned_key;
241             entry.value_ptr.* = .{
242                 .name = try self.allocator.dupe(u8, span.name),
243                 .file = try self.allocator.dupe(u8, file),
244                 .function = try dupeOptional(self.allocator, span.function),
245                 .line = span.line,
246                 .column = span.column,
247             };
248         }
249         const row = entry.value_ptr;
250         row.count += 1;
251         row.total_ns +|= span.total_ns;
252         row.self_ns +|= span.self_ns;
253         row.min_ns = @min(row.min_ns, span.total_ns);
254         row.max_ns = @max(row.max_ns, span.total_ns);
255         row.min_self_ns = @min(row.min_self_ns, span.self_ns);
256         row.max_self_ns = @max(row.max_self_ns, span.self_ns);
257         row.max_depth = @max(row.max_depth, span.depth);
258         try appendThread(self.allocator, &row.threads, span.thread);
259     }
260 
261     fn collect(self: *Analyzer, allocator: std.mem.Allocator, options: Options) !std.ArrayListUnmanaged(View) {
262         var views: std.ArrayListUnmanaged(View) = .empty;
263         var iter = self.rows.valueIterator();
264         while (iter.next()) |row| {
265             if (row.total_ns < options.min_total_ns) continue;
266             if (row.self_ns < options.min_self_ns) continue;
267             try views.append(allocator, .{
268                 .name = row.name,
269                 .file = row.file,
270                 .function = row.function,
271                 .line = row.line,
272                 .column = row.column,
273                 .count = row.count,
274                 .total_ns = row.total_ns,
275                 .self_ns = row.self_ns,
276                 .min_ns = if (row.min_ns == std.math.maxInt(u64)) 0 else row.min_ns,
277                 .max_ns = row.max_ns,
278                 .min_self_ns = if (row.min_self_ns == std.math.maxInt(u64)) 0 else row.min_self_ns,
279                 .max_self_ns = row.max_self_ns,
280                 .mean_ns = row.meanNs(),
281                 .mean_self_ns = row.meanSelfNs(),
282                 .max_depth = row.max_depth,
283                 .threads = row.threadCount(),
284             });
285         }
286         sortViews(views.items, options.sort);
287         return views;
288     }
289 };
290 
291 pub fn writeTextFromJsonlPath(
292     allocator: std.mem.Allocator,
293     path: []const u8,
294     writer: *std.Io.Writer,
295     options: Options,
296 ) !void {
297     var trace = tree.Analyzer.init(allocator);
298     defer trace.deinit();
299     try tree.ingestPath(&trace, path);
300     var analyzer = Analyzer.init(allocator);
301     defer analyzer.deinit();
302     try analyzer.ingestTree(&trace, options);
303     try writeText(allocator, &analyzer, writer, options);
304 }
305 
306 pub fn writeJsonlFromJsonlPath(
307     allocator: std.mem.Allocator,
308     path: []const u8,
309     writer: *std.Io.Writer,
310     options: Options,
311 ) !void {
312     var trace = tree.Analyzer.init(allocator);
313     defer trace.deinit();
314     try tree.ingestPath(&trace, path);
315     var analyzer = Analyzer.init(allocator);
316     defer analyzer.deinit();
317     try analyzer.ingestTree(&trace, options);
318     try writeJsonl(allocator, &analyzer, writer, options);
319 }
320 
321 fn writeText(allocator: std.mem.Allocator, analyzer: *Analyzer, writer: *std.Io.Writer, options: Options) !void {
322     var rows = try analyzer.collect(allocator, options);
323     defer rows.deinit(allocator);
324     try writer.print(
325         "tracy source rows={d} spans={d} matched_spans={d} open_spans={d} " ++
326             "superseded_spans={d} invalid_duration_spans={d} zero_spans={d} " ++
327             "no_file_spans={d} filtered_spans={d} duration_ns={d} sort={s}",
328         .{
329             rows.items.len,
330             analyzer.counters.spans,
331             analyzer.counters.matched_spans,
332             analyzer.counters.open_spans,
333             analyzer.counters.superseded_spans,
334             analyzer.counters.invalid_duration_spans,
335             analyzer.counters.zero_spans,
336             analyzer.counters.no_file_spans,
337             analyzer.counters.filtered_spans,
338             analyzer.counters.duration_ns,
339             options.sort.tag(),
340         },
341     );
342     if (options.root) |root| {
343         try writer.writeAll(" root=");
344         try pretty_json.writeString(writer, root);
345     }
346     if (options.match) |pattern| {
347         try writer.writeAll(" match=");
348         try pretty_json.writeString(writer, pattern);
349     }
350     try writer.writeByte('\n');
351     try analyzer.evidence.?.writeText(writer);
352     const limit = @min(options.top, rows.items.len);
353     for (rows.items[0..limit]) |row| {
354         var source_context = try loadSourceContext(allocator, row.file, row.line, options);
355         defer source_context.deinit(allocator);
356         try writer.writeAll("source-location name=");
357         try pretty_json.writeString(writer, row.name);
358         try writer.print(
359             " 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} max_depth={d} threads={d} file={s}:{d}:{d} source_state={s} path=",
360             .{
361                 row.count,
362                 row.total_ns,
363                 row.self_ns,
364                 row.mean_ns,
365                 row.mean_self_ns,
366                 row.min_ns,
367                 row.max_ns,
368                 row.min_self_ns,
369                 row.max_self_ns,
370                 row.max_depth,
371                 row.threads,
372                 row.file,
373                 row.line,
374                 row.column,
375                 source_context.state.tag(),
376             },
377         );
378         try pretty_json.writeString(writer, source_context.path);
379         try writer.print(" total_lines={d}", .{source_context.total_lines});
380         if (row.function) |function| {
381             try writer.writeAll(" function=");
382             try pretty_json.writeString(writer, function);
383         }
384         if (source_context.excerpt.len != 0) {
385             try writer.writeAll(" excerpt=");
386             try pretty_json.writeString(writer, source_context.excerpt);
387         }
388         try writer.writeByte('\n');
389     }
390 }
391 
392 fn writeJsonl(allocator: std.mem.Allocator, analyzer: *Analyzer, writer: *std.Io.Writer, options: Options) !void {
393     var rows = try analyzer.collect(allocator, options);
394     defer rows.deinit(allocator);
395     try writeJsonSummary(analyzer, writer, options, rows.items.len);
396     const limit = @min(options.top, rows.items.len);
397     for (rows.items[0..limit]) |row| {
398         var source_context = try loadSourceContext(allocator, row.file, row.line, options);
399         defer source_context.deinit(allocator);
400         try writeJsonLocation(writer, row, source_context);
401     }
402 }
403 
404 fn writeJsonSummary(
405     analyzer: *Analyzer,
406     writer: *std.Io.Writer,
407     options: Options,
408     rows: usize,
409 ) !void {
410     var stream = pretty_json.Writer.init(writer, .minified);
411     const object = try stream.object();
412     try object.field("schema", schema);
413     try object.field("kind", "summary");
414     try object.field("rows", rows);
415     try object.field("spans", analyzer.counters.spans);
416     try object.field("matched_spans", analyzer.counters.matched_spans);
417     try object.field("open_spans", analyzer.counters.open_spans);
418     try object.field("superseded_spans", analyzer.counters.superseded_spans);
419     try object.field("invalid_duration_spans", analyzer.counters.invalid_duration_spans);
420     try object.field("zero_spans", analyzer.counters.zero_spans);
421     try object.field("no_file_spans", analyzer.counters.no_file_spans);
422     try object.field("filtered_spans", analyzer.counters.filtered_spans);
423     try object.field("duration_ns", analyzer.counters.duration_ns);
424     try object.field("sort", options.sort.tag());
425     if (options.root) |root| try object.field("root", root);
426     if (options.match) |pattern| try object.field("match", pattern);
427     try analyzer.evidence.?.writeFields(object);
428     try object.endLine();
429 }
430 
431 fn writeJsonLocation(writer: *std.Io.Writer, row: View, source_context: SourceContext) !void {
432     var stream = pretty_json.Writer.init(writer, .minified);
433     const object = try stream.object();
434     try object.field("schema", schema);
435     try object.field("kind", "location");
436     try object.field("name", row.name);
437     try object.field("count", row.count);
438     try object.field("total_ns", row.total_ns);
439     try object.field("self_ns", row.self_ns);
440     try object.field("mean_ns", row.mean_ns);
441     try object.field("mean_self_ns", row.mean_self_ns);
442     try object.field("min_ns", row.min_ns);
443     try object.field("max_ns", row.max_ns);
444     try object.field("min_self_ns", row.min_self_ns);
445     try object.field("max_self_ns", row.max_self_ns);
446     try object.field("max_depth", row.max_depth);
447     try object.field("threads", row.threads);
448     try object.field("file", row.file);
449     try object.field("line", row.line);
450     try object.field("column", row.column);
451     try object.field("source_state", source_context.state.tag());
452     try object.field("path", source_context.path);
453     try object.field("total_lines", source_context.total_lines);
454     if (row.function) |function| try object.field("function", function);
455     if (source_context.excerpt.len != 0) try object.field("excerpt", source_context.excerpt);
456     try object.endLine();
457 }
458 
459 fn loadSourceContext(allocator: std.mem.Allocator, file: []const u8, line: u32, options: Options) !SourceContext {
460     const path = try resolvePath(allocator, file, options.root);
461     errdefer allocator.free(path);
462     if (line == 0) {
463         return .{
464             .path = path,
465             .state = .invalid_line,
466             .excerpt = try allocator.dupe(u8, ""),
467         };
468     }
469     const contents = sys.fs.cwd().readFileAlloc(sys.fs.debugIo(), path, allocator, .limited(max_source_bytes)) catch |err| switch (err) {
470         error.OutOfMemory => return err,
471         else => return .{
472             .path = path,
473             .state = .missing,
474             .excerpt = try allocator.dupe(u8, ""),
475         },
476     };
477     defer allocator.free(contents);
478     var excerpt = std.Io.Writer.Allocating.init(allocator);
479     defer excerpt.deinit();
480     const start_line = if (line > options.context_back) line - @as(u32, @intCast(@min(options.context_back, line - 1))) else 1;
481     const after = @as(u32, @intCast(@min(options.context, std.math.maxInt(u32) - line)));
482     const end_line = line + after;
483     const scan = try appendSourceLines(&excerpt.writer, contents, start_line, end_line, line);
484     return .{
485         .path = path,
486         .state = if (scan.target_found) .found else .out_of_range,
487         .excerpt = try allocator.dupe(u8, excerpt.written()),
488         .total_lines = scan.total_lines,
489     };
490 }
491 
492 fn resolvePath(allocator: std.mem.Allocator, file: []const u8, root: ?[]const u8) ![]u8 {
493     if (std.fs.path.isAbsolute(file)) return try allocator.dupe(u8, file);
494     const actual_root = root orelse return try allocator.dupe(u8, file);
495     if (actual_root.len == 0) return try allocator.dupe(u8, file);
496     return try std.fs.path.join(allocator, &.{ actual_root, file });
497 }
498 
499 fn appendSourceLines(writer: *std.Io.Writer, contents: []const u8, start_line: u32, end_line: u32, target_line: u32) !LineScan {
500     if (contents.len == 0) return .{};
501     var scan: LineScan = .{};
502     var line_no: u32 = 1;
503     var start: usize = 0;
504     var index: usize = 0;
505     while (index <= contents.len) : (index += 1) {
506         if (index == contents.len) {
507             if (start == contents.len) break;
508         } else if (contents[index] != '\n') {
509             continue;
510         }
511         var line = contents[start..index];
512         if (line.len != 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1];
513         if (line_no >= start_line and line_no <= end_line) {
514             try writer.print("{d}:{s}\n", .{ line_no, line });
515         }
516         if (line_no == target_line) scan.target_found = true;
517         scan.total_lines = line_no;
518         if (line_no == std.math.maxInt(u32)) break;
519         line_no += 1;
520         start = index + 1;
521     }
522     return scan;
523 }
524 
525 fn spanMatches(span: tree.Span, options: Options) bool {
526     const pattern = options.match orelse return true;
527     if (pattern.len == 0) return true;
528     if (contains(span.name, pattern, options.ignore_case)) return true;
529     if (span.text) |text| if (contains(text, pattern, options.ignore_case)) return true;
530     if (span.file) |file| if (contains(file, pattern, options.ignore_case)) return true;
531     if (span.function) |function| if (contains(function, pattern, options.ignore_case)) return true;
532     return false;
533 }
534 
535 fn contains(haystack: []const u8, needle: []const u8, ignore_case: bool) bool {
536     if (!ignore_case) return std.mem.indexOf(u8, haystack, needle) != null;
537     if (needle.len == 0) return true;
538     if (needle.len > haystack.len) return false;
539     var index: usize = 0;
540     while (index + needle.len <= haystack.len) : (index += 1) {
541         if (asciiEqlIgnoreCase(haystack[index .. index + needle.len], needle)) return true;
542     }
543     return false;
544 }
545 
546 fn asciiEqlIgnoreCase(left: []const u8, right: []const u8) bool {
547     if (left.len != right.len) return false;
548     for (left, right) |a, b| {
549         if (std.ascii.toLower(a) != std.ascii.toLower(b)) return false;
550     }
551     return true;
552 }
553 
554 fn dupeOptional(allocator: std.mem.Allocator, text: ?[]const u8) !?[]u8 {
555     const actual = text orelse return null;
556     return try allocator.dupe(u8, actual);
557 }
558 
559 fn appendThread(allocator: std.mem.Allocator, threads: *std.ArrayListUnmanaged(u64), thread: u64) !void {
560     for (threads.items) |existing| {
561         if (existing == thread) return;
562     }
563     try threads.append(allocator, thread);
564 }
565 
566 fn sortViews(items: []View, sort: Sort) void {
567     switch (sort) {
568         .self => std.mem.sort(View, items, {}, selfGreaterThan),
569         .total => std.mem.sort(View, items, {}, totalGreaterThan),
570         .mean => std.mem.sort(View, items, {}, meanGreaterThan),
571         .count => std.mem.sort(View, items, {}, countGreaterThan),
572         .file => std.mem.sort(View, items, {}, fileLessThan),
573         .name => std.mem.sort(View, items, {}, nameLessThan),
574     }
575 }
576 
577 fn selfGreaterThan(_: void, left: View, right: View) bool {
578     if (left.self_ns != right.self_ns) return left.self_ns > right.self_ns;
579     return totalGreaterThan({}, left, right);
580 }
581 
582 fn totalGreaterThan(_: void, left: View, right: View) bool {
583     if (left.total_ns != right.total_ns) return left.total_ns > right.total_ns;
584     return fileLessThan({}, left, right);
585 }
586 
587 fn meanGreaterThan(_: void, left: View, right: View) bool {
588     if (left.mean_ns != right.mean_ns) return left.mean_ns > right.mean_ns;
589     return totalGreaterThan({}, left, right);
590 }
591 
592 fn countGreaterThan(_: void, left: View, right: View) bool {
593     if (left.count != right.count) return left.count > right.count;
594     return totalGreaterThan({}, left, right);
595 }
596 
597 fn fileLessThan(_: void, left: View, right: View) bool {
598     const file_cmp = std.mem.order(u8, left.file, right.file);
599     if (file_cmp != .eq) return file_cmp == .lt;
600     if (left.line != right.line) return left.line < right.line;
601     if (left.column != right.column) return left.column < right.column;
602     const name_cmp = std.mem.order(u8, left.name, right.name);
603     return name_cmp == .lt;
604 }
605 
606 fn nameLessThan(_: void, left: View, right: View) bool {
607     const name_cmp = std.mem.order(u8, left.name, right.name);
608     if (name_cmp != .eq) return name_cmp == .lt;
609     const file_cmp = std.mem.order(u8, left.file, right.file);
610     if (file_cmp != .eq) return file_cmp == .lt;
611     if (left.line != right.line) return left.line < right.line;
612     return left.column < right.column;
613 }
614 
615 test "source annotates hot locations with local context" {
616     var tmp = std.testing.tmpDir(.{});
617     defer tmp.cleanup();
618     const root = try tmp.parent_dir.realPathFileAlloc(sys.fs.debugIo(), tmp.sub_path[0..], std.testing.allocator);
619     defer std.testing.allocator.free(root);
620     const source_path = try std.fs.path.join(std.testing.allocator, &.{ root, "phase.zig" });
621     defer std.testing.allocator.free(source_path);
622     try sys.fs.cwd().writeFile(sys.fs.debugIo(), .{
623         .sub_path = source_path,
624         .data = "const x = 1;\nconst y = 2;\nfn work() void {}\nconst z = 4;\n",
625     });
626 
627     var trace_bytes = std.Io.Writer.Allocating.init(std.testing.allocator);
628     defer trace_bytes.deinit();
629     try (event.TraceEvent{ .seq = 1, .kind = .zone_begin, .time_ns = 100, .thread = 1, .id = 1, .name = "work", .file = "phase.zig", .function = "work", .line = 3 }).writeJsonLine(&trace_bytes.writer);
630     try (event.TraceEvent{ .seq = 2, .kind = .zone_end, .time_ns = 180, .thread = 1, .id = 1 }).writeJsonLine(&trace_bytes.writer);
631     try (event.TraceEvent{ .seq = 3, .kind = .zone_begin, .time_ns = 190, .thread = 1, .id = 2, .name = "work", .file = "phase.zig", .function = "work", .line = 3 }).writeJsonLine(&trace_bytes.writer);
632     try (event.TraceEvent{ .seq = 4, .kind = .zone_end, .time_ns = 230, .thread = 1, .id = 2 }).writeJsonLine(&trace_bytes.writer);
633 
634     var trace = tree.Analyzer.init(std.testing.allocator);
635     defer trace.deinit();
636     try trace.ingestJsonlBytes(trace_bytes.written());
637 
638     var analyzer = Analyzer.init(std.testing.allocator);
639     defer analyzer.deinit();
640     const options = Options{ .root = root, .context = 1, .context_back = 1 };
641     try analyzer.ingestTree(&trace, options);
642 
643     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
644     defer out.deinit();
645     try writeText(std.testing.allocator, &analyzer, &out.writer, options);
646     const text = out.written();
647     try std.testing.expect(std.mem.indexOf(u8, text, "tracy source rows=1 spans=2 matched_spans=2") != null);
648     try std.testing.expect(std.mem.indexOf(u8, text, "source_state=found") != null);
649     try std.testing.expect(std.mem.indexOf(u8, text, "count=2 total_ns=120 self_ns=120") != null);
650     try std.testing.expect(std.mem.indexOf(u8, text, "excerpt=\"2:const y = 2;\\n3:fn work() void {}\\n4:const z = 4;\\n\"") != null);
651 }
652 
653 test "source jsonl reports out of range source lines" {
654     var tmp = std.testing.tmpDir(.{});
655     defer tmp.cleanup();
656     const root = try tmp.parent_dir.realPathFileAlloc(sys.fs.debugIo(), tmp.sub_path[0..], std.testing.allocator);
657     defer std.testing.allocator.free(root);
658     const source_path = try std.fs.path.join(std.testing.allocator, &.{ root, "short.zig" });
659     defer std.testing.allocator.free(source_path);
660     try sys.fs.cwd().writeFile(sys.fs.debugIo(), .{ .sub_path = source_path, .data = "const only = 1;\n" });
661 
662     var trace_bytes = std.Io.Writer.Allocating.init(std.testing.allocator);
663     defer trace_bytes.deinit();
664     try (event.TraceEvent{ .seq = 1, .kind = .zone_begin, .time_ns = 100, .thread = 1, .id = 1, .name = "short", .file = "short.zig", .function = "short", .line = 8 }).writeJsonLine(&trace_bytes.writer);
665     try (event.TraceEvent{ .seq = 2, .kind = .zone_end, .time_ns = 140, .thread = 1, .id = 1 }).writeJsonLine(&trace_bytes.writer);
666 
667     var trace = tree.Analyzer.init(std.testing.allocator);
668     defer trace.deinit();
669     try trace.ingestJsonlBytes(trace_bytes.written());
670 
671     var analyzer = Analyzer.init(std.testing.allocator);
672     defer analyzer.deinit();
673     const options = Options{ .root = root };
674     try analyzer.ingestTree(&trace, options);
675 
676     var out = std.Io.Writer.Allocating.init(std.testing.allocator);
677     defer out.deinit();
678     try writeJsonl(std.testing.allocator, &analyzer, &out.writer, options);
679     const text = out.written();
680     try std.testing.expect(std.mem.indexOf(u8, text, "\"schema\":\"tracy.source/v0\"") != null);
681     try std.testing.expect(std.mem.indexOf(u8, text, "\"source_state\":\"out-of-range\"") != null);
682     try std.testing.expect(std.mem.indexOf(u8, text, "\"total_lines\":1") != null);
683     try std.testing.expect(std.mem.indexOf(u8, text, "\"excerpt\":\"\"") == null);
684 }