From b364a8d487a15e55cf164739727517695fd466a8 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 27 Jul 2026 18:02:15 +0800 Subject: [PATCH] metrics: Add JS error count This isn't exhaustive, but it covers _most_ cases where we call v8 (and the most important ones, e.g. from ScriptManager and EventManager). There's also a Page counter which, in the future, I'd like to expose in some way such as via a custom cdp command or in a fetch --dump json to provide insight on the "health" of the page/result. --- src/Metrics.zig | 2 ++ src/browser/EventManager.zig | 2 ++ src/browser/EventManagerBase.zig | 5 ++++- src/browser/Page.zig | 21 +++++++++++++++++++ src/browser/tests/page_js_error.html | 9 ++++++++ .../webapi/SharedWorkerGlobalScope.zig | 2 ++ src/browser/webapi/Timers.zig | 3 +++ src/browser/webapi/Window.zig | 6 ++++++ src/browser/webapi/Worker.zig | 2 ++ src/browser/webapi/WorkerGlobalScope.zig | 7 +++++++ .../webapi/collections/DOMTokenList.zig | 3 ++- src/browser/webapi/collections/NodeList.zig | 3 ++- 12 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 src/browser/tests/page_js_error.html diff --git a/src/Metrics.zig b/src/Metrics.zig index e65148ee7..602eb52be 100644 --- a/src/Metrics.zig +++ b/src/Metrics.zig @@ -28,6 +28,7 @@ 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) = .{}, navigate: CounterEnum("type", @import("telemetry/telemetry.zig").Event.Navigate.Context) = .{}, @@ -82,6 +83,7 @@ const help = .{ .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", .navigate = "Navigations by initiating frame type", diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 003096cd0..3928236ba 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -352,6 +352,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts // don't propagate" rule as addEventListener listeners — see Listener.run. var caught: js.TryCatch.Caught = undefined; const handler_return: ?js.Value = ls.toLocal(inline_handler).tryCallWithThis(js.Value, target_et, .{event}, &caught) catch |err| ret: { + frame._page.recordJsError(err); log.warn(.event, "inline handler", .{ .err = err, .caught = caught }); break :ret null; }; @@ -408,6 +409,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts var caught: js.TryCatch.Caught = undefined; const handler_return: ?js.Value = ls.toLocal(inline_handler).tryCallWithThis(js.Value, current_target, .{event}, &caught) catch |err| ret: { + frame._page.recordJsError(err); log.warn(.event, "inline handler", .{ .err = err, .caught = caught }); break :ret null; }; diff --git a/src/browser/EventManagerBase.zig b/src/browser/EventManagerBase.zig index 7e6174251..885db7b14 100644 --- a/src/browser/EventManagerBase.zig +++ b/src/browser/EventManagerBase.zig @@ -289,6 +289,7 @@ pub fn dispatchDirect( event._current_target = target; var caught: js.TryCatch.Caught = undefined; _ = func.tryCallWithThis(void, target, .{event}, &caught) catch |err| { + page.recordJsError(err); if (err == error.JsException) { event._listeners_did_throw = true; } else { @@ -452,6 +453,7 @@ pub const Listener = struct { .string => |string| { const str = try arena.dupeZ(u8, string.str()); local.eval(str, null) catch |err| { + local.ctx.page.recordJsError(err); if (err == error.JsException) { event._listeners_did_throw = true; } else { @@ -516,7 +518,8 @@ pub const Listener = struct { .frame => |frame| frame.window.reportError(exc, frame) catch |err| { log.warn(.event, "listener report error", .{ .err = err }); }, - .worker => {}, + // No worker error-event plumbing here (yet); still count it. + .worker => local.ctx.page.recordJsError(error.JsException), } } }; diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 098370ede..1ced15e51 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -64,6 +64,10 @@ dom_version: usize = 0, // when it was called broadcast_sequence: u64 = 0, +// Uncaught JS errors attributed to this Page (all of its frames and workers): +// Not exhaustive: some swallowed-callback paths aren't routed here. +js_error_count: usize = 0, + // DOM object factory scoped to this Page's documents. factory: Factory, @@ -228,6 +232,11 @@ pub fn deinit(self: *Page) void { session.arena_pool.release(self.frame_arena); } +pub fn recordJsError(self: *Page, err: anyerror) void { + self.js_error_count += 1; + lp.metrics.js_errors.incr(if (err == error.JsException) .js_exception else .other); +} + pub fn getArena(self: *Page, size_or_bucket: anytype, debug: []const u8) !Allocator { return self.session.getArena(size_or_bucket, debug); } @@ -393,3 +402,15 @@ fn appendFrameExecutions(frame: *Frame, origin: []const u8, arena: Allocator, li try appendFrameExecutions(child, origin, arena, list); } } + +const testing = @import("../testing.zig"); + +test "Page: js_error_count" { + defer testing.reset(); + // One uncaught top-level script exception, one uncaught timer-callback + // exception. + const page = try testing.pageTest("page_js_error.html", .{}); + defer page.close(); + + try testing.expectEqual(2, page.frame().?._page.js_error_count); +} diff --git a/src/browser/tests/page_js_error.html b/src/browser/tests/page_js_error.html new file mode 100644 index 000000000..5a2936c11 --- /dev/null +++ b/src/browser/tests/page_js_error.html @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/src/browser/webapi/SharedWorkerGlobalScope.zig b/src/browser/webapi/SharedWorkerGlobalScope.zig index bf3a6719f..ad3456fb3 100644 --- a/src/browser/webapi/SharedWorkerGlobalScope.zig +++ b/src/browser/webapi/SharedWorkerGlobalScope.zig @@ -293,6 +293,7 @@ fn loadInitialScript(self: *SharedWorkerGlobalScope, script: []const u8) !void { return; } + js_context.page.recordJsError(err); const caught = try_catch.caughtOrError(self._script_arena.?, err); log.err(.browser, "shared worker script error", .{ .url = self._url, .caught = caught }); return; @@ -302,6 +303,7 @@ fn loadInitialScript(self: *SharedWorkerGlobalScope, script: []const u8) !void { return; } + js_context.page.recordJsError(err); const caught = try_catch.caughtOrError(self._script_arena.?, err); log.err(.browser, "shared worker module error", .{ .url = self._url, .caught = caught }); return; diff --git a/src/browser/webapi/Timers.zig b/src/browser/webapi/Timers.zig index ccfdf1f4b..7d2997ece 100644 --- a/src/browser/webapi/Timers.zig +++ b/src/browser/webapi/Timers.zig @@ -212,6 +212,7 @@ const ScheduleCallback = struct { .idle => { const IdleDeadline = @import("IdleDeadline.zig"); ls.toLocal(self.cb).call(void, .{IdleDeadline{}}) catch |err| { + self.exec.page.recordJsError(err); log.warn(.js, "idleCallback", .{ .name = self.name, .err = err }); }; }, @@ -221,11 +222,13 @@ const ScheduleCallback = struct { .worker => |worker| worker._performance.now(), }; ls.toLocal(self.cb).call(void, .{now}) catch |err| { + self.exec.page.recordJsError(err); log.warn(.js, "RAF", .{ .name = self.name, .err = err }); }; }, .normal => { ls.toLocal(self.cb).call(void, self.params) catch |err| { + self.exec.page.recordJsError(err); log.warn(.js, "timer", .{ .name = self.name, .err = err }); }; }, diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 56bd439df..4680e9293 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -579,6 +579,8 @@ pub fn reportError(self: *Window, err: js.Value, frame: *Frame) !void { return; } + frame._page.recordJsError(error.JsException); + const target = self.asEventTarget(); if (!frame._event_manager.hasDirectListeners(target, "error", self._on_error)) { if (comptime builtin.is_test == false) { @@ -1031,6 +1033,10 @@ pub fn unhandledPromiseRejection(self: *Window, no_handler: bool, rejection: js. break :blk .{ "rejectionhandled", self._on_rejection_handled }; }; + if (no_handler) { + frame._page.recordJsError(error.JsException); + } + const target = self.asEventTarget(); if (frame._event_manager.hasDirectListeners(target, event_name, attribute_callback)) { const event = (try @import("event/PromiseRejectionEvent.zig").init(event_name, .{ diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index 61fe9a1b4..9b15e0ab9 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -241,6 +241,7 @@ fn loadInitialScript(self: *Worker, script: []const u8) !void { return; } + js_context.page.recordJsError(err); const caught = try_catch.caughtOrError(self._script_arena.?, err); log.err(.browser, "worker script error", .{ .url = self._url, .caught = caught }); self.fireErrorEvent(caught.exception orelse @errorName(err), null); @@ -251,6 +252,7 @@ fn loadInitialScript(self: *Worker, script: []const u8) !void { return; } + js_context.page.recordJsError(err); const caught = try_catch.caughtOrError(self._script_arena.?, err); log.err(.browser, "worker module error", .{ .url = self._url, .caught = caught }); self.fireErrorEvent(caught.exception orelse @errorName(err), null); diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index 9836ac802..1fff908b6 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -360,6 +360,10 @@ pub fn unhandledPromiseRejection(self: *WorkerGlobalScope, no_handler: bool, rej break :blk .{ "rejectionhandled", self._on_rejection_handled }; }; + if (no_handler) { + self._page.recordJsError(error.JsException); + } + const target = self.asEventTarget(); if (self._event_manager.hasDirectListeners(target, event_name, attribute_callback)) { const event = (try @import("event/PromiseRejectionEvent.zig").init(event_name, .{ @@ -428,6 +432,7 @@ fn importScript(self: *WorkerGlobalScope, arena: Allocator, url: [:0]const u8) ! defer try_catch.deinit(); _ = ls.local.eval(response.body.items, url) catch |err| { + self._page.recordJsError(err); const caught = try_catch.caughtOrError(arena, err); log.err(.browser, "importScript", .{ .url = resolved_url, .caught = caught }); return; @@ -437,6 +442,8 @@ fn importScript(self: *WorkerGlobalScope, arena: Allocator, url: [:0]const u8) ! } pub fn reportError(self: *WorkerGlobalScope, err: JS.Value) !void { + self._page.recordJsError(error.JsException); + const error_event = try ErrorEvent.initTrusted(comptime .wrap("error"), .{ .@"error" = try err.persist(), .message = err.toStringSlice() catch "Unknown error", diff --git a/src/browser/webapi/collections/DOMTokenList.zig b/src/browser/webapi/collections/DOMTokenList.zig index 9ffe389e8..e71ea6b73 100644 --- a/src/browser/webapi/collections/DOMTokenList.zig +++ b/src/browser/webapi/collections/DOMTokenList.zig @@ -247,7 +247,8 @@ pub fn forEach(self: *DOMTokenList, cb_: js.Function, js_this_: ?js.Object, fram continue; } var caught: js.TryCatch.Caught = undefined; - cb.tryCall(void, .{ token, i, self }, &caught) catch { + cb.tryCall(void, .{ token, i, self }, &caught) catch |err| { + frame._page.recordJsError(err); log.debug(.js, "forEach callback", .{ .caught = caught, .source = "DOMTokenList" }); return; }; diff --git a/src/browser/webapi/collections/NodeList.zig b/src/browser/webapi/collections/NodeList.zig index 8b1ac85e7..1d9a44f90 100644 --- a/src/browser/webapi/collections/NodeList.zig +++ b/src/browser/webapi/collections/NodeList.zig @@ -98,7 +98,8 @@ pub fn forEach(self: *NodeList, cb: js.Function, frame: *Frame) !void { const node = try self.getAtIndex(@intCast(i), frame) orelse return; var caught: js.TryCatch.Caught = undefined; - cb.tryCall(void, .{ node, i, self }, &caught) catch { + cb.tryCall(void, .{ node, i, self }, &caught) catch |err| { + frame._page.recordJsError(err); log.debug(.js, "forEach callback", .{ .caught = caught, .source = "nodelist" }); return; };