From 32071e70c8f7d4a640ec451d262c36bd74dc4429 Mon Sep 17 00:00:00 2001 From: Harshit Singh Date: Wed, 14 Jan 2026 16:18:12 +0530 Subject: [PATCH 1/4] feat(wasm): initialised sentryWasmImages for webworkers --- .../browser/src/integrations/webWorker.ts | 27 +++- packages/wasm/src/index.ts | 138 +++++++++++++++++- 2 files changed, 154 insertions(+), 11 deletions(-) diff --git a/packages/browser/src/integrations/webWorker.ts b/packages/browser/src/integrations/webWorker.ts index 5af6c3b2553a..6cfd1ee28a1f 100644 --- a/packages/browser/src/integrations/webWorker.ts +++ b/packages/browser/src/integrations/webWorker.ts @@ -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'; @@ -12,6 +12,7 @@ interface WebWorkerMessage { _sentryDebugIds?: Record; _sentryModuleMetadata?: Record; // eslint-disable-line @typescript-eslint/no-explicit-any _sentryWorkerError?: SerializedWorkerError; + _sentryWasmImages?: Array; } interface SerializedWorkerError { @@ -135,6 +136,20 @@ 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 })._sentryWasmImages || []; + const newImages = event.data._sentryWasmImages.filter( + (newImg: DebugImage) => !existingImages.some(existing => existing.code_file === newImg.code_file), + ); + (WINDOW as typeof WINDOW & { _sentryWasmImages?: Array })._sentryWasmImages = [ + ...existingImages, + ...newImages, + ]; + } + // Handle unhandled rejections forwarded from worker if (event.data._sentryWorkerError) { DEBUG_BUILD && debug.log('Sentry worker rejection message received', event.data._sentryWorkerError); @@ -270,12 +285,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; } @@ -297,5 +313,10 @@ function isSentryMessage(eventData: unknown): eventData is WebWorkerMessage { return false; } + // Validate WASM images if present + if (hasWasmImages && !Array.isArray(eventData._sentryWasmImages)) { + return false; + } + return true; } diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts index 84076285fcdd..3ea89dad3d09 100644 --- a/packages/wasm/src/index.ts +++ b/packages/wasm/src/index.ts @@ -1,10 +1,26 @@ -import type { Event, IntegrationFn, StackFrame } from '@sentry/core'; -import { defineIntegration } from '@sentry/core'; +import type { DebugImage, Event, IntegrationFn, StackFrame } from '@sentry/core'; +import { defineIntegration, GLOBAL_OBJ } from '@sentry/core'; import { patchWebAssembly } from './patchWebAssembly'; -import { getImage, getImages } from './registry'; +import { getImage, getImages, getModuleInfo } from './registry'; const INTEGRATION_NAME = 'Wasm'; +// We use the same prefix as bundler plugins so that thirdPartyErrorFilterIntegration +// recognizes WASM frames as first-party code without needing modifications. +const BUNDLER_PLUGIN_APP_KEY_PREFIX = '_sentryBundlerPluginAppKey:'; + +/** + * Minimal interface for DedicatedWorkerGlobalScope. + * We can't use the actual type because it breaks everyone who doesn't have {"lib": ["WebWorker"]} + */ +interface MinimalDedicatedWorkerGlobalScope { + postMessage: (message: unknown) => void; +} + +interface RegisterWebWorkerWasmOptions { + self: MinimalDedicatedWorkerGlobalScope; +} + interface WasmIntegrationOptions { /** * Key to identify this application for third-party error filtering. @@ -14,6 +30,11 @@ interface WasmIntegrationOptions { applicationKey?: string; } +// Access WINDOW with proper typing for _sentryWasmImages +const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryWasmImages?: Array; +}; + const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => { return { name: INTEGRATION_NAME, @@ -34,7 +55,9 @@ const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => { if (hasAtLeastOneWasmFrameWithImage) { event.debug_meta = event.debug_meta || {}; - event.debug_meta.images = [...(event.debug_meta.images || []), ...getImages()]; + const mainThreadImages = getImages(); + const workerImages = WINDOW._sentryWasmImages || []; + event.debug_meta.images = [...(event.debug_meta.images || []), ...mainThreadImages, ...workerImages]; } return event; @@ -46,10 +69,6 @@ export const wasmIntegration = defineIntegration(_wasmIntegration); const PARSER_REGEX = /^(.*?):wasm-function\[\d+\]:(0x[a-fA-F0-9]+)$/; -// We use the same prefix as bundler plugins so that thirdPartyErrorFilterIntegration -// recognizes WASM frames as first-party code without needing modifications. -const BUNDLER_PLUGIN_APP_KEY_PREFIX = '_sentryBundlerPluginAppKey:'; - /** * Patches a list of stackframes with wasm data needed for server-side symbolication * if applicable. Returns true if the provided list of stack frames had at least one @@ -80,6 +99,7 @@ export function patchFrames(frames: Array, applicationKey?: string): if (match) { const index = getImage(match[1]); + const workerImageIndex = getWorkerImage(match[1]); frame.instruction_addr = match[2]; frame.filename = match[1]; frame.platform = 'native'; @@ -94,9 +114,111 @@ export function patchFrames(frames: Array, applicationKey?: string): if (index >= 0) { frame.addr_mode = `rel:${index}`; hasAtLeastOneWasmFrameWithImage = true; + } else if (workerImageIndex >= 0) { + const mainThreadImagesCount = getImages().length; + frame.addr_mode = `rel:${mainThreadImagesCount + workerImageIndex}`; + hasAtLeastOneWasmFrameWithImage = true; } } }); return hasAtLeastOneWasmFrameWithImage; } + +/** + * Looks up an image by URL in worker images. + */ +function getWorkerImage(url: string): number { + const workerImages = WINDOW._sentryWasmImages || []; + return workerImages.findIndex(image => { + return image.type === 'wasm' && image.code_file === url; + }); +} + +/** + * Use this function to register WASM support in a web worker. + * + * This function will: + * - Patch WebAssembly.instantiateStreaming and WebAssembly.compileStreaming in the worker + * - Forward WASM debug images to the parent thread for symbolication + * + * @param options {RegisterWebWorkerWasmOptions} Options: + * - `self`: The worker's global scope (self). + */ +export function registerWebWorkerWasm({ self }: RegisterWebWorkerWasmOptions): void { + patchWebAssemblyWithForwarding(self); +} + +/** + * Patches the WebAssembly object in the worker scope and forwards + * registered modules to the parent thread. + */ +function patchWebAssemblyWithForwarding(workerSelf: MinimalDedicatedWorkerGlobalScope): void { + if ('instantiateStreaming' in WebAssembly) { + const origInstantiateStreaming = WebAssembly.instantiateStreaming; + WebAssembly.instantiateStreaming = function instantiateStreaming( + response: Response | PromiseLike, + importObject: WebAssembly.Imports, + ): Promise { + return Promise.resolve(response).then(response => { + return origInstantiateStreaming(response, importObject).then(rv => { + if (response.url) { + registerModuleAndForward(rv.module, response.url, workerSelf); + } + return rv; + }); + }); + } as typeof WebAssembly.instantiateStreaming; + } + + if ('compileStreaming' in WebAssembly) { + const origCompileStreaming = WebAssembly.compileStreaming; + WebAssembly.compileStreaming = function compileStreaming( + source: Response | Promise, + ): Promise { + return Promise.resolve(source).then(response => { + return origCompileStreaming(response).then(module => { + if (response.url) { + registerModuleAndForward(module, response.url, workerSelf); + } + return module; + }); + }); + } as typeof WebAssembly.compileStreaming; + } +} + +/** + * Registers a WASM module and forwards its debug image to the parent thread. + */ +function registerModuleAndForward( + module: WebAssembly.Module, + url: string, + workerSelf: MinimalDedicatedWorkerGlobalScope, +): void { + const { buildId, debugFile } = getModuleInfo(module); + + if (buildId) { + let debugFileUrl = null; + if (debugFile) { + try { + debugFileUrl = new URL(debugFile, url).href; + } catch { + // Ignore + } + } + + const image: DebugImage = { + type: 'wasm', + code_id: buildId, + code_file: url, + debug_file: debugFileUrl, + debug_id: `${buildId.padEnd(32, '0').slice(0, 32)}0`, + }; + + workerSelf.postMessage({ + _sentryMessage: true, + _sentryWasmImages: [image], + }); + } +} From 02ea888f77a7fb2a6c5651a75776d29f8e7c45f9 Mon Sep 17 00:00:00 2001 From: Harshit Singh Date: Thu, 15 Jan 2026 01:16:27 +0530 Subject: [PATCH 2/4] feat(wasm): added tests for webworker --- .../test/integrations/webWorker.test.ts | 86 ++++++++++++ packages/wasm/test/webworker.test.ts | 122 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 packages/wasm/test/webworker.test.ts diff --git a/packages/browser/test/integrations/webWorker.test.ts b/packages/browser/test/integrations/webWorker.test.ts index 584f18ee9a75..869f0537f566 100644 --- a/packages/browser/test/integrations/webWorker.test.ts +++ b/packages/browser/test/integrations/webWorker.test.ts @@ -278,6 +278,92 @@ describe('webWorkerIntegration', () => { expect(mockEvent.stopImmediatePropagation).not.toHaveBeenCalled(); }); + it('processes WASM images from worker', () => { + (helpers.WINDOW as any)._sentryWasmImages = undefined; + const wasmImages = [ + { + type: 'wasm', + code_id: 'abc123', + code_file: 'http://localhost:8001/worker.wasm', + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + }, + ]; + + mockEvent.data = { + _sentryMessage: true, + _sentryWasmImages: wasmImages, + }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).toHaveBeenCalled(); + expect(mockDebugLog).toHaveBeenCalledWith('Sentry WASM images web worker message received', mockEvent.data); + expect((helpers.WINDOW as any)._sentryWasmImages).toEqual(wasmImages); + }); + + it('deduplicates WASM images by code_file URL', () => { + (helpers.WINDOW as any)._sentryWasmImages = [ + { + type: 'wasm', + code_id: 'abc123', + code_file: 'http://localhost:8001/existing.wasm', + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + }, + ]; + + mockEvent.data = { + _sentryMessage: true, + _sentryWasmImages: [ + { + type: 'wasm', + code_id: 'abc123', + code_file: 'http://localhost:8001/existing.wasm', // duplicate, should be ignored + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + }, + { + type: 'wasm', + code_id: 'def456', + code_file: 'http://localhost:8001/new.wasm', // new, should be added + debug_file: null, + debug_id: 'def45600000000000000000000000000', + }, + ], + }; + + messageHandler(mockEvent); + + expect((helpers.WINDOW as any)._sentryWasmImages).toEqual([ + { + type: 'wasm', + code_id: 'abc123', + code_file: 'http://localhost:8001/existing.wasm', + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + }, + { + type: 'wasm', + code_id: 'def456', + code_file: 'http://localhost:8001/new.wasm', + debug_file: null, + debug_id: 'def45600000000000000000000000000', + }, + ]); + }); + + it('ignores invalid WASM images (not an array)', () => { + mockEvent.data = { + _sentryMessage: true, + _sentryWasmImages: 'not-an-array', + }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).not.toHaveBeenCalled(); + }); + it('gives main thread precedence over worker for conflicting module metadata', () => { (helpers.WINDOW as any)._sentryModuleMetadata = { 'Error\n at shared-file.js:1:1': { '_sentryBundlerPluginAppKey:main-app': true, source: 'main' }, diff --git a/packages/wasm/test/webworker.test.ts b/packages/wasm/test/webworker.test.ts new file mode 100644 index 000000000000..e1e2742fcbd0 --- /dev/null +++ b/packages/wasm/test/webworker.test.ts @@ -0,0 +1,122 @@ +import type { DebugImage, StackFrame } from '@sentry/core'; +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { patchFrames, registerWebWorkerWasm } from '../src/index'; + +const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryWasmImages?: Array; +}; + +describe('registerWebWorkerWasm()', () => { + afterEach(() => { + delete WINDOW._sentryWasmImages; + vi.restoreAllMocks(); + }); + + it('should patch WebAssembly.instantiateStreaming when available', () => { + const mockPostMessage = vi.fn(); + const mockSelf = { postMessage: mockPostMessage }; + + const originalInstantiateStreaming = WebAssembly.instantiateStreaming; + + registerWebWorkerWasm({ self: mockSelf }); + + expect(WebAssembly.instantiateStreaming).not.toBe(originalInstantiateStreaming); + + WebAssembly.instantiateStreaming = originalInstantiateStreaming; + }); + + it('should patch WebAssembly.compileStreaming when available', () => { + const mockPostMessage = vi.fn(); + const mockSelf = { postMessage: mockPostMessage }; + + const originalCompileStreaming = WebAssembly.compileStreaming; + + registerWebWorkerWasm({ self: mockSelf }); + + expect(WebAssembly.compileStreaming).not.toBe(originalCompileStreaming); + + WebAssembly.compileStreaming = originalCompileStreaming; + }); +}); + +describe('patchFrames() with worker images', () => { + afterEach(() => { + delete WINDOW._sentryWasmImages; + }); + + it('should find image from worker when main thread has no matching image', () => { + WINDOW._sentryWasmImages = [ + { + type: 'wasm', + code_id: 'abc123', + code_file: 'http://localhost:8001/worker.wasm', + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + }, + ]; + + const frames: StackFrame[] = [ + { + filename: 'http://localhost:8001/worker.wasm:wasm-function[10]:0x1234', + function: 'worker_function', + in_app: true, + }, + ]; + + const result = patchFrames(frames); + + expect(result).toBe(true); + expect(frames[0]?.filename).toBe('http://localhost:8001/worker.wasm'); + expect(frames[0]?.instruction_addr).toBe('0x1234'); + expect(frames[0]?.platform).toBe('native'); + expect(frames[0]?.addr_mode).toBe('rel:0'); + }); + + it('should apply applicationKey to frames from worker images', () => { + // Set up worker images + WINDOW._sentryWasmImages = [ + { + type: 'wasm', + code_id: 'abc123', + code_file: 'http://localhost:8001/worker.wasm', + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + }, + ]; + + const frames: StackFrame[] = [ + { + filename: 'http://localhost:8001/worker.wasm:wasm-function[10]:0x1234', + function: 'worker_function', + in_app: true, + }, + ]; + + patchFrames(frames, 'my-worker-app'); + + expect(frames[0]?.module_metadata).toEqual({ + '_sentryBundlerPluginAppKey:my-worker-app': true, + }); + }); + + it('should return false when no matching image exists in main thread or worker', () => { + WINDOW._sentryWasmImages = []; + + const frames: StackFrame[] = [ + { + filename: 'http://localhost:8001/unknown.wasm:wasm-function[10]:0x1234', + function: 'unknown_function', + in_app: true, + }, + ]; + + const result = patchFrames(frames); + + expect(result).toBe(false); + expect(frames[0]?.filename).toBe('http://localhost:8001/unknown.wasm'); + expect(frames[0]?.instruction_addr).toBe('0x1234'); + expect(frames[0]?.platform).toBe('native'); + expect(frames[0]?.addr_mode).toBeUndefined(); + }); +}); From dc1a2c16c3b9274a137e7ecc29be661af0f15691 Mon Sep 17 00:00:00 2001 From: Harshit Singh Date: Fri, 16 Jan 2026 15:45:31 +0530 Subject: [PATCH 3/4] feat(wasm): address cursor comments and added test suite --- .../suites/wasm/webWorker/assets/worker.js | 86 +++++++++++ .../suites/wasm/webWorker/init.js | 15 ++ .../suites/wasm/webWorker/subject.js | 8 + .../suites/wasm/webWorker/template.html | 9 ++ .../suites/wasm/webWorker/test.ts | 138 ++++++++++++++++++ .../browser/src/integrations/webWorker.ts | 15 +- .../test/integrations/webWorker.test.ts | 16 ++ 7 files changed, 284 insertions(+), 3 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/wasm/webWorker/assets/worker.js create mode 100644 dev-packages/browser-integration-tests/suites/wasm/webWorker/init.js create mode 100644 dev-packages/browser-integration-tests/suites/wasm/webWorker/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/wasm/webWorker/template.html create mode 100644 dev-packages/browser-integration-tests/suites/wasm/webWorker/test.ts diff --git a/dev-packages/browser-integration-tests/suites/wasm/webWorker/assets/worker.js b/dev-packages/browser-integration-tests/suites/wasm/webWorker/assets/worker.js new file mode 100644 index 000000000000..3e92191373c0 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/webWorker/assets/worker.js @@ -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 => { + 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), { + 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' }); diff --git a/dev-packages/browser-integration-tests/suites/wasm/webWorker/init.js b/dev-packages/browser-integration-tests/suites/wasm/webWorker/init.js new file mode 100644 index 000000000000..39007ede4bc0 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/webWorker/init.js @@ -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; diff --git a/dev-packages/browser-integration-tests/suites/wasm/webWorker/subject.js b/dev-packages/browser-integration-tests/suites/wasm/webWorker/subject.js new file mode 100644 index 000000000000..22d1f7c57f03 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/webWorker/subject.js @@ -0,0 +1,8 @@ +window.events = []; + +window.triggerWasmError = () => { + window.wasmWorker.postMessage({ + type: 'load-wasm-and-crash', + wasmUrl: 'https://localhost:5887/simple.wasm', + }); +}; diff --git a/dev-packages/browser-integration-tests/suites/wasm/webWorker/template.html b/dev-packages/browser-integration-tests/suites/wasm/webWorker/template.html new file mode 100644 index 000000000000..37ffd3e87b17 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/webWorker/template.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/wasm/webWorker/test.ts b/dev-packages/browser-integration-tests/suites/wasm/webWorker/test.ts new file mode 100644 index 000000000000..312bfd98872f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/webWorker/test.ts @@ -0,0 +1,138 @@ +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', + }), + ]), + ); + + const wasmFrame = errorEvent.exception?.values?.[0]?.stacktrace?.frames?.find( + frame => frame.filename && frame.filename.includes('simple.wasm'), + ); + + if (wasmFrame) { + expect(wasmFrame.platform).toBe('native'); + expect(wasmFrame.instruction_addr).toBeDefined(); + expect(wasmFrame.addr_mode).toMatch(/^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); + + const wasmFrame = errorEvent.exception?.values?.[0]?.stacktrace?.frames?.find( + frame => frame.filename && frame.filename.includes('simple.wasm'), + ); + + if (wasmFrame) { + expect(wasmFrame.module_metadata).toEqual( + expect.objectContaining({ + '_sentryBundlerPluginAppKey:wasm-worker-app': true, + }), + ); + } + }, +); diff --git a/packages/browser/src/integrations/webWorker.ts b/packages/browser/src/integrations/webWorker.ts index 6cfd1ee28a1f..dcf540f9a0c0 100644 --- a/packages/browser/src/integrations/webWorker.ts +++ b/packages/browser/src/integrations/webWorker.ts @@ -142,11 +142,14 @@ function listenForSentryMessages(worker: Worker): void { const existingImages = (WINDOW as typeof WINDOW & { _sentryWasmImages?: Array })._sentryWasmImages || []; const newImages = event.data._sentryWasmImages.filter( - (newImg: DebugImage) => !existingImages.some(existing => existing.code_file === newImg.code_file), + (newImg: unknown) => + isPlainObject(newImg) && + typeof newImg.code_file === 'string' && + !existingImages.some(existing => existing.code_file === newImg.code_file), ); (WINDOW as typeof WINDOW & { _sentryWasmImages?: Array })._sentryWasmImages = [ ...existingImages, - ...newImages, + ...(newImages as Array), ]; } @@ -314,7 +317,13 @@ function isSentryMessage(eventData: unknown): eventData is WebWorkerMessage { } // Validate WASM images if present - if (hasWasmImages && !Array.isArray(eventData._sentryWasmImages)) { + if ( + hasWasmImages && + (!Array.isArray(eventData._sentryWasmImages) || + !eventData._sentryWasmImages.every( + (img: unknown) => isPlainObject(img) && typeof (img as { code_file?: unknown }).code_file === 'string', + )) + ) { return false; } diff --git a/packages/browser/test/integrations/webWorker.test.ts b/packages/browser/test/integrations/webWorker.test.ts index 869f0537f566..4696f78888e3 100644 --- a/packages/browser/test/integrations/webWorker.test.ts +++ b/packages/browser/test/integrations/webWorker.test.ts @@ -364,6 +364,22 @@ describe('webWorkerIntegration', () => { expect(mockEvent.stopImmediatePropagation).not.toHaveBeenCalled(); }); + it('ignores WASM images with invalid array elements (null, undefined, missing code_file)', () => { + mockEvent.data = { + _sentryMessage: true, + _sentryWasmImages: [ + null, + undefined, + { type: 'wasm' }, + { code_file: 123 }, + ], + }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).not.toHaveBeenCalled(); + }); + it('gives main thread precedence over worker for conflicting module metadata', () => { (helpers.WINDOW as any)._sentryModuleMetadata = { 'Error\n at shared-file.js:1:1': { '_sentryBundlerPluginAppKey:main-app': true, source: 'main' }, From 4796ccb3f11f8bb65e6c023383df1b104b09ef5e Mon Sep 17 00:00:00 2001 From: Harshit Singh Date: Fri, 16 Jan 2026 16:22:46 +0530 Subject: [PATCH 4/4] fix(wasm): address cursor comments --- packages/wasm/src/index.ts | 20 ++++++++++++++++---- packages/wasm/test/webworker.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts index 3ea89dad3d09..dc29e3950ba7 100644 --- a/packages/wasm/src/index.ts +++ b/packages/wasm/src/index.ts @@ -44,11 +44,14 @@ const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => { processEvent(event: Event): Event { let hasAtLeastOneWasmFrameWithImage = false; + const existingImagesCount = event.debug_meta?.images?.length || 0; + if (event.exception?.values) { event.exception.values.forEach(exception => { if (exception.stacktrace?.frames) { hasAtLeastOneWasmFrameWithImage = - hasAtLeastOneWasmFrameWithImage || patchFrames(exception.stacktrace.frames, options.applicationKey); + hasAtLeastOneWasmFrameWithImage || + patchFrames(exception.stacktrace.frames, options.applicationKey, existingImagesCount); } }); } @@ -73,9 +76,18 @@ const PARSER_REGEX = /^(.*?):wasm-function\[\d+\]:(0x[a-fA-F0-9]+)$/; * Patches a list of stackframes with wasm data needed for server-side symbolication * if applicable. Returns true if the provided list of stack frames had at least one * matching registered image. + * + * @param frames - Stack frames to patch + * @param applicationKey - Optional key for third-party error filtering + * @param existingImagesOffset - Number of existing debug images that will be prepended + * to the final images array (used to calculate correct addr_mode indices) */ // Only exported for tests -export function patchFrames(frames: Array, applicationKey?: string): boolean { +export function patchFrames( + frames: Array, + applicationKey?: string, + existingImagesOffset: number = 0, +): boolean { let hasAtLeastOneWasmFrameWithImage = false; frames.forEach(frame => { if (!frame.filename) { @@ -112,11 +124,11 @@ export function patchFrames(frames: Array, applicationKey?: string): } if (index >= 0) { - frame.addr_mode = `rel:${index}`; + frame.addr_mode = `rel:${existingImagesOffset + index}`; hasAtLeastOneWasmFrameWithImage = true; } else if (workerImageIndex >= 0) { const mainThreadImagesCount = getImages().length; - frame.addr_mode = `rel:${mainThreadImagesCount + workerImageIndex}`; + frame.addr_mode = `rel:${existingImagesOffset + mainThreadImagesCount + workerImageIndex}`; hasAtLeastOneWasmFrameWithImage = true; } } diff --git a/packages/wasm/test/webworker.test.ts b/packages/wasm/test/webworker.test.ts index e1e2742fcbd0..afaa5d999966 100644 --- a/packages/wasm/test/webworker.test.ts +++ b/packages/wasm/test/webworker.test.ts @@ -119,4 +119,29 @@ describe('patchFrames() with worker images', () => { expect(frames[0]?.platform).toBe('native'); expect(frames[0]?.addr_mode).toBeUndefined(); }); + + it('should offset addr_mode indices when existingImagesOffset is provided', () => { + WINDOW._sentryWasmImages = [ + { + type: 'wasm', + code_id: 'abc123', + code_file: 'http://localhost:8001/worker.wasm', + debug_file: null, + debug_id: 'abc12300000000000000000000000000', + }, + ]; + + const frames: StackFrame[] = [ + { + filename: 'http://localhost:8001/worker.wasm:wasm-function[10]:0x1234', + function: 'worker_function', + in_app: true, + }, + ]; + + const result = patchFrames(frames, undefined, 3); + + expect(result).toBe(true); + expect(frames[0]?.addr_mode).toBe('rel:3'); + }); });