From 6dba13b218798f4928d6fe12de8c807ec4a8eab9 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 11 Aug 2026 20:30:45 +0800 Subject: [PATCH] metrics: Add js memory gauge Unlike the histogram, which is only captured on page teardown, this attempts to capture the live memory (at a few selected known points, e.g. "load"). --- src/Arena.zig | 4 +++- src/Metrics.zig | 9 +++++++++ src/browser/Browser.zig | 13 +++++++++++++ src/browser/Frame.zig | 12 ++++++++++++ src/browser/Page.zig | 1 + 5 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/Arena.zig b/src/Arena.zig index 91041bf56..a72327aa9 100644 --- a/src/Arena.zig +++ b/src/Arena.zig @@ -128,7 +128,9 @@ fn shrank(self: *Arena, n: usize) void { } fn resized(self: *Arena, old_len: usize, new_len: usize) void { - if (new_len >= old_len) self.grew(new_len - old_len) else self.shrank(old_len - new_len); + // self.bytes always contains old_len, so this can't wrap + self.bytes = self.bytes - old_len + new_len; + lp.metrics.arena_memory_bytes.add(@as(i64, @intCast(new_len)) - @as(i64, @intCast(old_len))); } const vtable = Allocator.VTable{ diff --git a/src/Metrics.zig b/src/Metrics.zig index 0791e4031..d06fd9b17 100644 --- a/src/Metrics.zig +++ b/src/Metrics.zig @@ -34,6 +34,7 @@ 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, @@ -91,6 +92,7 @@ const help = .{ .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 CDP 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)", @@ -160,6 +162,13 @@ const Gauge = struct { _ = @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)}); diff --git a/src/browser/Browser.zig b/src/browser/Browser.zig index 42a2b20d2..225af81c5 100644 --- a/src/browser/Browser.zig +++ b/src/browser/Browser.zig @@ -53,6 +53,9 @@ selector_cache: Selector.Cache, // Pinned-arena bytes we haven't told v8 about yet arena_account: lp.Arena.Account = .{}, +// Our isolate's heap size as of the last reportJsHeap(). +last_reported_js_bytes: usize = 0, + // Permission state set via CDP Browser.grantPermissions / setPermission / // resetPermissions, keyed by permission name (e.g. "geolocation"). Read back // by navigator.permissions.query(). Scoped to the Browser so it persists @@ -139,6 +142,10 @@ pub fn deinit(self: *Browser) void { const allocator = self.allocator; self.closeSession(); + + lp.metrics.js_heap_physical_bytes.add(-@as(i64, @intCast(self.last_reported_js_bytes))); + self.last_reported_js_bytes = 0; + // After this returns, the watchdog thread holds no reference to our env // or http_client — required before either is torn down. self.app.watchdog.unregister(&self.watchdog_entry); @@ -210,6 +217,12 @@ pub fn flushArenaMemory(self: *Browser) void { self.env.isolate.adjustAmountOfExternalAllocatedMemory(delta); } +pub fn reportJsHeap(self: *Browser) void { + const bytes = self.env.isolate.getHeapStatistics().total_physical_size; + lp.metrics.js_heap_physical_bytes.add(@as(i64, @intCast(bytes)) - @as(i64, @intCast(self.last_reported_js_bytes))); + self.last_reported_js_bytes = bytes; +} + pub fn runMicrotasks(self: *Browser) void { self.env.runMicrotasks(); } diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 7d6aa65bd..8f7d2ab8b 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -472,6 +472,11 @@ pub fn init(self: *Frame, frame_id: u32, page: *Page, opts: InitOpts) !void { }.runIdleTasks, 200, .{ .name = "frame.runIdleTasks", .blocks_done = false }); } } + + if (parent == null) { + // no point reporting this for each child page + session.browser.reportJsHeap(); + } } pub fn deinit(self: *Frame) void { @@ -543,6 +548,9 @@ pub fn deinit(self: *Frame) void { const browser = page.session.browser; browser.http_client.abortOwner(&self._http_owner); + if (self.parent == null) { + browser.reportJsHeap(); + } browser.env.destroyContext(self.js); @@ -1228,6 +1236,10 @@ pub fn documentIsComplete(self: *Frame) void { error.JsException => {}, // already logged else => log.err(.frame, "document is complete", .{ .err = err, .type = self._type, .url = self.url }), }; + + if (self.parent == null) { + self._session.browser.reportJsHeap(); + } } fn _documentIsComplete(self: *Frame) !void { diff --git a/src/browser/Page.zig b/src/browser/Page.zig index de85880ad..f426ccd62 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -206,6 +206,7 @@ pub fn deinit(self: *Page) void { const session = self.session; lp.metrics.js_heap_size_bytes.observe(session.browser.env.isolate.getHeapStatistics().total_physical_size); + session.browser.reportJsHeap(); defer session.browser.env.memoryPressureNotification(.moderate); self.identity.deinit();