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
@@ -1,14 +1,34 @@
// This worker manually replicates what Sentry.registerWebWorker() does
// (In real code with a bundler, you'd import and call Sentry.registerWebWorker({ self }))

self._sentryDebugIds = {
'Error at http://sentry-test.io/worker.js': 'worker-debug-id-789',
};

// Send debug IDs
self.postMessage({
_sentryMessage: true,
_sentryDebugIds: self._sentryDebugIds,
});

// Set up unhandledrejection handler (same as registerWebWorker)
self.addEventListener('unhandledrejection', event => {
self.postMessage({
_sentryMessage: true,
_sentryWorkerError: {
reason: event.reason,
filename: self.location.href,
},
});
});

self.addEventListener('message', event => {
if (event.data.type === 'throw-error') {
throw new Error('Worker error for testing');
}

if (event.data.type === 'throw-rejection') {
// Create an unhandled rejection
Promise.reject(new Error('Worker unhandled rejection'));
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,17 @@ const worker = new Worker('/worker.js');

Sentry.addIntegration(Sentry.webWorkerIntegration({ worker }));

const btn = document.getElementById('errWorker');
const btnError = document.getElementById('errWorker');
const btnRejection = document.getElementById('rejectionWorker');

btn.addEventListener('click', () => {
btnError.addEventListener('click', () => {
worker.postMessage({
type: 'throw-error',
});
});

btnRejection.addEventListener('click', () => {
worker.postMessage({
type: 'throw-rejection',
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
</head>
<body>
<button id="errWorker">Throw error in worker</button>
<button id="rejectionWorker">Throw unhandled rejection in worker</button>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,32 @@ sentryTest('Assigns web worker debug IDs when using webWorkerIntegration', async
expect(image.code_file).toEqual('http://sentry-test.io/worker.js');
});
});

sentryTest('Captures unhandled rejections from web workers', async ({ getLocalTestUrl, page }) => {
const bundle = process.env.PW_BUNDLE;
if (bundle != null && !bundle.includes('esm') && !bundle.includes('cjs')) {
sentryTest.skip();
}

const url = await getLocalTestUrl({ testDir: __dirname });

const errorEventPromise = getFirstSentryEnvelopeRequest<Event>(page, url);

page.route('**/worker.js', route => {
route.fulfill({
path: `${__dirname}/assets/worker.js`,
});
});

const button = page.locator('#rejectionWorker');
await button.click();

const errorEvent = await errorEventPromise;

// Verify the unhandled rejection was captured
expect(errorEvent.exception?.values?.[0]?.value).toContain('Worker unhandled rejection');
expect(errorEvent.exception?.values?.[0]?.mechanism?.type).toBe('auto.browser.web_worker.onunhandledrejection');
expect(errorEvent.exception?.values?.[0]?.mechanism?.handled).toBe(false);
expect(errorEvent.contexts?.worker).toBeDefined();
expect(errorEvent.contexts?.worker?.filename).toContain('worker.js');
});
7 changes: 5 additions & 2 deletions packages/browser/src/integrations/globalhandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,10 @@ function _installGlobalOnUnhandledRejectionHandler(client: Client): void {
});
}

function _getUnhandledRejectionError(error: unknown): unknown {
/**
*
*/
export function _getUnhandledRejectionError(error: unknown): unknown {
if (isPrimitive(error)) {
return error;
}
Expand Down Expand Up @@ -138,7 +141,7 @@ function _getUnhandledRejectionError(error: unknown): unknown {
* @param reason: The `reason` property of the promise rejection
* @returns An Event object with an appropriate `exception` value
*/
function _eventFromRejectionWithPrimitive(reason: Primitive): Event {
export function _eventFromRejectionWithPrimitive(reason: Primitive): Event {
return {
exception: {
values: [
Expand Down
146 changes: 128 additions & 18 deletions packages/browser/src/integrations/webWorker.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import type { Integration, IntegrationFn } from '@sentry/core';
import { debug, defineIntegration, isPlainObject } 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 { _eventFromRejectionWithPrimitive, _getUnhandledRejectionError } from './globalhandlers';

export const INTEGRATION_NAME = 'WebWorker';

interface WebWorkerMessage {
_sentryMessage: boolean;
_sentryDebugIds?: Record<string, string>;
_sentryWorkerError?: SerializedWorkerError;
}

interface SerializedWorkerError {
reason: unknown;
filename?: string;
}

interface WebWorkerIntegrationOptions {
Expand Down Expand Up @@ -94,25 +102,75 @@ interface WebWorkerIntegration extends Integration {
export const webWorkerIntegration = defineIntegration(({ worker }: WebWorkerIntegrationOptions) => ({
name: INTEGRATION_NAME,
setupOnce: () => {
(Array.isArray(worker) ? worker : [worker]).forEach(w => listenForSentryDebugIdMessages(w));
(Array.isArray(worker) ? worker : [worker]).forEach(w => listenForSentryMessages(w));
},
addWorker: (worker: Worker) => listenForSentryDebugIdMessages(worker),
addWorker: (worker: Worker) => listenForSentryMessages(worker),
})) as IntegrationFn<WebWorkerIntegration>;

function listenForSentryDebugIdMessages(worker: Worker): void {
function listenForSentryMessages(worker: Worker): void {
worker.addEventListener('message', event => {
if (isSentryDebugIdMessage(event.data)) {
if (isSentryMessage(event.data)) {
event.stopImmediatePropagation(); // other listeners should not receive this message
DEBUG_BUILD && debug.log('Sentry debugId web worker message received', event.data);
WINDOW._sentryDebugIds = {
...event.data._sentryDebugIds,
// debugIds of the main thread have precedence over the worker's in case of a collision.
...WINDOW._sentryDebugIds,
};

// Handle debug IDs
if (event.data._sentryDebugIds) {
DEBUG_BUILD && debug.log('Sentry debugId web worker message received', event.data);
WINDOW._sentryDebugIds = {
...event.data._sentryDebugIds,
// debugIds of the main thread have precedence over the worker's in case of a collision.
...WINDOW._sentryDebugIds,
};
}

// Handle unhandled rejections forwarded from worker
if (event.data._sentryWorkerError) {
DEBUG_BUILD && debug.log('Sentry worker rejection message received', event.data._sentryWorkerError);
handleForwardedWorkerRejection(event.data._sentryWorkerError);
}
}
});
}

function handleForwardedWorkerRejection(workerError: SerializedWorkerError): void {
const client = getClient();
if (!client) {
return;
}

const stackParser = client.getOptions().stackParser;
const attachStacktrace = client.getOptions().attachStacktrace;

const error = workerError.reason;

// Follow same pattern as globalHandlers for unhandledrejection
// Handle both primitives and errors the same way
const event = isPrimitive(error)
? _eventFromRejectionWithPrimitive(error)
: eventFromUnknownInput(stackParser, error, undefined, attachStacktrace, true);

event.level = 'error';
Comment on lines +140 to +151
Copy link
Member

Choose a reason for hiding this comment

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

m: Is there a reason why we need to manually construct the event? can we call captureException(workerError.reason) instead? I might be missing something, so just asking.

Copy link
Member Author

Choose a reason for hiding this comment

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

This is how we handle errors in the global handler, i just want to preserve the same behavior as other promise rejections events.

Copy link
Member

Choose a reason for hiding this comment

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

Good point! I missed this and you're right, we should handle this in the same way.


// Add worker-specific context
if (workerError.filename) {
event.contexts = {
...event.contexts,
worker: {
filename: workerError.filename,
},
};
}

captureEvent(event, {
originalException: error,
mechanism: {
handled: false,
type: 'auto.browser.web_worker.onunhandledrejection',
},
});

DEBUG_BUILD && debug.log('Captured worker unhandled rejection', error);
}

/**
* Minimal interface for DedicatedWorkerGlobalScope, only requiring the postMessage method.
* (which is the only thing we need from the worker's global object)
Expand All @@ -124,6 +182,8 @@ function listenForSentryDebugIdMessages(worker: Worker): void {
*/
interface MinimalDedicatedWorkerGlobalScope {
postMessage: (message: unknown) => void;
addEventListener: (type: string, listener: (event: unknown) => void) => void;
location?: { href?: string };
}

interface RegisterWebWorkerOptions {
Expand All @@ -133,6 +193,14 @@ interface RegisterWebWorkerOptions {
/**
* Use this function to register the worker with the Sentry SDK.
*
* This function will:
* - Send debug IDs to the parent thread
* - Set up a handler for unhandled rejections in the worker
* - Forward unhandled rejections to the parent thread for capture
*
* Note: Synchronous errors in workers are already captured by globalHandlers.
* This only handles unhandled promise rejections which don't bubble to the parent.
*
* @example
* ```ts filename={worker.js}
* import * as Sentry from '@sentry/<your-sdk>';
Expand All @@ -147,17 +215,59 @@ 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
self.postMessage({
_sentryMessage: true,
_sentryDebugIds: self._sentryDebugIds ?? undefined,
});

// Set up unhandledrejection handler inside the worker
// Following the same pattern as globalHandlers
// unhandled rejections don't bubble to the parent thread, so we need to handle them here
self.addEventListener('unhandledrejection', (event: unknown) => {
const reason = _getUnhandledRejectionError(event);

// Forward the raw reason to parent thread
// The parent will handle primitives vs errors the same way globalHandlers does
const serializedError: SerializedWorkerError = {
reason: reason,
filename: self.location?.href,
Copy link
Member

Choose a reason for hiding this comment

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

good idea!

};

// Forward to parent thread
self.postMessage({
_sentryMessage: true,
_sentryWorkerError: serializedError,
});
Comment on lines +231 to +241
Copy link

Choose a reason for hiding this comment

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

Bug: Structured cloning of Error objects from web workers via postMessage causes loss of prototype, preventing proper stack trace parsing.
Severity: HIGH | Confidence: 0.95

🔍 Detailed Analysis

When an Error object is sent from a web worker to the main thread via postMessage, JavaScript's structured cloning converts it into a plain object, losing its Error prototype. This occurs for unhandled promise rejections in web workers. The eventFromUnknownInput() function treats the cloned object as a plain object, as isError() fails. Consequently, its stack property is not parsed into structured StackFrame objects but is serialized into the __serialized__ extra field, preventing proper stack trace display.

💡 Suggested Fix

Either serialize the stack property separately and pass it as a syntheticException, or serialize the stack frames directly from the worker before sending the message.

🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: packages/browser/src/integrations/webWorker.ts#L227-L241

Potential issue: When an `Error` object is sent from a web worker to the main thread via
`postMessage`, JavaScript's structured cloning converts it into a plain object, losing
its `Error` prototype. This occurs for unhandled promise rejections in web workers. The
`eventFromUnknownInput()` function treats the cloned object as a plain object, as
`isError()` fails. Consequently, its `stack` property is not parsed into structured
`StackFrame` objects but is serialized into the `__serialized__` extra field, preventing
proper stack trace display.

Did we get this right? 👍 / 👎 to inform future reviews.


DEBUG_BUILD && debug.log('[Sentry Worker] Forwarding unhandled rejection to parent', serializedError);
});

DEBUG_BUILD && debug.log('[Sentry Worker] Registered worker with unhandled rejection handling');
}

function isSentryDebugIdMessage(eventData: unknown): eventData is WebWorkerMessage {
return (
isPlainObject(eventData) &&
eventData._sentryMessage === true &&
'_sentryDebugIds' in eventData &&
(isPlainObject(eventData._sentryDebugIds) || eventData._sentryDebugIds === undefined)
);
function isSentryMessage(eventData: unknown): eventData is WebWorkerMessage {
if (!isPlainObject(eventData) || eventData._sentryMessage !== true) {
return false;
}

// Must have at least one of: debug IDs or worker error
const hasDebugIds = '_sentryDebugIds' in eventData;
const hasWorkerError = '_sentryWorkerError' in eventData;

if (!hasDebugIds && !hasWorkerError) {
return false;
}

// Validate debug IDs if present
if (hasDebugIds && !(isPlainObject(eventData._sentryDebugIds) || eventData._sentryDebugIds === undefined)) {
return false;
}

// Validate worker error if present
if (hasWorkerError && !isPlainObject(eventData._sentryWorkerError)) {
return false;
}

return true;
}
2 changes: 2 additions & 0 deletions packages/browser/test/integrations/webWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ describe('webWorkerIntegration', () => {
describe('registerWebWorker', () => {
let mockWorkerSelf: {
postMessage: ReturnType<typeof vi.fn>;
addEventListener: ReturnType<typeof vi.fn>;
_sentryDebugIds?: Record<string, string>;
};

Expand All @@ -224,6 +225,7 @@ describe('registerWebWorker', () => {

mockWorkerSelf = {
postMessage: vi.fn(),
addEventListener: vi.fn(),
};
});

Expand Down