From 725ad376703d9d5268e04955139e52f28590892a Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Tue, 8 Sep 2026 16:37:56 +0200 Subject: [PATCH 1/6] fix(browser): forward uncaught worker errors with their stack - 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 --- .../browser/src/integrations/webWorker.ts | 99 ++++++++++++++----- 1 file changed, 77 insertions(+), 22 deletions(-) diff --git a/packages/browser/src/integrations/webWorker.ts b/packages/browser/src/integrations/webWorker.ts index e9a8e338d287..5b2a4d1057ab 100644 --- a/packages/browser/src/integrations/webWorker.ts +++ b/packages/browser/src/integrations/webWorker.ts @@ -18,6 +18,8 @@ interface WebWorkerMessage { interface SerializedWorkerError { reason: unknown; filename?: string; + /** Absent on workers registered by an SDK version that only forwarded rejections. */ + kind?: 'error' | 'unhandledrejection'; } interface WebWorkerIntegrationOptions { @@ -153,31 +155,33 @@ function listenForSentryMessages(worker: Worker): void { ]; } - // Handle unhandled rejections forwarded from worker + // Handle errors and unhandled rejections forwarded from worker if (event.data._sentryWorkerError) { - DEBUG_BUILD && debug.log('Sentry worker rejection message received', event.data._sentryWorkerError); - handleForwardedWorkerRejection(event.data._sentryWorkerError); + DEBUG_BUILD && debug.log('Sentry worker error message received', event.data._sentryWorkerError); + handleForwardedWorkerError(event.data._sentryWorkerError); } } }); } -function handleForwardedWorkerRejection(workerError: SerializedWorkerError): void { +function handleForwardedWorkerError(workerError: SerializedWorkerError): void { const client = getClient(); if (!client) { return; } - const stackParser = client.getOptions().stackParser; - const attachStacktrace = client.getOptions().attachStacktrace; + const { stackParser, attachStacktrace } = client.getOptions(); const error = workerError.reason; + // Older workers only ever forwarded rejections and send no `kind`. + const isUnhandledRejection = workerError.kind !== 'error'; - // Follow same pattern as globalHandlers for unhandledrejection - // Handle both primitives and errors the same way - const event = isPrimitive(error) - ? _eventFromRejectionWithPrimitive(error) - : eventFromUnknownInput(stackParser, error, undefined, attachStacktrace, true); + // Follow same pattern as globalHandlers for each source. + // A thrown primitive is not a rejection, so the rejection-specific wording must not apply to it. + const event = + isUnhandledRejection && isPrimitive(error) + ? _eventFromRejectionWithPrimitive(error) + : eventFromUnknownInput(stackParser, error, undefined, attachStacktrace, isUnhandledRejection); event.level = 'error'; @@ -195,11 +199,11 @@ function handleForwardedWorkerRejection(workerError: SerializedWorkerError): voi originalException: error, mechanism: { handled: false, - type: 'auto.browser.web_worker.onunhandledrejection', + type: isUnhandledRejection ? 'auto.browser.web_worker.onunhandledrejection' : 'auto.browser.web_worker.onerror', }, }); - DEBUG_BUILD && debug.log('Captured worker unhandled rejection', error); + DEBUG_BUILD && debug.log(`Captured worker ${isUnhandledRejection ? 'unhandled rejection' : 'error'}`, error); } /** @@ -230,11 +234,12 @@ interface RegisterWebWorkerOptions { * This function will: * - Send debug IDs to the parent thread * - Send module metadata to the parent thread (for thirdPartyErrorFilterIntegration) - * - Set up a handler for unhandled rejections in the worker - * - Forward unhandled rejections to the parent thread for capture + * - Set up handlers for uncaught errors and unhandled rejections in the worker + * - Forward both to the parent thread for capture * - * Note: Synchronous errors in workers are already captured by globalHandlers. - * This only handles unhandled promise rejections which don't bubble to the parent. + * Note: uncaught errors do bubble to the parent, but the propagated `ErrorEvent` carries + * no `error` object, so globalHandlers can only build an event from the message string. + * Forwarding them here preserves the real stack, which matters most for wasm frames. * * @example * ```ts filename={worker.js} @@ -250,6 +255,10 @@ interface RegisterWebWorkerOptions { * - `self`: The worker instance you're calling this function from (self). */ export function registerWebWorker({ self }: RegisterWebWorkerOptions): void { + // Mirrors globalHandlersIntegration. The worker has no client of its own, so without this + // V8's default of 10 truncates stacks before they can be forwarded. + Error.stackTraceLimit = 50; + // Send debug IDs and raw module metadata to parent thread // The metadata will be parsed lazily on the main thread when needed self.postMessage({ @@ -258,6 +267,23 @@ export function registerWebWorker({ self }: RegisterWebWorkerOptions): void { _sentryModuleMetadata: self._sentryModuleMetadata ?? undefined, }); + // Set up error handler inside the worker + // Uncaught errors bubble to the parent, but structured clone preserves `stack` while the + // propagated ErrorEvent does not, so forwarding is what gives the parent real frames + self.addEventListener('error', (event: unknown) => { + const { error, message } = event as { error?: unknown; message?: string }; + + const serializedError: SerializedWorkerError = { + reason: error ?? message, + filename: self.location?.href, + kind: 'error', + }; + + postSerializedWorkerError(self, serializedError); + + DEBUG_BUILD && debug.log('[Sentry Worker] Forwarding error to parent', serializedError); + }); + // Set up unhandledrejection handler inside the worker // Following the same pattern as globalHandlers // unhandled rejections don't bubble to the parent thread, so we need to handle them here @@ -269,18 +295,47 @@ export function registerWebWorker({ self }: RegisterWebWorkerOptions): void { const serializedError: SerializedWorkerError = { reason: reason, filename: self.location?.href, + kind: 'unhandledrejection', }; - // Forward to parent thread + postSerializedWorkerError(self, serializedError); + + DEBUG_BUILD && debug.log('[Sentry Worker] Forwarding unhandled rejection to parent', serializedError); + }); + + DEBUG_BUILD && debug.log('[Sentry Worker] Registered worker with error and unhandled rejection handling'); +} + +/** + * `postMessage` structured-clones the reason. Errors clone well (`message`, `stack` and `cause` + * all survive), but exotic values raise `DataCloneError`, which must never escape the worker's + * own error handler. + */ +function postSerializedWorkerError( + self: MinimalDedicatedWorkerGlobalScope, + serializedError: SerializedWorkerError, +): void { + try { self.postMessage({ _sentryMessage: true, _sentryWorkerError: serializedError, }); + return; + } catch { + // Not cloneable, fall through and describe it instead. + } - DEBUG_BUILD && debug.log('[Sentry Worker] Forwarding unhandled rejection to parent', serializedError); - }); - - DEBUG_BUILD && debug.log('[Sentry Worker] Registered worker with unhandled rejection handling'); + try { + self.postMessage({ + _sentryMessage: true, + _sentryWorkerError: { + ...serializedError, + reason: `Worker error with non-cloneable reason: ${Object.prototype.toString.call(serializedError.reason)}`, + }, + }); + } catch { + // Dropping the forward is better than throwing out of the worker's error handler. + } } function isSentryMessage(eventData: unknown): eventData is WebWorkerMessage { From 5aa926ffe77dae1d951f29281c4743ba0f2ea37f Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Tue, 8 Sep 2026 16:52:09 +0200 Subject: [PATCH 2/6] test(browser): cover uncaught worker error forwarding --- .../tests/errors.test.ts | 43 ++-- .../test/integrations/webWorker.test.ts | 204 ++++++++++++++++++ 2 files changed, 234 insertions(+), 13 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts index a253c5ef4847..6126f0bf4b83 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts @@ -7,9 +7,14 @@ function waitForPageloadSpan() { }); } +// The throw still bubbles to the page after the worker forwards it, so globalHandlers emits a +// second, frameless event for the same error. Every test below selects the forwarded one by its +// mechanism, since that is the event carrying the real stack. +const WORKER_MECHANISM = 'auto.browser.web_worker.onerror'; + test('captures an error with debug ids and pageload trace context', async ({ page }) => { const errorEventPromise = waitForError('browser-webworker-vite', async event => { - return !event.type && !!event.exception?.values?.[0]; + return !event.type && event.exception?.values?.[0]?.mechanism?.type === WORKER_MECHANISM; }); const pageloadSpanPromise = waitForPageloadSpan(); @@ -24,9 +29,15 @@ test('captures an error with debug ids and pageload trace context', async ({ pag const pageloadSpan = await pageloadSpanPromise; expect(errorEvent.exception?.values).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught Error: Uncaught error in worker'); - expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toMatch(/worker-.+\.js$/); + expect(errorEvent.exception?.values?.[0]?.type).toBe('Error'); + expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught error in worker'); + expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toEqual( + expect.arrayContaining([expect.objectContaining({ filename: expect.stringMatching(/worker-.+\.js$/) })]), + ); + + expect(errorEvent.contexts?.worker).toEqual({ + filename: expect.stringMatching(/worker-.+\.js$/), + }); expect(errorEvent.transaction).toBe('/'); expect(pageloadSpan.name).toBe('Pageload'); @@ -75,7 +86,7 @@ test("user worker message handlers don't trigger for sentry messages", async ({ test('captures an error from the second eagerly added worker', async ({ page }) => { const errorEventPromise = waitForError('browser-webworker-vite', async event => { - return !event.type && !!event.exception?.values?.[0]; + return !event.type && event.exception?.values?.[0]?.mechanism?.type === WORKER_MECHANISM; }); const pageloadSpanPromise = waitForPageloadSpan(); @@ -90,9 +101,10 @@ test('captures an error from the second eagerly added worker', async ({ page }) const pageloadSpan = await pageloadSpanPromise; expect(errorEvent.exception?.values).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught Error: Uncaught error in worker 2'); - expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toMatch(/worker2-.+\.js$/); + expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught error in worker 2'); + expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toEqual( + expect.arrayContaining([expect.objectContaining({ filename: expect.stringMatching(/worker2-.+\.js$/) })]), + ); expect(errorEvent.transaction).toBe('/'); expect(pageloadSpan.name).toBe('Pageload'); @@ -120,7 +132,7 @@ test('captures an error from the second eagerly added worker', async ({ page }) test('captures an error from the third lazily added worker', async ({ page }) => { const errorEventPromise = waitForError('browser-webworker-vite', async event => { - return !event.type && !!event.exception?.values?.[0]; + return !event.type && event.exception?.values?.[0]?.mechanism?.type === WORKER_MECHANISM; }); const pageloadSpanPromise = waitForPageloadSpan(); @@ -135,9 +147,10 @@ test('captures an error from the third lazily added worker', async ({ page }) => const pageloadSpan = await pageloadSpanPromise; expect(errorEvent.exception?.values).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught Error: Uncaught error in worker 3'); - expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toMatch(/worker3-.+\.js$/); + expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught error in worker 3'); + expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toEqual( + expect.arrayContaining([expect.objectContaining({ filename: expect.stringMatching(/worker3-.+\.js$/) })]), + ); expect(errorEvent.transaction).toBe('/'); expect(pageloadSpan.name).toBe('Pageload'); @@ -165,7 +178,11 @@ test('captures an error from the third lazily added worker', async ({ page }) => test('worker errors are not tagged as third-party when module metadata is present', async ({ page }) => { const errorEventPromise = waitForError('browser-webworker-vite', async event => { - return !event.type && event.exception?.values?.[0]?.value === 'Uncaught Error: Uncaught error in worker'; + return ( + !event.type && + event.exception?.values?.[0]?.mechanism?.type === WORKER_MECHANISM && + event.exception?.values?.[0]?.value === 'Uncaught error in worker' + ); }); await page.goto('/'); diff --git a/packages/browser/test/integrations/webWorker.test.ts b/packages/browser/test/integrations/webWorker.test.ts index c239e31bd638..d64958af7dc1 100644 --- a/packages/browser/test/integrations/webWorker.test.ts +++ b/packages/browser/test/integrations/webWorker.test.ts @@ -4,8 +4,11 @@ import * as SentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { BrowserClient } from '../../src/client'; import * as helpers from '../../src/helpers'; import { INTEGRATION_NAME, registerWebWorker, webWorkerIntegration } from '../../src/integrations/webWorker'; +import { defaultStackParser } from '../../src/stack-parsers'; +import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; // Mock @sentry/core vi.mock('@sentry/core', async importActual => { @@ -510,6 +513,92 @@ describe('registerWebWorker', () => { _sentryModuleMetadata: rawMetadata, }); }); + + describe('error forwarding', () => { + // registerWebWorker raises this globally, so every test in here has to put it back. + const originalStackTraceLimit = Error.stackTraceLimit; + + afterEach(() => { + Error.stackTraceLimit = originalStackTraceLimit; + }); + + function getListener(type: string): (event: unknown) => void { + const call = mockWorkerSelf.addEventListener.mock.calls.find(([eventType]) => eventType === type); + return call![1]; + } + + it('raises the stack trace limit so forwarded stacks are not truncated', () => { + Error.stackTraceLimit = 10; + + registerWebWorker({ self: mockWorkerSelf as any }); + + expect(Error.stackTraceLimit).toBe(50); + }); + + it('forwards an uncaught error with its stack and kind "error"', () => { + registerWebWorker({ self: mockWorkerSelf as any }); + + const error = new Error('boom'); + getListener('error')({ error, message: 'Uncaught Error: boom' }); + + expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ + _sentryMessage: true, + _sentryWorkerError: { + reason: error, + filename: undefined, + kind: 'error', + }, + }); + }); + + it('falls back to the event message when there is no error object', () => { + registerWebWorker({ self: mockWorkerSelf as any }); + + getListener('error')({ error: null, message: 'Uncaught Error: boom' }); + + expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ + _sentryMessage: true, + _sentryWorkerError: expect.objectContaining({ + reason: 'Uncaught Error: boom', + kind: 'error', + }), + }); + }); + + it('tags forwarded rejections with kind "unhandledrejection"', () => { + registerWebWorker({ self: mockWorkerSelf as any }); + + const reason = new Error('rejected'); + getListener('unhandledrejection')({ reason }); + + expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ + _sentryMessage: true, + _sentryWorkerError: { + reason, + filename: undefined, + kind: 'unhandledrejection', + }, + }); + }); + + it('describes a non-cloneable reason instead of throwing out of the error handler', () => { + registerWebWorker({ self: mockWorkerSelf as any }); + + mockWorkerSelf.postMessage.mockImplementationOnce(() => { + throw new DOMException('could not be cloned', 'DataCloneError'); + }); + + expect(() => getListener('error')({ error: () => {} })).not.toThrow(); + + expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ + _sentryMessage: true, + _sentryWorkerError: expect.objectContaining({ + reason: 'Worker error with non-cloneable reason: [object Function]', + kind: 'error', + }), + }); + }); + }); }); describe('registerWebWorker and webWorkerIntegration', () => { @@ -614,3 +703,118 @@ describe('registerWebWorker and webWorkerIntegration', () => { }); }); }); + +describe('forwarded worker errors', () => { + let client: BrowserClient; + let captureEventSpy: ReturnType; + let messageHandler: (event: any) => void; + + beforeEach(() => { + vi.clearAllMocks(); + + client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + stackParser: defaultStackParser, + }); + SentryCore.setCurrentClient(client); + client.init(); + captureEventSpy = vi.spyOn(client, 'captureEvent'); + + const mockWorker = { addEventListener: vi.fn(), postMessage: vi.fn() }; + const integration = webWorkerIntegration({ worker: mockWorker as any }); + integration.setupOnce!(); + messageHandler = mockWorker.addEventListener.mock.calls[0]![1]; + }); + + function forward(workerError: Record): void { + messageHandler({ + data: { _sentryMessage: true, _sentryWorkerError: workerError }, + stopImmediatePropagation: vi.fn(), + }); + } + + function capturedEvent(): SentryCore.Event { + return captureEventSpy.mock.lastCall![0] as SentryCore.Event; + } + + it('captures a forwarded error with the onerror mechanism', () => { + const error = new Error('boom'); + + forward({ reason: error, filename: 'http://localhost/worker.js', kind: 'error' }); + + expect(captureEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ level: 'error' }), + expect.objectContaining({ + originalException: error, + mechanism: { handled: false, type: 'auto.browser.web_worker.onerror' }, + }), + expect.anything(), + ); + }); + + it('captures a forwarded rejection with the onunhandledrejection mechanism', () => { + const reason = new Error('rejected'); + + forward({ reason, kind: 'unhandledrejection' }); + + expect(captureEventSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + mechanism: { handled: false, type: 'auto.browser.web_worker.onunhandledrejection' }, + }), + expect.anything(), + ); + }); + + it('treats a payload without kind as a rejection, for workers on an older SDK', () => { + forward({ reason: new Error('rejected') }); + + expect(captureEventSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + mechanism: { handled: false, type: 'auto.browser.web_worker.onunhandledrejection' }, + }), + expect.anything(), + ); + }); + + it('does not apply promise-rejection wording to a thrown primitive', () => { + forward({ reason: 'just a string', kind: 'error' }); + + expect(capturedEvent().exception?.values?.[0]).toEqual( + expect.objectContaining({ + type: 'Error', + value: 'just a string', + }), + ); + }); + + it('keeps promise-rejection wording for a rejected primitive', () => { + forward({ reason: 'just a string', kind: 'unhandledrejection' }); + + expect(capturedEvent().exception?.values?.[0]).toEqual( + expect.objectContaining({ + type: 'UnhandledRejection', + value: 'Non-Error promise rejection captured with value: just a string', + }), + ); + }); + + it('parses the forwarded stack into frames, including wasm frames', () => { + const error = new Error('divide by zero'); + error.stack = [ + 'RuntimeError: divide by zero', + ' at trigger_crash (http://localhost:8080/maze.wasm:wasm-function[36]:0x2877)', + ' at runStepGame (http://localhost:8080/worker.js:12:9)', + ].join('\n'); + + forward({ reason: error, kind: 'error' }); + + expect(capturedEvent().exception?.values?.[0]?.stacktrace?.frames).toEqual( + expect.arrayContaining([ + expect.objectContaining({ filename: 'http://localhost:8080/maze.wasm:wasm-function[36]:0x2877' }), + expect.objectContaining({ filename: 'http://localhost:8080/worker.js' }), + ]), + ); + }); +}); From e1d3cae906e6a89a6b5299608e15fa271d1b2b23 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Fri, 11 Sep 2026 12:16:55 +0200 Subject: [PATCH 3/6] fix(browser): Dedupe forwarded worker errors and keep their name 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. --- .../tests/errors.test.ts | 41 ++- .../src/integrations/globalhandlers.ts | 5 +- .../browser/src/integrations/webWorker.ts | 140 +++++++--- .../test/integrations/webWorker.test.ts | 256 +++++++++++++----- 4 files changed, 312 insertions(+), 130 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts index 6126f0bf4b83..6b050b40a8f1 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts @@ -7,14 +7,14 @@ function waitForPageloadSpan() { }); } -// The throw still bubbles to the page after the worker forwards it, so globalHandlers emits a -// second, frameless event for the same error. Every test below selects the forwarded one by its -// mechanism, since that is the event carrying the real stack. +// The throw still bubbles to the page after the worker forwards it, but +// the integration makes globalHandlers skip that frameless copy. So the +// first error event to arrive must be the forwarded one. const WORKER_MECHANISM = 'auto.browser.web_worker.onerror'; test('captures an error with debug ids and pageload trace context', async ({ page }) => { const errorEventPromise = waitForError('browser-webworker-vite', async event => { - return !event.type && event.exception?.values?.[0]?.mechanism?.type === WORKER_MECHANISM; + return !event.type && !!event.exception?.values?.[0]; }); const pageloadSpanPromise = waitForPageloadSpan(); @@ -29,6 +29,7 @@ test('captures an error with debug ids and pageload trace context', async ({ pag const pageloadSpan = await pageloadSpanPromise; expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.mechanism?.type).toBe(WORKER_MECHANISM); expect(errorEvent.exception?.values?.[0]?.type).toBe('Error'); expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught error in worker'); expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toEqual( @@ -63,6 +64,26 @@ test('captures an error with debug ids and pageload trace context', async ({ pag }); }); +test('emits exactly one event for an uncaught worker error', async ({ page }) => { + const mechanisms: Array = []; + // Never resolves. It only records every error event that arrives, so + // the global handler's copy of the throw would show up here. + void waitForError('browser-webworker-vite', event => { + if (!event.type && event.exception?.values?.[0]) { + mechanisms.push(event.exception.values[0].mechanism?.type); + } + return false; + }); + + await page.goto('/'); + + await page.locator('#trigger-error').click(); + + await page.waitForTimeout(2000); + + expect(mechanisms).toEqual([WORKER_MECHANISM]); +}); + test("user worker message handlers don't trigger for sentry messages", async ({ page }) => { const workerReadyPromise = new Promise(resolve => { let workerMessageCount = 0; @@ -86,7 +107,7 @@ test("user worker message handlers don't trigger for sentry messages", async ({ test('captures an error from the second eagerly added worker', async ({ page }) => { const errorEventPromise = waitForError('browser-webworker-vite', async event => { - return !event.type && event.exception?.values?.[0]?.mechanism?.type === WORKER_MECHANISM; + return !event.type && !!event.exception?.values?.[0]; }); const pageloadSpanPromise = waitForPageloadSpan(); @@ -101,6 +122,7 @@ test('captures an error from the second eagerly added worker', async ({ page }) const pageloadSpan = await pageloadSpanPromise; expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.mechanism?.type).toBe(WORKER_MECHANISM); expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught error in worker 2'); expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toEqual( expect.arrayContaining([expect.objectContaining({ filename: expect.stringMatching(/worker2-.+\.js$/) })]), @@ -132,7 +154,7 @@ test('captures an error from the second eagerly added worker', async ({ page }) test('captures an error from the third lazily added worker', async ({ page }) => { const errorEventPromise = waitForError('browser-webworker-vite', async event => { - return !event.type && event.exception?.values?.[0]?.mechanism?.type === WORKER_MECHANISM; + return !event.type && !!event.exception?.values?.[0]; }); const pageloadSpanPromise = waitForPageloadSpan(); @@ -147,6 +169,7 @@ test('captures an error from the third lazily added worker', async ({ page }) => const pageloadSpan = await pageloadSpanPromise; expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.mechanism?.type).toBe(WORKER_MECHANISM); expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught error in worker 3'); expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toEqual( expect.arrayContaining([expect.objectContaining({ filename: expect.stringMatching(/worker3-.+\.js$/) })]), @@ -178,11 +201,7 @@ test('captures an error from the third lazily added worker', async ({ page }) => test('worker errors are not tagged as third-party when module metadata is present', async ({ page }) => { const errorEventPromise = waitForError('browser-webworker-vite', async event => { - return ( - !event.type && - event.exception?.values?.[0]?.mechanism?.type === WORKER_MECHANISM && - event.exception?.values?.[0]?.value === 'Uncaught error in worker' - ); + return !event.type && event.exception?.values?.[0]?.value === 'Uncaught error in worker'; }); await page.goto('/'); diff --git a/packages/browser/src/integrations/globalhandlers.ts b/packages/browser/src/integrations/globalhandlers.ts index 0dbeb46a0fce..f258913a8e16 100644 --- a/packages/browser/src/integrations/globalhandlers.ts +++ b/packages/browser/src/integrations/globalhandlers.ts @@ -156,7 +156,10 @@ export function _eventFromRejectionWithPrimitive(reason: Primitive): Event { }; } -function _enhanceEventWithInitialFrame( +/** + * Adds a frame built from the error location when the event has none. + */ +export function _enhanceEventWithInitialFrame( event: Event, url: string | undefined, lineno: number | undefined, diff --git a/packages/browser/src/integrations/webWorker.ts b/packages/browser/src/integrations/webWorker.ts index 5b2a4d1057ab..23b67f1a2e71 100644 --- a/packages/browser/src/integrations/webWorker.ts +++ b/packages/browser/src/integrations/webWorker.ts @@ -1,9 +1,23 @@ import type { DebugImage, Integration, IntegrationFn } from '@sentry/core'; -import { captureEvent, debug, defineIntegration, getClient, isPlainObject, isPrimitive } from '@sentry/core'; +import { + addNonEnumerableProperty, + captureEvent, + debug, + defineIntegration, + getClient, + isError, + isPlainObject, + isPrimitive, + normalize, +} from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; -import { eventFromUnknownInput } from '../eventbuilder'; -import { WINDOW } from '../helpers'; -import { _eventFromRejectionWithPrimitive, _getUnhandledRejectionError } from './globalhandlers'; +import { eventFromUnknownInput, extractMessage, extractType } from '../eventbuilder'; +import { ignoreNextOnError, WINDOW } from '../helpers'; +import { + _enhanceEventWithInitialFrame, + _eventFromRejectionWithPrimitive, + _getUnhandledRejectionError, +} from './globalhandlers'; export const INTEGRATION_NAME = 'WebWorker' as const; @@ -13,13 +27,21 @@ interface WebWorkerMessage { _sentryModuleMetadata?: Record; // eslint-disable-line @typescript-eslint/no-explicit-any _sentryWorkerError?: SerializedWorkerError; _sentryWasmImages?: Array; + /** Sent by workers that forward uncaught errors, not only rejections. */ + _sentryForwardsErrors?: boolean; } +type WorkerErrorKind = 'error' | 'unhandledrejection'; + interface SerializedWorkerError { reason: unknown; filename?: string; /** Absent on workers registered by an SDK version that only forwarded rejections. */ - kind?: 'error' | 'unhandledrejection'; + kind?: WorkerErrorKind; + /** Structured clone resets any name outside the built-in set to `Error`. */ + name?: string; + lineno?: number; + colno?: number; } interface WebWorkerIntegrationOptions { @@ -112,10 +134,27 @@ export const webWorkerIntegration = defineIntegration(({ worker }: WebWorkerInte })) as IntegrationFn; function listenForSentryMessages(worker: Worker): void { + let forwardsErrors = false; + + // An uncaught worker error fires `error` on the worker object and, unless + // cancelled, is then reported to `window.onerror` in the same task. The + // worker already forwarded it with a real stack, so the global handler + // must skip the message-only copy. Not cancelling keeps the browser's own + // console report. + worker.addEventListener('error', () => { + if (forwardsErrors) { + ignoreNextOnError(); + } + }); + worker.addEventListener('message', event => { if (isSentryMessage(event.data)) { event.stopImmediatePropagation(); // other listeners should not receive this message + if (event.data._sentryForwardsErrors) { + forwardsErrors = true; + } + // Handle debug IDs if (event.data._sentryDebugIds) { DEBUG_BUILD && debug.log('Sentry debugId web worker message received', event.data); @@ -172,9 +211,13 @@ function handleForwardedWorkerError(workerError: SerializedWorkerError): void { const { stackParser, attachStacktrace } = client.getOptions(); - const error = workerError.reason; + const { reason: error, kind, name, filename, lineno, colno } = workerError; // Older workers only ever forwarded rejections and send no `kind`. - const isUnhandledRejection = workerError.kind !== 'error'; + const isUnhandledRejection = kind !== 'error'; + + if (name && isError(error) && error.name !== name) { + addNonEnumerableProperty(error, 'name', name); + } // Follow same pattern as globalHandlers for each source. // A thrown primitive is not a rejection, so the rejection-specific wording must not apply to it. @@ -183,14 +226,18 @@ function handleForwardedWorkerError(workerError: SerializedWorkerError): void { ? _eventFromRejectionWithPrimitive(error) : eventFromUnknownInput(stackParser, error, undefined, attachStacktrace, isUnhandledRejection); + if (!isUnhandledRejection) { + _enhanceEventWithInitialFrame(event, filename, lineno, colno); + } + event.level = 'error'; // Add worker-specific context - if (workerError.filename) { + if (filename) { event.contexts = { ...event.contexts, worker: { - filename: workerError.filename, + filename, }, }; } @@ -256,7 +303,7 @@ interface RegisterWebWorkerOptions { */ export function registerWebWorker({ self }: RegisterWebWorkerOptions): void { // Mirrors globalHandlersIntegration. The worker has no client of its own, so without this - // V8's default of 10 truncates stacks before they can be forwarded. + // V8's default of 10 truncates stacks before this code forwards them. Error.stackTraceLimit = 50; // Send debug IDs and raw module metadata to parent thread @@ -265,51 +312,46 @@ export function registerWebWorker({ self }: RegisterWebWorkerOptions): void { _sentryMessage: true, _sentryDebugIds: self._sentryDebugIds ?? undefined, _sentryModuleMetadata: self._sentryModuleMetadata ?? undefined, + _sentryForwardsErrors: true, }); - // Set up error handler inside the worker - // Uncaught errors bubble to the parent, but structured clone preserves `stack` while the - // propagated ErrorEvent does not, so forwarding is what gives the parent real frames - self.addEventListener('error', (event: unknown) => { - const { error, message } = event as { error?: unknown; message?: string }; + const forward = (serializedError: Omit): void => { + const { reason } = serializedError; - const serializedError: SerializedWorkerError = { - reason: error ?? message, + postSerializedWorkerError(self, { + ...serializedError, filename: self.location?.href, - kind: 'error', - }; - - postSerializedWorkerError(self, serializedError); - - DEBUG_BUILD && debug.log('[Sentry Worker] Forwarding error to parent', serializedError); - }); + name: isError(reason) ? extractType(reason) : undefined, + }); - // Set up unhandledrejection handler inside the worker - // Following the same pattern as globalHandlers - // unhandled rejections don't bubble to the parent thread, so we need to handle them here - self.addEventListener('unhandledrejection', (event: unknown) => { - const reason = _getUnhandledRejectionError(event); + DEBUG_BUILD && debug.log(`[Sentry Worker] Forwarding ${serializedError.kind} to parent`, serializedError); + }; - // Forward the raw reason to parent thread - // The parent will handle primitives vs errors the same way globalHandlers does - const serializedError: SerializedWorkerError = { - reason: reason, - filename: self.location?.href, - kind: 'unhandledrejection', + // Uncaught errors bubble to the parent, but the propagated ErrorEvent + // carries no error object. Forwarding the object keeps the real stack. + self.addEventListener('error', (event: unknown) => { + const { error, message, lineno, colno } = event as { + error?: unknown; + message?: string; + lineno?: number; + colno?: number; }; - postSerializedWorkerError(self, serializedError); + forward({ kind: 'error', reason: error ?? message, lineno, colno }); + }); - DEBUG_BUILD && debug.log('[Sentry Worker] Forwarding unhandled rejection to parent', serializedError); + // Unhandled rejections do not bubble to the parent thread at all. + self.addEventListener('unhandledrejection', (event: unknown) => { + forward({ kind: 'unhandledrejection', reason: _getUnhandledRejectionError(event) }); }); DEBUG_BUILD && debug.log('[Sentry Worker] Registered worker with error and unhandled rejection handling'); } /** - * `postMessage` structured-clones the reason. Errors clone well (`message`, `stack` and `cause` - * all survive), but exotic values raise `DataCloneError`, which must never escape the worker's - * own error handler. + * `postMessage` structured-clones the reason. A `DataCloneError` must never + * escape the worker's own error handler, so the forward is retried with a + * cloneable stand-in that keeps as much of the original as possible. */ function postSerializedWorkerError( self: MinimalDedicatedWorkerGlobalScope, @@ -322,22 +364,30 @@ function postSerializedWorkerError( }); return; } catch { - // Not cloneable, fall through and describe it instead. + // Not cloneable, fall through and send a stand-in instead. } + const { reason } = serializedError; + // A fresh Error keeps message and stack but drops the `cause` that blocked + // the clone. `normalize` only produces cloneable output for everything else. + const cloneableReason = isError(reason) ? cloneableErrorFrom(reason) : normalize(reason); + try { self.postMessage({ _sentryMessage: true, - _sentryWorkerError: { - ...serializedError, - reason: `Worker error with non-cloneable reason: ${Object.prototype.toString.call(serializedError.reason)}`, - }, + _sentryWorkerError: { ...serializedError, reason: cloneableReason }, }); } catch { // Dropping the forward is better than throwing out of the worker's error handler. } } +function cloneableErrorFrom(error: Error): Error { + const clone = new Error(extractMessage(error)); + clone.stack = error.stack; + return clone; +} + function isSentryMessage(eventData: unknown): eventData is WebWorkerMessage { if (!isPlainObject(eventData) || eventData._sentryMessage !== true) { return false; diff --git a/packages/browser/test/integrations/webWorker.test.ts b/packages/browser/test/integrations/webWorker.test.ts index d64958af7dc1..bf38df7584a9 100644 --- a/packages/browser/test/integrations/webWorker.test.ts +++ b/packages/browser/test/integrations/webWorker.test.ts @@ -3,6 +3,7 @@ */ import * as SentryCore from '@sentry/core'; +import type { MockInstance } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { BrowserClient } from '../../src/client'; import * as helpers from '../../src/helpers'; @@ -30,8 +31,17 @@ vi.mock('../../src/helpers', () => ({ WINDOW: { _sentryDebugIds: undefined, }, + ignoreNextOnError: vi.fn(), })); +function getListener(addEventListener: ReturnType, type: string): (event: any) => void { + const call = addEventListener.mock.calls.find(([eventType]) => eventType === type); + if (!call) { + throw new Error(`No ${type} listener registered`); + } + return call[1]; +} + describe('webWorkerIntegration', () => { const mockDebugLog = SentryCore.debug.log as any; @@ -118,9 +128,7 @@ describe('webWorkerIntegration', () => { const integration = webWorkerIntegration({ worker: mockWorker as any }); integration.setupOnce!(); - // Extract the message handler from the addEventListener call - expect(mockWorker.addEventListener.mock.calls).toBeDefined(); - messageHandler = mockWorker.addEventListener.mock.calls[0]![1]; + messageHandler = getListener(mockWorker.addEventListener, 'message'); }); it('ignores non-Sentry messages', () => { @@ -412,6 +420,9 @@ describe('registerWebWorker', () => { _sentryModuleMetadata?: Record; }; + // registerWebWorker raises this globally, so every test has to put it back. + const originalStackTraceLimit = Error.stackTraceLimit; + beforeEach(() => { vi.clearAllMocks(); @@ -421,6 +432,10 @@ describe('registerWebWorker', () => { }; }); + afterEach(() => { + Error.stackTraceLimit = originalStackTraceLimit; + }); + it('posts message with _sentryMessage flag', () => { registerWebWorker({ self: mockWorkerSelf as any }); @@ -429,6 +444,7 @@ describe('registerWebWorker', () => { _sentryMessage: true, _sentryDebugIds: undefined, _sentryModuleMetadata: undefined, + _sentryForwardsErrors: true, }); }); @@ -448,6 +464,7 @@ describe('registerWebWorker', () => { 'worker-file2.js': 'debug-id-2', }, _sentryModuleMetadata: undefined, + _sentryForwardsErrors: true, }); }); @@ -461,6 +478,7 @@ describe('registerWebWorker', () => { _sentryMessage: true, _sentryDebugIds: undefined, _sentryModuleMetadata: undefined, + _sentryForwardsErrors: true, }); }); @@ -478,6 +496,7 @@ describe('registerWebWorker', () => { _sentryMessage: true, _sentryDebugIds: undefined, _sentryModuleMetadata: rawMetadata, + _sentryForwardsErrors: true, }); }); @@ -490,6 +509,7 @@ describe('registerWebWorker', () => { _sentryMessage: true, _sentryDebugIds: undefined, _sentryModuleMetadata: undefined, + _sentryForwardsErrors: true, }); }); @@ -511,20 +531,13 @@ describe('registerWebWorker', () => { 'worker-file.js': 'debug-id-1', }, _sentryModuleMetadata: rawMetadata, + _sentryForwardsErrors: true, }); }); describe('error forwarding', () => { - // registerWebWorker raises this globally, so every test in here has to put it back. - const originalStackTraceLimit = Error.stackTraceLimit; - - afterEach(() => { - Error.stackTraceLimit = originalStackTraceLimit; - }); - - function getListener(type: string): (event: unknown) => void { - const call = mockWorkerSelf.addEventListener.mock.calls.find(([eventType]) => eventType === type); - return call![1]; + function trigger(type: string, event: unknown): void { + getListener(mockWorkerSelf.addEventListener, type)(event); } it('raises the stack trace limit so forwarded stacks are not truncated', () => { @@ -535,11 +548,11 @@ describe('registerWebWorker', () => { expect(Error.stackTraceLimit).toBe(50); }); - it('forwards an uncaught error with its stack and kind "error"', () => { + it('forwards an uncaught error with its location, name and kind "error"', () => { registerWebWorker({ self: mockWorkerSelf as any }); const error = new Error('boom'); - getListener('error')({ error, message: 'Uncaught Error: boom' }); + trigger('error', { error, message: 'Uncaught Error: boom', lineno: 12, colno: 9 }); expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ _sentryMessage: true, @@ -547,20 +560,39 @@ describe('registerWebWorker', () => { reason: error, filename: undefined, kind: 'error', + name: 'Error', + lineno: 12, + colno: 9, }, }); }); + it('sends the error name separately because structured clone resets it', () => { + registerWebWorker({ self: mockWorkerSelf as any }); + + const error = new Error('divide by zero'); + error.name = 'RuntimeError'; + trigger('error', { error }); + + expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ + _sentryMessage: true, + _sentryWorkerError: expect.objectContaining({ reason: error, name: 'RuntimeError' }), + }); + }); + it('falls back to the event message when there is no error object', () => { registerWebWorker({ self: mockWorkerSelf as any }); - getListener('error')({ error: null, message: 'Uncaught Error: boom' }); + trigger('error', { error: null, message: 'Uncaught Error: boom', lineno: 3, colno: 7 }); expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ _sentryMessage: true, _sentryWorkerError: expect.objectContaining({ reason: 'Uncaught Error: boom', kind: 'error', + name: undefined, + lineno: 3, + colno: 7, }), }); }); @@ -569,7 +601,7 @@ describe('registerWebWorker', () => { registerWebWorker({ self: mockWorkerSelf as any }); const reason = new Error('rejected'); - getListener('unhandledrejection')({ reason }); + trigger('unhandledrejection', { reason }); expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ _sentryMessage: true, @@ -577,25 +609,73 @@ describe('registerWebWorker', () => { reason, filename: undefined, kind: 'unhandledrejection', + name: 'Error', }, }); }); - it('describes a non-cloneable reason instead of throwing out of the error handler', () => { - registerWebWorker({ self: mockWorkerSelf as any }); + describe('when the reason cannot be structured-cloned', () => { + beforeEach(() => { + mockWorkerSelf.postMessage.mockImplementation(message => structuredClone(message)); + }); - mockWorkerSelf.postMessage.mockImplementationOnce(() => { - throw new DOMException('could not be cloned', 'DataCloneError'); + it('retries with a fresh error that keeps message and stack but drops the cause', () => { + registerWebWorker({ self: mockWorkerSelf as any }); + + const error = new Error('boom') as Error & { cause?: unknown }; + error.cause = () => {}; + expect(() => trigger('error', { error })).not.toThrow(); + + // The mocked postMessage clones for real, so a third call proves the + // retry no longer carries the function that blocked the first one. + expect(mockWorkerSelf.postMessage).toHaveBeenCalledTimes(3); + expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ + _sentryMessage: true, + _sentryWorkerError: expect.objectContaining({ + reason: expect.objectContaining({ message: 'boom', stack: error.stack }), + name: 'Error', + kind: 'error', + }), + }); }); - expect(() => getListener('error')({ error: () => {} })).not.toThrow(); + it('keeps the message and stack of a WebAssembly.Exception', () => { + registerWebWorker({ self: mockWorkerSelf as any }); - expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ - _sentryMessage: true, - _sentryWorkerError: expect.objectContaining({ - reason: 'Worker error with non-cloneable reason: [object Function]', - kind: 'error', - }), + const tag = new WebAssembly.Tag({ parameters: [] }); + const exception = new WebAssembly.Exception(tag, [], { traceStack: true }); + trigger('error', { error: exception }); + + expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ + _sentryMessage: true, + _sentryWorkerError: expect.objectContaining({ + reason: expect.objectContaining({ message: 'wasm exception', stack: exception.stack }), + name: 'WebAssembly.Exception', + }), + }); + }); + + it('normalizes a reason that is not an error', () => { + registerWebWorker({ self: mockWorkerSelf as any }); + + trigger('unhandledrejection', { reason: { retry: () => {} } }); + + expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ + _sentryMessage: true, + _sentryWorkerError: expect.objectContaining({ + reason: { retry: '[Function: retry]' }, + kind: 'unhandledrejection', + }), + }); + }); + + it('does not throw out of the error handler when the retry fails as well', () => { + registerWebWorker({ self: mockWorkerSelf as any }); + mockWorkerSelf.postMessage.mockImplementation(() => { + throw new DOMException('could not be cloned', 'DataCloneError'); + }); + + expect(() => trigger('error', { error: new Error('boom') })).not.toThrow(); }); }); }); @@ -667,6 +747,7 @@ describe('registerWebWorker and webWorkerIntegration', () => { _sentryMessage: true, _sentryDebugIds: mockWorker._sentryDebugIds, _sentryModuleMetadata: undefined, + _sentryForwardsErrors: true, }); expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({ @@ -688,6 +769,7 @@ describe('registerWebWorker and webWorkerIntegration', () => { _sentryMessage: true, _sentryDebugIds: mockWorker3._sentryDebugIds, _sentryModuleMetadata: undefined, + _sentryForwardsErrors: true, }); expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({ @@ -706,8 +788,8 @@ describe('registerWebWorker and webWorkerIntegration', () => { describe('forwarded worker errors', () => { let client: BrowserClient; - let captureEventSpy: ReturnType; - let messageHandler: (event: any) => void; + let captureEventSpy: MockInstance; + let mockWorker: { addEventListener: ReturnType; postMessage: ReturnType }; beforeEach(() => { vi.clearAllMocks(); @@ -720,21 +802,31 @@ describe('forwarded worker errors', () => { client.init(); captureEventSpy = vi.spyOn(client, 'captureEvent'); - const mockWorker = { addEventListener: vi.fn(), postMessage: vi.fn() }; + mockWorker = { addEventListener: vi.fn(), postMessage: vi.fn() }; const integration = webWorkerIntegration({ worker: mockWorker as any }); integration.setupOnce!(); - messageHandler = mockWorker.addEventListener.mock.calls[0]![1]; }); - function forward(workerError: Record): void { - messageHandler({ - data: { _sentryMessage: true, _sentryWorkerError: workerError }, + function receive(data: Record): void { + getListener( + mockWorker.addEventListener, + 'message', + )({ + data: { _sentryMessage: true, ...data }, stopImmediatePropagation: vi.fn(), }); } - function capturedEvent(): SentryCore.Event { - return captureEventSpy.mock.lastCall![0] as SentryCore.Event; + function forward(workerError: Record): void { + receive({ _sentryWorkerError: workerError }); + } + + function expectCapturedException(exception: Record): void { + expect(captureEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ exception: { values: [expect.objectContaining(exception)] } }), + expect.anything(), + expect.anything(), + ); } it('captures a forwarded error with the onerror mechanism', () => { @@ -752,22 +844,12 @@ describe('forwarded worker errors', () => { ); }); - it('captures a forwarded rejection with the onunhandledrejection mechanism', () => { - const reason = new Error('rejected'); - - forward({ reason, kind: 'unhandledrejection' }); - - expect(captureEventSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - mechanism: { handled: false, type: 'auto.browser.web_worker.onunhandledrejection' }, - }), - expect.anything(), - ); - }); - - it('treats a payload without kind as a rejection, for workers on an older SDK', () => { - forward({ reason: new Error('rejected') }); + it.each([ + ['kind "unhandledrejection"', 'unhandledrejection'], + // Workers registered by an older SDK only forwarded rejections and sent no kind. + ['no kind', undefined], + ])('captures a forwarded rejection with %s using the onunhandledrejection mechanism', (_, kind) => { + forward({ reason: new Error('rejected'), kind }); expect(captureEventSpy).toHaveBeenCalledWith( expect.anything(), @@ -781,40 +863,68 @@ describe('forwarded worker errors', () => { it('does not apply promise-rejection wording to a thrown primitive', () => { forward({ reason: 'just a string', kind: 'error' }); - expect(capturedEvent().exception?.values?.[0]).toEqual( - expect.objectContaining({ - type: 'Error', - value: 'just a string', - }), - ); + expectCapturedException({ type: 'Error', value: 'just a string' }); }); it('keeps promise-rejection wording for a rejected primitive', () => { forward({ reason: 'just a string', kind: 'unhandledrejection' }); - expect(capturedEvent().exception?.values?.[0]).toEqual( - expect.objectContaining({ - type: 'UnhandledRejection', - value: 'Non-Error promise rejection captured with value: just a string', - }), - ); + expectCapturedException({ + type: 'UnhandledRejection', + value: 'Non-Error promise rejection captured with value: just a string', + }); }); - it('parses the forwarded stack into frames, including wasm frames', () => { + it('restores the name that structured clone dropped and parses the forwarded stack', () => { const error = new Error('divide by zero'); + error.name = 'RuntimeError'; error.stack = [ 'RuntimeError: divide by zero', ' at trigger_crash (http://localhost:8080/maze.wasm:wasm-function[36]:0x2877)', ' at runStepGame (http://localhost:8080/worker.js:12:9)', ].join('\n'); + const cloned = structuredClone(error); + expect(cloned.name).toBe('Error'); + + forward({ reason: cloned, name: 'RuntimeError', kind: 'error' }); + + expectCapturedException({ + type: 'RuntimeError', + value: 'divide by zero', + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ filename: 'http://localhost:8080/maze.wasm:wasm-function[36]:0x2877' }), + expect.objectContaining({ filename: 'http://localhost:8080/worker.js' }), + ]), + }, + }); + }); - forward({ reason: error, kind: 'error' }); + it('adds a frame from the error location when a message-only error has no stack', () => { + forward({ + reason: 'Uncaught Error: boom', + kind: 'error', + filename: 'http://localhost/worker.js', + lineno: 12, + colno: 9, + }); - expect(capturedEvent().exception?.values?.[0]?.stacktrace?.frames).toEqual( - expect.arrayContaining([ - expect.objectContaining({ filename: 'http://localhost:8080/maze.wasm:wasm-function[36]:0x2877' }), - expect.objectContaining({ filename: 'http://localhost:8080/worker.js' }), - ]), - ); + expectCapturedException({ + value: 'Uncaught Error: boom', + stacktrace: { + frames: [expect.objectContaining({ filename: 'http://localhost/worker.js', lineno: 12, colno: 9 })], + }, + }); + }); + + it('skips the global onerror copy only once the worker announced that it forwards errors', () => { + const onWorkerError = getListener(mockWorker.addEventListener, 'error'); + + onWorkerError({}); + expect(helpers.ignoreNextOnError).not.toHaveBeenCalled(); + + receive({ _sentryDebugIds: undefined, _sentryForwardsErrors: true }); + onWorkerError({}); + expect(helpers.ignoreNextOnError).toHaveBeenCalledTimes(1); }); }); From 62239dd8e5b127a6ccda8537d468e2b8f3fc4fed Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Fri, 11 Sep 2026 12:38:58 +0200 Subject: [PATCH 4/6] fix(browser): Use the throwing script for the worker fallback frame 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. --- .../tests/errors.test.ts | 18 ++++++++++++--- .../browser/src/integrations/webWorker.ts | 11 +++++---- .../test/integrations/webWorker.test.ts | 23 ++++++++++++++----- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts index 6b050b40a8f1..8a6f4ab60910 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts @@ -67,7 +67,7 @@ test('captures an error with debug ids and pageload trace context', async ({ pag test('emits exactly one event for an uncaught worker error', async ({ page }) => { const mechanisms: Array = []; // Never resolves. It only records every error event that arrives, so - // the global handler's copy of the throw would show up here. + // the global handler's copy of a throw would show up here. void waitForError('browser-webworker-vite', event => { if (!event.type && event.exception?.values?.[0]) { mechanisms.push(event.exception.values[0].mechanism?.type); @@ -75,13 +75,25 @@ test('emits exactly one event for an uncaught worker error', async ({ page }) => return false; }); + const firstErrorPromise = waitForError('browser-webworker-vite', event => { + return event.exception?.values?.[0]?.value === 'Uncaught error in worker'; + }); + const secondErrorPromise = waitForError('browser-webworker-vite', event => { + return event.exception?.values?.[0]?.value === 'Uncaught error in worker 2'; + }); + await page.goto('/'); await page.locator('#trigger-error').click(); + await firstErrorPromise; - await page.waitForTimeout(2000); + // The bubbled copy of the first throw is queued right behind the forwarded + // one, so the second worker's event arriving without it in between is the + // signal that it was suppressed. + await page.locator('#trigger-error-2').click(); + await secondErrorPromise; - expect(mechanisms).toEqual([WORKER_MECHANISM]); + expect(mechanisms).toEqual([WORKER_MECHANISM, WORKER_MECHANISM]); }); test("user worker message handlers don't trigger for sentry messages", async ({ page }) => { diff --git a/packages/browser/src/integrations/webWorker.ts b/packages/browser/src/integrations/webWorker.ts index 23b67f1a2e71..2d09106ba80e 100644 --- a/packages/browser/src/integrations/webWorker.ts +++ b/packages/browser/src/integrations/webWorker.ts @@ -40,6 +40,8 @@ interface SerializedWorkerError { kind?: WorkerErrorKind; /** Structured clone resets any name outside the built-in set to `Error`. */ name?: string; + /** Script the error was thrown in, which can differ from the worker script. */ + url?: string; lineno?: number; colno?: number; } @@ -211,7 +213,7 @@ function handleForwardedWorkerError(workerError: SerializedWorkerError): void { const { stackParser, attachStacktrace } = client.getOptions(); - const { reason: error, kind, name, filename, lineno, colno } = workerError; + const { reason: error, kind, name, filename, url, lineno, colno } = workerError; // Older workers only ever forwarded rejections and send no `kind`. const isUnhandledRejection = kind !== 'error'; @@ -227,7 +229,7 @@ function handleForwardedWorkerError(workerError: SerializedWorkerError): void { : eventFromUnknownInput(stackParser, error, undefined, attachStacktrace, isUnhandledRejection); if (!isUnhandledRejection) { - _enhanceEventWithInitialFrame(event, filename, lineno, colno); + _enhanceEventWithInitialFrame(event, url ?? filename, lineno, colno); } event.level = 'error'; @@ -330,14 +332,15 @@ export function registerWebWorker({ self }: RegisterWebWorkerOptions): void { // Uncaught errors bubble to the parent, but the propagated ErrorEvent // carries no error object. Forwarding the object keeps the real stack. self.addEventListener('error', (event: unknown) => { - const { error, message, lineno, colno } = event as { + const { error, message, filename, lineno, colno } = event as { error?: unknown; message?: string; + filename?: string; lineno?: number; colno?: number; }; - forward({ kind: 'error', reason: error ?? message, lineno, colno }); + forward({ kind: 'error', reason: error ?? message, url: filename, lineno, colno }); }); // Unhandled rejections do not bubble to the parent thread at all. diff --git a/packages/browser/test/integrations/webWorker.test.ts b/packages/browser/test/integrations/webWorker.test.ts index bf38df7584a9..836ecb165ec7 100644 --- a/packages/browser/test/integrations/webWorker.test.ts +++ b/packages/browser/test/integrations/webWorker.test.ts @@ -418,6 +418,7 @@ describe('registerWebWorker', () => { addEventListener: ReturnType; _sentryDebugIds?: Record; _sentryModuleMetadata?: Record; + location?: { href?: string }; }; // registerWebWorker raises this globally, so every test has to put it back. @@ -551,16 +552,24 @@ describe('registerWebWorker', () => { it('forwards an uncaught error with its location, name and kind "error"', () => { registerWebWorker({ self: mockWorkerSelf as any }); + mockWorkerSelf.location = { href: 'http://localhost/worker.js' }; const error = new Error('boom'); - trigger('error', { error, message: 'Uncaught Error: boom', lineno: 12, colno: 9 }); + trigger('error', { + error, + message: 'Uncaught Error: boom', + filename: 'http://localhost/chunk.js', + lineno: 12, + colno: 9, + }); expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ _sentryMessage: true, _sentryWorkerError: { reason: error, - filename: undefined, + filename: 'http://localhost/worker.js', kind: 'error', name: 'Error', + url: 'http://localhost/chunk.js', lineno: 12, colno: 9, }, @@ -900,20 +909,22 @@ describe('forwarded worker errors', () => { }); }); - it('adds a frame from the error location when a message-only error has no stack', () => { + it.each([ + ['the script that threw', 'http://localhost/chunk.js', 'http://localhost/chunk.js'], + ['the worker script when the event has no url', undefined, 'http://localhost/worker.js'], + ])('adds a frame at %s when a message-only error has no stack', (_, url, frameFilename) => { forward({ reason: 'Uncaught Error: boom', kind: 'error', filename: 'http://localhost/worker.js', + url, lineno: 12, colno: 9, }); expectCapturedException({ value: 'Uncaught Error: boom', - stacktrace: { - frames: [expect.objectContaining({ filename: 'http://localhost/worker.js', lineno: 12, colno: 9 })], - }, + stacktrace: { frames: [expect.objectContaining({ filename: frameFilename, lineno: 12, colno: 9 })] }, }); }); From c845e9da7ac56a476a5df58dfaf426d8ff263d05 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Fri, 11 Sep 2026 14:15:59 +0200 Subject: [PATCH 5/6] fix(browser): Treat an empty ErrorEvent filename as unknown An ErrorEvent reports an unknown script as an empty string, which skipped the worker script and left the fallback frame on the page URL. --- packages/browser/src/integrations/webWorker.ts | 3 ++- packages/browser/test/integrations/webWorker.test.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/browser/src/integrations/webWorker.ts b/packages/browser/src/integrations/webWorker.ts index 2d09106ba80e..2e08fa054e9a 100644 --- a/packages/browser/src/integrations/webWorker.ts +++ b/packages/browser/src/integrations/webWorker.ts @@ -229,7 +229,8 @@ function handleForwardedWorkerError(workerError: SerializedWorkerError): void { : eventFromUnknownInput(stackParser, error, undefined, attachStacktrace, isUnhandledRejection); if (!isUnhandledRejection) { - _enhanceEventWithInitialFrame(event, url ?? filename, lineno, colno); + // An ErrorEvent reports an unknown script as an empty string. + _enhanceEventWithInitialFrame(event, url || filename, lineno, colno); } event.level = 'error'; diff --git a/packages/browser/test/integrations/webWorker.test.ts b/packages/browser/test/integrations/webWorker.test.ts index 836ecb165ec7..96b3f89462c6 100644 --- a/packages/browser/test/integrations/webWorker.test.ts +++ b/packages/browser/test/integrations/webWorker.test.ts @@ -912,6 +912,7 @@ describe('forwarded worker errors', () => { it.each([ ['the script that threw', 'http://localhost/chunk.js', 'http://localhost/chunk.js'], ['the worker script when the event has no url', undefined, 'http://localhost/worker.js'], + ['the worker script when the event url is empty', '', 'http://localhost/worker.js'], ])('adds a frame at %s when a message-only error has no stack', (_, url, frameFilename) => { forward({ reason: 'Uncaught Error: boom', From 37dd41bd16f08871db6f0ce60271d0f906fd0950 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Fri, 11 Sep 2026 14:55:52 +0200 Subject: [PATCH 6/6] fix(browser): Retry a failed worker forward with plain data 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. --- .../browser/src/integrations/webWorker.ts | 29 +++++++----- .../test/integrations/webWorker.test.ts | 44 +++++++++++++++++-- 2 files changed, 58 insertions(+), 15 deletions(-) diff --git a/packages/browser/src/integrations/webWorker.ts b/packages/browser/src/integrations/webWorker.ts index 2e08fa054e9a..72748a01a450 100644 --- a/packages/browser/src/integrations/webWorker.ts +++ b/packages/browser/src/integrations/webWorker.ts @@ -44,6 +44,8 @@ interface SerializedWorkerError { url?: string; lineno?: number; colno?: number; + /** Set when `reason` is a plain `{ message, stack }` copy of an error that did not clone. */ + plainError?: boolean; } interface WebWorkerIntegrationOptions { @@ -213,10 +215,12 @@ function handleForwardedWorkerError(workerError: SerializedWorkerError): void { const { stackParser, attachStacktrace } = client.getOptions(); - const { reason: error, kind, name, filename, url, lineno, colno } = workerError; + const { reason, kind, name, filename, url, lineno, colno, plainError } = workerError; // Older workers only ever forwarded rejections and send no `kind`. const isUnhandledRejection = kind !== 'error'; + const error = plainError && isPlainObject(reason) ? errorFromPlain(reason) : reason; + if (name && isError(error) && error.name !== name) { addNonEnumerableProperty(error, 'name', name); } @@ -354,8 +358,10 @@ export function registerWebWorker({ self }: RegisterWebWorkerOptions): void { /** * `postMessage` structured-clones the reason. A `DataCloneError` must never - * escape the worker's own error handler, so the forward is retried with a - * cloneable stand-in that keeps as much of the original as possible. + * escape the worker's own error handler, so the forward is retried with + * plain data. The page suppresses the bubbled copy of every error once the + * worker has announced itself, so the retry must clone in every browser, + * including ones that cannot clone `Error` at all. */ function postSerializedWorkerError( self: MinimalDedicatedWorkerGlobalScope, @@ -368,28 +374,27 @@ function postSerializedWorkerError( }); return; } catch { - // Not cloneable, fall through and send a stand-in instead. + // Not cloneable, fall through and send plain data instead. } const { reason } = serializedError; - // A fresh Error keeps message and stack but drops the `cause` that blocked - // the clone. `normalize` only produces cloneable output for everything else. - const cloneableReason = isError(reason) ? cloneableErrorFrom(reason) : normalize(reason); + const plainError = isError(reason); + const plainReason = plainError ? { message: extractMessage(reason), stack: reason.stack } : normalize(reason); try { self.postMessage({ _sentryMessage: true, - _sentryWorkerError: { ...serializedError, reason: cloneableReason }, + _sentryWorkerError: { ...serializedError, reason: plainReason, plainError }, }); } catch { // Dropping the forward is better than throwing out of the worker's error handler. } } -function cloneableErrorFrom(error: Error): Error { - const clone = new Error(extractMessage(error)); - clone.stack = error.stack; - return clone; +function errorFromPlain(plain: Record): Error { + const error = new Error(String(plain.message)); + error.stack = typeof plain.stack === 'string' ? plain.stack : undefined; + return error; } function isSentryMessage(eventData: unknown): eventData is WebWorkerMessage { diff --git a/packages/browser/test/integrations/webWorker.test.ts b/packages/browser/test/integrations/webWorker.test.ts index 96b3f89462c6..6d1dc4cc4997 100644 --- a/packages/browser/test/integrations/webWorker.test.ts +++ b/packages/browser/test/integrations/webWorker.test.ts @@ -5,6 +5,7 @@ import * as SentryCore from '@sentry/core'; import type { MockInstance } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { isError } from '@sentry/core'; import { BrowserClient } from '../../src/client'; import * as helpers from '../../src/helpers'; import { INTEGRATION_NAME, registerWebWorker, webWorkerIntegration } from '../../src/integrations/webWorker'; @@ -628,7 +629,7 @@ describe('registerWebWorker', () => { mockWorkerSelf.postMessage.mockImplementation(message => structuredClone(message)); }); - it('retries with a fresh error that keeps message and stack but drops the cause', () => { + it('retries with a plain copy that keeps message and stack but drops the cause', () => { registerWebWorker({ self: mockWorkerSelf as any }); const error = new Error('boom') as Error & { cause?: unknown }; @@ -641,13 +642,34 @@ describe('registerWebWorker', () => { expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ _sentryMessage: true, _sentryWorkerError: expect.objectContaining({ - reason: expect.objectContaining({ message: 'boom', stack: error.stack }), + reason: { message: 'boom', stack: error.stack }, + plainError: true, name: 'Error', kind: 'error', }), }); }); + it('sends only plain data when the browser cannot clone errors at all', () => { + registerWebWorker({ self: mockWorkerSelf as any }); + mockWorkerSelf.postMessage.mockImplementation(message => { + if (isError(message._sentryWorkerError?.reason)) { + throw new DOMException('could not be cloned', 'DataCloneError'); + } + }); + + const error = new Error('boom'); + trigger('error', { error }); + + expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ + _sentryMessage: true, + _sentryWorkerError: expect.objectContaining({ + reason: { message: 'boom', stack: error.stack }, + plainError: true, + }), + }); + }); + it('keeps the message and stack of a WebAssembly.Exception', () => { registerWebWorker({ self: mockWorkerSelf as any }); @@ -658,7 +680,8 @@ describe('registerWebWorker', () => { expect(mockWorkerSelf.postMessage).toHaveBeenLastCalledWith({ _sentryMessage: true, _sentryWorkerError: expect.objectContaining({ - reason: expect.objectContaining({ message: 'wasm exception', stack: exception.stack }), + reason: { message: 'wasm exception', stack: exception.stack }, + plainError: true, name: 'WebAssembly.Exception', }), }); @@ -673,6 +696,7 @@ describe('registerWebWorker', () => { _sentryMessage: true, _sentryWorkerError: expect.objectContaining({ reason: { retry: '[Function: retry]' }, + plainError: false, kind: 'unhandledrejection', }), }); @@ -909,6 +933,20 @@ describe('forwarded worker errors', () => { }); }); + it('rebuilds an error from a plain copy and restores its name', () => { + const stack = ['RuntimeError: divide by zero', ' at runStepGame (http://localhost:8080/worker.js:12:9)'].join( + '\n', + ); + + forward({ reason: { message: 'divide by zero', stack }, plainError: true, name: 'RuntimeError', kind: 'error' }); + + expectCapturedException({ + type: 'RuntimeError', + value: 'divide by zero', + stacktrace: { frames: [expect.objectContaining({ filename: 'http://localhost:8080/worker.js', lineno: 12 })] }, + }); + }); + it.each([ ['the script that threw', 'http://localhost/chunk.js', 'http://localhost/chunk.js'], ['the worker script when the event has no url', undefined, 'http://localhost/worker.js'],