Skip to content

Add live-debugger plugin#296

Draft
watson wants to merge 29 commits intomasterfrom
watson/DEBUG-5291/add-live-debugger-plugin
Draft

Add live-debugger plugin#296
watson wants to merge 29 commits intomasterfrom
watson/DEBUG-5291/add-live-debugger-plugin

Conversation

@watson
Copy link
Copy Markdown

@watson watson commented Apr 1, 2026

Supersedes #253

What and why?

Adds a new Live Debugger build plugin (@dd/live-debugger-plugin) that automatically instruments JavaScript/TypeScript functions at build time to enable Live Debugger without requiring code rebuilds.

When enabled, every matching function in the application is wrapped with lightweight probes that can be activated at runtime via $dd_probes(functionId). When no probes are active the check returns undefined and all instrumentation is skipped, so the steady-state overhead is near-zero (~1.2x build overhead, negligible, near zero runtime cost when inactive).

This originated as an Innovation Week POC and has been hardened with bug fixes, performance optimizations (down from ~17x to ~1.2x build overhead), comprehensive test coverage, and filtering options.

Notes:

  • This PR is not intended to be production-ready. The goal is to land the plugin in a state that is ready for dogfooding. In particular, the function ID algorithm (<relative-file-path>;<function-name>) is a placeholder — the final stable ID scheme will be implemented in a follow-up PR before this is ready for end user consumption.
  • There is also a known escaping limitation in the current POC: function IDs are still embedded directly into generated code, so quoted method names containing a single quote can produce invalid output. This will be fixed before the plugin is considered production-ready.

How?

New plugin (packages/plugins/live-debugger/)

  • Transform pipeline (src/transform/): Uses Babel to parse the AST in read-only mode, collects instrumentation targets, then applies injections via MagicString (no AST mutation). Processes inner functions before outer ones so that appendLeft calls at shared positions stack correctly.
  • Function ID generation (src/transform/functionId.ts): Produces human-readable IDs in <relative-file-path>;<function-name> format. Anonymous functions get a parent-scoped sibling index. This algorithm is temporary and will be replaced with a stable, production-grade scheme in a follow-up.
  • Scope tracking (src/transform/scopeTracker.ts): Extracts variable names (params + locals) for entry and exit snapshots so probes can capture local state.
  • Instrumentation guards (src/transform/instrumentation.ts): Skips functions that can't be safely instrumented (e.g., generators, async generators) and respects // @dd-no-instrumentation skip comments.
  • Option validation (src/validate.ts): Validates all user-supplied config with descriptive error messages.

Configuration options:

  • enable — toggle the plugin (default: false)
  • include / exclude — file patterns to control scope (defaults include .js/.jsx/.ts/.tsx, exclude node_modules, minified files, virtual modules, Datadog SDK packages, etc.)
  • honorSkipComments — respect // @dd-no-instrumentation comments (default: true)
  • functionTypes — restrict to specific function kinds (e.g., functionDeclaration, arrowFunction, classMethod)
  • namedOnly — skip anonymous functions (default: false)

Factory integration:

  • Registered in packages/factory/src/index.ts and packages/core/src/types.ts via the standard plugin injection markers.
  • Exposed through all published bundler packages (webpack, esbuild, rollup, rspack, vite).

Runtime stubs:

  • The plugin injects a minimal no-op stub (~80 bytes) into all output chunks via context.inject(). This defines globalThis.$dd_probes as an empty function so instrumented code never crashes when the Datadog Browser Debugger SDK (@datadog/browser-debugger) is absent.
  • $dd_entry, $dd_return, and $dd_throw don't need stubs because they are always guarded by if (probe) checks in the injected instrumentation.
  • When the SDK loads and DD_DEBUGGER.init() is called, it overwrites $dd_probes with the real implementation and sets up the other three globals. Probes activate immediately — no rebuild required.

Testing & benchmarks:

  • transform unit tests covering block bodies, arrow expressions, nested functions, skip comments, semicolonless returns, object-literal methods, deterministic output, and error recovery
  • plugin unit tests covering child-compilation include/exclude fallback behavior
  • benchmark script (scripts/benchmark-subset.js) for measuring build overhead on real file subsets

@watson watson changed the title Add live-debugger plugin POC for Innovation Week Add live-debugger plugin Apr 1, 2026
jcdesousa and others added 11 commits April 1, 2026 21:31
   This POC adds a live-debugger plugin using Babel transforms to instrument code. Babel was chosen over WASM as
   it was more straightforward to implement within the time constraints of Innovation Week.
Enhance function ID generation to include source location for anonymous
functions (`file;<anonymous>@line:col:index`) and resolve names for class
private methods, member expression assignments (`obj.foo = () => {}`),
and literal property keys.

