Skip to content

Commit ccf807d

Browse files
committed
fix(error-tracking): search debug IDs progressively
1 parent 3eb123e commit ccf807d

2 files changed

Lines changed: 76 additions & 16 deletions

File tree

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

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

55
import { outputFileSync, rmSync } from '@dd/core/helpers/fs';
6+
import fsp from 'fs/promises';
67
import os from 'os';
78
import path from 'path';
89

9-
import { extractDebugId } from './debugId';
10+
import { DEBUG_ID_SEARCH_CHUNK_BYTES, extractDebugId } from './debugId';
1011

1112
describe('extractDebugId', () => {
1213
const debugId = '93fd4850-7b77-4f2e-9aa2-ba013e1a5027';
1314
const tempDir = path.join(os.tmpdir(), 'dd-build-plugins-debug-id-test');
1415

1516
afterEach(() => {
17+
jest.restoreAllMocks();
1618
rmSync(tempDir);
1719
});
1820

@@ -36,6 +38,20 @@ describe('extractDebugId', () => {
3638
await expect(extractDebugId(filePath)).resolves.toBe(debugId);
3739
});
3840

41+
test('Should stop reading after finding the debug ID in the first chunk', async () => {
42+
const literal = `ddDebugId:"${debugId}"`;
43+
const read = jest.fn(async (buffer: Buffer) => {
44+
buffer.write(literal);
45+
return { bytesRead: literal.length, buffer };
46+
});
47+
const close = jest.fn(async () => undefined);
48+
jest.spyOn(fsp, 'open').mockResolvedValue({ read, close } as never);
49+
50+
await expect(extractDebugId('first-chunk.min.js')).resolves.toBe(debugId);
51+
expect(read).toHaveBeenCalledTimes(1);
52+
expect(close).toHaveBeenCalledTimes(1);
53+
});
54+
3955
test('Should return undefined when there is no debug ID in the content', async () => {
4056
const filePath = path.join(tempDir, 'no-debug-id.min.js');
4157
outputFileSync(
@@ -46,6 +62,35 @@ describe('extractDebugId', () => {
4662
await expect(extractDebugId(filePath)).resolves.toBeUndefined();
4763
});
4864

65+
test('Should progressively find a debug ID after the first chunk', async () => {
66+
const filePath = path.join(tempDir, 'later-debug-id.min.js');
67+
outputFileSync(
68+
filePath,
69+
`${'x'.repeat(DEBUG_ID_SEARCH_CHUNK_BYTES + 100)}ddDebugId:"${debugId}"`,
70+
);
71+
72+
await expect(extractDebugId(filePath)).resolves.toBe(debugId);
73+
});
74+
75+
test('Should find a debug ID split across two chunks', async () => {
76+
const filePath = path.join(tempDir, 'split-debug-id.min.js');
77+
const literal = `ddDebugId:"${debugId}"`;
78+
const literalPrefixBytes = 20;
79+
outputFileSync(
80+
filePath,
81+
`${'x'.repeat(DEBUG_ID_SEARCH_CHUNK_BYTES - literalPrefixBytes)}${literal}`,
82+
);
83+
84+
await expect(extractDebugId(filePath)).resolves.toBe(debugId);
85+
});
86+
87+
test('Should scan to EOF and return undefined when a large file has no debug ID', async () => {
88+
const filePath = path.join(tempDir, 'large-no-debug-id.min.js');
89+
outputFileSync(filePath, 'x'.repeat(DEBUG_ID_SEARCH_CHUNK_BYTES * 3 + 100));
90+
91+
await expect(extractDebugId(filePath)).resolves.toBeUndefined();
92+
});
93+
4994
test('Should return undefined when the file cannot be read', async () => {
5095
const filePath = path.join(tempDir, 'missing.min.js');
5196

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

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,26 +13,42 @@ import fsp from 'fs/promises';
1313
// bundler renaming step.
1414
const DEBUG_ID_RX = /"?ddDebugId"?:"([0-9a-fA-F-]{36})"/;
1515

16-
// The RUM plugin injects its snippet as a BEFORE-position banner (packages/plugins/rum/src/index.ts),
17-
// so the ddDebugId literal always lands within the file's first couple hundred bytes, regardless
18-
// of the file's total size — no need to read the whole (potentially large) minified bundle to
19-
// find it. Measured against ~2.8k real built chunks (mixed bundlers/minifiers), the match always
20-
// ended by byte 242; this leaves ~4x headroom for longer service/version strings.
21-
export const DEBUG_ID_SEARCH_PREFIX_BYTES = 1024;
16+
// Read progressively so the common case only needs the first KiB, while still supporting
17+
// bundlers or transforms that place the injected snippet later in the artifact.
18+
export const DEBUG_ID_SEARCH_CHUNK_BYTES = 1024;
19+
20+
// Keep enough content from the previous chunk to match a debug ID literal split across a read
21+
// boundary. The longest supported literal is shorter than this overlap.
22+
const DEBUG_ID_SEARCH_OVERLAP_CHARACTERS = 64;
2223

2324
const matchDebugId = (fileContent: string): string | undefined => {
2425
return DEBUG_ID_RX.exec(fileContent)?.[1];
2526
};
2627

27-
// Read only the first DEBUG_ID_SEARCH_PREFIX_BYTES bytes of the file, since that's all
28-
// we need to find the ddDebugId literal and reading the whole (potentially large)
29-
// minified bundle into memory would be wasteful.
30-
const readFilePrefix = async (filePath: string): Promise<string> => {
28+
// Search in fixed-size reads and stop as soon as the debug ID is found. Only a small overlap is
29+
// retained between reads, so even the worst case (scanning to EOF) uses bounded memory.
30+
const readDebugId = async (filePath: string): Promise<string | undefined> => {
3131
const fd = await fsp.open(filePath, 'r');
3232
try {
33-
const buffer = Buffer.alloc(DEBUG_ID_SEARCH_PREFIX_BYTES);
34-
const { bytesRead } = await fd.read(buffer, 0, DEBUG_ID_SEARCH_PREFIX_BYTES, 0);
35-
return buffer.toString('utf-8', 0, bytesRead);
33+
const buffer = Buffer.alloc(DEBUG_ID_SEARCH_CHUNK_BYTES);
34+
let overlap = '';
35+
let position = 0;
36+
37+
while (true) {
38+
const { bytesRead } = await fd.read(buffer, 0, DEBUG_ID_SEARCH_CHUNK_BYTES, position);
39+
if (bytesRead === 0) {
40+
return undefined;
41+
}
42+
43+
const searchableContent = overlap + buffer.toString('utf-8', 0, bytesRead);
44+
const debugId = matchDebugId(searchableContent);
45+
if (debugId) {
46+
return debugId;
47+
}
48+
49+
overlap = searchableContent.slice(-DEBUG_ID_SEARCH_OVERLAP_CHARACTERS);
50+
position += bytesRead;
51+
}
3652
} finally {
3753
await fd.close();
3854
}
@@ -43,6 +59,5 @@ const readFilePrefix = async (filePath: string): Promise<string> => {
4359
// rename the file after injection (e.g. webpack/rspack's realContentHash), but the content,
4460
// and the debug_id embedded in it, is unaffected.
4561
export const extractDebugId = async (filePath: string): Promise<string | undefined> => {
46-
const fileContent = await readFilePrefix(filePath).catch(() => undefined);
47-
return fileContent ? matchDebugId(fileContent) : undefined;
62+
return readDebugId(filePath).catch(() => undefined);
4863
};

0 commit comments

Comments
 (0)