From 2048314c4a746618661208a9ad0a94b2808b416e Mon Sep 17 00:00:00 2001 From: Imane Largou Date: Mon, 27 Jul 2026 14:10:17 +0200 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20[RUM-WASM]=20Capture=20wasm=20m?= =?UTF-8?q?odule=20build=5Fids=20and=20enrich=20error=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intercepts WebAssembly.instantiate / instantiateStreaming at SDK script-load time to record (url, build_id) per loaded module, including lazily-loaded modules. On error capture, attaches error.wasm_modules[] and sets source_type='browser+wasm' so the backend can dispatch wasm symbolication. - New wasmModules/wasmModuleTracking.ts: hooks all four WebAssembly entry points; reads build_id via a minimal custom-section parser; registry stays live for lazy module loads - New wasmModules/wasmBinaryParser.ts: walks wasm binary sections, extracts build_id custom section or falls back to external_debug_info - errorCollection: populates error.wasm_modules[] and flips source_type to 'browser+wasm' when any module is registered - rawRumEvent.types: adds wasm_modules?: Array<{url, build_id}> to RawRumErrorEvent - main.ts: installs tracking synchronously at script-load time to close the race window before DD_RUM.init()'s deferred microtask Co-Authored-By: Claude Sonnet 4.6 --- .../src/tools/stackTrace/computeStackTrace.ts | 6 +- .../browser-rum-core/src/boot/startRum.ts | 8 + .../src/domain/error/errorCollection.ts | 5 +- .../domain/wasmModules/wasmBinaryParser.ts | 95 +++++++++++ .../domain/wasmModules/wasmModuleTracking.ts | 148 ++++++++++++++++++ packages/browser-rum-core/src/index.ts | 2 + .../browser-rum-core/src/rawRumEvent.types.ts | 6 +- packages/browser-rum/src/entries/main.ts | 9 +- 8 files changed, 275 insertions(+), 4 deletions(-) create mode 100644 packages/browser-rum-core/src/domain/wasmModules/wasmBinaryParser.ts create mode 100644 packages/browser-rum-core/src/domain/wasmModules/wasmModuleTracking.ts diff --git a/packages/browser-core/src/tools/stackTrace/computeStackTrace.ts b/packages/browser-core/src/tools/stackTrace/computeStackTrace.ts index 50885fc6ad..10d76fa772 100644 --- a/packages/browser-core/src/tools/stackTrace/computeStackTrace.ts +++ b/packages/browser-core/src/tools/stackTrace/computeStackTrace.ts @@ -39,7 +39,10 @@ export function computeStackTrace(ex: unknown): StackTrace { if (stackProperty) { stackProperty.split('\n').forEach((line) => { const stackFrame = - parseChromeLine(line) || parseChromeAnonymousLine(line) || parseWinLine(line) || parseGeckoLine(line) + parseChromeLine(line) || + parseChromeAnonymousLine(line) || + parseWinLine(line) || + parseGeckoLine(line) if (stackFrame) { if (!stackFrame.func && stackFrame.line) { stackFrame.func = UNKNOWN_FUNCTION @@ -135,6 +138,7 @@ function parseChromeAnonymousLine(line: string): StackFrame | undefined { } } + const WINJS_LINE_RE = /^\s*at (?:((?:\[object object\])?.+) )?\(?((?:file|ms-appx|https?|webpack|blob):.*?):(\d+)(?::(\d+))?\)?\s*$/i diff --git a/packages/browser-rum-core/src/boot/startRum.ts b/packages/browser-rum-core/src/boot/startRum.ts index 34ddb94cfe..987fa11240 100644 --- a/packages/browser-rum-core/src/boot/startRum.ts +++ b/packages/browser-rum-core/src/boot/startRum.ts @@ -26,6 +26,7 @@ import { startViewHistory } from '../domain/contexts/viewHistory' import { startRequestCollection } from '../domain/requestCollection' import { startActionCollection } from '../domain/action/actionCollection' import { startErrorCollection } from '../domain/error/errorCollection' +import { startWasmModuleTracking } from '../domain/wasmModules/wasmModuleTracking' import { startResourceCollection } from '../domain/resource/resourceCollection' import { startViewCollection } from '../domain/view/viewCollection' import { startRumBatch } from '../transport/startRumBatch' @@ -223,6 +224,13 @@ export function startRumEventCollection( const { stop: stopLongTaskCollection } = startLongTaskCollection(lifeCycle, configuration) cleanupTasks.push(stopLongTaskCollection) + // Intercept WebAssembly module loads to capture build_id. errorCollection + // reads it to set source_type='browser+wasm' and error.build_id. + // Must start before any wasm load — RUM is initialised before the page's + // wasm fetch in typical setups. + const stopWasmModuleTracking = startWasmModuleTracking() + cleanupTasks.push(stopWasmModuleTracking) + const { addError } = startErrorCollection(lifeCycle, configuration, bufferedDataObservable) startRequestCollection(lifeCycle, configuration, sessionManager, userContext, accountContext, bufferedDataObservable) diff --git a/packages/browser-rum-core/src/domain/error/errorCollection.ts b/packages/browser-rum-core/src/domain/error/errorCollection.ts index 822d8aa3bc..9af7fc3a3c 100644 --- a/packages/browser-rum-core/src/domain/error/errorCollection.ts +++ b/packages/browser-rum-core/src/domain/error/errorCollection.ts @@ -16,6 +16,7 @@ import { RumEventType } from '../../rawRumEvent.types' import type { LifeCycle, RawRumEventCollectedData } from '../lifeCycle' import { LifeCycleEventType } from '../lifeCycle' import type { RumErrorEventDomainContext } from '../../domainContext.types' +import { getLoadedWasmModules, hasLoadedWasmModules } from '../wasmModules/wasmModuleTracking' import { trackReportError } from './trackReportError' export interface ProvidedError { @@ -72,6 +73,7 @@ export function doStartErrorCollection(lifeCycle: LifeCycle) { } function processError(error: RawError): RawRumEventCollectedData { + const wasmActive = hasLoadedWasmModules() const rawRumEvent: RawRumErrorEvent = { date: error.startClocks.timeStamp, error: { @@ -84,9 +86,10 @@ function processError(error: RawError): RawRumEventCollectedData 28) { + // Bail on absurdly large LEB128 — shouldn't happen for valid wasm section sizes. + return { value: 0, nextOffset: bytes.length } + } + } + return { value: 0, nextOffset: bytes.length } +} + +function toHex(bytes: Uint8Array): string { + let result = '' + for (let i = 0; i < bytes.length; i++) { + result += bytes[i].toString(16).padStart(2, '0') + } + return result +} + +const CUSTOM_SECTION_ID = 0 +const WASM_MAGIC = [0x00, 0x61, 0x73, 0x6d] + +export function extractWasmBuildId(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + if (bytes.length < 8) { + return '' + } + for (let i = 0; i < WASM_MAGIC.length; i++) { + if (bytes[i] !== WASM_MAGIC[i]) { + return '' + } + } + + let offset = 8 // skip magic (4) + version (4) + let externalDebugInfoPayload: Uint8Array | null = null + const decoder = new TextDecoder('utf-8') + + while (offset < bytes.length) { + const sectionId = bytes[offset++] + const { value: sectionSize, nextOffset: afterSize } = readLEB128Unsigned(bytes, offset) + offset = afterSize + const sectionEnd = offset + sectionSize + + if (sectionId === CUSTOM_SECTION_ID) { + const { value: nameLen, nextOffset: afterNameLen } = readLEB128Unsigned(bytes, offset) + const name = decoder.decode(bytes.subarray(afterNameLen, afterNameLen + nameLen)) + const payload = bytes.subarray(afterNameLen + nameLen, sectionEnd) + + if (name === 'build_id') { + return toHex(payload) + } + if (name === 'external_debug_info') { + // Defer — only use if no standalone build_id is found later. + externalDebugInfoPayload = payload + } + } + + offset = sectionEnd + if (offset > bytes.length) { + break + } + } + + if (externalDebugInfoPayload && externalDebugInfoPayload.length > 0) { + // The trailing portion is the build ID. Default to last 16 bytes; if the + // payload is shorter, take the whole thing. + const idLen = Math.min(16, externalDebugInfoPayload.length) + return toHex(externalDebugInfoPayload.subarray(externalDebugInfoPayload.length - idLen)) + } + + return '' +} diff --git a/packages/browser-rum-core/src/domain/wasmModules/wasmModuleTracking.ts b/packages/browser-rum-core/src/domain/wasmModules/wasmModuleTracking.ts new file mode 100644 index 0000000000..404ec1d239 --- /dev/null +++ b/packages/browser-rum-core/src/domain/wasmModules/wasmModuleTracking.ts @@ -0,0 +1,148 @@ +// Intercepts WebAssembly module-creation entry points to record (url, build_id) +// per loaded module. errorCollection reads getLoadedWasmModules() to set +// source_type='browser+wasm' and error.wasm_modules on error events. +// Modules loaded lazily after the initial page load are captured automatically +// — the hooks stay active for the lifetime of the page. + +import { extractWasmBuildId } from './wasmBinaryParser' + +export interface WasmModuleEntry { + url: string + build_id: string +} + +const registry: Map = new Map() +let installed = false + +export function getLoadedWasmModules(): WasmModuleEntry[] { + return Array.from(registry.values()) +} + +export function hasLoadedWasmModules(): boolean { + return registry.size > 0 +} + +function recordModule(url: string, buffer: ArrayBuffer): void { + if (registry.has(url)) { + return + } + let buildId = '' + try { + buildId = extractWasmBuildId(buffer) + } catch { + // Parser must never throw — debug info absence is normal. + } + registry.set(url, { url, build_id: buildId }) +} + +// Extracts build_id from a Response without consuming it for the caller. +// Returns the original response so the actual instantiation can proceed +// without delay; build_id extraction races in parallel. +function captureFromResponseAsync(response: Response): Response { + const url = response.url || '' + if (!registry.has(url)) { + response + .clone() + .arrayBuffer() + .then((buf) => recordModule(url, buf)) + .catch((err) => { + // eslint-disable-next-line no-console + console.error('[wasm-tracking] capture failed:', err) + }) + } + return response +} + +export function startWasmModuleTracking(): () => void { + if (installed || typeof WebAssembly === 'undefined') { + return () => {} + } + installed = true + + const origInstantiate = WebAssembly.instantiate + const origCompile = WebAssembly.compile + const origInstantiateStreaming = (WebAssembly as any).instantiateStreaming + const origCompileStreaming = (WebAssembly as any).compileStreaming + + // Hook 1: instantiate(bytes | module, imports). For raw bytes, we can read + // build_id directly; for an already-compiled WebAssembly.Module we have no + // URL or bytes to inspect — register a placeholder. + WebAssembly.instantiate = function (this: typeof WebAssembly, source: any, importObject?: any) { + try { + if (source instanceof ArrayBuffer) { + recordModule('', source) + } else if (ArrayBuffer.isView(source)) { + recordModule('', (source as ArrayBufferView).buffer as ArrayBuffer) + } else if (source instanceof WebAssembly.Module) { + if (!registry.has('')) { + registry.set('', { url: '', build_id: '' }) + } + } + } catch { + // never let the hook break the host application + } + return origInstantiate.call(this, source, importObject) + } as typeof WebAssembly.instantiate + + WebAssembly.compile = function (this: typeof WebAssembly, bytes: any) { + try { + if (bytes instanceof ArrayBuffer) { + recordModule('', bytes) + } else if (ArrayBuffer.isView(bytes)) { + recordModule('', (bytes as ArrayBufferView).buffer as ArrayBuffer) + } + } catch { + // intentionally ignored + } + return origCompile.call(this, bytes) + } as typeof WebAssembly.compile + + if (origInstantiateStreaming) { + ;(WebAssembly as any).instantiateStreaming = function (this: typeof WebAssembly, source: any, importObject?: any) { + return Promise.resolve(source) + .then((response: Response) => { + try { + captureFromResponseAsync(response) + } catch { + // never block instantiation on capture failure + } + return origInstantiateStreaming.call(this, response, importObject) + }) + } + } else { + // eslint-disable-next-line no-console + console.warn('[wasm-tracking] WebAssembly.instantiateStreaming not present, skipping') + } + + if (origCompileStreaming) { + ;(WebAssembly as any).compileStreaming = function (this: typeof WebAssembly, source: any) { + return Promise.resolve(source).then((response: Response) => { + try { + captureFromResponseAsync(response) + } catch { + // never block compilation on capture failure + } + return origCompileStreaming.call(this, response) + }) + } + } + + return function stopWasmModuleTracking() { + WebAssembly.instantiate = origInstantiate + WebAssembly.compile = origCompile + if (origInstantiateStreaming) { + ;(WebAssembly as any).instantiateStreaming = origInstantiateStreaming + } + if (origCompileStreaming) { + ;(WebAssembly as any).compileStreaming = origCompileStreaming + } + registry.clear() + installed = false + } +} + +// Test-only helper to reset registry state between test cases. +export function resetWasmModuleRegistryForTesting(): void { + registry.clear() + installed = false +} diff --git a/packages/browser-rum-core/src/index.ts b/packages/browser-rum-core/src/index.ts index 8a69b1e665..d279e29a6f 100644 --- a/packages/browser-rum-core/src/index.ts +++ b/packages/browser-rum-core/src/index.ts @@ -78,3 +78,5 @@ export { createHooks } from './domain/hooks' export type { TracingOption, PropagatorType } from './domain/tracing/tracer.types' export type { TransportPayload } from './transport/formDataTransport' export { createFormDataTransport } from './transport/formDataTransport' + +export { startWasmModuleTracking } from './domain/wasmModules/wasmModuleTracking' diff --git a/packages/browser-rum-core/src/rawRumEvent.types.ts b/packages/browser-rum-core/src/rawRumEvent.types.ts index 00db4933f3..7552583bf4 100644 --- a/packages/browser-rum-core/src/rawRumEvent.types.ts +++ b/packages/browser-rum-core/src/rawRumEvent.types.ts @@ -117,7 +117,11 @@ export interface RawRumErrorEvent { message: string handling?: ErrorHandling causes?: RawErrorCause[] - source_type: 'browser' + source_type: 'browser' | 'browser+wasm' + wasm_modules?: Array<{ + url: string + build_id: string + }> csp?: Csp } view?: { diff --git a/packages/browser-rum/src/entries/main.ts b/packages/browser-rum/src/entries/main.ts index 038e1fa910..ca4498e02d 100644 --- a/packages/browser-rum/src/entries/main.ts +++ b/packages/browser-rum/src/entries/main.ts @@ -9,11 +9,18 @@ // Keep the following in sync with packages/browser-rum-slim/src/entries/main.ts import { defineGlobal, globalObject } from '@datadog/browser-core' import type { RumPublicApi } from '@datadog/browser-rum-core' -import { makeRumPublicApi } from '@datadog/browser-rum-core' +import { makeRumPublicApi, startWasmModuleTracking } from '@datadog/browser-rum-core' import { makeRecorderApi } from '../boot/recorderApi' import { createDeflateEncoder, startDeflateWorker } from '../domain/deflate' import { makeProfilerApi } from '../boot/profilerApi' +// Install WebAssembly hooks synchronously at SDK script-load time. DD_RUM.init() +// defers its startRum work to a microtask, which races against eagerly-loaded +// wasm modules — by the time the deferred wrap installs, instantiateStreaming +// may have already been called. Installing here (before any deferral) closes +// that race. +startWasmModuleTracking() + export type { User, Account, From 193a2da22998391caeb8889841399296e4543142 Mon Sep 17 00:00:00 2001 From: Imane Largou Date: Tue, 11 Aug 2026 11:54:39 +0200 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=94=A7=20[RUM-WASM]=20Remove=20stale?= =?UTF-8?q?=20fallback=20comment=20from=20wasmBinaryParser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../domain/wasmModules/wasmBinaryParser.ts | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 packages/browser-core/src/domain/wasmModules/wasmBinaryParser.ts diff --git a/packages/browser-core/src/domain/wasmModules/wasmBinaryParser.ts b/packages/browser-core/src/domain/wasmModules/wasmBinaryParser.ts new file mode 100644 index 0000000000..4cd78c71fa --- /dev/null +++ b/packages/browser-core/src/domain/wasmModules/wasmBinaryParser.ts @@ -0,0 +1,93 @@ +// Minimal wasm binary parser to extract `build_id` from a wasm module's +// custom sections. +// +// Tries (in priority order): +// 1. `build_id` custom section (Emscripten's convention with `-gseparate-dwarf`): +// payload bytes are the build ID directly. +// 2. `external_debug_info` custom section (the link from a stripped artefact +// to its companion debug file): the build ID is typically encoded at the +// end of the payload; we take the trailing 16 bytes as a pragmatic default. +// +// Returns an empty string if neither section is present (e.g. Rust wasm-bindgen +// output, or Emscripten without `-gseparate-dwarf`). + +function readLEB128Unsigned(bytes: Uint8Array, offset: number): { value: number; nextOffset: number } { + let value = 0 + let shift = 0 + let cursor = offset + while (cursor < bytes.length) { + const byte = bytes[cursor++] + value |= (byte & 0x7f) << shift + if ((byte & 0x80) === 0) { + return { value, nextOffset: cursor } + } + shift += 7 + if (shift > 28) { + // Bail on absurdly large LEB128 — shouldn't happen for valid wasm section sizes. + return { value: 0, nextOffset: bytes.length } + } + } + return { value: 0, nextOffset: bytes.length } +} + +function toHex(bytes: Uint8Array): string { + let result = '' + for (let i = 0; i < bytes.length; i++) { + result += bytes[i].toString(16).padStart(2, '0') + } + return result +} + +const CUSTOM_SECTION_ID = 0 +const WASM_MAGIC = [0x00, 0x61, 0x73, 0x6d] + +export function extractWasmBuildId(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + if (bytes.length < 8) { + return '' + } + for (let i = 0; i < WASM_MAGIC.length; i++) { + if (bytes[i] !== WASM_MAGIC[i]) { + return '' + } + } + + let offset = 8 // skip magic (4) + version (4) + let externalDebugInfoPayload: Uint8Array | null = null + const decoder = new TextDecoder('utf-8') + + while (offset < bytes.length) { + const sectionId = bytes[offset++] + const { value: sectionSize, nextOffset: afterSize } = readLEB128Unsigned(bytes, offset) + offset = afterSize + const sectionEnd = offset + sectionSize + + if (sectionId === CUSTOM_SECTION_ID) { + const { value: nameLen, nextOffset: afterNameLen } = readLEB128Unsigned(bytes, offset) + const name = decoder.decode(bytes.subarray(afterNameLen, afterNameLen + nameLen)) + const payload = bytes.subarray(afterNameLen + nameLen, sectionEnd) + + if (name === 'build_id') { + return toHex(payload) + } + if (name === 'external_debug_info') { + // Defer — only use if no standalone build_id is found later. + externalDebugInfoPayload = payload + } + } + + offset = sectionEnd + if (offset > bytes.length) { + break + } + } + + if (externalDebugInfoPayload && externalDebugInfoPayload.length > 0) { + // The trailing portion is the build ID. Default to last 16 bytes; if the + // payload is shorter, take the whole thing. + const idLen = Math.min(16, externalDebugInfoPayload.length) + return toHex(externalDebugInfoPayload.subarray(externalDebugInfoPayload.length - idLen)) + } + + return '' +} From 0070e34d8176dab1c7ee6eb0f0dd6b5211306b9f Mon Sep 17 00:00:00 2001 From: Imane Largou Date: Tue, 11 Aug 2026 17:57:01 +0200 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8=20[RUM-WASM]=20Report=20WASM=20er?= =?UTF-8?q?rors=20from=20Browser=20Logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wasmModules/wasmBinaryParser.spec.ts | 41 ++++++ .../domain/wasmModules/wasmBinaryParser.ts | 6 +- .../wasmModules/wasmModuleTracking.spec.ts | 77 +++++++++++ .../domain/wasmModules/wasmModuleTracking.ts | 121 +++++++++++------- packages/browser-core/src/index.ts | 2 + packages/browser-logs/src/boot/startLogs.ts | 3 + .../createErrorFieldFromRawError.spec.ts | 17 +++ .../domain/createErrorFieldFromRawError.ts | 5 + .../runtimeErrorCollection.spec.ts | 25 ++++ packages/browser-logs/src/entries/main.ts | 5 +- packages/browser-logs/src/logsEvent.types.ts | 11 ++ .../browser-logs/src/rawLogsEvent.types.ts | 4 +- .../browser-rum-core/src/boot/startRum.ts | 2 +- .../src/domain/error/errorCollection.ts | 9 +- .../domain/wasmModules/wasmBinaryParser.ts | 95 -------------- packages/browser-rum-core/src/index.ts | 2 - .../browser-rum-core/src/rawRumEvent.types.ts | 6 +- packages/browser-rum-slim/src/entries/main.ts | 5 +- packages/browser-rum/src/entries/main.ts | 4 +- 19 files changed, 283 insertions(+), 157 deletions(-) create mode 100644 packages/browser-core/src/domain/wasmModules/wasmBinaryParser.spec.ts create mode 100644 packages/browser-core/src/domain/wasmModules/wasmModuleTracking.spec.ts rename packages/{browser-rum-core => browser-core}/src/domain/wasmModules/wasmModuleTracking.ts (54%) delete mode 100644 packages/browser-rum-core/src/domain/wasmModules/wasmBinaryParser.ts diff --git a/packages/browser-core/src/domain/wasmModules/wasmBinaryParser.spec.ts b/packages/browser-core/src/domain/wasmModules/wasmBinaryParser.spec.ts new file mode 100644 index 0000000000..d0dcd02977 --- /dev/null +++ b/packages/browser-core/src/domain/wasmModules/wasmBinaryParser.spec.ts @@ -0,0 +1,41 @@ +import { extractWasmBuildId } from './wasmBinaryParser' + +const WASM_HEADER = [0, 97, 115, 109, 1, 0, 0, 0] + +function customSection(name: string, payload: number[]): number[] { + const encodedName = Array.from(new TextEncoder().encode(name)) + const contents = [encodedName.length, ...encodedName, ...payload] + return [0, contents.length, ...contents] +} + +function wasmWithSections(...sections: number[][]): ArrayBuffer { + return new Uint8Array([...WASM_HEADER, ...sections.flat()]).buffer +} + +describe('extractWasmBuildId', () => { + it('extracts and hex-encodes the build_id custom section', () => { + expect(extractWasmBuildId(wasmWithSections(customSection('build_id', [0, 1, 0xab, 0xff])))).toBe('0001abff') + }) + + it('prefers build_id over external_debug_info regardless of section order', () => { + const wasm = wasmWithSections( + customSection('external_debug_info', [1, 2, 3]), + customSection('build_id', [0xaa, 0xbb]) + ) + + expect(extractWasmBuildId(wasm)).toBe('aabb') + }) + + it('uses the trailing 16 bytes of external_debug_info as a fallback', () => { + const payload = Array.from({ length: 20 }, (_, index) => index) + + expect(extractWasmBuildId(wasmWithSections(customSection('external_debug_info', payload)))).toBe( + '0405060708090a0b0c0d0e0f10111213' + ) + }) + + it('returns an empty string for invalid or unannotated modules', () => { + expect(extractWasmBuildId(new Uint8Array([1, 2, 3]).buffer)).toBe('') + expect(extractWasmBuildId(wasmWithSections())).toBe('') + }) +}) diff --git a/packages/browser-core/src/domain/wasmModules/wasmBinaryParser.ts b/packages/browser-core/src/domain/wasmModules/wasmBinaryParser.ts index 4cd78c71fa..6fbc1acb4b 100644 --- a/packages/browser-core/src/domain/wasmModules/wasmBinaryParser.ts +++ b/packages/browser-core/src/domain/wasmModules/wasmBinaryParser.ts @@ -17,8 +17,8 @@ function readLEB128Unsigned(bytes: Uint8Array, offset: number): { value: number; let cursor = offset while (cursor < bytes.length) { const byte = bytes[cursor++] - value |= (byte & 0x7f) << shift - if ((byte & 0x80) === 0) { + value += (byte % 0x80) * 2 ** shift + if (byte < 0x80) { return { value, nextOffset: cursor } } shift += 7 @@ -41,7 +41,7 @@ function toHex(bytes: Uint8Array): string { const CUSTOM_SECTION_ID = 0 const WASM_MAGIC = [0x00, 0x61, 0x73, 0x6d] -export function extractWasmBuildId(buffer: ArrayBuffer): string { +export function extractWasmBuildId(buffer: ArrayBufferLike): string { const bytes = new Uint8Array(buffer) if (bytes.length < 8) { return '' diff --git a/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.spec.ts b/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.spec.ts new file mode 100644 index 0000000000..301246e5ca --- /dev/null +++ b/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.spec.ts @@ -0,0 +1,77 @@ +import type { RawError } from '../error/error.types' +import { + getLoadedWasmModules, + isWasmError, + resetWasmModuleRegistryForTesting, + startWasmModuleTracking, +} from './wasmModuleTracking' + +function makeError(stack?: string, causes?: RawError['causes']): Pick { + return { stack, causes } +} + +describe('isWasmError', () => { + ;[ + 'RuntimeError: unreachable\n at foo (https://example.com/app.wasm:wasm-function[42]:0x10)', + 'RuntimeError: unreachable\n at foo @ wasm://wasm/abc123:1:2', + 'RuntimeError: unreachable\n at foo @ [wasm code]', + 'RuntimeError: unreachable\n at namedFunction @ https://example.com/app.wasm', + ].forEach((stack) => { + it(`detects a WASM frame in ${stack}`, () => { + expect(isWasmError(makeError(stack))).toBe(true) + }) + }) + + it('detects a WASM frame in an error cause', () => { + expect( + isWasmError( + makeError('Error: wrapper\n at wrap @ https://example.com/app.js:1:1', [ + { + message: 'WASM cause', + source: 'source', + stack: 'RuntimeError: unreachable\n at wasm-function[3] @ [wasm code]', + }, + ]) + ) + ).toBe(true) + }) + + it('does not classify a regular runtime error as WASM', () => { + expect(isWasmError(makeError('RuntimeError: failure\n at foo @ https://example.com/app.js:1:1'))).toBe(false) + }) + + it('does not classify a JavaScript file containing .wasm in its name as WASM', () => { + expect(isWasmError(makeError('Error: failure\n at foo @ https://example.com/app.wasm.js:1:1'))).toBe(false) + }) +}) + +describe('startWasmModuleTracking', () => { + afterEach(() => { + resetWasmModuleRegistryForTesting() + }) + + it('records modules compiled from a view without including bytes outside of the view', async () => { + const wasmModule = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]) + const paddedBuffer = new Uint8Array(wasmModule.length + 2) + paddedBuffer.set(wasmModule, 1) + const moduleView = paddedBuffer.subarray(1, paddedBuffer.length - 1) + + startWasmModuleTracking() + await WebAssembly.compile(moduleView) + + expect(getLoadedWasmModules()).toEqual([{ url: '', build_id: '' }]) + }) + + it('keeps hooks installed until every tracking client stops', () => { + const originalCompile = WebAssembly.compile + const stopFirstClient = startWasmModuleTracking() + const trackedCompile = WebAssembly.compile + const stopSecondClient = startWasmModuleTracking() + + expect(trackedCompile).not.toBe(originalCompile) + stopFirstClient() + expect(WebAssembly.compile).toBe(trackedCompile) + stopSecondClient() + expect(WebAssembly.compile).toBe(originalCompile) + }) +}) diff --git a/packages/browser-rum-core/src/domain/wasmModules/wasmModuleTracking.ts b/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.ts similarity index 54% rename from packages/browser-rum-core/src/domain/wasmModules/wasmModuleTracking.ts rename to packages/browser-core/src/domain/wasmModules/wasmModuleTracking.ts index 404ec1d239..17db4a8637 100644 --- a/packages/browser-rum-core/src/domain/wasmModules/wasmModuleTracking.ts +++ b/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.ts @@ -1,28 +1,46 @@ -// Intercepts WebAssembly module-creation entry points to record (url, build_id) -// per loaded module. errorCollection reads getLoadedWasmModules() to set +// Intercepts WebAssembly module-creation entry points to record (url, build ID) +// per loaded module. Error collectors read getLoadedWasmModules() to set // source_type='browser+wasm' and error.wasm_modules on error events. // Modules loaded lazily after the initial page load are captured automatically // — the hooks stay active for the lifetime of the page. +import type { RawError } from '../error/error.types' import { extractWasmBuildId } from './wasmBinaryParser' -export interface WasmModuleEntry { +export interface RawWasmModule { url: string build_id: string } -const registry: Map = new Map() -let installed = false +interface WasmModuleEntry { + url: string + buildId: string +} + +const registry = new Map() +let stopTracking: (() => void) | undefined +let trackingClients = 0 -export function getLoadedWasmModules(): WasmModuleEntry[] { - return Array.from(registry.values()) +export function getLoadedWasmModules(): RawWasmModule[] { + return Array.from(registry.values(), ({ url, buildId }) => ({ url, build_id: buildId })) } -export function hasLoadedWasmModules(): boolean { - return registry.size > 0 +const WASM_STACK_FRAME_PATTERNS = [ + /wasm-function(?:\[|@)/i, + /\[wasm code\]/i, + /wasm:\/\//i, + /\.wasm(?=$|[:@)\s]|[?#])/i, +] + +export function isWasmError({ stack, causes }: Pick): boolean { + return [stack] + .concat(causes?.map((cause) => cause.stack) ?? []) + .some( + (candidate) => candidate !== undefined && WASM_STACK_FRAME_PATTERNS.some((pattern) => pattern.test(candidate)) + ) } -function recordModule(url: string, buffer: ArrayBuffer): void { +function recordModule(url: string, buffer: ArrayBufferLike): void { if (registry.has(url)) { return } @@ -32,7 +50,11 @@ function recordModule(url: string, buffer: ArrayBuffer): void { } catch { // Parser must never throw — debug info absence is normal. } - registry.set(url, { url, build_id: buildId }) + registry.set(url, { url, buildId }) +} + +function recordModuleFromView(url: string, view: ArrayBufferView): void { + recordModule(url, view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)) } // Extracts build_id from a Response without consuming it for the caller. @@ -45,24 +67,41 @@ function captureFromResponseAsync(response: Response): Response { .clone() .arrayBuffer() .then((buf) => recordModule(url, buf)) - .catch((err) => { - // eslint-disable-next-line no-console - console.error('[wasm-tracking] capture failed:', err) - }) + .catch(() => undefined) } return response } export function startWasmModuleTracking(): () => void { - if (installed || typeof WebAssembly === 'undefined') { - return () => {} + if (typeof WebAssembly === 'undefined') { + return () => undefined + } + + trackingClients += 1 + if (!stopTracking) { + stopTracking = installWasmModuleTracking() + } + + let stopped = false + return () => { + if (stopped) { + return + } + stopped = true + trackingClients -= 1 + if (trackingClients === 0) { + stopTracking?.() + stopTracking = undefined + registry.clear() + } } - installed = true +} +function installWasmModuleTracking(): () => void { const origInstantiate = WebAssembly.instantiate const origCompile = WebAssembly.compile - const origInstantiateStreaming = (WebAssembly as any).instantiateStreaming - const origCompileStreaming = (WebAssembly as any).compileStreaming + const origInstantiateStreaming = WebAssembly.instantiateStreaming + const origCompileStreaming = WebAssembly.compileStreaming // Hook 1: instantiate(bytes | module, imports). For raw bytes, we can read // build_id directly; for an already-compiled WebAssembly.Module we have no @@ -72,10 +111,10 @@ export function startWasmModuleTracking(): () => void { if (source instanceof ArrayBuffer) { recordModule('', source) } else if (ArrayBuffer.isView(source)) { - recordModule('', (source as ArrayBufferView).buffer as ArrayBuffer) + recordModuleFromView('', source) } else if (source instanceof WebAssembly.Module) { if (!registry.has('')) { - registry.set('', { url: '', build_id: '' }) + registry.set('', { url: '', buildId: '' }) } } } catch { @@ -89,7 +128,7 @@ export function startWasmModuleTracking(): () => void { if (bytes instanceof ArrayBuffer) { recordModule('', bytes) } else if (ArrayBuffer.isView(bytes)) { - recordModule('', (bytes as ArrayBufferView).buffer as ArrayBuffer) + recordModuleFromView('', bytes) } } catch { // intentionally ignored @@ -98,24 +137,20 @@ export function startWasmModuleTracking(): () => void { } as typeof WebAssembly.compile if (origInstantiateStreaming) { - ;(WebAssembly as any).instantiateStreaming = function (this: typeof WebAssembly, source: any, importObject?: any) { - return Promise.resolve(source) - .then((response: Response) => { - try { - captureFromResponseAsync(response) - } catch { - // never block instantiation on capture failure - } - return origInstantiateStreaming.call(this, response, importObject) - }) + WebAssembly.instantiateStreaming = function (source, importObject) { + return Promise.resolve(source).then((response: Response) => { + try { + captureFromResponseAsync(response) + } catch { + // never block instantiation on capture failure + } + return origInstantiateStreaming.call(this, response, importObject) + }) } - } else { - // eslint-disable-next-line no-console - console.warn('[wasm-tracking] WebAssembly.instantiateStreaming not present, skipping') } if (origCompileStreaming) { - ;(WebAssembly as any).compileStreaming = function (this: typeof WebAssembly, source: any) { + WebAssembly.compileStreaming = function (source) { return Promise.resolve(source).then((response: Response) => { try { captureFromResponseAsync(response) @@ -127,22 +162,22 @@ export function startWasmModuleTracking(): () => void { } } - return function stopWasmModuleTracking() { + return () => { WebAssembly.instantiate = origInstantiate WebAssembly.compile = origCompile if (origInstantiateStreaming) { - ;(WebAssembly as any).instantiateStreaming = origInstantiateStreaming + WebAssembly.instantiateStreaming = origInstantiateStreaming } if (origCompileStreaming) { - ;(WebAssembly as any).compileStreaming = origCompileStreaming + WebAssembly.compileStreaming = origCompileStreaming } - registry.clear() - installed = false } } // Test-only helper to reset registry state between test cases. export function resetWasmModuleRegistryForTesting(): void { + stopTracking?.() + stopTracking = undefined + trackingClients = 0 registry.clear() - installed = false } diff --git a/packages/browser-core/src/index.ts b/packages/browser-core/src/index.ts index 26b5aedc27..3399e6bd29 100644 --- a/packages/browser-core/src/index.ts +++ b/packages/browser-core/src/index.ts @@ -192,3 +192,5 @@ export * from './tools/stackTrace/handlingStack' export * from './domain/tags' export { correctedChildSampleRate, isSampled, resetSampleDecisionCache, sampleUsingKnuthFactor } from './domain/sampler' export { startTelemetrySessionContext } from './domain/contexts/telemetrySessionContext' +export type { RawWasmModule } from './domain/wasmModules/wasmModuleTracking' +export { getLoadedWasmModules, isWasmError, startWasmModuleTracking } from './domain/wasmModules/wasmModuleTracking' diff --git a/packages/browser-logs/src/boot/startLogs.ts b/packages/browser-logs/src/boot/startLogs.ts index 9eeee60e47..1d51264652 100644 --- a/packages/browser-logs/src/boot/startLogs.ts +++ b/packages/browser-logs/src/boot/startLogs.ts @@ -6,6 +6,7 @@ import { startGlobalContext, startUserContext, startTabContext, + startWasmModuleTracking, } from '@datadog/browser-core' import type { LogsConfiguration } from '../domain/configuration' import { startLogsAssembly } from '../domain/assembly' @@ -39,6 +40,8 @@ export function startLogs( const lifeCycle = new LifeCycle() const cleanupTasks: Array<() => void> = [] + cleanupTasks.push(startWasmModuleTracking()) + lifeCycle.subscribe(LifeCycleEventType.LOG_COLLECTED, (log) => sendToExtension('logs', log)) const reportError = startReportError(lifeCycle) diff --git a/packages/browser-logs/src/domain/createErrorFieldFromRawError.spec.ts b/packages/browser-logs/src/domain/createErrorFieldFromRawError.spec.ts index 0937e22a66..df9dddbf65 100644 --- a/packages/browser-logs/src/domain/createErrorFieldFromRawError.spec.ts +++ b/packages/browser-logs/src/domain/createErrorFieldFromRawError.spec.ts @@ -61,10 +61,27 @@ describe('createErrorFieldFromRawError', () => { ], fingerprint: 'corge', handling: ErrorHandling.HANDLED, + source_type: 'browser', }) }) it('includes the message if includeMessage is true', () => { expect(createErrorFieldFromRawError(exhaustiveRawError, { includeMessage: true }).message).toBe('quux') }) + + it('adds WebAssembly metadata when a cause has a WebAssembly frame', () => { + const error = createErrorFieldFromRawError({ + ...exhaustiveRawError, + causes: [ + { + source: ErrorSource.CONSOLE, + message: 'Wasm trap', + stack: 'RuntimeError: unreachable\n at wasm-function[42]:0x123', + }, + ], + }) + + expect(error.source_type).toBe('browser+wasm') + expect(error.wasm_modules).toEqual([]) + }) }) diff --git a/packages/browser-logs/src/domain/createErrorFieldFromRawError.ts b/packages/browser-logs/src/domain/createErrorFieldFromRawError.ts index a098151f95..c2c48426f5 100644 --- a/packages/browser-logs/src/domain/createErrorFieldFromRawError.ts +++ b/packages/browser-logs/src/domain/createErrorFieldFromRawError.ts @@ -1,4 +1,5 @@ import type { RawError } from '@datadog/browser-core' +import { getLoadedWasmModules, isWasmError } from '@datadog/browser-core' import type { RawLoggerLogsEvent } from '../rawLogsEvent.types' export function createErrorFieldFromRawError( @@ -11,6 +12,8 @@ export function createErrorFieldFromRawError( includeMessage = false, } = {} ): NonNullable { + const isWasm = isWasmError(rawError) + return { stack: rawError.stack, kind: rawError.type, @@ -18,5 +21,7 @@ export function createErrorFieldFromRawError( causes: rawError.causes, fingerprint: rawError.fingerprint, handling: rawError.handling, + source_type: isWasm ? 'browser+wasm' : 'browser', + ...(isWasm ? { wasm_modules: getLoadedWasmModules() } : {}), } } diff --git a/packages/browser-logs/src/domain/runtimeError/runtimeErrorCollection.spec.ts b/packages/browser-logs/src/domain/runtimeError/runtimeErrorCollection.spec.ts index 4c28272fc8..5ce8f45409 100644 --- a/packages/browser-logs/src/domain/runtimeError/runtimeErrorCollection.spec.ts +++ b/packages/browser-logs/src/domain/runtimeError/runtimeErrorCollection.spec.ts @@ -70,6 +70,7 @@ describe('runtime error collection', () => { handling: ErrorHandling.UNHANDLED, fingerprint: undefined, message: undefined, + source_type: 'browser', }, message: 'error!', status: StatusType.error, @@ -124,6 +125,7 @@ describe('runtime error collection', () => { ], fingerprint: undefined, message: undefined, + source_type: 'browser', }, message: 'High level error', status: StatusType.error, @@ -144,6 +146,29 @@ describe('runtime error collection', () => { expect(rawLogsEvents.length).toEqual(0) }) + it('should identify WASM runtime errors', () => { + const { rawLogsEvents, bufferedDataObservable } = startRuntimeErrorCollectionWithDefaults() + + bufferedDataObservable.notify({ + type: BufferedDataType.RUNTIME_ERROR, + data: { + ...RAW_ERROR, + stack: 'RuntimeError: unreachable\n at foo @ https://example.com/app.wasm:wasm-function[42]:0x10', + }, + }) + + expect(rawLogsEvents[0].rawLogsEvent.error).toEqual({ + kind: 'Error', + stack: jasmine.any(String), + causes: undefined, + handling: ErrorHandling.UNHANDLED, + fingerprint: undefined, + message: undefined, + source_type: 'browser+wasm', + wasm_modules: [], + }) + }) + it('should retrieve dd_context from runtime errors', () => { const { rawLogsEvents, bufferedDataObservable } = startRuntimeErrorCollectionWithDefaults() diff --git a/packages/browser-logs/src/entries/main.ts b/packages/browser-logs/src/entries/main.ts index 868f905837..9b427fefe6 100644 --- a/packages/browser-logs/src/entries/main.ts +++ b/packages/browser-logs/src/entries/main.ts @@ -7,10 +7,13 @@ * @see [Browser Log Collection](https://docs.datadoghq.com/logs/log_collection/javascript/) */ -import { defineGlobal, globalObject } from '@datadog/browser-core' +import { defineGlobal, globalObject, startWasmModuleTracking } from '@datadog/browser-core' import type { LogsPublicApi } from '../boot/logsPublicApi' import { makeLogsPublicApi } from '../boot/logsPublicApi' +// Install WebAssembly hooks before deferred Logs initialization so eagerly loaded modules are captured. +startWasmModuleTracking() + export type { InternalContext } from '../domain/contexts/internalContext' export type { LogsMessage } from '../domain/logger' export { Logger, HandlerType } from '../domain/logger' diff --git a/packages/browser-logs/src/logsEvent.types.ts b/packages/browser-logs/src/logsEvent.types.ts index 7387cfd82c..c56c3595a1 100644 --- a/packages/browser-logs/src/logsEvent.types.ts +++ b/packages/browser-logs/src/logsEvent.types.ts @@ -105,6 +105,17 @@ export interface LogsEvent { type?: string stack?: string }> + /** + * The language or platform impacting the error stack trace format + */ + source_type?: 'browser' | 'browser+wasm' + /** + * WebAssembly modules available for stack trace symbolication + */ + wasm_modules?: Array<{ + url: string + build_id: string + }> [k: string]: unknown } diff --git a/packages/browser-logs/src/rawLogsEvent.types.ts b/packages/browser-logs/src/rawLogsEvent.types.ts index 8ae09d843e..4694abc7ac 100644 --- a/packages/browser-logs/src/rawLogsEvent.types.ts +++ b/packages/browser-logs/src/rawLogsEvent.types.ts @@ -1,4 +1,4 @@ -import type { ErrorSource, RawErrorCause, ErrorHandling } from '@datadog/browser-core' +import type { ErrorSource, RawErrorCause, ErrorHandling, RawWasmModule } from '@datadog/browser-core' import type { TimeStamp } from '@datadog/js-core/time' import type { StatusType } from './domain/logger/isAuthorized' @@ -17,6 +17,8 @@ interface Error { fingerprint?: string causes?: RawErrorCause[] handling: ErrorHandling | undefined + source_type?: 'browser' | 'browser+wasm' + wasm_modules?: RawWasmModule[] } interface CommonRawLogsEvent { diff --git a/packages/browser-rum-core/src/boot/startRum.ts b/packages/browser-rum-core/src/boot/startRum.ts index 6d5ec397d7..54e07733ab 100644 --- a/packages/browser-rum-core/src/boot/startRum.ts +++ b/packages/browser-rum-core/src/boot/startRum.ts @@ -19,6 +19,7 @@ import { ErrorSource, isExperimentalFeatureEnabled, ExperimentalFeature, + startWasmModuleTracking, } from '@datadog/browser-core' import { clocksNow } from '@datadog/js-core/time' import { createDOMMutationObservable } from '../browser/domMutationObservable' @@ -30,7 +31,6 @@ import { startRequestCollection } from '../domain/requestCollection' import { startWebSocketCollection } from '../domain/resource/webSocketCollection' import { startActionCollection } from '../domain/action/actionCollection' import { startErrorCollection } from '../domain/error/errorCollection' -import { startWasmModuleTracking } from '../domain/wasmModules/wasmModuleTracking' import { startResourceCollection } from '../domain/resource/resourceCollection' import { startViewCollection } from '../domain/view/viewCollection' import { startRumBatch } from '../transport/startRumBatch' diff --git a/packages/browser-rum-core/src/domain/error/errorCollection.ts b/packages/browser-rum-core/src/domain/error/errorCollection.ts index b2a5e413cc..f97cb47f12 100644 --- a/packages/browser-rum-core/src/domain/error/errorCollection.ts +++ b/packages/browser-rum-core/src/domain/error/errorCollection.ts @@ -8,6 +8,8 @@ import { generateUUID, computeRawError, ErrorHandling, + getLoadedWasmModules, + isWasmError, NonErrorPrefix, } from '@datadog/browser-core' import type { RawRumErrorEvent } from '../../rawRumEvent.types' @@ -15,7 +17,6 @@ import { RumEventType } from '../../rawRumEvent.types' import type { LifeCycle, RawRumEventCollectedData } from '../lifeCycle' import { LifeCycleEventType } from '../lifeCycle' import type { RumErrorEventDomainContext } from '../../domainContext.types' -import { getLoadedWasmModules, hasLoadedWasmModules } from '../wasmModules/wasmModuleTracking' import { trackReportError } from './trackReportError' export interface ProvidedError { @@ -68,7 +69,7 @@ export function doStartErrorCollection(lifeCycle: LifeCycle) { } function processError(error: RawError): RawRumEventCollectedData { - const wasmActive = hasLoadedWasmModules() + const isWasm = isWasmError(error) const rawRumEvent: RawRumErrorEvent = { date: error.startClocks.timeStamp, error: { @@ -81,10 +82,10 @@ function processError(error: RawError): RawRumEventCollectedData 28) { - // Bail on absurdly large LEB128 — shouldn't happen for valid wasm section sizes. - return { value: 0, nextOffset: bytes.length } - } - } - return { value: 0, nextOffset: bytes.length } -} - -function toHex(bytes: Uint8Array): string { - let result = '' - for (let i = 0; i < bytes.length; i++) { - result += bytes[i].toString(16).padStart(2, '0') - } - return result -} - -const CUSTOM_SECTION_ID = 0 -const WASM_MAGIC = [0x00, 0x61, 0x73, 0x6d] - -export function extractWasmBuildId(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer) - if (bytes.length < 8) { - return '' - } - for (let i = 0; i < WASM_MAGIC.length; i++) { - if (bytes[i] !== WASM_MAGIC[i]) { - return '' - } - } - - let offset = 8 // skip magic (4) + version (4) - let externalDebugInfoPayload: Uint8Array | null = null - const decoder = new TextDecoder('utf-8') - - while (offset < bytes.length) { - const sectionId = bytes[offset++] - const { value: sectionSize, nextOffset: afterSize } = readLEB128Unsigned(bytes, offset) - offset = afterSize - const sectionEnd = offset + sectionSize - - if (sectionId === CUSTOM_SECTION_ID) { - const { value: nameLen, nextOffset: afterNameLen } = readLEB128Unsigned(bytes, offset) - const name = decoder.decode(bytes.subarray(afterNameLen, afterNameLen + nameLen)) - const payload = bytes.subarray(afterNameLen + nameLen, sectionEnd) - - if (name === 'build_id') { - return toHex(payload) - } - if (name === 'external_debug_info') { - // Defer — only use if no standalone build_id is found later. - externalDebugInfoPayload = payload - } - } - - offset = sectionEnd - if (offset > bytes.length) { - break - } - } - - if (externalDebugInfoPayload && externalDebugInfoPayload.length > 0) { - // The trailing portion is the build ID. Default to last 16 bytes; if the - // payload is shorter, take the whole thing. - const idLen = Math.min(16, externalDebugInfoPayload.length) - return toHex(externalDebugInfoPayload.subarray(externalDebugInfoPayload.length - idLen)) - } - - return '' -} diff --git a/packages/browser-rum-core/src/index.ts b/packages/browser-rum-core/src/index.ts index 3ee3147bd0..a56b23fe55 100644 --- a/packages/browser-rum-core/src/index.ts +++ b/packages/browser-rum-core/src/index.ts @@ -80,5 +80,3 @@ export { createHooks } from './domain/hooks' export type { TracingOption, PropagatorType } from './domain/tracing/tracer.types' export type { TransportPayload } from './transport/formDataTransport' export { createFormDataTransport } from './transport/formDataTransport' - -export { startWasmModuleTracking } from './domain/wasmModules/wasmModuleTracking' diff --git a/packages/browser-rum-core/src/rawRumEvent.types.ts b/packages/browser-rum-core/src/rawRumEvent.types.ts index 84e6128300..59be112275 100644 --- a/packages/browser-rum-core/src/rawRumEvent.types.ts +++ b/packages/browser-rum-core/src/rawRumEvent.types.ts @@ -8,6 +8,7 @@ import type { Csp, Context, DebugIdEntry, + RawWasmModule, } from '@datadog/browser-core' import type { GraphQlMetadata } from './domain/resource/graphql' import type { PageState } from './domain/contexts/pageStateHistory' @@ -136,10 +137,7 @@ export interface RawRumErrorEvent { handling?: ErrorHandling causes?: RawErrorCause[] source_type: 'browser' | 'browser+wasm' - wasm_modules?: Array<{ - url: string - build_id: string - }> + wasm_modules?: RawWasmModule[] csp?: Csp } _dd?: { diff --git a/packages/browser-rum-slim/src/entries/main.ts b/packages/browser-rum-slim/src/entries/main.ts index 0753df8dda..6264d840ab 100644 --- a/packages/browser-rum-slim/src/entries/main.ts +++ b/packages/browser-rum-slim/src/entries/main.ts @@ -1,10 +1,13 @@ // Keep the following in sync with packages/browser-rum/src/entries/main.ts -import { defineGlobal, globalObject } from '@datadog/browser-core' +import { defineGlobal, globalObject, startWasmModuleTracking } from '@datadog/browser-core' import type { RumPublicApi } from '@datadog/browser-rum-core' import { makeRumPublicApi } from '@datadog/browser-rum-core' import { makeRecorderApiStub } from '../boot/stubRecorderApi' import { makeProfilerApiStub } from '../boot/stubProfilerApi' +// Install WebAssembly hooks before deferred RUM initialization so eagerly loaded modules are captured. +startWasmModuleTracking() + export type { User, Account, diff --git a/packages/browser-rum/src/entries/main.ts b/packages/browser-rum/src/entries/main.ts index 28356f85e4..d601b49b23 100644 --- a/packages/browser-rum/src/entries/main.ts +++ b/packages/browser-rum/src/entries/main.ts @@ -7,9 +7,9 @@ */ // Keep the following in sync with packages/browser-rum-slim/src/entries/main.ts -import { defineGlobal, globalObject } from '@datadog/browser-core' +import { defineGlobal, globalObject, startWasmModuleTracking } from '@datadog/browser-core' import type { RumPublicApi } from '@datadog/browser-rum-core' -import { makeRumPublicApi, startWasmModuleTracking } from '@datadog/browser-rum-core' +import { makeRumPublicApi } from '@datadog/browser-rum-core' import { makeRecorderApi } from '../boot/recorderApi' import { createDeflateEncoder, startDeflateWorker } from '../domain/deflate' import { makeProfilerApi } from '../boot/profilerApi' From aded8c602dc839440826b739b3919785a6573b58 Mon Sep 17 00:00:00 2001 From: Imane Largou Date: Wed, 12 Aug 2026 11:04:56 +0200 Subject: [PATCH 4/7] =?UTF-8?q?=E2=9C=85=20[RUM-16985]=20Test=20WASM=20err?= =?UTF-8?q?or=20reporting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- .../domain/telemetry/telemetryEvent.types.ts | 36 +++++++++++++- .../wasmModules/wasmModuleTracking.spec.ts | 16 ++++++- .../src/tools/stackTrace/computeStackTrace.ts | 6 +-- .../src/domain/error/errorCollection.spec.ts | 22 +++++++++ .../browser-rum-core/src/rumEvent.types.ts | 48 +++++++++++++++++-- test/e2e/lib/framework/createTest.ts | 7 +++ test/e2e/lib/framework/pageSetups.ts | 1 + test/e2e/lib/framework/serverApps/mock.ts | 12 ++++- test/e2e/scenario/logs.scenario.ts | 25 ++++++++++ yarn.lock | 8 ++-- 11 files changed, 163 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index adac79cc4c..304aa37acf 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "woke": "scripts/cli woke" }, "devDependencies": { - "@datadog/rum-events-format": "DataDog/rum-events-format#commit=e18f1d3b6a018f00061239bf14fbfb748c9538a3", + "@datadog/rum-events-format": "DataDog/rum-events-format#commit=e8a7d91a4d94c3acee37e397262f7994bdd440f3", "@eslint/js": "10.0.1", "@jsdevtools/coverage-istanbul-loader": "3.0.5", "@microsoft/api-extractor": "7.58.12", diff --git a/packages/browser-core/src/domain/telemetry/telemetryEvent.types.ts b/packages/browser-core/src/domain/telemetry/telemetryEvent.types.ts index dac746d2a6..0602913ef2 100644 --- a/packages/browser-core/src/domain/telemetry/telemetryEvent.types.ts +++ b/packages/browser-core/src/domain/telemetry/telemetryEvent.types.ts @@ -463,6 +463,36 @@ export type TelemetryConfigurationEvent = CommonTelemetryProperties & { * Whether a proxy is used for remote configuration */ use_remote_configuration_proxy?: boolean + /** + * Metadata of the remote configuration currently applied for this session + */ + remote_configuration?: { + /** + * Identifier of the remote configuration bundle this metadata belongs to + */ + config_id?: string + /** + * CDN version identifier of the applied configuration + */ + version_id?: string + /** + * CDN publish timestamp of the applied configuration, in ms from epoch + */ + last_modified?: number + /** + * Timestamp at which the device fetched and cached this configuration version, in ms from epoch + */ + last_synced?: number + /** + * Timestamp at which this configuration version was first observed as applied by the device, in ms from epoch. Stamped once and reused on every subsequent session that runs on the same version + */ + first_applied?: number + /** + * Identifier of the sync that produced this configuration version, used to deduplicate repeat sessions from the same device without a persistent identifier + */ + sync_id?: string + [k: string]: unknown + } /** * The percentage of sessions with Profiling enabled */ @@ -487,6 +517,10 @@ export type TelemetryConfigurationEvent = CommonTelemetryProperties & { * Whether the beta track WebSockets feature is enabled */ beta_track_web_sockets?: boolean + /** + * Whether tracing feature's client-side-stats generation is enabled + */ + use_client_side_stats?: boolean [k: string]: unknown } [k: string]: unknown @@ -598,7 +632,7 @@ export interface CommonTelemetryProperties { | 'unity' | 'kotlin-multiplatform' | 'electron' - | 'rum-cpp' + | 'cpp' | 'maui' /** * The version of the SDK generating the telemetry event diff --git a/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.spec.ts b/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.spec.ts index 301246e5ca..15c2754cd6 100644 --- a/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.spec.ts +++ b/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.spec.ts @@ -1,4 +1,5 @@ import type { RawError } from '../error/error.types' +import { registerCleanupTask } from '../../../test' import { getLoadedWasmModules, isWasmError, @@ -46,8 +47,19 @@ describe('isWasmError', () => { }) describe('startWasmModuleTracking', () => { - afterEach(() => { - resetWasmModuleRegistryForTesting() + beforeEach(() => { + registerCleanupTask(resetWasmModuleRegistryForTesting) + }) + + it('records the build ID of modules instantiated from bytes', async () => { + const wasmModule = new Uint8Array([ + 0, 97, 115, 109, 1, 0, 0, 0, 0, 11, 8, 98, 117, 105, 108, 100, 95, 105, 100, 0xab, 0xcd, + ]) + + startWasmModuleTracking() + await WebAssembly.instantiate(wasmModule) + + expect(getLoadedWasmModules()).toEqual([{ url: '', build_id: 'abcd' }]) }) it('records modules compiled from a view without including bytes outside of the view', async () => { diff --git a/packages/browser-core/src/tools/stackTrace/computeStackTrace.ts b/packages/browser-core/src/tools/stackTrace/computeStackTrace.ts index 64097155e2..b66618c6d7 100644 --- a/packages/browser-core/src/tools/stackTrace/computeStackTrace.ts +++ b/packages/browser-core/src/tools/stackTrace/computeStackTrace.ts @@ -39,10 +39,7 @@ export function computeStackTrace(ex: unknown): StackTrace { if (stackProperty) { stackProperty.split('\n').forEach((line) => { const stackFrame = - parseChromeLine(line) || - parseChromeAnonymousLine(line) || - parseWinLine(line) || - parseGeckoLine(line) + parseChromeLine(line) || parseChromeAnonymousLine(line) || parseWinLine(line) || parseGeckoLine(line) if (stackFrame) { if (!stackFrame.func && stackFrame.line) { stackFrame.func = UNKNOWN_FUNCTION @@ -137,7 +134,6 @@ function parseChromeAnonymousLine(line: string): StackFrame | undefined { } } - const WINJS_LINE_RE = /^\s*at (?:((?:\[object object\])?.+) )?\(?((?:file|ms-appx|https?|webpack|blob):.*?):(\d+)(?::(\d+))?\)?\s*$/i diff --git a/packages/browser-rum-core/src/domain/error/errorCollection.spec.ts b/packages/browser-rum-core/src/domain/error/errorCollection.spec.ts index ce7aaf1d4e..e8814b23f6 100644 --- a/packages/browser-rum-core/src/domain/error/errorCollection.spec.ts +++ b/packages/browser-rum-core/src/domain/error/errorCollection.spec.ts @@ -307,6 +307,28 @@ describe('error collection', () => { expect((rawRumEvents[0].rawRumEvent as RawRumErrorEvent).error.csp?.disposition).toEqual('enforce') }) + it('should identify WebAssembly errors', () => { + setupErrorCollection() + + lifeCycle.notify(LifeCycleEventType.RAW_ERROR_COLLECTED, { + error: { + message: 'unreachable', + source: ErrorSource.SOURCE, + stack: 'RuntimeError: unreachable\n at foo (wasm://wasm/abc123:wasm-function[42]:0x10)', + startClocks: { relative: 1234 as RelativeTime, timeStamp: 123456789 as TimeStamp }, + originalError: new WebAssembly.RuntimeError('unreachable'), + handling: ErrorHandling.UNHANDLED, + }, + }) + + expect((rawRumEvents[0].rawRumEvent as RawRumErrorEvent).error).toEqual( + jasmine.objectContaining({ + source_type: 'browser+wasm', + wasm_modules: [], + }) + ) + }) + it('should merge dd_context from the original error with addError context', () => { setupErrorCollection() const error = new Error('foo') diff --git a/packages/browser-rum-core/src/rumEvent.types.ts b/packages/browser-rum-core/src/rumEvent.types.ts index dcdabc0c1b..8b376eed50 100644 --- a/packages/browser-rum-core/src/rumEvent.types.ts +++ b/packages/browser-rum-core/src/rumEvent.types.ts @@ -104,7 +104,7 @@ export type RumActionEvent = CommonProperties & /** * View properties */ - readonly view?: { + readonly view: { /** * Is the action starting in the foreground (focus in browser) */ @@ -178,6 +178,9 @@ export type RumTransitionEvent = CommonProperties & { * RUM event type */ readonly type: 'transition' + readonly view: { + [k: string]: unknown + } /** * Stream properties */ @@ -306,6 +309,7 @@ export type RumErrorEvent = CommonProperties & readonly source_type?: | 'android' | 'browser' + | 'browser+wasm' | 'ios' | 'react-native' | 'flutter' @@ -420,6 +424,20 @@ export type RumErrorEvent = CommonProperties & readonly arch?: string [k: string]: unknown }[] + /** + * WebAssembly modules available for stack trace symbolication. + */ + readonly wasm_modules?: { + /** + * URL identifying the WebAssembly module. + */ + readonly url: string + /** + * Build ID used to identify the WebAssembly debug symbols. + */ + readonly build_id: string + [k: string]: unknown + }[] /** * A boolean value saying if any of the stack traces was truncated due to minification. */ @@ -487,7 +505,7 @@ export type RumErrorEvent = CommonProperties & /** * View properties */ - readonly view?: { + readonly view: { /** * Is the error starting in the foreground (focus in browser) */ @@ -536,6 +554,9 @@ export type RumLongTaskEvent = CommonProperties & * RUM event type */ readonly type: 'long_task' + readonly view: { + [k: string]: unknown + } /** * Long Task properties */ @@ -669,6 +690,9 @@ export type RumResourceEvent = CommonProperties & * RUM event type */ readonly type: 'resource' + readonly view: { + [k: string]: unknown + } /** * Resource properties */ @@ -824,6 +848,10 @@ export type RumResourceEvent = CommonProperties & * Delivery type of the resource */ readonly delivery_type?: 'cache' | 'navigational-prefetch' | 'other' + /** + * Whether the resource was served from the device's local cache + */ + readonly local_cache_hit?: boolean /** * The provider for this resource */ @@ -943,6 +971,9 @@ export type RumViewUpdateEvent = ViewContainerSchema & * RUM event type */ readonly type: 'view_update' + readonly view: { + [k: string]: unknown + } [k: string]: unknown } export type RumVitalEvent = RumVitalDurationEvent | RumVitalOperationStepEvent @@ -975,6 +1006,9 @@ export type RumVitalEventCommonProperties = CommonProperties & * RUM event type */ readonly type: 'vital' + readonly view: { + [k: string]: unknown + } /** * Vital properties */ @@ -1107,12 +1141,12 @@ export interface CommonProperties { | 'unity' | 'kotlin-multiplatform' | 'electron' - | 'rum-cpp' + | 'cpp' | 'maui' /** * View properties */ - readonly view: { + readonly view?: { /** * UUID of the view */ @@ -1392,6 +1426,10 @@ export interface CommonProperties { * The percentage of sessions with traced resources */ readonly trace_sample_rate?: number + /** + * Session Replay experimental features enabled in the SDK configuration + */ + readonly session_replay_experimental_features?: string[] [k: string]: unknown } /** @@ -1453,7 +1491,7 @@ export interface ViewContainerSchema { | 'unity' | 'kotlin-multiplatform' | 'electron' - | 'rum-cpp' + | 'cpp' | 'maui' [k: string]: unknown } diff --git a/test/e2e/lib/framework/createTest.ts b/test/e2e/lib/framework/createTest.ts index 21a501d1b6..c872e63309 100644 --- a/test/e2e/lib/framework/createTest.ts +++ b/test/e2e/lib/framework/createTest.ts @@ -110,6 +110,7 @@ class TestBuilder { private setups: Array<{ factory: SetupFactory; name?: string }> = DEFAULT_SETUPS private testFixture: typeof test = test private mockClock = false + private allowWasmUnsafeEval = false private extension: { rumConfiguration?: RumInitConfiguration logsConfiguration?: LogsInitConfiguration @@ -177,6 +178,11 @@ class TestBuilder { return this } + withWasmUnsafeEval() { + this.allowWasmUnsafeEval = true + return this + } + withVueApp(routerVersion: 'v4' | 'v5' = 'v5') { this.baseUrlHooks.push((baseUrl, servers, { rum, context }) => { baseUrl.port = routerVersion === 'v4' ? VUE_ROUTER_V4_APP_PORT : VUE_ROUTER_APP_PORT @@ -312,6 +318,7 @@ class TestBuilder { worker: this.worker, callerLocation: this.callerLocation, mockClock: this.mockClock, + allowWasmUnsafeEval: this.allowWasmUnsafeEval, salesforceApp: this.salesforceApp, } diff --git a/test/e2e/lib/framework/pageSetups.ts b/test/e2e/lib/framework/pageSetups.ts index d939baf4cf..6274440905 100644 --- a/test/e2e/lib/framework/pageSetups.ts +++ b/test/e2e/lib/framework/pageSetups.ts @@ -36,6 +36,7 @@ export interface SetupOptions { worker?: WorkerOptions callerLocation?: CallerLocation mockClock: boolean + allowWasmUnsafeEval: boolean salesforceApp: SalesforceApp | undefined } diff --git a/test/e2e/lib/framework/serverApps/mock.ts b/test/e2e/lib/framework/serverApps/mock.ts index 461c592443..cee4d4ecc6 100644 --- a/test/e2e/lib/framework/serverApps/mock.ts +++ b/test/e2e/lib/framework/serverApps/mock.ts @@ -14,9 +14,13 @@ import { workerSetup } from '../pageSetups' import { rawDataToString } from '../../helpers/rawDataToString' export const LARGE_RESPONSE_MIN_BYTE_SIZE = 100_000 +const WASM_MODULE_WITH_BUILD_ID = [ + 0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 3, 2, 1, 0, 7, 7, 1, 3, 114, 117, 110, 0, 0, 10, 5, 1, 3, 0, 0, 11, 0, + 11, 8, 98, 117, 105, 108, 100, 95, 105, 100, 0xab, 0xcd, +] export function createMockServerApp(servers: Servers, setup: string, setupOptions?: SetupOptions): MockServerApp { - const { remoteConfiguration, worker } = setupOptions ?? {} + const { remoteConfiguration, worker, allowWasmUnsafeEval } = setupOptions ?? {} const app = express() let largeResponseBytesWritten = 0 @@ -51,6 +55,10 @@ export function createMockServerApp(servers: Servers, setup: string, setupOption generateLargeResponse(res, chunkText) }) + app.get('/test-module.wasm', (_req, res) => { + res.type('application/wasm').send(Buffer.from(WASM_MODULE_WITH_BUILD_ID)) + }) + app.get('/sw.js', (_req, res) => { res.contentType('application/javascript').send( workerSetup( @@ -180,7 +188,7 @@ export function createMockServerApp(servers: Servers, setup: string, setupOption 'Content-Security-Policy', [ `connect-src ${servers.datadogHttpApi.origin} ${servers.base.origin} ${webSocketUrl} ${servers.crossOrigin.origin} https://quota.browser-intake-datadoghq.com`, - `script-src 'self' 'unsafe-inline' ${servers.crossOrigin.origin}`, + `script-src 'self' 'unsafe-inline'${allowWasmUnsafeEval ? " 'wasm-unsafe-eval'" : ''} ${servers.crossOrigin.origin}`, "worker-src blob: 'self'", ].join(';') ) diff --git a/test/e2e/scenario/logs.scenario.ts b/test/e2e/scenario/logs.scenario.ts index 4028f4d4eb..1eabac0a45 100644 --- a/test/e2e/scenario/logs.scenario.ts +++ b/test/e2e/scenario/logs.scenario.ts @@ -287,6 +287,31 @@ test.describe('logs', () => { }) }) + createTest('send WebAssembly runtime errors with module metadata') + .withRum() + .withLogs({ forwardErrorsToLogs: true }) + .withWasmUnsafeEval() + .run(async ({ baseUrl, intakeRegistry, flushEvents, page, withBrowserLogs }) => { + await page.evaluate(async () => { + const { instance } = await WebAssembly.instantiateStreaming(fetch('/test-module.wasm')) + + setTimeout(() => (instance.exports.run as () => void)()) + }) + + await flushEvents() + const expectedWasmModules = [{ url: new URL('/test-module.wasm', baseUrl).href, build_id: 'abcd' }] + + expect(intakeRegistry.logsEvents).toHaveLength(1) + expect(intakeRegistry.logsEvents[0].error?.source_type).toBe('browser+wasm') + expect(intakeRegistry.logsEvents[0].error?.wasm_modules).toEqual(expectedWasmModules) + expect(intakeRegistry.rumErrorEvents).toHaveLength(1) + expect(intakeRegistry.rumErrorEvents[0].error.source_type).toBe('browser+wasm') + expect(intakeRegistry.rumErrorEvents[0].error.wasm_modules).toEqual(expectedWasmModules) + withBrowserLogs((browserLogs) => { + expect(browserLogs).toHaveLength(1) + }) + }) + createTest('add RUM internal context to logs') .withRum() .withLogs() diff --git a/yarn.lock b/yarn.lock index 96dbb8daf7..92ff0aaf45 100644 --- a/yarn.lock +++ b/yarn.lock @@ -712,10 +712,10 @@ __metadata: languageName: unknown linkType: soft -"@datadog/rum-events-format@DataDog/rum-events-format#commit=e18f1d3b6a018f00061239bf14fbfb748c9538a3": +"@datadog/rum-events-format@DataDog/rum-events-format#commit=e8a7d91a4d94c3acee37e397262f7994bdd440f3": version: 0.0.0 - resolution: "@datadog/rum-events-format@https://github.com/DataDog/rum-events-format.git#commit=e18f1d3b6a018f00061239bf14fbfb748c9538a3" - checksum: 10c0/b8306ceac7e936a517dc2cce03d9c6e0b8db9206433214d36eeae0d2e2e7089feec8c4f7c5f077eb4edf45d8abbbbbaa45808fc0a52b46c6f711e0d924221744 + resolution: "@datadog/rum-events-format@https://github.com/DataDog/rum-events-format.git#commit=e8a7d91a4d94c3acee37e397262f7994bdd440f3" + checksum: 10c0/be7dc8d799af0c1a92f18621c29ac95e2c511e42ac2ba88a953d25fe8ee73127d1c9b254893eb1bbdd1e80df76c53f5448017bcd43fcee151859fc2533eaa8fb languageName: node linkType: hard @@ -6178,7 +6178,7 @@ __metadata: version: 0.0.0-use.local resolution: "browser-sdk@workspace:." dependencies: - "@datadog/rum-events-format": "DataDog/rum-events-format#commit=e18f1d3b6a018f00061239bf14fbfb748c9538a3" + "@datadog/rum-events-format": "DataDog/rum-events-format#commit=e8a7d91a4d94c3acee37e397262f7994bdd440f3" "@eslint/js": "npm:10.0.1" "@jsdevtools/coverage-istanbul-loader": "npm:3.0.5" "@microsoft/api-extractor": "npm:7.58.12" From 8f16b678a63d593be30f70fdd110fc91a056db15 Mon Sep 17 00:00:00 2001 From: Imane Largou Date: Wed, 12 Aug 2026 13:54:40 +0200 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=90=9B=20[RUM-16985]=20Stabilize=20WA?= =?UTF-8?q?SM=20error=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wasmModules/wasmModuleTracking.spec.ts | 32 +++++++++++++++ .../domain/wasmModules/wasmModuleTracking.ts | 39 +++++++++---------- .../createErrorFieldFromRawError.spec.ts | 1 - .../domain/createErrorFieldFromRawError.ts | 3 +- .../runtimeErrorCollection.spec.ts | 2 - 5 files changed, 52 insertions(+), 25 deletions(-) diff --git a/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.spec.ts b/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.spec.ts index 15c2754cd6..c69aa18c27 100644 --- a/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.spec.ts +++ b/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.spec.ts @@ -74,6 +74,38 @@ describe('startWasmModuleTracking', () => { expect(getLoadedWasmModules()).toEqual([{ url: '', build_id: '' }]) }) + it('waits for module metadata before resolving streaming instantiation', async () => { + const wasmModule = new Uint8Array([ + 0, 97, 115, 109, 1, 0, 0, 0, 0, 11, 8, 98, 117, 105, 108, 100, 95, 105, 100, 0xab, 0xcd, + ]) + let resolveArrayBuffer!: (buffer: ArrayBuffer) => void + const arrayBufferPromise = new Promise((resolve) => { + resolveArrayBuffer = resolve + }) + const response = { + url: 'https://example.com/module.wasm', + clone: () => ({ arrayBuffer: () => arrayBufferPromise }), + } as Response + const instantiateStreamingSpy = spyOn(WebAssembly, 'instantiateStreaming').and.resolveTo( + {} as WebAssembly.WebAssemblyInstantiatedSource + ) + + startWasmModuleTracking() + let isResolved = false + const instantiatePromise = WebAssembly.instantiateStreaming(response).then(() => { + isResolved = true + }) + await new Promise((resolve) => setTimeout(resolve)) + + expect(instantiateStreamingSpy).toHaveBeenCalled() + expect(isResolved).toBe(false) + + resolveArrayBuffer(wasmModule.buffer) + await instantiatePromise + + expect(getLoadedWasmModules()).toEqual([{ url: response.url, build_id: 'abcd' }]) + }) + it('keeps hooks installed until every tracking client stops', () => { const originalCompile = WebAssembly.compile const stopFirstClient = startWasmModuleTracking() diff --git a/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.ts b/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.ts index 17db4a8637..48d5b8f5e6 100644 --- a/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.ts +++ b/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.ts @@ -57,19 +57,24 @@ function recordModuleFromView(url: string, view: ArrayBufferView): void { recordModule(url, view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)) } -// Extracts build_id from a Response without consuming it for the caller. -// Returns the original response so the actual instantiation can proceed -// without delay; build_id extraction races in parallel. -function captureFromResponseAsync(response: Response): Response { +// Extracts build_id from a Response clone without consuming it for the caller. +// Streaming compilation and metadata extraction happen in parallel, but the +// wrapper only resolves once both are done. This guarantees that an error +// thrown immediately by an exported function can reference the loaded module. +function captureFromResponse(response: Response): Promise { const url = response.url || '' - if (!registry.has(url)) { - response + if (registry.has(url)) { + return Promise.resolve() + } + try { + return response .clone() .arrayBuffer() - .then((buf) => recordModule(url, buf)) + .then((buffer) => recordModule(url, buffer)) .catch(() => undefined) + } catch { + return Promise.resolve() } - return response } export function startWasmModuleTracking(): () => void { @@ -139,12 +144,10 @@ function installWasmModuleTracking(): () => void { if (origInstantiateStreaming) { WebAssembly.instantiateStreaming = function (source, importObject) { return Promise.resolve(source).then((response: Response) => { - try { - captureFromResponseAsync(response) - } catch { - // never block instantiation on capture failure - } - return origInstantiateStreaming.call(this, response, importObject) + const capturePromise = captureFromResponse(response) + return Promise.all([origInstantiateStreaming.call(this, response, importObject), capturePromise]).then( + ([result]) => result + ) }) } } @@ -152,12 +155,8 @@ function installWasmModuleTracking(): () => void { if (origCompileStreaming) { WebAssembly.compileStreaming = function (source) { return Promise.resolve(source).then((response: Response) => { - try { - captureFromResponseAsync(response) - } catch { - // never block compilation on capture failure - } - return origCompileStreaming.call(this, response) + const capturePromise = captureFromResponse(response) + return Promise.all([origCompileStreaming.call(this, response), capturePromise]).then(([module]) => module) }) } } diff --git a/packages/browser-logs/src/domain/createErrorFieldFromRawError.spec.ts b/packages/browser-logs/src/domain/createErrorFieldFromRawError.spec.ts index df9dddbf65..c7dfd435df 100644 --- a/packages/browser-logs/src/domain/createErrorFieldFromRawError.spec.ts +++ b/packages/browser-logs/src/domain/createErrorFieldFromRawError.spec.ts @@ -61,7 +61,6 @@ describe('createErrorFieldFromRawError', () => { ], fingerprint: 'corge', handling: ErrorHandling.HANDLED, - source_type: 'browser', }) }) diff --git a/packages/browser-logs/src/domain/createErrorFieldFromRawError.ts b/packages/browser-logs/src/domain/createErrorFieldFromRawError.ts index c2c48426f5..e0cf892e55 100644 --- a/packages/browser-logs/src/domain/createErrorFieldFromRawError.ts +++ b/packages/browser-logs/src/domain/createErrorFieldFromRawError.ts @@ -21,7 +21,6 @@ export function createErrorFieldFromRawError( causes: rawError.causes, fingerprint: rawError.fingerprint, handling: rawError.handling, - source_type: isWasm ? 'browser+wasm' : 'browser', - ...(isWasm ? { wasm_modules: getLoadedWasmModules() } : {}), + ...(isWasm ? { source_type: 'browser+wasm' as const, wasm_modules: getLoadedWasmModules() } : {}), } } diff --git a/packages/browser-logs/src/domain/runtimeError/runtimeErrorCollection.spec.ts b/packages/browser-logs/src/domain/runtimeError/runtimeErrorCollection.spec.ts index 5ce8f45409..415bf6c200 100644 --- a/packages/browser-logs/src/domain/runtimeError/runtimeErrorCollection.spec.ts +++ b/packages/browser-logs/src/domain/runtimeError/runtimeErrorCollection.spec.ts @@ -70,7 +70,6 @@ describe('runtime error collection', () => { handling: ErrorHandling.UNHANDLED, fingerprint: undefined, message: undefined, - source_type: 'browser', }, message: 'error!', status: StatusType.error, @@ -125,7 +124,6 @@ describe('runtime error collection', () => { ], fingerprint: undefined, message: undefined, - source_type: 'browser', }, message: 'High level error', status: StatusType.error, From 5f434742774526f40bef3cb8d23b0ee8372c6728 Mon Sep 17 00:00:00 2001 From: Imane Largou Date: Wed, 12 Aug 2026 14:14:30 +0200 Subject: [PATCH 6/7] =?UTF-8?q?=E2=9C=85=20[RUM-16985]=20Support=20browser?= =?UTF-8?q?s=20without=20streaming=20WASM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wasmModules/wasmModuleTracking.spec.ts | 45 +++++++++++-------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.spec.ts b/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.spec.ts index c69aa18c27..8c4b1ed527 100644 --- a/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.spec.ts +++ b/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.spec.ts @@ -86,24 +86,33 @@ describe('startWasmModuleTracking', () => { url: 'https://example.com/module.wasm', clone: () => ({ arrayBuffer: () => arrayBufferPromise }), } as Response - const instantiateStreamingSpy = spyOn(WebAssembly, 'instantiateStreaming').and.resolveTo( - {} as WebAssembly.WebAssemblyInstantiatedSource - ) - - startWasmModuleTracking() - let isResolved = false - const instantiatePromise = WebAssembly.instantiateStreaming(response).then(() => { - isResolved = true - }) - await new Promise((resolve) => setTimeout(resolve)) - - expect(instantiateStreamingSpy).toHaveBeenCalled() - expect(isResolved).toBe(false) - - resolveArrayBuffer(wasmModule.buffer) - await instantiatePromise - - expect(getLoadedWasmModules()).toEqual([{ url: response.url, build_id: 'abcd' }]) + const originalInstantiateStreaming = WebAssembly.instantiateStreaming + const instantiateStreamingSpy = jasmine.createSpy().and.resolveTo({}) + WebAssembly.instantiateStreaming = instantiateStreamingSpy + + const stopTracking = startWasmModuleTracking() + try { + let isResolved = false + const instantiatePromise = WebAssembly.instantiateStreaming(response).then(() => { + isResolved = true + }) + await new Promise((resolve) => setTimeout(resolve)) + + expect(instantiateStreamingSpy).toHaveBeenCalled() + expect(isResolved).toBe(false) + + resolveArrayBuffer(wasmModule.buffer) + await instantiatePromise + + expect(getLoadedWasmModules()).toEqual([{ url: response.url, build_id: 'abcd' }]) + } finally { + stopTracking() + if (originalInstantiateStreaming) { + WebAssembly.instantiateStreaming = originalInstantiateStreaming + } else { + delete (WebAssembly as Partial).instantiateStreaming + } + } }) it('keeps hooks installed until every tracking client stops', () => { From 511f8109184c34c567251720bec33d6aea650116 Mon Sep 17 00:00:00 2001 From: Imane Largou Date: Wed, 12 Aug 2026 14:22:21 +0200 Subject: [PATCH 7/7] =?UTF-8?q?=E2=9C=85=20[RUM-16985]=20Scope=20WASM=20ru?= =?UTF-8?q?ntime=20E2E=20by=20browser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/e2e/scenario/logs.scenario.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/e2e/scenario/logs.scenario.ts b/test/e2e/scenario/logs.scenario.ts index 1eabac0a45..20282b04e6 100644 --- a/test/e2e/scenario/logs.scenario.ts +++ b/test/e2e/scenario/logs.scenario.ts @@ -292,6 +292,11 @@ test.describe('logs', () => { .withLogs({ forwardErrorsToLogs: true }) .withWasmUnsafeEval() .run(async ({ baseUrl, intakeRegistry, flushEvents, page, withBrowserLogs }) => { + test.skip( + test.info().project.name === 'webkit' || test.info().project.name === 'chromium-pinned', + 'These browser versions do not expose uncaught WebAssembly traps through the runtime error event' + ) + await page.evaluate(async () => { const { instance } = await WebAssembly.instantiateStreaming(fetch('/test-module.wasm'))