Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,6 @@ packages/plugins/async-queue @yoannmoin

# Output
packages/plugins/output @yoannmoinet

# Live Debugger
packages/plugins/live-debugger/ @DataDog/rum-browser
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
3 changes: 3 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
107 changes: 107 additions & 0 deletions packages/plugins/live-debugger/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Live Debugger Plugin <!-- #omit in toc -->

Automatically instrument JavaScript functions at build time to enable Live Debugger without requiring code rebuilds.

<!-- The title and the following line will both be added to the root README.md -->

## Table of content <!-- #omit in toc -->

<!-- This is auto generated with yarn cli integrity -->

<!-- #toc -->
- [Configuration](#configuration)
- [How it works](#how-it-works)
- [liveDebugger.enable](#livedebuggerenable)
- [liveDebugger.include](#livedebuggerinclude)
- [liveDebugger.exclude](#livedebuggerexclude)
- [liveDebugger.skipHotFunctions](#livedebuggerskiphotfunctions)
<!-- #toc -->

## 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: `<file-path>;<function-name>`)
- 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.
35 changes: 35 additions & 0 deletions packages/plugins/live-debugger/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
11 changes: 11 additions & 0 deletions packages/plugins/live-debugger/src/constants.ts
Original file line number Diff line number Diff line change
@@ -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';
101 changes: 101 additions & 0 deletions packages/plugins/live-debugger/src/index.ts
Original file line number Diff line number Diff line change
@@ -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)];
};
106 changes: 106 additions & 0 deletions packages/plugins/live-debugger/src/transform/functionId.ts
Original file line number Diff line number Diff line change
@@ -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): <relative-file-path>;<function-name>
* Example: src/utils.js;add
*
* NOTE: This POC format only supports uniquely named functions.
* Anonymous functions will use the format <file-path>;<anonymous>:<index>
*/
export function generateFunctionId(
filePath: string,
buildRoot: string,
functionPath: NodePath<t.Function>,
): 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;<anonymous>:index
const index = countPreviousAnonymousSiblings(functionPath);
return `${relativePath};<anonymous>:${index}`;
}
}

/**
* Get the name of a function if available
*/
function getFunctionName(functionPath: NodePath<t.Function>): 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<t.Function>): 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<t.Function>) {
// 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;
}
Loading
Loading