Skip to content
Draft
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as Sentry from '@sentry/browser';
import { registerWebWorkerWasm } from '@sentry/wasm';

window.Sentry = Sentry;

Sentry.init({
traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
});

// `registerWebWorkerWasm` installs the same patches a worker would, and reports
// every registered module to the scope it is given. Collecting them here is the
// only way to observe registration from the page, since main-thread images stay
// module-internal until a frame matches one.
window.registeredImages = [];
registerWebWorkerWasm({
self: {
postMessage: message => window.registeredImages.push(...(message._sentryWasmImages || [])),
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
window.loadWasmFromBuffer = async () => {
const response = await fetch('https://localhost:5887/simple.wasm');
const buffer = await response.arrayBuffer();

await WebAssembly.instantiate(new Uint8Array(buffer), {
env: {
external_func: () => {},
},
});

return window.registeredImages;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { Page, Route } from '@playwright/test';
import { expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
import { sentryTest } from '../../../utils/fixtures';
import { shouldSkipWASMTests } from '../../../utils/wasmHelpers';

function serveWasmFixture(page: Page): Promise<void> {
return page.route('**/simple.wasm', (route: Route) => {
const wasmModule = fs.readFileSync(path.resolve(__dirname, '..', 'simple.wasm'));

return route.fulfill({
status: 200,
body: wasmModule,
headers: {
'Content-Type': 'application/wasm',
},
});
});
}

sentryTest(
'registers a module loaded via fetch, arrayBuffer and instantiate under its response url',
async ({ getLocalTestUrl, page, browserName }) => {
if (shouldSkipWASMTests(browserName)) {
sentryTest.skip();
}

const url = await getLocalTestUrl({ testDir: __dirname });
await serveWasmFixture(page);
await page.goto(url);

const images = await page.evaluate(async () => {
// @ts-expect-error this function exists
return window.loadWasmFromBuffer();
});

expect(images).toEqual([
{
type: 'wasm',
code_file: 'https://localhost:5887/simple.wasm',
code_id: '0ba020cdd2444f7eafdd25999a8e9010',
debug_file: null,
debug_id: '0ba020cdd2444f7eafdd25999a8e90100',
},
]);
},
);
47 changes: 40 additions & 7 deletions packages/wasm/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { DebugImage, Event, IntegrationFn, StackFrame } from '@sentry/core';
import type { Event, IntegrationFn, StackFrame } from '@sentry/core';
import { defineIntegration, GLOBAL_OBJ } from '@sentry/core';
import { uniqueImageForSyntheticFilename } from './matchSyntheticWasmFilename';
import { patchWebAssembly } from './patchWebAssembly';
import { getImage, getImages, registerModule } from './registry';
import { getImage, getImages, registerModule, toProtocolDebugImage, type RegisteredWasmImage } from './registry';

const INTEGRATION_NAME = 'Wasm';

Expand Down Expand Up @@ -32,7 +33,7 @@ interface WasmIntegrationOptions {

// Access WINDOW with proper typing for _sentryWasmImages
const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
_sentryWasmImages?: Array<DebugImage>;
_sentryWasmImages?: Array<RegisteredWasmImage>;
};

const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => {
Expand All @@ -58,8 +59,8 @@ const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => {

if (hasAtLeastOneWasmFrameWithImage) {
event.debug_meta = event.debug_meta || {};
const mainThreadImages = getImages();
const workerImages = WINDOW._sentryWasmImages || [];
const mainThreadImages = getImages().map(toProtocolDebugImage);
const workerImages = (WINDOW._sentryWasmImages || []).map(toProtocolDebugImage);
event.debug_meta.images = [...(event.debug_meta.images || []), ...mainThreadImages, ...workerImages];
}

Expand Down Expand Up @@ -109,9 +110,11 @@ export function patchFrames(
match = frame.filename.match(PARSER_REGEX) as null | [string, string, string];
}

// `<url>:wasm-function[N]:0xADDR` — address is still in filename (JS parser did not split it).
// `<url>` is usually the fetch URL (`http://…/app.wasm`); Chrome may instead use `wasm://wasm/<file>-<hash>`.
if (match) {
const index = getImage(match[1]);
const workerImageIndex = getWorkerImage(match[1]);
let index = getImage(match[1]);
let workerImageIndex = getWorkerImage(match[1]);
frame.instruction_addr = match[2];
frame.filename = match[1];
frame.platform = 'native';
Expand All @@ -123,6 +126,19 @@ export function patchFrames(
};
}

// Exact `code_file` miss: `match[1]` is `wasm://wasm/…`, not the registered http URL.
if (index < 0 && workerImageIndex < 0) {
const unique = uniqueImageForSyntheticFilename(match[1], getImages(), WINDOW._sentryWasmImages || []);
if (unique) {
frame.filename = unique.codeFile;
if (unique.worker) {
workerImageIndex = unique.index;
} else {
index = unique.index;
}
}
}

if (index >= 0) {
frame.addr_mode = `rel:${existingImagesOffset + index}`;
hasAtLeastOneWasmFrameWithImage = true;
Expand All @@ -131,6 +147,23 @@ export function patchFrames(
frame.addr_mode = `rel:${existingImagesOffset + mainThreadImagesCount + workerImageIndex}`;
hasAtLeastOneWasmFrameWithImage = true;
}
} else {
// Bare `wasm://wasm/<file>-<hash>` — JS parser already set `instruction_addr`.
const unique = uniqueImageForSyntheticFilename(frame.filename, getImages(), WINDOW._sentryWasmImages || []);
if (unique && frame.instruction_addr) {
frame.filename = unique.codeFile;
frame.platform = 'native';
if (applicationKey) {
frame.module_metadata = {
...frame.module_metadata,
[`${BUNDLER_PLUGIN_APP_KEY_PREFIX}${applicationKey}`]: true,
};
}
frame.addr_mode = unique.worker
? `rel:${existingImagesOffset + getImages().length + unique.index}`
: `rel:${existingImagesOffset + unique.index}`;
hasAtLeastOneWasmFrameWithImage = true;
}
}
});

Expand Down
125 changes: 125 additions & 0 deletions packages/wasm/src/matchSyntheticWasmFilename.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import type { DebugImage } from '@sentry/core';
import type { RegisteredWasmImage } from './registry';

/**
* Maps Chrome `wasm://wasm/<name>-<hash>` frames to a registered `code_file`.
*
* Prefer the wasm `name` section (`moduleName`). If that section is missing or
* does not match the stack label, guess from the fetch URL basename (including
* wasm-bindgen `_bg.wasm` → `.wasm`). Hits are accepted only when every
* candidate shares one `debug_id`.
*
* Hash-only `wasm://wasm/<id>` is not mapped (see #23781).
*
* Fetch-URL frames (`http://…/file.wasm:wasm-function[…]`) still use exact
* `code_file` lookup in `patchFrames`, not this matcher.
*/

export type SyntheticWasmImageHit = {
index: number;
worker: boolean;
codeFile: string;
};

type Hit = SyntheticWasmImageHit & { debugId: string };

/** Last path segment of a registered wasm URL (`http://…/demo_bg.wasm` → `demo_bg.wasm`). */
export function fileBasename(url: string): string | undefined {
try {
return new URL(url).pathname.split('/').pop() || undefined;
} catch {
return url.split('/').pop();
}
}

/**
* Chrome's module label without the `wasm://wasm/` prefix or trailing isolate hash.
* `wasm://wasm/demo.wasm-000197f6` → `demo.wasm`. Hash-only `wasm://wasm/0bee4c4e` → `0bee4c4e`.
*/
export function syntheticModuleName(filename: string): string | undefined {
const body = filename.match(/^wasm:\/\/wasm\/(.+)$/i)?.[1];
if (!body) {
return undefined;
}
return body.replace(/-[0-9a-fA-F]{6,16}$/, '');
}

/**
* Fetch filename plus known packaging aliases.
*
* wasm-bindgen writes `foo_bg.wasm` next to `foo.js` but the stack label is often
* `foo.wasm`. Used when the name section is missing or does not match.
*/
export function namesForRegisteredWasm(codeFile: string): string[] {
const basename = fileBasename(codeFile);
if (!basename) {
return [];
}

const names = [basename];
const withoutBindgenBg = basename.replace(/_bg\.wasm$/i, '.wasm');
if (withoutBindgenBg !== basename) {
names.push(withoutBindgenBg);
}
return names;
}

export function registeredWasmMatchesSyntheticName(codeFile: string, syntheticName: string): boolean {
return namesForRegisteredWasm(codeFile).includes(syntheticName);
}

function wasmNameSectionName(image: DebugImage): string | undefined {
const moduleName = (image as RegisteredWasmImage).moduleName;
return typeof moduleName === 'string' && moduleName.length > 0 ? moduleName : undefined;
}

export function imageMatchesSyntheticName(image: DebugImage, syntheticName: string): boolean {
if (wasmNameSectionName(image) === syntheticName) {
return true;
}
return typeof image.code_file === 'string' && registeredWasmMatchesSyntheticName(image.code_file, syntheticName);
}

/**
* Multiple URLs may register the same binary. Only use a hit when every candidate
* shares one `debug_id`. Different binaries with the same name stay unmatched.
*/
export function uniqueHitByDebugId<T extends { debugId: string }>(hits: T[]): T | undefined {
const debugIds = new Set(hits.map(hit => hit.debugId));
return debugIds.size === 1 ? hits[0] : undefined;
}

/**
* Chrome's isolate hash is not a debug_id and is not on `WebAssembly.Module`.
* `wasm://wasm/<hex>` with no module name must not pick an image (see #23781).
*/
function isHashOnlySyntheticName(name: string): boolean {
return /^[0-9a-fA-F]{6,16}$/.test(name);
}

export function uniqueImageForSyntheticFilename(
filename: string,
pageImages: ReadonlyArray<DebugImage>,
workerImages: ReadonlyArray<DebugImage>,
): SyntheticWasmImageHit | undefined {
const name = syntheticModuleName(filename);
if (!name || isHashOnlySyntheticName(name)) {
return undefined;
}

const hits: Hit[] = [];
const consider = (images: ReadonlyArray<DebugImage>, worker: boolean): void => {
images.forEach((image, index) => {
if (image.type === 'wasm' && typeof image.code_file === 'string' && imageMatchesSyntheticName(image, name)) {
hits.push({ index, worker, codeFile: image.code_file, debugId: image.debug_id });
}
});
};
consider(pageImages, false);
consider(workerImages, true);
const hit = uniqueHitByDebugId(hits);
if (!hit) {
return undefined;
}
return { index: hit.index, worker: hit.worker, codeFile: hit.codeFile };
}
100 changes: 100 additions & 0 deletions packages/wasm/src/patchWasmResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { fill } from '@sentry/core';

/**
* Streaming wasm registration (`instantiateStreaming` / `compileStreaming`) reads the module URL
* from `Response.url`. Non-streaming paths (`WebAssembly.instantiate` / `compile` with bytes) only
* receive a buffer, no URL, so registration would otherwise be skipped.
*
* This module patches `Response.prototype.arrayBuffer` and `bytes` so that when wasm is fetched
* and then loaded from bytes, we can map the resulting `ArrayBuffer` back to the fetch URL via
* `getWasmSourceUrl()` and register the module in `patchNonStreamingWebAssembly`.
*/
const wasmSourceUrls = new WeakMap<ArrayBuffer, string>();

let responseReadersPatched = false;

/**
* Resolves a wasm source buffer back to its fetch URL, when known.
*/
export function getWasmSourceUrl(source: unknown): string | undefined {
const buffer = toArrayBuffer(source);
if (!buffer) {
return undefined;
}

return wasmSourceUrls.get(buffer);
}

function toArrayBuffer(source: unknown): ArrayBuffer | undefined {
if (source instanceof ArrayBuffer) {
return source;
}

if (ArrayBuffer.isView(source)) {
const { buffer } = source;
return buffer instanceof ArrayBuffer ? buffer : undefined;
}

return undefined;
}

function looksLikeWasmResponse(response: Response): boolean {
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/wasm')) {
return true;
}

const { url } = response;
return Boolean(url && /\.wasm(?:\?|#|$)/i.test(url));
}

/**
* Runs inside the caller's `arrayBuffer()` / `bytes()` promise chain, so it must never throw:
* a failure here would reject a body read that has nothing to do with wasm.
*/
function tagResponseSource(response: Response, source: unknown): void {
try {
const buffer = toArrayBuffer(source);
if (buffer && response.url && looksLikeWasmResponse(response)) {
wasmSourceUrls.set(buffer, response.url);
}
} catch {
// see above
}
}

/**
* Patches Response body readers so wasm bytes remember their fetch URL.
*/
export function patchWasmResponseBodyReaders(): void {
if (responseReadersPatched || typeof Response === 'undefined') {
return;
}

responseReadersPatched = true;

fill(Response.prototype, 'arrayBuffer', (original: (this: Response) => Promise<ArrayBuffer>) => {
return function arrayBuffer(this: Response): Promise<ArrayBuffer> {
const bufferPromise: Promise<ArrayBuffer> = original.call(this);
return bufferPromise.then(buffer => {
tagResponseSource(this, buffer);
return buffer;
});
};
});

fill(Response.prototype, 'bytes', (original: (this: Response) => Promise<Uint8Array>) => {
return function bytes(this: Response): Promise<Uint8Array> {
const bytesPromise: Promise<Uint8Array> = original.call(this);
return bytesPromise.then(bytes => {
tagResponseSource(this, bytes);
return bytes;
});
};
});
}

/** @internal */
export function _resetResponsePatchForTests(): void {
responseReadersPatched = false;
}
Loading
Loading