Skip to content

Commit cf6a8f8

Browse files
committed
fix(browser): Capture unhandled rejection errors for web worker integration
1 parent 455c231 commit cf6a8f8

File tree

6 files changed

+192
-22
lines changed

6 files changed

+192
-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: any) => 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: any) => {
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 && console.log('[Sentry Worker] Forwarding unhandled rejection to parent', serializedError);
244+
});
245+
246+
DEBUG_BUILD && console.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
}

0 commit comments

Comments
 (0)