Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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
Expand Down Expand Up @@ -598,7 +632,7 @@ export interface CommonTelemetryProperties {
| 'unity'
| 'kotlin-multiplatform'
| 'electron'
| 'rum-cpp'
| 'cpp'
| 'maui'
/**
* The version of the SDK generating the telemetry event
Expand Down
Original file line number Diff line number Diff line change
@@ -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('')
})
})
Original file line number Diff line number Diff line change
@@ -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 ''
}
Original file line number Diff line number Diff line change
@@ -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<RawError, 'stack' | 'causes'> {
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: '<wasm-instantiate-bytes>', 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: '<wasm-compile-bytes>', 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<ArrayBuffer>((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<typeof WebAssembly>).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)
})
})
Loading
Loading