Extract variables from destructured parameters (object/array patterns,
rest elements, assignment patterns, TS parameter properties) so captured
scope snapshots are more complete.

Add a trailing `$dd_return` call for functions with implicit undefined
returns, and remove the single-try-catch heuristic that could falsely
skip user code resembling instrumented output.

Skip files containing unsupported import patterns (CSS modules, workers,
sprites, dynamic imports) before parsing, and switch the parser to
`sourceType: 'unambiguous'` for broader compatibility.

Track granular instrumentation metrics (failed, skipped-by-comment,
skipped-file, skipped-unsupported) and surface them in the build-end log
to aid debugging and monitoring of the transform pipeline.
Replace Babel codegen with MagicString string injection, eliminating
@babel/generator (the most expensive operation) entirely. The AST
traverse is now read-only — it collects injection positions, then
MagicString applies all insertions in a single pass.

Additional optimizations:
- Add regex pre-filter to skip files with no function syntax before parsing
- Skip code generation entirely when no functions were instrumented
- Cap captured variables at 25 to fix RangeError crash on large scopes
- Replace O(n²) countPreviousAnonymousSiblings with pre-indexed Map
- Hoist variable capture into shared helpers instead of duplicating at each return
- Replace Babel scope.bindings with direct param/declaration walk
…nd new defaults

- Enforce exclude patterns at runtime to cover bundler child compilations
  where unplugin's native filter is not applied (e.g. worker bundles)
- Handle parenthesized arrow expression bodies when wrapping in blocks
- Filter TypeScript `this` parameter from captured variable names
- Process inner functions before outer ones to fix MagicString ordering
- Deduplicate entry/exit snapshot helpers when variables are identical
- Switch source maps to hires: false for faster generation
- Add DD_LD_LIMIT env var to cap number of instrumented files
- Exclude /pyodide-lib/ from instrumentation by default
- Export validateSyntax utility for syntax checking
- Tighten type annotations and add null-safety assertions
- Refactor pre-filter tests to assert unchanged code and undefined map
- Add pre-filter case for classes with no instrumentable methods
- Add full-output smoke tests verifying exact instrumented code for
  block-body functions and arrow expressions
Allow users to restrict which function kinds are instrumented via a new
`functionTypes` option (e.g. only arrow functions or class methods), and
to skip anonymous functions entirely with `namedOnly`.

- Define FunctionKind type and VALID_FUNCTION_KINDS constant
- Add filtering logic in transformCode based on AST node type and name
- Wire options through from plugin config to the transform layer
- Validate both options in validateOptions with descriptive errors
- Add comprehensive test coverage for both options and their combination
Add a benchmark tool that measures transform performance and size
overhead on a configurable set of web-ui source files, with support
for repeated runs and per-file + aggregate reporting.

- Add scripts/benchmark-subset.js with yarn benchmark:subset command
- Exclude plugin scripts directories from ESLint overrides
The previous name implied the option was about "hot functions" when it
actually controls whether @dd-no-instrumentation magic comments are
respected. The new name better describes the behavior.
The old live-debugger-helpers.ts was from an earlier iteration and
defined globals ($dd_start, $dd_instrumentation, etc.) that no longer
match what the transform actually produces ($dd_probes, $dd_entry,
$dd_return, $dd_throw).

Replace it with a minimal stub that only defines a no-op $dd_probes on
globalThis. This is the only global that needs a fallback because it's
called unconditionally by every instrumented function — the other three
are guarded by `if (probe)` checks and only need to exist once the SDK
activates probes.

The live-debugger plugin now injects this stub as a banner into all
output chunks via context.inject(), using the same injection system as
the RUM plugin. This ensures instrumented code never crashes when the
Datadog Browser Debugger SDK (@datadog/browser-debugger) is absent,
while adding ~80 bytes to each chunk.
@watson watson force-pushed the watson/DEBUG-5291/add-live-debugger-plugin branch from 221a99f to 0704915 Compare April 1, 2026 19:32
Copy link
Copy Markdown
Author

watson commented Apr 1, 2026

This stack of pull requests is managed by Graphite. Learn more about stacking.

@watson watson force-pushed the watson/DEBUG-5291/add-live-debugger-plugin branch 2 times, most recently from c08af42 to b98932a Compare April 1, 2026 19:36
watson added 2 commits April 1, 2026 21:47
…eference

Bring documentation up to date with the current plugin implementation:

- Document functionTypes and namedOnly configuration options
- Update exclude defaults with pyodide-lib, browser SDK patterns
- Rewrite transformation examples to reflect deferred helper functions
  ($dd_e/$dd_l) and $dd_rv comma-expression return wrapping
- Add arrow expression body transformation example
- Add "Skipped function types" section (generators, constructors)
- Update table of contents with new sections
- Fix stale config snippet in root README (replace removed
  skipHotFunctions with honorSkipComments, functionTypes, namedOnly)

