Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
44 changes: 39 additions & 5 deletions packages/browser/src/integrations/webWorker.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
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';

interface WebWorkerMessage {
_sentryMessage: boolean;
_sentryDebugIds?: Record<string, string>;
_sentryModuleMetadata?: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
Copy link
Copy Markdown
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
Copy Markdown
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 +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);
Expand Down Expand Up @@ -187,14 +205,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 +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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

naive q: why do we need to parse the self._sentryModuleMetadata here? can't we do this in the main thread in mergeMetadata, potentially by re-calling ensureMetadataStacksAreParsed?

I'm probably missing something, so just asking 😅

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No, you're right. We don't need to parse the module metadata on the worker, can just pass it along and handle everything on the main-thread. That should reduce code and some api I exposed.

Thanks!


// 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
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}

Comment on lines 273 to +294
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

l: I'm wondering if we should turn around this validation logic to default to false and only emit true in case we find fitting fields on the event data? This would make it easier to add new fields because we don't have to always extend the first if (!hasDebugIds && !hasModuleMetadata && !hasWorkerError) check. wdyt?

(logaf-l so feel free to skip)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think that ends up with even more complicated code because we still need to ensure that present fields are validated, we can't exit early basically. The code is more readable this way, unless I'm missing something 😅.

// Validate worker error if present
if (hasWorkerError && !isPlainObject(eventData._sentryWorkerError)) {
return false;
Expand Down
139 changes: 139 additions & 0 deletions packages/browser/test/integrations/webWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ vi.mock('@sentry/core', async importActual => {
debug: {
log: vi.fn(),
},
mergeMetadataMap: vi.fn(),
getFilenameToMetadataMap: vi.fn(),
};
});

Expand Down Expand Up @@ -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();
});
});
});
});
Expand All @@ -218,6 +288,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 +307,7 @@ describe('registerWebWorker', () => {
expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({
_sentryMessage: true,
_sentryDebugIds: undefined,
_sentryModuleMetadata: undefined,
});
});

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

Expand All @@ -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,
});
});
});
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, mergeMetadataMap } from './metadata';
export { escapeStringForRegex } from './vendor/escapeStringForRegex';

export type { Attachment } from './types-hoist/attachment';
Expand Down
Loading
Loading