From 862a096d1c00f99db46ebcd3947b8a5bce675283 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 9 Jan 2026 10:19:16 +0100 Subject: [PATCH 1/2] fix(browser): Forward worker metadata for third-party error filtering The `thirdPartyErrorFilterIntegration` was not able to identify first-party worker code as because module metadata stayed in the worker's separate global scope and wasn't accessible to the main thread. We now forward the metadata the same way we forward debug ids to the main thread which allows first-party worker code to be identified as such. Closes: #18705 --- .../browser-webworker-vite/src/main.ts | 8 +- .../tests/errors.test.ts | 16 ++ .../browser-webworker-vite/vite.config.ts | 2 + .../browser/src/integrations/webWorker.ts | 44 ++++- .../test/integrations/webWorker.test.ts | 139 ++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/src/metadata.ts | 46 ++++++ packages/core/test/lib/metadata.test.ts | 154 +++++++++++++++++- 8 files changed, 402 insertions(+), 8 deletions(-) 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..5537178d02f3 100644 --- a/packages/browser/src/integrations/webWorker.ts +++ b/packages/browser/src/integrations/webWorker.ts @@ -1,8 +1,18 @@ import type { Integration, IntegrationFn } from '@sentry/core'; -import { captureEvent, debug, defineIntegration, getClient, isPlainObject, isPrimitive } from '@sentry/core'; +import { + captureEvent, + debug, + defineIntegration, + getClient, + getFilenameToMetadataMap, + isPlainObject, + isPrimitive, + mergeMetadataMap, +} from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { eventFromUnknownInput } from '../eventbuilder'; import { WINDOW } from '../helpers'; +import { defaultStackParser } from '../stack-parsers'; import { _eventFromRejectionWithPrimitive, _getUnhandledRejectionError } from './globalhandlers'; export const INTEGRATION_NAME = 'WebWorker'; @@ -10,6 +20,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 +133,13 @@ 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 metadata into the main thread's metadata cache + mergeMetadataMap(event.data._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 +205,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 +216,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 +237,13 @@ 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 + const moduleMetadata = self._sentryModuleMetadata ? getFilenameToMetadataMap(defaultStackParser) : undefined; + + // Send debug IDs and module metadata to parent thread self.postMessage({ _sentryMessage: true, _sentryDebugIds: self._sentryDebugIds ?? undefined, + _sentryModuleMetadata: moduleMetadata, }); // Set up unhandledrejection handler inside the worker @@ -251,11 +276,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 +290,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..4956ccfb7774 100644 --- a/packages/browser/test/integrations/webWorker.test.ts +++ b/packages/browser/test/integrations/webWorker.test.ts @@ -14,6 +14,8 @@ vi.mock('@sentry/core', async importActual => { debug: { log: vi.fn(), }, + mergeMetadataMap: vi.fn(), + getFilenameToMetadataMap: vi.fn(), }; }); @@ -209,6 +211,74 @@ describe('webWorkerIntegration', () => { 'main.js': 'main-debug', }); }); + + it('processes module metadata from worker', () => { + const mockMergeMetadataMap = SentryCore.mergeMetadataMap as any; + const moduleMetadata = { + 'worker-file1.js': { '_sentryBundlerPluginAppKey:my-app': true }, + 'worker-file2.js': { '_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(mockMergeMetadataMap).toHaveBeenCalledWith(moduleMetadata); + }); + + it('handles message with both debug IDs and module metadata', () => { + const mockMergeMetadataMap = SentryCore.mergeMetadataMap as any; + const moduleMetadata = { + 'worker-file.js': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mockEvent.data = { + _sentryMessage: true, + _sentryDebugIds: { 'worker-file.js': 'debug-id-1' }, + _sentryModuleMetadata: moduleMetadata, + }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).toHaveBeenCalled(); + expect(mockMergeMetadataMap).toHaveBeenCalledWith(moduleMetadata); + expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({ + 'worker-file.js': 'debug-id-1', + }); + }); + + it('accepts message with only module metadata', () => { + const mockMergeMetadataMap = SentryCore.mergeMetadataMap as any; + const moduleMetadata = { + 'worker-file.js': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mockEvent.data = { + _sentryMessage: true, + _sentryModuleMetadata: moduleMetadata, + }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).toHaveBeenCalled(); + expect(mockMergeMetadataMap).toHaveBeenCalledWith(moduleMetadata); + }); + + it('ignores invalid module metadata', () => { + mockEvent.data = { + _sentryMessage: true, + _sentryModuleMetadata: 'not-an-object', + }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).not.toHaveBeenCalled(); + }); }); }); }); @@ -218,6 +288,7 @@ describe('registerWebWorker', () => { postMessage: ReturnType; addEventListener: ReturnType; _sentryDebugIds?: Record; + _sentryModuleMetadata?: Record; }; beforeEach(() => { @@ -236,6 +307,7 @@ describe('registerWebWorker', () => { expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ _sentryMessage: true, _sentryDebugIds: undefined, + _sentryModuleMetadata: undefined, }); }); @@ -254,6 +326,7 @@ describe('registerWebWorker', () => { 'worker-file1.js': 'debug-id-1', 'worker-file2.js': 'debug-id-2', }, + _sentryModuleMetadata: undefined, }); }); @@ -266,6 +339,72 @@ describe('registerWebWorker', () => { expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ _sentryMessage: true, _sentryDebugIds: undefined, + _sentryModuleMetadata: undefined, + }); + }); + + it('calls getFilenameToMetadataMap when module metadata is available', () => { + const mockGetFilenameToMetadataMap = SentryCore.getFilenameToMetadataMap as any; + const extractedMetadata = { + 'worker-file1.js': { '_sentryBundlerPluginAppKey:my-app': true }, + 'worker-file2.js': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mockWorkerSelf._sentryModuleMetadata = { + 'Error\n at worker-file1.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, + 'Error\n at worker-file2.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mockGetFilenameToMetadataMap.mockReturnValue(extractedMetadata); + + registerWebWorker({ self: mockWorkerSelf as any }); + + expect(mockGetFilenameToMetadataMap).toHaveBeenCalledWith(expect.any(Function)); + expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ + _sentryMessage: true, + _sentryDebugIds: undefined, + _sentryModuleMetadata: extractedMetadata, + }); + }); + + it('does not call getFilenameToMetadataMap when module metadata is not available', () => { + const mockGetFilenameToMetadataMap = SentryCore.getFilenameToMetadataMap as any; + + mockWorkerSelf._sentryModuleMetadata = undefined; + + registerWebWorker({ self: mockWorkerSelf as any }); + + expect(mockGetFilenameToMetadataMap).not.toHaveBeenCalled(); + expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ + _sentryMessage: true, + _sentryDebugIds: undefined, + _sentryModuleMetadata: undefined, + }); + }); + + it('includes both debug IDs and module metadata when both available', () => { + const mockGetFilenameToMetadataMap = SentryCore.getFilenameToMetadataMap as any; + const extractedMetadata = { + 'worker-file.js': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mockWorkerSelf._sentryDebugIds = { + 'worker-file.js': 'debug-id-1', + }; + mockWorkerSelf._sentryModuleMetadata = { + 'Error\n at worker-file.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mockGetFilenameToMetadataMap.mockReturnValue(extractedMetadata); + + registerWebWorker({ self: mockWorkerSelf as any }); + + expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ + _sentryMessage: true, + _sentryDebugIds: { + 'worker-file.js': 'debug-id-1', + }, + _sentryModuleMetadata: extractedMetadata, }); }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 30e24c3b35c7..e64ae98592b6 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, mergeMetadataMap } 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..32da0b1369af 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; @@ -36,6 +67,21 @@ function ensureMetadataStacksAreParsed(parser: StackParser): void { } } +/** + * Merges a filename-to-metadata map into the internal metadata cache. + * This is used to integrate metadata from web workers into the main thread. + * + * @param metadataMap - A map of filename to metadata object + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function mergeMetadataMap(metadataMap: Record): void { + for (const filename of Object.keys(metadataMap)) { + if (!filenameMetadataMap.has(filename)) { + filenameMetadataMap.set(filename, metadataMap[filename]); + } + } +} + /** * Retrieve metadata for a specific JavaScript file URL. * diff --git a/packages/core/test/lib/metadata.test.ts b/packages/core/test/lib/metadata.test.ts index bedf4cdcf7e9..d9a692f5bb20 100644 --- a/packages/core/test/lib/metadata.test.ts +++ b/packages/core/test/lib/metadata.test.ts @@ -1,5 +1,11 @@ -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, + mergeMetadataMap, + 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 +50,10 @@ describe('Metadata', () => { GLOBAL_OBJ._sentryModuleMetadata[stack] = { team: 'frontend' }; }); + afterEach(() => { + delete GLOBAL_OBJ._sentryModuleMetadata; + }); + it('is parsed', () => { const metadata = getMetadataForUrl(parser, __filename); @@ -97,3 +107,143 @@ 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(); + }); +}); + +describe('mergeMetadataMap', () => { + beforeEach(() => { + delete GLOBAL_OBJ._sentryModuleMetadata; + }); + + it('merges metadata from a map into internal cache', () => { + const workerMetadata = { + 'worker-file1.js': { '_sentryBundlerPluginAppKey:my-app': true }, + 'worker-file2.js': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mergeMetadataMap(workerMetadata); + + const metadata1 = getMetadataForUrl(parser, 'worker-file1.js'); + const metadata2 = getMetadataForUrl(parser, 'worker-file2.js'); + + expect(metadata1).toEqual({ '_sentryBundlerPluginAppKey:my-app': true }); + expect(metadata2).toEqual({ '_sentryBundlerPluginAppKey:my-app': true }); + }); + + it('does not overwrite existing metadata', () => { + const stack = `Error + at Object. (/existing-file.js:10:15)`; + + GLOBAL_OBJ._sentryModuleMetadata = { + [stack]: { '_sentryBundlerPluginAppKey:main-app': true, existing: true }, + }; + + const existingMetadata = getMetadataForUrl(parser, '/existing-file.js'); + expect(existingMetadata).toEqual({ '_sentryBundlerPluginAppKey:main-app': true, existing: true }); + + const workerMetadata = { + '/existing-file.js': { '_sentryBundlerPluginAppKey:worker-app': true, worker: true }, + }; + + mergeMetadataMap(workerMetadata); + + const metadataAfterMerge = getMetadataForUrl(parser, '/existing-file.js'); + expect(metadataAfterMerge).toEqual({ '_sentryBundlerPluginAppKey:main-app': true, existing: true }); + }); + + it('handles empty metadata map', () => { + mergeMetadataMap({}); + + const metadata = getMetadataForUrl(parser, 'nonexistent-file.js'); + expect(metadata).toBeUndefined(); + }); + + it('adds new files without affecting existing ones', () => { + const stack = `Error + at Object. (/main-file.js:10:15)`; + + GLOBAL_OBJ._sentryModuleMetadata = { + [stack]: { '_sentryBundlerPluginAppKey:main-app': true }, + }; + + getMetadataForUrl(parser, '/main-file.js'); + + const workerMetadata = { + '/worker-file.js': { '_sentryBundlerPluginAppKey:worker-app': true }, + }; + + mergeMetadataMap(workerMetadata); + + const mainMetadata = getMetadataForUrl(parser, '/main-file.js'); + const workerMetadataResult = getMetadataForUrl(parser, '/worker-file.js'); + + expect(mainMetadata).toEqual({ '_sentryBundlerPluginAppKey:main-app': true }); + expect(workerMetadataResult).toEqual({ '_sentryBundlerPluginAppKey:worker-app': true }); + }); +}); From 81ddea1da85c7616fea215dc08314cdf88bd9094 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 9 Jan 2026 15:04:23 +0100 Subject: [PATCH 2/2] Simplify by sending raw module data from worker to main-thread --- .../browser/src/integrations/webWorker.ts | 28 +++---- .../test/integrations/webWorker.test.ts | 80 ++++++++++--------- packages/core/src/index.ts | 2 +- packages/core/src/metadata.ts | 15 ---- packages/core/test/lib/metadata.test.ts | 73 ----------------- 5 files changed, 56 insertions(+), 142 deletions(-) diff --git a/packages/browser/src/integrations/webWorker.ts b/packages/browser/src/integrations/webWorker.ts index 5537178d02f3..5af6c3b2553a 100644 --- a/packages/browser/src/integrations/webWorker.ts +++ b/packages/browser/src/integrations/webWorker.ts @@ -1,18 +1,8 @@ import type { Integration, IntegrationFn } from '@sentry/core'; -import { - captureEvent, - debug, - defineIntegration, - getClient, - getFilenameToMetadataMap, - isPlainObject, - isPrimitive, - mergeMetadataMap, -} from '@sentry/core'; +import { captureEvent, debug, defineIntegration, getClient, isPlainObject, isPrimitive } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { eventFromUnknownInput } from '../eventbuilder'; import { WINDOW } from '../helpers'; -import { defaultStackParser } from '../stack-parsers'; import { _eventFromRejectionWithPrimitive, _getUnhandledRejectionError } from './globalhandlers'; export const INTEGRATION_NAME = 'WebWorker'; @@ -136,8 +126,13 @@ 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 metadata into the main thread's metadata cache - mergeMetadataMap(event.data._sentryModuleMetadata); + // 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 @@ -237,13 +232,12 @@ interface RegisterWebWorkerOptions { * - `self`: The worker instance you're calling this function from (self). */ export function registerWebWorker({ self }: RegisterWebWorkerOptions): void { - const moduleMetadata = self._sentryModuleMetadata ? getFilenameToMetadataMap(defaultStackParser) : undefined; - - // Send debug IDs and module metadata 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: moduleMetadata, + _sentryModuleMetadata: self._sentryModuleMetadata ?? undefined, }); // Set up unhandledrejection handler inside the worker diff --git a/packages/browser/test/integrations/webWorker.test.ts b/packages/browser/test/integrations/webWorker.test.ts index 4956ccfb7774..584f18ee9a75 100644 --- a/packages/browser/test/integrations/webWorker.test.ts +++ b/packages/browser/test/integrations/webWorker.test.ts @@ -14,8 +14,6 @@ vi.mock('@sentry/core', async importActual => { debug: { log: vi.fn(), }, - mergeMetadataMap: vi.fn(), - getFilenameToMetadataMap: vi.fn(), }; }); @@ -213,10 +211,10 @@ describe('webWorkerIntegration', () => { }); it('processes module metadata from worker', () => { - const mockMergeMetadataMap = SentryCore.mergeMetadataMap as any; + (helpers.WINDOW as any)._sentryModuleMetadata = undefined; const moduleMetadata = { - 'worker-file1.js': { '_sentryBundlerPluginAppKey:my-app': true }, - 'worker-file2.js': { '_sentryBundlerPluginAppKey:my-app': true }, + '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 = { @@ -228,13 +226,13 @@ describe('webWorkerIntegration', () => { expect(mockEvent.stopImmediatePropagation).toHaveBeenCalled(); expect(mockDebugLog).toHaveBeenCalledWith('Sentry module metadata web worker message received', mockEvent.data); - expect(mockMergeMetadataMap).toHaveBeenCalledWith(moduleMetadata); + expect((helpers.WINDOW as any)._sentryModuleMetadata).toEqual(moduleMetadata); }); it('handles message with both debug IDs and module metadata', () => { - const mockMergeMetadataMap = SentryCore.mergeMetadataMap as any; + (helpers.WINDOW as any)._sentryModuleMetadata = undefined; const moduleMetadata = { - 'worker-file.js': { '_sentryBundlerPluginAppKey:my-app': true }, + 'Error\n at worker-file.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, }; mockEvent.data = { @@ -246,16 +244,16 @@ describe('webWorkerIntegration', () => { messageHandler(mockEvent); expect(mockEvent.stopImmediatePropagation).toHaveBeenCalled(); - expect(mockMergeMetadataMap).toHaveBeenCalledWith(moduleMetadata); + 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', () => { - const mockMergeMetadataMap = SentryCore.mergeMetadataMap as any; + (helpers.WINDOW as any)._sentryModuleMetadata = undefined; const moduleMetadata = { - 'worker-file.js': { '_sentryBundlerPluginAppKey:my-app': true }, + 'Error\n at worker-file.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, }; mockEvent.data = { @@ -266,7 +264,7 @@ describe('webWorkerIntegration', () => { messageHandler(mockEvent); expect(mockEvent.stopImmediatePropagation).toHaveBeenCalled(); - expect(mockMergeMetadataMap).toHaveBeenCalledWith(moduleMetadata); + expect((helpers.WINDOW as any)._sentryModuleMetadata).toEqual(moduleMetadata); }); it('ignores invalid module metadata', () => { @@ -279,6 +277,29 @@ describe('webWorkerIntegration', () => { 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 + }); + }); }); }); }); @@ -343,38 +364,28 @@ describe('registerWebWorker', () => { }); }); - it('calls getFilenameToMetadataMap when module metadata is available', () => { - const mockGetFilenameToMetadataMap = SentryCore.getFilenameToMetadataMap as any; - const extractedMetadata = { - 'worker-file1.js': { '_sentryBundlerPluginAppKey:my-app': true }, - 'worker-file2.js': { '_sentryBundlerPluginAppKey:my-app': true }, - }; - - mockWorkerSelf._sentryModuleMetadata = { + 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 }, }; - mockGetFilenameToMetadataMap.mockReturnValue(extractedMetadata); + mockWorkerSelf._sentryModuleMetadata = rawMetadata; registerWebWorker({ self: mockWorkerSelf as any }); - expect(mockGetFilenameToMetadataMap).toHaveBeenCalledWith(expect.any(Function)); expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ _sentryMessage: true, _sentryDebugIds: undefined, - _sentryModuleMetadata: extractedMetadata, + _sentryModuleMetadata: rawMetadata, }); }); - it('does not call getFilenameToMetadataMap when module metadata is not available', () => { - const mockGetFilenameToMetadataMap = SentryCore.getFilenameToMetadataMap as any; - + it('sends undefined module metadata when not available', () => { mockWorkerSelf._sentryModuleMetadata = undefined; registerWebWorker({ self: mockWorkerSelf as any }); - expect(mockGetFilenameToMetadataMap).not.toHaveBeenCalled(); expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ _sentryMessage: true, _sentryDebugIds: undefined, @@ -383,19 +394,14 @@ describe('registerWebWorker', () => { }); it('includes both debug IDs and module metadata when both available', () => { - const mockGetFilenameToMetadataMap = SentryCore.getFilenameToMetadataMap as any; - const extractedMetadata = { - 'worker-file.js': { '_sentryBundlerPluginAppKey:my-app': true }, + const rawMetadata = { + 'Error\n at worker-file.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, }; mockWorkerSelf._sentryDebugIds = { 'worker-file.js': 'debug-id-1', }; - mockWorkerSelf._sentryModuleMetadata = { - 'Error\n at worker-file.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, - }; - - mockGetFilenameToMetadataMap.mockReturnValue(extractedMetadata); + mockWorkerSelf._sentryModuleMetadata = rawMetadata; registerWebWorker({ self: mockWorkerSelf as any }); @@ -404,7 +410,7 @@ describe('registerWebWorker', () => { _sentryDebugIds: { 'worker-file.js': 'debug-id-1', }, - _sentryModuleMetadata: extractedMetadata, + _sentryModuleMetadata: rawMetadata, }); }); }); @@ -474,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({ @@ -494,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 e64ae98592b6..24eac7807364 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -322,7 +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, mergeMetadataMap } from './metadata'; +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 32da0b1369af..54ee4a1e1eb4 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -67,21 +67,6 @@ function ensureMetadataStacksAreParsed(parser: StackParser): void { } } -/** - * Merges a filename-to-metadata map into the internal metadata cache. - * This is used to integrate metadata from web workers into the main thread. - * - * @param metadataMap - A map of filename to metadata object - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function mergeMetadataMap(metadataMap: Record): void { - for (const filename of Object.keys(metadataMap)) { - if (!filenameMetadataMap.has(filename)) { - filenameMetadataMap.set(filename, metadataMap[filename]); - } - } -} - /** * Retrieve metadata for a specific JavaScript file URL. * diff --git a/packages/core/test/lib/metadata.test.ts b/packages/core/test/lib/metadata.test.ts index d9a692f5bb20..e312698a6cf8 100644 --- a/packages/core/test/lib/metadata.test.ts +++ b/packages/core/test/lib/metadata.test.ts @@ -3,7 +3,6 @@ import { addMetadataToStackFrames, getFilenameToMetadataMap, getMetadataForUrl, - mergeMetadataMap, stripMetadataFromStackFrames, } from '../../src/metadata'; import type { Event } from '../../src/types-hoist/event'; @@ -175,75 +174,3 @@ describe('getFilenameToMetadataMap', () => { expect(result['/path/to/same-file.js']).toBeDefined(); }); }); - -describe('mergeMetadataMap', () => { - beforeEach(() => { - delete GLOBAL_OBJ._sentryModuleMetadata; - }); - - it('merges metadata from a map into internal cache', () => { - const workerMetadata = { - 'worker-file1.js': { '_sentryBundlerPluginAppKey:my-app': true }, - 'worker-file2.js': { '_sentryBundlerPluginAppKey:my-app': true }, - }; - - mergeMetadataMap(workerMetadata); - - const metadata1 = getMetadataForUrl(parser, 'worker-file1.js'); - const metadata2 = getMetadataForUrl(parser, 'worker-file2.js'); - - expect(metadata1).toEqual({ '_sentryBundlerPluginAppKey:my-app': true }); - expect(metadata2).toEqual({ '_sentryBundlerPluginAppKey:my-app': true }); - }); - - it('does not overwrite existing metadata', () => { - const stack = `Error - at Object. (/existing-file.js:10:15)`; - - GLOBAL_OBJ._sentryModuleMetadata = { - [stack]: { '_sentryBundlerPluginAppKey:main-app': true, existing: true }, - }; - - const existingMetadata = getMetadataForUrl(parser, '/existing-file.js'); - expect(existingMetadata).toEqual({ '_sentryBundlerPluginAppKey:main-app': true, existing: true }); - - const workerMetadata = { - '/existing-file.js': { '_sentryBundlerPluginAppKey:worker-app': true, worker: true }, - }; - - mergeMetadataMap(workerMetadata); - - const metadataAfterMerge = getMetadataForUrl(parser, '/existing-file.js'); - expect(metadataAfterMerge).toEqual({ '_sentryBundlerPluginAppKey:main-app': true, existing: true }); - }); - - it('handles empty metadata map', () => { - mergeMetadataMap({}); - - const metadata = getMetadataForUrl(parser, 'nonexistent-file.js'); - expect(metadata).toBeUndefined(); - }); - - it('adds new files without affecting existing ones', () => { - const stack = `Error - at Object. (/main-file.js:10:15)`; - - GLOBAL_OBJ._sentryModuleMetadata = { - [stack]: { '_sentryBundlerPluginAppKey:main-app': true }, - }; - - getMetadataForUrl(parser, '/main-file.js'); - - const workerMetadata = { - '/worker-file.js': { '_sentryBundlerPluginAppKey:worker-app': true }, - }; - - mergeMetadataMap(workerMetadata); - - const mainMetadata = getMetadataForUrl(parser, '/main-file.js'); - const workerMetadataResult = getMetadataForUrl(parser, '/worker-file.js'); - - expect(mainMetadata).toEqual({ '_sentryBundlerPluginAppKey:main-app': true }); - expect(workerMetadataResult).toEqual({ '_sentryBundlerPluginAppKey:worker-app': true }); - }); -});