fix(browser): forward uncaught worker errors with their stack - #24210
d2anamaria wants to merge 11 commits into
Conversation
- Add an `error` listener in `registerWebWorker` that posts `event.error` (falling back to `event.message`) over the existing `_sentryWorkerError` channel - Add optional `kind` discriminator to `SerializedWorkerError`; a missing `kind` means rejection, so workers registered by an older SDK keep working - Rename `handleForwardedWorkerRejection` to `handleForwardedWorkerError` and branch on `kind` for both the mechanism and `eventFromUnknownInput`'s `isUnhandledRejection` argument - Report forwarded throws under the `auto.browser.web_worker.onerror` mechanism - Restrict `_eventFromRejectionWithPrimitive` to rejections so a thrown primitive is not labelled "Non-Error promise rejection" - Set `Error.stackTraceLimit = 50` in the worker, matching globalHandlersIntegration, since V8's default of 10 truncates stacks before they are forwarded - Wrap the forwarding `postMessage` so a non-cloneable reason is described instead of raising DataCloneError out of the worker's error handler - Correct the doc comment claiming globalHandlers already captures sync worker errors
size-limit report 📦
|
|
👋 @logaretm, @msonnb, @andreiborza — Please review this PR when you get a chance! |
The parent already holds the Worker object, so it listens for its error event and tells globalHandlers to skip the frameless copy that bubbles to window.onerror. The event is not cancelled, so the browser still prints its own report. The skip only applies once the worker announced that it forwards errors, so workers on an older SDK keep the bubbled event. Structured clone resets any error name outside the built-in set, so the worker sends the name separately and the parent restores it. When the reason cannot be cloned, the worker retries with a fresh Error that keeps message and stack, or with a normalized value for anything else. Message-only errors get a frame from the ErrorEvent location, the same way globalHandlers does.
The ErrorEvent filename can differ from the worker script when the error comes from an imported module, so the worker forwards it and the page builds the fallback frame from it. The single-event e2e test now waits for a second worker's event instead of sleeping. The bubbled copy of the first throw is queued right behind the forwarded one, so it would arrive before that event.
An ErrorEvent reports an unknown script as an empty string, which skipped the worker script and left the fallback frame on the page URL.
The page suppresses the bubbled copy of every error once the worker has announced itself, so a retry that posts another Error loses the error completely in browsers that cannot clone Error at all. The retry now sends a plain message and stack copy and the page rebuilds the Error.
|
👋 @chargome — Please review this PR when you get a chance! |
logaretm
left a comment
There was a problem hiding this comment.
Thanks for the PR, I did a quick glance over it and two things popped up.
logaretm
left a comment
There was a problem hiding this comment.
Looks mostly fine to me now, one more Q tho
The page received every uncaught worker error twice: once forwarded with its stack and once bubbled to window.onerror without one. The worker now cancels its error event when, and only when, the forward succeeded, so the message-only copy never leaves the worker and nothing on the page has to correlate the two. A failed forward, a stopped listener or an older SDK all leave the bubbled report in place. A cancelled error prints nothing, so the worker logs it to keep it in DevTools. Cancelling also silences error listeners on the Worker object in the page, so the page replays the event for them with the error object attached. A dispatched event does not reach window.onerror. The e2e app also throws a primitive in the worker to show that the ErrorEvent position gives such an error a usable frame.
b5a897c to
7e4d6b9
Compare
| if (!isUnhandledRejection && typeof ErrorEvent === 'function') { | ||
| worker.dispatchEvent(new ErrorEvent('error', { message, filename: url || filename, lineno, colno, error })); | ||
| } |
There was a problem hiding this comment.
Bug: When a primitive is thrown in a worker, the replayed ErrorEvent is dispatched with a string as the error property, violating the spec which expects an Error object.
Severity: LOW
Suggested Fix
When handling a forwarded error that is not an Error object (i.e., a primitive), create a new Error object from the primitive value before dispatching the ErrorEvent. For example, new ErrorEvent('error', { ..., error: new Error(error) }).
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: packages/browser/src/integrations/webWorker.ts#L209-L211
Potential issue: When a primitive value, such as a string, is thrown inside a web
worker, the integration forwards this value to the main thread. The code then creates
and dispatches a new `ErrorEvent` on the worker object, using the primitive value
directly for the `error` property. This violates the `ErrorEvent` specification, which
expects the `error` property to be an `Error` object. While Sentry's own error capturing
is unaffected, any user-defined error listeners on the worker might behave unexpectedly
if they assume `event.error` is an `Error` object and try to access properties like
`stack`.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 7e4d6b9. Configure here.
| consoleSandbox(() => { | ||
| // eslint-disable-next-line no-console | ||
| console.error(reason); | ||
| }); |
There was a problem hiding this comment.
Cancelled worker errors can be dropped
High Severity
registerWebWorker calls preventDefault after postMessage succeeds, which only queues the payload. If that worker is not hooked up to webWorkerIntegration, or the page bundle is older and never replays the event, the throw never reaches globalHandlers or Worker error listeners, so both the Sentry event and user handlers are lost.
Triggered by project rule: PR Review Guidelines for Cursor Bot
Reviewed by Cursor Bugbot for commit 7e4d6b9. Configure here.
There was a problem hiding this comment.
@timfish I think this is legit. Can we keep the previous matching approach but register the match when the _sentryWorkerError payload arrives instead of relying on the flag?
Worst case we get duplicate errors instead of dropped errors.
|
👋 @chargome, @msonnb, @andreiborza — Please review this PR when you get a chance! |


