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