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
2 changes: 1 addition & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export type ChunkInfo = {
};

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

export enum InjectPosition {
BEFORE,
Expand Down
54 changes: 54 additions & 0 deletions packages/plugins/error-tracking/src/sourcemaps/debugId.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// 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 { outputFileSync, rmSync } from '@dd/core/helpers/fs';
import os from 'os';
import path from 'path';

import { 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(() => {
rmSync(tempDir);
});

test('Should extract the debug ID when the key is quoted (unminified JSON.stringify output)', async () => {
const filePath = path.join(tempDir, 'quoted.min.js');
outputFileSync(
filePath,
`!function(){}({"service":"app","version":"1.0.0","ddDebugId":"${debugId}"},"DD_SOURCE_CODE_CONTEXT");`,
);

await expect(extractDebugId(filePath)).resolves.toBe(debugId);
});

test('Should extract the debug ID when the key is unquoted (minifiers strip quotes from valid identifier keys)', async () => {
const filePath = path.join(tempDir, 'unquoted.min.js');
outputFileSync(
filePath,
`!function(){}({service:"app",version:"1.0.0",ddDebugId:"${debugId}"},"DD_SOURCE_CODE_CONTEXT");`,
);

await expect(extractDebugId(filePath)).resolves.toBe(debugId);
});

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(
filePath,
`!function(){}({service:"app",version:"1.0.0"},"DD_SOURCE_CODE_CONTEXT");`,
);

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');

await expect(extractDebugId(filePath)).resolves.toBeUndefined();
});
});
48 changes: 48 additions & 0 deletions packages/plugins/error-tracking/src/sourcemaps/debugId.ts
Original file line number Diff line number Diff line change
@@ -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 fsp from 'fs/promises';

// Matches the `ddDebugId:"<uuid>"` literal the RUM plugin injects into each chunk's own content
// (see packages/plugins/rum/src/getSourceCodeContextSnippet.ts). The key is quoted in source
// (`JSON.stringify(context)`) but minifiers like terser strip quotes from object keys that are
// valid identifiers, so the built output can have either `"ddDebugId":"..."` or `ddDebugId:"..."`.
// Reading it back out of the file we're about to upload means we never have to trust a filename
// as a coordination key between the RUM plugin and this one, so it stays correct across any
// 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;

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> => {
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);
} finally {
await fd.close();
}
};

// Reads the minified file's own content and extracts the debug_id from it, instead of
// trusting a filename as a coordination key with the RUM plugin — the bundler may still
// 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;
};
3 changes: 3 additions & 0 deletions packages/plugins/error-tracking/src/sourcemaps/payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type Metadata = {
version: string;
git_repository_url?: string;
git_commit_sha?: string;
debug_id?: string;
};

