diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 59779568a..ef0a41aca 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -40,3 +40,6 @@ packages/plugins/async-queue @yoannmoin # Output packages/plugins/output @yoannmoinet + +# Live Debugger +packages/plugins/live-debugger/ @DataDog/rum-browser diff --git a/.yarn/cache/@babel-generator-npm-7.28.5-fd8f3ae6b1-ae618f0a17.zip b/.yarn/cache/@babel-generator-npm-7.28.5-fd8f3ae6b1-ae618f0a17.zip new file mode 100644 index 000000000..3b704f2c2 Binary files /dev/null and b/.yarn/cache/@babel-generator-npm-7.28.5-fd8f3ae6b1-ae618f0a17.zip differ diff --git a/.yarn/cache/@babel-helper-globals-npm-7.28.0-8d79c12faf-91445f7edf.zip b/.yarn/cache/@babel-helper-globals-npm-7.28.0-8d79c12faf-91445f7edf.zip new file mode 100644 index 000000000..17e2b2cd1 Binary files /dev/null and b/.yarn/cache/@babel-helper-globals-npm-7.28.0-8d79c12faf-91445f7edf.zip differ diff --git a/.yarn/cache/@babel-helper-validator-identifier-npm-7.28.5-1953d49d2b-8e5d9b0133.zip b/.yarn/cache/@babel-helper-validator-identifier-npm-7.28.5-1953d49d2b-8e5d9b0133.zip new file mode 100644 index 000000000..c67a0ac56 Binary files /dev/null and b/.yarn/cache/@babel-helper-validator-identifier-npm-7.28.5-1953d49d2b-8e5d9b0133.zip differ diff --git a/.yarn/cache/@babel-parser-npm-7.28.5-f2345a6b62-8d9bfb437a.zip b/.yarn/cache/@babel-parser-npm-7.28.5-f2345a6b62-8d9bfb437a.zip new file mode 100644 index 000000000..31eab620f Binary files /dev/null and b/.yarn/cache/@babel-parser-npm-7.28.5-f2345a6b62-8d9bfb437a.zip differ diff --git a/.yarn/cache/@babel-traverse-npm-7.28.5-2b51d83636-1fce426f5e.zip b/.yarn/cache/@babel-traverse-npm-7.28.5-2b51d83636-1fce426f5e.zip new file mode 100644 index 000000000..ee7bbbe50 Binary files /dev/null and b/.yarn/cache/@babel-traverse-npm-7.28.5-2b51d83636-1fce426f5e.zip differ diff --git a/.yarn/cache/@babel-types-npm-7.28.5-582d7cca8a-4256bb9fb2.zip b/.yarn/cache/@babel-types-npm-7.28.5-582d7cca8a-4256bb9fb2.zip new file mode 100644 index 000000000..6938c8cff Binary files /dev/null and b/.yarn/cache/@babel-types-npm-7.28.5-582d7cca8a-4256bb9fb2.zip differ diff --git a/.yarn/cache/@jridgewell-gen-mapping-npm-0.3.13-9bd96ac800-902f8261dc.zip b/.yarn/cache/@jridgewell-gen-mapping-npm-0.3.13-9bd96ac800-902f8261dc.zip new file mode 100644 index 000000000..e130971fd Binary files /dev/null and b/.yarn/cache/@jridgewell-gen-mapping-npm-0.3.13-9bd96ac800-902f8261dc.zip differ diff --git a/.yarn/cache/@jridgewell-trace-mapping-npm-0.3.31-1ae81d75ac-da0283270e.zip b/.yarn/cache/@jridgewell-trace-mapping-npm-0.3.31-1ae81d75ac-da0283270e.zip new file mode 100644 index 000000000..d61ababcd Binary files /dev/null and b/.yarn/cache/@jridgewell-trace-mapping-npm-0.3.31-1ae81d75ac-da0283270e.zip differ diff --git a/.yarn/cache/@types-babel__generator-npm-7.27.0-a5af33547a-f572e67a9a.zip b/.yarn/cache/@types-babel__generator-npm-7.27.0-a5af33547a-f572e67a9a.zip new file mode 100644 index 000000000..65cc4dd80 Binary files /dev/null and b/.yarn/cache/@types-babel__generator-npm-7.27.0-a5af33547a-f572e67a9a.zip differ diff --git a/.yarn/cache/@types-babel__traverse-npm-7.28.0-44a48c1b20-371c5e1b40.zip b/.yarn/cache/@types-babel__traverse-npm-7.28.0-44a48c1b20-371c5e1b40.zip new file mode 100644 index 000000000..554380a9b Binary files /dev/null and b/.yarn/cache/@types-babel__traverse-npm-7.28.0-44a48c1b20-371c5e1b40.zip differ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f3a3908b0..6c5c02e67 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -10,6 +10,8 @@ import type { TrackedFilesMatcher } from '@dd/internal-git-plugin/trackedFilesMa // #imports-injection-marker import type { ErrorTrackingOptions } from '@dd/error-tracking-plugin/types'; import type * as errorTracking from '@dd/error-tracking-plugin'; +import type { LiveDebuggerOptions } from '@dd/live-debugger-plugin/types'; +import type * as liveDebugger from '@dd/live-debugger-plugin'; import type { MetricsOptions } from '@dd/metrics-plugin/types'; import type * as metrics from '@dd/metrics-plugin'; import type { OutputOptions } from '@dd/output-plugin/types'; @@ -255,6 +257,7 @@ export interface Options extends BaseOptions { // Each product should have a unique entry. // #types-injection-marker [errorTracking.CONFIG_KEY]?: ErrorTrackingOptions; + [liveDebugger.CONFIG_KEY]?: LiveDebuggerOptions; [metrics.CONFIG_KEY]?: MetricsOptions; [output.CONFIG_KEY]?: OutputOptions; [rum.CONFIG_KEY]?: RumOptions; diff --git a/packages/plugins/live-debugger/README.md b/packages/plugins/live-debugger/README.md new file mode 100644 index 000000000..058897c4b --- /dev/null +++ b/packages/plugins/live-debugger/README.md @@ -0,0 +1,107 @@ +# Live Debugger Plugin + +Automatically instrument JavaScript functions at build time to enable Live Debugger without requiring code rebuilds. + + + +## Table of content + + + + +- [Configuration](#configuration) +- [How it works](#how-it-works) + - [liveDebugger.enable](#livedebuggerenable) + - [liveDebugger.include](#livedebuggerinclude) + - [liveDebugger.exclude](#livedebuggerexclude) + - [liveDebugger.skipHotFunctions](#livedebuggerskiphotfunctions) + + +## Configuration + +```ts +liveDebugger?: { + enable?: boolean; + include?: (string | RegExp)[]; + exclude?: (string | RegExp)[]; + skipHotFunctions?: boolean; +} +``` + +## How it works + +The Live Debugger plugin automatically instruments all JavaScript functions in your application at build time. It adds lightweight checks that can be activated at runtime without rebuilding your code. + +Each instrumented function gets: +- A unique, stable function ID (format: `;`) +- A `$dd_probes()` call that returns active probes for that function (or `undefined` if none) +- Entry point tracking with parameter capture via `$dd_entry()` +- Return value tracking with local variable capture via `$dd_return()` +- Exception tracking with variable state at throw time via `$dd_throw()` + +The instrumentation checks whether probes are active by calling `$dd_probes(functionId)`. When no probes are active, the function returns `undefined` and all instrumentation is skipped. This approach reduces bundle size by eliminating the need for individual global variables per function. + +**Example transformation:** + +```javascript +// Before +function add(a, b) { + const sum = a + b; + return sum; +} + +// After +function add(a, b) { + const $dd_p = $dd_probes('src/utils.js;add'); + try { + if ($dd_p) $dd_entry($dd_p, this, { a, b }); + const sum = a + b; + return $dd_p ? $dd_return($dd_p, sum, this, { a, b }, { sum }) : sum; + } catch (e) { + if ($dd_p) $dd_throw($dd_p, e, this, { a, b }); + throw e; + } +} +``` + +### liveDebugger.enable + +> default: `false` + +Enable or disable Live Debugger. When enabled, all matching JavaScript files will be instrumented at build time. + +### liveDebugger.include + +> default: `[/\.[jt]sx?$/]` + +Array of file patterns (strings or RegExp) to include for instrumentation. By default, all JavaScript and TypeScript files (`.js`, `.jsx`, `.ts`, `.tsx`) are included. + +### liveDebugger.exclude + +> default: `[/\/node_modules\//, /\.min\.js$/, /^vite\//, /\0/, /commonjsHelpers\.js$/, /__vite-browser-external/]` + +Array of file patterns (strings or RegExp) to exclude from instrumentation. By default, the following are excluded: +- `node_modules` - Third-party dependencies +- Minified files (`.min.js`) +- Vite internal modules (e.g., `vite/modulepreload-polyfill`) +- Virtual modules (Rollup/Vite convention using null byte prefix) +- Rollup commonjs helpers +- Vite browser externals + +### liveDebugger.skipHotFunctions + +> default: `true` + +Skip instrumentation of functions marked with the `// @dd-no-instrumentation` comment. This is useful for performance-critical functions where even the no-op overhead should be avoided. + +**Example:** + +```javascript +// @dd-no-instrumentation +function hotPath() { + // This function will not be instrumented +} +``` + +> [!NOTE] +> Live Debugger requires the RUM SDK to be loaded for the runtime helper functions (`$dd_probes`, `$dd_entry`, `$dd_return`, `$dd_throw`). These are automatically injected when both `liveDebugger.enable` and RUM are configured. diff --git a/packages/plugins/live-debugger/package.json b/packages/plugins/live-debugger/package.json new file mode 100644 index 000000000..be60924ed --- /dev/null +++ b/packages/plugins/live-debugger/package.json @@ -0,0 +1,35 @@ +{ + "name": "@dd/live-debugger-plugin", + "packageManager": "yarn@4.0.2", + "license": "MIT", + "private": true, + "author": "Datadog", + "description": "Instruments JavaScript functions at build time for Live Debugger.", + "homepage": "https://github.com/DataDog/build-plugins/tree/main/packages/plugins/live-debugger#readme", + "repository": { + "type": "git", + "url": "https://github.com/DataDog/build-plugins", + "directory": "packages/plugins/live-debugger" + }, + "exports": { + ".": "./src/index.ts", + "./*": "./src/*.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@babel/generator": "^7.23.0", + "@babel/parser": "^7.23.0", + "@babel/traverse": "^7.23.0", + "@babel/types": "^7.23.0", + "@dd/core": "workspace:*", + "chalk": "2.3.1" + }, + "devDependencies": { + "@types/babel__core": "^7.20.0", + "@types/babel__generator": "^7.6.0", + "@types/babel__traverse": "^7.20.0", + "typescript": "5.4.3" + } +} diff --git a/packages/plugins/live-debugger/src/constants.ts b/packages/plugins/live-debugger/src/constants.ts new file mode 100644 index 000000000..137408d06 --- /dev/null +++ b/packages/plugins/live-debugger/src/constants.ts @@ -0,0 +1,11 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { PluginName } from '@dd/core/types'; + +export const CONFIG_KEY = 'liveDebugger' as const; +export const PLUGIN_NAME: PluginName = 'datadog-live-debugger-plugin' as const; + +// Skip instrumentation comment +export const SKIP_INSTRUMENTATION_COMMENT = '@dd-no-instrumentation'; diff --git a/packages/plugins/live-debugger/src/index.ts b/packages/plugins/live-debugger/src/index.ts new file mode 100644 index 000000000..dfe3fc7a9 --- /dev/null +++ b/packages/plugins/live-debugger/src/index.ts @@ -0,0 +1,101 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { GetPlugins, GlobalContext, PluginOptions } from '@dd/core/types'; + +import { CONFIG_KEY, PLUGIN_NAME } from './constants'; +import { transformCode } from './transform'; +import type { LiveDebuggerOptions, LiveDebuggerOptionsWithDefaults } from './types'; +import { validateOptions } from './validate'; + +export { CONFIG_KEY, PLUGIN_NAME }; + +// Export types for factory integration +export type types = { + LiveDebuggerOptions: LiveDebuggerOptions; +}; + +export const getLiveDebuggerPlugin = ( + pluginOptions: LiveDebuggerOptionsWithDefaults, + context: GlobalContext, +): PluginOptions => { + const log = context.getLogger(PLUGIN_NAME); + + let instrumentedCount = 0; + let totalFunctions = 0; + let fileCount = 0; + + return { + name: PLUGIN_NAME, + // Enforce when the plugin will be executed. + // Not supported by Rollup and ESBuild. + // https://vitejs.dev/guide/api-plugin.html#plugin-ordering + enforce: 'post', + transform: { + filter: { + id: { + include: pluginOptions.include, + exclude: pluginOptions.exclude, + }, + }, + handler(code, id) { + try { + const result = transformCode({ + code, + filePath: id, + buildRoot: context.buildRoot, + skipHotFunctions: pluginOptions.skipHotFunctions, + }); + + if (result.instrumentedCount === 0) { + return { + // No changes, return original code + code, + }; + } + + instrumentedCount += result.instrumentedCount; + totalFunctions += result.totalFunctions; + fileCount++; + + return { + code: result.code, + map: result.map, + }; + } catch (e) { + log.error(`Instrumentation Error in ${id}: ${e}`, { forward: true }); + return { + code, + }; + } + }, + }, + buildEnd: () => { + if (instrumentedCount > 0) { + log.info( + `Live Debugger: ${instrumentedCount}/${totalFunctions} functions instrumented across ${fileCount} files`, + { + forward: true, + context: { + instrumentedCount, + totalFunctions, + fileCount, + }, + }, + ); + } + }, + }; +}; + +export const getPlugins: GetPlugins = ({ options, context }) => { + const log = context.getLogger(PLUGIN_NAME); + const validatedOptions = validateOptions(options, log); + + if (!validatedOptions.enable) { + return []; + } + + return [getLiveDebuggerPlugin(validatedOptions, context)]; +}; diff --git a/packages/plugins/live-debugger/src/transform/functionId.ts b/packages/plugins/live-debugger/src/transform/functionId.ts new file mode 100644 index 000000000..257498612 --- /dev/null +++ b/packages/plugins/live-debugger/src/transform/functionId.ts @@ -0,0 +1,106 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +// @ts-nocheck - Babel type conflicts between @babel/parser and @babel/types versions +import type { NodePath } from '@babel/traverse'; +import * as t from '@babel/types'; +import path from 'path'; + +/** + * Generate a stable, unique function ID + * Format (POC): ; + * Example: src/utils.js;add + * + * NOTE: This POC format only supports uniquely named functions. + * Anonymous functions will use the format ;: + */ +export function generateFunctionId( + filePath: string, + buildRoot: string, + functionPath: NodePath, +): string { + const relativePath = path.relative(buildRoot, filePath).replace(/\\/g, '/'); + const functionName = getFunctionName(functionPath); + + if (functionName) { + // Named function: file.js;functionName + return `${relativePath};${functionName}`; + } else { + // Anonymous function: file.js;:index + const index = countPreviousAnonymousSiblings(functionPath); + return `${relativePath};:${index}`; + } +} + +/** + * Get the name of a function if available + */ +function getFunctionName(functionPath: NodePath): string | null { + const node = functionPath.node; + const parent = functionPath.parent; + + // Named function declaration: function foo() {} + if (t.isIdentifier(node.id)) { + return node.id.name; + } + + // Object/Class method: { foo() {} } or class { foo() {} } + if ((t.isObjectMethod(node) || t.isClassMethod(node)) && t.isIdentifier(node.key)) { + return node.key.name; + } + + // Variable declaration: const foo = () => {} + if (t.isVariableDeclarator(parent) && t.isIdentifier(parent.id)) { + return parent.id.name; + } + + // Assignment: foo = () => {} + if (t.isAssignmentExpression(parent) && t.isIdentifier(parent.left)) { + return parent.left.name; + } + + // Object property: { foo: () => {} } + if (t.isObjectProperty(parent) && t.isIdentifier(parent.key)) { + return parent.key.name; + } + + return null; +} + +/** + * Count anonymous functions before this one at the same parent level + */ +function countPreviousAnonymousSiblings(functionPath: NodePath): number { + const parent = functionPath.parentPath; + if (!parent) { + return 0; + } + + let count = 0; + const targetNode = functionPath.node; + + // Find all function children of the parent + parent.traverse({ + Function(fnPath: NodePath) { + // Don't traverse into nested functions + if (fnPath.parentPath !== parent) { + fnPath.skip(); + return; + } + + // Stop when we reach our target function + if (fnPath.node === targetNode) { + fnPath.stop(); + return; + } + + // Count if it's anonymous + if (!getFunctionName(fnPath)) { + count++; + } + }, + }); + + return count; +} diff --git a/packages/plugins/live-debugger/src/transform/index.ts b/packages/plugins/live-debugger/src/transform/index.ts new file mode 100644 index 000000000..68c19e7ee --- /dev/null +++ b/packages/plugins/live-debugger/src/transform/index.ts @@ -0,0 +1,91 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +// @ts-nocheck - Babel type conflicts between @babel/parser and @babel/types versions +// Use require for better CommonJS/ESM compatibility with Babel packages +import { SKIP_INSTRUMENTATION_COMMENT } from '../constants'; + +import { generateFunctionId } from './functionId'; +import { canInstrumentFunction, instrumentFunction, shouldSkipFunction } from './instrumentation'; + +const generate = require('@babel/generator').default; +const { parse } = require('@babel/parser'); +const traverse = require('@babel/traverse').default; + +export interface TransformOptions { + code: string; + filePath: string; + buildRoot: string; + skipHotFunctions: boolean; +} + +export interface TransformResult { + code: string; + map?: any; + instrumentedCount: number; + totalFunctions: number; +} + +/** + * Transform JavaScript code to add Dynamic Instrumentation + * Uses Babel to parse, transform, and generate code + */ +export function transformCode(options: TransformOptions): TransformResult { + const { code, filePath, buildRoot, skipHotFunctions } = options; + + let instrumentedCount = 0; + let totalFunctions = 0; + + // Parse the code + const ast = parse(code, { + sourceType: 'module', + plugins: ['jsx', 'typescript'], + sourceFilename: filePath, + }); + + // Traverse and instrument functions + traverse(ast, { + Function(path) { + totalFunctions++; + + // Check if we should skip this function + if (!canInstrumentFunction(path)) { + return; + } + + if (skipHotFunctions && shouldSkipFunction(path, SKIP_INSTRUMENTATION_COMMENT)) { + return; + } + + // Generate function ID + const functionId = generateFunctionId(filePath, buildRoot, path); + + // Instrument the function + try { + instrumentFunction(path, functionId); + instrumentedCount++; + } catch (error) { + // Skip functions that fail to instrument + // Errors are logged in debug mode + } + }, + }); + + // Generate the transformed code + const output = generate( + ast, + { + sourceMaps: true, + sourceFileName: filePath, + }, + code, + ); + + return { + code: output.code, + map: output.map, + instrumentedCount, + totalFunctions, + }; +} diff --git a/packages/plugins/live-debugger/src/transform/instrumentation.ts b/packages/plugins/live-debugger/src/transform/instrumentation.ts new file mode 100644 index 000000000..c417a76d2 --- /dev/null +++ b/packages/plugins/live-debugger/src/transform/instrumentation.ts @@ -0,0 +1,310 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +// @ts-nocheck - Babel type conflicts between @babel/parser and @babel/types versions +import type { NodePath } from '@babel/traverse'; +import * as t from '@babel/types'; + +import { buildVariableCaptureExpression, getVariablesToCapture } from './scopeTracker'; + +/** + * Check if a function should be skipped based on comments + */ +export function shouldSkipFunction( + functionPath: NodePath, + skipComment: string, +): boolean { + const node = functionPath.node; + + // Check leading comments + if (node.leadingComments) { + for (const comment of node.leadingComments) { + if (comment.value.includes(skipComment)) { + return true; + } + } + } + + // Check comments on parent (for function expressions/arrow functions) + if (functionPath.parentPath && functionPath.parentPath.node.leadingComments) { + for (const comment of functionPath.parentPath.node.leadingComments) { + if (comment.value.includes(skipComment)) { + return true; + } + } + } + + return false; +} + +/** + * Check if function should be instrumented + * Skips: generators, constructors, already instrumented + */ +export function canInstrumentFunction(functionPath: NodePath): boolean { + const node = functionPath.node; + + // Skip generators + if (node.generator) { + return false; + } + + // Skip constructors + if (t.isClassMethod(node) && node.kind === 'constructor') { + return false; + } + + // Skip if already has try-catch (might be already instrumented) + if (functionPath.node.body && t.isBlockStatement(functionPath.node.body)) { + const body = functionPath.node.body.body; + if ( + body.length === 1 && + t.isTryStatement(body[0]) && + body[0].handler && + body[0].handler.body.body.some( + (stmt) => t.isThrowStatement(stmt) || t.isExpressionStatement(stmt), + ) + ) { + // Looks like it might already be instrumented + return false; + } + } + + return true; +} + +// Counter for generating unique probe variable names +let probeVarCounter = 0; + +/** + * Instrument a function with Dynamic Instrumentation code + * Transforms the function body to add $dd_entry, $dd_return, $dd_throw calls + * using the new $dd_probes() pattern. + */ +export function instrumentFunction(functionPath: NodePath, functionId: string): void { + const node = functionPath.node; + + // Generate unique probe variable name for this function + const probeVarName = `$dd_p${probeVarCounter++}`; + + // Get variables to capture + const entryVars = getVariablesToCapture(functionPath, true, false); // Only params at entry + const exitVars = getVariablesToCapture(functionPath, true, true); // Params + locals at exit + + // Convert arrow function with expression body to block statement + if (t.isArrowFunctionExpression(node) && !t.isBlockStatement(node.body)) { + node.body = t.blockStatement([t.returnStatement(node.body)]); + } + + const body = node.body as t.BlockStatement; + const originalStatements = body.body; + + // Create probe variable: const $dd_p0 = $dd_probes('') + const probeVarDecl = t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier(probeVarName), + t.callExpression(t.identifier('$dd_probes'), [t.stringLiteral(functionId)]), + ), + ]); + + // Build the $dd_entry call at entry: $dd_entry($dd_pN, this, {args}) + const argsObj = buildVariableCaptureExpression(entryVars); + const startCall = t.expressionStatement( + t.callExpression(t.identifier('$dd_entry'), [ + t.identifier(probeVarName), + t.thisExpression(), + argsObj, + ]), + ); + + // Wrap start call with if ($dd_pN) + const startIfStatement = t.ifStatement(t.identifier(probeVarName), startCall); + + // Transform return statements + const transformedStatements: t.Statement[] = []; + for (const stmt of originalStatements) { + transformedStatements.push( + transformReturnStatements(stmt, functionId, probeVarName, entryVars, exitVars), + ); + } + + // Build catch block: if ($dd_pN) $dd_throw($dd_pN, error, this, args); throw e; + const argsAtThrow = buildVariableCaptureExpression(entryVars); + const catchClause = t.catchClause( + t.identifier('e'), + t.blockStatement([ + t.ifStatement( + t.identifier(probeVarName), + t.expressionStatement( + t.callExpression(t.identifier('$dd_throw'), [ + t.identifier(probeVarName), + t.identifier('e'), + t.thisExpression(), + argsAtThrow, + ]), + ), + ), + t.throwStatement(t.identifier('e')), + ]), + ); + + // Build the try-catch block + const tryStatement = t.tryStatement( + t.blockStatement([startIfStatement, ...transformedStatements]), + catchClause, + ); + + // Replace function body with instrumented version + // Structure: const $dd_p = ...; try { ... } catch { ... } + body.body = [probeVarDecl, tryStatement]; +} + +/** + * Transform return statements to wrap with $dd_return + */ +function transformReturnStatements( + statement: t.Statement, + functionId: string, + probeVarName: string, + entryVars: string[], + allVars: string[], +): t.Statement { + if (t.isReturnStatement(statement)) { + const returnValue = statement.argument || t.identifier('undefined'); + + // Build: $dd_pN ? $dd_return($dd_pN, value, this, args, locals) : value + const argsObj = buildVariableCaptureExpression(entryVars); + const localsObj = buildVariableCaptureExpression(allVars); + + const instrumentedReturn = t.conditionalExpression( + t.identifier(probeVarName), + t.callExpression(t.identifier('$dd_return'), [ + t.identifier(probeVarName), + returnValue, + t.thisExpression(), + argsObj, + localsObj, + ]), + returnValue, + ); + + return t.returnStatement(instrumentedReturn); + } + + // Recursively transform nested blocks + if (t.isBlockStatement(statement)) { + return t.blockStatement( + statement.body.map((stmt) => + transformReturnStatements(stmt, functionId, probeVarName, entryVars, allVars), + ), + ); + } + + if (t.isIfStatement(statement)) { + return t.ifStatement( + statement.test, + transformReturnStatements( + statement.consequent, + functionId, + probeVarName, + entryVars, + allVars, + ) as any, + statement.alternate + ? (transformReturnStatements( + statement.alternate, + functionId, + probeVarName, + entryVars, + allVars, + ) as any) + : undefined, + ); + } + + if (t.isWhileStatement(statement) || t.isDoWhileStatement(statement)) { + return { + ...statement, + body: transformReturnStatements( + statement.body, + functionId, + probeVarName, + entryVars, + allVars, + ) as any, + }; + } + + if ( + t.isForStatement(statement) || + t.isForInStatement(statement) || + t.isForOfStatement(statement) + ) { + return { + ...statement, + body: transformReturnStatements( + statement.body, + functionId, + probeVarName, + entryVars, + allVars, + ) as any, + }; + } + + if (t.isSwitchStatement(statement)) { + return t.switchStatement( + statement.discriminant, + statement.cases.map((caseClause) => + t.switchCase( + caseClause.test, + caseClause.consequent.map((stmt) => + transformReturnStatements( + stmt, + functionId, + probeVarName, + entryVars, + allVars, + ), + ), + ), + ), + ); + } + + if (t.isTryStatement(statement)) { + return t.tryStatement( + transformReturnStatements( + statement.block, + functionId, + probeVarName, + entryVars, + allVars, + ) as any, + statement.handler + ? t.catchClause( + statement.handler.param, + transformReturnStatements( + statement.handler.body, + functionId, + probeVarName, + entryVars, + allVars, + ) as any, + ) + : null, + statement.finalizer + ? (transformReturnStatements( + statement.finalizer, + functionId, + probeVarName, + entryVars, + allVars, + ) as any) + : undefined, + ); + } + + return statement; +} diff --git a/packages/plugins/live-debugger/src/transform/scopeTracker.ts b/packages/plugins/live-debugger/src/transform/scopeTracker.ts new file mode 100644 index 000000000..0ef03aceb --- /dev/null +++ b/packages/plugins/live-debugger/src/transform/scopeTracker.ts @@ -0,0 +1,85 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +// @ts-nocheck - Babel type conflicts between @babel/parser and @babel/types versions +import type { NodePath } from '@babel/traverse'; +import * as t from '@babel/types'; + +export interface ScopeVariables { + params: string[]; + locals: string[]; +} + +/** + * Extract all variables in scope for a function + * This includes parameters and local variable declarations + */ +export function extractScopeVariables(functionPath: NodePath): ScopeVariables { + const params: string[] = []; + const locals: string[] = []; + + // Extract parameters + functionPath.node.params.forEach((param) => { + if (t.isIdentifier(param)) { + params.push(param.name); + } else if (t.isRestElement(param) && t.isIdentifier(param.argument)) { + params.push(param.argument.name); + } else if (t.isAssignmentPattern(param) && t.isIdentifier(param.left)) { + params.push(param.left.name); + } + // Note: destructuring patterns are more complex and we'll skip them for now + }); + + // Extract local variables from bindings + const bindings = functionPath.scope.bindings; + Object.keys(bindings).forEach((name) => { + const binding = bindings[name]; + // Skip parameters (already added) and skip function name itself + if ( + !params.includes(name) && + name !== (t.isIdentifier(functionPath.node.id) ? functionPath.node.id.name : '') + ) { + // Only include var/let/const declarations + if (['var', 'let', 'const'].includes(binding.kind)) { + locals.push(name); + } + } + }); + + return { params, locals }; +} + +/** + * Build an object expression capturing the specified variables + * Returns: { a, b, c } as an AST node + */ +export function buildVariableCaptureExpression(variables: string[]): t.ObjectExpression { + return t.objectExpression( + variables.map((name) => + t.objectProperty(t.identifier(name), t.identifier(name), false, true), + ), + ); +} + +/** + * Get all variables that should be captured at a specific point + * (for use at function entry, return, or throw) + */ +export function getVariablesToCapture( + functionPath: NodePath, + includeParams: boolean = true, + includeLocals: boolean = true, +): string[] { + const { params, locals } = extractScopeVariables(functionPath); + const variables: string[] = []; + + if (includeParams) { + variables.push(...params); + } + if (includeLocals) { + variables.push(...locals); + } + + return variables; +} diff --git a/packages/plugins/live-debugger/src/types.ts b/packages/plugins/live-debugger/src/types.ts new file mode 100644 index 000000000..e5a239cbb --- /dev/null +++ b/packages/plugins/live-debugger/src/types.ts @@ -0,0 +1,17 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +export type LiveDebuggerOptions = { + enable?: boolean; + include?: (string | RegExp)[]; + exclude?: (string | RegExp)[]; + skipHotFunctions?: boolean; // Honor @dd-no-instrumentation comments +}; + +export type LiveDebuggerOptionsWithDefaults = { + enable: boolean; + include: (string | RegExp)[]; + exclude: (string | RegExp)[]; + skipHotFunctions: boolean; +}; diff --git a/packages/plugins/live-debugger/src/validate.ts b/packages/plugins/live-debugger/src/validate.ts new file mode 100644 index 000000000..4954dfccd --- /dev/null +++ b/packages/plugins/live-debugger/src/validate.ts @@ -0,0 +1,75 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { Logger, Options } from '@dd/core/types'; +import chalk from 'chalk'; + +import { CONFIG_KEY, PLUGIN_NAME } from './constants'; +import type { LiveDebuggerOptions, LiveDebuggerOptionsWithDefaults } from './types'; + +const red = chalk.bold.red; + +export const validateOptions = (config: Options, log: Logger): LiveDebuggerOptionsWithDefaults => { + const pluginConfig: LiveDebuggerOptions = config[CONFIG_KEY] || {}; + const errors: string[] = []; + + // Validate include option + if (pluginConfig.include !== undefined) { + if (!Array.isArray(pluginConfig.include)) { + errors.push(`${red('include')} must be an array of strings or RegExp`); + } else { + for (const pattern of pluginConfig.include) { + if (typeof pattern !== 'string' && !(pattern instanceof RegExp)) { + errors.push(`${red('include')} patterns must be strings or RegExp`); + break; + } + } + } + } + + // Validate exclude option + if (pluginConfig.exclude !== undefined) { + if (!Array.isArray(pluginConfig.exclude)) { + errors.push(`${red('exclude')} must be an array of strings or RegExp`); + } else { + for (const pattern of pluginConfig.exclude) { + if (typeof pattern !== 'string' && !(pattern instanceof RegExp)) { + errors.push(`${red('exclude')} patterns must be strings or RegExp`); + break; + } + } + } + } + + // Validate skipHotFunctions option + if ( + pluginConfig.skipHotFunctions !== undefined && + typeof pluginConfig.skipHotFunctions !== 'boolean' + ) { + errors.push(`${red('skipHotFunctions')} must be a boolean`); + } + + // Throw if there are any errors + if (errors.length) { + log.error(`\n - ${errors.join('\n - ')}`); + throw new Error(`Invalid configuration for ${PLUGIN_NAME}.`); + } + + // Build the final configuration with defaults + return { + enable: !!pluginConfig.enable, + include: pluginConfig.include || [/\.[jt]sx?$/], // .js, .jsx, .ts, .tsx + exclude: pluginConfig.exclude || [ + /\/node_modules\//, + /\.min\.js$/, + /^vite\//, // Vite internal modules + /\0/, // Virtual modules (Rollup/Vite convention) + /commonjsHelpers\.js$/, // Rollup commonjs helpers + /__vite-browser-external/, // Vite browser externals + /@datadog\/browser-/, // Datadog browser SDK packages (when npm linked) + /browser-sdk\/packages\//, // Datadog browser SDK source files + ], + skipHotFunctions: pluginConfig.skipHotFunctions ?? true, + }; +}; diff --git a/packages/plugins/live-debugger/tsconfig.json b/packages/plugins/live-debugger/tsconfig.json new file mode 100644 index 000000000..32b367efa --- /dev/null +++ b/packages/plugins/live-debugger/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./", + "outDir": "./dist" + }, + "include": ["**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/plugins/rum/src/built/live-debugger-helpers.ts b/packages/plugins/rum/src/built/live-debugger-helpers.ts new file mode 100644 index 000000000..5b418eba7 --- /dev/null +++ b/packages/plugins/rum/src/built/live-debugger-helpers.ts @@ -0,0 +1,120 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* eslint-env browser */ +/* global globalThis, Proxy */ +/* eslint-disable no-console */ + +/** + * Dynamic Instrumentation Runtime Helpers + * + * These functions provide the runtime support for Dynamic Instrumentation. + * They are injected into the browser bundle and provide no-op stubs by default. + */ + +const globalDI: any = globalThis; + +// Global instrumentation state +globalDI.$dd_instrumentation = globalDI.$dd_instrumentation || { + enabled: new Map(), + handlers: { + start: (id: string, vars: any) => { + if (globalDI.$dd_instrumentation?.debug) { + console.log('[DD] Function start:', id, vars); + } + }, + return: (id: string, returnValue: any, vars: any) => { + if (globalDI.$dd_instrumentation?.debug) { + console.log('[DD] Function return:', id, returnValue, vars); + } + return returnValue; + }, + throw: (id: string, error: any, vars: any) => { + if (globalDI.$dd_instrumentation?.debug) { + console.error('[DD] Function throw:', id, error, vars); + } + }, + }, + debug: false, +}; + +// Global runtime functions +globalDI.$dd_start = (id: string, vars: any) => { + try { + globalDI.$dd_instrumentation?.handlers.start(id, vars); + } catch (e) { + if (globalDI.$dd_instrumentation?.debug) { + console.error('[DD] Error in $dd_start:', e); + } + } +}; + +globalDI.$dd_return = (id: string, returnValue: any, vars: any) => { + try { + return globalDI.$dd_instrumentation?.handlers.return(id, returnValue, vars); + } catch (e) { + if (globalDI.$dd_instrumentation?.debug) { + console.error('[DD] Error in $dd_return:', e); + } + return returnValue; + } +}; + +globalDI.$dd_throw = (id: string, error: any, vars: any) => { + try { + globalDI.$dd_instrumentation?.handlers.throw(id, error, vars); + } catch (e) { + if (globalDI.$dd_instrumentation?.debug) { + console.error('[DD] Error in $dd_throw:', e); + } + } +}; + +// API functions +globalDI.$dd_enableProbe = (id: string) => { + if (globalDI.$dd_instrumentation) { + globalDI.$dd_instrumentation.enabled.set(id, true); + if (globalDI.$dd_instrumentation.debug) { + console.log('[DD] Enabled probe:', id); + } + } +}; + +globalDI.$dd_disableProbe = (id: string) => { + if (globalDI.$dd_instrumentation) { + globalDI.$dd_instrumentation.enabled.set(id, false); + if (globalDI.$dd_instrumentation.debug) { + console.log('[DD] Disabled probe:', id); + } + } +}; + +globalDI.$dd_enableDebug = () => { + if (globalDI.$dd_instrumentation) { + globalDI.$dd_instrumentation.debug = true; + console.log('[DD] Debug mode enabled'); + } +}; + +// Create global probe flags dynamically +// Each instrumented function checks its own $dd_ flag +const probeHandler = { + get(target: any, prop: string) { + if (prop.startsWith('$dd_')) { + // All probe flags default to false (no-op) + // Will be activated via Remote Config in future implementation + return false; + } + return target[prop]; + }, +}; + +// Apply proxy to globalThis for dynamic probe flags +if (typeof Proxy !== 'undefined') { + try { + Object.setPrototypeOf(globalDI, new Proxy(Object.getPrototypeOf(globalDI), probeHandler)); + } catch (e) { + // Proxy not supported or failed, probe flags will be undefined (falsy) + } +} diff --git a/yarn.lock b/yarn.lock index 436c7ae56..0d27608cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -103,6 +103,19 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.23.0, @babel/generator@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/generator@npm:7.28.5" + dependencies: + "@babel/parser": "npm:^7.28.5" + "@babel/types": "npm:^7.28.5" + "@jridgewell/gen-mapping": "npm:^0.3.12" + "@jridgewell/trace-mapping": "npm:^0.3.28" + jsesc: "npm:^3.0.2" + checksum: 10/ae618f0a17a6d76c3983e1fd5d9c2f5fdc07703a119efdb813a7d9b8ad4be0a07d4c6f0d718440d2de01a68e321f64e2d63c77fc5d43ae47ae143746ef28ac1f + languageName: node + linkType: hard + "@babel/generator@npm:^7.24.5": version: 7.24.5 resolution: "@babel/generator@npm:7.24.5" @@ -236,6 +249,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-globals@npm:^7.28.0": + version: 7.28.0 + resolution: "@babel/helper-globals@npm:7.28.0" + checksum: 10/91445f7edfde9b65dcac47f4f858f68dc1661bf73332060ab67ad7cc7b313421099a2bfc4bda30c3db3842cfa1e86fffbb0d7b2c5205a177d91b22c8d7d9cb47 + languageName: node + linkType: hard + "@babel/helper-hoist-variables@npm:^7.22.5": version: 7.22.5 resolution: "@babel/helper-hoist-variables@npm:7.22.5" @@ -405,6 +425,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-identifier@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/helper-validator-identifier@npm:7.28.5" + checksum: 10/8e5d9b0133702cfacc7f368bf792f0f8ac0483794877c6dca5fcb73810ee138e27527701826fb58a40a004f3a5ec0a2f3c3dd5e326d262530b119918f3132ba7 + languageName: node + linkType: hard + "@babel/helper-validator-option@npm:^7.23.5": version: 7.23.5 resolution: "@babel/helper-validator-option@npm:7.23.5" @@ -472,6 +499,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.23.0, @babel/parser@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/parser@npm:7.28.5" + dependencies: + "@babel/types": "npm:^7.28.5" + bin: + parser: ./bin/babel-parser.js + checksum: 10/8d9bfb437af6c97a7f6351840b9ac06b4529ba79d6d3def24d6c2996ab38ff7f1f9d301e868ca84a93a3050fadb3d09dbc5105b24634cd281671ac11eebe8df7 + languageName: node + linkType: hard + "@babel/parser@npm:^7.27.2, @babel/parser@npm:^7.27.4, @babel/parser@npm:^7.27.5": version: 7.27.5 resolution: "@babel/parser@npm:7.27.5" @@ -1555,6 +1593,21 @@ __metadata: languageName: node linkType: hard +"@babel/traverse@npm:^7.23.0": + version: 7.28.5 + resolution: "@babel/traverse@npm:7.28.5" + dependencies: + "@babel/code-frame": "npm:^7.27.1" + "@babel/generator": "npm:^7.28.5" + "@babel/helper-globals": "npm:^7.28.0" + "@babel/parser": "npm:^7.28.5" + "@babel/template": "npm:^7.27.2" + "@babel/types": "npm:^7.28.5" + debug: "npm:^4.3.1" + checksum: 10/1fce426f5ea494913c40f33298ce219708e703f71cac7ac045ebde64b5a7b17b9275dfa4e05fb92c3f123136913dff62c8113172f4a5de66dab566123dbe7437 + languageName: node + linkType: hard + "@babel/traverse@npm:^7.24.5": version: 7.24.5 resolution: "@babel/traverse@npm:7.24.5" @@ -1609,6 +1662,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.28.2, @babel/types@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/types@npm:7.28.5" + dependencies: + "@babel/helper-string-parser": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.28.5" + checksum: 10/4256bb9fb2298c4f9b320bde56e625b7091ea8d2433d98dcf524d4086150da0b6555aabd7d0725162670614a9ac5bf036d1134ca13dedc9707f988670f1362d7 + languageName: node + linkType: hard + "@bcoe/v8-coverage@npm:^0.2.3": version: 0.2.3 resolution: "@bcoe/v8-coverage@npm:0.2.3" @@ -2004,6 +2067,23 @@ __metadata: languageName: unknown linkType: soft +"@dd/live-debugger-plugin@workspace:packages/plugins/live-debugger": + version: 0.0.0-use.local + resolution: "@dd/live-debugger-plugin@workspace:packages/plugins/live-debugger" + dependencies: + "@babel/generator": "npm:^7.23.0" + "@babel/parser": "npm:^7.23.0" + "@babel/traverse": "npm:^7.23.0" + "@babel/types": "npm:^7.23.0" + "@dd/core": "workspace:*" + "@types/babel__core": "npm:^7.20.0" + "@types/babel__generator": "npm:^7.6.0" + "@types/babel__traverse": "npm:^7.20.0" + chalk: "npm:2.3.1" + typescript: "npm:5.4.3" + languageName: unknown + linkType: soft + "@dd/metrics-plugin@workspace:*, @dd/metrics-plugin@workspace:packages/plugins/metrics": version: 0.0.0-use.local resolution: "@dd/metrics-plugin@workspace:packages/plugins/metrics" @@ -3012,6 +3092,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/gen-mapping@npm:^0.3.12": + 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: 10/902f8261dcf450b4af7b93f9656918e02eec80a2169e155000cb2059f90113dd98f3ccf6efc6072cee1dd84cac48cade51da236972d942babc40e4c23da4d62a + languageName: node + linkType: hard + "@jridgewell/gen-mapping@npm:^0.3.5": version: 0.3.5 resolution: "@jridgewell/gen-mapping@npm:0.3.5" @@ -3084,6 +3174,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/trace-mapping@npm:^0.3.28": + 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: 10/da0283270e691bdb5543806077548532791608e52386cfbbf3b9e8fb00457859d1bd01d512851161c886eb3a2f3ce6fd9bcf25db8edf3bddedd275bd4a88d606 + languageName: node + linkType: hard + "@kwsites/file-exists@npm:^1.1.1": version: 1.1.1 resolution: "@kwsites/file-exists@npm:1.1.1" @@ -3796,7 +3896,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__core@npm:^7, @types/babel__core@npm:^7.20.5": +"@types/babel__core@npm:^7, @types/babel__core@npm:^7.20.0, @types/babel__core@npm:^7.20.5": version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" dependencies: @@ -3818,6 +3918,15 @@ __metadata: languageName: node linkType: hard +"@types/babel__generator@npm:^7.6.0": + version: 7.27.0 + resolution: "@types/babel__generator@npm:7.27.0" + dependencies: + "@babel/types": "npm:^7.0.0" + checksum: 10/f572e67a9a39397664350a4437d8a7fbd34acc83ff4887a8cf08349e39f8aeb5ad2f70fb78a0a0a23a280affe3a5f4c25f50966abdce292bcf31237af1c27b1a + languageName: node + linkType: hard + "@types/babel__preset-env@npm:^7": version: 7.9.6 resolution: "@types/babel__preset-env@npm:7.9.6" @@ -3844,6 +3953,15 @@ __metadata: languageName: node linkType: hard +"@types/babel__traverse@npm:^7.20.0": + version: 7.28.0 + resolution: "@types/babel__traverse@npm:7.28.0" + dependencies: + "@babel/types": "npm:^7.28.2" + checksum: 10/371c5e1b40399ef17570e630b2943617b84fafde2860a56f0ebc113d8edb1d0534ade0175af89eda1ae35160903c33057ed42457e165d4aa287fedab2c82abcf + languageName: node + linkType: hard + "@types/chalk@npm:2.2.0": version: 2.2.0 resolution: "@types/chalk@npm:2.2.0"