From 2a10b2daaec93e9d8b6566adc4974a16f8d45bcf Mon Sep 17 00:00:00 2001 From: Hugo Silva Date: Wed, 2 Sep 2026 18:40:29 +0200 Subject: [PATCH 1/7] feat(rum): upload source maps by debug ID --- README.md | 12 +++ packages/factory/src/index.test.ts | 10 +++ packages/factory/src/index.ts | 5 +- packages/plugins/error-tracking/README.md | 2 + .../plugins/error-tracking/src/index.test.ts | 13 ++++ .../src/sourcemaps/files.test.ts | 28 ++++++- .../error-tracking/src/sourcemaps/files.ts | 21 +++-- .../src/sourcemaps/payload.test.ts | 15 ++++ .../error-tracking/src/sourcemaps/payload.ts | 14 ++-- .../src/sourcemaps/sender.test.ts | 40 ++++++++++ .../error-tracking/src/sourcemaps/sender.ts | 21 +++-- .../src/sourcemaps/upload-metrics.ts | 7 +- packages/plugins/error-tracking/src/types.ts | 26 ++++++- .../error-tracking/src/validate.test.ts | 77 ++++++++++++++++++- .../plugins/error-tracking/src/validate.ts | 50 +++++++++++- packages/plugins/rum/README.md | 43 +++++++++++ packages/plugins/rum/src/types.ts | 1 + packages/plugins/rum/src/validate.test.ts | 21 +++++ packages/plugins/rum/src/validate.ts | 6 ++ packages/tests/src/_jest/helpers/mocks.ts | 20 +++-- 20 files changed, 401 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index d51a23df1..7f078befb 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,12 @@ 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?: boolean; + service?: string; + upload?: boolean; + version?: string; + }; }; } ``` @@ -407,6 +413,12 @@ datadogWebpackPlugin({ clientToken?: string, // [...] See https://docs.datadoghq.com/real_user_monitoring/browser/setup/client?tab=rum#configuration for all options. }, + sourceCodeContext?: { + debugId?: boolean, + service?: string, + upload?: boolean, + version?: string, + }, } }); ``` diff --git a/packages/factory/src/index.test.ts b/packages/factory/src/index.test.ts index eb2261485..b0d476aae 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,15 @@ describe('Factory', () => { expect(hasPlugin(plugins, 'output')).toBe(true); }); + test('Should include error tracking for RUM debug ID uploads', () => { + const plugins = invokeFactory({ + auth: { apiKey: '123' }, + logLevel: 'none', + rum: { sourceCodeContext: { 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/index.ts b/packages/factory/src/index.ts index 775800e2b..cbce44781 100644 --- a/packages/factory/src/index.ts +++ b/packages/factory/src/index.ts @@ -179,7 +179,10 @@ export const buildPluginFactory = ({ ]; for (const [name, configKey, getPlugins] of userFacingPlugins) { - if (resolveEnable(options, configKey, log)) { + const debugIdSourcemapUploadEnabled = + configKey === errorTracking.CONFIG_KEY && + options.rum?.sourceCodeContext?.upload === true; + if (resolveEnable(options, configKey, log) || debugIdSourcemapUploadEnabled) { pluginsToAdd.push([name, getPlugins]); } } diff --git a/packages/plugins/error-tracking/README.md b/packages/plugins/error-tracking/README.md index 3e32cb7de..88f600369 100644 --- a/packages/plugins/error-tracking/README.md +++ b/packages/plugins/error-tracking/README.md @@ -48,6 +48,8 @@ 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. +The `errorTracking.sourcemaps` options configure service/version matching. To upload source maps by debug ID without configuring a service, release version, or minified path prefix, use `rum.sourceCodeContext.debugId` and `rum.sourceCodeContext.upload` instead. See the [RUM plugin documentation](/packages/plugins/rum#source-code-context). + > [!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`). diff --git a/packages/plugins/error-tracking/src/index.test.ts b/packages/plugins/error-tracking/src/index.test.ts index 97a29b164..52b768804 100644 --- a/packages/plugins/error-tracking/src/index.test.ts +++ b/packages/plugins/error-tracking/src/index.test.ts @@ -8,6 +8,7 @@ import { extractDebugId, } from '@dd/error-tracking-plugin/sourcemaps/debugId'; import { uploadSourcemaps } from '@dd/error-tracking-plugin/sourcemaps/index'; +import { SourcemapsUploadMode } from '@dd/error-tracking-plugin/types'; import { getPlugins } from '@dd/error-tracking-plugin'; import { getGetPluginsArg, @@ -43,6 +44,18 @@ 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, + rum: { sourceCodeContext: { debugId: true, upload: true } }, + }); + expect(uploadSourcemapsMock).toHaveBeenCalledTimes(BUNDLERS.length); + expect(uploadSourcemapsMock.mock.calls[0][0]).toMatchObject({ + sourcemaps: { mode: SourcemapsUploadMode.DEBUG_ID }, + }); + }); + 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..fab2a30ae 100644 --- a/packages/plugins/error-tracking/src/sourcemaps/files.ts +++ b/packages/plugins/error-tracking/src/sourcemaps/files.ts @@ -6,7 +6,12 @@ import type { Output } from '@dd/core/types'; import chalk from 'chalk'; import path from 'path'; -import type { SourcemapsOptionsWithDefaults, Sourcemap, MinifiedPathPrefix } from '../types'; +import { + SourcemapsUploadMode, + type SourcemapsOptionsWithDefaults, + type Sourcemap, + type MinifiedPathPrefix, +} from '../types'; type PartialSourcemap = Pick; @@ -34,7 +39,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 +50,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 +79,14 @@ export const getSourcemapsFiles = ( .map((file) => file.filepath); const sourcemapFiles = sourcemapFilesList.map((sourcemapFilePath) => { + const minifiedPathPrefix = + options.mode === SourcemapsUploadMode.SERVICE_VERSION + ? 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.test.ts b/packages/plugins/error-tracking/src/sourcemaps/payload.test.ts index 87cb0eaa9..48434e3eb 100644 --- a/packages/plugins/error-tracking/src/sourcemaps/payload.test.ts +++ b/packages/plugins/error-tracking/src/sourcemaps/payload.test.ts @@ -129,5 +129,20 @@ describe('Error Tracking Plugins Sourcemaps Payloads', () => { expect(payload.warnings).toHaveLength(0); expect(payload.errors).toHaveLength(0); }); + + test('Should require a debug ID for debug ID uploads', async () => { + const payload = await getPayload( + getSourcemapMock(), + getMetadataMock({ service: undefined, version: undefined }), + undefined, + undefined, + undefined, + true, + ); + + expect(payload.errors).toContain( + 'No debug ID found in minified file: /path/to/minified.min.js', + ); + }); }); }); diff --git a/packages/plugins/error-tracking/src/sourcemaps/payload.ts b/packages/plugins/error-tracking/src/sourcemaps/payload.ts index d68c95017..e71683d99 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,16 +79,17 @@ 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, + debugIdRequired = false, ): Promise => { const validity = await getSourcemapValidity(sourcemap, prefix); const errors: string[] = []; @@ -174,6 +175,9 @@ export const getPayload = async ( if (!validity.sourcemap.exists) { errors.push(`Sourcemap file not found: ${sourcemap.sourcemapFilePath}`); } + if (debugIdRequired && !debugId) { + errors.push(`No debug ID found in minified file: ${sourcemap.minifiedFilePath}`); + } if (validity.repeatedPrefix) { warnings.push( `The minified file path contains a repeated pattern with the minified path prefix: ${validity.repeatedPrefix}`, diff --git a/packages/plugins/error-tracking/src/sourcemaps/sender.test.ts b/packages/plugins/error-tracking/src/sourcemaps/sender.test.ts index edb3a607d..282c8d5bb 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,45 @@ 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); + expect(getPayloadSpy.mock.calls[0][5]).toBe(true); + + getPayloadSpy.mockRestore(); + rmSync(tempDir); + }); }); describe('upload', () => { diff --git a/packages/plugins/error-tracking/src/sourcemaps/sender.ts b/packages/plugins/error-tracking/src/sourcemaps/sender.ts index 3f9549211..be7e93888 100644 --- a/packages/plugins/error-tracking/src/sourcemaps/sender.ts +++ b/packages/plugins/error-tracking/src/sourcemaps/sender.ts @@ -16,7 +16,7 @@ import type { Logger, Metric, RepositoryData } from '@dd/core/types'; import chalk from 'chalk'; import PQueue from 'p-queue'; -import type { SourcemapsOptionsWithDefaults, Sourcemap } from '../types'; +import { SourcemapsUploadMode, type SourcemapsOptionsWithDefaults, type Sourcemap } from '../types'; import { extractDebugId } from './debugId'; import type { Metadata, MultipartFileValue, Payload } from './payload'; @@ -187,16 +187,20 @@ export const sendSourcemaps = async ( log: Logger, ) => { const start = Date.now(); - const prefix = options.minifiedPathPrefix; + const prefix = + options.mode === SourcemapsUploadMode.SERVICE_VERSION + ? 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.mode === SourcemapsUploadMode.SERVICE_VERSION + ? { service: options.service, version: options.releaseVersion } + : {}), }; const payloadsTimer = log.time('Compute payloads'); @@ -210,7 +214,14 @@ export const sendSourcemaps = async ( if (debugId) { debugIdCount += 1; } - return getPayload(sourcemap, metadata, prefix, context.git, debugId); + return getPayload( + sourcemap, + metadata, + prefix, + context.git, + debugId, + options.mode === SourcemapsUploadMode.DEBUG_ID, + ); }), ); payloadsTimer.end(); diff --git a/packages/plugins/error-tracking/src/sourcemaps/upload-metrics.ts b/packages/plugins/error-tracking/src/sourcemaps/upload-metrics.ts index 766aa99f2..405c6dc04 100644 --- a/packages/plugins/error-tracking/src/sourcemaps/upload-metrics.ts +++ b/packages/plugins/error-tracking/src/sourcemaps/upload-metrics.ts @@ -5,7 +5,7 @@ import { normalizeTagValue } from '@dd/core/helpers/strings'; import type { Metric } from '@dd/core/types'; -import type { SourcemapsOptionsWithDefaults } from '../types'; +import { SourcemapsUploadMode, type SourcemapsOptionsWithDefaults } from '../types'; import type { UploadContext } from './sender'; @@ -44,7 +44,10 @@ export const createSourcemapUploadMetrics = ( options: SourcemapsOptionsWithDefaults, ): SourcemapUploadMetrics => ({ metrics: new Map(), - baseTags: [`service:${options.service}`], + baseTags: + options.mode === SourcemapsUploadMode.SERVICE_VERSION + ? [`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..e11d5f3e8 100644 --- a/packages/plugins/error-tracking/src/types.ts +++ b/packages/plugins/error-tracking/src/types.ts @@ -15,7 +15,29 @@ export type SourcemapsOptions = { service: string; }; -export type SourcemapsOptionsWithDefaults = Required; +export enum SourcemapsUploadMode { + DEBUG_ID = 'debug-id', + SERVICE_VERSION = 'service-version', +} + +type SourcemapsUploadOptionsWithDefaults = { + bailOnError: boolean; + dryRun: boolean; + maxConcurrency: number; +}; + +export type ServiceVersionSourcemapsOptionsWithDefaults = SourcemapsUploadOptionsWithDefaults & + Required & { + mode: SourcemapsUploadMode.SERVICE_VERSION; + }; + +export type DebugIdSourcemapsOptionsWithDefaults = SourcemapsUploadOptionsWithDefaults & { + mode: SourcemapsUploadMode.DEBUG_ID; +}; + +export type SourcemapsOptionsWithDefaults = + | ServiceVersionSourcemapsOptionsWithDefaults + | DebugIdSourcemapsOptionsWithDefaults; export type ErrorTrackingOptions = { enable?: boolean; @@ -32,7 +54,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..a2fff88e1 100644 --- a/packages/plugins/error-tracking/src/validate.test.ts +++ b/packages/plugins/error-tracking/src/validate.test.ts @@ -2,7 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { SourcemapsOptions } from '@dd/error-tracking-plugin/types'; +import { SourcemapsUploadMode, type SourcemapsOptions } from '@dd/error-tracking-plugin/types'; import { validateOptions, validateSourcemapsOptions } from '@dd/error-tracking-plugin/validate'; import { getMinimalSourcemapsConfiguration, mockLogger } from '@dd/tests/_jest/helpers/mocks'; import stripAnsi from 'strip-ansi'; @@ -83,9 +83,84 @@ describe('Error Tracking Plugins validate', () => { dryRun: false, maxConcurrency: 20, ...configObject, + mode: SourcemapsUploadMode.SERVICE_VERSION, }); }); + test('Should configure debug ID uploads without service, version, or path options', () => { + const { config, errors } = validateSourcemapsOptions({ + auth: { apiKey: '123' }, + rum: { sourceCodeContext: { debugId: true, upload: true } }, + }); + + expect(errors).toHaveLength(0); + expect(config).toEqual({ + bailOnError: false, + dryRun: false, + maxConcurrency: 20, + mode: SourcemapsUploadMode.DEBUG_ID, + }); + }); + + test('Should reject debug ID uploads without debug ID injection', () => { + const { errors } = validateSourcemapsOptions({ + auth: { apiKey: '123' }, + rum: { sourceCodeContext: { upload: 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({ + rum: { sourceCodeContext: { debugId: true, upload: 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: { sourcemaps: getMinimalSourcemapsConfiguration() }, + rum: { sourceCodeContext: { debugId: true, upload: true } }, + }); + + expect(errors.map(stripAnsi)).toContain( + 'errorTracking.sourcemaps cannot be combined with rum.sourceCodeContext.upload.', + ); + }); + + test('Should reject debug ID uploads when RUM is disabled', () => { + const { errors } = validateSourcemapsOptions({ + auth: { apiKey: '123' }, + rum: { + enable: false, + sourceCodeContext: { debugId: true, upload: true }, + }, + }); + + expect(errors.map(stripAnsi)).toContain( + 'rum must be enabled to upload source maps by debug ID.', + ); + }); + + test('Should reject debug ID uploads when error tracking is disabled', () => { + const { errors } = validateSourcemapsOptions({ + auth: { apiKey: '123' }, + errorTracking: { enable: false }, + rum: { sourceCodeContext: { debugId: true, upload: true } }, + }); + + expect(errors.map(stripAnsi)).toContain( + 'errorTracking cannot be disabled when rum.sourceCodeContext.upload is enabled.', + ); + }); + 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..83dd4b7b5 100644 --- a/packages/plugins/error-tracking/src/validate.ts +++ b/packages/plugins/error-tracking/src/validate.ts @@ -6,10 +6,11 @@ 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 { + SourcemapsUploadMode, + type ErrorTrackingOptions, + type ErrorTrackingOptionsWithDefaults, + type SourcemapsOptionsWithDefaults, } from './types'; // Deal with validation and defaults here. @@ -61,6 +62,46 @@ export const validateSourcemapsOptions = ( const toReturn: ToReturn = { errors: [], }; + const debugIdUpload = config.rum?.sourceCodeContext?.upload === true; + + if (debugIdUpload) { + if (config.rum?.enable === false) { + toReturn.errors.push( + `${red('rum')} must be enabled to upload source maps by debug ID.`, + ); + } + if (!config.rum?.sourceCodeContext?.debugId) { + toReturn.errors.push( + `${red('rum.sourceCodeContext.debugId')} must be enabled to upload source maps by debug ID.`, + ); + } + if (validatedOptions.sourcemaps) { + toReturn.errors.push( + `${red('errorTracking.sourcemaps')} cannot be combined with ${red('rum.sourceCodeContext.upload')}.`, + ); + } + if (validatedOptions.enable === false) { + toReturn.errors.push( + `${red('errorTracking')} cannot be disabled when ${red('rum.sourceCodeContext.upload')} is enabled.`, + ); + } + if (!config.auth?.apiKey) { + toReturn.errors.push( + `${red('auth.apiKey')} is required to upload source maps by debug ID.`, + ); + } + + if (toReturn.errors.length === 0) { + toReturn.config = { + bailOnError: false, + dryRun: false, + maxConcurrency: 20, + mode: SourcemapsUploadMode.DEBUG_ID, + }; + } + + return toReturn; + } if (validatedOptions.sourcemaps) { const sourcemapsCfg = validatedOptions.sourcemaps; @@ -112,6 +153,7 @@ export const validateSourcemapsOptions = ( dryRun: false, maxConcurrency: 20, ...sourcemapsCfg, + mode: SourcemapsUploadMode.SERVICE_VERSION, releaseVersion, }; } diff --git a/packages/plugins/rum/README.md b/packages/plugins/rum/README.md index dc141d9c9..d02ec0f6a 100644 --- a/packages/plugins/rum/README.md +++ b/packages/plugins/rum/README.md @@ -17,6 +17,9 @@ 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) + - [rum.sourceCodeContext.upload](#rumsourcecodecontextupload) ## Configuration @@ -32,6 +35,12 @@ rum?: { clientToken?: string; // [...] See https://docs.datadoghq.com/real_user_monitoring/browser/setup/client?tab=rum#configuration for all options. }; + sourceCodeContext?: { + debugId?: boolean; + service?: string; + upload?: boolean; + version?: string; + }; } ``` @@ -98,3 +107,37 @@ 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 and upload the corresponding source maps directly during the build: + +```ts +datadogWebpackPlugin({ + auth: { + apiKey: process.env.DATADOG_API_KEY, + }, + rum: { + sourceCodeContext: { + debugId: true, + upload: true, + }, + }, +}); +``` + +This debug ID upload mode does not require `service`, `version`, or `minifiedPathPrefix`. + +### 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. + +### rum.sourceCodeContext.upload + +> default: `false` + +Upload source maps by debug ID during the build. This requires `rum.sourceCodeContext.debugId: true` and a Datadog API key set through `auth.apiKey` or `DATADOG_API_KEY`. diff --git a/packages/plugins/rum/src/types.ts b/packages/plugins/rum/src/types.ts index 445f16d37..2de924a9d 100644 --- a/packages/plugins/rum/src/types.ts +++ b/packages/plugins/rum/src/types.ts @@ -11,6 +11,7 @@ export type SourceCodeContextOptions = { service?: string; version?: string; debugId?: boolean; + upload?: boolean; }; export type RumOptions = { diff --git a/packages/plugins/rum/src/validate.test.ts b/packages/plugins/rum/src/validate.test.ts index 9da4a92c6..8f5012678 100644 --- a/packages/plugins/rum/src/validate.test.ts +++ b/packages/plugins/rum/src/validate.test.ts @@ -53,6 +53,27 @@ describe('sourceCodeContext validation', () => { expect(result.config).toEqual(expect.objectContaining({ service: 'checkout' })); }); + test('should require debug ID injection when uploads are enabled', () => { + const pluginOptions = { + ...defaultPluginOptions, + rum: { sourceCodeContext: { upload: true } }, + }; + const result = validateSourceCodeContextOptions(pluginOptions); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('"rum.sourceCodeContext.debugId"')]), + ); + }); + + test('should accept debug ID injection with uploads enabled', () => { + const pluginOptions = { + ...defaultPluginOptions, + rum: { sourceCodeContext: { debugId: true, upload: true } }, + }; + const result = validateSourceCodeContextOptions(pluginOptions); + expect(result.errors).toHaveLength(0); + expect(result.config).toEqual({ debugId: true, upload: true, version: undefined }); + }); + 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..46133ffd6 100644 --- a/packages/plugins/rum/src/validate.ts +++ b/packages/plugins/rum/src/validate.ts @@ -168,6 +168,12 @@ export const validateSourceCodeContextOptions = ( const cfg: SourceCodeContextOptions = validatedOptions.sourceCodeContext; + if (cfg.upload && !cfg.debugId) { + toReturn.errors.push( + `${red('"rum.sourceCodeContext.debugId"')} must be enabled to upload source maps by debug ID.`, + ); + } + if (!cfg?.debugId && (!cfg?.service || typeof cfg.service !== 'string')) { toReturn.errors.push(`Missing ${red('"rum.sourceCodeContext.service"')}.`); } diff --git a/packages/tests/src/_jest/helpers/mocks.ts b/packages/tests/src/_jest/helpers/mocks.ts index 907dfda97..e4fe30c6e 100644 --- a/packages/tests/src/_jest/helpers/mocks.ts +++ b/packages/tests/src/_jest/helpers/mocks.ts @@ -34,10 +34,12 @@ import type { MultipartValue, Payload, } from '@dd/error-tracking-plugin/sourcemaps/payload'; -import type { - SourcemapsOptions, - SourcemapsOptionsWithDefaults, - Sourcemap, +import { + SourcemapsUploadMode, + 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'; @@ -388,18 +390,26 @@ export const getMinimalSourcemapsConfiguration = ( export const getSourcemapsConfiguration = ( options: Partial = {}, -): SourcemapsOptionsWithDefaults => { +): ServiceVersionSourcemapsOptionsWithDefaults => { return { bailOnError: false, dryRun: false, maxConcurrency: 10, minifiedPathPrefix: '/prefix', + mode: SourcemapsUploadMode.SERVICE_VERSION, releaseVersion: '1.0.0', service: 'error-tracking-build-plugin-sourcemaps', ...options, }; }; +export const getDebugIdSourcemapsConfiguration = (): DebugIdSourcemapsOptionsWithDefaults => ({ + bailOnError: false, + dryRun: false, + maxConcurrency: 10, + mode: SourcemapsUploadMode.DEBUG_ID, +}); + export const getSourcemapMock = (options: Partial = {}): Sourcemap => { return { minifiedFilePath: '/path/to/minified.min.js', From dadabfa91ae37458ce2bd0b5ef75cee851f91cf7 Mon Sep 17 00:00:00 2001 From: Hugo Silva Date: Thu, 3 Sep 2026 14:49:25 +0200 Subject: [PATCH 2/7] refactor(rum): configure debug ID uploads in sourcemaps --- README.md | 78 +++++++---- packages/factory/src/index.test.ts | 3 +- packages/factory/src/index.ts | 5 +- packages/plugins/error-tracking/README.md | 35 +++-- .../plugins/error-tracking/src/index.test.ts | 3 +- packages/plugins/error-tracking/src/types.ts | 19 ++- .../error-tracking/src/validate.test.ts | 46 ++++--- .../plugins/error-tracking/src/validate.ts | 125 +++++++++--------- packages/plugins/rum/README.md | 27 ++-- .../rum/src/getSourceCodeContextSnippet.ts | 21 +-- packages/plugins/rum/src/index.test.ts | 17 +-- packages/plugins/rum/src/types.ts | 17 ++- packages/plugins/rum/src/validate.test.ts | 40 ++++-- packages/plugins/rum/src/validate.ts | 33 +++-- packages/tests/src/_jest/helpers/mocks.ts | 4 +- .../sourceCodeContext.spec.ts | 31 ++++- 16 files changed, 309 insertions(+), 195 deletions(-) diff --git a/README.md b/README.md index 7f078befb..a93b85794 100644 --- a/README.md +++ b/README.md @@ -106,14 +106,22 @@ Follow the specific documentation for each bundler: }; 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,12 +153,15 @@ 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?: boolean; - service?: string; - upload?: boolean; - version?: string; - }; + sourceCodeContext?: + | { + debugId: true; + } + | { + debugId?: false; + service: string; + version?: string; + }; }; } ``` @@ -320,14 +331,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, + }, } }); ``` @@ -413,12 +432,15 @@ datadogWebpackPlugin({ clientToken?: string, // [...] See https://docs.datadoghq.com/real_user_monitoring/browser/setup/client?tab=rum#configuration for all options. }, - sourceCodeContext?: { - debugId?: boolean, - service?: string, - upload?: boolean, - version?: string, - }, + sourceCodeContext?: + | { + debugId: true, + } + | { + debugId?: false, + service: string, + version?: string, + }, } }); ``` diff --git a/packages/factory/src/index.test.ts b/packages/factory/src/index.test.ts index b0d476aae..f699d2dd1 100644 --- a/packages/factory/src/index.test.ts +++ b/packages/factory/src/index.test.ts @@ -60,8 +60,9 @@ describe('Factory', () => { 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, upload: true } }, + rum: { sourceCodeContext: { debugId: true } }, }); expect(hasPlugin(plugins, ERROR_TRACKING_PLUGIN_NAME)).toBe(true); }); diff --git a/packages/factory/src/index.ts b/packages/factory/src/index.ts index cbce44781..775800e2b 100644 --- a/packages/factory/src/index.ts +++ b/packages/factory/src/index.ts @@ -179,10 +179,7 @@ export const buildPluginFactory = ({ ]; for (const [name, configKey, getPlugins] of userFacingPlugins) { - const debugIdSourcemapUploadEnabled = - configKey === errorTracking.CONFIG_KEY && - options.rum?.sourceCodeContext?.upload === true; - if (resolveEnable(options, configKey, log) || debugIdSourcemapUploadEnabled) { + if (resolveEnable(options, configKey, log)) { pluginsToAdd.push([name, getPlugins]); } } diff --git a/packages/plugins/error-tracking/README.md b/packages/plugins/error-tracking/README.md index 88f600369..6a39420cf 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,7 +57,9 @@ 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. -The `errorTracking.sourcemaps` options configure service/version matching. To upload source maps by debug ID without configuring a service, release version, or minified path prefix, use `rum.sourceCodeContext.debugId` and `rum.sourceCodeContext.upload` instead. See the [RUM plugin documentation](/packages/plugins/rum#source-code-context). +Configure `errorTracking.sourcemaps.debugId: true` to upload by debug ID, or configure `service`, `releaseVersion`, and `minifiedPathPrefix` to use service/version matching. The two configurations are mutually exclusive. + +Debug ID uploads also require `rum.sourceCodeContext.debugId: true` so the build plugin injects a debug ID into each bundle. They do not require a service, release version, or minified path prefix. Omit `errorTracking.sourcemaps` if another tool, such as `datadog-ci`, performs the upload. > [!NOTE] > You can override the domain used in the request with the `DATADOG_SITE` environment variable or the `auth.site` options (eg. `datadoghq.eu`). @@ -60,6 +71,12 @@ The `errorTracking.sourcemaps` options configure service/version matching. To up 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 52b768804..eba4a1add 100644 --- a/packages/plugins/error-tracking/src/index.test.ts +++ b/packages/plugins/error-tracking/src/index.test.ts @@ -48,7 +48,8 @@ describe('Error Tracking Plugin', () => { await runBundlers({ auth: { apiKey: '123' }, enableGit: false, - rum: { sourceCodeContext: { debugId: true, upload: true } }, + errorTracking: { sourcemaps: { debugId: true } }, + rum: { sourceCodeContext: { debugId: true } }, }); expect(uploadSourcemapsMock).toHaveBeenCalledTimes(BUNDLERS.length); expect(uploadSourcemapsMock.mock.calls[0][0]).toMatchObject({ diff --git a/packages/plugins/error-tracking/src/types.ts b/packages/plugins/error-tracking/src/types.ts index e11d5f3e8..ab1060249 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,6 +26,8 @@ export type SourcemapsOptions = { service: string; }; +export type SourcemapsOptions = DebugIdSourcemapsOptions | ServiceVersionSourcemapsOptions; + export enum SourcemapsUploadMode { DEBUG_ID = 'debug-id', SERVICE_VERSION = 'service-version', @@ -27,7 +40,9 @@ type SourcemapsUploadOptionsWithDefaults = { }; export type ServiceVersionSourcemapsOptionsWithDefaults = SourcemapsUploadOptionsWithDefaults & - Required & { + Required< + Pick + > & { mode: SourcemapsUploadMode.SERVICE_VERSION; }; diff --git a/packages/plugins/error-tracking/src/validate.test.ts b/packages/plugins/error-tracking/src/validate.test.ts index a2fff88e1..8b757c450 100644 --- a/packages/plugins/error-tracking/src/validate.test.ts +++ b/packages/plugins/error-tracking/src/validate.test.ts @@ -90,7 +90,8 @@ describe('Error Tracking Plugins validate', () => { test('Should configure debug ID uploads without service, version, or path options', () => { const { config, errors } = validateSourcemapsOptions({ auth: { apiKey: '123' }, - rum: { sourceCodeContext: { debugId: true, upload: true } }, + errorTracking: { sourcemaps: { debugId: true } }, + rum: { sourceCodeContext: { debugId: true } }, }); expect(errors).toHaveLength(0); @@ -105,7 +106,7 @@ describe('Error Tracking Plugins validate', () => { test('Should reject debug ID uploads without debug ID injection', () => { const { errors } = validateSourcemapsOptions({ auth: { apiKey: '123' }, - rum: { sourceCodeContext: { upload: true } }, + errorTracking: { sourcemaps: { debugId: true } }, }); expect(errors.map(stripAnsi)).toContain( @@ -115,7 +116,8 @@ describe('Error Tracking Plugins validate', () => { test('Should reject debug ID uploads without an API key', () => { const { errors } = validateSourcemapsOptions({ - rum: { sourceCodeContext: { debugId: true, upload: true } }, + errorTracking: { sourcemaps: { debugId: true } }, + rum: { sourceCodeContext: { debugId: true } }, }); expect(errors.map(stripAnsi)).toContain( @@ -126,21 +128,39 @@ describe('Error Tracking Plugins validate', () => { test('Should reject combined debug ID and service/version uploads', () => { const { errors } = validateSourcemapsOptions({ auth: { apiKey: '123' }, - errorTracking: { sourcemaps: getMinimalSourcemapsConfiguration() }, - rum: { sourceCodeContext: { debugId: true, upload: true } }, + 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( - 'errorTracking.sourcemaps cannot be combined with rum.sourceCodeContext.upload.', + '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, upload: true }, + sourceCodeContext: { debugId: true }, }, }); @@ -149,18 +169,6 @@ describe('Error Tracking Plugins validate', () => { ); }); - test('Should reject debug ID uploads when error tracking is disabled', () => { - const { errors } = validateSourcemapsOptions({ - auth: { apiKey: '123' }, - errorTracking: { enable: false }, - rum: { sourceCodeContext: { debugId: true, upload: true } }, - }); - - expect(errors.map(stripAnsi)).toContain( - 'errorTracking cannot be disabled when rum.sourceCodeContext.upload is enabled.', - ); - }); - 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 83dd4b7b5..3c29ddc92 100644 --- a/packages/plugins/error-tracking/src/validate.ts +++ b/packages/plugins/error-tracking/src/validate.ts @@ -62,9 +62,14 @@ export const validateSourcemapsOptions = ( const toReturn: ToReturn = { errors: [], }; - const debugIdUpload = config.rum?.sourceCodeContext?.upload === true; - if (debugIdUpload) { + if (!validatedOptions.sourcemaps) { + return toReturn; + } + + const sourcemapsCfg = validatedOptions.sourcemaps; + + if (sourcemapsCfg.debugId === true) { if (config.rum?.enable === false) { toReturn.errors.push( `${red('rum')} must be enabled to upload source maps by debug ID.`, @@ -75,14 +80,13 @@ export const validateSourcemapsOptions = ( `${red('rum.sourceCodeContext.debugId')} must be enabled to upload source maps by debug ID.`, ); } - if (validatedOptions.sourcemaps) { - toReturn.errors.push( - `${red('errorTracking.sourcemaps')} cannot be combined with ${red('rum.sourceCodeContext.upload')}.`, - ); - } - if (validatedOptions.enable === false) { + if ( + sourcemapsCfg.service !== undefined || + sourcemapsCfg.releaseVersion !== undefined || + sourcemapsCfg.minifiedPathPrefix !== undefined + ) { toReturn.errors.push( - `${red('errorTracking')} cannot be disabled when ${red('rum.sourceCodeContext.upload')} is enabled.`, + `${red('sourcemaps.service')}, ${red('sourcemaps.releaseVersion')}, and ${red('sourcemaps.minifiedPathPrefix')} cannot be used when ${red('sourcemaps.debugId')} is enabled.`, ); } if (!config.auth?.apiKey) { @@ -92,10 +96,12 @@ export const validateSourcemapsOptions = ( } if (toReturn.errors.length === 0) { + const { debugId: _debugId, ...uploadOptions } = sourcemapsCfg; toReturn.config = { bailOnError: false, dryRun: false, maxConcurrency: 20, + ...uploadOptions, mode: SourcemapsUploadMode.DEBUG_ID, }; } @@ -103,60 +109,57 @@ export const validateSourcemapsOptions = ( return toReturn; } - if (validatedOptions.sourcemaps) { - const sourcemapsCfg = validatedOptions.sourcemaps; - - // 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.`); - } + // 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 '/'.`, - ); - } + // 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, - mode: SourcemapsUploadMode.SERVICE_VERSION, - releaseVersion, - }; - } + // 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) { + const { debugId: _debugId, ...serviceVersionOptions } = sourcemapsCfg; + toReturn.config = { + bailOnError: false, + dryRun: false, + maxConcurrency: 20, + ...serviceVersionOptions, + mode: SourcemapsUploadMode.SERVICE_VERSION, + releaseVersion, + }; } return toReturn; diff --git a/packages/plugins/rum/README.md b/packages/plugins/rum/README.md index d02ec0f6a..ef04f6c5a 100644 --- a/packages/plugins/rum/README.md +++ b/packages/plugins/rum/README.md @@ -19,7 +19,6 @@ Interact with Real User Monitoring (RUM) directly from your build system. - [rum.sdk.clientToken](#rumsdkclienttoken) - [Source Code Context](#source-code-context) - [rum.sourceCodeContext.debugId](#rumsourcecodecontextdebugid) - - [rum.sourceCodeContext.upload](#rumsourcecodecontextupload) ## Configuration @@ -35,12 +34,15 @@ rum?: { clientToken?: string; // [...] See https://docs.datadoghq.com/real_user_monitoring/browser/setup/client?tab=rum#configuration for all options. }; - sourceCodeContext?: { - debugId?: boolean; - service?: string; - upload?: boolean; - version?: string; - }; + sourceCodeContext?: + | { + debugId: true; + } + | { + debugId?: false; + service: string; + version?: string; + }; } ``` @@ -112,7 +114,7 @@ A [Datadog client token](https://docs.datadoghq.com/account_management/api-app-k Inject metadata that lets Datadog associate runtime stack frames with uploaded source maps. -To inject debug IDs and upload the corresponding source maps directly during the build: +To inject debug IDs: ```ts datadogWebpackPlugin({ @@ -122,22 +124,15 @@ datadogWebpackPlugin({ rum: { sourceCodeContext: { debugId: true, - upload: true, }, }, }); ``` -This debug ID upload mode does not require `service`, `version`, or `minifiedPathPrefix`. +To upload the corresponding source maps directly during the build, configure `errorTracking.sourcemaps.debugId`. See the [Error Tracking plugin documentation](/packages/plugins/error-tracking#sourcemaps-upload). ### 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. - -### rum.sourceCodeContext.upload - -> default: `false` - -Upload source maps by debug ID during the build. This requires `rum.sourceCodeContext.debugId: true` and a Datadog API key set through `auth.apiKey` or `DATADOG_API_KEY`. diff --git a/packages/plugins/rum/src/getSourceCodeContextSnippet.ts b/packages/plugins/rum/src/getSourceCodeContextSnippet.ts index 312b03b0c..67e99d3dc 100644 --- a/packages/plugins/rum/src/getSourceCodeContextSnippet.ts +++ b/packages/plugins/rum/src/getSourceCodeContextSnippet.ts @@ -40,20 +40,23 @@ export const getSourceCodeContextSnippet = ( chunk?: ChunkInfo, ): SourceCodeContextSnippet => { let debugId: string | undefined; - if (contextOptions.debugId) { + let context: SourceCodeContext; + + if (contextOptions.debugId === true) { // Compute deterministic debug IDs whenever possible to prevent the backend from storing // duplicate source maps for identical builds. debugId = chunk ? stringToUUID(chunk.sourceOrHash) : randomUUID(); + context = { + // The `dd` prefix lets upload tools locate the value and send it as sourcemap metadata. + ddDebugId: debugId, + }; + } else { + context = { + service: contextOptions.service, + version: contextOptions.version, + }; } - 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, - }; - 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)});`; return { code, debugId }; diff --git a/packages/plugins/rum/src/index.test.ts b/packages/plugins/rum/src/index.test.ts index 80d034236..98fd72a0d 100644 --- a/packages/plugins/rum/src/index.test.ts +++ b/packages/plugins/rum/src/index.test.ts @@ -55,19 +55,12 @@ 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', () => { - const value = run({ - sourceCodeContext: { - debugId: true, - service: 'checkout', - version: '1.2.3', - }, - })[0] as () => string; + test('Should not serialize service and version with a debug ID', () => { + const value = run({ sourceCodeContext: { debugId: true } })[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).not.toContain('"service"'); + expect(code).not.toContain('"version"'); }); }); diff --git a/packages/plugins/rum/src/types.ts b/packages/plugins/rum/src/types.ts index 2de924a9d..211e5d652 100644 --- a/packages/plugins/rum/src/types.ts +++ b/packages/plugins/rum/src/types.ts @@ -7,13 +7,22 @@ import type { Assign } from '@dd/core/types'; import type { RumInitConfiguration } from './browserSdkTypes'; import type { PrivacyOptions, PrivacyOptionsWithDefaults } from './privacy/types'; -export type SourceCodeContextOptions = { - service?: string; +type DebugIdSourceCodeContextOptions = { + debugId: true; + service?: never; + version?: never; +}; + +type ServiceVersionSourceCodeContextOptions = { + debugId?: false; + service: string; version?: string; - debugId?: boolean; - upload?: boolean; }; +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 8f5012678..d71211abd 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,25 +54,46 @@ describe('sourceCodeContext validation', () => { expect(result.config).toEqual(expect.objectContaining({ service: 'checkout' })); }); - test('should require debug ID injection when uploads are enabled', () => { + test('should accept debug ID injection', () => { const pluginOptions = { ...defaultPluginOptions, - rum: { sourceCodeContext: { upload: true } }, + rum: { sourceCodeContext: { debugId: true } as const }, }; const result = validateSourceCodeContextOptions(pluginOptions); - expect(result.errors).toEqual( - expect.arrayContaining([expect.stringContaining('"rum.sourceCodeContext.debugId"')]), - ); + expect(result.errors).toHaveLength(0); + expect(result.config).toEqual({ debugId: true }); }); - test('should accept debug ID injection with uploads enabled', () => { + test('should reject service and version when debug ID injection is enabled', () => { const pluginOptions = { ...defaultPluginOptions, - rum: { sourceCodeContext: { debugId: true, upload: true } }, + rum: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sourceCodeContext: { + debugId: true, + service: 'checkout', + version: '1.2.3', + } as any, + }, }; const result = validateSourceCodeContextOptions(pluginOptions); - expect(result.errors).toHaveLength(0); - expect(result.config).toEqual({ debugId: true, upload: true, version: undefined }); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.stringContaining('"rum.sourceCodeContext.service"'), + expect.stringContaining('"rum.sourceCodeContext.version"'), + ]), + ); + expect(result.config).toBeUndefined(); + }); + + test('should make debug ID and service/version identities mutually exclusive in types', () => { + // @ts-expect-error - debug ID cannot be combined with the service/version identity. + const mixedIdentity: SourceCodeContextOptions = { + debugId: true, + service: 'checkout', + version: '1.2.3', + }; + expect(mixedIdentity).toBeDefined(); }); test('should error when service is missing', () => { diff --git a/packages/plugins/rum/src/validate.ts b/packages/plugins/rum/src/validate.ts index 46133ffd6..170c2aee6 100644 --- a/packages/plugins/rum/src/validate.ts +++ b/packages/plugins/rum/src/validate.ts @@ -168,22 +168,31 @@ export const validateSourceCodeContextOptions = ( const cfg: SourceCodeContextOptions = validatedOptions.sourceCodeContext; - if (cfg.upload && !cfg.debugId) { - toReturn.errors.push( - `${red('"rum.sourceCodeContext.debugId"')} must be enabled to upload source maps by debug ID.`, - ); + if (cfg.debugId === true) { + if (cfg.service !== undefined || cfg.version !== undefined) { + toReturn.errors.push( + `${red('"rum.sourceCodeContext.service"')} and ${red('"rum.sourceCodeContext.version"')} cannot be used when ${red('"rum.sourceCodeContext.debugId"')} is enabled.`, + ); + } + + if (toReturn.errors.length === 0) { + toReturn.config = { debugId: true }; + } + return toReturn; } - if (!cfg?.debugId && (!cfg?.service || typeof cfg.service !== 'string')) { + 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`. This only applies to the + // service/version identity; debug ID source code context has no 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 e4fe30c6e..b2c6a26c3 100644 --- a/packages/tests/src/_jest/helpers/mocks.ts +++ b/packages/tests/src/_jest/helpers/mocks.ts @@ -378,7 +378,7 @@ export const getMetricsConfiguration = ( }); export const getMinimalSourcemapsConfiguration = ( - options: Partial = {}, + options: Partial> = {}, ): SourcemapsOptions => { return { minifiedPathPrefix: '/prefix', @@ -389,7 +389,7 @@ export const getMinimalSourcemapsConfiguration = ( }; export const getSourcemapsConfiguration = ( - options: Partial = {}, + options: Partial> = {}, ): ServiceVersionSourcemapsOptionsWithDefaults => { return { bailOnError: false, 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); From 66a1f0d284d3d389b49cdb56844a570ed7e3164d Mon Sep 17 00:00:00 2001 From: Hugo Silva Date: Fri, 4 Sep 2026 13:56:14 +0200 Subject: [PATCH 3/7] feat: simplify debug ID sourcemap configuration --- README.md | 38 ++++++++ packages/core/src/types.ts | 19 ++++ packages/factory/src/index.test.ts | 17 ++++ packages/factory/src/validate.test.ts | 76 ++++++++++++++++ packages/factory/src/validate.ts | 87 ++++++++++++++++++- packages/plugins/error-tracking/README.md | 16 +++- packages/plugins/rum/README.md | 13 ++- .../tools/src/commands/integrity/readme.ts | 14 ++- 8 files changed, 265 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index a93b85794..543c69c66 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,6 +105,18 @@ 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?: @@ -314,6 +327,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. Do not combine them with the top-level `sourcemaps` option. + ## Features 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 f699d2dd1..601a3f5f4 100644 --- a/packages/factory/src/index.test.ts +++ b/packages/factory/src/index.test.ts @@ -67,6 +67,23 @@ describe('Factory', () => { 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..fc63b6de5 100644 --- a/packages/factory/src/validate.test.ts +++ b/packages/factory/src/validate.test.ts @@ -100,4 +100,80 @@ 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.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: true } }, + sourcemaps: { debugId: true }, + }, + error: /cannot be combined with rum\.sourceCodeContext/, + }, + { + 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..b68f5607d 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,93 @@ 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 !== undefined) { + errors.push('sourcemaps cannot be combined with rum.sourceCodeContext'); + } + 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: { 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 +147,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 +160,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 6a39420cf..d210c2340 100644 --- a/packages/plugins/error-tracking/README.md +++ b/packages/plugins/error-tracking/README.md @@ -57,9 +57,21 @@ 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. -Configure `errorTracking.sourcemaps.debugId: true` to upload by debug ID, or configure `service`, `releaseVersion`, and `minifiedPathPrefix` to use service/version matching. The two configurations are mutually exclusive. +For new debug ID configurations, use the top-level `sourcemaps` option: -Debug ID uploads also require `rum.sourceCodeContext.debugId: true` so the build plugin injects a debug ID into each bundle. They do not require a service, release version, or minified path prefix. Omit `errorTracking.sourcemaps` if another tool, such as `datadog-ci`, performs the upload. +```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. The two configurations are mutually exclusive. > [!NOTE] > You can override the domain used in the request with the `DATADOG_SITE` environment variable or the `auth.site` options (eg. `datadoghq.eu`). diff --git a/packages/plugins/rum/README.md b/packages/plugins/rum/README.md index ef04f6c5a..5e5a21cf3 100644 --- a/packages/plugins/rum/README.md +++ b/packages/plugins/rum/README.md @@ -118,18 +118,15 @@ To inject debug IDs: ```ts datadogWebpackPlugin({ - auth: { - apiKey: process.env.DATADOG_API_KEY, - }, - rum: { - sourceCodeContext: { - debugId: true, - }, + sourcemaps: { + debugId: true, }, }); ``` -To upload the corresponding source maps directly during the build, configure `errorTracking.sourcemaps.debugId`. See the [Error Tracking plugin documentation](/packages/plugins/error-tracking#sourcemaps-upload). +To upload the corresponding source maps directly during the build, also set `sourcemaps.upload: true` and configure `auth.apiKey`. + +The existing `rum.sourceCodeContext.debugId` configuration remains supported. ### rum.sourceCodeContext.debugId 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[] = []; From 773f1128219bb3e16eea8975388371cc2a17ac41 Mon Sep 17 00:00:00 2001 From: Hugo Silva Date: Fri, 4 Sep 2026 14:25:58 +0200 Subject: [PATCH 4/7] docs(rum): link source map upload options --- packages/plugins/rum/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/rum/README.md b/packages/plugins/rum/README.md index 5e5a21cf3..b1327555e 100644 --- a/packages/plugins/rum/README.md +++ b/packages/plugins/rum/README.md @@ -124,7 +124,7 @@ datadogWebpackPlugin({ }); ``` -To upload the corresponding source maps directly during the build, also set `sourcemaps.upload: true` and configure `auth.apiKey`. +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. From 4cbb00dd5af8312501d51ee60427590368657639 Mon Sep 17 00:00:00 2001 From: Hugo Silva Date: Mon, 7 Sep 2026 19:31:04 +0200 Subject: [PATCH 5/7] fix(rum): preserve metadata with debug IDs --- README.md | 6 +++- packages/factory/src/validate.test.ts | 23 ++++++++++-- packages/factory/src/validate.ts | 11 ++++-- packages/plugins/error-tracking/README.md | 2 +- .../plugins/error-tracking/src/index.test.ts | 3 +- .../error-tracking/src/sourcemaps/files.ts | 5 +-- .../error-tracking/src/sourcemaps/sender.ts | 18 +++------- .../src/sourcemaps/upload-metrics.ts | 7 ++-- packages/plugins/error-tracking/src/types.ts | 9 ++--- .../error-tracking/src/validate.test.ts | 6 ++-- .../plugins/error-tracking/src/validate.ts | 10 ++---- packages/plugins/rum/README.md | 4 ++- .../rum/src/getSourceCodeContextSnippet.ts | 31 ++++++++-------- packages/plugins/rum/src/index.test.ts | 15 ++++++++ packages/plugins/rum/src/types.ts | 4 +-- packages/plugins/rum/src/validate.test.ts | 36 ++++++++++++------- packages/plugins/rum/src/validate.ts | 16 +++------ packages/tests/src/_jest/helpers/mocks.ts | 9 +++-- 18 files changed, 118 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 543c69c66..1aec317e2 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,8 @@ Follow the specific documentation for each bundler: sourceCodeContext?: | { debugId: true; + service?: string; + version?: string; } | { debugId?: false; @@ -350,7 +352,7 @@ Setting `debugId: true` enables injection. Set `upload: true` to upload the sour The `bailOnError`, `dryRun`, and `maxConcurrency` upload options are available when `upload` is `true`. -Existing `rum.sourceCodeContext` and `errorTracking.sourcemaps` configurations remain supported. Do not combine them with the top-level `sourcemaps` option. +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 @@ -473,6 +475,8 @@ datadogWebpackPlugin({ sourceCodeContext?: | { debugId: true, + service?: string, + version?: string, } | { debugId?: false, diff --git a/packages/factory/src/validate.test.ts b/packages/factory/src/validate.test.ts index fc63b6de5..ef8d54d77 100644 --- a/packages/factory/src/validate.test.ts +++ b/packages/factory/src/validate.test.ts @@ -145,6 +145,25 @@ describe('factory validateOptions', () => { ).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 } }, @@ -160,10 +179,10 @@ describe('factory validateOptions', () => { }, { input: { - rum: { sourceCodeContext: { debugId: true } }, + rum: { sourceCodeContext: { debugId: false, service: 'checkout' } }, sourcemaps: { debugId: true }, }, - error: /cannot be combined with rum\.sourceCodeContext/, + error: /rum\.sourceCodeContext\.debugId cannot be false/, }, { input: { diff --git a/packages/factory/src/validate.ts b/packages/factory/src/validate.ts index b68f5607d..785231757 100644 --- a/packages/factory/src/validate.ts +++ b/packages/factory/src/validate.ts @@ -81,8 +81,10 @@ const normalizeSourcemapsOptions = (options: Options, errors: string[]): Options 'sourcemaps.bailOnError, sourcemaps.dryRun, and sourcemaps.maxConcurrency require sourcemaps.upload to be true', ); } - if (options.rum?.sourceCodeContext !== undefined) { - errors.push('sourcemaps cannot be combined with rum.sourceCodeContext'); + 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'); @@ -102,7 +104,10 @@ const normalizeSourcemapsOptions = (options: Options, errors: string[]): Options ...options, rum: { ...options.rum, - sourceCodeContext: { debugId: true }, + sourceCodeContext: { + ...options.rum?.sourceCodeContext, + debugId: true, + }, }, }; diff --git a/packages/plugins/error-tracking/README.md b/packages/plugins/error-tracking/README.md index d210c2340..46d65f7e8 100644 --- a/packages/plugins/error-tracking/README.md +++ b/packages/plugins/error-tracking/README.md @@ -71,7 +71,7 @@ datadogWebpackPlugin({ }); ``` -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. The two configurations are mutually exclusive. +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`). diff --git a/packages/plugins/error-tracking/src/index.test.ts b/packages/plugins/error-tracking/src/index.test.ts index eba4a1add..b631e4ed5 100644 --- a/packages/plugins/error-tracking/src/index.test.ts +++ b/packages/plugins/error-tracking/src/index.test.ts @@ -8,7 +8,6 @@ import { extractDebugId, } from '@dd/error-tracking-plugin/sourcemaps/debugId'; import { uploadSourcemaps } from '@dd/error-tracking-plugin/sourcemaps/index'; -import { SourcemapsUploadMode } from '@dd/error-tracking-plugin/types'; import { getPlugins } from '@dd/error-tracking-plugin'; import { getGetPluginsArg, @@ -53,7 +52,7 @@ describe('Error Tracking Plugin', () => { }); expect(uploadSourcemapsMock).toHaveBeenCalledTimes(BUNDLERS.length); expect(uploadSourcemapsMock.mock.calls[0][0]).toMatchObject({ - sourcemaps: { mode: SourcemapsUploadMode.DEBUG_ID }, + sourcemaps: { debugId: true }, }); }); diff --git a/packages/plugins/error-tracking/src/sourcemaps/files.ts b/packages/plugins/error-tracking/src/sourcemaps/files.ts index fab2a30ae..7574d4421 100644 --- a/packages/plugins/error-tracking/src/sourcemaps/files.ts +++ b/packages/plugins/error-tracking/src/sourcemaps/files.ts @@ -7,7 +7,6 @@ import chalk from 'chalk'; import path from 'path'; import { - SourcemapsUploadMode, type SourcemapsOptionsWithDefaults, type Sourcemap, type MinifiedPathPrefix, @@ -80,9 +79,7 @@ export const getSourcemapsFiles = ( const sourcemapFiles = sourcemapFilesList.map((sourcemapFilePath) => { const minifiedPathPrefix = - options.mode === SourcemapsUploadMode.SERVICE_VERSION - ? options.minifiedPathPrefix - : undefined; + options.debugId === false ? options.minifiedPathPrefix : undefined; return { ...decomposePath(minifiedPathPrefix, context.outDir, sourcemapFilePath), sourcemapFilePath, diff --git a/packages/plugins/error-tracking/src/sourcemaps/sender.ts b/packages/plugins/error-tracking/src/sourcemaps/sender.ts index be7e93888..38ebe801a 100644 --- a/packages/plugins/error-tracking/src/sourcemaps/sender.ts +++ b/packages/plugins/error-tracking/src/sourcemaps/sender.ts @@ -16,7 +16,7 @@ import type { Logger, Metric, RepositoryData } from '@dd/core/types'; import chalk from 'chalk'; import PQueue from 'p-queue'; -import { SourcemapsUploadMode, type SourcemapsOptionsWithDefaults, type Sourcemap } from '../types'; +import type { SourcemapsOptionsWithDefaults, Sourcemap } from '../types'; import { extractDebugId } from './debugId'; import type { Metadata, MultipartFileValue, Payload } from './payload'; @@ -187,10 +187,7 @@ export const sendSourcemaps = async ( log: Logger, ) => { const start = Date.now(); - const prefix = - options.mode === SourcemapsUploadMode.SERVICE_VERSION - ? options.minifiedPathPrefix - : undefined; + const prefix = options.debugId === false ? options.minifiedPathPrefix : undefined; const metadata: Metadata = { git_repository_url: context.git?.remote, @@ -198,7 +195,7 @@ export const sendSourcemaps = async ( plugin_version: context.version, project_path: context.outDir, type: 'js_sourcemap', - ...(options.mode === SourcemapsUploadMode.SERVICE_VERSION + ...(options.debugId === false ? { service: options.service, version: options.releaseVersion } : {}), }; @@ -214,14 +211,7 @@ export const sendSourcemaps = async ( if (debugId) { debugIdCount += 1; } - return getPayload( - sourcemap, - metadata, - prefix, - context.git, - debugId, - options.mode === SourcemapsUploadMode.DEBUG_ID, - ); + return getPayload(sourcemap, metadata, prefix, context.git, debugId, options.debugId); }), ); payloadsTimer.end(); diff --git a/packages/plugins/error-tracking/src/sourcemaps/upload-metrics.ts b/packages/plugins/error-tracking/src/sourcemaps/upload-metrics.ts index 405c6dc04..a821b67f7 100644 --- a/packages/plugins/error-tracking/src/sourcemaps/upload-metrics.ts +++ b/packages/plugins/error-tracking/src/sourcemaps/upload-metrics.ts @@ -5,7 +5,7 @@ import { normalizeTagValue } from '@dd/core/helpers/strings'; import type { Metric } from '@dd/core/types'; -import { SourcemapsUploadMode, type SourcemapsOptionsWithDefaults } from '../types'; +import type { SourcemapsOptionsWithDefaults } from '../types'; import type { UploadContext } from './sender'; @@ -44,10 +44,7 @@ export const createSourcemapUploadMetrics = ( options: SourcemapsOptionsWithDefaults, ): SourcemapUploadMetrics => ({ metrics: new Map(), - baseTags: - options.mode === SourcemapsUploadMode.SERVICE_VERSION - ? [`service:${options.service}`] - : ['matching:debug_id'], + 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 ab1060249..be69829c0 100644 --- a/packages/plugins/error-tracking/src/types.ts +++ b/packages/plugins/error-tracking/src/types.ts @@ -28,11 +28,6 @@ type ServiceVersionSourcemapsOptions = SourcemapsUploadOptions & { export type SourcemapsOptions = DebugIdSourcemapsOptions | ServiceVersionSourcemapsOptions; -export enum SourcemapsUploadMode { - DEBUG_ID = 'debug-id', - SERVICE_VERSION = 'service-version', -} - type SourcemapsUploadOptionsWithDefaults = { bailOnError: boolean; dryRun: boolean; @@ -43,11 +38,11 @@ export type ServiceVersionSourcemapsOptionsWithDefaults = SourcemapsUploadOption Required< Pick > & { - mode: SourcemapsUploadMode.SERVICE_VERSION; + debugId: false; }; export type DebugIdSourcemapsOptionsWithDefaults = SourcemapsUploadOptionsWithDefaults & { - mode: SourcemapsUploadMode.DEBUG_ID; + debugId: true; }; export type SourcemapsOptionsWithDefaults = diff --git a/packages/plugins/error-tracking/src/validate.test.ts b/packages/plugins/error-tracking/src/validate.test.ts index 8b757c450..19ca56513 100644 --- a/packages/plugins/error-tracking/src/validate.test.ts +++ b/packages/plugins/error-tracking/src/validate.test.ts @@ -2,7 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { SourcemapsUploadMode, type SourcemapsOptions } from '@dd/error-tracking-plugin/types'; +import type { SourcemapsOptions } from '@dd/error-tracking-plugin/types'; import { validateOptions, validateSourcemapsOptions } from '@dd/error-tracking-plugin/validate'; import { getMinimalSourcemapsConfiguration, mockLogger } from '@dd/tests/_jest/helpers/mocks'; import stripAnsi from 'strip-ansi'; @@ -80,10 +80,10 @@ describe('Error Tracking Plugins validate', () => { expect(errors).toHaveLength(0); expect(config).toEqual({ bailOnError: false, + debugId: false, dryRun: false, maxConcurrency: 20, ...configObject, - mode: SourcemapsUploadMode.SERVICE_VERSION, }); }); @@ -97,9 +97,9 @@ describe('Error Tracking Plugins validate', () => { expect(errors).toHaveLength(0); expect(config).toEqual({ bailOnError: false, + debugId: true, dryRun: false, maxConcurrency: 20, - mode: SourcemapsUploadMode.DEBUG_ID, }); }); diff --git a/packages/plugins/error-tracking/src/validate.ts b/packages/plugins/error-tracking/src/validate.ts index 3c29ddc92..ed0928bef 100644 --- a/packages/plugins/error-tracking/src/validate.ts +++ b/packages/plugins/error-tracking/src/validate.ts @@ -7,7 +7,6 @@ import chalk from 'chalk'; import { CONFIG_KEY, PLUGIN_NAME } from './constants'; import { - SourcemapsUploadMode, type ErrorTrackingOptions, type ErrorTrackingOptionsWithDefaults, type SourcemapsOptionsWithDefaults, @@ -96,13 +95,11 @@ export const validateSourcemapsOptions = ( } if (toReturn.errors.length === 0) { - const { debugId: _debugId, ...uploadOptions } = sourcemapsCfg; toReturn.config = { bailOnError: false, dryRun: false, maxConcurrency: 20, - ...uploadOptions, - mode: SourcemapsUploadMode.DEBUG_ID, + ...sourcemapsCfg, }; } @@ -151,13 +148,12 @@ export const validateSourcemapsOptions = ( // resolves; otherwise an error has been recorded and the caller will // throw before the config is read. if (releaseVersion) { - const { debugId: _debugId, ...serviceVersionOptions } = sourcemapsCfg; toReturn.config = { bailOnError: false, dryRun: false, maxConcurrency: 20, - ...serviceVersionOptions, - mode: SourcemapsUploadMode.SERVICE_VERSION, + ...sourcemapsCfg, + debugId: false, releaseVersion, }; } diff --git a/packages/plugins/rum/README.md b/packages/plugins/rum/README.md index b1327555e..17e2241d7 100644 --- a/packages/plugins/rum/README.md +++ b/packages/plugins/rum/README.md @@ -37,6 +37,8 @@ rum?: { sourceCodeContext?: | { debugId: true; + service?: string; + version?: string; } | { debugId?: false; @@ -126,7 +128,7 @@ datadogWebpackPlugin({ 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. +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 diff --git a/packages/plugins/rum/src/getSourceCodeContextSnippet.ts b/packages/plugins/rum/src/getSourceCodeContextSnippet.ts index 67e99d3dc..0866b5b08 100644 --- a/packages/plugins/rum/src/getSourceCodeContextSnippet.ts +++ b/packages/plugins/rum/src/getSourceCodeContextSnippet.ts @@ -39,23 +39,20 @@ export const getSourceCodeContextSnippet = ( contextOptions: SourceCodeContextOptions, chunk?: ChunkInfo, ): SourceCodeContextSnippet => { - let debugId: string | undefined; - let context: SourceCodeContext; - - if (contextOptions.debugId === true) { - // Compute deterministic debug IDs whenever possible to prevent the backend from storing - // duplicate source maps for identical builds. - debugId = chunk ? stringToUUID(chunk.sourceOrHash) : randomUUID(); - context = { - // The `dd` prefix lets upload tools locate the value and send it as sourcemap metadata. - ddDebugId: debugId, - }; - } else { - context = { - service: contextOptions.service, - version: contextOptions.version, - }; - } + // Compute deterministic debug IDs whenever possible to prevent the backend from storing + // duplicate source maps for identical builds. + const debugId = + contextOptions.debugId === true + ? chunk + ? stringToUUID(chunk.sourceOrHash) + : randomUUID() + : undefined; + const context: SourceCodeContext = { + 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 98fd72a0d..4ca011b42 100644 --- a/packages/plugins/rum/src/index.test.ts +++ b/packages/plugins/rum/src/index.test.ts @@ -63,4 +63,19 @@ describe('RUM Plugin', () => { 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, + service: 'checkout', + version: '1.2.3', + }, + })[0] as () => string; + const code = value(); + + 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 211e5d652..7eaa37abc 100644 --- a/packages/plugins/rum/src/types.ts +++ b/packages/plugins/rum/src/types.ts @@ -9,8 +9,8 @@ import type { PrivacyOptions, PrivacyOptionsWithDefaults } from './privacy/types type DebugIdSourceCodeContextOptions = { debugId: true; - service?: never; - version?: never; + service?: string; + version?: string; }; type ServiceVersionSourceCodeContextOptions = { diff --git a/packages/plugins/rum/src/validate.test.ts b/packages/plugins/rum/src/validate.test.ts index d71211abd..ba1021413 100644 --- a/packages/plugins/rum/src/validate.test.ts +++ b/packages/plugins/rum/src/validate.test.ts @@ -64,30 +64,27 @@ describe('sourceCodeContext validation', () => { expect(result.config).toEqual({ debugId: true }); }); - test('should reject service and version when debug ID injection is enabled', () => { + test('should preserve service and version when debug ID injection is enabled', () => { const pluginOptions = { ...defaultPluginOptions, rum: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any sourceCodeContext: { debugId: true, service: 'checkout', version: '1.2.3', - } as any, + } as const, }, }; const result = validateSourceCodeContextOptions(pluginOptions); - expect(result.errors).toEqual( - expect.arrayContaining([ - expect.stringContaining('"rum.sourceCodeContext.service"'), - expect.stringContaining('"rum.sourceCodeContext.version"'), - ]), - ); - expect(result.config).toBeUndefined(); + expect(result.errors).toHaveLength(0); + expect(result.config).toEqual({ + debugId: true, + service: 'checkout', + version: '1.2.3', + }); }); - test('should make debug ID and service/version identities mutually exclusive in types', () => { - // @ts-expect-error - debug ID cannot be combined with the service/version identity. + test('should allow debug ID and service/version identities together in types', () => { const mixedIdentity: SourceCodeContextOptions = { debugId: true, service: 'checkout', @@ -96,6 +93,21 @@ describe('sourceCodeContext validation', () => { 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 170c2aee6..06595356f 100644 --- a/packages/plugins/rum/src/validate.ts +++ b/packages/plugins/rum/src/validate.ts @@ -169,15 +169,10 @@ export const validateSourceCodeContextOptions = ( const cfg: SourceCodeContextOptions = validatedOptions.sourceCodeContext; if (cfg.debugId === true) { - if (cfg.service !== undefined || cfg.version !== undefined) { - toReturn.errors.push( - `${red('"rum.sourceCodeContext.service"')} and ${red('"rum.sourceCodeContext.version"')} cannot be used when ${red('"rum.sourceCodeContext.debugId"')} is enabled.`, - ); - } - - if (toReturn.errors.length === 0) { - toReturn.config = { debugId: true }; - } + toReturn.config = { + ...cfg, + version: cfg.version || (cfg.service ? options.metadata?.version : undefined), + }; return toReturn; } @@ -187,8 +182,7 @@ export const validateSourceCodeContextOptions = ( if (toReturn.errors.length === 0) { // Resolve `version`: prefer the plugin-specific option, then fall back to - // the shared top-level `metadata.version`. This only applies to the - // service/version identity; debug ID source code context has no version. + // the shared top-level `metadata.version`. toReturn.config = { ...cfg, version: cfg.version || options.metadata?.version, diff --git a/packages/tests/src/_jest/helpers/mocks.ts b/packages/tests/src/_jest/helpers/mocks.ts index b2c6a26c3..d3b7c2033 100644 --- a/packages/tests/src/_jest/helpers/mocks.ts +++ b/packages/tests/src/_jest/helpers/mocks.ts @@ -35,7 +35,6 @@ import type { Payload, } from '@dd/error-tracking-plugin/sourcemaps/payload'; import { - SourcemapsUploadMode, type DebugIdSourcemapsOptionsWithDefaults, type ServiceVersionSourcemapsOptionsWithDefaults, type SourcemapsOptions, @@ -378,7 +377,7 @@ export const getMetricsConfiguration = ( }); export const getMinimalSourcemapsConfiguration = ( - options: Partial> = {}, + options: Partial = {}, ): SourcemapsOptions => { return { minifiedPathPrefix: '/prefix', @@ -389,14 +388,14 @@ export const getMinimalSourcemapsConfiguration = ( }; export const getSourcemapsConfiguration = ( - options: Partial> = {}, + options: Partial = {}, ): ServiceVersionSourcemapsOptionsWithDefaults => { return { bailOnError: false, dryRun: false, + debugId: false, maxConcurrency: 10, minifiedPathPrefix: '/prefix', - mode: SourcemapsUploadMode.SERVICE_VERSION, releaseVersion: '1.0.0', service: 'error-tracking-build-plugin-sourcemaps', ...options, @@ -405,9 +404,9 @@ export const getSourcemapsConfiguration = ( export const getDebugIdSourcemapsConfiguration = (): DebugIdSourcemapsOptionsWithDefaults => ({ bailOnError: false, + debugId: true, dryRun: false, maxConcurrency: 10, - mode: SourcemapsUploadMode.DEBUG_ID, }); export const getSourcemapMock = (options: Partial = {}): Sourcemap => { From c655c0c476712a348dc06d4dbed96bfd79f9ca24 Mon Sep 17 00:00:00 2001 From: Hugo Silva Date: Tue, 8 Sep 2026 10:59:01 +0200 Subject: [PATCH 6/7] refactor(rum): simplify debug ID assignment --- .../plugins/rum/src/getSourceCodeContextSnippet.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/plugins/rum/src/getSourceCodeContextSnippet.ts b/packages/plugins/rum/src/getSourceCodeContextSnippet.ts index 0866b5b08..80d4411bf 100644 --- a/packages/plugins/rum/src/getSourceCodeContextSnippet.ts +++ b/packages/plugins/rum/src/getSourceCodeContextSnippet.ts @@ -41,12 +41,10 @@ export const getSourceCodeContextSnippet = ( ): SourceCodeContextSnippet => { // Compute deterministic debug IDs whenever possible to prevent the backend from storing // duplicate source maps for identical builds. - const debugId = - contextOptions.debugId === true - ? chunk - ? stringToUUID(chunk.sourceOrHash) - : randomUUID() - : undefined; + let debugId: string | undefined; + if (contextOptions.debugId === true) { + debugId = chunk ? stringToUUID(chunk.sourceOrHash) : randomUUID(); + } const context: SourceCodeContext = { service: contextOptions.service, version: contextOptions.version, From 035b29fbf15cbe77812dac4bbca3b08654261cfd Mon Sep 17 00:00:00 2001 From: Hugo Silva Date: Tue, 8 Sep 2026 11:47:46 +0200 Subject: [PATCH 7/7] fix(error-tracking): skip sourcemaps without debug IDs --- .../src/sourcemaps/payload.test.ts | 15 -- .../error-tracking/src/sourcemaps/payload.ts | 4 - .../src/sourcemaps/sender.test.ts | 141 +++++++++++++++++- .../error-tracking/src/sourcemaps/sender.ts | 50 +++++-- 4 files changed, 181 insertions(+), 29 deletions(-) diff --git a/packages/plugins/error-tracking/src/sourcemaps/payload.test.ts b/packages/plugins/error-tracking/src/sourcemaps/payload.test.ts index 48434e3eb..87cb0eaa9 100644 --- a/packages/plugins/error-tracking/src/sourcemaps/payload.test.ts +++ b/packages/plugins/error-tracking/src/sourcemaps/payload.test.ts @@ -129,20 +129,5 @@ describe('Error Tracking Plugins Sourcemaps Payloads', () => { expect(payload.warnings).toHaveLength(0); expect(payload.errors).toHaveLength(0); }); - - test('Should require a debug ID for debug ID uploads', async () => { - const payload = await getPayload( - getSourcemapMock(), - getMetadataMock({ service: undefined, version: undefined }), - undefined, - undefined, - undefined, - true, - ); - - expect(payload.errors).toContain( - 'No debug ID found in minified file: /path/to/minified.min.js', - ); - }); }); }); diff --git a/packages/plugins/error-tracking/src/sourcemaps/payload.ts b/packages/plugins/error-tracking/src/sourcemaps/payload.ts index e71683d99..435ca80cb 100644 --- a/packages/plugins/error-tracking/src/sourcemaps/payload.ts +++ b/packages/plugins/error-tracking/src/sourcemaps/payload.ts @@ -89,7 +89,6 @@ export const getPayload = async ( prefix?: string, git?: RepositoryData, debugId?: string, - debugIdRequired = false, ): Promise => { const validity = await getSourcemapValidity(sourcemap, prefix); const errors: string[] = []; @@ -175,9 +174,6 @@ export const getPayload = async ( if (!validity.sourcemap.exists) { errors.push(`Sourcemap file not found: ${sourcemap.sourcemapFilePath}`); } - if (debugIdRequired && !debugId) { - errors.push(`No debug ID found in minified file: ${sourcemap.minifiedFilePath}`); - } if (validity.repeatedPrefix) { warnings.push( `The minified file path contains a repeated pattern with the minified path prefix: ${validity.repeatedPrefix}`, diff --git a/packages/plugins/error-tracking/src/sourcemaps/sender.test.ts b/packages/plugins/error-tracking/src/sourcemaps/sender.test.ts index 282c8d5bb..648848559 100644 --- a/packages/plugins/error-tracking/src/sourcemaps/sender.test.ts +++ b/packages/plugins/error-tracking/src/sourcemaps/sender.test.ts @@ -248,11 +248,150 @@ describe('Error Tracking Plugin Sourcemaps', () => { 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); - expect(getPayloadSpy.mock.calls[0][5]).toBe(true); 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 38ebe801a..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'; @@ -204,23 +209,50 @@ export const sendSourcemaps = async ( // @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, options.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 - ')}`); @@ -253,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) {