From 808245cd30a63c2154339faed8c63685f2abf09f Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 14 Jul 2025 14:56:36 +0200 Subject: [PATCH 01/10] feat(browser): Add debugId sync APIs between web worker and main thread --- .../browser/src/integrations/webWorker.ts | 107 ++++++ .../test/integrations/webWorker.test.ts | 332 ++++++++++++++++++ 2 files changed, 439 insertions(+) create mode 100644 packages/browser/src/integrations/webWorker.ts create mode 100644 packages/browser/test/integrations/webWorker.test.ts diff --git a/packages/browser/src/integrations/webWorker.ts b/packages/browser/src/integrations/webWorker.ts new file mode 100644 index 000000000000..f20df0021296 --- /dev/null +++ b/packages/browser/src/integrations/webWorker.ts @@ -0,0 +1,107 @@ +import { debug, defineIntegration, isPlainObject } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import { WINDOW } from '../helpers'; + +export const INTEGRATION_NAME = 'WebWorker'; + +interface WebWorkerMessage { + _sentryMessage: boolean; + _sentryDebugIds: Record; +} + +interface WebWorkerIntegrationOptions { + worker: Worker; +} + +/** + * Use this integration to set up Sentry with web workers. + * + * IMPORTANT: This integration must be added **before** you start listening to + * any messages from the worker. Otherwise, your message handlers will receive + * messages from Sentry which you need to ignore. + * + * This integration only has an effect, if you call `Sentry.registerWorker(self)` + * from within the worker you're adding to the integration. + * + * Given that you want to initialize the SDK as early as possible, you most likely + * want to add the integration after initializing the SDK: + * + * @example: + * ```ts filename={main.js} + * import * as Sentry from '@sentry/'; + * + * // some time earlier: + * Sentry.init(...) + * + * // 1. Initialize the worker + * const worker = new Worker(new URL('./worker.ts', import.meta.url)); + * + * // 2. Add the integration + * Sentry.addIntegration(Sentry.webWorkerIntegration({ worker })); + * + * // 3. Register message listeners on the worker + * worker.addEventListener('message', event => { + * // ... + * }); + * ``` + * + * Of course, you can also directly add the integration in Sentry.init: + * ```ts filename={main.js} + * import * as Sentry from '@sentry/'; + * + * // 1. Initialize the worker + * const worker = new Worker(new URL('./worker.ts', import.meta.url)); + * + * // 2. Initialize the SDK + * Sentry.init({ + * integrations: [Sentry.webWorkerIntegration({ worker })] + * }); + * + * // 3. Register message listeners on the worker + * worker.addEventListener('message', event => { + * // ... + * }); + * ``` + */ +export const webWorkerIntegration = defineIntegration(({ worker }: WebWorkerIntegrationOptions) => ({ + name: INTEGRATION_NAME, + setupOnce: () => { + worker.addEventListener('message', event => { + if (isWebWorkerMessage(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, + }; + } + }); + }, +})); + +/** + * Use this function to register the worker with the Sentry SDK. + * + * @example + * ```ts filename={worker.js} + * import * as Sentry from '@sentry/'; + * + * // Do this as early as possible in your worker. + * Sentry.registerWorker(self); + * + * // continue setting up your worker + * self.postMessage(...) + * ``` + * @param self The worker instance. + */ +export function registerWebWorker(self: Worker & { _sentryDebugIds: Record }): void { + self.postMessage({ + _sentryMessage: true, + _sentryDebugIds: self._sentryDebugIds ?? undefined, + }); +} + +function isWebWorkerMessage(eventData: unknown): eventData is WebWorkerMessage { + return isPlainObject(eventData) && eventData._sentryMessage === true && typeof eventData._sentry === 'object'; +} diff --git a/packages/browser/test/integrations/webWorker.test.ts b/packages/browser/test/integrations/webWorker.test.ts new file mode 100644 index 000000000000..fa8e5a295700 --- /dev/null +++ b/packages/browser/test/integrations/webWorker.test.ts @@ -0,0 +1,332 @@ +/** + * @vitest-environment jsdom + */ + +import * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as helpers from '../../src/helpers'; +import { INTEGRATION_NAME, registerWebWorker, webWorkerIntegration } from '../../src/integrations/webWorker'; + +// Mock @sentry/core +vi.mock('@sentry/core', async importActual => { + return { + ...((await importActual()) as any), + debug: { + log: vi.fn(), + }, + }; +}); + +// Mock debug build +vi.mock('../../src/debug-build', () => ({ + DEBUG_BUILD: true, +})); + +// Mock helpers +vi.mock('../../src/helpers', () => ({ + WINDOW: { + _sentryDebugIds: undefined, + }, +})); + +describe('webWorkerIntegration', () => { + const mockDebugLog = SentryCore.debug.log as any; + + let mockWorker: { + addEventListener: ReturnType; + postMessage: ReturnType; + _sentryDebugIds?: Record; + }; + + let mockEvent: { + data: any; + stopImmediatePropagation: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Reset WINDOW mock + (helpers.WINDOW as any)._sentryDebugIds = undefined; + + // Setup mock worker + mockWorker = { + addEventListener: vi.fn(), + postMessage: vi.fn(), + }; + + // Setup mock event + mockEvent = { + data: {}, + stopImmediatePropagation: vi.fn(), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('integration creation', () => { + it('creates integration with correct name', () => { + const integration = webWorkerIntegration({ worker: mockWorker as any }); + + expect(integration.name).toBe(INTEGRATION_NAME); + expect(integration.name).toBe('WebWorker'); + }); + + it('returns a properly structured integration object', () => { + const integration = webWorkerIntegration({ worker: mockWorker as any }); + + expect(typeof integration).toBe('object'); + expect(integration.name).toBeDefined(); + expect(integration.setupOnce).toBeDefined(); + }); + + it('returns integration object with setupOnce function', () => { + const integration = webWorkerIntegration({ worker: mockWorker as any }); + + expect(integration).toMatchObject({ + name: 'WebWorker', + setupOnce: expect.any(Function), + }); + }); + }); + + describe('setupOnce', () => { + it('adds message event listener to worker', () => { + const integration = webWorkerIntegration({ worker: mockWorker as any }); + + expect(integration.setupOnce).toBeDefined(); + integration.setupOnce!(); + + expect(mockWorker.addEventListener).toHaveBeenCalledWith('message', expect.any(Function)); + }); + + describe('message handler', () => { + let messageHandler: (event: any) => void; + + beforeEach(() => { + const integration = webWorkerIntegration({ worker: mockWorker as any }); + expect(integration.setupOnce).toBeDefined(); + integration.setupOnce!(); + + // Extract the message handler from the addEventListener call + expect(mockWorker.addEventListener.mock.calls).toBeDefined(); + messageHandler = mockWorker.addEventListener.mock.calls![0]![1]; + }); + + it('ignores non-Sentry messages', () => { + mockEvent.data = { someData: 'value' }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).not.toHaveBeenCalled(); + expect(mockDebugLog).not.toHaveBeenCalled(); + }); + + it('ignores plain objects without _sentryMessage flag', () => { + mockEvent.data = { + someData: 'value', + _sentry: {}, + }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).not.toHaveBeenCalled(); + expect(mockDebugLog).not.toHaveBeenCalled(); + }); + + it('ignores messages without _sentry object', () => { + mockEvent.data = { + _sentryMessage: true, + _sentryDebugIds: { 'file1.js': 'debug-id-1' }, + }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).not.toHaveBeenCalled(); + expect(mockDebugLog).not.toHaveBeenCalled(); + }); + + it('processes valid Sentry messages', () => { + mockEvent.data = { + _sentryMessage: true, + _sentryDebugIds: { 'file1.js': 'debug-id-1' }, + _sentry: {}, + }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).toHaveBeenCalled(); + expect(mockDebugLog).toHaveBeenCalledWith('Sentry debugId web worker message received', mockEvent.data); + }); + + it('merges debug IDs with worker precedence for new IDs', () => { + (helpers.WINDOW as any)._sentryDebugIds = undefined; + + mockEvent.data = { + _sentryMessage: true, + _sentryDebugIds: { + 'worker-file1.js': 'worker-debug-1', + 'worker-file2.js': 'worker-debug-2', + }, + _sentry: {}, + }; + + messageHandler(mockEvent); + + expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({ + 'worker-file1.js': 'worker-debug-1', + 'worker-file2.js': 'worker-debug-2', + }); + }); + + it('gives main thread precedence over worker for conflicting debug IDs', () => { + (helpers.WINDOW as any)._sentryDebugIds = { + 'shared-file.js': 'main-debug-id', + 'main-only.js': 'main-debug-2', + }; + + mockEvent.data = { + _sentryMessage: true, + _sentryDebugIds: { + 'shared-file.js': 'worker-debug-id', // Should be overridden + 'worker-only.js': 'worker-debug-3', // Should be kept + }, + _sentry: {}, + }; + + messageHandler(mockEvent); + + expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({ + 'shared-file.js': 'main-debug-id', // Main thread wins + 'main-only.js': 'main-debug-2', // Main thread preserved + 'worker-only.js': 'worker-debug-3', // Worker added + }); + }); + + it('handles empty debug IDs from worker', () => { + (helpers.WINDOW as any)._sentryDebugIds = { 'main.js': 'main-debug' }; + + mockEvent.data = { + _sentryMessage: true, + _sentryDebugIds: {}, + _sentry: {}, + }; + + messageHandler(mockEvent); + + expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({ + 'main.js': 'main-debug', + }); + }); + }); + }); +}); + +describe('registerWebWorker', () => { + let mockWorkerSelf: { + postMessage: ReturnType; + _sentryDebugIds?: Record; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockWorkerSelf = { + postMessage: vi.fn(), + }; + }); + + it('posts message with _sentryMessage flag', () => { + registerWebWorker(mockWorkerSelf as any); + + expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ + _sentryMessage: true, + _sentryDebugIds: undefined, + }); + }); + + it('includes debug IDs when available', () => { + mockWorkerSelf._sentryDebugIds = { + 'worker-file1.js': 'debug-id-1', + 'worker-file2.js': 'debug-id-2', + }; + + registerWebWorker(mockWorkerSelf as any); + + expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ + _sentryMessage: true, + _sentryDebugIds: { + 'worker-file1.js': 'debug-id-1', + 'worker-file2.js': 'debug-id-2', + }, + }); + }); + + it('handles undefined debug IDs', () => { + mockWorkerSelf._sentryDebugIds = undefined; + + registerWebWorker(mockWorkerSelf as any); + + expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ + _sentryMessage: true, + _sentryDebugIds: undefined, + }); + }); + + it('handles empty debug IDs object', () => { + mockWorkerSelf._sentryDebugIds = {}; + + registerWebWorker(mockWorkerSelf as any); + + expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ + _sentryMessage: true, + _sentryDebugIds: {}, + }); + }); + + it('calls postMessage exactly once', () => { + registerWebWorker(mockWorkerSelf as any); + + expect(mockWorkerSelf.postMessage).toHaveBeenCalledTimes(1); + }); +}); + +describe('event propagation ', () => { + // Since isWebWorkerMessage is not exported, we test it indirectly through the integration + let messageHandler: (event: any) => void; + let mockWorker: { addEventListener: ReturnType }; + + beforeEach(() => { + mockWorker = { addEventListener: vi.fn() }; + const integration = webWorkerIntegration({ worker: mockWorker as any }); + integration.setupOnce!(); + expect(mockWorker.addEventListener).toHaveBeenCalled(); + expect(mockWorker.addEventListener.mock.calls).toBeDefined(); + messageHandler = mockWorker.addEventListener.mock.calls![0]![1]; + }); + + it.each([ + ['plain object but missing _sentryMessage: { _sentry: {} }', { _sentry: {} }], + [ + 'plain object but _sentryMessage is false: { _sentryMessage: false, _sentry: {} }', + { _sentryMessage: false, _sentry: {} }, + ], + ['plain object but missing _sentry: { _sentryMessage: true }', { _sentryMessage: true }], + [ + 'plain object but _sentry is not an object: { _sentryMessage: true, _sentry: "not-object" }', + { _sentryMessage: true, _sentry: 'not-object' }, + ], + ])("doesn't stop propagation for %s", (_desc, data) => { + const mockEvent = { stopImmediatePropagation: vi.fn(), data }; + messageHandler(mockEvent); + expect(mockEvent.stopImmediatePropagation).not.toHaveBeenCalled(); + }); + + it('stops propagation for sentry message', () => { + const mockEvent = { stopImmediatePropagation: vi.fn(), data: { _sentryMessage: true, _sentryDebugIds: {} } }; + messageHandler(mockEvent); + expect(mockEvent.stopImmediatePropagation).toHaveBeenCalledTimes(1); + }); +}); From 683e95e99b63575ff39d3182789336a20cac2ff9 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 14 Jul 2025 16:45:13 +0200 Subject: [PATCH 02/10] streamline tests --- .../browser/src/integrations/webWorker.ts | 2 +- .../test/integrations/webWorker.test.ts | 131 ++++++------------ 2 files changed, 46 insertions(+), 87 deletions(-) diff --git a/packages/browser/src/integrations/webWorker.ts b/packages/browser/src/integrations/webWorker.ts index f20df0021296..dd6558451c84 100644 --- a/packages/browser/src/integrations/webWorker.ts +++ b/packages/browser/src/integrations/webWorker.ts @@ -103,5 +103,5 @@ export function registerWebWorker(self: Worker & { _sentryDebugIds: Record { vi.clearAllMocks(); }); - describe('integration creation', () => { - it('creates integration with correct name', () => { - const integration = webWorkerIntegration({ worker: mockWorker as any }); - - expect(integration.name).toBe(INTEGRATION_NAME); - expect(integration.name).toBe('WebWorker'); - }); - - it('returns a properly structured integration object', () => { - const integration = webWorkerIntegration({ worker: mockWorker as any }); - - expect(typeof integration).toBe('object'); - expect(integration.name).toBeDefined(); - expect(integration.setupOnce).toBeDefined(); - }); - - it('returns integration object with setupOnce function', () => { - const integration = webWorkerIntegration({ worker: mockWorker as any }); + it('creates integration with correct name', () => { + const integration = webWorkerIntegration({ worker: mockWorker as any }); - expect(integration).toMatchObject({ - name: 'WebWorker', - setupOnce: expect.any(Function), - }); - }); + expect(integration.name).toBe(INTEGRATION_NAME); + expect(integration.name).toBe('WebWorker'); + expect(typeof integration.setupOnce).toBe('function'); }); describe('setupOnce', () => { it('adds message event listener to worker', () => { const integration = webWorkerIntegration({ worker: mockWorker as any }); - expect(integration.setupOnce).toBeDefined(); integration.setupOnce!(); expect(mockWorker.addEventListener).toHaveBeenCalledWith('message', expect.any(Function)); @@ -107,7 +88,6 @@ describe('webWorkerIntegration', () => { beforeEach(() => { const integration = webWorkerIntegration({ worker: mockWorker as any }); - expect(integration.setupOnce).toBeDefined(); integration.setupOnce!(); // Extract the message handler from the addEventListener call @@ -136,23 +116,10 @@ describe('webWorkerIntegration', () => { expect(mockDebugLog).not.toHaveBeenCalled(); }); - it('ignores messages without _sentry object', () => { - mockEvent.data = { - _sentryMessage: true, - _sentryDebugIds: { 'file1.js': 'debug-id-1' }, - }; - - messageHandler(mockEvent); - - expect(mockEvent.stopImmediatePropagation).not.toHaveBeenCalled(); - expect(mockDebugLog).not.toHaveBeenCalled(); - }); - it('processes valid Sentry messages', () => { mockEvent.data = { _sentryMessage: true, _sentryDebugIds: { 'file1.js': 'debug-id-1' }, - _sentry: {}, }; messageHandler(mockEvent); @@ -170,7 +137,6 @@ describe('webWorkerIntegration', () => { 'worker-file1.js': 'worker-debug-1', 'worker-file2.js': 'worker-debug-2', }, - _sentry: {}, }; messageHandler(mockEvent); @@ -193,7 +159,6 @@ describe('webWorkerIntegration', () => { 'shared-file.js': 'worker-debug-id', // Should be overridden 'worker-only.js': 'worker-debug-3', // Should be kept }, - _sentry: {}, }; messageHandler(mockEvent); @@ -211,7 +176,6 @@ describe('webWorkerIntegration', () => { mockEvent.data = { _sentryMessage: true, _sentryDebugIds: {}, - _sentry: {}, }; messageHandler(mockEvent); @@ -241,6 +205,7 @@ describe('registerWebWorker', () => { it('posts message with _sentryMessage flag', () => { registerWebWorker(mockWorkerSelf as any); + expect(mockWorkerSelf.postMessage).toHaveBeenCalledTimes(1); expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ _sentryMessage: true, _sentryDebugIds: undefined, @@ -255,6 +220,7 @@ describe('registerWebWorker', () => { registerWebWorker(mockWorkerSelf as any); + expect(mockWorkerSelf.postMessage).toHaveBeenCalledTimes(1); expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ _sentryMessage: true, _sentryDebugIds: { @@ -269,64 +235,57 @@ describe('registerWebWorker', () => { registerWebWorker(mockWorkerSelf as any); + expect(mockWorkerSelf.postMessage).toHaveBeenCalledTimes(1); expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ _sentryMessage: true, _sentryDebugIds: undefined, }); }); +}); - it('handles empty debug IDs object', () => { - mockWorkerSelf._sentryDebugIds = {}; - - registerWebWorker(mockWorkerSelf as any); +describe('registerWebWorker and webWorkerIntegration', () => { + beforeEach(() => {}); - expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ - _sentryMessage: true, - _sentryDebugIds: {}, - }); - }); + it('works together', () => { + (helpers.WINDOW as any)._sentryDebugIds = { + 'main-file1.js': 'main-debug-1', + 'main-file2.js': 'main-debug-2', + 'shared-file.js': 'main-debug-id', + }; - it('calls postMessage exactly once', () => { - registerWebWorker(mockWorkerSelf as any); + let cb: ((arg0: any) => any) | undefined = undefined; - expect(mockWorkerSelf.postMessage).toHaveBeenCalledTimes(1); - }); -}); - -describe('event propagation ', () => { - // Since isWebWorkerMessage is not exported, we test it indirectly through the integration - let messageHandler: (event: any) => void; - let mockWorker: { addEventListener: ReturnType }; + // Setup mock worker + const mockWorker = { + _sentryDebugIds: { + 'worker-file1.js': 'worker-debug-1', + 'worker-file2.js': 'worker-debug-2', + 'shared-file.js': 'worker-debug-id', + }, + addEventListener: vi.fn((_, l) => (cb = l)), + postMessage: vi.fn(message => { + // @ts-expect-error - cb is defined + cb({ data: message, stopImmediatePropagation: vi.fn() }); + }), + }; - beforeEach(() => { - mockWorker = { addEventListener: vi.fn() }; const integration = webWorkerIntegration({ worker: mockWorker as any }); integration.setupOnce!(); - expect(mockWorker.addEventListener).toHaveBeenCalled(); - expect(mockWorker.addEventListener.mock.calls).toBeDefined(); - messageHandler = mockWorker.addEventListener.mock.calls![0]![1]; - }); - it.each([ - ['plain object but missing _sentryMessage: { _sentry: {} }', { _sentry: {} }], - [ - 'plain object but _sentryMessage is false: { _sentryMessage: false, _sentry: {} }', - { _sentryMessage: false, _sentry: {} }, - ], - ['plain object but missing _sentry: { _sentryMessage: true }', { _sentryMessage: true }], - [ - 'plain object but _sentry is not an object: { _sentryMessage: true, _sentry: "not-object" }', - { _sentryMessage: true, _sentry: 'not-object' }, - ], - ])("doesn't stop propagation for %s", (_desc, data) => { - const mockEvent = { stopImmediatePropagation: vi.fn(), data }; - messageHandler(mockEvent); - expect(mockEvent.stopImmediatePropagation).not.toHaveBeenCalled(); - }); + registerWebWorker(mockWorker as any); + + expect(mockWorker.addEventListener).toHaveBeenCalledWith('message', expect.any(Function)); + expect(mockWorker.postMessage).toHaveBeenCalledWith({ + _sentryMessage: true, + _sentryDebugIds: mockWorker._sentryDebugIds, + }); - it('stops propagation for sentry message', () => { - const mockEvent = { stopImmediatePropagation: vi.fn(), data: { _sentryMessage: true, _sentryDebugIds: {} } }; - messageHandler(mockEvent); - expect(mockEvent.stopImmediatePropagation).toHaveBeenCalledTimes(1); + expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({ + 'main-file1.js': 'main-debug-1', + 'main-file2.js': 'main-debug-2', + 'shared-file.js': 'main-debug-id', + 'worker-file1.js': 'worker-debug-1', + 'worker-file2.js': 'worker-debug-2', + }); }); }); From a294ec4405a0b079c8968025a93ded113d4c91bf Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 14 Jul 2025 16:58:07 +0200 Subject: [PATCH 03/10] . --- .../test/integrations/webWorker.test.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/browser/test/integrations/webWorker.test.ts b/packages/browser/test/integrations/webWorker.test.ts index 64f8c877b08d..a26527e2f60d 100644 --- a/packages/browser/test/integrations/webWorker.test.ts +++ b/packages/browser/test/integrations/webWorker.test.ts @@ -248,9 +248,9 @@ describe('registerWebWorker and webWorkerIntegration', () => { it('works together', () => { (helpers.WINDOW as any)._sentryDebugIds = { - 'main-file1.js': 'main-debug-1', - 'main-file2.js': 'main-debug-2', - 'shared-file.js': 'main-debug-id', + 'Error at \n /main-file1.js': 'main-debug-1', + 'Error at \n /main-file2.js': 'main-debug-2', + 'Error at \n /shared-file.js': 'main-debug-id', }; let cb: ((arg0: any) => any) | undefined = undefined; @@ -258,9 +258,9 @@ describe('registerWebWorker and webWorkerIntegration', () => { // Setup mock worker const mockWorker = { _sentryDebugIds: { - 'worker-file1.js': 'worker-debug-1', - 'worker-file2.js': 'worker-debug-2', - 'shared-file.js': 'worker-debug-id', + 'Error at \n /worker-file1.js': 'worker-debug-1', + 'Error at \n /worker-file2.js': 'worker-debug-2', + 'Error at \n /shared-file.js': 'worker-debug-id', }, addEventListener: vi.fn((_, l) => (cb = l)), postMessage: vi.fn(message => { @@ -281,11 +281,11 @@ describe('registerWebWorker and webWorkerIntegration', () => { }); expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({ - 'main-file1.js': 'main-debug-1', - 'main-file2.js': 'main-debug-2', - 'shared-file.js': 'main-debug-id', - 'worker-file1.js': 'worker-debug-1', - 'worker-file2.js': 'worker-debug-2', + 'Error at \n /main-file1.js': 'main-debug-1', + 'Error at \n /main-file2.js': 'main-debug-2', + 'Error at \n /shared-file.js': 'main-debug-id', + 'Error at \n /worker-file1.js': 'worker-debug-1', + 'Error at \n /worker-file2.js': 'worker-debug-2', }); }); }); From 0426e920ef6e83c9b61e8a5f69de447b0ef4e453 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 16 Jul 2025 10:08:01 +0200 Subject: [PATCH 04/10] add integration test, add public export --- .../integrations/webWorker/assets/worker.js | 14 +++++++ .../suites/integrations/webWorker/init.js | 27 +++++++++++++ .../suites/integrations/webWorker/subject.js | 0 .../integrations/webWorker/template.html | 9 +++++ .../suites/integrations/webWorker/test.ts | 38 +++++++++++++++++++ packages/browser/src/index.ts | 1 + 6 files changed, 89 insertions(+) create mode 100644 dev-packages/browser-integration-tests/suites/integrations/webWorker/assets/worker.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/webWorker/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/webWorker/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/webWorker/template.html create mode 100644 dev-packages/browser-integration-tests/suites/integrations/webWorker/test.ts 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 new file mode 100644 index 000000000000..59af46d764e2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/webWorker/assets/worker.js @@ -0,0 +1,14 @@ +self._sentryDebugIds = { + 'Error at http://sentry-test.io/worker.js': 'worker-debug-id-789', +}; + +self.postMessage({ + _sentryMessage: true, + _sentryDebugIds: self._sentryDebugIds, +}); + +self.addEventListener('message', event => { + if (event.data.type === 'throw-error') { + throw new Error('Worker error for testing'); + } +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/webWorker/init.js b/dev-packages/browser-integration-tests/suites/integrations/webWorker/init.js new file mode 100644 index 000000000000..72025ed7d0d6 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/webWorker/init.js @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/browser'; + +// Initialize Sentry with webWorker integration +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + debug: true, + beforeSend(event) { + console.log('xx beforeSend', JSON.stringify(event.exception.values[0].stacktrace.frames, null, 2)); + return event; + }, +}); + +const worker = new Worker('/worker.js'); + +worker.addEventListener('message', event => { + console.log('xx message', event); +}); + +Sentry.addIntegration(Sentry.webWorkerIntegration({ worker })); + +const btn = document.getElementById('errWorker'); + +btn.addEventListener('click', () => { + worker.postMessage({ + type: 'throw-error', + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/webWorker/subject.js b/dev-packages/browser-integration-tests/suites/integrations/webWorker/subject.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/dev-packages/browser-integration-tests/suites/integrations/webWorker/template.html b/dev-packages/browser-integration-tests/suites/integrations/webWorker/template.html new file mode 100644 index 000000000000..1c36227c5a3d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/webWorker/template.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/integrations/webWorker/test.ts b/dev-packages/browser-integration-tests/suites/integrations/webWorker/test.ts new file mode 100644 index 000000000000..cc5a8b3c7cf0 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/webWorker/test.ts @@ -0,0 +1,38 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest } from '../../../utils/helpers'; + +sentryTest('Assigns web worker debug IDs when using webWorkerIntegration', async ({ getLocalTestUrl, page }) => { + const bundle = process.env.PW_BUNDLE as string | undefined; + 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('#errWorker'); + await button.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.debug_meta?.images).toBeDefined(); + + const debugImages = errorEvent.debug_meta?.images || []; + + expect(debugImages.length).toBe(1); + + debugImages.forEach(image => { + expect(image.type).toBe('sourcemap'); + expect(image.debug_id).toEqual('worker-debug-id-789'); + expect(image.code_file).toEqual('http://sentry-test.io/worker.js'); + }); +}); diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 963d8ab38546..57c302d50b6a 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -73,3 +73,4 @@ export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integratio export { unleashIntegration } from './integrations/featureFlags/unleash'; export { statsigIntegration } from './integrations/featureFlags/statsig'; export { diagnoseSdkConnectivity } from './diagnose-sdk'; +export { webWorkerIntegration } from './integrations/webWorker'; From 3acf7a8113515f74acc402786fee25ebd33f76a1 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 16 Jul 2025 10:54:54 +0200 Subject: [PATCH 05/10] wip e2e test --- .../browser-webworker-vite/index.html | 15 ++++++ .../browser-webworker-vite/package.json | 23 +++++++++ .../playwright.config.mjs | 7 +++ .../browser-webworker-vite/src/main.ts | 27 ++++++++++ .../browser-webworker-vite/src/vite-env.d.ts | 1 + .../browser-webworker-vite/src/worker.ts | 17 +++++++ .../start-event-proxy.mjs | 6 +++ .../tests/errors.test.ts | 51 +++++++++++++++++++ .../browser-webworker-vite/tsconfig.json | 25 +++++++++ .../browser-webworker-vite/vite.config.ts | 26 ++++++++++ packages/browser/src/index.ts | 2 +- .../browser/src/integrations/webWorker.ts | 14 +++-- .../test/integrations/webWorker.test.ts | 8 +-- 13 files changed, 214 insertions(+), 8 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/browser-webworker-vite/index.html create mode 100644 dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json create mode 100644 dev-packages/e2e-tests/test-applications/browser-webworker-vite/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts create mode 100644 dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/vite-env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker.ts create mode 100644 dev-packages/e2e-tests/test-applications/browser-webworker-vite/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/browser-webworker-vite/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/browser-webworker-vite/vite.config.ts diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/index.html b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/index.html new file mode 100644 index 000000000000..842c46e5a235 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/index.html @@ -0,0 +1,15 @@ + + + + + + Vite + TS + + +
+ + + + diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json new file mode 100644 index 000000000000..188ff22793b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json @@ -0,0 +1,23 @@ +{ + "name": "gh-sentry-javascript-bundler-plugins-755-vite-worker-bundles", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "rm -rf dist &&tsc && vite build", + "preview": "vite preview --port 3030", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "devDependencies": { + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "~5.8.3", + "vite": "^7.0.4" + }, + "dependencies": { + "@sentry/browser": "^9.38.0", + "@sentry/vite-plugin": "^3.5.0" + } +} diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/playwright.config.mjs new file mode 100644 index 000000000000..94212f193252 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm preview`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts new file mode 100644 index 000000000000..8ac5378a294b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts @@ -0,0 +1,27 @@ +import './style.css'; +import MyWorker from './worker.ts?worker'; +import * as Sentry from '@sentry/browser'; + +Sentry.init({ + dsn: import.meta.env.E2E_TEST_DSN, + environment: import.meta.env.MODE || 'development', + tracesSampleRate: 1.0, + debug: true, + integrations: [Sentry.browserTracingIntegration()], +}); + +const worker = new MyWorker(); + +Sentry.addIntegration(Sentry.webWorkerIntegration({ worker })); + +worker.addEventListener('message', event => { + // this is part of the test, do not delete + console.log('xx received message from worker', event); +}); + +document.querySelector('#trigger-error')!.addEventListener('click', () => { + worker.postMessage({ + type: 'TRIGGER_ERROR', + data: 'This message triggers an uncaught error!', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/vite-env.d.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/vite-env.d.ts new file mode 100644 index 000000000000..11f02fe2a006 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker.ts new file mode 100644 index 000000000000..455e8e395901 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker.ts @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/browser'; + +// type cast necessary because TS thinks this file is part of the main +// thread where self is of type `Window` instead of `Worker` +Sentry.registerWebWorker({ self: self as unknown as Worker }); + +// Let the main thread know the worker is ready +self.postMessage({ + msg: 'WORKER_READY', +}); + +self.addEventListener('message', event => { + if (event.data.msg === 'TRIGGER_ERROR') { + // This will throw an uncaught error in the worker + throw new Error(`Uncaught error in worker`); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/start-event-proxy.mjs new file mode 100644 index 000000000000..102c13c48379 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'browser-webworker-vite', +}); 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 new file mode 100644 index 000000000000..81779c5db2ee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +const E2E_TEST_APP_NAME = 'browser-webworker-vite'; + +test('captures an error with debug ids and pageload trace context', async ({ page }) => { + const errorEventPromise = waitForError(E2E_TEST_APP_NAME, event => { + return !event.type && event.exception?.values?.[0]?.value === 'Uncaught error in worker'; + }); + + const transactionPromise = waitForTransaction(E2E_TEST_APP_NAME, transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/'); + + await page.locator('id=trigger-error').click(); + + const errorEvent = await errorEventPromise; + const transactionEvent = await transactionPromise; + + const pageloadTraceId = transactionEvent.contexts?.trace?.trace_id; + const pageloadSpanId = transactionEvent.contexts?.trace?.span_id; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught error in worker'); + expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toContain('worker.js'); + + expect(errorEvent.transaction).toBe('/'); + expect(transactionEvent.transaction).toBe('/'); + + expect(errorEvent.request).toEqual({ + url: 'http://localhost:4173/', + headers: expect.any(Object), + }); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: pageloadTraceId, + span_id: pageloadSpanId, + }); + + expect(errorEvent.debug_meta).toEqual({ + sourcemaps: { + 'worker.js': { + version: expect.any(String), + url: expect.any(String), + }, + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tsconfig.json b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tsconfig.json new file mode 100644 index 000000000000..4f5edc248c88 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/vite.config.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/vite.config.ts new file mode 100644 index 000000000000..38bae8842c4a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/vite.config.ts @@ -0,0 +1,26 @@ +import { sentryVitePlugin } from '@sentry/vite-plugin'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + sourcemap: 'hidden', + }, + + plugins: [ + sentryVitePlugin({ + org: process.env.E2E_TEST_SENTRY_ORG_SLUG, + project: process.env.E2E_TEST_SENTRY_PROJECT, + authToken: process.env.E2E_TEST_AUTH_TOKEN, + }), + ], + + worker: { + plugins: () => [ + ...sentryVitePlugin({ + org: process.env.E2E_TEST_SENTRY_ORG_SLUG, + project: process.env.E2E_TEST_SENTRY_PROJECT, + authToken: process.env.E2E_TEST_AUTH_TOKEN, + }), + ], + }, +}); diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 57c302d50b6a..0bc523506454 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -73,4 +73,4 @@ export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integratio export { unleashIntegration } from './integrations/featureFlags/unleash'; export { statsigIntegration } from './integrations/featureFlags/statsig'; export { diagnoseSdkConnectivity } from './diagnose-sdk'; -export { webWorkerIntegration } from './integrations/webWorker'; +export { webWorkerIntegration, registerWebWorker } from './integrations/webWorker'; diff --git a/packages/browser/src/integrations/webWorker.ts b/packages/browser/src/integrations/webWorker.ts index dd6558451c84..ed2627568bba 100644 --- a/packages/browser/src/integrations/webWorker.ts +++ b/packages/browser/src/integrations/webWorker.ts @@ -62,6 +62,9 @@ interface WebWorkerIntegrationOptions { * // ... * }); * ``` + * + * @param options {WebWorkerIntegrationOptions} Integration options: + * - `worker`: The worker instance. */ export const webWorkerIntegration = defineIntegration(({ worker }: WebWorkerIntegrationOptions) => ({ name: INTEGRATION_NAME, @@ -80,6 +83,10 @@ export const webWorkerIntegration = defineIntegration(({ worker }: WebWorkerInte }, })); +interface RegisterWebWorkerOptions { + self: Worker & { _sentryDebugIds?: Record }; +} + /** * Use this function to register the worker with the Sentry SDK. * @@ -88,14 +95,15 @@ export const webWorkerIntegration = defineIntegration(({ worker }: WebWorkerInte * import * as Sentry from '@sentry/'; * * // Do this as early as possible in your worker. - * Sentry.registerWorker(self); + * Sentry.registerWorker({ self }); * * // continue setting up your worker * self.postMessage(...) * ``` - * @param self The worker instance. + * @param options {RegisterWebWorkerOptions} Integration options: + * - `self`: The worker instance you're calling this function from (self). */ -export function registerWebWorker(self: Worker & { _sentryDebugIds: Record }): void { +export function registerWebWorker({ self }: RegisterWebWorkerOptions): void { self.postMessage({ _sentryMessage: true, _sentryDebugIds: self._sentryDebugIds ?? undefined, diff --git a/packages/browser/test/integrations/webWorker.test.ts b/packages/browser/test/integrations/webWorker.test.ts index a26527e2f60d..297150bcd134 100644 --- a/packages/browser/test/integrations/webWorker.test.ts +++ b/packages/browser/test/integrations/webWorker.test.ts @@ -203,7 +203,7 @@ describe('registerWebWorker', () => { }); it('posts message with _sentryMessage flag', () => { - registerWebWorker(mockWorkerSelf as any); + registerWebWorker({ self: mockWorkerSelf as any }); expect(mockWorkerSelf.postMessage).toHaveBeenCalledTimes(1); expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ @@ -218,7 +218,7 @@ describe('registerWebWorker', () => { 'worker-file2.js': 'debug-id-2', }; - registerWebWorker(mockWorkerSelf as any); + registerWebWorker({ self: mockWorkerSelf as any }); expect(mockWorkerSelf.postMessage).toHaveBeenCalledTimes(1); expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ @@ -233,7 +233,7 @@ describe('registerWebWorker', () => { it('handles undefined debug IDs', () => { mockWorkerSelf._sentryDebugIds = undefined; - registerWebWorker(mockWorkerSelf as any); + registerWebWorker({ self: mockWorkerSelf as any }); expect(mockWorkerSelf.postMessage).toHaveBeenCalledTimes(1); expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ @@ -272,7 +272,7 @@ describe('registerWebWorker and webWorkerIntegration', () => { const integration = webWorkerIntegration({ worker: mockWorker as any }); integration.setupOnce!(); - registerWebWorker(mockWorker as any); + registerWebWorker({ self: mockWorker as any }); expect(mockWorker.addEventListener).toHaveBeenCalledWith('message', expect.any(Function)); expect(mockWorker.postMessage).toHaveBeenCalledWith({ From a52eb3c5e943ea569899768dd3a33738792ee47d Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 16 Jul 2025 12:39:51 +0200 Subject: [PATCH 06/10] add working e2e tests --- dev-packages/e2e-tests/lib/copyToTemp.ts | 2 +- .../browser-webworker-vite/.npmrc | 2 + .../browser-webworker-vite/package.json | 12 +++-- .../playwright.config.mjs | 3 ++ .../browser-webworker-vite/src/main.ts | 9 ++-- .../tests/errors.test.ts | 50 +++++++++++++------ .../browser-webworker-vite/vite.config.ts | 3 ++ 7 files changed, 58 insertions(+), 23 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/browser-webworker-vite/.npmrc diff --git a/dev-packages/e2e-tests/lib/copyToTemp.ts b/dev-packages/e2e-tests/lib/copyToTemp.ts index d6667978b924..830ff76f6077 100644 --- a/dev-packages/e2e-tests/lib/copyToTemp.ts +++ b/dev-packages/e2e-tests/lib/copyToTemp.ts @@ -28,7 +28,7 @@ function fixPackageJson(cwd: string): void { // 2. Fix volta extends if (!packageJson.volta) { - throw new Error('No volta config found, please provide one!'); + throw new Error("No volta config found, please add one to the test app's package.json!"); } if (typeof packageJson.volta.extends === 'string') { diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/.npmrc b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json index 188ff22793b4..fcda3617e5c5 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json @@ -1,12 +1,13 @@ { - "name": "gh-sentry-javascript-bundler-plugins-755-vite-worker-bundles", + "name": "browser-webworker-vite", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", - "build": "rm -rf dist &&tsc && vite build", + "build": "rm -rf dist && tsc && vite build", "preview": "vite preview --port 3030", + "test": "playwright test", "test:build": "pnpm install && pnpm build", "test:assert": "pnpm test" }, @@ -17,7 +18,12 @@ "vite": "^7.0.4" }, "dependencies": { - "@sentry/browser": "^9.38.0", + "@sentry/browser": "latest || *", "@sentry/vite-plugin": "^3.5.0" + }, + "volta": { + "node": "20.19.2", + "yarn": "1.22.22", + "pnpm": "9.15.9" } } diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/playwright.config.mjs index 94212f193252..bf40ebae4467 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/playwright.config.mjs @@ -2,6 +2,9 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; const config = getPlaywrightConfig({ startCommand: `pnpm preview`, + eventProxyFile: 'start-event-proxy.mjs', + eventProxyPort: 3031, + port: 3030, }); export default config; diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts index 8ac5378a294b..d7cbcdad0be5 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts @@ -1,13 +1,13 @@ -import './style.css'; import MyWorker from './worker.ts?worker'; import * as Sentry from '@sentry/browser'; Sentry.init({ - dsn: import.meta.env.E2E_TEST_DSN, + dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, environment: import.meta.env.MODE || 'development', tracesSampleRate: 1.0, debug: true, integrations: [Sentry.browserTracingIntegration()], + tunnel: 'http://localhost:3031/', // proxy server }); const worker = new MyWorker(); @@ -16,12 +16,11 @@ Sentry.addIntegration(Sentry.webWorkerIntegration({ worker })); worker.addEventListener('message', event => { // this is part of the test, do not delete - console.log('xx received message from worker', event); + console.log('received message from worker:', event.data.msg); }); document.querySelector('#trigger-error')!.addEventListener('click', () => { worker.postMessage({ - type: 'TRIGGER_ERROR', - data: 'This message triggers an uncaught error!', + msg: 'TRIGGER_ERROR', }); }); 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 81779c5db2ee..13d044709914 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 @@ -1,20 +1,20 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; -const E2E_TEST_APP_NAME = 'browser-webworker-vite'; - test('captures an error with debug ids and pageload trace context', async ({ page }) => { - const errorEventPromise = waitForError(E2E_TEST_APP_NAME, event => { - return !event.type && event.exception?.values?.[0]?.value === 'Uncaught error in worker'; + const errorEventPromise = waitForError('browser-webworker-vite', async event => { + return !event.type && !!event.exception?.values?.[0]; }); - const transactionPromise = waitForTransaction(E2E_TEST_APP_NAME, transactionEvent => { + const transactionPromise = waitForTransaction('browser-webworker-vite', transactionEvent => { return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; }); await page.goto('/'); - await page.locator('id=trigger-error').click(); + await page.locator('#trigger-error').click(); + + await page.waitForTimeout(1000); const errorEvent = await errorEventPromise; const transactionEvent = await transactionPromise; @@ -23,15 +23,15 @@ test('captures an error with debug ids and pageload trace context', async ({ pag const pageloadSpanId = transactionEvent.contexts?.trace?.span_id; expect(errorEvent.exception?.values).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught error in worker'); + 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).toContain('worker.js'); + expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toMatch(/worker-.+\.js$/); expect(errorEvent.transaction).toBe('/'); expect(transactionEvent.transaction).toBe('/'); expect(errorEvent.request).toEqual({ - url: 'http://localhost:4173/', + url: 'http://localhost:3030/', headers: expect.any(Object), }); @@ -41,11 +41,33 @@ test('captures an error with debug ids and pageload trace context', async ({ pag }); expect(errorEvent.debug_meta).toEqual({ - sourcemaps: { - 'worker.js': { - version: expect.any(String), - url: expect.any(String), + images: [ + { + code_file: expect.stringMatching(/http:\/\/localhost:3030\/assets\/worker-.+\.js/), + debug_id: expect.stringMatching(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/), + type: 'sourcemap', }, - }, + ], + }); +}); + +test("user worker message handlers don't trigger for sentry messages", async ({ page }) => { + const workerReadyPromise = new Promise(resolve => { + let workerMessageCount = 0; + page.on('console', msg => { + if (msg.text().startsWith('received message from worker:')) { + workerMessageCount++; + } + + if (msg.text() === 'received message from worker: WORKER_READY') { + resolve(workerMessageCount); + } + }); }); + + await page.goto('/'); + + const workerMessageCount = await workerReadyPromise; + + expect(workerMessageCount).toBe(1); }); diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/vite.config.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/vite.config.ts index 38bae8842c4a..df010d9b426c 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/vite.config.ts @@ -4,6 +4,7 @@ import { defineConfig } from 'vite'; export default defineConfig({ build: { sourcemap: 'hidden', + envPrefix: ['PUBLIC_'], }, plugins: [ @@ -23,4 +24,6 @@ export default defineConfig({ }), ], }, + + envPrefix: ['PUBLIC_'], }); From 6f773f669e41981d3872f8fa53af66d77b632c62 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 16 Jul 2025 12:43:15 +0200 Subject: [PATCH 07/10] fix lint error --- .../suites/integrations/webWorker/init.js | 9 --------- 1 file changed, 9 deletions(-) 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 72025ed7d0d6..aa08cd652418 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/webWorker/init.js +++ b/dev-packages/browser-integration-tests/suites/integrations/webWorker/init.js @@ -3,19 +3,10 @@ import * as Sentry from '@sentry/browser'; // Initialize Sentry with webWorker integration Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - debug: true, - beforeSend(event) { - console.log('xx beforeSend', JSON.stringify(event.exception.values[0].stacktrace.frames, null, 2)); - return event; - }, }); const worker = new Worker('/worker.js'); -worker.addEventListener('message', event => { - console.log('xx message', event); -}); - Sentry.addIntegration(Sentry.webWorkerIntegration({ worker })); const btn = document.getElementById('errWorker'); From e656490984e2f314276634d14564ab6400958301 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 16 Jul 2025 13:15:18 +0200 Subject: [PATCH 08/10] make `isWebWorkerMessage` more robust --- packages/browser/src/integrations/webWorker.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/browser/src/integrations/webWorker.ts b/packages/browser/src/integrations/webWorker.ts index ed2627568bba..be7c221a8444 100644 --- a/packages/browser/src/integrations/webWorker.ts +++ b/packages/browser/src/integrations/webWorker.ts @@ -6,7 +6,7 @@ export const INTEGRATION_NAME = 'WebWorker'; interface WebWorkerMessage { _sentryMessage: boolean; - _sentryDebugIds: Record; + _sentryDebugIds?: Record; } interface WebWorkerIntegrationOptions { @@ -111,5 +111,10 @@ export function registerWebWorker({ self }: RegisterWebWorkerOptions): void { } function isWebWorkerMessage(eventData: unknown): eventData is WebWorkerMessage { - return isPlainObject(eventData) && eventData._sentryMessage === true && typeof eventData._sentryDebugIds === 'object'; + return ( + isPlainObject(eventData) && + eventData._sentryMessage === true && + '_sentryDebugIds' in eventData && + (isPlainObject(eventData._sentryDebugIds) || eventData._sentryDebugIds === undefined) + ); } From 9ff304c04e1248484aacaa69b2c9f2744878061c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 16 Jul 2025 13:15:58 +0200 Subject: [PATCH 09/10] better naming --- packages/browser/src/integrations/webWorker.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/browser/src/integrations/webWorker.ts b/packages/browser/src/integrations/webWorker.ts index be7c221a8444..27362f7badcd 100644 --- a/packages/browser/src/integrations/webWorker.ts +++ b/packages/browser/src/integrations/webWorker.ts @@ -70,7 +70,7 @@ export const webWorkerIntegration = defineIntegration(({ worker }: WebWorkerInte name: INTEGRATION_NAME, setupOnce: () => { worker.addEventListener('message', event => { - if (isWebWorkerMessage(event.data)) { + if (isSentryDebugIdMessage(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 = { @@ -110,7 +110,7 @@ export function registerWebWorker({ self }: RegisterWebWorkerOptions): void { }); } -function isWebWorkerMessage(eventData: unknown): eventData is WebWorkerMessage { +function isSentryDebugIdMessage(eventData: unknown): eventData is WebWorkerMessage { return ( isPlainObject(eventData) && eventData._sentryMessage === true && From fd6cec8cde3593f59491157b59b180d69808b794 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 16 Jul 2025 15:48:37 +0200 Subject: [PATCH 10/10] support multiple workers --- .../browser-webworker-vite/index.html | 6 ++ .../browser-webworker-vite/src/main.ts | 20 +++- .../browser-webworker-vite/src/worker2.ts | 17 +++ .../browser-webworker-vite/src/worker3.ts | 17 +++ .../tests/errors.test.ts | 100 ++++++++++++++++++ .../browser/src/integrations/webWorker.ts | 64 ++++++++--- .../test/integrations/webWorker.test.ts | 91 ++++++++++++++-- 7 files changed, 291 insertions(+), 24 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker2.ts create mode 100644 dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker3.ts diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/index.html b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/index.html index 842c46e5a235..0ebc79719432 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/index.html +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/index.html @@ -11,5 +11,11 @@ + + diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts index d7cbcdad0be5..b017c1bfdc4d 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts @@ -1,4 +1,5 @@ import MyWorker from './worker.ts?worker'; +import MyWorker2 from './worker2.ts?worker'; import * as Sentry from '@sentry/browser'; Sentry.init({ @@ -11,8 +12,10 @@ Sentry.init({ }); const worker = new MyWorker(); +const worker2 = new MyWorker2(); -Sentry.addIntegration(Sentry.webWorkerIntegration({ worker })); +const webWorkerIntegration = Sentry.webWorkerIntegration({ worker: [worker, worker2] }); +Sentry.addIntegration(webWorkerIntegration); worker.addEventListener('message', event => { // this is part of the test, do not delete @@ -24,3 +27,18 @@ document.querySelector('#trigger-error')!.addEventListener('c msg: 'TRIGGER_ERROR', }); }); + +document.querySelector('#trigger-error-2')!.addEventListener('click', () => { + worker2.postMessage({ + msg: 'TRIGGER_ERROR', + }); +}); + +document.querySelector('#trigger-error-3')!.addEventListener('click', async () => { + const Worker3 = await import('./worker3.ts?worker'); + const worker3 = new Worker3.default(); + webWorkerIntegration.addWorker(worker3); + worker3.postMessage({ + msg: 'TRIGGER_ERROR', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker2.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker2.ts new file mode 100644 index 000000000000..8dfb70b32853 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker2.ts @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/browser'; + +// type cast necessary because TS thinks this file is part of the main +// thread where self is of type `Window` instead of `Worker` +Sentry.registerWebWorker({ self: self as unknown as Worker }); + +// Let the main thread know the worker is ready +self.postMessage({ + msg: 'WORKER_2_READY', +}); + +self.addEventListener('message', event => { + if (event.data.msg === 'TRIGGER_ERROR') { + // This will throw an uncaught error in the worker + throw new Error(`Uncaught error in worker 2`); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker3.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker3.ts new file mode 100644 index 000000000000..d68265c24ab7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker3.ts @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/browser'; + +// type cast necessary because TS thinks this file is part of the main +// thread where self is of type `Window` instead of `Worker` +Sentry.registerWebWorker({ self: self as unknown as Worker }); + +// Let the main thread know the worker is ready +self.postMessage({ + msg: 'WORKER_3_READY', +}); + +self.addEventListener('message', event => { + if (event.data.msg === 'TRIGGER_ERROR') { + // This will throw an uncaught error in the worker + throw new Error(`Uncaught error in worker 3`); + } +}); 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 13d044709914..e298fa525efb 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 @@ -71,3 +71,103 @@ test("user worker message handlers don't trigger for sentry messages", async ({ expect(workerMessageCount).toBe(1); }); + +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]; + }); + + const transactionPromise = waitForTransaction('browser-webworker-vite', transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/'); + + await page.locator('#trigger-error-2').click(); + + await page.waitForTimeout(1000); + + const errorEvent = await errorEventPromise; + const transactionEvent = await transactionPromise; + + const pageloadTraceId = transactionEvent.contexts?.trace?.trace_id; + const pageloadSpanId = transactionEvent.contexts?.trace?.span_id; + + 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.transaction).toBe('/'); + expect(transactionEvent.transaction).toBe('/'); + + expect(errorEvent.request).toEqual({ + url: 'http://localhost:3030/', + headers: expect.any(Object), + }); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: pageloadTraceId, + span_id: pageloadSpanId, + }); + + expect(errorEvent.debug_meta).toEqual({ + images: [ + { + code_file: expect.stringMatching(/http:\/\/localhost:3030\/assets\/worker2-.+\.js/), + debug_id: expect.stringMatching(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/), + type: 'sourcemap', + }, + ], + }); +}); + +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]; + }); + + const transactionPromise = waitForTransaction('browser-webworker-vite', transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/'); + + await page.locator('#trigger-error-3').click(); + + await page.waitForTimeout(1000); + + const errorEvent = await errorEventPromise; + const transactionEvent = await transactionPromise; + + const pageloadTraceId = transactionEvent.contexts?.trace?.trace_id; + const pageloadSpanId = transactionEvent.contexts?.trace?.span_id; + + 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.transaction).toBe('/'); + expect(transactionEvent.transaction).toBe('/'); + + expect(errorEvent.request).toEqual({ + url: 'http://localhost:3030/', + headers: expect.any(Object), + }); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: pageloadTraceId, + span_id: pageloadSpanId, + }); + + expect(errorEvent.debug_meta).toEqual({ + images: [ + { + code_file: expect.stringMatching(/http:\/\/localhost:3030\/assets\/worker3-.+\.js/), + debug_id: expect.stringMatching(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/), + type: 'sourcemap', + }, + ], + }); +}); diff --git a/packages/browser/src/integrations/webWorker.ts b/packages/browser/src/integrations/webWorker.ts index 27362f7badcd..f422f372a463 100644 --- a/packages/browser/src/integrations/webWorker.ts +++ b/packages/browser/src/integrations/webWorker.ts @@ -1,3 +1,4 @@ +import type { Integration, IntegrationFn } from '@sentry/core'; import { debug, defineIntegration, isPlainObject } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../helpers'; @@ -10,7 +11,11 @@ interface WebWorkerMessage { } interface WebWorkerIntegrationOptions { - worker: Worker; + worker: Worker | Array; +} + +interface WebWorkerIntegration extends Integration { + addWorker: (worker: Worker) => void; } /** @@ -18,13 +23,13 @@ interface WebWorkerIntegrationOptions { * * IMPORTANT: This integration must be added **before** you start listening to * any messages from the worker. Otherwise, your message handlers will receive - * messages from Sentry which you need to ignore. + * messages from the Sentry SDK which you need to ignore. * * This integration only has an effect, if you call `Sentry.registerWorker(self)` - * from within the worker you're adding to the integration. + * from within the worker(s) you're adding to the integration. * * Given that you want to initialize the SDK as early as possible, you most likely - * want to add the integration after initializing the SDK: + * want to add this integration **after** initializing the SDK: * * @example: * ```ts filename={main.js} @@ -37,7 +42,8 @@ interface WebWorkerIntegrationOptions { * const worker = new Worker(new URL('./worker.ts', import.meta.url)); * * // 2. Add the integration - * Sentry.addIntegration(Sentry.webWorkerIntegration({ worker })); + * const webWorkerIntegration = Sentry.webWorkerIntegration({ worker }); + * Sentry.addIntegration(webWorkerIntegration); * * // 3. Register message listeners on the worker * worker.addEventListener('message', event => { @@ -45,6 +51,25 @@ interface WebWorkerIntegrationOptions { * }); * ``` * + * If you initialize multiple workers at the same time, you can also pass an array of workers + * to the integration: + * + * ```ts filename={main.js} + * const webWorkerIntegration = Sentry.webWorkerIntegration({ worker: [worker1, worker2] }); + * Sentry.addIntegration(webWorkerIntegration); + * ``` + * + * If you have any additional workers that you initialize at a later point, + * you can add them to the integration as follows: + * + * ```ts filename={main.js} + * const webWorkerIntegration = Sentry.webWorkerIntegration({ worker: worker1 }); + * Sentry.addIntegration(webWorkerIntegration); + * + * // sometime later: + * webWorkerIntegration.addWorker(worker2); + * ``` + * * Of course, you can also directly add the integration in Sentry.init: * ```ts filename={main.js} * import * as Sentry from '@sentry/'; @@ -69,19 +94,24 @@ interface WebWorkerIntegrationOptions { export const webWorkerIntegration = defineIntegration(({ worker }: WebWorkerIntegrationOptions) => ({ name: INTEGRATION_NAME, setupOnce: () => { - worker.addEventListener('message', event => { - if (isSentryDebugIdMessage(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, - }; - } - }); + (Array.isArray(worker) ? worker : [worker]).forEach(w => listenForSentryDebugIdMessages(w)); }, -})); + addWorker: (worker: Worker) => listenForSentryDebugIdMessages(worker), +})) as IntegrationFn; + +function listenForSentryDebugIdMessages(worker: Worker): void { + worker.addEventListener('message', event => { + if (isSentryDebugIdMessage(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, + }; + } + }); +} interface RegisterWebWorkerOptions { self: Worker & { _sentryDebugIds?: Record }; diff --git a/packages/browser/test/integrations/webWorker.test.ts b/packages/browser/test/integrations/webWorker.test.ts index 297150bcd134..eacd2b53344d 100644 --- a/packages/browser/test/integrations/webWorker.test.ts +++ b/packages/browser/test/integrations/webWorker.test.ts @@ -38,6 +38,12 @@ describe('webWorkerIntegration', () => { _sentryDebugIds?: Record; }; + let mockWorker2: { + addEventListener: ReturnType; + postMessage: ReturnType; + _sentryDebugIds?: Record; + }; + let mockEvent: { data: any; stopImmediatePropagation: ReturnType; @@ -55,6 +61,11 @@ describe('webWorkerIntegration', () => { postMessage: vi.fn(), }; + mockWorker2 = { + addEventListener: vi.fn(), + postMessage: vi.fn(), + }; + // Setup mock event mockEvent = { data: {}, @@ -75,7 +86,7 @@ describe('webWorkerIntegration', () => { }); describe('setupOnce', () => { - it('adds message event listener to worker', () => { + it('adds message event listener to the worker', () => { const integration = webWorkerIntegration({ worker: mockWorker as any }); integration.setupOnce!(); @@ -83,6 +94,20 @@ describe('webWorkerIntegration', () => { expect(mockWorker.addEventListener).toHaveBeenCalledWith('message', expect.any(Function)); }); + it('adds message event listener to multiple workers passed to the integration', () => { + const integration = webWorkerIntegration({ worker: [mockWorker, mockWorker2] as any }); + integration.setupOnce!(); + expect(mockWorker.addEventListener).toHaveBeenCalledWith('message', expect.any(Function)); + expect(mockWorker2.addEventListener).toHaveBeenCalledWith('message', expect.any(Function)); + }); + + it('adds message event listener to a worker added later', () => { + const integration = webWorkerIntegration({ worker: mockWorker as any }); + integration.setupOnce!(); + integration.addWorker(mockWorker2 as any); + expect(mockWorker2.addEventListener).toHaveBeenCalledWith('message', expect.any(Function)); + }); + describe('message handler', () => { let messageHandler: (event: any) => void; @@ -246,14 +271,16 @@ describe('registerWebWorker', () => { describe('registerWebWorker and webWorkerIntegration', () => { beforeEach(() => {}); - it('works together', () => { + it('work together (with multiple workers)', () => { (helpers.WINDOW as any)._sentryDebugIds = { 'Error at \n /main-file1.js': 'main-debug-1', 'Error at \n /main-file2.js': 'main-debug-2', 'Error at \n /shared-file.js': 'main-debug-id', }; - let cb: ((arg0: any) => any) | undefined = undefined; + let cb1: ((arg0: any) => any) | undefined = undefined; + let cb2: ((arg0: any) => any) | undefined = undefined; + let cb3: ((arg0: any) => any) | undefined = undefined; // Setup mock worker const mockWorker = { @@ -262,19 +289,47 @@ describe('registerWebWorker and webWorkerIntegration', () => { 'Error at \n /worker-file2.js': 'worker-debug-2', 'Error at \n /shared-file.js': 'worker-debug-id', }, - addEventListener: vi.fn((_, l) => (cb = l)), + addEventListener: vi.fn((_, l) => (cb1 = l)), postMessage: vi.fn(message => { // @ts-expect-error - cb is defined - cb({ data: message, stopImmediatePropagation: vi.fn() }); + cb1({ data: message, stopImmediatePropagation: vi.fn() }); }), }; - const integration = webWorkerIntegration({ worker: mockWorker as any }); + const mockWorker2 = { + _sentryDebugIds: { + 'Error at \n /worker-2-file1.js': 'worker-2-debug-1', + 'Error at \n /worker-2-file2.js': 'worker-2-debug-2', + }, + + addEventListener: vi.fn((_, l) => (cb2 = l)), + postMessage: vi.fn(message => { + // @ts-expect-error - cb is defined + cb2({ data: message, stopImmediatePropagation: vi.fn() }); + }), + }; + + const mockWorker3 = { + _sentryDebugIds: { + 'Error at \n /worker-3-file1.js': 'worker-3-debug-1', + 'Error at \n /worker-3-file2.js': 'worker-3-debug-2', + }, + addEventListener: vi.fn((_, l) => (cb3 = l)), + postMessage: vi.fn(message => { + // @ts-expect-error - cb is defined + cb3({ data: message, stopImmediatePropagation: vi.fn() }); + }), + }; + + const integration = webWorkerIntegration({ worker: [mockWorker as any, mockWorker2 as any] }); integration.setupOnce!(); registerWebWorker({ self: mockWorker as any }); + registerWebWorker({ self: mockWorker2 as any }); expect(mockWorker.addEventListener).toHaveBeenCalledWith('message', expect.any(Function)); + expect(mockWorker2.addEventListener).toHaveBeenCalledWith('message', expect.any(Function)); + expect(mockWorker.postMessage).toHaveBeenCalledWith({ _sentryMessage: true, _sentryDebugIds: mockWorker._sentryDebugIds, @@ -286,6 +341,30 @@ describe('registerWebWorker and webWorkerIntegration', () => { 'Error at \n /shared-file.js': 'main-debug-id', 'Error at \n /worker-file1.js': 'worker-debug-1', 'Error at \n /worker-file2.js': 'worker-debug-2', + 'Error at \n /worker-2-file1.js': 'worker-2-debug-1', + 'Error at \n /worker-2-file2.js': 'worker-2-debug-2', + }); + + integration.addWorker(mockWorker3 as any); + registerWebWorker({ self: mockWorker3 as any }); + + expect(mockWorker3.addEventListener).toHaveBeenCalledWith('message', expect.any(Function)); + + expect(mockWorker3.postMessage).toHaveBeenCalledWith({ + _sentryMessage: true, + _sentryDebugIds: mockWorker3._sentryDebugIds, + }); + + expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({ + 'Error at \n /main-file1.js': 'main-debug-1', + 'Error at \n /main-file2.js': 'main-debug-2', + 'Error at \n /shared-file.js': 'main-debug-id', + 'Error at \n /worker-file1.js': 'worker-debug-1', + 'Error at \n /worker-file2.js': 'worker-debug-2', + 'Error at \n /worker-2-file1.js': 'worker-2-debug-1', + 'Error at \n /worker-2-file2.js': 'worker-2-debug-2', + 'Error at \n /worker-3-file1.js': 'worker-3-debug-1', + 'Error at \n /worker-3-file2.js': 'worker-3-debug-2', }); }); });