// 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(); const Driver = @import("server/Driver.zig").Protocol; serve_http_requests: CounterEnum("status", @import("network/http.zig").StatusCategory) = .{}, serve_http_evictions: Counter = .{}, serve_inbox_backlog: Counter = .{}, serve_connections: CounterEnum("driver", Driver) = .{}, serve_connection_limit: Counter = .{}, serve_active_connections: GaugeEnum("driver", Driver) = .{}, serve_commands: CounterEnum("driver", Driver) = .{}, serve_unknown_commands: CounterEnum("driver", Driver) = .{}, 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_physical_bytes: Gauge = .{}, 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, }) = .{}, http_navigation_delay_ms: Histogram(&.{ 10, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, }) = .{}, robots_status: CounterEnum("category", @import("network/http.zig").StatusCategory) = .{}, robots_access: CounterEnum("result", enum { allow, deny }) = .{}, cors_check: CounterEnum("result", enum { same_origin, no_cors, simple, preflight }) = .{}, cors_preflight: CounterEnum("result", enum { allowed, blocked }) = .{}, cors_response: CounterEnum("result", enum { allowed, blocked }) = .{}, adblock_verdicts: CounterEnum("verdict", @import("network/adblock/AdBlocker.zig").Verdict) = .{}, adblock_rules: GaugeEnum("state", enum { loaded, skipped, cosmetic }) = .{}, // Emitted as each metric's "# HELP" line. A field without an entry is a // compile error. const help = .{ .serve_http_requests = "HTTP responses sent, by status category (includes the pre-parse 400/413 rejections)", .serve_http_evictions = "HTTP connections closed for sitting past their deadline without completing a request", .serve_inbox_backlog = "Websocket connections closed for queueing more unprocessed messages than the worker could drain", .serve_connections = "Websocket connections accepted, by driver protocol", .serve_connection_limit = "Accepts deferred because the connection budget was full: the listener pauses until a slot frees (counted before any handshake, so no driver label)", .serve_active_connections = "Currently connected clients, by driver protocol", .serve_commands = "Commands dispatched, by driver protocol", .serve_unknown_commands = "Commands rejected for an unknown domain, module or method, by driver protocol", .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_physical_bytes = "V8 heap physical size summed over every live isolate (one per connection).", .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", .http_navigation_delay_ms = "Time in milliseconds a throttled top-level navigation waited", .robots_status = "robots.txt response status", .robots_access = "robots.txt result", .cors_check = "CORS initial classification: same_origin/no_cors need no CORS handling, simple needs response validation only, preflight needs an OPTIONS round-trip first", .cors_preflight = "CORS preflight (OPTIONS) results, one per request that required one", .cors_response = "CORS actual-response validation results", .adblock_verdicts = "Adblocker decisions for evaluated requests, by verdict (none = not blocked, allowed = an exception overrode a block)", .adblock_rules = "Filter-list rules by fate: loaded into the matcher, skipped as unsupported, or cosmetic (domain-scoped element hiding, outside the network realm)", }; 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); } // For callers that track an absolute value and report the change since // their last report. There's no set(): the gauge is a sum over threads, // so a caller can only move its own contribution. pub fn add(self: *Gauge, n: i64) void { _ = @atomicRmw(isize, &self.value, .Add, @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(); } pub fn add(self: *Self, tag: T, n: i64) void { self.values.getPtr(tag).add(n); } 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()); }