Skip to content

Commit 7868fe5

Browse files
authored
fix(browser): Forward worker metadata for third-party error filtering (#18756)
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
1 parent dbe6520 commit 7868fe5

8 files changed

Lines changed: 315 additions & 7 deletions

File tree

dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ Sentry.init({
77
environment: import.meta.env.MODE || 'development',
88
tracesSampleRate: 1.0,
99
debug: true,
10-
integrations: [Sentry.browserTracingIntegration()],
10+
integrations: [
11+
Sentry.browserTracingIntegration(),
12+
Sentry.thirdPartyErrorFilterIntegration({
13+
behaviour: 'apply-tag-if-contains-third-party-frames',
14+
filterKeys: ['browser-webworker-vite'],
15+
}),
16+
],
1117
tunnel: 'http://localhost:3031/', // proxy server
1218
});
1319

dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,19 @@ test('captures an error from the third lazily added worker', async ({ page }) =>
171171
],
172172
});
173173
});
174+
175+
test('worker errors are not tagged as third-party when module metadata is present', async ({ page }) => {
176+
const errorEventPromise = waitForError('browser-webworker-vite', async event => {
177+
return !event.type && event.exception?.values?.[0]?.value === 'Uncaught Error: Uncaught error in worker';
178+
});
179+
180+
await page.goto('/');
181+
182+
await page.locator('#trigger-error').click();
183+
184+
await page.waitForTimeout(1000);
185+
186+
const errorEvent = await errorEventPromise;
187+
188+
expect(errorEvent.tags?.third_party_code).toBeUndefined();
189+
});

dev-packages/e2e-tests/test-applications/browser-webworker-vite/vite.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export default defineConfig({
1212
org: process.env.E2E_TEST_SENTRY_ORG_SLUG,
1313
project: process.env.E2E_TEST_SENTRY_PROJECT,
1414
authToken: process.env.E2E_TEST_AUTH_TOKEN,
15+
applicationKey: 'browser-webworker-vite',
1516
}),
1617
],
1718

@@ -21,6 +22,7 @@ export default defineConfig({
2122
org: process.env.E2E_TEST_SENTRY_ORG_SLUG,
2223
project: process.env.E2E_TEST_SENTRY_PROJECT,
2324
authToken: process.env.E2E_TEST_AUTH_TOKEN,
25+
applicationKey: 'browser-webworker-vite',
2426
}),
2527
],
2628
},

packages/browser/src/integrations/webWorker.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export const INTEGRATION_NAME = 'WebWorker';
1010
interface WebWorkerMessage {
1111
_sentryMessage: boolean;
1212
_sentryDebugIds?: Record<string, string>;
13+
_sentryModuleMetadata?: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
1314
_sentryWorkerError?: SerializedWorkerError;
1415
}
1516

@@ -122,6 +123,18 @@ function listenForSentryMessages(worker: Worker): void {
122123
};
123124
}
124125

