✨ [RUM-16985] Capture wasm module build_ids and enrich error events - #4920
✨ [RUM-16985] Capture wasm module build_ids and enrich error events#4920ImaneLargou wants to merge 18 commits into
Conversation
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 <noreply@anthropic.com>
|
All contributors have signed the CLA ✍️ ✅ |
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bundles Sizes Evolution
|
|
I have read the CLA Document and I hereby sign the CLA |
| // 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<void> { | ||
| const url = response.url || '<wasm-instantiate-streaming-no-url>' |
There was a problem hiding this comment.
❓ question: How could the URL be empty? Is this just because of the types?
There was a problem hiding this comment.
It can be empty when the response is created manually with new Response(wasmBytes) instead of coming from fetch(). This is probably uncommon, but the fallback handles that edge case. I can remove the fallback if we don’t want to send a synthetic URL.
| 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() | ||
| } | ||
| } |
There was a problem hiding this comment.
❓ question: How many clients could we expect tracking WebAssembly?
There was a problem hiding this comment.
Once the module calls are removed, we expect at most two clients: RUM and Logs.
| import { makeLogsPublicApi } from '../boot/logsPublicApi' | ||
|
|
||
| // Install WebAssembly hooks before deferred Logs initialization so eagerly loaded modules are captured. | ||
| startWasmModuleTracking() |
There was a problem hiding this comment.
🔨 warning: The philosophy of the SDK is not to override any APIs unless we are allowed to track the user.
This should be deferred until SDK initialization.
| // reads it to set source_type='browser+wasm' and error.wasm_modules. | ||
| // Must start before any wasm load — RUM is initialised before the page's | ||
| // wasm fetch in typical setups. | ||
| const stopWasmModuleTracking = startWasmModuleTracking() |
There was a problem hiding this comment.
❓ question: Are we not initializing this in different places?
There was a problem hiding this comment.
Yes, this was part of my first tests at wasm module tracking but modules loaded before startRum runs were missed. Forgot to remove it 😅. I'll delete it.
| // wasm modules — by the time the deferred wrap installs, instantiateStreaming | ||
| // may have already been called. Installing here (before any deferral) closes | ||
| // that race. | ||
| startWasmModuleTracking() |
There was a problem hiding this comment.
🔨 warning: Mentioned before.
| import { makeProfilerApiStub } from '../boot/stubProfilerApi' | ||
|
|
||
| // Install WebAssembly hooks before deferred RUM initialization so eagerly loaded modules are captured. | ||
| startWasmModuleTracking() |
There was a problem hiding this comment.
🔨 warning: Keeping track of all the places where this is automatically tracked.
…-experiment # Conflicts: # package.json # packages/browser-core/src/domain/telemetry/telemetryEvent.types.ts # yarn.lock
…-experiment # Conflicts: # package.json # packages/browser-logs/src/domain/runtimeError/runtimeErrorCollection.spec.ts # packages/browser-logs/src/rawLogsEvent.types.ts # yarn.lock
| trackingConsentState.onGrantedOnce(() => { | ||
| startTrackingConsentContext(hooks, trackingConsentState) | ||
| mockable(startTelemetry)(TelemetryService.LOGS, configuration, hooks.assembleTelemetry, sdkName) | ||
| stopWasmModuleTracking = mockable(startWasmModuleTracking)() |
There was a problem hiding this comment.
question: why do we start module tracking here?
There was a problem hiding this comment.
It starts after consent but before the asynchronous session-manager startup to avoid missing modules loaded during that wait. When I was testing with rum, it seemed like modules were missing if we start later than this.
| // 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) { |
There was a problem hiding this comment.
suggestion: use instrumentMethod to instrument all methods.
| // 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<void> { |
There was a problem hiding this comment.
nitpick: this might be better written as an async function
There was a problem hiding this comment.
This function has been removed. We now extract the build ID directly from the compiled WebAssembly.Module, so we no longer clone or read the response body.
| WebAssembly.compileStreaming = function (source) { | ||
| return Promise.resolve(source).then((response: Response) => { | ||
| const capturePromise = captureFromResponse(response) | ||
| return Promise.all([origCompileStreaming.call(this, response), capturePromise]).then(([module]) => module) |
There was a problem hiding this comment.
issue: using instrumentMethod here would make sure all received argument are passed to the original compileStreaming method (mdn shows that it supports a compileOptions object)
There was a problem hiding this comment.
Changed it to use instrumentMethod!
| try { | ||
| return response | ||
| .clone() | ||
| .arrayBuffer() |
There was a problem hiding this comment.
issue: you are downloading the whole response in memory -- that will have an impact on large wasm files. Would it be possible to read only a limited amount of bytes instead?
There was a problem hiding this comment.
Updated the PR so now we extract the build ID directly from the compiled WebAssembly.Module, and we no longer clone or read the response body.
| await page.evaluate(async () => { | ||
| const { instance } = await WebAssembly.instantiateStreaming(fetch('/test-module.wasm')) | ||
|
|
||
| setTimeout(() => (instance.exports.run as () => void)()) |
There was a problem hiding this comment.
question: why do we need this setTimeout?
There was a problem hiding this comment.
The setTimeout makes the WASM trap occur as an uncaught browser error. If we call run() directly inside page.evaluate(), the exception is reported as an evaluation failure instead.
| }) | ||
|
|
||
| createTest('send WebAssembly runtime errors with module metadata') | ||
| .withRum() |
There was a problem hiding this comment.
suggestion: don't add a rum test in logs.scenario. Move the test in a common scenario file, or add split the test (one for rum, one for logs)
There was a problem hiding this comment.
Tests are split into one for logs and one for rum now.
Motivation
Browser WebAssembly runtime errors currently lack the module metadata required for WASM symbolication.
This change identifies errors containing WebAssembly stack frames and reports the loaded WASM module URL and build ID. This allows the error-processing pipeline to distinguish ordinary browser errors from errors that should use WASM/DWARF symbolication.
Related upstream schema change: DataDog/rum-events-format#427
Changes
WebAssembly.instantiateWebAssembly.instantiateStreamingWebAssembly.compileWebAssembly.compileStreamingbuild_idcustom section, withexternal_debug_infoas a fallback.error.source_type: "browser+wasm"error.wasm_modules, containing the module URL and build ID.jsframes do not qualify an error as WASM.rum-events-formatschema change.Test instructions
Run the relevant unit tests:
Run the E2E scenario:
yarn test:e2e -g "send WebAssembly runtime errors with module metadata"The E2E test loads a real .wasm module using
WebAssembly.instantiateStreaming, triggers a runtime error, and verifies that both RUM and Logs events contain:{ "source_type": "browser+wasm", "wasm_modules": [ { "url": "<test module URL>", "build_id": "abcd" } ] }Checklist