// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const lp = @import("lightpanda"); const Metrics = @This(); cdp_connections: Counter = .{}, cdp_connection_limit: Counter = .{}, cdp_active_connections: Gauge = .{}, cdp_commands: Counter = .{}, cdp_unknown_commands: Counter = .{}, js_heap_limits: Counter = .{}, script_errors: Counter = .{}, js_errors: CounterEnum("kind", enum { js_exception, other }) = .{}, arena_hit: CounterEnum("size", @import("ArenaPool.zig").BucketSize) = .{}, arena_miss: CounterEnum("size", @import("ArenaPool.zig").BucketSize) = .{}, arena_inflight: GaugeEnum("size", @import("ArenaPool.zig").BucketSize) = .{}, arena_memory_bytes: Gauge = .{}, navigate: CounterEnum("type", @import("telemetry/telemetry.zig").Event.Navigate.Context) = .{}, js_heap_size_bytes: Histogram(&.{ 4 * 1024 * 1024, 8 * 1024 * 1024, 16 * 1024 * 1024, 32 * 1024 * 1024, 64 * 1024 * 1024, 128 * 1024 * 1024, 256 * 1024 * 1024, 512 * 1024 * 1024, }) = .{}, http_requests: CounterEnum("mode", enum { sync, async }) = .{}, http_status: CounterEnum("category", @import("network/http.zig").StatusCategory) = .{}, http_error: CounterEnum("reason", @import("network/http.zig").ErrorReason) = .{}, http_cache: CounterEnum("result", enum { hit, miss, revalidated }) = .{}, http_redirects: Counter = .{}, http_duration_ms: Histogram(&.{ 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, }) = .{}, http_response_size_bytes: Histogram(&.{ 32 * 1024, 64 * 1024, 128 * 1024, 256 * 1024, 512 * 1024, 1024 * 1024, 2 * 1024 * 1024, 4 * 1024 * 1024, }) = .{}, robots_status: CounterEnum("category", @import("network/http.zig").StatusCategory) = .{}, robots_access: CounterEnum("result", enum { allow, deny }) = .{}, // Emitted as each metric's "# HELP" line. A field without an entry is a // compile error. const help = .{ .cdp_connections = "CDP websocket connections accepted", .cdp_connection_limit = "Connections rejected because --cdp-max-connections was reached", .cdp_active_connections = "Currently connected CDP clients", .cdp_commands = "CDP commands dispatched", .cdp_unknown_commands = "CDP commands rejected for an unknown domain or method", .js_heap_limits = "Pages terminated for reaching the V8 heap limit", .script_errors = "Scripts that failed to evaluate, e.g. an uncaught top-level exception", .js_errors = "Uncaught JS errors (script exceptions, listener/callback throws, unhandled promise rejections); kind=js_exception is a thrown JS value, other is an internal failure (e.g. compilation error, terminated execution)", .arena_hit = "Arena pool acquisitions served from the free list", .arena_miss = "Arena pool acquisitions that had to allocate a new arena", .arena_inflight = "Arenas currently checked out of the pool. Above the bucket's max, every acquisition is a miss and every release is discarded", .arena_memory_bytes = "Backing memory held by pooled arenas, including capacity retained on the free list", .navigate = "Navigations by initiating frame type", .js_heap_size_bytes = "V8 heap physical size, sampled when a page is closed", .http_requests = "HTTP requests submitted, by dispatch mode (excludes internal requests like robots.txt)", .http_status = "Final HTTP response status category (redirects counted once, at the final hop)", .http_error = "HTTP requests that failed before delivering a response, by cause", .http_cache = "HTTP cache lookups by outcome", .http_redirects = "HTTP redirect hops followed", .http_duration_ms = "HTTP request wall-clock duration in milliseconds", .http_response_size_bytes = "HTTP response body size in bytes", .robots_status = "robots.txt response status", .robots_access = "robots.txt result", }; pub fn write(self: *const Metrics, writer: *std.Io.Writer) void { self._write(writer) catch |err| { lp.log.err(.app, "metrics write", .{ .err = err }); }; } fn _write(self: *const Metrics, writer: *std.Io.Writer) !void { try writer.print( "# HELP build_info Lightpanda build information\n" ++ "# TYPE build_info gauge\nbuild_info{{version=\"{s}\"}} 1\n", .{lp.build_config.version}, ); inline for (@typeInfo(Metrics).@"struct".fields) |f| { try @field(self, f.name).write(f.name, @field(help, f.name), writer); } } const Counter = struct { count: usize = 0, pub fn incr(self: *Counter) void { self.incrBy(1); } pub fn incrBy(self: *Counter, c: usize) void { _ = @atomicRmw(usize, &self.count, .Add, c, .monotonic); } fn write(self: *const Counter, comptime name: []const u8, comptime help_text: []const u8, writer: *std.Io.Writer) !void { try writer.writeAll("# HELP " ++ name ++ "_total " ++ help_text ++ "\n" ++ "# TYPE " ++ name ++ "_total counter\n"); try writer.print(name ++ "_total {d}\n", .{self.get()}); } fn get(self: *const Counter) usize { return @atomicLoad(usize, &self.count, .monotonic); } }; const Gauge = struct { value: isize = 0, pub fn incr(self: *Gauge) void { self.incrBy(1); } pub fn incrBy(self: *Gauge, n: usize) void { _ = @atomicRmw(isize, &self.value, .Add, @intCast(n), .monotonic); } pub fn decr(self: *Gauge) void { self.decrBy(1); } pub fn decrBy(self: *Gauge, n: usize) void { _ = @atomicRmw(isize, &self.value, .Sub, @intCast(n), .monotonic); } fn write(self: *const Gauge, comptime name: []const u8, comptime help_text: []const u8, writer: *std.Io.Writer) !void { try writer.writeAll("# HELP " ++ name ++ " " ++ help_text ++ "\n" ++ "# TYPE " ++ name ++ " gauge\n"); try writer.print(name ++ " {d}\n", .{@atomicLoad(isize, &self.value, .monotonic)}); } }; fn GaugeEnum(comptime label: []const u8, comptime T: type) type { return struct { values: std.enums.EnumArray(T, Gauge) = .initFill(.{}), pub const Tag = T; pub const label_name = label; const Self = @This(); pub fn incr(self: *Self, tag: T) void { self.values.getPtr(tag).incr(); } pub fn decr(self: *Self, tag: T) void { self.values.getPtr(tag).decr(); } fn write(self: *const Self, comptime name: []const u8, comptime help_text: []const u8, writer: *std.Io.Writer) !void { try writer.writeAll("# HELP " ++ name ++ " " ++ help_text ++ "\n" ++ "# TYPE " ++ name ++ " gauge\n"); inline for (comptime std.enums.values(Tag)) |tag| { const value = @atomicLoad(isize, &self.values.getPtrConst(tag).value, .monotonic); try writer.print(name ++ "{{" ++ label ++ "=\"" ++ @tagName(tag) ++ "\"}} {d}\n", .{value}); } } }; } fn CounterEnum(comptime label: []const u8, comptime T: type) type { return struct { counts: std.enums.EnumArray(T, Counter) = .initFill(.{}), pub const Tag = T; pub const label_name = label; const Self = @This(); pub fn incr(self: *Self, tag: T) void { self.incrBy(tag, 1); } pub fn incrBy(self: *Self, tag: T, c: usize) void { self.counts.getPtr(tag).incrBy(c); } fn write(self: *const Self, comptime name: []const u8, comptime help_text: []const u8, writer: *std.Io.Writer) !void { try writer.writeAll("# HELP " ++ name ++ "_total " ++ help_text ++ "\n" ++ "# TYPE " ++ name ++ "_total counter\n"); inline for (comptime std.enums.values(Tag)) |tag| { try writer.print(name ++ "_total{{" ++ label ++ "=\"" ++ @tagName(tag) ++ "\"}} {d}\n", .{self.counts.getPtrConst(tag).get()}); } } }; } fn Histogram(comptime upper_bounds: []const usize) type { comptime { std.debug.assert(upper_bounds.len > 0); for (upper_bounds[0 .. upper_bounds.len - 1], upper_bounds[1..]) |a, b| { std.debug.assert(a < b); } } return struct { sum: usize = 0, count: usize = 0, buckets: [upper_bounds.len]usize = @splat(0), const Self = @This(); pub fn observe(self: *Self, value: usize) void { _ = @atomicRmw(usize, &self.count, .Add, 1, .monotonic); _ = @atomicRmw(usize, &self.sum, .Add, value, .monotonic); inline for (upper_bounds, 0..) |upper, i| { if (value <= upper) { _ = @atomicRmw(usize, &self.buckets[i], .Add, 1, .monotonic); return; } } // falls in the implicit +Inf bucket, which is derived from count } fn write(self: *const Self, comptime name: []const u8, comptime help_text: []const u8, writer: *std.Io.Writer) !void { try writer.writeAll("# HELP " ++ name ++ " " ++ help_text ++ "\n" ++ "# TYPE " ++ name ++ " histogram\n"); // le buckets are cumulative var sum: usize = 0; inline for (upper_bounds, 0..) |upper, i| { sum += @atomicLoad(usize, &self.buckets[i], .monotonic); try writer.print(name ++ "_bucket{{le=\"" ++ std.fmt.comptimePrint("{d}", .{upper}) ++ "\"}} {d}\n", .{sum}); } const count = @atomicLoad(usize, &self.count, .monotonic); try writer.print( name ++ "_bucket{{le=\"+Inf\"}} {d}\n" ++ name ++ "_sum {d}\n" ++ name ++ "_count {d}\n", .{ count, @atomicLoad(usize, &self.sum, .monotonic), count }, ); } }; } const testing = @import("testing.zig"); test "Metrics: Counter" { var w = std.Io.Writer.Allocating.init(testing.allocator); defer w.deinit(); var c = Counter{}; c.incr(); c.incrBy(3); try c.write("stat", "counts stats", &w.writer); try testing.expectEqual( \\# HELP stat_total counts stats \\# TYPE stat_total counter \\stat_total 4 \\ , w.written()); } test "Metrics: Gauge" { var w = std.Io.Writer.Allocating.init(testing.allocator); defer w.deinit(); var g = Gauge{}; g.incr(); g.incr(); g.decr(); try g.write("stat", "counts stats", &w.writer); try testing.expectEqual( \\# HELP stat counts stats \\# TYPE stat gauge \\stat 1 \\ , w.written()); } test "Metrics: CounterEnum" { var w = std.Io.Writer.Allocating.init(testing.allocator); defer w.deinit(); var c = CounterEnum("color", enum { red, blue }){}; c.incr(.blue); c.incrBy(.blue, 2); try c.write("stat", "counts stats", &w.writer); try testing.expectEqual( \\# HELP stat_total counts stats \\# TYPE stat_total counter \\stat_total{color="red"} 0 \\stat_total{color="blue"} 3 \\ , w.written()); } test "Metrics: Histogram" { var w = std.Io.Writer.Allocating.init(testing.allocator); defer w.deinit(); var h = Histogram(&.{ 10, 100 }){}; h.observe(5); h.observe(10); // le is inclusive h.observe(50); h.observe(200); // +Inf only try h.write("stat", "counts stats", &w.writer); try testing.expectEqual( \\# HELP stat counts stats \\# TYPE stat histogram \\stat_bucket{le="10"} 2 \\stat_bucket{le="100"} 3 \\stat_bucket{le="+Inf"} 4 \\stat_sum 265 \\stat_count 4 \\ , w.written()); }