lib/coz/src/profile.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const pretty_json = @import("pretty").json;
  3 
  4 pub const schema = "coz.profile/v1";
  5 
  6 pub const Location = struct {
  7     file: []const u8,
  8     line: u64,
  9 };
 10 
 11 pub const Startup = struct {
 12     timestamp_ns: u64,
 13 };
 14 
 15 pub const Experiment = struct {
 16     selected: Location,
 17     virtual_speedup: f64,
 18     duration_ns: u64,
 19     selected_samples: u64,
 20 };
 21 
 22 pub const Throughput = struct {
 23     name: []const u8,
 24     delta: u64,
 25 };
 26 
 27 pub const Latency = struct {
 28     name: []const u8,
 29     arrivals: u64,
 30     departures: u64,
 31     outstanding: u64,
 32 };
 33 
 34 pub const Runtime = struct {
 35     duration_ns: u64,
 36 };
 37 
 38 pub const Sample = struct {
 39     location: Location,
 40     count: u64,
 41 };
 42 
 43 pub const LossCounter = union(enum) {
 44     available: u64,
 45     unsupported,
 46     read_failed,
 47 };
 48 
 49 pub const TerminalStatus = enum {
 50     complete,
 51     not_started,
 52     stop_failed,
 53     drain_failed,
 54 };
 55 
 56 pub const Sampling = struct {
 57     record_count: u64 = 0,
 58     sample_record_count: u64 = 0,
 59     lost_record_count: u64 = 0,
 60     lost_event_count: u64 = 0,
 61     lost_samples_record_count: u64 = 0,
 62     lost_samples_count: u64 = 0,
 63     throttle_record_count: u64 = 0,
 64     unthrottle_record_count: u64 = 0,
 65     loss_counter: LossCounter = .unsupported,
 66     terminal_status: TerminalStatus = .not_started,
 67 };
 68 
 69 pub const Event = union(enum) {
 70     startup: Startup,
 71     experiment: Experiment,
 72     throughput: Throughput,
 73     latency: Latency,
 74     runtime: Runtime,
 75     sample: Sample,
 76     sampling: Sampling,
 77 
 78     pub fn writeJsonLine(self: Event, writer: *std.Io.Writer) !void {
 79         var stringify = pretty_json.Writer.init(writer, .minified);
 80         try stringify.beginObject();
 81         try stringify.objectField("schema");
 82         try stringify.write(schema);
 83 
 84         switch (self) {
 85             .startup => |event| {
 86                 try stringify.objectField("event");
 87                 try stringify.write("startup");
 88                 try stringify.objectField("timestamp_ns");
 89                 try stringify.write(event.timestamp_ns);
 90             },
 91             .experiment => |event| {
 92                 try stringify.objectField("event");
 93                 try stringify.write("experiment");
 94                 try stringify.objectField("selected");
 95                 try writeLocation(&stringify, event.selected);
 96                 try stringify.objectField("virtual_speedup");
 97                 try stringify.write(event.virtual_speedup);
 98                 try stringify.objectField("duration_ns");
 99                 try stringify.write(event.duration_ns);
100                 try stringify.objectField("selected_samples");
101                 try stringify.write(event.selected_samples);
102             },
103             .throughput => |event| {
104                 try stringify.objectField("event");
105                 try stringify.write("throughput");
106                 try stringify.objectField("name");
107                 try stringify.write(event.name);
108                 try stringify.objectField("delta");
109                 try stringify.write(event.delta);
110             },
111             .latency => |event| {
112                 try stringify.objectField("event");
113                 try stringify.write("latency");
114                 try stringify.objectField("name");
115                 try stringify.write(event.name);
116                 try stringify.objectField("arrivals");
117                 try stringify.write(event.arrivals);
118                 try stringify.objectField("departures");
119                 try stringify.write(event.departures);
120                 try stringify.objectField("outstanding");
121                 try stringify.write(event.outstanding);
122             },
123             .runtime => |event| {
124                 try stringify.objectField("event");
125                 try stringify.write("runtime");
126                 try stringify.objectField("duration_ns");
127                 try stringify.write(event.duration_ns);
128             },
129             .sample => |event| {
130                 try stringify.objectField("event");
131                 try stringify.write("sample");
132                 try stringify.objectField("location");
133                 try writeLocation(&stringify, event.location);
134                 try stringify.objectField("count");
135                 try stringify.write(event.count);
136             },
137             .sampling => |event| {
138                 try stringify.objectField("event");
139                 try stringify.write("sampling");
140                 try writeSampling(&stringify, event);
141             },
142         }
143 
144         try stringify.endObject();
145         try writer.writeByte('\n');
146     }
147 };
148 
149 pub const ParsedEvent = struct {
150     event: Event,
151 
152     pub fn deinit(self: *ParsedEvent, allocator: std.mem.Allocator) void {
153         switch (self.event) {
154             .startup, .runtime, .sampling => {},
155             .experiment => |event| allocator.free(event.selected.file),
156             .throughput => |event| allocator.free(event.name),
157             .latency => |event| allocator.free(event.name),
158             .sample => |event| allocator.free(event.location.file),
159         }
160         self.* = undefined;
161     }
162 };
163 
164 pub fn parseJsonLine(allocator: std.mem.Allocator, line: []const u8) !ParsedEvent {
165     var parsed = try std.json.parseFromSlice(std.json.Value, allocator, std.mem.trim(u8, line, " \t\r\n"), .{});
166     defer parsed.deinit();
167 
168     const object = switch (parsed.value) {
169         .object => |object| object,
170         else => return error.InvalidProfileJson,
171     };
172 
173     const actual_schema = try jsonString(object.get("schema") orelse return error.InvalidProfileJson);
174     if (!std.mem.eql(u8, actual_schema, schema)) return error.UnsupportedProfileSchema;
175 
176     const event_name = try jsonString(object.get("event") orelse return error.InvalidProfileJson);
177     if (std.mem.eql(u8, event_name, "startup")) {
178         return .{ .event = .{ .startup = .{
179             .timestamp_ns = try jsonU64(object.get("timestamp_ns") orelse return error.InvalidProfileJson),
180         } } };
181     }
182     if (std.mem.eql(u8, event_name, "experiment")) {
183         const virtual_speedup = try jsonF64(object.get("virtual_speedup") orelse return error.InvalidProfileJson);
184         const duration_ns = try jsonU64(object.get("duration_ns") orelse return error.InvalidProfileJson);
185         const selected_samples = try jsonU64(object.get("selected_samples") orelse return error.InvalidProfileJson);
186         return .{ .event = .{ .experiment = .{
187             .selected = try parseOwnedLocation(allocator, object.get("selected") orelse return error.InvalidProfileJson),
188             .virtual_speedup = virtual_speedup,
189             .duration_ns = duration_ns,
190             .selected_samples = selected_samples,
191         } } };
192     }
193     if (std.mem.eql(u8, event_name, "throughput")) {
194         const delta = try jsonU64(object.get("delta") orelse return error.InvalidProfileJson);
195         return .{ .event = .{ .throughput = .{
196             .name = try allocator.dupe(u8, try jsonString(object.get("name") orelse return error.InvalidProfileJson)),
197             .delta = delta,
198         } } };
199     }
200     if (std.mem.eql(u8, event_name, "latency")) {
201         const arrivals = try jsonU64(object.get("arrivals") orelse return error.InvalidProfileJson);
202         const departures = try jsonU64(object.get("departures") orelse return error.InvalidProfileJson);
203         const outstanding = try jsonU64(object.get("outstanding") orelse return error.InvalidProfileJson);
204         return .{ .event = .{ .latency = .{
205             .name = try allocator.dupe(u8, try jsonString(object.get("name") orelse return error.InvalidProfileJson)),
206             .arrivals = arrivals,
207             .departures = departures,
208             .outstanding = outstanding,
209         } } };
210     }
211     if (std.mem.eql(u8, event_name, "runtime")) {
212         return .{ .event = .{ .runtime = .{
213             .duration_ns = try jsonU64(object.get("duration_ns") orelse return error.InvalidProfileJson),
214         } } };
215     }
216     if (std.mem.eql(u8, event_name, "sample")) {
217         const count = try jsonU64(object.get("count") orelse return error.InvalidProfileJson);
218         return .{ .event = .{ .sample = .{
219             .location = try parseOwnedLocation(allocator, object.get("location") orelse return error.InvalidProfileJson),
220             .count = count,
221         } } };
222     }
223     if (std.mem.eql(u8, event_name, "sampling")) {
224         return .{ .event = .{ .sampling = try parseSampling(object) } };
225     }
226 
227     return error.InvalidProfileJson;
228 }
229 
230 fn writeSampling(stringify: *pretty_json.Writer, event: Sampling) !void {
231     try writeU64Field(stringify, "record_count", event.record_count);
232     try writeU64Field(stringify, "sample_record_count", event.sample_record_count);
233     try writeU64Field(stringify, "lost_record_count", event.lost_record_count);
234     try writeU64Field(stringify, "lost_event_count", event.lost_event_count);
235     try writeU64Field(stringify, "lost_samples_record_count", event.lost_samples_record_count);
236     try writeU64Field(stringify, "lost_samples_count", event.lost_samples_count);
237     try writeU64Field(stringify, "throttle_record_count", event.throttle_record_count);
238     try writeU64Field(stringify, "unthrottle_record_count", event.unthrottle_record_count);
239     try stringify.objectField("loss_counter_status");
240     try stringify.write(lossCounterStatus(event.loss_counter));
241     try stringify.objectField("loss_counter_value");
242     switch (event.loss_counter) {
243         .available => |value| try stringify.write(value),
244         .unsupported, .read_failed => try stringify.write(null),
245     }
246     try stringify.objectField("terminal_status");
247     try stringify.write(@tagName(event.terminal_status));
248 }
249 
250 fn writeU64Field(stringify: *pretty_json.Writer, name: []const u8, value: u64) !void {
251     try stringify.objectField(name);
252     try stringify.write(value);
253 }
254 
255 fn lossCounterStatus(counter: LossCounter) []const u8 {
256     return switch (counter) {
257         .available => "available",
258         .unsupported => "unsupported",
259         .read_failed => "read_failed",
260     };
261 }
262 
263 fn parseSampling(object: std.json.ObjectMap) !Sampling {
264     const status = try jsonString(
265         object.get("loss_counter_status") orelse return error.InvalidProfileJson,
266     );
267     const value = object.get("loss_counter_value") orelse return error.InvalidProfileJson;
268     return .{
269         .record_count = try requiredU64(object, "record_count"),
270         .sample_record_count = try requiredU64(object, "sample_record_count"),
271         .lost_record_count = try requiredU64(object, "lost_record_count"),
272         .lost_event_count = try requiredU64(object, "lost_event_count"),
273         .lost_samples_record_count = try requiredU64(object, "lost_samples_record_count"),
274         .lost_samples_count = try requiredU64(object, "lost_samples_count"),
275         .throttle_record_count = try requiredU64(object, "throttle_record_count"),
276         .unthrottle_record_count = try requiredU64(object, "unthrottle_record_count"),
277         .loss_counter = try parseLossCounter(status, value),
278         .terminal_status = try parseTerminalStatus(object),
279     };
280 }
281 
282 fn parseTerminalStatus(object: std.json.ObjectMap) !TerminalStatus {
283     const value = object.get("terminal_status") orelse return error.InvalidProfileJson;
284     const status = try jsonString(value);
285     inline for (
286         @typeInfo(TerminalStatus).@"enum".field_names,
287         @typeInfo(TerminalStatus).@"enum".field_values,
288     ) |field_name, field_name_value| {
289         const field = .{ .name = field_name, .value = field_name_value };
290         if (std.mem.eql(u8, status, field.name)) return @fromBackingInt(@intCast(field.value));
291     }
292     return error.InvalidProfileJson;
293 }
294 
295 fn requiredU64(object: std.json.ObjectMap, name: []const u8) !u64 {
296     return jsonU64(object.get(name) orelse return error.InvalidProfileJson);
297 }
298 
299 fn parseLossCounter(status: []const u8, value: std.json.Value) !LossCounter {
300     if (std.mem.eql(u8, status, "available")) return .{
301         .available = try jsonU64(value),
302     };
303     if (value != .null) return error.InvalidProfileJson;
304     if (std.mem.eql(u8, status, "unsupported")) return .unsupported;
305     if (std.mem.eql(u8, status, "read_failed")) return .read_failed;
306     return error.InvalidProfileJson;
307 }
308 
309 fn writeLocation(stringify: *pretty_json.Writer, location: Location) !void {
310     try stringify.beginObject();
311     try stringify.objectField("file");
312     try stringify.write(location.file);
313     try stringify.objectField("line");
314     try stringify.write(location.line);
315     try stringify.endObject();
316 }
317 
318 fn parseOwnedLocation(allocator: std.mem.Allocator, value: std.json.Value) !Location {
319     const object = switch (value) {
320         .object => |object| object,
321         else => return error.InvalidProfileJson,
322     };
323     const line = try jsonU64(object.get("line") orelse return error.InvalidProfileJson);
324     return .{
325         .file = try allocator.dupe(u8, try jsonString(object.get("file") orelse return error.InvalidProfileJson)),
326         .line = line,
327     };
328 }
329 
330 fn jsonString(value: std.json.Value) ![]const u8 {
331     return switch (value) {
332         .string => |text| text,
333         else => error.InvalidProfileJson,
334     };
335 }
336 
337 fn jsonU64(value: std.json.Value) !u64 {
338     return switch (value) {
339         .integer => |integer| if (integer < 0) error.InvalidProfileJson else @intCast(integer),
340         .number_string => |text| std.fmt.parseInt(u64, text, 10) catch error.InvalidProfileJson,
341         else => error.InvalidProfileJson,
342     };
343 }
344 
345 fn jsonF64(value: std.json.Value) !f64 {
346     return switch (value) {
347         .integer => |integer| @floatFromInt(integer),
348         .float => |float| float,
349         else => error.InvalidProfileJson,
350     };
351 }
352 
353 fn writeEventToBuffer(buffer: []u8, event: Event) ![]const u8 {
354     var writer = std.Io.Writer.fixed(buffer);
355     try event.writeJsonLine(&writer);
356     return writer.buffered();
357 }
358 
359 test "startup record writes versioned JSONL" {
360     var buffer: [128]u8 = undefined;
361     const line = try writeEventToBuffer(&buffer, .{ .startup = .{ .timestamp_ns = 123 } });
362 
363     try std.testing.expectEqualStrings(
364         "{\"schema\":\"coz.profile/v1\",\"event\":\"startup\",\"timestamp_ns\":123}\n",
365         line,
366     );
367 }
368 
369 test "experiment record round-trips selected line and virtual speedup" {
370     var buffer: [512]u8 = undefined;
371     const line = try writeEventToBuffer(&buffer, .{ .experiment = .{
372         .selected = .{ .file = "src/main.zig", .line = 42 },
373         .virtual_speedup = 0.25,
374         .duration_ns = 500_000_000,
375         .selected_samples = 17,
376     } });
377 
378     var parsed = try parseJsonLine(std.testing.allocator, line);
379     defer parsed.deinit(std.testing.allocator);
380 
381     const event = switch (parsed.event) {
382         .experiment => |event| event,
383         else => return error.WrongEvent,
384     };
385 
386     try std.testing.expectEqualStrings("src/main.zig", event.selected.file);
387     try std.testing.expectEqual(@as(u64, 42), event.selected.line);
388     try std.testing.expectEqual(@as(f64, 0.25), event.virtual_speedup);
389     try std.testing.expectEqual(@as(u64, 500_000_000), event.duration_ns);
390     try std.testing.expectEqual(@as(u64, 17), event.selected_samples);
391 }
392 
393 test "throughput record round-trips escaped names" {
394     var buffer: [512]u8 = undefined;
395     const line = try writeEventToBuffer(&buffer, .{ .throughput = .{
396         .name = "items \"done\"\n",
397         .delta = 91,
398     } });
399 
400     var parsed = try parseJsonLine(std.testing.allocator, line);
401     defer parsed.deinit(std.testing.allocator);
402 
403     const event = switch (parsed.event) {
404         .throughput => |event| event,
405         else => return error.WrongEvent,
406     };
407 
408     try std.testing.expectEqualStrings("items \"done\"\n", event.name);
409     try std.testing.expectEqual(@as(u64, 91), event.delta);
410 }
411 
412 test "latency record round-trips arrivals departures and outstanding work" {
413     var buffer: [512]u8 = undefined;
414     const line = try writeEventToBuffer(&buffer, .{ .latency = .{
415         .name = "request",
416         .arrivals = 10,
417         .departures = 7,
418         .outstanding = 3,
419     } });
420 
421     var parsed = try parseJsonLine(std.testing.allocator, line);
422     defer parsed.deinit(std.testing.allocator);
423 
424     const event = switch (parsed.event) {
425         .latency => |event| event,
426         else => return error.WrongEvent,
427     };
428 
429     try std.testing.expectEqualStrings("request", event.name);
430     try std.testing.expectEqual(@as(u64, 10), event.arrivals);
431     try std.testing.expectEqual(@as(u64, 7), event.departures);
432     try std.testing.expectEqual(@as(u64, 3), event.outstanding);
433 }
434 
435 test "runtime and sample records keep nanosecond and location fields explicit" {
436     var runtime_buffer: [256]u8 = undefined;
437     const runtime_line = try writeEventToBuffer(&runtime_buffer, .{ .runtime = .{ .duration_ns = 99 } });
438     var parsed_runtime = try parseJsonLine(std.testing.allocator, runtime_line);
439     defer parsed_runtime.deinit(std.testing.allocator);
440 
441     const runtime = switch (parsed_runtime.event) {
442         .runtime => |event| event,
443         else => return error.WrongEvent,
444     };
445     try std.testing.expectEqual(@as(u64, 99), runtime.duration_ns);
446 
447     var sample_buffer: [512]u8 = undefined;
448     const sample_line = try writeEventToBuffer(&sample_buffer, .{ .sample = .{
449         .location = .{ .file = "src/hot.zig", .line = 9 },
450         .count = 1234,
451     } });
452     var parsed_sample = try parseJsonLine(std.testing.allocator, sample_line);
453     defer parsed_sample.deinit(std.testing.allocator);
454 
455     const sample = switch (parsed_sample.event) {
456         .sample => |event| event,
457         else => return error.WrongEvent,
458     };
459     try std.testing.expectEqualStrings("src/hot.zig", sample.location.file);
460     try std.testing.expectEqual(@as(u64, 9), sample.location.line);
461     try std.testing.expectEqual(@as(u64, 1234), sample.count);
462 }
463 
464 test "sampling record round-trips transport evidence" {
465     var buffer: [1024]u8 = undefined;
466     const line = try writeEventToBuffer(&buffer, .{ .sampling = .{
467         .record_count = 100,
468         .sample_record_count = 91,
469         .lost_record_count = 1,
470         .lost_event_count = 3,
471         .lost_samples_record_count = 2,
472         .lost_samples_count = 4,
473         .throttle_record_count = 1,
474         .unthrottle_record_count = 1,
475         .loss_counter = .{ .available = 7 },
476         .terminal_status = .complete,
477     } });
478     var parsed = try parseJsonLine(std.testing.allocator, line);
479     defer parsed.deinit(std.testing.allocator);
480 
481     const sampling = switch (parsed.event) {
482         .sampling => |event| event,
483         else => return error.WrongEvent,
484     };
485     try std.testing.expectEqual(@as(u64, 100), sampling.record_count);
486     try std.testing.expectEqual(@as(u64, 91), sampling.sample_record_count);
487     try std.testing.expectEqual(@as(u64, 3), sampling.lost_event_count);
488     try std.testing.expectEqual(@as(u64, 4), sampling.lost_samples_count);
489     try std.testing.expectEqual(@as(u64, 7), sampling.loss_counter.available);
490     try std.testing.expectEqual(TerminalStatus.complete, sampling.terminal_status);
491 }
492 
493 test "sampling record validates loss counter state" {
494     const prefix =
495         "{\"schema\":\"coz.profile/v1\",\"event\":\"sampling\"," ++
496         "\"record_count\":0,\"sample_record_count\":0,\"lost_record_count\":0," ++
497         "\"lost_event_count\":0,\"lost_samples_record_count\":0," ++
498         "\"lost_samples_count\":0,\"throttle_record_count\":0," ++
499         "\"unthrottle_record_count\":0,";
500     try std.testing.expectError(error.InvalidProfileJson, parseJsonLine(
501         std.testing.allocator,
502         prefix ++ "\"loss_counter_status\":\"available\",\"loss_counter_value\":null}\n",
503     ));
504     try std.testing.expectError(error.InvalidProfileJson, parseJsonLine(
505         std.testing.allocator,
506         prefix ++ "\"loss_counter_status\":\"unsupported\",\"loss_counter_value\":1}\n",
507     ));
508 }
509 
510 test "parser rejects incompatible schema and missing fields" {
511     try std.testing.expectError(
512         error.UnsupportedProfileSchema,
513         parseJsonLine(std.testing.allocator, "{\"schema\":\"coz.profile/v2\",\"event\":\"runtime\",\"duration_ns\":1}\n"),
514     );
515     try std.testing.expectError(
516         error.InvalidProfileJson,
517         parseJsonLine(std.testing.allocator, "{\"schema\":\"coz.profile/v1\",\"event\":\"runtime\"}\n"),
518     );
519 }