type SourcemapValidity = {
Expand Down Expand Up @@ -87,6 +88,7 @@ export const getPayload = async (
metadata: Metadata,
prefix: string,
git?: RepositoryData,
debugId?: string,
): Promise<Payload> => {
const validity = await getSourcemapValidity(sourcemap, prefix);
const errors: string[] = [];
Expand All @@ -102,6 +104,7 @@ export const getPayload = async (
},
value: JSON.stringify({
...metadata,
debug_id: debugId,
minified_url: sourcemap.minifiedUrl,
}),
},
Expand Down
52 changes: 51 additions & 1 deletion packages/plugins/error-tracking/src/sourcemaps/sender.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

import { outputFileSync, rmSync } from '@dd/core/helpers/fs';
import { doRequest } from '@dd/core/helpers/request';
import {
getData,
Expand All @@ -21,6 +22,10 @@ import {
getSourcemapsConfiguration,
addFixtureFiles,
} from '@dd/tests/_jest/helpers/mocks';
import os from 'os';
import path from 'path';

import * as payloadModule from './payload';

jest.mock('@dd/core/helpers/fs', () => {
const original = jest.requireActual('@dd/core/helpers/fs');
Expand Down Expand Up @@ -139,7 +144,9 @@ describe('Error Tracking Plugin Sourcemaps', () => {
mockLogger,
);

expect(mockLogFn).toHaveBeenCalledTimes(1);
// Only the debug ID extraction summary (debug) and the payload error (error)
// should be logged — the debug summary logs unconditionally before the error check.
expect(mockLogFn.mock.calls.filter(([, level]) => level === 'error')).toHaveLength(1);
expect(mockLogFn).toHaveBeenCalledWith(
expect.stringMatching('Failed to prepare payloads, aborting upload'),
'error',
Expand All @@ -163,6 +170,49 @@ describe('Error Tracking Plugin Sourcemaps', () => {
}).rejects.toThrow('Failed to prepare payloads, aborting upload');
expect(doRequestMock).not.toHaveBeenCalled();
});

test('Should resolve the debug ID straight from the minified file content, regardless of its filename', async () => {
// The minified file's name here has nothing to do with the debug ID lookup —
// it's extracted from the file's own content, so it survives any bundler
// renaming step (e.g. webpack/rspack's realContentHash) that happens after
// the RUM plugin injects it.
const debugId = '12345678-1234-4123-8123-123456789012';
// Minifiers strip quotes from object keys that are valid identifiers, so the
// real on-disk shape has an unquoted key, not `"ddDebugId":"..."`.
const minifiedFileContent = `Some JS File with some content.(function(c,n){...})({ddDebugId:"${debugId}"},"DD_SOURCE_CODE_CONTEXT");`;
// debugId.ts reads the minified file straight off disk (not through a mockable
// fs helper), so it needs a real file on top of the virtual fixture used for
// the checkFile validity checks below.
const tempDir = path.join(os.tmpdir(), 'dd-build-plugins-sender-debug-id-test');
const minifiedFilePath = path.join(tempDir, 'minified.min.js');
outputFileSync(minifiedFilePath, minifiedFileContent);

addFixtureFiles({
[minifiedFilePath]: minifiedFileContent,
'/path/to/sourcemap.js.map': '{"version":3,"sources":["/path/to/minified.min.js"]}',
});

const getPayloadSpy = jest.spyOn(payloadModule, 'getPayload');

const sourcemap = getSourcemapMock({
minifiedFilePath,
relativePath: 'path/to/minified.min.js',
});

await sendSourcemaps(
[sourcemap],
getSourcemapsConfiguration(),
senderContextMock,
mockLogger,
);

expect(getPayloadSpy).toHaveBeenCalledTimes(1);
const debugIdArg = getPayloadSpy.mock.calls[0][4];
expect(debugIdArg).toBe(debugId);

getPayloadSpy.mockRestore();
rmSync(tempDir);
});
});

describe('upload', () => {
Expand Down
18 changes: 16 additions & 2 deletions packages/plugins/error-tracking/src/sourcemaps/sender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import PQueue from 'p-queue';

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

import { extractDebugId } from './debugId';
import type { Metadata, MultipartFileValue, Payload } from './payload';
import { getPayload } from './payload';
import {
Expand Down Expand Up @@ -199,10 +200,23 @@ export const sendSourcemaps = async (
};

const payloadsTimer = log.time('Compute payloads');
const payloads = await Promise.all(
sourcemaps.map((sourcemap) => getPayload(sourcemap, metadata, prefix, context.git)),
// @ts-expect-error PQueue's default isn't typed.
const Queue = PQueue.default ? PQueue.default : PQueue;
const payloadsQueue = new Queue({ concurrency: options.maxConcurrency });
let debugIdCount = 0;
const payloads: Payload[] = await payloadsQueue.addAll(
sourcemaps.map((sourcemap) => async () => {
const debugId = await extractDebugId(sourcemap.minifiedFilePath);
if (debugId) {
debugIdCount += 1;
}
return getPayload(sourcemap, metadata, prefix, context.git, debugId);
}),
);
payloadsTimer.end();
log.debug(
`Extracted debug_id for ${green(`${debugIdCount}/${sourcemaps.length}`)} sourcemaps.`,
);

const errors = payloads.map((payload) => payload.errors).flat();
const warnings = payloads.map((payload) => payload.warnings).flat();
Expand Down
6 changes: 3 additions & 3 deletions packages/plugins/injection/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,10 @@ export const prepareInjections = async (
contentsToInject: ContentsToInject,
cwd: string = process.cwd(),
) => {
// Per-chunk functions: adapt from public API (sourceOrHash?: string) to internal (chunk: ChunkInfo).
// Per-chunk functions receive the full ChunkInfo for the chunk they're injected into.
const dynamicPerChunk = toInject.filter(isPerChunk).map((item) => {
const userFn = item.value as (sourceOrHash?: string) => string;
return { ...item, value: (chunk: ChunkInfo) => userFn(chunk.sourceOrHash) };
const userFn = item.value as (chunk?: ChunkInfo) => string;
return { ...item, value: (chunk: ChunkInfo) => userFn(chunk) };
});

// Static items (strings and async loaders) are resolved once per build.
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/injection/src/xpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export const getXpackPlugin =
continue;
}

const fileName = path.basename(file);
const fileName = base;
// Resolve static and per-chunk content in one pass.
const banner = getContentToInject(contentsToInject, InjectPosition.BEFORE, {
sourceOrHash: chunkSource,
Expand Down
17 changes: 13 additions & 4 deletions packages/plugins/rum/src/getSourceCodeContextSnippet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

import type { ChunkInfo } from '@dd/core/types';
import { randomUUID } from 'crypto';

import { stringToUUID } from './debugId';
Expand All @@ -28,10 +29,16 @@ type SourceCodeContext = {
version?: string;
ddDebugId?: string;
};

export type SourceCodeContextSnippet = {
code: string;
debugId?: string;
};

export const getSourceCodeContextSnippet = (
contextOptions: SourceCodeContextOptions,
codeOrHash?: string,
): string => {
chunk?: ChunkInfo,
): SourceCodeContextSnippet => {
const context: SourceCodeContext = {
service: contextOptions.service,
version: contextOptions.version,
Expand All @@ -42,8 +49,10 @@ export const getSourceCodeContextSnippet = (
//
// The `dd` prefix in `ddDebugId` allows upload tools (for example, datadog-ci) to reliably locate the
// debug ID with a regex and send it as upload metadata alongside the source map.
context.ddDebugId = codeOrHash ? stringToUUID(codeOrHash) : randomUUID();
context.ddDebugId = chunk ? stringToUUID(chunk.sourceOrHash) : 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)});`;
const code = `(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)});`;

return { code, debugId: context.ddDebugId };
};
8 changes: 7 additions & 1 deletion packages/plugins/rum/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,13 @@ export const getPlugins: GetPlugins = ({ options, context }) => {
type: 'code',
position: InjectPosition.BEFORE,
injectIntoAllChunks: true,
value: (sourceOrHash) => getSourceCodeContextSnippet(sourceCodeContext, sourceOrHash),
value: (chunk) => {
// The debug_id is embedded directly in the returned code (see
// getSourceCodeContextSnippet.ts); error-tracking reads it back out of the
// built file's content at upload time, so it doesn't need to be tracked here.
const { code } = getSourceCodeContextSnippet(sourceCodeContext, chunk);
return code;
},
});
}

Expand Down
Loading