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..aa08cd652418 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/webWorker/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +// Initialize Sentry with webWorker integration +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', +}); + +const worker = new Worker('/worker.js'); + +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/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/index.html b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/index.html new file mode 100644 index 000000000000..0ebc79719432 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/index.html @@ -0,0 +1,21 @@ + + + + + + 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..fcda3617e5c5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json @@ -0,0 +1,29 @@ +{ + "name": "browser-webworker-vite", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "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" + }, + "devDependencies": { + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "~5.8.3", + "vite": "^7.0.4" + }, + "dependencies": { + "@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 new file mode 100644 index 000000000000..bf40ebae4467 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/playwright.config.mjs @@ -0,0 +1,10 @@ +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 new file mode 100644 index 000000000000..b017c1bfdc4d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts @@ -0,0 +1,44 @@ +import MyWorker from './worker.ts?worker'; +import MyWorker2 from './worker2.ts?worker'; +import * as Sentry from '@sentry/browser'; + +Sentry.init({ + 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(); +const worker2 = new MyWorker2(); + +const webWorkerIntegration = Sentry.webWorkerIntegration({ worker: [worker, worker2] }); +Sentry.addIntegration(webWorkerIntegration); + +worker.addEventListener('message', event => { + // this is part of the test, do not delete + console.log('received message from worker:', event.data.msg); +}); + +document.querySelector('#trigger-error')!.addEventListener('click', () => { + worker.postMessage({ + 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/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/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/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..e298fa525efb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts @@ -0,0 +1,173 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +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]; + }); + + const transactionPromise = waitForTransaction('browser-webworker-vite', transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/'); + + await page.locator('#trigger-error').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'); + expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toHaveLength(1); + 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: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\/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); +}); + +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/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..df010d9b426c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/vite.config.ts @@ -0,0 +1,29 @@ +import { sentryVitePlugin } from '@sentry/vite-plugin'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + sourcemap: 'hidden', + envPrefix: ['PUBLIC_'], + }, + + 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, + }), + ], + }, + + envPrefix: ['PUBLIC_'], +}); diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 963d8ab38546..0bc523506454 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, registerWebWorker } from './integrations/webWorker'; diff --git a/packages/browser/src/integrations/webWorker.ts b/packages/browser/src/integrations/webWorker.ts new file mode 100644 index 000000000000..f422f372a463 --- /dev/null +++ b/packages/browser/src/integrations/webWorker.ts @@ -0,0 +1,150 @@ +import type { Integration, IntegrationFn } from '@sentry/core'; +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 | Array; +} + +interface WebWorkerIntegration extends Integration { + addWorker: (worker: Worker) => void; +} + +/** + * 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 the Sentry SDK which you need to ignore. + * + * This integration only has an effect, if you call `Sentry.registerWorker(self)` + * 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 this 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 + * const webWorkerIntegration = Sentry.webWorkerIntegration({ worker }); + * Sentry.addIntegration(webWorkerIntegration); + * + * // 3. Register message listeners on the worker + * worker.addEventListener('message', event => { + * // ... + * }); + * ``` + * + * 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/'; + * + * // 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 => { + * // ... + * }); + * ``` + * + * @param options {WebWorkerIntegrationOptions} Integration options: + * - `worker`: The worker instance. + */ +export const webWorkerIntegration = defineIntegration(({ worker }: WebWorkerIntegrationOptions) => ({ + name: INTEGRATION_NAME, + setupOnce: () => { + (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 }; +} + +/** + * 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 options {RegisterWebWorkerOptions} Integration options: + * - `self`: The worker instance you're calling this function from (self). + */ +export function registerWebWorker({ self }: RegisterWebWorkerOptions): void { + self.postMessage({ + _sentryMessage: true, + _sentryDebugIds: self._sentryDebugIds ?? undefined, + }); +} + +function isSentryDebugIdMessage(eventData: unknown): eventData is WebWorkerMessage { + return ( + isPlainObject(eventData) && + eventData._sentryMessage === true && + '_sentryDebugIds' in eventData && + (isPlainObject(eventData._sentryDebugIds) || eventData._sentryDebugIds === undefined) + ); +} diff --git a/packages/browser/test/integrations/webWorker.test.ts b/packages/browser/test/integrations/webWorker.test.ts new file mode 100644 index 000000000000..eacd2b53344d --- /dev/null +++ b/packages/browser/test/integrations/webWorker.test.ts @@ -0,0 +1,370 @@ +/** + * @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 mockWorker2: { + 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(), + }; + + mockWorker2 = { + addEventListener: vi.fn(), + postMessage: vi.fn(), + }; + + // Setup mock event + mockEvent = { + data: {}, + stopImmediatePropagation: vi.fn(), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('creates integration with correct name', () => { + const integration = webWorkerIntegration({ worker: mockWorker as any }); + + 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 the worker', () => { + const integration = webWorkerIntegration({ worker: mockWorker as any }); + + integration.setupOnce!(); + + 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; + + beforeEach(() => { + const integration = webWorkerIntegration({ worker: mockWorker as any }); + integration.setupOnce!(); + + // Extract the message handler from the addEventListener call + expect(mockWorker.addEventListener.mock.calls).toBeDefined(); + messageHandler = mockWorker.addEventListener.mock.calls![0]![1]; + }); + + 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('processes valid Sentry messages', () => { + mockEvent.data = { + _sentryMessage: true, + _sentryDebugIds: { 'file1.js': 'debug-id-1' }, + }; + + 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', + }, + }; + + 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 + }, + }; + + 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: {}, + }; + + 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({ self: mockWorkerSelf as any }); + + expect(mockWorkerSelf.postMessage).toHaveBeenCalledTimes(1); + 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({ self: mockWorkerSelf as any }); + + expect(mockWorkerSelf.postMessage).toHaveBeenCalledTimes(1); + 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({ self: mockWorkerSelf as any }); + + expect(mockWorkerSelf.postMessage).toHaveBeenCalledTimes(1); + expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ + _sentryMessage: true, + _sentryDebugIds: undefined, + }); + }); +}); + +describe('registerWebWorker and webWorkerIntegration', () => { + beforeEach(() => {}); + + 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 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 = { + _sentryDebugIds: { + '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) => (cb1 = l)), + postMessage: vi.fn(message => { + // @ts-expect-error - cb is defined + cb1({ data: message, stopImmediatePropagation: vi.fn() }); + }), + }; + + 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, + }); + + 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', + }); + + 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', + }); + }); +});