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