Rename "Dynamic Instrumentation" to "Live Debugger" in the
transformCode JSDoc comment.
Replace @babel/generator with magic-string in all published plugin
package.json files and update yarn.lock. Also fix license headers and
import ordering in live-debugger transform.
@watson watson force-pushed the watson/DEBUG-5291/add-live-debugger-plugin branch from b98932a to 419e8d5 Compare April 1, 2026 19:48
watson added 10 commits April 1, 2026 22:00
When a block-body function ends with a semicolon-free `return` (e.g.
`function add(a,b){return a+b}`), the return-wrapping suffix and the
postamble can target the same MagicString position. Reorder the
appendLeft calls so return wrapping runs before the postamble insertion,
ensuring the suffix stacks correctly at shared positions.
The HAS_FUNCTION_SYNTAX regex only matched `function`, `=>`, and
`class`, so files containing only object-literal method shorthand
(e.g. `{ method() {} }`) were silently skipped and never instrumented.

Add `\)\s*\{` to the regex to catch method shorthand definitions.
False positives (e.g. `if () {`) are harmless — they just trigger a
Babel parse that finds zero functions.
The manual fallback that re-checks filters for bundler child compilations
(e.g. web worker bundles in rspack/webpack) only enforced exclude patterns,
allowing files outside the intended include scope to be instrumented.
Add a matching runtime include check so both filters are applied consistently.
…p comments

The @dd-no-instrumentation comment was not recognized on common
declaration forms like `const fn = () => 1` or `export const fn = () => 1`
because Babel attaches leading comments to the VariableDeclaration or
ExportNamedDeclaration, not to the function node or its immediate parent.

Replace the two-level parent check with an upward walk that checks
leadingComments at every ancestor until it reaches a statement boundary,
plus one additional level for wrapping export declarations.

Add seven tests covering positive cases (const, export const, function
declaration) and false-positive guards (consecutive declarations, nested
functions, multi-declarator const, unrelated prior statement).
Move probeVarCounter from module scope into transformCode so it resets
to 0 on each invocation. The previous global counter caused the same
file to produce different output across rebuilds, breaking chunk hash
stability in watch mode and incremental builds.
Root README.md will be automatically updated
…pe safety

Remove @ts-nocheck from all transform files by introducing a local
BabelPath interface that avoids the cross-package type conflict between
@types/babel__traverse's bundled @babel/types and the directly imported
@babel/types. Replace all `any` types and `as` casts with proper type
narrowing. Convert require() calls to ESM imports. Use shared
getContextMock test helper instead of hand-rolled mock. Delete orphaned
rum/src/built/live-debugger-helpers.ts (stubs are already injected via
context.inject in the plugin entry point).
@watson watson force-pushed the watson/DEBUG-5291/add-live-debugger-plugin branch from 0d13d3b to afec47e Compare April 1, 2026 22:09
…onments

The conversion from require() to ESM imports broke the @babel/traverse
import in bundled environments. The old code used
`require('@babel/traverse').default` to access the traverse function,
but `import traverse from '@babel/traverse'` relies on the bundler's
CJS-ESM interop to unwrap .default automatically. rspack/webpack
double-wrap the namespace, resulting in traverse being { default: fn }
instead of fn — causing "y is not a function" errors at build time.

Extract the interop resolution into a testable resolveCjsDefaultExport
helper that normalizes both shapes, and add tests covering the direct,
double-wrapped, and actual @babel/traverse module cases.
@watson watson force-pushed the watson/DEBUG-5291/add-live-debugger-plugin branch from cf34001 to f53e9fc Compare April 1, 2026 22:58
@watson watson force-pushed the watson/DEBUG-5291/add-live-debugger-plugin branch from 7a4a2c6 to a3e15c2 Compare April 2, 2026 07:19
watson added 3 commits April 2, 2026 09:58
…n runs

The live-debugger transform pulled in @babel/parser, @babel/traverse,
@babel/types, and magic-string during plugin initialization. Because the
factory imports all plugins eagerly, every build paid that startup cost
even when live-debugger was disabled, which pushed the E2E suite past
Playwright's global timeout.

Lazy-load the transform runtime the first time instrumentation actually
runs so disabled builds avoid the Babel startup overhead while keeping
the published plugin bundle layout intact.
Add regression coverage for the live-debugger transform runtime and a browser
smoke test for an instrumented app running without the debugger SDK. Keep the
runtime smoke test scoped to the esbuild + Chromium slice so it protects the
behavior without pushing the E2E suite over CI's time budget.
@watson watson force-pushed the watson/DEBUG-5291/add-live-debugger-plugin branch from 6614e50 to f9499d8 Compare April 2, 2026 08:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants