Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}),
],

Expand All @@ -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',
}),
],
},
Expand Down
36 changes: 32 additions & 4 deletions packages/browser/src/integrations/webWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const INTEGRATION_NAME = 'WebWorker';
interface WebWorkerMessage {
_sentryMessage: boolean;
_sentryDebugIds?: Record<string, string>;
_sentryModuleMetadata?: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: Why not unknown?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be aligned with the way we define it in the main-thread, maybe we can change that at a later point?

_sentryWorkerError?: SerializedWorkerError;
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -187,14 +200,18 @@ interface MinimalDedicatedWorkerGlobalScope {
}

interface RegisterWebWorkerOptions {
self: MinimalDedicatedWorkerGlobalScope & { _sentryDebugIds?: Record<string, string> };
self: MinimalDedicatedWorkerGlobalScope & {
_sentryDebugIds?: Record<string, string>;
_sentryModuleMetadata?: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
};
}

/**
* Use this function to register the worker with the Sentry SDK.
*
* 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
*
Expand All @@ -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
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
Expand Down
147 changes: 147 additions & 0 deletions packages/browser/test/integrations/webWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
});
});
});
});
Expand All @@ -218,6 +309,7 @@ describe('registerWebWorker', () => {
postMessage: ReturnType<typeof vi.fn>;
addEventListener: ReturnType<typeof vi.fn>;
_sentryDebugIds?: Record<string, string>;
_sentryModuleMetadata?: Record<string, any>;
};

beforeEach(() => {
Expand All @@ -236,6 +328,7 @@ describe('registerWebWorker', () => {
expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({
_sentryMessage: true,
_sentryDebugIds: undefined,
_sentryModuleMetadata: undefined,
});
});

Expand All @@ -254,6 +347,7 @@ describe('registerWebWorker', () => {
'worker-file1.js': 'debug-id-1',
'worker-file2.js': 'debug-id-2',
},
_sentryModuleMetadata: undefined,
});
});

Expand All @@ -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,
});
});
});
Expand Down Expand Up @@ -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({
Expand All @@ -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({
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,37 @@ const filenameMetadataMap = new Map<string, any>();
/** Set of stack strings that have already been parsed. */
const parsedStacks = new Set<string>();

/**
* 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<string, any> {
if (!GLOBAL_OBJ._sentryModuleMetadata) {
return {};
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const filenameMap: Record<string, any> = {};

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;
Expand Down
Loading
Loading