diff --git a/README.md b/README.md
index d51a23df1..1aec317e2 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,7 @@ To interact with Datadog directly from your builds.
- [`logLevel`](#loglevel)
- [`metadata.name`](#metadataname)
- [`metadata.version`](#metadataversion)
+ - [`sourcemaps`](#sourcemaps)
- [Features](#features)
- [Error Tracking](#error-tracking-----)
- [Metrics](#metrics-----)
@@ -104,16 +105,36 @@ Follow the specific documentation for each bundler:
name?: string;
version?: string;
};
+ sourcemaps?:
+ | {
+ debugId: true;
+ upload?: false;
+ }
+ | {
+ bailOnError?: boolean;
+ debugId: true;
+ dryRun?: boolean;
+ maxConcurrency?: number;
+ upload: true;
+ };
errorTracking?: {
enable?: boolean;
- sourcemaps?: {
- bailOnError?: boolean;
- dryRun?: boolean;
- maxConcurrency?: number;
- minifiedPathPrefix: string;
- releaseVersion: string;
- service: string;
- };
+ sourcemaps?:
+ | {
+ bailOnError?: boolean;
+ debugId: true;
+ dryRun?: boolean;
+ maxConcurrency?: number;
+ }
+ | {
+ bailOnError?: boolean;
+ debugId?: false;
+ dryRun?: boolean;
+ maxConcurrency?: number;
+ minifiedPathPrefix: string;
+ releaseVersion?: string;
+ service: string;
+ };
};
metrics?: {
enable?: boolean;
@@ -145,6 +166,17 @@ Follow the specific documentation for each bundler:
clientToken?: string;
// [...] See https://docs.datadoghq.com/real_user_monitoring/browser/setup/client?tab=rum#configuration for all options.
};
+ sourceCodeContext?:
+ | {
+ debugId: true;
+ service?: string;
+ version?: string;
+ }
+ | {
+ debugId?: false;
+ service: string;
+ version?: string;
+ };
};
}
```
@@ -297,6 +329,31 @@ This is used to identify the build in logs, metrics and spans.
An immutable identifier for the deployed build (typically a release tag, a git commit SHA, or a CI build ID).
This is the canonical place to declare the version once. Plugins that need a build version (for sourcemap upload, source-code resolution, runtime SDK initialization, etc.) read it from here unless they're given a more specific override.
+### `sourcemaps`
+
+> default: `null`
+
+Inject a debug ID into each JavaScript bundle and, optionally, upload the corresponding source maps during the build.
+
+```typescript
+{
+ auth: {
+ apiKey: process.env.DATADOG_API_KEY,
+ site: 'datadoghq.com',
+ },
+ sourcemaps: {
+ debugId: true,
+ upload: true,
+ },
+}
+```
+
+Setting `debugId: true` enables injection. Set `upload: true` to upload the source maps directly from the build plugin. Uploading requires `auth.apiKey` or the `DATADOG_API_KEY` environment variable.
+
+The `bailOnError`, `dryRun`, and `maxConcurrency` upload options are available when `upload` is `true`.
+
+Existing `rum.sourceCodeContext` and `errorTracking.sourcemaps` configurations remain supported. The top-level `sourcemaps` option can be combined with `rum.sourceCodeContext.service` and `rum.sourceCodeContext.version`; this metadata is independent of debug ID matching. Do not combine the top-level option with `errorTracking.sourcemaps` or with `rum.sourceCodeContext.debugId: false`.
+
## Features
@@ -314,14 +371,22 @@ This is the canonical place to declare the version once. Plugins that need a bui
datadogWebpackPlugin({
errorTracking?: {
enable?: boolean,
- sourcemaps?: {
- bailOnError?: boolean,
- dryRun?: boolean,
- maxConcurrency?: number,
- minifiedPathPrefix: string,
- releaseVersion: string,
- service: string,
- },
+ sourcemaps?:
+ | {
+ bailOnError?: boolean,
+ debugId: true,
+ dryRun?: boolean,
+ maxConcurrency?: number,
+ }
+ | {
+ bailOnError?: boolean,
+ debugId?: false,
+ dryRun?: boolean,
+ maxConcurrency?: number,
+ minifiedPathPrefix: string,
+ releaseVersion?: string,
+ service: string,
+ },
}
});
```
@@ -407,6 +472,17 @@ datadogWebpackPlugin({
clientToken?: string,
// [...] See https://docs.datadoghq.com/real_user_monitoring/browser/setup/client?tab=rum#configuration for all options.
},
+ sourceCodeContext?:
+ | {
+ debugId: true,
+ service?: string,
+ version?: string,
+ }
+ | {
+ debugId?: false,
+ service: string,
+ version?: string,
+ },
}
});
```
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 67f2f2f75..c978745a9 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -280,6 +280,24 @@ export interface BaseOptions {
logLevel?: LogLevel;
}
+type SourcemapsUploadEnabledOptions = {
+ upload: true;
+ bailOnError?: boolean;
+ dryRun?: boolean;
+ maxConcurrency?: number;
+};
+
+type SourcemapsInjectionOnlyOptions = {
+ upload?: false;
+ bailOnError?: never;
+ dryRun?: never;
+ maxConcurrency?: never;
+};
+
+export type SourcemapsOptions = {
+ debugId: true;
+} & (SourcemapsUploadEnabledOptions | SourcemapsInjectionOnlyOptions);
+
export interface Options extends BaseOptions {
// Each product should have a unique entry.
// #types-injection-marker
@@ -290,6 +308,7 @@ export interface Options extends BaseOptions {
[output.CONFIG_KEY]?: OutputOptions;
[rum.CONFIG_KEY]?: RumOptions;
// #types-injection-marker
+ sourcemaps?: SourcemapsOptions;
customPlugins?: GetCustomPlugins;
}
diff --git a/packages/factory/src/index.test.ts b/packages/factory/src/index.test.ts
index eb2261485..601a3f5f4 100644
--- a/packages/factory/src/index.test.ts
+++ b/packages/factory/src/index.test.ts
@@ -3,6 +3,7 @@
// Copyright 2019-Present Datadog, Inc.
import type { PluginOptions, Options } from '@dd/core/types';
+import { PLUGIN_NAME as ERROR_TRACKING_PLUGIN_NAME } from '@dd/error-tracking-plugin';
import { buildPluginFactory } from '@dd/factory';
const invokeFactory = (opts: Options): PluginOptions[] => {
@@ -56,6 +57,33 @@ describe('Factory', () => {
expect(hasPlugin(plugins, 'output')).toBe(true);
});
+ test('Should include error tracking for RUM debug ID uploads', () => {
+ const plugins = invokeFactory({
+ auth: { apiKey: '123' },
+ errorTracking: { sourcemaps: { debugId: true } },
+ logLevel: 'none',
+ rum: { sourceCodeContext: { debugId: true } },
+ });
+ expect(hasPlugin(plugins, ERROR_TRACKING_PLUGIN_NAME)).toBe(true);
+ });
+
+ test('Should not upload from the top-level sourcemaps option by default', () => {
+ const plugins = invokeFactory({
+ logLevel: 'none',
+ sourcemaps: { debugId: true },
+ });
+ expect(hasPlugin(plugins, ERROR_TRACKING_PLUGIN_NAME)).toBe(false);
+ });
+
+ test('Should inject and upload debug ID source maps from the top-level sourcemaps option', () => {
+ const plugins = invokeFactory({
+ auth: { apiKey: '123' },
+ logLevel: 'none',
+ sourcemaps: { debugId: true, upload: true },
+ });
+ expect(hasPlugin(plugins, ERROR_TRACKING_PLUGIN_NAME)).toBe(true);
+ });
+
test('Should coerce a non-boolean enable value and still include the plugin', () => {
const plugins = invokeFactory({
logLevel: 'none',
diff --git a/packages/factory/src/validate.test.ts b/packages/factory/src/validate.test.ts
index 81306a366..ef8d54d77 100644
--- a/packages/factory/src/validate.test.ts
+++ b/packages/factory/src/validate.test.ts
@@ -100,4 +100,99 @@ describe('factory validateOptions', () => {
).not.toThrow();
});
});
+
+ describe('sourcemaps', () => {
+ it('should normalize debug ID injection without upload', () => {
+ expect(validateOptions({ sourcemaps: { debugId: true } })).toEqual(
+ expect.objectContaining({
+ rum: { sourceCodeContext: { debugId: true } },
+ }),
+ );
+ expect(
+ validateOptions({ sourcemaps: { debugId: true } }).errorTracking,
+ ).toBeUndefined();
+ });
+
+ it('should normalize debug ID injection and upload options', () => {
+ expect(
+ validateOptions({
+ sourcemaps: {
+ bailOnError: true,
+ debugId: true,
+ dryRun: true,
+ maxConcurrency: 5,
+ upload: true,
+ },
+ }),
+ ).toEqual(
+ expect.objectContaining({
+ errorTracking: {
+ sourcemaps: {
+ bailOnError: true,
+ debugId: true,
+ dryRun: true,
+ maxConcurrency: 5,
+ },
+ },
+ rum: { sourceCodeContext: { debugId: true } },
+ }),
+ );
+ });
+
+ it('should leave omitted upload options unset for downstream defaults', () => {
+ expect(
+ validateOptions({ sourcemaps: { debugId: true, upload: true } }).errorTracking,
+ ).toEqual({ sourcemaps: { debugId: true } });
+ });
+
+ it('should preserve source code context metadata while enabling debug IDs', () => {
+ expect(
+ validateOptions({
+ metadata: { version: '1.2.3' },
+ rum: { sourceCodeContext: { service: 'checkout' } },
+ sourcemaps: { debugId: true, upload: true },
+ }),
+ ).toEqual(
+ expect.objectContaining({
+ rum: {
+ sourceCodeContext: {
+ debugId: true,
+ service: 'checkout',
+ },
+ },
+ }),
+ );
+ });
+
+ it.each([
+ {
+ input: { sourcemaps: { debugId: false } },
+ error: /sourcemaps\.debugId must be true/,
+ },
+ {
+ input: { sourcemaps: { debugId: true, upload: 'yes' } },
+ error: /sourcemaps\.upload must be a boolean/,
+ },
+ {
+ input: { sourcemaps: { bailOnError: true, debugId: true } },
+ error: /require sourcemaps\.upload to be true/,
+ },
+ {
+ input: {
+ rum: { sourceCodeContext: { debugId: false, service: 'checkout' } },
+ sourcemaps: { debugId: true },
+ },
+ error: /rum\.sourceCodeContext\.debugId cannot be false/,
+ },
+ {
+ input: {
+ errorTracking: { sourcemaps: { debugId: true } },
+ sourcemaps: { debugId: true, upload: true },
+ },
+ error: /cannot be combined with errorTracking\.sourcemaps/,
+ },
+ ])('should reject invalid or conflicting configuration', ({ input, error }) => {
+ expect(() => validateOptions(input as unknown as Options)).toThrow(error);
+ });
+ });
});
diff --git a/packages/factory/src/validate.ts b/packages/factory/src/validate.ts
index a2084dc38..785231757 100644
--- a/packages/factory/src/validate.ts
+++ b/packages/factory/src/validate.ts
@@ -10,6 +10,7 @@ import type {
BuildMetadata,
Options,
OptionsWithDefaults,
+ SourcemapsOptions,
} from '@dd/core/types';
const SITES_DOC_URL = 'https://docs.datadoghq.com/getting_started/site/';
@@ -47,15 +48,98 @@ const validateMetadata = (metadata: BuildMetadata | undefined): string[] => {
return errors;
};
+const normalizeSourcemapsOptions = (options: Options, errors: string[]): Options => {
+ if (options.sourcemaps === undefined) {
+ return options;
+ }
+
+ if (
+ options.sourcemaps === null ||
+ typeof options.sourcemaps !== 'object' ||
+ Array.isArray(options.sourcemaps)
+ ) {
+ errors.push('sourcemaps must be an object');
+ return options;
+ }
+
+ const sourcemaps = options.sourcemaps as SourcemapsOptions;
+ const runtimeOptions = sourcemaps as unknown as Record;
+
+ if (runtimeOptions.debugId !== true) {
+ errors.push('sourcemaps.debugId must be true');
+ }
+ if (runtimeOptions.upload !== undefined && typeof runtimeOptions.upload !== 'boolean') {
+ errors.push('sourcemaps.upload must be a boolean');
+ }
+ if (
+ runtimeOptions.upload !== true &&
+ ['bailOnError', 'dryRun', 'maxConcurrency'].some(
+ (option) => runtimeOptions[option] !== undefined,
+ )
+ ) {
+ errors.push(
+ 'sourcemaps.bailOnError, sourcemaps.dryRun, and sourcemaps.maxConcurrency require sourcemaps.upload to be true',
+ );
+ }
+ if (options.rum?.sourceCodeContext?.debugId === false) {
+ errors.push(
+ 'rum.sourceCodeContext.debugId cannot be false when sourcemaps.debugId is true',
+ );
+ }
+ if (options.rum?.enable === false) {
+ errors.push('rum.enable cannot be false when sourcemaps is configured');
+ }
+ if (options.errorTracking?.sourcemaps !== undefined) {
+ errors.push('sourcemaps cannot be combined with errorTracking.sourcemaps');
+ }
+ if (runtimeOptions.upload === true && options.errorTracking?.enable === false) {
+ errors.push('errorTracking.enable cannot be false when sourcemaps.upload is true');
+ }
+
+ if (errors.length > 0) {
+ return options;
+ }
+
+ const normalized: Options = {
+ ...options,
+ rum: {
+ ...options.rum,
+ sourceCodeContext: {
+ ...options.rum?.sourceCodeContext,
+ debugId: true,
+ },
+ },
+ };
+
+ if (sourcemaps.upload === true) {
+ normalized.errorTracking = {
+ ...options.errorTracking,
+ sourcemaps: {
+ debugId: true,
+ ...(sourcemaps.bailOnError !== undefined && {
+ bailOnError: sourcemaps.bailOnError,
+ }),
+ ...(sourcemaps.dryRun !== undefined && { dryRun: sourcemaps.dryRun }),
+ ...(sourcemaps.maxConcurrency !== undefined && {
+ maxConcurrency: sourcemaps.maxConcurrency,
+ }),
+ },
+ };
+ }
+
+ return normalized;
+};
+
export const validateOptions = (options: Options = {}): OptionsWithDefaults => {
const errors: string[] = validateMetadata(options.metadata);
+ const normalizedOptions = normalizeSourcemapsOptions(options, errors);
// DATADOG_SITE env var takes precedence over configuration; only validate
// auth.site when no env var is set, so a stale auth.site can't block a
// build that has already opted into an env override.
const envRaw = getDDEnvValue('SITE');
const resolvedSite =
resolveSite(envRaw, 'DATADOG_SITE/DD_SITE', errors) ??
- resolveSite(options.auth?.site, 'auth.site', errors);
+ resolveSite(normalizedOptions.auth?.site, 'auth.site', errors);
const auth: AuthOptionsWithDefaults = {
site: resolvedSite?.site ?? DEFAULT_SITE,
@@ -68,12 +152,12 @@ export const validateOptions = (options: Options = {}): OptionsWithDefaults => {
// Prevent these from being accidentally logged.
Object.defineProperty(auth, 'apiKey', {
- value: getDDEnvValue('API_KEY') || options.auth?.apiKey,
+ value: getDDEnvValue('API_KEY') || normalizedOptions.auth?.apiKey,
enumerable: false,
});
Object.defineProperty(auth, 'appKey', {
- value: getDDEnvValue('APP_KEY') || options.auth?.appKey,
+ value: getDDEnvValue('APP_KEY') || normalizedOptions.auth?.appKey,
enumerable: false,
});
@@ -81,7 +165,7 @@ export const validateOptions = (options: Options = {}): OptionsWithDefaults => {
enableGit: true,
logLevel: 'warn',
metadata: {},
- ...options,
+ ...normalizedOptions,
auth,
};
};
diff --git a/packages/plugins/error-tracking/README.md b/packages/plugins/error-tracking/README.md
index 3e32cb7de..46d65f7e8 100644
--- a/packages/plugins/error-tracking/README.md
+++ b/packages/plugins/error-tracking/README.md
@@ -13,6 +13,7 @@ Interact with Error Tracking directly from your build system.
- [errorTracking.enable](#errortrackingenable)
- [Sourcemaps Upload](#sourcemaps-upload)
- [errorTracking.sourcemaps.bailOnError](#errortrackingsourcemapsbailonerror)
+ - [errorTracking.sourcemaps.debugId](#errortrackingsourcemapsdebugid)
- [errorTracking.sourcemaps.dryRun](#errortrackingsourcemapsdryrun)
- [errorTracking.sourcemaps.maxConcurrency](#errortrackingsourcemapsmaxconcurrency)
- [errorTracking.sourcemaps.minifiedPathPrefix](#errortrackingsourcemapsminifiedpathprefix)
@@ -25,14 +26,22 @@ Interact with Error Tracking directly from your build system.
```ts
errorTracking?: {
enable?: boolean;
- sourcemaps?: {
- bailOnError?: boolean;
- dryRun?: boolean;
- maxConcurrency?: number;
- minifiedPathPrefix: string;
- releaseVersion: string;
- service: string;
- };
+ sourcemaps?:
+ | {
+ bailOnError?: boolean;
+ debugId: true;
+ dryRun?: boolean;
+ maxConcurrency?: number;
+ }
+ | {
+ bailOnError?: boolean;
+ debugId?: false;
+ dryRun?: boolean;
+ maxConcurrency?: number;
+ minifiedPathPrefix: string;
+ releaseVersion?: string;
+ service: string;
+ };
}
```
@@ -48,6 +57,22 @@ Must be a boolean. Non-boolean values are coerced today but will be rejected in
Upload JavaScript sourcemaps to Datadog to un-minify your errors.
+For new debug ID configurations, use the top-level `sourcemaps` option:
+
+```ts
+datadogWebpackPlugin({
+ auth: {
+ apiKey: process.env.DATADOG_API_KEY,
+ },
+ sourcemaps: {
+ debugId: true,
+ upload: true,
+ },
+});
+```
+
+Existing `errorTracking.sourcemaps` configurations remain supported. Configure `errorTracking.sourcemaps.debugId: true` to upload by debug ID, or configure `service`, `releaseVersion`, and `minifiedPathPrefix` to use service/version matching. These two upload-matching strategies are mutually exclusive. RUM source code context may still include service and version alongside an injected debug ID because that metadata is independent of source-map matching.
+
> [!NOTE]
> You can override the domain used in the request with the `DATADOG_SITE` environment variable or the `auth.site` options (eg. `datadoghq.eu`).
> You can override the full intake URL by setting the `DATADOG_SOURCEMAP_INTAKE_URL` environment variable (eg. `https://sourcemap-intake.datadoghq.com/v1/input`).
@@ -58,6 +83,12 @@ Upload JavaScript sourcemaps to Datadog to un-minify your errors.
Should the upload of sourcemaps fail the build on first error?
+### errorTracking.sourcemaps.debugId
+
+> default: `false`
+
+Upload source maps using the debug IDs injected with `rum.sourceCodeContext.debugId: true`.
+
### errorTracking.sourcemaps.dryRun
> default: `false`
diff --git a/packages/plugins/error-tracking/src/index.test.ts b/packages/plugins/error-tracking/src/index.test.ts
index 97a29b164..b631e4ed5 100644
--- a/packages/plugins/error-tracking/src/index.test.ts
+++ b/packages/plugins/error-tracking/src/index.test.ts
@@ -43,6 +43,19 @@ describe('Error Tracking Plugin', () => {
expect(uploadSourcemapsMock).toHaveBeenCalledTimes(BUNDLERS.length);
});
+ test('Should process source maps when RUM debug ID uploads are enabled.', async () => {
+ await runBundlers({
+ auth: { apiKey: '123' },
+ enableGit: false,
+ errorTracking: { sourcemaps: { debugId: true } },
+ rum: { sourceCodeContext: { debugId: true } },
+ });
+ expect(uploadSourcemapsMock).toHaveBeenCalledTimes(BUNDLERS.length);
+ expect(uploadSourcemapsMock.mock.calls[0][0]).toMatchObject({
+ sourcemaps: { debugId: true },
+ });
+ });
+
test('Should not send sourcemap upload metrics unless metrics are enabled.', async () => {
await runBundlers({
enableGit: false,
diff --git a/packages/plugins/error-tracking/src/sourcemaps/files.test.ts b/packages/plugins/error-tracking/src/sourcemaps/files.test.ts
index 979dbedd3..6aef8e375 100644
--- a/packages/plugins/error-tracking/src/sourcemaps/files.test.ts
+++ b/packages/plugins/error-tracking/src/sourcemaps/files.test.ts
@@ -8,7 +8,10 @@ import {
decomposePath,
getSourcemapsFiles,
} from '@dd/error-tracking-plugin/sourcemaps/files';
-import { getSourcemapsConfiguration } from '@dd/tests/_jest/helpers/mocks';
+import {
+ getDebugIdSourcemapsConfiguration,
+ getSourcemapsConfiguration,
+} from '@dd/tests/_jest/helpers/mocks';
import stripAnsi from 'strip-ansi';
import type { MinifiedPathPrefix } from '../types';
@@ -233,5 +236,28 @@ describe('Error Tracking Plugin Sourcemaps Files', () => {
sourcemapFilePath: '/build/app.js.map',
});
});
+
+ test('Should use relative paths for debug ID uploads', () => {
+ const result = getSourcemapsFiles(getDebugIdSourcemapsConfiguration(), {
+ outDir: '/build',
+ outputs: [
+ {
+ name: 'app.js.map',
+ filepath: '/build/assets/app.js.map',
+ inputs: [],
+ size: 500,
+ type: 'js',
+ },
+ ],
+ });
+
+ expect(result[0]).toEqual({
+ minifiedFilePath: '/build/assets/app.js',
+ minifiedPathPrefix: undefined,
+ minifiedUrl: 'assets/app.js',
+ relativePath: 'assets/app.js',
+ sourcemapFilePath: '/build/assets/app.js.map',
+ });
+ });
});
});
diff --git a/packages/plugins/error-tracking/src/sourcemaps/files.ts b/packages/plugins/error-tracking/src/sourcemaps/files.ts
index 759a4dc6a..7574d4421 100644
--- a/packages/plugins/error-tracking/src/sourcemaps/files.ts
+++ b/packages/plugins/error-tracking/src/sourcemaps/files.ts
@@ -6,7 +6,11 @@ import type { Output } from '@dd/core/types';
import chalk from 'chalk';
import path from 'path';
-import type { SourcemapsOptionsWithDefaults, Sourcemap, MinifiedPathPrefix } from '../types';
+import {
+ type SourcemapsOptionsWithDefaults,
+ type Sourcemap,
+ type MinifiedPathPrefix,
+} from '../types';
type PartialSourcemap = Pick;
@@ -34,7 +38,7 @@ export const joinUrlOrPath = (prefix: MinifiedPathPrefix, relativePath: string):
};
export const decomposePath = (
- prefix: MinifiedPathPrefix,
+ prefix: MinifiedPathPrefix | undefined,
// This is coming from context.bundler.outDir, which is absolute.
absoluteOutDir: string,
sourcemapFilePath: string,
@@ -45,7 +49,9 @@ export const decomposePath = (
const minifiedFilePath = sourcemapFilePath.replace(/\.map$/, '');
const relativePath = path.relative(absoluteOutDir, minifiedFilePath);
- const minifiedUrl = joinUrlOrPath(prefix, relativePath);
+ const minifiedUrl = prefix
+ ? joinUrlOrPath(prefix, relativePath)
+ : relativePath.split(path.sep).join('/');
return {
minifiedFilePath,
@@ -72,10 +78,12 @@ export const getSourcemapsFiles = (
.map((file) => file.filepath);
const sourcemapFiles = sourcemapFilesList.map((sourcemapFilePath) => {
+ const minifiedPathPrefix =
+ options.debugId === false ? options.minifiedPathPrefix : undefined;
return {
- ...decomposePath(options.minifiedPathPrefix, context.outDir, sourcemapFilePath),
+ ...decomposePath(minifiedPathPrefix, context.outDir, sourcemapFilePath),
sourcemapFilePath,
- minifiedPathPrefix: options.minifiedPathPrefix,
+ minifiedPathPrefix,
};
});
diff --git a/packages/plugins/error-tracking/src/sourcemaps/payload.ts b/packages/plugins/error-tracking/src/sourcemaps/payload.ts
index d68c95017..435ca80cb 100644
--- a/packages/plugins/error-tracking/src/sourcemaps/payload.ts
+++ b/packages/plugins/error-tracking/src/sourcemaps/payload.ts
@@ -17,9 +17,9 @@ export type Payload = {
export type Metadata = {
plugin_version: string;
project_path: string;
- service: string;
type: string;
- version: string;
+ service?: string;
+ version?: string;
git_repository_url?: string;
git_commit_sha?: string;
debug_id?: string;
@@ -69,7 +69,7 @@ export const prefixRepeat = (filePath: string, prefix: string): string => {
const getSourcemapValidity = async (
sourcemap: Sourcemap,
- prefix: string,
+ prefix?: string,
): Promise => {
const [resultMinFile, resultSourcemap] = await Promise.all([
checkFile(sourcemap.minifiedFilePath),
@@ -79,14 +79,14 @@ const getSourcemapValidity = async (
return {
file: resultMinFile,
sourcemap: resultSourcemap,
- repeatedPrefix: prefixRepeat(sourcemap.relativePath, prefix),
+ repeatedPrefix: prefix ? prefixRepeat(sourcemap.relativePath, prefix) : '',
};
};
export const getPayload = async (
sourcemap: Sourcemap,
metadata: Metadata,
- prefix: string,
+ prefix?: string,
git?: RepositoryData,
debugId?: string,
): Promise => {
diff --git a/packages/plugins/error-tracking/src/sourcemaps/sender.test.ts b/packages/plugins/error-tracking/src/sourcemaps/sender.test.ts
index edb3a607d..648848559 100644
--- a/packages/plugins/error-tracking/src/sourcemaps/sender.test.ts
+++ b/packages/plugins/error-tracking/src/sourcemaps/sender.test.ts
@@ -18,6 +18,7 @@ import {
mockLogFn,
mockLogger,
getPayloadMock,
+ getDebugIdSourcemapsConfiguration,
getSourcemapMock,
getSourcemapsConfiguration,
addFixtureFiles,
@@ -213,6 +214,184 @@ describe('Error Tracking Plugin Sourcemaps', () => {
getPayloadSpy.mockRestore();
rmSync(tempDir);
});
+
+ test('Should upload by debug ID without service, version, or path configuration', async () => {
+ const debugId = '12345678-1234-4123-8123-123456789012';
+ const minifiedFileContent = `({ddDebugId:"${debugId}"},"DD_SOURCE_CODE_CONTEXT");`;
+ const tempDir = path.join(os.tmpdir(), 'dd-build-plugins-debug-id-upload-test');
+ const minifiedFilePath = path.join(tempDir, 'app.js');
+ const sourcemapFilePath = path.join(tempDir, 'app.js.map');
+ outputFileSync(minifiedFilePath, minifiedFileContent);
+ outputFileSync(sourcemapFilePath, '{"version":3,"sources":["app.js"]}');
+ addFixtureFiles({
+ [minifiedFilePath]: minifiedFileContent,
+ [sourcemapFilePath]: '{"version":3,"sources":["app.js"]}',
+ });
+ const getPayloadSpy = jest.spyOn(payloadModule, 'getPayload');
+
+ await sendSourcemaps(
+ [
+ getSourcemapMock({
+ minifiedFilePath,
+ minifiedPathPrefix: undefined,
+ minifiedUrl: 'app.js',
+ relativePath: 'app.js',
+ sourcemapFilePath,
+ }),
+ ],
+ getDebugIdSourcemapsConfiguration(),
+ senderContextMock,
+ mockLogger,
+ );
+
+ expect(getPayloadSpy).toHaveBeenCalledTimes(1);
+ expect(getPayloadSpy.mock.calls[0][1]).not.toHaveProperty('service');
+ expect(getPayloadSpy.mock.calls[0][1]).not.toHaveProperty('version');
+ expect(getPayloadSpy.mock.calls[0][4]).toBe(debugId);
+
+ getPayloadSpy.mockRestore();
+ rmSync(tempDir);
+ });
+
+ test('Should skip files without a debug ID and upload the remaining sourcemaps', async () => {
+ const debugId = '12345678-1234-4123-8123-123456789012';
+ const tempDir = path.join(os.tmpdir(), 'dd-build-plugins-partial-debug-id-test');
+ const minifiedFileWithDebugId = path.join(tempDir, 'with-debug-id.js');
+ const minifiedFileWithoutDebugId = path.join(tempDir, 'without-debug-id.js');
+ const sourcemapWithDebugId = path.join(tempDir, 'with-debug-id.js.map');
+ const sourcemapWithoutDebugId = path.join(tempDir, 'without-debug-id.js.map');
+ const contentWithDebugId = `({ddDebugId:"${debugId}"},"DD_SOURCE_CODE_CONTEXT");`;
+ const contentWithoutDebugId = 'console.log("no debug id");';
+ const sourcemapContent = '{"version":3,"sources":[]}';
+
+ outputFileSync(minifiedFileWithDebugId, contentWithDebugId);
+ outputFileSync(minifiedFileWithoutDebugId, contentWithoutDebugId);
+ outputFileSync(sourcemapWithDebugId, sourcemapContent);
+ outputFileSync(sourcemapWithoutDebugId, sourcemapContent);
+ addFixtureFiles({
+ [minifiedFileWithDebugId]: contentWithDebugId,
+ [minifiedFileWithoutDebugId]: contentWithoutDebugId,
+ [sourcemapWithDebugId]: sourcemapContent,
+ [sourcemapWithoutDebugId]: sourcemapContent,
+ });
+ const getPayloadSpy = jest.spyOn(payloadModule, 'getPayload');
+
+ await sendSourcemaps(
+ [
+ getSourcemapMock({
+ minifiedFilePath: minifiedFileWithDebugId,
+ sourcemapFilePath: sourcemapWithDebugId,
+ }),
+ getSourcemapMock({
+ minifiedFilePath: minifiedFileWithoutDebugId,
+ sourcemapFilePath: sourcemapWithoutDebugId,
+ }),
+ ],
+ getDebugIdSourcemapsConfiguration(),
+ senderContextMock,
+ mockLogger,
+ );
+
+ expect(getPayloadSpy).toHaveBeenCalledTimes(1);
+ expect(getPayloadSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ minifiedFilePath: minifiedFileWithDebugId }),
+ expect.any(Object),
+ undefined,
+ senderContextMock.git,
+ debugId,
+ );
+ expect(doRequestMock).toHaveBeenCalledTimes(1);
+ expect(mockLogFn).toHaveBeenCalledWith(
+ expect.stringContaining(
+ `Skipping sourcemap ${sourcemapWithoutDebugId} because no debug ID was found in ${minifiedFileWithoutDebugId}`,
+ ),
+ 'warn',
+ );
+ expect(mockLogFn).toHaveBeenCalledWith(
+ expect.stringMatching(/Done uploading .*1\/1.* sourcemaps/),
+ 'debug',
+ );
+
+ getPayloadSpy.mockRestore();
+ rmSync(tempDir);
+ });
+
+ test('Should abort when all debug IDs are missing', async () => {
+ const tempDir = path.join(os.tmpdir(), 'dd-build-plugins-no-debug-id');
+ const minifiedFilePath = path.join(tempDir, 'app.js');
+ const sourcemapFilePath = path.join(tempDir, 'app.js.map');
+ const minifiedFileContent = 'console.log("no debug id");';
+ const sourcemapContent = '{"version":3,"sources":["app.js"]}';
+ outputFileSync(minifiedFilePath, minifiedFileContent);
+ outputFileSync(sourcemapFilePath, sourcemapContent);
+ addFixtureFiles({
+ [minifiedFilePath]: minifiedFileContent,
+ [sourcemapFilePath]: sourcemapContent,
+ });
+ const getPayloadSpy = jest.spyOn(payloadModule, 'getPayload');
+
+ await sendSourcemaps(
+ [getSourcemapMock({ minifiedFilePath, sourcemapFilePath })],
+ getDebugIdSourcemapsConfiguration(),
+ senderContextMock,
+ mockLogger,
+ );
+
+ expect(getPayloadSpy).not.toHaveBeenCalled();
+ expect(doRequestMock).not.toHaveBeenCalled();
+ expect(mockLogFn).toHaveBeenCalledWith(
+ 'No debug ID found in any minified file. Aborting upload.',
+ 'error',
+ );
+
+ getPayloadSpy.mockRestore();
+ rmSync(tempDir);
+ });
+
+ test('Should throw when all debug IDs are missing and bailOnError is enabled', async () => {
+ const tempDir = path.join(os.tmpdir(), 'dd-build-plugins-no-debug-id-bail');
+ const minifiedFilePath = path.join(tempDir, 'app.js');
+ const sourcemapFilePath = path.join(tempDir, 'app.js.map');
+ const minifiedFileContent = 'console.log("no debug id");';
+ const sourcemapContent = '{"version":3,"sources":["app.js"]}';
+ outputFileSync(minifiedFilePath, minifiedFileContent);
+ outputFileSync(sourcemapFilePath, sourcemapContent);
+ addFixtureFiles({
+ [minifiedFilePath]: minifiedFileContent,
+ [sourcemapFilePath]: sourcemapContent,
+ });
+ const getPayloadSpy = jest.spyOn(payloadModule, 'getPayload');
+
+ await expect(
+ sendSourcemaps(
+ [getSourcemapMock({ minifiedFilePath, sourcemapFilePath })],
+ { ...getDebugIdSourcemapsConfiguration(), bailOnError: true },
+ senderContextMock,
+ mockLogger,
+ ),
+ ).rejects.toThrow('No debug ID found in any minified file. Aborting upload.');
+
+ expect(getPayloadSpy).not.toHaveBeenCalled();
+ expect(doRequestMock).not.toHaveBeenCalled();
+
+ getPayloadSpy.mockRestore();
+ rmSync(tempDir);
+ });
+
+ test('Should not report missing debug IDs when no sourcemaps were found', async () => {
+ await sendSourcemaps(
+ [],
+ getDebugIdSourcemapsConfiguration(),
+ senderContextMock,
+ mockLogger,
+ );
+
+ expect(doRequestMock).not.toHaveBeenCalled();
+ expect(mockLogFn).not.toHaveBeenCalledWith(
+ 'No debug ID found in any minified file. Aborting upload.',
+ 'error',
+ );
+ });
});
describe('upload', () => {
diff --git a/packages/plugins/error-tracking/src/sourcemaps/sender.ts b/packages/plugins/error-tracking/src/sourcemaps/sender.ts
index 3f9549211..216d6be44 100644
--- a/packages/plugins/error-tracking/src/sourcemaps/sender.ts
+++ b/packages/plugins/error-tracking/src/sourcemaps/sender.ts
@@ -37,6 +37,11 @@ type FileMetadata = {
file: string;
};
+type SourcemapWithDebugId = {
+ sourcemap: Sourcemap;
+ debugId?: string;
+};
+
export const SOURCEMAPS_API_SUBDOMAIN = 'sourcemap-intake';
export const SOURCEMAPS_API_PATH = 'api/v2/srcmap';
@@ -187,39 +192,67 @@ export const sendSourcemaps = async (
log: Logger,
) => {
const start = Date.now();
- const prefix = options.minifiedPathPrefix;
+ const prefix = options.debugId === false ? options.minifiedPathPrefix : undefined;
const metadata: Metadata = {
git_repository_url: context.git?.remote,
git_commit_sha: context.git?.hash,
plugin_version: context.version,
project_path: context.outDir,
- service: options.service,
type: 'js_sourcemap',
- version: options.releaseVersion,
+ ...(options.debugId === false
+ ? { service: options.service, version: options.releaseVersion }
+ : {}),
};
const payloadsTimer = log.time('Compute payloads');
// @ts-expect-error PQueue's default isn't typed.
const Queue = PQueue.default ? PQueue.default : PQueue;
const payloadsQueue = new Queue({ concurrency: options.maxConcurrency });
- let debugIdCount = 0;
- const payloads: Payload[] = await payloadsQueue.addAll(
+ const sourcemapsWithDebugIds: SourcemapWithDebugId[] = await payloadsQueue.addAll(
sourcemaps.map((sourcemap) => async () => {
const debugId = await extractDebugId(sourcemap.minifiedFilePath);
- if (debugId) {
- debugIdCount += 1;
- }
- return getPayload(sourcemap, metadata, prefix, context.git, debugId);
+ return { sourcemap, debugId };
}),
);
- payloadsTimer.end();
+ const debugIdCount = sourcemapsWithDebugIds.filter(({ debugId }) => debugId).length;
log.debug(
`Extracted debug_id for ${green(`${debugIdCount}/${sourcemaps.length}`)} sourcemaps.`,
);
+ if (options.debugId && sourcemaps.length > 0 && debugIdCount === 0) {
+ payloadsTimer.end();
+ const errorMsg = 'No debug ID found in any minified file. Aborting upload.';
+ log.error(errorMsg);
+ if (options.bailOnError === true) {
+ throw new Error(errorMsg);
+ }
+ return;
+ }
+
+ const skippedSourcemaps = options.debugId
+ ? sourcemapsWithDebugIds.filter(({ debugId }) => !debugId)
+ : [];
+ const sourcemapsToUpload = options.debugId
+ ? sourcemapsWithDebugIds.filter(({ debugId }) => debugId)
+ : sourcemapsWithDebugIds;
+ const payloads: Payload[] = await payloadsQueue.addAll(
+ sourcemapsToUpload.map(
+ ({ sourcemap, debugId }) =>
+ async () =>
+ getPayload(sourcemap, metadata, prefix, context.git, debugId),
+ ),
+ );
+ payloadsTimer.end();
+
const errors = payloads.map((payload) => payload.errors).flat();
- const warnings = payloads.map((payload) => payload.warnings).flat();
+ const warnings = [
+ ...skippedSourcemaps.map(
+ ({ sourcemap }) =>
+ `Skipping sourcemap ${sourcemap.sourcemapFilePath} because no debug ID was found in ${sourcemap.minifiedFilePath}`,
+ ),
+ ...payloads.map((payload) => payload.warnings).flat(),
+ ];
if (warnings.length > 0) {
log.warn(`Warnings while preparing payloads:\n - ${warnings.join('\n - ')}`);
@@ -252,7 +285,7 @@ export const sendSourcemaps = async (
);
uploadTimer.end();
log.debug(
- `Done uploading ${green(`${sourcemaps.length - uploadErrors.length}/${sourcemaps.length}`)} sourcemaps in ${green(formatDuration(Date.now() - start))}.`,
+ `Done uploading ${green(`${payloads.length - uploadErrors.length}/${payloads.length}`)} sourcemaps in ${green(formatDuration(Date.now() - start))}.`,
);
if (uploadErrors.length > 0) {
diff --git a/packages/plugins/error-tracking/src/sourcemaps/upload-metrics.ts b/packages/plugins/error-tracking/src/sourcemaps/upload-metrics.ts
index 766aa99f2..a821b67f7 100644
--- a/packages/plugins/error-tracking/src/sourcemaps/upload-metrics.ts
+++ b/packages/plugins/error-tracking/src/sourcemaps/upload-metrics.ts
@@ -44,7 +44,7 @@ export const createSourcemapUploadMetrics = (
options: SourcemapsOptionsWithDefaults,
): SourcemapUploadMetrics => ({
metrics: new Map(),
- baseTags: [`service:${options.service}`],
+ baseTags: options.debugId === false ? [`service:${options.service}`] : ['matching:debug_id'],
});
const incrementUploadMetric = (
diff --git a/packages/plugins/error-tracking/src/types.ts b/packages/plugins/error-tracking/src/types.ts
index 87e8f5e73..be69829c0 100644
--- a/packages/plugins/error-tracking/src/types.ts
+++ b/packages/plugins/error-tracking/src/types.ts
@@ -4,10 +4,21 @@
export type MinifiedPathPrefix = `http://${string}` | `https://${string}` | `/${string}`;
-export type SourcemapsOptions = {
+type SourcemapsUploadOptions = {
bailOnError?: boolean;
dryRun?: boolean;
maxConcurrency?: number;
+};
+
+type DebugIdSourcemapsOptions = SourcemapsUploadOptions & {
+ debugId: true;
+ minifiedPathPrefix?: never;
+ releaseVersion?: never;
+ service?: never;
+};
+
+type ServiceVersionSourcemapsOptions = SourcemapsUploadOptions & {
+ debugId?: false;
minifiedPathPrefix: MinifiedPathPrefix;
// Optional: when omitted, the validator falls back to the shared
// top-level `metadata.version`. At least one of the two must be set.
@@ -15,7 +26,28 @@ export type SourcemapsOptions = {
service: string;
};
-export type SourcemapsOptionsWithDefaults = Required;
+export type SourcemapsOptions = DebugIdSourcemapsOptions | ServiceVersionSourcemapsOptions;
+
+type SourcemapsUploadOptionsWithDefaults = {
+ bailOnError: boolean;
+ dryRun: boolean;
+ maxConcurrency: number;
+};
+
+export type ServiceVersionSourcemapsOptionsWithDefaults = SourcemapsUploadOptionsWithDefaults &
+ Required<
+ Pick
+ > & {
+ debugId: false;
+ };
+
+export type DebugIdSourcemapsOptionsWithDefaults = SourcemapsUploadOptionsWithDefaults & {
+ debugId: true;
+};
+
+export type SourcemapsOptionsWithDefaults =
+ | ServiceVersionSourcemapsOptionsWithDefaults
+ | DebugIdSourcemapsOptionsWithDefaults;
export type ErrorTrackingOptions = {
enable?: boolean;
@@ -32,7 +64,7 @@ export type ErrorTrackingOptionsWithSourcemaps = {
export type Sourcemap = {
minifiedFilePath: string;
- minifiedPathPrefix: MinifiedPathPrefix;
+ minifiedPathPrefix?: MinifiedPathPrefix;
minifiedUrl: string;
relativePath: string;
sourcemapFilePath: string;
diff --git a/packages/plugins/error-tracking/src/validate.test.ts b/packages/plugins/error-tracking/src/validate.test.ts
index ce8203709..19ca56513 100644
--- a/packages/plugins/error-tracking/src/validate.test.ts
+++ b/packages/plugins/error-tracking/src/validate.test.ts
@@ -80,12 +80,95 @@ describe('Error Tracking Plugins validate', () => {
expect(errors).toHaveLength(0);
expect(config).toEqual({
bailOnError: false,
+ debugId: false,
dryRun: false,
maxConcurrency: 20,
...configObject,
});
});
+ test('Should configure debug ID uploads without service, version, or path options', () => {
+ const { config, errors } = validateSourcemapsOptions({
+ auth: { apiKey: '123' },
+ errorTracking: { sourcemaps: { debugId: true } },
+ rum: { sourceCodeContext: { debugId: true } },
+ });
+
+ expect(errors).toHaveLength(0);
+ expect(config).toEqual({
+ bailOnError: false,
+ debugId: true,
+ dryRun: false,
+ maxConcurrency: 20,
+ });
+ });
+
+ test('Should reject debug ID uploads without debug ID injection', () => {
+ const { errors } = validateSourcemapsOptions({
+ auth: { apiKey: '123' },
+ errorTracking: { sourcemaps: { debugId: true } },
+ });
+
+ expect(errors.map(stripAnsi)).toContain(
+ 'rum.sourceCodeContext.debugId must be enabled to upload source maps by debug ID.',
+ );
+ });
+
+ test('Should reject debug ID uploads without an API key', () => {
+ const { errors } = validateSourcemapsOptions({
+ errorTracking: { sourcemaps: { debugId: true } },
+ rum: { sourceCodeContext: { debugId: true } },
+ });
+
+ expect(errors.map(stripAnsi)).toContain(
+ 'auth.apiKey is required to upload source maps by debug ID.',
+ );
+ });
+
+ test('Should reject combined debug ID and service/version uploads', () => {
+ const { errors } = validateSourcemapsOptions({
+ auth: { apiKey: '123' },
+ errorTracking: {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ sourcemaps: {
+ debugId: true,
+ ...getMinimalSourcemapsConfiguration(),
+ } as any,
+ },
+ rum: { sourceCodeContext: { debugId: true } },
+ });
+
+ expect(errors.map(stripAnsi)).toContain(
+ 'sourcemaps.service, sourcemaps.releaseVersion, and sourcemaps.minifiedPathPrefix cannot be used when sourcemaps.debugId is enabled.',
+ );
+ });
+
+ test('Should make debug ID and service/version uploads mutually exclusive in types', () => {
+ const mixedUpload: SourcemapsOptions = {
+ debugId: true,
+ // @ts-expect-error - debug ID cannot be combined with service/version matching.
+ minifiedPathPrefix: '/prefix',
+ releaseVersion: '1.0.0',
+ service: 'checkout',
+ };
+ expect(mixedUpload).toBeDefined();
+ });
+
+ test('Should reject debug ID uploads when RUM is disabled', () => {
+ const { errors } = validateSourcemapsOptions({
+ auth: { apiKey: '123' },
+ errorTracking: { sourcemaps: { debugId: true } },
+ rum: {
+ enable: false,
+ sourceCodeContext: { debugId: true },
+ },
+ });
+
+ expect(errors.map(stripAnsi)).toContain(
+ 'rum must be enabled to upload source maps by debug ID.',
+ );
+ });
+
test('Should fall back to metadata.version when sourcemaps.releaseVersion is unset', () => {
const { config, errors } = validateSourcemapsOptions({
metadata: { version: '2.0.0' },
diff --git a/packages/plugins/error-tracking/src/validate.ts b/packages/plugins/error-tracking/src/validate.ts
index f96b9241d..ed0928bef 100644
--- a/packages/plugins/error-tracking/src/validate.ts
+++ b/packages/plugins/error-tracking/src/validate.ts
@@ -6,10 +6,10 @@ import type { Logger, Options } from '@dd/core/types';
import chalk from 'chalk';
import { CONFIG_KEY, PLUGIN_NAME } from './constants';
-import type {
- ErrorTrackingOptions,
- ErrorTrackingOptionsWithDefaults,
- SourcemapsOptionsWithDefaults,
+import {
+ type ErrorTrackingOptions,
+ type ErrorTrackingOptionsWithDefaults,
+ type SourcemapsOptionsWithDefaults,
} from './types';
// Deal with validation and defaults here.
@@ -62,59 +62,100 @@ export const validateSourcemapsOptions = (
errors: [],
};
- if (validatedOptions.sourcemaps) {
- const sourcemapsCfg = validatedOptions.sourcemaps;
+ if (!validatedOptions.sourcemaps) {
+ return toReturn;
+ }
- // Resolve `releaseVersion`: prefer the plugin-specific option, then
- // fall back to the shared top-level `metadata.version`. Letting users
- // configure one canonical build version at the top level keeps every
- // consumer (live-debugger, sourcemaps, …) reading from the same place.
- const releaseVersion = sourcemapsCfg.releaseVersion || config.metadata?.version;
+ const sourcemapsCfg = validatedOptions.sourcemaps;
- // Validate the configuration.
- if (!releaseVersion) {
+ if (sourcemapsCfg.debugId === true) {
+ if (config.rum?.enable === false) {
toReturn.errors.push(
- `${red('sourcemaps.releaseVersion')} is required (set it directly or via ${red('metadata.version')}).`,
+ `${red('rum')} must be enabled to upload source maps by debug ID.`,
);
}
- if (
- sourcemapsCfg.releaseVersion &&
- config.metadata?.version &&
- sourcemapsCfg.releaseVersion !== config.metadata.version
- ) {
+ if (!config.rum?.sourceCodeContext?.debugId) {
toReturn.errors.push(
- `${red('sourcemaps.releaseVersion')} must match ${red('metadata.version')} when both are configured.`,
+ `${red('rum.sourceCodeContext.debugId')} must be enabled to upload source maps by debug ID.`,
);
}
- if (!sourcemapsCfg.service) {
- toReturn.errors.push(`${red('sourcemaps.service')} is required.`);
- }
- if (!sourcemapsCfg.minifiedPathPrefix) {
- toReturn.errors.push(`${red('sourcemaps.minifiedPathPrefix')} is required.`);
- }
-
- // Validate the minifiedPathPrefix.
if (
- sourcemapsCfg.minifiedPathPrefix &&
- !validateMinifiedPathPrefix(sourcemapsCfg.minifiedPathPrefix)
+ sourcemapsCfg.service !== undefined ||
+ sourcemapsCfg.releaseVersion !== undefined ||
+ sourcemapsCfg.minifiedPathPrefix !== undefined
) {
toReturn.errors.push(
- `${red('sourcemaps.minifiedPathPrefix')} must be a valid URL or start with '/'.`,
+ `${red('sourcemaps.service')}, ${red('sourcemaps.releaseVersion')}, and ${red('sourcemaps.minifiedPathPrefix')} cannot be used when ${red('sourcemaps.debugId')} is enabled.`,
+ );
+ }
+ if (!config.auth?.apiKey) {
+ toReturn.errors.push(
+ `${red('auth.apiKey')} is required to upload source maps by debug ID.`,
);
}
- // Build the resolved config only when `releaseVersion` actually
- // resolves; otherwise an error has been recorded and the caller will
- // throw before the config is read.
- if (releaseVersion) {
+ if (toReturn.errors.length === 0) {
toReturn.config = {
bailOnError: false,
dryRun: false,
maxConcurrency: 20,
...sourcemapsCfg,
- releaseVersion,
};
}
+
+ return toReturn;
+ }
+
+ // Resolve `releaseVersion`: prefer the plugin-specific option, then
+ // fall back to the shared top-level `metadata.version`. Letting users
+ // configure one canonical build version at the top level keeps every
+ // consumer (live-debugger, sourcemaps, …) reading from the same place.
+ const releaseVersion = sourcemapsCfg.releaseVersion || config.metadata?.version;
+
+ // Validate the configuration.
+ if (!releaseVersion) {
+ toReturn.errors.push(
+ `${red('sourcemaps.releaseVersion')} is required (set it directly or via ${red('metadata.version')}).`,
+ );
+ }
+ if (
+ sourcemapsCfg.releaseVersion &&
+ config.metadata?.version &&
+ sourcemapsCfg.releaseVersion !== config.metadata.version
+ ) {
+ toReturn.errors.push(
+ `${red('sourcemaps.releaseVersion')} must match ${red('metadata.version')} when both are configured.`,
+ );
+ }
+ if (!sourcemapsCfg.service) {
+ toReturn.errors.push(`${red('sourcemaps.service')} is required.`);
+ }
+ if (!sourcemapsCfg.minifiedPathPrefix) {
+ toReturn.errors.push(`${red('sourcemaps.minifiedPathPrefix')} is required.`);
+ }
+
+ // Validate the minifiedPathPrefix.
+ if (
+ sourcemapsCfg.minifiedPathPrefix &&
+ !validateMinifiedPathPrefix(sourcemapsCfg.minifiedPathPrefix)
+ ) {
+ toReturn.errors.push(
+ `${red('sourcemaps.minifiedPathPrefix')} must be a valid URL or start with '/'.`,
+ );
+ }
+
+ // Build the resolved config only when `releaseVersion` actually
+ // resolves; otherwise an error has been recorded and the caller will
+ // throw before the config is read.
+ if (releaseVersion) {
+ toReturn.config = {
+ bailOnError: false,
+ dryRun: false,
+ maxConcurrency: 20,
+ ...sourcemapsCfg,
+ debugId: false,
+ releaseVersion,
+ };
}
return toReturn;
diff --git a/packages/plugins/rum/README.md b/packages/plugins/rum/README.md
index dc141d9c9..17e2241d7 100644
--- a/packages/plugins/rum/README.md
+++ b/packages/plugins/rum/README.md
@@ -17,6 +17,8 @@ Interact with Real User Monitoring (RUM) directly from your build system.
- [Using global `DD_RUM`](#using-global-ddrum)
- [rum.sdk.applicationId](#rumsdkapplicationid)
- [rum.sdk.clientToken](#rumsdkclienttoken)
+- [Source Code Context](#source-code-context)
+ - [rum.sourceCodeContext.debugId](#rumsourcecodecontextdebugid)
## Configuration
@@ -32,6 +34,17 @@ rum?: {
clientToken?: string;
// [...] See https://docs.datadoghq.com/real_user_monitoring/browser/setup/client?tab=rum#configuration for all options.
};
+ sourceCodeContext?:
+ | {
+ debugId: true;
+ service?: string;
+ version?: string;
+ }
+ | {
+ debugId?: false;
+ service: string;
+ version?: string;
+ };
}
```
@@ -98,3 +111,27 @@ A [Datadog client token](https://docs.datadoghq.com/account_management/api-app-k
> [!NOTE]
> If not provided, the plugin will attempt to fetch the client token using the API.
> You need to provide both `auth.apiKey` and `auth.appKey` with the `rum_apps_read` permission.
+
+## Source Code Context
+
+Inject metadata that lets Datadog associate runtime stack frames with uploaded source maps.
+
+To inject debug IDs:
+
+```ts
+datadogWebpackPlugin({
+ sourcemaps: {
+ debugId: true,
+ },
+});
+```
+
+To upload the corresponding source maps directly during the build, also set `sourcemaps.upload: true` and configure `auth.apiKey`. See the [Error Tracking plugin documentation](/packages/plugins/error-tracking#sourcemaps-upload) for upload configuration and options.
+
+The existing `rum.sourceCodeContext.debugId` configuration remains supported. Service and version can be provided alongside `debugId`; they identify and filter the application or micro-frontend, while the debug ID independently associates stack frames with source maps.
+
+### rum.sourceCodeContext.debugId
+
+> default: `false`
+
+Inject a deterministic debug ID into each JavaScript bundle. The RUM SDK uses it to associate stack frames with source maps.
diff --git a/packages/plugins/rum/src/getSourceCodeContextSnippet.ts b/packages/plugins/rum/src/getSourceCodeContextSnippet.ts
index 312b03b0c..80d4411bf 100644
--- a/packages/plugins/rum/src/getSourceCodeContextSnippet.ts
+++ b/packages/plugins/rum/src/getSourceCodeContextSnippet.ts
@@ -39,19 +39,17 @@ export const getSourceCodeContextSnippet = (
contextOptions: SourceCodeContextOptions,
chunk?: ChunkInfo,
): SourceCodeContextSnippet => {
+ // Compute deterministic debug IDs whenever possible to prevent the backend from storing
+ // duplicate source maps for identical builds.
let debugId: string | undefined;
- if (contextOptions.debugId) {
- // Compute deterministic debug IDs whenever possible to prevent the backend from storing
- // duplicate source maps for identical builds.
+ if (contextOptions.debugId === true) {
debugId = chunk ? stringToUUID(chunk.sourceOrHash) : randomUUID();
}
-
const context: SourceCodeContext = {
- // The `dd` prefix lets upload tools locate the value and send it as sourcemap metadata.
- // Keep the debug ID first so upload tools can find it with a bounded prefix read.
- ddDebugId: debugId,
service: contextOptions.service,
version: contextOptions.version,
+ // The `dd` prefix lets upload tools locate the value and send it as sourcemap metadata.
+ ddDebugId: debugId,
};
const code = `(function(c,n){try{if(typeof window==='undefined')return;var w=window,m=w[n]=w[n]||{},s=new Error().stack;s&&(m[s]=c)}catch(e){}})(${JSON.stringify(context)},${JSON.stringify(DEFAULT_SOURCE_CODE_CONTEXT_VARIABLE)});`;
diff --git a/packages/plugins/rum/src/index.test.ts b/packages/plugins/rum/src/index.test.ts
index 80d034236..4ca011b42 100644
--- a/packages/plugins/rum/src/index.test.ts
+++ b/packages/plugins/rum/src/index.test.ts
@@ -55,7 +55,16 @@ describe('RUM Plugin', () => {
expect(value()).toMatch(/(?=.*DD_SOURCE_CODE_CONTEXT)(?=.*"ddDebugId":"[0-9a-f-]+")/);
});
- test('Should serialize the debug ID before source code context metadata', () => {
+ test('Should not serialize service and version with a debug ID', () => {
+ const value = run({ sourceCodeContext: { debugId: true } })[0] as () => string;
+ const code = value();
+
+ expect(code).toContain('"ddDebugId"');
+ expect(code).not.toContain('"service"');
+ expect(code).not.toContain('"version"');
+ });
+
+ test('Should serialize service and version alongside a debug ID', () => {
const value = run({
sourceCodeContext: {
debugId: true,
@@ -64,10 +73,9 @@ describe('RUM Plugin', () => {
},
})[0] as () => string;
const code = value();
- const debugIdIndex = code.indexOf('"ddDebugId"');
- expect(debugIdIndex).toBeGreaterThanOrEqual(0);
- expect(debugIdIndex).toBeLessThan(code.indexOf('"service"'));
- expect(debugIdIndex).toBeLessThan(code.indexOf('"version"'));
+ expect(code).toContain('"ddDebugId"');
+ expect(code).toContain('"service":"checkout"');
+ expect(code).toContain('"version":"1.2.3"');
});
});
diff --git a/packages/plugins/rum/src/types.ts b/packages/plugins/rum/src/types.ts
index 445f16d37..7eaa37abc 100644
--- a/packages/plugins/rum/src/types.ts
+++ b/packages/plugins/rum/src/types.ts
@@ -7,12 +7,22 @@ import type { Assign } from '@dd/core/types';
import type { RumInitConfiguration } from './browserSdkTypes';
import type { PrivacyOptions, PrivacyOptionsWithDefaults } from './privacy/types';
-export type SourceCodeContextOptions = {
+type DebugIdSourceCodeContextOptions = {
+ debugId: true;
service?: string;
version?: string;
- debugId?: boolean;
};
+type ServiceVersionSourceCodeContextOptions = {
+ debugId?: false;
+ service: string;
+ version?: string;
+};
+
+export type SourceCodeContextOptions =
+ | DebugIdSourceCodeContextOptions
+ | ServiceVersionSourceCodeContextOptions;
+
export type RumOptions = {
enable?: boolean;
sdk?: SDKOptions;
diff --git a/packages/plugins/rum/src/validate.test.ts b/packages/plugins/rum/src/validate.test.ts
index 9da4a92c6..ba1021413 100644
--- a/packages/plugins/rum/src/validate.test.ts
+++ b/packages/plugins/rum/src/validate.test.ts
@@ -5,6 +5,7 @@
import { defaultPluginOptions } from '@dd/tests/_jest/helpers/mocks';
import { createFilter } from '@rollup/pluginutils';
+import type { SourceCodeContextOptions } from './types';
import { validatePrivacyOptions, validateSourceCodeContextOptions } from './validate';
describe('Test privacy plugin option exclude regex', () => {
@@ -53,6 +54,60 @@ describe('sourceCodeContext validation', () => {
expect(result.config).toEqual(expect.objectContaining({ service: 'checkout' }));
});
+ test('should accept debug ID injection', () => {
+ const pluginOptions = {
+ ...defaultPluginOptions,
+ rum: { sourceCodeContext: { debugId: true } as const },
+ };
+ const result = validateSourceCodeContextOptions(pluginOptions);
+ expect(result.errors).toHaveLength(0);
+ expect(result.config).toEqual({ debugId: true });
+ });
+
+ test('should preserve service and version when debug ID injection is enabled', () => {
+ const pluginOptions = {
+ ...defaultPluginOptions,
+ rum: {
+ sourceCodeContext: {
+ debugId: true,
+ service: 'checkout',
+ version: '1.2.3',
+ } as const,
+ },
+ };
+ const result = validateSourceCodeContextOptions(pluginOptions);
+ expect(result.errors).toHaveLength(0);
+ expect(result.config).toEqual({
+ debugId: true,
+ service: 'checkout',
+ version: '1.2.3',
+ });
+ });
+
+ test('should allow debug ID and service/version identities together in types', () => {
+ const mixedIdentity: SourceCodeContextOptions = {
+ debugId: true,
+ service: 'checkout',
+ version: '1.2.3',
+ };
+ expect(mixedIdentity).toBeDefined();
+ });
+
+ test('should fall back to metadata.version for combined debug ID and service context', () => {
+ const pluginOptions = {
+ ...defaultPluginOptions,
+ metadata: { version: '1.2.3' },
+ rum: { sourceCodeContext: { debugId: true, service: 'checkout' } as const },
+ };
+ const result = validateSourceCodeContextOptions(pluginOptions);
+ expect(result.errors).toHaveLength(0);
+ expect(result.config).toEqual({
+ debugId: true,
+ service: 'checkout',
+ version: '1.2.3',
+ });
+ });
+
test('should error when service is missing', () => {
const pluginOptions = {
...defaultPluginOptions,
diff --git a/packages/plugins/rum/src/validate.ts b/packages/plugins/rum/src/validate.ts
index c7e5547f4..06595356f 100644
--- a/packages/plugins/rum/src/validate.ts
+++ b/packages/plugins/rum/src/validate.ts
@@ -168,16 +168,25 @@ export const validateSourceCodeContextOptions = (
const cfg: SourceCodeContextOptions = validatedOptions.sourceCodeContext;
- if (!cfg?.debugId && (!cfg?.service || typeof cfg.service !== 'string')) {
+ if (cfg.debugId === true) {
+ toReturn.config = {
+ ...cfg,
+ version: cfg.version || (cfg.service ? options.metadata?.version : undefined),
+ };
+ return toReturn;
+ }
+
+ if (!cfg.service || typeof cfg.service !== 'string') {
toReturn.errors.push(`Missing ${red('"rum.sourceCodeContext.service"')}.`);
}
- // Resolve `version`: prefer the plugin-specific option, then fall back to
- // the shared top-level `metadata.version`. This keeps `metadata.version`
- // as the single canonical place to declare the deployed build identifier.
- toReturn.config = {
- ...cfg,
- version: cfg.version || options.metadata?.version,
- };
+ if (toReturn.errors.length === 0) {
+ // Resolve `version`: prefer the plugin-specific option, then fall back to
+ // the shared top-level `metadata.version`.
+ toReturn.config = {
+ ...cfg,
+ version: cfg.version || options.metadata?.version,
+ };
+ }
return toReturn;
};
diff --git a/packages/tests/src/_jest/helpers/mocks.ts b/packages/tests/src/_jest/helpers/mocks.ts
index 907dfda97..d3b7c2033 100644
--- a/packages/tests/src/_jest/helpers/mocks.ts
+++ b/packages/tests/src/_jest/helpers/mocks.ts
@@ -34,10 +34,11 @@ import type {
MultipartValue,
Payload,
} from '@dd/error-tracking-plugin/sourcemaps/payload';
-import type {
- SourcemapsOptions,
- SourcemapsOptionsWithDefaults,
- Sourcemap,
+import {
+ type DebugIdSourcemapsOptionsWithDefaults,
+ type ServiceVersionSourcemapsOptionsWithDefaults,
+ type SourcemapsOptions,
+ type Sourcemap,
} from '@dd/error-tracking-plugin/types';
import { TrackedFilesMatcher } from '@dd/internal-git-plugin/trackedFilesMatcher';
import type { Compilation, Module, MetricsOptions } from '@dd/metrics-plugin/types';
@@ -376,7 +377,7 @@ export const getMetricsConfiguration = (
});
export const getMinimalSourcemapsConfiguration = (
- options: Partial = {},
+ options: Partial = {},
): SourcemapsOptions => {
return {
minifiedPathPrefix: '/prefix',
@@ -387,11 +388,12 @@ export const getMinimalSourcemapsConfiguration = (
};
export const getSourcemapsConfiguration = (
- options: Partial = {},
-): SourcemapsOptionsWithDefaults => {
+ options: Partial = {},
+): ServiceVersionSourcemapsOptionsWithDefaults => {
return {
bailOnError: false,
dryRun: false,
+ debugId: false,
maxConcurrency: 10,
minifiedPathPrefix: '/prefix',
releaseVersion: '1.0.0',
@@ -400,6 +402,13 @@ export const getSourcemapsConfiguration = (
};
};
+export const getDebugIdSourcemapsConfiguration = (): DebugIdSourcemapsOptionsWithDefaults => ({
+ bailOnError: false,
+ debugId: true,
+ dryRun: false,
+ maxConcurrency: 10,
+});
+
export const getSourcemapMock = (options: Partial = {}): Sourcemap => {
return {
minifiedFilePath: '/path/to/minified.min.js',
diff --git a/packages/tests/src/e2e/sourceCodeContext/sourceCodeContext.spec.ts b/packages/tests/src/e2e/sourceCodeContext/sourceCodeContext.spec.ts
index 0da456c3b..6acf85838 100644
--- a/packages/tests/src/e2e/sourceCodeContext/sourceCodeContext.spec.ts
+++ b/packages/tests/src/e2e/sourceCodeContext/sourceCodeContext.spec.ts
@@ -40,19 +40,32 @@ const getDebugIds = async (page: Page): Promise => {
);
};
-async function build(publicDir: string, suiteName: string, bundlers: BundlerName[]) {
+async function build(
+ publicDir: string,
+ suiteName: string,
+ bundlers: BundlerName[],
+ pluginConfig: typeof defaultConfig,
+) {
const source = path.resolve(__dirname, 'project');
const destination = path.resolve(publicDir, suiteName);
await verifyProjectBuild(source, destination, bundlers, pluginConfig, { splitting: true });
}
-const pluginConfig = {
+const debugIdPluginConfig = {
+ ...defaultConfig,
+ rum: {
+ sourceCodeContext: {
+ debugId: true as const,
+ },
+ },
+};
+
+const serviceVersionPluginConfig = {
...defaultConfig,
rum: {
sourceCodeContext: {
service: SERVICE_NAME,
version: SERVICE_VERSION,
- debugId: true,
},
},
};
@@ -60,7 +73,13 @@ const pluginConfig = {
describe('Source Code Context', () => {
// Build our fixture project.
beforeAll(async ({ publicDir, bundlers, suiteName }) => {
- await build(publicDir, suiteName, bundlers);
+ await build(publicDir, suiteName, bundlers, debugIdPluginConfig);
+ await build(
+ publicDir,
+ `${suiteName}-service-version`,
+ bundlers,
+ serviceVersionPluginConfig,
+ );
});
test('Should inject DD_SOURCE_CODE_CONTEXT global variable', async ({
@@ -119,7 +138,7 @@ describe('Source Code Context', () => {
suiteName,
devServerUrl,
}) => {
- await userFlow(`${devServerUrl}/${suiteName}`, page, bundler);
+ await userFlow(`${devServerUrl}/${suiteName}-service-version`, page, bundler);
// Initialize RUM with beforeSend.
await page.evaluate(() => {
@@ -193,7 +212,7 @@ describe('Source Code Context', () => {
'rspack content hash is not deterministic across build directories when devtool is enabled',
);
- await build(publicDir, `${suiteName}-rebuild`, bundlers);
+ await build(publicDir, `${suiteName}-rebuild`, bundlers, debugIdPluginConfig);
await userFlow(`${devServerUrl}/${suiteName}`, page, bundler);
const firstBuildIds = await getDebugIds(page);
diff --git a/packages/tools/src/commands/integrity/readme.ts b/packages/tools/src/commands/integrity/readme.ts
index b8c7e7451..f3c923740 100644
--- a/packages/tools/src/commands/integrity/readme.ts
+++ b/packages/tools/src/commands/integrity/readme.ts
@@ -363,7 +363,19 @@ export const updateReadmes = async (plugins: Workspace[], bundlers: Workspace[])
metadata?: {
name?: string;
version?: string;
- }
+ };
+ sourcemaps?:
+ | {
+ debugId: true;
+ upload?: false;
+ }
+ | {
+ bailOnError?: boolean;
+ debugId: true;
+ dryRun?: boolean;
+ maxConcurrency?: number;
+ upload: true;
+ }
`,
];
const errors: string[] = [];