Skip to content
Open
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,9 +7,14 @@ function waitForPageloadSpan() {
});
}

// The throw still bubbles to the page after the worker forwards it, so globalHandlers emits a
// second, frameless event for the same error. Every test below selects the forwarded one by its
// mechanism, since that is the event carrying the real stack.
const WORKER_MECHANISM = 'auto.browser.web_worker.onerror';

test('captures an error with debug ids and pageload trace context', async ({ page }) => {
const errorEventPromise = waitForError('browser-webworker-vite', async event => {
return !event.type && !!event.exception?.values?.[0];
return !event.type && event.exception?.values?.[0]?.mechanism?.type === WORKER_MECHANISM;
});

const pageloadSpanPromise = waitForPageloadSpan();
Expand All @@ -24,9 +29,15 @@ test('captures an error with debug ids and pageload trace context', async ({ pag
const pageloadSpan = await pageloadSpanPromise;

expect(errorEvent.exception?.values).toHaveLength(1);
expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught Error: Uncaught error in worker');
expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toHaveLength(1);
expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toMatch(/worker-.+\.js$/);
expect(errorEvent.exception?.values?.[0]?.type).toBe('Error');
expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught error in worker');
expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toEqual(
expect.arrayContaining([expect.objectContaining({ filename: expect.stringMatching(/worker-.+\.js$/) })]),
);

expect(errorEvent.contexts?.worker).toEqual({
filename: expect.stringMatching(/worker-.+\.js$/),
});

expect(errorEvent.transaction).toBe('/');
expect(pageloadSpan.name).toBe('Pageload');
Expand Down Expand Up @@ -75,7 +86,7 @@ test("user worker message handlers don't trigger for sentry messages", async ({

test('captures an error from the second eagerly added worker', async ({ page }) => {
const errorEventPromise = waitForError('browser-webworker-vite', async event => {
return !event.type && !!event.exception?.values?.[0];
return !event.type && event.exception?.values?.[0]?.mechanism?.type === WORKER_MECHANISM;
});

const pageloadSpanPromise = waitForPageloadSpan();
Expand All @@ -90,9 +101,10 @@ test('captures an error from the second eagerly added worker', async ({ page })
const pageloadSpan = await pageloadSpanPromise;

expect(errorEvent.exception?.values).toHaveLength(1);
expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught Error: Uncaught error in worker 2');
expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toHaveLength(1);
expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toMatch(/worker2-.+\.js$/);
expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught error in worker 2');
expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toEqual(
expect.arrayContaining([expect.objectContaining({ filename: expect.stringMatching(/worker2-.+\.js$/) })]),
);

expect(errorEvent.transaction).toBe('/');
expect(pageloadSpan.name).toBe('Pageload');
Expand Down Expand Up @@ -120,7 +132,7 @@ test('captures an error from the second eagerly added worker', async ({ page })

test('captures an error from the third lazily added worker', async ({ page }) => {
const errorEventPromise = waitForError('browser-webworker-vite', async event => {
return !event.type && !!event.exception?.values?.[0];
return !event.type && event.exception?.values?.[0]?.mechanism?.type === WORKER_MECHANISM;
});

const pageloadSpanPromise = waitForPageloadSpan();
Expand All @@ -135,9 +147,10 @@ test('captures an error from the third lazily added worker', async ({ page }) =>
const pageloadSpan = await pageloadSpanPromise;

expect(errorEvent.exception?.values).toHaveLength(1);
expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught Error: Uncaught error in worker 3');
expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toHaveLength(1);
expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toMatch(/worker3-.+\.js$/);
expect(errorEvent.exception?.values?.[0]?.value).toBe('Uncaught error in worker 3');
expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toEqual(
expect.arrayContaining([expect.objectContaining({ filename: expect.stringMatching(/worker3-.+\.js$/) })]),
);

expect(errorEvent.transaction).toBe('/');
expect(pageloadSpan.name).toBe('Pageload');
Expand Down Expand Up @@ -165,7 +178,11 @@ 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';
return (
!event.type &&
event.exception?.values?.[0]?.mechanism?.type === WORKER_MECHANISM &&
event.exception?.values?.[0]?.value === 'Uncaught error in worker'
);
});

await page.goto('/');
Expand Down
99 changes: 77 additions & 22 deletions packages/browser/src/integrations/webWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ interface WebWorkerMessage {
interface SerializedWorkerError {
reason: unknown;
filename?: string;
/** Absent on workers registered by an SDK version that only forwarded rejections. */
kind?: 'error' | 'unhandledrejection';
}

interface WebWorkerIntegrationOptions {
Expand Down Expand Up @@ -153,31 +155,33 @@ function listenForSentryMessages(worker: Worker): void {
];
}

// Handle unhandled rejections forwarded from worker
// Handle errors and 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);
DEBUG_BUILD && debug.log('Sentry worker error message received', event.data._sentryWorkerError);
handleForwardedWorkerError(event.data._sentryWorkerError);
}
}
});
}

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

const stackParser = client.getOptions().stackParser;
const attachStacktrace = client.getOptions().attachStacktrace;
const { stackParser, attachStacktrace } = client.getOptions();

const error = workerError.reason;
// Older workers only ever forwarded rejections and send no `kind`.
const isUnhandledRejection = workerError.kind !== 'error';

// 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);
// Follow same pattern as globalHandlers for each source.
// A thrown primitive is not a rejection, so the rejection-specific wording must not apply to it.
const event =
isUnhandledRejection && isPrimitive(error)
? _eventFromRejectionWithPrimitive(error)
: eventFromUnknownInput(stackParser, error, undefined, attachStacktrace, isUnhandledRejection);

event.level = 'error';

Expand All @@ -195,11 +199,11 @@ function handleForwardedWorkerRejection(workerError: SerializedWorkerError): voi
originalException: error,
mechanism: {
handled: false,
type: 'auto.browser.web_worker.onunhandledrejection',
type: isUnhandledRejection ? 'auto.browser.web_worker.onunhandledrejection' : 'auto.browser.web_worker.onerror',
},
});

DEBUG_BUILD && debug.log('Captured worker unhandled rejection', error);
DEBUG_BUILD && debug.log(`Captured worker ${isUnhandledRejection ? 'unhandled rejection' : 'error'}`, error);
}

/**
Expand Down Expand Up @@ -230,11 +234,12 @@ 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
* - Set up handlers for uncaught errors and unhandled rejections in the worker
* - Forward both 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.
* Note: uncaught errors do bubble to the parent, but the propagated `ErrorEvent` carries
* no `error` object, so globalHandlers can only build an event from the message string.
* Forwarding them here preserves the real stack, which matters most for wasm frames.
*
* @example
* ```ts filename={worker.js}
Expand All @@ -250,6 +255,10 @@ interface RegisterWebWorkerOptions {
* - `self`: The worker instance you're calling this function from (self).
*/
export function registerWebWorker({ self }: RegisterWebWorkerOptions): void {
// Mirrors globalHandlersIntegration. The worker has no client of its own, so without this
// V8's default of 10 truncates stacks before they can be forwarded.
Error.stackTraceLimit = 50;

// Send debug IDs and raw module metadata to parent thread
// The metadata will be parsed lazily on the main thread when needed
self.postMessage({
Expand All @@ -258,6 +267,23 @@ export function registerWebWorker({ self }: RegisterWebWorkerOptions): void {
_sentryModuleMetadata: self._sentryModuleMetadata ?? undefined,
});

// Set up error handler inside the worker
// Uncaught errors bubble to the parent, but structured clone preserves `stack` while the
// propagated ErrorEvent does not, so forwarding is what gives the parent real frames
self.addEventListener('error', (event: unknown) => {
const { error, message } = event as { error?: unknown; message?: string };

const serializedError: SerializedWorkerError = {
reason: error ?? message,
filename: self.location?.href,
kind: 'error',
};

postSerializedWorkerError(self, serializedError);

DEBUG_BUILD && debug.log('[Sentry Worker] Forwarding error to parent', serializedError);
});
Comment thread
cursor[bot] marked this conversation as resolved.

// 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
Expand All @@ -269,18 +295,47 @@ export function registerWebWorker({ self }: RegisterWebWorkerOptions): void {
const serializedError: SerializedWorkerError = {
reason: reason,
filename: self.location?.href,
kind: 'unhandledrejection',
};

// Forward to parent thread
postSerializedWorkerError(self, serializedError);

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

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

/**
* `postMessage` structured-clones the reason. Errors clone well (`message`, `stack` and `cause`
* all survive), but exotic values raise `DataCloneError`, which must never escape the worker's
* own error handler.
*/
function postSerializedWorkerError(
self: MinimalDedicatedWorkerGlobalScope,
serializedError: SerializedWorkerError,
): void {
try {
self.postMessage({
_sentryMessage: true,
_sentryWorkerError: serializedError,
});
return;
} catch {
// Not cloneable, fall through and describe it instead.
}

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

DEBUG_BUILD && debug.log('[Sentry Worker] Registered worker with unhandled rejection handling');
try {
self.postMessage({
_sentryMessage: true,
_sentryWorkerError: {
...serializedError,
reason: `Worker error with non-cloneable reason: ${Object.prototype.toString.call(serializedError.reason)}`,
},
});
} catch {
// Dropping the forward is better than throwing out of the worker's error handler.
}
}

function isSentryMessage(eventData: unknown): eventData is WebWorkerMessage {
Expand Down
Loading
Loading