Skip to content

Commit 3292484

Browse files
Merge pull request #324 from DataDog/watson/DEBUG-5291/fix-enable
Centralize `enable` option handling in the factory Co-authored-by: watson <thomas.watson@datadoghq.com>
2 parents 36eb0cc + ac775fe commit 3292484

39 files changed

Lines changed: 287 additions & 283 deletions
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2019-Present Datadog, Inc.
4+
5+
import { mockLogFn, mockLogger } from '@dd/tests/_jest/helpers/mocks';
6+
7+
import { resetEnableWarnings, resolveEnable } from './options';
8+
9+
beforeEach(() => {
10+
jest.clearAllMocks();
11+
resetEnableWarnings();
12+
});
13+
14+
describe('resolveEnable', () => {
15+
describe('standard boolean / omitted values', () => {
16+
const cases = [
17+
{
18+
description: 'return false when the config key is undefined',
19+
options: {},
20+
expected: false,
21+
},
22+
{
23+
description: 'return false when the config key is null',
24+
options: { myPlugin: null },
25+
expected: false,
26+
},
27+
{
28+
description: 'return true when the config key is a truthy object without enable',
29+
options: { myPlugin: { someOther: 'val' } },
30+
expected: true,
31+
},
32+
{
33+
description: 'return true when enable is true',
34+
options: { myPlugin: { enable: true } },
35+
expected: true,
36+
},
37+
{
38+
description: 'return false when enable is false',
39+
options: { myPlugin: { enable: false } },
40+
expected: false,
41+
},
42+
{
43+
description: 'return true when enable is undefined (object present)',
44+
options: { myPlugin: { enable: undefined } },
45+
expected: true,
46+
},
47+
];
48+
49+
test.each(cases)('should $description', ({ options, expected }) => {
50+
expect(resolveEnable(options, 'myPlugin', mockLogger)).toBe(expected);
51+
expect(mockLogFn).not.toHaveBeenCalled();
52+
});
53+
});
54+
55+
describe('non-boolean coercion with deprecation warning', () => {
56+
const cases = [
57+
{
58+
description: 'coerce enable: 1 to true and warn',
59+
options: { myPlugin: { enable: 1 } },
60+
expected: true,
61+
},
62+
{
63+
description: 'coerce enable: 0 to false and warn',
64+
options: { myPlugin: { enable: 0 } },
65+
expected: false,
66+
},
67+
{
68+
description: 'coerce enable: "true" to true and warn',
69+
options: { myPlugin: { enable: 'true' } },
70+
expected: true,
71+
},
72+
{
73+
description: 'coerce enable: "" to false and warn',
74+
options: { myPlugin: { enable: '' } },
75+
expected: false,
76+
},
77+
];
78+
79+
test.each(cases)('should $description', ({ options, expected }) => {
80+
expect(resolveEnable(options, 'myPlugin', mockLogger)).toBe(expected);
81+
expect(mockLogFn).toHaveBeenCalledTimes(1);
82+
expect(mockLogFn).toHaveBeenCalledWith(
83+
expect.stringContaining('myPlugin.enable'),
84+
'warn',
85+
);
86+
});
87+
});
88+
89+
describe('warn-once behavior', () => {
90+
test('should only warn once per config key across multiple calls', () => {
91+
resolveEnable({ myPlugin: { enable: 1 } }, 'myPlugin', mockLogger);
92+
resolveEnable({ myPlugin: { enable: 'yes' } }, 'myPlugin', mockLogger);
93+
expect(mockLogFn).toHaveBeenCalledTimes(1);
94+
});
95+
96+
test('should warn separately for different config keys', () => {
97+
resolveEnable({ pluginA: { enable: 1 } }, 'pluginA', mockLogger);
98+
resolveEnable({ pluginB: { enable: 1 } }, 'pluginB', mockLogger);
99+
expect(mockLogFn).toHaveBeenCalledTimes(2);
100+
});
101+
});
102+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2019-Present Datadog, Inc.
4+
5+
import type { Logger } from '@dd/core/types';
6+
7+
const warnedKeys = new Set<string>();
8+
9+
/**
10+
* Resolve the `enable` value for a plugin config key, emitting a deprecation
11+
* warning when the caller passes a non-boolean truthy/falsy value.
12+
*
13+
* Semantics:
14+
* - Config key absent / undefined / falsy → false (plugin disabled).
15+
* - Config key is a truthy object without an `enable` property → true.
16+
* - Config key is a truthy object with `enable` set → coerce to boolean,
17+
* warning once per key if it isn't already a boolean.
18+
*/
19+
export const resolveEnable = <T extends { [K in C]?: unknown }, C extends string>(
20+
options: T,
21+
configKey: C,
22+
log: Logger,
23+
): boolean => {
24+
const pluginConfig = options[configKey];
25+
26+
if (pluginConfig && typeof pluginConfig === 'object' && 'enable' in pluginConfig) {
27+
const value = pluginConfig.enable;
28+
29+
if (typeof value !== 'boolean' && value !== undefined) {
30+
if (!warnedKeys.has(configKey)) {
31+
warnedKeys.add(configKey);
32+
log.warn(
33+
`\`${configKey}.enable\` should be a boolean, got ${typeof value}. ` +
34+
`Non-boolean values are coerced today but will be rejected in the next major.`,
35+
);
36+
}
37+
}
38+
39+
if (value !== undefined) {
40+
// TODO(next major): drop this coercion and reject non-boolean `enable`
41+
// outright. The warning above gives callers one major to migrate.
42+
return !!value;
43+
}
44+
}
45+
46+
return !!pluginConfig;
47+
};
48+
49+
/** @internal Exposed only for tests to reset the warn-once set between cases. */
50+
export const resetEnableWarnings = (): void => {
51+
warnedKeys.clear();
52+
};

packages/factory/src/index.test.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,67 @@
22
// This product includes software developed at Datadog (https://www.datadoghq.com/).
33
// Copyright 2019-Present Datadog, Inc.
44

5+
import type { PluginOptions, Options } from '@dd/core/types';
6+
import { buildPluginFactory } from '@dd/factory';
7+
8+
const invokeFactory = (opts: Options): PluginOptions[] => {
9+
const factory = buildPluginFactory({ bundler: {}, version: '1.0.0' });
10+
return factory.raw(opts, { framework: 'esbuild' }) as PluginOptions[];
11+
};
12+
13+
const hasPlugin = (plugins: PluginOptions[], name: string) =>
14+
plugins.some((plugin) => plugin.name.includes(name));
15+
516
describe('Factory', () => {
6-
test('Should not throw with no options', async () => {
7-
const { buildPluginFactory } = await import('@dd/factory');
17+
test('Should not throw with no options', () => {
818
expect(() => {
919
const factory = buildPluginFactory({ bundler: {}, version: '1.0.0' });
1020
// Vite could call the factory without options.
1121
// @ts-expect-error - We are testing the factory without options.
1222
factory.vite();
1323
}).not.toThrow();
1424
});
25+
26+
describe('enable gating for user-facing plugins', () => {
27+
// The factory is the single source of truth for `<configKey>.enable`.
28+
// Each user-facing plugin is skipped when its config key is absent or
29+
// explicitly disabled, and included when the config key is present.
30+
31+
test('Should skip a plugin when its config key is absent', () => {
32+
const plugins = invokeFactory({ logLevel: 'none' });
33+
expect(hasPlugin(plugins, 'output')).toBe(false);
34+
expect(hasPlugin(plugins, 'metrics')).toBe(false);
35+
expect(hasPlugin(plugins, 'rum')).toBe(false);
36+
});
37+
38+
test('Should include a plugin when its config key is present', () => {
39+
const plugins = invokeFactory({ logLevel: 'none', output: {} });
40+
expect(hasPlugin(plugins, 'output')).toBe(true);
41+
});
42+
43+
test('Should skip a plugin when enable: false', () => {
44+
const plugins = invokeFactory({
45+
logLevel: 'none',
46+
output: { enable: false },
47+
});
48+
expect(hasPlugin(plugins, 'output')).toBe(false);
49+
});
50+
51+
test('Should include a plugin when enable: true', () => {
52+
const plugins = invokeFactory({
53+
logLevel: 'none',
54+
output: { enable: true },
55+
});
56+
expect(hasPlugin(plugins, 'output')).toBe(true);
57+
});
58+
59+
test('Should coerce a non-boolean enable value and still include the plugin', () => {
60+
const plugins = invokeFactory({
61+
logLevel: 'none',
62+
// @ts-expect-error - intentional non-boolean to exercise coercion
63+
output: { enable: 1 },
64+
});
65+
expect(hasPlugin(plugins, 'output')).toBe(true);
66+
});
67+
});
1568
});

packages/factory/src/index.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { getContext } from './helpers/context';
3434
import { wrapGetPlugins } from './helpers/wrapPlugins';
3535
import { ALL_ENVS, HOST_NAME } from '@dd/core/constants';
3636
import { notifyOnEnvOverrides } from '@dd/core/helpers/env';
37+
import { resolveEnable } from '@dd/core/helpers/options';
3738
// #imports-injection-marker
3839
import * as apps from '@dd/apps-plugin';
3940
import * as errorTracking from '@dd/error-tracking-plugin';
@@ -160,17 +161,27 @@ export const buildPluginFactory = ({
160161
pluginsToAdd.push(['custom', options.customPlugins]);
161162
}
162163

163-
// Add the customer facing plugins.
164-
pluginsToAdd.push(
164+
// Customer-facing plugins are gated by their `<configKey>.enable` flag.
165+
// Resolving here lets every plugin share the same semantics:
166+
// - config key absent → disabled
167+
// - config key present without `enable` → enabled
168+
// - non-boolean `enable` → coerced, with a single deprecation warning
169+
const userFacingPlugins: [name: string, configKey: string, GetPlugins][] = [
165170
// #configs-injection-marker
166-
['apps', apps.getPlugins],
167-
['error-tracking', errorTracking.getPlugins],
168-
['live-debugger', liveDebugger.getPlugins],
169-
['metrics', metrics.getPlugins],
170-
['output', output.getPlugins],
171-
['rum', rum.getPlugins],
171+
['apps', apps.CONFIG_KEY, apps.getPlugins],
172+
['error-tracking', errorTracking.CONFIG_KEY, errorTracking.getPlugins],
173+
['live-debugger', liveDebugger.CONFIG_KEY, liveDebugger.getPlugins],
174+
['metrics', metrics.CONFIG_KEY, metrics.getPlugins],
175+
['output', output.CONFIG_KEY, output.getPlugins],
176+
['rum', rum.CONFIG_KEY, rum.getPlugins],
172177
// #configs-injection-marker
173-
);
178+
];
179+
180+
for (const [name, configKey, getPlugins] of userFacingPlugins) {
181+
if (resolveEnable(options, configKey, log)) {
182+
pluginsToAdd.push([name, getPlugins]);
183+
}
184+
}
174185

175186
// Initialize all our plugins.
176187
for (const [name, getPlugins] of pluginsToAdd) {

packages/plugins/apps/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,12 @@ Setting the `apps.dryRun` configuration will override any value set in the envir
5454

5555
### apps.enable
5656

57-
> default: `true` when an `apps` config block is present
57+
> default: `true` when an `apps` config block is present, `false` otherwise.
5858
5959
Enable or disable the plugin without removing its configuration.
6060

61+
Must be a boolean. Non-boolean values are coerced today but will be rejected in a future major release.
62+
6163
### apps.include
6264

6365
> default: `[]`

packages/plugins/apps/src/index.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,6 @@ export type types = {
1919
export const getPlugins: GetPlugins = ({ options, context, bundler }) => {
2020
const log = context.getLogger(PLUGIN_NAME);
2121
const validatedOptions = validateOptions(options);
22-
if (!validatedOptions.enable) {
23-
return [];
24-
}
2522

2623
if (context.bundler.name !== 'vite') {
2724
log.warn(`The apps plugin only supports Vite; skipping under '${context.bundler.name}'.`);

packages/plugins/apps/src/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,7 @@ export type AppsManifest = {
2525
};
2626

2727
// We don't enforce identifier, as it needs to be dynamically computed if absent.
28-
export type AppsOptionsWithDefaults = WithRequired<AppsOptions, 'enable' | 'include' | 'dryRun'>;
28+
export type AppsOptionsWithDefaults = Omit<
29+
WithRequired<AppsOptions, 'include' | 'dryRun'>,
30+
'enable'
31+
>;

packages/plugins/apps/src/validate.test.ts

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,42 +5,11 @@
55
import { validateOptions } from '@dd/apps-plugin/validate';
66

77
describe('Apps Plugin - validateOptions', () => {
8-
describe('enable flag', () => {
9-
const cases = [
10-
{
11-
description: 'return false when no apps config is provided',
12-
input: {},
13-
expected: false,
14-
},
15-
{
16-
description: 'return true when apps config is an empty object',
17-
input: { apps: {} },
18-
expected: true,
19-
},
20-
{
21-
description: 'respect explicit enable true',
22-
input: { apps: { enable: true } },
23-
expected: true,
24-
},
25-
{
26-
description: 'respect explicit enable false',
27-
input: { apps: { enable: false } },
28-
expected: false,
29-
},
30-
];
31-
32-
test.each(cases)('Should $description', ({ input, expected }) => {
33-
const result = validateOptions(input);
34-
expect(result.enable).toBe(expected);
35-
});
36-
});
37-
388
describe('defaults', () => {
399
test('Should set defaults when nothing is provided', () => {
4010
const result = validateOptions({});
4111
expect(result).toEqual({
4212
dryRun: true,
43-
enable: false,
4413
include: [],
4514
identifier: undefined,
4615
name: undefined,
@@ -91,7 +60,6 @@ describe('Apps Plugin - validateOptions', () => {
9160

9261
expect(result).toEqual({
9362
dryRun: true,
94-
enable: true,
9563
include: ['public/**/*', 'dist/**/*'],
9664
identifier: 'my-app',
9765
name: undefined,

packages/plugins/apps/src/validate.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,11 @@ import type { AppsOptions, AppsOptionsWithDefaults } from './types';
1010

1111
export const validateOptions = (options: Options): AppsOptionsWithDefaults => {
1212
const resolvedOptions = (options[CONFIG_KEY] || {}) as AppsOptions;
13-
const enable = resolvedOptions.enable ?? !!options[CONFIG_KEY];
1413

15-
const validatedOptions: AppsOptionsWithDefaults = {
16-
enable,
14+
return {
1715
include: resolvedOptions.include || [],
1816
dryRun: resolvedOptions.dryRun ?? !getDDEnvValue('APPS_UPLOAD_ASSETS'),
1917
identifier: resolvedOptions.identifier?.trim(),
2018
name: resolvedOptions.name?.trim() || options.metadata?.name?.trim(),
2119
};
22-
23-
return validatedOptions;
2420
};

packages/plugins/error-tracking/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Interact with Error Tracking directly from your build system.
1010

1111
<!-- #toc -->
1212
- [Configuration](#configuration)
13+
- [errorTracking.enable](#errortrackingenable)
1314
- [Sourcemaps Upload](#sourcemaps-upload)
1415
- [errorTracking.sourcemaps.bailOnError](#errortrackingsourcemapsbailonerror)
1516
- [errorTracking.sourcemaps.dryRun](#errortrackingsourcemapsdryrun)
@@ -35,6 +36,14 @@ errorTracking?: {
3536
}
3637
```
3738

39+
### errorTracking.enable
40+
41+
> default: `true` when an `errorTracking` config block is present, `false` otherwise.
42+
43+
Enable or disable the plugin without removing its configuration.
44+
45+
Must be a boolean. Non-boolean values are coerced today but will be rejected in a future major release.
46+
3847
## Sourcemaps Upload
3948

4049
Upload JavaScript sourcemaps to Datadog to un-minify your errors.

0 commit comments

Comments
 (0)