diff --git a/eslint-local-rules/disallowSideEffects.js b/eslint-local-rules/disallowSideEffects.js index 890c5852df..906925cbd2 100644 --- a/eslint-local-rules/disallowSideEffects.js +++ b/eslint-local-rules/disallowSideEffects.js @@ -29,6 +29,7 @@ const pathsWithSideEffect = new Set([ `${packagesRoot}/logs/src/entries/main.ts`, `${packagesRoot}/rum/src/entries/main.ts`, `${packagesRoot}/rum-slim/src/entries/main.ts`, + `${packagesRoot}/debugger/src/entries/main.ts`, ]) // Those packages are known to have no side effects when evaluated diff --git a/packages/core/src/domain/configuration/configuration.ts b/packages/core/src/domain/configuration/configuration.ts index 29c42d5b6b..b74bd744cd 100644 --- a/packages/core/src/domain/configuration/configuration.ts +++ b/packages/core/src/domain/configuration/configuration.ts @@ -276,7 +276,7 @@ export interface InitConfiguration { * * @internal */ - source?: 'browser' | 'flutter' | 'unity' | undefined + source?: 'browser' | 'flutter' | 'unity' | 'dd_debugger' | undefined /** * [Internal option] Additional configuration for the SDK. @@ -331,7 +331,7 @@ export interface Configuration extends TransportConfiguration { // internal sdkVersion: string | undefined - source: 'browser' | 'flutter' | 'unity' + source: 'browser' | 'flutter' | 'unity' | 'dd_debugger' variant: string | undefined } diff --git a/packages/core/src/domain/configuration/transportConfiguration.ts b/packages/core/src/domain/configuration/transportConfiguration.ts index 3ea3d0440c..c43fc71414 100644 --- a/packages/core/src/domain/configuration/transportConfiguration.ts +++ b/packages/core/src/domain/configuration/transportConfiguration.ts @@ -14,7 +14,7 @@ export interface TransportConfiguration { datacenter?: string | undefined replica?: ReplicaConfiguration site: Site - source: 'browser' | 'flutter' | 'unity' + source: 'browser' | 'flutter' | 'unity' | 'dd_debugger' } export interface ReplicaConfiguration { @@ -38,7 +38,7 @@ export function computeTransportConfiguration(initConfiguration: InitConfigurati } function validateSource(source: string | undefined) { - if (source === 'flutter' || source === 'unity') { + if (source === 'flutter' || source === 'unity' || source === 'dd_debugger') { return source } return 'browser' diff --git a/packages/core/src/domain/telemetry/telemetryEvent.types.ts b/packages/core/src/domain/telemetry/telemetryEvent.types.ts index 81033d1c71..f84902fbf4 100644 --- a/packages/core/src/domain/telemetry/telemetryEvent.types.ts +++ b/packages/core/src/domain/telemetry/telemetryEvent.types.ts @@ -585,6 +585,7 @@ export interface CommonTelemetryProperties { | 'kotlin-multiplatform' | 'electron' | 'rum-cpp' + | 'dd_debugger' /** * The version of the SDK generating the telemetry event */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b2a3d034d8..724ec19890 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,6 +7,7 @@ export { isSampleRate, buildEndpointHost, isIntakeUrl, + computeTransportConfiguration, } from './domain/configuration' export * from './domain/intakeSites' export type { TrackingConsentState } from './domain/trackingConsent' @@ -57,7 +58,7 @@ export { SESSION_NOT_TRACKED, SessionPersistence, } from './domain/session/sessionConstants' -export type { BandwidthStats, HttpRequest, HttpRequestEvent, Payload, FlushEvent, FlushReason } from './transport' +export type { Batch, BandwidthStats, HttpRequest, HttpRequestEvent, Payload, FlushEvent, FlushReason } from './transport' export { createHttpRequest, canUseEventBridge, diff --git a/packages/core/src/transport/index.ts b/packages/core/src/transport/index.ts index ce6307356c..bf12615aa9 100644 --- a/packages/core/src/transport/index.ts +++ b/packages/core/src/transport/index.ts @@ -2,6 +2,7 @@ export type { BandwidthStats, HttpRequest, HttpRequestEvent, Payload, RetryInfo export { createHttpRequest } from './httpRequest' export type { BrowserWindowWithEventBridge, DatadogEventBridge } from './eventBridge' export { canUseEventBridge, bridgeSupports, getEventBridge, BridgeCapability } from './eventBridge' +export type { Batch } from './batch' export { createBatch } from './batch' export type { FlushController, FlushEvent, FlushReason } from './flushController' export { createFlushController, FLUSH_DURATION_LIMIT } from './flushController' diff --git a/packages/debugger/README.md b/packages/debugger/README.md new file mode 100644 index 0000000000..f191fe58f5 --- /dev/null +++ b/packages/debugger/README.md @@ -0,0 +1,88 @@ +# Live Debugger Browser Monitoring + +Datadog Live Debugger enables you to capture function execution snapshots, evaluate conditions, and collect runtime data from your application without modifying source code. + +## Usage + +To start using the live debugger, add [`@datadog/browser-debugger`](https://www.npmjs.com/package/@datadog/browser-debugger) to your `package.json` file, then initialize it with: + +```javascript +import { datadogLiveDebugger } from '@datadog/browser-debugger' + +datadogLiveDebugger.init({ + clientToken: '', + applicationId: '', + service: 'my-web-application', + site: '', + env: 'production', + version: '1.0.0', +}) +``` + +The debugger automatically polls for probe updates from the Delivery API. + +You can also add probes programmatically: + +```javascript +datadogLiveDebugger.addProbe({ + id: 'probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'MyClass', methodName: 'myMethod' }, + template: 'Method executed with duration: {@duration}ms', + segments: [ + { str: 'Method executed with duration: ' }, + { dsl: '@duration', json: { ref: '@duration' } }, + { str: 'ms' }, + ], + captureSnapshot: true, + capture: { maxReferenceDepth: 3 }, + sampling: { snapshotsPerSecond: 5000 }, + evaluateAt: 'EXIT', +}) +``` + +## Integration with RUM + +The Live Debugger integrates seamlessly with Datadog RUM to provide enhanced context and correlation: + +```javascript +import { datadogRum } from '@datadog/browser-rum' +import { datadogLiveDebugger } from '@datadog/browser-debugger' + +// Initialize RUM first +datadogRum.init({ + applicationId: '', + clientToken: '', + site: '', + service: 'my-web-application', + env: 'production', +}) + +// Then initialize Live Debugger +datadogLiveDebugger.init({ + clientToken: '', + applicationId: '', + service: 'my-web-application', + site: '', + env: 'production', +}) + +// Add your probe configurations +// datadogLiveDebugger.addProbe({ ... }) +``` + +When both are initialized, debugger snapshots will automatically include RUM context (session, view, user action). + +## Features + +- **Dynamic Instrumentation**: Capture function entry/exit without code changes +- **Conditional Breakpoints**: Evaluate conditions before capturing snapshots +- **Template Expressions**: Evaluate custom messages with runtime context +- **Rate Limiting**: Built-in sampling to prevent performance impact +- **Stack Traces**: Automatic stack trace capture for debugging +- **Variable Capture**: Deep capture of arguments, locals, and return values + + + +[1]: https://docs.datadoghq.com/dynamic_instrumentation/ diff --git a/packages/debugger/package.json b/packages/debugger/package.json new file mode 100644 index 0000000000..427b12496b --- /dev/null +++ b/packages/debugger/package.json @@ -0,0 +1,26 @@ +{ + "name": "@datadog/browser-debugger", + "version": "6.30.1", + "license": "Apache-2.0", + "main": "cjs/entries/main.js", + "module": "esm/entries/main.js", + "types": "cjs/entries/main.d.ts", + "scripts": { + "build": "node ../../scripts/build/build-package.ts --modules --bundle datadog-debugger.js", + "build:bundle": "node ../../scripts/build/build-package.ts --bundle datadog-debugger.js" + }, + "dependencies": { + "@datadog/browser-core": "6.30.1" + }, + "repository": { + "type": "git", + "url": "https://github.com/DataDog/browser-sdk.git", + "directory": "packages/debugger" + }, + "volta": { + "extends": "../../package.json" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/debugger/src/domain/api.spec.ts b/packages/debugger/src/domain/api.spec.ts new file mode 100644 index 0000000000..efcad34e94 --- /dev/null +++ b/packages/debugger/src/domain/api.spec.ts @@ -0,0 +1,583 @@ +import { registerCleanupTask } from '@datadog/browser-core/test' +import { onEntry, onReturn, onThrow } from './api' +import { addProbe, getProbes, clearProbes } from './probes' +import type { Probe } from './probes' + +describe('api', () => { + let mockSendRawLog: jasmine.Spy + let mockGetInitConfiguration: jasmine.Spy + let mockRumGetInternalContext: jasmine.Spy + + beforeEach(() => { + clearProbes() + + // Mock DD_LOGS global for liveDebug + mockSendRawLog = jasmine.createSpy('sendRawLog') + mockGetInitConfiguration = jasmine + .createSpy('getInitConfiguration') + .and.returnValue({ service: 'test-service', env: 'test-env' }) + ;(window as any).DD_LOGS = { + sendRawLog: mockSendRawLog, + getInitConfiguration: mockGetInitConfiguration, + } + + // Mock DD_RUM global for context + mockRumGetInternalContext = jasmine.createSpy('getInternalContext').and.returnValue({ + session_id: 'test-session', + view: { id: 'test-view' }, + user_action: { id: 'test-action' }, + application_id: 'test-app-id', + }) + ;(window as any).DD_RUM = { + version: '1.0.0', + getInternalContext: mockRumGetInternalContext, + } + + registerCleanupTask(() => { + delete (window as any).DD_LOGS + delete (window as any).DD_RUM + }) + }) + + afterEach(() => { + clearProbes() + }) + + describe('onEntry and onReturn', () => { + it('should capture this inside arguments.fields', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'testMethod' }, + template: 'Test message', + captureSnapshot: true, + capture: { maxReferenceDepth: 1 }, + sampling: { snapshotsPerSecond: 5000 }, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const self = { name: 'testObj' } + const args = { a: 1, b: 2 } + const probes = getProbes('TestClass;testMethod')! + onEntry(probes, self, args) + onReturn(probes, 'result', self, args, {}) + + const payload = mockSendRawLog.calls.mostRecent().args[0] + const snapshot = payload.debugger.snapshot + + // Verify entry.arguments structure - now flat + expect(snapshot.captures.entry.arguments).toEqual({ + a: { type: 'number', value: '1' }, + b: { type: 'number', value: '2' }, + this: { + type: 'Object', + fields: { + name: { type: 'string', value: 'testObj' }, + }, + }, + }) + + // Verify return.arguments structure - now flat + expect(snapshot.captures.return.arguments).toEqual({ + a: { type: 'number', value: '1' }, + b: { type: 'number', value: '2' }, + this: { + type: 'Object', + fields: { + name: { type: 'string', value: 'testObj' }, + }, + }, + }) + + // Verify return.locals structure - also flat + expect(snapshot.captures.return.locals['@return']).toEqual({ + type: 'string', + value: 'result', + }) + }) + + it('should capture entry and return for simple probe', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'testMethod' }, + template: 'Test message', + captureSnapshot: true, + capture: { maxReferenceDepth: 1 }, + sampling: { snapshotsPerSecond: 5000 }, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const self = { name: 'test' } + const args = { arg1: 'value1', arg2: 42 } + + const probes = getProbes('TestClass;testMethod')! + onEntry(probes, self, args) + const result = onReturn(probes, 'returnValue', self, args, {}) + + expect(result).toBe('returnValue') + expect(mockSendRawLog).toHaveBeenCalledTimes(1) + + const payload = mockSendRawLog.calls.mostRecent().args[0] + expect(payload.message).toBe('Test message') + expect(payload.debugger.snapshot).toEqual( + jasmine.objectContaining({ id: jasmine.any(String), captures: jasmine.any(Object) }) + ) + }) + + it('should skip probe if sampling budget exceeded', () => { + // Use a very low sampling rate to ensure budget is exceeded + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'budgetTest' }, + template: 'Test', + captureSnapshot: true, + capture: { maxReferenceDepth: 1 }, + sampling: { snapshotsPerSecond: 0.5 }, // 0.5 per second = 2000ms between samples + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const probes = getProbes('TestClass;budgetTest')! + // First call should work + onEntry(probes, {}, {}) + onReturn(probes, null, {}, {}, {}) + expect(mockSendRawLog).toHaveBeenCalledTimes(1) + + // Second immediate call should be skipped (less than 2000ms passed) + onEntry(probes, {}, {}) + onReturn(probes, null, {}, {}, {}) + + // Still only one call because sampling budget not refreshed + expect(mockSendRawLog).toHaveBeenCalledTimes(1) + }) + + it('should evaluate condition at ENTRY', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'conditionEntry' }, + when: { + dsl: 'x > 5', + json: { gt: [{ ref: 'x' }, 5] }, + }, + template: 'Condition passed', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + let probes = getProbes('TestClass;conditionEntry')! + // Should fire when condition passes + onEntry(probes, {}, { x: 10 }) + onReturn(probes, null, {}, { x: 10 }, {}) + expect(mockSendRawLog).toHaveBeenCalledTimes(1) + + clearProbes() + addProbe(probe) + mockSendRawLog.calls.reset() + + probes = getProbes('TestClass;conditionEntry')! + // Should not fire when condition fails + onEntry(probes, {}, { x: 3 }) + onReturn(probes, null, {}, { x: 3 }, {}) + expect(mockSendRawLog).not.toHaveBeenCalled() + }) + + it('should evaluate condition at EXIT with @return', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'conditionExit' }, + when: { + dsl: '@return > 10', + json: { gt: [{ ref: '@return' }, 10] }, + }, + template: 'Return value check', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'EXIT', + } + addProbe(probe) + + let probes = getProbes('TestClass;conditionExit')! + // Should fire when return value > 10 + onEntry(probes, {}, {}) + onReturn(probes, 15, {}, {}, {}) + expect(mockSendRawLog).toHaveBeenCalledTimes(1) + + clearProbes() + addProbe(probe) + mockSendRawLog.calls.reset() + + probes = getProbes('TestClass;conditionExit')! + // Should not fire when return value <= 10 + onEntry(probes, {}, {}) + onReturn(probes, 5, {}, {}, {}) + expect(mockSendRawLog).not.toHaveBeenCalled() + }) + + // TODO: Validate that this test is actually correct + it('should capture entry snapshot only for ENTRY evaluation with no condition', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'entrySnapshot' }, + template: 'Test', + captureSnapshot: true, + capture: { maxReferenceDepth: 1 }, + sampling: {}, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const probes = getProbes('TestClass;entrySnapshot')! + onEntry(probes, { name: 'obj' }, { arg: 'value' }) + onReturn(probes, 'result', { name: 'obj' }, { arg: 'value' }, { local: 'data' }) + + const payload = mockSendRawLog.calls.mostRecent().args[0] + const snapshot = payload.debugger.snapshot + expect(snapshot.captures).toEqual({ + entry: { + arguments: { + arg: { type: 'string', value: 'value' }, + this: { type: 'Object', fields: { name: { type: 'string', value: 'obj' } } }, + }, + }, + return: { + arguments: { + arg: { type: 'string', value: 'value' }, + this: { type: 'Object', fields: { name: { type: 'string', value: 'obj' } } }, + }, + locals: { + local: { type: 'string', value: 'data' }, + '@return': { type: 'string', value: 'result' }, + }, + }, + }) + }) + + it('should only capture return snapshot for EXIT evaluation with condition', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'exitSnapshot' }, + when: { + dsl: '@return === true', + json: { eq: [{ ref: '@return' }, true] }, + }, + template: 'Test', + captureSnapshot: true, + capture: { maxReferenceDepth: 1 }, + sampling: {}, + evaluateAt: 'EXIT', + } + addProbe(probe) + + const probes = getProbes('TestClass;exitSnapshot')! + onEntry(probes, {}, { arg: 'value' }) + onReturn(probes, true, {}, { arg: 'value' }, {}) + + const payload = mockSendRawLog.calls.mostRecent().args[0] + const snapshot = payload.debugger.snapshot + expect(snapshot.captures.entry).toBeUndefined() + expect(snapshot.captures.return).toBeDefined() + }) + + it('should include duration in snapshot', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'durationTest' }, + template: 'Test', + captureSnapshot: true, + capture: { maxReferenceDepth: 1 }, + sampling: {}, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const probes = getProbes('TestClass;durationTest')! + onEntry(probes, {}, {}) + + // Simulate some time passing + const startTime = performance.now() + while (performance.now() - startTime < 10) { + // Wait + } + + onReturn(probes, null, {}, {}, {}) + + const payload = mockSendRawLog.calls.mostRecent().args[0] + const snapshot = payload.debugger.snapshot + expect(snapshot.duration).toBeGreaterThan(0) + expect(snapshot.duration).toBeGreaterThanOrEqual(10000000) // Should be in nanoseconds (>= 10ms) + }) + + it('should include RUM context in logger', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'rumContext' }, + template: 'Test', + segments: [{ str: 'Test' }], + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const probes = getProbes('TestClass;rumContext')! + onEntry(probes, {}, {}) + onReturn(probes, null, {}, {}, {}) + + const payload = mockSendRawLog.calls.mostRecent().args[0] + const dd = payload.dd + expect(dd).toEqual({ + trace_id: 'test-session', + span_id: 'test-action', + }) + }) + }) + + describe('onThrow', () => { + it('should capture this inside arguments.fields for exceptions', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'throwTest' }, + template: 'Test', + captureSnapshot: true, + capture: { maxReferenceDepth: 1 }, + sampling: {}, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const self = { name: 'testObj' } + const args = { a: 1, b: 2 } + const error = new Error('Test error') + const probes = getProbes('TestClass;throwTest')! + onEntry(probes, self, args) + onThrow(probes, error, self, args) + + const payload = mockSendRawLog.calls.mostRecent().args[0] + const snapshot = payload.debugger.snapshot + + // Verify return.arguments structure - now flat + expect(snapshot.captures.return.arguments).toEqual({ + a: { type: 'number', value: '1' }, + b: { type: 'number', value: '2' }, + this: { + type: 'Object', + fields: { + name: { type: 'string', value: 'testObj' }, + }, + }, + }) + + // Verify throwable is still present + expect(snapshot.captures.return.throwable).toEqual({ + message: 'Test error', + stacktrace: jasmine.any(Array), + }) + for (const frame of snapshot.captures.return.throwable.stacktrace) { + expect(frame).toEqual( + jasmine.objectContaining({ + fileName: jasmine.any(String), + function: jasmine.any(String), + lineNumber: jasmine.any(Number), + columnNumber: jasmine.any(Number), + }) + ) + } + }) + + it('should capture exception details', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'throwTest' }, + template: 'Test', + captureSnapshot: true, + capture: { maxReferenceDepth: 1 }, + sampling: {}, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const probes = getProbes('TestClass;throwTest')! + const error = new Error('Test error') + onEntry(probes, {}, { arg: 'value' }) + onThrow(probes, error, {}, { arg: 'value' }) + + expect(mockSendRawLog).toHaveBeenCalledTimes(1) + + const payload = mockSendRawLog.calls.mostRecent().args[0] + const snapshot = payload.debugger.snapshot + expect(snapshot.captures.return.throwable).toEqual({ + message: 'Test error', + stacktrace: jasmine.any(Array), + }) + for (const frame of snapshot.captures.return.throwable.stacktrace) { + expect(frame).toEqual( + jasmine.objectContaining({ + fileName: jasmine.any(String), + function: jasmine.any(String), + lineNumber: jasmine.any(Number), + columnNumber: jasmine.any(Number), + }) + ) + } + }) + + it('should evaluate EXIT condition with @exception', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'exceptionCondition' }, + when: { + dsl: '@exception.message', + json: { getmember: [{ ref: '@exception' }, 'message'] }, + }, + template: 'Exception captured', + captureSnapshot: false, + capture: { maxReferenceDepth: 1 }, + sampling: {}, + evaluateAt: 'EXIT', + } + addProbe(probe) + + const probes = getProbes('TestClass;exceptionCondition')! + const error = new Error('Test error') + onEntry(probes, {}, {}) + onThrow(probes, error, {}, {}) + + expect(mockSendRawLog).toHaveBeenCalledTimes(1) + }) + + it('should handle onThrow without preceding onEntry', () => { + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'throwWithoutEntry' }, + template: 'Test', + captureSnapshot: true, + capture: { maxReferenceDepth: 1 }, + sampling: {}, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const probes = getProbes('TestClass;throwWithoutEntry')! + const error = new Error('Test error') + onEntry(probes, {}, {}) + onThrow(probes, error, {}, {}) + + // Should work without errors + expect(mockSendRawLog).toHaveBeenCalledTimes(1) + }) + }) + + describe('global snapshot budget', () => { + it('should respect global snapshot rate limit', () => { + const probes: Probe[] = [] + for (let i = 0; i < 30; i++) { + const probe: Probe = { + id: `probe-${i}`, + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: `method${i}` }, + template: 'Test', + captureSnapshot: true, + capture: {}, + sampling: { snapshotsPerSecond: 5000 }, + evaluateAt: 'ENTRY', + } + addProbe(probe) + probes.push(probe) + } + + // Try to fire 30 probes rapidly + for (let i = 0; i < 30; i++) { + const probes = getProbes(`TestClass;method${i}`)! + onEntry(probes, {}, {}) + onReturn(probes, null, {}, {}, {}) + } + + // Should only get 25 calls (global limit) + expect(mockSendRawLog).toHaveBeenCalledTimes(25) + }) + }) + + describe('error handling', () => { + it('should handle missing DD_RUM gracefully', () => { + delete (window as any).DD_RUM + + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'errorHandling' }, + template: 'Test', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const probes = getProbes('TestClass;errorHandling')! + expect(() => { + onEntry(probes, {}, {}) + onReturn(probes, null, {}, {}, {}) + }).not.toThrow() + + // Should still send to DD_LOGS even without DD_RUM + expect(mockSendRawLog).toHaveBeenCalledTimes(1) + }) + + it('should handle missing DD_LOGS gracefully', () => { + delete (window as any).DD_LOGS + + const probe: Probe = { + id: 'test-probe', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'errorHandling' }, + template: 'Test', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + addProbe(probe) + + const probes = getProbes('TestClass;errorHandling')! + expect(() => { + onEntry(probes, {}, {}) + onReturn(probes, null, {}, {}, {}) + }).not.toThrow() + }) + }) +}) diff --git a/packages/debugger/src/domain/api.ts b/packages/debugger/src/domain/api.ts new file mode 100644 index 0000000000..908691aa55 --- /dev/null +++ b/packages/debugger/src/domain/api.ts @@ -0,0 +1,370 @@ +import type { Batch } from '@datadog/browser-core' + +import { timeStampNow, ErrorSource, display, buildTags, buildTag } from '@datadog/browser-core' +import type { LiveDebuggerPublicApi } from '../entries/main' +import type { LiveDebuggerInitConfiguration } from '../entries/main' +import { capture, captureFields } from './capture' +import type { InitializedProbe } from './probes' +import { checkGlobalSnapshotBudget } from './probes' +import { captureStackTrace, parseStackTrace } from './stacktrace' +import type { StackFrame } from './stacktrace' +import { evaluateProbeMessage } from './template' +import { evaluateProbeCondition } from './condition' + +interface ActiveEntry { + start: number + timestamp?: number + message?: string + entry?: { + arguments: Record + } + stack?: StackFrame[] + duration?: number + return?: { + arguments?: Record + locals?: Record + throwable?: { + message: string + stacktrace: StackFrame[] + } + } + exception?: Error +} + +interface BrowserWindow extends Window { + DD_DEBUGGER?: LiveDebuggerPublicApi +} + +const active = new Map>() + +// Cache hostname at module initialization since it won't change during the app lifetime +const hostname = typeof window !== 'undefined' && window.location ? window.location.hostname : 'unknown' + +const serviceVersion = `1.0.0-${crypto.randomUUID().slice(0, 8)}` // eslint-disable-line local-rules/disallow-side-effects + +const threadName = detectThreadName() // eslint-disable-line local-rules/disallow-side-effects + +// Lazy cache for application_id - once RUM is initialized, this won't change +let cachedApplicationId: string | undefined | null = null // null = not yet fetched, undefined = no app_id available + +let debuggerBatch: Batch | undefined +let debuggerConfig: LiveDebuggerInitConfiguration | undefined + +export function initDebuggerTransport(config: LiveDebuggerInitConfiguration, batch: Batch): void { + debuggerConfig = config + debuggerBatch = batch +} + +/** + * Called when entering an instrumented function + * + * @param probes - Array of probes for this function + * @param self - The 'this' context + * @param args - Function arguments + */ +export function onEntry(probes: InitializedProbe[], self: any, args: Record): void { + const start = performance.now() + + // TODO: A lot of repeated work performed for each probe that could be shared between probes + for (const probe of probes) { + let stack = active.get(probe.id) // TODO: Should we use the functionId instead? + if (!stack) { + stack = [] + active.set(probe.id, stack) + } + + // Skip if sampling budget is exceeded + if ( + start - probe.lastCaptureMs < probe.msBetweenSampling || + !checkGlobalSnapshotBudget(start, probe.captureSnapshot) + ) { + stack.push(null) + continue + } + + // Update last capture time + probe.lastCaptureMs = start + + let timestamp: number | undefined + let message: string | undefined + if (probe.evaluateAt === 'ENTRY') { + // Build context for condition and message evaluation + const context = { ...args, this: self } + + // Check condition - if it fails, don't evaluate or capture anything + if (!evaluateProbeCondition(probe, context)) { + // Still push to stack so onReturn/onThrow can pop it, but mark as skipped + stack.push(null) + continue + } + + timestamp = timeStampNow() + message = evaluateProbeMessage(probe, context) + } + + // Special case for evaluateAt=EXIT with a condition: we only capture the return snapshot + const shouldCaptureEntrySnapshot = probe.captureSnapshot && (probe.evaluateAt === 'ENTRY' || !probe.condition) + const entry = shouldCaptureEntrySnapshot + ? { + arguments: { + ...captureFields(args, probe.capture), + this: capture(self, probe.capture), + }, + } + : undefined + + stack.push({ + start, + timestamp, + message, + entry, + stack: probe.captureSnapshot ? captureStackTrace(1) : undefined, + }) + } +} + +/** + * Called when exiting an instrumented function normally + * + * @param probes - Array of probes for this function + * @param value - Return value + * @param self - The 'this' context + * @param args - Function arguments + * @param locals - Local variables + * @returns The return value (passed through) + */ +export function onReturn( + probes: InitializedProbe[], + value: any, + self: any, + args: Record, + locals: Record +): any { + const end = performance.now() + + // TODO: A lot of repeated work performed for each probe that could be shared between probes + for (const probe of probes) { + const stack = active.get(probe.id) // TODO: Should we use the functionId instead? + if (!stack) { + continue // TODO: This shouldn't be possible, do we need it? Should we warn? + } + const result = stack.pop() + if (!result) { + continue + } + + result.duration = end - result.start + + if (probe.evaluateAt === 'EXIT') { + result.timestamp = timeStampNow() + + const context = { + ...args, + ...locals, + this: self, + $dd_duration: result.duration, + $dd_return: value, + } + + if (!evaluateProbeCondition(probe, context)) { + continue + } + + result.message = evaluateProbeMessage(probe, context) + } + + result.return = probe.captureSnapshot + ? { + arguments: { + ...captureFields(args, probe.capture), + this: capture(self, probe.capture), + }, + locals: { + ...captureFields(locals, probe.capture), + '@return': capture(value, probe.capture), + }, + } + : undefined + + sendDebuggerSnapshot(probe, result) + } + + return value +} + +/** + * Called when exiting an instrumented function via exception + * + * @param probes - Array of probes for this function + * @param error - The thrown error + * @param self - The 'this' context + * @param args - Function arguments + */ +export function onThrow(probes: InitializedProbe[], error: Error, self: any, args: Record): void { + const end = performance.now() + + // TODO: A lot of repeated work performed for each probe that could be shared between probes + for (const probe of probes) { + const stack = active.get(probe.id) // TODO: Should we use the functionId instead? + if (!stack) { + continue // TODO: This shouldn't be possible, do we need it? Should we warn? + } + const result = stack.pop() + if (!result) { + continue + } + + result.duration = end - result.start + result.exception = error + + if (probe.evaluateAt === 'EXIT') { + result.timestamp = timeStampNow() + + const context = { + ...args, + this: self, + $dd_duration: result.duration, + $dd_exception: error, + } + + if (!evaluateProbeCondition(probe, context)) { + continue + } + + result.message = evaluateProbeMessage(probe, context) + } + + result.return = { + arguments: probe.captureSnapshot + ? { + ...captureFields(args, probe.capture), + this: capture(self, probe.capture), + } + : undefined, + throwable: { + message: error.message, + stacktrace: parseStackTrace(error), + }, + } + + sendDebuggerSnapshot(probe, result) + } +} + +/** + * Send a debugger snapshot to Datadog via the debugger's own transport. + * + * @param probe - The probe that was executed + * @param result - The result of the probe execution + */ +function sendDebuggerSnapshot(probe: any, result: ActiveEntry): void { + if (!debuggerBatch || !debuggerConfig) { + display.warn('Debugger transport is not initialized. Make sure DD_DEBUGGER.init() has been called.') + return + } + + const snapshot = { + id: crypto.randomUUID(), + timestamp: result.timestamp!, + probe: { + id: probe.id, + version: probe.version, + location: { + // TODO: Are our hardcoded where.* keys correct according to the spec? + method: probe.where.methodName, + type: probe.where.typeName, + }, + }, + stack: result.stack, + language: 'javascript', + duration: result.duration! * 1e6, // to nanoseconds + captures: + result.entry || result.return + ? { + entry: result.entry, + return: result.return, + } + : undefined, + } + + // Note: We inline the DD_RUM type instead of importing RumPublicApi from @datadog/browser-rum-core + // to avoid adding it as a peer dependency of this package. + const rumApi = (window as Window & { DD_RUM?: { getInternalContext?(): { application_id?: string; session_id?: string } | undefined } }).DD_RUM + const liveDebuggerApi = (window as BrowserWindow).DD_DEBUGGER + + // TODO: Fill out logger with the right information + const logger = { + name: probe.where.typeName, + method: probe.where.methodName, + version: liveDebuggerApi?.version, + // thread_id: 1, + thread_name: threadName, + } + + // Get the RUM internal context for trace correlation + const rumContext = rumApi?.getInternalContext?.() + const dd = { + trace_id: rumContext?.session_id, + span_id: rumContext?.user_action?.id || rumContext?.view?.id, + } + + const service = debuggerConfig.service // FIXME: Can we instead resolve the service name in the backend from the applicationId? + + // Get application_id from RUM internal context if available (same way regular loggers get it) + // Only cache if we get a value or confirm RUM is initialized (to handle late RUM initialization) + // TODO: Maybe don't keep re-trying if it fails? + if (cachedApplicationId === null) { + const ddRum = (window as any).DD_RUM + if (ddRum && typeof ddRum.getInternalContext === 'function') { + try { + const getInternalContext = ddRum.getInternalContext as ( + startTime?: number + ) => { application_id?: string } | undefined + const rumInternalContext = getInternalContext() + cachedApplicationId = rumInternalContext?.application_id + } catch { + // ignore + } + } + } + + // Build ddtags from the debugger's own configuration + const configTags = buildTags(debuggerConfig as any) + const ddtags = configTags.concat( + // buildTag('source', 'dd_debugger'), + buildTag('version', serviceVersion), + buildTag('debugger_version', liveDebuggerApi?.version), + buildTag('host_name', hostname) // TODO: Is this needed? + // buildTag('git.commit.sha', 'fd8163131f3150b86b792eee85eb583df81615da'), + // buildTag('git.repository_url', 'https://github.com/datadog/debugger-demos'), + ) + + const payload = { + date: (snapshot as any).timestamp, // TODO: This isn't in the backend tracer payloads + message: result.message || '', + status: 'info' as const, + origin: ErrorSource.LOGGER, // TODO: This isn't in the backend tracer payloads + hostname, + ...(service && { service }), + ...(ddtags.length > 0 && { ddtags: ddtags.join(',') }), + ...(cachedApplicationId && { application_id: cachedApplicationId }), // TODO: Is this even needed? + logger, + dd, + debugger: { snapshot }, + } + + debuggerBatch.add(payload as any) +} + +function detectThreadName() { + if (typeof window !== 'undefined' && typeof document !== 'undefined') { + return 'main' + } + if (typeof ServiceWorkerGlobalScope !== 'undefined' && self instanceof ServiceWorkerGlobalScope) { + return 'service-worker' + } + if (typeof importScripts === 'function') { + return 'web-worker' + } + return 'unknown' +} diff --git a/packages/debugger/src/domain/capture.spec.ts b/packages/debugger/src/domain/capture.spec.ts new file mode 100644 index 0000000000..75cdcfde92 --- /dev/null +++ b/packages/debugger/src/domain/capture.spec.ts @@ -0,0 +1,530 @@ +import { capture, captureFields } from './capture' + +describe('capture', () => { + const defaultOpts = { + maxReferenceDepth: 3, + maxCollectionSize: 100, + maxFieldCount: 20, + maxLength: 255, + } + + describe('primitive types', () => { + it('should capture null', () => { + const result = capture(null, defaultOpts) + expect(result).toEqual({ type: 'null', isNull: true }) + }) + + it('should capture undefined', () => { + const result = capture(undefined, defaultOpts) + expect(result).toEqual({ type: 'undefined' }) + }) + + it('should capture boolean', () => { + expect(capture(true, defaultOpts)).toEqual({ type: 'boolean', value: 'true' }) + expect(capture(false, defaultOpts)).toEqual({ type: 'boolean', value: 'false' }) + }) + + it('should capture number', () => { + expect(capture(42, defaultOpts)).toEqual({ type: 'number', value: '42' }) + expect(capture(3.14, defaultOpts)).toEqual({ type: 'number', value: '3.14' }) + expect(capture(NaN, defaultOpts)).toEqual({ type: 'number', value: 'NaN' }) + expect(capture(Infinity, defaultOpts)).toEqual({ type: 'number', value: 'Infinity' }) + }) + + it('should capture string', () => { + const result = capture('hello', defaultOpts) + expect(result).toEqual({ type: 'string', value: 'hello' }) + }) + + it('should capture bigint', () => { + const result = capture(BigInt(123), defaultOpts) + expect(result).toEqual({ type: 'bigint', value: '123' }) + }) + + it('should capture symbol', () => { + const sym = Symbol('test') + const result = capture(sym, defaultOpts) + expect(result).toEqual({ type: 'symbol', value: 'test' }) + }) + + it('should capture symbol without description', () => { + const sym = Symbol() + const result = capture(sym, defaultOpts) + expect(result).toEqual({ type: 'symbol', value: '' }) + }) + }) + + describe('string truncation', () => { + it('should truncate long strings', () => { + const longString = 'a'.repeat(300) + const result = capture(longString, { ...defaultOpts, maxLength: 10 }) + + expect(result).toEqual({ + type: 'string', + value: 'aaaaaaaaaa', + truncated: true, + size: 300, + }) + }) + + it('should not truncate strings under maxLength', () => { + const result = capture('short', { ...defaultOpts, maxLength: 10 }) + expect(result).toEqual({ type: 'string', value: 'short' }) + }) + }) + + describe('built-in objects', () => { + it('should capture Date', () => { + const date = new Date('2024-01-01T00:00:00.000Z') + const result = capture(date, defaultOpts) + expect(result).toEqual({ type: 'Date', value: '2024-01-01T00:00:00.000Z' }) + }) + + it('should capture RegExp', () => { + const regex = /test/gi + const result = capture(regex, defaultOpts) + expect(result).toEqual({ type: 'RegExp', value: '/test/gi' }) + }) + + it('should capture Error', () => { + const error = new Error('test error') + const result = capture(error, defaultOpts) as any + + expect(result).toEqual({ + type: 'Error', + fields: { + message: { type: 'string', value: 'test error' }, + name: { type: 'string', value: 'Error' }, + stack: { type: 'string', value: jasmine.any(String), truncated: true, size: error.stack!.length }, + }, + }) + }) + + it('should capture custom Error types', () => { + class CustomError extends Error { + constructor(message: string) { + super(message) + this.name = 'CustomError' + } + } + const error = new CustomError('custom error') + const result = capture(error, defaultOpts) as any + + expect(result.type).toBe('CustomError') + expect(result.fields.name).toEqual({ type: 'string', value: 'CustomError' }) + }) + + it('should capture Error with cause', () => { + const cause = new Error('cause error') + // @ts-expect-error - cause is not a valid argument for Error constructor + const error = new Error('main error', { cause }) + const result = capture(error, defaultOpts) as any + + expect(result.fields.cause).toEqual({ + type: 'Error', + fields: { + message: { type: 'string', value: 'cause error' }, + name: { type: 'string', value: 'Error' }, + stack: { type: 'string', value: jasmine.any(String), truncated: true, size: cause.stack!.length }, + }, + }) + }) + + it('should capture Promise', () => { + const promise = Promise.resolve(42) + const result = capture(promise, defaultOpts) + expect(result).toEqual({ type: 'Promise', notCapturedReason: 'Promise state cannot be inspected' }) + }) + }) + + describe('arrays', () => { + it('should capture array', () => { + const arr = [1, 'two', true] + const result = capture(arr, defaultOpts) as any + + expect(result.type).toBe('Array') + expect(result.elements).toEqual([ + { type: 'number', value: '1' }, + { type: 'string', value: 'two' }, + { type: 'boolean', value: 'true' }, + ]) + }) + + it('should truncate large arrays', () => { + const arr = Array(200).fill(1) + const result = capture(arr, { ...defaultOpts, maxCollectionSize: 3 }) as any + + expect(result.type).toBe('Array') + expect(result.elements.length).toBe(3) + expect(result.notCapturedReason).toBe('collectionSize') + expect(result.size).toBe(200) + }) + + it('should handle nested arrays', () => { + const arr = [ + [1, 2], + [3, 4], + ] + const result = capture(arr, defaultOpts) as any + + expect(result.type).toBe('Array') + expect(result.elements[0].type).toBe('Array') + expect(result.elements[0].elements).toEqual([ + { type: 'number', value: '1' }, + { type: 'number', value: '2' }, + ]) + }) + }) + + describe('Map and Set', () => { + it('should capture Map', () => { + const map = new Map([ + ['key1', 'value1'], + ['key2', 42], + ]) + const result = capture(map, defaultOpts) as any + + expect(result.type).toBe('Map') + expect(result.entries).toEqual([ + [ + { type: 'string', value: 'key1' }, + { type: 'string', value: 'value1' }, + ], + [ + { type: 'string', value: 'key2' }, + { type: 'number', value: '42' }, + ], + ]) + }) + + it('should truncate large Maps', () => { + const map = new Map() + for (let i = 0; i < 200; i++) { + map.set(`key${i}`, i) + } + const result = capture(map, { ...defaultOpts, maxCollectionSize: 3 }) as any + + expect(result.entries.length).toBe(3) + expect(result.notCapturedReason).toBe('collectionSize') + expect(result.size).toBe(200) + }) + + it('should capture Set', () => { + const set = new Set([1, 'two', true]) + const result = capture(set, defaultOpts) as any + + expect(result.type).toBe('Set') + expect(result.elements).toEqual([ + { type: 'number', value: '1' }, + { type: 'string', value: 'two' }, + { type: 'boolean', value: 'true' }, + ]) + }) + + it('should truncate large Sets', () => { + const set = new Set() + for (let i = 0; i < 200; i++) { + set.add(i) + } + const result = capture(set, { ...defaultOpts, maxCollectionSize: 3 }) as any + + expect(result.elements.length).toBe(3) + expect(result.notCapturedReason).toBe('collectionSize') + expect(result.size).toBe(200) + }) + + it('should handle WeakMap', () => { + const weakMap = new WeakMap() + const result = capture(weakMap, defaultOpts) + expect(result).toEqual({ type: 'WeakMap', notCapturedReason: 'WeakMap contents cannot be enumerated' }) + }) + + it('should handle WeakSet', () => { + const weakSet = new WeakSet() + const result = capture(weakSet, defaultOpts) + expect(result).toEqual({ type: 'WeakSet', notCapturedReason: 'WeakSet contents cannot be enumerated' }) + }) + }) + + describe('objects', () => { + it('should capture plain object', () => { + const obj = { a: 1, b: 'two' } + const result = capture(obj, defaultOpts) as any + + expect(result.type).toBe('Object') + expect(result.fields.a).toEqual({ type: 'number', value: '1' }) + expect(result.fields.b).toEqual({ type: 'string', value: 'two' }) + }) + + it('should capture nested objects', () => { + const obj = { outer: { inner: 'value' } } + const result = capture(obj, defaultOpts) as any + + expect(result.fields.outer.type).toBe('Object') + expect(result.fields.outer.fields.inner).toEqual({ type: 'string', value: 'value' }) + }) + + it('should respect maxReferenceDepth', () => { + const obj = { level1: { level2: { level3: { level4: 'deep' } } } } + const result = capture(obj, { ...defaultOpts, maxReferenceDepth: 2 }) as any + + expect(result.fields.level1.fields.level2.notCapturedReason).toBe('depth') + }) + + it('should truncate objects with many fields', () => { + const obj: any = {} + for (let i = 0; i < 30; i++) { + obj[`field${i}`] = i + } + const result = capture(obj, { ...defaultOpts, maxFieldCount: 5 }) as any + + expect(Object.keys(result.fields).length).toBe(5) + expect(result.notCapturedReason).toBe('fieldCount') + expect(result.size).toBe(30) + }) + + it('should handle objects with symbol keys', () => { + const sym = Symbol('test') + const obj = { [sym]: 'value' } + const result = capture(obj, defaultOpts) as any + + expect(result.fields.test).toEqual({ type: 'string', value: 'value' }) + }) + + it('should escape dots in field names', () => { + const obj = { 'field.with.dots': 'value' } + const result = capture(obj, defaultOpts) as any + + expect(result.fields.field_with_dots).toEqual({ type: 'string', value: 'value' }) + }) + + it('should handle getters that throw', () => { + const obj = { + get throwing() { + throw new Error('getter error') + }, + } + const result = capture(obj, defaultOpts) as any + + expect(result.fields.throwing).toEqual({ + type: 'undefined', + notCapturedReason: 'Error accessing property', + }) + }) + + it('should capture custom class instances', () => { + class MyClass { + public field = 'value' + } + const instance = new MyClass() + const result = capture(instance, defaultOpts) as any + + expect(result.type).toBe('MyClass') + expect(result.fields.field).toEqual({ type: 'string', value: 'value' }) + }) + }) + + describe('functions', () => { + it('should capture function', () => { + function myFunc() {} // eslint-disable-line @typescript-eslint/no-empty-function + const result = capture(myFunc, defaultOpts) as any + + expect(result.type).toBe('Function') + }) + + it('should capture class as class', () => { + class MyClass {} + const result = capture(MyClass, defaultOpts) + + expect(result.type).toBe('class MyClass') + }) + + it('should capture anonymous class', () => { + const AnonymousClass = class {} + const result = capture(AnonymousClass, defaultOpts) + + expect(result.type).toBe('class') + }) + + it('should respect depth for functions', () => { + function myFunc() {} // eslint-disable-line @typescript-eslint/no-empty-function + const result = capture(myFunc, { ...defaultOpts, maxReferenceDepth: 0 }) + + expect(result).toEqual({ type: 'Function', notCapturedReason: 'depth' }) + }) + }) + + describe('binary data', () => { + it('should capture ArrayBuffer', () => { + const buffer = new ArrayBuffer(16) + const result = capture(buffer, defaultOpts) + + expect(result).toEqual({ + type: 'ArrayBuffer', + value: '[ArrayBuffer(16)]', + }) + }) + + it('should capture SharedArrayBuffer', () => { + if (typeof SharedArrayBuffer === 'undefined') { + // Skip test if SharedArrayBuffer is not available + return + } + const buffer = new SharedArrayBuffer(16) + const result = capture(buffer, defaultOpts) + + expect(result).toEqual({ + type: 'SharedArrayBuffer', + value: '[SharedArrayBuffer(16)]', + }) + }) + + it('should capture DataView', () => { + const buffer = new ArrayBuffer(16) + const view = new DataView(buffer, 4, 8) + const result = capture(view, defaultOpts) as any + + expect(result.type).toBe('DataView') + expect(result.fields.byteLength).toEqual({ type: 'number', value: '8' }) + expect(result.fields.byteOffset).toEqual({ type: 'number', value: '4' }) + expect(result.fields.buffer).toEqual({ type: 'ArrayBuffer', value: '[ArrayBuffer(16)]' }) + }) + + it('should capture Uint8Array', () => { + const arr = new Uint8Array([1, 2, 3]) + const result = capture(arr, defaultOpts) as any + + expect(result.type).toBe('Uint8Array') + expect(result.elements).toEqual([ + { type: 'number', value: '1' }, + { type: 'number', value: '2' }, + { type: 'number', value: '3' }, + ]) + expect(result.fields.byteLength).toEqual({ type: 'number', value: '3' }) + expect(result.fields.length).toEqual({ type: 'number', value: '3' }) + }) + + it('should truncate large TypedArrays', () => { + const arr = new Uint8Array(200) + const result = capture(arr, { ...defaultOpts, maxCollectionSize: 3 }) as any + + expect(result.elements.length).toBe(3) + expect(result.notCapturedReason).toBe('collectionSize') + expect(result.size).toBe(200) + }) + }) + + describe('circular references', () => { + it('should handle circular references by respecting depth limit', () => { + const obj: any = { name: 'root' } + obj.self = obj + const result = capture(obj, { ...defaultOpts, maxReferenceDepth: 1 }) as any + + expect(result.fields.name).toEqual({ type: 'string', value: 'root' }) + expect(result.fields.self.notCapturedReason).toBe('depth') + }) + }) +}) + +describe('captureFields', () => { + const defaultOpts = { + maxReferenceDepth: 3, + maxCollectionSize: 100, + maxFieldCount: 20, + maxLength: 255, + } + + it('should return fields directly without wrapper', () => { + const obj = { a: 1, b: 'hello', c: true } + const result = captureFields(obj, defaultOpts) + + // Should be Record, not CapturedValue + expect(result).toEqual({ + a: { type: 'number', value: '1' }, + b: { type: 'string', value: 'hello' }, + c: { type: 'boolean', value: 'true' }, + }) + + // Should NOT have type/fields wrapper + expect((result as any).type).toBeUndefined() + expect((result as any).fields).toBeUndefined() + }) + + it('should capture nested objects in fields', () => { + const obj = { + name: 'test', + nested: { value: 42 }, + } + const result = captureFields(obj, defaultOpts) + + expect(result).toEqual({ + name: { type: 'string', value: 'test' }, + nested: { + type: 'Object', + fields: { + value: { type: 'number', value: '42' }, + }, + }, + }) + }) + + it('should respect maxFieldCount', () => { + const obj = { a: 1, b: 2, c: 3, d: 4, e: 5 } + const result = captureFields(obj, { ...defaultOpts, maxFieldCount: 3 }) + + const keys = Object.keys(result) + expect(keys.length).toBe(3) + }) + + it('should respect maxReferenceDepth', () => { + const obj = { + level1: { + level2: { + level3: 'deep', + }, + }, + } + const result = captureFields(obj, { ...defaultOpts, maxReferenceDepth: 2 }) + + expect(result.level1).toEqual({ + type: 'Object', + fields: { + level2: { + type: 'Object', + notCapturedReason: 'depth', + }, + }, + }) + }) + + it('should handle properties with dots in names', () => { + const obj = { 'some.property': 'value' } + const result = captureFields(obj, defaultOpts) + + expect(result['some_property']).toEqual({ type: 'string', value: 'value' }) + }) + + it('should handle symbol keys', () => { + const sym = Symbol('test') + const obj = { [sym]: 'symbolValue' } + const result = captureFields(obj, defaultOpts) + + expect(result.test).toEqual({ type: 'string', value: 'symbolValue' }) + }) + + it('should handle property access errors', () => { + const obj = {} + Object.defineProperty(obj, 'throwing', { + get() { + throw new Error('Access denied') + }, + enumerable: true, + }) + const result = captureFields(obj, defaultOpts) + + expect(result.throwing).toEqual({ + type: 'undefined', + notCapturedReason: 'Error accessing property', + }) + }) +}) diff --git a/packages/debugger/src/domain/capture.ts b/packages/debugger/src/domain/capture.ts new file mode 100644 index 0000000000..0f1b171e6a --- /dev/null +++ b/packages/debugger/src/domain/capture.ts @@ -0,0 +1,494 @@ +export interface CaptureOptions { + maxReferenceDepth?: number + maxCollectionSize?: number + maxFieldCount?: number + maxLength?: number +} + +export interface CapturedValue { + type: string + value?: string + isNull?: boolean + truncated?: boolean + size?: number + notCapturedReason?: string + fields?: Record + elements?: CapturedValue[] + entries?: Array<[CapturedValue, CapturedValue]> +} + +const hasReplaceAll = typeof (String.prototype as any).replaceAll === 'function' +const replaceDots = hasReplaceAll + ? // @ts-expect-error - replaceAll is not a function on String.prototype in older browsers + (str: string) => str.replaceAll('.', '_') // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return + : (str: string) => str.replace(/\./g, '_') + +const DEFAULT_MAX_REFERENCE_DEPTH = 3 +const DEFAULT_MAX_COLLECTION_SIZE = 100 +const DEFAULT_MAX_FIELD_COUNT = 20 +const DEFAULT_MAX_LENGTH = 255 + +/** + * Capture the value of the given object with configurable limits + * + * @param value - The value to capture + * @param opts - The capture options + * @param opts.maxReferenceDepth - The maximum depth of references to capture + * @param opts.maxCollectionSize - The maximum size of collections to capture + * @param opts.maxFieldCount - The maximum number of fields to capture + * @param opts.maxLength - The maximum length of strings to capture + * @returns The captured value representation + */ +export function capture( + value: unknown, + { + maxReferenceDepth = DEFAULT_MAX_REFERENCE_DEPTH, + maxCollectionSize = DEFAULT_MAX_COLLECTION_SIZE, + maxFieldCount = DEFAULT_MAX_FIELD_COUNT, + maxLength = DEFAULT_MAX_LENGTH, + }: CaptureOptions +): CapturedValue { + return captureValue(value, 0, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) +} + +/** + * Capture the fields of an object directly without the outer CapturedValue wrapper + * + * @param obj - The object to capture + * @param opts - The capture options + * @param opts.maxReferenceDepth - The maximum depth of references to capture + * @param opts.maxCollectionSize - The maximum size of collections to capture + * @param opts.maxFieldCount - The maximum number of fields to capture + * @param opts.maxLength - The maximum length of strings to capture + * @returns A record mapping property names to their captured values + */ +export function captureFields( + obj: object, + { + maxReferenceDepth = DEFAULT_MAX_REFERENCE_DEPTH, + maxCollectionSize = DEFAULT_MAX_COLLECTION_SIZE, + maxFieldCount = DEFAULT_MAX_FIELD_COUNT, + maxLength = DEFAULT_MAX_LENGTH, + }: CaptureOptions +): Record { + return captureObjectPropertiesFields(obj, 0, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) +} + +function captureValue( + value: unknown, + depth: number, + maxReferenceDepth: number, + maxCollectionSize: number, + maxFieldCount: number, + maxLength: number +): CapturedValue { + // Handle null first as typeof null === 'object' + if (value === null) { + return { type: 'null', isNull: true } + } + + const type = typeof value + + switch (type) { + case 'undefined': + return { type: 'undefined' } + case 'boolean': + return { type: 'boolean', value: String(value) } // eslint-disable-line @typescript-eslint/no-base-to-string + case 'number': + return { type: 'number', value: String(value) } // eslint-disable-line @typescript-eslint/no-base-to-string + case 'string': + return captureString(value as string, maxLength) + case 'symbol': + return { type: 'symbol', value: (value as symbol).description || '' } + case 'bigint': + return { type: 'bigint', value: String(value) } // eslint-disable-line @typescript-eslint/no-base-to-string + case 'function': + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return captureFunction(value as Function, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + case 'object': + return captureObject(value as object, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + default: + return { type: String(type), notCapturedReason: 'Unsupported type' } + } +} + +function captureString(str: string, maxLength: number): CapturedValue { + const size = str.length + + if (size <= maxLength) { + return { type: 'string', value: str } + } + + return { + type: 'string', + value: str.slice(0, maxLength), + truncated: true, + size, + } +} + +function captureFunction( + fn: Function, // eslint-disable-line @typescript-eslint/no-unsafe-function-type + depth: number, + maxReferenceDepth: number, + maxCollectionSize: number, + maxFieldCount: number, + maxLength: number +): CapturedValue { + // Check if it's a class by converting to string and checking for 'class' keyword + const fnStr = Function.prototype.toString.call(fn) + const classMatch = fnStr.match(/^class\s([^{]*)/) + + if (classMatch !== null) { + // This is a class + const className = classMatch[1].trim() + return { type: className ? `class ${className}` : 'class' } + } + + // This is a function - serialize it as an object with its properties + if (depth >= maxReferenceDepth) { + return { type: 'Function', notCapturedReason: 'depth' } + } + + return captureObjectProperties( + fn as any, + 'Function', + depth, + maxReferenceDepth, + maxCollectionSize, + maxFieldCount, + maxLength + ) +} + +function captureObject( + obj: object, + depth: number, + maxReferenceDepth: number, + maxCollectionSize: number, + maxFieldCount: number, + maxLength: number +): CapturedValue { + if (depth >= maxReferenceDepth) { + return { type: (obj as any).constructor?.name ?? 'Object', notCapturedReason: 'depth' } + } + + // Built-in objects with specialized serialization + if (obj instanceof Date) { + return { type: 'Date', value: obj.toISOString() } + } + if (obj instanceof RegExp) { + return { type: 'RegExp', value: obj.toString() } + } + if (obj instanceof Error) { + return captureError(obj, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + } + if (obj instanceof Promise) { + return { type: 'Promise', notCapturedReason: 'Promise state cannot be inspected' } + } + + // Collections + if (Array.isArray(obj)) { + return captureArray(obj, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + } + if (obj instanceof Map) { + return captureMap(obj, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + } + if (obj instanceof Set) { + return captureSet(obj, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + } + if (obj instanceof WeakMap) { + return { type: 'WeakMap', notCapturedReason: 'WeakMap contents cannot be enumerated' } + } + if (obj instanceof WeakSet) { + return { type: 'WeakSet', notCapturedReason: 'WeakSet contents cannot be enumerated' } + } + + // Binary data + if (obj instanceof ArrayBuffer) { + return captureArrayBuffer(obj) + } + if (typeof SharedArrayBuffer !== 'undefined' && obj instanceof SharedArrayBuffer) { + return captureSharedArrayBuffer(obj) + } + if (obj instanceof DataView) { + return captureDataView(obj) + } + if (ArrayBuffer.isView(obj) && !(obj instanceof DataView)) { + return captureTypedArray(obj as TypedArray, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + } + + // Custom objects + const typeName = (obj as any).constructor?.name ?? 'Object' + return captureObjectProperties(obj, typeName, depth, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) +} + +function captureObjectPropertiesFields( + obj: any, + depth: number, + maxReferenceDepth: number, + maxCollectionSize: number, + maxFieldCount: number, + maxLength: number +): Record { + const keys = Object.getOwnPropertyNames(obj) + const symbolKeys = Object.getOwnPropertySymbols(obj) + const allKeys: Array = (keys as Array).concat(symbolKeys) + + const keysToCapture = allKeys.slice(0, maxFieldCount) + + const fields: Record = {} + for (const key of keysToCapture) { + const keyStr = String(key) + const keyName = + typeof key === 'symbol' ? key.description || key.toString() : keyStr.includes('.') ? replaceDots(keyStr) : keyStr + + try { + const propValue = obj[key] + fields[keyName] = captureValue( + propValue, + depth + 1, + maxReferenceDepth, + maxCollectionSize, + maxFieldCount, + maxLength + ) + } catch { + // Handle getters that throw or other access errors + fields[keyName] = { type: 'undefined', notCapturedReason: 'Error accessing property' } + } + } + + return fields +} + +function captureObjectProperties( + obj: any, + typeName: string, + depth: number, + maxReferenceDepth: number, + maxCollectionSize: number, + maxFieldCount: number, + maxLength: number +): CapturedValue { + const keys = Object.getOwnPropertyNames(obj) + const symbolKeys = Object.getOwnPropertySymbols(obj) + const allKeys: Array = (keys as Array).concat(symbolKeys) + const totalFields = allKeys.length + + const fields = captureObjectPropertiesFields( + obj, + depth, + maxReferenceDepth, + maxCollectionSize, + maxFieldCount, + maxLength + ) + + const result: CapturedValue = { type: typeName, fields } + + if (totalFields > maxFieldCount) { + result.notCapturedReason = 'fieldCount' + result.size = totalFields + } + + return result +} + +function captureArray( + arr: unknown[], + depth: number, + maxReferenceDepth: number, + maxCollectionSize: number, + maxFieldCount: number, + maxLength: number +): CapturedValue { + const totalSize = arr.length + const itemsToCapture = Math.min(totalSize, maxCollectionSize) + + const elements: CapturedValue[] = [] + for (let i = 0; i < itemsToCapture; i++) { + elements.push(captureValue(arr[i], depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength)) + } + + const result: CapturedValue = { type: 'Array', elements } + + if (totalSize > maxCollectionSize) { + result.notCapturedReason = 'collectionSize' + result.size = totalSize + } + + return result +} + +function captureMap( + map: Map, + depth: number, + maxReferenceDepth: number, + maxCollectionSize: number, + maxFieldCount: number, + maxLength: number +): CapturedValue { + const totalSize = map.size + const entriesToCapture = Math.min(totalSize, maxCollectionSize) + + const entries: Array<[CapturedValue, CapturedValue]> = [] + let count = 0 + for (const [key, value] of map) { + if (count >= entriesToCapture) { + break + } + entries.push([ + captureValue(key, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength), + captureValue(value, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength), + ]) + count++ + } + + const result: CapturedValue = { type: 'Map', entries } + + if (totalSize > maxCollectionSize) { + result.notCapturedReason = 'collectionSize' + result.size = totalSize + } + + return result +} + +function captureSet( + set: Set, + depth: number, + maxReferenceDepth: number, + maxCollectionSize: number, + maxFieldCount: number, + maxLength: number +): CapturedValue { + const totalSize = set.size + const itemsToCapture = Math.min(totalSize, maxCollectionSize) + + const elements: CapturedValue[] = [] + let count = 0 + for (const value of set) { + if (count >= itemsToCapture) { + break + } + elements.push(captureValue(value, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength)) + count++ + } + + const result: CapturedValue = { type: 'Set', elements } + + if (totalSize > maxCollectionSize) { + result.notCapturedReason = 'collectionSize' + result.size = totalSize + } + + return result +} + +function captureError( + err: Error, + depth: number, + maxReferenceDepth: number, + maxCollectionSize: number, + maxFieldCount: number, + maxLength: number +): CapturedValue { + const typeName = (err as any).constructor?.name ?? 'Error' + const fields: Record = { + message: captureValue(err.message, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength), + name: captureValue(err.name, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength), + } + + if (err.stack !== undefined) { + fields.stack = captureValue(err.stack, depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + } + + if ((err as any).cause !== undefined) { + fields.cause = captureValue( + (err as any).cause, + depth + 1, + maxReferenceDepth, + maxCollectionSize, + maxFieldCount, + maxLength + ) + } + + return { type: typeName, fields } +} + +function captureArrayBuffer(buffer: ArrayBuffer): CapturedValue { + return { + type: 'ArrayBuffer', + value: `[ArrayBuffer(${buffer.byteLength})]`, + } +} + +function captureSharedArrayBuffer(buffer: SharedArrayBuffer): CapturedValue { + return { + type: 'SharedArrayBuffer', + value: `[SharedArrayBuffer(${buffer.byteLength})]`, + } +} + +function captureDataView(view: DataView): CapturedValue { + return { + type: 'DataView', + fields: { + byteLength: { type: 'number', value: String(view.byteLength) }, + byteOffset: { type: 'number', value: String(view.byteOffset) }, + buffer: { type: 'ArrayBuffer', value: `[ArrayBuffer(${view.buffer.byteLength})]` }, + }, + } +} + +type TypedArray = + | Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Float32Array + | Float64Array + | BigInt64Array + | BigUint64Array + +function captureTypedArray( + typedArray: TypedArray, + depth: number, + maxReferenceDepth: number, + maxCollectionSize: number, + maxFieldCount: number, + maxLength: number +): CapturedValue { + const typeName = typedArray.constructor?.name ?? 'TypedArray' + const totalSize = typedArray.length + const itemsToCapture = Math.min(totalSize, maxCollectionSize) + + const elements: CapturedValue[] = [] + for (let i = 0; i < itemsToCapture; i++) { + elements.push( + captureValue(typedArray[i], depth + 1, maxReferenceDepth, maxCollectionSize, maxFieldCount, maxLength) + ) + } + + const result: CapturedValue = { + type: typeName, + elements, + fields: { + byteLength: { type: 'number', value: String(typedArray.byteLength) }, + byteOffset: { type: 'number', value: String(typedArray.byteOffset) }, + length: { type: 'number', value: String(typedArray.length) }, + }, + } + + if (totalSize > maxCollectionSize) { + result.notCapturedReason = 'collectionSize' + result.size = totalSize + } + + return result +} diff --git a/packages/debugger/src/domain/condition.spec.ts b/packages/debugger/src/domain/condition.spec.ts new file mode 100644 index 0000000000..0b4d96ef66 --- /dev/null +++ b/packages/debugger/src/domain/condition.spec.ts @@ -0,0 +1,178 @@ +import { display } from '@datadog/browser-core' +import { evaluateProbeCondition } from './condition' + +describe('condition', () => { + let displayErrorSpy: jasmine.Spy + + beforeEach(() => { + displayErrorSpy = spyOn(display, 'error') + }) + + describe('evaluateProbeCondition', () => { + it('should return true when probe has no condition', () => { + const probe: any = {} + const result = evaluateProbeCondition(probe, {}) + + expect(result).toBe(true) + }) + + it('should return true for simple true condition', () => { + const probe: any = { + condition: 'true', + } + const result = evaluateProbeCondition(probe, {}) + + expect(result).toBe(true) + }) + + it('should return false for simple false condition', () => { + const probe: any = { + condition: 'false', + } + const result = evaluateProbeCondition(probe, {}) + + expect(result).toBe(false) + }) + + it('should evaluate condition with context variables', () => { + const probe: any = { + condition: 'x > 5', + } + + expect(evaluateProbeCondition(probe, { x: 10 })).toBe(true) + expect(evaluateProbeCondition(probe, { x: 3 })).toBe(false) + }) + + it('should evaluate complex conditions', () => { + const probe: any = { + condition: 'x > 5 && y < 20', + } + + expect(evaluateProbeCondition(probe, { x: 10, y: 15 })).toBe(true) + expect(evaluateProbeCondition(probe, { x: 3, y: 15 })).toBe(false) + expect(evaluateProbeCondition(probe, { x: 10, y: 25 })).toBe(false) + }) + + it('should evaluate conditions with string operations', () => { + const probe: any = { + condition: 'name === "John"', + } + + expect(evaluateProbeCondition(probe, { name: 'John' })).toBe(true) + expect(evaluateProbeCondition(probe, { name: 'Jane' })).toBe(false) + }) + + it('should evaluate conditions with multiple variables', () => { + const probe: any = { + condition: 'a + b === 10', + } + + expect(evaluateProbeCondition(probe, { a: 5, b: 5 })).toBe(true) + expect(evaluateProbeCondition(probe, { a: 3, b: 4 })).toBe(false) + }) + + it('should coerce non-boolean results to boolean', () => { + const probe: any = { + condition: 'x', + } + + expect(evaluateProbeCondition(probe, { x: 1 })).toBe(true) + expect(evaluateProbeCondition(probe, { x: 0 })).toBe(false) + expect(evaluateProbeCondition(probe, { x: 'hello' })).toBe(true) + expect(evaluateProbeCondition(probe, { x: '' })).toBe(false) + expect(evaluateProbeCondition(probe, { x: null })).toBe(false) + expect(evaluateProbeCondition(probe, { x: undefined })).toBe(false) + }) + + it('should handle condition evaluation errors gracefully', () => { + const probe: any = { + id: 'test-probe', + condition: 'nonExistent.property', + } + + // Should return true (fire probe) when condition evaluation fails + const result = evaluateProbeCondition(probe, {}) + expect(result).toBe(true) + + // Should log error + expect(displayErrorSpy).toHaveBeenCalledWith( + jasmine.stringContaining('Failed to evaluate condition for probe test-probe'), + jasmine.any(Error) + ) + }) + + it('should handle syntax errors in condition', () => { + const probe: any = { + condition: 'invalid syntax !!!', + } + + const result = evaluateProbeCondition(probe, {}) + expect(result).toBe(true) + expect(displayErrorSpy).toHaveBeenCalled() + }) + + it('should handle conditions with special variables', () => { + const probe: any = { + condition: '$dd_return > 0', // Compiled condition (@ prefix already converted to $dd_) + } + + expect(evaluateProbeCondition(probe, { $dd_return: 10 })).toBe(true) + expect(evaluateProbeCondition(probe, { $dd_return: -5 })).toBe(false) + }) + + it('should handle conditions with this context', () => { + const probe: any = { + condition: 'this.value === 42', + } + + expect(evaluateProbeCondition(probe, { this: { value: 42 } })).toBe(true) + expect(evaluateProbeCondition(probe, { this: { value: 10 } })).toBe(false) + }) + + it('should handle array operations', () => { + const probe: any = { + condition: 'arr.length > 0', + } + + expect(evaluateProbeCondition(probe, { arr: [1, 2, 3] })).toBe(true) + expect(evaluateProbeCondition(probe, { arr: [] })).toBe(false) + }) + + it('should handle object property checks', () => { + const probe: any = { + condition: 'obj.hasOwnProperty("key")', + } + + expect(evaluateProbeCondition(probe, { obj: { key: 'value' } })).toBe(true) + expect(evaluateProbeCondition(probe, { obj: {} })).toBe(false) + }) + + it('should handle typeof checks', () => { + const probe: any = { + condition: 'typeof value === "number"', + } + + expect(evaluateProbeCondition(probe, { value: 42 })).toBe(true) + expect(evaluateProbeCondition(probe, { value: 'string' })).toBe(false) + }) + + it('should handle nested property access', () => { + const probe: any = { + condition: 'user.profile.age >= 18', + } + + expect(evaluateProbeCondition(probe, { user: { profile: { age: 25 } } })).toBe(true) + expect(evaluateProbeCondition(probe, { user: { profile: { age: 15 } } })).toBe(false) + }) + + it('should handle null/undefined checks', () => { + const probe: any = { + condition: 'value !== null && value !== undefined', + } + + expect(evaluateProbeCondition(probe, { value: 42 })).toBe(true) + expect(evaluateProbeCondition(probe, { value: null })).toBe(false) + expect(evaluateProbeCondition(probe, { value: undefined })).toBe(false) + }) + }) +}) diff --git a/packages/debugger/src/domain/condition.ts b/packages/debugger/src/domain/condition.ts new file mode 100644 index 0000000000..4043f09bc2 --- /dev/null +++ b/packages/debugger/src/domain/condition.ts @@ -0,0 +1,36 @@ +import { display } from '@datadog/browser-core' +export interface ProbeWithCondition { + id: string + condition?: string +} + +/** + * Evaluate probe condition to determine if probe should fire + * + * @param probe - Probe configuration + * @param context - Runtime context with variables + * @returns True if condition passes (or no condition), false otherwise + */ +export function evaluateProbeCondition(probe: ProbeWithCondition, context: Record): boolean { + // If no condition, probe always fires + if (!probe.condition) { + return true + } + + try { + // Separate 'this' from other context variables + const { this: thisValue, ...otherContext } = context + const contextKeys = Object.keys(otherContext) + const contextValues = Object.values(otherContext) + + // Create function and execute with proper 'this' binding + // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval + const fn = new Function(...contextKeys, `return ${probe.condition}`) + return Boolean(fn.call(thisValue, ...contextValues)) + } catch (e) { + // If condition evaluation fails, log error and let probe fire + // TODO: Handle error properly + display.error(`Failed to evaluate condition for probe ${probe.id}:`, e) + return true + } +} diff --git a/packages/debugger/src/domain/deliveryApi.spec.ts b/packages/debugger/src/domain/deliveryApi.spec.ts new file mode 100644 index 0000000000..ebcb7cb7ef --- /dev/null +++ b/packages/debugger/src/domain/deliveryApi.spec.ts @@ -0,0 +1,257 @@ +import { display } from '@datadog/browser-core' +import { registerCleanupTask, mockClock } from '@datadog/browser-core/test' +import type { Clock } from '@datadog/browser-core/test' +import { getProbes, clearProbes } from './probes' +import type { Probe } from './probes' +import { startDeliveryApiPolling, stopDeliveryApiPolling, clearDeliveryApiState } from './deliveryApi' +import type { DeliveryApiConfiguration } from './deliveryApi' + +describe('deliveryApi', () => { + let fetchSpy: jasmine.Spy + let clock: Clock + + function makeConfig(overrides: Partial = {}): DeliveryApiConfiguration { + return { + applicationId: 'test-app-id', + env: 'staging', + version: '1.0.0', + pollInterval: 5000, + ...overrides, + } + } + + function respondWith(data: object, status = 200) { + fetchSpy.and.returnValue( + Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + }) + ) + } + + beforeEach(() => { + clock = mockClock() + clearProbes() + clearDeliveryApiState() + fetchSpy = spyOn(window, 'fetch') + respondWith({ nextCursor: '', updates: [], deletions: [] }) + + registerCleanupTask(() => { + stopDeliveryApiPolling() + clearDeliveryApiState() + clearProbes() + }) + }) + + describe('startDeliveryApiPolling', () => { + it('should make an initial POST request to the delivery API', () => { + startDeliveryApiPolling(makeConfig()) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const [url, options] = fetchSpy.calls.mostRecent().args + expect(url).toBe('/api/ui/debugger/probe-delivery') + expect(options.method).toBe('POST') + expect(options.credentials).toBe('same-origin') + expect(options.headers['Content-Type']).toBe('application/json; charset=utf-8') + expect(options.headers['Accept']).toBe('application/vnd.datadog.debugger-probes+json; version=1') + }) + + it('should send the correct request body', () => { + startDeliveryApiPolling(makeConfig()) + + const [, options] = fetchSpy.calls.mostRecent().args + const body = JSON.parse(options.body) + expect(body).toEqual({ + applicationId: 'test-app-id', + clientName: 'browser', + clientVersion: jasmine.stringMatching(/.+/), + env: 'staging', + serviceVersion: '1.0.0', + }) + }) + + it('should not include nextCursor in the first request', () => { + startDeliveryApiPolling(makeConfig()) + + const [, options] = fetchSpy.calls.mostRecent().args + const body = JSON.parse(options.body) + expect(body.nextCursor).toBeUndefined() + }) + + it('should warn if polling is already started', () => { + const warnSpy = spyOn(display, 'warn') + startDeliveryApiPolling(makeConfig()) + startDeliveryApiPolling(makeConfig()) + + expect(warnSpy).toHaveBeenCalledWith(jasmine.stringMatching(/already started/)) + }) + + it('should add probes from the updates array', async () => { + respondWith({ + nextCursor: 'cursor-1', + updates: [makeProbe({ id: 'probe-1', version: 1 })], + deletions: [], + }) + + startDeliveryApiPolling(makeConfig()) + await flushPromises() + + const probes = getProbes('test.js;testMethod') + expect(probes).toBeDefined() + expect(probes!.length).toBe(1) + expect(probes![0].id).toBe('probe-1') + }) + + it('should remove probes listed in deletions', async () => { + // First poll: add the probe via the delivery API + respondWith({ + nextCursor: 'cursor-1', + updates: [makeProbe({ id: 'probe-to-delete', version: 1 })], + deletions: [], + }) + + startDeliveryApiPolling(makeConfig()) + await flushPromises() + expect(getProbes('test.js;testMethod')).toBeDefined() + + // Second poll: delete it + respondWith({ + nextCursor: 'cursor-2', + updates: [], + deletions: ['probe-to-delete'], + }) + + clock.tick(5000) + await flushPromises() + + expect(getProbes('test.js;testMethod')).toBeUndefined() + }) + + it('should send nextCursor in subsequent requests', async () => { + respondWith({ + nextCursor: 'cursor-abc', + updates: [], + deletions: [], + }) + + startDeliveryApiPolling(makeConfig()) + await flushPromises() + + // Tick to trigger next poll + respondWith({ nextCursor: 'cursor-def', updates: [], deletions: [] }) + clock.tick(5000) + + expect(fetchSpy).toHaveBeenCalledTimes(2) + const [, options] = fetchSpy.calls.mostRecent().args + const body = JSON.parse(options.body) + expect(body.nextCursor).toBe('cursor-abc') + }) + + it('should update existing probes when they appear in updates again', async () => { + respondWith({ + nextCursor: 'cursor-1', + updates: [makeProbe({ id: 'probe-1', version: 1 })], + deletions: [], + }) + + startDeliveryApiPolling(makeConfig()) + await flushPromises() + + respondWith({ + nextCursor: 'cursor-2', + updates: [makeProbe({ id: 'probe-1', version: 2 })], + deletions: [], + }) + + clock.tick(5000) + await flushPromises() + + const probes = getProbes('test.js;testMethod') + expect(probes).toBeDefined() + expect(probes!.length).toBe(1) + expect(probes![0].version).toBe(2) + }) + + it('should log an error when the response is not ok', async () => { + const errorSpy = spyOn(display, 'error') + respondWith({}, 500) + + startDeliveryApiPolling(makeConfig()) + await flushPromises() + + expect(errorSpy).toHaveBeenCalledWith(jasmine.stringMatching(/failed with status 500/), jasmine.any(String)) + }) + + it('should log an error when fetch throws', async () => { + const errorSpy = spyOn(display, 'error') + fetchSpy.and.returnValue(Promise.reject(new Error('network error'))) + + startDeliveryApiPolling(makeConfig()) + await flushPromises() + + expect(errorSpy).toHaveBeenCalledWith(jasmine.stringMatching(/poll error/), jasmine.any(Error)) + }) + + it('should poll at the configured interval', () => { + respondWith({ nextCursor: '', updates: [], deletions: [] }) + + startDeliveryApiPolling(makeConfig({ pollInterval: 3000 })) + expect(fetchSpy).toHaveBeenCalledTimes(1) + + clock.tick(3000) + expect(fetchSpy).toHaveBeenCalledTimes(2) + + clock.tick(3000) + expect(fetchSpy).toHaveBeenCalledTimes(3) + }) + + it('should default to 60 second polling interval', () => { + respondWith({ nextCursor: '', updates: [], deletions: [] }) + + startDeliveryApiPolling(makeConfig({ pollInterval: undefined })) + expect(fetchSpy).toHaveBeenCalledTimes(1) + + clock.tick(59_999) + expect(fetchSpy).toHaveBeenCalledTimes(1) + + clock.tick(1) + expect(fetchSpy).toHaveBeenCalledTimes(2) + }) + }) + + describe('stopDeliveryApiPolling', () => { + it('should stop the polling interval', () => { + respondWith({ nextCursor: '', updates: [], deletions: [] }) + + startDeliveryApiPolling(makeConfig()) + expect(fetchSpy).toHaveBeenCalledTimes(1) + + stopDeliveryApiPolling() + clock.tick(5000) + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + }) +}) + +async function flushPromises() { + for (let i = 0; i < 10; i++) { + await Promise.resolve() + } +} + +function makeProbe(overrides: Partial = {}): Probe { + return { + id: 'probe-1', + version: 1, + type: 'LOG_PROBE', + where: { typeName: 'test.js', methodName: 'testMethod' }, + template: 'Test message', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + ...overrides, + } +} diff --git a/packages/debugger/src/domain/deliveryApi.ts b/packages/debugger/src/domain/deliveryApi.ts new file mode 100644 index 0000000000..161012a05e --- /dev/null +++ b/packages/debugger/src/domain/deliveryApi.ts @@ -0,0 +1,141 @@ +import { display, fetch, setInterval, clearInterval } from '@datadog/browser-core' +import { addProbe, removeProbe } from './probes' +import type { Probe } from './probes' + +declare const __BUILD_ENV__SDK_VERSION__: string + +const DELIVERY_API_PATH = '/api/ui/debugger/probe-delivery' +const DEFAULT_HEADERS: Record = { + 'Content-Type': 'application/json; charset=utf-8', + 'Accept': 'application/vnd.datadog.debugger-probes+json; version=1', +} + +export interface DeliveryApiConfiguration { + applicationId: string + env?: string + version?: string + pollInterval?: number +} + +interface DeliveryApiResponse { + nextCursor: string + updates: Probe[] + deletions: string[] +} + +let pollIntervalId: number | undefined +let currentCursor: string | undefined +let knownProbeIds = new Set() + +/** + * Start polling the Datadog Delivery API for probe updates. + * + * This is designed for dogfooding the Live Debugger inside the Datadog web UI, + * where the user is already authenticated via session cookies (ValidUser auth). + * Requests are same-origin, so no explicit domain is needed. + */ +export function startDeliveryApiPolling(config: DeliveryApiConfiguration): void { + if (pollIntervalId !== undefined) { + display.warn('Live Debugger: Delivery API polling already started') + return + } + + const pollInterval = config.pollInterval || 60_000 + + const baseRequestBody = { + applicationId: config.applicationId, + clientName: 'browser', + clientVersion: __BUILD_ENV__SDK_VERSION__, + env: config.env, + serviceVersion: config.version, + } + + const poll = async () => { + try { + const body: Record = { ...baseRequestBody } + if (currentCursor) { + body.nextCursor = currentCursor + } + + const response = await fetch(DELIVERY_API_PATH, { + method: 'POST', + headers: { ...DEFAULT_HEADERS }, + body: JSON.stringify(body), + credentials: 'same-origin', + }) + + if (!response.ok) { + // TODO: Remove response body logging once dogfooding is complete + let errorBody = '' + try { + errorBody = await response.text() + } catch { + // ignore + } + display.error(`Live Debugger: Delivery API poll failed with status ${response.status}`, errorBody) + return + } + + const data: DeliveryApiResponse = await response.json() + + if (data.nextCursor) { + currentCursor = data.nextCursor + } + + for (const probeId of data.deletions || []) { + if (knownProbeIds.has(probeId)) { + try { + removeProbe(probeId) + knownProbeIds.delete(probeId) + } catch (err) { + display.error(`Live Debugger: Failed to remove probe ${probeId}:`, err as Error) + } + } + } + + for (const probe of data.updates || []) { + if (!probe.id) { + continue + } + + if (knownProbeIds.has(probe.id)) { + try { + removeProbe(probe.id) + } catch { + // Probe may have been removed by a deletion in the same response + } + } + + try { + addProbe(probe) + knownProbeIds.add(probe.id) + } catch (err) { + display.error(`Live Debugger: Failed to add probe ${probe.id}:`, err as Error) + } + } + } catch (err) { + display.error('Live Debugger: Delivery API poll error:', err as Error) + } + } + + void poll() + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + pollIntervalId = setInterval(poll, pollInterval) +} + +export function stopDeliveryApiPolling(): void { + if (pollIntervalId !== undefined) { + clearInterval(pollIntervalId) + pollIntervalId = undefined + } +} + +export function clearDeliveryApiState(): void { + currentCursor = undefined + knownProbeIds = new Set() + if (pollIntervalId !== undefined) { + clearInterval(pollIntervalId) + pollIntervalId = undefined + } +} diff --git a/packages/debugger/src/domain/expression.spec.ts b/packages/debugger/src/domain/expression.spec.ts new file mode 100644 index 0000000000..f4f77d0d67 --- /dev/null +++ b/packages/debugger/src/domain/expression.spec.ts @@ -0,0 +1,249 @@ +import { + literals, + references, + propertyAccess, + sizes, + equality, + stringManipulation, + stringComparison, + logicalOperators, + collectionOperations, + membershipAndMatching, + typeAndDefinitionChecks, +} from '../../test' +import type { TestCase } from '../../test' + +import { compile } from './expression' + +// Flatten all test cases into a single array +const testCases: TestCase[] = [ + ...literals, + ...references, + ...propertyAccess, + ...sizes, + ...equality, + ...stringManipulation, + ...stringComparison, + ...logicalOperators, + ...collectionOperations, + ...membershipAndMatching, + ...typeAndDefinitionChecks, +] + +describe('Expression language', () => { + describe('condition compilation', () => { + const testNameCounts = new Map() + + for (const testCase of testCases) { + let before: (() => void) | undefined + let ast: any + let vars: Record = {} + let suffix: string | undefined + let expected: any + let execute = true + + if (Array.isArray(testCase)) { + ;[ast, vars, expected] = testCase + } else { + // Allow for more expressive test cases in situations where the default tuple is not enough + ;({ before, ast, vars = {}, suffix, expected, execute = true } = testCase) + } + + const baseName = generateTestCaseName(ast, vars, expected, suffix, execute) + const uniqueName = makeUniqueName(baseName, testNameCounts) + + it(uniqueName, () => { + if (before) { + before() + } + + if (execute === false) { + if (expected instanceof Error) { + expect(() => compile(ast)).toThrowError(expected.constructor as new (...args: any[]) => Error) + } else { + expect(compile(ast)).toBe(expected) + } + return + } + + const compiledResult = compile(ast) + const compiledCode = typeof compiledResult === 'string' ? compiledResult : String(compiledResult) + const code = suffix + ? `const result = (() => { + return ${compiledCode} + })() + ${suffix} + return result` + : `return ${compiledCode}` + + // Create a function with the vars as parameters + // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval + const fn = new Function(...Object.keys(vars), code) + const args = Object.values(vars) + + if (expected instanceof Error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call + expect(() => fn(...args)).toThrowError(expected.constructor as new (...args: any[]) => Error) + } else { + const result = runWithDebug(fn, args) + if (expected !== null && typeof expected === 'object') { + expect(result).toEqual(expected) + } else { + expect(result).toBe(expected) + } + } + }) + } + }) + + // Keep some specific tests for additional coverage + describe('literal optimization', () => { + it('should not wrap literal numbers in coercion guards', () => { + const result = compile({ gt: [{ ref: 'x' }, 10] }) + // The right side should be just "10", not wrapped in a guard function + expect(result).toContain('> 10') + expect(result).not.toMatch(/> \(\(val\) => \{/) + }) + + it('should wrap non-literal values in coercion guards', () => { + const result = compile({ gt: [{ ref: 'x' }, { ref: 'y' }] }) + // Both sides should be wrapped + expect(result).toContain('((val) => {') + }) + + it('should handle literal booleans without wrapping', () => { + const result = compile({ gt: [{ ref: 'x' }, true] }) + // Boolean true evaluates, but shouldn't be wrapped for gt since it's not a number + // Actually, booleans get coerced, so they should still be wrapped + expect(result).toContain('>') + }) + + it('should handle literal null without wrapping', () => { + const result = compile({ gt: [{ ref: 'x' }, null] }) + expect(result).toContain('>') + }) + }) + + describe('evaluation edge cases', () => { + it('should evaluate literal comparisons correctly', () => { + const x = 15 // eslint-disable-line @typescript-eslint/no-unused-vars + const compiled = compile({ gt: [{ ref: 'x' }, 10] }) + const code = typeof compiled === 'string' ? compiled : String(compiled) + const result = eval(code) // eslint-disable-line no-eval + expect(result).toBe(true) + }) + + it('should handle literal in left position', () => { + const x = 5 // eslint-disable-line @typescript-eslint/no-unused-vars + const compiled = compile({ gt: [10, { ref: 'x' }] }) + const code = typeof compiled === 'string' ? compiled : String(compiled) + const result = eval(code) // eslint-disable-line no-eval + expect(result).toBe(true) + }) + + it('should handle both literals', () => { + const compiled = compile({ gt: [20, 10] }) + const code = typeof compiled === 'string' ? compiled : String(compiled) + const result = eval(code) // eslint-disable-line no-eval + expect(result).toBe(true) + }) + }) +}) + +function makeUniqueName(baseName: string, testNameCounts: Map): string { + const count = testNameCounts.get(baseName) || 0 + testNameCounts.set(baseName, count + 1) + + if (count === 0) { + return baseName + } + + return `${baseName} [#${count + 1}]` +} + +function generateTestCaseName( + ast: any, + vars: Record, + expected: any, + suffix?: string, + execute?: boolean +): string { + const code = Object.entries(vars) + .map(([key, value]) => `${key} = ${serialize(value)}`) + .join('; ') + + const expectedStr = expected instanceof Error ? expected.constructor.name : serialize(expected) + let name = `${JSON.stringify(ast)} + "${code}" => ${expectedStr}` + + // Add suffix to make test names unique when present + if (suffix) { + name += ` (with: ${suffix.replace(/\n/g, ' ').substring(0, 50)})` + } + + // Indicate when compilation is tested without execution + if (execute === false) { + name += ' [compile-only]' + } + + return name +} + +function serialize(value: any): string { + try { + if (value === undefined) { + return 'undefined' + } + if (typeof value === 'function') { + return 'function' + } + if (typeof value === 'symbol') { + return value.toString() + } + + // Distinguish between primitive strings and String objects + if (typeof value === 'string') { + return JSON.stringify(value) + } + if (value instanceof String) { + return `String(${JSON.stringify(value.valueOf())})` + } + + // Handle other objects with constructor names for better distinction + if (value && typeof value === 'object') { + const constructorName = value.constructor?.name + if (constructorName && constructorName !== 'Object' && constructorName !== 'Array') { + // For built-in types like Set, Map, WeakSet, etc., show constructor name + if (['Set', 'Map', 'WeakSet', 'WeakMap', 'Int16Array', 'Int32Array', 'RegExp'].includes(constructorName)) { + return `${constructorName}(${JSON.stringify(value).substring(0, 50)})` + } + // For custom objects, just show the constructor name + return `${constructorName}{}` + } + } + + return JSON.stringify(value) + } catch { + // Some values are not serializable to JSON, so we fall back to stringification + const str = String(value) + return str.length > 50 ? `${str.substring(0, 50)}…` : str + } +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +function runWithDebug(fn: Function, args: any[] = []): any { + try { + return fn(...args) // eslint-disable-line @typescript-eslint/no-unsafe-call + } catch (e) { + // Output the compiled expression for easier debugging + // eslint-disable-next-line no-console + console.log( + [ + 'Compiled expression:', + '--------------------------------------------------------------------------------', + fn.toString(), + '--------------------------------------------------------------------------------', + ].join('\n') + ) + throw e + } +} diff --git a/packages/debugger/src/domain/expression.ts b/packages/debugger/src/domain/expression.ts new file mode 100644 index 0000000000..927554c443 --- /dev/null +++ b/packages/debugger/src/domain/expression.ts @@ -0,0 +1,348 @@ +/** + * DSL expression language compiler for browser live debugger. + * Compiles DSL expressions into executable JavaScript code. + * Used by both conditions and template segments. + * Adapted from dd-trace-js/packages/dd-trace/src/debugger/devtools_client/condition.js + */ + +const identifierRegex = /^[@a-zA-Z_$][\w$]*$/ + +// The following identifiers have purposefully not been included in this list: +// - The reserved words `this` and `super` as they can have valid use cases as `ref` values +// - The literals `undefined` and `Infinity` as they can be useful as `ref` values, especially to check if a +// variable is `undefined`. +// - The following future reserved words in older standards, as they can now be used safely: +// `abstract`, `boolean`, `byte`, `char`, `double`, `final`, `float`, `goto`, `int`, `long`, `native`, `short`, +// `synchronized`, `throws`, `transient`, `volatile`. +const reservedWords = new Set([ + // Reserved words + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'debugger', + 'default', + 'delete', + 'do', + 'else', + 'export', + 'extends', + 'false', + 'finally', + 'for', + 'function', + 'if', + 'import', + 'in', + 'instanceof', + 'new', + 'null', + 'return', + 'switch', + 'throw', + 'true', + 'try', + 'typeof', + 'var', + 'void', + 'while', + 'with', + + // Reserved in strict mode + 'let', + 'static', + 'yield', + + // Reserved in module code or async function bodies: + 'await', + + // Future reserved words + 'enum', + + // Future reserved words in strict mode + 'implements', + 'interface', + 'package', + 'private', + 'protected', + 'public', + + // Literals + 'NaN', +]) + +const PRIMITIVE_TYPES = new Set(['string', 'number', 'bigint', 'boolean', 'undefined', 'symbol', 'null']) + +export type ExpressionNode = + | null + | string + | number + | boolean + | { not: ExpressionNode } + | { len: ExpressionNode } + | { count: ExpressionNode } + | { isEmpty: ExpressionNode } + | { isDefined: ExpressionNode } + | { instanceof: [ExpressionNode, string] } + | { ref: string } + | { eq: ExpressionNode[] } + | { ne: ExpressionNode[] } + | { gt: ExpressionNode[] } + | { ge: ExpressionNode[] } + | { lt: ExpressionNode[] } + | { le: ExpressionNode[] } + | { any: ExpressionNode[] } // eslint-disable-line id-denylist + | { all: ExpressionNode[] } + | { and: ExpressionNode[] } + | { or: ExpressionNode[] } + | { startsWith: ExpressionNode[] } + | { endsWith: ExpressionNode[] } + | { contains: ExpressionNode[] } + | { matches: ExpressionNode[] } + | { filter: ExpressionNode[] } + | { substring: ExpressionNode[] } + | { getmember: ExpressionNode[] } + | { index: ExpressionNode[] } + +/** + * Compile a DSL expression node to JavaScript code + * + * @param node - DSL expression node + * @returns Compiled JavaScript code as a string, or raw primitive values + */ +export function compile(node: ExpressionNode): string | number | boolean | null { + if (node === null || typeof node === 'number' || typeof node === 'boolean') { + return node + } else if (typeof node === 'string') { + return JSON.stringify(node) + } + + const [type, value] = Object.entries(node)[0] + + if (type === 'not') { + return `!(${compile(value as ExpressionNode)})` + } else if (type === 'len' || type === 'count') { + return getSize(compile(value as ExpressionNode) as string) + } else if (type === 'isEmpty') { + return `${getSize(compile(value as ExpressionNode) as string)} === 0` + } else if (type === 'isDefined') { + return `(() => { + try { + ${compile(value as ExpressionNode)} + return true + } catch { + return false + } + })()` + } else if (type === 'instanceof') { + const [target, typeName] = value as [ExpressionNode, string] + return isPrimitiveType(typeName) + ? `(typeof ${compile(target)} === '${typeName}')` + : `Function.prototype[Symbol.hasInstance].call(${assertIdentifier(typeName)}, ${compile(target)})` + } else if (type === 'ref') { + const refValue = value as string + if (refValue.startsWith('@')) { + return `$dd_${refValue.slice(1)}` + } + return assertIdentifier(refValue) + } else if (Array.isArray(value)) { + const args = value.map((v) => compile(v as ExpressionNode)) + switch (type) { + case 'eq': + return `(${args[0]}) === (${args[1]})` + case 'ne': + return `(${args[0]}) !== (${args[1]})` + case 'gt': + return `${guardAgainstCoercionSideEffects(args[0])} > ${guardAgainstCoercionSideEffects(args[1])}` + case 'ge': + return `${guardAgainstCoercionSideEffects(args[0])} >= ${guardAgainstCoercionSideEffects(args[1])}` + case 'lt': + return `${guardAgainstCoercionSideEffects(args[0])} < ${guardAgainstCoercionSideEffects(args[1])}` + case 'le': + return `${guardAgainstCoercionSideEffects(args[0])} <= ${guardAgainstCoercionSideEffects(args[1])}` + case 'any': + return iterateOn('some', args[0] as string, args[1] as string) + case 'all': + return iterateOn('every', args[0] as string, args[1] as string) + case 'and': + return `(${args.join(') && (')})` + case 'or': + return `(${args.join(') || (')})` + case 'startsWith': + return `String.prototype.startsWith.call(${assertString(args[0])}, ${assertString(args[1])})` + case 'endsWith': + return `String.prototype.endsWith.call(${assertString(args[0])}, ${assertString(args[1])})` + case 'contains': + return `((obj, elm) => { + if (${isString('obj')}) { + return String.prototype.includes.call(obj, elm) + } else if (Array.isArray(obj)) { + return Array.prototype.includes.call(obj, elm) + } else if (${isTypedArray('obj')}) { + return Object.getPrototypeOf(Int8Array.prototype).includes.call(obj, elm) + } else if (${isInstanceOf('Set', 'obj')}) { + return Set.prototype.has.call(obj, elm) + } else if (${isInstanceOf('WeakSet', 'obj')}) { + return WeakSet.prototype.has.call(obj, elm) + } else if (${isInstanceOf('Map', 'obj')}) { + return Map.prototype.has.call(obj, elm) + } else if (${isInstanceOf('WeakMap', 'obj')}) { + return WeakMap.prototype.has.call(obj, elm) + } else { + throw new TypeError('Variable does not support contains') + } + })(${args[0]}, ${args[1]})` + case 'matches': + return `((str, regex) => { + if (${isString('str')}) { + const regexIsString = ${isString('regex')} + if (regexIsString || Object.getPrototypeOf(regex) === RegExp.prototype) { + return RegExp.prototype.test.call(regexIsString ? new RegExp(regex) : regex, str) + } else { + throw new TypeError('Regular expression must be either a string or an instance of RegExp') + } + } else { + throw new TypeError('Variable is not a string') + } + })(${args[0]}, ${args[1]})` + case 'filter': + return `(($dd_var) => { + return ${isIterableCollection('$dd_var')} + ? Array.from($dd_var).filter(($dd_it) => ${args[1]}) + : Object.entries($dd_var).reduce((acc, [$dd_key, $dd_value]) => { + if (${args[1]}) acc[$dd_key] = $dd_value + return acc + }, {}) + })(${args[0]})` + case 'substring': + return `((str) => { + if (${isString('str')}) { + return String.prototype.substring.call(str, ${args[1]}, ${args[2]}) + } else { + throw new TypeError('Variable is not a string') + } + })(${args[0]})` + case 'getmember': + return accessProperty(args[0] as string, args[1] as string, false) + case 'index': + return accessProperty(args[0] as string, args[1] as string, true) + } + } + + throw new TypeError(`Unknown AST node type: ${type}`) +} + +function iterateOn(fnName: string, variable: string, callbackCode: string): string { + return `(($dd_val) => { + return ${isIterableCollection('$dd_val')} + ? Array.from($dd_val).${fnName}(($dd_it) => ${callbackCode}) + : Object.entries($dd_val).${fnName}(([$dd_key, $dd_value]) => ${callbackCode}) + })(${variable})` +} + +function isString(variable: string): string { + return `(typeof ${variable} === 'string' || ${variable} instanceof String)` +} + +function isPrimitiveType(type: string): boolean { + return PRIMITIVE_TYPES.has(type) +} + +function isIterableCollection(variable: string): string { + return ( + `(${isArrayOrTypedArray(variable)} || ${isInstanceOf('Set', variable)} || ` + + `${isInstanceOf('WeakSet', variable)})` + ) +} + +function isArrayOrTypedArray(variable: string): string { + return `(Array.isArray(${variable}) || ${isTypedArray(variable)})` +} + +function isTypedArray(variable: string): string { + return `(${variable} instanceof Object.getPrototypeOf(Int8Array))` +} + +function isInstanceOf(type: string, variable: string): string { + return `(${variable} instanceof ${type})` +} + +function getSize(variable: string): string { + return `((val) => { + if (${isString('val')} || ${isArrayOrTypedArray('val')}) { + return ${guardAgainstPropertyAccessSideEffects('val', '"length"')} + } else if (${isInstanceOf('Set', 'val')} || ${isInstanceOf('Map', 'val')}) { + return ${guardAgainstPropertyAccessSideEffects('val', '"size"')} + } else if (${isInstanceOf('WeakSet', 'val')} || ${isInstanceOf('WeakMap', 'val')}) { + throw new TypeError('Cannot get size of WeakSet or WeakMap') + } else if (typeof val === 'object' && val !== null) { + return Object.keys(val).length + } else { + throw new TypeError('Cannot get length of variable') + } + })(${variable})` +} + +function accessProperty(variable: string, keyOrIndex: string, allowMapAccess: boolean): string { + return `((val, key) => { + if (${isInstanceOf('Map', 'val')}) { + ${allowMapAccess ? 'return Map.prototype.get.call(val, key)' : "throw new Error('Accessing a Map is not allowed')"} + } else if (${isInstanceOf('WeakMap', 'val')}) { + ${allowMapAccess ? 'return WeakMap.prototype.get.call(val, key)' : "throw new Error('Accessing a WeakMap is not allowed')"} + } else if (${isInstanceOf('Set', 'val')} || ${isInstanceOf('WeakSet', 'val')}) { + throw new Error('Accessing a Set or WeakSet is not allowed') + } else { + return ${guardAgainstPropertyAccessSideEffects('val', 'key')} + } + })(${variable}, ${keyOrIndex})` +} + +function guardAgainstPropertyAccessSideEffects(variable: string, propertyName: string): string { + return `((val, key) => { + if (Object.getOwnPropertyDescriptor(val, key)?.get !== undefined) { + throw new Error('Possibility of side effect') + } else { + return val[key] + } + })(${variable}, ${propertyName})` +} + +function guardAgainstCoercionSideEffects(variable: string | number | boolean | null): string { + // shortcut if we're comparing number literals + if (typeof variable === 'number') { + return String(variable) + } + + return `((val) => { + if ( + typeof val === 'object' && val !== null && ( + val[Symbol.toPrimitive] !== undefined || + val.valueOf !== Object.prototype.valueOf || + val.toString !== Object.prototype.toString + ) + ) { + throw new Error('Possibility of side effect due to coercion methods') + } else { + return val + } + })(${variable})` +} + +function assertString(variable: string | number | boolean | null): string { + return `((val) => { + if (${isString('val')}) { + return val + } else { + throw new TypeError('Variable is not a string') + } + })(${variable})` +} + +function assertIdentifier(value: string): string { + if (!identifierRegex.test(value) || reservedWords.has(value)) { + throw new SyntaxError(`Illegal identifier: ${value}`) + } + return value +} diff --git a/packages/debugger/src/domain/probes.spec.ts b/packages/debugger/src/domain/probes.spec.ts new file mode 100644 index 0000000000..96faa5ea4a --- /dev/null +++ b/packages/debugger/src/domain/probes.spec.ts @@ -0,0 +1,468 @@ +import { display } from '@datadog/browser-core' +import { registerCleanupTask } from '@datadog/browser-core/test' +import { initializeProbe, getProbes, addProbe, removeProbe, checkGlobalSnapshotBudget, clearProbes } from './probes' +import type { Probe } from './probes' + +interface TemplateWithCache { + createFunction: (params: string[]) => (...args: any[]) => any + clearCache?: () => void +} + +describe('probes', () => { + beforeEach(() => { + clearProbes() + }) + + afterEach(() => { + registerCleanupTask(() => clearProbes()) + }) + + describe('addProbe and getProbes', () => { + it('should add and retrieve a probe', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'test.js', methodName: 'testMethod' }, + template: 'Test message', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + addProbe(probe) + const retrieved = getProbes('test.js;testMethod') + + expect(retrieved).toEqual([ + jasmine.objectContaining({ + id: 'test-probe-1', + templateRequiresEvaluation: false, + }), + ]) + }) + + it('should return undefined for non-existent probe', () => { + const retrieved = getProbes('non-existent') + expect(retrieved).toBeUndefined() + }) + }) + + describe('removeProbe', () => { + it('should remove a probe', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'testMethod' }, + template: 'Test', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + addProbe(probe) + expect(getProbes('TestClass;testMethod')).toBeDefined() + + removeProbe('test-probe-1') + expect(getProbes('TestClass;testMethod')).toBeUndefined() + }) + + it('should clear function cache when removing probe with dynamic template', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'cacheTest' }, + template: '', + segments: [{ dsl: 'x', json: { ref: 'x' } }], + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + addProbe(probe) + const retrieved = getProbes('TestClass;cacheTest') + const template = retrieved![0].template as TemplateWithCache + + // Create some cached functions + template.createFunction(['x', 'y']) + template.createFunction(['x', 'z']) + + // Spy on clearCache method + const clearCacheSpy = jasmine.createSpy('clearCache') + template.clearCache = clearCacheSpy + + removeProbe('test-probe-1') + + // Verify clearCache was called + expect(clearCacheSpy).toHaveBeenCalled() + }) + + it('should handle removing probe with static template without errors', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'staticTest' }, + template: 'Static message', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + addProbe(probe) + + // Should not throw when removing probe with static template (no clearCache method) + expect(() => removeProbe('test-probe-1')).not.toThrow() + }) + }) + + describe('initializeProbe', () => { + it('should initialize probe with static template', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'initStatic' }, + template: 'Static message', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + initializeProbe(probe) + + expect(probe).toEqual( + jasmine.objectContaining({ + templateRequiresEvaluation: false, + template: 'Static message', + msBetweenSampling: jasmine.any(Number), + lastCaptureMs: -Infinity, + }) + ) + }) + + it('should initialize probe with dynamic template', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'initDynamic' }, + template: '', + segments: [{ str: 'Value: ' }, { dsl: 'x', json: { ref: 'x' } }], + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + initializeProbe(probe) + + expect(probe).toEqual( + jasmine.objectContaining({ + templateRequiresEvaluation: true, + template: { + createFunction: jasmine.any(Function), + clearCache: jasmine.any(Function), + }, + }) + ) + expect(probe.segments).toBeUndefined() // Should be deleted after initialization + }) + + it('should compile condition when present', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'conditionCompile' }, + when: { + dsl: 'x > 5', + json: { gt: [{ ref: 'x' }, 5] }, + }, + template: 'Message', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'EXIT', + } + + initializeProbe(probe) + + expect(probe.condition).toEqual(jasmine.any(String)) + }) + + it('should handle condition compilation errors', () => { + const displayErrorSpy = spyOn(display, 'error') + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'conditionError' }, + when: { + dsl: 'invalid', + json: { invalidOp: 'bad' } as any, + }, + template: 'Message', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'EXIT', + } + + initializeProbe(probe) + + expect(displayErrorSpy).toHaveBeenCalledWith( + jasmine.stringContaining('Cannot compile condition'), + jasmine.any(Error) + ) + }) + + it('should calculate msBetweenSampling for snapshot probes', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'samplingCalc' }, + template: 'Message', + captureSnapshot: true, + capture: {}, + sampling: { snapshotsPerSecond: 10 }, + evaluateAt: 'ENTRY', + } + + initializeProbe(probe) + + expect(probe.msBetweenSampling).toBe(100) // 1000ms / 10 = 100ms + }) + + it('should use default sampling rate for snapshot probes without explicit rate', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'samplingDefault' }, + template: 'Message', + captureSnapshot: true, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + initializeProbe(probe) + + expect(probe.msBetweenSampling).toBe(1000) // 1 snapshot per second by default + }) + + it('should use high default sampling rate for non-snapshot probes', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'samplingHigh' }, + template: 'Message', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + initializeProbe(probe) + + expect(probe.msBetweenSampling).toBeLessThan(1) // 5000 per second = 0.2ms + }) + + it('should cache compiled functions by context keys', () => { + const probe: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'cacheKeys' }, + template: '', + segments: [{ dsl: 'x', json: { ref: 'x' } }], + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + initializeProbe(probe) + + const template = probe.template as TemplateWithCache + const fn1 = template.createFunction(['x', 'y']) + const fn2 = template.createFunction(['x', 'y']) + + // Should return the same cached function + expect(fn1).toBe(fn2) + + const fn3 = template.createFunction(['x', 'z']) + // Different keys should create different function + expect(fn1).not.toBe(fn3) + }) + }) + + describe('clearProbes', () => { + it('should clear all probes', () => { + const probe1: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'clear1' }, + template: 'Test 1', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + const probe2: Probe = { + id: 'test-probe-2', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'clear2' }, + template: 'Test 2', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + addProbe(probe1) + addProbe(probe2) + + clearProbes() + + expect(getProbes('TestClass;clear1')).toBeUndefined() + expect(getProbes('TestClass;clear2')).toBeUndefined() + }) + + it('should clear function caches for all probes with dynamic templates', () => { + const probe1: Probe = { + id: 'test-probe-1', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'clearCache1' }, + template: '', + segments: [{ dsl: 'x', json: { ref: 'x' } }], + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + const probe2: Probe = { + id: 'test-probe-2', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'clearCache2' }, + template: '', + segments: [{ dsl: 'y', json: { ref: 'y' } }], + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + const probe3: Probe = { + id: 'test-probe-3', + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName: 'clearCache3' }, + template: 'Static message', + captureSnapshot: false, + capture: {}, + sampling: {}, + evaluateAt: 'ENTRY', + } + + addProbe(probe1) + addProbe(probe2) + addProbe(probe3) + + const template1 = getProbes('TestClass;clearCache1')![0].template as TemplateWithCache + const template2 = getProbes('TestClass;clearCache2')![0].template as TemplateWithCache + + // Create some cached functions + template1.createFunction(['x']) + template2.createFunction(['y']) + + // Spy on clearCache methods + const clearCache1Spy = jasmine.createSpy('clearCache1') + const clearCache2Spy = jasmine.createSpy('clearCache2') + template1.clearCache = clearCache1Spy + template2.clearCache = clearCache2Spy + + clearProbes() + + // Verify clearCache was called for both dynamic template probes + expect(clearCache1Spy).toHaveBeenCalled() + expect(clearCache2Spy).toHaveBeenCalled() + }) + }) + + describe('checkGlobalSnapshotBudget', () => { + it('should allow non-snapshot probes without limit', () => { + for (let i = 0; i < 100; i++) { + expect(checkGlobalSnapshotBudget(Date.now(), false)).toBe(true) + } + }) + + it('should allow snapshots within global budget', () => { + const now = Date.now() + for (let i = 0; i < 25; i++) { + expect(checkGlobalSnapshotBudget(now + i, true)).toBe(true) + } + }) + + it('should reject snapshots beyond global budget', () => { + const now = Date.now() + // Use up the budget + for (let i = 0; i < 25; i++) { + checkGlobalSnapshotBudget(now + i, true) + } + + // Next one should be rejected + expect(checkGlobalSnapshotBudget(now + 26, true)).toBe(false) + }) + + it('should reset budget after time window', () => { + const now = Date.now() + + // Use up the budget + for (let i = 0; i < 25; i++) { + checkGlobalSnapshotBudget(now + i, true) + } + + // Should be rejected + expect(checkGlobalSnapshotBudget(now + 100, true)).toBe(false) + + // After 1 second, should allow again + expect(checkGlobalSnapshotBudget(now + 1100, true)).toBe(true) + }) + + it('should track budget correctly across time windows', () => { + const baseTime = Date.now() + + // First window - use 20 snapshots + for (let i = 0; i < 20; i++) { + expect(checkGlobalSnapshotBudget(baseTime + i, true)).toBe(true) + } + + // Still within same window - 5 more should work + for (let i = 0; i < 5; i++) { + expect(checkGlobalSnapshotBudget(baseTime + 500 + i, true)).toBe(true) + } + + // Now at limit + expect(checkGlobalSnapshotBudget(baseTime + 600, true)).toBe(false) + + // New window + expect(checkGlobalSnapshotBudget(baseTime + 1500, true)).toBe(true) + }) + }) +}) diff --git a/packages/debugger/src/domain/probes.ts b/packages/debugger/src/domain/probes.ts new file mode 100644 index 0000000000..ac5b3929e1 --- /dev/null +++ b/packages/debugger/src/domain/probes.ts @@ -0,0 +1,261 @@ +import { display } from '@datadog/browser-core' +import { compile } from './expression' +import { templateRequiresEvaluation, compileSegments } from './template' +import type { TemplateSegment, CompiledTemplate } from './template' +import type { CaptureOptions } from './capture' + +// Sampling rate limits +const MAX_SNAPSHOTS_PER_SECOND_GLOBALLY = 25 +const MAX_SNAPSHOTS_PER_SECOND_PER_PROBE = 1 +const MAX_NON_SNAPSHOTS_PER_SECOND_PER_PROBE = 5000 + +// Global snapshot rate limiting +let globalSnapshotSamplingRateWindowStart = 0 +let snapshotsSampledWithinTheLastSecond = 0 + +export interface ProbeWhere { + typeName?: string + methodName?: string + sourceFile?: string + lines?: string[] +} + +export interface ProbeWhen { + dsl: string + json: any +} + +export interface ProbeSampling { + snapshotsPerSecond?: number +} + +export interface Probe { + id: string + version: number + type: string + where: ProbeWhere + when?: ProbeWhen + template: string | CompiledTemplate + segments?: TemplateSegment[] + captureSnapshot: boolean + capture: CaptureOptions + sampling: ProbeSampling + evaluateAt: 'ENTRY' | 'EXIT' + location?: { + file?: string + lines?: string[] + method?: string + } +} + +export interface InitializedProbe extends Probe { + templateRequiresEvaluation: boolean + functionId: string + condition?: string + msBetweenSampling: number + lastCaptureMs: number +} + +// Pre-populate with a dummy key to help V8 optimize property lookups. +// Removing this shows a much larger performance overhead. +// Benchmarks show that using an object is much faster than a Map. +const activeProbes: Record = { + // @ts-expect-error - Pre-populate with a dummy key to help V8 optimize property lookups. + __dummy__: undefined, +} +const probeIdToFunctionId: Record = { + // @ts-expect-error - Pre-populate with a dummy key to help V8 optimize property lookups. + __dummy__: undefined, +} + +/** + * Add a probe to the registry + * + * @param probe - The probe configuration + */ +export function addProbe(probe: Probe): void { + initializeProbe(probe) + let probes = activeProbes[probe.functionId] + if (!probes) { + probes = [] + activeProbes[probe.functionId] = probes + } + probes.push(probe) + probeIdToFunctionId[probe.id] = probe.functionId +} + +/** + * Get initialized probes by function ID + * + * @param functionId - The probe function ID + * @returns The initialized probes + */ +export function getProbes(functionId: string): InitializedProbe[] | undefined { + return activeProbes[functionId] +} + +/** + * Get all active probes across all functions + * + * @returns Array of all active probes + */ +export function getAllProbes(): InitializedProbe[] { + const allProbes: InitializedProbe[] = [] + for (const probes of Object.values(activeProbes)) { + if (probes) { + allProbes.push(...probes) + } + } + return allProbes +} + +/** + * Remove a probe from the registry + * + * @param id - The probe ID + */ +export function removeProbe(id: string): void { + const functionId = probeIdToFunctionId[id] + if (!functionId) { + throw new Error(`Probe with id ${id} not found`) + } + const probes = activeProbes[functionId] + if (!probes) { + throw new Error(`Probes with function id ${functionId} not found`) + } + for (let i = 0; i < probes.length; i++) { + const probe = probes[i] + if (probe.id === id) { + if (typeof probe.template === 'object' && 'clearCache' in probe.template && probe.template.clearCache) { + probe.template.clearCache() + } + probes.splice(i, 1) + break + } + } + delete probeIdToFunctionId[id] + if (probes.length === 0) { + delete activeProbes[functionId] + } +} + +/** + * Clear all probes (useful for testing) + */ +export function clearProbes(): void { + for (const probes of Object.values(activeProbes)) { + if (probes) { + for (const probe of probes) { + if (typeof probe.template === 'object' && 'clearCache' in probe.template && probe.template.clearCache) { + probe.template.clearCache() + } + } + } + } + for (const functionId of Object.keys(activeProbes)) { + if (functionId !== '__dummy__') { + delete activeProbes[functionId] + } + } + for (const probeId of Object.keys(probeIdToFunctionId)) { + if (probeId !== '__dummy__') { + delete probeIdToFunctionId[probeId] + } + } + globalSnapshotSamplingRateWindowStart = 0 + snapshotsSampledWithinTheLastSecond = 0 +} + +/** + * Check global snapshot sampling budget + * + * @param now - Current timestamp in milliseconds + * @param captureSnapshot - Whether this probe captures snapshots + * @returns True if within budget, false if rate limited + */ +export function checkGlobalSnapshotBudget(now: number, captureSnapshot: boolean): boolean { + // Only enforce global budget for probes that capture snapshots + if (!captureSnapshot) { + return true + } + + // Reset counter if a second has passed + // This algorithm is not a perfect sliding window, but it's quick and easy + if (now - globalSnapshotSamplingRateWindowStart > 1000) { + snapshotsSampledWithinTheLastSecond = 1 + globalSnapshotSamplingRateWindowStart = now + return true + } + + // Check if we've exceeded the global limit + if (snapshotsSampledWithinTheLastSecond >= MAX_SNAPSHOTS_PER_SECOND_GLOBALLY) { + return false + } + + // Increment counter and allow + snapshotsSampledWithinTheLastSecond++ + return true +} + +/** + * Initialize a probe by preprocessing template segments, conditions, and sampling + * + * @param probe - The probe configuration + */ +export function initializeProbe(probe: Probe): asserts probe is InitializedProbe { + // TODO: Add support for anonymous functions (Currently only uniquely named functions are supported) + ;(probe as InitializedProbe).functionId = `${probe.where.typeName};${probe.where.methodName}` + + // Compile condition if present + try { + if (probe.when?.json) { + ;(probe as InitializedProbe).condition = String(compile(probe.when.json)) + } + } catch (err) { + // TODO: Handle error properly + display.error( + `Cannot compile condition expression: ${probe.when!.dsl} (probe: ${probe.id}, version: ${probe.version})`, + err as Error + ) + } + + // Optimize for fast calculations when probe is hit + ;(probe as InitializedProbe).templateRequiresEvaluation = templateRequiresEvaluation(probe.segments) + if ((probe as InitializedProbe).templateRequiresEvaluation) { + const segmentsCode = compileSegments(probe.segments!) + + // Pre-build the function body template with the segments code + // The actual function will be created at runtime with dynamic parameter names + // But at least we avoid rebuilding this string every time + // TODO: Can we know ahead of time what the names of the context keys will be? + const fnBodyTemplate = `return ${segmentsCode};` + + // Cache compiled functions by context keys to avoid recreating them + const functionCache = new Map any[]>() + + // Store the template with a factory that caches functions + probe.template = { + createFunction: (contextKeys: string[]) => { + const cacheKey = contextKeys.join(',') + let fn = functionCache.get(cacheKey) + if (!fn) { + // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval + fn = new Function('$dd_inspect', ...contextKeys, fnBodyTemplate) as (...args: any[]) => any[] + functionCache.set(cacheKey, fn) + } + return fn + }, + clearCache: () => { + functionCache.clear() + }, + } + } + delete probe.segments + + // Optimize for fast calculations when probe is hit - calculate sampling budget + const snapshotsPerSecond = + probe.sampling?.snapshotsPerSecond ?? + (probe.captureSnapshot ? MAX_SNAPSHOTS_PER_SECOND_PER_PROBE : MAX_NON_SNAPSHOTS_PER_SECOND_PER_PROBE) + ;(probe as InitializedProbe).msBetweenSampling = (1 / snapshotsPerSecond) * 1000 // Convert to milliseconds + ;(probe as InitializedProbe).lastCaptureMs = -Infinity // Initialize to -Infinity to allow first call +} diff --git a/packages/debugger/src/domain/stacktrace.spec.ts b/packages/debugger/src/domain/stacktrace.spec.ts new file mode 100644 index 0000000000..935843b09f --- /dev/null +++ b/packages/debugger/src/domain/stacktrace.spec.ts @@ -0,0 +1,235 @@ +import { captureStackTrace, parseStackTrace } from './stacktrace' + +describe('stacktrace', () => { + describe('parseStackTrace', () => { + it('should parse Chrome/V8 stack trace format with function names', () => { + const error = { + stack: `Error: test error + at captureStackTrace (http://example.com/stacktrace.js:1:1) + at myFunction (http://example.com/app.js:42:10) + at anotherFunction (http://example.com/app.js:100:5)`, + } as Error + + const result = parseStackTrace(error) + + expect(result).toEqual([ + { + fileName: 'http://example.com/app.js', + function: 'myFunction', + lineNumber: 42, + columnNumber: 10, + }, + { + fileName: 'http://example.com/app.js', + function: 'anotherFunction', + lineNumber: 100, + columnNumber: 5, + }, + ]) + }) + + it('should parse Chrome/V8 stack trace format without function names', () => { + const error = { + stack: `Error: test error + at captureStackTrace (http://example.com/stacktrace.js:1:1) + at http://example.com/app.js:42:10 + at http://example.com/app.js:100:5`, + } as Error + + const result = parseStackTrace(error) + + expect(result).toEqual([ + { + fileName: 'http://example.com/app.js', + function: '', + lineNumber: 42, + columnNumber: 10, + }, + { + fileName: 'http://example.com/app.js', + function: '', + lineNumber: 100, + columnNumber: 5, + }, + ]) + }) + + it('should parse Firefox stack trace format', () => { + const error = { + stack: `test error +captureStackTrace@http://example.com/stacktrace.js:1:1 +myFunction@http://example.com/app.js:42:10 +anotherFunction@http://example.com/app.js:100:5`, + } as Error + + const result = parseStackTrace(error) + + expect(result).toEqual([ + { + fileName: 'http://example.com/app.js', + function: 'myFunction', + lineNumber: 42, + columnNumber: 10, + }, + { + fileName: 'http://example.com/app.js', + function: 'anotherFunction', + lineNumber: 100, + columnNumber: 5, + }, + ]) + }) + + it('should skip frames when skipFrames is specified', () => { + const error = { + stack: `Error: test error + at captureStackTrace (http://example.com/stacktrace.js:1:1) + at frameToSkip (http://example.com/app.js:10:5) + at myFunction (http://example.com/app.js:42:10) + at anotherFunction (http://example.com/app.js:100:5)`, + } as Error + + const result = parseStackTrace(error, 1) + + expect(result).toEqual([ + { + fileName: 'http://example.com/app.js', + function: 'myFunction', + lineNumber: 42, + columnNumber: 10, + }, + { + fileName: 'http://example.com/app.js', + function: 'anotherFunction', + lineNumber: 100, + columnNumber: 5, + }, + ]) + }) + + it('should return empty array when error has no stack', () => { + const error = {} as Error + + const result = parseStackTrace(error) + + expect(result).toEqual([]) + }) + + it('should handle empty stack string', () => { + const error = { stack: '' } as Error + + const result = parseStackTrace(error) + + expect(result).toEqual([]) + }) + + it('should skip malformed stack lines', () => { + const error = { + stack: `Error: test error + at captureStackTrace (http://example.com/stacktrace.js:1:1) + at myFunction (http://example.com/app.js:42:10) + some malformed line without proper format + at anotherFunction (http://example.com/app.js:100:5)`, + } as Error + + const result = parseStackTrace(error) + + expect(result).toEqual([ + { + fileName: 'http://example.com/app.js', + function: 'myFunction', + lineNumber: 42, + columnNumber: 10, + }, + { + fileName: 'http://example.com/app.js', + function: 'anotherFunction', + lineNumber: 100, + columnNumber: 5, + }, + ]) + }) + + it('should handle file paths with spaces', () => { + const error = { + stack: `Error: test error + at captureStackTrace (http://example.com/stacktrace.js:1:1) + at myFunction (http://example.com/my app.js:42:10)`, + } as Error + + const result = parseStackTrace(error) + + expect(result).toEqual([ + { + fileName: 'http://example.com/my app.js', + function: 'myFunction', + lineNumber: 42, + columnNumber: 10, + }, + ]) + }) + + it('should trim whitespace from function and file names', () => { + const error = { + stack: `Error: test error + at captureStackTrace (http://example.com/stacktrace.js:1:1) + at myFunction ( http://example.com/app.js :42:10)`, + } as Error + + const result = parseStackTrace(error) + + expect(result).toEqual([ + { + fileName: 'http://example.com/app.js', + function: 'myFunction', + lineNumber: 42, + columnNumber: 10, + }, + ]) + }) + }) + + describe('captureStackTrace', () => { + it('should capture current stack trace', () => { + const result = captureStackTrace() + + expect(result.length).toBeGreaterThan(0) + expect(result[0]).toEqual( + jasmine.objectContaining({ + fileName: jasmine.any(String), + function: jasmine.any(String), + lineNumber: jasmine.any(Number), + columnNumber: jasmine.any(Number), + }) + ) + }) + + it('should skip frames when specified', () => { + function testFunction() { + return captureStackTrace(0) + } + + function wrapperFunction() { + return testFunction() + } + + const resultWithoutSkip = wrapperFunction() + const resultWithSkip = captureStackTrace(1) + + // When skipping frames, we should have fewer frames + expect(resultWithSkip.length).toBeLessThan(resultWithoutSkip.length) + }) + + it('should skip captureStackTrace itself and error creation', () => { + function namedFunction() { + return captureStackTrace() + } + + const result = namedFunction() + + // The first frame should be namedFunction, not captureStackTrace + // (Note: exact function name matching depends on browser/minification) + expect(result.length).toBeGreaterThan(0) + }) + }) +}) diff --git a/packages/debugger/src/domain/stacktrace.ts b/packages/debugger/src/domain/stacktrace.ts new file mode 100644 index 0000000000..697a39e1e6 --- /dev/null +++ b/packages/debugger/src/domain/stacktrace.ts @@ -0,0 +1,61 @@ +export interface StackFrame { + fileName: string + function: string + lineNumber: number + columnNumber: number +} + +/** + * Capture the current stack trace + * + * @param skipFrames - Number of frames to skip from the top of the stack (default: 0) + * @returns Array of stack frames + */ +export function captureStackTrace(skipFrames = 0): StackFrame[] { + const error = new Error() + return parseStackTrace(error, skipFrames) +} + +/** + * Parse a stack trace from an Error object + * + * @param error - Error object with stack property + * @param skipFrames - Number of frames to skip from the top of the stack (default: 0) + * @returns Array of stack frames + */ +export function parseStackTrace(error: Error, skipFrames = 0): StackFrame[] { + const stack: StackFrame[] = [] + if (!error.stack) { + return stack + } + const stackLines = error.stack.split('\n') + + // Skip the first line (error message), the captureStackTrace frame, and any additional frames to skip + for (let i = 2 + skipFrames; i < stackLines.length; i++) { + const line = stackLines[i].trim() + + // Match various stack frame formats: + // Chrome/V8: "at functionName (file:line:column)" or "at file:line:column" + // Firefox: "functionName@file:line:column" + const chromeMatch = line.match(/at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?/) + const firefoxMatch = line.match(/(.+?)@(.+?):(\d+):(\d+)/) + + const match = chromeMatch || firefoxMatch + if (match) { + const isChrome = !!chromeMatch + const functionName = isChrome ? match[1] || '' : match[1] || '' + const fileName = isChrome ? match[2] : match[2] + const lineNumber = parseInt(isChrome ? match[3] : match[3], 10) + const columnNumber = parseInt(isChrome ? match[4] : match[4], 10) + + stack.push({ + fileName: fileName.trim(), + function: functionName.trim(), + lineNumber, + columnNumber, + }) + } + } + + return stack +} diff --git a/packages/debugger/src/domain/template.spec.ts b/packages/debugger/src/domain/template.spec.ts new file mode 100644 index 0000000000..e07f13fb73 --- /dev/null +++ b/packages/debugger/src/domain/template.spec.ts @@ -0,0 +1,430 @@ +import { templateRequiresEvaluation, compileSegments, evaluateProbeMessage, browserInspect } from './template' + +describe('template', () => { + describe('templateRequiresEvaluation', () => { + it('should return false for undefined segments', () => { + expect(templateRequiresEvaluation(undefined)).toBe(false) + }) + + it('should return false for segments with only static strings', () => { + const segments = [{ str: 'hello' }, { str: ' world' }] + expect(templateRequiresEvaluation(segments)).toBe(false) + }) + + it('should return true for segments with DSL expressions', () => { + const segments = [{ str: 'Value: ' }, { dsl: 'x', json: { ref: 'x' } }] + expect(templateRequiresEvaluation(segments)).toBe(true) + }) + + it('should return true if any segment has DSL', () => { + const segments = [{ str: 'hello' }, { dsl: 'x', json: { ref: 'x' } }, { str: 'world' }] + expect(templateRequiresEvaluation(segments)).toBe(true) + }) + }) + + describe('compileSegments', () => { + it('should compile static string segments', () => { + const segments = [{ str: 'hello' }, { str: ' world' }] + const result = compileSegments(segments) + + expect(result).toBe('["hello"," world"]') + }) + + it('should compile DSL expression segments', () => { + const segments = [{ str: 'Value: ' }, { dsl: 'x', json: { ref: 'x' } }] + const result = compileSegments(segments) + + expect(result).toContain('(() => {') + expect(result).toContain('try {') + expect(result).toContain('catch (e) {') + }) + + it('should compile mixed static and dynamic segments', () => { + const segments = [ + { str: 'x=' }, + { dsl: 'x', json: { ref: 'x' } }, + { str: ', y=' }, + { dsl: 'y', json: { ref: 'y' } }, + ] + const result = compileSegments(segments) + + expect(result).toContain('"x="') + expect(result).toContain('(() => {') + expect(result).toContain('", y="') + }) + + it('should handle errors in DSL evaluation', () => { + const segments = [{ dsl: 'badExpr', json: { ref: 'nonExistent' } }] + const code = compileSegments(segments) + + // The compiled code should have error handling + expect(code).toContain('catch (e)') + expect(code).toContain('message') + }) + }) + + describe('browserInspect', () => { + it('should inspect null', () => { + expect(browserInspect(null)).toBe('null') + }) + + it('should inspect undefined', () => { + expect(browserInspect(undefined)).toBe('undefined') + }) + + it('should inspect strings', () => { + expect(browserInspect('hello')).toBe('hello') + }) + + it('should inspect numbers', () => { + expect(browserInspect(42)).toBe('42') + expect(browserInspect(3.14)).toBe('3.14') + }) + + it('should inspect booleans', () => { + expect(browserInspect(true)).toBe('true') + expect(browserInspect(false)).toBe('false') + }) + + it('should inspect bigint', () => { + expect(browserInspect(BigInt(123))).toBe('123n') + }) + + it('should inspect symbols', () => { + const sym = Symbol('test') + expect(browserInspect(sym)).toContain('Symbol(test)') + }) + + it('should inspect functions', () => { + function myFunc() {} // eslint-disable-line @typescript-eslint/no-empty-function + const result = browserInspect(myFunc) + expect(result).toBe('[Function: myFunc]') + }) + + it('should inspect anonymous functions', () => { + const result = browserInspect(() => {}) // eslint-disable-line @typescript-eslint/no-empty-function + expect(result).toContain('[Function:') + }) + + it('should inspect plain objects', () => { + const result = browserInspect({ a: 1, b: 2 }) + expect(result).toBe('{"a":1,"b":2}') + }) + + it('should inspect arrays', () => { + const result = browserInspect([1, 2, 3]) + expect(result).toBe('[1,2,3]') + }) + + it('should handle circular references gracefully', () => { + const obj: any = { name: 'test' } + obj.self = obj + const result = browserInspect(obj) + // Should either return [Object] or handle the error + expect(result).toBeTruthy() + }) + + it('should handle objects without constructor', () => { + const obj = Object.create(null) + const result = browserInspect(obj) + expect(result).toBe('{}') + }) + + describe('limits', () => { + describe('maxStringLength (8KB)', () => { + it('should truncate very long strings', () => { + const longString = 'a'.repeat(10000) + const result = browserInspect(longString) + expect(result).toBe(`${'a'.repeat(8192)}…`) + }) + + it('should not truncate strings shorter than 8KB', () => { + const shortString = 'a'.repeat(100) + const result = browserInspect(shortString) + expect(result).toBe(shortString) + }) + }) + + describe('maxArrayLength (3)', () => { + it('should truncate arrays longer than 3 elements', () => { + const longArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + const result = browserInspect(longArray) + expect(result).toBe('[1,2,3, ... 7 more items]') + }) + + it('should not truncate arrays with 3 or fewer elements', () => { + const shortArray = [1, 2, 3] + const result = browserInspect(shortArray) + expect(result).toBe('[1,2,3]') + }) + + it('should handle empty arrays', () => { + const result = browserInspect([]) + expect(result).toBe('[]') + }) + }) + + describe('depth (0)', () => { + it('should fully stringify plain objects (depth limit applies to arrays)', () => { + const nested = { a: { b: { c: { d: 'deep' } } } } + const result = browserInspect(nested) + // Objects are fully stringified via JSON.stringify + expect(result).toBe('{"a":{"b":{"c":{"d":"deep"}}}}') + }) + + it('should show root array but collapse nested arrays at depth 0', () => { + const nested = [[['deep']]] + const result = browserInspect(nested) + expect(result).toBe('[[Array]]') + }) + + it('should show array structure but collapse nested objects in arrays', () => { + const nested = [{ a: 1 }, { b: 2 }, { c: 3 }] + const result = browserInspect(nested) + expect(result).toBe('[[Object],[Object],[Object]]') + }) + }) + + describe('combined limits', () => { + it('should apply both maxStringLength and maxArrayLength', () => { + const data = ['a'.repeat(10000), 'b'.repeat(10000), 'c'.repeat(10000), 'd'.repeat(10000)] + const result = browserInspect(data) + expect(result).toContain(`${'a'.repeat(8192)}…`) + expect(result).toContain(`${'b'.repeat(8192)}…`) + expect(result).toContain(`${'c'.repeat(8192)}…`) + expect(result).toContain('1 more items') + }) + + it('should respect depth with maxArrayLength', () => { + const nested = [{ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }] + const result = browserInspect(nested) + expect(result).toBe('[[Object],[Object],[Object], ... 1 more items]') + }) + }) + }) + }) + + describe('evaluateProbeMessage', () => { + it('should return static template string when no evaluation needed', () => { + const probe: any = { + templateRequiresEvaluation: false, + template: 'Static message', + } + const result = evaluateProbeMessage(probe, {}) + expect(result).toBe('Static message') + }) + + it('should evaluate template with simple expressions', () => { + const template: any = { + createFunction: (keys: string[]) => { + expect(keys).toEqual(['x', 'y']) + // eslint-disable-next-line camelcase, @typescript-eslint/no-unused-vars + return ($dd_inspect: any, x: number, y: number) => [`x=${x}, y=${y}`] + }, + } + + const probe: any = { + templateRequiresEvaluation: true, + template, + } + + const result = evaluateProbeMessage(probe, { x: 10, y: 20 }) + expect(result).toBe('x=10, y=20') + }) + + it('should handle segments with static and dynamic parts', () => { + const template: any = { + createFunction: + (keys: string[]) => + // eslint-disable-next-line camelcase, @typescript-eslint/no-unused-vars + ($dd_inspect: any, ...values: any[]) => { + const context: any = {} + keys.forEach((key, i) => { + context[key] = values[i] + }) + return ['Value: ', String(context.value)] + }, + } + + const probe: any = { + templateRequiresEvaluation: true, + template, + } + + const result = evaluateProbeMessage(probe, { value: 42 }) + expect(result).toBe('Value: 42') + }) + + it('should handle error objects in segments', () => { + const template: any = { + createFunction: () => () => [{ expr: 'bad.expr', message: 'TypeError: Cannot read property' }], + } + + const probe: any = { + templateRequiresEvaluation: true, + template, + } + + const result = evaluateProbeMessage(probe, {}) + expect(result).toBe('{TypeError: Cannot read property}') + }) + + it('should handle non-string segments', () => { + const template: any = { + createFunction: () => () => [42, ' ', true, ' ', null], + } + + const probe: any = { + templateRequiresEvaluation: true, + template, + } + + const result = evaluateProbeMessage(probe, {}) + expect(result).toBe('42 true null') + }) + + it('should handle templates with this context', () => { + const segments = [ + { str: 'Method called on ' }, + { dsl: 'this.name', json: { getmember: [{ ref: 'this' }, 'name'] } }, + { str: ' with arg=' }, + { dsl: 'a', json: { ref: 'a' } }, + ] + + // Compile the segments like initializeProbe does + const segmentsCode = compileSegments(segments) + const fnBodyTemplate = `return ${segmentsCode};` + const functionCache = new Map any[]>() + + const template = { + createFunction: (contextKeys: string[]) => { + const cacheKey = contextKeys.join(',') + let fn = functionCache.get(cacheKey) + if (!fn) { + // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval + fn = new Function('$dd_inspect', ...contextKeys, fnBodyTemplate) as (...args: any[]) => any[] + functionCache.set(cacheKey, fn) + } + return fn + }, + } + + const probe: any = { + templateRequiresEvaluation: true, + template, + } + + const context = { + this: { name: 'MyClass' }, + a: 42, + } + + const result = evaluateProbeMessage(probe, context) + expect(result).toBe('Method called on MyClass with arg=42') + }) + + it('should handle templates without this context', () => { + const segments = [{ str: 'Simple message with ' }, { dsl: 'a', json: { ref: 'a' } }] + + // Compile the segments like initializeProbe does + const segmentsCode = compileSegments(segments) + const fnBodyTemplate = `return ${segmentsCode};` + const functionCache = new Map any[]>() + + const template = { + createFunction: (contextKeys: string[]) => { + const cacheKey = contextKeys.join(',') + let fn = functionCache.get(cacheKey) + if (!fn) { + // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval + fn = new Function('$dd_inspect', ...contextKeys, fnBodyTemplate) as (...args: any[]) => any[] + functionCache.set(cacheKey, fn) + } + return fn + }, + } + + const probe: any = { + templateRequiresEvaluation: true, + template, + } + + // Context without 'this' + const context = { + a: 42, + } + + const result = evaluateProbeMessage(probe, context) + expect(result).toBe('Simple message with 42') + }) + + it('should handle template evaluation errors', () => { + const template: any = { + createFunction: () => () => { + throw new Error('Evaluation failed') + }, + } + + const probe: any = { + templateRequiresEvaluation: true, + template, + } + + const result = evaluateProbeMessage(probe, {}) + expect(result).toBe('{Error: Evaluation failed}') + }) + + it('should truncate long messages', () => { + const longMessage = 'a'.repeat(10000) + const template: any = { + createFunction: () => () => [longMessage], + } + + const probe: any = { + templateRequiresEvaluation: true, + template, + } + + const result = evaluateProbeMessage(probe, {}) + expect(result.length).toBeLessThanOrEqual(8192 + 1) // 8KB + ellipsis + expect(result).toContain('…') + }) + + it('should use browserInspect for object values', () => { + const template: any = { + // eslint-disable-next-line camelcase + createFunction: () => ($dd_inspect: (value: any, options: any) => string, obj: any) => [ + 'Object: ', + $dd_inspect(obj, {}), + ], + } + + const probe: any = { + templateRequiresEvaluation: true, + template, + } + + const result = evaluateProbeMessage(probe, { obj: { a: 1, b: 2 } }) + expect(result).toBe('Object: {"a":1,"b":2}') + }) + }) + + describe('integration', () => { + it('should compile and evaluate complete template', () => { + const segments = [ + { str: 'User ' }, + { dsl: 'name', json: { ref: 'name' } }, + { str: ' has ' }, + { dsl: 'count', json: { ref: 'count' } }, + { str: ' items' }, + ] + + const compiledCode = compileSegments(segments) + expect(compiledCode).toBeTruthy() + + // The compiled code would be used to create a function + // This demonstrates the flow even though the actual function creation + // happens in the probe initialization + }) + }) +}) diff --git a/packages/debugger/src/domain/template.ts b/packages/debugger/src/domain/template.ts new file mode 100644 index 0000000000..27655a07f0 --- /dev/null +++ b/packages/debugger/src/domain/template.ts @@ -0,0 +1,248 @@ +/** + * Template compilation and evaluation utilities for live debugger + */ + +import { compile } from './expression' + +const MAX_MESSAGE_LENGTH = 8 * 1024 // 8KB + +export interface TemplateSegment { + str?: string + dsl?: string + json?: any +} + +export interface CompiledTemplate { + createFunction: (keys: string[]) => (...args: any[]) => any[] + clearCache?: () => void +} + +// Options for browserInspect - controls how values are stringified +const INSPECT_MAX_ARRAY_LENGTH = 3 +const INSPECT_MAX_STRING_LENGTH = 8 * 1024 // 8KB + +/** + * Check if template segments require runtime evaluation + * + * @param segments - Array of segment objects + * @returns True if segments contain expressions to evaluate + */ +export function templateRequiresEvaluation(segments: TemplateSegment[] | undefined): boolean { + if (segments === undefined) { + return false + } + for (const { dsl } of segments) { + if (dsl !== undefined) { + return true + } + } + return false +} + +/** + * Compile template segments into executable code + * + * @param segments - Array of segment objects with str (static) or dsl/json (dynamic) + * @returns Compiled JavaScript code that returns an array + */ +export function compileSegments(segments: TemplateSegment[]): string { + let segmentsCode = '[' + for (let i = 0; i < segments.length; i++) { + const { str, dsl, json } = segments[i] + segmentsCode += + str === undefined + ? `(() => { + try { + const result = ${compile(json)} + return typeof result === 'string' ? result : $dd_inspect(result) + } catch (e) { + return { expr: ${JSON.stringify(dsl)}, message: \`\${e.name}: \${e.message}\` } + } + })()` + : JSON.stringify(str) + if (i !== segments.length - 1) { + segmentsCode += ',' + } + } + segmentsCode += ']' + + // Return the compiled array code (not the function yet - that's done with context) + return segmentsCode +} + +/** + * Browser-compatible inspect function for template segment evaluation + * + * @param value - Value to inspect + * @returns String representation of the value + */ +// TODO: Should we use a 3rd party library instead of implementing our own? +export function browserInspect(value: unknown): string { + return browserInspectInternal(value) +} + +function browserInspectInternal(value: unknown, depthExceeded: boolean = false): string { + if (value === null) { + return 'null' + } + if (value === undefined) { + return 'undefined' + } + + if (typeof value === 'string') { + if (value.length > INSPECT_MAX_STRING_LENGTH) { + return `${value.slice(0, INSPECT_MAX_STRING_LENGTH)}…` + } + return value + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + if (typeof value === 'bigint') { + return `${value}n` + } + if (typeof value === 'symbol') { + return value.toString() + } + if (typeof value === 'function') { + return `[Function: ${value.name || 'anonymous'}]` + } + + // Handle arrays + if (Array.isArray(value)) { + // Special case: if depth is exceeded AND the array contains arrays, collapse entirely + if (depthExceeded && value.length > 0 && Array.isArray(value[0])) { + return '[Array]' + } + + if (value.length > INSPECT_MAX_ARRAY_LENGTH) { + const truncated = value.slice(0, INSPECT_MAX_ARRAY_LENGTH) + const remaining = value.length - INSPECT_MAX_ARRAY_LENGTH + const items = truncated.map((item) => inspectValueInternal(item, true)).join(',') + return `[${items}, ... ${remaining} more items]` + } + // Recursively inspect array items with increased depth + const items = value.map((item) => inspectValueInternal(item, true)).join(',') + return `[${items}]` + } + + // Handle objects + if (depthExceeded) { + return '[Object]' + } + + try { + // Create custom replacer to handle maxStringLength in nested values + const replacer = (_key: string, val: unknown) => { + if (typeof val === 'string' && val.length > INSPECT_MAX_STRING_LENGTH) { + return `${val.slice(0, INSPECT_MAX_STRING_LENGTH)}…` + } + return val + } + return JSON.stringify(value, replacer, 0) + } catch { + return `[${(value as any).constructor?.name || 'Object'}]` + } +} + +/** + * Helper function to inspect a value + * Used for recursive inspection of array/object elements + */ +function inspectValueInternal(value: unknown, depthExceeded: boolean = false): string { + if (value === null) { + return 'null' + } + if (value === undefined) { + return 'undefined' + } + if (typeof value === 'string') { + // For nested strings in arrays, we need to quote them like JSON + const str = value.length > INSPECT_MAX_STRING_LENGTH ? `${value.slice(0, INSPECT_MAX_STRING_LENGTH)}…` : value + return JSON.stringify(str) + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + if (typeof value === 'bigint') { + return `${value}n` + } + + // For nested objects/arrays, check depth + if (depthExceeded) { + if (Array.isArray(value)) { + return '[Array]' + } + if (typeof value === 'object') { + return '[Object]' + } + } + + // Recursively inspect with browserInspectInternal + return browserInspectInternal(value, true) +} + +/** + * Evaluate compiled template with runtime context + * + * @param compiledTemplate - Template object with createFunction factory + * @param context - Runtime context with variables + * @returns Array of segment results (strings or error objects) + */ +function evalCompiledTemplate(compiledTemplate: CompiledTemplate, context: Record): any[] { + // Separate 'this' from other context variables + const { this: thisValue, ...otherContext } = context + const contextKeys = Object.keys(otherContext) + const contextValues = Object.values(otherContext) + + // Create function with dynamic parameters (function body was pre-built during initialization) + const fn = compiledTemplate.createFunction(contextKeys) + + // Execute with browserInspect and context values, binding 'this' context + return fn.call(thisValue, browserInspect, ...contextValues) +} + +export interface ProbeWithTemplate { + templateRequiresEvaluation: boolean + template: CompiledTemplate | string +} + +/** + * Evaluate probe message from template and runtime result + * + * @param probe - Probe configuration + * @param context - Runtime execution context + * @returns Evaluated and truncated message + */ +export function evaluateProbeMessage(probe: ProbeWithTemplate, context: Record): string { + let message = '' + + if (probe.templateRequiresEvaluation) { + try { + const segments = evalCompiledTemplate(probe.template as CompiledTemplate, context) + message = segments + .map((seg) => { + if (typeof seg === 'string') { + return seg + } else if (seg && typeof seg === 'object' && seg.expr) { + // Error object from template evaluation + return `{${seg.message}}` + } + return String(seg) + }) + .join('') + } catch (e) { + message = `{Error: ${(e as Error).message}}` + } + } else { + message = probe.template as string + } + + // Truncate message if it exceeds maximum length + if (message.length > MAX_MESSAGE_LENGTH) { + message = `${message.slice(0, MAX_MESSAGE_LENGTH)}…` + } + + return message +} diff --git a/packages/debugger/src/entries/main.ts b/packages/debugger/src/entries/main.ts new file mode 100644 index 0000000000..8bb71cbc38 --- /dev/null +++ b/packages/debugger/src/entries/main.ts @@ -0,0 +1,189 @@ +/** + * Datadog Browser Live Debugger SDK + * Provides dynamic instrumentation capabilities for browser applications. + * + * @packageDocumentation + * @see [Live Debugger Documentation](https://docs.datadoghq.com/dynamic_instrumentation/) + */ + +import { defineGlobal, getGlobalObject, makePublicApi, display } from '@datadog/browser-core' +import type { PublicApi, Site } from '@datadog/browser-core' +import { onEntry, onReturn, onThrow, initDebuggerTransport } from '../domain/api' +import { startDeliveryApiPolling } from '../domain/deliveryApi' +import { addProbe, getProbes, getAllProbes, removeProbe, clearProbes } from '../domain/probes' +import type { Probe } from '../domain/probes' +import { startDebuggerBatch } from '../transport/startDebuggerBatch' + +export type { Probe, ProbeWhere, ProbeWhen, ProbeSampling, InitializedProbe } from '../domain/probes' +export type { CaptureOptions, CapturedValue } from '../domain/capture' +export type { StackFrame } from '../domain/stacktrace' +export type { Site } from '@datadog/browser-core' + +/** + * Configuration options for initializing the Live Debugger SDK + */ +export interface LiveDebuggerInitConfiguration { + /** + * The client token for Datadog. Required for authenticating your application with Datadog. + * + * @category Authentication + */ + clientToken: string + + /** + * The Datadog site to send data to + * + * @category Transport + * @defaultValue 'datadoghq.com' + */ + site?: Site + + /** + * The service name for your application + * + * @category Data Collection + */ + service: string + + /** + * The application's environment (e.g., prod, staging) + * + * @category Data Collection + */ + env?: string + + /** + * The application's version + * + * @category Data Collection + */ + version?: string + + /** + * The RUM application ID. + * + * @category Delivery API + */ + applicationId: string + + /** + * Polling interval in milliseconds for fetching probe updates + * + * @category Delivery API + * @defaultValue 60000 + */ + pollInterval?: number +} + +/** + * Public API for the Live Debugger browser SDK. + * + * @category Main + */ +export interface LiveDebuggerPublicApi extends PublicApi { + /** + * Initialize the Live Debugger SDK + * + * @category Init + * @param initConfiguration - Configuration options + * @example + * ```ts + * datadogLiveDebugger.init({ + * clientToken: '', + * applicationId: '', + * service: 'my-app', + * site: 'datadoghq.com', + * env: 'production' + * }) + * ``` + */ + init: (initConfiguration: LiveDebuggerInitConfiguration) => void + + /** + * Add a new probe to the debugger + * + * @category Probes + * @param probe - Probe configuration + */ + addProbe: (probe: Probe) => void + + /** + * Remove a probe by ID + * + * @category Probes + * @param id - Probe ID to remove + */ + removeProbe: (id: string) => void + + /** + * Clear all probes + * + * @category Probes + */ + clearProbes: () => void + + /** + * Get all currently active probes across all instrumented functions + * + * @category Probes + * @returns Array of all active probes + */ + getProbes: () => Probe[] +} + +/** + * Create the public API for the Live Debugger + */ +function makeLiveDebuggerPublicApi(): LiveDebuggerPublicApi { + return makePublicApi({ + init: (initConfiguration: LiveDebuggerInitConfiguration) => { + // Initialize debugger's own transport + const batch = startDebuggerBatch(initConfiguration) + initDebuggerTransport(initConfiguration, batch) + + // Expose internal hooks on globalThis for instrumented code + if (typeof globalThis !== 'undefined') { + ;(globalThis as any).$dd_entry = onEntry + ;(globalThis as any).$dd_return = onReturn + ;(globalThis as any).$dd_throw = onThrow + ;(globalThis as any).$dd_probes = getProbes + } + + startDeliveryApiPolling({ + applicationId: initConfiguration.applicationId, + env: initConfiguration.env, + version: initConfiguration.version, + pollInterval: initConfiguration.pollInterval, + }) + }, + + addProbe: (probe: Probe) => { + addProbe(probe) + }, + + removeProbe: (id: string) => { + removeProbe(id) + }, + + clearProbes: () => { + clearProbes() + }, + + getProbes: () => getAllProbes(), + }) +} + +/** + * The global Live Debugger instance. Use this to call Live Debugger methods. + * + * @category Main + * @see {@link LiveDebuggerPublicApi} + * @see [Live Debugger Documentation](https://docs.datadoghq.com/dynamic_instrumentation/) + */ +export const datadogLiveDebugger = makeLiveDebuggerPublicApi() + +interface BrowserWindow extends Window { + DD_DEBUGGER?: LiveDebuggerPublicApi +} + +defineGlobal(getGlobalObject(), 'DD_DEBUGGER', datadogLiveDebugger) diff --git a/packages/debugger/src/index.ts b/packages/debugger/src/index.ts new file mode 100644 index 0000000000..022c052a8e --- /dev/null +++ b/packages/debugger/src/index.ts @@ -0,0 +1,22 @@ +// Internal exports for use by other packages if needed +export { onEntry, onReturn, onThrow } from './domain/api' +export { + addProbe, + removeProbe, + getProbes, + clearProbes, + initializeProbe, + checkGlobalSnapshotBudget, +} from './domain/probes' +export { capture } from './domain/capture' +export { captureStackTrace, parseStackTrace } from './domain/stacktrace' +export { compile } from './domain/expression' +export { compileSegments, templateRequiresEvaluation, evaluateProbeMessage, browserInspect } from './domain/template' +export { evaluateProbeCondition } from './domain/condition' + +export type { Probe, InitializedProbe, ProbeWhere, ProbeWhen, ProbeSampling } from './domain/probes' +export type { CaptureOptions, CapturedValue } from './domain/capture' +export type { StackFrame } from './domain/stacktrace' +export type { ExpressionNode } from './domain/expression' +export type { TemplateSegment, CompiledTemplate, ProbeWithTemplate } from './domain/template' +export type { ProbeWithCondition } from './domain/condition' diff --git a/packages/debugger/src/transport/startDebuggerBatch.ts b/packages/debugger/src/transport/startDebuggerBatch.ts new file mode 100644 index 0000000000..660cd13734 --- /dev/null +++ b/packages/debugger/src/transport/startDebuggerBatch.ts @@ -0,0 +1,53 @@ +import type { InitConfiguration, PageMayExitEvent } from '@datadog/browser-core' +import { + createBatch, + createFlushController, + createHttpRequest, + createIdentityEncoder, + computeTransportConfiguration, + Observable, + PageExitReason, + display, +} from '@datadog/browser-core' +import type { Batch } from '@datadog/browser-core' + +export function startDebuggerBatch(initConfiguration: InitConfiguration): Batch { + const { logsEndpointBuilder } = computeTransportConfiguration(initConfiguration) + + const batch = createBatch({ + encoder: createIdentityEncoder(), + request: createHttpRequest([logsEndpointBuilder], (error) => display.error('Debugger transport error:', error)), + flushController: createFlushController({ + pageMayExitObservable: createSimplePageMayExitObservable(), + sessionExpireObservable: new Observable(), + }), + }) + + return batch +} + +function createSimplePageMayExitObservable(): Observable { + return new Observable((observable) => { + if (typeof window === 'undefined') { + return + } + + const onVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + observable.notify({ reason: PageExitReason.HIDDEN }) + } + } + + const onBeforeUnload = () => { + observable.notify({ reason: PageExitReason.UNLOADING }) + } + + window.addEventListener('visibilitychange', onVisibilityChange, { capture: true }) + window.addEventListener('beforeunload', onBeforeUnload) + + return () => { + window.removeEventListener('visibilitychange', onVisibilityChange, { capture: true }) + window.removeEventListener('beforeunload', onBeforeUnload) + } + }) +} diff --git a/packages/debugger/test/expression-test-cases.ts b/packages/debugger/test/expression-test-cases.ts new file mode 100644 index 0000000000..628f504cdb --- /dev/null +++ b/packages/debugger/test/expression-test-cases.ts @@ -0,0 +1,811 @@ +/** + * Test case definitions for the expression compiler. + * Adapted from dd-trace-js/packages/dd-trace/test/debugger/devtools_client/condition-test-cases.js + */ + +import type { ExpressionNode } from '../src/domain/expression' + +export type VariableBindings = Record + +export type TestCaseTuple = [ExpressionNode, VariableBindings, unknown] +export interface TestCaseObject { + ast: ExpressionNode + vars?: VariableBindings + expected?: unknown + execute?: boolean + before?: () => void + suffix?: string +} +export type TestCase = TestCaseTuple | TestCaseObject + +class CustomObject {} +class HasInstanceSideEffect { + static [Symbol.hasInstance](): boolean { + throw new Error('This should never throw!') + } +} +const weakKey = { weak: 'key' } +const objectWithToPrimitiveSymbol = Object.create(Object.prototype, { + [Symbol.toPrimitive]: { + value: () => { + throw new Error('This should never throw!') + }, + }, +}) +class EvilRegex extends RegExp { + exec(_string: string): RegExpExecArray | null { + throw new Error('This should never throw!') + } +} + +export const literals: TestCase[] = [ + [null, {}, null], + [42, {}, 42], + [true, {}, true], + ['foo', {}, 'foo'], +] + +export const references: TestCase[] = [ + [{ ref: 'foo' }, { foo: 42 }, 42], + [{ ref: 'foo' }, {}, new ReferenceError('foo is not defined')], + + // Reserved words, but we allow them as they can be useful + [{ ref: 'this' }, {}, globalThis], // Unless bound, `this` defaults to the global object + { ast: { ref: 'super' }, expected: 'super', execute: false }, + + // Literals, but we allow them as they can be useful + [{ ref: 'undefined' }, {}, undefined], + [{ ref: 'Infinity' }, {}, Infinity], + + // Old standard reserved words, no need to disallow them + [{ ref: 'abstract' }, { abstract: 42 }, 42], + + // Input sanitization + { + ast: { ref: 'break' }, + vars: { foo: { bar: 42 } }, + expected: new SyntaxError('Illegal identifier: break'), + execute: false, + }, + { + ast: { ref: 'let' }, + vars: { foo: { bar: 42 } }, + expected: new SyntaxError('Illegal identifier: let'), + execute: false, + }, + { + ast: { ref: 'await' }, + vars: { foo: { bar: 42 } }, + expected: new SyntaxError('Illegal identifier: await'), + execute: false, + }, + { + ast: { ref: 'enum' }, + vars: { foo: { bar: 42 } }, + expected: new SyntaxError('Illegal identifier: enum'), + execute: false, + }, + { + ast: { ref: 'implements' }, + vars: { foo: { bar: 42 } }, + expected: new SyntaxError('Illegal identifier: implements'), + execute: false, + }, + { ast: { ref: 'NaN' }, expected: new SyntaxError('Illegal identifier: NaN'), execute: false }, + { + ast: { ref: 'foo.bar' }, + vars: { foo: { bar: 42 } }, + expected: new SyntaxError('Illegal identifier: foo.bar'), + execute: false, + }, + { + ast: { ref: 'foo()' }, + vars: { foo: () => {} }, + expected: new SyntaxError('Illegal identifier: foo()'), + execute: false, + }, + { + ast: { ref: 'foo; bar' }, + vars: { foo: 1, bar: 2 }, + expected: new SyntaxError('Illegal identifier: foo; bar'), + execute: false, + }, + { + ast: { ref: 'foo\nbar' }, + vars: { foo: 1, bar: 2 }, + expected: new SyntaxError('Illegal identifier: foo\nbar'), + execute: false, + }, + { + ast: { ref: 'throw new Error()' }, + expected: new SyntaxError('Illegal identifier: throw new Error()'), + execute: false, + }, +] + +export const propertyAccess: TestCase[] = [ + [{ getmember: [{ ref: 'obj' }, 'foo'] }, { obj: { foo: 'test-me' } }, 'test-me'], + [{ getmember: [{ getmember: [{ ref: 'obj' }, 'foo'] }, 'bar'] }, { obj: { foo: { bar: 'test-me' } } }, 'test-me'], + [ + { getmember: [{ ref: 'set' }, 'foo'] }, + { set: new Set(['foo', 'bar']) }, + new Error('Accessing a Set or WeakSet is not allowed'), + ], + [ + { getmember: [{ ref: 'wset' }, { ref: 'key' }] }, + { key: weakKey, wset: new WeakSet([weakKey]) }, + new Error('Accessing a Set or WeakSet is not allowed'), + ], + [ + { getmember: [{ ref: 'map' }, 'foo'] }, + { map: new Map([['foo', 'bar']]) }, + new Error('Accessing a Map is not allowed'), + ], + [ + { getmember: [{ ref: 'wmap' }, { ref: 'key' }] }, + { key: weakKey, wmap: new WeakMap([[weakKey, 'bar']]) }, + new Error('Accessing a WeakMap is not allowed'), + ], + [ + { getmember: [{ ref: 'obj' }, 'getter'] }, + { + obj: Object.create(Object.prototype, { + getter: { + get() { + return 'x' + }, + }, + }), + }, + new Error('Possibility of side effect'), + ], + + [{ index: [{ ref: 'arr' }, 1] }, { arr: ['foo', 'bar'] }, 'bar'], + [{ index: [{ ref: 'arr' }, 100] }, { arr: ['foo', 'bar'] }, undefined], // Should throw according to spec + [{ index: [{ ref: 'obj' }, 'foo'] }, { obj: { foo: 'bar' } }, 'bar'], + [{ index: [{ ref: 'obj' }, 'bar'] }, { obj: { foo: 'bar' } }, undefined], // Should throw according to spec + [ + { index: [{ ref: 'set' }, 'foo'] }, + { set: new Set(['foo']) }, + new Error('Accessing a Set or WeakSet is not allowed'), + ], + [ + { index: [{ ref: 'set' }, 'bar'] }, + { set: new Set(['foo']) }, + new Error('Accessing a Set or WeakSet is not allowed'), + ], + [{ index: [{ ref: 'map' }, 'foo'] }, { map: new Map([['foo', 'bar']]) }, 'bar'], + [{ index: [{ ref: 'map' }, 'bar'] }, { map: new Map([['foo', 'bar']]) }, undefined], // Should throw according to spec + [{ index: [{ ref: 'wmap' }, { ref: 'key' }] }, { key: weakKey, wmap: new WeakMap([[weakKey, 'bar']]) }, 'bar'], + [ + { index: [{ ref: 'wmap' }, { ref: 'key' }] }, + { key: {}, wmap: new WeakMap([[weakKey, 'bar']]) }, + undefined, // Should throw according to spec + ], + [ + { index: [{ ref: 'set' }, { ref: 'key' }] }, + { key: weakKey, set: new WeakSet([weakKey]) }, + new Error('Accessing a Set or WeakSet is not allowed'), + ], + [ + { index: [{ ref: 'set' }, { ref: 'key' }] }, + { key: {}, set: new WeakSet([weakKey]) }, + new Error('Accessing a Set or WeakSet is not allowed'), + ], + [ + { index: [{ ref: 'obj' }, 'getter'] }, + { + obj: Object.create(Object.prototype, { + getter: { + get() { + return 'x' + }, + }, + }), + }, + new Error('Possibility of side effect'), + ], +] + +export const sizes: TestCase[] = [ + [{ len: { ref: 'str' } }, { str: 'hello' }, 5], + [{ len: { ref: 'str' } }, { str: String('hello') }, 5], + [{ len: { ref: 'str' } }, { str: new String('hello') }, 5], // eslint-disable-line no-new-wrappers + [{ len: { ref: 'arr' } }, { arr: [1, 2, 3] }, 3], + [{ len: { ref: 'set' } }, { set: new Set([1, 2]) }, 2], + [ + { len: { ref: 'set' } }, + { set: overloadPropertyWithGetter(new Set([1, 2]), 'size') }, + new Error('Possibility of side effect'), + ], + [{ len: { ref: 'map' } }, { map: new Map([[1, 2]]) }, 1], + [ + { len: { ref: 'map' } }, + { map: overloadPropertyWithGetter(new Map([[1, 2]]), 'size') }, + new Error('Possibility of side effect'), + ], + [{ len: { ref: 'wset' } }, { wset: new WeakSet([weakKey]) }, new TypeError('Cannot get size of WeakSet or WeakMap')], + [ + { len: { ref: 'wmap' } }, + { wmap: new WeakMap([[weakKey, 2]]) }, + new TypeError('Cannot get size of WeakSet or WeakMap'), + ], + [{ len: { getmember: [{ ref: 'obj' }, 'arr'] } }, { obj: { arr: Array(10).fill(0) } }, 10], + [{ len: { getmember: [{ ref: 'obj' }, 'tarr'] } }, { obj: { tarr: new Int16Array([10, 20, 30]) } }, 3], + [ + { len: { getmember: [{ ref: 'obj' }, 'tarr'] } }, + { obj: { tarr: overloadPropertyWithGetter(new Int16Array([10, 20, 30]), 'length') } }, + new Error('Possibility of side effect'), + ], + [{ len: { ref: 'pojo' } }, { pojo: { a: 1, b: 2, c: 3 } }, 3], + [ + { len: { getmember: [{ ref: 'obj' }, 'unknownProp'] } }, + { obj: {} }, + new TypeError('Cannot get length of variable'), + ], + [{ len: { ref: 'invalid' } }, {}, new ReferenceError('invalid is not defined')], + + // `count` should be implemented as a synonym for `len`, so we shouldn't need to test it as thoroughly + [{ count: { ref: 'str' } }, { str: 'hello' }, 5], + [{ count: { ref: 'arr' } }, { arr: [1, 2, 3] }, 3], + + [{ isEmpty: { ref: 'str' } }, { str: '' }, true], + [{ isEmpty: { ref: 'str' } }, { str: 'hello' }, false], + [{ isEmpty: { ref: 'str' } }, { str: String('') }, true], + [{ isEmpty: { ref: 'str' } }, { str: String('hello') }, false], + [{ isEmpty: { ref: 'str' } }, { str: new String('') }, true], // eslint-disable-line no-new-wrappers + [{ isEmpty: { ref: 'str' } }, { str: new String('hello') }, false], // eslint-disable-line no-new-wrappers + [{ isEmpty: { ref: 'arr' } }, { arr: [] }, true], + [{ isEmpty: { ref: 'arr' } }, { arr: [1, 2, 3] }, false], + [{ isEmpty: { ref: 'tarr' } }, { tarr: new Int32Array(0) }, true], + [{ isEmpty: { ref: 'tarr' } }, { tarr: new Int32Array([1, 2, 3]) }, false], + [{ isEmpty: { ref: 'set' } }, { set: new Set() }, true], + [{ isEmpty: { ref: 'set' } }, { set: new Set([1, 2, 3]) }, false], + [{ isEmpty: { ref: 'map' } }, { map: new Map() }, true], + [ + { isEmpty: { ref: 'map' } }, + { + map: new Map([ + ['a', 1], + ['b', 2], + ]), + }, + false, + ], + [{ isEmpty: { ref: 'obj' } }, { obj: new WeakSet() }, new TypeError('Cannot get size of WeakSet or WeakMap')], +] + +export const equality: TestCase[] = [ + [{ eq: [{ ref: 'str' }, 'foo'] }, { str: 'foo' }, true], + [{ eq: [{ ref: 'str' }, 'foo'] }, { str: 'bar' }, false], + [{ eq: [{ ref: 'str' }, 'foo'] }, { str: String('foo') }, true], + [{ eq: [{ ref: 'str' }, 'foo'] }, { str: String('bar') }, false], + // TODO: Is this the expected behavior? + [{ eq: [{ ref: 'str' }, 'foo'] }, { str: new String('foo') }, false], // eslint-disable-line no-new-wrappers + [{ eq: [{ ref: 'bool' }, true] }, { bool: true }, true], + [{ eq: [{ ref: 'nil' }, null] }, { nil: null }, true], + [{ eq: [{ ref: 'foo' }, { ref: 'undefined' }] }, { foo: undefined }, true], + [{ eq: [{ ref: 'foo' }, { ref: 'undefined' }] }, { foo: null }, false], + [{ eq: [{ ref: 'nan' }, { ref: 'nan' }] }, { nan: NaN }, false], + [{ eq: [{ getmember: [{ ref: 'obj' }, 'foo'] }, { ref: 'undefined' }] }, { obj: { foo: undefined } }, true], + [{ eq: [{ getmember: [{ ref: 'obj' }, 'foo'] }, { ref: 'undefined' }] }, { obj: {} }, true], + [{ eq: [{ getmember: [{ ref: 'obj' }, 'foo'] }, { ref: 'undefined' }] }, { obj: { foo: null } }, false], + [{ eq: [{ or: [true, false] }, { and: [true, false] }] }, {}, false], + + [{ ne: [{ ref: 'str' }, 'foo'] }, { str: 'foo' }, false], + [{ ne: [{ ref: 'str' }, 'foo'] }, { str: 'bar' }, true], + [{ ne: [{ ref: 'str' }, 'foo'] }, { str: String('foo') }, false], + [{ ne: [{ ref: 'str' }, 'foo'] }, { str: String('bar') }, true], + // TODO: Is this the expected behavior? + [{ ne: [{ ref: 'str' }, 'foo'] }, { str: new String('foo') }, true], // eslint-disable-line no-new-wrappers + [{ ne: [{ ref: 'bool' }, true] }, { bool: true }, false], + [{ ne: [{ ref: 'nil' }, null] }, { nil: null }, false], + [{ ne: [{ or: [false, true] }, { and: [true, false] }] }, {}, true], + + [{ gt: [{ ref: 'num' }, 42] }, { num: 43 }, true], + [{ gt: [{ ref: 'num' }, 42] }, { num: 42 }, false], + [{ gt: [{ ref: 'num' }, 42] }, { num: 41 }, false], + [{ gt: [{ ref: 'str' }, 'a'] }, { str: 'b' }, true], + [{ gt: [{ ref: 'str' }, 'a'] }, { str: 'a' }, false], + [{ gt: [{ ref: 'str' }, 'b'] }, { str: 'a' }, false], + [{ gt: [{ or: [2, 0] }, { and: [1, 1] }] }, {}, true], + { ast: { gt: [1, 2] }, expected: '1 > 2', execute: false }, + [ + { gt: [{ ref: 'obj' }, 5] }, + { obj: objectWithToPrimitiveSymbol }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { gt: [5, { ref: 'obj' }] }, + { obj: objectWithToPrimitiveSymbol }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { gt: [{ ref: 'obj' }, 5] }, + { + obj: { + valueOf() { + throw new Error('This should never throw!') + }, + }, + }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { gt: [5, { ref: 'obj' }] }, + { + obj: { + valueOf() { + throw new Error('This should never throw!') + }, + }, + }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { gt: [{ ref: 'obj' }, 5] }, + { + obj: { + toString() { + throw new Error('This should never throw!') + }, + }, + }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { gt: [5, { ref: 'obj' }] }, + { + obj: { + toString() { + throw new Error('This should never throw!') + }, + }, + }, + new Error('Possibility of side effect due to coercion methods'), + ], + + [{ ge: [{ ref: 'num' }, 42] }, { num: 43 }, true], + [{ ge: [{ ref: 'num' }, 42] }, { num: 42 }, true], + [{ ge: [{ ref: 'num' }, 42] }, { num: 41 }, false], + [{ ge: [{ ref: 'str' }, 'a'] }, { str: 'b' }, true], + [{ ge: [{ ref: 'str' }, 'a'] }, { str: 'a' }, true], + [{ ge: [{ ref: 'str' }, 'b'] }, { str: 'a' }, false], + [{ ge: [{ or: [1, 0] }, { and: [1, 2] }] }, {}, false], + { ast: { ge: [1, 2] }, expected: '1 >= 2', execute: false }, + [ + { ge: [{ ref: 'obj' }, 5] }, + { obj: objectWithToPrimitiveSymbol }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { ge: [{ ref: 'obj' }, 5] }, + { + obj: { + valueOf() { + throw new Error('This should never throw!') + }, + }, + }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { ge: [{ ref: 'obj' }, 5] }, + { + obj: { + toString() { + throw new Error('This should never throw!') + }, + }, + }, + new Error('Possibility of side effect due to coercion methods'), + ], + + [{ lt: [{ ref: 'num' }, 42] }, { num: 43 }, false], + [{ lt: [{ ref: 'num' }, 42] }, { num: 42 }, false], + [{ lt: [{ ref: 'num' }, 42] }, { num: 41 }, true], + [{ lt: [{ ref: 'str' }, 'a'] }, { str: 'b' }, false], + [{ lt: [{ ref: 'str' }, 'a'] }, { str: 'a' }, false], + [{ lt: [{ ref: 'str' }, 'b'] }, { str: 'a' }, true], + [{ lt: [{ or: [1, 0] }, { and: [1, 0] }] }, {}, false], + { ast: { lt: [1, 2] }, expected: '1 < 2', execute: false }, + [ + { lt: [{ ref: 'obj' }, 5] }, + { obj: objectWithToPrimitiveSymbol }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { lt: [{ ref: 'obj' }, 5] }, + { + obj: { + valueOf() { + throw new Error('This should never throw!') + }, + }, + }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { lt: [{ ref: 'obj' }, 5] }, + { + obj: { + toString() { + throw new Error('This should never throw!') + }, + }, + }, + new Error('Possibility of side effect due to coercion methods'), + ], + + [{ le: [{ ref: 'num' }, 42] }, { num: 43 }, false], + [{ le: [{ ref: 'num' }, 42] }, { num: 42 }, true], + [{ le: [{ ref: 'num' }, 42] }, { num: 41 }, true], + [{ le: [{ ref: 'str' }, 'a'] }, { str: 'b' }, false], + [{ le: [{ ref: 'str' }, 'a'] }, { str: 'a' }, true], + [{ le: [{ ref: 'str' }, 'b'] }, { str: 'a' }, true], + [{ le: [{ or: [2, 0] }, { and: [1, 1] }] }, {}, false], + { ast: { le: [1, 2] }, expected: '1 <= 2', execute: false }, + [ + { le: [{ ref: 'obj' }, 5] }, + { obj: objectWithToPrimitiveSymbol }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { le: [{ ref: 'obj' }, 5] }, + { + obj: { + valueOf() { + throw new Error('This should never throw!') + }, + }, + }, + new Error('Possibility of side effect due to coercion methods'), + ], + [ + { le: [{ ref: 'obj' }, 5] }, + { + obj: { + toString() { + throw new Error('This should never throw!') + }, + }, + }, + new Error('Possibility of side effect due to coercion methods'), + ], +] + +export const stringManipulation: TestCase[] = [ + [{ substring: [{ ref: 'str' }, 4, 7] }, { str: 'hello world' }, 'hello world'.substring(4, 7)], + [{ substring: [{ ref: 'str' }, 4] }, { str: 'hello world' }, 'hello world'.substring(4)], + [{ substring: [{ ref: 'str' }, 4, 4] }, { str: 'hello world' }, 'hello world'.substring(4, 4)], + [{ substring: [{ ref: 'str' }, 7, 4] }, { str: 'hello world' }, 'hello world'.substring(7, 4)], + [{ substring: [{ ref: 'str' }, -1, 100] }, { str: 'hello world' }, 'hello world'.substring(-1, 100)], + [{ substring: [{ ref: 'invalid' }, 4, 7] }, { invalid: {} }, new TypeError('Variable is not a string')], + [{ substring: [{ ref: 'str' }, 4, 7] }, { str: String('hello world') }, 'hello world'.substring(4, 7)], + // eslint-disable-next-line no-new-wrappers + [{ substring: [{ ref: 'str' }, 4, 7] }, { str: new String('hello world') }, 'hello world'.substring(4, 7)], + [ + { substring: [{ ref: 'str' }, 4, 7] }, + { str: overloadMethod(new String('hello world'), 'substring') }, // eslint-disable-line no-new-wrappers + 'hello world'.substring(4, 7), + ], + [ + { substring: [{ ref: 'str' }, 4, 7] }, + { str: new (createClassWithOverloadedMethodInPrototypeChain(String, 'substring'))('hello world') }, + 'hello world'.substring(4, 7), + ], +] + +export const stringComparison: TestCase[] = [ + [{ startsWith: [{ ref: 'str' }, 'hello'] }, { str: 'hello world!' }, true], + [{ startsWith: [{ ref: 'str' }, 'world'] }, { str: 'hello world!' }, false], + [{ startsWith: [{ ref: 'str' }, { ref: 'prefix' }] }, { str: 'hello world!', prefix: 'hello' }, true], + [{ startsWith: [{ getmember: [{ ref: 'obj' }, 'str'] }, 'hello'] }, { obj: { str: 'hello world!' } }, true], + [{ startsWith: [{ ref: 'str' }, 'hello'] }, { str: String('hello world!') }, true], + [{ startsWith: [{ ref: 'str' }, 'world'] }, { str: String('hello world!') }, false], + // eslint-disable-next-line no-new-wrappers + [{ startsWith: [{ ref: 'str' }, 'hello'] }, { str: new String('hello world!') }, true], + // eslint-disable-next-line no-new-wrappers + [{ startsWith: [{ ref: 'str' }, 'world'] }, { str: new String('hello world!') }, false], + // eslint-disable-next-line no-new-wrappers + [{ startsWith: [{ ref: 'str' }, 'hello'] }, { str: overloadMethod(new String('hello world!'), 'startsWith') }, true], + [ + { startsWith: [{ ref: 'str' }, 'hello'] }, + { + str: Object.create({ + startsWith() { + throw new Error('This should never throw!') + }, + }), + }, + new TypeError('Variable is not a string'), + ], + [ + { startsWith: ['hello world!', { ref: 'str' }] }, + { + str: { + toString() { + throw new Error('This should never throw!') + }, + }, + }, + new TypeError('Variable is not a string'), + ], + [ + { startsWith: [{ ref: 'str' }, 'hello'] }, + { str: new (createClassWithOverloadedMethodInPrototypeChain(String, 'startsWith'))('hello world!') }, + true, + ], + + [{ endsWith: [{ ref: 'str' }, 'hello'] }, { str: 'hello world!' }, false], + [{ endsWith: [{ ref: 'str' }, 'world!'] }, { str: 'hello world!' }, true], + [{ endsWith: [{ ref: 'str' }, { ref: 'suffix' }] }, { str: 'hello world!', suffix: 'world!' }, true], + [{ endsWith: [{ getmember: [{ ref: 'obj' }, 'str'] }, 'world!'] }, { obj: { str: 'hello world!' } }, true], + [{ endsWith: [{ ref: 'str' }, 'hello'] }, { str: String('hello world!') }, false], + [{ endsWith: [{ ref: 'str' }, 'world!'] }, { str: String('hello world!') }, true], + // eslint-disable-next-line no-new-wrappers + [{ endsWith: [{ ref: 'str' }, 'hello'] }, { str: new String('hello world!') }, false], + // eslint-disable-next-line no-new-wrappers + [{ endsWith: [{ ref: 'str' }, 'world!'] }, { str: new String('hello world!') }, true], + // eslint-disable-next-line no-new-wrappers + [{ endsWith: [{ ref: 'str' }, 'world!'] }, { str: overloadMethod(new String('hello world!'), 'endsWith') }, true], + [ + { endsWith: [{ ref: 'str' }, 'hello'] }, + { + str: Object.create({ + endsWith() { + throw new Error('This should never throw!') + }, + }), + }, + new TypeError('Variable is not a string'), + ], + [ + { endsWith: ['hello world!', { ref: 'str' }] }, + { + str: { + toString() { + throw new Error('This should never throw!') + }, + }, + }, + new TypeError('Variable is not a string'), + ], + [ + { endsWith: [{ ref: 'str' }, 'world!'] }, + { str: new (createClassWithOverloadedMethodInPrototypeChain(String, 'endsWith'))('hello world!') }, + true, + ], +] + +export const logicalOperators: TestCase[] = [ + [{ any: [{ ref: 'arr' }, { isEmpty: { ref: '@it' } }] }, { arr: ['foo', 'bar', ''] }, true], + [{ any: [{ ref: 'arr' }, { isEmpty: { ref: '@it' } }] }, { arr: ['foo', 'bar', 'baz'] }, false], + [{ any: [{ ref: 'obj' }, { isEmpty: { ref: '@value' } }] }, { obj: { 0: 'foo', 1: 'bar', 2: '' } }, true], + [{ any: [{ ref: 'obj' }, { isEmpty: { ref: '@value' } }] }, { obj: { 0: 'foo', 1: 'bar', 2: 'baz' } }, false], + [{ any: [{ ref: 'obj' }, { isEmpty: { ref: '@key' } }] }, { obj: { foo: 0, bar: 1, '': 2 } }, true], + [{ any: [{ ref: 'obj' }, { isEmpty: { ref: '@key' } }] }, { obj: { foo: 0, bar: 1, baz: 2 } }, false], + + [{ all: [{ ref: 'arr' }, { isEmpty: { ref: '@it' } }] }, { arr: ['foo', ''] }, false], + [{ all: [{ ref: 'arr' }, { isEmpty: { ref: '@it' } }] }, { arr: ['', ''] }, true], + [{ all: [{ ref: 'obj' }, { isEmpty: { ref: '@value' } }] }, { obj: { 0: 'foo', 1: '' } }, false], + [{ all: [{ ref: 'obj' }, { isEmpty: { ref: '@value' } }] }, { obj: { 0: '', 1: '' } }, true], + [{ all: [{ ref: 'obj' }, { isEmpty: { ref: '@key' } }] }, { obj: { foo: 0 } }, false], + [{ all: [{ ref: 'obj' }, { isEmpty: { ref: '@key' } }] }, { obj: { '': 0 } }, true], + + [{ or: [{ ref: 'bar' }, { ref: 'foo' }] }, { bar: 42 }, 42], + [{ or: [{ ref: 'bar' }, { ref: 'foo' }] }, { bar: 0 }, new ReferenceError('foo is not defined')], + + [{ and: [{ ref: 'bar' }, { ref: 'foo' }] }, { bar: 0 }, 0], + [{ and: [{ ref: 'bar' }, { ref: 'foo' }] }, { bar: 42 }, new ReferenceError('foo is not defined')], +] + +export const collectionOperations: TestCase[] = [ + [{ filter: [{ ref: 'arr' }, { not: { isEmpty: { ref: '@it' } } }] }, { arr: ['foo', 'bar', ''] }, ['foo', 'bar']], + [{ filter: [{ ref: 'tarr' }, { gt: [{ ref: '@it' }, 15] }] }, { tarr: new Int16Array([10, 20, 30]) }, [20, 30]], + [ + { filter: [{ ref: 'set' }, { not: { isEmpty: { ref: '@it' } } }] }, + { set: new Set(['foo', 'bar', '']) }, + ['foo', 'bar'], + ], + [ + { filter: [{ ref: 'obj' }, { not: { isEmpty: { ref: '@value' } } }] }, + { obj: { 1: 'foo', 2: 'bar', 3: '' } }, + { 1: 'foo', 2: 'bar' }, + ], + [ + { filter: [{ ref: 'obj' }, { not: { isEmpty: { ref: '@key' } } }] }, + { obj: { foo: 1, bar: 2, '': 3 } }, + { foo: 1, bar: 2 }, + ], +] + +export const membershipAndMatching: TestCase[] = [ + [{ contains: [{ ref: 'str' }, 'world'] }, { str: 'hello world!' }, true], + [{ contains: [{ ref: 'str' }, 'missing'] }, { str: 'hello world!' }, false], + [{ contains: [{ ref: 'str' }, 'world'] }, { str: String('hello world!') }, true], + [{ contains: [{ ref: 'str' }, 'missing'] }, { str: String('hello world!') }, false], + // eslint-disable-next-line no-new-wrappers + [{ contains: [{ ref: 'str' }, 'world'] }, { str: new String('hello world!') }, true], + // eslint-disable-next-line no-new-wrappers + [{ contains: [{ ref: 'str' }, 'missing'] }, { str: new String('hello world!') }, false], + // eslint-disable-next-line no-new-wrappers + [{ contains: [{ ref: 'str' }, 'world'] }, { str: overloadMethod(new String('hello world!'), 'includes') }, true], + [{ contains: [{ ref: 'arr' }, 'foo'] }, { arr: ['foo', 'bar'] }, true], + [{ contains: [{ ref: 'arr' }, 'missing'] }, { arr: ['foo', 'bar'] }, false], + [{ contains: [{ ref: 'arr' }, 'foo'] }, { arr: overloadMethod(['foo', 'bar'], 'includes') }, true], + [{ contains: [{ ref: 'tarr' }, 10] }, { tarr: new Int16Array([10, 20]) }, true], + [{ contains: [{ ref: 'tarr' }, 30] }, { tarr: new Int16Array([10, 20]) }, false], + [{ contains: [{ ref: 'tarr' }, 10] }, { tarr: overloadMethod(new Int16Array([10, 20]), 'includes') }, true], + [{ contains: [{ ref: 'set' }, 'foo'] }, { set: new Set(['foo', 'bar']) }, true], + [{ contains: [{ ref: 'set' }, 'missing'] }, { set: new Set(['foo', 'bar']) }, false], + [{ contains: [{ ref: 'set' }, 'foo'] }, { set: overloadMethod(new Set(['foo', 'bar']), 'has') }, true], + [{ contains: [{ ref: 'wset' }, { ref: 'key' }] }, { key: weakKey, wset: new WeakSet([weakKey]) }, true], + [{ contains: [{ ref: 'wset' }, { ref: 'key' }] }, { key: {}, wset: new WeakSet([weakKey]) }, false], + [ + { contains: [{ ref: 'wset' }, { ref: 'key' }] }, + { key: weakKey, wset: overloadMethod(new WeakSet([weakKey]), 'has') }, + true, + ], + [{ contains: [{ ref: 'map' }, 'foo'] }, { map: new Map([['foo', 'bar']]) }, true], + [{ contains: [{ ref: 'map' }, 'missing'] }, { map: new Map([['foo', 'bar']]) }, false], + [{ contains: [{ ref: 'map' }, 'foo'] }, { map: overloadMethod(new Map([['foo', 'bar']]), 'has') }, true], + [{ contains: [{ ref: 'wmap' }, { ref: 'key' }] }, { key: weakKey, wmap: new WeakMap([[weakKey, 'bar']]) }, true], + [{ contains: [{ ref: 'wmap' }, { ref: 'key' }] }, { key: {}, wmap: new WeakMap([[weakKey, 'bar']]) }, false], + [ + { contains: [{ ref: 'wmap' }, { ref: 'key' }] }, + { key: weakKey, wmap: overloadMethod(new WeakMap([[weakKey, 'bar']]), 'has') }, + true, + ], + [{ contains: [{ ref: 'obj' }, 'foo'] }, { obj: { foo: 'bar' } }, new TypeError('Variable does not support contains')], + [ + { contains: [{ ref: 'obj' }, 'missing'] }, + { obj: { foo: 'bar' } }, + new TypeError('Variable does not support contains'), + ], + [ + { contains: [{ ref: 'str' }, 'world'] }, + { str: new (createClassWithOverloadedMethodInPrototypeChain(String, 'includes'))('hello world!') }, + true, + ], + [ + { contains: [{ ref: 'arr' }, 'foo'] }, + { arr: new (createClassWithOverloadedMethodInPrototypeChain(Array, 'includes'))('foo', 'bar') }, + true, + ], + [ + { contains: [{ ref: 'tarr' }, 10] }, + { tarr: new (createClassWithOverloadedMethodInPrototypeChain(Int32Array, 'includes'))([10, 20]) }, + true, + ], + [ + { contains: [{ ref: 'set' }, 'foo'] }, + { set: new (createClassWithOverloadedMethodInPrototypeChain(Set, 'has'))(['foo', 'bar']) }, + true, + ], + [ + { contains: [{ ref: 'map' }, 'foo'] }, + { map: new (createClassWithOverloadedMethodInPrototypeChain(Map, 'has'))([['foo', 'bar']]) }, + true, + ], + + [{ matches: [{ ref: 'foo' }, '[0-9]+'] }, { foo: '42' }, true], + [{ matches: [{ ref: 'foo' }, '[0-9]+'] }, { foo: String('42') }, true], + // eslint-disable-next-line no-new-wrappers + [{ matches: [{ ref: 'foo' }, '[0-9]+'] }, { foo: new String('42') }, true], + // eslint-disable-next-line no-new-wrappers + [{ matches: [{ ref: 'foo' }, '[0-9]+'] }, { foo: overloadMethod(new String('42'), 'match') }, true], + [{ matches: [{ ref: 'foo' }, '[0-9]+'] }, { foo: {} }, new TypeError('Variable is not a string')], + [{ matches: [{ ref: 'foo' }, { ref: 'regex' }] }, { foo: '42', regex: /[0-9]+/ }, true], + [{ matches: [{ ref: 'foo' }, { ref: 'regex' }] }, { foo: '42', regex: overloadMethod(/[0-9]+/, 'test') }, true], + [{ matches: [{ ref: 'foo' }, { ref: 'regex' }] }, { foo: '42', regex: String('[0-9]+') }, true], + // eslint-disable-next-line no-new-wrappers + [{ matches: [{ ref: 'foo' }, { ref: 'regex' }] }, { foo: '42', regex: new String('[0-9]+') }, true], + [ + { matches: [{ ref: 'foo' }, { ref: 'regex' }] }, + { foo: '42', regex: overloadMethod(new String('[0-9]+'), 'match') }, // eslint-disable-line no-new-wrappers + true, + ], + [ + { matches: [{ ref: 'foo' }, { ref: 'regex' }] }, + { foo: '42', regex: overloadMethod({}, Symbol.match) }, + new TypeError('Regular expression must be either a string or an instance of RegExp'), + ], + [{ matches: [{ ref: 'foo' }, { ref: 'regex' }] }, { foo: '42', regex: overloadMethod(/[0-9]+/, Symbol.match) }, true], + [ + { matches: [{ ref: 'foo' }, '[0-9]+'] }, + { foo: new (createClassWithOverloadedMethodInPrototypeChain(String, 'match'))('42') }, + true, + ], + [ + { matches: ['42', { ref: 'regex' }] }, + { regex: new EvilRegex('[0-9]+') }, + new TypeError('Regular expression must be either a string or an instance of RegExp'), + ], +] + +export const typeAndDefinitionChecks: TestCase[] = [ + // Primitive types + [{ instanceof: [{ ref: 'foo' }, 'string'] }, { foo: 'foo' }, true], + [{ instanceof: [{ ref: 'foo' }, 'number'] }, { foo: 42 }, true], + [{ instanceof: [{ ref: 'foo' }, 'number'] }, { foo: '42' }, false], + // @ts-expect-error BigInt is not supported in ES5 + [{ instanceof: [{ ref: 'foo' }, 'bigint'] }, { foo: 42n }, true], + [{ instanceof: [{ ref: 'foo' }, 'boolean'] }, { foo: false }, true], + [{ instanceof: [{ ref: 'foo' }, 'boolean'] }, { foo: 0 }, false], + [{ instanceof: [{ ref: 'foo' }, 'undefined'] }, { foo: undefined }, true], + [{ instanceof: [{ ref: 'foo' }, 'symbol'] }, { foo: Symbol('foo') }, true], + [{ instanceof: [{ ref: 'foo' }, 'null'] }, { foo: null }, false], // typeof null is 'object' + + // Objects + [{ instanceof: [{ ref: 'bar' }, 'Object'] }, { bar: {} }, true], + [{ instanceof: [{ ref: 'bar' }, 'Error'] }, { bar: new Error() }, true], + [{ instanceof: [{ ref: 'bar' }, 'Error'] }, { bar: {} }, false], + [{ instanceof: [{ ref: 'bar' }, 'CustomObject'] }, { bar: new CustomObject(), CustomObject }, true], + [ + { instanceof: [{ ref: 'bar' }, 'HasInstanceSideEffect'] }, + { bar: new HasInstanceSideEffect(), HasInstanceSideEffect }, + true, + ], + { + ast: { instanceof: [{ ref: 'foo' }, 'foo.bar'] }, + expected: new SyntaxError('Illegal identifier: foo.bar'), + execute: false, + }, + + [{ isDefined: { ref: 'foo' } }, { bar: 42 }, false], + [{ isDefined: { ref: 'bar' } }, { bar: 42 }, true], + [{ isDefined: { ref: 'bar' } }, { bar: undefined }, true], + { ast: { isDefined: { ref: 'foo' } }, suffix: 'const foo = undefined', expected: false }, + { ast: { isDefined: { ref: 'foo' } }, suffix: 'const foo = 42', expected: false }, + { ast: { isDefined: { ref: 'foo' } }, suffix: 'let foo', expected: false }, + { ast: { isDefined: { ref: 'foo' } }, suffix: 'let foo = undefined', expected: false }, + { ast: { isDefined: { ref: 'foo' } }, suffix: 'let foo = 42', expected: false }, + { ast: { isDefined: { ref: 'foo' } }, suffix: 'var foo', expected: true }, // var is hoisted + { ast: { isDefined: { ref: 'foo' } }, suffix: 'var foo = undefined', expected: true }, // var is hoisted + { ast: { isDefined: { ref: 'foo' } }, suffix: 'var foo = 42', expected: true }, // var is hoisted + { ast: { isDefined: { ref: 'foo' } }, suffix: 'function foo () {}', expected: true }, // function is hoisted + { ast: { isDefined: { ref: 'foo' } }, suffix: '', expected: false }, +] + +/** + * Define a getter on the provided object that throws on access. + */ +function overloadPropertyWithGetter(obj: T, propName: string): T { + Object.defineProperty(obj, propName, { + get() { + throw new Error('This should never throw!') + }, + }) + return obj +} + +/** + * Overwrite a method/property on the object with a throwing function. + */ +function overloadMethod(obj: T, methodName: PropertyKey): T { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(obj as any)[methodName] = () => { + throw new Error('This should never throw!') + } + return obj +} + +/** + * Create a subclass of the given built-in where the given property/method is overloaded + * in the prototype chain to throw, and return a further subclass constructor. + */ +function createClassWithOverloadedMethodInPrototypeChain any>( + Builtin: T, + propName: PropertyKey +): T { + class Klass extends Builtin { + [propName](): void { + throw new Error('This should never throw!') + } + } + + class SubKlass extends Klass {} + + return SubKlass as T +} diff --git a/packages/debugger/test/index.ts b/packages/debugger/test/index.ts new file mode 100644 index 0000000000..802187c358 --- /dev/null +++ b/packages/debugger/test/index.ts @@ -0,0 +1 @@ +export * from './expression-test-cases' diff --git a/sandbox/debugger/debugger.js b/sandbox/debugger/debugger.js new file mode 100644 index 0000000000..4e76a1aaca --- /dev/null +++ b/sandbox/debugger/debugger.js @@ -0,0 +1,89 @@ +;(() => { + // @ts-expect-error - DD_LIVE_DEBUGGER does exist + if (!window.DD_LIVE_DEBUGGER) { + console.error('DD_LIVE_DEBUGGER is not available. Make sure the live debugger SDK is loaded.') + return + } + + // @ts-expect-error - DD_LIVE_DEBUGGER does exist + window.DD_LIVE_DEBUGGER.init({ + service: 'poc-browser-live-debugger', + env: 'watson-dev', + version: `1.0.0-${crypto.randomUUID().slice(0, 8)}`, + remoteConfigProxyUrl: 'http://localhost:3030', + }) + + // TODO: Remove hardcoded probes once dynamic probe management is implemented + // Method log probe with snapshot on entry and return, evaluated at entry + // window.DD_LIVE_DEBUGGER.addProbe({ + // id: '72174a44-93ef-48b6-b1e4-33058941b6bf', + // version: 0, + // type: 'LOG_PROBE', + // where: { typeName: 'probes-go-here.js', methodName: 'sometimesThrows' }, + // template: 'Calling probes-go-here.js.sometimesThrows with a={a}, b={b}', + // segments: [ + // { str: 'Calling probes-go-here.js.sometimesThrows with a=' }, + // { dsl: 'a', json: { ref: 'a' } }, + // { str: ', b=' }, + // { dsl: 'b', json: { ref: 'b' } }, + // ], + // captureSnapshot: true, + // capture: { maxReferenceDepth: 3 }, + // sampling: { snapshotsPerSecond: 1 }, + // evaluateAt: 'ENTRY', + // }) + + // // Method log probe with snapshot on entry and return, evaluated at exit + // window.DD_LIVE_DEBUGGER.addProbe({ + // id: 'd2646fee-564d-4dec-820d-2c419cc86194', + // version: 0, + // type: 'LOG_PROBE', + // where: { typeName: 'probes-go-here.js', methodName: 'withLocals' }, + // template: 'Executed probes-go-here.js.withLocals, it took {@duration}ms', + // segments: [ + // { str: 'Executed probes-go-here.js.withLocals, it took ' }, + // { dsl: '@duration', json: { ref: '@duration' } }, + // { str: 'ms' }, + // ], + // captureSnapshot: true, + // capture: { maxReferenceDepth: 3 }, + // sampling: { snapshotsPerSecond: 1 }, + // evaluateAt: 'EXIT', + // }) + + // // Method log probe without snapshot, evaluated at exit + // window.DD_LIVE_DEBUGGER.addProbe({ + // id: '26c27b15-1d58-4f5d-b1f1-2d40607fb431', + // version: 0, + // type: 'LOG_PROBE', + // where: { typeName: 'probes-go-here.js', methodName: 'noLocals' }, + // template: 'Executed probes-go-here.js.noLocals, it took {@duration}ms', + // segments: [ + // { str: 'Executed probes-go-here.js.noLocals, it took ' }, + // { dsl: '@duration', json: { ref: '@duration' } }, + // { str: 'ms' }, + // ], + // captureSnapshot: false, + // capture: { maxReferenceDepth: 3 }, + // sampling: { snapshotsPerSecond: 5000 }, + // evaluateAt: 'EXIT', + // }) + + // // Method log probe with condition - only fires if duration > 100ms + // window.DD_LIVE_DEBUGGER.addProbe({ + // id: '80c7bbad-16c4-4518-a5f1-3582601b5aba', + // version: 0, + // type: 'LOG_PROBE', + // where: { typeName: 'probes-go-here.js', methodName: 'sometimesSlow' }, + // when: { + // dsl: '@duration > 100', + // json: { gt: [{ ref: '@duration' }, 100] }, + // }, + // template: 'Slow execution detected: {@duration}ms', + // segments: [{ str: 'Slow execution detected: ' }, { dsl: '@duration', json: { ref: '@duration' } }, { str: 'ms' }], + // captureSnapshot: true, + // capture: { maxReferenceDepth: 3 }, + // sampling: { snapshotsPerSecond: 1 }, + // evaluateAt: 'EXIT', + // }) +})() diff --git a/sandbox/debugger/probes-go-here.js b/sandbox/debugger/probes-go-here.js new file mode 100644 index 0000000000..b58382e975 --- /dev/null +++ b/sandbox/debugger/probes-go-here.js @@ -0,0 +1,74 @@ +/** + * The functions in this file have manually been instrumented with the Datadog Live Debugger probes. + * In reality this would be done by a build step that injects the necessary code into the functions. + */ + +function sometimesThrows(a, b) { + const $dd_p = $dd_probes('probes-go-here.js;sometimesThrows') + try { + if ($dd_p) $dd_entry($dd_p, this, { a, b }) + const sum = a + b + if (Math.random() < 0.2) { + throw new Error('Sometimes throws') + } + return $dd_p ? $dd_return($dd_p, sum, this, { a, b }, { sum }) : sum + } catch (e) { + if ($dd_p) $dd_throw($dd_p, e, this, { a, b }) + throw e + } +} + +function withLocals(a, b) { + const $dd_p = $dd_probes('probes-go-here.js;withLocals') + try { + if ($dd_p) $dd_entry($dd_p, this, { a, b }) + const arr = [a, b, a + b] + const obj = { a, b, total: arr[2], label: null } + let label + if (arr[2] > 10) { + label = 'big' + } else { + label = false + } + obj.label = label + return $dd_p ? $dd_return($dd_p, obj, this, { a, b }, { arr, obj, label }) : obj + } catch (e) { + if ($dd_p) $dd_throw($dd_p, e, this, { a, b }) + throw e + } +} + +function noLocals(a, b) { + const $dd_p = $dd_probes('probes-go-here.js;noLocals') + try { + if ($dd_p) $dd_entry($dd_p, this, { a, b }) + return $dd_p ? $dd_return($dd_p, a * b, this, { a, b }, {}) : a * b + } catch (e) { + if ($dd_p) $dd_throw($dd_p, e, this, { a, b }) + throw e + } +} + +function sometimesSlow() { + const $dd_p = $dd_probes('probes-go-here.js;sometimesSlow') + try { + if ($dd_p) $dd_entry($dd_p, this, {}) + // This function usually runs fast (10ms) but occasionally slow (150-200ms) + const isSlow = Math.random() < 0.2 // 20% chance to be slow + const delay = isSlow ? 150 + Math.random() * 50 : 10 + + const start = performance.now() + while (performance.now() - start < delay) { + // Busy-wait loop to simulate work + } + if ($dd_p) $dd_return($dd_p, undefined, this, {}, { isSlow, delay, start }) + } catch (e) { + if ($dd_p) $dd_throw($dd_p, e, this, {}) + throw e + } +} + +setInterval(sometimesThrows, 1000, 1, 2) +setInterval(withLocals, 1100, 3, 4) +setInterval(noLocals, 1200, 5, 6) +setInterval(sometimesSlow, 1300) diff --git a/sandbox/debugger/rc-proxy/.env.example b/sandbox/debugger/rc-proxy/.env.example new file mode 100644 index 0000000000..77e59de754 --- /dev/null +++ b/sandbox/debugger/rc-proxy/.env.example @@ -0,0 +1,43 @@ +# Remote Config Proxy Configuration +# +# The proxy supports two modes: +# 1. Agent mode (recommended for POC): Poll local Datadog agent +# 2. Backend mode: Poll Datadog RC backend directly (requires backend access) + +# ============================================================================ +# MODE 1: Agent Mode (recommended for POC/development) +# ============================================================================ +# Polls your local Datadog agent at http://localhost:8126/v0.7/config +# Benefits: +# - No CORS issues (proxy handles CORS) +# - No RC backend access needed +# - Uses API key already configured in your Docker agent +# - Gets the same probes your backend services see + +AGENT_URL=http://localhost:8126 + +# ============================================================================ +# MODE 2: Backend Mode (for production when RC backend access is granted) +# ============================================================================ +# Polls Datadog RC backend directly at https://config.{site}/api/v0.1/configurations +# Requirements: +# - RC backend team approval +# - Valid Datadog API key with RC access +# +# To use backend mode, comment out AGENT_URL above and uncomment these: + +# DD_API_KEY=your_datadog_api_key_here +# DD_SITE=datadoghq.com + +# ============================================================================ +# Optional Configuration (applies to both modes) +# ============================================================================ + +# Port for the proxy server (default: 3030) +PORT=3030 + +# How often to poll for RC updates in milliseconds (default: 5000) +POLL_INTERVAL=5000 + +# How long to keep inactive browser clients in milliseconds (default: 30000) +CLIENT_TTL=30000 \ No newline at end of file diff --git a/sandbox/debugger/rc-proxy/.gitignore b/sandbox/debugger/rc-proxy/.gitignore new file mode 100644 index 0000000000..9d82971cae --- /dev/null +++ b/sandbox/debugger/rc-proxy/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +*.log +.DS_Store + diff --git a/sandbox/debugger/rc-proxy/QUICKSTART.md b/sandbox/debugger/rc-proxy/QUICKSTART.md new file mode 100644 index 0000000000..432d23ca4e --- /dev/null +++ b/sandbox/debugger/rc-proxy/QUICKSTART.md @@ -0,0 +1,188 @@ +# Quick Start Guide - RC Proxy for Browser Live Debugger + +## What's Been Implemented + +A complete Remote Config proxy system for browser Live Debugger consisting of: + +### 1. RC Proxy Service (`sandbox/rc-proxy/`) + +- **Client Tracker**: Tracks active browser clients with 30s TTL (like Datadog Agent) +- **RC Client**: Communicates with Datadog RC backend using protobuf +- **Express Server**: Serves probes via HTTP GET endpoints +- **Background Polling**: Automatically polls RC when clients are active + +### 2. Browser SDK Integration (`packages/live-debugger/`) + +- **Remote Config Module**: Polls proxy and syncs probes automatically +- **Init Configuration**: Added `remoteConfigProxyUrl` option +- **Probe Synchronization**: Adds, removes, and updates probes dynamically + +### 3. Test Page (`sandbox/live-debugger-test.html`) + +- Simple UI to test the RC proxy integration +- Shows active probes, proxy health, and fired probes +- Test functions to trigger instrumented code + +## Getting Started + +### Step 1: Set Up the RC Proxy + +```bash +cd sandbox/rc-proxy + +# Install dependencies +npm install + +# Copy the example env file and add your API key +cp .env.example .env +# Then edit .env and set your DD_API_KEY +``` + +### Step 2: Start the RC Proxy + +```bash +npm start +``` + +The proxy will start on http://localhost:3030 + +You should see: + +``` +✅ Server running on port 3030 + - Probes endpoint: http://localhost:3030/probes?service=my-app + - Health endpoint: http://localhost:3030/health + - Info endpoint: http://localhost:3030/ + +[Polling] Starting background polling (interval: 5000ms) +[Polling] No active clients, skipping RC poll +``` + +### Step 3: Configure Probes in Datadog + +1. Go to Datadog Live Debugger UI +2. Create probes for service: `browser-test-app` +3. Target JavaScript functions (e.g., `testFunction1`, `testFunction2`) + +### Step 4: Build the Browser SDK + +```bash +cd /path/to/browser-sdk +yarn build:bundle:live-debugger +``` + +This creates `packages/live-debugger/bundle/live-debugger.js` + +### Step 5: Open the Test Page + +Open `sandbox/live-debugger-test.html` in your browser. + +The page will: + +- Initialize Live Debugger with RC proxy URL +- Start polling the proxy every 5 seconds +- Display active probes from Datadog +- Show when probes fire + +### Step 6: Test the Integration + +1. **Check Proxy Health**: Click "Check Proxy Health" to see proxy status +2. **View Probes**: Click "Refresh Probes" to see current probes from RC +3. **Trigger Functions**: Click test function buttons to fire probes +4. **Monitor Logs**: Watch console log for probe firing events + +## How It Works + +``` +┌──────────┐ ┌──────────────┐ ┌─────────────┐ +│ Browser │ poll │ RC Proxy │ poll │ Datadog │ +│ (test │─────────▶│ (localhost │─────────▶│ RC Backend │ +│ page) │ /probes │ :3030) │ protobuf │ │ +└──────────┘ └──────────────┘ └─────────────┘ + │ │ │ + │ service metadata │ active clients │ + │ (service, env, ver) │ (protobuf Client msgs) │ + │ │ │ + ▼ ▼ ▼ + registers client tracks clients returns probes + gets probes back polls RC backend for all clients +``` + +## Troubleshooting + +### Proxy says "No active clients" + +This is normal when no browsers are connected. Open the test page and it will start polling. + +### Browser not receiving probes + +1. Check browser console for errors +2. Verify `remoteConfigProxyUrl` is correct +3. Check proxy `/health` endpoint +4. Ensure probes are configured in Datadog for service `browser-test-app` + +### Proxy returning 401 from Datadog + +Check your `DD_API_KEY` in `.env` file is valid and has RC scope. + +### Probes not firing + +1. Verify probes are loaded (check "Current Probes" section) +2. Ensure probe targets match function names +3. Check browser console for Live Debugger errors + +## Next Steps + +### For Production Use + +1. **Add Authentication**: Secure the proxy endpoints +2. **Restrict CORS**: Limit to specific origins +3. **Deploy with HTTPS**: Use a proper SSL certificate +4. **Monitor**: Add observability for proxy health +5. **Scale**: Consider multiple proxy instances + +### Extend the POC + +1. **Multiple Services**: Test with different service names +2. **Probe Types**: Test LOG_PROBE, METRIC_PROBE, SPAN_PROBE +3. **Conditions**: Test probes with when conditions +4. **Sampling**: Test probe sampling rates +5. **Error Handling**: Test with network failures + +## Architecture Details + +### RC Proxy Files + +- `index.js` - Main Express server and endpoints +- `rc-client.js` - Datadog RC protocol client (protobuf) +- `client-tracker.js` - Active client tracking with TTL +- `config.js` - Environment configuration +- `remoteconfig.proto` - Protobuf schema + +### Browser SDK Files + +- `packages/live-debugger/src/entries/main.ts` - Init with RC config +- `packages/live-debugger/src/domain/remoteConfig.ts` - RC polling and sync +- `packages/live-debugger/src/domain/probes.ts` - Probe management + +### Key Features + +- ✅ Dynamic client tracking (30s TTL) +- ✅ Protobuf protocol compatibility +- ✅ Hardcoded to LIVE_DEBUGGING only +- ✅ Automatic probe synchronization +- ✅ Background polling (5s default) +- ✅ CORS enabled for localhost + +## Support + +For questions or issues: + +1. Check the proxy logs for errors +2. Check browser console for SDK errors +3. Verify RC is enabled in your Datadog org +4. Ensure probes are configured correctly in Datadog + +## License + +Apache-2.0 diff --git a/sandbox/debugger/rc-proxy/README.agent-mode.md b/sandbox/debugger/rc-proxy/README.agent-mode.md new file mode 100644 index 0000000000..bb8d8a7295 --- /dev/null +++ b/sandbox/debugger/rc-proxy/README.agent-mode.md @@ -0,0 +1,84 @@ +# Agent Mode Configuration + +## Overview + +The RC proxy supports two modes: + +1. **Agent Mode** (recommended for POC): Polls your local Datadog agent + - ✅ No CORS issues + - ✅ No RC backend access needed + - ✅ Works exactly like tracer libraries + - ✅ Uses the same API key already configured in your Docker agent + +2. **Backend Mode**: Polls Datadog RC backend directly + - ❌ Currently broken + +## Quick Start with Agent Mode + +### 1. Update your .env file + +```bash +# RC Proxy Configuration - Agent Mode +AGENT_URL=http://localhost:8126 + +# Optional +PORT=3030 +POLL_INTERVAL=5000 +CLIENT_TTL=30000 +``` + +### 2. Make sure your Docker agent is running + +```bash +docker ps | grep datadog/agent +``` + +### 3. Start the proxy + +```bash +npm start +``` + +### 4. The proxy will now: + +- Poll your local agent at `http://localhost:8126/v0.7/config` +- Get the same LIVE_DEBUGGING probes your backend services see +- Serve them to the browser SDK (with CORS headers) + +### 5. Browser SDK configuration stays the same + +In `sandbox/debugger.js`: + +```javascript +window.DD_LIVE_DEBUGGER.init({ + service: 'my-service', + env: 'production', + version: '1.0.0', + remoteConfigProxyUrl: 'http://localhost:3030', + remoteConfigPollInterval: 5000, +}) +``` + +## How It Works + +``` +Browser SDK --[HTTP/JSON]--> RC Proxy --[HTTP/JSON]--> Local Agent --[HTTP/Protobuf]--> Datadog RC Backend + (CORS OK) (No CORS) (With API Key) +``` + +The proxy acts as a CORS-enabled bridge between the browser and your local agent. + +## Switching Back to Backend Mode + +When RC backend access is resolved, update `.env`: + +```bash +# Backend Mode +DD_API_KEY=your_api_key_here +DD_SITE=datadoghq.com + +# Comment out or remove +# AGENT_URL=http://localhost:8126 +``` + +The proxy will automatically detect the mode based on which variables are set. diff --git a/sandbox/debugger/rc-proxy/README.md b/sandbox/debugger/rc-proxy/README.md new file mode 100644 index 0000000000..aa7f062ca9 --- /dev/null +++ b/sandbox/debugger/rc-proxy/README.md @@ -0,0 +1,234 @@ +# Remote Config Proxy for Browser Live Debugger + +A Node.js proxy service that enables browser Live Debugger to receive probe configurations from Datadog Remote Config, similar to how the Datadog Agent proxies RC for backend tracers. + +## Architecture + +This proxy implements the same client-tracking pattern as the Datadog Agent: + +1. **Browser clients** poll `/probes?service=my-app&env=prod&version=1.0.0` every 5 seconds +2. **Proxy tracks** active clients with a 30-second TTL +3. **Proxy polls** Datadog RC backend every 5 seconds, including all active clients +4. **Proxy returns** LIVE_DEBUGGING probes to browsers + +This allows multiple browser applications to share a single proxy, just like backend services share the Datadog Agent. + +## Features + +- ✅ **Dynamic Client Tracking**: Automatically tracks active browser clients by service/env/version +- ✅ **Protobuf Protocol**: Full Remote Config protocol implementation +- ✅ **LIVE_DEBUGGING Only**: Hardcoded to only request and serve LIVE_DEBUGGING product (security) +- ✅ **TTL-based Expiration**: Clients expire after 30 seconds of inactivity +- ✅ **Background Polling**: Continuously polls RC backend when clients are active +- ✅ **CORS Enabled**: Allows browser access from any origin +- ✅ **Health Monitoring**: Status endpoint for monitoring + +## Setup + +### 1. Install Dependencies + +```bash +cd sandbox/rc-proxy +npm install +``` + +### 2. Configure Environment + +Copy the example environment file and configure your API key: + +```bash +cp .env.example .env +``` + +Then edit `.env` and set your `DD_API_KEY`. See `.env.example` for all available configuration options. + +### 3. Run the Proxy + +```bash +npm start +``` + +The proxy will start on `http://localhost:3030`. + +## API Endpoints + +### GET /probes + +Register a client and fetch probes for it. + +**Query Parameters:** + +- `service` (required): Service name +- `env` (optional): Environment (e.g., `prod`, `staging`) +- `version` (optional): Application version + +**Example:** + +```bash +curl "http://localhost:3030/probes?service=my-app&env=prod&version=1.0.0" +``` + +**Response:** + +```json +{ + "probes": [ + { + "id": "probe-uuid", + "type": "LOG_PROBE", + "where": { "typeName": "MyClass", "methodName": "myMethod" }, + ... + } + ], + "count": 1, + "lastPollTime": "2024-01-01T00:00:00.000Z" +} +``` + +### GET /health + +Get proxy status and health information. + +**Example:** + +```bash +curl http://localhost:3030/health +``` + +**Response:** + +```json +{ + "ok": true, + "lastPollTime": "2024-01-01T00:00:00.000Z", + "lastPollError": null, + "activeClientCount": 2, + "probeCount": 5, + "config": { + "site": "datadoghq.com", + "pollInterval": 5000, + "clientTTL": 30000 + } +} +``` + +### GET / + +Get proxy information and available endpoints. + +## How It Works + +### Client Tracking + +When a browser calls `/probes?service=my-app`, the proxy: + +1. Registers/updates the client with current timestamp +2. Assigns a unique `runtime_id` to the client +3. Tracks the client's service, env, and version +4. Returns probes from the cache + +Clients must call `/probes` at least once every 30 seconds to stay active. + +### Remote Config Polling + +Every 5 seconds, the proxy: + +1. Gets all active (non-expired) clients +2. Builds a protobuf `LatestConfigsRequest` with all active clients +3. POSTs to `https://config.datadoghq.com/api/v0.1/configurations` +4. Parses the protobuf `LatestConfigsResponse` +5. Extracts LIVE_DEBUGGING probes from target files +6. Updates the probe cache + +If no clients are active, the proxy skips the RC poll to save resources. + +## Integration with Browser SDK + +Initialize the Live Debugger with the proxy URL: + +```javascript +import { datadogLiveDebugger } from '@datadog/browser-live-debugger' + +datadogLiveDebugger.init({ + clientToken: '', + site: 'datadoghq.com', + service: 'my-browser-app', + env: 'production', + version: '1.0.0', + remoteConfigProxyUrl: 'http://localhost:3030', +}) +``` + +The SDK will automatically: + +- Poll `/probes` every 5 seconds with service metadata +- Add new probes dynamically +- Remove probes that are no longer configured +- Handle probe version updates + +## Troubleshooting + +### "No active clients, skipping RC poll" + +This is normal when no browsers are connected. The proxy will resume polling when a browser calls `/probes`. + +### "RC backend returned 401" + +Check that your `DD_API_KEY` is valid and has the Remote Config scope enabled. + +### "RC backend returned 404" + +Remote Config might not be enabled for your organization. Contact Datadog support. + +### Probes not appearing in browser + +1. Check the `/health` endpoint to see if probes are in the cache +2. Verify the browser is sending the correct `service` name +3. Check browser console for polling errors +4. Ensure probes are configured in Datadog for your service + +## Security Notes + +- This proxy is **hardcoded to LIVE_DEBUGGING only** for security +- The proxy exposes CORS to all origins (intended for local development) +- For production use, you should: + - Add authentication to the proxy endpoints + - Restrict CORS to specific origins + - Use HTTPS + - Deploy behind a proper security layer + +## Development + +### Running in Development Mode + +```bash +npm run dev +``` + +This uses Node's `--watch` flag to restart on file changes. + +### Project Structure + +``` +sandbox/rc-proxy/ +├── index.js # Main Express server +├── rc-client.js # Remote Config protocol client +├── client-tracker.js # Active client tracking with TTL +├── config.js # Environment configuration +├── remoteconfig.proto # Protobuf schema +├── package.json # Dependencies +├── .env # Environment variables (not committed) +└── README.md # This file +``` + +## Technical Details + +- **Protocol**: Uses Datadog's Remote Config protobuf protocol +- **Products**: Hardcoded to `['LIVE_DEBUGGING']` +- **Client ID**: Each browser client gets a unique UUID +- **Version Tracking**: Tracks RC versions to minimize data transfer +- **Probe Format**: Compatible with dd-trace-js debugger probe format + +## License + +Apache-2.0 diff --git a/sandbox/debugger/rc-proxy/agent-client.js b/sandbox/debugger/rc-proxy/agent-client.js new file mode 100644 index 0000000000..9247241864 --- /dev/null +++ b/sandbox/debugger/rc-proxy/agent-client.js @@ -0,0 +1,386 @@ +/** + * Agent Client - Polls local Datadog agent for Remote Config updates + * + * This client connects to a local Datadog agent's /v0.7/config endpoint, + * which is the same endpoint used by tracers (dd-trace-js, dd-trace-py, etc.) + * + * The agent endpoint uses JSON (not protobuf) and returns probe configurations + * directly without requiring API keys or external network access. + */ + +import { randomUUID } from 'crypto' + +// Apply states for config_states +const UNACKNOWLEDGED = 0 +const ACKNOWLEDGED = 1 +const ERROR = 2 + +export default class AgentClient { + constructor(agentUrl) { + this.agentUrl = agentUrl + this.endpoint = '/v0.7/config' + + // Generate stable client ID (represents the browser RC proxy) + this.clientId = this._generateClientId() + + // Track RC state (using snake_case for agent JSON API) + this.state = { + root_version: 1, + targets_version: 0, + config_states: [], + has_error: false, + error: '', + backend_client_state: '', + } + + this.cachedTargetFiles = [] + + // Track applied configs (like dd-trace-js RemoteConfigManager) + // Map + this.appliedConfigs = new Map() + } + + /** + * Initialize the agent client (no-op for agent mode, kept for compatibility) + */ + async initialize() { + return Promise.resolve() + } + + /** + * Get all currently applied probes + * @returns {Array} Array of probe objects + */ + getAllProbes() { + return Array.from(this.appliedConfigs.values()) + .map((config) => config.probe) + .filter((probe) => probe != null) + } + + /** + * Poll the local agent for RC updates + * + * @param {Array} activeClients - Array of browser clients from ClientTracker + * @returns {Promise} Array of probe objects + */ + async poll(activeClients) { + try { + // Build client payload for each active browser client + const clients = activeClients.map((client) => this._buildClientPayload(client)) + + // The agent expects a single client per request, but we have multiple browser clients + // For POC, we'll merge all into one composite client + const payload = this._buildAgentRequest(clients) + + const response = await fetch(`${this.agentUrl}${this.endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(10000), // 10 second timeout + }) + + if (!response.ok) { + let errorBody = '' + try { + errorBody = await response.text() + console.error('[AgentClient] Error response body:', errorBody) + } catch (e) { + // Ignore + } + throw new Error(`Agent returned ${response.status}: ${response.statusText}`) + } + + const data = await response.json() + + // Parse config update (similar to dd-trace-js RemoteConfigManager.parseConfig) + const probes = this._parseConfigUpdate(data) + + if (probes.length > 0) { + console.log(`[AgentClient] Received ${probes.length} new/updated probe(s)`) + } + + return probes + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + const error = new Error( + `Cannot connect to agent at ${this.agentUrl} - connection refused. Is the agent running?` + ) + error.cause = err.cause + throw error + } else if (err.name === 'TimeoutError' || err.cause?.code === 'ETIMEDOUT') { + const error = new Error(`Agent request timed out at ${this.agentUrl}. The agent may be unreachable.`) + error.cause = err.cause + throw error + } else if (err.cause?.code === 'ENOTFOUND') { + const error = new Error(`Cannot resolve hostname for agent at ${this.agentUrl}. Check the agent URL.`) + error.cause = err.cause + throw error + } + + console.error('[AgentClient] Poll failed:', err.message) + throw err + } + } + + /** + * Parse config update from agent response (similar to dd-trace-js parseConfig) + * @private + */ + _parseConfigUpdate(data) { + const clientConfigs = data.client_configs || [] + const targetFiles = data.target_files || [] + + // Parse TUF targets metadata + const targetsMetadata = this._fromBase64JSON(data.targets) + + if (!targetsMetadata || !targetsMetadata.signed) { + return [] + } + + const targets = targetsMetadata.signed.targets + const newVersion = targetsMetadata.signed.version + + this.state.targets_version = newVersion + + // Update backend client state if present + if (targetsMetadata.signed.custom && targetsMetadata.signed.custom.opaque_backend_state) { + this.state.backend_client_state = targetsMetadata.signed.custom.opaque_backend_state + } + + // Determine which configs to unapply, apply, or modify + const toUnapply = [] + const toApply = [] + const toModify = [] + + // Find configs to unapply (in appliedConfigs but not in clientConfigs) + for (const [path, appliedConfig] of this.appliedConfigs.entries()) { + if (!clientConfigs.includes(path)) { + toUnapply.push(appliedConfig) + } + } + + // Find configs to apply or modify + for (const path of clientConfigs) { + const meta = targets[path] + if (!meta) { + console.warn(`[AgentClient] Unable to find target for path ${path}`) + continue + } + + const current = this.appliedConfigs.get(path) + + // If we already have this config with the same hash, skip it + if (current && current.hashes.sha256 === meta.hashes.sha256) { + continue + } + + // Find the target file with the probe data + const file = targetFiles.find((f) => f.path === path) + if (!file) { + console.warn(`[AgentClient] Unable to find file for path ${path}`) + continue + } + + // Parse the probe from the file + let probe + try { + probe = this._extractProbeFromFile(file) + } catch (err) { + console.error(`[AgentClient] Failed to parse probe from ${path}:`, err.message) + continue + } + + // Create config entry + const config = { + path, + version: meta.custom?.v || 0, + hashes: meta.hashes, + length: meta.length, + apply_state: ACKNOWLEDGED, + apply_error: '', + probe, + } + + if (current) { + toModify.push(config) + } else { + toApply.push(config) + } + } + + // Apply changes to appliedConfigs map + for (const config of toUnapply) { + this.appliedConfigs.delete(config.path) + } + + for (const config of [...toApply, ...toModify]) { + this.appliedConfigs.set(config.path, config) + } + + // Build config_states for next request + this.state.config_states = Array.from(this.appliedConfigs.values()).map((config) => ({ + product: 'LIVE_DEBUGGING', + id: config.path, + version: config.version, + apply_state: config.apply_state, + apply_error: config.apply_error, + })) + + // Build cached_target_files for next request + this.cachedTargetFiles = Array.from(this.appliedConfigs.values()).map((config) => { + const hashes = [] + if (config.hashes) { + for (const [algorithm, hashValue] of Object.entries(config.hashes)) { + hashes.push({ algorithm, hash: hashValue }) + } + } + return { + path: config.path, + length: config.length, + hashes, + } + }) + + // Log summary if there were changes + if (toApply.length > 0 || toModify.length > 0 || toUnapply.length > 0) { + console.log( + `[AgentClient] Config changes: +${toApply.length} new, ~${toModify.length} modified, -${toUnapply.length} removed (total: ${this.appliedConfigs.size})` + ) + } + + // Return only the new/modified probes + return [...toApply, ...toModify].map((c) => c.probe) + } + + /** + * Extract a single probe from a target file + * @private + */ + _extractProbeFromFile(file) { + if (!file.raw) { + throw new Error(`File ${file.path} has no raw content`) + } + + // Parse the raw probe config (base64-encoded JSON from agent) + let probeConfig + if (typeof file.raw === 'string') { + probeConfig = this._fromBase64JSON(file.raw) + } else if (Buffer.isBuffer(file.raw)) { + probeConfig = this._fromBase64JSON(file.raw.toString('utf8')) + } else if (file.raw.type === 'Buffer' && Array.isArray(file.raw.data)) { + probeConfig = this._fromBase64JSON(Buffer.from(file.raw.data).toString('utf8')) + } else { + throw new Error(`Unknown raw format for ${file.path}`) + } + + if (!probeConfig) { + throw new Error(`Failed to decode base64 JSON for ${file.path}`) + } + + return probeConfig + } + + /** + * Build the client payload for the agent endpoint (uses snake_case for JSON) + * @private + */ + _buildClientPayload(browserClient) { + return { + state: this.state, + id: this.clientId, + products: ['LIVE_DEBUGGING'], + is_tracer: true, + client_tracer: { + runtime_id: browserClient.runtimeId, + language: 'node', // Match dd-trace-js to avoid agent filtering + tracer_version: '6.0.0-pre', // Match dd-trace-js + service: browserClient.service, + env: browserClient.env || '', + app_version: browserClient.version || '6.0.0-pre', // Match dd-trace-js + extra_services: [], + tags: [ + `service:${browserClient.service}`, + ...(browserClient.env ? [`env:${browserClient.env}`] : []), + ...(browserClient.version ? [`version:${browserClient.version}`] : []), + `runtime-id:${browserClient.runtimeId}`, + `_dd.rc.client_id:${this.clientId}`, + ], + }, + capabilities: Buffer.alloc(1).toString('base64'), + } + } + + /** + * Build the agent request (using first client, as agent expects single client) + * @private + */ + _buildAgentRequest(clients) { + // For simplicity, use the first client's metadata + // In production, we might need multiple requests or merge strategies + const client = clients.length > 0 ? clients[0] : this._buildDefaultClient() + + return { + client: client, + cached_target_files: this.cachedTargetFiles, + } + } + + /** + * Build a default client when no browser clients are active + * @private + */ + _buildDefaultClient() { + return { + state: this.state, + id: this.clientId, + // Subscribe to same products as dd-trace-js to match its behavior + products: ['ASM_FEATURES', 'APM_TRACING', 'AGENT_CONFIG', 'AGENT_TASK', 'LIVE_DEBUGGING'], + is_tracer: true, + client_tracer: { + runtime_id: this._generateRuntimeId(), + language: 'node', // Match dd-trace-js + tracer_version: '6.0.0-pre', // Match dd-trace-js + service: 'browser-live-debugger-proxy', + env: '', + app_version: '6.0.0-pre', // Match dd-trace-js + extra_services: [], + tags: [], + }, + capabilities: Buffer.alloc(1).toString('base64'), + } + } + + /** + * Generate a unique client ID (like dd-trace-js does with uuid()) + * @private + */ + _generateClientId() { + // Generate a new UUID on each proxy startup (agent caches client state) + // Using a deterministic ID would cause the agent to think we already have all configs + return randomUUID() + } + + /** + * Generate a runtime ID + * @private + */ + _generateRuntimeId() { + return 'runtime-' + Math.random().toString(36).substring(2, 15) + } + + /** + * Decode base64-encoded JSON (used for TUF targets) + * @private + */ + _fromBase64JSON(str) { + if (!str) return null + try { + return JSON.parse(Buffer.from(str, 'base64').toString('utf8')) + } catch (err) { + console.error('[AgentClient] Failed to decode base64 JSON:', err.message) + return null + } + } +} diff --git a/sandbox/debugger/rc-proxy/client-tracker.js b/sandbox/debugger/rc-proxy/client-tracker.js new file mode 100644 index 0000000000..e361a6fe2e --- /dev/null +++ b/sandbox/debugger/rc-proxy/client-tracker.js @@ -0,0 +1,140 @@ +import { randomUUID } from 'crypto' + +/** + * Client Tracker + * + * Tracks active browser clients (similar to how the Datadog Agent tracks tracer clients). + * Each client has a TTL and is expired if not seen within the configured timeout. + */ + +class ClientTracker { + constructor(clientTTL = 30000) { + this.clientTTL = clientTTL + this.clients = new Map() // Key: clientKey (service:env:version), Value: client info + } + + /** + * Generate a unique key for a client based on service, env, and version + */ + _getClientKey(service, env, version) { + return `${service}:${env || 'none'}:${version || 'none'}` + } + + /** + * Register or update a client + * @param {string} service - Service name + * @param {string} env - Environment (optional) + * @param {string} version - App version (optional) + * @returns {object} The registered client with isNew flag + */ + registerClient(service, env = '', version = '') { + const clientKey = this._getClientKey(service, env, version) + const now = Date.now() + + let client = this.clients.get(clientKey) + let isNew = false + + if (!client) { + // New client - generate runtime ID + client = { + id: randomUUID(), + runtimeId: randomUUID(), + service, + env, + version, + lastSeen: now, + createdAt: now, + } + this.clients.set(clientKey, client) + isNew = true + console.log(`[ClientTracker] New client registered: ${clientKey}`) + } else { + // Update last seen timestamp + client.lastSeen = now + } + + return { client, isNew } + } + + /** + * Get all active clients (not expired) + * Prunes expired clients as a side effect + * @returns {Array} Array of active clients + */ + getActiveClients() { + const now = Date.now() + const activeClients = [] + + for (const [key, client] of this.clients.entries()) { + if (now - client.lastSeen > this.clientTTL) { + // Client has expired + this.clients.delete(key) + console.log(`[ClientTracker] Client expired: ${key}`) + } else { + activeClients.push(client) + } + } + + return activeClients + } + + /** + * Build protobuf Client message for a browser client + * @param {object} client - Client info from tracker + * @returns {object} Protobuf Client message structure + */ + buildClientMessage(client) { + return { + state: { + rootVersion: 1, + targetsVersion: 0, + configStates: [], + hasError: false, + error: '', + backendClientState: Buffer.alloc(0), + }, + id: client.id, + products: ['LIVE_DEBUGGING'], // Hardcoded to LIVE_DEBUGGING only + isTracer: true, + clientTracer: { + runtimeId: client.runtimeId, + language: 'javascript', + tracerVersion: 'browser-live-debugger/1.0.0', + service: client.service, + env: client.env || '', + appVersion: client.version || '', + tags: [], + extraServices: [], + processTags: [], + containerTags: [], + }, + isAgent: false, + isUpdater: false, + lastSeen: Math.floor(client.lastSeen / 1000), // Convert to seconds + capabilities: Buffer.alloc(1), // Default capability + } + } + + /** + * Get count of active clients (without expiring) + */ + getActiveClientCount() { + const now = Date.now() + let count = 0 + for (const client of this.clients.values()) { + if (now - client.lastSeen <= this.clientTTL) { + count++ + } + } + return count + } + + /** + * Clear all clients (useful for testing) + */ + clear() { + this.clients.clear() + } +} + +export default ClientTracker diff --git a/sandbox/debugger/rc-proxy/config.js b/sandbox/debugger/rc-proxy/config.js new file mode 100644 index 0000000000..4b20cf450f --- /dev/null +++ b/sandbox/debugger/rc-proxy/config.js @@ -0,0 +1,66 @@ +import dotenv from 'dotenv' + +// Load environment variables from .env file +dotenv.config() + +/** + * Configuration for the RC Proxy + * + * Supports two modes: + * 1. Agent mode (AGENT_URL): Polls local Datadog agent - for POC/development + * 2. Backend mode (DD_API_KEY + DD_SITE): Polls Datadog RC backend directly - requires backend access + */ + +// Determine mode +const AGENT_URL = process.env.AGENT_URL +const DD_API_KEY = process.env.DD_API_KEY + +const useAgentMode = !!AGENT_URL + +if (!useAgentMode && !DD_API_KEY) { + console.error('ERROR: Either AGENT_URL or DD_API_KEY must be set') + console.error(' - AGENT_URL: URL of local Datadog agent (e.g., http://localhost:8126)') + console.error(' - DD_API_KEY + DD_SITE: For direct RC backend access') + process.exit(1) +} + +// Optional configuration with defaults +const config = { + mode: useAgentMode ? 'agent' : 'backend', + agentUrl: AGENT_URL, + apiKey: DD_API_KEY, + site: process.env.DD_SITE || 'datadoghq.com', + port: parseInt(process.env.PORT || '3030', 10), + pollInterval: parseInt(process.env.POLL_INTERVAL || '5000', 10), + clientTTL: parseInt(process.env.CLIENT_TTL || '30000', 10), +} + +// Validate numeric values +if (isNaN(config.port) || config.port < 1 || config.port > 65535) { + console.error('ERROR: PORT must be a valid port number (1-65535)') + process.exit(1) +} + +if (isNaN(config.pollInterval) || config.pollInterval < 1000) { + console.error('ERROR: POLL_INTERVAL must be at least 1000ms') + process.exit(1) +} + +if (isNaN(config.clientTTL) || config.clientTTL < 1000) { + console.error('ERROR: CLIENT_TTL must be at least 1000ms') + process.exit(1) +} + +console.log('[Config] Configuration loaded:') +console.log(` - Mode: ${config.mode}`) +if (config.mode === 'agent') { + console.log(` - Agent URL: ${config.agentUrl}`) +} else { + console.log(` - Site: ${config.site}`) + console.log(` - API Key: ${config.apiKey.substring(0, 8)}...`) +} +console.log(` - Port: ${config.port}`) +console.log(` - Poll Interval: ${config.pollInterval}ms`) +console.log(` - Client TTL: ${config.clientTTL}ms`) + +export default config diff --git a/sandbox/debugger/rc-proxy/index.js b/sandbox/debugger/rc-proxy/index.js new file mode 100644 index 0000000000..a62107b4bd --- /dev/null +++ b/sandbox/debugger/rc-proxy/index.js @@ -0,0 +1,291 @@ +import express from 'express' +import config from './config.js' +import ClientTracker from './client-tracker.js' +import RCClient from './rc-client.js' +import AgentClient from './agent-client.js' +import { sendDummyTrace } from './trace-sender.js' + +/** + * Remote Config Proxy Server + * + * Main server that: + * - Tracks active browser clients + * - Polls Datadog RC backend for LIVE_DEBUGGING probes + * - Serves probes to browsers via HTTP GET + */ + +const app = express() + +// Initialize client tracker +const clientTracker = new ClientTracker(config.clientTTL) + +// Initialize RC client based on mode +let rcClient +if (config.mode === 'agent') { + console.log('[Init] Using local agent mode') + rcClient = new AgentClient(config.agentUrl) +} else { + console.log('[Init] Using backend mode (direct RC access)') + rcClient = new RCClient(config.apiKey, config.site) +} + +// Probe cache +const probeCache = new Map() // Key: probe ID, Value: probe object +let lastPollTime = null +let lastPollError = null + +// CORS middleware - allow browser access from localhost +app.use((req, res, next) => { + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Methods', 'GET, OPTIONS') + res.header('Access-Control-Allow-Headers', 'Content-Type') + + if (req.method === 'OPTIONS') { + return res.sendStatus(200) + } + + next() +}) + +// JSON middleware +app.use(express.json()) + +/** + * GET /probes + * + * Register a client and return all probes. + * Query params: + * - service (required): Service name + * - env (optional): Environment + * - version (optional): App version + */ +app.get('/probes', (req, res) => { + const { service, env, version } = req.query + + // Validate required params and ensure they are strings + if (!service || typeof service !== 'string') { + return res.status(400).json({ + error: 'Missing required query parameter: service', + }) + } + + const envStr = typeof env === 'string' ? env : '' + const versionStr = typeof version === 'string' ? version : '' + + // Register/update client + let isNewClient = false + try { + const result = clientTracker.registerClient(service, envStr, versionStr) + isNewClient = result.isNew + + // Send dummy trace for new clients in agent mode + if (isNewClient && config.mode === 'agent' && config.agentUrl) { + // Don't await - send trace in background + sendDummyTrace(config.agentUrl, service, envStr).catch((err) => { + console.error('[Server] Failed to send trace for new client:', err) + }) + } + } catch (err) { + console.error('[Server] Error registering client:', err) + } + + // Return all probes from cache + const probes = Array.from(probeCache.values()) + + res.json({ + probes, + count: probes.length, + lastPollTime, + }) +}) + +/** + * GET /health + * + * Health check endpoint + */ +app.get('/health', (req, res) => { + const activeClientCount = clientTracker.getActiveClientCount() + + res.json({ + ok: !lastPollError, + lastPollTime, + lastPollError: lastPollError ? lastPollError.message : null, + activeClientCount, + probeCount: probeCache.size, + config: { + mode: config.mode, + agentUrl: config.agentUrl, + site: config.site, + pollInterval: config.pollInterval, + clientTTL: config.clientTTL, + }, + }) +}) + +/** + * GET / + * + * Welcome message and API documentation + */ +app.get('/', (req, res) => { + res.json({ + name: 'Datadog Remote Config Proxy', + version: '1.0.0', + description: 'Proxy for browser Live Debugger Remote Config (LIVE_DEBUGGING only)', + endpoints: { + '/probes': 'GET - Register client and fetch probes. Query params: service (required), env, version', + '/health': 'GET - Health check and status', + '/': 'GET - This information', + }, + status: { + activeClients: clientTracker.getActiveClientCount(), + probes: probeCache.size, + lastPoll: lastPollTime, + }, + }) +}) + +/** + * Background polling loop + * + * Polls Datadog RC backend periodically and updates probe cache + */ +async function pollLoop() { + try { + // Get active clients + const activeClients = clientTracker.getActiveClients() + + if (activeClients.length === 0) { + lastPollError = null + return + } + + // Poll RC backend/agent (returns only new/modified probes) + let deltaProbes + if (config.mode === 'agent') { + // Agent mode: pass raw client data + deltaProbes = await rcClient.poll(activeClients) + } else { + // Backend mode: build protobuf client messages + const pbClientMessages = activeClients.map((client) => clientTracker.buildClientMessage(client)) + deltaProbes = await rcClient.poll(pbClientMessages) + } + + // Update probe cache with all currently applied probes (not just the delta) + const allProbes = rcClient.getAllProbes() + probeCache.clear() + for (const probe of allProbes) { + if (probe.id) { + probeCache.set(probe.id, probe) + } + } + + lastPollTime = new Date().toISOString() + lastPollError = null + + if (deltaProbes.length > 0) { + console.log(`[Polling] Updated: ${deltaProbes.length} probe(s) changed (total: ${allProbes.length})`) + } + } catch (err) { + if (config.mode === 'agent') { + if (err.message.includes('connection refused') || err.message.includes('ECONNREFUSED')) { + console.error(`[Polling] ❌ Cannot connect to agent at ${config.agentUrl} - Is the agent still running?`) + } else { + console.error('[Polling] Poll error:', err.message) + } + } else { + console.error('[Polling] Poll error:', err.message) + } + lastPollError = err + } +} + +/** + * Validate that the agent is reachable + */ +async function validateAgentConnection() { + if (config.mode !== 'agent') { + return // Skip validation for backend mode + } + + try { + console.log(`[Validation] Checking agent connection at ${config.agentUrl}...`) + + // Try to connect to the agent's info endpoint + const response = await fetch(`${config.agentUrl}/info`, { + method: 'GET', + signal: AbortSignal.timeout(5000), // 5 second timeout + }) + + if (!response.ok) { + throw new Error(`Agent returned status ${response.status}`) + } + + console.log('[Validation] ✅ Agent connection successful') + } catch (err) { + console.error('\n❌ ERROR: Cannot connect to Datadog Agent') + console.error(` Agent URL: ${config.agentUrl}`) + console.error(` Error: ${err.message}\n`) + console.error('Please ensure that:') + console.error(' 1. Docker is running (if using Docker)') + console.error(' 2. The Datadog Agent is running') + console.error(' 3. The agent is accessible at the configured URL') + console.error(' 4. The agent port (default: 8126) is correct\n') + + if (err.cause?.code === 'ECONNREFUSED') { + console.error('The connection was refused. The agent is not listening on this port.\n') + } else if (err.name === 'TimeoutError' || err.cause?.code === 'ETIMEDOUT') { + console.error('The connection timed out. The agent may be unreachable.\n') + } + + process.exit(1) + } +} + +/** + * Start the server + */ +async function start() { + console.log('🚀 Starting Remote Config Proxy...') + + // Validate agent connection first + await validateAgentConnection() + + // Initialize RC client (load protobuf) + try { + await rcClient.initialize() + } catch (err) { + console.error('Failed to initialize RC client:', err) + process.exit(1) + } + + // Start Express server + app.listen(config.port, () => { + console.log(`\n✅ Proxy running on http://localhost:${config.port}`) + console.log(` Polling agent at ${config.agentUrl} every ${config.pollInterval}ms\n`) + }) + + // Initial poll + await pollLoop() + + // Set up interval + setInterval(pollLoop, config.pollInterval) +} + +// Handle graceful shutdown +process.on('SIGINT', () => { + console.log('\n👋 Shutting down...') + process.exit(0) +}) + +process.on('SIGTERM', () => { + console.log('\n👋 Shutting down...') + process.exit(0) +}) + +// Start the server +start().catch((err) => { + console.error('Failed to start server:', err) + process.exit(1) +}) diff --git a/sandbox/debugger/rc-proxy/package.json b/sandbox/debugger/rc-proxy/package.json new file mode 100644 index 0000000000..f577b82ab5 --- /dev/null +++ b/sandbox/debugger/rc-proxy/package.json @@ -0,0 +1,24 @@ +{ + "name": "@datadog/browser-rc-proxy", + "version": "1.0.0", + "description": "Remote Config proxy for browser Live Debugger POC", + "main": "index.js", + "type": "module", + "scripts": { + "start": "node index.js", + "dev": "node --watch index.js" + }, + "keywords": [ + "datadog", + "remote-config", + "live-debugger" + ], + "author": "Datadog", + "license": "Apache-2.0", + "dependencies": { + "express": "^4.18.2", + "protobufjs": "^7.2.5", + "uuid": "^9.0.1", + "dotenv": "^16.3.1" + } +} diff --git a/sandbox/debugger/rc-proxy/rc-client.js b/sandbox/debugger/rc-proxy/rc-client.js new file mode 100644 index 0000000000..de115d0b41 --- /dev/null +++ b/sandbox/debugger/rc-proxy/rc-client.js @@ -0,0 +1,236 @@ +import protobuf from 'protobufjs' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +/** + * Remote Config Client + * + * Communicates with Datadog's Remote Config backend using protobuf protocol. + * Hardcoded to LIVE_DEBUGGING product only. + */ + +class RCClient { + constructor(apiKey, site = 'datadoghq.com') { + this.apiKey = apiKey + this.site = site + this.baseURL = `https://config.${site}` + this.endpoint = '/api/v0.1/configurations' + + // Version tracking for RC protocol + this.versions = { + config_snapshot: 0, + config_root: 0, + director_root: 0, + } + + // Track products - first request uses new_products, then moves to products + this.products = new Set() + this.isFirstRequest = true + + // Protobuf types (loaded async) + this.proto = null + this.initialized = false + } + + /** + * Load and compile protobuf definitions + */ + async initialize() { + if (this.initialized) return + + try { + const protoPath = join(__dirname, 'remoteconfig.proto') + const root = await protobuf.load(protoPath) + + this.proto = { + LatestConfigsRequest: root.lookupType('datadog.config.LatestConfigsRequest'), + LatestConfigsResponse: root.lookupType('datadog.config.LatestConfigsResponse'), + Client: root.lookupType('datadog.config.Client'), + ClientState: root.lookupType('datadog.config.ClientState'), + ClientTracer: root.lookupType('datadog.config.ClientTracer'), + } + + this.initialized = true + console.log('[RCClient] Protobuf definitions loaded') + } catch (err) { + console.error('[RCClient] Failed to load protobuf definitions:', err) + throw err + } + } + + /** + * Poll Remote Config backend for updates + * @param {Array} activeClients - Array of active client info from ClientTracker + * @returns {Promise} Array of parsed probe objects + */ + async poll(activeClients = []) { + if (!this.initialized) { + await this.initialize() + } + + // Build active_clients protobuf messages + const pbClients = activeClients.map((clientInfo) => { + return this.proto.Client.create(clientInfo) + }) + + // On first request, LIVE_DEBUGGING goes in newProducts + // On subsequent requests, it goes in products + const products = this.isFirstRequest ? [] : ['LIVE_DEBUGGING'] + const newProducts = this.isFirstRequest ? ['LIVE_DEBUGGING'] : [] + + // Build the request + const request = this.proto.LatestConfigsRequest.create({ + hostname: 'browser-rc-proxy', + agentVersion: 'browser-rc-proxy/1.0.0', + currentConfigSnapshotVersion: this.versions.config_snapshot, + currentConfigRootVersion: this.versions.config_root, + currentDirectorRootVersion: this.versions.director_root, + products: products, + newProducts: newProducts, + activeClients: pbClients, + backendClientState: Buffer.alloc(0), + hasError: false, + error: '', + traceAgentEnv: '', + orgUuid: '', + tags: [], + agentUuid: '', + }) + + // Debug log the request structure before encoding + console.log( + '[RCClient] Request structure:', + JSON.stringify( + { + hostname: request.hostname, + agentVersion: request.agentVersion, + products: request.products, + newProducts: request.newProducts, + activeClients: request.activeClients.map((c) => ({ + id: c.id, + service: c.clientTracer?.service, + env: c.clientTracer?.env, + isTracer: c.isTracer, + })), + currentConfigSnapshotVersion: request.currentConfigSnapshotVersion, + currentConfigRootVersion: request.currentConfigRootVersion, + currentDirectorRootVersion: request.currentDirectorRootVersion, + }, + null, + 2 + ) + ) + + // Encode to protobuf + const requestBuffer = this.proto.LatestConfigsRequest.encode(request).finish() + + console.log('[RCClient] Encoded protobuf size:', requestBuffer.length, 'bytes') + + // Make HTTP request to Datadog + const url = `${this.baseURL}${this.endpoint}` + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'DD-Api-Key': this.apiKey, + 'Content-Type': 'application/x-protobuf', + 'User-Agent': 'browser-rc-proxy/1.0.0', + }, + body: requestBuffer, + }) + + if (!response.ok) { + // Try to get response body for debugging + let errorBody = '' + try { + errorBody = await response.text() + } catch (e) { + // Ignore + } + console.error(`[RCClient] RC backend error response body: ${errorBody}`) + throw new Error(`RC backend returned ${response.status}: ${response.statusText}`) + } + + // Decode protobuf response + const responseBuffer = Buffer.from(await response.arrayBuffer()) + const decodedResponse = this.proto.LatestConfigsResponse.decode(responseBuffer) + + // Update version tracking + if (decodedResponse.configMetas) { + if (decodedResponse.configMetas.snapshot) { + this.versions.config_snapshot = decodedResponse.configMetas.snapshot.version || 0 + } + if (decodedResponse.configMetas.roots && decodedResponse.configMetas.roots.length > 0) { + const latestRoot = decodedResponse.configMetas.roots[decodedResponse.configMetas.roots.length - 1] + this.versions.config_root = latestRoot.version || 0 + } + } + if (decodedResponse.directorMetas) { + if (decodedResponse.directorMetas.roots && decodedResponse.directorMetas.roots.length > 0) { + const latestRoot = decodedResponse.directorMetas.roots[decodedResponse.directorMetas.roots.length - 1] + this.versions.director_root = latestRoot.version || 0 + } + } + + // Mark that first request is complete + if (this.isFirstRequest) { + this.isFirstRequest = false + this.products.add('LIVE_DEBUGGING') + } + + // Extract and parse target files (probes) + const probes = this._extractProbes(decodedResponse.targetFiles || []) + + console.log( + `[RCClient] Poll successful. Received ${probes.length} probes. Versions: ${JSON.stringify(this.versions)}` + ) + + return probes + } catch (err) { + console.error('[RCClient] Poll failed:', err.message) + throw err + } + } + + /** + * Extract and parse probes from target files + * @param {Array} targetFiles - Array of File messages from RC response + * @returns {Array} Array of parsed probe objects + */ + _extractProbes(targetFiles) { + const probes = [] + + for (const file of targetFiles) { + try { + // Target files contain base64-encoded JSON + const jsonStr = file.raw.toString('utf-8') + const config = JSON.parse(jsonStr) + + // LIVE_DEBUGGING configs typically have a structure like: + // { "probe": { ... probe data ... } } + // or could be an array of probes + + if (config.probe) { + probes.push(config.probe) + } else if (Array.isArray(config)) { + probes.push(...config) + } else if (config.probes && Array.isArray(config.probes)) { + probes.push(...config.probes) + } else { + // Assume the entire config is a probe + probes.push(config) + } + } catch (err) { + console.error(`[RCClient] Failed to parse target file ${file.path}:`, err.message) + } + } + + return probes + } +} + +export default RCClient diff --git a/sandbox/debugger/rc-proxy/remoteconfig.proto b/sandbox/debugger/rc-proxy/remoteconfig.proto new file mode 100644 index 0000000000..7b89fddcd8 --- /dev/null +++ b/sandbox/debugger/rc-proxy/remoteconfig.proto @@ -0,0 +1,156 @@ +syntax = "proto3"; + +package datadog.config; + +// Backend definitions + +message ConfigMetas { + repeated TopMeta roots = 1; + TopMeta timestamp = 2; + TopMeta snapshot = 3; + TopMeta topTargets = 4; + repeated DelegatedMeta delegatedTargets = 5; +} + +message DirectorMetas { + repeated TopMeta roots = 1; + TopMeta timestamp = 2; + TopMeta snapshot = 3; + TopMeta targets = 4; +} + +message DelegatedMeta { + uint64 version = 1; + string role = 2; + bytes raw = 3; +} + +message TopMeta { + uint64 version = 1; + bytes raw = 2; +} + +message File { + string path = 1; + bytes raw = 2; +} + +// Backend queries + +message LatestConfigsRequest { + string hostname = 1; + string agentVersion = 2; + uint64 current_config_snapshot_version = 3; + uint64 current_config_root_version = 9; + uint64 current_director_root_version = 8; + repeated string products = 4; + repeated string new_products = 5; + repeated Client active_clients = 6; + bytes backend_client_state = 10; + bool has_error = 11; + string error = 12; + string trace_agent_env = 13; + string org_uuid = 14; + repeated string tags = 15; + string agent_uuid = 16; +} + +message LatestConfigsResponse { + ConfigMetas config_metas = 1; + DirectorMetas director_metas = 2; + repeated File target_files = 3; +} + +// Client definitions + +message Client { + ClientState state = 1; + string id = 2; + repeated string products = 3; + reserved 4, 5; + bool is_tracer = 6; + ClientTracer client_tracer = 7; + bool is_agent = 8; + ClientAgent client_agent = 9; + uint64 last_seen = 10; + bytes capabilities = 11; + reserved 12, 13; + bool is_updater = 14; + ClientUpdater client_updater = 15; +} + +message ClientTracer { + string runtime_id = 1; + string language = 2; + string tracer_version = 3; + string service = 4; + repeated string extra_services = 8; + string env = 5; + string app_version = 6; + repeated string tags = 7; + repeated string process_tags = 9; + repeated string container_tags = 10; +} + +message ClientAgent { + string name = 1; + string version = 2; + string cluster_name = 3; + string cluster_id = 4; + repeated string cws_workloads = 5; +} + +message ClientUpdater { + repeated string tags = 1; + repeated PackageState packages = 2; + uint64 available_disk_space = 3; +} + +message PackageState { + string package = 1; + string stable_version = 2; + string experiment_version = 3; + PackageStateTask task = 4; + reserved 5, 6, 7, 8, 9, 10; + string stable_config_version = 11; + string experiment_config_version = 12; + string running_version = 13; + string running_config_version = 14; +} + +message PackageStateTask { + string id = 1; + TaskState state = 2; + TaskError error = 3; +} + +enum TaskState { + IDLE = 0; + RUNNING = 1; + DONE = 2; + INVALID_STATE = 3; + ERROR = 4; +} + +message TaskError { + uint64 code = 1; + string message = 2; +} + +message ConfigState { + string id = 1; + uint64 version = 2; + string product = 3; + uint64 apply_state = 4; + string apply_error = 5; +} + +message ClientState { + uint64 root_version = 1; + uint64 targets_version = 2; + repeated ConfigState config_states = 3; + bool has_error = 4; + string error = 5; + bytes backend_client_state = 6; +} + diff --git a/sandbox/debugger/rc-proxy/trace-sender.js b/sandbox/debugger/rc-proxy/trace-sender.js new file mode 100644 index 0000000000..5ba62a2dc8 --- /dev/null +++ b/sandbox/debugger/rc-proxy/trace-sender.js @@ -0,0 +1,111 @@ +/** + * Trace Sender Module + * + * Sends dummy traces to the Datadog Agent trace intake API + */ + +/** + * Generate a random 64-bit ID as a number + */ +function generateId() { + return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER) +} + +/** + * Get current time in nanoseconds since epoch + */ +function getCurrentTimeNanos() { + return Date.now() * 1000000 // Convert milliseconds to nanoseconds +} + +/** + * Create a dummy span payload + * + * @param {string} service - Service name for the span + * @param {string} env - Environment for the span + */ +function createDummySpan(service, env) { + const traceId = generateId() + const spanId = generateId() + const now = getCurrentTimeNanos() + + return { + trace_id: traceId, + span_id: spanId, + parent_id: 0, + service: service, + name: 'proxy.client_registered', + resource: 'client_registration_healthcheck', + start: now, + duration: 1000000, // 1ms in nanoseconds + error: 0, + meta: { + env: env || 'none', + proxy_type: 'rc-proxy-client-registration', + 'git.repository_url': 'https://github.com/watson/browser-sdk', + 'git.commit.sha': '7399fd1dc9cbdaa2ba414b741b8324655b321762', + }, + metrics: { + _sampling_priority_v1: 1, + }, + type: 'custom', + } +} + +/** + * Send a dummy trace to the agent + * + * @param {string} agentUrl - Base URL of the Datadog agent (e.g., http://localhost:8126) + * @param {string} service - Service name for the span + * @param {string} env - Environment for the span + * @returns {Promise} - Returns true if successful, false otherwise + */ +export async function sendDummyTrace(agentUrl, service, env) { + try { + const span = createDummySpan(service, env) + const payload = [[span]] // Array of traces, each trace is an array of spans + + // The agent trace intake endpoint + const traceUrl = `${agentUrl}/v0.4/traces` + + console.log(`[TraceHealthCheck] Sending dummy trace for client: ${service} (${env || 'none'})...`) + + const response = await fetch(traceUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Datadog-Meta-Tracer-Version': '1.0.0', + 'Datadog-Meta-Lang': 'javascript', + 'X-Datadog-Trace-Count': '1', + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(5000), // 5 second timeout + }) + + if (!response.ok) { + const errorText = await response.text() + console.error(`[TraceHealthCheck] Failed to send trace: ${response.status} ${response.statusText}`) + console.error(`[TraceHealthCheck] Response: ${errorText}`) + return false + } + + console.log('[TraceHealthCheck] ✅ Dummy trace sent successfully') + console.log(`[TraceHealthCheck] Trace ID: ${span.trace_id}`) + return true + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error( + `[TraceHealthCheck] Cannot connect to agent at ${agentUrl} - connection refused. Is the agent running?` + ) + } else if (err.name === 'TimeoutError' || err.cause?.code === 'ETIMEDOUT') { + console.error(`[TraceHealthCheck] Agent request timed out at ${agentUrl}. The agent may be unreachable.`) + } else if (err.cause?.code === 'ENOTFOUND') { + console.error(`[TraceHealthCheck] Cannot resolve hostname for agent at ${agentUrl}. Check the agent URL.`) + } else { + console.error('[TraceHealthCheck] Error sending dummy trace:', err.message) + } + return false + } +} + +export default { sendDummyTrace } diff --git a/sandbox/debugger/test.html b/sandbox/debugger/test.html new file mode 100644 index 0000000000..6966a9ad66 --- /dev/null +++ b/sandbox/debugger/test.html @@ -0,0 +1,389 @@ + + + + + + Live Debugger Test + + + +
+

🐛 Live Debugger Test

+ +
+

Configuration

+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+ +
+

Status

+
Ready - Configure and click "Start Live Debugger" to begin
+
+
+ Active Probes + 0 +
+
+
+ +
+

RC Proxy Health

+ +
Click button to check proxy health
+
+ +
+

Current Probes

+ +
No probes loaded yet
+
+ +
+

Console Log

+ +
+
+
+ + + + + + + + + + + diff --git a/scripts/build/build-test-apps.ts b/scripts/build/build-test-apps.ts index 1135abc88a..546cab42ac 100644 --- a/scripts/build/build-test-apps.ts +++ b/scripts/build/build-test-apps.ts @@ -28,6 +28,7 @@ const APPS: AppConfig[] = [ { name: 'nextjs' }, { name: 'angular-app' }, { name: 'vue-router-app' }, + { name: 'instrumentation-overhead' }, // React Router apps { name: 'react-router-v6-app' }, diff --git a/scripts/dev-server/lib/server.ts b/scripts/dev-server/lib/server.ts index 2ba9fca460..980792d902 100644 --- a/scripts/dev-server/lib/server.ts +++ b/scripts/dev-server/lib/server.ts @@ -15,7 +15,7 @@ const sandboxPath = './sandbox' const START_PORT = 8080 const MAX_PORT = 8180 -const PACKAGES_WITH_BUNDLE = ['rum', 'rum-slim', 'logs', 'worker'] +const PACKAGES_WITH_BUNDLE = ['rum', 'rum-slim', 'logs', 'worker', 'debugger'] export function runServer({ writeIntakeFile = true }: { writeIntakeFile?: boolean } = {}): void { if (writeIntakeFile) { diff --git a/test/apps/.gitignore b/test/apps/.gitignore index 3c6fddc975..b779dd1e8c 100644 --- a/test/apps/.gitignore +++ b/test/apps/.gitignore @@ -3,3 +3,5 @@ invalid-tracking-origin/ react-router-v7-app/ cdn-extension/ appendChild-extension/ +*/dist/ +*/.yarn/ diff --git a/test/apps/instrumentation-overhead/index.html b/test/apps/instrumentation-overhead/index.html new file mode 100644 index 0000000000..4c909f744f --- /dev/null +++ b/test/apps/instrumentation-overhead/index.html @@ -0,0 +1,13 @@ + + + + + + Instrumentation Overhead Benchmark + + +

Instrumentation Overhead Benchmark

+

This page is used for benchmarking debugger instrumentation overhead.

+ + + diff --git a/test/apps/instrumentation-overhead/package.json b/test/apps/instrumentation-overhead/package.json new file mode 100644 index 0000000000..eef455c82d --- /dev/null +++ b/test/apps/instrumentation-overhead/package.json @@ -0,0 +1,22 @@ +{ + "name": "instrumentation-overhead-app", + "private": true, + "scripts": { + "build": "webpack --config ./webpack.config.js" + }, + "dependencies": { + "@datadog/browser-debugger": "file:../../../packages/debugger/package.tgz" + }, + "resolutions": { + "@datadog/browser-core": "file:../../../packages/core/package.tgz" + }, + "devDependencies": { + "ts-loader": "6.2.1", + "typescript": "3.8.2", + "webpack": "5.94.0" + }, + "volta": { + "extends": "../../../package.json" + }, + "packageManager": "yarn@4.11.0" +} diff --git a/test/apps/instrumentation-overhead/src/app.ts b/test/apps/instrumentation-overhead/src/app.ts new file mode 100644 index 0000000000..a728229cfe --- /dev/null +++ b/test/apps/instrumentation-overhead/src/app.ts @@ -0,0 +1,23 @@ +/** + * Main entry point for instrumentation overhead benchmark + * Exposes both instrumented and non-instrumented functions to window + */ + +import * as nonInstrumented from './functions' +import * as instrumented from './instrumented' + +declare global { + interface Window { + testFunctions: { + add1: (a: number, b: number) => number + add2: (a: number, b: number) => number + } + USE_INSTRUMENTED?: boolean + } +} + +// Expose functions to window based on configuration +// The benchmark will set USE_INSTRUMENTED flag before loading this script +if (typeof window !== 'undefined') { + window.testFunctions = window.USE_INSTRUMENTED ? instrumented : nonInstrumented +} diff --git a/test/apps/instrumentation-overhead/src/functions.ts b/test/apps/instrumentation-overhead/src/functions.ts new file mode 100644 index 0000000000..630a0a4c47 --- /dev/null +++ b/test/apps/instrumentation-overhead/src/functions.ts @@ -0,0 +1,12 @@ +/** + * Non-instrumented baseline functions + * These are the original functions without any instrumentation overhead + */ + +export function add1(a: number, b: number): number { + return a + b +} + +export function add2(a: number, b: number): number { + return a + b +} diff --git a/test/apps/instrumentation-overhead/src/instrumented.ts b/test/apps/instrumentation-overhead/src/instrumented.ts new file mode 100644 index 0000000000..1e0dfd7c8d --- /dev/null +++ b/test/apps/instrumentation-overhead/src/instrumented.ts @@ -0,0 +1,40 @@ +/** + * Instrumented versions of the functions + * These follow the debugger instrumentation pattern + */ + +// Global hooks injected by debugger SDK +declare const $dd_probes: (functionId: string) => any[] | undefined +declare const $dd_entry: (probes: any[], self: any, args: Record) => void +declare const $dd_return: ( + probes: any[], + value: any, + self: any, + args: Record, + locals: Record +) => any +declare const $dd_throw: (probes: any[], error: Error, self: any, args: Record) => void + +export function add1(a: number, b: number): number { + const $dd_p = $dd_probes('instrumented.ts;add1') + try { + if ($dd_p) $dd_entry($dd_p, null, { a, b }) + const result = a + b + return $dd_p ? $dd_return($dd_p, result, null, { a, b }, { result }) : result + } catch (e) { + if ($dd_p) $dd_throw($dd_p, e as Error, null, { a, b }) + throw e + } +} + +export function add2(a: number, b: number): number { + const $dd_p = $dd_probes('instrumented.ts;add2') + try { + if ($dd_p) $dd_entry($dd_p, null, { a, b }) + const result = a + b + return $dd_p ? $dd_return($dd_p, result, null, { a, b }, { result }) : result + } catch (e) { + if ($dd_p) $dd_throw($dd_p, e as Error, null, { a, b }) + throw e + } +} diff --git a/test/apps/instrumentation-overhead/tsconfig.json b/test/apps/instrumentation-overhead/tsconfig.json new file mode 100644 index 0000000000..82a5ffffac --- /dev/null +++ b/test/apps/instrumentation-overhead/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "outDir": "./dist/", + "strict": true, + "module": "commonjs", + "moduleResolution": "node", + "target": "es5", + "lib": ["ES2015", "DOM"], + "types": [] + } +} diff --git a/test/apps/instrumentation-overhead/webpack.config.js b/test/apps/instrumentation-overhead/webpack.config.js new file mode 100644 index 0000000000..5038bebedc --- /dev/null +++ b/test/apps/instrumentation-overhead/webpack.config.js @@ -0,0 +1,25 @@ +const path = require('node:path') + +module.exports = { + mode: 'production', + entry: './src/app.ts', + target: ['web', 'es2018'], + module: { + rules: [ + { + test: /\.ts$/, + use: 'ts-loader', + }, + ], + }, + resolve: { + extensions: ['.ts', '.js'], + }, + optimization: { + chunkIds: 'named', + }, + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'app.js', + }, +} diff --git a/test/apps/instrumentation-overhead/yarn.lock b/test/apps/instrumentation-overhead/yarn.lock new file mode 100644 index 0000000000..d52f9fca86 --- /dev/null +++ b/test/apps/instrumentation-overhead/yarn.lock @@ -0,0 +1,1117 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"@datadog/browser-core@file:../../../packages/core/package.tgz::locator=instrumentation-overhead-app%40workspace%3A.": + version: 6.24.1 + resolution: "@datadog/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=2cb026&locator=instrumentation-overhead-app%40workspace%3A." + checksum: 10c0/ecd295c8a225ac8fa3a5165ed163be03e78804a963cadb95ae48ebc4acfb44f1e7e81b2908ce4dc95d7c36d34112c921e54bf43eac33bfafe18dd8aa1597c93e + languageName: node + linkType: hard + +"@datadog/browser-debugger@file:../../../packages/debugger/package.tgz::locator=instrumentation-overhead-app%40workspace%3A.": + version: 6.24.1 + resolution: "@datadog/browser-debugger@file:../../../packages/debugger/package.tgz#../../../packages/debugger/package.tgz::hash=8233ef&locator=instrumentation-overhead-app%40workspace%3A." + dependencies: + "@datadog/browser-core": "npm:6.24.1" + peerDependencies: + "@datadog/browser-rum-core": 6.24.1 + peerDependenciesMeta: + "@datadog/browser-rum-core": + optional: true + checksum: 10c0/db99857408d656a05e4c0f618e73c6328398c1efdbb524666ed64aedacd43c4769f5dfc854d2726d9a6f376a00a2560521af267ac537c123be5b7c710ace1345 + languageName: node + linkType: hard + +"@jridgewell/gen-mapping@npm:^0.3.5": + version: 0.3.13 + resolution: "@jridgewell/gen-mapping@npm:0.3.13" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.0" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/9a7d65fb13bd9aec1fbab74cda08496839b7e2ceb31f5ab922b323e94d7c481ce0fc4fd7e12e2610915ed8af51178bdc61e168e92a8c8b8303b030b03489b13b + languageName: node + linkType: hard + +"@jridgewell/resolve-uri@npm:^3.1.0": + version: 3.1.2 + resolution: "@jridgewell/resolve-uri@npm:3.1.2" + checksum: 10c0/d502e6fb516b35032331406d4e962c21fe77cdf1cbdb49c6142bcbd9e30507094b18972778a6e27cbad756209cfe34b1a27729e6fa08a2eb92b33943f680cf1e + languageName: node + linkType: hard + +"@jridgewell/source-map@npm:^0.3.3": + version: 0.3.11 + resolution: "@jridgewell/source-map@npm:0.3.11" + dependencies: + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.25" + checksum: 10c0/50a4fdafe0b8f655cb2877e59fe81320272eaa4ccdbe6b9b87f10614b2220399ae3e05c16137a59db1f189523b42c7f88bd097ee991dbd7bc0e01113c583e844 + languageName: node + linkType: hard + +"@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0": + version: 1.5.5 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" + checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0 + languageName: node + linkType: hard + +"@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25": + version: 0.3.31 + resolution: "@jridgewell/trace-mapping@npm:0.3.31" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10c0/4b30ec8cd56c5fd9a661f088230af01e0c1a3888d11ffb6b47639700f71225be21d1f7e168048d6d4f9449207b978a235c07c8f15c07705685d16dc06280e9d9 + languageName: node + linkType: hard + +"@types/estree@npm:^1.0.5": + version: 1.0.8 + resolution: "@types/estree@npm:1.0.8" + checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5 + languageName: node + linkType: hard + +"@types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9": + version: 7.0.15 + resolution: "@types/json-schema@npm:7.0.15" + checksum: 10c0/a996a745e6c5d60292f36731dd41341339d4eeed8180bb09226e5c8d23759067692b1d88e5d91d72ee83dfc00d3aca8e7bd43ea120516c17922cbcb7c3e252db + languageName: node + linkType: hard + +"@types/node@npm:*": + version: 24.10.2 + resolution: "@types/node@npm:24.10.2" + dependencies: + undici-types: "npm:~7.16.0" + checksum: 10c0/560c894e1a9bf7468718ceca8cd520361fd0d3fcc0b020c2f028fc722b28b5b56aecd16736a9b753d52a14837c066cf23480a8582ead59adc63a7e4333bc976c + languageName: node + linkType: hard + +"@webassemblyjs/ast@npm:1.14.1, @webassemblyjs/ast@npm:^1.12.1": + version: 1.14.1 + resolution: "@webassemblyjs/ast@npm:1.14.1" + dependencies: + "@webassemblyjs/helper-numbers": "npm:1.13.2" + "@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2" + checksum: 10c0/67a59be8ed50ddd33fbb2e09daa5193ac215bf7f40a9371be9a0d9797a114d0d1196316d2f3943efdb923a3d809175e1563a3cb80c814fb8edccd1e77494972b + languageName: node + linkType: hard + +"@webassemblyjs/floating-point-hex-parser@npm:1.13.2": + version: 1.13.2 + resolution: "@webassemblyjs/floating-point-hex-parser@npm:1.13.2" + checksum: 10c0/0e88bdb8b50507d9938be64df0867f00396b55eba9df7d3546eb5dc0ca64d62e06f8d881ec4a6153f2127d0f4c11d102b6e7d17aec2f26bb5ff95a5e60652412 + languageName: node + linkType: hard + +"@webassemblyjs/helper-api-error@npm:1.13.2": + version: 1.13.2 + resolution: "@webassemblyjs/helper-api-error@npm:1.13.2" + checksum: 10c0/31be497f996ed30aae4c08cac3cce50c8dcd5b29660383c0155fce1753804fc55d47fcba74e10141c7dd2899033164e117b3bcfcda23a6b043e4ded4f1003dfb + languageName: node + linkType: hard + +"@webassemblyjs/helper-buffer@npm:1.14.1": + version: 1.14.1 + resolution: "@webassemblyjs/helper-buffer@npm:1.14.1" + checksum: 10c0/0d54105dc373c0fe6287f1091e41e3a02e36cdc05e8cf8533cdc16c59ff05a646355415893449d3768cda588af451c274f13263300a251dc11a575bc4c9bd210 + languageName: node + linkType: hard + +"@webassemblyjs/helper-numbers@npm:1.13.2": + version: 1.13.2 + resolution: "@webassemblyjs/helper-numbers@npm:1.13.2" + dependencies: + "@webassemblyjs/floating-point-hex-parser": "npm:1.13.2" + "@webassemblyjs/helper-api-error": "npm:1.13.2" + "@xtuc/long": "npm:4.2.2" + checksum: 10c0/9c46852f31b234a8fb5a5a9d3f027bc542392a0d4de32f1a9c0075d5e8684aa073cb5929b56df565500b3f9cc0a2ab983b650314295b9bf208d1a1651bfc825a + languageName: node + linkType: hard + +"@webassemblyjs/helper-wasm-bytecode@npm:1.13.2": + version: 1.13.2 + resolution: "@webassemblyjs/helper-wasm-bytecode@npm:1.13.2" + checksum: 10c0/c4355d14f369b30cf3cbdd3acfafc7d0488e086be6d578e3c9780bd1b512932352246be96e034e2a7fcfba4f540ec813352f312bfcbbfe5bcfbf694f82ccc682 + languageName: node + linkType: hard + +"@webassemblyjs/helper-wasm-section@npm:1.14.1": + version: 1.14.1 + resolution: "@webassemblyjs/helper-wasm-section@npm:1.14.1" + dependencies: + "@webassemblyjs/ast": "npm:1.14.1" + "@webassemblyjs/helper-buffer": "npm:1.14.1" + "@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2" + "@webassemblyjs/wasm-gen": "npm:1.14.1" + checksum: 10c0/1f9b33731c3c6dbac3a9c483269562fa00d1b6a4e7133217f40e83e975e636fd0f8736e53abd9a47b06b66082ecc976c7384391ab0a68e12d509ea4e4b948d64 + languageName: node + linkType: hard + +"@webassemblyjs/ieee754@npm:1.13.2": + version: 1.13.2 + resolution: "@webassemblyjs/ieee754@npm:1.13.2" + dependencies: + "@xtuc/ieee754": "npm:^1.2.0" + checksum: 10c0/2e732ca78c6fbae3c9b112f4915d85caecdab285c0b337954b180460290ccd0fb00d2b1dc4bb69df3504abead5191e0d28d0d17dfd6c9d2f30acac8c4961c8a7 + languageName: node + linkType: hard + +"@webassemblyjs/leb128@npm:1.13.2": + version: 1.13.2 + resolution: "@webassemblyjs/leb128@npm:1.13.2" + dependencies: + "@xtuc/long": "npm:4.2.2" + checksum: 10c0/dad5ef9e383c8ab523ce432dfd80098384bf01c45f70eb179d594f85ce5db2f80fa8c9cba03adafd85684e6d6310f0d3969a882538975989919329ac4c984659 + languageName: node + linkType: hard + +"@webassemblyjs/utf8@npm:1.13.2": + version: 1.13.2 + resolution: "@webassemblyjs/utf8@npm:1.13.2" + checksum: 10c0/d3fac9130b0e3e5a1a7f2886124a278e9323827c87a2b971e6d0da22a2ba1278ac9f66a4f2e363ecd9fac8da42e6941b22df061a119e5c0335f81006de9ee799 + languageName: node + linkType: hard + +"@webassemblyjs/wasm-edit@npm:^1.12.1": + version: 1.14.1 + resolution: "@webassemblyjs/wasm-edit@npm:1.14.1" + dependencies: + "@webassemblyjs/ast": "npm:1.14.1" + "@webassemblyjs/helper-buffer": "npm:1.14.1" + "@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2" + "@webassemblyjs/helper-wasm-section": "npm:1.14.1" + "@webassemblyjs/wasm-gen": "npm:1.14.1" + "@webassemblyjs/wasm-opt": "npm:1.14.1" + "@webassemblyjs/wasm-parser": "npm:1.14.1" + "@webassemblyjs/wast-printer": "npm:1.14.1" + checksum: 10c0/5ac4781086a2ca4b320bdbfd965a209655fe8a208ca38d89197148f8597e587c9a2c94fb6bd6f1a7dbd4527c49c6844fcdc2af981f8d793a97bf63a016aa86d2 + languageName: node + linkType: hard + +"@webassemblyjs/wasm-gen@npm:1.14.1": + version: 1.14.1 + resolution: "@webassemblyjs/wasm-gen@npm:1.14.1" + dependencies: + "@webassemblyjs/ast": "npm:1.14.1" + "@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2" + "@webassemblyjs/ieee754": "npm:1.13.2" + "@webassemblyjs/leb128": "npm:1.13.2" + "@webassemblyjs/utf8": "npm:1.13.2" + checksum: 10c0/d678810d7f3f8fecb2e2bdadfb9afad2ec1d2bc79f59e4711ab49c81cec578371e22732d4966f59067abe5fba8e9c54923b57060a729d28d408e608beef67b10 + languageName: node + linkType: hard + +"@webassemblyjs/wasm-opt@npm:1.14.1": + version: 1.14.1 + resolution: "@webassemblyjs/wasm-opt@npm:1.14.1" + dependencies: + "@webassemblyjs/ast": "npm:1.14.1" + "@webassemblyjs/helper-buffer": "npm:1.14.1" + "@webassemblyjs/wasm-gen": "npm:1.14.1" + "@webassemblyjs/wasm-parser": "npm:1.14.1" + checksum: 10c0/515bfb15277ee99ba6b11d2232ddbf22aed32aad6d0956fe8a0a0a004a1b5a3a277a71d9a3a38365d0538ac40d1b7b7243b1a244ad6cd6dece1c1bb2eb5de7ee + languageName: node + linkType: hard + +"@webassemblyjs/wasm-parser@npm:1.14.1, @webassemblyjs/wasm-parser@npm:^1.12.1": + version: 1.14.1 + resolution: "@webassemblyjs/wasm-parser@npm:1.14.1" + dependencies: + "@webassemblyjs/ast": "npm:1.14.1" + "@webassemblyjs/helper-api-error": "npm:1.13.2" + "@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2" + "@webassemblyjs/ieee754": "npm:1.13.2" + "@webassemblyjs/leb128": "npm:1.13.2" + "@webassemblyjs/utf8": "npm:1.13.2" + checksum: 10c0/95427b9e5addbd0f647939bd28e3e06b8deefdbdadcf892385b5edc70091bf9b92fa5faac3fce8333554437c5d85835afef8c8a7d9d27ab6ba01ffab954db8c6 + languageName: node + linkType: hard + +"@webassemblyjs/wast-printer@npm:1.14.1": + version: 1.14.1 + resolution: "@webassemblyjs/wast-printer@npm:1.14.1" + dependencies: + "@webassemblyjs/ast": "npm:1.14.1" + "@xtuc/long": "npm:4.2.2" + checksum: 10c0/8d7768608996a052545251e896eac079c98e0401842af8dd4de78fba8d90bd505efb6c537e909cd6dae96e09db3fa2e765a6f26492553a675da56e2db51f9d24 + languageName: node + linkType: hard + +"@xtuc/ieee754@npm:^1.2.0": + version: 1.2.0 + resolution: "@xtuc/ieee754@npm:1.2.0" + checksum: 10c0/a8565d29d135039bd99ae4b2220d3e167d22cf53f867e491ed479b3f84f895742d0097f935b19aab90265a23d5d46711e4204f14c479ae3637fbf06c4666882f + languageName: node + linkType: hard + +"@xtuc/long@npm:4.2.2": + version: 4.2.2 + resolution: "@xtuc/long@npm:4.2.2" + checksum: 10c0/8582cbc69c79ad2d31568c412129bf23d2b1210a1dfb60c82d5a1df93334da4ee51f3057051658569e2c196d8dc33bc05ae6b974a711d0d16e801e1d0647ccd1 + languageName: node + linkType: hard + +"acorn-import-attributes@npm:^1.9.5": + version: 1.9.5 + resolution: "acorn-import-attributes@npm:1.9.5" + peerDependencies: + acorn: ^8 + checksum: 10c0/5926eaaead2326d5a86f322ff1b617b0f698aa61dc719a5baa0e9d955c9885cc71febac3fb5bacff71bbf2c4f9c12db2056883c68c53eb962c048b952e1e013d + languageName: node + linkType: hard + +"acorn@npm:^8.15.0, acorn@npm:^8.7.1": + version: 8.15.0 + resolution: "acorn@npm:8.15.0" + bin: + acorn: bin/acorn + checksum: 10c0/dec73ff59b7d6628a01eebaece7f2bdb8bb62b9b5926dcad0f8931f2b8b79c2be21f6c68ac095592adb5adb15831a3635d9343e6a91d028bbe85d564875ec3ec + languageName: node + linkType: hard + +"ajv-formats@npm:^2.1.1": + version: 2.1.1 + resolution: "ajv-formats@npm:2.1.1" + dependencies: + ajv: "npm:^8.0.0" + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + checksum: 10c0/e43ba22e91b6a48d96224b83d260d3a3a561b42d391f8d3c6d2c1559f9aa5b253bfb306bc94bbeca1d967c014e15a6efe9a207309e95b3eaae07fcbcdc2af662 + languageName: node + linkType: hard + +"ajv-keywords@npm:^3.5.2": + version: 3.5.2 + resolution: "ajv-keywords@npm:3.5.2" + peerDependencies: + ajv: ^6.9.1 + checksum: 10c0/0c57a47cbd656e8cdfd99d7c2264de5868918ffa207c8d7a72a7f63379d4333254b2ba03d69e3c035e996a3fd3eb6d5725d7a1597cca10694296e32510546360 + languageName: node + linkType: hard + +"ajv-keywords@npm:^5.1.0": + version: 5.1.0 + resolution: "ajv-keywords@npm:5.1.0" + dependencies: + fast-deep-equal: "npm:^3.1.3" + peerDependencies: + ajv: ^8.8.2 + checksum: 10c0/18bec51f0171b83123ba1d8883c126e60c6f420cef885250898bf77a8d3e65e3bfb9e8564f497e30bdbe762a83e0d144a36931328616a973ee669dc74d4a9590 + languageName: node + linkType: hard + +"ajv@npm:^6.12.5": + version: 6.12.6 + resolution: "ajv@npm:6.12.6" + dependencies: + fast-deep-equal: "npm:^3.1.1" + fast-json-stable-stringify: "npm:^2.0.0" + json-schema-traverse: "npm:^0.4.1" + uri-js: "npm:^4.2.2" + checksum: 10c0/41e23642cbe545889245b9d2a45854ebba51cda6c778ebced9649420d9205f2efb39cb43dbc41e358409223b1ea43303ae4839db682c848b891e4811da1a5a71 + languageName: node + linkType: hard + +"ajv@npm:^8.0.0, ajv@npm:^8.9.0": + version: 8.17.1 + resolution: "ajv@npm:8.17.1" + dependencies: + fast-deep-equal: "npm:^3.1.3" + fast-uri: "npm:^3.0.1" + json-schema-traverse: "npm:^1.0.0" + require-from-string: "npm:^2.0.2" + checksum: 10c0/ec3ba10a573c6b60f94639ffc53526275917a2df6810e4ab5a6b959d87459f9ef3f00d5e7865b82677cb7d21590355b34da14d1d0b9c32d75f95a187e76fff35 + languageName: node + linkType: hard + +"ansi-styles@npm:^3.2.1": + version: 3.2.1 + resolution: "ansi-styles@npm:3.2.1" + dependencies: + color-convert: "npm:^1.9.0" + checksum: 10c0/ece5a8ef069fcc5298f67e3f4771a663129abd174ea2dfa87923a2be2abf6cd367ef72ac87942da00ce85bd1d651d4cd8595aebdb1b385889b89b205860e977b + languageName: node + linkType: hard + +"baseline-browser-mapping@npm:^2.9.0": + version: 2.9.5 + resolution: "baseline-browser-mapping@npm:2.9.5" + bin: + baseline-browser-mapping: dist/cli.js + checksum: 10c0/581bd4f578a1b5ccd48ad0155f760c412803f02e97259deec99150b87c95766266b607b09aefa65da96dc5b925165706d675f10413630e767391e26a4f7afcdf + languageName: node + linkType: hard + +"big.js@npm:^5.2.2": + version: 5.2.2 + resolution: "big.js@npm:5.2.2" + checksum: 10c0/230520f1ff920b2d2ce3e372d77a33faa4fa60d802fe01ca4ffbc321ee06023fe9a741ac02793ee778040a16b7e497f7d60c504d1c402b8fdab6f03bb785a25f + languageName: node + linkType: hard + +"braces@npm:^3.0.3": + version: 3.0.3 + resolution: "braces@npm:3.0.3" + dependencies: + fill-range: "npm:^7.1.1" + checksum: 10c0/7c6dfd30c338d2997ba77500539227b9d1f85e388a5f43220865201e407e076783d0881f2d297b9f80951b4c957fcf0b51c1d2d24227631643c3f7c284b0aa04 + languageName: node + linkType: hard + +"browserslist@npm:^4.21.10": + version: 4.28.1 + resolution: "browserslist@npm:4.28.1" + dependencies: + baseline-browser-mapping: "npm:^2.9.0" + caniuse-lite: "npm:^1.0.30001759" + electron-to-chromium: "npm:^1.5.263" + node-releases: "npm:^2.0.27" + update-browserslist-db: "npm:^1.2.0" + bin: + browserslist: cli.js + checksum: 10c0/545a5fa9d7234e3777a7177ec1e9134bb2ba60a69e6b95683f6982b1473aad347c77c1264ccf2ac5dea609a9731fbfbda6b85782bdca70f80f86e28a402504bd + languageName: node + linkType: hard + +"buffer-from@npm:^1.0.0": + version: 1.1.2 + resolution: "buffer-from@npm:1.1.2" + checksum: 10c0/124fff9d66d691a86d3b062eff4663fe437a9d9ee4b47b1b9e97f5a5d14f6d5399345db80f796827be7c95e70a8e765dd404b7c3ff3b3324f98e9b0c8826cc34 + languageName: node + linkType: hard + +"caniuse-lite@npm:^1.0.30001759": + version: 1.0.30001760 + resolution: "caniuse-lite@npm:1.0.30001760" + checksum: 10c0/cee26dff5c5b15ba073ab230200e43c0d4e88dc3bac0afe0c9ab963df70aaa876c3e513dde42a027f317136bf6e274818d77b073708b74c5807dfad33c029d3c + languageName: node + linkType: hard + +"chalk@npm:^2.3.0": + version: 2.4.2 + resolution: "chalk@npm:2.4.2" + dependencies: + ansi-styles: "npm:^3.2.1" + escape-string-regexp: "npm:^1.0.5" + supports-color: "npm:^5.3.0" + checksum: 10c0/e6543f02ec877732e3a2d1c3c3323ddb4d39fbab687c23f526e25bd4c6a9bf3b83a696e8c769d078e04e5754921648f7821b2a2acfd16c550435fd630026e073 + languageName: node + linkType: hard + +"chrome-trace-event@npm:^1.0.2": + version: 1.0.4 + resolution: "chrome-trace-event@npm:1.0.4" + checksum: 10c0/3058da7a5f4934b87cf6a90ef5fb68ebc5f7d06f143ed5a4650208e5d7acae47bc03ec844b29fbf5ba7e46e8daa6acecc878f7983a4f4bb7271593da91e61ff5 + languageName: node + linkType: hard + +"color-convert@npm:^1.9.0": + version: 1.9.3 + resolution: "color-convert@npm:1.9.3" + dependencies: + color-name: "npm:1.1.3" + checksum: 10c0/5ad3c534949a8c68fca8fbc6f09068f435f0ad290ab8b2f76841b9e6af7e0bb57b98cb05b0e19fe33f5d91e5a8611ad457e5f69e0a484caad1f7487fd0e8253c + languageName: node + linkType: hard + +"color-name@npm:1.1.3": + version: 1.1.3 + resolution: "color-name@npm:1.1.3" + checksum: 10c0/566a3d42cca25b9b3cd5528cd7754b8e89c0eb646b7f214e8e2eaddb69994ac5f0557d9c175eb5d8f0ad73531140d9c47525085ee752a91a2ab15ab459caf6d6 + languageName: node + linkType: hard + +"commander@npm:^2.20.0": + version: 2.20.3 + resolution: "commander@npm:2.20.3" + checksum: 10c0/74c781a5248c2402a0a3e966a0a2bba3c054aad144f5c023364be83265e796b20565aa9feff624132ff629aa64e16999fa40a743c10c12f7c61e96a794b99288 + languageName: node + linkType: hard + +"core-util-is@npm:~1.0.0": + version: 1.0.3 + resolution: "core-util-is@npm:1.0.3" + checksum: 10c0/90a0e40abbddfd7618f8ccd63a74d88deea94e77d0e8dbbea059fa7ebebb8fbb4e2909667fe26f3a467073de1a542ebe6ae4c73a73745ac5833786759cd906c9 + languageName: node + linkType: hard + +"electron-to-chromium@npm:^1.5.263": + version: 1.5.267 + resolution: "electron-to-chromium@npm:1.5.267" + checksum: 10c0/0732bdb891b657f2e43266a3db8cf86fff6cecdcc8d693a92beff214e136cb5c2ee7dc5945ed75fa1db16e16bad0c38695527a020d15f39e79084e0b2e447621 + languageName: node + linkType: hard + +"emojis-list@npm:^3.0.0": + version: 3.0.0 + resolution: "emojis-list@npm:3.0.0" + checksum: 10c0/7dc4394b7b910444910ad64b812392159a21e1a7ecc637c775a440227dcb4f80eff7fe61f4453a7d7603fa23d23d30cc93fe9e4b5ed985b88d6441cd4a35117b + languageName: node + linkType: hard + +"enhanced-resolve@npm:^4.0.0": + version: 4.5.0 + resolution: "enhanced-resolve@npm:4.5.0" + dependencies: + graceful-fs: "npm:^4.1.2" + memory-fs: "npm:^0.5.0" + tapable: "npm:^1.0.0" + checksum: 10c0/d95fc630606ea35bed21c4a029bbb1681919571a2d1d2011c7fc42a26a9e48ed3d74a89949ce331e1fd3229850a303e3218b887b92951330f16bdfbb93a10e64 + languageName: node + linkType: hard + +"enhanced-resolve@npm:^5.17.1": + version: 5.18.3 + resolution: "enhanced-resolve@npm:5.18.3" + dependencies: + graceful-fs: "npm:^4.2.4" + tapable: "npm:^2.2.0" + checksum: 10c0/d413c23c2d494e4c1c9c9ac7d60b812083dc6d446699ed495e69c920988af0a3c66bf3f8d0e7a45cb1686c2d4c1df9f4e7352d973f5b56fe63d8d711dd0ccc54 + languageName: node + linkType: hard + +"errno@npm:^0.1.3": + version: 0.1.8 + resolution: "errno@npm:0.1.8" + dependencies: + prr: "npm:~1.0.1" + bin: + errno: cli.js + checksum: 10c0/83758951967ec57bf00b5f5b7dc797e6d65a6171e57ea57adcf1bd1a0b477fd9b5b35fae5be1ff18f4090ed156bce1db749fe7e317aac19d485a5d150f6a4936 + languageName: node + linkType: hard + +"es-module-lexer@npm:^1.2.1": + version: 1.7.0 + resolution: "es-module-lexer@npm:1.7.0" + checksum: 10c0/4c935affcbfeba7fb4533e1da10fa8568043df1e3574b869385980de9e2d475ddc36769891936dbb07036edb3c3786a8b78ccf44964cd130dedc1f2c984b6c7b + languageName: node + linkType: hard + +"escalade@npm:^3.2.0": + version: 3.2.0 + resolution: "escalade@npm:3.2.0" + checksum: 10c0/ced4dd3a78e15897ed3be74e635110bbf3b08877b0a41be50dcb325ee0e0b5f65fc2d50e9845194d7c4633f327e2e1c6cce00a71b617c5673df0374201d67f65 + languageName: node + linkType: hard + +"escape-string-regexp@npm:^1.0.5": + version: 1.0.5 + resolution: "escape-string-regexp@npm:1.0.5" + checksum: 10c0/a968ad453dd0c2724e14a4f20e177aaf32bb384ab41b674a8454afe9a41c5e6fe8903323e0a1052f56289d04bd600f81278edf140b0fcc02f5cac98d0f5b5371 + languageName: node + linkType: hard + +"eslint-scope@npm:5.1.1": + version: 5.1.1 + resolution: "eslint-scope@npm:5.1.1" + dependencies: + esrecurse: "npm:^4.3.0" + estraverse: "npm:^4.1.1" + checksum: 10c0/d30ef9dc1c1cbdece34db1539a4933fe3f9b14e1ffb27ecc85987902ee663ad7c9473bbd49a9a03195a373741e62e2f807c4938992e019b511993d163450e70a + languageName: node + linkType: hard + +"esrecurse@npm:^4.3.0": + version: 4.3.0 + resolution: "esrecurse@npm:4.3.0" + dependencies: + estraverse: "npm:^5.2.0" + checksum: 10c0/81a37116d1408ded88ada45b9fb16dbd26fba3aadc369ce50fcaf82a0bac12772ebd7b24cd7b91fc66786bf2c1ac7b5f196bc990a473efff972f5cb338877cf5 + languageName: node + linkType: hard + +"estraverse@npm:^4.1.1": + version: 4.3.0 + resolution: "estraverse@npm:4.3.0" + checksum: 10c0/9cb46463ef8a8a4905d3708a652d60122a0c20bb58dec7e0e12ab0e7235123d74214fc0141d743c381813e1b992767e2708194f6f6e0f9fd00c1b4e0887b8b6d + languageName: node + linkType: hard + +"estraverse@npm:^5.2.0": + version: 5.3.0 + resolution: "estraverse@npm:5.3.0" + checksum: 10c0/1ff9447b96263dec95d6d67431c5e0771eb9776427421260a3e2f0fdd5d6bd4f8e37a7338f5ad2880c9f143450c9b1e4fc2069060724570a49cf9cf0312bd107 + languageName: node + linkType: hard + +"events@npm:^3.2.0": + version: 3.3.0 + resolution: "events@npm:3.3.0" + checksum: 10c0/d6b6f2adbccbcda74ddbab52ed07db727ef52e31a61ed26db9feb7dc62af7fc8e060defa65e5f8af9449b86b52cc1a1f6a79f2eafcf4e62add2b7a1fa4a432f6 + languageName: node + linkType: hard + +"fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": + version: 3.1.3 + resolution: "fast-deep-equal@npm:3.1.3" + checksum: 10c0/40dedc862eb8992c54579c66d914635afbec43350afbbe991235fdcb4e3a8d5af1b23ae7e79bef7d4882d0ecee06c3197488026998fb19f72dc95acff1d1b1d0 + languageName: node + linkType: hard + +"fast-json-stable-stringify@npm:^2.0.0": + version: 2.1.0 + resolution: "fast-json-stable-stringify@npm:2.1.0" + checksum: 10c0/7f081eb0b8a64e0057b3bb03f974b3ef00135fbf36c1c710895cd9300f13c94ba809bb3a81cf4e1b03f6e5285610a61abbd7602d0652de423144dfee5a389c9b + languageName: node + linkType: hard + +"fast-uri@npm:^3.0.1": + version: 3.1.0 + resolution: "fast-uri@npm:3.1.0" + checksum: 10c0/44364adca566f70f40d1e9b772c923138d47efeac2ae9732a872baafd77061f26b097ba2f68f0892885ad177becd065520412b8ffeec34b16c99433c5b9e2de7 + languageName: node + linkType: hard + +"fill-range@npm:^7.1.1": + version: 7.1.1 + resolution: "fill-range@npm:7.1.1" + dependencies: + to-regex-range: "npm:^5.0.1" + checksum: 10c0/b75b691bbe065472f38824f694c2f7449d7f5004aa950426a2c28f0306c60db9b880c0b0e4ed819997ffb882d1da02cfcfc819bddc94d71627f5269682edf018 + languageName: node + linkType: hard + +"glob-to-regexp@npm:^0.4.1": + version: 0.4.1 + resolution: "glob-to-regexp@npm:0.4.1" + checksum: 10c0/0486925072d7a916f052842772b61c3e86247f0a80cc0deb9b5a3e8a1a9faad5b04fb6f58986a09f34d3e96cd2a22a24b7e9882fb1cf904c31e9a310de96c429 + languageName: node + linkType: hard + +"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4": + version: 4.2.11 + resolution: "graceful-fs@npm:4.2.11" + checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 + languageName: node + linkType: hard + +"has-flag@npm:^3.0.0": + version: 3.0.0 + resolution: "has-flag@npm:3.0.0" + checksum: 10c0/1c6c83b14b8b1b3c25b0727b8ba3e3b647f99e9e6e13eb7322107261de07a4c1be56fc0d45678fc376e09772a3a1642ccdaf8fc69bdf123b6c086598397ce473 + languageName: node + linkType: hard + +"has-flag@npm:^4.0.0": + version: 4.0.0 + resolution: "has-flag@npm:4.0.0" + checksum: 10c0/2e789c61b7888d66993e14e8331449e525ef42aac53c627cc53d1c3334e768bcb6abdc4f5f0de1478a25beec6f0bd62c7549058b7ac53e924040d4f301f02fd1 + languageName: node + linkType: hard + +"inherits@npm:~2.0.3": + version: 2.0.4 + resolution: "inherits@npm:2.0.4" + checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 + languageName: node + linkType: hard + +"instrumentation-overhead-app@workspace:.": + version: 0.0.0-use.local + resolution: "instrumentation-overhead-app@workspace:." + dependencies: + "@datadog/browser-debugger": "file:../../../packages/debugger/package.tgz" + ts-loader: "npm:6.2.1" + typescript: "npm:3.8.2" + webpack: "npm:5.94.0" + languageName: unknown + linkType: soft + +"is-number@npm:^7.0.0": + version: 7.0.0 + resolution: "is-number@npm:7.0.0" + checksum: 10c0/b4686d0d3053146095ccd45346461bc8e53b80aeb7671cc52a4de02dbbf7dc0d1d2a986e2fe4ae206984b4d34ef37e8b795ebc4f4295c978373e6575e295d811 + languageName: node + linkType: hard + +"isarray@npm:~1.0.0": + version: 1.0.0 + resolution: "isarray@npm:1.0.0" + checksum: 10c0/18b5be6669be53425f0b84098732670ed4e727e3af33bc7f948aac01782110eb9a18b3b329c5323bcdd3acdaae547ee077d3951317e7f133bff7105264b3003d + languageName: node + linkType: hard + +"jest-worker@npm:^27.4.5": + version: 27.5.1 + resolution: "jest-worker@npm:27.5.1" + dependencies: + "@types/node": "npm:*" + merge-stream: "npm:^2.0.0" + supports-color: "npm:^8.0.0" + checksum: 10c0/8c4737ffd03887b3c6768e4cc3ca0269c0336c1e4b1b120943958ddb035ed2a0fc6acab6dc99631720a3720af4e708ff84fb45382ad1e83c27946adf3623969b + languageName: node + linkType: hard + +"json-parse-even-better-errors@npm:^2.3.1": + version: 2.3.1 + resolution: "json-parse-even-better-errors@npm:2.3.1" + checksum: 10c0/140932564c8f0b88455432e0f33c4cb4086b8868e37524e07e723f4eaedb9425bdc2bafd71bd1d9765bd15fd1e2d126972bc83990f55c467168c228c24d665f3 + languageName: node + linkType: hard + +"json-schema-traverse@npm:^0.4.1": + version: 0.4.1 + resolution: "json-schema-traverse@npm:0.4.1" + checksum: 10c0/108fa90d4cc6f08243aedc6da16c408daf81793bf903e9fd5ab21983cda433d5d2da49e40711da016289465ec2e62e0324dcdfbc06275a607fe3233fde4942ce + languageName: node + linkType: hard + +"json-schema-traverse@npm:^1.0.0": + version: 1.0.0 + resolution: "json-schema-traverse@npm:1.0.0" + checksum: 10c0/71e30015d7f3d6dc1c316d6298047c8ef98a06d31ad064919976583eb61e1018a60a0067338f0f79cabc00d84af3fcc489bd48ce8a46ea165d9541ba17fb30c6 + languageName: node + linkType: hard + +"json5@npm:^1.0.1": + version: 1.0.2 + resolution: "json5@npm:1.0.2" + dependencies: + minimist: "npm:^1.2.0" + bin: + json5: lib/cli.js + checksum: 10c0/9ee316bf21f000b00752e6c2a3b79ecf5324515a5c60ee88983a1910a45426b643a4f3461657586e8aeca87aaf96f0a519b0516d2ae527a6c3e7eed80f68717f + languageName: node + linkType: hard + +"loader-runner@npm:^4.2.0": + version: 4.3.1 + resolution: "loader-runner@npm:4.3.1" + checksum: 10c0/a523b6329f114e0a98317158e30a7dfce044b731521be5399464010472a93a15ece44757d1eaed1d8845019869c5390218bc1c7c3110f4eeaef5157394486eac + languageName: node + linkType: hard + +"loader-utils@npm:^1.0.2": + version: 1.4.2 + resolution: "loader-utils@npm:1.4.2" + dependencies: + big.js: "npm:^5.2.2" + emojis-list: "npm:^3.0.0" + json5: "npm:^1.0.1" + checksum: 10c0/2b726088b5526f7605615e3e28043ae9bbd2453f4a85898e1151f3c39dbf7a2b65d09f3996bc588d92ac7e717ded529d3e1ea3ea42c433393be84a58234a2f53 + languageName: node + linkType: hard + +"memory-fs@npm:^0.5.0": + version: 0.5.0 + resolution: "memory-fs@npm:0.5.0" + dependencies: + errno: "npm:^0.1.3" + readable-stream: "npm:^2.0.1" + checksum: 10c0/2737a27b14a9e8b8cd757be2ad99e8cc504b78a78aba9d6aa18ff1ef528e2223a433413d2df6ab5332997a5a8ccf075e6c6e90e31ab732a55455ca620e4a720b + languageName: node + linkType: hard + +"merge-stream@npm:^2.0.0": + version: 2.0.0 + resolution: "merge-stream@npm:2.0.0" + checksum: 10c0/867fdbb30a6d58b011449b8885601ec1690c3e41c759ecd5a9d609094f7aed0096c37823ff4a7190ef0b8f22cc86beb7049196ff68c016e3b3c671d0dac91ce5 + languageName: node + linkType: hard + +"micromatch@npm:^4.0.0": + version: 4.0.8 + resolution: "micromatch@npm:4.0.8" + dependencies: + braces: "npm:^3.0.3" + picomatch: "npm:^2.3.1" + checksum: 10c0/166fa6eb926b9553f32ef81f5f531d27b4ce7da60e5baf8c021d043b27a388fb95e46a8038d5045877881e673f8134122b59624d5cecbd16eb50a42e7a6b5ca8 + languageName: node + linkType: hard + +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa + languageName: node + linkType: hard + +"mime-types@npm:^2.1.27": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: "npm:1.52.0" + checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2 + languageName: node + linkType: hard + +"minimist@npm:^1.2.0": + version: 1.2.8 + resolution: "minimist@npm:1.2.8" + checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6 + languageName: node + linkType: hard + +"neo-async@npm:^2.6.2": + version: 2.6.2 + resolution: "neo-async@npm:2.6.2" + checksum: 10c0/c2f5a604a54a8ec5438a342e1f356dff4bc33ccccdb6dc668d94fe8e5eccfc9d2c2eea6064b0967a767ba63b33763f51ccf2cd2441b461a7322656c1f06b3f5d + languageName: node + linkType: hard + +"node-releases@npm:^2.0.27": + version: 2.0.27 + resolution: "node-releases@npm:2.0.27" + checksum: 10c0/f1e6583b7833ea81880627748d28a3a7ff5703d5409328c216ae57befbced10ce2c991bea86434e8ec39003bd017f70481e2e5f8c1f7e0a7663241f81d6e00e2 + languageName: node + linkType: hard + +"picocolors@npm:^1.1.1": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 + languageName: node + linkType: hard + +"picomatch@npm:^2.3.1": + version: 2.3.1 + resolution: "picomatch@npm:2.3.1" + checksum: 10c0/26c02b8d06f03206fc2ab8d16f19960f2ff9e81a658f831ecb656d8f17d9edc799e8364b1f4a7873e89d9702dff96204be0fa26fe4181f6843f040f819dac4be + languageName: node + linkType: hard + +"process-nextick-args@npm:~2.0.0": + version: 2.0.1 + resolution: "process-nextick-args@npm:2.0.1" + checksum: 10c0/bec089239487833d46b59d80327a1605e1c5287eaad770a291add7f45fda1bb5e28b38e0e061add0a1d0ee0984788ce74fa394d345eed1c420cacf392c554367 + languageName: node + linkType: hard + +"prr@npm:~1.0.1": + version: 1.0.1 + resolution: "prr@npm:1.0.1" + checksum: 10c0/5b9272c602e4f4472a215e58daff88f802923b84bc39c8860376bb1c0e42aaf18c25d69ad974bd06ec6db6f544b783edecd5502cd3d184748d99080d68e4be5f + languageName: node + linkType: hard + +"punycode@npm:^2.1.0": + version: 2.3.1 + resolution: "punycode@npm:2.3.1" + checksum: 10c0/14f76a8206bc3464f794fb2e3d3cc665ae416c01893ad7a02b23766eb07159144ee612ad67af5e84fa4479ccfe67678c4feb126b0485651b302babf66f04f9e9 + languageName: node + linkType: hard + +"randombytes@npm:^2.1.0": + version: 2.1.0 + resolution: "randombytes@npm:2.1.0" + dependencies: + safe-buffer: "npm:^5.1.0" + checksum: 10c0/50395efda7a8c94f5dffab564f9ff89736064d32addf0cc7e8bf5e4166f09f8ded7a0849ca6c2d2a59478f7d90f78f20d8048bca3cdf8be09d8e8a10790388f3 + languageName: node + linkType: hard + +"readable-stream@npm:^2.0.1": + version: 2.3.8 + resolution: "readable-stream@npm:2.3.8" + dependencies: + core-util-is: "npm:~1.0.0" + inherits: "npm:~2.0.3" + isarray: "npm:~1.0.0" + process-nextick-args: "npm:~2.0.0" + safe-buffer: "npm:~5.1.1" + string_decoder: "npm:~1.1.1" + util-deprecate: "npm:~1.0.1" + checksum: 10c0/7efdb01f3853bc35ac62ea25493567bf588773213f5f4a79f9c365e1ad13bab845ac0dae7bc946270dc40c3929483228415e92a3fc600cc7e4548992f41ee3fa + languageName: node + linkType: hard + +"require-from-string@npm:^2.0.2": + version: 2.0.2 + resolution: "require-from-string@npm:2.0.2" + checksum: 10c0/aaa267e0c5b022fc5fd4eef49d8285086b15f2a1c54b28240fdf03599cbd9c26049fee3eab894f2e1f6ca65e513b030a7c264201e3f005601e80c49fb2937ce2 + languageName: node + linkType: hard + +"safe-buffer@npm:^5.1.0": + version: 5.2.1 + resolution: "safe-buffer@npm:5.2.1" + checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 + languageName: node + linkType: hard + +"safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": + version: 5.1.2 + resolution: "safe-buffer@npm:5.1.2" + checksum: 10c0/780ba6b5d99cc9a40f7b951d47152297d0e260f0df01472a1b99d4889679a4b94a13d644f7dbc4f022572f09ae9005fa2fbb93bbbd83643316f365a3e9a45b21 + languageName: node + linkType: hard + +"schema-utils@npm:^3.2.0": + version: 3.3.0 + resolution: "schema-utils@npm:3.3.0" + dependencies: + "@types/json-schema": "npm:^7.0.8" + ajv: "npm:^6.12.5" + ajv-keywords: "npm:^3.5.2" + checksum: 10c0/fafdbde91ad8aa1316bc543d4b61e65ea86970aebbfb750bfb6d8a6c287a23e415e0e926c2498696b242f63af1aab8e585252637fabe811fd37b604351da6500 + languageName: node + linkType: hard + +"schema-utils@npm:^4.3.0": + version: 4.3.3 + resolution: "schema-utils@npm:4.3.3" + dependencies: + "@types/json-schema": "npm:^7.0.9" + ajv: "npm:^8.9.0" + ajv-formats: "npm:^2.1.1" + ajv-keywords: "npm:^5.1.0" + checksum: 10c0/1c8d2c480a026d7c02ab2ecbe5919133a096d6a721a3f201fa50663e4f30f6d6ba020dfddd93cb828b66b922e76b342e103edd19a62c95c8f60e9079cc403202 + languageName: node + linkType: hard + +"semver@npm:^6.0.0": + version: 6.3.1 + resolution: "semver@npm:6.3.1" + bin: + semver: bin/semver.js + checksum: 10c0/e3d79b609071caa78bcb6ce2ad81c7966a46a7431d9d58b8800cfa9cb6a63699b3899a0e4bcce36167a284578212d9ae6942b6929ba4aa5015c079a67751d42d + languageName: node + linkType: hard + +"serialize-javascript@npm:^6.0.2": + version: 6.0.2 + resolution: "serialize-javascript@npm:6.0.2" + dependencies: + randombytes: "npm:^2.1.0" + checksum: 10c0/2dd09ef4b65a1289ba24a788b1423a035581bef60817bea1f01eda8e3bda623f86357665fe7ac1b50f6d4f583f97db9615b3f07b2a2e8cbcb75033965f771dd2 + languageName: node + linkType: hard + +"source-map-support@npm:~0.5.20": + version: 0.5.21 + resolution: "source-map-support@npm:0.5.21" + dependencies: + buffer-from: "npm:^1.0.0" + source-map: "npm:^0.6.0" + checksum: 10c0/9ee09942f415e0f721d6daad3917ec1516af746a8120bba7bb56278707a37f1eb8642bde456e98454b8a885023af81a16e646869975f06afc1a711fb90484e7d + languageName: node + linkType: hard + +"source-map@npm:^0.6.0": + version: 0.6.1 + resolution: "source-map@npm:0.6.1" + checksum: 10c0/ab55398007c5e5532957cb0beee2368529618ac0ab372d789806f5718123cc4367d57de3904b4e6a4170eb5a0b0f41373066d02ca0735a0c4d75c7d328d3e011 + languageName: node + linkType: hard + +"string_decoder@npm:~1.1.1": + version: 1.1.1 + resolution: "string_decoder@npm:1.1.1" + dependencies: + safe-buffer: "npm:~5.1.0" + checksum: 10c0/b4f89f3a92fd101b5653ca3c99550e07bdf9e13b35037e9e2a1c7b47cec4e55e06ff3fc468e314a0b5e80bfbaf65c1ca5a84978764884ae9413bec1fc6ca924e + languageName: node + linkType: hard + +"supports-color@npm:^5.3.0": + version: 5.5.0 + resolution: "supports-color@npm:5.5.0" + dependencies: + has-flag: "npm:^3.0.0" + checksum: 10c0/6ae5ff319bfbb021f8a86da8ea1f8db52fac8bd4d499492e30ec17095b58af11f0c55f8577390a749b1c4dde691b6a0315dab78f5f54c9b3d83f8fb5905c1c05 + languageName: node + linkType: hard + +"supports-color@npm:^8.0.0": + version: 8.1.1 + resolution: "supports-color@npm:8.1.1" + dependencies: + has-flag: "npm:^4.0.0" + checksum: 10c0/ea1d3c275dd604c974670f63943ed9bd83623edc102430c05adb8efc56ba492746b6e95386e7831b872ec3807fd89dd8eb43f735195f37b5ec343e4234cc7e89 + languageName: node + linkType: hard + +"tapable@npm:^1.0.0": + version: 1.1.3 + resolution: "tapable@npm:1.1.3" + checksum: 10c0/c9f0265e55e45821ec672b9b9ee8a35d95bf3ea6b352199f8606a2799018e89cfe4433c554d424b31fc67c4be26b05d4f36dc3c607def416fdb2514cd63dba50 + languageName: node + linkType: hard + +"tapable@npm:^2.1.1, tapable@npm:^2.2.0": + version: 2.3.0 + resolution: "tapable@npm:2.3.0" + checksum: 10c0/cb9d67cc2c6a74dedc812ef3085d9d681edd2c1fa18e4aef57a3c0605fdbe44e6b8ea00bd9ef21bc74dd45314e39d31227aa031ebf2f5e38164df514136f2681 + languageName: node + linkType: hard + +"terser-webpack-plugin@npm:^5.3.10": + version: 5.3.15 + resolution: "terser-webpack-plugin@npm:5.3.15" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.25" + jest-worker: "npm:^27.4.5" + schema-utils: "npm:^4.3.0" + serialize-javascript: "npm:^6.0.2" + terser: "npm:^5.31.1" + peerDependencies: + webpack: ^5.1.0 + peerDependenciesMeta: + "@swc/core": + optional: true + esbuild: + optional: true + uglify-js: + optional: true + checksum: 10c0/e36493869e22b3d76aa9e8ae75802efc5f07461eec819823d5ce78c79f73de5c8c50b6da1e9674274e53fb4e60c09deacc83e9e9c74bce700b6fba74ecbaae7a + languageName: node + linkType: hard + +"terser@npm:^5.31.1": + version: 5.44.1 + resolution: "terser@npm:5.44.1" + dependencies: + "@jridgewell/source-map": "npm:^0.3.3" + acorn: "npm:^8.15.0" + commander: "npm:^2.20.0" + source-map-support: "npm:~0.5.20" + bin: + terser: bin/terser + checksum: 10c0/ee7a76692cb39b1ed22c30ff366c33ff3c977d9bb769575338ff5664676168fcba59192fb5168ef80c7cd901ef5411a1b0351261f5eaa50decf0fc71f63bde75 + languageName: node + linkType: hard + +"to-regex-range@npm:^5.0.1": + version: 5.0.1 + resolution: "to-regex-range@npm:5.0.1" + dependencies: + is-number: "npm:^7.0.0" + checksum: 10c0/487988b0a19c654ff3e1961b87f471702e708fa8a8dd02a298ef16da7206692e8552a0250e8b3e8759270f62e9d8314616f6da274734d3b558b1fc7b7724e892 + languageName: node + linkType: hard + +"ts-loader@npm:6.2.1": + version: 6.2.1 + resolution: "ts-loader@npm:6.2.1" + dependencies: + chalk: "npm:^2.3.0" + enhanced-resolve: "npm:^4.0.0" + loader-utils: "npm:^1.0.2" + micromatch: "npm:^4.0.0" + semver: "npm:^6.0.0" + peerDependencies: + typescript: "*" + checksum: 10c0/a1fcedbb73eea4a7f1d3d68085b4c715fee82778c4a1c2585064595ef58ad6e7e2861ccf833e3d8e0f7d04f9a53eca43725ec974853e740e97eba58608e8926a + languageName: node + linkType: hard + +"typescript@npm:3.8.2": + version: 3.8.2 + resolution: "typescript@npm:3.8.2" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/ed213a768a4f7c328cbb11070ac362d53c246220220d683ed720d324b8c6a18c531845de4ea785056124a486fb38593c4f310436b427843f4280d72390c494e2 + languageName: node + linkType: hard + +"typescript@patch:typescript@npm%3A3.8.2#optional!builtin": + version: 3.8.2 + resolution: "typescript@patch:typescript@npm%3A3.8.2#optional!builtin::version=3.8.2&hash=c44097" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/eba5ef2314aab52dcfce7a6af1041c861e81d1900ab32c50dd280bf283c784129334b6d839e34ca7fa07300bce29d94f6ae46323e6572ef6d79a4f536df46f62 + languageName: node + linkType: hard + +"undici-types@npm:~7.16.0": + version: 7.16.0 + resolution: "undici-types@npm:7.16.0" + checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a + languageName: node + linkType: hard + +"update-browserslist-db@npm:^1.2.0": + version: 1.2.2 + resolution: "update-browserslist-db@npm:1.2.2" + dependencies: + escalade: "npm:^3.2.0" + picocolors: "npm:^1.1.1" + peerDependencies: + browserslist: ">= 4.21.0" + bin: + update-browserslist-db: cli.js + checksum: 10c0/39c3ea08b397ffc8dc3a1c517f5c6ed5cc4179b5e185383dab9bf745879623c12062a2e6bf4f9427cc59389c7bfa0010e86858b923c1e349e32fdddd9b043bb2 + languageName: node + linkType: hard + +"uri-js@npm:^4.2.2": + version: 4.4.1 + resolution: "uri-js@npm:4.4.1" + dependencies: + punycode: "npm:^2.1.0" + checksum: 10c0/4ef57b45aa820d7ac6496e9208559986c665e49447cb072744c13b66925a362d96dd5a46c4530a6b8e203e5db5fe849369444440cb22ecfc26c679359e5dfa3c + languageName: node + linkType: hard + +"util-deprecate@npm:~1.0.1": + version: 1.0.2 + resolution: "util-deprecate@npm:1.0.2" + checksum: 10c0/41a5bdd214df2f6c3ecf8622745e4a366c4adced864bc3c833739791aeeeb1838119af7daed4ba36428114b5c67dcda034a79c882e97e43c03e66a4dd7389942 + languageName: node + linkType: hard + +"watchpack@npm:^2.4.1": + version: 2.4.4 + resolution: "watchpack@npm:2.4.4" + dependencies: + glob-to-regexp: "npm:^0.4.1" + graceful-fs: "npm:^4.1.2" + checksum: 10c0/6c0901f75ce245d33991225af915eea1c5ae4ba087f3aee2b70dd377d4cacb34bef02a48daf109da9d59b2d31ec6463d924a0d72f8618ae1643dd07b95de5275 + languageName: node + linkType: hard + +"webpack-sources@npm:^3.2.3": + version: 3.3.3 + resolution: "webpack-sources@npm:3.3.3" + checksum: 10c0/ab732f6933b513ba4d505130418995ddef6df988421fccf3289e53583c6a39e205c4a0739cee98950964552d3006604912679c736031337fb4a9d78d8576ed40 + languageName: node + linkType: hard + +"webpack@npm:5.94.0": + version: 5.94.0 + resolution: "webpack@npm:5.94.0" + dependencies: + "@types/estree": "npm:^1.0.5" + "@webassemblyjs/ast": "npm:^1.12.1" + "@webassemblyjs/wasm-edit": "npm:^1.12.1" + "@webassemblyjs/wasm-parser": "npm:^1.12.1" + acorn: "npm:^8.7.1" + acorn-import-attributes: "npm:^1.9.5" + browserslist: "npm:^4.21.10" + chrome-trace-event: "npm:^1.0.2" + enhanced-resolve: "npm:^5.17.1" + es-module-lexer: "npm:^1.2.1" + eslint-scope: "npm:5.1.1" + events: "npm:^3.2.0" + glob-to-regexp: "npm:^0.4.1" + graceful-fs: "npm:^4.2.11" + json-parse-even-better-errors: "npm:^2.3.1" + loader-runner: "npm:^4.2.0" + mime-types: "npm:^2.1.27" + neo-async: "npm:^2.6.2" + schema-utils: "npm:^3.2.0" + tapable: "npm:^2.1.1" + terser-webpack-plugin: "npm:^5.3.10" + watchpack: "npm:^2.4.1" + webpack-sources: "npm:^3.2.3" + peerDependenciesMeta: + webpack-cli: + optional: true + bin: + webpack: bin/webpack.js + checksum: 10c0/b4d1b751f634079bd177a89eef84d80fa5bb8d6fc15d72ab40fc2b9ca5167a79b56585e1a849e9e27e259803ee5c4365cb719e54af70a43c06358ec268ff4ebf + languageName: node + linkType: hard diff --git a/test/performance/createBenchmarkTest.ts b/test/performance/createBenchmarkTest.ts index 2a124fd891..cd22f298bb 100644 --- a/test/performance/createBenchmarkTest.ts +++ b/test/performance/createBenchmarkTest.ts @@ -10,7 +10,15 @@ import type { Server } from './server' import { startPerformanceServer } from './server' import { CLIENT_TOKEN, APPLICATION_ID, DATADOG_SITE, SDK_BUNDLE_URL } from './configuration' -const SCENARIO_CONFIGURATIONS = ['none', 'rum', 'rum_replay', 'rum_profiling', 'none_with_headers'] as const +const SCENARIO_CONFIGURATIONS = [ + 'none', + 'rum', + 'rum_replay', + 'rum_profiling', + 'none_with_headers', + 'instrumented_no_probes', + 'instrumented_with_probes', +] as const type ScenarioConfiguration = (typeof SCENARIO_CONFIGURATIONS)[number] type TestRunner = (page: Page, takeMeasurements: () => Promise, appUrl: string) => Promise | void @@ -38,8 +46,12 @@ export function createBenchmarkTest(scenarioName: string) { const { stopProfiling, takeMeasurements } = await startProfiling(page, cdpSession) - if (shouldInjectSDK(scenarioConfiguration)) { - await injectSDK(page, scenarioConfiguration, scenarioName) + if (shouldInjectRumSDK(scenarioConfiguration)) { + await injectRumSDK(page, scenarioConfiguration, scenarioName) + } + + if (shouldInjectLiveDebugger(scenarioConfiguration)) { + await injectLiveDebugger(page, scenarioConfiguration, scenarioName) } await runner(page, takeMeasurements, buildAppUrl(server.origin, scenarioConfiguration)) @@ -70,7 +82,7 @@ interface PageInitScriptParameters { scenarioName: string } -async function injectSDK(page: Page, scenarioConfiguration: ScenarioConfiguration, scenarioName: string) { +async function injectRumSDK(page: Page, scenarioConfiguration: ScenarioConfiguration, scenarioName: string) { const configuration: Partial = { clientToken: CLIENT_TOKEN, applicationId: APPLICATION_ID, @@ -116,6 +128,89 @@ async function injectSDK(page: Page, scenarioConfiguration: ScenarioConfiguratio ) } +async function injectLiveDebugger(page: Page, scenarioConfiguration: ScenarioConfiguration, scenarioName: string) { + // Set flag for app to use instrumented functions + await page.addInitScript(() => { + ;(window as any).USE_INSTRUMENTED = true + }) + + // Load debugger SDK (using local build for now) + await page.addInitScript(() => { + // Define global hooks that instrumented code expects + // These do minimal work that the VM can't optimize away + // Signatures match the real implementations from packages/debugger/src/domain/api.ts + + // Pre-populate with a dummy key to help V8 optimize property lookups. + // Removing this shows a much larger performance overhead. + // Benchmarks show that using an object is much faster than a Map. + const probesObj: Record = { __dummy__: undefined } + + // Container used to hold some data manipulated by the $dd_* functions to ensure the VM doesn't optimize them away. + const callCounts = { entry: 0, return: 0, throw: 0 } + + ;(window as any).$dd_probes = (functionId: string) => probesObj[functionId] + ;(window as any).$dd_entry = (probes: any[], self: any, args: Record) => { + callCounts.entry++ + } + ;(window as any).$dd_return = ( + probes: any[], + value: any, + self: any, + args: Record, + locals: Record + ) => { + callCounts.return++ + return value + } + ;(window as any).$dd_throw = (probes: any[], error: Error, self: any, args: Record) => { + callCounts.throw++ + } + + // Variables starting with $_dd are not going to exist in the real code, but are on the global scope in this benchmark to allow the benchmark to modify them. + ;(window as any).$_dd_probesObj = probesObj + ;(window as any).$_dd_callCounts = callCounts + }) + + // Initialize debugger after page loads + await page.addInitScript( + ({ scenarioConfiguration }: { scenarioConfiguration: ScenarioConfiguration }) => { + document.addEventListener('DOMContentLoaded', () => { + // In a real scenario, DD_DEBUGGER would be loaded from a bundle + // For now, we're just testing the instrumentation overhead with the hooks + const browserWindow = window as any + + // Mock init that sets up the hooks properly + if (!browserWindow.DD_DEBUGGER) { + browserWindow.DD_DEBUGGER = { + init: () => { + // Hooks are already defined in the init script + }, + addProbe: (probe: any) => { + // For instrumented_with_probes, add the probe to the object + if (scenarioConfiguration === 'instrumented_with_probes') { + browserWindow.$_dd_probesObj['instrumented.ts;add1'] = [probe] + } + }, + version: 'test', + } + + // Auto-init for testing + browserWindow.DD_DEBUGGER.init() + + // Add probe for add1 in instrumented_with_probes scenario + if (scenarioConfiguration === 'instrumented_with_probes') { + browserWindow.DD_DEBUGGER.addProbe({ + id: 'test-probe', + functionId: 'instrumented.ts;add1', + }) + } + } + }) + }, + { scenarioConfiguration } + ) +} + /** * Warm-up by loading a page to eliminate inflated TTFB seen on the very first load. * Inflated TTFB can come from cold-path costs (DNS resolution, TCP/TLS handshake, etc.). @@ -126,11 +221,17 @@ async function warmup(browser: Browser, url: string) { } async function getSDKVersion(page: Page) { - return await page.evaluate(() => (window as BrowserWindow).DD_RUM?.version || '') + return await page.evaluate( + () => (window as BrowserWindow).DD_RUM?.version || (window as BrowserWindow).DD_DEBUGGER?.version || '' + ) } -function shouldInjectSDK(scenarioConfiguration: ScenarioConfiguration): boolean { - return !['none', 'none_with_headers'].includes(scenarioConfiguration) +function shouldInjectRumSDK(scenarioConfiguration: ScenarioConfiguration): boolean { + return ['rum', 'rum_replay', 'rum_profiling'].includes(scenarioConfiguration) +} + +function shouldInjectLiveDebugger(scenarioConfiguration: ScenarioConfiguration): boolean { + return ['instrumented_no_probes', 'instrumented_with_probes'].includes(scenarioConfiguration) } function buildAppUrl(origin: string, scenarioConfiguration: ScenarioConfiguration): string { @@ -138,6 +239,9 @@ function buildAppUrl(origin: string, scenarioConfiguration: ScenarioConfiguratio if (scenarioConfiguration === 'rum_profiling' || scenarioConfiguration === 'none_with_headers') { url.searchParams.set('profiling', 'true') } + if (scenarioConfiguration === 'instrumented_no_probes' || scenarioConfiguration === 'instrumented_with_probes') { + url.searchParams.set('instrumented', 'true') + } return url.toString() } diff --git a/test/performance/profiling.type.ts b/test/performance/profiling.type.ts index 79c31d47a3..d7717414d1 100644 --- a/test/performance/profiling.type.ts +++ b/test/performance/profiling.type.ts @@ -3,6 +3,11 @@ import type { RumPublicApi } from '@datadog/browser-rum-core' export interface BrowserWindow extends Window { DD_RUM?: RumPublicApi + DD_DEBUGGER?: { + init: () => void + addProbe: (probe: any) => void + version?: string + } __webVitalsMetrics__?: WebVitalsMetrics } diff --git a/test/performance/scenarios/instrumentation-overhead.scenario.ts b/test/performance/scenarios/instrumentation-overhead.scenario.ts new file mode 100644 index 0000000000..da04c635bc --- /dev/null +++ b/test/performance/scenarios/instrumentation-overhead.scenario.ts @@ -0,0 +1,56 @@ +import { test } from '@playwright/test' +import { createBenchmarkTest } from '../createBenchmarkTest' + +test.describe('benchmark', () => { + void createBenchmarkTest('instrumentationOverhead').run(async (page, takeMeasurements, appUrl) => { + // Navigate to app and wait for initial load + await page.goto(appUrl, { waitUntil: 'domcontentloaded' }) + + // WARMUP PHASE: Allow JIT to optimize (100,000+ calls before measurement) + await page.evaluate(() => { + let warmupSum = 0 + for (let i = 0; i < 100_000; i++) { + warmupSum += window.testFunctions.add1(i, i + 1) + warmupSum += window.testFunctions.add2(i, i + 1) + } + console.log('Warmup sum:', warmupSum) // Ensure VM doesn't eliminate warmup + }) + + // Start measuring after warmup + await takeMeasurements() + + // MEASUREMENT PHASE: Heavy stress test + // Benchmark add1, but call add2 occasionally, which under the instrumented_with_probes scenario is instrumented. + // This is to measure if the instrumentation of add2 has an impact on the VM optimization of add1. + await page.evaluate(() => { + let sum1 = 0 + let sum2 = 0 + + const start = performance.now() + for (let i = 0; i < 10_000_000; i++) { + sum1 += window.testFunctions.add1(i, i + 1) + + if (i % 100_000 === 0) { + sum2 += window.testFunctions.add2(i, i + 1) + } + } + const totalTime = performance.now() - start + + // Log accumulated results to ensure VM cannot optimize away function bodies + const callCounts = (window as any).$_dd_callCounts + const message = `Benchmark complete - ${totalTime.toFixed(1)}ms total, sum1: ${sum1}, sum2: ${sum2}${ + callCounts + ? `, instrumentation: entry=${callCounts.entry} return=${callCounts.return} throw=${callCounts.throw}` + : '' + }` + console.log(message) + + // Also set on window so we can retrieve it + ;(window as any).benchmarkResult = { sum1, sum2, totalTime, callCounts } + }) + + // Retrieve and log the result to verify it ran + const result = await page.evaluate(() => (window as any).benchmarkResult) + console.log('Playwright: Benchmark result:', result) + }) +}) diff --git a/test/performance/server.ts b/test/performance/server.ts index fe53ed70f6..da02b15744 100644 --- a/test/performance/server.ts +++ b/test/performance/server.ts @@ -20,6 +20,7 @@ export function startPerformanceServer(scenarioName: string): Promise { const appMap: Record = { heavy: '../apps/react-heavy-spa/dist', shopistLike: '../apps/react-shopist-like/dist', + instrumentationOverhead: '../apps/instrumentation-overhead', } const distPath = path.resolve(import.meta.dirname, appMap[scenarioName]) diff --git a/yarn.lock b/yarn.lock index 64169689d4..1840fdf11c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -317,6 +317,13 @@ __metadata: languageName: node linkType: hard +"@datadog/browser-core@npm:6.30.1": + version: 6.30.1 + resolution: "@datadog/browser-core@npm:6.30.1" + checksum: 10c0/3a17ddcfbc1d8321941f8119958e8e050f83eb9f7bc7895c30e6e73d33f66fd50e1194756e0dc10830e59721a1fd7829e57e236c73588471d413596566e1f2c4 + languageName: node + linkType: hard + "@datadog/browser-core@npm:6.32.0, @datadog/browser-core@workspace:*, @datadog/browser-core@workspace:packages/core": version: 0.0.0-use.local resolution: "@datadog/browser-core@workspace:packages/core" @@ -326,6 +333,14 @@ __metadata: languageName: unknown linkType: soft +"@datadog/browser-debugger@workspace:packages/debugger": + version: 0.0.0-use.local + resolution: "@datadog/browser-debugger@workspace:packages/debugger" + dependencies: + "@datadog/browser-core": "npm:6.30.1" + languageName: unknown + linkType: soft + "@datadog/browser-logs@workspace:*, @datadog/browser-logs@workspace:packages/logs": version: 0.0.0-use.local resolution: "@datadog/browser-logs@workspace:packages/logs"