Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion packages/plugins/error-tracking/src/sourcemaps/debugId.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand All @@ -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);
});
Comment on lines +41 to +53

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(
Expand All @@ -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');

Expand Down
45 changes: 30 additions & 15 deletions packages/plugins/error-tracking/src/sourcemaps/debugId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> => {
// 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<string | undefined> => {
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();
}
Expand All @@ -43,6 +59,5 @@ const readFilePrefix = async (filePath: string): Promise<string> => {
// 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<string | undefined> => {
const fileContent = await readFilePrefix(filePath).catch(() => undefined);
return fileContent ? matchDebugId(fileContent) : undefined;
return readDebugId(filePath).catch(() => undefined);
};
Loading