diff --git a/dev-packages/browser-integration-tests/suites/integrations/webWorker/assets/worker.js b/dev-packages/browser-integration-tests/suites/integrations/webWorker/assets/worker.js index 59af46d764e2..8b70a34fc46e 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/webWorker/assets/worker.js +++ b/dev-packages/browser-integration-tests/suites/integrations/webWorker/assets/worker.js @@ -1,14 +1,34 @@ +// This worker manually replicates what Sentry.registerWebWorker() does +// (In real code with a bundler, you'd import and call Sentry.registerWebWorker({ self })) + self._sentryDebugIds = { 'Error at http://sentry-test.io/worker.js': 'worker-debug-id-789', }; +// Send debug IDs self.postMessage({ _sentryMessage: true, _sentryDebugIds: self._sentryDebugIds, }); +// Set up unhandledrejection handler (same as registerWebWorker) +self.addEventListener('unhandledrejection', event => { + self.postMessage({ + _sentryMessage: true, + _sentryWorkerError: { + reason: event.reason, + filename: self.location.href, + }, + }); +}); + self.addEventListener('message', event => { if (event.data.type === 'throw-error') { throw new Error('Worker error for testing'); } + + if (event.data.type === 'throw-rejection') { + // Create an unhandled rejection + Promise.reject(new Error('Worker unhandled rejection')); + } }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/webWorker/init.js b/dev-packages/browser-integration-tests/suites/integrations/webWorker/init.js index aa08cd652418..100b16a2d408 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/webWorker/init.js +++ b/dev-packages/browser-integration-tests/suites/integrations/webWorker/init.js @@ -9,10 +9,17 @@ const worker = new Worker('/worker.js'); Sentry.addIntegration(Sentry.webWorkerIntegration({ worker })); -const btn = document.getElementById('errWorker'); +const btnError = document.getElementById('errWorker'); +const btnRejection = document.getElementById('rejectionWorker'); -btn.addEventListener('click', () => { +btnError.addEventListener('click', () => { worker.postMessage({ type: 'throw-error', }); }); + +btnRejection.addEventListener('click', () => { + worker.postMessage({ + type: 'throw-rejection', + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/webWorker/template.html b/dev-packages/browser-integration-tests/suites/integrations/webWorker/template.html index 1c36227c5a3d..d1124baa59a9 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/webWorker/template.html +++ b/dev-packages/browser-integration-tests/suites/integrations/webWorker/template.html @@ -5,5 +5,6 @@ + diff --git a/dev-packages/browser-integration-tests/suites/integrations/webWorker/test.ts b/dev-packages/browser-integration-tests/suites/integrations/webWorker/test.ts index bb5adf0ac70a..8133a24253f9 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/webWorker/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/webWorker/test.ts @@ -36,3 +36,32 @@ sentryTest('Assigns web worker debug IDs when using webWorkerIntegration', async expect(image.code_file).toEqual('http://sentry-test.io/worker.js'); }); }); + +sentryTest('Captures unhandled rejections from web workers', async ({ getLocalTestUrl, page }) => { + const bundle = process.env.PW_BUNDLE; + if (bundle != null && !bundle.includes('esm') && !bundle.includes('cjs')) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const errorEventPromise = getFirstSentryEnvelopeRequest(page, url); + + page.route('**/worker.js', route => { + route.fulfill({ + path: `${__dirname}/assets/worker.js`, + }); + }); + + const button = page.locator('#rejectionWorker'); + await button.click(); + + const errorEvent = await errorEventPromise; + + // Verify the unhandled rejection was captured + expect(errorEvent.exception?.values?.[0]?.value).toContain('Worker unhandled rejection'); + expect(errorEvent.exception?.values?.[0]?.mechanism?.type).toBe('auto.browser.web_worker.onunhandledrejection'); + expect(errorEvent.exception?.values?.[0]?.mechanism?.handled).toBe(false); + expect(errorEvent.contexts?.worker).toBeDefined(); + expect(errorEvent.contexts?.worker?.filename).toContain('worker.js'); +}); diff --git a/packages/browser/src/integrations/globalhandlers.ts b/packages/browser/src/integrations/globalhandlers.ts index fa815db7c15e..aa7b2fa9e412 100644 --- a/packages/browser/src/integrations/globalhandlers.ts +++ b/packages/browser/src/integrations/globalhandlers.ts @@ -104,7 +104,10 @@ function _installGlobalOnUnhandledRejectionHandler(client: Client): void { }); } -function _getUnhandledRejectionError(error: unknown): unknown { +/** + * + */ +export function _getUnhandledRejectionError(error: unknown): unknown { if (isPrimitive(error)) { return error; } @@ -138,7 +141,7 @@ function _getUnhandledRejectionError(error: unknown): unknown { * @param reason: The `reason` property of the promise rejection * @returns An Event object with an appropriate `exception` value */ -function _eventFromRejectionWithPrimitive(reason: Primitive): Event { +export function _eventFromRejectionWithPrimitive(reason: Primitive): Event { return { exception: { values: [ diff --git a/packages/browser/src/integrations/webWorker.ts b/packages/browser/src/integrations/webWorker.ts index 1f7a266a9c8e..e95e161e703c 100644 --- a/packages/browser/src/integrations/webWorker.ts +++ b/packages/browser/src/integrations/webWorker.ts @@ -1,13 +1,21 @@ import type { Integration, IntegrationFn } from '@sentry/core'; -import { debug, defineIntegration, isPlainObject } from '@sentry/core'; +import { captureEvent, debug, defineIntegration, getClient, isPlainObject, isPrimitive } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; +import { eventFromUnknownInput } from '../eventbuilder'; import { WINDOW } from '../helpers'; +import { _eventFromRejectionWithPrimitive, _getUnhandledRejectionError } from './globalhandlers'; export const INTEGRATION_NAME = 'WebWorker'; interface WebWorkerMessage { _sentryMessage: boolean; _sentryDebugIds?: Record; + _sentryWorkerError?: SerializedWorkerError; +} + +interface SerializedWorkerError { + reason: unknown; + filename?: string; } interface WebWorkerIntegrationOptions { @@ -94,25 +102,75 @@ interface WebWorkerIntegration extends Integration { export const webWorkerIntegration = defineIntegration(({ worker }: WebWorkerIntegrationOptions) => ({ name: INTEGRATION_NAME, setupOnce: () => { - (Array.isArray(worker) ? worker : [worker]).forEach(w => listenForSentryDebugIdMessages(w)); + (Array.isArray(worker) ? worker : [worker]).forEach(w => listenForSentryMessages(w)); }, - addWorker: (worker: Worker) => listenForSentryDebugIdMessages(worker), + addWorker: (worker: Worker) => listenForSentryMessages(worker), })) as IntegrationFn; -function listenForSentryDebugIdMessages(worker: Worker): void { +function listenForSentryMessages(worker: Worker): void { worker.addEventListener('message', event => { - if (isSentryDebugIdMessage(event.data)) { + if (isSentryMessage(event.data)) { event.stopImmediatePropagation(); // other listeners should not receive this message - DEBUG_BUILD && debug.log('Sentry debugId web worker message received', event.data); - WINDOW._sentryDebugIds = { - ...event.data._sentryDebugIds, - // debugIds of the main thread have precedence over the worker's in case of a collision. - ...WINDOW._sentryDebugIds, - }; + + // Handle debug IDs + if (event.data._sentryDebugIds) { + DEBUG_BUILD && debug.log('Sentry debugId web worker message received', event.data); + WINDOW._sentryDebugIds = { + ...event.data._sentryDebugIds, + // debugIds of the main thread have precedence over the worker's in case of a collision. + ...WINDOW._sentryDebugIds, + }; + } + + // Handle 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); + } } }); } +function handleForwardedWorkerRejection(workerError: SerializedWorkerError): void { + const client = getClient(); + if (!client) { + return; + } + + const stackParser = client.getOptions().stackParser; + const attachStacktrace = client.getOptions().attachStacktrace; + + const error = workerError.reason; + + // 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); + + event.level = 'error'; + + // Add worker-specific context + if (workerError.filename) { + event.contexts = { + ...event.contexts, + worker: { + filename: workerError.filename, + }, + }; + } + + captureEvent(event, { + originalException: error, + mechanism: { + handled: false, + type: 'auto.browser.web_worker.onunhandledrejection', + }, + }); + + DEBUG_BUILD && debug.log('Captured worker unhandled rejection', error); +} + /** * Minimal interface for DedicatedWorkerGlobalScope, only requiring the postMessage method. * (which is the only thing we need from the worker's global object) @@ -124,6 +182,8 @@ function listenForSentryDebugIdMessages(worker: Worker): void { */ interface MinimalDedicatedWorkerGlobalScope { postMessage: (message: unknown) => void; + addEventListener: (type: string, listener: (event: unknown) => void) => void; + location?: { href?: string }; } interface RegisterWebWorkerOptions { @@ -133,6 +193,14 @@ interface RegisterWebWorkerOptions { /** * Use this function to register the worker with the Sentry SDK. * + * This function will: + * - Send debug IDs to the parent thread + * - Set up a handler for unhandled rejections in the worker + * - Forward unhandled rejections 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. + * * @example * ```ts filename={worker.js} * import * as Sentry from '@sentry/'; @@ -147,17 +215,59 @@ interface RegisterWebWorkerOptions { * - `self`: The worker instance you're calling this function from (self). */ export function registerWebWorker({ self }: RegisterWebWorkerOptions): void { + // Send debug IDs to parent thread self.postMessage({ _sentryMessage: true, _sentryDebugIds: self._sentryDebugIds ?? 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); + + // 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, + }; + + // Forward to parent thread + self.postMessage({ + _sentryMessage: true, + _sentryWorkerError: serializedError, + }); + + DEBUG_BUILD && debug.log('[Sentry Worker] Forwarding unhandled rejection to parent', serializedError); + }); + + DEBUG_BUILD && debug.log('[Sentry Worker] Registered worker with unhandled rejection handling'); } -function isSentryDebugIdMessage(eventData: unknown): eventData is WebWorkerMessage { - return ( - isPlainObject(eventData) && - eventData._sentryMessage === true && - '_sentryDebugIds' in eventData && - (isPlainObject(eventData._sentryDebugIds) || eventData._sentryDebugIds === undefined) - ); +function isSentryMessage(eventData: unknown): eventData is WebWorkerMessage { + if (!isPlainObject(eventData) || eventData._sentryMessage !== true) { + return false; + } + + // Must have at least one of: debug IDs or worker error + const hasDebugIds = '_sentryDebugIds' in eventData; + const hasWorkerError = '_sentryWorkerError' in eventData; + + if (!hasDebugIds && !hasWorkerError) { + return false; + } + + // Validate debug IDs if present + if (hasDebugIds && !(isPlainObject(eventData._sentryDebugIds) || eventData._sentryDebugIds === undefined)) { + return false; + } + + // Validate worker error if present + if (hasWorkerError && !isPlainObject(eventData._sentryWorkerError)) { + return false; + } + + return true; } diff --git a/packages/browser/test/integrations/webWorker.test.ts b/packages/browser/test/integrations/webWorker.test.ts index 4dfea4983949..b72895621339 100644 --- a/packages/browser/test/integrations/webWorker.test.ts +++ b/packages/browser/test/integrations/webWorker.test.ts @@ -216,6 +216,7 @@ describe('webWorkerIntegration', () => { describe('registerWebWorker', () => { let mockWorkerSelf: { postMessage: ReturnType; + addEventListener: ReturnType; _sentryDebugIds?: Record; }; @@ -224,6 +225,7 @@ describe('registerWebWorker', () => { mockWorkerSelf = { postMessage: vi.fn(), + addEventListener: vi.fn(), }; });