lib/trace/src/cli.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const pretty = @import("pretty");
  3 const pretty_usage = @import("pretty_usage");
  4 const sys = @import("sys");
  5 const trace_mod = @import("trace");
  6 const event = trace_mod.event;
  7 const replay = trace_mod.replay;
  8 const session_mod = trace_mod.session;
  9 const store_mod = trace_mod.store;
 10 
 11 const Allocator = std.mem.Allocator;
 12 const timepoint_string_capacity = 54;
 13 
 14 const usage_commands = [_][]const u8{
 15     "trace record --trace <path> -- <program> [args...]",
 16     "trace verify <trace>",
 17     "trace summary <trace> [--format jsonl]",
 18     "trace events <trace> [--format jsonl]",
 19     "trace timepoints <trace> [--format jsonl]",
 20     "trace user-events <trace> [--label <label>] [--protocol <protocol>] [--payload <key=value>]... [--format jsonl]",
 21     "trace user-event-stats <trace> --group-by <payload-field> --metric <payload-field> [--sort-by total|count|min|max|mean|group] [--label <label>] [--protocol <protocol>] [--payload <key=value>]... [--format jsonl]",
 22     "trace threads <trace> [--format jsonl]",
 23     "trace objects <trace> [--format jsonl]",
 24     "trace export <trace> [--format jsonl]",
 25     "trace replay <trace> --to <timepoint>",
 26     "trace checkpoints <trace>",
 27     "trace frames <trace> --at <timepoint>",
 28     "trace reverse-watch <trace> --from <timepoint> --object <id>",
 29 };
 30 
 31 pub fn dispatch(allocator: Allocator, args: []const []const u8) !u8 {
 32     if (args.len < 2) {
 33         try writeUsage(allocator, sys.stdio.stderr());
 34         return 1;
 35     }
 36 
 37     const command = args[1];
 38     if (std.mem.eql(u8, command, "record")) return try record(allocator, args[2..]);
 39     if (std.mem.eql(u8, command, "verify")) return try verify(allocator, args[2..]);
 40     if (std.mem.eql(u8, command, "summary")) return try summary(allocator, args[2..]);
 41     if (std.mem.eql(u8, command, "events")) return try dumpEvents(allocator, args[2..]);
 42     if (std.mem.eql(u8, command, "timepoints")) return try timepoints(allocator, args[2..]);
 43     if (std.mem.eql(u8, command, "user-events")) return try userEvents(allocator, args[2..]);
 44     if (std.mem.eql(u8, command, "user-event-stats")) return try userEventStats(allocator, args[2..]);
 45     if (std.mem.eql(u8, command, "threads")) return try threads(allocator, args[2..]);
 46     if (std.mem.eql(u8, command, "objects")) return try objects(allocator, args[2..]);
 47     if (std.mem.eql(u8, command, "export")) return try dumpEvents(allocator, args[2..]);
 48     if (std.mem.eql(u8, command, "replay")) return try replayCommand(allocator, args[2..]);
 49     if (std.mem.eql(u8, command, "checkpoints")) return try checkpoints(allocator, args[2..]);
 50     if (std.mem.eql(u8, command, "frames")) return try frames(allocator, args[2..]);
 51     if (std.mem.eql(u8, command, "reverse-watch")) return try reverseWatch(allocator, args[2..]);
 52     if (std.mem.eql(u8, command, "help") or std.mem.eql(u8, command, "--help")) {
 53         try writeUsage(allocator, sys.stdio.stdout());
 54         return 0;
 55     }
 56     return error.UnknownCommand;
 57 }
 58 
 59 pub fn printError(allocator: Allocator, file: sys.stdio.File, text: []const u8) !void {
 60     try pretty_usage.Terminal.init(allocator, file, .{}).writeErrorText("trace", text);
 61 }
 62 
 63 fn record(allocator: Allocator, args: []const []const u8) !u8 {
 64     var trace_path: ?[]const u8 = null;
 65     var separator_index: ?usize = null;
 66 
 67     var index: usize = 0;
 68     while (index < args.len) : (index += 1) {
 69         if (std.mem.eql(u8, args[index], "--trace")) {
 70             index += 1;
 71             if (index >= args.len) return error.MissingTracePath;
 72             trace_path = args[index];
 73         } else if (std.mem.eql(u8, args[index], "--")) {
 74             separator_index = index;
 75             break;
 76         } else {
 77             return error.UnknownArgument;
 78         }
 79     }
 80 
 81     const trace = trace_path orelse return error.MissingTracePath;
 82     const sep = separator_index orelse return error.MissingProgram;
 83     const child_argv = args[sep + 1 ..];
 84     if (child_argv.len == 0) return error.MissingProgram;
 85 
 86     const manifest = manifestForRecord();
 87     const limits = store_mod.Limits{};
 88     var writer_storage = try store_mod.WriterStorage.init(
 89         allocator,
 90         store_mod.WriterLimits.fromOpen(trace, manifest, limits),
 91     );
 92     defer writer_storage.deinit(allocator);
 93     writer_storage.activate();
 94     var trace_writer: store_mod.Writer = .{};
 95     try trace_writer.open(&writer_storage, trace, manifest, limits);
 96     defer trace_writer.deinit();
 97     var session = session_mod.Session.initRecord(allocator, trace_writer.sink());
 98     defer session.deinit();
 99 
100     _ = try session.sessionStart(1, child_argv[0]);
101     const argv_bytes = try joinArgs(allocator, child_argv);
102     defer allocator.free(argv_bytes);
103     _ = try session.userEvent(1, "process.argv", argv_bytes);
104     const exit_code = try runChild(allocator, child_argv);
105     _ = try session.sessionEnd(1, exit_code);
106     try trace_writer.finish();
107 
108     return if (exit_code == 0) 0 else 1;
109 }
110 
111 fn verify(allocator: Allocator, args: []const []const u8) !u8 {
112     if (args.len != 1) return error.InvalidArguments;
113     const result = try verifyTrace(allocator, args[0]);
114 
115     var stdout_buffer: [4096]u8 = undefined;
116     var stdout = sys.stdio.stdout().writer(sys.stdio.debugIo(), &stdout_buffer);
117     var stream = pretty.json.Writer.init(&stdout.interface, .minified);
118     const object = try stream.object();
119     try object.field("event", "verify");
120     try object.field("status", "ok");
121     try object.field("chunk_count", result.chunk_count);
122     try object.field("event_count", result.event_count);
123     try object.field("event_bytes", result.event_bytes);
124     try object.field("checksum", result.checksum);
125     try object.endLine();
126     try stdout.interface.flush();
127     return 0;
128 }
129 
130 fn dumpEvents(allocator: Allocator, args: []const []const u8) !u8 {
131     if (args.len != 1 and args.len != 3) return error.InvalidArguments;
132     if (args.len == 3 and (!std.mem.eql(u8, args[1], "--format") or !std.mem.eql(u8, args[2], "jsonl"))) {
133         return error.UnsupportedFormat;
134     }
135 
136     var reader_storage: store_mod.ReaderStorage = undefined;
137     var trace_reader: store_mod.Reader = .{};
138     try openReader(&reader_storage, &trace_reader, allocator, args[0]);
139     defer reader_storage.deinit(allocator);
140     defer trace_reader.deinit();
141 
142     var stdout_buffer: [8192]u8 = undefined;
143     var stdout = sys.stdio.stdout().writer(sys.stdio.debugIo(), &stdout_buffer);
144     const source = trace_reader.source();
145     while (try source.peek()) |item| {
146         try item.writeJsonLine(&stdout.interface);
147         source.advance();
148     }
149     try stdout.interface.flush();
150     return 0;
151 }
152 
153 fn summary(allocator: Allocator, args: []const []const u8) !u8 {
154     if (args.len != 1 and args.len != 3) return error.InvalidArguments;
155     if (args.len == 3 and (!std.mem.eql(u8, args[1], "--format") or !std.mem.eql(u8, args[2], "jsonl"))) {
156         return error.UnsupportedFormat;
157     }
158 
159     var reader_storage: store_mod.ReaderStorage = undefined;
160     var trace_reader: store_mod.Reader = .{};
161     try openReader(&reader_storage, &trace_reader, allocator, args[0]);
162     defer reader_storage.deinit(allocator);
163     defer trace_reader.deinit();
164 
165     var stdout_buffer: [8192]u8 = undefined;
166     var stdout = sys.stdio.stdout().writer(sys.stdio.debugIo(), &stdout_buffer);
167     try replay.writeSummaryJsonl(allocator, trace_reader.source(), &stdout.interface);
168     try stdout.interface.flush();
169     return 0;
170 }
171 
172 fn timepoints(allocator: Allocator, args: []const []const u8) !u8 {
173     if (args.len != 1 and args.len != 3) return error.InvalidArguments;
174     if (args.len == 3 and (!std.mem.eql(u8, args[1], "--format") or !std.mem.eql(u8, args[2], "jsonl"))) {
175         return error.UnsupportedFormat;
176     }
177 
178     var reader_storage: store_mod.ReaderStorage = undefined;
179     var trace_reader: store_mod.Reader = .{};
180     try openReader(&reader_storage, &trace_reader, allocator, args[0]);
181     defer reader_storage.deinit(allocator);
182     defer trace_reader.deinit();
183 
184     var stdout_buffer: [8192]u8 = undefined;
185     var stdout = sys.stdio.stdout().writer(sys.stdio.debugIo(), &stdout_buffer);
186     try replay.writeTimepointsJsonl(trace_reader.source(), &stdout.interface);
187     try stdout.interface.flush();
188     return 0;
189 }
190 
191 const UserEventsArgs = struct {
192     trace_path: []const u8,
193     filter: replay.UserEventFilter = .{},
194 
195     fn deinit(self: *UserEventsArgs, allocator: Allocator) void {
196         if (self.filter.payload_filters.len != 0) allocator.free(self.filter.payload_filters);
197         self.* = undefined;
198     }
199 };
200 
201 const UserEventStatsArgs = struct {
202     trace_path: []const u8,
203     filter: replay.UserEventFilter = .{},
204     group_field: ?[]const u8 = null,
205     metric_field: ?[]const u8 = null,
206     sort: replay.UserEventStatSort = .total,
207 
208     fn deinit(self: *UserEventStatsArgs, allocator: Allocator) void {
209         if (self.filter.payload_filters.len != 0) allocator.free(self.filter.payload_filters);
210         self.* = undefined;
211     }
212 };
213 
214 fn userEvents(allocator: Allocator, args: []const []const u8) !u8 {
215     var parsed = try parseUserEventsArgs(allocator, args);
216     defer parsed.deinit(allocator);
217     var reader_storage: store_mod.ReaderStorage = undefined;
218     var trace_reader: store_mod.Reader = .{};
219     try openReader(&reader_storage, &trace_reader, allocator, parsed.trace_path);
220     defer reader_storage.deinit(allocator);
221     defer trace_reader.deinit();
222 
223     var stdout_buffer: [8192]u8 = undefined;
224     var stdout = sys.stdio.stdout().writer(sys.stdio.debugIo(), &stdout_buffer);
225     try replay.writeUserEventsJsonl(allocator, trace_reader.source(), parsed.filter, &stdout.interface);
226     try stdout.interface.flush();
227     return 0;
228 }
229 
230 fn userEventStats(allocator: Allocator, args: []const []const u8) !u8 {
231     var parsed = try parseUserEventStatsArgs(allocator, args);
232     defer parsed.deinit(allocator);
233     var reader_storage: store_mod.ReaderStorage = undefined;
234     var trace_reader: store_mod.Reader = .{};
235     try openReader(&reader_storage, &trace_reader, allocator, parsed.trace_path);
236     defer reader_storage.deinit(allocator);
237     defer trace_reader.deinit();
238 
239     var stdout_buffer: [8192]u8 = undefined;
240     var stdout = sys.stdio.stdout().writer(sys.stdio.debugIo(), &stdout_buffer);
241     try replay.writeUserEventStatsJsonl(
242         allocator,
243         trace_reader.source(),
244         parsed.filter,
245         parsed.group_field.?,
246         parsed.metric_field.?,
247         parsed.sort,
248         &stdout.interface,
249     );
250     try stdout.interface.flush();
251     return 0;
252 }
253 
254 fn parseUserEventsArgs(allocator: Allocator, args: []const []const u8) !UserEventsArgs {
255     if (args.len == 0) return error.InvalidArguments;
256     var parsed = UserEventsArgs{ .trace_path = args[0] };
257     var payload_filters: std.ArrayListUnmanaged(replay.UserPayloadFilter) = .empty;
258     errdefer payload_filters.deinit(allocator);
259 
260     var index: usize = 1;
261     while (index < args.len) : (index += 1) {
262         if (std.mem.eql(u8, args[index], "--format")) {
263             index += 1;
264             if (index >= args.len) return error.InvalidArguments;
265             if (!std.mem.eql(u8, args[index], "jsonl")) return error.UnsupportedFormat;
266         } else if (std.mem.eql(u8, args[index], "--label")) {
267             index += 1;
268             if (index >= args.len) return error.InvalidArguments;
269             parsed.filter.label = args[index];
270         } else if (std.mem.eql(u8, args[index], "--protocol")) {
271             index += 1;
272             if (index >= args.len) return error.InvalidArguments;
273             parsed.filter.protocol = args[index];
274         } else if (std.mem.eql(u8, args[index], "--payload")) {
275             index += 1;
276             if (index >= args.len) return error.InvalidArguments;
277             appendPayloadFilter(allocator, &payload_filters, args[index]) catch return error.InvalidArguments;
278         } else {
279             return error.UnknownArgument;
280         }
281     }
282 
283     parsed.filter.payload_filters = try payloadFiltersSlice(allocator, &payload_filters);
284     return parsed;
285 }
286 
287 fn parseUserEventStatsArgs(allocator: Allocator, args: []const []const u8) !UserEventStatsArgs {
288     if (args.len == 0) return error.InvalidArguments;
289     var parsed = UserEventStatsArgs{ .trace_path = args[0] };
290     var payload_filters: std.ArrayListUnmanaged(replay.UserPayloadFilter) = .empty;
291     errdefer payload_filters.deinit(allocator);
292 
293     var index: usize = 1;
294     while (index < args.len) : (index += 1) {
295         if (std.mem.eql(u8, args[index], "--format")) {
296             index += 1;
297             if (index >= args.len) return error.InvalidArguments;
298             if (!std.mem.eql(u8, args[index], "jsonl")) return error.UnsupportedFormat;
299         } else if (std.mem.eql(u8, args[index], "--label")) {
300             index += 1;
301             if (index >= args.len) return error.InvalidArguments;
302             parsed.filter.label = args[index];
303         } else if (std.mem.eql(u8, args[index], "--protocol")) {
304             index += 1;
305             if (index >= args.len) return error.InvalidArguments;
306             parsed.filter.protocol = args[index];
307         } else if (std.mem.eql(u8, args[index], "--payload")) {
308             index += 1;
309             if (index >= args.len) return error.InvalidArguments;
310             appendPayloadFilter(allocator, &payload_filters, args[index]) catch return error.InvalidArguments;
311         } else if (std.mem.eql(u8, args[index], "--group-by")) {
312             index += 1;
313             if (index >= args.len) return error.InvalidArguments;
314             parsed.group_field = args[index];
315         } else if (std.mem.eql(u8, args[index], "--metric")) {
316             index += 1;
317             if (index >= args.len) return error.InvalidArguments;
318             parsed.metric_field = args[index];
319         } else if (std.mem.eql(u8, args[index], "--sort-by")) {
320             index += 1;
321             if (index >= args.len) return error.InvalidArguments;
322             parsed.sort = replay.UserEventStatSort.parse(args[index]) orelse return error.InvalidArguments;
323         } else {
324             return error.UnknownArgument;
325         }
326     }
327 
328     if (parsed.group_field == null or parsed.metric_field == null) return error.InvalidArguments;
329     parsed.filter.payload_filters = try payloadFiltersSlice(allocator, &payload_filters);
330     return parsed;
331 }
332 
333 fn appendPayloadFilter(
334     allocator: Allocator,
335     payload_filters: *std.ArrayListUnmanaged(replay.UserPayloadFilter),
336     text: []const u8,
337 ) !void {
338     const split = std.mem.indexOfScalar(u8, text, '=') orelse return error.InvalidPayloadFilter;
339     if (split == 0 or split + 1 >= text.len) return error.InvalidPayloadFilter;
340     try payload_filters.append(allocator, .{
341         .field = text[0..split],
342         .value = text[split + 1 ..],
343     });
344 }
345 
346 fn payloadFiltersSlice(
347     allocator: Allocator,
348     payload_filters: *std.ArrayListUnmanaged(replay.UserPayloadFilter),
349 ) ![]const replay.UserPayloadFilter {
350     if (payload_filters.items.len == 0) return &.{};
351     return try payload_filters.toOwnedSlice(allocator);
352 }
353 
354 fn threads(allocator: Allocator, args: []const []const u8) !u8 {
355     if (args.len != 1 and args.len != 3) return error.InvalidArguments;
356     if (args.len == 3 and (!std.mem.eql(u8, args[1], "--format") or !std.mem.eql(u8, args[2], "jsonl"))) {
357         return error.UnsupportedFormat;
358     }
359 
360     var reader_storage: store_mod.ReaderStorage = undefined;
361     var trace_reader: store_mod.Reader = .{};
362     try openReader(&reader_storage, &trace_reader, allocator, args[0]);
363     defer reader_storage.deinit(allocator);
364     defer trace_reader.deinit();
365 
366     var stdout_buffer: [8192]u8 = undefined;
367     var stdout = sys.stdio.stdout().writer(sys.stdio.debugIo(), &stdout_buffer);
368     try replay.writeThreadsJsonl(allocator, trace_reader.source(), &stdout.interface);
369     try stdout.interface.flush();
370     return 0;
371 }
372 
373 fn objects(allocator: Allocator, args: []const []const u8) !u8 {
374     if (args.len != 1 and args.len != 3) return error.InvalidArguments;
375     if (args.len == 3 and (!std.mem.eql(u8, args[1], "--format") or !std.mem.eql(u8, args[2], "jsonl"))) {
376         return error.UnsupportedFormat;
377     }
378 
379     var reader_storage: store_mod.ReaderStorage = undefined;
380     var trace_reader: store_mod.Reader = .{};
381     try openReader(&reader_storage, &trace_reader, allocator, args[0]);
382     defer reader_storage.deinit(allocator);
383     defer trace_reader.deinit();
384 
385     var stdout_buffer: [8192]u8 = undefined;
386     var stdout = sys.stdio.stdout().writer(sys.stdio.debugIo(), &stdout_buffer);
387     try replay.writeObjectsJsonl(allocator, trace_reader.source(), &stdout.interface);
388     try stdout.interface.flush();
389     return 0;
390 }
391 
392 fn replayCommand(allocator: Allocator, args: []const []const u8) !u8 {
393     if (args.len != 3 or !std.mem.eql(u8, args[1], "--to")) return error.InvalidArguments;
394     const target = try parseTimepoint(args[2]);
395     var reader_storage: store_mod.ReaderStorage = undefined;
396     var trace_reader: store_mod.Reader = .{};
397     try openReader(&reader_storage, &trace_reader, allocator, args[0]);
398     defer reader_storage.deinit(allocator);
399     defer trace_reader.deinit();
400     const checkpoint = try replay.nearestCheckpointTimepoint(trace_reader.source(), target);
401 
402     var stdout_buffer: [4096]u8 = undefined;
403     var stdout = sys.stdio.stdout().writer(sys.stdio.debugIo(), &stdout_buffer);
404     var stream = pretty.json.Writer.init(&stdout.interface, .minified);
405     const object = try stream.object();
406     try object.field("event", "replay");
407     try object.formattedString(
408         "target",
409         timepoint_string_capacity,
410         "{d}:{d}:{d}",
411         .{ target.epoch, target.thread_id, target.seq },
412     );
413     if (checkpoint) |item| {
414         try object.formattedString(
415             "checkpoint",
416             timepoint_string_capacity,
417             "{d}:{d}:{d}",
418             .{ item.epoch, item.thread_id, item.seq },
419         );
420     } else {
421         try object.field("checkpoint", null);
422     }
423     try object.endLine();
424     try stdout.interface.flush();
425     return 0;
426 }
427 
428 fn checkpoints(allocator: Allocator, args: []const []const u8) !u8 {
429     if (args.len != 1) return error.InvalidArguments;
430     var reader_storage: store_mod.ReaderStorage = undefined;
431     var trace_reader: store_mod.Reader = .{};
432     try openReader(&reader_storage, &trace_reader, allocator, args[0]);
433     defer reader_storage.deinit(allocator);
434     defer trace_reader.deinit();
435 
436     var stdout_buffer: [8192]u8 = undefined;
437     var stdout = sys.stdio.stdout().writer(sys.stdio.debugIo(), &stdout_buffer);
438     const source = trace_reader.source();
439     while (try source.peek()) |item| {
440         if (item.kind == .checkpoint) try item.writeJsonLine(&stdout.interface);
441         source.advance();
442     }
443     try stdout.interface.flush();
444     return 0;
445 }
446 
447 fn frames(allocator: Allocator, args: []const []const u8) !u8 {
448     if (args.len != 3 or !std.mem.eql(u8, args[1], "--at")) return error.InvalidArguments;
449     const target = try parseTimepoint(args[2]);
450     var reader_storage: store_mod.ReaderStorage = undefined;
451     var trace_reader: store_mod.Reader = .{};
452     try openReader(&reader_storage, &trace_reader, allocator, args[0]);
453     defer reader_storage.deinit(allocator);
454     defer trace_reader.deinit();
455     const stack = try replay.framesAtAlloc(allocator, trace_reader.source(), target);
456     defer allocator.free(stack);
457 
458     var stdout_buffer: [8192]u8 = undefined;
459     var stdout = sys.stdio.stdout().writer(sys.stdio.debugIo(), &stdout_buffer);
460     for (stack) |frame| {
461         try frame.writeJsonLine(&stdout.interface);
462     }
463     try stdout.interface.flush();
464     return 0;
465 }
466 
467 fn reverseWatch(allocator: Allocator, args: []const []const u8) !u8 {
468     if (args.len != 5 or
469         !std.mem.eql(u8, args[1], "--from") or
470         !std.mem.eql(u8, args[3], "--object"))
471     {
472         return error.InvalidArguments;
473     }
474     const target = try parseTimepoint(args[2]);
475     const object_id = try std.fmt.parseUnsigned(u64, args[4], 0);
476     var reader_storage: store_mod.ReaderStorage = undefined;
477     var trace_reader: store_mod.Reader = .{};
478     try openReader(&reader_storage, &trace_reader, allocator, args[0]);
479     defer reader_storage.deinit(allocator);
480     defer trace_reader.deinit();
481 
482     var stdout_buffer: [8192]u8 = undefined;
483     var stdout = sys.stdio.stdout().writer(sys.stdio.debugIo(), &stdout_buffer);
484     var matched: ?event.Event = null;
485     defer if (matched) |*item| item.deinit(allocator);
486     const source = trace_reader.source();
487     while (try source.peek()) |item| {
488         if (item.timepoint.beforeOrEqual(target) and item.object_id == object_id and
489             (item.kind == .allocation or item.kind == .free))
490         {
491             const owned = try item.cloneAlloc(allocator);
492             if (matched) |*previous| previous.deinit(allocator);
493             matched = owned;
494         }
495         source.advance();
496     }
497     if (matched) |item| try item.writeJsonLine(&stdout.interface);
498     try stdout.interface.flush();
499     return 0;
500 }
501 
502 fn maximumReaderLimits(path: []const u8) store_mod.ReaderLimits {
503     return store_mod.ReaderLimits.fromOpen(path, .{
504         .max_event_bytes = store_mod.Limits.maximum_event_bytes,
505         .max_chunk_bytes = store_mod.Limits.maximum_chunk_bytes,
506     });
507 }
508 
509 fn openReader(
510     reader_storage: *store_mod.ReaderStorage,
511     trace_reader: *store_mod.Reader,
512     allocator: Allocator,
513     path: []const u8,
514 ) !void {
515     reader_storage.* = try store_mod.ReaderStorage.init(allocator, maximumReaderLimits(path));
516     errdefer reader_storage.deinit(allocator);
517     reader_storage.activate();
518     try trace_reader.open(reader_storage, path);
519 }
520 
521 fn verifyTrace(allocator: Allocator, path: []const u8) !store_mod.VerifyResult {
522     var reader_storage = try store_mod.ReaderStorage.init(allocator, maximumReaderLimits(path));
523     defer reader_storage.deinit(allocator);
524     reader_storage.activate();
525     return try store_mod.verify(&reader_storage, path);
526 }
527 
528 fn runChild(allocator: Allocator, argv: []const []const u8) !i64 {
529     var io_state = sys.thread.initThreadedIo(allocator, .{});
530     defer io_state.deinit();
531     const io = io_state.io();
532 
533     var child = try sys.process.spawn(io, .{
534         .argv = argv,
535         .stdin = .inherit,
536         .stdout = .inherit,
537         .stderr = .inherit,
538     });
539     const term = try sys.process.wait(&child, io);
540     return sys.process.exitCode(term);
541 }
542 
543 fn joinArgs(allocator: Allocator, args: []const []const u8) ![]u8 {
544     var out = std.Io.Writer.Allocating.init(allocator);
545     errdefer out.deinit();
546     for (args, 0..) |arg, index| {
547         if (index != 0) try out.writer.writeByte(0);
548         try out.writer.writeAll(arg);
549     }
550     return try out.toOwnedSlice();
551 }
552 
553 fn manifestForRecord() store_mod.Manifest {
554     return .{
555         .target_triple = sys.capabilities.current.archName(),
556         .build_id = "trace-cli",
557         .mode = "record",
558         .status = "open",
559     };
560 }
561 
562 fn parseTimepoint(text: []const u8) !event.Timepoint {
563     if (std.mem.indexOfScalar(u8, text, ':') == null) {
564         return .{ .thread_id = 1, .seq = try std.fmt.parseUnsigned(u64, text, 0) };
565     }
566 
567     var parts = std.mem.splitScalar(u8, text, ':');
568     const epoch_text = parts.next() orelse return error.InvalidTimepoint;
569     const thread_text = parts.next() orelse return error.InvalidTimepoint;
570     const seq_text = parts.next() orelse return error.InvalidTimepoint;
571     if (parts.next() != null) return error.InvalidTimepoint;
572 
573     return .{
574         .epoch = try std.fmt.parseUnsigned(u64, epoch_text, 0),
575         .thread_id = @intCast(try std.fmt.parseUnsigned(u64, thread_text, 0)),
576         .seq = try std.fmt.parseUnsigned(u64, seq_text, 0),
577     };
578 }
579 
580 fn writeUsage(allocator: Allocator, file: sys.stdio.File) !void {
581     var arena_state = std.heap.ArenaAllocator.init(allocator);
582     defer arena_state.deinit();
583     const builder = pretty.Builder.init(arena_state.allocator());
584     try pretty_usage.Terminal.init(allocator, file, .{}).writeDoc(try usageDocAlloc(builder));
585 }
586 
587 fn renderUsageAlloc(allocator: Allocator, options: pretty.LayoutOptions) ![]u8 {
588     var arena_state = std.heap.ArenaAllocator.init(allocator);
589     defer arena_state.deinit();
590     const builder = pretty.Builder.init(arena_state.allocator());
591     const doc = try usageDocAlloc(builder);
592     return try pretty.renderAlloc(allocator, doc, options);
593 }
594 
595 fn usageDocAlloc(builder: pretty.Builder) !pretty.Doc {
596     var parts: std.ArrayListUnmanaged(pretty.Doc) = .empty;
597     try parts.append(builder.allocator, try builder.styledText(.keyword, "usage"));
598     try parts.append(builder.allocator, try builder.punct(":"));
599     try parts.append(builder.allocator, pretty.hardline);
600     for (usage_commands) |command| {
601         try parts.append(builder.allocator, try builder.spaces(2));
602         try parts.append(builder.allocator, try builder.nest(4, try builder.styledWords(.name, command)));
603         try parts.append(builder.allocator, pretty.hardline);
604     }
605     return try builder.concat(parts.items);
606 }
607 
608 test "timepoint parser accepts full and seq-only forms" {
609     try std.testing.expect(event.Timepoint.eql(
610         .{ .epoch = 1, .thread_id = 2, .seq = 3 },
611         try parseTimepoint("1:2:3"),
612     ));
613     try std.testing.expect(event.Timepoint.eql(
614         .{ .epoch = 0, .thread_id = 1, .seq = 99 },
615         try parseTimepoint("99"),
616     ));
617 }
618 
619 test "usage renders through pretty plain and colored output" {
620     const plain = try renderUsageAlloc(std.testing.allocator, .{ .width = 88 });
621     defer std.testing.allocator.free(plain);
622     try std.testing.expect(std.mem.indexOf(u8, plain, "\x1b[") == null);
623     try std.testing.expect(std.mem.indexOf(u8, plain, "usage:\n") != null);
624     try std.testing.expect(std.mem.indexOf(u8, plain, "trace user-events <trace>") != null);
625 
626     const colored = try renderUsageAlloc(std.testing.allocator, .{ .width = 88, .color = .ansi });
627     defer std.testing.allocator.free(colored);
628     try std.testing.expect(std.mem.indexOf(u8, colored, "\x1b[") != null);
629 }
630 
631 test "usage wraps long command lines at terminal width" {
632     const rendered = try renderUsageAlloc(std.testing.allocator, .{ .width = 48 });
633     defer std.testing.allocator.free(rendered);
634     try std.testing.expect(std.mem.indexOf(u8, rendered, "trace user-event-stats <trace> --group-by\n    <payload-field>") != null);
635 }
636 
637 test "errors render through pretty plain and colored output" {
638     const plain = try pretty_usage.renderErrorTextAlloc(
639         std.testing.allocator,
640         "trace",
641         "InvalidArguments",
642         .{ .layout = .{ .width = 88 } },
643     );
644     defer std.testing.allocator.free(plain);
645     try std.testing.expectEqualStrings("trace: InvalidArguments\n", plain);
646 
647     const colored = try pretty_usage.renderErrorTextAlloc(
648         std.testing.allocator,
649         "trace",
650         "InvalidArguments",
651         .{ .layout = .{ .width = 88, .color = .ansi } },
652     );
653     defer std.testing.allocator.free(colored);
654     try std.testing.expect(std.mem.indexOf(u8, colored, "\x1b[") != null);
655     try std.testing.expect(std.mem.indexOf(u8, colored, "InvalidArguments") != null);
656 }
657 
658 test "user event argument parser accepts filters" {
659     var parsed = try parseUserEventsArgs(std.testing.allocator, &.{
660         "run.trace",
661         "--label",
662         "compiler.query.hit",
663         "--protocol",
664         "tiny.compiler.query/v1",
665         "--payload",
666         "product_kind=function_summary",
667         "--payload",
668         "product_identity=8",
669         "--format",
670         "jsonl",
671     });
672     defer parsed.deinit(std.testing.allocator);
673 
674     try std.testing.expectEqualStrings("run.trace", parsed.trace_path);
675     try std.testing.expectEqualStrings("compiler.query.hit", parsed.filter.label.?);
676     try std.testing.expectEqualStrings("tiny.compiler.query/v1", parsed.filter.protocol.?);
677     try std.testing.expectEqual(@as(usize, 2), parsed.filter.payload_filters.len);
678     try std.testing.expectEqualStrings("product_kind", parsed.filter.payload_filters[0].field);
679     try std.testing.expectEqualStrings("function_summary", parsed.filter.payload_filters[0].value);
680     try std.testing.expectEqualStrings("product_identity", parsed.filter.payload_filters[1].field);
681     try std.testing.expectEqualStrings("8", parsed.filter.payload_filters[1].value);
682     try std.testing.expectError(error.UnsupportedFormat, parseUserEventsArgs(std.testing.allocator, &.{ "run.trace", "--format", "text" }));
683     try std.testing.expectError(error.InvalidArguments, parseUserEventsArgs(std.testing.allocator, &.{ "run.trace", "--payload", "product_kind" }));
684 }
685 
686 test "user event stats argument parser requires group and metric" {
687     var parsed = try parseUserEventStatsArgs(std.testing.allocator, &.{
688         "run.trace",
689         "--label",
690         "compiler.query.finish",
691         "--protocol",
692         "tiny.compiler.query/v1",
693         "--payload",
694         "event=query_finish",
695         "--payload",
696         "product_kind=partial_evaluation",
697         "--group-by",
698         "product_kind",
699         "--metric",
700         "elapsed_ns",
701         "--sort-by",
702         "mean",
703         "--format",
704         "jsonl",
705     });
706     defer parsed.deinit(std.testing.allocator);
707 
708     try std.testing.expectEqualStrings("run.trace", parsed.trace_path);
709     try std.testing.expectEqualStrings("compiler.query.finish", parsed.filter.label.?);
710     try std.testing.expectEqualStrings("tiny.compiler.query/v1", parsed.filter.protocol.?);
711     try std.testing.expectEqual(@as(usize, 2), parsed.filter.payload_filters.len);
712     try std.testing.expectEqualStrings("event", parsed.filter.payload_filters[0].field);
713     try std.testing.expectEqualStrings("query_finish", parsed.filter.payload_filters[0].value);
714     try std.testing.expectEqualStrings("product_kind", parsed.filter.payload_filters[1].field);
715     try std.testing.expectEqualStrings("partial_evaluation", parsed.filter.payload_filters[1].value);
716     try std.testing.expectEqualStrings("product_kind", parsed.group_field.?);
717     try std.testing.expectEqualStrings("elapsed_ns", parsed.metric_field.?);
718     try std.testing.expectEqual(replay.UserEventStatSort.mean, parsed.sort);
719     try std.testing.expectError(error.InvalidArguments, parseUserEventStatsArgs(std.testing.allocator, &.{ "run.trace", "--group-by", "product_kind" }));
720     try std.testing.expectError(error.InvalidArguments, parseUserEventStatsArgs(std.testing.allocator, &.{ "run.trace", "--metric", "elapsed_ns" }));
721     try std.testing.expectError(error.InvalidArguments, parseUserEventStatsArgs(std.testing.allocator, &.{ "run.trace", "--group-by", "product_kind", "--metric", "elapsed_ns", "--sort-by", "median" }));
722 }
723 
724 test "record command creates a verifiable trace" {
725     if (!sys.capabilities.current.isLinux()) return error.SkipZigTest;
726 
727     const allocator = std.testing.allocator;
728     var tmp = std.testing.tmpDir(.{});
729     defer tmp.cleanup();
730 
731     const tmp_root = try tmp.parent_dir.realPathFileAlloc(sys.fs.debugIo(), tmp.sub_path[0..], allocator);
732     defer allocator.free(tmp_root);
733     const trace_path = try sys.path.join(allocator, &.{ tmp_root, "cli-record.trace" });
734     defer allocator.free(trace_path);
735 
736     const args = [_][]const u8{
737         "trace",
738         "record",
739         "--trace",
740         trace_path,
741         "--",
742         "/bin/true",
743     };
744     try std.testing.expectEqual(@as(u8, 0), try dispatch(allocator, &args));
745 
746     const result = try verifyTrace(allocator, trace_path);
747     try std.testing.expectEqual(@as(u64, 3), result.event_count);
748 }