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
21 changes: 21 additions & 0 deletions packages/plugins/live-debugger/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -56,6 +57,7 @@ liveDebugger?: {
honorSkipComments?: boolean;
functionTypes?: FunctionKind[];
namedOnly?: boolean;
decorators?: 'legacy' | 'modern';
}
```

Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ function benchmarkFile(filePath, repeatCount, buildRoot) {
honorSkipComments: false,
functionTypes: undefined,
namedOnly: false,
decorators: 'legacy',
}),
);

Expand Down
1 change: 1 addition & 0 deletions packages/plugins/live-debugger/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const makeOptions = (
honorSkipComments: false,
functionTypes: undefined,
namedOnly: false,
decorators: 'legacy',
...overrides,
});

Expand Down
1 change: 1 addition & 0 deletions packages/plugins/live-debugger/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export const getLiveDebuggerPlugin = (
honorSkipComments: pluginOptions.honorSkipComments,
functionTypes: pluginOptions.functionTypes,
namedOnly: pluginOptions.namedOnly,
decorators: pluginOptions.decorators,
});

instrumentedCount += result.instrumentedCount;
Expand Down
78 changes: 77 additions & 1 deletion packages/plugins/live-debugger/src/transform/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TransformOptions, 'code'> = {
filePath: '/src/utils.ts',
buildRoot: '/',
honorSkipComments: false,
functionTypes: undefined,
namedOnly: false,
decorators: 'legacy',
};

describe('transformCode', () => {
Expand Down Expand Up @@ -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({
Expand Down
25 changes: 21 additions & 4 deletions packages/plugins/live-debugger/src/transform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -22,7 +23,7 @@ type ParseFn = (
code: string,
options: {
sourceType: 'unambiguous';
plugins: string[];
plugins: ParserPlugin[];
sourceFilename: string;
},
) => BabelTypes.File;
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TransformOptions, 'code'> = {
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;
Expand Down
10 changes: 10 additions & 0 deletions packages/plugins/live-debugger/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,22 @@ 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)[];
exclude?: (string | RegExp)[];
honorSkipComments?: boolean;
functionTypes?: FunctionKind[];
namedOnly?: boolean;
decorators?: DecoratorSyntax;
};

export type LiveDebuggerOptionsWithDefaults = {
Expand All @@ -29,4 +38,5 @@ export type LiveDebuggerOptionsWithDefaults = {
honorSkipComments: boolean;
functionTypes: FunctionKind[] | undefined;
namedOnly: boolean;
decorators: DecoratorSyntax;
};
35 changes: 35 additions & 0 deletions packages/plugins/live-debugger/src/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ describe('validateOptions', () => {
honorSkipComments: true,
functionTypes: undefined,
namedOnly: false,
decorators: 'legacy',
} satisfies LiveDebuggerOptionsWithDefaults,
},
{
Expand All @@ -51,6 +52,7 @@ describe('validateOptions', () => {
honorSkipComments: true,
functionTypes: undefined,
namedOnly: false,
decorators: 'legacy',
} satisfies LiveDebuggerOptionsWithDefaults,
},
{
Expand All @@ -63,6 +65,7 @@ describe('validateOptions', () => {
honorSkipComments: true,
functionTypes: undefined,
namedOnly: false,
decorators: 'legacy',
} satisfies LiveDebuggerOptionsWithDefaults,
},
{
Expand Down Expand Up @@ -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: [] }),
Expand Down Expand Up @@ -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({
Expand Down
11 changes: 10 additions & 1 deletion packages/plugins/live-debugger/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 - ')}`);
Expand All @@ -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',
};
};
Loading