Skip to content

Commit 76afe7d

Browse files
feat(wasm): initialised sentryWasmImages for webworkers (#18812)
Before submitting a pull request, please take a look at our [Contributing](https://github.com/getsentry/sentry-javascript/blob/master/CONTRIBUTING.md) guidelines and verify: - [x] If you've added code that should be tested, please add tests. - [x] Ensure your code lints and the test suite passes (`yarn lint`) & (`yarn test`). - [x] Link an issue if there is one related to your pull request. If no issue is linked, one will be auto-generated and linked. Closes #18779 --------- Co-authored-by: Andrei Borza <andrei.borza@sentry.io>
1 parent 11f38a7 commit 76afe7d

File tree

10 files changed

+693
-37
lines changed

10 files changed

+693
-37
lines changed
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// This worker manually replicates what Sentry.registerWebWorkerWasm() does.
2+
// the reason for manual replication is that it allows us to test the message-passing protocol
3+
// between worker and main thread independent of SDK implementation details
4+
// in production code you would do: registerWebWorkerWasm({ self });
5+
6+
const origInstantiateStreaming = WebAssembly.instantiateStreaming;
7+
WebAssembly.instantiateStreaming = function instantiateStreaming(response, importObject) {
8+
return Promise.resolve(response).then(res => {
9+
return origInstantiateStreaming(res, importObject).then(rv => {
10+
if (res.url) {
11+
registerModuleAndForward(rv.module, res.url);
12+
}
13+
return rv;
14+
});
15+
});
16+
};
17+
18+
function registerModuleAndForward(module, url) {
19+
const buildId = getBuildId(module);
20+
21+
if (buildId) {
22+
const image = {
23+
type: 'wasm',
24+
code_id: buildId,
25+
code_file: url,
26+
debug_file: null,
27+
debug_id: `${`${buildId}00000000000000000000000000000000`.slice(0, 32)}0`,
28+
};
29+
30+
self.postMessage({
31+
_sentryMessage: true,
32+
_sentryWasmImages: [image],
33+
});
34+
}
35+
}
36+
37+
// Extract build ID from WASM module
38+
function getBuildId(module) {
39+
const sections = WebAssembly.Module.customSections(module, 'build_id');
40+
if (sections.length > 0) {
41+
const buildId = Array.from(new Uint8Array(sections[0]))
42+
.map(b => b.toString(16).padStart(2, '0'))
43+
.join('');
44+
return buildId;
45+
}
46+
return null;
47+
}
48+
49+
// Handle messages from the main thread
50+
self.addEventListener('message', async event => {
51+
function crash() {
52+
throw new Error('WASM error from worker');
53+
}
54+
55+
if (event.data.type === 'load-wasm-and-crash') {
56+
const wasmUrl = event.data.wasmUrl;
57+
58+
try {
59+
const { instance } = await WebAssembly.instantiateStreaming(fetch(wasmUrl), {
60+
env: {
61+
external_func: crash,
62+
},
63+
});
64+
65+
instance.exports.internal_func();
66+
} catch (err) {
67+
self.postMessage({
68+
_sentryMessage: true,
69+
_sentryWorkerError: {
70+
reason: err,
71+
filename: self.location.href,
72+
},
73+
});
74+
}
75+
}
76+
});
77+
78+
self.addEventListener('unhandledrejection', event => {
79+
self.postMessage({
80+
_sentryMessage: true,
81+
_sentryWorkerError: {
82+
reason: event.reason,
83+
filename: self.location.href,
84+
},
85+
});
86+
});
87+
88+
// Let the main thread know that worker is ready
89+
self.postMessage({ _sentryMessage: false, type: 'WORKER_READY' });
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import * as Sentry from '@sentry/browser';
2+
import { wasmIntegration } from '@sentry/wasm';
3+
4+
window.Sentry = Sentry;
5+
6+
Sentry.init({
7+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
8+
integrations: [wasmIntegration({ applicationKey: 'wasm-worker-app' })],
9+
});
10+
11+
const worker = new Worker('/worker.js');
12+
13+
Sentry.addIntegration(Sentry.webWorkerIntegration({ worker }));
14+
15+
window.wasmWorker = worker;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
window.events = [];
2+
3+
window.triggerWasmError = () => {
4+
window.wasmWorker.postMessage({
5+
type: 'load-wasm-and-crash',
6+
wasmUrl: 'https://localhost:5887/simple.wasm',
7+
});
8+
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<button id="triggerWasmError">Trigger WASM Error in Worker</button>
8+
</body>
9+
</html>
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { expect } from '@playwright/test';
2+
import fs from 'fs';
3+
import path from 'path';
4+
import { sentryTest } from '../../../utils/fixtures';
5+
import { envelopeRequestParser, waitForErrorRequest } from '../../../utils/helpers';
6+
import { shouldSkipWASMTests } from '../../../utils/wasmHelpers';
7+
8+
declare global {
9+
interface Window {
10+
wasmWorker: Worker;
11+
triggerWasmError: () => void;
12+
}
13+
}
14+
15+
const bundle = process.env.PW_BUNDLE || '';
16+
if (bundle.startsWith('bundle')) {
17+
sentryTest.skip();
18+
}
19+
20+
sentryTest(
21+
'WASM debug images from worker should be forwarded to main thread and attached to events',
22+
async ({ getLocalTestUrl, page, browserName }) => {
23+
if (shouldSkipWASMTests(browserName)) {
24+
sentryTest.skip();
25+
}
26+
27+
const url = await getLocalTestUrl({ testDir: __dirname });
28+
29+
await page.route('**/simple.wasm', route => {
30+
const wasmModule = fs.readFileSync(path.resolve(__dirname, '../simple.wasm'));
31+
return route.fulfill({
32+
status: 200,
33+
body: wasmModule,
34+
headers: {
35+
'Content-Type': 'application/wasm',
36+
},
37+
});
38+
});
39+
40+
await page.route('**/worker.js', route => {
41+
return route.fulfill({
42+
path: `${__dirname}/assets/worker.js`,
43+
});
44+
});
45+
46+
const errorEventPromise = waitForErrorRequest(page, e => {
47+
return e.exception?.values?.[0]?.value === 'WASM error from worker';
48+
});
49+
50+
await page.goto(url);
51+
52+
await page.waitForFunction(() => window.wasmWorker !== undefined);
53+
54+
await page.evaluate(() => {
55+
window.triggerWasmError();
56+
});
57+
58+
const errorEvent = envelopeRequestParser(await errorEventPromise);
59+
60+
expect(errorEvent.exception?.values?.[0]?.value).toBe('WASM error from worker');
61+
62+
expect(errorEvent.debug_meta?.images).toBeDefined();
63+
expect(errorEvent.debug_meta?.images).toEqual(
64+
expect.arrayContaining([
65+
expect.objectContaining({
66+
type: 'wasm',
67+
code_file: expect.stringMatching(/simple\.wasm$/),
68+
code_id: '0ba020cdd2444f7eafdd25999a8e9010',
69+
debug_id: '0ba020cdd2444f7eafdd25999a8e90100',
70+
}),
71+
]),
72+
);
73+
74+
expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toEqual(
75+
expect.arrayContaining([
76+
expect.objectContaining({
77+
filename: expect.stringMatching(/simple\.wasm$/),
78+
platform: 'native',
79+
instruction_addr: expect.stringMatching(/^0x[a-fA-F\d]+$/),
80+
addr_mode: expect.stringMatching(/^rel:\d+$/),
81+
}),
82+
]),
83+
);
84+
},
85+
);
86+
87+
sentryTest(
88+
'WASM frames from worker should be recognized as first-party when applicationKey is configured',
89+
async ({ getLocalTestUrl, page, browserName }) => {
90+
if (shouldSkipWASMTests(browserName)) {
91+
sentryTest.skip();
92+
}
93+
94+
const url = await getLocalTestUrl({ testDir: __dirname });
95+
96+
await page.route('**/simple.wasm', route => {
97+
const wasmModule = fs.readFileSync(path.resolve(__dirname, '../simple.wasm'));
98+
return route.fulfill({
99+
status: 200,
100+
body: wasmModule,
101+
headers: {
102+
'Content-Type': 'application/wasm',
103+
},
104+
});
105+
});
106+
107+
await page.route('**/worker.js', route => {
108+
return route.fulfill({
109+
path: `${__dirname}/assets/worker.js`,
110+
});
111+
});
112+
113+
const errorEventPromise = waitForErrorRequest(page, e => {
114+
return e.exception?.values?.[0]?.value === 'WASM error from worker';
115+
});
116+
117+
await page.goto(url);
118+
119+
await page.waitForFunction(() => window.wasmWorker !== undefined);
120+
121+
await page.evaluate(() => {
122+
window.triggerWasmError();
123+
});
124+
125+
const errorEvent = envelopeRequestParser(await errorEventPromise);
126+
127+
expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toEqual(
128+
expect.arrayContaining([
129+
expect.objectContaining({
130+
filename: expect.stringMatching(/simple\.wasm$/),
131+
platform: 'native',
132+
module_metadata: expect.objectContaining({
133+
'_sentryBundlerPluginAppKey:wasm-worker-app': true,
134+
}),
135+
}),
136+
]),
137+
);
138+
},
139+
);

packages/browser/src/integrations/webWorker.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Integration, IntegrationFn } from '@sentry/core';
1+
import type { DebugImage, Integration, IntegrationFn } from '@sentry/core';
22
import { captureEvent, debug, defineIntegration, getClient, isPlainObject, isPrimitive } from '@sentry/core';
33
import { DEBUG_BUILD } from '../debug-build';
44
import { eventFromUnknownInput } from '../eventbuilder';
@@ -12,6 +12,7 @@ interface WebWorkerMessage {
1212
_sentryDebugIds?: Record<string, string>;
1313
_sentryModuleMetadata?: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
1414
_sentryWorkerError?: SerializedWorkerError;
15+
_sentryWasmImages?: Array<DebugImage>;
1516
}
1617

1718
interface SerializedWorkerError {
@@ -135,6 +136,23 @@ function listenForSentryMessages(worker: Worker): void {
135136
};
136137
}
137138

139+
// Handle WASM images from worker
140+
if (event.data._sentryWasmImages) {
141+
DEBUG_BUILD && debug.log('Sentry WASM images web worker message received', event.data);
142+
const existingImages =
143+
(WINDOW as typeof WINDOW & { _sentryWasmImages?: Array<DebugImage> })._sentryWasmImages || [];
144+
const newImages = event.data._sentryWasmImages.filter(
145+
(newImg: unknown) =>
146+
isPlainObject(newImg) &&
147+
typeof newImg.code_file === 'string' &&
148+
!existingImages.some(existing => existing.code_file === newImg.code_file),
149+
);
150+
(WINDOW as typeof WINDOW & { _sentryWasmImages?: Array<DebugImage> })._sentryWasmImages = [
151+
...existingImages,
152+
...newImages,
153+
];
154+
}
155+
138156
// Handle unhandled rejections forwarded from worker
139157
if (event.data._sentryWorkerError) {
140158
DEBUG_BUILD && debug.log('Sentry worker rejection message received', event.data._sentryWorkerError);
@@ -270,12 +288,13 @@ function isSentryMessage(eventData: unknown): eventData is WebWorkerMessage {
270288
return false;
271289
}
272290

273-
// Must have at least one of: debug IDs, module metadata, or worker error
291+
// Must have at least one of: debug IDs, module metadata, worker error, or WASM images
274292
const hasDebugIds = '_sentryDebugIds' in eventData;
275293
const hasModuleMetadata = '_sentryModuleMetadata' in eventData;
276294
const hasWorkerError = '_sentryWorkerError' in eventData;
295+
const hasWasmImages = '_sentryWasmImages' in eventData;
277296

278-
if (!hasDebugIds && !hasModuleMetadata && !hasWorkerError) {
297+
if (!hasDebugIds && !hasModuleMetadata && !hasWorkerError && !hasWasmImages) {
279298
return false;
280299
}
281300

@@ -297,5 +316,16 @@ function isSentryMessage(eventData: unknown): eventData is WebWorkerMessage {
297316
return false;
298317
}
299318

319+
// Validate WASM images if present
320+
if (
321+
hasWasmImages &&
322+
(!Array.isArray(eventData._sentryWasmImages) ||
323+
!eventData._sentryWasmImages.every(
324+
(img: unknown) => isPlainObject(img) && typeof (img as { code_file?: unknown }).code_file === 'string',
325+
))
326+
) {
327+
return false;
328+
}
329+
300330
return true;
301331
}

0 commit comments

Comments
 (0)