From ccf807d83ac84c89a1ffa20904392cdd609e7883 Mon Sep 17 00:00:00 2001 From: Hugo Silva Date: Fri, 14 Aug 2026 15:32:03 +0200 Subject: [PATCH] fix(error-tracking): search debug IDs progressively --- .../src/sourcemaps/debugId.test.ts | 47 ++++++++++++++++++- .../error-tracking/src/sourcemaps/debugId.ts | 45 ++++++++++++------ 2 files changed, 76 insertions(+), 16 deletions(-) diff --git a/packages/plugins/error-tracking/src/sourcemaps/debugId.test.ts b/packages/plugins/error-tracking/src/sourcemaps/debugId.test.ts index 7f5c810c4..0fcf2a449 100644 --- a/packages/plugins/error-tracking/src/sourcemaps/debugId.test.ts +++ b/packages/plugins/error-tracking/src/sourcemaps/debugId.test.ts @@ -3,16 +3,18 @@ // Copyright 2019-Present Datadog, Inc. import { outputFileSync, rmSync } from '@dd/core/helpers/fs'; +import fsp from 'fs/promises'; import os from 'os'; import path from 'path'; -import { extractDebugId } from './debugId'; +import { DEBUG_ID_SEARCH_CHUNK_BYTES, extractDebugId } from './debugId'; describe('extractDebugId', () => { const debugId = '93fd4850-7b77-4f2e-9aa2-ba013e1a5027'; const tempDir = path.join(os.tmpdir(), 'dd-build-plugins-debug-id-test'); afterEach(() => { + jest.restoreAllMocks(); rmSync(tempDir); }); @@ -36,6 +38,20 @@ describe('extractDebugId', () => { await expect(extractDebugId(filePath)).resolves.toBe(debugId); }); + test('Should stop reading after finding the debug ID in the first chunk', async () => { + const literal = `ddDebugId:"${debugId}"`; + const read = jest.fn(async (buffer: Buffer) => { + buffer.write(literal); + return { bytesRead: literal.length, buffer }; + }); + const close = jest.fn(async () => undefined); + jest.spyOn(fsp, 'open').mockResolvedValue({ read, close } as never); + + await expect(extractDebugId('first-chunk.min.js')).resolves.toBe(debugId); + expect(read).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + }); + test('Should return undefined when there is no debug ID in the content', async () => { const filePath = path.join(tempDir, 'no-debug-id.min.js'); outputFileSync( @@ -46,6 +62,35 @@ describe('extractDebugId', () => { await expect(extractDebugId(filePath)).resolves.toBeUndefined(); }); + test('Should progressively find a debug ID after the first chunk', async () => { + const filePath = path.join(tempDir, 'later-debug-id.min.js'); + outputFileSync( + filePath, + `${'x'.repeat(DEBUG_ID_SEARCH_CHUNK_BYTES + 100)}ddDebugId:"${debugId}"`, + ); + + await expect(extractDebugId(filePath)).resolves.toBe(debugId); + }); + + test('Should find a debug ID split across two chunks', async () => { + const filePath = path.join(tempDir, 'split-debug-id.min.js'); + const literal = `ddDebugId:"${debugId}"`; + const literalPrefixBytes = 20; + outputFileSync( + filePath, + `${'x'.repeat(DEBUG_ID_SEARCH_CHUNK_BYTES - literalPrefixBytes)}${literal}`, + ); + + await expect(extractDebugId(filePath)).resolves.toBe(debugId); + }); + + test('Should scan to EOF and return undefined when a large file has no debug ID', async () => { + const filePath = path.join(tempDir, 'large-no-debug-id.min.js'); + outputFileSync(filePath, 'x'.repeat(DEBUG_ID_SEARCH_CHUNK_BYTES * 3 + 100)); + + await expect(extractDebugId(filePath)).resolves.toBeUndefined(); + }); + test('Should return undefined when the file cannot be read', async () => { const filePath = path.join(tempDir, 'missing.min.js'); diff --git a/packages/plugins/error-tracking/src/sourcemaps/debugId.ts b/packages/plugins/error-tracking/src/sourcemaps/debugId.ts index bb543fd89..85ac74c71 100644 --- a/packages/plugins/error-tracking/src/sourcemaps/debugId.ts +++ b/packages/plugins/error-tracking/src/sourcemaps/debugId.ts @@ -13,26 +13,42 @@ import fsp from 'fs/promises'; // bundler renaming step. const DEBUG_ID_RX = /"?ddDebugId"?:"([0-9a-fA-F-]{36})"/; -// The RUM plugin injects its snippet as a BEFORE-position banner (packages/plugins/rum/src/index.ts), -// so the ddDebugId literal always lands within the file's first couple hundred bytes, regardless -// of the file's total size — no need to read the whole (potentially large) minified bundle to -// find it. Measured against ~2.8k real built chunks (mixed bundlers/minifiers), the match always -// ended by byte 242; this leaves ~4x headroom for longer service/version strings. -export const DEBUG_ID_SEARCH_PREFIX_BYTES = 1024; +// Read progressively so the common case only needs the first KiB, while still supporting +// bundlers or transforms that place the injected snippet later in the artifact. +export const DEBUG_ID_SEARCH_CHUNK_BYTES = 1024; + +// Keep enough content from the previous chunk to match a debug ID literal split across a read +// boundary. The longest supported literal is shorter than this overlap. +const DEBUG_ID_SEARCH_OVERLAP_CHARACTERS = 64; const matchDebugId = (fileContent: string): string | undefined => { return DEBUG_ID_RX.exec(fileContent)?.[1]; }; -// Read only the first DEBUG_ID_SEARCH_PREFIX_BYTES bytes of the file, since that's all -// we need to find the ddDebugId literal and reading the whole (potentially large) -// minified bundle into memory would be wasteful. -const readFilePrefix = async (filePath: string): Promise => { +// Search in fixed-size reads and stop as soon as the debug ID is found. Only a small overlap is +// retained between reads, so even the worst case (scanning to EOF) uses bounded memory. +const readDebugId = async (filePath: string): Promise => { const fd = await fsp.open(filePath, 'r'); try { - const buffer = Buffer.alloc(DEBUG_ID_SEARCH_PREFIX_BYTES); - const { bytesRead } = await fd.read(buffer, 0, DEBUG_ID_SEARCH_PREFIX_BYTES, 0); - return buffer.toString('utf-8', 0, bytesRead); + const buffer = Buffer.alloc(DEBUG_ID_SEARCH_CHUNK_BYTES); + let overlap = ''; + let position = 0; + + while (true) { + const { bytesRead } = await fd.read(buffer, 0, DEBUG_ID_SEARCH_CHUNK_BYTES, position); + if (bytesRead === 0) { + return undefined; + } + + const searchableContent = overlap + buffer.toString('utf-8', 0, bytesRead); + const debugId = matchDebugId(searchableContent); + if (debugId) { + return debugId; + } + + overlap = searchableContent.slice(-DEBUG_ID_SEARCH_OVERLAP_CHARACTERS); + position += bytesRead; + } } finally { await fd.close(); } @@ -43,6 +59,5 @@ const readFilePrefix = async (filePath: string): Promise => { // rename the file after injection (e.g. webpack/rspack's realContentHash), but the content, // and the debug_id embedded in it, is unaffected. export const extractDebugId = async (filePath: string): Promise => { - const fileContent = await readFilePrefix(filePath).catch(() => undefined); - return fileContent ? matchDebugId(fileContent) : undefined; + return readDebugId(filePath).catch(() => undefined); };