Skip to content

Commit 600e27a

Browse files
authored
fix(browser): Capture unhandled rejection errors for web worker integration (#18054)
Previously, only synchronous errors thrown in web workers would bubble up and be captured. Unhandled promise rejections within workers would fail silently since they don't propagate to the parent thread automatically. This enhancement ensures complete error coverage for web worker code.
1 parent b57ade1 commit 600e27a

File tree

7 files changed

+194
-22
lines changed

7 files changed

+194
-22
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,34 @@
1+
// This worker manually replicates what Sentry.registerWebWorker() does
2+
// (In real code with a bundler, you'd import and call Sentry.registerWebWorker({ self }))
3+
14
self._sentryDebugIds = {
25
'Error at http://sentry-test.io/worker.js': 'worker-debug-id-789',
36
};
47

8+
// Send debug IDs
59
self.postMessage({
610
_sentryMessage: true,
711
_sentryDebugIds: self._sentryDebugIds,
812
});
913

14+
// Set up unhandledrejection handler (same as registerWebWorker)
15+
self.addEventListener('unhandledrejection', event => {
16+
self.postMessage({
17+
_sentryMessage: true,
18+
_sentryWorkerError: {
19+
reason: event.reason,
20+
filename: self.location.href,
21+
},
22+
});
23+
});
24+
1025
self.addEventListener('message', event => {
1126
if (event.data.type === 'throw-error') {
1227
throw new Error('Worker error for testing');
1328
}
29+
30+
if (event.data.type === 'throw-rejection') {
31+
// Create an unhandled rejection
32+
Promise.reject(new Error('Worker unhandled rejection'));
33+
}
1434
});

dev-packages/browser-integration-tests/suites/integrations/webWorker/init.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,17 @@ const worker = new Worker('/worker.js');
99

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

12-
const btn = document.getElementById('errWorker');
12+
const btnError = document.getElementById('errWorker');
13+
const btnRejection = document.getElementById('rejectionWorker');
1314

14-
btn.addEventListener('click', () => {
15+
btnError.addEventListener('click', () => {
1516
worker.postMessage({
1617
type: 'throw-error',
1718
});
1819
});
20+
21+
btnRejection.addEventListener('click', () => {
22+
worker.postMessage({
23+
type: 'throw-rejection',
24+
});
25+
});

dev-packages/browser-integration-tests/suites/integrations/webWorker/template.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@
55
</head>
66
<body>
77
<button id="errWorker">Throw error in worker</button>
8+
<button id="rejectionWorker">Throw unhandled rejection in worker</button>
89
</body>
910
</html>

dev-packages/browser-integration-tests/suites/integrations/webWorker/test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,32 @@ sentryTest('Assigns web worker debug IDs when using webWorkerIntegration', async
3636
expect(image.code_file).toEqual('http://sentry-test.io/worker.js');
3737
});
3838
});
39+
40+
sentryTest('Captures unhandled rejections from web workers', async ({ getLocalTestUrl, page }) => {
41+
const bundle = process.env.PW_BUNDLE;
42+
if (bundle != null && !bundle.includes('esm') && !bundle.includes('cjs')) {
43+
sentryTest.skip();
44+
}
45+
46+
const url = await getLocalTestUrl({ testDir: __dirname });
47+
48+
const errorEventPromise = getFirstSentryEnvelopeRequest<Event>(page, url);
49+
50+
page.route('**/worker.js', route => {
51+
route.fulfill({
52+
path: `${__dirname}/assets/worker.js`,
53+
});
54+
});
55+
56+
const button = page.locator('#rejectionWorker');
57+
await button.click();
58+
59+
const errorEvent = await errorEventPromise;
60+
61+
// Verify the unhandled rejection was captured
62+
expect(errorEvent.exception?.values?.[0]?.value).toContain('Worker unhandled rejection');
63+
expect(errorEvent.exception?.values?.[0]?.mechanism?.type).toBe('auto.browser.web_worker.onunhandledrejection');
64+
expect(errorEvent.exception?.values?.[0]?.mechanism?.handled).toBe(false);
65+
expect(errorEvent.contexts?.worker).toBeDefined();
66+
expect(errorEvent.contexts?.worker?.filename).toContain('worker.js');
67+
});

packages/browser/src/integrations/globalhandlers.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,10 @@ function _installGlobalOnUnhandledRejectionHandler(client: Client): void {
104104
});
105105
}
106106

