From bc280ccaeaeffa61c6c4621090bcdd2fadd5a4de Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Fri, 3 Jul 2026 12:36:54 +0200 Subject: [PATCH] Add decorator support to Live Debugger instrumentation The transform parsed sources with only the `jsx` and `typescript` Babel plugins, so any file using decorators (Angular, NestJS, TypeORM, MobX, ...) failed to parse and was skipped entirely, leaving its functions uninstrumented. Enable decorator parsing and expose a `decorators` option to pick the grammar, since Babel cannot enable both decorator proposals at once: - `legacy` (default): TypeScript `experimentalDecorators` semantics, including parameter decorators. This covers the overwhelming majority of real-world decorator usage. - `modern`: the TC39 Stage 3 proposal, including `accessor` fields and decorators before `export`. A file written in the non-selected style still fails safe -- it is skipped, never miscompiled. Defaulting to `legacy` adds no measurable parse overhead for codebases without decorators. Add validation and defaulting for the option, thread it through to the parser, and cover both modes with tests. Update the README and regenerate its table of contents. --- packages/plugins/live-debugger/README.md | 21 +++++ .../live-debugger/scripts/benchmark-subset.js | 1 + .../plugins/live-debugger/src/index.test.ts | 1 + packages/plugins/live-debugger/src/index.ts | 1 + .../live-debugger/src/transform/index.test.ts | 78 ++++++++++++++++++- .../live-debugger/src/transform/index.ts | 25 +++++- .../src/transform/lazy-deps.test.ts | 5 +- packages/plugins/live-debugger/src/types.ts | 10 +++ .../live-debugger/src/validate.test.ts | 35 +++++++++ .../plugins/live-debugger/src/validate.ts | 11 ++- 10 files changed, 181 insertions(+), 7 deletions(-) diff --git a/packages/plugins/live-debugger/README.md b/packages/plugins/live-debugger/README.md index c367cdee3..46258ec4d 100644 --- a/packages/plugins/live-debugger/README.md +++ b/packages/plugins/live-debugger/README.md @@ -19,6 +19,7 @@ Automatically instrument JavaScript functions at build time to enable Live Debug - [liveDebugger.honorSkipComments](#livedebuggerhonorskipcomments) - [liveDebugger.functionTypes](#livedebuggerfunctiontypes) - [liveDebugger.namedOnly](#livedebuggernamedonly) + - [liveDebugger.decorators](#livedebuggerdecorators) - [Skipped function types](#skipped-function-types) - [Runtime requirements](#runtime-requirements) - [Safe fallback when the SDK is absent](#safe-fallback-when-the-sdk-is-absent) @@ -56,6 +57,7 @@ liveDebugger?: { honorSkipComments?: boolean; functionTypes?: FunctionKind[]; namedOnly?: boolean; + decorators?: 'legacy' | 'modern'; } ``` @@ -201,6 +203,25 @@ With `namedOnly: true`: - `[1, 2].map((x) => x * 2)` — **skipped** (anonymous callback) - `function add(a, b) { return a + b; }` — **instrumented** (named declaration) +### liveDebugger.decorators + +> default: `'legacy'` + +Selects which decorator syntax the parser accepts when reading your source. The parser cannot enable both grammars at once, so pick the one your codebase uses: + +- `'legacy'` — TypeScript's `experimentalDecorators` semantics. This covers the vast majority of decorator usage, including Angular, NestJS, and TypeORM, and is the only mode that allows **parameter decorators** (e.g. `constructor(@Inject(TOKEN) svc: Svc)`). +- `'modern'` — the TC39 Stage 3 decorators proposal, including `accessor` auto-accessor fields and decorators placed before `export`. Parameter decorators are not part of this proposal. + +If a file uses the decorator style that is **not** selected, it fails to parse and is skipped (left uninstrumented) — it is never miscompiled. Files with no decorators are unaffected by this option. + +**Example — opt into Stage 3 decorators:** + +```ts +liveDebugger: { + decorators: 'modern', +} +``` + ## Skipped function types The following function types are always skipped regardless of configuration: diff --git a/packages/plugins/live-debugger/scripts/benchmark-subset.js b/packages/plugins/live-debugger/scripts/benchmark-subset.js index 09788d461..ad5590652 100644 --- a/packages/plugins/live-debugger/scripts/benchmark-subset.js +++ b/packages/plugins/live-debugger/scripts/benchmark-subset.js @@ -179,6 +179,7 @@ function benchmarkFile(filePath, repeatCount, buildRoot) { honorSkipComments: false, functionTypes: undefined, namedOnly: false, + decorators: 'legacy', }), ); diff --git a/packages/plugins/live-debugger/src/index.test.ts b/packages/plugins/live-debugger/src/index.test.ts index 2bbd7f5fa..54dd441f8 100644 --- a/packages/plugins/live-debugger/src/index.test.ts +++ b/packages/plugins/live-debugger/src/index.test.ts @@ -20,6 +20,7 @@ const makeOptions = ( honorSkipComments: false, functionTypes: undefined, namedOnly: false, + decorators: 'legacy', ...overrides, }); diff --git a/packages/plugins/live-debugger/src/index.ts b/packages/plugins/live-debugger/src/index.ts index 88f90a3e4..2c3cbc60c 100644 --- a/packages/plugins/live-debugger/src/index.ts +++ b/packages/plugins/live-debugger/src/index.ts @@ -85,6 +85,7 @@ export const getLiveDebuggerPlugin = ( honorSkipComments: pluginOptions.honorSkipComments, functionTypes: pluginOptions.functionTypes, namedOnly: pluginOptions.namedOnly, + decorators: pluginOptions.decorators, }); instrumentedCount += result.instrumentedCount; diff --git a/packages/plugins/live-debugger/src/transform/index.test.ts b/packages/plugins/live-debugger/src/transform/index.test.ts index 9a108296a..fce1a3506 100644 --- a/packages/plugins/live-debugger/src/transform/index.test.ts +++ b/packages/plugins/live-debugger/src/transform/index.test.ts @@ -3,17 +3,20 @@ // Copyright 2019-Present Datadog, Inc. import { parse } from '@babel/parser'; +import type { ParserPlugin } from '@babel/parser'; import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping'; import { runInNewContext } from 'vm'; import { transformCode } from './index'; +import type { TransformOptions } from './index'; -const BASE_OPTIONS = { +const BASE_OPTIONS: Omit = { filePath: '/src/utils.ts', buildRoot: '/', honorSkipComments: false, functionTypes: undefined, namedOnly: false, + decorators: 'legacy', }; describe('transformCode', () => { @@ -594,6 +597,79 @@ describe('transformCode', () => { ); }); + describe('decorators', () => { + const LEGACY_PLUGINS: ParserPlugin[] = ['jsx', 'typescript', 'decorators-legacy']; + const MODERN_PLUGINS: ParserPlugin[] = [ + 'jsx', + 'typescript', + 'decorators', + 'decoratorAutoAccessors', + ]; + + function parseError(code: string, plugins: ParserPlugin[]): string | null { + try { + parse(code, { + sourceType: 'unambiguous', + plugins, + sourceFilename: '/src/utils.ts', + }); + return null; + } catch (e: unknown) { + return e instanceof Error ? e.message : String(e); + } + } + + // TypeScript `experimentalDecorators` style, including parameter decorators. + const LEGACY_DECORATED = [ + '@Component({ selector: "app" })', + 'export class AppComponent {', + ' @Input() title = "";', + ' constructor(@Inject(TOKEN) private svc: Svc) {}', + ' @HostListener("click")', + ' onClick() { return this.title; }', + '}', + ].join('\n'); + + // TC39 Stage 3 style, including an `accessor` auto-accessor field. + const MODERN_DECORATED = [ + 'class Counter {', + ' @logged accessor count = 0;', + ' @logged increment() { return (this.count += 1); }', + '}', + ].join('\n'); + + it('parses and instruments legacy decorators by default', () => { + const result = transformCode({ ...BASE_OPTIONS, code: LEGACY_DECORATED }); + + expect(result.instrumentedCount).toBeGreaterThanOrEqual(1); + expect(parseError(result.code, LEGACY_PLUGINS)).toBeNull(); + expect(result.code).toContain('$dd_probes'); + expect(result.code).toContain('catch(e)'); + }); + + it('parses and instruments modern decorators when decorators is "modern"', () => { + const result = transformCode({ + ...BASE_OPTIONS, + code: MODERN_DECORATED, + decorators: 'modern', + }); + + expect(result.instrumentedCount).toBeGreaterThanOrEqual(1); + expect(parseError(result.code, MODERN_PLUGINS)).toBeNull(); + expect(result.code).toContain('$dd_probes'); + expect(result.code).toContain('catch(e)'); + }); + + it('cannot parse legacy parameter decorators under modern mode', () => { + // Documents why the option exists: the two decorator grammars are + // mutually exclusive. A file the plugin can't parse throws here and + // is caught (and skipped) one level up in the plugin handler. + expect(() => + transformCode({ ...BASE_OPTIONS, code: LEGACY_DECORATED, decorators: 'modern' }), + ).toThrow(); + }); + }); + describe('skipping', () => { it('should skip generators', () => { const result = transformCode({ diff --git a/packages/plugins/live-debugger/src/transform/index.ts b/packages/plugins/live-debugger/src/transform/index.ts index 5abc0a099..971a77ba9 100644 --- a/packages/plugins/live-debugger/src/transform/index.ts +++ b/packages/plugins/live-debugger/src/transform/index.ts @@ -2,13 +2,14 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import type { ParserPlugin } from '@babel/parser'; import type _traverse from '@babel/traverse'; import type * as BabelTypes from '@babel/types'; import type MagicStringType from 'magic-string'; import type { SourceMap } from 'magic-string'; import { SKIP_INSTRUMENTATION_COMMENT } from '../constants'; -import type { FunctionKind } from '../types'; +import type { DecoratorSyntax, FunctionKind } from '../types'; import type { BabelPath, BabelTypesModule } from './babel-path.types'; import { resolveCjsDefaultExport } from './cjs-interop'; @@ -22,7 +23,7 @@ type ParseFn = ( code: string, options: { sourceType: 'unambiguous'; - plugins: string[]; + plugins: ParserPlugin[]; sourceFilename: string; }, ) => BabelTypes.File; @@ -202,6 +203,21 @@ export interface TransformOptions { honorSkipComments: boolean; functionTypes: FunctionKind[] | undefined; namedOnly: boolean; + decorators: DecoratorSyntax; +} + +/** + * Build the Babel parser plugin list. Decorators are opt-in per proposal + * because Babel cannot enable the legacy and Stage 3 grammars simultaneously. + */ +function getParserPlugins(decorators: DecoratorSyntax): ParserPlugin[] { + if (decorators === 'modern') { + // TC39 Stage 3 decorators, including `accessor` auto-accessor fields. + return ['jsx', 'typescript', 'decorators', 'decoratorAutoAccessors']; + } + + // TypeScript's `experimentalDecorators` grammar (also allows parameter decorators). + return ['jsx', 'typescript', 'decorators-legacy']; } export interface TransformResult { @@ -220,7 +236,8 @@ export interface TransformResult { * Uses Babel to parse and read the AST, then MagicString for injection. */ export function transformCode(options: TransformOptions): TransformResult { - const { code, filePath, buildRoot, honorSkipComments, functionTypes, namedOnly } = options; + const { code, filePath, buildRoot, honorSkipComments, functionTypes, namedOnly, decorators } = + options; let failedCount = 0; let instrumentedCount = 0; @@ -255,7 +272,7 @@ export function transformCode(options: TransformOptions): TransformResult { getTransformRuntime(); const ast = parse(code, { sourceType: 'unambiguous', - plugins: ['jsx', 'typescript'], + plugins: getParserPlugins(decorators), sourceFilename: filePath, }); diff --git a/packages/plugins/live-debugger/src/transform/lazy-deps.test.ts b/packages/plugins/live-debugger/src/transform/lazy-deps.test.ts index 575802aae..3c269d993 100644 --- a/packages/plugins/live-debugger/src/transform/lazy-deps.test.ts +++ b/packages/plugins/live-debugger/src/transform/lazy-deps.test.ts @@ -2,12 +2,15 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -const BASE_OPTIONS = { +import type { TransformOptions } from './index'; + +const BASE_OPTIONS: Omit = { filePath: '/src/utils.ts', buildRoot: '/', honorSkipComments: false, functionTypes: undefined, namedOnly: false, + decorators: 'legacy', }; const PEER_DEPS = ['@babel/parser', '@babel/traverse', '@babel/types', 'magic-string'] as const; diff --git a/packages/plugins/live-debugger/src/types.ts b/packages/plugins/live-debugger/src/types.ts index f6d69074a..601ecf825 100644 --- a/packages/plugins/live-debugger/src/types.ts +++ b/packages/plugins/live-debugger/src/types.ts @@ -13,6 +13,14 @@ export const VALID_FUNCTION_KINDS = [ export type FunctionKind = (typeof VALID_FUNCTION_KINDS)[number]; +// Which decorator syntax the parser should accept. `legacy` matches +// TypeScript's `experimentalDecorators` (Angular, NestJS, TypeORM, including +// parameter decorators); `modern` matches the TC39 Stage 3 proposal +// (`accessor` fields, decorators before `export`). +export const VALID_DECORATOR_SYNTAXES = ['legacy', 'modern'] as const; + +export type DecoratorSyntax = (typeof VALID_DECORATOR_SYNTAXES)[number]; + export type LiveDebuggerOptions = { enable?: boolean; include?: (string | RegExp)[]; @@ -20,6 +28,7 @@ export type LiveDebuggerOptions = { honorSkipComments?: boolean; functionTypes?: FunctionKind[]; namedOnly?: boolean; + decorators?: DecoratorSyntax; }; export type LiveDebuggerOptionsWithDefaults = { @@ -29,4 +38,5 @@ export type LiveDebuggerOptionsWithDefaults = { honorSkipComments: boolean; functionTypes: FunctionKind[] | undefined; namedOnly: boolean; + decorators: DecoratorSyntax; }; diff --git a/packages/plugins/live-debugger/src/validate.test.ts b/packages/plugins/live-debugger/src/validate.test.ts index 09a282a58..96e5eb951 100644 --- a/packages/plugins/live-debugger/src/validate.test.ts +++ b/packages/plugins/live-debugger/src/validate.test.ts @@ -39,6 +39,7 @@ describe('validateOptions', () => { honorSkipComments: true, functionTypes: undefined, namedOnly: false, + decorators: 'legacy', } satisfies LiveDebuggerOptionsWithDefaults, }, { @@ -51,6 +52,7 @@ describe('validateOptions', () => { honorSkipComments: true, functionTypes: undefined, namedOnly: false, + decorators: 'legacy', } satisfies LiveDebuggerOptionsWithDefaults, }, { @@ -63,6 +65,7 @@ describe('validateOptions', () => { honorSkipComments: true, functionTypes: undefined, namedOnly: false, + decorators: 'legacy', } satisfies LiveDebuggerOptionsWithDefaults, }, { @@ -169,6 +172,16 @@ describe('validateOptions', () => { input: makeConfig({ namedOnly: false }), expected: expect.objectContaining({ namedOnly: false }), }, + { + description: 'accept decorators as legacy', + input: makeConfig({ decorators: 'legacy' }), + expected: expect.objectContaining({ decorators: 'legacy' }), + }, + { + description: 'accept decorators as modern', + input: makeConfig({ decorators: 'modern' }), + expected: expect.objectContaining({ decorators: 'modern' }), + }, { description: 'accept an empty include array', input: makeConfig({ include: [] }), @@ -306,6 +319,28 @@ describe('validateOptions', () => { }); }); + describe('invalid decorators', () => { + const cases = [ + { + description: 'reject decorators with an unknown value', + input: makeInvalidConfig({ decorators: 'stage3' }), + }, + { + description: 'reject decorators when a boolean', + input: makeInvalidConfig({ decorators: true }), + }, + ]; + + test.each(cases)('should $description', ({ input }) => { + expect(() => validateOptions(input, mockLogger)).toThrow( + `Invalid configuration for ${PLUGIN_NAME}.`, + ); + expect(mockError).toHaveBeenCalledWith( + expect.stringMatching(/decorators.*must be one of/), + ); + }); + }); + describe('multiple errors', () => { it('should aggregate all validation errors before throwing', () => { const input = makeInvalidConfig({ diff --git a/packages/plugins/live-debugger/src/validate.ts b/packages/plugins/live-debugger/src/validate.ts index eb8020792..286eeb764 100644 --- a/packages/plugins/live-debugger/src/validate.ts +++ b/packages/plugins/live-debugger/src/validate.ts @@ -7,7 +7,7 @@ import chalk from 'chalk'; import { CONFIG_KEY, PLUGIN_NAME } from './constants'; import type { LiveDebuggerOptions, LiveDebuggerOptionsWithDefaults } from './types'; -import { VALID_FUNCTION_KINDS } from './types'; +import { VALID_DECORATOR_SYNTAXES, VALID_FUNCTION_KINDS } from './types'; const red = chalk.bold.red; @@ -73,6 +73,14 @@ export const validateOptions = (config: Options, log: Logger): LiveDebuggerOptio errors.push(`${red('namedOnly')} must be a boolean`); } + // Validate decorators option + if ( + pluginConfig.decorators !== undefined && + !VALID_DECORATOR_SYNTAXES.includes(pluginConfig.decorators) + ) { + errors.push(`${red('decorators')} must be one of: ${VALID_DECORATOR_SYNTAXES.join(', ')}`); + } + // Throw if there are any errors if (errors.length) { log.error(`\n - ${errors.join('\n - ')}`); @@ -97,5 +105,6 @@ export const validateOptions = (config: Options, log: Logger): LiveDebuggerOptio honorSkipComments: pluginConfig.honorSkipComments ?? true, functionTypes: pluginConfig.functionTypes, namedOnly: pluginConfig.namedOnly ?? false, + decorators: pluginConfig.decorators ?? 'legacy', }; };