Skip to content

Commit 9e20c37

Browse files
committed
fix(error-tracking): derive debug_id from file content, not a filename Map
The RUM plugin injects a debug_id into every built chunk's own JS content. error-tracking's sourcemap uploader now extracts that debug_id back out of each file when it uploads that file's sourcemap, instead of relying on filename-based coordination between the two plugins. Previously, error-tracking got the debug_id from a side-channel Map that the RUM plugin populated at injection time, keyed by the chunk's filename. Bundlers (webpack/rspack) rename chunks after injection via realContentHash, so by the time error-tracking looked the value up by the chunk's final filename, the key no longer matched — the debug_id was silently dropped for almost every chunk in a real build. Instead of coordinating through a filename-keyed Map, error-tracking now reads each minified file's own content directly and extracts the debug_id from the ddDebugId literal the RUM plugin already embeds inside it. This works no matter what renaming the bundler does afterward, since the debug_id travels with the file's content, not its name.
1 parent 80ef2a7 commit 9e20c37

14 files changed

Lines changed: 202 additions & 16 deletions

File tree

packages/core/src/helpers/fs.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
// Copyright 2019-Present Datadog, Inc.
44

55
import { addFixtureFiles } from '@dd/tests/_jest/helpers/mocks';
6+
import os from 'os';
67
import path from 'path';
78

8-
import { checkFile } from './fs';
9+
import { checkFile, outputFileSync, readFilePrefix, rmSync } from './fs';
910

1011
jest.mock('fs/promises', () => {
1112
const original = jest.requireActual('fs/promises');
@@ -33,3 +34,27 @@ describe('checkFile', () => {
3334
expect(validity).toEqual(expected);
3435
});
3536
});
37+
38+
describe('readFilePrefix', () => {
39+
const tempDir = path.join(os.tmpdir(), 'dd-build-plugins-fs-test');
40+
41+
afterEach(() => {
42+
rmSync(tempDir);
43+
});
44+
45+
test('Should return the whole file when it is smaller than maxBytes.', async () => {
46+
const filePath = path.join(tempDir, 'small.js');
47+
outputFileSync(filePath, 'short content');
48+
49+
const content = await readFilePrefix(filePath, 1024);
50+
expect(content).toBe('short content');
51+
});
52+
53+
test('Should only return the first maxBytes of a file larger than maxBytes.', async () => {
54+
const filePath = path.join(tempDir, 'large.js');
55+
outputFileSync(filePath, 'a'.repeat(10_000));
56+
57+
const content = await readFilePrefix(filePath, 10);
58+
expect(content).toBe('a'.repeat(10));
59+
});
60+
});

packages/core/src/helpers/fs.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,20 @@ export const readFile = (filepath: string) => {
7676
return fsp.readFile(filepath, { encoding: 'utf-8' });
7777
};
7878

79+
// Read only the first `maxBytes` bytes of a file.
80+
// Useful when the data we need is guaranteed to live near the start of the file
81+
// and reading the whole (potentially large) file into memory would be wasteful.
82+
export const readFilePrefix = async (filepath: string, maxBytes: number): Promise<string> => {
83+
const fd = await fsp.open(filepath, 'r');
84+
try {
85+
const buffer = Buffer.alloc(maxBytes);
86+
const { bytesRead } = await fd.read(buffer, 0, maxBytes, 0);
87+
return buffer.toString('utf-8', 0, bytesRead);
88+
} finally {
89+
await fd.close();
90+
}
91+
};
92+
7993
export const readFileSync = (filepath: string) => {
8094
return fs.readFileSync(filepath, { encoding: 'utf-8' });
8195
};

packages/core/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ export type ChunkInfo = {
134134
};
135135

136136
// Static string, lazy async loader (e.g. file fetch), or per-chunk code generator.
137-
export type InjectedValue = string | (() => Promise<string>) | ((sourceOrHash?: string) => string);
137+
export type InjectedValue = string | (() => Promise<string>) | ((chunk?: ChunkInfo) => string);
138138

