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/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 { 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' }), + ]), + ); + }); +});