126+
// Handle module metadata
127+
if (event.data._sentryModuleMetadata) {
128+
DEBUG_BUILD && debug.log('Sentry module metadata web worker message received', event.data);
129+
// Merge worker's raw metadata into the global object
130+
// It will be parsed lazily when needed by getMetadataForUrl
131+
WINDOW._sentryModuleMetadata = {
132+
...event.data._sentryModuleMetadata,
133+
// Module metadata of the main thread have precedence over the worker's in case of a collision.
134+
...WINDOW._sentryModuleMetadata,
135+
};
136+
}
137+
125138
// Handle unhandled rejections forwarded from worker
126139
if (event.data._sentryWorkerError) {
127140
DEBUG_BUILD && debug.log('Sentry worker rejection message received', event.data._sentryWorkerError);
@@ -187,14 +200,18 @@ interface MinimalDedicatedWorkerGlobalScope {
187200
}
188201

189202
interface RegisterWebWorkerOptions {
190-
self: MinimalDedicatedWorkerGlobalScope & { _sentryDebugIds?: Record<string, string> };
203+
self: MinimalDedicatedWorkerGlobalScope & {
204+
_sentryDebugIds?: Record<string, string>;
205+
_sentryModuleMetadata?: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
206+
};
191207
}
192208

193209
/**
194210
* Use this function to register the worker with the Sentry SDK.
195211
*
196212
* This function will:
197213
* - Send debug IDs to the parent thread
214+
* - Send module metadata to the parent thread (for thirdPartyErrorFilterIntegration)
198215
* - Set up a handler for unhandled rejections in the worker
199216
* - Forward unhandled rejections to the parent thread for capture
200217
*
@@ -215,10 +232,12 @@ interface RegisterWebWorkerOptions {
215232
* - `self`: The worker instance you're calling this function from (self).
216233
*/
217234
export function registerWebWorker({ self }: RegisterWebWorkerOptions): void {
218-
// Send debug IDs to parent thread
235+
// Send debug IDs and raw module metadata to parent thread
236+
// The metadata will be parsed lazily on the main thread when needed
219237
self.postMessage({
220238
_sentryMessage: true,
221239
_sentryDebugIds: self._sentryDebugIds ?? undefined,
240+
_sentryModuleMetadata: self._sentryModuleMetadata ?? undefined,
222241
});
223242

224243
// Set up unhandledrejection handler inside the worker
@@ -251,11 +270,12 @@ function isSentryMessage(eventData: unknown): eventData is WebWorkerMessage {
251270
return false;
252271
}
253272

254-
// Must have at least one of: debug IDs or worker error
273+
// Must have at least one of: debug IDs, module metadata, or worker error
255274
const hasDebugIds = '_sentryDebugIds' in eventData;
275+
const hasModuleMetadata = '_sentryModuleMetadata' in eventData;
256276
const hasWorkerError = '_sentryWorkerError' in eventData;
257277

258-
if (!hasDebugIds && !hasWorkerError) {
278+
if (!hasDebugIds && !hasModuleMetadata && !hasWorkerError) {
259279
return false;
260280
}
261281

@@ -264,6 +284,14 @@ function isSentryMessage(eventData: unknown): eventData is WebWorkerMessage {
264284
return false;
265285
}
266286

287+
// Validate module metadata if present
288+
if (
289+
hasModuleMetadata &&
290+
!(isPlainObject(eventData._sentryModuleMetadata) || eventData._sentryModuleMetadata === undefined)
291+
) {
292+
return false;
293+
}
294+
267295
// Validate worker error if present
268296
if (hasWorkerError && !isPlainObject(eventData._sentryWorkerError)) {
269297
return false;

packages/browser/test/integrations/webWorker.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,97 @@ describe('webWorkerIntegration', () => {
209209
'main.js': 'main-debug',
210210
});
211211
});
212+
213+
it('processes module metadata from worker', () => {
214+
(helpers.WINDOW as any)._sentryModuleMetadata = undefined;
215+
const moduleMetadata = {
216+
'Error\n at worker-file1.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true },
217+
'Error\n at worker-file2.js:2:2': { '_sentryBundlerPluginAppKey:my-app': true },
218+
};
219+
220+
mockEvent.data = {
221+
_sentryMessage: true,
222+
_sentryModuleMetadata: moduleMetadata,
223+
};
224+
225+
messageHandler(mockEvent);
226+
227+
expect(mockEvent.stopImmediatePropagation).toHaveBeenCalled();
228+
expect(mockDebugLog).toHaveBeenCalledWith('Sentry module metadata web worker message received', mockEvent.data);
229+
expect((helpers.WINDOW as any)._sentryModuleMetadata).toEqual(moduleMetadata);
230+
});
231+
232+
it('handles message with both debug IDs and module metadata', () => {
233+
(helpers.WINDOW as any)._sentryModuleMetadata = undefined;
234+
const moduleMetadata = {
235+
'Error\n at worker-file.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true },
236+
};
237+
238+
mockEvent.data = {
239+
_sentryMessage: true,
240+
_sentryDebugIds: { 'worker-file.js': 'debug-id-1' },
241+
_sentryModuleMetadata: moduleMetadata,
242+
};
243+
244+
messageHandler(mockEvent);
245+
246+
expect(mockEvent.stopImmediatePropagation).toHaveBeenCalled();
247+
expect((helpers.WINDOW as any)._sentryModuleMetadata).toEqual(moduleMetadata);
248+
expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({
249+
'worker-file.js': 'debug-id-1',
250+
});
251+
});
252+
253+
it('accepts message with only module metadata', () => {
254+
(helpers.WINDOW as any)._sentryModuleMetadata = undefined;
255+
const moduleMetadata = {
256+
'Error\n at worker-file.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true },
257+
};
258+
259+
mockEvent.data = {
260+
_sentryMessage: true,
261+
_sentryModuleMetadata: moduleMetadata,
262+
};
263+
264+
messageHandler(mockEvent);
265+
266+
expect(mockEvent.stopImmediatePropagation).toHaveBeenCalled();
267+
expect((helpers.WINDOW as any)._sentryModuleMetadata).toEqual(moduleMetadata);
268+
});
269+
270+
it('ignores invalid module metadata', () => {
271+
mockEvent.data = {
272+
_sentryMessage: true,
273+
_sentryModuleMetadata: 'not-an-object',
274+
};
275+
276+
messageHandler(mockEvent);
277+
278+
expect(mockEvent.stopImmediatePropagation).not.toHaveBeenCalled();
279+
});
280+
281+
it('gives main thread precedence over worker for conflicting module metadata', () => {
282+
(helpers.WINDOW as any)._sentryModuleMetadata = {
283+
'Error\n at shared-file.js:1:1': { '_sentryBundlerPluginAppKey:main-app': true, source: 'main' },
284+
'Error\n at main-only.js:1:1': { '_sentryBundlerPluginAppKey:main-app': true },
285+
};
286+
287+
mockEvent.data = {
288+
_sentryMessage: true,
289+
_sentryModuleMetadata: {
290+
'Error\n at shared-file.js:1:1': { '_sentryBundlerPluginAppKey:worker-app': true, source: 'worker' },
291+
'Error\n at worker-only.js:1:1': { '_sentryBundlerPluginAppKey:worker-app': true },
292+
},
293+
};
294+
295+
messageHandler(mockEvent);
296+
297+
expect((helpers.WINDOW as any)._sentryModuleMetadata).toEqual({
298+
'Error\n at shared-file.js:1:1': { '_sentryBundlerPluginAppKey:main-app': true, source: 'main' }, // Main thread wins
299+
'Error\n at main-only.js:1:1': { '_sentryBundlerPluginAppKey:main-app': true }, // Main thread preserved
300+
'Error\n at worker-only.js:1:1': { '_sentryBundlerPluginAppKey:worker-app': true }, // Worker added
301+
});
302+
});
212303
});
213304
});
214305
});
@@ -218,6 +309,7 @@ describe('registerWebWorker', () => {
218309
postMessage: ReturnType<typeof vi.fn>;
219310
addEventListener: ReturnType<typeof vi.fn>;
220311
_sentryDebugIds?: Record<string, string>;
312+
_sentryModuleMetadata?: Record<string, any>;
221313
};
222314

223315
beforeEach(() => {
@@ -236,6 +328,7 @@ describe('registerWebWorker', () => {
236328
expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({
237329
_sentryMessage: true,
238330
_sentryDebugIds: undefined,
331+
_sentryModuleMetadata: undefined,
239332
});
240333
});
241334

@@ -254,6 +347,7 @@ describe('registerWebWorker', () => {
254347
'worker-file1.js': 'debug-id-1',
255348
'worker-file2.js': 'debug-id-2',
256349
},
350+
_sentryModuleMetadata: undefined,
257351
});
258352
});
259353

