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 b017c1bfdc4d..238ec062663a 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 @@ -7,7 +7,13 @@ Sentry.init({ environment: import.meta.env.MODE || 'development', tracesSampleRate: 1.0, debug: true, - integrations: [Sentry.browserTracingIntegration()], + integrations: [ + Sentry.browserTracingIntegration(), + Sentry.thirdPartyErrorFilterIntegration({ + behaviour: 'apply-tag-if-contains-third-party-frames', + filterKeys: ['browser-webworker-vite'], + }), + ], tunnel: 'http://localhost:3031/', // proxy server }); 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 e298fa525efb..d12e61111c85 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 @@ -171,3 +171,19 @@ test('captures an error from the third lazily added worker', async ({ page }) => ], }); }); + +test('worker errors are not tagged as third-party when module metadata is present', async ({ page }) => { + const errorEventPromise = waitForError('browser-webworker-vite', async event => { + return !event.type && event.exception?.values?.[0]?.value === 'Uncaught Error: Uncaught error in worker'; + }); + + await page.goto('/'); + + await page.locator('#trigger-error').click(); + + await page.waitForTimeout(1000); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.tags?.third_party_code).toBeUndefined(); +}); 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 df010d9b426c..190aa3749e3f 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 @@ -12,6 +12,7 @@ export default defineConfig({ org: process.env.E2E_TEST_SENTRY_ORG_SLUG, project: process.env.E2E_TEST_SENTRY_PROJECT, authToken: process.env.E2E_TEST_AUTH_TOKEN, + applicationKey: 'browser-webworker-vite', }), ], @@ -21,6 +22,7 @@ export default defineConfig({ org: process.env.E2E_TEST_SENTRY_ORG_SLUG, project: process.env.E2E_TEST_SENTRY_PROJECT, authToken: process.env.E2E_TEST_AUTH_TOKEN, + applicationKey: 'browser-webworker-vite', }), ], }, diff --git a/packages/browser/src/integrations/webWorker.ts b/packages/browser/src/integrations/webWorker.ts index e95e161e703c..5af6c3b2553a 100644 --- a/packages/browser/src/integrations/webWorker.ts +++ b/packages/browser/src/integrations/webWorker.ts @@ -10,6 +10,7 @@ export const INTEGRATION_NAME = 'WebWorker'; interface WebWorkerMessage { _sentryMessage: boolean; _sentryDebugIds?: Record; + _sentryModuleMetadata?: Record; // eslint-disable-line @typescript-eslint/no-explicit-any _sentryWorkerError?: SerializedWorkerError; } @@ -122,6 +123,18 @@ function listenForSentryMessages(worker: Worker): void { }; } + // Handle module metadata + if (event.data._sentryModuleMetadata) { + DEBUG_BUILD && debug.log('Sentry module metadata web worker message received', event.data); + // Merge worker's raw metadata into the global object + // It will be parsed lazily when needed by getMetadataForUrl + WINDOW._sentryModuleMetadata = { + ...event.data._sentryModuleMetadata, + // Module metadata of the main thread have precedence over the worker's in case of a collision. + ...WINDOW._sentryModuleMetadata, + }; + } + // Handle unhandled rejections forwarded from worker if (event.data._sentryWorkerError) { DEBUG_BUILD && debug.log('Sentry worker rejection message received', event.data._sentryWorkerError); @@ -187,7 +200,10 @@ interface MinimalDedicatedWorkerGlobalScope { } interface RegisterWebWorkerOptions { - self: MinimalDedicatedWorkerGlobalScope & { _sentryDebugIds?: Record }; + self: MinimalDedicatedWorkerGlobalScope & { + _sentryDebugIds?: Record; + _sentryModuleMetadata?: Record; // eslint-disable-line @typescript-eslint/no-explicit-any + }; } /** @@ -195,6 +211,7 @@ interface RegisterWebWorkerOptions { * * This function will: * - Send debug IDs to the parent thread + * - Send module metadata to the parent thread (for thirdPartyErrorFilterIntegration) * - Set up a handler for unhandled rejections in the worker * - Forward unhandled rejections to the parent thread for capture * @@ -215,10 +232,12 @@ interface RegisterWebWorkerOptions { * - `self`: The worker instance you're calling this function from (self). */ export function registerWebWorker({ self }: RegisterWebWorkerOptions): void { - // Send debug IDs to parent thread + // Send debug IDs and raw module metadata to parent thread + // The metadata will be parsed lazily on the main thread when needed self.postMessage({ _sentryMessage: true, _sentryDebugIds: self._sentryDebugIds ?? undefined, + _sentryModuleMetadata: self._sentryModuleMetadata ?? undefined, }); // Set up unhandledrejection handler inside the worker @@ -251,11 +270,12 @@ function isSentryMessage(eventData: unknown): eventData is WebWorkerMessage { return false; } - // Must have at least one of: debug IDs or worker error + // Must have at least one of: debug IDs, module metadata, or worker error const hasDebugIds = '_sentryDebugIds' in eventData; + const hasModuleMetadata = '_sentryModuleMetadata' in eventData; const hasWorkerError = '_sentryWorkerError' in eventData; - if (!hasDebugIds && !hasWorkerError) { + if (!hasDebugIds && !hasModuleMetadata && !hasWorkerError) { return false; } @@ -264,6 +284,14 @@ function isSentryMessage(eventData: unknown): eventData is WebWorkerMessage { return false; } + // Validate module metadata if present + if ( + hasModuleMetadata && + !(isPlainObject(eventData._sentryModuleMetadata) || eventData._sentryModuleMetadata === undefined) + ) { + return false; + } + // Validate worker error if present if (hasWorkerError && !isPlainObject(eventData._sentryWorkerError)) { return false; diff --git a/packages/browser/test/integrations/webWorker.test.ts b/packages/browser/test/integrations/webWorker.test.ts index b72895621339..584f18ee9a75 100644 --- a/packages/browser/test/integrations/webWorker.test.ts +++ b/packages/browser/test/integrations/webWorker.test.ts @@ -209,6 +209,97 @@ describe('webWorkerIntegration', () => { 'main.js': 'main-debug', }); }); + + it('processes module metadata from worker', () => { + (helpers.WINDOW as any)._sentryModuleMetadata = undefined; + const moduleMetadata = { + 'Error\n at worker-file1.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, + 'Error\n at worker-file2.js:2:2': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mockEvent.data = { + _sentryMessage: true, + _sentryModuleMetadata: moduleMetadata, + }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).toHaveBeenCalled(); + expect(mockDebugLog).toHaveBeenCalledWith('Sentry module metadata web worker message received', mockEvent.data); + expect((helpers.WINDOW as any)._sentryModuleMetadata).toEqual(moduleMetadata); + }); + + it('handles message with both debug IDs and module metadata', () => { + (helpers.WINDOW as any)._sentryModuleMetadata = undefined; + const moduleMetadata = { + 'Error\n at worker-file.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mockEvent.data = { + _sentryMessage: true, + _sentryDebugIds: { 'worker-file.js': 'debug-id-1' }, + _sentryModuleMetadata: moduleMetadata, + }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).toHaveBeenCalled(); + expect((helpers.WINDOW as any)._sentryModuleMetadata).toEqual(moduleMetadata); + expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({ + 'worker-file.js': 'debug-id-1', + }); + }); + + it('accepts message with only module metadata', () => { + (helpers.WINDOW as any)._sentryModuleMetadata = undefined; + const moduleMetadata = { + 'Error\n at worker-file.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mockEvent.data = { + _sentryMessage: true, + _sentryModuleMetadata: moduleMetadata, + }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).toHaveBeenCalled(); + expect((helpers.WINDOW as any)._sentryModuleMetadata).toEqual(moduleMetadata); + }); + + it('ignores invalid module metadata', () => { + mockEvent.data = { + _sentryMessage: true, + _sentryModuleMetadata: 'not-an-object', + }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).not.toHaveBeenCalled(); + }); + + it('gives main thread precedence over worker for conflicting module metadata', () => { + (helpers.WINDOW as any)._sentryModuleMetadata = { + 'Error\n at shared-file.js:1:1': { '_sentryBundlerPluginAppKey:main-app': true, source: 'main' }, + 'Error\n at main-only.js:1:1': { '_sentryBundlerPluginAppKey:main-app': true }, + }; + + mockEvent.data = { + _sentryMessage: true, + _sentryModuleMetadata: { + 'Error\n at shared-file.js:1:1': { '_sentryBundlerPluginAppKey:worker-app': true, source: 'worker' }, + 'Error\n at worker-only.js:1:1': { '_sentryBundlerPluginAppKey:worker-app': true }, + }, + }; + + messageHandler(mockEvent); + + expect((helpers.WINDOW as any)._sentryModuleMetadata).toEqual({ + 'Error\n at shared-file.js:1:1': { '_sentryBundlerPluginAppKey:main-app': true, source: 'main' }, // Main thread wins + 'Error\n at main-only.js:1:1': { '_sentryBundlerPluginAppKey:main-app': true }, // Main thread preserved + 'Error\n at worker-only.js:1:1': { '_sentryBundlerPluginAppKey:worker-app': true }, // Worker added + }); + }); }); }); }); @@ -218,6 +309,7 @@ describe('registerWebWorker', () => { postMessage: ReturnType; addEventListener: ReturnType; _sentryDebugIds?: Record; + _sentryModuleMetadata?: Record; }; beforeEach(() => { @@ -236,6 +328,7 @@ describe('registerWebWorker', () => { expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ _sentryMessage: true, _sentryDebugIds: undefined, + _sentryModuleMetadata: undefined, }); }); @@ -254,6 +347,7 @@ describe('registerWebWorker', () => { 'worker-file1.js': 'debug-id-1', 'worker-file2.js': 'debug-id-2', }, + _sentryModuleMetadata: undefined, }); }); @@ -266,6 +360,57 @@ describe('registerWebWorker', () => { expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ _sentryMessage: true, _sentryDebugIds: undefined, + _sentryModuleMetadata: undefined, + }); + }); + + it('includes raw module metadata when available', () => { + const rawMetadata = { + 'Error\n at worker-file1.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, + 'Error\n at worker-file2.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mockWorkerSelf._sentryModuleMetadata = rawMetadata; + + registerWebWorker({ self: mockWorkerSelf as any }); + + expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ + _sentryMessage: true, + _sentryDebugIds: undefined, + _sentryModuleMetadata: rawMetadata, + }); + }); + + it('sends undefined module metadata when not available', () => { + mockWorkerSelf._sentryModuleMetadata = undefined; + + registerWebWorker({ self: mockWorkerSelf as any }); + + expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ + _sentryMessage: true, + _sentryDebugIds: undefined, + _sentryModuleMetadata: undefined, + }); + }); + + it('includes both debug IDs and module metadata when both available', () => { + const rawMetadata = { + 'Error\n at worker-file.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mockWorkerSelf._sentryDebugIds = { + 'worker-file.js': 'debug-id-1', + }; + mockWorkerSelf._sentryModuleMetadata = rawMetadata; + + registerWebWorker({ self: mockWorkerSelf as any }); + + expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ + _sentryMessage: true, + _sentryDebugIds: { + 'worker-file.js': 'debug-id-1', + }, + _sentryModuleMetadata: rawMetadata, }); }); }); @@ -335,6 +480,7 @@ describe('registerWebWorker and webWorkerIntegration', () => { expect(mockWorker.postMessage).toHaveBeenCalledWith({ _sentryMessage: true, _sentryDebugIds: mockWorker._sentryDebugIds, + _sentryModuleMetadata: undefined, }); expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({ @@ -355,6 +501,7 @@ describe('registerWebWorker and webWorkerIntegration', () => { expect(mockWorker3.postMessage).toHaveBeenCalledWith({ _sentryMessage: true, _sentryDebugIds: mockWorker3._sentryDebugIds, + _sentryModuleMetadata: undefined, }); expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 30e24c3b35c7..24eac7807364 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -322,6 +322,7 @@ export { vercelWaitUntil } from './utils/vercelWaitUntil'; export { flushIfServerless } from './utils/flushIfServerless'; export { SDK_VERSION } from './utils/version'; export { getDebugImagesForResources, getFilenameToDebugIdMap } from './utils/debug-ids'; +export { getFilenameToMetadataMap } from './metadata'; export { escapeStringForRegex } from './vendor/escapeStringForRegex'; export type { Attachment } from './types-hoist/attachment'; diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index 1ee93e8dcd5a..54ee4a1e1eb4 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -8,6 +8,37 @@ const filenameMetadataMap = new Map(); /** Set of stack strings that have already been parsed. */ const parsedStacks = new Set(); +/** + * Builds a map of filenames to module metadata from the global _sentryModuleMetadata object. + * This is useful for forwarding metadata from web workers to the main thread. + * + * @param parser - Stack parser to use for extracting filenames from stack traces + * @returns A map of filename to metadata object + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getFilenameToMetadataMap(parser: StackParser): Record { + if (!GLOBAL_OBJ._sentryModuleMetadata) { + return {}; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const filenameMap: Record = {}; + + for (const stack of Object.keys(GLOBAL_OBJ._sentryModuleMetadata)) { + const metadata = GLOBAL_OBJ._sentryModuleMetadata[stack]; + const frames = parser(stack); + + for (const frame of frames.reverse()) { + if (frame.filename) { + filenameMap[frame.filename] = metadata; + break; + } + } + } + + return filenameMap; +} + function ensureMetadataStacksAreParsed(parser: StackParser): void { if (!GLOBAL_OBJ._sentryModuleMetadata) { return; diff --git a/packages/core/test/lib/metadata.test.ts b/packages/core/test/lib/metadata.test.ts index bedf4cdcf7e9..e312698a6cf8 100644 --- a/packages/core/test/lib/metadata.test.ts +++ b/packages/core/test/lib/metadata.test.ts @@ -1,5 +1,10 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { addMetadataToStackFrames, getMetadataForUrl, stripMetadataFromStackFrames } from '../../src/metadata'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + addMetadataToStackFrames, + getFilenameToMetadataMap, + getMetadataForUrl, + stripMetadataFromStackFrames, +} from '../../src/metadata'; import type { Event } from '../../src/types-hoist/event'; import { nodeStackLineParser } from '../../src/utils/node-stack-trace'; import { createStackParser } from '../../src/utils/stacktrace'; @@ -44,6 +49,10 @@ describe('Metadata', () => { GLOBAL_OBJ._sentryModuleMetadata[stack] = { team: 'frontend' }; }); + afterEach(() => { + delete GLOBAL_OBJ._sentryModuleMetadata; + }); + it('is parsed', () => { const metadata = getMetadataForUrl(parser, __filename); @@ -97,3 +106,71 @@ describe('Metadata', () => { ]); }); }); + +describe('getFilenameToMetadataMap', () => { + afterEach(() => { + delete GLOBAL_OBJ._sentryModuleMetadata; + }); + + it('returns empty object when no metadata is available', () => { + delete GLOBAL_OBJ._sentryModuleMetadata; + + const result = getFilenameToMetadataMap(parser); + + expect(result).toEqual({}); + }); + + it('extracts filenames from stack traces and maps to metadata', () => { + const stack1 = `Error + at Object. (/path/to/file1.js:10:15) + at Module._compile (internal/modules/cjs/loader.js:1063:30)`; + + const stack2 = `Error + at processTicksAndRejections (/path/to/file2.js:20:25)`; + + GLOBAL_OBJ._sentryModuleMetadata = { + [stack1]: { '_sentryBundlerPluginAppKey:my-app': true, team: 'frontend' }, + [stack2]: { '_sentryBundlerPluginAppKey:my-app': true, team: 'backend' }, + }; + + const result = getFilenameToMetadataMap(parser); + + expect(result).toEqual({ + '/path/to/file1.js': { '_sentryBundlerPluginAppKey:my-app': true, team: 'frontend' }, + '/path/to/file2.js': { '_sentryBundlerPluginAppKey:my-app': true, team: 'backend' }, + }); + }); + + it('handles stack traces with native code frames', () => { + const stackNoFilename = `Error + at [native code]`; + + GLOBAL_OBJ._sentryModuleMetadata = { + [stackNoFilename]: { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + const result = getFilenameToMetadataMap(parser); + + // Native code may be parsed as a filename by the parser + // This is acceptable behavior as long as we don't error + expect(result).toBeDefined(); + }); + + it('handles multiple stacks with the same filename', () => { + const stack1 = `Error + at functionA (/path/to/same-file.js:10:15)`; + + const stack2 = `Error + at functionB (/path/to/same-file.js:20:25)`; + + GLOBAL_OBJ._sentryModuleMetadata = { + [stack1]: { '_sentryBundlerPluginAppKey:app1': true }, + [stack2]: { '_sentryBundlerPluginAppKey:app2': true }, + }; + + const result = getFilenameToMetadataMap(parser); + + // Last one wins (based on iteration order) + expect(result['/path/to/same-file.js']).toBeDefined(); + }); +});