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 }