@@ -266,6 +360,57 @@ describe('registerWebWorker', () => {
266360
expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({
267361
_sentryMessage: true,
268362
_sentryDebugIds: undefined,
363+
_sentryModuleMetadata: undefined,
364+
});
365+
});
366+
367+
it('includes raw module metadata when available', () => {
368+
const rawMetadata = {
369+
'Error\n at worker-file1.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true },
370+
'Error\n at worker-file2.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true },
371+
};
372+
373+
mockWorkerSelf._sentryModuleMetadata = rawMetadata;
374+
375+
registerWebWorker({ self: mockWorkerSelf as any });
376+
377+
expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({
378+
_sentryMessage: true,
379+
_sentryDebugIds: undefined,
380+
_sentryModuleMetadata: rawMetadata,
381+
});
382+
});
383+
384+
it('sends undefined module metadata when not available', () => {
385+
mockWorkerSelf._sentryModuleMetadata = undefined;
386+
387+
registerWebWorker({ self: mockWorkerSelf as any });
388+
389+
expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({
390+
_sentryMessage: true,
391+
_sentryDebugIds: undefined,
392+
_sentryModuleMetadata: undefined,
393+
});
394+
});
395+
396+
it('includes both debug IDs and module metadata when both available', () => {
397+
const rawMetadata = {
398+
'Error\n at worker-file.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true },
399+
};
400+
401+
mockWorkerSelf._sentryDebugIds = {
402+
'worker-file.js': 'debug-id-1',
403+
};
404+
mockWorkerSelf._sentryModuleMetadata = rawMetadata;
405+
406+
registerWebWorker({ self: mockWorkerSelf as any });
407+
408+
expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({
409+
_sentryMessage: true,
410+
_sentryDebugIds: {
411+
'worker-file.js': 'debug-id-1',
412+
},
413+
_sentryModuleMetadata: rawMetadata,
269414
});
270415
});
271416
});
@@ -335,6 +480,7 @@ describe('registerWebWorker and webWorkerIntegration', () => {
335480
expect(mockWorker.postMessage).toHaveBeenCalledWith({
336481
_sentryMessage: true,
337482
_sentryDebugIds: mockWorker._sentryDebugIds,
483+
_sentryModuleMetadata: undefined,
338484
});
339485

340486
expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({
@@ -355,6 +501,7 @@ describe('registerWebWorker and webWorkerIntegration', () => {
355501
expect(mockWorker3.postMessage).toHaveBeenCalledWith({
356502
_sentryMessage: true,
357503
_sentryDebugIds: mockWorker3._sentryDebugIds,
504+
_sentryModuleMetadata: undefined,
358505
});
359506

360507
expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,7 @@ export { vercelWaitUntil } from './utils/vercelWaitUntil';
322322
export { flushIfServerless } from './utils/flushIfServerless';
323323
export { SDK_VERSION } from './utils/version';
324324
export { getDebugImagesForResources, getFilenameToDebugIdMap } from './utils/debug-ids';
325+
export { getFilenameToMetadataMap } from './metadata';
325326
export { escapeStringForRegex } from './vendor/escapeStringForRegex';
326327

327328
export type { Attachment } from './types-hoist/attachment';

packages/core/src/metadata.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,37 @@ const filenameMetadataMap = new Map<string, any>();
88
/** Set of stack strings that have already been parsed. */
99
const parsedStacks = new Set<string>();
1010

11+
/**
12+
* Builds a map of filenames to module metadata from the global _sentryModuleMetadata object.
13+
* This is useful for forwarding metadata from web workers to the main thread.
14+
*
15+
* @param parser - Stack parser to use for extracting filenames from stack traces
16+
* @returns A map of filename to metadata object
17+
*/
18+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
19+
export function getFilenameToMetadataMap(parser: StackParser): Record<string, any> {
20+
if (!GLOBAL_OBJ._sentryModuleMetadata) {
21+
return {};
22+
}
23+
24+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
25+
const filenameMap: Record<string, any> = {};
26+
27+
for (const stack of Object.keys(GLOBAL_OBJ._sentryModuleMetadata)) {
28+
const metadata = GLOBAL_OBJ._sentryModuleMetadata[stack];
29+
const frames = parser(stack);
30+
31+
for (const frame of frames.reverse()) {
32+
if (frame.filename) {
33+
filenameMap[frame.filename] = metadata;
34+
break;
35+
}
36+
}
37+
}
38+
39+
return filenameMap;
40+
}
41+
1142
function ensureMetadataStacksAreParsed(parser: StackParser): void {
1243
if (!GLOBAL_OBJ._sentryModuleMetadata) {
1344
return;

0 commit comments

Comments
 (0)