From bf9a162a5432ec0106bb243486f762625e2b780b Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Tue, 7 Apr 2026 10:37:46 +0200 Subject: [PATCH] Add Live Debugger package Introduce the browser debugger SDK and probe execution pipeline so browser code can evaluate conditions, capture snapshots, and render probe messages at runtime. Add Delivery API polling plus sandbox and performance tooling to support probe delivery and testing. --- .github/CODEOWNERS | 7 + LICENSE-3rdparty.csv | 1 + eslint-local-rules/disallowSideEffects.js | 1 + .../src/domain/configuration/configuration.ts | 15 +- .../domain/configuration/endpointBuilder.ts | 2 +- .../configuration/transportConfiguration.ts | 6 +- packages/core/src/index.ts | 11 +- packages/core/src/transport/index.ts | 1 + packages/debugger/LICENSE | 201 ++++ packages/debugger/README.md | 35 + packages/debugger/package.json | 38 + packages/debugger/src/domain/activeEntries.ts | 31 + packages/debugger/src/domain/api.spec.ts | 653 +++++++++++++ packages/debugger/src/domain/api.ts | 331 +++++++ packages/debugger/src/domain/capture.spec.ts | 556 +++++++++++ packages/debugger/src/domain/capture.ts | 497 ++++++++++ .../debugger/src/domain/condition.spec.ts | 178 ++++ packages/debugger/src/domain/condition.ts | 73 ++ .../debugger/src/domain/deliveryApi.spec.ts | 263 ++++++ packages/debugger/src/domain/deliveryApi.ts | 146 +++ .../debugger/src/domain/expression.spec.ts | 321 +++++++ packages/debugger/src/domain/expression.ts | 349 +++++++ packages/debugger/src/domain/probes.spec.ts | 471 ++++++++++ packages/debugger/src/domain/probes.ts | 272 ++++++ .../debugger/src/domain/stacktrace.spec.ts | 235 +++++ packages/debugger/src/domain/stacktrace.ts | 61 ++ packages/debugger/src/domain/template.spec.ts | 434 +++++++++ packages/debugger/src/domain/template.ts | 248 +++++ packages/debugger/src/entries/main.spec.ts | 11 + packages/debugger/src/entries/main.ts | 138 +++ .../src/transport/startDebuggerBatch.ts | 53 ++ packages/debugger/test/expressionTestCases.ts | 820 ++++++++++++++++ packages/debugger/test/index.ts | 1 + scripts/build/build-test-apps.ts | 9 +- scripts/dev-server/lib/server.ts | 2 +- test/apps/.gitignore | 2 + test/apps/instrumentation-overhead/index.html | 13 + .../instrumentation-overhead/package.json | 22 + test/apps/instrumentation-overhead/src/app.ts | 23 + .../instrumentation-overhead/src/functions.ts | 12 + .../src/instrumented.ts | 43 + .../instrumentation-overhead/tsconfig.json | 11 + .../webpack.config.js | 25 + test/apps/instrumentation-overhead/yarn.lock | 884 ++++++++++++++++++ test/apps/vanilla/app.ts | 7 + test/apps/vanilla/package.json | 9 +- test/e2e/lib/framework/createTest.ts | 14 +- test/e2e/lib/framework/httpServers.ts | 1 + test/e2e/lib/framework/index.ts | 7 +- .../lib/framework/intakeProxyMiddleware.ts | 40 +- test/e2e/lib/framework/intakeRegistry.ts | 17 + test/e2e/lib/framework/pageSetups.ts | 38 +- test/e2e/lib/framework/serverApps/mock.ts | 8 + test/e2e/lib/helpers/configuration.ts | 6 + test/e2e/lib/types/global.ts | 2 + test/e2e/scenario/debugger.scenario.ts | 306 ++++++ test/performance/createBenchmarkTest.ts | 117 ++- test/performance/profiling.type.ts | 4 + .../instrumentationOverhead.scenario.ts | 66 ++ test/performance/server.ts | 1 + test/unit/browsers.conf.ts | 5 + tsconfig.base.json | 4 +- typedoc.json | 2 +- yarn.lock | 11 +- 64 files changed, 8142 insertions(+), 29 deletions(-) create mode 100644 packages/debugger/LICENSE create mode 100644 packages/debugger/README.md create mode 100644 packages/debugger/package.json create mode 100644 packages/debugger/src/domain/activeEntries.ts create mode 100644 packages/debugger/src/domain/api.spec.ts create mode 100644 packages/debugger/src/domain/api.ts create mode 100644 packages/debugger/src/domain/capture.spec.ts create mode 100644 packages/debugger/src/domain/capture.ts create mode 100644 packages/debugger/src/domain/condition.spec.ts create mode 100644 packages/debugger/src/domain/condition.ts create mode 100644 packages/debugger/src/domain/deliveryApi.spec.ts create mode 100644 packages/debugger/src/domain/deliveryApi.ts create mode 100644 packages/debugger/src/domain/expression.spec.ts create mode 100644 packages/debugger/src/domain/expression.ts create mode 100644 packages/debugger/src/domain/probes.spec.ts create mode 100644 packages/debugger/src/domain/probes.ts create mode 100644 packages/debugger/src/domain/stacktrace.spec.ts create mode 100644 packages/debugger/src/domain/stacktrace.ts create mode 100644 packages/debugger/src/domain/template.spec.ts create mode 100644 packages/debugger/src/domain/template.ts create mode 100644 packages/debugger/src/entries/main.spec.ts create mode 100644 packages/debugger/src/entries/main.ts create mode 100644 packages/debugger/src/transport/startDebuggerBatch.ts create mode 100644 packages/debugger/test/expressionTestCases.ts create mode 100644 packages/debugger/test/index.ts create mode 100644 test/apps/instrumentation-overhead/index.html create mode 100644 test/apps/instrumentation-overhead/package.json create mode 100644 test/apps/instrumentation-overhead/src/app.ts create mode 100644 test/apps/instrumentation-overhead/src/functions.ts create mode 100644 test/apps/instrumentation-overhead/src/instrumented.ts create mode 100644 test/apps/instrumentation-overhead/tsconfig.json create mode 100644 test/apps/instrumentation-overhead/webpack.config.js create mode 100644 test/apps/instrumentation-overhead/yarn.lock create mode 100644 test/e2e/scenario/debugger.scenario.ts create mode 100644 test/performance/scenarios/instrumentationOverhead.scenario.ts 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: