Merge pull request #3063 from lightpanda-io/js-error-metrics

metrics: Add JS error count
This commit is contained in:
Karl Seguin
2026-07-28 09:20:54 +08:00
committed by GitHub
12 changed files with 62 additions and 3 deletions

View File

@@ -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",

View File

@@ -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;
};

View File

@@ -290,6 +290,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 {
@@ -453,6 +454,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 {
@@ -517,7 +519,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),
}
}
};

View File

@@ -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);
}

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<body>
<!-- Fixture for the Page.js_error_count test: one uncaught top-level
exception and one uncaught timer-callback exception. -->
<script>throw new Error("boom");</script>
<script>setTimeout(() => { throw new Error("boom timer"); }, 1);</script>
</body>
</html>

View File

@@ -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;

View File

@@ -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 });
};
},

View File

@@ -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, .{

View File

@@ -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);

View File

@@ -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",

View File

@@ -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;
};

View File

@@ -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;
};