diff --git a/package.json b/package.json index b72695c0de..40b2c1e265 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/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 new file mode 100644 index 0000000000..6fbc1acb4b --- /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 % 0x80) * 2 ** shift + if (byte < 0x80) { + 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: ArrayBufferLike): 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-core/src/domain/wasmModules/wasmModuleTracking.spec.ts b/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.spec.ts new file mode 100644 index 0000000000..8c4b1ed527 --- /dev/null +++ b/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.spec.ts @@ -0,0 +1,130 @@ +import type { RawError } from '../error/error.types' +import { registerCleanupTask } from '../../../test' +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', () => { + 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 () => { + 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('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 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', () => { + 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-core/src/domain/wasmModules/wasmModuleTracking.ts b/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.ts new file mode 100644 index 0000000000..48d5b8f5e6 --- /dev/null +++ b/packages/browser-core/src/domain/wasmModules/wasmModuleTracking.ts @@ -0,0 +1,182 @@ +// 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 RawWasmModule { + url: string + build_id: string +} + +interface WasmModuleEntry { + url: string + buildId: string +} + +const registry = new Map() +let stopTracking: (() => void) | undefined +let trackingClients = 0 + +export function getLoadedWasmModules(): RawWasmModule[] { + return Array.from(registry.values(), ({ url, buildId }) => ({ url, build_id: buildId })) +} + +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: ArrayBufferLike): 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, 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 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)) { + return Promise.resolve() + } + try { + return response + .clone() + .arrayBuffer() + .then((buffer) => recordModule(url, buffer)) + .catch(() => undefined) + } catch { + return Promise.resolve() + } +} + +export function startWasmModuleTracking(): () => void { + 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() + } + } +} + +function installWasmModuleTracking(): () => void { + const origInstantiate = WebAssembly.instantiate + const origCompile = WebAssembly.compile + 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 + // 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)) { + recordModuleFromView('', source) + } else if (source instanceof WebAssembly.Module) { + if (!registry.has('')) { + registry.set('', { url: '', buildId: '' }) + } + } + } 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)) { + recordModuleFromView('', bytes) + } + } catch { + // intentionally ignored + } + return origCompile.call(this, bytes) + } as typeof WebAssembly.compile + + if (origInstantiateStreaming) { + WebAssembly.instantiateStreaming = function (source, importObject) { + return Promise.resolve(source).then((response: Response) => { + const capturePromise = captureFromResponse(response) + return Promise.all([origInstantiateStreaming.call(this, response, importObject), capturePromise]).then( + ([result]) => result + ) + }) + } + } + + if (origCompileStreaming) { + 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) + }) + } + } + + return () => { + WebAssembly.instantiate = origInstantiate + WebAssembly.compile = origCompile + if (origInstantiateStreaming) { + WebAssembly.instantiateStreaming = origInstantiateStreaming + } + if (origCompileStreaming) { + WebAssembly.compileStreaming = origCompileStreaming + } + } +} + +// Test-only helper to reset registry state between test cases. +export function resetWasmModuleRegistryForTesting(): void { + stopTracking?.() + stopTracking = undefined + trackingClients = 0 + registry.clear() +} 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..c7dfd435df 100644 --- a/packages/browser-logs/src/domain/createErrorFieldFromRawError.spec.ts +++ b/packages/browser-logs/src/domain/createErrorFieldFromRawError.spec.ts @@ -67,4 +67,20 @@ describe('createErrorFieldFromRawError', () => { 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..e0cf892e55 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,6 @@ export function createErrorFieldFromRawError( causes: rawError.causes, fingerprint: rawError.fingerprint, handling: rawError.handling, + ...(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 4c28272fc8..415bf6c200 100644 --- a/packages/browser-logs/src/domain/runtimeError/runtimeErrorCollection.spec.ts +++ b/packages/browser-logs/src/domain/runtimeError/runtimeErrorCollection.spec.ts @@ -144,6 +144,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 0ce5ec300c..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' @@ -220,6 +221,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.wasm_modules. + // 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, bufferedDataObservable) startRequestCollection(lifeCycle, configuration, sessionManager, userContext, accountContext, bufferedDataObservable) 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/domain/error/errorCollection.ts b/packages/browser-rum-core/src/domain/error/errorCollection.ts index 2efd463214..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' @@ -67,6 +69,7 @@ export function doStartErrorCollection(lifeCycle: LifeCycle) { } function processError(error: RawError): RawRumEventCollectedData { + const isWasm = isWasmError(error) const rawRumEvent: RawRumErrorEvent = { date: error.startClocks.timeStamp, error: { @@ -79,9 +82,10 @@ function processError(error: RawError): RawRumEventCollectedData = 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..20282b04e6 100644 --- a/test/e2e/scenario/logs.scenario.ts +++ b/test/e2e/scenario/logs.scenario.ts @@ -287,6 +287,36 @@ test.describe('logs', () => { }) }) + createTest('send WebAssembly runtime errors with module metadata') + .withRum() + .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')) + + 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 ade4e35c04..9683ca8bd5 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 @@ -6081,7 +6081,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"