139139
export enum InjectPosition {
140140
BEFORE,
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2019-Present Datadog, Inc.
4+
5+
import { extractDebugId } from './debugId';
6+
7+
describe('extractDebugId', () => {
8+
const debugId = '93fd4850-7b77-4f2e-9aa2-ba013e1a5027';
9+
10+
test('Should extract the debug ID when the key is quoted (unminified JSON.stringify output)', () => {
11+
const content = `!function(){}({"service":"app","version":"1.0.0","ddDebugId":"${debugId}"},"DD_SOURCE_CODE_CONTEXT");`;
12+
expect(extractDebugId(content)).toBe(debugId);
13+
});
14+
15+
test('Should extract the debug ID when the key is unquoted (minifiers strip quotes from valid identifier keys)', () => {
16+
const content = `!function(){}({service:"app",version:"1.0.0",ddDebugId:"${debugId}"},"DD_SOURCE_CODE_CONTEXT");`;
17+
expect(extractDebugId(content)).toBe(debugId);
18+
});
19+
20+
test('Should return undefined when there is no debug ID in the content', () => {
21+
const content = `!function(){}({service:"app",version:"1.0.0"},"DD_SOURCE_CODE_CONTEXT");`;
22+
expect(extractDebugId(content)).toBeUndefined();
23+
});
24+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2019-Present Datadog, Inc.
4+
5+
// Matches the `ddDebugId:"<uuid>"` literal the RUM plugin injects into each chunk's own content
6+
// (see packages/plugins/rum/src/getSourceCodeContextSnippet.ts). The key is quoted in source
7+
// (`JSON.stringify(context)`) but minifiers like terser strip quotes from object keys that are
8+
// valid identifiers, so the built output can have either `"ddDebugId":"..."` or `ddDebugId:"..."`.
9+
// Reading it back out of the file we're about to upload means we never have to trust a filename
10+
// as a coordination key between the RUM plugin and this one, so it stays correct across any
11+
// bundler renaming step.
12+
const DEBUG_ID_RX = /"?ddDebugId"?:"([0-9a-fA-F-]{36})"/;
13+
14+
// The RUM plugin injects its snippet as a BEFORE-position banner (packages/plugins/rum/src/index.ts),
15+
// so the ddDebugId literal always lands within the file's first couple hundred bytes, regardless
16+
// of the file's total size — no need to read the whole (potentially large) minified bundle to
17+
// find it. Measured against ~2.8k real built chunks (mixed bundlers/minifiers), the match always
18+
// ended by byte 242; this leaves ~4x headroom for longer service/version strings.
19+
export const DEBUG_ID_SEARCH_PREFIX_BYTES = 1024;
20+
21+
export const extractDebugId = (fileContent: string): string | undefined => {
22+
return DEBUG_ID_RX.exec(fileContent)?.[1];
23+
};

packages/plugins/error-tracking/src/sourcemaps/payload.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type Metadata = {
2222
version: string;
2323
git_repository_url?: string;
2424
git_commit_sha?: string;
25+
debug_id?: string;
2526
};
2627

2728
type SourcemapValidity = {
@@ -87,6 +88,7 @@ export const getPayload = async (
8788
metadata: Metadata,
8889
prefix: string,
8990
git?: RepositoryData,
91+
debugId?: string,
9092
): Promise<Payload> => {
9193
const validity = await getSourcemapValidity(sourcemap, prefix);
9294
const errors: string[] = [];
@@ -102,6 +104,7 @@ export const getPayload = async (
102104
},
103105
value: JSON.stringify({
104106
...metadata,
107+
debug_id: debugId,
105108
minified_url: sourcemap.minifiedUrl,
106109
}),
107110
},

packages/plugins/error-tracking/src/sourcemaps/sender.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,15 @@ import {
2222
addFixtureFiles,
2323
} from '@dd/tests/_jest/helpers/mocks';
2424

25+
import * as payloadModule from './payload';
26+
2527
jest.mock('@dd/core/helpers/fs', () => {
2628
const original = jest.requireActual('@dd/core/helpers/fs');
2729
return {
2830
...original,
2931
checkFile: jest.fn(),
3032
getFile: jest.fn(),
33+
readFilePrefix: jest.fn(),
3134
};
3235
});
3336

@@ -139,7 +142,9 @@ describe('Error Tracking Plugin Sourcemaps', () => {
139142
mockLogger,
140143
);
141144

142-
expect(mockLogFn).toHaveBeenCalledTimes(1);
145+
// Only the debug ID extraction summary (debug) and the payload error (error)
146+
// should be logged — the debug summary logs unconditionally before the error check.
147+
expect(mockLogFn.mock.calls.filter(([, level]) => level === 'error')).toHaveLength(1);
143148
expect(mockLogFn).toHaveBeenCalledWith(
144149
expect.stringMatching('Failed to prepare payloads, aborting upload'),
145150
'error',
@@ -163,6 +168,38 @@ describe('Error Tracking Plugin Sourcemaps', () => {
163168
}).rejects.toThrow('Failed to prepare payloads, aborting upload');
164169
expect(doRequestMock).not.toHaveBeenCalled();
165170
});
171+
172+
test('Should resolve the debug ID straight from the minified file content, regardless of its filename', async () => {
173+
// The minified file's name here has nothing to do with the debug ID lookup —
174+
// it's extracted from the file's own content, so it survives any bundler
175+
// renaming step (e.g. webpack/rspack's realContentHash) that happens after
176+
// the RUM plugin injects it.
177+
const debugId = '12345678-1234-4123-8123-123456789012';
178+
addFixtureFiles({
179+
'/path/to/minified.min.js':
180+
// Minifiers strip quotes from object keys that are valid identifiers, so
181+
// the real on-disk shape has an unquoted key, not `"ddDebugId":"..."`.
182+
`Some JS File with some content.(function(c,n){...})({ddDebugId:"${debugId}"},"DD_SOURCE_CODE_CONTEXT");`,
183+
'/path/to/sourcemap.js.map': '{"version":3,"sources":["/path/to/minified.min.js"]}',
184+
});
185+
186+
const getPayloadSpy = jest.spyOn(payloadModule, 'getPayload');
187+
188+
const sourcemap = getSourcemapMock({ relativePath: 'path/to/minified.min.js' });
189+
190+
await sendSourcemaps(
191+
[sourcemap],
192+
getSourcemapsConfiguration(),
193+
senderContextMock,
194+
mockLogger,
195+
);
196+
197+
expect(getPayloadSpy).toHaveBeenCalledTimes(1);
198+
const debugIdArg = getPayloadSpy.mock.calls[0][4];
199+
expect(debugIdArg).toBe(debugId);
200+
201+
getPayloadSpy.mockRestore();
202+
});
166203
});
167204

168205
describe('upload', () => {

packages/plugins/error-tracking/src/sourcemaps/sender.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// Copyright 2019-Present Datadog, Inc.
44

55
import { getDDEnvValue } from '@dd/core/helpers/env';
6-
import { getFile } from '@dd/core/helpers/fs';
6+
import { getFile, readFilePrefix } from '@dd/core/helpers/fs';
77
import {
88
createRequestData,
99
doRequest,
@@ -18,6 +18,7 @@ import PQueue from 'p-queue';
1818

1919
import type { SourcemapsOptionsWithDefaults, Sourcemap } from '../types';
2020

21+
import { DEBUG_ID_SEARCH_PREFIX_BYTES, extractDebugId } from './debugId';
2122
import type { Metadata, MultipartFileValue, Payload } from './payload';
2223
import { getPayload } from './payload';
2324
import {
@@ -199,10 +200,33 @@ export const sendSourcemaps = async (
199200
};
200201

201202
const payloadsTimer = log.time('Compute payloads');
202-
const payloads = await Promise.all(
203-
sourcemaps.map((sourcemap) => getPayload(sourcemap, metadata, prefix, context.git)),
203+
// @ts-expect-error PQueue's default isn't typed.
204+
const Queue = PQueue.default ? PQueue.default : PQueue;
205+
const payloadsQueue = new Queue({ concurrency: options.maxConcurrency });
206+
let debugIdCount = 0;
207+
const payloads: Payload[] = await payloadsQueue.addAll(
208+
sourcemaps.map((sourcemap) => async () => {
209+
// Read the debug_id straight from the minified file's own content instead of
210+
// trusting a filename as a coordination key with the RUM plugin — the bundler may
211+
// still rename the file after injection (e.g. webpack/rspack's realContentHash),
212+
// but the content, and the debug_id embedded in it, is unaffected. Only the file's
213+
// prefix is read, since the RUM plugin's injected snippet always lands near the
214+
// start of the file (see DEBUG_ID_SEARCH_PREFIX_BYTES).
215+
const fileContent = await readFilePrefix(
216+
sourcemap.minifiedFilePath,
217+
DEBUG_ID_SEARCH_PREFIX_BYTES,
218+
).catch(() => undefined);
219+
const debugId = fileContent ? extractDebugId(fileContent) : undefined;
220+
if (debugId) {
221+
debugIdCount += 1;
222+
}
223+
return getPayload(sourcemap, metadata, prefix, context.git, debugId);
224+
}),
204225
);
205226
payloadsTimer.end();
227+
log.debug(
228+
`Extracted debug_id for ${green(`${debugIdCount}/${sourcemaps.length}`)} sourcemaps.`,
229+
);
206230

207231
const errors = payloads.map((payload) => payload.errors).flat();
208232
const warnings = payloads.map((payload) => payload.warnings).flat();

packages/plugins/injection/src/esbuild.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,16 @@ export const getEsbuildPlugin = (
133133
const sourcemap = await fsp
134134
.readFile(mapPath, 'utf-8')
135135
.catch(() => false as const);
136-
const fileName = path.basename(absolutePath);
136+
// Keep the path relative to the output directory (subdirectories
137+
// included) so it matches the relativePath computed for sourcemap
138+
// uploads in error-tracking/src/sourcemaps/files.ts. A bare
139+
// basename would break debug-id lookups for nested chunks.
140+
const fileName = context.bundler.outDir
141+
? path
142+
.relative(context.bundler.outDir, absolutePath)
143+
.split(path.sep)
144+
.join('/')
145+
: path.basename(absolutePath);
137146
// Resolve static and per-chunk content in one pass.
138147
const banner = getContentToInject(
139148
contentsToInject,

packages/plugins/injection/src/helpers.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,10 @@ export const prepareInjections = async (
124124
contentsToInject: ContentsToInject,
125125
cwd: string = process.cwd(),
126126
) => {
127-
// Per-chunk functions: adapt from public API (sourceOrHash?: string) to internal (chunk: ChunkInfo).
127+
// Per-chunk functions receive the full ChunkInfo for the chunk they're injected into.
128128
const dynamicPerChunk = toInject.filter(isPerChunk).map((item) => {
129-
const userFn = item.value as (sourceOrHash?: string) => string;
130-
return { ...item, value: (chunk: ChunkInfo) => userFn(chunk.sourceOrHash) };
129+
const userFn = item.value as (chunk?: ChunkInfo) => string;
130+
return { ...item, value: (chunk: ChunkInfo) => userFn(chunk) };
131131
});
132132

133133
// Static items (strings and async loaders) are resolved once per build.

0 commit comments

Comments
 (0)