diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4293d648be..6341acad68 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -12,3 +12,10 @@ test/e2e/scenario/recorder/** @Datadog/rum-browser @Datadog/se # Docs /README.md @Datadog/rum-browser @DataDog/documentation + +# Debugger +packages/debugger @Datadog/rum-browser @DataDog/debugger +packages/debugger/README.md @Datadog/rum-browser @DataDog/debugger @DataDog/documentation +test/apps/instrumentation-overhead @Datadog/rum-browser @DataDog/debugger +test/e2e/scenario/debugger.scenario.ts @Datadog/rum-browser @DataDog/debugger +test/performance/scenarios/instrumentationOverhead.scenario.ts @Datadog/rum-browser @DataDog/debugger diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index 97b9fba1fc..597bf5278b 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -38,6 +38,7 @@ dev,@vitejs/plugin-react,MIT,Copyright (c) 2019-present Evan You & Vite Contribu dev,@vitejs/plugin-vue,MIT,Copyright (c) 2019-present, Yuxi (Evan) You and Vite contributors dev,@module-federation/enhanced,MIT, Copyright (c) 2020 ScriptedAlchemy LLC (Zack Jackson) Zhou Shaw (zhouxiao) dev,@vue/test-utils,MIT,Copyright (c) 2021-present vuejs +dev,acorn,MIT,Copyright (C) 2012-2022 by various contributors (see AUTHORS) dev,ajv,MIT,Copyright 2015-2017 Evgeny Poberezkin dev,babel-loader,MIT,Copyright (c) 2014-2019 Luís Couto dev,browserstack-local,MIT,Copyright 2016 BrowserStack 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..60908d7525 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. @@ -311,6 +311,8 @@ export interface ReplicaUserConfiguration { clientToken: string } +export type SdkSource = 'browser' | 'flutter' | 'unity' + export interface Configuration extends TransportConfiguration { // Built from init configuration beforeSend: GenericBeforeSendCallback | undefined @@ -331,10 +333,14 @@ export interface Configuration extends TransportConfiguration { // internal sdkVersion: string | undefined - source: 'browser' | 'flutter' | 'unity' + source: SdkSource variant: string | undefined } +function toSdkSource(source: TransportConfiguration['source']): SdkSource { + return source === 'dd_debugger' ? 'browser' : source +} + function isString(tag: unknown, tagName: string): tag is string | undefined | null { if (tag !== undefined && tag !== null && typeof tag !== 'string') { display.error(`${tagName} must be defined as a string`) @@ -398,6 +404,8 @@ export function validateAndBuildConfiguration( return } + const transportConfiguration = computeTransportConfiguration(initConfiguration) + return { beforeSend: initConfiguration.beforeSend && catchUserErrors(initConfiguration.beforeSend, 'beforeSend threw an error:'), @@ -423,7 +431,8 @@ export function validateAndBuildConfiguration( variant: initConfiguration.variant, sdkVersion: initConfiguration.sdkVersion, - ...computeTransportConfiguration(initConfiguration), + ...transportConfiguration, + source: toSdkSource(transportConfiguration.source), } } diff --git a/packages/core/src/domain/configuration/endpointBuilder.ts b/packages/core/src/domain/configuration/endpointBuilder.ts index 78754b6fa5..69a79ba0dc 100644 --- a/packages/core/src/domain/configuration/endpointBuilder.ts +++ b/packages/core/src/domain/configuration/endpointBuilder.ts @@ -8,7 +8,7 @@ import type { InitConfiguration } from './configuration' // replaced at build time declare const __BUILD_ENV__SDK_VERSION__: string -export type TrackType = 'logs' | 'rum' | 'replay' | 'profile' | 'exposures' | 'flagevaluation' +export type TrackType = 'logs' | 'rum' | 'replay' | 'profile' | 'exposures' | 'flagevaluation' | 'debugger' export type ApiType = | 'fetch' | 'beacon' diff --git a/packages/core/src/domain/configuration/transportConfiguration.ts b/packages/core/src/domain/configuration/transportConfiguration.ts index 3ea3d0440c..19f5ac85bf 100644 --- a/packages/core/src/domain/configuration/transportConfiguration.ts +++ b/packages/core/src/domain/configuration/transportConfiguration.ts @@ -11,10 +11,11 @@ export interface TransportConfiguration { profilingEndpointBuilder: EndpointBuilder exposuresEndpointBuilder: EndpointBuilder flagEvaluationEndpointBuilder: EndpointBuilder + debuggerEndpointBuilder: EndpointBuilder datacenter?: string | undefined replica?: ReplicaConfiguration site: Site - source: 'browser' | 'flutter' | 'unity' + source: 'browser' | 'flutter' | 'unity' | 'dd_debugger' } export interface ReplicaConfiguration { @@ -38,7 +39,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' @@ -52,6 +53,7 @@ function computeEndpointBuilders(initConfiguration: InitConfiguration) { sessionReplayEndpointBuilder: createEndpointBuilder(initConfiguration, 'replay'), exposuresEndpointBuilder: createEndpointBuilder(initConfiguration, 'exposures'), flagEvaluationEndpointBuilder: createEndpointBuilder(initConfiguration, 'flagevaluation'), + debuggerEndpointBuilder: createEndpointBuilder(initConfiguration, 'debugger'), } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b2a3d034d8..002e2fad62 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,15 @@ 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/LICENSE b/packages/debugger/LICENSE new file mode 100644 index 0000000000..e6d7fbc979 --- /dev/null +++ b/packages/debugger/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019-Present Datadog, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/debugger/README.md b/packages/debugger/README.md new file mode 100644 index 0000000000..e091466c63 --- /dev/null +++ b/packages/debugger/README.md @@ -0,0 +1,35 @@ +# Browser Live Debugger + +Datadog Live Debugger enables you to capture function execution snapshots, evaluate conditions, and collect runtime data from your application without modifying source code. + +See the [dedicated Datadog documentation][1] for more details. + +## Usage + +To start collecting data, add [`@datadog/browser-debugger`][2] to your `package.json` file, then initialize it with: + +```js +import { datadogDebugger } from '@datadog/browser-debugger' + +datadogDebugger.init({ + applicationId: '', + clientToken: '', + site: '', + service: 'my-web-application', + // env: 'production', + // version: '1.0.0', +}) +``` + +If [Datadog RUM][3] is also initialized on the page, debugger snapshots automatically include RUM context (session, view, user action) without any additional configuration. + +## Troubleshooting + +Need help? Contact [Datadog Support][4]. + + + +[1]: https://docs.datadoghq.com/tracing/live_debugger/ +[2]: https://www.npmjs.com/package/@datadog/browser-debugger +[3]: https://docs.datadoghq.com/real_user_monitoring/browser +[4]: https://docs.datadoghq.com/help/ diff --git a/packages/debugger/package.json b/packages/debugger/package.json new file mode 100644 index 0000000000..c65d01b6ab --- /dev/null +++ b/packages/debugger/package.json @@ -0,0 +1,38 @@ +{ + "name": "@datadog/browser-debugger", + "version": "6.32.0", + "license": "Apache-2.0", + "main": "cjs/entries/main.js", + "module": "esm/entries/main.js", + "types": "cjs/entries/main.d.ts", + "files": [ + "bundle/**/*.js", + "cjs", + "esm", + "src", + "!src/**/*.spec.ts", + "!src/**/*.specHelper.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", + "prepack": "yarn build" + }, + "devDependencies": { + "acorn": "8.16.0" + }, + "dependencies": { + "@datadog/browser-core": "6.32.0" + }, + "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/activeEntries.ts b/packages/debugger/src/domain/activeEntries.ts new file mode 100644 index 0000000000..569fdc2728 --- /dev/null +++ b/packages/debugger/src/domain/activeEntries.ts @@ -0,0 +1,31 @@ +import type { StackFrame } from './stacktrace' + +export 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 +} + +export const active = new Map>() + +export function clearActiveEntries(probeId?: string): void { + if (probeId !== undefined) { + active.delete(probeId) + } else { + active.clear() + } +} diff --git a/packages/debugger/src/domain/api.spec.ts b/packages/debugger/src/domain/api.spec.ts new file mode 100644 index 0000000000..f5f7aa73d4 --- /dev/null +++ b/packages/debugger/src/domain/api.spec.ts @@ -0,0 +1,653 @@ +import { registerCleanupTask } from '@datadog/browser-core/test' +import { onEntry, onReturn, onThrow, initDebuggerTransport, resetDebuggerTransport } from './api' +import { addProbe, removeProbe, getProbes, clearProbes } from './probes' +import type { Probe } from './probes' + +describe('api', () => { + let mockBatchAdd: jasmine.Spy + let mockRumGetInternalContext: jasmine.Spy + + beforeEach(() => { + clearProbes() + + mockBatchAdd = jasmine.createSpy('batchAdd') + initDebuggerTransport({ service: 'test-service', env: 'test-env' } as any, { add: mockBatchAdd } as any) + + // 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, + } + ;(window as any).DD_DEBUGGER = { + version: '0.0.1', + } + + registerCleanupTask(() => { + delete (window as any).DD_RUM + delete (window as any).DD_DEBUGGER + resetDebuggerTransport() + 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 = mockBatchAdd.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(mockBatchAdd).toHaveBeenCalledTimes(1) + + const payload = mockBatchAdd.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(mockBatchAdd).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(mockBatchAdd).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(mockBatchAdd).toHaveBeenCalledTimes(1) + + clearProbes() + addProbe(probe) + mockBatchAdd.calls.reset() + + probes = getProbes('TestClass;conditionEntry')! + // Should not fire when condition fails + onEntry(probes, {}, { x: 3 }) + onReturn(probes, null, {}, { x: 3 }, {}) + expect(mockBatchAdd).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(mockBatchAdd).toHaveBeenCalledTimes(1) + + clearProbes() + addProbe(probe) + mockBatchAdd.calls.reset() + + probes = getProbes('TestClass;conditionExit')! + // Should not fire when return value <= 10 + onEntry(probes, {}, {}) + onReturn(probes, 5, {}, {}, {}) + expect(mockBatchAdd).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 = mockBatchAdd.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 = mockBatchAdd.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 = mockBatchAdd.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 = mockBatchAdd.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 = mockBatchAdd.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(mockBatchAdd).toHaveBeenCalledTimes(1) + + const payload = mockBatchAdd.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(mockBatchAdd).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') + onThrow(probes, error, {}, {}) + + expect(mockBatchAdd).not.toHaveBeenCalled() + }) + }) + + 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(mockBatchAdd).toHaveBeenCalledTimes(25) + }) + }) + + describe('active entries cleanup', () => { + function createProbe(id: string, methodName: string): Probe { + return { + id, + version: 0, + type: 'LOG_PROBE', + where: { typeName: 'TestClass', methodName }, + template: 'Test', + captureSnapshot: false, + capture: {}, + sampling: { snapshotsPerSecond: 5000 }, + evaluateAt: 'ENTRY', + } + } + + it('should discard in-flight entries when a probe is removed', () => { + const probe = createProbe('cleanup-probe', 'cleanupTest') + addProbe(probe) + + const probes = getProbes('TestClass;cleanupTest')! + onEntry(probes, {}, {}) + + removeProbe('cleanup-probe') + addProbe(probe) + + const newProbes = getProbes('TestClass;cleanupTest')! + onReturn(newProbes, null, {}, {}, {}) + + expect(mockBatchAdd).not.toHaveBeenCalled() + }) + + it('should discard in-flight entries when all probes are cleared', () => { + const probe = createProbe('cleanup-probe', 'clearAllTest') + addProbe(probe) + + const probes = getProbes('TestClass;clearAllTest')! + onEntry(probes, {}, {}) + + clearProbes() + addProbe(probe) + + const newProbes = getProbes('TestClass;clearAllTest')! + onReturn(newProbes, null, {}, {}, {}) + + expect(mockBatchAdd).not.toHaveBeenCalled() + }) + + it('should not leak active entries after onReturn completes', () => { + const probe = createProbe('leak-probe', 'leakTest') + addProbe(probe) + + const probes = getProbes('TestClass;leakTest')! + onEntry(probes, {}, {}) + onReturn(probes, null, {}, {}, {}) + expect(mockBatchAdd).toHaveBeenCalledTimes(1) + + mockBatchAdd.calls.reset() + + // A second onReturn without onEntry should not produce a snapshot + onReturn(probes, null, {}, {}, {}) + expect(mockBatchAdd).not.toHaveBeenCalled() + }) + + it('should not leak active entries after onThrow completes', () => { + const probe = createProbe('throw-leak-probe', 'throwLeakTest') + addProbe(probe) + + const probes = getProbes('TestClass;throwLeakTest')! + onEntry(probes, {}, {}) + onThrow(probes, new Error('test'), {}, {}) + expect(mockBatchAdd).toHaveBeenCalledTimes(1) + + mockBatchAdd.calls.reset() + + // A second onThrow without onEntry should not produce a snapshot + onThrow(probes, new Error('test'), {}, {}) + expect(mockBatchAdd).not.toHaveBeenCalled() + }) + }) + + 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() + + expect(mockBatchAdd).toHaveBeenCalledTimes(1) + }) + + it('should handle uninitialized debugger transport gracefully', () => { + resetDebuggerTransport() + + 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..51dd9c396c --- /dev/null +++ b/packages/debugger/src/domain/api.ts @@ -0,0 +1,331 @@ +import type { Batch, Context, RumInternalContext } from '@datadog/browser-core' + +import { timeStampNow, display, buildTag, generateUUID, getGlobalObject } from '@datadog/browser-core' +import type { BrowserWindow, DebuggerInitConfiguration } from '../entries/main' +import { capture, captureFields } from './capture' +import type { InitializedProbe } from './probes' +import { checkGlobalSnapshotBudget } from './probes' +import type { ActiveEntry } from './activeEntries' +import { active } from './activeEntries' +import { captureStackTrace, parseStackTrace } from './stacktrace' +import { evaluateProbeMessage } from './template' +import { evaluateProbeCondition } from './condition' + +interface Rum { + getInternalContext?: () => RumInternalContext | undefined +} + +// Cache hostname at module initialization since it won't change during the app lifetime +const globalObj = getGlobalObject() // eslint-disable-line local-rules/disallow-side-effects +const hostname = 'location' in globalObj ? globalObj.location.hostname : 'unknown' + +const threadName = detectThreadName() // eslint-disable-line local-rules/disallow-side-effects + +let debuggerBatch: Batch | undefined +let debuggerConfig: DebuggerInitConfiguration | undefined + +export function initDebuggerTransport(config: DebuggerInitConfiguration, batch: Batch): void { + debuggerConfig = config + debuggerBatch = batch +} + +export function resetDebuggerTransport(): void { + debuggerBatch = undefined + debuggerConfig = undefined + active.clear() +} + +/** + * 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 (stack.length === 0) { + active.delete(probe.id) + } + 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 (stack.length === 0) { + active.delete(probe.id) + } + 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: InitializedProbe, 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: generateUUID(), + 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, + } + + const rumApi = globalObj.DD_RUM + const debuggerApi = globalObj.DD_DEBUGGER! + + // TODO: Fill out logger with the right information + const logger = { + name: probe.where.typeName, + method: probe.where.methodName, + version: debuggerApi.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 ddtags = [ + buildTag('sdk_version', debuggerApi.version), + buildTag('env', debuggerConfig.env), + buildTag('service', debuggerConfig.service), + buildTag('version', debuggerConfig.version), + buildTag('debugger_version', debuggerApi.version), + buildTag('host_name', hostname), + ] + + const payload: Context = { + message: result.message || '', + hostname, + service: debuggerConfig.service, + ddtags: ddtags.join(','), + logger, + dd, + debugger: { snapshot }, + } + + debuggerBatch.add(payload) +} + +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' +} + +declare const ServiceWorkerGlobalScope: typeof EventTarget +declare function importScripts(...urls: string[]): void diff --git a/packages/debugger/src/domain/capture.spec.ts b/packages/debugger/src/domain/capture.spec.ts new file mode 100644 index 0000000000..dfb4a5f795 --- /dev/null +++ b/packages/debugger/src/domain/capture.spec.ts @@ -0,0 +1,556 @@ +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', () => { + if (typeof BigInt === 'undefined') { + pending('BigInt is not supported in this browser') + return + } + const result = capture(BigInt(123), defaultOpts) + expect(result).toEqual({ type: 'bigint', value: '123' }) + }) + + it('should capture symbol', () => { + if (!('description' in Symbol.prototype)) { + pending('Symbol.description is not supported in this browser') + return + } + 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 invalid Date without throwing', () => { + const date = new Date('invalid') + const result = capture(date, defaultOpts) + expect(result).toEqual({ type: 'Date', value: 'Invalid Date' }) + }) + + 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 }) + if ((error as any).cause === undefined) { + pending('Error cause is not supported in this browser') + return + } + 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', () => { + if (!('description' in Symbol.prototype)) { + pending('Symbol.description is not supported in this browser') + return + } + 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', () => { + if (!('description' in Symbol.prototype)) { + pending('Symbol.description is not supported in this browser') + return + } + 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..24234571fa --- /dev/null +++ b/packages/debugger/src/domain/capture.ts @@ -0,0 +1,497 @@ +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 + ? (str: string) => (str as string & { replaceAll: (s: string, r: string) => string }).replaceAll('.', '_') + : (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) { + try { + return { type: 'Date', value: obj.toISOString() } + } catch { + return { type: 'Date', value: String(obj) } + } + } + 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..1312c8fde6 --- /dev/null +++ b/packages/debugger/src/domain/condition.spec.ts @@ -0,0 +1,178 @@ +import { display } from '@datadog/browser-core' +import { evaluateProbeCondition, compileCondition } 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: compileCondition('true'), + } + const result = evaluateProbeCondition(probe, {}) + + expect(result).toBe(true) + }) + + it('should return false for simple false condition', () => { + const probe: any = { + condition: compileCondition('false'), + } + const result = evaluateProbeCondition(probe, {}) + + expect(result).toBe(false) + }) + + it('should evaluate condition with context variables', () => { + const probe: any = { + condition: compileCondition('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: compileCondition('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: compileCondition('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: compileCondition('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: compileCondition('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: compileCondition('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: compileCondition('invalid syntax !!!'), + } + + const result = evaluateProbeCondition(probe, {}) + expect(result).toBe(true) + expect(displayErrorSpy).toHaveBeenCalled() + }) + + it('should handle conditions with special variables', () => { + const probe: any = { + condition: compileCondition('$dd_return > 0'), + } + + 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: compileCondition('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: compileCondition('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: compileCondition('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: compileCondition('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: compileCondition('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: compileCondition('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..20b805990f --- /dev/null +++ b/packages/debugger/src/domain/condition.ts @@ -0,0 +1,73 @@ +import { display } from '@datadog/browser-core' + +export interface CompiledCondition { + evaluate: (contextKeys: string[]) => (...args: any[]) => boolean + clearCache: () => void +} + +export interface ProbeWithCondition { + id: string + condition?: CompiledCondition +} + +/** + * Pre-compile a condition expression into a cached function factory. + * + * The returned `evaluate` method accepts the runtime context keys (e.g. `['x', 'y']`) and + * returns a Function whose parameters match those keys. Context values are passed positionally + * at call time via `fn.call(thisValue, ...contextValues)`. + * + * Because `new Function()` is expensive (it parses and compiles JS source), we cache the + * resulting Function objects keyed by context keys. For ENTRY probes there is always exactly one + * cache entry. For EXIT probes there can be two — one for the normal-return path and one for the + * exception path — since they provide different context variables. + */ +export function compileCondition(condition: string): CompiledCondition { + const fnBody = `return ${condition}` + const functionCache = new Map boolean>() + + return { + evaluate: (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(...contextKeys, fnBody) as (...args: any[]) => boolean + functionCache.set(cacheKey, fn) + } + return fn + }, + clearCache: () => { + functionCache.clear() + }, + } +} + +/** + * 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) + + const fn = probe.condition.evaluate(contextKeys) + 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..9a42919e7e --- /dev/null +++ b/packages/debugger/src/domain/deliveryApi.spec.ts @@ -0,0 +1,263 @@ +import { display, getGlobalObject } from '@datadog/browser-core' +import { registerCleanupTask, mockClock, replaceMockable } 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 not start polling when location is not available', () => { + replaceMockable(getGlobalObject, (() => ({})) as unknown as typeof getGlobalObject) + startDeliveryApiPolling(makeConfig()) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + 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..140fb8a3d7 --- /dev/null +++ b/packages/debugger/src/domain/deliveryApi.ts @@ -0,0 +1,146 @@ +import type { TimeoutId } from '@datadog/browser-core' +import { display, fetch, getGlobalObject, mockable, 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: TimeoutId | 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 (!('location' in mockable(getGlobalObject)())) { + return + } + + if (pollIntervalId !== undefined) { + display.warn('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(`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(`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(`Debugger: Failed to add probe ${probe.id}:`, err as Error) + } + } + } catch (err) { + display.error('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..582d5877ff --- /dev/null +++ b/packages/debugger/src/domain/expression.spec.ts @@ -0,0 +1,321 @@ +import * as acorn from 'acorn' +import { + literals, + references, + propertyAccess, + sizes, + equality, + stringManipulation, + stringComparison, + logicalOperators, + collectionOperations, + membershipAndMatching, + typeAndDefinitionChecks, +} from '../../test' +import type { TestCase } from '../../test' +import { OLDEST_BROWSER_ECMA_VERSION } from '../../../../test/unit/browsers.conf' + +import { compile } from './expression' +import { compileSegments } from './template' + +// 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) + }) + }) +}) + +// Validate that all compiled expressions produce JavaScript compatible with the oldest target browser. +// The expression compiler generates code as strings evaluated at runtime via new Function(), +// bypassing TypeScript compilation and webpack transpilation. This test catches usage of syntax +// not supported by older browsers (e.g., optional chaining ?., optional catch binding, nullish coalescing ??). +describe('browser compatibility of generated code', () => { + function assertECMAVersionCompatible(code: string, params: string[] = []) { + const functionCode = `function f(${params.join(', ')}) { ${code} }` + try { + acorn.parse(functionCode, { ecmaVersion: OLDEST_BROWSER_ECMA_VERSION, sourceType: 'script' }) + } catch (e: unknown) { + fail( + `Generated code is not ES${OLDEST_BROWSER_ECMA_VERSION}-compatible: ${(e as Error).message}\n\nGenerated code:\n${code}` + ) + } + } + + describe('expressions', () => { + const testNameCounts = new Map() + + for (const testCase of testCases) { + let ast: any + let vars: Record = {} + let suffix: string | undefined + let execute = true + + if (Array.isArray(testCase)) { + ;[ast, vars] = testCase + } else { + ;({ ast, vars = {}, suffix, execute = true } = testCase) + } + + if (execute === false) { + continue + } + + const baseName = JSON.stringify(ast) + const uniqueName = makeUniqueName(baseName, testNameCounts) + + it(uniqueName, () => { + const compiledResult = compile(ast) + const compiledCode = typeof compiledResult === 'string' ? compiledResult : String(compiledResult) + const code = suffix + ? `const result = (() => { + return ${compiledCode} + })() + ${suffix} + return result` + : `return ${compiledCode}` + + assertECMAVersionCompatible(code, Object.keys(vars)) + }) + } + }) + + describe('template segments', () => { + it('should generate code compatible with the oldest target browser for compiled segments', () => { + const segmentsCode = compileSegments([{ str: 'Hello ' }, { dsl: 'name', json: { ref: 'name' } }, { str: '!' }]) + + assertECMAVersionCompatible(`return ${segmentsCode}`, ['$dd_inspect', 'name']) + }) + + it('should generate code compatible with the oldest target browser for segments with complex expressions', () => { + const segmentsCode = compileSegments([{ dsl: 'obj.field', json: { getmember: [{ ref: 'obj' }, 'field'] } }]) + + assertECMAVersionCompatible(`return ${segmentsCode}`, ['$dd_inspect', 'obj']) + }) + }) +}) + +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..86919d0eef --- /dev/null +++ b/packages/debugger/src/domain/expression.ts @@ -0,0 +1,349 @@ +/** + * DSL expression language compiler for Live Debugger SDK. + * 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 (e) { + 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) => { + const desc = Object.getOwnPropertyDescriptor(val, key); + if (desc && desc.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..dd1c79be01 --- /dev/null +++ b/packages/debugger/src/domain/probes.spec.ts @@ -0,0 +1,471 @@ +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() + + 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.objectContaining({ + evaluate: jasmine.any(Function), + clearCache: jasmine.any(Function), + }) + ) + }) + + 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..c8e06b6cb9 --- /dev/null +++ b/packages/debugger/src/domain/probes.ts @@ -0,0 +1,272 @@ +import { display } from '@datadog/browser-core' +import { clearActiveEntries } from './activeEntries' +import { compile } from './expression' +import { compileCondition } from './condition' +import type { CompiledCondition } from './condition' +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?: CompiledCondition + msBetweenSampling: number + lastCaptureMs: number +} + +// Pre-populate with a placeholder 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 placeholder key to help V8 optimize property lookups. + __placeholder__: undefined, +} +const probeIdToFunctionId: Record = { + // @ts-expect-error - Pre-populate with a placeholder key to help V8 optimize property lookups. + __placeholder__: 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' && probe.template !== null && probe.template.clearCache) { + probe.template.clearCache() + } + if (typeof probe.condition === 'object' && probe.condition !== null && probe.condition.clearCache) { + probe.condition.clearCache() + } + probes.splice(i, 1) + clearActiveEntries(id) + 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' && probe.template !== null && probe.template.clearCache) { + probe.template.clearCache() + } + if (typeof probe.condition === 'object' && probe.condition !== null && probe.condition.clearCache) { + probe.condition.clearCache() + } + } + } + } + for (const functionId of Object.keys(activeProbes)) { + if (functionId !== '__placeholder__') { + delete activeProbes[functionId] + } + } + for (const probeId of Object.keys(probeIdToFunctionId)) { + if (probeId !== '__placeholder__') { + delete probeIdToFunctionId[probeId] + } + } + clearActiveEntries() + 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 = compileCondition(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 so we avoid rebuilding this string on every probe hit. + // The actual Function is created at runtime because the parameter names (context keys) + // aren't known until call time. For ENTRY probes there is exactly one set of keys; for + // EXIT probes there can be two (normal-return vs exception path). + 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..479fa2008c --- /dev/null +++ b/packages/debugger/src/domain/stacktrace.ts @@ -0,0 +1,61 @@ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- `type` is needed for implicit index signature compatibility with Context +export type 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 functionName = match[1] || '' + const fileName = match[2] + const lineNumber = parseInt(match[3], 10) + const columnNumber = parseInt(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..b8de8c45c8 --- /dev/null +++ b/packages/debugger/src/domain/template.spec.ts @@ -0,0 +1,434 @@ +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', () => { + if (typeof BigInt === 'undefined') { + pending('BigInt is not supported in this browser') + return + } + 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..783ab43f94 --- /dev/null +++ b/packages/debugger/src/domain/template.ts @@ -0,0 +1,248 @@ +/** + * Template compilation and evaluation utilities for Live Debugger SDK. + */ + +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.spec.ts b/packages/debugger/src/entries/main.spec.ts new file mode 100644 index 0000000000..c75239d842 --- /dev/null +++ b/packages/debugger/src/entries/main.spec.ts @@ -0,0 +1,11 @@ +import { datadogDebugger } from './main' + +describe('datadogDebugger', () => { + it('should only expose init, version, and onReady', () => { + expect(datadogDebugger).toEqual({ + init: jasmine.any(Function), + version: jasmine.any(String), + onReady: jasmine.any(Function), + }) + }) +}) diff --git a/packages/debugger/src/entries/main.ts b/packages/debugger/src/entries/main.ts new file mode 100644 index 0000000000..064fd39c6a --- /dev/null +++ b/packages/debugger/src/entries/main.ts @@ -0,0 +1,138 @@ +/** + * Datadog Browser Live Debugger SDK + * Provides live debugger capabilities for browser applications. + * + * @packageDocumentation + * @see [Live Debugger Documentation](https://docs.datadoghq.com/tracing/live_debugger/) + */ + +import { defineGlobal, getGlobalObject, makePublicApi } 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 { getProbes } from '../domain/probes' +import { startDebuggerBatch } from '../transport/startDebuggerBatch' + +/** + * Configuration options for initializing the Live Debugger SDK + */ +export interface DebuggerInitConfiguration { + /** + * The RUM application ID. + * + * @category Delivery API + */ + applicationId: string + + /** + * 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 + + /** + * 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 DebuggerPublicApi extends PublicApi { + /** + * Initialize the Live Debugger SDK + * + * @category Init + * @param initConfiguration - Configuration options + * @example + * ```ts + * datadogDebugger.init({ + * applicationId: '', + * clientToken: '', + * service: 'my-app', + * site: 'datadoghq.com', + * env: 'production' + * }) + * ``` + */ + init: (initConfiguration: DebuggerInitConfiguration) => void +} + +/** + * Create the public API for the Live Debugger + */ +function makeDebuggerPublicApi(): DebuggerPublicApi { + return makePublicApi({ + init: (initConfiguration: DebuggerInitConfiguration) => { + // 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, + }) + }, + }) +} + +/** + * The global Live Debugger instance. Use this to call Live Debugger methods. + * + * @category Main + * @see {@link DebuggerPublicApi} + * @see [Live Debugger Documentation](https://docs.datadoghq.com/tracing/live_debugger/) + */ +export const datadogDebugger = makeDebuggerPublicApi() + +export interface BrowserWindow extends Window { + DD_DEBUGGER?: DebuggerPublicApi +} + +defineGlobal(getGlobalObject(), 'DD_DEBUGGER', datadogDebugger) diff --git a/packages/debugger/src/transport/startDebuggerBatch.ts b/packages/debugger/src/transport/startDebuggerBatch.ts new file mode 100644 index 0000000000..ce4517f10e --- /dev/null +++ b/packages/debugger/src/transport/startDebuggerBatch.ts @@ -0,0 +1,53 @@ +import type { InitConfiguration, PageMayExitEvent, Batch } from '@datadog/browser-core' +import { + addEventListener, + createBatch, + createFlushController, + createHttpRequest, + createIdentityEncoder, + computeTransportConfiguration, + Observable, + PageExitReason, + display, +} from '@datadog/browser-core' + +export function startDebuggerBatch(initConfiguration: InitConfiguration): Batch { + const { debuggerEndpointBuilder } = computeTransportConfiguration({ ...initConfiguration, source: 'dd_debugger' }) + + const batch = createBatch({ + encoder: createIdentityEncoder(), + request: createHttpRequest([debuggerEndpointBuilder], (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 }) + } + + const visibilityListener = addEventListener({}, window, 'visibilitychange', onVisibilityChange, { capture: true }) + const unloadListener = addEventListener({}, window, 'beforeunload', onBeforeUnload) + + return () => { + visibilityListener.stop() + unloadListener.stop() + } + }) +} diff --git a/packages/debugger/test/expressionTestCases.ts b/packages/debugger/test/expressionTestCases.ts new file mode 100644 index 0000000000..b18becbf0c --- /dev/null +++ b/packages/debugger/test/expressionTestCases.ts @@ -0,0 +1,820 @@ +/** + * 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: () => undefined }, + 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, + ], +] + +/* eslint-disable id-denylist -- `any` and `all` are expression language operator names, not JS identifiers */ +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')], +] +/* eslint-enable id-denylist */ + +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], + { + ast: { instanceof: [{ ref: 'foo' }, 'bigint'] }, + vars: { foo: typeof BigInt !== 'undefined' ? BigInt(42) : undefined }, + expected: typeof BigInt !== 'undefined' ? true : undefined, + before: () => { + if (typeof BigInt === 'undefined') { + pending('BigInt is not supported in this browser') + } + }, + }, + [{ 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 { + ;(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..cc01b08bd6 --- /dev/null +++ b/packages/debugger/test/index.ts @@ -0,0 +1 @@ +export * from './expressionTestCases' diff --git a/scripts/build/build-test-apps.ts b/scripts/build/build-test-apps.ts index 9f83992402..48ae13dafa 100644 --- a/scripts/build/build-test-apps.ts +++ b/scripts/build/build-test-apps.ts @@ -29,6 +29,7 @@ const APPS: AppConfig[] = [ { name: 'angular-app' }, { name: 'vue-router-app' }, { name: 'nuxt-app' }, + { name: 'instrumentation-overhead' }, // React Router apps { name: 'react-router-v6-app' }, @@ -122,9 +123,13 @@ function buildApp(appName: string) { // so local packages are marked as optional peer dependencies and only installed when we build the test apps const packageJson = JSON.parse(fs.readFileSync(path.join(appPath, 'package.json'), 'utf-8')) if (packageJson.peerDependencies) { - // For each peer dependency, install it + // For each peer dependency, install it. + // Use the resolution path when available so that unpublished packages (e.g. new packages + // that only exist locally as .tgz files) can be installed without hitting the registry. for (const [name] of Object.entries(packageJson.peerDependencies)) { - command`yarn add -D ${name}`.withCurrentWorkingDirectory(appPath).run() + const resolution = packageJson.resolutions?.[name] + const dep = resolution ? `${name}@${resolution}` : name + command`yarn add -D ${dep}`.withCurrentWorkingDirectory(appPath).run() } // revert package.json & yarn.lock changes if they are versioned const areFilesVersioned = command`git ls-files package.json yarn.lock`.withCurrentWorkingDirectory(appPath).run() 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..792eb688a1 --- /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": "9.5.1", + "typescript": "5.9.3", + "webpack": "5.105.2" + }, + "volta": { + "extends": "../../../package.json" + }, + "packageManager": "yarn@4.12.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..e993c7cdbf --- /dev/null +++ b/test/apps/instrumentation-overhead/src/instrumented.ts @@ -0,0 +1,43 @@ +/* eslint-disable camelcase, curly, @typescript-eslint/no-unsafe-return */ + +/** + * Instrumented versions of the functions. + * These follow the debugger instrumentation pattern where $dd_* identifiers + * are injected by the debugger SDK at runtime. + */ + +// 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..5589541935 --- /dev/null +++ b/test/apps/instrumentation-overhead/yarn.lock @@ -0,0 +1,884 @@ +# 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.32.0 + resolution: "@datadog/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=96405e&locator=instrumentation-overhead-app%40workspace%3A." + checksum: 10c0/e4e5ca667f2076e20971ce1dc674d018b60bf58630da46ad25a37034e9ac11ec25297a002d71f19e0524784caa3f777d730ee4ac77fe91032a6349c0bf14e7aa + languageName: node + linkType: hard + +"@datadog/browser-debugger@file:../../../packages/debugger/package.tgz::locator=instrumentation-overhead-app%40workspace%3A.": + version: 6.32.0 + resolution: "@datadog/browser-debugger@file:../../../packages/debugger/package.tgz#../../../packages/debugger/package.tgz::hash=dfd49f&locator=instrumentation-overhead-app%40workspace%3A." + dependencies: + "@datadog/browser-core": "npm:6.32.0" + checksum: 10c0/5a0ee48f198141482a3db6f4a865f56ca63b8a951cf2fb383045fda327ba8a3d858140cd05c5603efa018b763be66b6b68af3bb05fe3c82c43c80e4fa0c9482f + 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/eslint-scope@npm:^3.7.7": + version: 3.7.7 + resolution: "@types/eslint-scope@npm:3.7.7" + dependencies: + "@types/eslint": "npm:*" + "@types/estree": "npm:*" + checksum: 10c0/a0ecbdf2f03912679440550817ff77ef39a30fa8bfdacaf6372b88b1f931828aec392f52283240f0d648cf3055c5ddc564544a626bcf245f3d09fcb099ebe3cc + languageName: node + linkType: hard + +"@types/eslint@npm:*": + version: 9.6.1 + resolution: "@types/eslint@npm:9.6.1" + dependencies: + "@types/estree": "npm:*" + "@types/json-schema": "npm:*" + checksum: 10c0/69ba24fee600d1e4c5abe0df086c1a4d798abf13792d8cfab912d76817fe1a894359a1518557d21237fbaf6eda93c5ab9309143dee4c59ef54336d1b3570420e + languageName: node + linkType: hard + +"@types/estree@npm:*, @types/estree@npm:^1.0.8": + version: 1.0.8 + resolution: "@types/estree@npm:1.0.8" + checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5 + languageName: node + linkType: hard + +"@types/json-schema@npm:*, @types/json-schema@npm:^7.0.15, @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.14.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.14.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.14.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-phases@npm:^1.0.3": + version: 1.0.4 + resolution: "acorn-import-phases@npm:1.0.4" + peerDependencies: + acorn: ^8.14.0 + checksum: 10c0/338eb46fc1aed5544f628344cb9af189450b401d152ceadbf1f5746901a5d923016cd0e7740d5606062d374fdf6941c29bb515d2bd133c4f4242d5d4cd73a3c7 + languageName: node + linkType: hard + +"acorn@npm:^8.15.0": + 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:^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:^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:^4.1.0": + version: 4.3.0 + resolution: "ansi-styles@npm:4.3.0" + dependencies: + color-convert: "npm:^2.0.1" + checksum: 10c0/895a23929da416f2bd3de7e9cb4eabd340949328ab85ddd6e484a637d8f6820d485f53933446f5291c3b760cbc488beb8e88573dd0f9c7daf83dccc8fe81b041 + languageName: node + linkType: hard + +"baseline-browser-mapping@npm:^2.10.12": + version: 2.10.16 + resolution: "baseline-browser-mapping@npm:2.10.16" + bin: + baseline-browser-mapping: dist/cli.cjs + checksum: 10c0/9947243bb8f16db3f8e05397c5c3e7a91243dcf2d1ec6a681ad5ffabeadee36c1061cd18e5f0432088df6798fa0689890ba26db173b9ad23c5650709b7b2e7cf + 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.28.1": + version: 4.28.2 + resolution: "browserslist@npm:4.28.2" + dependencies: + baseline-browser-mapping: "npm:^2.10.12" + caniuse-lite: "npm:^1.0.30001782" + electron-to-chromium: "npm:^1.5.328" + node-releases: "npm:^2.0.36" + update-browserslist-db: "npm:^1.2.3" + bin: + browserslist: cli.js + checksum: 10c0/c0228b6330f785b7fa59d2d360124ec6d9322f96ed9f3ee1f873e33ecc9503a6f0ffc3b71191a28c4ff6e930b753b30043da1c33844a9548f3018d491f09ce60 + 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.30001782": + version: 1.0.30001786 + resolution: "caniuse-lite@npm:1.0.30001786" + checksum: 10c0/9895f0add1991eefb91cfae98e7baa9daffc6b862b0996c983d30e6be90ef679b6aef32dcb6eca312977fb67c2636ee575820f101213e69c1e0dbffd6ee8e09e + languageName: node + linkType: hard + +"chalk@npm:^4.1.0": + version: 4.1.2 + resolution: "chalk@npm:4.1.2" + dependencies: + ansi-styles: "npm:^4.1.0" + supports-color: "npm:^7.1.0" + checksum: 10c0/4a3fef5cc34975c898ffe77141450f679721df9dde00f6c304353fa9c8b571929123b26a0e4617bde5018977eb655b31970c297b91b63ee83bb82aeb04666880 + 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:^2.0.1": + version: 2.0.1 + resolution: "color-convert@npm:2.0.1" + dependencies: + color-name: "npm:~1.1.4" + checksum: 10c0/37e1150172f2e311fe1b2df62c6293a342ee7380da7b9cfdba67ea539909afbd74da27033208d01d6d5cfc65ee7868a22e18d7e7648e004425441c0f8a15a7d7 + languageName: node + linkType: hard + +"color-name@npm:~1.1.4": + version: 1.1.4 + resolution: "color-name@npm:1.1.4" + checksum: 10c0/a1a3f914156960902f46f7f56bc62effc6c94e84b2cae157a526b1c1f74b677a47ec602bf68a61abfa2b42d15b7c5651c6dbe72a43af720bc588dff885b10f95 + 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 + +"electron-to-chromium@npm:^1.5.328": + version: 1.5.332 + resolution: "electron-to-chromium@npm:1.5.332" + checksum: 10c0/0e9aedd5634e81f323919a5fba7e1cf34c18860262a50915fb2371a6c65324cbfa50eaea231d05d235cc40b1b60cfaf0c1546d3f2daa6ad6c03e89dfe21cb55f + languageName: node + linkType: hard + +"enhanced-resolve@npm:^5.0.0, enhanced-resolve@npm:^5.19.0": + version: 5.20.1 + resolution: "enhanced-resolve@npm:5.20.1" + dependencies: + graceful-fs: "npm:^4.2.4" + tapable: "npm:^2.3.0" + checksum: 10c0/c6503ee1b2d725843e047e774445ecb12b779aa52db25d11ebe18d4b3adc148d3d993d2038b3d0c38ad836c9c4b3930fbc55df42f72b44785e2f94e5530eda69 + languageName: node + linkType: hard + +"es-module-lexer@npm:^2.0.0": + version: 2.0.0 + resolution: "es-module-lexer@npm:2.0.0" + checksum: 10c0/ae78dbbd43035a4b972c46cfb6877e374ea290adfc62bc2f5a083fea242c0b2baaab25c5886af86be55f092f4a326741cb94334cd3c478c383fdc8a9ec5ff817 + 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 + +"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.3": + version: 3.1.3 + resolution: "fast-deep-equal@npm:3.1.3" + checksum: 10c0/40dedc862eb8992c54579c66d914635afbec43350afbbe991235fdcb4e3a8d5af1b23ae7e79bef7d4882d0ecee06c3197488026998fb19f72dc95acff1d1b1d0 + 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:^4.0.0": + version: 4.0.0 + resolution: "has-flag@npm:4.0.0" + checksum: 10c0/2e789c61b7888d66993e14e8331449e525ef42aac53c627cc53d1c3334e768bcb6abdc4f5f0de1478a25beec6f0bd62c7549058b7ac53e924040d4f301f02fd1 + 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:9.5.1" + typescript: "npm:5.9.3" + webpack: "npm:5.105.2" + 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 + +"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:^1.0.0": + version: 1.0.0 + resolution: "json-schema-traverse@npm:1.0.0" + checksum: 10c0/71e30015d7f3d6dc1c316d6298047c8ef98a06d31ad064919976583eb61e1018a60a0067338f0f79cabc00d84af3fcc489bd48ce8a46ea165d9541ba17fb30c6 + languageName: node + linkType: hard + +"loader-runner@npm:^4.3.1": + version: 4.3.1 + resolution: "loader-runner@npm:4.3.1" + checksum: 10c0/a523b6329f114e0a98317158e30a7dfce044b731521be5399464010472a93a15ece44757d1eaed1d8845019869c5390218bc1c7c3110f4eeaef5157394486eac + 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 + +"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.36": + version: 2.0.37 + resolution: "node-releases@npm:2.0.37" + checksum: 10c0/306df89190b3225d0cb001260de52f0befd225a782ec85311ce97b0aa3b2e22f5e4e4c00395c6dc9bc9ef440c64723f6205fe1e27d32b8dd1d140891fbadf901 + 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 + +"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 + +"schema-utils@npm:^4.3.0, schema-utils@npm:^4.3.3": + 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:^7.3.4": + version: 7.7.4 + resolution: "semver@npm:7.7.4" + bin: + semver: bin/semver.js + checksum: 10c0/5215ad0234e2845d4ea5bb9d836d42b03499546ddafb12075566899fc617f68794bb6f146076b6881d755de17d6c6cc73372555879ec7dce2c2feee947866ad2 + 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 + +"source-map@npm:^0.7.4": + version: 0.7.6 + resolution: "source-map@npm:0.7.6" + checksum: 10c0/59f6f05538539b274ba771d2e9e32f6c65451982510564438e048bc1352f019c6efcdc6dd07909b1968144941c14015c2c7d4369fb7c4d7d53ae769716dcc16c + languageName: node + linkType: hard + +"supports-color@npm:^7.1.0": + version: 7.2.0 + resolution: "supports-color@npm:7.2.0" + dependencies: + has-flag: "npm:^4.0.0" + checksum: 10c0/afb4c88521b8b136b5f5f95160c98dee7243dc79d5432db7efc27efb219385bbc7d9427398e43dd6cc730a0f87d5085ce1652af7efbe391327bc0a7d0f7fc124 + 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:^2.3.0": + version: 2.3.2 + resolution: "tapable@npm:2.3.2" + checksum: 10c0/45ec8bd8963907f35bba875f9b3e9a5afa5ba11a9a4e4a2d7b2313d983cb2741386fd7dd3e54b13055b2be942971aac369d197e02263ec9216c59c0a8069ed7f + languageName: node + linkType: hard + +"terser-webpack-plugin@npm:^5.3.16": + version: 5.4.0 + resolution: "terser-webpack-plugin@npm:5.4.0" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.25" + jest-worker: "npm:^27.4.5" + schema-utils: "npm:^4.3.0" + terser: "npm:^5.31.1" + peerDependencies: + webpack: ^5.1.0 + peerDependenciesMeta: + "@swc/core": + optional: true + esbuild: + optional: true + uglify-js: + optional: true + checksum: 10c0/1feed4b9575af795dae6af0c8f0d76d6e1fb7b357b8628d90e834c23a651b918a58cdc48d0ae6c1f0581f74bc8169b33c3b8d049f2d2190bac4e310964e59fde + 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:9.5.1": + version: 9.5.1 + resolution: "ts-loader@npm:9.5.1" + dependencies: + chalk: "npm:^4.1.0" + enhanced-resolve: "npm:^5.0.0" + micromatch: "npm:^4.0.0" + semver: "npm:^7.3.4" + source-map: "npm:^0.7.4" + peerDependencies: + typescript: "*" + webpack: ^5.0.0 + checksum: 10c0/7dc1e3e5d3d032b6ef27836032f02c57077dfbcdf5817cbbc16b7b8609e7ed1d0ec157a03eaac07960161d8ad4a9e030c4d6722fe33540cf6ee75156c7f9c33d + languageName: node + linkType: hard + +"typescript@npm:5.9.3": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5 + languageName: node + linkType: hard + +"typescript@patch:typescript@npm%3A5.9.3#optional!builtin": + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/ad09fdf7a756814dce65bc60c1657b40d44451346858eea230e10f2e95a289d9183b6e32e5c11e95acc0ccc214b4f36289dcad4bf1886b0adb84d711d336a430 + 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.3": + version: 1.2.3 + resolution: "update-browserslist-db@npm:1.2.3" + dependencies: + escalade: "npm:^3.2.0" + picocolors: "npm:^1.1.1" + peerDependencies: + browserslist: ">= 4.21.0" + bin: + update-browserslist-db: cli.js + checksum: 10c0/13a00355ea822388f68af57410ce3255941d5fb9b7c49342c4709a07c9f230bbef7f7499ae0ca7e0de532e79a82cc0c4edbd125f1a323a1845bf914efddf8bec + languageName: node + linkType: hard + +"watchpack@npm:^2.5.1": + version: 2.5.1 + resolution: "watchpack@npm:2.5.1" + dependencies: + glob-to-regexp: "npm:^0.4.1" + graceful-fs: "npm:^4.1.2" + checksum: 10c0/dffbb483d1f61be90dc570630a1eb308581e2227d507d783b1d94a57ac7b705ecd9a1a4b73d73c15eab596d39874e5276a3d9cb88bbb698bafc3f8d08c34cf17 + languageName: node + linkType: hard + +"webpack-sources@npm:^3.3.3": + version: 3.3.4 + resolution: "webpack-sources@npm:3.3.4" + checksum: 10c0/94a42508531338eb41939cf1d48a4a8a6db97f3a47e5453cff2133a68d3169ca779d4bcbe9dfed072ce16611959eba1e16f085bc2dc56714e1a1c1783fd661a3 + languageName: node + linkType: hard + +"webpack@npm:5.105.2": + version: 5.105.2 + resolution: "webpack@npm:5.105.2" + dependencies: + "@types/eslint-scope": "npm:^3.7.7" + "@types/estree": "npm:^1.0.8" + "@types/json-schema": "npm:^7.0.15" + "@webassemblyjs/ast": "npm:^1.14.1" + "@webassemblyjs/wasm-edit": "npm:^1.14.1" + "@webassemblyjs/wasm-parser": "npm:^1.14.1" + acorn: "npm:^8.15.0" + acorn-import-phases: "npm:^1.0.3" + browserslist: "npm:^4.28.1" + chrome-trace-event: "npm:^1.0.2" + enhanced-resolve: "npm:^5.19.0" + es-module-lexer: "npm:^2.0.0" + 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.3.1" + mime-types: "npm:^2.1.27" + neo-async: "npm:^2.6.2" + schema-utils: "npm:^4.3.3" + tapable: "npm:^2.3.0" + terser-webpack-plugin: "npm:^5.3.16" + watchpack: "npm:^2.5.1" + webpack-sources: "npm:^3.3.3" + peerDependenciesMeta: + webpack-cli: + optional: true + bin: + webpack: bin/webpack.js + checksum: 10c0/565df8072c00d72e0a22e136971862b7eac7beb8b8d39a2ae4ab00838941ea58acc5b49dd7ea268e3d839810756cb86ba5c272b3a25904f6db7807cfa8ed0b29 + languageName: node + linkType: hard diff --git a/test/apps/vanilla/app.ts b/test/apps/vanilla/app.ts index c76bb788d2..f34e6c0b74 100644 --- a/test/apps/vanilla/app.ts +++ b/test/apps/vanilla/app.ts @@ -1,10 +1,12 @@ import { datadogLogs } from '@datadog/browser-logs' import { datadogRum } from '@datadog/browser-rum' +import { datadogDebugger } from '@datadog/browser-debugger' declare global { interface Window { LOGS_INIT?: () => void RUM_INIT?: () => void + DEBUGGER_INIT?: () => void } } @@ -16,9 +18,14 @@ if (typeof window !== 'undefined') { if (window.RUM_INIT) { window.RUM_INIT() } + + if (window.DEBUGGER_INIT) { + window.DEBUGGER_INIT() + } } else { // compat test datadogLogs.init({ clientToken: 'xxx', beforeSend: undefined }) datadogRum.init({ clientToken: 'xxx', applicationId: 'xxx', beforeSend: undefined }) datadogRum.setUser({ id: undefined }) + datadogDebugger.init({ clientToken: 'xxx', applicationId: 'xxx', service: 'xxx' }) } diff --git a/test/apps/vanilla/package.json b/test/apps/vanilla/package.json index 9594499639..cdf3887d4a 100644 --- a/test/apps/vanilla/package.json +++ b/test/apps/vanilla/package.json @@ -8,7 +8,8 @@ }, "peerDependencies": { "@datadog/browser-logs": "*", - "@datadog/browser-rum": "*" + "@datadog/browser-rum": "*", + "@datadog/browser-debugger": "*" }, "peerDependenciesMeta": { "@datadog/browser-logs": { @@ -16,6 +17,9 @@ }, "@datadog/browser-rum": { "optional": true + }, + "@datadog/browser-debugger": { + "optional": true } }, "resolutions": { @@ -25,7 +29,8 @@ "@datadog/browser-rum-core": "file:../../../packages/rum-core/package.tgz", "@datadog/browser-rum-react": "file:../../../packages/rum-react/package.tgz", "@datadog/browser-rum-slim": "file:../../../packages/rum-slim/package.tgz", - "@datadog/browser-worker": "file:../../../packages/worker/package.tgz" + "@datadog/browser-worker": "file:../../../packages/worker/package.tgz", + "@datadog/browser-debugger": "file:../../../packages/debugger/package.tgz" }, "devDependencies": { "ts-loader": "9.5.4", diff --git a/test/e2e/lib/framework/createTest.ts b/test/e2e/lib/framework/createTest.ts index e84a3e247b..22dab09df5 100644 --- a/test/e2e/lib/framework/createTest.ts +++ b/test/e2e/lib/framework/createTest.ts @@ -1,4 +1,5 @@ import type { LogsInitConfiguration } from '@datadog/browser-logs' +import type { DebuggerInitConfiguration } from '@datadog/browser-debugger' import type { RumInitConfiguration, RemoteConfiguration } from '@datadog/browser-rum-core' import type { BrowserContext, Page } from '@playwright/test' import { test, expect } from '@playwright/test' @@ -6,7 +7,11 @@ import { addTag, addTestOptimizationTags } from '../helpers/tags' import { getRunId } from '../../../envUtils' import type { BrowserLog } from '../helpers/browser' import { BrowserLogsManager, deleteAllCookies, getBrowserName, sendXhr } from '../helpers/browser' -import { DEFAULT_LOGS_CONFIGURATION, DEFAULT_RUM_CONFIGURATION } from '../helpers/configuration' +import { + DEFAULT_DEBUGGER_CONFIGURATION, + DEFAULT_LOGS_CONFIGURATION, + DEFAULT_RUM_CONFIGURATION, +} from '../helpers/configuration' import { validateRumFormat } from '../helpers/validation' import type { BrowserConfiguration } from '../../../browsers.conf' import { NEXTJS_APP_ROUTER_PORT, NUXT_APP_PORT, VUE_ROUTER_APP_PORT } from '../helpers/playwright' @@ -48,6 +53,7 @@ class TestBuilder { private rumConfiguration: RumInitConfiguration | undefined = undefined private alsoRunWithRumSlim = false private logsConfiguration: LogsInitConfiguration | undefined = undefined + private debuggerConfiguration: DebuggerInitConfiguration | undefined = undefined private remoteConfiguration?: RemoteConfiguration = undefined private head = '' private body = '' @@ -91,6 +97,11 @@ class TestBuilder { return this } + withDebugger(debuggerInitConfiguration?: Partial) { + this.debuggerConfiguration = { ...DEFAULT_DEBUGGER_CONFIGURATION, ...debuggerInitConfiguration } + return this + } + withHead(head: string) { this.head = head return this @@ -218,6 +229,7 @@ class TestBuilder { head: this.head, logs: this.logsConfiguration, rum: this.rumConfiguration, + debugger: this.debuggerConfiguration, remoteConfiguration: this.remoteConfiguration, rumInit: this.rumInit, logsInit: this.logsInit, diff --git a/test/e2e/lib/framework/httpServers.ts b/test/e2e/lib/framework/httpServers.ts index 7dfef9430e..b71eb65c22 100644 --- a/test/e2e/lib/framework/httpServers.ts +++ b/test/e2e/lib/framework/httpServers.ts @@ -12,6 +12,7 @@ export type ServerApp = (req: http.IncomingMessage, res: http.ServerResponse) => export type MockServerApp = ServerApp & { getLargeResponseWroteSize(): number + setDebuggerProbes(probes: object[]): void } export interface Server { diff --git a/test/e2e/lib/framework/index.ts b/test/e2e/lib/framework/index.ts index 5e275e1610..e8aec78f37 100644 --- a/test/e2e/lib/framework/index.ts +++ b/test/e2e/lib/framework/index.ts @@ -1,5 +1,9 @@ export { createTest } from './createTest' -export { DEFAULT_RUM_CONFIGURATION, DEFAULT_LOGS_CONFIGURATION } from '../helpers/configuration' +export { + DEFAULT_RUM_CONFIGURATION, + DEFAULT_LOGS_CONFIGURATION, + DEFAULT_DEBUGGER_CONFIGURATION, +} from '../helpers/configuration' export { createExtension } from './createExtension' export { createWorker } from './createWorker' export { @@ -13,6 +17,7 @@ export { } from './pageSetups' export { IntakeRegistry } from './intakeRegistry' export { getTestServers, waitForServersIdle } from './httpServers' +export type { Servers } from './httpServers' export { flushEvents } from './flushEvents' export { waitForRequests } from './waitForRequests' export { LARGE_RESPONSE_MIN_BYTE_SIZE } from './serverApps/mock' diff --git a/test/e2e/lib/framework/intakeProxyMiddleware.ts b/test/e2e/lib/framework/intakeProxyMiddleware.ts index 4cfc0dbf24..7dbd68bc2c 100644 --- a/test/e2e/lib/framework/intakeProxyMiddleware.ts +++ b/test/e2e/lib/framework/intakeProxyMiddleware.ts @@ -51,7 +51,17 @@ export type ProfileIntakeRequest = { } } & BaseIntakeRequest -export type IntakeRequest = LogsIntakeRequest | RumIntakeRequest | ReplayIntakeRequest | ProfileIntakeRequest +export type DebuggerIntakeRequest = { + intakeType: 'debugger' + events: Array> +} & BaseIntakeRequest + +export type IntakeRequest = + | LogsIntakeRequest + | RumIntakeRequest + | ReplayIntakeRequest + | ProfileIntakeRequest + | DebuggerIntakeRequest interface IntakeRequestInfos { isBridge: boolean @@ -104,7 +114,13 @@ function computeIntakeRequestInfos(req: express.Request): IntakeRequestInfos { let intakeType: IntakeRequest['intakeType'] // pathname = /api/v2/rum const endpoint = pathname.split(/[/?]/)[3] - if (endpoint === 'logs' || endpoint === 'rum' || endpoint === 'replay' || endpoint === 'profile') { + if ( + endpoint === 'logs' || + endpoint === 'rum' || + endpoint === 'replay' || + endpoint === 'profile' || + endpoint === 'debugger' + ) { intakeType = endpoint } else { throw new Error("Can't find intake type") @@ -124,6 +140,9 @@ function readIntakeRequest(req: express.Request, infos: IntakeRequestInfos): Pro if (infos.intakeType === 'profile') { return readProfileIntakeRequest(req, infos as IntakeRequestInfos & { intakeType: 'profile' }) } + if (infos.intakeType === 'debugger') { + return readDebuggerIntakeRequest(req, infos as IntakeRequestInfos & { intakeType: 'debugger' }) + } return readRumOrLogsIntakeRequest(req, infos as IntakeRequestInfos & { intakeType: 'rum' | 'logs' }) } @@ -143,6 +162,23 @@ async function readRumOrLogsIntakeRequest( } } +async function readDebuggerIntakeRequest( + req: express.Request, + infos: IntakeRequestInfos & { intakeType: 'debugger' } +): Promise { + const rawBody = await readStream(req) + const encodedBody = infos.encoding === 'deflate' ? inflateSync(rawBody) : rawBody + + return { + ...infos, + events: encodedBody + .toString('utf-8') + .split('\n') + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + .map((line): Record => JSON.parse(line)), + } +} + function readReplayIntakeRequest( req: express.Request, infos: IntakeRequestInfos & { intakeType: 'replay' } diff --git a/test/e2e/lib/framework/intakeRegistry.ts b/test/e2e/lib/framework/intakeRegistry.ts index dcff6b46a6..dc703f844f 100644 --- a/test/e2e/lib/framework/intakeRegistry.ts +++ b/test/e2e/lib/framework/intakeRegistry.ts @@ -14,6 +14,7 @@ import type { TelemetryUsageEvent, } from '@datadog/browser-core' import type { + DebuggerIntakeRequest, IntakeRequest, LogsIntakeRequest, ProfileIntakeRequest, @@ -138,6 +139,18 @@ export class IntakeRegistry { get profileEvents() { return this.profileRequests.map((request) => request.event) } + + // + // Debugger + // + + get debuggerRequests() { + return this.requests.filter(isDebuggerIntakeRequest) + } + + get debuggerEvents() { + return this.debuggerRequests.flatMap((request) => request.events) + } } function isLogsIntakeRequest(request: IntakeRequest): request is LogsIntakeRequest { @@ -156,6 +169,10 @@ function isProfileIntakeRequest(request: IntakeRequest): request is ProfileIntak return request.intakeType === 'profile' } +function isDebuggerIntakeRequest(request: IntakeRequest): request is DebuggerIntakeRequest { + return request.intakeType === 'debugger' +} + function isRumEvent(event: RumEvent | TelemetryEvent): event is RumEvent { return !isTelemetryEvent(event) } diff --git a/test/e2e/lib/framework/pageSetups.ts b/test/e2e/lib/framework/pageSetups.ts index f5590fe441..464282ef65 100644 --- a/test/e2e/lib/framework/pageSetups.ts +++ b/test/e2e/lib/framework/pageSetups.ts @@ -1,6 +1,7 @@ import { generateUUID, INTAKE_URL_PARAMETERS } from '@datadog/browser-core' import type { LogsInitConfiguration } from '@datadog/browser-logs' import type { RumInitConfiguration, RemoteConfiguration } from '@datadog/browser-rum-core' +import type { DebuggerInitConfiguration } from '@datadog/browser-debugger' import type test from '@playwright/test' import { isBrowserStack, isContinuousIntegration } from './environment' import type { Servers } from './httpServers' @@ -11,6 +12,7 @@ export interface SetupOptions { logs?: LogsInitConfiguration logsInit: (initConfiguration: LogsInitConfiguration) => void rumInit: (initConfiguration: RumInitConfiguration) => void + debugger?: DebuggerInitConfiguration remoteConfiguration?: RemoteConfiguration eventBridge: boolean head?: string @@ -75,7 +77,7 @@ n=o.getElementsByTagName(u)[0];n.parentNode.insertBefore(d,n) })(window,document,'script','${url}','${globalName}')` } - const { logsScriptUrl, rumScriptUrl } = createCrossOriginScriptUrls(servers, options) + const { logsScriptUrl, rumScriptUrl, debuggerScriptUrl } = createCrossOriginScriptUrls(servers, options) if (options.logs) { footer += html`` } + if (options.debugger) { + footer += html`` + } + return basePage({ header, body: options.body, @@ -115,7 +126,7 @@ export function bundleSetup(options: SetupOptions, servers: Servers) { header += setupExtension(options, servers) } - const { logsScriptUrl, rumScriptUrl } = createCrossOriginScriptUrls(servers, options) + const { logsScriptUrl, rumScriptUrl, debuggerScriptUrl } = createCrossOriginScriptUrls(servers, options) if (options.logs) { header += html`` @@ -133,6 +144,15 @@ export function bundleSetup(options: SetupOptions, servers: Servers) { ` } + if (options.debugger) { + header += html` + + + ` + } + return basePage({ header, body: options.body, @@ -168,6 +188,14 @@ export function npmSetup(options: SetupOptions, servers: Servers) { ` } + if (options.debugger) { + header += html`` + } + header += html`` return basePage({ @@ -339,7 +367,10 @@ function isJsonIncompatibleValue(value: unknown): value is JsonIncompatibleValue return typeof value === 'function' || value instanceof RegExp } -export function formatConfiguration(initConfiguration: LogsInitConfiguration | RumInitConfiguration, servers: Servers) { +export function formatConfiguration( + initConfiguration: LogsInitConfiguration | RumInitConfiguration | DebuggerInitConfiguration, + servers: Servers +) { const jsonIncompatibles = new Map() let result = JSON.stringify( @@ -374,5 +405,6 @@ export function createCrossOriginScriptUrls(servers: Servers, options: SetupOpti return { logsScriptUrl: `${servers.crossOrigin.origin}/datadog-logs.js`, rumScriptUrl: `${servers.crossOrigin.origin}/${options.useRumSlim ? 'datadog-rum-slim.js' : 'datadog-rum.js'}`, + debuggerScriptUrl: `${servers.crossOrigin.origin}/datadog-debugger.js`, } } diff --git a/test/e2e/lib/framework/serverApps/mock.ts b/test/e2e/lib/framework/serverApps/mock.ts index 96494aefad..8fac2d9a8a 100644 --- a/test/e2e/lib/framework/serverApps/mock.ts +++ b/test/e2e/lib/framework/serverApps/mock.ts @@ -15,6 +15,7 @@ export function createMockServerApp(servers: Servers, setup: string, setupOption const { remoteConfiguration, worker } = setupOptions ?? {} const app = express() let largeResponseBytesWritten = 0 + let debuggerProbes: object[] = [] app.use(cors()) app.disable('etag') // disable automatic resource caching @@ -219,10 +220,17 @@ export function createMockServerApp(servers: Servers, setup: string, setupOption res.send(JSON.stringify(remoteConfiguration)) }) + app.post('/api/ui/debugger/probe-delivery', (_req, res) => { + res.json({ nextCursor: '', updates: debuggerProbes, deletions: [] }) + }) + return Object.assign(app, { getLargeResponseWroteSize() { return largeResponseBytesWritten }, + setDebuggerProbes(probes: object[]) { + debuggerProbes = probes + }, }) } diff --git a/test/e2e/lib/helpers/configuration.ts b/test/e2e/lib/helpers/configuration.ts index cfe9437d0e..b5e0578669 100644 --- a/test/e2e/lib/helpers/configuration.ts +++ b/test/e2e/lib/helpers/configuration.ts @@ -25,3 +25,9 @@ export const DEFAULT_LOGS_CONFIGURATION = { telemetryUsageSampleRate: 100, telemetryConfigurationSampleRate: 100, } + +export const DEFAULT_DEBUGGER_CONFIGURATION = { + applicationId: APPLICATION_ID, + clientToken: CLIENT_TOKEN, + service: 'browser-sdk-e2e-test', +} diff --git a/test/e2e/lib/types/global.ts b/test/e2e/lib/types/global.ts index 1837e63ecc..e1a1c8c16b 100644 --- a/test/e2e/lib/types/global.ts +++ b/test/e2e/lib/types/global.ts @@ -1,10 +1,12 @@ import type { LogsGlobal } from '@datadog/browser-logs' +import type { DebuggerPublicApi } from '@datadog/browser-debugger' import type { RumGlobal } from '@datadog/browser-rum' declare global { interface Window { DD_LOGS?: LogsGlobal DD_RUM?: RumGlobal + DD_DEBUGGER?: DebuggerPublicApi DD_SOURCE_CODE_CONTEXT?: { [stack: string]: { service: string; version?: string } } } } diff --git a/test/e2e/scenario/debugger.scenario.ts b/test/e2e/scenario/debugger.scenario.ts new file mode 100644 index 0000000000..07fcd5c3a3 --- /dev/null +++ b/test/e2e/scenario/debugger.scenario.ts @@ -0,0 +1,306 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call, camelcase */ +import { test, expect } from '@playwright/test' +import type { Page } from '@playwright/test' +import { createTest } from '../lib/framework' +import type { Servers } from '../lib/framework' + +function setDebuggerProbes(servers: Servers, probes: object[]) { + servers.base.app.setDebuggerProbes(probes) +} + +function makeProbe({ + id = 'test-probe-1', + version = 1, + typeName = 'TestModule', + methodName = 'testFunction', + template = 'Probe hit', + captureSnapshot = true, + evaluateAt = 'EXIT' as const, + condition, + segments, +}: { + id?: string + version?: number + typeName?: string + methodName?: string + template?: string + captureSnapshot?: boolean + evaluateAt?: 'ENTRY' | 'EXIT' + condition?: { dsl: string; json: unknown } + segments?: Array<{ str?: string; dsl?: string; json?: unknown }> +} = {}) { + return { + id, + version, + type: 'LOG_PROBE', + where: { typeName, methodName }, + template, + segments: segments ?? [{ str: template }], + captureSnapshot, + capture: {}, + sampling: { snapshotsPerSecond: 5000 }, + evaluateAt, + when: condition, + } +} + +/** + * Injects an instrumented function into the page that calls the debugger hooks. + * The function is named `testFunction` and registered under the `TestModule;testFunction` function ID. + */ +async function injectInstrumentedFunction(page: Page) { + await page.evaluate(() => { + const $dd_probes = (globalThis as any).$dd_probes as (id: string) => unknown[] | undefined + const $dd_entry = (globalThis as any).$dd_entry as (probes: unknown[], self: unknown, args: object) => void + const $dd_return = (globalThis as any).$dd_return as ( + probes: unknown[], + value: unknown, + self: unknown, + args: object, + locals: object + ) => unknown + + ;(window as any).testFunction = function testFunction(a: unknown, b: unknown) { + const probes = $dd_probes('TestModule;testFunction') + if (probes) { + $dd_entry(probes, this, { a, b }) + } + const result = String(a) + String(b) + const returnValue = result + if (probes) { + return $dd_return(probes, returnValue, this, { a, b }, { result }) + } + return returnValue + } + }) +} + +/** + * Injects an instrumented function that throws, triggering `$dd_throw`. + */ +async function injectThrowingFunction(page: Page) { + await page.evaluate(() => { + const $dd_probes = (globalThis as any).$dd_probes as (id: string) => unknown[] | undefined + const $dd_entry = (globalThis as any).$dd_entry as (probes: unknown[], self: unknown, args: object) => void + const $dd_throw = (globalThis as any).$dd_throw as ( + probes: unknown[], + error: Error, + self: unknown, + args: object + ) => void + + ;(window as any).throwingFunction = function throwingFunction(msg: string) { + const probes = $dd_probes('TestModule;throwingFunction') + if (probes) { + $dd_entry(probes, this, { msg }) + } + try { + throw new Error(msg) + } catch (e) { + if (probes) { + $dd_throw(probes, e as Error, this, { msg }) + } + throw e + } + } + }) +} + +test.describe('debugger', () => { + createTest('send debugger snapshot when instrumented function is called') + .withDebugger() + .run(async ({ intakeRegistry, flushEvents, page, browserName, servers }) => { + test.skip(browserName !== 'chromium', 'Debugger tests require Chromium') + + const probe = makeProbe() + setDebuggerProbes(servers, [probe]) + + await page.reload() + await injectInstrumentedFunction(page) + + await page.evaluate(() => { + ;(window as any).testFunction('hello', ' world') + }) + + await flushEvents() + + expect(intakeRegistry.debuggerEvents.length).toBeGreaterThanOrEqual(1) + + const event = intakeRegistry.debuggerEvents[0] + expect(event.message).toBe('Probe hit') + expect(event.service).toBe('browser-sdk-e2e-test') + expect(event.hostname).toBeDefined() + + const snapshot = (event.debugger as any).snapshot + expect(snapshot.probe.id).toBe('test-probe-1') + expect(snapshot.language).toBe('javascript') + expect(snapshot.duration).toBeGreaterThan(0) + }) + + createTest('capture function arguments and return value') + .withDebugger() + .run(async ({ intakeRegistry, flushEvents, page, browserName, servers }) => { + test.skip(browserName !== 'chromium', 'Debugger tests require Chromium') + + const probe = makeProbe({ captureSnapshot: true }) + setDebuggerProbes(servers, [probe]) + + await page.reload() + await injectInstrumentedFunction(page) + + await page.evaluate(() => { + ;(window as any).testFunction('foo', 'bar') + }) + + await flushEvents() + + expect(intakeRegistry.debuggerEvents.length).toBeGreaterThanOrEqual(1) + + const snapshot = (intakeRegistry.debuggerEvents[0].debugger as any).snapshot + expect(snapshot.captures).toBeDefined() + expect(snapshot.captures.return).toBeDefined() + + const returnCapture = snapshot.captures.return + expect(returnCapture.locals['@return']).toBeDefined() + expect(returnCapture.locals['@return'].value).toBe('foobar') + }) + + createTest('capture exception in snapshot on throw') + .withDebugger() + .run(async ({ intakeRegistry, flushEvents, page, browserName, servers }) => { + test.skip(browserName !== 'chromium', 'Debugger tests require Chromium') + + const probe = makeProbe({ + typeName: 'TestModule', + methodName: 'throwingFunction', + }) + setDebuggerProbes(servers, [probe]) + + await page.reload() + await injectThrowingFunction(page) + + await page.evaluate(() => { + try { + ;(window as any).throwingFunction('test error') + } catch { + // expected + } + }) + + await flushEvents() + + expect(intakeRegistry.debuggerEvents.length).toBeGreaterThanOrEqual(1) + + const snapshot = (intakeRegistry.debuggerEvents[0].debugger as any).snapshot + expect(snapshot.captures.return.throwable).toBeDefined() + expect(snapshot.captures.return.throwable.message).toBe('test error') + }) + + createTest('evaluate probe message template with expression segments') + .withDebugger() + .run(async ({ intakeRegistry, flushEvents, page, browserName, servers }) => { + test.skip(browserName !== 'chromium', 'Debugger tests require Chromium') + + const probe = makeProbe({ + template: '', + segments: [ + { str: 'Result is: ' }, + { dsl: 'a', json: { ref: 'a' } }, + { str: ' and ' }, + { dsl: 'b', json: { ref: 'b' } }, + ], + }) + setDebuggerProbes(servers, [probe]) + + await page.reload() + await injectInstrumentedFunction(page) + + await page.evaluate(() => { + ;(window as any).testFunction('X', 'Y') + }) + + await flushEvents() + + expect(intakeRegistry.debuggerEvents.length).toBeGreaterThanOrEqual(1) + expect(intakeRegistry.debuggerEvents[0].message).toBe('Result is: X and Y') + }) + + createTest('do not send snapshot when probe condition is not met') + .withDebugger() + .run(async ({ intakeRegistry, flushEvents, page, browserName, servers }) => { + test.skip(browserName !== 'chromium', 'Debugger tests require Chromium') + + const probe = makeProbe({ + evaluateAt: 'EXIT', + condition: { + dsl: '$dd_return == "match"', + json: { eq: [{ ref: '$dd_return' }, 'match'] }, + }, + }) + setDebuggerProbes(servers, [probe]) + + await page.reload() + await injectInstrumentedFunction(page) + + await page.evaluate(() => { + ;(window as any).testFunction('no', 'match') + }) + + await flushEvents() + + expect(intakeRegistry.debuggerEvents).toHaveLength(0) + }) + + createTest('send snapshot when probe condition is met') + .withDebugger() + .run(async ({ intakeRegistry, flushEvents, page, browserName, servers }) => { + test.skip(browserName !== 'chromium', 'Debugger tests require Chromium') + + const probe = makeProbe({ + evaluateAt: 'EXIT', + condition: { + dsl: '$dd_return == "foobar"', + json: { eq: [{ ref: '$dd_return' }, 'foobar'] }, + }, + }) + setDebuggerProbes(servers, [probe]) + + await page.reload() + await injectInstrumentedFunction(page) + + await page.evaluate(() => { + ;(window as any).testFunction('foo', 'bar') + }) + + await flushEvents() + + expect(intakeRegistry.debuggerEvents.length).toBeGreaterThanOrEqual(1) + expect(intakeRegistry.debuggerEvents[0].message).toBe('Probe hit') + }) + + createTest('include RUM correlation data when RUM is active') + .withRum() + .withDebugger() + .run(async ({ intakeRegistry, flushEvents, page, browserName, servers }) => { + test.skip(browserName !== 'chromium', 'Debugger tests require Chromium') + + const probe = makeProbe() + setDebuggerProbes(servers, [probe]) + + await page.reload() + await injectInstrumentedFunction(page) + + await page.evaluate(() => { + ;(window as any).testFunction('hello', ' world') + }) + + await flushEvents() + + expect(intakeRegistry.debuggerEvents.length).toBeGreaterThanOrEqual(1) + + const event = intakeRegistry.debuggerEvents[0] + const dd = event.dd as { trace_id?: string; span_id?: string } + expect(dd.trace_id).toBeDefined() + expect(dd.trace_id).not.toBe('') + }) +}) diff --git a/test/performance/createBenchmarkTest.ts b/test/performance/createBenchmarkTest.ts index 2a124fd891..d424567701 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 (shouldInjectDebugger(scenarioConfiguration)) { + await injectDebugger(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,88 @@ async function injectSDK(page: Page, scenarioConfiguration: ScenarioConfiguratio ) } +async function injectDebugger(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 placeholder 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 = { __placeholder__: 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 } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + ;(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 // eslint-disable-line @typescript-eslint/no-unsafe-return + } + ;(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 + }, + version: 'test', + } + + // Auto-init for testing + browserWindow.DD_DEBUGGER.init() // eslint-disable-line @typescript-eslint/no-unsafe-call + + // Add probe for add1 in instrumented_with_probes scenario. + // In the real SDK, probes are added internally by the delivery API polling, + // not via a public method. Here we write directly to the probes object. + if (scenarioConfiguration === 'instrumented_with_probes') { + browserWindow.$_dd_probesObj['instrumented.ts;add1'] = [ + { + 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 +220,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 shouldInjectDebugger(scenarioConfiguration: ScenarioConfiguration): boolean { + return ['instrumented_no_probes', 'instrumented_with_probes'].includes(scenarioConfiguration) } function buildAppUrl(origin: string, scenarioConfiguration: ScenarioConfiguration): string { @@ -138,6 +238,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..80805d822c 100644 --- a/test/performance/profiling.type.ts +++ b/test/performance/profiling.type.ts @@ -3,6 +3,10 @@ import type { RumPublicApi } from '@datadog/browser-rum-core' export interface BrowserWindow extends Window { DD_RUM?: RumPublicApi + DD_DEBUGGER?: { + init: () => void + version?: string + } __webVitalsMetrics__?: WebVitalsMetrics } diff --git a/test/performance/scenarios/instrumentationOverhead.scenario.ts b/test/performance/scenarios/instrumentationOverhead.scenario.ts new file mode 100644 index 0000000000..9e5252330b --- /dev/null +++ b/test/performance/scenarios/instrumentationOverhead.scenario.ts @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +import { test } from '@playwright/test' +import { createBenchmarkTest } from '../createBenchmarkTest' + +declare global { + interface Window { + testFunctions: { + add1: (a: number, b: number) => number + add2: (a: number, b: number) => number + } + } +} + +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/test/unit/browsers.conf.ts b/test/unit/browsers.conf.ts index ebf634fb2e..a04ae3a03f 100644 --- a/test/unit/browsers.conf.ts +++ b/test/unit/browsers.conf.ts @@ -2,6 +2,11 @@ import type { BrowserConfiguration } from '../browsers.conf' +// The ECMAScript version supported by the oldest browser in the list below (Chrome 63 → ES2017). +// Used by tests that validate runtime-generated code strings (e.g. the expression compiler) which +// bypass TypeScript/webpack transpilation and must only use syntax supported by all target browsers. +export const OLDEST_BROWSER_ECMA_VERSION = 2017 + export const browserConfigurations: BrowserConfiguration[] = [ { sessionName: 'Edge', diff --git a/tsconfig.base.json b/tsconfig.base.json index 4819f3b399..b64e039ebf 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -41,7 +41,9 @@ "@datadog/browser-rum-nextjs": ["./packages/rum-nextjs/src/entries/main"], - "@datadog/browser-worker": ["./packages/worker/src/entries/main"] + "@datadog/browser-worker": ["./packages/worker/src/entries/main"], + + "@datadog/browser-debugger": ["./packages/debugger/src/entries/main"] } } } diff --git a/typedoc.json b/typedoc.json index 4ed1055538..055a6773e4 100644 --- a/typedoc.json +++ b/typedoc.json @@ -7,7 +7,7 @@ ], "entryPointStrategy": "packages", "includeVersion": true, - "exclude": ["packages/core", "packages/rum-core", "packages/worker"], + "exclude": ["packages/core", "packages/rum-core", "packages/worker", "packages/debugger"], "validation": { "notExported": true, "invalidLink": true, diff --git a/yarn.lock b/yarn.lock index 09bc67f1f1..b70e31d5d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -326,6 +326,15 @@ __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.32.0" + acorn: "npm:8.16.0" + 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" @@ -3327,7 +3336,7 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.15.0, acorn@npm:^8.16.0": +"acorn@npm:8.16.0, acorn@npm:^8.15.0, acorn@npm:^8.16.0": version: 8.16.0 resolution: "acorn@npm:8.16.0" bin: