Skip to content

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

Merged
timfish merged 15 commits into
developfrom
ana/fix/wasm/worker-uncaught
Sep 21, 2026
Merged

timfish merged 15 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 and the page has confirmed it handles forwarded errors. The page sends that confirmation once, in reply to the first message from a worker that declared the capability, so a worker added to the integration after its announce is confirmed on its first forwarded error, and older workers never receive a message their handlers would not expect. Each forwarded error records whether the worker cancelled the native event, and the page replays it on the Worker object only in that case. Nothing on the page has to correlate the two reports, and a failed forward, a stopped listener, a worker the page never added to the integration or an older bundle on either side all leave the bubbled report in place. The worst case is a duplicate for an error thrown during the startup round trip. 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.16 kB +0.32% +92 B 🔺
@sentry/browser - with treeshaking flags 27.42 kB +0.32% +85 B 🔺
@sentry/browser - with treeshaking flags tracing without tracing 27.33 kB +0.33% +89 B 🔺
@sentry/browser (incl. Tracing) 50.67 kB +0.22% +110 B 🔺
@sentry/browser (incl. Tracing + Span Streaming) 50.69 kB +0.22% +108 B 🔺
@sentry/browser (incl. Tracing, Profiling) 53.68 kB +0.2% +103 B 🔺
@sentry/browser (incl. Tracing, Replay) 90.21 kB +0.11% +97 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 79.31 kB +0.14% +108 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas) 94.9 kB +0.1% +87 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback) 107.86 kB +0.07% +74 B 🔺
@sentry/browser (incl. Feedback) 46.69 kB +0.18% +81 B 🔺
@sentry/browser (incl. sendFeedback) 34.22 kB +0.26% +87 B 🔺
@sentry/browser (incl. FeedbackAsync) 39.32 kB +0.2% +77 B 🔺
@sentry/browser (incl. Metrics) 30.18 kB +0.32% +94 B 🔺
@sentry/browser (incl. Logs) 30.43 kB +0.3% +91 B 🔺
@sentry/browser (incl. Metrics & Logs) 31.09 kB +0.3% +92 B 🔺
@sentry/react 30.91 kB +0.28% +85 B 🔺
@sentry/react (incl. Tracing) 52.97 kB +0.16% +82 B 🔺
@sentry/vue 36.41 kB +0.24% +86 B 🔺
@sentry/vue (incl. Tracing) 52.96 kB +0.17% +89 B 🔺
@sentry/svelte 29.18 kB +0.3% +86 B 🔺
CDN Bundle 30.86 kB +0.3% +92 B 🔺
CDN Bundle (incl. Tracing) 51.17 kB +0.16% +81 B 🔺
CDN Bundle (incl. Logs, Metrics) 33.12 kB +0.3% +97 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) 53.15 kB +0.15% +79 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) 73.79 kB +0.11% +79 B 🔺
CDN Bundle (incl. Tracing, Replay) 88.7 kB +0.09% +73 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 90.64 kB +0.09% +77 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 94.76 kB +0.11% +95 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 96.78 kB +0.09% +79 B 🔺
CDN Bundle - uncompressed 91.25 kB +0.2% +175 B 🔺
CDN Bundle (incl. Tracing) - uncompressed 152.7 kB +0.12% +177 B 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed 97.82 kB +0.18% +175 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 158.65 kB +0.12% +177 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 227.23 kB +0.08% +177 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 272.27 kB +0.07% +177 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 278.21 kB +0.07% +177 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 285.97 kB +0.07% +177 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 291.9 kB +0.07% +177 B 🔺
@sentry/nextjs (client) 55.3 kB +0.14% +76 B 🔺
@sentry/sveltekit (client) 51.1 kB +0.18% +87 B 🔺
@sentry/core/server 39.97 kB - -
@sentry/core/browser 13.63 kB - -
@sentry/node 133.37 kB +0.02% +20 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 82.15 kB - -
@sentry/node - without tracing 89.92 kB +0.03% +20 B 🔺
@sentry/node - without channel injection 112.16 kB +0.02% +20 B 🔺
@sentry/aws-serverless 98.19 kB +0.02% +18 B 🔺
@sentry/cloudflare (withSentry) - minified 204.67 kB - -
@sentry/cloudflare (withSentry) 509.34 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

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

@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 Outdated
@github-actions

Copy link
Copy Markdown
Contributor

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

A successful postMessage only proves the forward was queued. A worker
that the page never added to the integration, or a page on an older
bundle, would have lost the error entirely once the worker cancelled it.
The page now replies to the worker's announce, and the worker cancels
its error events only once it has that reply. Older workers never sent
the capability, so they never receive the reply either.

@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 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 615cd67. Configure here.

Comment thread packages/browser/src/integrations/webWorker.ts Outdated
Comment thread packages/browser/src/integrations/webWorker.ts Outdated
…e workers

The page replayed every forwarded error, so before the acknowledgement
arrived listeners on the worker object ran twice, once for the native
event and once for the replay. The worker now marks each forwarded error
with whether it cancelled the native event, and the page replays only
those.

The capability travelled only on the one-shot announce, so a worker
added after it was never acknowledged and kept bubbling for good. Every
worker message now carries it, and the page acknowledges on the first
one it sees.
Comment thread packages/browser/src/integrations/webWorker.ts Outdated
@timfish
timfish requested review from logaretm and removed request for logaretm September 21, 2026 15:22
…forwarded

The worker no longer cancels its error events or waits for an
acknowledgement from the page. The page records each forwarded error and
skips the one window.onerror report that matches it, so an error thrown
while the worker script first runs is no longer reported twice, and a
missing forward can only cause a duplicate, never a lost error.
… test

Each waitForError opens its own stream to the event proxy and events are
not ordered across streams, so a separate recorder could lag behind the
event that ends the test and miss it.

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

Pushed some changes to reduce the back and forth for some edge cases. LGTM now.

@timfish
timfish merged commit 5db16e5 into develop Sep 21, 2026
326 checks passed
@timfish
timfish deleted the ana/fix/wasm/worker-uncaught branch September 21, 2026 22:01
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