Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
77b7868
✨ [DEBUG-6444] Capture Exception Replay snapshots for RUM errors
watson Oct 28, 2026
8bad8f7
✨ [DEBUG-6444] Only send Exception Replay snapshots for errors RUM sends
watson Oct 29, 2026
a7c0844
👷 [DEBUG-6444] Fix the browser-rum-core declaration build
watson Oct 29, 2026
d6ba3df
👷 [DEBUG-6444] Point @datadog/rum-events-format at the Exception Repl…
watson Oct 29, 2026
d17501a
⚡️ [DEBUG-6444] Give Exception Replay its own capture time budget
watson Oct 31, 2026
5baae08
👌 [DEBUG-6444] Address the automated review of Exception Replay
watson Nov 3, 2026
8db607d
✅ [DEBUG-6444] Fix the async Exception Replay spec in browsers withou…
watson Nov 3, 2026
9cbf27e
👌 [DEBUG-6444] Address the second automated review of Exception Replay
watson Nov 3, 2026
e129a45
🔀 Merge the addPlugin() branch (#5220)
watson Nov 3, 2026
3926d7f
✨ [DEBUG-6444] Add Exception Replay to RUM from DD_DEBUGGER.init()
watson Nov 3, 2026
f72c087
🔀 Merge the addPlugin() review fixes (#5220)
watson Nov 3, 2026
3ff80b1
🔀 Merge the addPlugin() fix for plugins added before init() (#5220)
watson Nov 3, 2026
bfa04bb
🔒 [DEBUG-6444] Honor tracking consent set before init() in Exception …
watson Nov 3, 2026
c9b7862
👌 [DEBUG-6444] Don't link an unnamed function to a named frame
watson Nov 3, 2026
aa5ab4d
🔀 Merge the addPlugin() fixes for aborted init() (#5220)
watson Nov 3, 2026
df1a364
🔀 Merge the addPlugin() start once init() accepts the configuration (…
watson Nov 3, 2026
6dd9b29
✅ [DEBUG-6444] Probe async callers after a rejected await in the Exce…
watson Nov 4, 2026
4d7f7cc
👌 [DEBUG-6444] Call every eventCollected callback, even after one ret…
watson Nov 4, 2026
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=099b70ec2255f45ee4a557c53bd509694cd86212",
"@datadog/rum-events-format": "DataDog/rum-events-format#commit=070d92554ad397af100972184177404adbb36a7f",
"@eslint/js": "10.0.1",
"@jsdevtools/coverage-istanbul-loader": "3.0.5",
"@microsoft/api-extractor": "7.59.4",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,10 @@ export type TelemetryConfigurationEvent = CommonTelemetryProperties & {
* The version of MAUI used in a .NET MAUI application
*/
maui_version?: string
/**
* The version of Unreal Engine used in an Unreal Engine application
*/
unreal_version?: string
/**
* The threshold used for iOS App Hangs monitoring (in milliseconds)
*/
Expand Down Expand Up @@ -456,7 +460,7 @@ export type TelemetryConfigurationEvent = CommonTelemetryProperties & {
*/
sdk_version?: string
/**
* The source of the SDK, e.g., 'browser', 'ios', 'android', 'flutter', 'react-native', 'unity', 'kotlin-multiplatform', 'maui'.
* The source of the SDK, e.g., 'browser', 'ios', 'android', 'flutter', 'react-native', 'unity', 'kotlin-multiplatform', 'maui', 'unreal'.
*/
source?: string
/**
Expand Down Expand Up @@ -654,6 +658,7 @@ export interface CommonTelemetryProperties {
| 'electron'
| 'cpp'
| 'maui'
| 'unreal'
/**
* The version of the SDK generating the telemetry event
*/
Expand Down
13 changes: 13 additions & 0 deletions packages/browser-debugger/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ If you set `version` for version-specific probe targeting, use an immutable depl

When the [RUM Browser SDK][4] is loaded on the page, the first hit of each probe creates a RUM custom action named `probe: <function> (<file>)`. For probes evaluated at function entry, the action lasts until the instrumented function returns or throws. For probes evaluated at function exit, the action is a point in time at the function exit. The action's context includes the probe, the ID of the snapshot sent for that hit, and the outcome (`return` or `throw`, with the error type). Only hits that pass sampling and the probe condition count. To disable this, set `trackProbeHitsAsRumActions: false` in `init()`.

## Exception Replay

Exception Replay captures the arguments and `this` of the instrumented functions an error passes through, and shows them on the error's stack frames in Error Tracking, without adding probes. It requires the [RUM Browser SDK][4], at the same version as the Live Debugger SDK:

1. Build your application with the Live Debugger build plugin's [`exceptionReplay`][8] option.
2. Load the RUM Browser SDK, or its async loading snippet, before you call `DD_DEBUGGER.init()`. With npm, importing `@datadog/browser-rum` is enough. You don't need to change your RUM configuration: `DD_DEBUGGER.init()` adds Exception Replay to the RUM Browser SDK, whether or not RUM is already initialized.
3. Initialize the Live Debugger SDK as shown in [Usage](#usage). It must send to the same Datadog site and organization as the RUM Browser SDK, or Error Tracking can't find the snapshots of RUM errors.

The SDK captures the three innermost instrumented frames of an exception as it's thrown. It only sends them as snapshots once RUM sends the error, after `beforeSend` and the event rate limits. It doesn't capture anything in sessions that RUM doesn't track, or before tracking consent is granted. Snapshots are linked to frames by their position in `error.stack`, so a `beforeSend` that removes or reorders frames there can show them on the wrong frames. An error with the same type and innermost instrumented function is reported with snapshots at most once an hour. Values of variables and properties with sensitive names, such as `password` or `token`, are redacted. Local variables aren't captured.

To limit the time spent on the main thread, Exception Replay captures at most 10 exceptions per second, and spends at most 25 ms per second on average capturing them. When a capture takes longer, capturing pauses until the time is made up. These limits are separate from `maxSnapshotsPerSecondGlobally`, which only applies to probes.

## Troubleshooting

Need help? Contact [Datadog Support][3].
Expand All @@ -64,3 +76,4 @@ Need help? Contact [Datadog Support][3].
[5]: https://github.com/DataDog/build-plugins/tree/master/packages/plugins/live-debugger
[6]: https://github.com/DataDog/build-plugins/tree/master/packages/plugins/live-debugger#source-maps
[7]: https://github.com/DataDog/build-plugins/tree/master/packages/plugins/live-debugger#limitations-during-preview
[8]: https://github.com/DataDog/build-plugins/tree/master/packages/plugins/live-debugger#livedebuggerexceptionreplay
1 change: 1 addition & 0 deletions packages/browser-debugger/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"prepack": "yarn build"
},
"devDependencies": {
"@datadog/browser-rum-core": "workspace:*",
"acorn": "8.19.0"
},
"dependencies": {
Expand Down
89 changes: 83 additions & 6 deletions packages/browser-debugger/src/domain/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { monitorError } from '@datadog/js-core/monitor'
import { buildDebugIdByUrl, buildTag, mergeArrays } from '@datadog/browser-core'
import type { BrowserWindow, DebuggerInitConfiguration } from '../entries/main'
import { capture, captureFields } from './capture'
import type { CaptureContext } from './capture'
import type { CaptureContext, CaptureOptions } from './capture'
import type { InitializedProbe } from './probes'
import {
checkConditionErrorBudget,
Expand All @@ -23,6 +23,7 @@ import { evaluateProbeMessage } from './template'
import { evaluateProbeCondition, isConditionEvaluationError } from './condition'
import { display } from './display'
import { formatThrowable } from './error'
import type { Throwable } from './error'
import { evaluateCaptureExpressions } from './captureExpressions'
import { addExitProbeRumAction, startProbeRumAction, stopProbeRumAction } from './rumAction'

Expand Down Expand Up @@ -434,11 +435,84 @@ function queueDebuggerSnapshot(result: ActiveEntry): void {
recordProbeEventSent(probe)
}

/**
* A snapshot that no probe produced: Exception Replay captures one for each instrumented frame an
* exception leaves (see `exceptionReplay.ts`).
*/
export interface ExceptionSnapshot {
id: string
exceptionId: string
timestamp: number
message: string
location: { type: string; method: string }
stack: StackFrame[]
arguments: Record<string, any>
throwable: Throwable
}

/**
* Whether snapshots can be sent, which requires `DD_DEBUGGER.init()` to have been called.
*/
export function isDebuggerTransportInitialized(): boolean {
return debuggerBatch !== undefined && debuggerConfig !== undefined
}

/**
* Queue an Exception Replay snapshot for delivery via the debugger's own transport. Does nothing
* until `DD_DEBUGGER.init()` has been called.
*/
export function queueExceptionSnapshot(snapshot: ExceptionSnapshot): void {
if (!debuggerBatch || !debuggerConfig) {
return
}

const version = globalObj.DD_DEBUGGER!.version
const debugIds = buildDebugIdByUrl(collectStackFrameUrls(snapshot.throwable.stacktrace, snapshot.stack))

debuggerBatch.add({
message: snapshot.message,
service: debuggerConfig.service,
ddtags: getDebuggerDDtags(version),
_dd: (debugIds ? { debug_ids: debugIds } : undefined) as ContextValue,
logger: {
name: snapshot.location.type,
method: snapshot.location.method,
version,
thread_name: threadName,
},
debugger: {
snapshot: {
id: snapshot.id,
// Marks the snapshot as an Exception Replay snapshot in the Datadog UI
exceptionId: snapshot.exceptionId,
timestamp: snapshot.timestamp,
probe: {
// No probe produced the snapshot, so like the tracers' Exception Replay, give it a probe
// ID of its own
id: generateUUID(),
version: 0,
location: snapshot.location,
},
stack: snapshot.stack,
language: 'javascript',
captures: {
return: { arguments: snapshot.arguments, throwable: snapshot.throwable },
} as ContextValue,
},
},
})
}

function buildSnapshotDebugIds(result: ActiveEntry) {
return buildDebugIdByUrl(collectStackFrameUrls(result.return?.throwable?.stacktrace, result.stack))
}

function collectStackFrameUrls(...stacks: Array<StackFrame[] | undefined>): string[] {
const urls: string[] = []
appendStackFrameUrls(urls, result.return?.throwable?.stacktrace)
appendStackFrameUrls(urls, result.stack)
return buildDebugIdByUrl(urls)
for (const stack of stacks) {
appendStackFrameUrls(urls, stack)
}
return urls
}

function appendStackFrameUrls(urls: string[], stack: StackFrame[] | undefined): void {
Expand All @@ -451,10 +525,13 @@ function appendStackFrameUrls(urls: string[], stack: StackFrame[] | undefined):
}
}

function captureArguments(
/**
* Capture a function's arguments, along with its receiver as `this` unless it's the global object.
*/
export function captureArguments(
args: Record<string, any>,
self: any,
captureOptions: InitializedProbe['capture'],
captureOptions: CaptureOptions,
captureCtx: CaptureContext
): Record<string, any> {
const fields = captureFields(args, captureOptions, captureCtx)
Expand Down
106 changes: 106 additions & 0 deletions packages/browser-debugger/src/domain/capture.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -793,3 +793,109 @@ describe('capture timeout', () => {
expect(result.field2).toEqual({ type: 'Object', notCapturedReason: 'timeout' })
})
})

describe('capture redaction', () => {
const defaultOpts = {}

function redactingContext(): CaptureContext {
return { deadline: Infinity, timedOut: false, redact: true }
}

it('should redact the values of fields named after sensitive identifiers', () => {
const result = captureFields(
{ password: 'hunter2', apiKey: 'abc', 'X-Csrf-Token': 'def', session: { id: 1 }, username: 'alice' },
defaultOpts,
redactingContext()
)

expect(result).toEqual({
password: { type: 'string', notCapturedReason: 'redactedIdent' },
apiKey: { type: 'string', notCapturedReason: 'redactedIdent' },
'X-Csrf-Token': { type: 'string', notCapturedReason: 'redactedIdent' },
session: { type: 'Object', notCapturedReason: 'redactedIdent' },
username: { type: 'string', value: 'alice' },
})
})

it('should redact nested fields', () => {
const result = capture({ user: { name: 'alice', token: 'abc' } }, defaultOpts, redactingContext())

expect(result.fields!.user.fields).toEqual({
name: { type: 'string', value: 'alice' },
token: { type: 'string', notCapturedReason: 'redactedIdent' },
})
})

it('should redact the values of map entries with sensitive string keys', () => {
const result = capture(
new Map<unknown, unknown>([
['Authorization', 'Bearer abc'],
[1, 'one'],
]),
defaultOpts,
redactingContext()
)

expect(result.entries).toEqual([
[
{ type: 'string', value: 'Authorization' },
{ type: 'string', notCapturedReason: 'redactedIdent' },
],
[
{ type: 'number', value: '1' },
{ type: 'string', value: 'one' },
],
])
})

it('should redact the values of fields and map entries with sensitive symbol keys', () => {
const passwordKey = Symbol('password')
const tokenKey = Symbol('token')

expect(
captureFields({ [passwordKey]: 'hunter2', [Symbol('name')]: 'alice' }, defaultOpts, redactingContext())
).toEqual({
password: { type: 'string', notCapturedReason: 'redactedIdent' },
name: { type: 'string', value: 'alice' },
})
expect(capture(new Map([[tokenKey, 'abc']]), defaultOpts, redactingContext()).entries).toEqual([
[jasmine.objectContaining({ type: 'symbol' }), { type: 'string', notCapturedReason: 'redactedIdent' }],
])
})

it('should not use the constructor name of a redacted object', () => {
expect(captureFields({ password: { constructor: { name: 'hunter2' } } }, defaultOpts, redactingContext())).toEqual({
password: { type: 'Object', notCapturedReason: 'redactedIdent' },
})
})

it('should not read a redacted field twice', () => {
const getter = jasmine.createSpy('getter').and.returnValue('hunter2')
const obj = {}
Object.defineProperty(obj, 'password', { get: getter, enumerable: true })

captureFields(obj, defaultOpts, redactingContext())

expect(getter).toHaveBeenCalledTimes(1)
})

it('should report a redacted field whose getter throws', () => {
const obj = {}
Object.defineProperty(obj, 'password', {
get: () => {
throw new Error('boom')
},
enumerable: true,
})

expect(captureFields(obj, defaultOpts, redactingContext())).toEqual({
password: { notCapturedReason: 'redactedIdent' },
})
})

it('should not redact unless the context asks for it', () => {
const result = captureFields({ password: 'hunter2' }, defaultOpts, { deadline: Infinity, timedOut: false })

expect(result).toEqual({ password: { type: 'string', value: 'hunter2' } })
})
})
55 changes: 44 additions & 11 deletions packages/browser-debugger/src/domain/capture.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getConstructorName, isError } from '@datadog/browser-core'
import { isRedactedIdentifier } from './redaction'

export interface CaptureOptions {
maxReferenceDepth?: number
Expand Down Expand Up @@ -26,6 +27,11 @@ export interface CapturedValue {
export interface CaptureContext {
deadline: number
timedOut: boolean
/**
* Whether to redact the values of properties and map entries named after a sensitive identifier
* (see {@link isRedactedIdentifier}).
*/
redact?: boolean
}

// Prefer replaceAll to replace because it's ~20% faster in this hot code path.
Expand Down Expand Up @@ -321,16 +327,10 @@ function captureObjectPropertiesFields(
const keyName =
typeof key === 'symbol' ? key.description || key.toString() : keyStr.includes('.') ? replaceDots(keyStr) : keyStr

fields[keyName] = captureProperty(
obj,
key,
depth,
maxReferenceDepth,
maxCollectionSize,
maxFieldCount,
maxLength,
ctx
)
fields[keyName] =
ctx.redact && isRedactedKey(key)
? captureRedactedProperty(obj, key)
: captureProperty(obj, key, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength, ctx)
}

return fields
Expand Down Expand Up @@ -424,7 +424,9 @@ function captureMap(
}
entries.push([
captureValue(key, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength, ctx),
captureValue(value, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength, ctx),
ctx.redact && isRedactedKey(key)
? captureRedactedValue(value)
: captureValue(value, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength, ctx),
])
count++
}
Expand Down Expand Up @@ -547,6 +549,37 @@ function captureProperty(
}
}

// Symbol keys are captured under their description, so they're redacted by it too
function isRedactedKey(key: unknown): boolean {
if (typeof key === 'symbol') {
return key.description !== undefined && isRedactedIdentifier(key.description)
}
return typeof key === 'string' && isRedactedIdentifier(key)
}

function captureRedactedProperty(obj: any, property: string | symbol): CapturedValue {
try {
return captureRedactedValue(obj[property])
} catch {
return { notCapturedReason: 'redactedIdent' }
}
}

function captureRedactedValue(value: unknown): CapturedValue {
let type: string
if (value === null) {
type = 'null'
} else if (typeof value === 'object') {
// Not the constructor name, which a redacted object controls, as in `{ constructor: { name } }`
type = Array.isArray(value) ? 'Array' : 'Object'
} else if (typeof value === 'function') {
type = 'Function'
} else {
type = typeof value
}
return { type, notCapturedReason: 'redactedIdent' }
}

function captureOptionalProperty(
obj: any,
property: string | symbol,
Expand Down
Loading
Loading