Uncaught errors thrown inside a web worker reach Sentry without a usable stack. They bubble to the page, so an event is still created, but the propagated
ErrorEventcarries no error object, only a message string. The result is an event with a single synthetic frame pointing at the worker bundle and a value prefixed withUncaught.For plain JavaScript that degrades acceptably: one frame plus a sourcemap still locates the throw. For WebAssembly it fails outright. No frame carries a wasm URL, so the wasm integration finds nothing to match and the event ships with no debug images, even though the worker's images already reached the page. An uncaught wasm trap in a worker is unsymbolicatable today, while the identical trap wrapped in try/catch symbolicates fine.
Root cause
registerWebWorkeronly forwarded unhandled rejections, on the assumption that synchronous errors were already covered by the global handlers. They are captured, but only from the message string, because an error that crosses a worker boundary loses its error object by design.Solution
Uncaught worker errors are now forwarded to the page over the same channel that already carries rejections. Structured clone preserves
message,stackandcause, so the page receives a real error and parses a real stack, the same outcome the caught path already produced. Wasm frames then match their debug images and symbolicate normally. Forwarded errors are distinguishable from rejections by their mechanism, and the worker's stack trace limit now matches the page's so deep stacks are no longer truncated before being sent.Structured clone resets any error name outside the built-in set to
Error, which would turn a wasmRuntimeErroror a custom subclass into a plainError. The worker sends the name separately and the page restores it before building the event.The throw would still bubble to the page after the worker forwards it, so the worker cancels its error event when, and only when, the forward succeeded. Nothing on the page has to correlate the two reports, and a failed forward, a stopped listener or an older SDK in the worker all leave the bubbled report in place. A cancelled error prints nothing, so the worker logs it to keep it visible in DevTools. Cancelling also silences
errorlisteners on theWorkerobject in the page, so the page replays the event for them with the error object attached, which the native event never carries. A dispatched event does not reachwindow.onerror.When the error value cannot be structured-cloned, for example a
WebAssembly.Exceptionor an error whosecauseholds a function, the worker retries with a plain copy of the message and stack that clones in every browser, and the page rebuilds the error from it. Values that are not errors go throughnormalizeinstead.Errors that arrive without an error object, such as cross-origin script errors, get a frame from the
ErrorEventlocation, the same way the global handlers do.Limitations
A worker on this version paired with a page bundle on an older version still forwards, but the older page ignores the new fields and labels every forwarded error as an unhandled rejection.