107-
function _getUnhandledRejectionError(error: unknown): unknown {
107+
/**
108+
*
109+
*/
110+
export function _getUnhandledRejectionError(error: unknown): unknown {
108111
if (isPrimitive(error)) {
109112
return error;
110113
}
@@ -138,7 +141,7 @@ function _getUnhandledRejectionError(error: unknown): unknown {
138141
* @param reason: The `reason` property of the promise rejection
139142
* @returns An Event object with an appropriate `exception` value
140143
*/
141-
function _eventFromRejectionWithPrimitive(reason: Primitive): Event {
144+
export function _eventFromRejectionWithPrimitive(reason: Primitive): Event {
142145
return {
143146
exception: {
144147
values: [

packages/browser/src/integrations/webWorker.ts

Lines changed: 128 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
import type { Integration, IntegrationFn } from '@sentry/core';
2-
import { debug, defineIntegration, isPlainObject } from '@sentry/core';
2+
import { captureEvent, debug, defineIntegration, getClient, isPlainObject, isPrimitive } from '@sentry/core';
33
import { DEBUG_BUILD } from '../debug-build';
4+
import { eventFromUnknownInput } from '../eventbuilder';
45
import { WINDOW } from '../helpers';
6+
import { _eventFromRejectionWithPrimitive, _getUnhandledRejectionError } from './globalhandlers';
57

68
export const INTEGRATION_NAME = 'WebWorker';
79

810
interface WebWorkerMessage {
911
_sentryMessage: boolean;
1012
_sentryDebugIds?: Record<string, string>;
13+
_sentryWorkerError?: SerializedWorkerError;
14+
}
15+
16+
interface SerializedWorkerError {
17+
reason: unknown;
18+
filename?: string;
1119
}
1220

1321
interface WebWorkerIntegrationOptions {
@@ -94,25 +102,75 @@ interface WebWorkerIntegration extends Integration {
94102
export const webWorkerIntegration = defineIntegration(({ worker }: WebWorkerIntegrationOptions) => ({
95103
name: INTEGRATION_NAME,
96104
setupOnce: () => {
97-
(Array.isArray(worker) ? worker : [worker]).forEach(w => listenForSentryDebugIdMessages(w));
105+
(Array.isArray(worker) ? worker : [worker]).forEach(w => listenForSentryMessages(w));
98106
},
99-
addWorker: (worker: Worker) => listenForSentryDebugIdMessages(worker),
107+
addWorker: (worker: Worker) => listenForSentryMessages(worker),
100108
})) as IntegrationFn<WebWorkerIntegration>;
101109

102-
function listenForSentryDebugIdMessages(worker: Worker): void {
110+
function listenForSentryMessages(worker: Worker): void {
103111
worker.addEventListener('message', event => {
104-
if (isSentryDebugIdMessage(event.data)) {
112+
if (isSentryMessage(event.data)) {
105113
event.stopImmediatePropagation(); // other listeners should not receive this message
106-
DEBUG_BUILD && debug.log('Sentry debugId web worker message received', event.data);
107-
WINDOW._sentryDebugIds = {
108-
...event.data._sentryDebugIds,
109-
// debugIds of the main thread have precedence over the worker's in case of a collision.
110-
...WINDOW._sentryDebugIds,
111-
};
114+
115+
// Handle debug IDs
116+
if (event.data._sentryDebugIds) {
117+
DEBUG_BUILD && debug.log('Sentry debugId web worker message received', event.data);
118+
WINDOW._sentryDebugIds = {
119+
...event.data._sentryDebugIds,
120+
// debugIds of the main thread have precedence over the worker's in case of a collision.
121+
...WINDOW._sentryDebugIds,
122+
};
123+
}
124+
125+
// Handle unhandled rejections forwarded from worker
126+
if (event.data._sentryWorkerError) {
127+
DEBUG_BUILD && debug.log('Sentry worker rejection message received', event.data._sentryWorkerError);
128+
handleForwardedWorkerRejection(event.data._sentryWorkerError);
129+
}
112130
}
113131
});
114132
}
115133

134+
function handleForwardedWorkerRejection(workerError: SerializedWorkerError): void {
135+
const client = getClient();
136+
if (!client) {
137+
return;
138+
}
139+
140+
const stackParser = client.getOptions().stackParser;
141+
const attachStacktrace = client.getOptions().attachStacktrace;
142+
143+
const error = workerError.reason;
144+
145+
// Follow same pattern as globalHandlers for unhandledrejection
146+
// Handle both primitives and errors the same way
147+
const event = isPrimitive(error)
148+
? _eventFromRejectionWithPrimitive(error)
149+
: eventFromUnknownInput(stackParser, error, undefined, attachStacktrace, true);
150+
151+
event.level = 'error';
152+
153+
// Add worker-specific context
154+
if (workerError.filename) {
155+
event.contexts = {
156+
...event.contexts,
157+
worker: {
158+
filename: workerError.filename,
159+
},
160+
};
161+
}
162+
163+
captureEvent(event, {
164+
originalException: error,
165+
mechanism: {
166+
handled: false,
167+
type: 'auto.browser.web_worker.onunhandledrejection',
168+
},
169+
});
170+
171+
DEBUG_BUILD && debug.log('Captured worker unhandled rejection', error);
172+
}
173+
116174
/**
117175
* Minimal interface for DedicatedWorkerGlobalScope, only requiring the postMessage method.
118176
* (which is the only thing we need from the worker's global object)
@@ -124,6 +182,8 @@ function listenForSentryDebugIdMessages(worker: Worker): void {
124182
*/
125183
interface MinimalDedicatedWorkerGlobalScope {
126184
postMessage: (message: unknown) => void;
185+
addEventListener: (type: string, listener: (event: unknown) => void) => void;
186+
location?: { href?: string };
127187
}
128188

129189
interface RegisterWebWorkerOptions {
@@ -133,6 +193,14 @@ interface RegisterWebWorkerOptions {
133193
/**
134194
* Use this function to register the worker with the Sentry SDK.
135195
*
196+
* This function will:
197+
* - Send debug IDs to the parent thread
198+
* - Set up a handler for unhandled rejections in the worker
199+
* - Forward unhandled rejections to the parent thread for capture
200+
*
201+
* Note: Synchronous errors in workers are already captured by globalHandlers.
202+
* This only handles unhandled promise rejections which don't bubble to the parent.
203+
*
136204
* @example
137205
* ```ts filename={worker.js}
138206
* import * as Sentry from '@sentry/<your-sdk>';
@@ -147,17 +215,59 @@ interface RegisterWebWorkerOptions {
147215
* - `self`: The worker instance you're calling this function from (self).
148216
*/
149217
export function registerWebWorker({ self }: RegisterWebWorkerOptions): void {
218+
// Send debug IDs to parent thread
150219
self.postMessage({
151220
_sentryMessage: true,
152221
_sentryDebugIds: self._sentryDebugIds ?? undefined,
153222
});
223+
224+
// Set up unhandledrejection handler inside the worker
225+
// Following the same pattern as globalHandlers
226+
// unhandled rejections don't bubble to the parent thread, so we need to handle them here
227+
self.addEventListener('unhandledrejection', (event: unknown) => {
228+
const reason = _getUnhandledRejectionError(event);
229+
230+
// Forward the raw reason to parent thread
231+
// The parent will handle primitives vs errors the same way globalHandlers does
232+
const serializedError: SerializedWorkerError = {
233+
reason: reason,
234+
filename: self.location?.href,
235+
};
236+
237+
// Forward to parent thread
238+
self.postMessage({
239+
_sentryMessage: true,
240+
_sentryWorkerError: serializedError,
241+
});
242+
243+
DEBUG_BUILD && debug.log('[Sentry Worker] Forwarding unhandled rejection to parent', serializedError);
244+
});
245+
246+
DEBUG_BUILD && debug.log('[Sentry Worker] Registered worker with unhandled rejection handling');
154247
}
155248

156-
function isSentryDebugIdMessage(eventData: unknown): eventData is WebWorkerMessage {
157-
return (
158-
isPlainObject(eventData) &&
159-
eventData._sentryMessage === true &&
160-
'_sentryDebugIds' in eventData &&
161-
(isPlainObject(eventData._sentryDebugIds) || eventData._sentryDebugIds === undefined)
162-
);
249+
function isSentryMessage(eventData: unknown): eventData is WebWorkerMessage {
250+
if (!isPlainObject(eventData) || eventData._sentryMessage !== true) {
251+
return false;
252+
}
253+
254+
// Must have at least one of: debug IDs or worker error
255+
const hasDebugIds = '_sentryDebugIds' in eventData;
256+
const hasWorkerError = '_sentryWorkerError' in eventData;
257+
258+
if (!hasDebugIds && !hasWorkerError) {
259+
return false;
260+
}
261+
262+
// Validate debug IDs if present
263+
if (hasDebugIds && !(isPlainObject(eventData._sentryDebugIds) || eventData._sentryDebugIds === undefined)) {
264+
return false;
265+
}
266+
267+
// Validate worker error if present
268+
if (hasWorkerError && !isPlainObject(eventData._sentryWorkerError)) {
269+
return false;
270+
}
271+
272+
return true;
163273
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ describe('webWorkerIntegration', () => {
216216
describe('registerWebWorker', () => {
217217
let mockWorkerSelf: {
218218
postMessage: ReturnType<typeof vi.fn>;
219+
addEventListener: ReturnType<typeof vi.fn>;
219220
_sentryDebugIds?: Record<string, string>;
220221
};
221222

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

225226
mockWorkerSelf = {
226227
postMessage: vi.fn(),
228+
addEventListener: vi.fn(),
227229
};
228230
});
229231

0 commit comments

Comments
 (0)