Skip to content

fix(browser): forward uncaught worker errors with their stack - #24210

Open
d2anamaria wants to merge 11 commits into
developfrom
ana/fix/wasm/worker-uncaught
Open

d2anamaria wants to merge 11 commits into
developfrom
ana/fix/wasm/worker-uncaught

Conversation

@d2anamaria

@d2anamaria d2anamaria commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

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 ErrorEvent carries 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 with Uncaught .

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

registerWebWorker only 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, stack and cause, 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 wasm RuntimeError or a custom subclass into a plain Error. 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 error listeners on the Worker object 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 reach window.onerror.

When the error value cannot be structured-cloned, for example a WebAssembly.Exception or an error whose cause holds 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 through normalize instead.

Errors that arrive without an error object, such as cross-origin script errors, get a frame from the ErrorEvent location, 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.

- 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
@d2anamaria
d2anamaria requested a review from a team as a code owner September 8, 2026 14:45
@d2anamaria
d2anamaria requested review from andreiborza, logaretm and msonnb and removed request for a team September 8, 2026 14:45

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/browser/src/integrations/webWorker.ts
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 29.09 kB - -
@sentry/browser - with treeshaking flags 27.35 kB - -
@sentry/browser - with treeshaking flags tracing without tracing 27.26 kB - -
@sentry/browser (incl. Tracing) 50.6 kB - -
@sentry/browser (incl. Tracing + Span Streaming) 50.62 kB - -
@sentry/browser (incl. Tracing, Profiling) 53.61 kB - -
@sentry/browser (incl. Tracing, Replay) 90.15 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 79.25 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 94.85 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 107.83 kB - -
@sentry/browser (incl. Feedback) 46.62 kB - -
@sentry/browser (incl. sendFeedback) 34.15 kB - -
@sentry/browser (incl. FeedbackAsync) 39.26 kB - -
@sentry/browser (incl. Metrics) 30.1 kB - -
@sentry/browser (incl. Logs) 30.35 kB - -
@sentry/browser (incl. Metrics & Logs) 31.02 kB - -
@sentry/react 30.84 kB - -
@sentry/react (incl. Tracing) 52.94 kB - -
@sentry/vue 36.34 kB - -
@sentry/vue (incl. Tracing) 52.91 kB - -
@sentry/svelte 29.11 kB - -
CDN Bundle 30.8 kB - -
CDN Bundle (incl. Tracing) 51.15 kB - -
CDN Bundle (incl. Logs, Metrics) 33.06 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 53.14 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 73.75 kB - -
CDN Bundle (incl. Tracing, Replay) 88.69 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 90.63 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 94.73 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 96.78 kB - -
CDN Bundle - uncompressed 91.16 kB - -
CDN Bundle (incl. Tracing) - uncompressed 152.66 kB - -
CDN Bundle (incl. Logs, Metrics) - uncompressed 97.73 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 158.61 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 227.14 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 272.23 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 278.17 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 285.93 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 291.86 kB - -
@sentry/nextjs (client) 55.27 kB - -
@sentry/sveltekit (client) 51.05 kB - -
@sentry/core/server 39.63 kB - -
@sentry/core/browser 13.66 kB - -
@sentry/node 132.37 kB +0.02% +19 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 82.03 kB - -
@sentry/node - without tracing 89.82 kB +0.04% +30 B 🔺
@sentry/node - without channel injection 111.23 kB +0.02% +19 B 🔺
@sentry/aws-serverless 98.06 kB +0.03% +26 B 🔺
@sentry/cloudflare (withSentry) - minified 204.69 kB - -
@sentry/cloudflare (withSentry) 509.25 kB - -

View base workflow run

@d2anamaria
d2anamaria requested a review from chargome September 9, 2026 07:19
@github-actions

Copy link
Copy Markdown
Contributor

👋 @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.
cursor[bot]

This comment was marked as outdated.

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.
cursor[bot]

This comment was marked as outdated.

An ErrorEvent reports an unknown script as an empty string, which
skipped the worker script and left the fallback frame on the page URL.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/browser/src/integrations/webWorker.ts
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.
@github-actions

Copy link
Copy Markdown
Contributor

👋 @chargome — Please review this PR when you get a chance!

@logaretm logaretm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR, I did a quick glance over it and two things popped up.

Comment thread packages/browser/src/integrations/webWorker.ts Outdated
Comment thread packages/browser/src/integrations/webWorker.ts
@timfish
timfish requested a review from logaretm September 17, 2026 10:32
Comment thread packages/browser/src/integrations/webWorker.ts Outdated

@logaretm logaretm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks mostly fine to me now, one more Q tho

Comment thread packages/browser/src/integrations/webWorker.ts Outdated
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.
@timfish
timfish force-pushed the ana/fix/wasm/worker-uncaught branch from b5a897c to 7e4d6b9 Compare September 17, 2026 15:34
@timfish
timfish requested a review from logaretm September 17, 2026 15:36
Comment on lines +209 to +211
if (!isUnhandledRejection && typeof ErrorEvent === 'function') {
worker.dispatchEvent(new ErrorEvent('error', { message, filename: url || filename, lineno, colno, error }));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 7e4d6b9. Configure here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@github-actions

Copy link
Copy Markdown
Contributor

👋 @chargome, @msonnb, @andreiborza — Please review this PR when you get a chance!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants