From 566a9f155ab02f42954951c96ee18655e8009cff Mon Sep 17 00:00:00 2001 From: Aymeric Mortemousque Date: Thu, 4 Jun 2026 14:55:03 +0200 Subject: [PATCH 1/8] Add debug_id snippet injection in error tracking plugin --- packages/plugins/error-tracking/package.json | 1 + .../error-tracking/src/debug-id/constants.ts | 10 ++ .../error-tracking/src/debug-id/esbuild.ts | 49 ++++++ .../error-tracking/src/debug-id/index.test.ts | 162 ++++++++++++++++++ .../error-tracking/src/debug-id/index.ts | 29 ++++ .../error-tracking/src/debug-id/rollup.ts | 33 ++++ .../error-tracking/src/debug-id/utils.test.ts | 67 ++++++++ .../error-tracking/src/debug-id/utils.ts | 51 ++++++ .../error-tracking/src/debug-id/xpack.ts | 48 ++++++ packages/plugins/error-tracking/src/index.ts | 17 +- packages/plugins/error-tracking/src/types.ts | 2 + .../published/esbuild-plugin/package.json | 1 + packages/published/rollup-plugin/package.json | 1 + packages/published/rspack-plugin/package.json | 1 + packages/published/vite-plugin/package.json | 1 + .../published/webpack-plugin/package.json | 1 + .../tests/src/e2e/debugId/debugId.spec.ts | 124 ++++++++++++++ .../tests/src/e2e/debugId/project/chunk.js | 8 + .../tests/src/e2e/debugId/project/index.html | 18 ++ .../tests/src/e2e/debugId/project/index.js | 11 ++ yarn.lock | 6 + 21 files changed, 635 insertions(+), 6 deletions(-) create mode 100644 packages/plugins/error-tracking/src/debug-id/constants.ts create mode 100644 packages/plugins/error-tracking/src/debug-id/esbuild.ts create mode 100644 packages/plugins/error-tracking/src/debug-id/index.test.ts create mode 100644 packages/plugins/error-tracking/src/debug-id/index.ts create mode 100644 packages/plugins/error-tracking/src/debug-id/rollup.ts create mode 100644 packages/plugins/error-tracking/src/debug-id/utils.test.ts create mode 100644 packages/plugins/error-tracking/src/debug-id/utils.ts create mode 100644 packages/plugins/error-tracking/src/debug-id/xpack.ts create mode 100644 packages/tests/src/e2e/debugId/debugId.spec.ts create mode 100644 packages/tests/src/e2e/debugId/project/chunk.js create mode 100644 packages/tests/src/e2e/debugId/project/index.html create mode 100644 packages/tests/src/e2e/debugId/project/index.js diff --git a/packages/plugins/error-tracking/package.json b/packages/plugins/error-tracking/package.json index 620623247..b193464bd 100644 --- a/packages/plugins/error-tracking/package.json +++ b/packages/plugins/error-tracking/package.json @@ -22,6 +22,7 @@ "dependencies": { "@dd/core": "workspace:*", "chalk": "2.3.1", + "magic-string": "0.30.21", "outdent": "0.8.0", "p-queue": "6.6.2" }, diff --git a/packages/plugins/error-tracking/src/debug-id/constants.ts b/packages/plugins/error-tracking/src/debug-id/constants.ts new file mode 100644 index 000000000..8233603ca --- /dev/null +++ b/packages/plugins/error-tracking/src/debug-id/constants.ts @@ -0,0 +1,10 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { PluginName } from '@dd/core/types'; + +export const PLUGIN_NAME: PluginName = 'datadog-error-tracking-debug-id-plugin' as const; + +// JS output extensions we inject the debug_id into. +export const SUPPORTED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']); diff --git a/packages/plugins/error-tracking/src/debug-id/esbuild.ts b/packages/plugins/error-tracking/src/debug-id/esbuild.ts new file mode 100644 index 000000000..e1ba74437 --- /dev/null +++ b/packages/plugins/error-tracking/src/debug-id/esbuild.ts @@ -0,0 +1,49 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { getAbsolutePath } from '@dd/core/helpers/paths'; +import type { GlobalContext, Logger, PluginOptions } from '@dd/core/types'; +import fsp from 'fs/promises'; +import path from 'path'; + +import { SUPPORTED_EXTENSIONS } from './constants'; +import { getSnippet, stringToUUID } from './utils'; + +export const getDebugIdEsbuildPlugin = ( + log: Logger, + context: GlobalContext, + debugIds: Map, +): PluginOptions['esbuild'] => ({ + setup(build) { + build.initialOptions.metafile = true; + + build.onEnd(async (result) => { + if (!result.metafile) { + log.warn('Missing metafile — debug_id injection skipped.'); + return; + } + + const proms: Promise[] = []; + + for (const [p] of Object.entries(result.metafile.outputs)) { + const absolutePath = getAbsolutePath(context.buildRoot, p); + + if (!SUPPORTED_EXTENSIONS.has(path.extname(absolutePath))) { + continue; + } + + proms.push( + (async () => { + const source = await fsp.readFile(absolutePath, 'utf-8'); + const uuid = stringToUUID(source); + debugIds.set(absolutePath, uuid); + await fsp.writeFile(absolutePath, `${getSnippet(uuid)}\n${source}`); + })(), + ); + } + + await Promise.all(proms); + }); + }, +}); diff --git a/packages/plugins/error-tracking/src/debug-id/index.test.ts b/packages/plugins/error-tracking/src/debug-id/index.test.ts new file mode 100644 index 000000000..4bebc76c0 --- /dev/null +++ b/packages/plugins/error-tracking/src/debug-id/index.test.ts @@ -0,0 +1,162 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { datadogEsbuildPlugin } from '@datadog/esbuild-plugin'; +import { datadogRollupPlugin } from '@datadog/rollup-plugin'; +import { datadogRspackPlugin } from '@datadog/rspack-plugin'; +import { datadogVitePlugin } from '@datadog/vite-plugin'; +import { datadogWebpackPlugin } from '@datadog/webpack-plugin'; +import { rm } from '@dd/core/helpers/fs'; +import { getUniqueId } from '@dd/core/helpers/strings'; +import { prepareWorkingDir } from '@dd/tests/_jest/helpers/env'; +import { easyProjectEntry, defaultPluginOptions } from '@dd/tests/_jest/helpers/mocks'; +import { + buildWithEsbuild, + buildWithRollup, + buildWithVite, + buildWithWebpack, + buildWithRspack, +} from '@dd/tools/bundlers'; +import fsp from 'fs/promises'; +import path from 'path'; + +const UUID_RX = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/; +// The snippet passes the debug_id as a quoted UUID argument to its IIFE. +// Match any quoted UUID-v4 string (quote style can change under minification). +const SNIPPET_DEBUG_ID_RX = + /["']([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})["']/; + +// Verify the runtime snippet was prepended and embeds a valid debug_id. +const expectDebugIdSnippet = (jsContent: string) => { + expect(jsContent).toContain('DD_DEBUG_IDS'); + const match = jsContent.match(SNIPPET_DEBUG_ID_RX); + expect(match).not.toBeNull(); + expect(match![1]).toMatch(UUID_RX); +}; + +describe('Debug ID Injection', () => { + const seed = `${Math.abs(jest.getSeed())}.${getUniqueId()}`; + let workingDir: string; + + beforeAll(async () => { + workingDir = await prepareWorkingDir(seed); + }); + + afterAll(async () => { + if (!process.env.NO_CLEANUP) { + await rm(workingDir); + } + }); + + test('esbuild: prepends the DD_DEBUG_IDS snippet with a valid debug_id', async () => { + const outDir = path.resolve(workingDir, 'dist-debug-id-esbuild'); + const { errors } = await buildWithEsbuild({ + absWorkingDir: workingDir, + bundle: true, + entryPoints: { main: path.resolve(workingDir, easyProjectEntry) }, + outdir: outDir, + sourcemap: true, + plugins: [ + datadogEsbuildPlugin({ + ...defaultPluginOptions, + errorTracking: { debugId: true }, + }), + ], + }); + + expect(errors).toEqual([]); + + const jsContent = await fsp.readFile(path.resolve(outDir, 'main.js'), 'utf-8'); + expectDebugIdSnippet(jsContent); + }); + + test('rollup: prepends the DD_DEBUG_IDS snippet with a valid debug_id', async () => { + const outDir = path.resolve(workingDir, 'dist-debug-id-rollup'); + const { errors } = await buildWithRollup({ + input: { main: path.resolve(workingDir, easyProjectEntry) }, + output: { dir: outDir, sourcemap: true }, + plugins: [ + datadogRollupPlugin({ + ...defaultPluginOptions, + errorTracking: { debugId: true }, + }), + ], + }); + + expect(errors).toEqual([]); + + const jsContent = await fsp.readFile(path.resolve(outDir, 'main.js'), 'utf-8'); + expectDebugIdSnippet(jsContent); + }); + + test('vite: prepends the DD_DEBUG_IDS snippet with a valid debug_id', async () => { + const outDir = path.resolve(workingDir, 'dist-debug-id-vite'); + const { errors } = await buildWithVite({ + root: workingDir, + build: { + outDir, + sourcemap: true, + rollupOptions: { + input: { main: path.resolve(workingDir, easyProjectEntry) }, + output: { entryFileNames: 'assets/[name].js' }, + }, + }, + plugins: [ + datadogVitePlugin({ + ...defaultPluginOptions, + errorTracking: { debugId: true }, + }), + ], + }); + + expect(errors).toEqual([]); + + const jsContent = await fsp.readFile(path.resolve(outDir, 'assets/main.js'), 'utf-8'); + expectDebugIdSnippet(jsContent); + }); + + test('webpack: prepends the DD_DEBUG_IDS snippet with a valid debug_id', async () => { + const outDir = path.resolve(workingDir, 'dist-debug-id-webpack'); + const { errors } = await buildWithWebpack({ + context: workingDir, + mode: 'development', + devtool: 'source-map', + entry: { main: path.resolve(workingDir, easyProjectEntry) }, + output: { path: outDir }, + plugins: [ + datadogWebpackPlugin({ + ...defaultPluginOptions, + errorTracking: { debugId: true }, + }), + ], + }); + + expect(errors).toEqual([]); + + const jsContent = await fsp.readFile(path.resolve(outDir, 'main.js'), 'utf-8'); + expectDebugIdSnippet(jsContent); + }); + + test('rspack: prepends the DD_DEBUG_IDS snippet with a valid debug_id', async () => { + const outDir = path.resolve(workingDir, 'dist-debug-id-rspack'); + const { errors } = await buildWithRspack({ + context: workingDir, + mode: 'development', + devtool: 'source-map', + entry: { main: path.resolve(workingDir, easyProjectEntry) }, + output: { path: outDir }, + plugins: [ + datadogRspackPlugin({ + ...defaultPluginOptions, + errorTracking: { debugId: true }, + }), + ], + }); + + expect(errors).toEqual([]); + + const jsContent = await fsp.readFile(path.resolve(outDir, 'main.js'), 'utf-8'); + expectDebugIdSnippet(jsContent); + }); +}); diff --git a/packages/plugins/error-tracking/src/debug-id/index.ts b/packages/plugins/error-tracking/src/debug-id/index.ts new file mode 100644 index 000000000..725d6a002 --- /dev/null +++ b/packages/plugins/error-tracking/src/debug-id/index.ts @@ -0,0 +1,29 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { GlobalContext, Logger, PluginOptions } from '@dd/core/types'; + +import { PLUGIN_NAME } from './constants'; +import { getDebugIdEsbuildPlugin } from './esbuild'; +import { getDebugIdRollupPlugin } from './rollup'; +import { getDebugIdXpackPlugin } from './xpack'; + +export const getDebugIdPlugin = ( + bundler: any, + log: Logger, + context: GlobalContext, + debugIds: Map, +): PluginOptions => { + // rollup and vite both consume rollup's generateBundle hook. + const rollupPlugin = getDebugIdRollupPlugin(context, debugIds); + return { + name: PLUGIN_NAME, + enforce: 'post', + esbuild: getDebugIdEsbuildPlugin(log, context, debugIds), + rollup: rollupPlugin, + vite: rollupPlugin, + webpack: getDebugIdXpackPlugin(bundler, log, context, debugIds), + rspack: getDebugIdXpackPlugin(bundler, log, context, debugIds), + }; +}; diff --git a/packages/plugins/error-tracking/src/debug-id/rollup.ts b/packages/plugins/error-tracking/src/debug-id/rollup.ts new file mode 100644 index 000000000..65a5d0b5c --- /dev/null +++ b/packages/plugins/error-tracking/src/debug-id/rollup.ts @@ -0,0 +1,33 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { GlobalContext, PluginOptions } from '@dd/core/types'; +import MagicString from 'magic-string'; +import path from 'path'; +import type { RenderedChunk } from 'rollup'; + +import { SUPPORTED_EXTENSIONS } from './constants'; +import { getSnippet, stringToUUID } from './utils'; + +// Shared by rollup and vite: both consume rollup's `renderChunk` hook. +// We hash the chunk's code (content-based, deterministic) so the debug_id +// changes whenever the output changes, even when the filename has no hash. +export const getDebugIdRollupPlugin = ( + context: GlobalContext, + debugIds: Map, +): PluginOptions['rollup'] => ({ + renderChunk(code, chunk: RenderedChunk) { + if (!SUPPORTED_EXTENSIONS.has(path.extname(chunk.fileName))) { + return null; + } + const uuid = stringToUUID(code); + const absolutePath = path.resolve(context.bundler.outDir, chunk.fileName); + debugIds.set(absolutePath, uuid); + + const s = new MagicString(code); + s.prepend(`${getSnippet(uuid)}\n`); + + return { code: s.toString(), map: s.generateMap({ file: chunk.fileName, hires: true }) }; + }, +}); diff --git a/packages/plugins/error-tracking/src/debug-id/utils.test.ts b/packages/plugins/error-tracking/src/debug-id/utils.test.ts new file mode 100644 index 000000000..d53587d98 --- /dev/null +++ b/packages/plugins/error-tracking/src/debug-id/utils.test.ts @@ -0,0 +1,67 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { stringToUUID, getSnippet, getDebugIdFromSource } from './utils'; + +const UUID_RX = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/; + +describe('stringToUUID', () => { + test('should produce a valid UUID v4 format', () => { + expect(stringToUUID('hello')).toMatch(UUID_RX); + }); + + test('should be deterministic for the same input', () => { + expect(stringToUUID('hello')).toBe(stringToUUID('hello')); + }); + + test('should differ for different inputs', () => { + expect(stringToUUID('hello')).not.toBe(stringToUUID('world')); + }); +}); + +describe('getSnippet', () => { + const uuid = '12345678-1234-4234-8234-123456789abc'; + + test('should include the UUID in the output', () => { + expect(getSnippet(uuid)).toContain(uuid); + }); + + test('should assign to DD_DEBUG_IDS global', () => { + expect(getSnippet(uuid)).toContain('DD_DEBUG_IDS'); + }); + + test('should be a single line (no newlines)', () => { + expect(getSnippet(uuid)).not.toContain('\n'); + }); + + test('should pass the UUID and variable name as IIFE arguments', () => { + expect(getSnippet(uuid)).toContain(`})("${uuid}","DD_DEBUG_IDS")`); + }); + + test('should guard on window before accessing it', () => { + expect(getSnippet(uuid)).toContain("typeof window==='undefined'"); + }); +}); + +describe('getDebugIdFromSource', () => { + const uuid = '12345678-1234-4234-8234-123456789abc'; + + test('should round-trip the UUID injected by getSnippet', () => { + const source = `${getSnippet(uuid)}\nconsole.log('app');`; + expect(getDebugIdFromSource(source)).toBe(uuid); + }); + + test('should recover the UUID from a minified single-quote variant', () => { + const minified = `})('${uuid}','DD_DEBUG_IDS')`; + expect(getDebugIdFromSource(minified)).toBe(uuid); + }); + + test('should return undefined when no UUID is present', () => { + expect(getDebugIdFromSource('console.log("no debug id here");')).toBeUndefined(); + }); + + test('should not match a stray UUID unrelated to DD_DEBUG_IDS', () => { + expect(getDebugIdFromSource(`var id="${uuid}";`)).toBeUndefined(); + }); +}); diff --git a/packages/plugins/error-tracking/src/debug-id/utils.ts b/packages/plugins/error-tracking/src/debug-id/utils.ts new file mode 100644 index 000000000..2ea4f7c6e --- /dev/null +++ b/packages/plugins/error-tracking/src/debug-id/utils.ts @@ -0,0 +1,51 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { createHash } from 'crypto'; + +const VARIANT_CHARS = ['8', '9', 'a', 'b'] as const; + +// MD5(input) → deterministic UUID-v4-shaped identifier. +export const stringToUUID = (input: string): string => { + const md5 = createHash('md5').update(input).digest('hex'); + const withVersion = `${md5.slice(0, 12)}4${md5.slice(13)}`; + const variantIndex = withVersion.charCodeAt(16) % 4; + const withVariant = `${withVersion.slice(0, 16)}${VARIANT_CHARS[variantIndex]}${withVersion.slice(17)}`; + return [ + withVariant.slice(0, 8), + withVariant.slice(8, 12), + withVariant.slice(12, 16), + withVariant.slice(16, 20), + withVariant.slice(20, 32), + ].join('-'); +}; + +export const DD_DEBUG_IDS_VARIABLE = 'DD_DEBUG_IDS' as const; + +// Runtime snippet, prepended to each emitted JS file. It registers the debug_id +// keyed by the script's own `new Error().stack`, so the SDK can collect it at +// runtime and the upload step can recover it by parsing the snippet. +// SSR-safe: checks window before accessing, never throws. +// +// Unminified version: +// (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) {} +// })(debugId, variableName); +export const getSnippet = (uuid: string): string => { + return `(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(uuid)},${JSON.stringify(DD_DEBUG_IDS_VARIABLE)});`; +}; + +// Recovers the debug_id from a snippet-injected source. +const DEBUG_ID_RX = /([0-9a-f-]{36})["'],["']DD_DEBUG_IDS/; + +export const getDebugIdFromSource = (source: string): string | undefined => { + const match = source.match(DEBUG_ID_RX); + return match ? match[1] : undefined; +}; diff --git a/packages/plugins/error-tracking/src/debug-id/xpack.ts b/packages/plugins/error-tracking/src/debug-id/xpack.ts new file mode 100644 index 000000000..7ffe93b99 --- /dev/null +++ b/packages/plugins/error-tracking/src/debug-id/xpack.ts @@ -0,0 +1,48 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { GlobalContext, Logger, PluginOptions } from '@dd/core/types'; +import path from 'path'; + +import { PLUGIN_NAME, SUPPORTED_EXTENSIONS } from './constants'; +import { getSnippet, stringToUUID } from './utils'; + +export const getDebugIdXpackPlugin = + ( + bundler: any, + log: Logger, + context: GlobalContext, + debugIds: Map, + ): PluginOptions['webpack'] & PluginOptions['rspack'] => + (compiler) => { + const ConcatSource = bundler.sources.ConcatSource; + + compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { + const stage = bundler.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS; + + compilation.hooks.processAssets.tap({ name: PLUGIN_NAME, stage }, () => { + for (const chunk of compilation.chunks) { + const contentHash = chunk.contentHash?.javascript; + if (!contentHash) { + log.warn('Chunk has no javascript contentHash — debug_id skipped.'); + continue; + } + const uuid = stringToUUID(contentHash); + + for (const file of chunk.files) { + if (!SUPPORTED_EXTENSIONS.has(path.extname(file))) { + continue; + } + const absolutePath = path.resolve(context.bundler.outDir, file); + debugIds.set(absolutePath, uuid); + + const snippet = getSnippet(uuid); + compilation.updateAsset(file, (old) => { + return new ConcatSource(snippet, '\n', old); + }); + } + } + }); + }); + }; diff --git a/packages/plugins/error-tracking/src/index.ts b/packages/plugins/error-tracking/src/index.ts index 12c51f3d7..3cb160177 100644 --- a/packages/plugins/error-tracking/src/index.ts +++ b/packages/plugins/error-tracking/src/index.ts @@ -7,6 +7,7 @@ import { shouldGetGitInfo } from '@dd/core/helpers/plugins'; import type { BuildReport, GetPlugins, RepositoryData } from '@dd/core/types'; import { PLUGIN_NAME } from './constants'; +import { getDebugIdPlugin } from './debug-id/index'; import { uploadSourcemaps } from './sourcemaps'; import type { ErrorTrackingOptions, ErrorTrackingOptionsWithSourcemaps } from './types'; import { validateOptions } from './validate'; @@ -14,11 +15,10 @@ import { validateOptions } from './validate'; export { CONFIG_KEY, PLUGIN_NAME } from './constants'; export type types = { - // Add the types you'd like to expose here. ErrorTrackingOptions: ErrorTrackingOptions; }; -export const getPlugins: GetPlugins = ({ options, context }) => { +export const getPlugins: GetPlugins = ({ bundler, options, context }) => { const log = context.getLogger(PLUGIN_NAME); const timeOptions = log.time('validate options'); const validatedOptions = validateOptions(options, log); @@ -29,6 +29,8 @@ export const getPlugins: GetPlugins = ({ options, context }) => { let buildReport: BuildReport | undefined; let sourcemapsHandled: boolean = false; + const debugIds = new Map(); + const handleSourcemaps = async () => { if (!validatedOptions.sourcemaps || sourcemapsHandled) { return; @@ -54,7 +56,7 @@ export const getPlugins: GetPlugins = ({ options, context }) => { totalTime.end(); }; - return [ + const plugins: ReturnType = [ { name: PLUGIN_NAME, enforce: 'post', @@ -73,13 +75,16 @@ export const getPlugins: GetPlugins = ({ options, context }) => { } }, async asyncTrueEnd() { - // If we're at the end and sourcemaps have not been handled yet, - // just do it. It can happen when git data isn't accessible for some reason. - // For insteance, when working from an unpushed repository. if (!sourcemapsHandled) { await handleSourcemaps(); } }, }, ]; + + if (validatedOptions.debugId) { + plugins.push(getDebugIdPlugin(bundler, log, context, debugIds)); + } + + return plugins; }; diff --git a/packages/plugins/error-tracking/src/types.ts b/packages/plugins/error-tracking/src/types.ts index 87e8f5e73..35b8be6fb 100644 --- a/packages/plugins/error-tracking/src/types.ts +++ b/packages/plugins/error-tracking/src/types.ts @@ -19,10 +19,12 @@ export type SourcemapsOptionsWithDefaults = Required; export type ErrorTrackingOptions = { enable?: boolean; + debugId?: boolean; sourcemaps?: SourcemapsOptions; }; export type ErrorTrackingOptionsWithDefaults = { + debugId?: boolean; sourcemaps?: SourcemapsOptionsWithDefaults; }; diff --git a/packages/published/esbuild-plugin/package.json b/packages/published/esbuild-plugin/package.json index 8212b6e88..817c1c49d 100644 --- a/packages/published/esbuild-plugin/package.json +++ b/packages/published/esbuild-plugin/package.json @@ -58,6 +58,7 @@ "glob": "11.1.0", "json-stream-stringify": "3.1.6", "jszip": "3.10.1", + "magic-string": "0.30.21", "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", diff --git a/packages/published/rollup-plugin/package.json b/packages/published/rollup-plugin/package.json index d75cb2de2..20c91db0a 100644 --- a/packages/published/rollup-plugin/package.json +++ b/packages/published/rollup-plugin/package.json @@ -61,6 +61,7 @@ "glob": "11.1.0", "json-stream-stringify": "3.1.6", "jszip": "3.10.1", + "magic-string": "0.30.21", "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", diff --git a/packages/published/rspack-plugin/package.json b/packages/published/rspack-plugin/package.json index b3d34d85f..79331161d 100644 --- a/packages/published/rspack-plugin/package.json +++ b/packages/published/rspack-plugin/package.json @@ -58,6 +58,7 @@ "glob": "11.1.0", "json-stream-stringify": "3.1.6", "jszip": "3.10.1", + "magic-string": "0.30.21", "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", diff --git a/packages/published/vite-plugin/package.json b/packages/published/vite-plugin/package.json index a4cac0c57..36cc9858e 100644 --- a/packages/published/vite-plugin/package.json +++ b/packages/published/vite-plugin/package.json @@ -58,6 +58,7 @@ "glob": "11.1.0", "json-stream-stringify": "3.1.6", "jszip": "3.10.1", + "magic-string": "0.30.21", "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", diff --git a/packages/published/webpack-plugin/package.json b/packages/published/webpack-plugin/package.json index 1501c56ef..562624dd4 100644 --- a/packages/published/webpack-plugin/package.json +++ b/packages/published/webpack-plugin/package.json @@ -58,6 +58,7 @@ "glob": "11.1.0", "json-stream-stringify": "3.1.6", "jszip": "3.10.1", + "magic-string": "0.30.21", "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", diff --git a/packages/tests/src/e2e/debugId/debugId.spec.ts b/packages/tests/src/e2e/debugId/debugId.spec.ts new file mode 100644 index 000000000..05ad9f476 --- /dev/null +++ b/packages/tests/src/e2e/debugId/debugId.spec.ts @@ -0,0 +1,124 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* eslint-env browser */ +/* global globalThis */ +import { verifyProjectBuild } from '@dd/tests/_playwright/helpers/buildProject'; +import type { TestOptions } from '@dd/tests/_playwright/testParams'; +import { test } from '@dd/tests/_playwright/testParams'; +import { defaultConfig } from '@dd/tools/plugins'; +import type { Page } from '@playwright/test'; +import path from 'path'; + +// Have a similar experience to Jest. +const { expect, beforeAll, describe } = test; + +const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +const userFlow = async (url: string, page: Page, bundler: TestOptions['bundler']) => { + // Navigate to our page. + await page.goto(`${url}/index.html?context_bundler=${bundler}`); + await page.waitForSelector('body'); +}; + +// Collect the debug_id values the injected snippet registered on the page. +const getDebugIds = async (page: Page): Promise => { + return page.evaluate(() => Object.values((globalThis as any)['DD_DEBUG_IDS'] || {})); +}; + +describe('Debug ID', () => { + // Build our fixture project with debug_id injection enabled. + beforeAll(async ({ publicDir, bundlers, suiteName }) => { + const source = path.resolve(__dirname, 'project'); + const destination = path.resolve(publicDir, suiteName); + await verifyProjectBuild( + source, + destination, + bundlers, + { + ...defaultConfig, + errorTracking: { + debugId: true, + }, + }, + { splitting: true }, + ); + }); + + test('Should register the entry debug_id on window.DD_DEBUG_IDS', async ({ + page, + bundler, + browserName, + suiteName, + devServerUrl, + }) => { + const errors: string[] = []; + const testBaseUrl = `${devServerUrl}/${suiteName}`; + + // Listen for errors on the page. + page.on('pageerror', (error) => errors.push(error.message)); + page.on('response', async (response) => { + if (!response.ok()) { + const url = response.request().url(); + const prefix = `[${bundler} ${browserName} ${response.status()}]`; + errors.push(`${prefix} ${url}`); + } + }); + + await userFlow(testBaseUrl, page, bundler); + + const debugIds = await getDebugIds(page); + + // The entry script registered at least one debug_id, and it is a valid UUID. + expect(debugIds.length).toBeGreaterThanOrEqual(1); + for (const debugId of debugIds) { + expect(debugId).toMatch(UUID_RX); + } + expect(errors).toEqual([]); + }); + + test('Should register a distinct debug_id for a dynamically loaded chunk', async ({ + page, + bundler, + suiteName, + devServerUrl, + }) => { + await userFlow(`${devServerUrl}/${suiteName}`, page, bundler); + + const before = await getDebugIds(page); + + // Loading a separate chunk evaluates its own injected snippet. + await page.click('#load_chunk'); + await page.waitForFunction(() => window.chunkLoaded === true); + + const after = await getDebugIds(page); + + // The chunk contributed at least one new, distinct debug_id (per emitted file). + expect(after.length).toBeGreaterThan(before.length); + const newDebugIds = after.filter((debugId) => !before.includes(debugId)); + expect(newDebugIds.length).toBeGreaterThanOrEqual(1); + for (const debugId of after) { + expect(debugId).toMatch(UUID_RX); + } + }); + + test('Should not throw errors', async ({ page, bundler, suiteName, devServerUrl }) => { + const errors: string[] = []; + const testBaseUrl = `${devServerUrl}/${suiteName}`; + + // Listen for errors on the page. + page.on('pageerror', (error) => errors.push(error.message)); + + // Mock error to confirm the snippet's try/catch swallows failures. + await page.addInitScript(() => { + (globalThis as any).Error = function () { + // eslint-disable-next-line no-throw-literal + throw 'Test error from debug id snippet'; + }; + }); + + await userFlow(testBaseUrl, page, bundler); + expect(errors).toEqual([]); + }); +}); diff --git a/packages/tests/src/e2e/debugId/project/chunk.js b/packages/tests/src/e2e/debugId/project/chunk.js new file mode 100644 index 000000000..5a8bbf80b --- /dev/null +++ b/packages/tests/src/e2e/debugId/project/chunk.js @@ -0,0 +1,8 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* eslint-env browser */ + +// Used by Playwright tests to ensure the chunk module has been evaluated. +window.chunkLoaded = true; diff --git a/packages/tests/src/e2e/debugId/project/index.html b/packages/tests/src/e2e/debugId/project/index.html new file mode 100644 index 000000000..335d862bb --- /dev/null +++ b/packages/tests/src/e2e/debugId/project/index.html @@ -0,0 +1,18 @@ + + + + + + + Debug ID Test + + + +

Debug ID Test - {{bundler}}

+

Testing debug_id injection.

+ + + + + + diff --git a/packages/tests/src/e2e/debugId/project/index.js b/packages/tests/src/e2e/debugId/project/index.js new file mode 100644 index 000000000..8641355e6 --- /dev/null +++ b/packages/tests/src/e2e/debugId/project/index.js @@ -0,0 +1,11 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* eslint-env browser */ + +const $ = document.querySelector.bind(document); + +$('#load_chunk').addEventListener('click', async () => { + await import('./chunk.js'); +}); diff --git a/yarn.lock b/yarn.lock index 495ca9fae..9f54f934f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1705,6 +1705,7 @@ __metadata: glob: "npm:11.1.0" json-stream-stringify: "npm:3.1.6" jszip: "npm:3.10.1" + magic-string: "npm:0.30.21" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" pretty-bytes: "npm:5.6.0" @@ -1765,6 +1766,7 @@ __metadata: glob: "npm:11.1.0" json-stream-stringify: "npm:3.1.6" jszip: "npm:3.10.1" + magic-string: "npm:0.30.21" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" pretty-bytes: "npm:5.6.0" @@ -1818,6 +1820,7 @@ __metadata: glob: "npm:11.1.0" json-stream-stringify: "npm:3.1.6" jszip: "npm:3.10.1" + magic-string: "npm:0.30.21" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" pretty-bytes: "npm:5.6.0" @@ -1871,6 +1874,7 @@ __metadata: glob: "npm:11.1.0" json-stream-stringify: "npm:3.1.6" jszip: "npm:3.10.1" + magic-string: "npm:0.30.21" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" pretty-bytes: "npm:5.6.0" @@ -1924,6 +1928,7 @@ __metadata: glob: "npm:11.1.0" json-stream-stringify: "npm:3.1.6" jszip: "npm:3.10.1" + magic-string: "npm:0.30.21" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" pretty-bytes: "npm:5.6.0" @@ -1997,6 +2002,7 @@ __metadata: dependencies: "@dd/core": "workspace:*" chalk: "npm:2.3.1" + magic-string: "npm:0.30.21" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" typescript: "npm:5.4.3" From c244d8d196d7eb5881dfc866366950c9ae75ea2e Mon Sep 17 00:00:00 2001 From: Aymeric Mortemousque Date: Thu, 11 Jun 2026 09:25:22 +0200 Subject: [PATCH 2/8] Source code context and debug Id in a single snippet. Extend injection plugin to handle debugid generation --- packages/core/src/types.ts | 12 +- packages/plugins/apps/src/identifier.test.ts | 2 +- packages/plugins/apps/src/identifier.ts | 5 +- .../error-tracking/src/debug-id/constants.ts | 10 -- .../error-tracking/src/debug-id/esbuild.ts | 49 ------ .../error-tracking/src/debug-id/index.test.ts | 162 ------------------ .../error-tracking/src/debug-id/index.ts | 29 ---- .../error-tracking/src/debug-id/rollup.ts | 33 ---- .../error-tracking/src/debug-id/utils.test.ts | 67 -------- .../error-tracking/src/debug-id/utils.ts | 51 ------ .../error-tracking/src/debug-id/xpack.ts | 48 ------ .../plugins/error-tracking/src/debugId.ts | 39 +++++ packages/plugins/error-tracking/src/index.ts | 15 +- packages/plugins/injection/package.json | 3 +- packages/plugins/injection/src/esbuild.ts | 68 ++++---- .../plugins/injection/src/helpers.test.ts | 76 ++++---- packages/plugins/injection/src/helpers.ts | 156 +++++++---------- packages/plugins/injection/src/index.test.ts | 11 +- packages/plugins/injection/src/index.ts | 22 +-- packages/plugins/injection/src/rollup.ts | 73 ++++---- packages/plugins/injection/src/types.ts | 9 +- packages/plugins/injection/src/xpack.ts | 58 +++---- .../plugins/live-debugger/src/index.test.ts | 6 +- packages/plugins/live-debugger/src/index.ts | 2 +- packages/plugins/rum/src/index.ts | 2 +- .../tests/src/e2e/debugId/debugId.spec.ts | 6 +- yarn.lock | 1 + 27 files changed, 288 insertions(+), 727 deletions(-) delete mode 100644 packages/plugins/error-tracking/src/debug-id/constants.ts delete mode 100644 packages/plugins/error-tracking/src/debug-id/esbuild.ts delete mode 100644 packages/plugins/error-tracking/src/debug-id/index.test.ts delete mode 100644 packages/plugins/error-tracking/src/debug-id/index.ts delete mode 100644 packages/plugins/error-tracking/src/debug-id/rollup.ts delete mode 100644 packages/plugins/error-tracking/src/debug-id/utils.test.ts delete mode 100644 packages/plugins/error-tracking/src/debug-id/utils.ts delete mode 100644 packages/plugins/error-tracking/src/debug-id/xpack.ts create mode 100644 packages/plugins/error-tracking/src/debugId.ts diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 356ee31f3..c2f20829c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -127,7 +127,15 @@ export type BundlerReport = GlobalData['bundler'] & { rawConfig?: any; }; -export type InjectedValue = string | (() => Promise); +export type ChunkInfo = { + sourceOrHash: string; + fileName: string; + isEntry: boolean; +}; + +// Static string, lazy async loader (e.g. file fetch), or per-chunk code generator. +export type InjectedValue = string | (() => Promise) | ((sourceOrHash?: string) => string); + export enum InjectPosition { BEFORE, MIDDLE, @@ -139,7 +147,7 @@ export type ToInjectItem = { value: InjectedValue; fallback?: ToInjectItem; } & ( - | { position?: InjectPosition.BEFORE | InjectPosition.AFTER; injectIntoAllChunks?: boolean } + | { position: InjectPosition.BEFORE | InjectPosition.AFTER; allChunks?: boolean } | { position: InjectPosition.MIDDLE } ); diff --git a/packages/plugins/apps/src/identifier.test.ts b/packages/plugins/apps/src/identifier.test.ts index c01f9917b..9286d7481 100644 --- a/packages/plugins/apps/src/identifier.test.ts +++ b/packages/plugins/apps/src/identifier.test.ts @@ -95,7 +95,7 @@ describe('Apps Plugin - identifier helpers', () => { describe('buildIdentifier', () => { test('Should hash the combination of repository and name when both exist', () => { const result = buildIdentifier('https://github.com/org/repo', 'my-app'); - // The identifier should be a 32-character MD5 hash + // The identifier should be a 32-character hex string (SHA-256 truncated to 128 bits) expect(result).toMatch(/^[a-f0-9]{32}$/); // Verify it's consistent expect(buildIdentifier('https://github.com/org/repo', 'my-app')).toBe(result); diff --git a/packages/plugins/apps/src/identifier.ts b/packages/plugins/apps/src/identifier.ts index 5fb90d66b..9b3a8308e 100644 --- a/packages/plugins/apps/src/identifier.ts +++ b/packages/plugins/apps/src/identifier.ts @@ -76,9 +76,8 @@ export const buildIdentifier = (repository?: string, name?: string): string | un } const plainIdentifier = `${repository}:${name}`; - // Use MD5 hash (128 bits, 32 hex characters) for a compact identifier - // MD5 is sufficient for non-cryptographic purposes like creating unique identifiers - return createHash('md5').update(plainIdentifier).digest('hex'); + // SHA-256 truncated to 128 bits (32 hex characters) for a compact identifier. + return createHash('sha256').update(plainIdentifier).digest('hex').slice(0, 32); }; export const resolveIdentifier = ( diff --git a/packages/plugins/error-tracking/src/debug-id/constants.ts b/packages/plugins/error-tracking/src/debug-id/constants.ts deleted file mode 100644 index 8233603ca..000000000 --- a/packages/plugins/error-tracking/src/debug-id/constants.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import type { PluginName } from '@dd/core/types'; - -export const PLUGIN_NAME: PluginName = 'datadog-error-tracking-debug-id-plugin' as const; - -// JS output extensions we inject the debug_id into. -export const SUPPORTED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']); diff --git a/packages/plugins/error-tracking/src/debug-id/esbuild.ts b/packages/plugins/error-tracking/src/debug-id/esbuild.ts deleted file mode 100644 index e1ba74437..000000000 --- a/packages/plugins/error-tracking/src/debug-id/esbuild.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import { getAbsolutePath } from '@dd/core/helpers/paths'; -import type { GlobalContext, Logger, PluginOptions } from '@dd/core/types'; -import fsp from 'fs/promises'; -import path from 'path'; - -import { SUPPORTED_EXTENSIONS } from './constants'; -import { getSnippet, stringToUUID } from './utils'; - -export const getDebugIdEsbuildPlugin = ( - log: Logger, - context: GlobalContext, - debugIds: Map, -): PluginOptions['esbuild'] => ({ - setup(build) { - build.initialOptions.metafile = true; - - build.onEnd(async (result) => { - if (!result.metafile) { - log.warn('Missing metafile — debug_id injection skipped.'); - return; - } - - const proms: Promise[] = []; - - for (const [p] of Object.entries(result.metafile.outputs)) { - const absolutePath = getAbsolutePath(context.buildRoot, p); - - if (!SUPPORTED_EXTENSIONS.has(path.extname(absolutePath))) { - continue; - } - - proms.push( - (async () => { - const source = await fsp.readFile(absolutePath, 'utf-8'); - const uuid = stringToUUID(source); - debugIds.set(absolutePath, uuid); - await fsp.writeFile(absolutePath, `${getSnippet(uuid)}\n${source}`); - })(), - ); - } - - await Promise.all(proms); - }); - }, -}); diff --git a/packages/plugins/error-tracking/src/debug-id/index.test.ts b/packages/plugins/error-tracking/src/debug-id/index.test.ts deleted file mode 100644 index 4bebc76c0..000000000 --- a/packages/plugins/error-tracking/src/debug-id/index.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import { datadogEsbuildPlugin } from '@datadog/esbuild-plugin'; -import { datadogRollupPlugin } from '@datadog/rollup-plugin'; -import { datadogRspackPlugin } from '@datadog/rspack-plugin'; -import { datadogVitePlugin } from '@datadog/vite-plugin'; -import { datadogWebpackPlugin } from '@datadog/webpack-plugin'; -import { rm } from '@dd/core/helpers/fs'; -import { getUniqueId } from '@dd/core/helpers/strings'; -import { prepareWorkingDir } from '@dd/tests/_jest/helpers/env'; -import { easyProjectEntry, defaultPluginOptions } from '@dd/tests/_jest/helpers/mocks'; -import { - buildWithEsbuild, - buildWithRollup, - buildWithVite, - buildWithWebpack, - buildWithRspack, -} from '@dd/tools/bundlers'; -import fsp from 'fs/promises'; -import path from 'path'; - -const UUID_RX = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/; -// The snippet passes the debug_id as a quoted UUID argument to its IIFE. -// Match any quoted UUID-v4 string (quote style can change under minification). -const SNIPPET_DEBUG_ID_RX = - /["']([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})["']/; - -// Verify the runtime snippet was prepended and embeds a valid debug_id. -const expectDebugIdSnippet = (jsContent: string) => { - expect(jsContent).toContain('DD_DEBUG_IDS'); - const match = jsContent.match(SNIPPET_DEBUG_ID_RX); - expect(match).not.toBeNull(); - expect(match![1]).toMatch(UUID_RX); -}; - -describe('Debug ID Injection', () => { - const seed = `${Math.abs(jest.getSeed())}.${getUniqueId()}`; - let workingDir: string; - - beforeAll(async () => { - workingDir = await prepareWorkingDir(seed); - }); - - afterAll(async () => { - if (!process.env.NO_CLEANUP) { - await rm(workingDir); - } - }); - - test('esbuild: prepends the DD_DEBUG_IDS snippet with a valid debug_id', async () => { - const outDir = path.resolve(workingDir, 'dist-debug-id-esbuild'); - const { errors } = await buildWithEsbuild({ - absWorkingDir: workingDir, - bundle: true, - entryPoints: { main: path.resolve(workingDir, easyProjectEntry) }, - outdir: outDir, - sourcemap: true, - plugins: [ - datadogEsbuildPlugin({ - ...defaultPluginOptions, - errorTracking: { debugId: true }, - }), - ], - }); - - expect(errors).toEqual([]); - - const jsContent = await fsp.readFile(path.resolve(outDir, 'main.js'), 'utf-8'); - expectDebugIdSnippet(jsContent); - }); - - test('rollup: prepends the DD_DEBUG_IDS snippet with a valid debug_id', async () => { - const outDir = path.resolve(workingDir, 'dist-debug-id-rollup'); - const { errors } = await buildWithRollup({ - input: { main: path.resolve(workingDir, easyProjectEntry) }, - output: { dir: outDir, sourcemap: true }, - plugins: [ - datadogRollupPlugin({ - ...defaultPluginOptions, - errorTracking: { debugId: true }, - }), - ], - }); - - expect(errors).toEqual([]); - - const jsContent = await fsp.readFile(path.resolve(outDir, 'main.js'), 'utf-8'); - expectDebugIdSnippet(jsContent); - }); - - test('vite: prepends the DD_DEBUG_IDS snippet with a valid debug_id', async () => { - const outDir = path.resolve(workingDir, 'dist-debug-id-vite'); - const { errors } = await buildWithVite({ - root: workingDir, - build: { - outDir, - sourcemap: true, - rollupOptions: { - input: { main: path.resolve(workingDir, easyProjectEntry) }, - output: { entryFileNames: 'assets/[name].js' }, - }, - }, - plugins: [ - datadogVitePlugin({ - ...defaultPluginOptions, - errorTracking: { debugId: true }, - }), - ], - }); - - expect(errors).toEqual([]); - - const jsContent = await fsp.readFile(path.resolve(outDir, 'assets/main.js'), 'utf-8'); - expectDebugIdSnippet(jsContent); - }); - - test('webpack: prepends the DD_DEBUG_IDS snippet with a valid debug_id', async () => { - const outDir = path.resolve(workingDir, 'dist-debug-id-webpack'); - const { errors } = await buildWithWebpack({ - context: workingDir, - mode: 'development', - devtool: 'source-map', - entry: { main: path.resolve(workingDir, easyProjectEntry) }, - output: { path: outDir }, - plugins: [ - datadogWebpackPlugin({ - ...defaultPluginOptions, - errorTracking: { debugId: true }, - }), - ], - }); - - expect(errors).toEqual([]); - - const jsContent = await fsp.readFile(path.resolve(outDir, 'main.js'), 'utf-8'); - expectDebugIdSnippet(jsContent); - }); - - test('rspack: prepends the DD_DEBUG_IDS snippet with a valid debug_id', async () => { - const outDir = path.resolve(workingDir, 'dist-debug-id-rspack'); - const { errors } = await buildWithRspack({ - context: workingDir, - mode: 'development', - devtool: 'source-map', - entry: { main: path.resolve(workingDir, easyProjectEntry) }, - output: { path: outDir }, - plugins: [ - datadogRspackPlugin({ - ...defaultPluginOptions, - errorTracking: { debugId: true }, - }), - ], - }); - - expect(errors).toEqual([]); - - const jsContent = await fsp.readFile(path.resolve(outDir, 'main.js'), 'utf-8'); - expectDebugIdSnippet(jsContent); - }); -}); diff --git a/packages/plugins/error-tracking/src/debug-id/index.ts b/packages/plugins/error-tracking/src/debug-id/index.ts deleted file mode 100644 index 725d6a002..000000000 --- a/packages/plugins/error-tracking/src/debug-id/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import type { GlobalContext, Logger, PluginOptions } from '@dd/core/types'; - -import { PLUGIN_NAME } from './constants'; -import { getDebugIdEsbuildPlugin } from './esbuild'; -import { getDebugIdRollupPlugin } from './rollup'; -import { getDebugIdXpackPlugin } from './xpack'; - -export const getDebugIdPlugin = ( - bundler: any, - log: Logger, - context: GlobalContext, - debugIds: Map, -): PluginOptions => { - // rollup and vite both consume rollup's generateBundle hook. - const rollupPlugin = getDebugIdRollupPlugin(context, debugIds); - return { - name: PLUGIN_NAME, - enforce: 'post', - esbuild: getDebugIdEsbuildPlugin(log, context, debugIds), - rollup: rollupPlugin, - vite: rollupPlugin, - webpack: getDebugIdXpackPlugin(bundler, log, context, debugIds), - rspack: getDebugIdXpackPlugin(bundler, log, context, debugIds), - }; -}; diff --git a/packages/plugins/error-tracking/src/debug-id/rollup.ts b/packages/plugins/error-tracking/src/debug-id/rollup.ts deleted file mode 100644 index 65a5d0b5c..000000000 --- a/packages/plugins/error-tracking/src/debug-id/rollup.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import type { GlobalContext, PluginOptions } from '@dd/core/types'; -import MagicString from 'magic-string'; -import path from 'path'; -import type { RenderedChunk } from 'rollup'; - -import { SUPPORTED_EXTENSIONS } from './constants'; -import { getSnippet, stringToUUID } from './utils'; - -// Shared by rollup and vite: both consume rollup's `renderChunk` hook. -// We hash the chunk's code (content-based, deterministic) so the debug_id -// changes whenever the output changes, even when the filename has no hash. -export const getDebugIdRollupPlugin = ( - context: GlobalContext, - debugIds: Map, -): PluginOptions['rollup'] => ({ - renderChunk(code, chunk: RenderedChunk) { - if (!SUPPORTED_EXTENSIONS.has(path.extname(chunk.fileName))) { - return null; - } - const uuid = stringToUUID(code); - const absolutePath = path.resolve(context.bundler.outDir, chunk.fileName); - debugIds.set(absolutePath, uuid); - - const s = new MagicString(code); - s.prepend(`${getSnippet(uuid)}\n`); - - return { code: s.toString(), map: s.generateMap({ file: chunk.fileName, hires: true }) }; - }, -}); diff --git a/packages/plugins/error-tracking/src/debug-id/utils.test.ts b/packages/plugins/error-tracking/src/debug-id/utils.test.ts deleted file mode 100644 index d53587d98..000000000 --- a/packages/plugins/error-tracking/src/debug-id/utils.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import { stringToUUID, getSnippet, getDebugIdFromSource } from './utils'; - -const UUID_RX = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/; - -describe('stringToUUID', () => { - test('should produce a valid UUID v4 format', () => { - expect(stringToUUID('hello')).toMatch(UUID_RX); - }); - - test('should be deterministic for the same input', () => { - expect(stringToUUID('hello')).toBe(stringToUUID('hello')); - }); - - test('should differ for different inputs', () => { - expect(stringToUUID('hello')).not.toBe(stringToUUID('world')); - }); -}); - -describe('getSnippet', () => { - const uuid = '12345678-1234-4234-8234-123456789abc'; - - test('should include the UUID in the output', () => { - expect(getSnippet(uuid)).toContain(uuid); - }); - - test('should assign to DD_DEBUG_IDS global', () => { - expect(getSnippet(uuid)).toContain('DD_DEBUG_IDS'); - }); - - test('should be a single line (no newlines)', () => { - expect(getSnippet(uuid)).not.toContain('\n'); - }); - - test('should pass the UUID and variable name as IIFE arguments', () => { - expect(getSnippet(uuid)).toContain(`})("${uuid}","DD_DEBUG_IDS")`); - }); - - test('should guard on window before accessing it', () => { - expect(getSnippet(uuid)).toContain("typeof window==='undefined'"); - }); -}); - -describe('getDebugIdFromSource', () => { - const uuid = '12345678-1234-4234-8234-123456789abc'; - - test('should round-trip the UUID injected by getSnippet', () => { - const source = `${getSnippet(uuid)}\nconsole.log('app');`; - expect(getDebugIdFromSource(source)).toBe(uuid); - }); - - test('should recover the UUID from a minified single-quote variant', () => { - const minified = `})('${uuid}','DD_DEBUG_IDS')`; - expect(getDebugIdFromSource(minified)).toBe(uuid); - }); - - test('should return undefined when no UUID is present', () => { - expect(getDebugIdFromSource('console.log("no debug id here");')).toBeUndefined(); - }); - - test('should not match a stray UUID unrelated to DD_DEBUG_IDS', () => { - expect(getDebugIdFromSource(`var id="${uuid}";`)).toBeUndefined(); - }); -}); diff --git a/packages/plugins/error-tracking/src/debug-id/utils.ts b/packages/plugins/error-tracking/src/debug-id/utils.ts deleted file mode 100644 index 2ea4f7c6e..000000000 --- a/packages/plugins/error-tracking/src/debug-id/utils.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import { createHash } from 'crypto'; - -const VARIANT_CHARS = ['8', '9', 'a', 'b'] as const; - -// MD5(input) → deterministic UUID-v4-shaped identifier. -export const stringToUUID = (input: string): string => { - const md5 = createHash('md5').update(input).digest('hex'); - const withVersion = `${md5.slice(0, 12)}4${md5.slice(13)}`; - const variantIndex = withVersion.charCodeAt(16) % 4; - const withVariant = `${withVersion.slice(0, 16)}${VARIANT_CHARS[variantIndex]}${withVersion.slice(17)}`; - return [ - withVariant.slice(0, 8), - withVariant.slice(8, 12), - withVariant.slice(12, 16), - withVariant.slice(16, 20), - withVariant.slice(20, 32), - ].join('-'); -}; - -export const DD_DEBUG_IDS_VARIABLE = 'DD_DEBUG_IDS' as const; - -// Runtime snippet, prepended to each emitted JS file. It registers the debug_id -// keyed by the script's own `new Error().stack`, so the SDK can collect it at -// runtime and the upload step can recover it by parsing the snippet. -// SSR-safe: checks window before accessing, never throws. -// -// Unminified version: -// (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) {} -// })(debugId, variableName); -export const getSnippet = (uuid: string): string => { - return `(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(uuid)},${JSON.stringify(DD_DEBUG_IDS_VARIABLE)});`; -}; - -// Recovers the debug_id from a snippet-injected source. -const DEBUG_ID_RX = /([0-9a-f-]{36})["'],["']DD_DEBUG_IDS/; - -export const getDebugIdFromSource = (source: string): string | undefined => { - const match = source.match(DEBUG_ID_RX); - return match ? match[1] : undefined; -}; diff --git a/packages/plugins/error-tracking/src/debug-id/xpack.ts b/packages/plugins/error-tracking/src/debug-id/xpack.ts deleted file mode 100644 index 7ffe93b99..000000000 --- a/packages/plugins/error-tracking/src/debug-id/xpack.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import type { GlobalContext, Logger, PluginOptions } from '@dd/core/types'; -import path from 'path'; - -import { PLUGIN_NAME, SUPPORTED_EXTENSIONS } from './constants'; -import { getSnippet, stringToUUID } from './utils'; - -export const getDebugIdXpackPlugin = - ( - bundler: any, - log: Logger, - context: GlobalContext, - debugIds: Map, - ): PluginOptions['webpack'] & PluginOptions['rspack'] => - (compiler) => { - const ConcatSource = bundler.sources.ConcatSource; - - compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { - const stage = bundler.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS; - - compilation.hooks.processAssets.tap({ name: PLUGIN_NAME, stage }, () => { - for (const chunk of compilation.chunks) { - const contentHash = chunk.contentHash?.javascript; - if (!contentHash) { - log.warn('Chunk has no javascript contentHash — debug_id skipped.'); - continue; - } - const uuid = stringToUUID(contentHash); - - for (const file of chunk.files) { - if (!SUPPORTED_EXTENSIONS.has(path.extname(file))) { - continue; - } - const absolutePath = path.resolve(context.bundler.outDir, file); - debugIds.set(absolutePath, uuid); - - const snippet = getSnippet(uuid); - compilation.updateAsset(file, (old) => { - return new ConcatSource(snippet, '\n', old); - }); - } - } - }); - }); - }; diff --git a/packages/plugins/error-tracking/src/debugId.ts b/packages/plugins/error-tracking/src/debugId.ts new file mode 100644 index 000000000..a4777290f --- /dev/null +++ b/packages/plugins/error-tracking/src/debugId.ts @@ -0,0 +1,39 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { createHash, randomUUID } from 'crypto'; + +export const SUPPORTED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']); +export const DD_DEBUG_IDS = 'DD_DEBUG_IDS' as const; + +const VARIANT_CHARS = ['8', '9', 'a', 'b'] as const; + +export const getDebugIdSnippet = (codeOrHash?: string): string => { + const debugId = codeOrHash ? stringToUUID(codeOrHash) : randomUUID(); + return `(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(debugId)},${JSON.stringify(DD_DEBUG_IDS)});`; +}; + +// SHA-256(input) truncated to 128 bits → deterministic UUID-v4-shaped identifier. +// SHA-256 is used instead of MD5 for FIPS 140-2/3 compliance. +export const stringToUUID = (input: string): string => { + const hash = createHash('sha256').update(input).digest('hex').slice(0, 32); + const withVersion = `${hash.slice(0, 12)}4${hash.slice(13)}`; + const variantIndex = withVersion.charCodeAt(16) % 4; + const withVariant = `${withVersion.slice(0, 16)}${VARIANT_CHARS[variantIndex]}${withVersion.slice(17)}`; + return [ + withVariant.slice(0, 8), + withVariant.slice(8, 12), + withVariant.slice(12, 16), + withVariant.slice(16, 20), + withVariant.slice(20, 32), + ].join('-'); +}; + +// The snippet ends with (debugId, DD_DEBUG_IDS) — capture the UUID before the known marker. +const DEBUG_ID_RX = new RegExp(`"([^"]+)",${JSON.stringify(DD_DEBUG_IDS)}`); + +export const getDebugIdFromSource = (source: string): string | undefined => { + const match = source.match(DEBUG_ID_RX); + return match ? match[1] : undefined; +}; diff --git a/packages/plugins/error-tracking/src/index.ts b/packages/plugins/error-tracking/src/index.ts index 3cb160177..94e0c9e7e 100644 --- a/packages/plugins/error-tracking/src/index.ts +++ b/packages/plugins/error-tracking/src/index.ts @@ -5,9 +5,10 @@ import { resolveEnable } from '@dd/core/helpers/options'; import { shouldGetGitInfo } from '@dd/core/helpers/plugins'; import type { BuildReport, GetPlugins, RepositoryData } from '@dd/core/types'; +import { InjectPosition } from '@dd/core/types'; import { PLUGIN_NAME } from './constants'; -import { getDebugIdPlugin } from './debug-id/index'; +import { getDebugIdSnippet } from './debugId'; import { uploadSourcemaps } from './sourcemaps'; import type { ErrorTrackingOptions, ErrorTrackingOptionsWithSourcemaps } from './types'; import { validateOptions } from './validate'; @@ -29,8 +30,6 @@ export const getPlugins: GetPlugins = ({ bundler, options, context }) => { let buildReport: BuildReport | undefined; let sourcemapsHandled: boolean = false; - const debugIds = new Map(); - const handleSourcemaps = async () => { if (!validatedOptions.sourcemaps || sourcemapsHandled) { return; @@ -75,6 +74,9 @@ export const getPlugins: GetPlugins = ({ bundler, options, context }) => { } }, async asyncTrueEnd() { + // If we're at the end and sourcemaps have not been handled yet, + // just do it. It can happen when git data isn't accessible for some reason. + // For insteance, when working from an unpushed repository. if (!sourcemapsHandled) { await handleSourcemaps(); } @@ -83,7 +85,12 @@ export const getPlugins: GetPlugins = ({ bundler, options, context }) => { ]; if (validatedOptions.debugId) { - plugins.push(getDebugIdPlugin(bundler, log, context, debugIds)); + context.inject({ + type: 'code', + position: InjectPosition.BEFORE, + allChunks: true, + value: (sourceOrHash) => getDebugIdSnippet(sourceOrHash), + }); } return plugins; diff --git a/packages/plugins/injection/package.json b/packages/plugins/injection/package.json index 7f3ac3946..7edee65db 100644 --- a/packages/plugins/injection/package.json +++ b/packages/plugins/injection/package.json @@ -21,7 +21,8 @@ "dependencies": { "@dd/core": "workspace:*", "@dd/internal-build-report-plugin": "workspace:*", - "chalk": "2.3.1" + "chalk": "2.3.1", + "magic-string": "0.30.21" }, "devDependencies": { "typescript": "5.4.3" diff --git a/packages/plugins/injection/src/esbuild.ts b/packages/plugins/injection/src/esbuild.ts index 8c59b38a9..a39bb3e9c 100644 --- a/packages/plugins/injection/src/esbuild.ts +++ b/packages/plugins/injection/src/esbuild.ts @@ -77,9 +77,7 @@ export const getEsbuildPlugin = ( namespace: PLUGIN_NAME, }, async () => { - const content = getContentToInject(contentsToInject, { - position: InjectPosition.MIDDLE, - }); + const content = getContentToInject(contentsToInject, InjectPosition.MIDDLE); return { // We can't use an empty string otherwise esbuild will crash. @@ -98,28 +96,13 @@ export const getEsbuildPlugin = ( return; } - const bannerForEntries = getContentToInject(contentsToInject, { - position: InjectPosition.BEFORE, - }); - const footerForEntries = getContentToInject(contentsToInject, { - position: InjectPosition.AFTER, - }); - const bannerForAllChunks = getContentToInject(contentsToInject, { - position: InjectPosition.BEFORE, - onAllChunks: true, - }); - const footerForAllChunks = getContentToInject(contentsToInject, { - position: InjectPosition.AFTER, - onAllChunks: true, - }); - - if ( - !bannerForEntries && - !footerForEntries && - !bannerForAllChunks && - !footerForAllChunks - ) { - // Nothing to inject. + // Nothing registered to prepend/append. + const hasBeforeOrAfter = contentsToInject.some( + (content) => + content.position === InjectPosition.BEFORE || + content.position === InjectPosition.AFTER, + ); + if (!hasBeforeOrAfter) { return; } @@ -128,17 +111,9 @@ export const getEsbuildPlugin = ( // Process all output files for (const [p, o] of Object.entries(result.metafile.outputs)) { // Determine if this is an entry point - const isEntry = - o.entryPoint && entries.some((e) => e.resolved.endsWith(o.entryPoint!)); - - // Get the appropriate banner and footer - const banner = isEntry ? bannerForEntries : bannerForAllChunks; - const footer = isEntry ? footerForEntries : footerForAllChunks; - - // Skip if nothing to inject for this chunk type - if (!banner && !footer) { - continue; - } + const isEntry = Boolean( + o.entryPoint && entries.some((e) => e.resolved.endsWith(o.entryPoint!)), + ); const absolutePath = getAbsolutePath(context.buildRoot, p); const { base, ext } = path.parse(absolutePath); @@ -153,8 +128,25 @@ export const getEsbuildPlugin = ( proms.push( (async () => { try { - const source = await fsp.readFile(absolutePath, 'utf-8'); - const data = await esbuild.transform(source, { + const sourceOrHash = await fsp.readFile(absolutePath, 'utf-8'); + const fileName = path.basename(absolutePath); + // Resolve static and per-chunk content in one pass. + const banner = getContentToInject( + contentsToInject, + InjectPosition.BEFORE, + { sourceOrHash, fileName, isEntry }, + ); + const footer = getContentToInject( + contentsToInject, + InjectPosition.AFTER, + { sourceOrHash, fileName, isEntry }, + ); + + if (!banner && !footer) { + return; + } + + const data = await esbuild.transform(sourceOrHash, { loader: 'default', banner, footer, diff --git a/packages/plugins/injection/src/helpers.test.ts b/packages/plugins/injection/src/helpers.test.ts index 7d96ca53f..45433e8e8 100644 --- a/packages/plugins/injection/src/helpers.test.ts +++ b/packages/plugins/injection/src/helpers.test.ts @@ -4,11 +4,10 @@ import { InjectPosition, type ToInjectItem } from '@dd/core/types'; import { - processInjections, - processItem, - processLocalFile, + prepareInjections, processDistantFile, - getInjectedValue, + processLocalFile, + resolveWithFallback, } from '@dd/internal-injection-plugin/helpers'; import { addFixtureFiles, mockLogger } from '@dd/tests/_jest/helpers/mocks'; import nock from 'nock'; @@ -26,19 +25,26 @@ const localFileContent = 'local file content'; const distantFileContent = 'distant file content'; const codeContent = 'code content'; -const code: ToInjectItem = { type: 'code', value: codeContent }; -const existingFile: ToInjectItem = { type: 'file', value: 'fixtures/local-file.js' }; +const code: ToInjectItem = { type: 'code', value: codeContent, position: InjectPosition.BEFORE }; +const existingFile: ToInjectItem = { + type: 'file', + value: 'fixtures/local-file.js', + position: InjectPosition.BEFORE, +}; const nonExistingFile: ToInjectItem = { type: 'file', value: 'fixtures/non-existing-file.js', + position: InjectPosition.BEFORE, }; const existingDistantFile: ToInjectItem = { type: 'file', value: 'https://example.com/distant-file.js', + position: InjectPosition.BEFORE, }; const nonExistingDistantFile: ToInjectItem = { type: 'file', value: 'https://example.com/non-existing-distant-file.js', + position: InjectPosition.BEFORE, }; describe('Injection Plugin Helpers', () => { @@ -52,55 +58,37 @@ describe('Injection Plugin Helpers', () => { // Add some fixtures. addFixtureFiles( { - [await getInjectedValue(existingFile)]: localFileContent, + [existingFile.value as string]: localFileContent, }, process.cwd(), ); }); - describe('processInjections', () => { + describe('prepareInjections', () => { test('Should process injections without throwing.', async () => { - const items: Map = new Map([ - ['code', code], - ['existingFile', existingFile], - ['nonExistingFile', nonExistingFile], - ['existingDistantFile', existingDistantFile], - ['nonExistingDistantFile', nonExistingDistantFile], - ]); + const items: ToInjectItem[] = [ + code, + existingFile, + nonExistingFile, + existingDistantFile, + nonExistingDistantFile, + ]; - const results = await processInjections(items, mockLogger); - expect(Array.from(results.entries())).toEqual([ - [ - 'code', - { - position: InjectPosition.BEFORE, - value: codeContent, - injectIntoAllChunks: false, - }, - ], - [ - 'existingFile', - { - position: InjectPosition.BEFORE, - value: localFileContent, - injectIntoAllChunks: false, - }, - ], - [ - 'existingDistantFile', - { - position: InjectPosition.BEFORE, - value: distantFileContent, - injectIntoAllChunks: false, - }, - ], + const contentsToInject: ReturnType> = []; + await prepareInjections(mockLogger, items, contentsToInject); + expect(contentsToInject).toEqual([ + { type: 'code', position: InjectPosition.BEFORE, value: codeContent }, + { type: 'file', position: InjectPosition.BEFORE, value: localFileContent }, + { type: 'file', position: InjectPosition.BEFORE, value: '' }, + { type: 'file', position: InjectPosition.BEFORE, value: distantFileContent }, + { type: 'file', position: InjectPosition.BEFORE, value: '' }, ]); expect(nockScope.isDone()).toBe(true); }); }); - describe('processItem', () => { + describe('resolveWithFallback', () => { test.each<{ description: string; item: ToInjectItem; expectation?: string }>([ { description: 'basic code', @@ -145,7 +133,9 @@ describe('Injection Plugin Helpers', () => { }, ])('Should process $description without throwing.', async ({ item, expectation }) => { expect.assertions(1); - return expect(processItem(item, mockLogger)).resolves.toEqual(expectation); + return expect(resolveWithFallback(item, mockLogger)).resolves.toEqual( + expectation ?? '', + ); }); }); diff --git a/packages/plugins/injection/src/helpers.ts b/packages/plugins/injection/src/helpers.ts index 32a164ab2..b57abfc4e 100644 --- a/packages/plugins/injection/src/helpers.ts +++ b/packages/plugins/injection/src/helpers.ts @@ -6,8 +6,7 @@ import { readFile } from '@dd/core/helpers/fs'; import { getAbsolutePath } from '@dd/core/helpers/paths'; import { doRequest } from '@dd/core/helpers/request'; import { truncateString } from '@dd/core/helpers/strings'; -import type { Logger, ToInjectItem } from '@dd/core/types'; -import { InjectPosition } from '@dd/core/types'; +import type { InjectPosition, ChunkInfo, Logger, ToInjectItem } from '@dd/core/types'; import chalk from 'chalk'; import { @@ -22,14 +21,6 @@ const yellow = chalk.bold.yellow; const MAX_TIMEOUT_IN_MS = 5000; -export const getInjectedValue = async (item: ToInjectItem): Promise => { - if (typeof item.value === 'function') { - return item.value(); - } - - return item.value; -}; - export const processDistantFile = async ( url: string, timeout: number = MAX_TIMEOUT_IN_MS, @@ -62,105 +53,83 @@ export const processLocalFile = async ( return readFile(absolutePath); }; -export const processItem = async ( +export const getContentToInject = ( + contentToInject: ContentToInject[], + position: InjectPosition, + chunk?: ChunkInfo, +) => { + const filtered = contentToInject.filter((content) => { + return content.position === position && (!chunk || chunk.isEntry || content.allChunks); + }); + + // Resolve function-valued content against the current chunk, drop empties. + const values = filtered + .map((content) => (isFunction(content.value) ? content.value(chunk!) : content.value)) + .filter(Boolean); + + if (values.length === 0) { + return ''; + } + + const stringToInject = values + // Wrapping it in order to avoid variable name collisions. + .map((value) => `(() => {${value}})();`) + .join('\n\n'); + return `${BEFORE_INJECTION}\n${stringToInject}\n${AFTER_INJECTION}`; +}; + +export const getInjectedValue = async (item: ToInjectItem): Promise => { + if (isFunction(item.value)) { + return item.value(); + } + + return item.value; +}; + +export const resolveWithFallback = async ( item: ToInjectItem, log: Logger, cwd: string = process.cwd(), -): Promise => { - let result: string | undefined; +): Promise => { const value = await getInjectedValue(item); + try { if (item.type === 'file') { - if (value.match(DISTANT_FILE_RX)) { - result = await processDistantFile(value); - } else { - result = await processLocalFile(value, cwd); - } - } else if (item.type === 'code') { - // TODO: Confirm the code actually executes without errors. - result = value; - } else { - throw new Error(`Invalid item type "${item.type}", only accepts "code" or "file".`); + const filePath = value; + return await (filePath.match(DISTANT_FILE_RX) + ? processDistantFile(filePath) + : processLocalFile(filePath, cwd)); } + return isFunction(item.value) ? item.value() : item.value; } catch (error: any) { const itemId = `${item.type} - ${truncateString(value)}`; if (item.fallback) { - // In case of any error, we'll fallback to next item in queue. log.debug(`Fallback for "${itemId}": ${error.toString()}`); - result = await processItem(item.fallback, log, cwd); - } else { - // Or return an empty string. - log.warn(`Failed "${itemId}": ${error.toString()}`); + return resolveWithFallback(item.fallback, log, cwd); } - } - - return result; -}; - -export const processInjections = async ( - toInject: Map, - log: Logger, - cwd: string = process.cwd(), -): Promise< - Map -> => { - const toReturn = new Map(); - - // Processing sequentially all the items. - for (const [id, item] of toInject.entries()) { - // eslint-disable-next-line no-await-in-loop - const value = await processItem(item, log, cwd); - if (value) { - const position = item.position || InjectPosition.BEFORE; - toReturn.set(id, { - value, - injectIntoAllChunks: - 'injectIntoAllChunks' in item ? item.injectIntoAllChunks : false, - position, - }); - } - } - - return toReturn; -}; - -export const getContentToInject = ( - contentToInject: ContentToInject[], - options: { - position: InjectPosition; - onAllChunks?: boolean; - }, -) => { - const filtered = contentToInject.filter((content) => { - return ( - content.position === options.position && - (!options.onAllChunks || content.injectIntoAllChunks) - ); - }); - - if (filtered.length === 0) { + log.warn(`Failed "${itemId}": ${error.toString()}`); return ''; } - - const stringToInject = filtered - // Wrapping it in order to avoid variable name collisions. - .map((content) => `(() => {${content.value}})();`) - .join('\n\n'); - return `${BEFORE_INJECTION}\n${stringToInject}\n${AFTER_INJECTION}`; }; - -// Prepare and fetch the content to inject. -export const addInjections = async ( +export const prepareInjections = async ( log: Logger, - toInject: Map, + toInject: ToInjectItem[], contentsToInject: ContentsToInject, cwd: string = process.cwd(), ) => { - const results = await processInjections(toInject, log, cwd); - // Add processed content to the array - for (const value of results.values()) { - contentsToInject.push(value); - } + // Per-chunk functions are resolved lazily at chunk emit time. + const dynamicPerChunk = toInject.filter(isPerChunk); + + // Static items (strings and async loaders) are resolved once per build. + const staticInject = toInject.filter((item) => !isPerChunk(item)); + const resolvedStaticInject = await Promise.all( + staticInject.map(async (item) => ({ + ...item, + value: await resolveWithFallback(item, log, cwd), + })), + ); + + contentsToInject.push(...dynamicPerChunk, ...resolvedStaticInject); }; export interface NodeSystemError extends Error { @@ -178,3 +147,10 @@ export const isFileSupported = (ext: string): boolean => { export const warnUnsupportedFile = (log: Logger, ext: string, filename: string): void => { log.warn(`"${yellow(ext)}" files are not supported (${yellow(filename)}).`); }; + +const isPerChunk = ( + item: ToInjectItem, +): item is ToInjectItem & { value: (chunk: ChunkInfo) => string } => + typeof item.value === 'function' && item.value.length === 1; + +const isFunction = (value: any): value is Function => typeof value === 'function'; diff --git a/packages/plugins/injection/src/index.test.ts b/packages/plugins/injection/src/index.test.ts index 6104a5b34..8a8cf7871 100644 --- a/packages/plugins/injection/src/index.test.ts +++ b/packages/plugins/injection/src/index.test.ts @@ -9,7 +9,7 @@ import { getUniqueId } from '@dd/core/helpers/strings'; import type { Assign, BundlerName, Options, ToInjectItem } from '@dd/core/types'; import { InjectPosition } from '@dd/core/types'; import { AFTER_INJECTION, BEFORE_INJECTION } from '@dd/internal-injection-plugin/constants'; -import { addInjections, isFileSupported } from '@dd/internal-injection-plugin/helpers'; +import { prepareInjections, isFileSupported } from '@dd/internal-injection-plugin/helpers'; import { getOutDir, prepareWorkingDir } from '@dd/tests/_jest/helpers/env'; import { hardProjectEntries, @@ -80,11 +80,11 @@ jest.mock('@dd/internal-injection-plugin/helpers', () => { const original = jest.requireActual('@dd/internal-injection-plugin/helpers'); return { ...original, - addInjections: jest.fn(original.addInjections), + prepareInjections: jest.fn(original.prepareInjections), }; }); -const addInjectionsMock = jest.mocked(addInjections); +const prepareInjectionsMock = jest.mocked(prepareInjections); const getLog = (type: ContentType, position: Position) => { const positionString = `in ${position}`; @@ -118,6 +118,7 @@ describe('Injection Plugin', () => { const injectedItem: ToInjectItem = { type: 'code', value: getInjectedString(bundlerName), + position: InjectPosition.BEFORE, }; context.inject(injectedItem); return [ @@ -136,7 +137,7 @@ describe('Injection Plugin', () => { }); buildErrors.push(...errors); // Store the calls, because Jest resets mocks in beforeEach 🤷 - calls.push(...addInjectionsMock.mock.calls.flatMap((c) => Array.from(c[1].values()))); + calls.push(...prepareInjectionsMock.mock.calls.flatMap((c) => c[1])); }); test('Should not error on build', () => { @@ -321,7 +322,7 @@ describe('Injection Plugin', () => { type: injectType, value: injection.value, position: positionType as InjectPosition.BEFORE | InjectPosition.AFTER, - injectIntoAllChunks: true, + allChunks: true, }); } diff --git a/packages/plugins/injection/src/index.ts b/packages/plugins/injection/src/index.ts index 5298075a5..0e7e97e22 100644 --- a/packages/plugins/injection/src/index.ts +++ b/packages/plugins/injection/src/index.ts @@ -4,7 +4,6 @@ import { INJECTED_FILE_RX } from '@dd/core/constants'; import { isXpack } from '@dd/core/helpers/bundlers'; -import { getUniqueId } from '@dd/core/helpers/strings'; import { InjectPosition, type GetInternalPlugins, @@ -15,7 +14,7 @@ import { import { PLUGIN_NAME } from './constants'; import { getEsbuildPlugin } from './esbuild'; -import { addInjections, getContentToInject } from './helpers'; +import { prepareInjections, getContentToInject } from './helpers'; import { getRollupPlugin } from './rollup'; import type { ContentsToInject } from './types'; import { getXpackPlugin } from './xpack'; @@ -26,13 +25,13 @@ export const getInjectionPlugins: GetInternalPlugins = (arg: GetPluginsArg) => { const { bundler, context } = arg; const log = context.getLogger(PLUGIN_NAME); // Storage for all the injections. - const injections: Map = new Map(); + const injections: ToInjectItem[] = []; // Storage for all the positional contents we want to inject. const contentsToInject: ContentsToInject = []; context.inject = (item: ToInjectItem) => { - injections.set(getUniqueId(), item); + injections.push(item); }; const plugin: PluginOptions = { @@ -51,11 +50,10 @@ export const getInjectionPlugins: GetInternalPlugins = (arg: GetPluginsArg) => { transformIndexHtml: { order: 'pre', handler() { - // For Vite, we inject MIDDLE content by adding a script tag - // that references the virtual injected file - const middleContent = getContentToInject(contentsToInject, { - position: InjectPosition.MIDDLE, - }); + const middleContent = getContentToInject( + contentsToInject, + InjectPosition.MIDDLE, + ); if (middleContent) { // Return a tag descriptor instead of modifying HTML directly return [ @@ -84,9 +82,7 @@ export const getInjectionPlugins: GetInternalPlugins = (arg: GetPluginsArg) => { }, handler() { return { - code: getContentToInject(contentsToInject, { - position: InjectPosition.MIDDLE, - }), + code: getContentToInject(contentsToInject, InjectPosition.MIDDLE), }; }, }; @@ -97,7 +93,7 @@ export const getInjectionPlugins: GetInternalPlugins = (arg: GetPluginsArg) => { // Here for all the other non-xpack bundlers. plugin.buildStart = async () => { // Prepare the injections. - await addInjections(log, injections, contentsToInject, context.buildRoot); + await prepareInjections(log, injections, contentsToInject, context.buildRoot); }; } diff --git a/packages/plugins/injection/src/rollup.ts b/packages/plugins/injection/src/rollup.ts index 2e3878e9c..8663f82df 100644 --- a/packages/plugins/injection/src/rollup.ts +++ b/packages/plugins/injection/src/rollup.ts @@ -6,7 +6,9 @@ import { INJECTED_FILE } from '@dd/core/constants'; import { isInjectionFile } from '@dd/core/helpers/plugins'; import type { Logger, PluginOptions } from '@dd/core/types'; import { InjectPosition } from '@dd/core/types'; +import MagicString from 'magic-string'; import path from 'path'; +import type { RenderedChunk } from 'rollup'; import { getContentToInject, isFileSupported, warnUnsupportedFile } from './helpers'; import type { ContentsToInject } from './types'; @@ -20,24 +22,41 @@ export const getRollupPlugin = ( contentsToInject: ContentsToInject, ): PluginOptions['rollup'] => { return { - banner(chunk) { - const banner = getContentToInject(contentsToInject, { - position: InjectPosition.BEFORE, - onAllChunks: !chunk.isEntry, + renderChunk(code, chunk: RenderedChunk) { + const { base, ext } = path.parse(chunk.fileName); + if (!isFileSupported(ext)) { + warnUnsupportedFile(log, ext, base); + return null; + } + + const banner = getContentToInject(contentsToInject, InjectPosition.BEFORE, { + sourceOrHash: code, + fileName: chunk.fileName, + isEntry: chunk.isEntry, + }); + const footer = getContentToInject(contentsToInject, InjectPosition.AFTER, { + sourceOrHash: code, + fileName: chunk.fileName, + isEntry: chunk.isEntry, }); - if (banner === '' || !chunk.fileName) { - return ''; + if (!banner && !footer) { + return null; } - const { base, ext } = path.parse(chunk.fileName); - const isOutputSupported = isFileSupported(ext); - if (!isOutputSupported) { - warnUnsupportedFile(log, ext, base); - return ''; + const s = new MagicString(code); + + if (banner) { + s.prepend(`${banner}\n`); + } + if (footer) { + s.append(`\n${footer}`); } - return banner; + return { + code: s.toString(), + map: s.generateMap({ file: chunk.fileName, hires: true }), + }; }, async resolveId(source, importer, options) { if (isInjectionFile(source)) { @@ -45,12 +64,7 @@ export const getRollupPlugin = ( // "treeshake.moduleSideEffects: false" may prevent the injection from being included. return { id: source, moduleSideEffects: true }; } - if ( - options.isEntry && - getContentToInject(contentsToInject, { - position: InjectPosition.MIDDLE, - }) - ) { + if (options.isEntry && getContentToInject(contentsToInject, InjectPosition.MIDDLE)) { // Skip HTML entries - Vite handles these specially via transformIndexHtml // The proxy mechanism breaks Vite's HTML->module tracking if (source.endsWith('.html')) { @@ -86,9 +100,7 @@ export const getRollupPlugin = ( load(id) { if (isInjectionFile(id)) { // Replace with injection content. - return getContentToInject(contentsToInject, { - position: InjectPosition.MIDDLE, - }); + return getContentToInject(contentsToInject, InjectPosition.MIDDLE); } if (id.endsWith(TO_INJECT_SUFFIX)) { const entryId = id.slice(0, -TO_INJECT_SUFFIX.length); @@ -103,24 +115,5 @@ export const getRollupPlugin = ( } return null; }, - footer(chunk) { - const footer = getContentToInject(contentsToInject, { - position: InjectPosition.AFTER, - onAllChunks: !chunk.isEntry, - }); - - if (footer === '' || !chunk.fileName) { - return ''; - } - - const { base, ext } = path.parse(chunk.fileName); - const isOutputSupported = isFileSupported(ext); - if (!isOutputSupported) { - warnUnsupportedFile(log, ext, base); - return ''; - } - - return footer; - }, }; }; diff --git a/packages/plugins/injection/src/types.ts b/packages/plugins/injection/src/types.ts index 84d0d835f..88cd13ec3 100644 --- a/packages/plugins/injection/src/types.ts +++ b/packages/plugins/injection/src/types.ts @@ -2,12 +2,15 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { InjectPosition } from '@dd/core/types'; +import type { ChunkInfo, InjectPosition } from '@dd/core/types'; + +// A static string, an async resolver, or a function resolved per emitted chunk. +export type InjectValue = string | (() => Promise) | ((chunk: ChunkInfo) => string); export type ContentToInject = { - injectIntoAllChunks: boolean; + allChunks?: boolean; position: InjectPosition; - value: string; + value: InjectValue; }; export type ContentsToInject = Array; diff --git a/packages/plugins/injection/src/xpack.ts b/packages/plugins/injection/src/xpack.ts index ec7ed8a96..d7ee94cc7 100644 --- a/packages/plugins/injection/src/xpack.ts +++ b/packages/plugins/injection/src/xpack.ts @@ -9,7 +9,12 @@ import { InjectPosition } from '@dd/core/types'; import path from 'path'; import { PLUGIN_NAME } from './constants'; -import { getContentToInject, addInjections, isFileSupported, warnUnsupportedFile } from './helpers'; +import { + getContentToInject, + prepareInjections, + isFileSupported, + warnUnsupportedFile, +} from './helpers'; import type { ContentsToInject } from './types'; export const getXpackPlugin = @@ -17,7 +22,7 @@ export const getXpackPlugin = bundler: any, log: Logger, context: GlobalContext, - toInject: Map, + toInject: ToInjectItem[], contentsToInject: ContentsToInject, ): PluginOptions['rspack'] & PluginOptions['webpack'] => (compiler) => { @@ -100,7 +105,7 @@ export const getXpackPlugin = // Otherwise they'll be empty once resolved. const setupInjections = async () => { // Prepare the injections. - await addInjections(log, toInject, contentsToInject, context.buildRoot); + await prepareInjections(log, toInject, contentsToInject, context.buildRoot); }; // For one-time builds (production mode) @@ -115,33 +120,11 @@ export const getXpackPlugin = // with both banner and footer. compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { const hookCb = () => { - const bannerForEntries = getContentToInject(contentsToInject, { - position: InjectPosition.BEFORE, - }); - const footerForEntries = getContentToInject(contentsToInject, { - position: InjectPosition.AFTER, - }); - const bannerForAllChunks = getContentToInject(contentsToInject, { - position: InjectPosition.BEFORE, - onAllChunks: true, - }); - const footerForAllChunks = getContentToInject(contentsToInject, { - position: InjectPosition.AFTER, - onAllChunks: true, - }); - for (const chunk of compilation.chunks) { - let banner = bannerForEntries; - let footer = footerForEntries; - - if (!chunk.canBeInitial()) { - banner = bannerForAllChunks; - footer = footerForAllChunks; - } - - if (banner === '' && footer === '') { - continue; - } + const isEntry = chunk.canBeInitial(); + // Per-chunk content (e.g. context snippets) keys off the chunk + // content hash, the only chunk source available at this stage. + const chunkSource = chunk.contentHash?.javascript ?? chunk.hash; for (const file of chunk.files) { const { base, ext } = path.parse(file); @@ -151,6 +134,23 @@ export const getXpackPlugin = continue; } + const fileName = path.basename(file); + // Resolve static and per-chunk content in one pass. + const banner = getContentToInject(contentsToInject, InjectPosition.BEFORE, { + sourceOrHash: chunkSource, + fileName, + isEntry, + }); + const footer = getContentToInject(contentsToInject, InjectPosition.AFTER, { + sourceOrHash: chunkSource, + fileName, + isEntry, + }); + + if (banner === '' && footer === '') { + continue; + } + compilation.updateAsset(file, (old) => { const cached = cache.get(old); diff --git a/packages/plugins/live-debugger/src/index.test.ts b/packages/plugins/live-debugger/src/index.test.ts index 2bbd7f5fa..26599da92 100644 --- a/packages/plugins/live-debugger/src/index.test.ts +++ b/packages/plugins/live-debugger/src/index.test.ts @@ -418,7 +418,7 @@ describe('getPlugins', () => { expect(arg.context.inject).toHaveBeenCalledWith({ type: 'code', position: InjectPosition.BEFORE, - injectIntoAllChunks: true, + allChunks: true, value: getRuntimeBootstrap(), }); }); @@ -436,7 +436,7 @@ describe('getPlugins', () => { expect(arg.context.inject).toHaveBeenCalledWith({ type: 'code', position: InjectPosition.BEFORE, - injectIntoAllChunks: true, + allChunks: true, value: getRuntimeBootstrap('1.0.0'), }); }); @@ -453,7 +453,7 @@ describe('getPlugins', () => { expect(arg.context.inject).toHaveBeenCalledWith({ type: 'code', position: InjectPosition.BEFORE, - injectIntoAllChunks: true, + allChunks: true, value: getRuntimeBootstrap(), }); }); diff --git a/packages/plugins/live-debugger/src/index.ts b/packages/plugins/live-debugger/src/index.ts index 88f90a3e4..ae16bfa97 100644 --- a/packages/plugins/live-debugger/src/index.ts +++ b/packages/plugins/live-debugger/src/index.ts @@ -158,7 +158,7 @@ export const getPlugins: GetPlugins = ({ options, context }) => { context.inject({ type: 'code', position: InjectPosition.BEFORE, - injectIntoAllChunks: true, + allChunks: true, value: getRuntimeBootstrap(validatedOptions.version), }); diff --git a/packages/plugins/rum/src/index.ts b/packages/plugins/rum/src/index.ts index 249aadca7..819cec974 100644 --- a/packages/plugins/rum/src/index.ts +++ b/packages/plugins/rum/src/index.ts @@ -35,7 +35,7 @@ export const getPlugins: GetPlugins = ({ options, context }) => { context.inject({ type: 'code', position: InjectPosition.BEFORE, - injectIntoAllChunks: true, + allChunks: true, value: getSourceCodeContextSnippet(validatedOptions.sourceCodeContext), }); } diff --git a/packages/tests/src/e2e/debugId/debugId.spec.ts b/packages/tests/src/e2e/debugId/debugId.spec.ts index 05ad9f476..a4d5712cb 100644 --- a/packages/tests/src/e2e/debugId/debugId.spec.ts +++ b/packages/tests/src/e2e/debugId/debugId.spec.ts @@ -24,7 +24,11 @@ const userFlow = async (url: string, page: Page, bundler: TestOptions['bundler'] // Collect the debug_id values the injected snippet registered on the page. const getDebugIds = async (page: Page): Promise => { - return page.evaluate(() => Object.values((globalThis as any)['DD_DEBUG_IDS'] || {})); + return page.evaluate(() => + Object.values((globalThis as any)['DD_DEBUG_IDS'] || {}).filter( + (id: unknown): id is string => typeof id === 'string', + ), + ); }; describe('Debug ID', () => { diff --git a/yarn.lock b/yarn.lock index 9f54f934f..3aa160990 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2097,6 +2097,7 @@ __metadata: "@dd/core": "workspace:*" "@dd/internal-build-report-plugin": "workspace:*" chalk: "npm:2.3.1" + magic-string: "npm:0.30.21" typescript: "npm:5.4.3" languageName: unknown linkType: soft From cb32c25132f2eb326f124675f36ea9f9f427d17d Mon Sep 17 00:00:00 2001 From: Aymeric Mortemousque Date: Fri, 12 Jun 2026 11:07:19 +0200 Subject: [PATCH 3/8] simplify --- packages/plugins/injection/src/helpers.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/plugins/injection/src/helpers.ts b/packages/plugins/injection/src/helpers.ts index b57abfc4e..3888a81d4 100644 --- a/packages/plugins/injection/src/helpers.ts +++ b/packages/plugins/injection/src/helpers.ts @@ -78,20 +78,12 @@ export const getContentToInject = ( return `${BEFORE_INJECTION}\n${stringToInject}\n${AFTER_INJECTION}`; }; -export const getInjectedValue = async (item: ToInjectItem): Promise => { - if (isFunction(item.value)) { - return item.value(); - } - - return item.value; -}; - export const resolveWithFallback = async ( item: ToInjectItem, log: Logger, cwd: string = process.cwd(), ): Promise => { - const value = await getInjectedValue(item); + const value = isFunction(item.value) ? await item.value() : item.value; try { if (item.type === 'file') { @@ -100,7 +92,7 @@ export const resolveWithFallback = async ( ? processDistantFile(filePath) : processLocalFile(filePath, cwd)); } - return isFunction(item.value) ? item.value() : item.value; + return value; } catch (error: any) { const itemId = `${item.type} - ${truncateString(value)}`; if (item.fallback) { @@ -117,8 +109,11 @@ export const prepareInjections = async ( contentsToInject: ContentsToInject, cwd: string = process.cwd(), ) => { - // Per-chunk functions are resolved lazily at chunk emit time. - const dynamicPerChunk = toInject.filter(isPerChunk); + // Per-chunk functions: adapt from public API (sourceOrHash?: string) to internal (chunk: ChunkInfo). + const dynamicPerChunk = toInject.filter(isPerChunk).map((item) => { + const userFn = item.value as (sourceOrHash?: string) => string; + return { ...item, value: (chunk: ChunkInfo) => userFn(chunk.sourceOrHash) }; + }); // Static items (strings and async loaders) are resolved once per build. const staticInject = toInject.filter((item) => !isPerChunk(item)); From f00a73f94f4810e725143ee4241ce864a16424b8 Mon Sep 17 00:00:00 2001 From: Aymeric Mortemousque Date: Tue, 16 Jun 2026 17:06:39 +0200 Subject: [PATCH 4/8] use same snippet for sourceCodeContext and debugId --- packages/core/src/types.ts | 2 +- packages/plugins/error-tracking/src/index.ts | 13 +-- packages/plugins/injection/src/helpers.ts | 5 +- packages/plugins/injection/src/index.test.ts | 2 +- packages/plugins/injection/src/index.ts | 2 + packages/plugins/injection/src/types.ts | 2 +- .../plugins/live-debugger/src/index.test.ts | 6 +- packages/plugins/live-debugger/src/index.ts | 2 +- .../{error-tracking => rum}/src/debugId.ts | 21 ++--- .../rum/src/getSourceCodeContextSnippet.ts | 24 ++++- packages/plugins/rum/src/index.test.ts | 92 ++++++------------- packages/plugins/rum/src/index.ts | 7 +- packages/plugins/rum/src/types.ts | 3 +- packages/plugins/rum/src/validate.ts | 2 +- .../tests/src/e2e/debugId/project/chunk.js | 8 -- .../tests/src/e2e/debugId/project/index.html | 18 ---- .../tests/src/e2e/debugId/project/index.js | 11 --- .../debugId.spec.ts | 71 ++++++++++---- 18 files changed, 133 insertions(+), 158 deletions(-) rename packages/plugins/{error-tracking => rum}/src/debugId.ts (66%) delete mode 100644 packages/tests/src/e2e/debugId/project/chunk.js delete mode 100644 packages/tests/src/e2e/debugId/project/index.html delete mode 100644 packages/tests/src/e2e/debugId/project/index.js rename packages/tests/src/e2e/{debugId => sourceCodeContext}/debugId.spec.ts (68%) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c2f20829c..05883a231 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -147,7 +147,7 @@ export type ToInjectItem = { value: InjectedValue; fallback?: ToInjectItem; } & ( - | { position: InjectPosition.BEFORE | InjectPosition.AFTER; allChunks?: boolean } + | { position: InjectPosition.BEFORE | InjectPosition.AFTER; injectIntoAllChunks?: boolean } | { position: InjectPosition.MIDDLE } ); diff --git a/packages/plugins/error-tracking/src/index.ts b/packages/plugins/error-tracking/src/index.ts index 94e0c9e7e..1a7e34353 100644 --- a/packages/plugins/error-tracking/src/index.ts +++ b/packages/plugins/error-tracking/src/index.ts @@ -5,10 +5,8 @@ import { resolveEnable } from '@dd/core/helpers/options'; import { shouldGetGitInfo } from '@dd/core/helpers/plugins'; import type { BuildReport, GetPlugins, RepositoryData } from '@dd/core/types'; -import { InjectPosition } from '@dd/core/types'; import { PLUGIN_NAME } from './constants'; -import { getDebugIdSnippet } from './debugId'; import { uploadSourcemaps } from './sourcemaps'; import type { ErrorTrackingOptions, ErrorTrackingOptionsWithSourcemaps } from './types'; import { validateOptions } from './validate'; @@ -19,7 +17,7 @@ export type types = { ErrorTrackingOptions: ErrorTrackingOptions; }; -export const getPlugins: GetPlugins = ({ bundler, options, context }) => { +export const getPlugins: GetPlugins = ({ options, context }) => { const log = context.getLogger(PLUGIN_NAME); const timeOptions = log.time('validate options'); const validatedOptions = validateOptions(options, log); @@ -84,14 +82,5 @@ export const getPlugins: GetPlugins = ({ bundler, options, context }) => { }, ]; - if (validatedOptions.debugId) { - context.inject({ - type: 'code', - position: InjectPosition.BEFORE, - allChunks: true, - value: (sourceOrHash) => getDebugIdSnippet(sourceOrHash), - }); - } - return plugins; }; diff --git a/packages/plugins/injection/src/helpers.ts b/packages/plugins/injection/src/helpers.ts index 3888a81d4..426cdfd78 100644 --- a/packages/plugins/injection/src/helpers.ts +++ b/packages/plugins/injection/src/helpers.ts @@ -59,7 +59,10 @@ export const getContentToInject = ( chunk?: ChunkInfo, ) => { const filtered = contentToInject.filter((content) => { - return content.position === position && (!chunk || chunk.isEntry || content.allChunks); + return ( + content.position === position && + (!chunk || chunk.isEntry || content.injectIntoAllChunks) + ); }); // Resolve function-valued content against the current chunk, drop empties. diff --git a/packages/plugins/injection/src/index.test.ts b/packages/plugins/injection/src/index.test.ts index 8a8cf7871..e88c58acf 100644 --- a/packages/plugins/injection/src/index.test.ts +++ b/packages/plugins/injection/src/index.test.ts @@ -322,7 +322,7 @@ describe('Injection Plugin', () => { type: injectType, value: injection.value, position: positionType as InjectPosition.BEFORE | InjectPosition.AFTER, - allChunks: true, + injectIntoAllChunks: true, }); } diff --git a/packages/plugins/injection/src/index.ts b/packages/plugins/injection/src/index.ts index 0e7e97e22..055d693b8 100644 --- a/packages/plugins/injection/src/index.ts +++ b/packages/plugins/injection/src/index.ts @@ -50,6 +50,8 @@ export const getInjectionPlugins: GetInternalPlugins = (arg: GetPluginsArg) => { transformIndexHtml: { order: 'pre', handler() { + // For Vite, we inject MIDDLE content by adding a script tag + // that references the virtual injected file const middleContent = getContentToInject( contentsToInject, InjectPosition.MIDDLE, diff --git a/packages/plugins/injection/src/types.ts b/packages/plugins/injection/src/types.ts index 88cd13ec3..65c2bda9f 100644 --- a/packages/plugins/injection/src/types.ts +++ b/packages/plugins/injection/src/types.ts @@ -8,7 +8,7 @@ import type { ChunkInfo, InjectPosition } from '@dd/core/types'; export type InjectValue = string | (() => Promise) | ((chunk: ChunkInfo) => string); export type ContentToInject = { - allChunks?: boolean; + injectIntoAllChunks?: boolean; position: InjectPosition; value: InjectValue; }; diff --git a/packages/plugins/live-debugger/src/index.test.ts b/packages/plugins/live-debugger/src/index.test.ts index 26599da92..2bbd7f5fa 100644 --- a/packages/plugins/live-debugger/src/index.test.ts +++ b/packages/plugins/live-debugger/src/index.test.ts @@ -418,7 +418,7 @@ describe('getPlugins', () => { expect(arg.context.inject).toHaveBeenCalledWith({ type: 'code', position: InjectPosition.BEFORE, - allChunks: true, + injectIntoAllChunks: true, value: getRuntimeBootstrap(), }); }); @@ -436,7 +436,7 @@ describe('getPlugins', () => { expect(arg.context.inject).toHaveBeenCalledWith({ type: 'code', position: InjectPosition.BEFORE, - allChunks: true, + injectIntoAllChunks: true, value: getRuntimeBootstrap('1.0.0'), }); }); @@ -453,7 +453,7 @@ describe('getPlugins', () => { expect(arg.context.inject).toHaveBeenCalledWith({ type: 'code', position: InjectPosition.BEFORE, - allChunks: true, + injectIntoAllChunks: true, value: getRuntimeBootstrap(), }); }); diff --git a/packages/plugins/live-debugger/src/index.ts b/packages/plugins/live-debugger/src/index.ts index ae16bfa97..88f90a3e4 100644 --- a/packages/plugins/live-debugger/src/index.ts +++ b/packages/plugins/live-debugger/src/index.ts @@ -158,7 +158,7 @@ export const getPlugins: GetPlugins = ({ options, context }) => { context.inject({ type: 'code', position: InjectPosition.BEFORE, - allChunks: true, + injectIntoAllChunks: true, value: getRuntimeBootstrap(validatedOptions.version), }); diff --git a/packages/plugins/error-tracking/src/debugId.ts b/packages/plugins/rum/src/debugId.ts similarity index 66% rename from packages/plugins/error-tracking/src/debugId.ts rename to packages/plugins/rum/src/debugId.ts index a4777290f..90bc956f4 100644 --- a/packages/plugins/error-tracking/src/debugId.ts +++ b/packages/plugins/rum/src/debugId.ts @@ -2,17 +2,18 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { createHash, randomUUID } from 'crypto'; +import { createHash } from 'crypto'; export const SUPPORTED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']); -export const DD_DEBUG_IDS = 'DD_DEBUG_IDS' as const; -const VARIANT_CHARS = ['8', '9', 'a', 'b'] as const; +// The debug ID is embedded in the DD_SOURCE_CODE_CONTEXT context object as a "debugId" field. +const DEBUG_ID_RX = /"ddDebugId":"([^"]+)"/; -export const getDebugIdSnippet = (codeOrHash?: string): string => { - const debugId = codeOrHash ? stringToUUID(codeOrHash) : randomUUID(); - return `(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(debugId)},${JSON.stringify(DD_DEBUG_IDS)});`; +export const getDebugIdFromSource = (source: string): string | undefined => { + const match = source.match(DEBUG_ID_RX); + return match ? match[1] : undefined; }; +const VARIANT_CHARS = ['8', '9', 'a', 'b'] as const; // SHA-256(input) truncated to 128 bits → deterministic UUID-v4-shaped identifier. // SHA-256 is used instead of MD5 for FIPS 140-2/3 compliance. @@ -29,11 +30,3 @@ export const stringToUUID = (input: string): string => { withVariant.slice(20, 32), ].join('-'); }; - -// The snippet ends with (debugId, DD_DEBUG_IDS) — capture the UUID before the known marker. -const DEBUG_ID_RX = new RegExp(`"([^"]+)",${JSON.stringify(DD_DEBUG_IDS)}`); - -export const getDebugIdFromSource = (source: string): string | undefined => { - const match = source.match(DEBUG_ID_RX); - return match ? match[1] : undefined; -}; diff --git a/packages/plugins/rum/src/getSourceCodeContextSnippet.ts b/packages/plugins/rum/src/getSourceCodeContextSnippet.ts index 036b02fe1..c13614805 100644 --- a/packages/plugins/rum/src/getSourceCodeContextSnippet.ts +++ b/packages/plugins/rum/src/getSourceCodeContextSnippet.ts @@ -2,6 +2,9 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import { randomUUID } from 'crypto'; + +import { stringToUUID } from './debugId'; import type { SourceCodeContextOptions } from './types'; export const DEFAULT_SOURCE_CODE_CONTEXT_VARIABLE = 'DD_SOURCE_CODE_CONTEXT' as const; @@ -19,6 +22,25 @@ export const DEFAULT_SOURCE_CODE_CONTEXT_VARIABLE = 'DD_SOURCE_CODE_CONTEXT' as // s && (m[s] = c) // } catch (e) {} // })(context, variableName); -export const getSourceCodeContextSnippet = (context: SourceCodeContextOptions): string => { + +type SourceCodeContext = { + service?: string; + version?: string; + ddDebugId?: string; +}; +export const getSourceCodeContextSnippet = ( + contextOptions: SourceCodeContextOptions, + codeOrHash?: string, +): string => { + const context: SourceCodeContext = { + service: contextOptions.service, + version: contextOptions.version, + }; + + if (contextOptions.debugId) { + // Compute deterministic debug IDs whenever possible preventing the backend from storing duplicate source maps for identical build + context.ddDebugId = codeOrHash ? stringToUUID(codeOrHash) : randomUUID(); + } + return `(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 35306fab8..2ba67a511 100644 --- a/packages/plugins/rum/src/index.test.ts +++ b/packages/plugins/rum/src/index.test.ts @@ -2,6 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import type { ToInjectItem } from '@dd/core/types'; import type { RumOptions } from '@dd/rum-plugin/types'; import { getPlugins } from '@dd/rum-plugin'; import { @@ -18,72 +19,39 @@ jest.mock('@dd/rum-plugin/sdk', () => ({ })); describe('RUM Plugin', () => { - const injections = { - 'browser-sdk': path.resolve('../plugins/rum/src/rum-browser-sdk.js'), - 'sdk-init': injectionValue, - 'source-code-context': - /(?=.*DD_SOURCE_CODE_CONTEXT)(?=.*"service":"checkout")(?=.*"version":"1\.2\.3")/, + const run = (config: RumOptions) => { + const injectSpy = jest.fn((_item: ToInjectItem) => {}); + getPlugins( + getGetPluginsArg( + { ...defaultPluginOptions, rum: config }, + getContextMock({ inject: injectSpy }), + ), + ); + return injectSpy.mock.calls.map(([item]) => item.value); }; - const expectations: { - type: string; - config: RumOptions; - should: { inject: (keyof typeof injections)[] }; - }[] = [ - { - type: 'no sdk', - config: {}, - should: { inject: [] }, - }, - { - type: 'sdk', - config: { sdk: { applicationId: 'app-id' } }, - should: { inject: ['browser-sdk', 'sdk-init'] }, - }, - { - type: 'source code context', - config: { - sourceCodeContext: { - service: 'checkout', - version: '1.2.3', - }, - }, - should: { inject: ['source-code-context'] }, - }, - ]; - describe('getPlugins', () => { - const injectMock = jest.fn(); - test('Should initialize the plugin', async () => { - getPlugins( - getGetPluginsArg( - { - rum: { - sdk: { applicationId: 'app-id', clientToken: '123' }, - }, - }, - { inject: injectMock }, - ), - ); - expect(injectMock).toHaveBeenCalled(); - }); + test('Should inject nothing with no config', () => { + expect(run({})).toHaveLength(0); }); - test.each(expectations)( - 'Should inject the necessary files with "$type".', - async ({ config, should }) => { - const mockContext = getContextMock(); - const pluginConfig = { ...defaultPluginOptions, rum: config }; + test('Should inject SDK files with sdk config', () => { + const values = run({ sdk: { applicationId: 'app-id' } }); + expect(values).toHaveLength(2); + expect(values).toContain(path.resolve('../plugins/rum/src/rum-browser-sdk.js')); + expect(values).toContain(injectionValue); + }); - getPlugins(getGetPluginsArg(pluginConfig, mockContext)); + test('Should inject source code context snippet', () => { + const value = run({ + sourceCodeContext: { service: 'checkout', version: '1.2.3' }, + })[0] as () => string; + expect(value()).toMatch( + /(?=.*DD_SOURCE_CODE_CONTEXT)(?=.*"service":"checkout")(?=.*"version":"1\.2\.3")/, + ); + }); - expect(mockContext.inject).toHaveBeenCalledTimes(should.inject.length); - for (const inject of should.inject) { - expect(mockContext.inject).toHaveBeenCalledWith( - expect.objectContaining({ - value: expect.stringMatching(injections[inject]), - }), - ); - } - }, - ); + test('Should inject debug id snippet', () => { + const value = run({ sourceCodeContext: { debugId: true } })[0] as () => string; + expect(value()).toMatch(/(?=.*DD_SOURCE_CODE_CONTEXT)(?=.*"ddDebugId":"[0-9a-f-]+")/); + }); }); diff --git a/packages/plugins/rum/src/index.ts b/packages/plugins/rum/src/index.ts index 819cec974..d5e227a03 100644 --- a/packages/plugins/rum/src/index.ts +++ b/packages/plugins/rum/src/index.ts @@ -30,13 +30,14 @@ export const getPlugins: GetPlugins = ({ options, context }) => { const log = context.getLogger(PLUGIN_NAME); const validatedOptions = validateOptions(options, log); const plugins: PluginOptions[] = []; + const sourceCodeContext = validatedOptions.sourceCodeContext; - if (validatedOptions.sourceCodeContext) { + if (sourceCodeContext) { context.inject({ type: 'code', position: InjectPosition.BEFORE, - allChunks: true, - value: getSourceCodeContextSnippet(validatedOptions.sourceCodeContext), + injectIntoAllChunks: true, + value: (sourceOrHash) => getSourceCodeContextSnippet(sourceCodeContext, sourceOrHash), }); } diff --git a/packages/plugins/rum/src/types.ts b/packages/plugins/rum/src/types.ts index ef16abe5b..445f16d37 100644 --- a/packages/plugins/rum/src/types.ts +++ b/packages/plugins/rum/src/types.ts @@ -8,8 +8,9 @@ import type { RumInitConfiguration } from './browserSdkTypes'; import type { PrivacyOptions, PrivacyOptionsWithDefaults } from './privacy/types'; export type SourceCodeContextOptions = { - service: string; + service?: string; version?: string; + debugId?: boolean; }; export type RumOptions = { diff --git a/packages/plugins/rum/src/validate.ts b/packages/plugins/rum/src/validate.ts index 8241be69c..c7e5547f4 100644 --- a/packages/plugins/rum/src/validate.ts +++ b/packages/plugins/rum/src/validate.ts @@ -168,7 +168,7 @@ export const validateSourceCodeContextOptions = ( const cfg: SourceCodeContextOptions = validatedOptions.sourceCodeContext; - if (!cfg?.service || typeof cfg.service !== 'string') { + if (!cfg?.debugId && (!cfg?.service || typeof cfg.service !== 'string')) { toReturn.errors.push(`Missing ${red('"rum.sourceCodeContext.service"')}.`); } diff --git a/packages/tests/src/e2e/debugId/project/chunk.js b/packages/tests/src/e2e/debugId/project/chunk.js deleted file mode 100644 index 5a8bbf80b..000000000 --- a/packages/tests/src/e2e/debugId/project/chunk.js +++ /dev/null @@ -1,8 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -/* eslint-env browser */ - -// Used by Playwright tests to ensure the chunk module has been evaluated. -window.chunkLoaded = true; diff --git a/packages/tests/src/e2e/debugId/project/index.html b/packages/tests/src/e2e/debugId/project/index.html deleted file mode 100644 index 335d862bb..000000000 --- a/packages/tests/src/e2e/debugId/project/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - Debug ID Test - - - -

Debug ID Test - {{bundler}}

-

Testing debug_id injection.

- - - - - - diff --git a/packages/tests/src/e2e/debugId/project/index.js b/packages/tests/src/e2e/debugId/project/index.js deleted file mode 100644 index 8641355e6..000000000 --- a/packages/tests/src/e2e/debugId/project/index.js +++ /dev/null @@ -1,11 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -/* eslint-env browser */ - -const $ = document.querySelector.bind(document); - -$('#load_chunk').addEventListener('click', async () => { - await import('./chunk.js'); -}); diff --git a/packages/tests/src/e2e/debugId/debugId.spec.ts b/packages/tests/src/e2e/sourceCodeContext/debugId.spec.ts similarity index 68% rename from packages/tests/src/e2e/debugId/debugId.spec.ts rename to packages/tests/src/e2e/sourceCodeContext/debugId.spec.ts index a4d5712cb..34def4bb5 100644 --- a/packages/tests/src/e2e/debugId/debugId.spec.ts +++ b/packages/tests/src/e2e/sourceCodeContext/debugId.spec.ts @@ -4,6 +4,7 @@ /* eslint-env browser */ /* global globalThis */ +import type { BundlerName } from '@dd/core/types'; import { verifyProjectBuild } from '@dd/tests/_playwright/helpers/buildProject'; import type { TestOptions } from '@dd/tests/_playwright/testParams'; import { test } from '@dd/tests/_playwright/testParams'; @@ -22,35 +23,41 @@ const userFlow = async (url: string, page: Page, bundler: TestOptions['bundler'] await page.waitForSelector('body'); }; -// Collect the debug_id values the injected snippet registered on the page. +// Collect the debug_id values from DD_SOURCE_CODE_CONTEXT entries. const getDebugIds = async (page: Page): Promise => { return page.evaluate(() => - Object.values((globalThis as any)['DD_DEBUG_IDS'] || {}).filter( - (id: unknown): id is string => typeof id === 'string', - ), + Object.values((globalThis as any)['DD_SOURCE_CODE_CONTEXT'] || {}) + .map((ctx: unknown) => (ctx as Record)?.ddDebugId) + .filter((id: unknown): id is string => typeof id === 'string'), ); }; -describe('Debug ID', () => { - // Build our fixture project with debug_id injection enabled. - beforeAll(async ({ publicDir, bundlers, suiteName }) => { - const source = path.resolve(__dirname, 'project'); - const destination = path.resolve(publicDir, suiteName); - await verifyProjectBuild( - source, - destination, - bundlers, - { - ...defaultConfig, - errorTracking: { +async function build(publicDir: string, suiteName: string, bundlers: BundlerName[]) { + const source = path.resolve(__dirname, 'project'); + const destination = path.resolve(publicDir, suiteName); + await verifyProjectBuild( + source, + destination, + bundlers, + { + ...defaultConfig, + rum: { + sourceCodeContext: { debugId: true, }, }, - { splitting: true }, - ); + }, + { splitting: true }, + ); +} + +describe('Debug ID', () => { + // Build our fixture project with debug_id injection enabled. + beforeAll(async ({ publicDir, bundlers, suiteName }) => { + await build(publicDir, suiteName, bundlers); }); - test('Should register the entry debug_id on window.DD_DEBUG_IDS', async ({ + test('Should register the entry debug_id on window.DD_SOURCE_CODE_CONTEXT', async ({ page, bundler, browserName, @@ -107,6 +114,32 @@ describe('Debug ID', () => { } }); + test('Should generate the same debug_id across two builds', async ({ + page, + bundler, + suiteName, + devServerUrl, + publicDir, + bundlers, + }) => { + // rspack chunk.contentHash.javascript is non-deterministic across builds when devtool + // is enabled, causing different debug IDs each time. + test.skip( + bundler === 'rspack', + 'rspack content hash is not deterministic across build directories when devtool is enabled', + ); + + await build(publicDir, `${suiteName}-rebuild`, bundlers); + + await userFlow(`${devServerUrl}/${suiteName}`, page, bundler); + const firstBuildIds = await getDebugIds(page); + + await userFlow(`${devServerUrl}/${suiteName}-rebuild`, page, bundler); + const secondBuildIds = await getDebugIds(page); + + expect(firstBuildIds.sort()).toEqual(secondBuildIds.sort()); + }); + test('Should not throw errors', async ({ page, bundler, suiteName, devServerUrl }) => { const errors: string[] = []; const testBaseUrl = `${devServerUrl}/${suiteName}`; From 8087b7f6176fe7d0d2e12e40767d9ccfaf9add91 Mon Sep 17 00:00:00 2001 From: Aymeric Mortemousque Date: Tue, 16 Jun 2026 17:45:19 +0200 Subject: [PATCH 5/8] do not read file in esbuilt if no chunck injection --- packages/plugins/injection/src/esbuild.ts | 26 ++++++++++++++++------- packages/plugins/injection/src/helpers.ts | 14 +++++++++++- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/packages/plugins/injection/src/esbuild.ts b/packages/plugins/injection/src/esbuild.ts index a39bb3e9c..4a5654fe4 100644 --- a/packages/plugins/injection/src/esbuild.ts +++ b/packages/plugins/injection/src/esbuild.ts @@ -15,9 +15,11 @@ import path from 'path'; import { PLUGIN_NAME } from './constants'; import { getContentToInject, + hasChunkInjection, isNodeSystemError, isFileSupported, warnUnsupportedFile, + hasBeforeAfterInjection, } from './helpers'; import type { ContentsToInject } from './types'; @@ -96,13 +98,7 @@ export const getEsbuildPlugin = ( return; } - // Nothing registered to prepend/append. - const hasBeforeOrAfter = contentsToInject.some( - (content) => - content.position === InjectPosition.BEFORE || - content.position === InjectPosition.AFTER, - ); - if (!hasBeforeOrAfter) { + if (!hasBeforeAfterInjection(contentsToInject)) { return; } @@ -115,6 +111,10 @@ export const getEsbuildPlugin = ( o.entryPoint && entries.some((e) => e.resolved.endsWith(o.entryPoint!)), ); + if (!isEntry && !hasChunkInjection(contentsToInject)) { + return; + } + const absolutePath = getAbsolutePath(context.buildRoot, p); const { base, ext } = path.parse(absolutePath); @@ -146,14 +146,24 @@ export const getEsbuildPlugin = ( return; } + const mapPath = `${absolutePath}.map`; + const hasSourcemap = await fsp + .access(mapPath) + .then(() => true) + .catch(() => false); + const data = await esbuild.transform(sourceOrHash, { loader: 'default', banner, footer, + sourcemap: hasSourcemap ? 'external' : undefined, + sourcefile: path.basename(absolutePath), }); - // FIXME: Handle sourcemaps. await fsp.writeFile(absolutePath, data.code); + if (hasSourcemap && data.map) { + await fsp.writeFile(mapPath, data.map); + } } catch (e) { if (isNodeSystemError(e) && e.code === 'ENOENT') { // When we are using sub-builds, the entry file of sub-builds may not exist diff --git a/packages/plugins/injection/src/helpers.ts b/packages/plugins/injection/src/helpers.ts index 426cdfd78..5ed30bbf5 100644 --- a/packages/plugins/injection/src/helpers.ts +++ b/packages/plugins/injection/src/helpers.ts @@ -6,7 +6,8 @@ import { readFile } from '@dd/core/helpers/fs'; import { getAbsolutePath } from '@dd/core/helpers/paths'; import { doRequest } from '@dd/core/helpers/request'; import { truncateString } from '@dd/core/helpers/strings'; -import type { InjectPosition, ChunkInfo, Logger, ToInjectItem } from '@dd/core/types'; +import type { ChunkInfo, Logger, ToInjectItem } from '@dd/core/types'; +import { InjectPosition } from '@dd/core/types'; import chalk from 'chalk'; import { @@ -53,6 +54,17 @@ export const processLocalFile = async ( return readFile(absolutePath); }; +export function hasBeforeAfterInjection(contentsToInject: ContentToInject[]) { + return contentsToInject.some( + (content) => + content.position === InjectPosition.BEFORE || content.position === InjectPosition.AFTER, + ); +} + +export function hasChunkInjection(contentsToInject: ContentToInject[]) { + return contentsToInject.some((content) => content.injectIntoAllChunks); +} + export const getContentToInject = ( contentToInject: ContentToInject[], position: InjectPosition, From 81e19b82be95d1d3154c33c480e5af32a70dcb2b Mon Sep 17 00:00:00 2001 From: Aymeric Mortemousque Date: Wed, 17 Jun 2026 14:00:06 +0200 Subject: [PATCH 6/8] fix test Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/types.ts | 2 +- packages/plugins/injection/src/esbuild.ts | 2 +- packages/plugins/injection/src/helpers.ts | 9 +- .../src/e2e/sourceCodeContext/debugId.spec.ts | 161 ------------------ .../sourceCodeContext.spec.ts | 105 +++++++++--- 5 files changed, 90 insertions(+), 189 deletions(-) delete mode 100644 packages/tests/src/e2e/sourceCodeContext/debugId.spec.ts diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 05883a231..335320914 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -147,7 +147,7 @@ export type ToInjectItem = { value: InjectedValue; fallback?: ToInjectItem; } & ( - | { position: InjectPosition.BEFORE | InjectPosition.AFTER; injectIntoAllChunks?: boolean } + | { position?: InjectPosition.BEFORE | InjectPosition.AFTER; injectIntoAllChunks?: boolean } | { position: InjectPosition.MIDDLE } ); diff --git a/packages/plugins/injection/src/esbuild.ts b/packages/plugins/injection/src/esbuild.ts index 4a5654fe4..97fcfd264 100644 --- a/packages/plugins/injection/src/esbuild.ts +++ b/packages/plugins/injection/src/esbuild.ts @@ -112,7 +112,7 @@ export const getEsbuildPlugin = ( ); if (!isEntry && !hasChunkInjection(contentsToInject)) { - return; + continue; } const absolutePath = getAbsolutePath(context.buildRoot, p); diff --git a/packages/plugins/injection/src/helpers.ts b/packages/plugins/injection/src/helpers.ts index 5ed30bbf5..4cde070d0 100644 --- a/packages/plugins/injection/src/helpers.ts +++ b/packages/plugins/injection/src/helpers.ts @@ -139,7 +139,14 @@ export const prepareInjections = async ( })), ); - contentsToInject.push(...dynamicPerChunk, ...resolvedStaticInject); + const normalize = (item: T) => ({ + ...item, + position: item.position ?? InjectPosition.BEFORE, + }); + contentsToInject.push( + ...dynamicPerChunk.map(normalize), + ...resolvedStaticInject.map(normalize), + ); }; export interface NodeSystemError extends Error { diff --git a/packages/tests/src/e2e/sourceCodeContext/debugId.spec.ts b/packages/tests/src/e2e/sourceCodeContext/debugId.spec.ts deleted file mode 100644 index 34def4bb5..000000000 --- a/packages/tests/src/e2e/sourceCodeContext/debugId.spec.ts +++ /dev/null @@ -1,161 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -/* eslint-env browser */ -/* global globalThis */ -import type { BundlerName } from '@dd/core/types'; -import { verifyProjectBuild } from '@dd/tests/_playwright/helpers/buildProject'; -import type { TestOptions } from '@dd/tests/_playwright/testParams'; -import { test } from '@dd/tests/_playwright/testParams'; -import { defaultConfig } from '@dd/tools/plugins'; -import type { Page } from '@playwright/test'; -import path from 'path'; - -// Have a similar experience to Jest. -const { expect, beforeAll, describe } = test; - -const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; - -const userFlow = async (url: string, page: Page, bundler: TestOptions['bundler']) => { - // Navigate to our page. - await page.goto(`${url}/index.html?context_bundler=${bundler}`); - await page.waitForSelector('body'); -}; - -// Collect the debug_id values from DD_SOURCE_CODE_CONTEXT entries. -const getDebugIds = async (page: Page): Promise => { - return page.evaluate(() => - Object.values((globalThis as any)['DD_SOURCE_CODE_CONTEXT'] || {}) - .map((ctx: unknown) => (ctx as Record)?.ddDebugId) - .filter((id: unknown): id is string => typeof id === 'string'), - ); -}; - -async function build(publicDir: string, suiteName: string, bundlers: BundlerName[]) { - const source = path.resolve(__dirname, 'project'); - const destination = path.resolve(publicDir, suiteName); - await verifyProjectBuild( - source, - destination, - bundlers, - { - ...defaultConfig, - rum: { - sourceCodeContext: { - debugId: true, - }, - }, - }, - { splitting: true }, - ); -} - -describe('Debug ID', () => { - // Build our fixture project with debug_id injection enabled. - beforeAll(async ({ publicDir, bundlers, suiteName }) => { - await build(publicDir, suiteName, bundlers); - }); - - test('Should register the entry debug_id on window.DD_SOURCE_CODE_CONTEXT', async ({ - page, - bundler, - browserName, - suiteName, - devServerUrl, - }) => { - const errors: string[] = []; - const testBaseUrl = `${devServerUrl}/${suiteName}`; - - // Listen for errors on the page. - page.on('pageerror', (error) => errors.push(error.message)); - page.on('response', async (response) => { - if (!response.ok()) { - const url = response.request().url(); - const prefix = `[${bundler} ${browserName} ${response.status()}]`; - errors.push(`${prefix} ${url}`); - } - }); - - await userFlow(testBaseUrl, page, bundler); - - const debugIds = await getDebugIds(page); - - // The entry script registered at least one debug_id, and it is a valid UUID. - expect(debugIds.length).toBeGreaterThanOrEqual(1); - for (const debugId of debugIds) { - expect(debugId).toMatch(UUID_RX); - } - expect(errors).toEqual([]); - }); - - test('Should register a distinct debug_id for a dynamically loaded chunk', async ({ - page, - bundler, - suiteName, - devServerUrl, - }) => { - await userFlow(`${devServerUrl}/${suiteName}`, page, bundler); - - const before = await getDebugIds(page); - - // Loading a separate chunk evaluates its own injected snippet. - await page.click('#load_chunk'); - await page.waitForFunction(() => window.chunkLoaded === true); - - const after = await getDebugIds(page); - - // The chunk contributed at least one new, distinct debug_id (per emitted file). - expect(after.length).toBeGreaterThan(before.length); - const newDebugIds = after.filter((debugId) => !before.includes(debugId)); - expect(newDebugIds.length).toBeGreaterThanOrEqual(1); - for (const debugId of after) { - expect(debugId).toMatch(UUID_RX); - } - }); - - test('Should generate the same debug_id across two builds', async ({ - page, - bundler, - suiteName, - devServerUrl, - publicDir, - bundlers, - }) => { - // rspack chunk.contentHash.javascript is non-deterministic across builds when devtool - // is enabled, causing different debug IDs each time. - test.skip( - bundler === 'rspack', - 'rspack content hash is not deterministic across build directories when devtool is enabled', - ); - - await build(publicDir, `${suiteName}-rebuild`, bundlers); - - await userFlow(`${devServerUrl}/${suiteName}`, page, bundler); - const firstBuildIds = await getDebugIds(page); - - await userFlow(`${devServerUrl}/${suiteName}-rebuild`, page, bundler); - const secondBuildIds = await getDebugIds(page); - - expect(firstBuildIds.sort()).toEqual(secondBuildIds.sort()); - }); - - test('Should not throw errors', async ({ page, bundler, suiteName, devServerUrl }) => { - const errors: string[] = []; - const testBaseUrl = `${devServerUrl}/${suiteName}`; - - // Listen for errors on the page. - page.on('pageerror', (error) => errors.push(error.message)); - - // Mock error to confirm the snippet's try/catch swallows failures. - await page.addInitScript(() => { - (globalThis as any).Error = function () { - // eslint-disable-next-line no-throw-literal - throw 'Test error from debug id snippet'; - }; - }); - - await userFlow(testBaseUrl, page, bundler); - expect(errors).toEqual([]); - }); -}); diff --git a/packages/tests/src/e2e/sourceCodeContext/sourceCodeContext.spec.ts b/packages/tests/src/e2e/sourceCodeContext/sourceCodeContext.spec.ts index 4381e979f..0da456c3b 100644 --- a/packages/tests/src/e2e/sourceCodeContext/sourceCodeContext.spec.ts +++ b/packages/tests/src/e2e/sourceCodeContext/sourceCodeContext.spec.ts @@ -4,6 +4,7 @@ /* eslint-env browser */ /* global globalThis */ +import type { BundlerName } from '@dd/core/types'; import { verifyProjectBuild } from '@dd/tests/_playwright/helpers/buildProject'; import type { TestOptions } from '@dd/tests/_playwright/testParams'; import { test } from '@dd/tests/_playwright/testParams'; @@ -16,6 +17,7 @@ const { expect, beforeAll, describe } = test; const SERVICE_NAME = 'test-micro-frontend'; const SERVICE_VERSION = '1.2.3'; +const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const userFlow = async (url: string, page: Page, bundler: TestOptions['bundler']) => { // Navigate to our page. @@ -30,27 +32,35 @@ const getRUMEvents = async (page: Page) => { return page.evaluate(() => (globalThis as any).rum_events); }; +const getDebugIds = async (page: Page): Promise => { + return page.evaluate(() => + Object.values((globalThis as any)['DD_SOURCE_CODE_CONTEXT'] || {}) + .map((ctx: unknown) => (ctx as Record)?.ddDebugId) + .filter((id: unknown): id is string => typeof id === 'string'), + ); +}; + +async function build(publicDir: string, suiteName: string, bundlers: BundlerName[]) { + const source = path.resolve(__dirname, 'project'); + const destination = path.resolve(publicDir, suiteName); + await verifyProjectBuild(source, destination, bundlers, pluginConfig, { splitting: true }); +} + +const pluginConfig = { + ...defaultConfig, + rum: { + sourceCodeContext: { + service: SERVICE_NAME, + version: SERVICE_VERSION, + debugId: true, + }, + }, +}; + describe('Source Code Context', () => { // Build our fixture project. beforeAll(async ({ publicDir, bundlers, suiteName }) => { - const source = path.resolve(__dirname, 'project'); - const destination = path.resolve(publicDir, suiteName); - await verifyProjectBuild( - source, - destination, - bundlers, - { - ...defaultConfig, - rum: { - enable: true, - sourceCodeContext: { - service: SERVICE_NAME, - version: SERVICE_VERSION, - }, - }, - }, - { splitting: true }, - ); + await build(publicDir, suiteName, bundlers); }); test('Should inject DD_SOURCE_CODE_CONTEXT global variable', async ({ @@ -84,13 +94,7 @@ describe('Source Code Context', () => { expect(errors).toEqual([]); }); - test('Should not throw errors', async ({ - page, - bundler, - browserName, - suiteName, - devServerUrl, - }) => { + test('Should not throw errors', async ({ page, bundler, suiteName, devServerUrl }) => { const errors: string[] = []; const testBaseUrl = `${devServerUrl}/${suiteName}`; @@ -148,4 +152,55 @@ describe('Source Code Context', () => { expect(entryError).toMatchObject({ version: SERVICE_VERSION, service: SERVICE_NAME }); expect(chunkError).toMatchObject({ version: SERVICE_VERSION, service: SERVICE_NAME }); }); + + test('Should register a distinct debug_id for a dynamically loaded chunk', async ({ + page, + bundler, + suiteName, + devServerUrl, + }) => { + await userFlow(`${devServerUrl}/${suiteName}`, page, bundler); + + const before = await getDebugIds(page); + + // Loading a separate chunk evaluates its own injected snippet. + await page.click('#load_chunk'); + await page.waitForFunction(() => window.chunkLoaded === true); + + const after = await getDebugIds(page); + + // The chunk contributed at least one new, distinct debug_id (per emitted file). + expect(after.length).toBeGreaterThan(before.length); + const newDebugIds = after.filter((debugId) => !before.includes(debugId)); + expect(newDebugIds.length).toBeGreaterThanOrEqual(1); + for (const debugId of after) { + expect(debugId).toMatch(UUID_RX); + } + }); + + test('Should generate the same debug_id across two builds', async ({ + page, + bundler, + suiteName, + devServerUrl, + publicDir, + bundlers, + }) => { + // rspack chunk.contentHash.javascript is non-deterministic across builds when devtool + // is enabled, causing different debug IDs each time. + test.skip( + bundler === 'rspack', + 'rspack content hash is not deterministic across build directories when devtool is enabled', + ); + + await build(publicDir, `${suiteName}-rebuild`, bundlers); + + await userFlow(`${devServerUrl}/${suiteName}`, page, bundler); + const firstBuildIds = await getDebugIds(page); + + await userFlow(`${devServerUrl}/${suiteName}-rebuild`, page, bundler); + const secondBuildIds = await getDebugIds(page); + + expect(firstBuildIds.sort()).toEqual(secondBuildIds.sort()); + }); }); From b93045d3cb6f33cd9292b93a71725db1015c2079 Mon Sep 17 00:00:00 2001 From: Aymeric Mortemousque Date: Wed, 17 Jun 2026 14:08:03 +0200 Subject: [PATCH 7/8] Optimize esbuilt reading and writting files --- packages/plugins/injection/src/esbuild.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/plugins/injection/src/esbuild.ts b/packages/plugins/injection/src/esbuild.ts index 97fcfd264..c13e0d250 100644 --- a/packages/plugins/injection/src/esbuild.ts +++ b/packages/plugins/injection/src/esbuild.ts @@ -128,7 +128,14 @@ export const getEsbuildPlugin = ( proms.push( (async () => { try { - const sourceOrHash = await fsp.readFile(absolutePath, 'utf-8'); + const mapPath = `${absolutePath}.map`; + const [sourceOrHash, hasSourcemap] = await Promise.all([ + fsp.readFile(absolutePath, 'utf-8'), + fsp + .access(mapPath) + .then(() => true) + .catch(() => false), + ]); const fileName = path.basename(absolutePath); // Resolve static and per-chunk content in one pass. const banner = getContentToInject( @@ -146,12 +153,6 @@ export const getEsbuildPlugin = ( return; } - const mapPath = `${absolutePath}.map`; - const hasSourcemap = await fsp - .access(mapPath) - .then(() => true) - .catch(() => false); - const data = await esbuild.transform(sourceOrHash, { loader: 'default', banner, @@ -160,10 +161,10 @@ export const getEsbuildPlugin = ( sourcefile: path.basename(absolutePath), }); - await fsp.writeFile(absolutePath, data.code); - if (hasSourcemap && data.map) { - await fsp.writeFile(mapPath, data.map); - } + await Promise.all([ + fsp.writeFile(absolutePath, data.code), + hasSourcemap && data.map ? fsp.writeFile(mapPath, data.map) : null, + ]); } catch (e) { if (isNodeSystemError(e) && e.code === 'ENOENT') { // When we are using sub-builds, the entry file of sub-builds may not exist From 88eab9368eb4e8b13853a4206765ac664c4bc338 Mon Sep 17 00:00:00 2001 From: Aymeric Mortemousque Date: Wed, 17 Jun 2026 14:36:49 +0200 Subject: [PATCH 8/8] fix package dependencies --- packages/plugins/error-tracking/package.json | 1 - packages/plugins/injection/src/esbuild.ts | 2 +- packages/published/esbuild-plugin/package.json | 6 +----- packages/published/rollup-plugin/package.json | 4 ---- packages/published/vite-plugin/package.json | 4 ---- packages/published/webpack-plugin/package.json | 4 ---- yarn.lock | 13 ------------- 7 files changed, 2 insertions(+), 32 deletions(-) diff --git a/packages/plugins/error-tracking/package.json b/packages/plugins/error-tracking/package.json index b193464bd..620623247 100644 --- a/packages/plugins/error-tracking/package.json +++ b/packages/plugins/error-tracking/package.json @@ -22,7 +22,6 @@ "dependencies": { "@dd/core": "workspace:*", "chalk": "2.3.1", - "magic-string": "0.30.21", "outdent": "0.8.0", "p-queue": "6.6.2" }, diff --git a/packages/plugins/injection/src/esbuild.ts b/packages/plugins/injection/src/esbuild.ts index c13e0d250..8260d2333 100644 --- a/packages/plugins/injection/src/esbuild.ts +++ b/packages/plugins/injection/src/esbuild.ts @@ -158,7 +158,7 @@ export const getEsbuildPlugin = ( banner, footer, sourcemap: hasSourcemap ? 'external' : undefined, - sourcefile: path.basename(absolutePath), + sourcefile: fileName, }); await Promise.all([ diff --git a/packages/published/esbuild-plugin/package.json b/packages/published/esbuild-plugin/package.json index 817c1c49d..c824b7b72 100644 --- a/packages/published/esbuild-plugin/package.json +++ b/packages/published/esbuild-plugin/package.json @@ -89,8 +89,7 @@ "@babel/parser": "^7.24.5", "@babel/traverse": "^7.24.5", "@babel/types": "^7.24.5", - "esbuild": ">=0.x", - "magic-string": "^0.30.0" + "esbuild": ">=0.x" }, "peerDependenciesMeta": { "@babel/parser": { @@ -101,9 +100,6 @@ }, "@babel/types": { "optional": true - }, - "magic-string": { - "optional": true } } } diff --git a/packages/published/rollup-plugin/package.json b/packages/published/rollup-plugin/package.json index 20c91db0a..44f7ca399 100644 --- a/packages/published/rollup-plugin/package.json +++ b/packages/published/rollup-plugin/package.json @@ -92,7 +92,6 @@ "@babel/parser": "^7.24.5", "@babel/traverse": "^7.24.5", "@babel/types": "^7.24.5", - "magic-string": "^0.30.0", "rollup": ">= 3.x < 5.x" }, "peerDependenciesMeta": { @@ -104,9 +103,6 @@ }, "@babel/types": { "optional": true - }, - "magic-string": { - "optional": true } } } diff --git a/packages/published/vite-plugin/package.json b/packages/published/vite-plugin/package.json index 36cc9858e..cdf4c03ac 100644 --- a/packages/published/vite-plugin/package.json +++ b/packages/published/vite-plugin/package.json @@ -89,7 +89,6 @@ "@babel/parser": "^7.24.5", "@babel/traverse": "^7.24.5", "@babel/types": "^7.24.5", - "magic-string": "^0.30.0", "vite": ">= 5.x <= 7.x" }, "peerDependenciesMeta": { @@ -101,9 +100,6 @@ }, "@babel/types": { "optional": true - }, - "magic-string": { - "optional": true } } } diff --git a/packages/published/webpack-plugin/package.json b/packages/published/webpack-plugin/package.json index 562624dd4..1d1346312 100644 --- a/packages/published/webpack-plugin/package.json +++ b/packages/published/webpack-plugin/package.json @@ -89,7 +89,6 @@ "@babel/parser": "^7.24.5", "@babel/traverse": "^7.24.5", "@babel/types": "^7.24.5", - "magic-string": "^0.30.0", "webpack": ">= 5.x < 6.x" }, "peerDependenciesMeta": { @@ -101,9 +100,6 @@ }, "@babel/types": { "optional": true - }, - "magic-string": { - "optional": true } } } diff --git a/yarn.lock b/yarn.lock index 3aa160990..5c99d323c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1719,7 +1719,6 @@ __metadata: "@babel/traverse": ^7.24.5 "@babel/types": ^7.24.5 esbuild: ">=0.x" - magic-string: ^0.30.0 peerDependenciesMeta: "@babel/parser": optional: true @@ -1727,8 +1726,6 @@ __metadata: optional: true "@babel/types": optional: true - magic-string: - optional: true languageName: unknown linkType: soft @@ -1779,7 +1776,6 @@ __metadata: "@babel/parser": ^7.24.5 "@babel/traverse": ^7.24.5 "@babel/types": ^7.24.5 - magic-string: ^0.30.0 rollup: ">= 3.x < 5.x" peerDependenciesMeta: "@babel/parser": @@ -1788,8 +1784,6 @@ __metadata: optional: true "@babel/types": optional: true - magic-string: - optional: true languageName: unknown linkType: soft @@ -1887,7 +1881,6 @@ __metadata: "@babel/parser": ^7.24.5 "@babel/traverse": ^7.24.5 "@babel/types": ^7.24.5 - magic-string: ^0.30.0 vite: ">= 5.x <= 7.x" peerDependenciesMeta: "@babel/parser": @@ -1896,8 +1889,6 @@ __metadata: optional: true "@babel/types": optional: true - magic-string: - optional: true languageName: unknown linkType: soft @@ -1941,7 +1932,6 @@ __metadata: "@babel/parser": ^7.24.5 "@babel/traverse": ^7.24.5 "@babel/types": ^7.24.5 - magic-string: ^0.30.0 webpack: ">= 5.x < 6.x" peerDependenciesMeta: "@babel/parser": @@ -1950,8 +1940,6 @@ __metadata: optional: true "@babel/types": optional: true - magic-string: - optional: true languageName: unknown linkType: soft @@ -2002,7 +1990,6 @@ __metadata: dependencies: "@dd/core": "workspace:*" chalk: "npm:2.3.1" - magic-string: "npm:0.30.21" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" typescript: "npm:5.4.3"