Skip to content
Open
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// This worker manually just replicates what the actual Sentry.registerWebWorkerWasm() does

const origInstantiateStreaming = WebAssembly.instantiateStreaming;
WebAssembly.instantiateStreaming = function instantiateStreaming(response, importObject) {
return Promise.resolve(response).then(res => {
return origInstantiateStreaming(res, importObject).then(rv => {
if (res.url) {
registerModuleAndForward(rv.module, res.url);
}
return rv;
});
});
};

function registerModuleAndForward(module, url) {
const buildId = getBuildId(module);

if (buildId) {
const image = {
type: 'wasm',
code_id: buildId,
code_file: url,
debug_file: null,
debug_id: (buildId + '00000000000000000000000000000000').slice(0, 32) + '0',
};

self.postMessage({
_sentryMessage: true,
_sentryWasmImages: [image],
});
}
}

// Extract build ID from WASM module
function getBuildId(module) {
const sections = WebAssembly.Module.customSections(module, 'build_id');
if (sections.length > 0) {
const buildId = Array.from(new Uint8Array(sections[0]))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
return buildId;
}
return null;
}

// Handle messages from the main thread
self.addEventListener('message', async event => {

Check warning

Code scanning / CodeQL

Missing origin verification in `postMessage` handler Medium

Postmessage handler has no origin check.
if (event.data.type === 'load-wasm-and-crash') {
const wasmUrl = event.data.wasmUrl;

function crash() {
throw new Error('WASM error from worker');
}

try {
const { instance } = await WebAssembly.instantiateStreaming(fetch(wasmUrl), {

Check warning

Code scanning / CodeQL

Client-side request forgery Medium

The
URL
of this request depends on a
user-provided value
.
env: {
external_func: crash,
},
});

instance.exports.internal_func();
} catch (err) {
self.postMessage({
_sentryMessage: true,
_sentryWorkerError: {
reason: err,
filename: self.location.href,
},
});
}
}
});

self.addEventListener('unhandledrejection', event => {
self.postMessage({
_sentryMessage: true,
_sentryWorkerError: {
reason: event.reason,
filename: self.location.href,
},
});
});

// Let the main thread know that worker is ready
self.postMessage({ _sentryMessage: false, type: 'WORKER_READY' });
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as Sentry from '@sentry/browser';
import { wasmIntegration } from '@sentry/wasm';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
integrations: [wasmIntegration({ applicationKey: 'wasm-worker-app' })],
});

const worker = new Worker('/worker.js');

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

window.wasmWorker = worker;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
window.events = [];

window.triggerWasmError = () => {
window.wasmWorker.postMessage({
type: 'load-wasm-and-crash',
wasmUrl: 'https://localhost:5887/simple.wasm',
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<button id="triggerWasmError">Trigger WASM Error in Worker</button>
</body>
</html>
139 changes: 139 additions & 0 deletions dev-packages/browser-integration-tests/suites/wasm/webWorker/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
import { sentryTest } from '../../../utils/fixtures';
import { envelopeRequestParser, waitForErrorRequest } from '../../../utils/helpers';
import { shouldSkipWASMTests } from '../../../utils/wasmHelpers';

declare global {
interface Window {
wasmWorker: Worker;
triggerWasmError: () => void;
}
}

const bundle = process.env.PW_BUNDLE || '';
if (bundle.startsWith('bundle')) {
sentryTest.skip();
}

sentryTest(
'WASM debug images from worker should be forwarded to main thread and attached to events',
async ({ getLocalTestUrl, page, browserName }) => {
if (shouldSkipWASMTests(browserName)) {
sentryTest.skip();
}

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

await page.route('**/simple.wasm', route => {
const wasmModule = fs.readFileSync(path.resolve(__dirname, '../simple.wasm'));
return route.fulfill({
status: 200,
body: wasmModule,
headers: {
'Content-Type': 'application/wasm',
},
});
});

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

const errorEventPromise = waitForErrorRequest(page, e => {
return e.exception?.values?.[0]?.value === 'WASM error from worker';
});

await page.goto(url);

await page.waitForFunction(() => window.wasmWorker !== undefined);

await page.evaluate(() => {
window.triggerWasmError();
});

const errorEvent = envelopeRequestParser(await errorEventPromise);

expect(errorEvent.exception?.values?.[0]?.value).toBe('WASM error from worker');

expect(errorEvent.debug_meta?.images).toBeDefined();
expect(errorEvent.debug_meta?.images).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'wasm',
code_file: expect.stringMatching(/simple\.wasm$/),
code_id: '0ba020cdd2444f7eafdd25999a8e9010',
debug_id: '0ba020cdd2444f7eafdd25999a8e90100',
}),
]),
);

expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toEqual(
expect.arrayContaining([
expect.objectContaining({
filename: expect.stringMatching(/simple\.wasm$/),
platform: 'native',
instruction_addr: expect.stringMatching(/^0x[a-fA-F0-9]+$/),
addr_mode: expect.stringMatching(/^rel:\d+$/),
}),
]),
);
},
);

sentryTest(
'WASM frames from worker should be recognized as first-party when applicationKey is configured',
async ({ getLocalTestUrl, page, browserName }) => {
if (shouldSkipWASMTests(browserName)) {
sentryTest.skip();
}

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

await page.route('**/simple.wasm', route => {
const wasmModule = fs.readFileSync(path.resolve(__dirname, '../simple.wasm'));
return route.fulfill({
status: 200,
body: wasmModule,
headers: {
'Content-Type': 'application/wasm',
},
});
});

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

const errorEventPromise = waitForErrorRequest(page, e => {
return e.exception?.values?.[0]?.value === 'WASM error from worker';
});

await page.goto(url);

await page.waitForFunction(() => window.wasmWorker !== undefined);

await page.evaluate(() => {
window.triggerWasmError();
});

const errorEvent = envelopeRequestParser(await errorEventPromise);

expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toEqual(
expect.arrayContaining([
expect.objectContaining({
filename: expect.stringMatching(/simple\.wasm$/),
platform: 'native',
module_metadata: expect.objectContaining({
'_sentryBundlerPluginAppKey:wasm-worker-app': true,
}),
}),
]),
);
},
);
36 changes: 33 additions & 3 deletions packages/browser/src/integrations/webWorker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Integration, IntegrationFn } from '@sentry/core';
import type { DebugImage, Integration, IntegrationFn } from '@sentry/core';
import { captureEvent, debug, defineIntegration, getClient, isPlainObject, isPrimitive } from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
import { eventFromUnknownInput } from '../eventbuilder';
Expand All @@ -12,6 +12,7 @@ interface WebWorkerMessage {
_sentryDebugIds?: Record<string, string>;
_sentryModuleMetadata?: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
_sentryWorkerError?: SerializedWorkerError;
_sentryWasmImages?: Array<DebugImage>;
}

interface SerializedWorkerError {
Expand Down Expand Up @@ -135,6 +136,23 @@ function listenForSentryMessages(worker: Worker): void {
};
}

// Handle WASM images from worker
if (event.data._sentryWasmImages) {
DEBUG_BUILD && debug.log('Sentry WASM images web worker message received', event.data);
const existingImages =
(WINDOW as typeof WINDOW & { _sentryWasmImages?: Array<DebugImage> })._sentryWasmImages || [];
const newImages = event.data._sentryWasmImages.filter(
(newImg: unknown) =>
isPlainObject(newImg) &&
typeof newImg.code_file === 'string' &&
!existingImages.some(existing => existing.code_file === newImg.code_file),
);
(WINDOW as typeof WINDOW & { _sentryWasmImages?: Array<DebugImage> })._sentryWasmImages = [
...existingImages,
...(newImages as Array<DebugImage>),
];
}

// Handle unhandled rejections forwarded from worker
if (event.data._sentryWorkerError) {
DEBUG_BUILD && debug.log('Sentry worker rejection message received', event.data._sentryWorkerError);
Expand Down Expand Up @@ -270,12 +288,13 @@ function isSentryMessage(eventData: unknown): eventData is WebWorkerMessage {
return false;
}

// Must have at least one of: debug IDs, module metadata, or worker error
// Must have at least one of: debug IDs, module metadata, worker error, or WASM images
const hasDebugIds = '_sentryDebugIds' in eventData;
const hasModuleMetadata = '_sentryModuleMetadata' in eventData;
const hasWorkerError = '_sentryWorkerError' in eventData;
const hasWasmImages = '_sentryWasmImages' in eventData;

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

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

// Validate WASM images if present
if (
hasWasmImages &&
(!Array.isArray(eventData._sentryWasmImages) ||
!eventData._sentryWasmImages.every(
(img: unknown) => isPlainObject(img) && typeof (img as { code_file?: unknown }).code_file === 'string',
))
) {
return false;
}

return true;
}
Loading
Loading