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
4 changes: 4 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ export type TriggerHook<R> = <K extends keyof CustomHooks>(
) => R;
export type GlobalContext = {
addMetric: (metric: Metric) => void;
artifactsPending: boolean;
artifactsReady: Promise<void>;
asyncHook: TriggerHook<Promise<void[]>>;
auth: AuthOptionsWithDefaults;
build: BuildReport;
Expand All @@ -198,6 +200,8 @@ export type GlobalContext = {
git?: RepositoryData;
hook: TriggerHook<void>;
inject: (item: ToInjectItem) => void;
markArtifactsPending: () => void;
markArtifactsReady: (error?: unknown) => void;
pluginNames: string[];
plugins: (PluginOptions | CustomPluginOptions)[];
queue: (promise: Promise<any>) => void;
Expand Down
46 changes: 45 additions & 1 deletion packages/factory/src/helpers/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,54 @@

import type { Options, GlobalContext } from '@dd/core/types';
import { BUNDLER_VERSIONS } from '@dd/tests/_jest/helpers/constants';
import { defaultPluginOptions } from '@dd/tests/_jest/helpers/mocks';
import { defaultPluginOptions, getMockData, getMockStores } from '@dd/tests/_jest/helpers/mocks';
import { BUNDLERS, runBundlers } from '@dd/tests/_jest/helpers/runBundlers';

import { getContext } from './context';

describe('Factory Helpers', () => {
describe('artifacts-ready barrier', () => {
const createContext = () =>
getContext({
start: Date.now(),
options: defaultPluginOptions,
data: getMockData(),
stores: getMockStores(),
});

test('Should start ready and wait while artifacts are pending.', async () => {
const context = createContext();
await expect(context.artifactsReady).resolves.toBeUndefined();

context.markArtifactsPending();
let ready = false;
context.artifactsReady.then(() => {
ready = true;
});
await Promise.resolve();
expect(ready).toBe(false);

context.markArtifactsReady();
await expect(context.artifactsReady).resolves.toBeUndefined();
expect(ready).toBe(true);
});

test('Should reject a failed rewrite and support a later build.', async () => {
const context = createContext();
const injectionError = new Error('injection failed');

context.markArtifactsPending();
const failedBuild = context.artifactsReady;
context.markArtifactsReady(injectionError);
await expect(failedBuild).rejects.toBe(injectionError);

context.markArtifactsPending();
const nextBuild = context.artifactsReady;
context.markArtifactsReady();
await expect(nextBuild).resolves.toBeUndefined();
});
});

// Intercept contexts to verify it at the moment they're used.
const initialContexts: Record<string, GlobalContext> = {};
const buildRoots: Record<string, string> = {};
Expand Down
34 changes: 34 additions & 0 deletions packages/factory/src/helpers/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export const getContext = ({
addMetric: () => {
throw new Error('AddMetric function called before it was initialized.');
},
artifactsPending: false,
artifactsReady: Promise.resolve(),
auth: options.auth,
pluginNames: [],
bundler: {
Expand All @@ -61,6 +63,8 @@ export const getContext = ({
inject: () => {
throw new Error('Inject function called before it was initialized.');
},
markArtifactsPending: () => {},
markArtifactsReady: () => {},
plugins: [],
// This will be updated in the async-queue plugin on initialization.
queue: () => {
Expand All @@ -71,5 +75,35 @@ export const getContext = ({
version: data.version,
};

let resolveArtifacts: (() => void) | undefined;
let rejectArtifacts: ((error: unknown) => void) | undefined;

context.markArtifactsPending = () => {
if (resolveArtifacts || rejectArtifacts) {
return;
}

context.artifactsReady = new Promise<void>((resolve, reject) => {
resolveArtifacts = resolve;
rejectArtifacts = reject;
});
context.artifactsPending = true;
context.artifactsReady.catch(() => undefined);
};

context.markArtifactsReady = (error?: unknown) => {
const resolve = resolveArtifacts;
const reject = rejectArtifacts;
resolveArtifacts = undefined;
rejectArtifacts = undefined;
context.artifactsPending = false;

if (error === undefined) {
resolve?.();
} else {
reject?.(error);
}
};

return context;
};
60 changes: 59 additions & 1 deletion packages/plugins/error-tracking/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

import { extractDebugId } from '@dd/error-tracking-plugin/sourcemaps/debugId';
import { uploadSourcemaps } from '@dd/error-tracking-plugin/sourcemaps/index';
import { getPlugins } from '@dd/error-tracking-plugin';
import { getGetPluginsArg, getSourcemapsConfiguration } from '@dd/tests/_jest/helpers/mocks';
import {
getGetPluginsArg,
getMockBuildReport,
getSourcemapsConfiguration,
hardProjectEntries,
} from '@dd/tests/_jest/helpers/mocks';
import { BUNDLERS, runBundlers } from '@dd/tests/_jest/helpers/runBundlers';

jest.mock('@dd/error-tracking-plugin/sourcemaps/index', () => {
Expand Down Expand Up @@ -67,4 +73,56 @@ describe('Error Tracking Plugin', () => {

expect(uploadSourcemapsMock).not.toHaveBeenCalled();
});

test('Should wait for artifacts and deduplicate concurrent lifecycle hooks.', async () => {
let markArtifactsReady!: () => void;
const artifactsReady = new Promise<void>((resolve) => {
markArtifactsReady = resolve;
});
const arg = getGetPluginsArg(
{
enableGit: false,
errorTracking: { sourcemaps: getSourcemapsConfiguration() },
},
{ artifactsPending: true, artifactsReady },
);
const plugin = getPlugins(arg)[0];

const buildReportHook = plugin.buildReport!(getMockBuildReport());
const trueEndHook = plugin.asyncTrueEnd!();
await Promise.resolve();
expect(uploadSourcemapsMock).not.toHaveBeenCalled();

markArtifactsReady();
await Promise.all([buildReportHook, trueEndHook]);
expect(uploadSourcemapsMock).toHaveBeenCalledTimes(1);
});

test('Should expose all esbuild debug IDs before sourcemap upload.', async () => {
const debugIdsAtUpload: (string | undefined)[] = [];
uploadSourcemapsMock.mockImplementationOnce(async (_options, context) => {
const javascriptOutputs = (context.outputs || []).filter(({ filepath }) =>
filepath.endsWith('.js'),
);
debugIdsAtUpload.push(
...(await Promise.all(
javascriptOutputs.map(({ filepath }) => extractDebugId(filepath)),
)),
);
});

const { errors } = await runBundlers(
{
enableGit: false,
errorTracking: { sourcemaps: getSourcemapsConfiguration() },
rum: { sourceCodeContext: { debugId: true } },
},
{ entry: hardProjectEntries, splitting: true },
['esbuild'],
);

expect(errors).toHaveLength(0);
expect(debugIdsAtUpload.length).toBeGreaterThan(2);
expect(debugIdsAtUpload).not.toContain(undefined);
});
});
58 changes: 37 additions & 21 deletions packages/plugins/error-tracking/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,30 +28,46 @@ export const getPlugins: GetPlugins = ({ options, context }) => {
let gitInfo: RepositoryData | undefined;
let buildReport: BuildReport | undefined;
let sourcemapsHandled: boolean = false;
let sourcemapsHandling: Promise<void> | undefined;

const handleSourcemaps = async () => {
if (!validatedOptions.sourcemaps || sourcemapsHandled) {
return;
}
sourcemapsHandled = true;
const totalTime = log.time('sourcemaps process');
await uploadSourcemaps(
// Need the "as" because Typescript doesn't understand that we've already checked for sourcemaps.
validatedOptions as ErrorTrackingOptionsWithSourcemaps,
{
apiKey: context.auth.apiKey,
bundlerName: context.bundler.name,
git: gitInfo,
addMetric: context.addMetric,
outDir: context.bundler.outDir,
outputs: buildReport?.outputs || [],
sendMetrics: sendSourcemapUploadMetrics,
site: context.auth.site,
version: context.version,
},
log,
);
totalTime.end();
if (!sourcemapsHandling) {
sourcemapsHandling = (async () => {
await context.artifactsReady;
sourcemapsHandled = true;
const totalTime = log.time('sourcemaps process');
await uploadSourcemaps(
// Need the "as" because Typescript doesn't understand that we've already checked for sourcemaps.
validatedOptions as ErrorTrackingOptionsWithSourcemaps,
{
apiKey: context.auth.apiKey,
bundlerName: context.bundler.name,
git: gitInfo,
addMetric: context.addMetric,
outDir: context.bundler.outDir,
outputs: buildReport?.outputs || [],
sendMetrics: sendSourcemapUploadMetrics,
site: context.auth.site,
version: context.version,
},
log,
);
totalTime.end();
})();
}

await sourcemapsHandling;
};

const handleOrQueueSourcemaps = async () => {
if (context.artifactsPending) {
context.queue(handleSourcemaps());
return;
}
await handleSourcemaps();
};

const plugins: ReturnType<GetPlugins> = [
Expand All @@ -62,14 +78,14 @@ export const getPlugins: GetPlugins = ({ options, context }) => {
gitInfo = repoData;

if (buildReport) {
await handleSourcemaps();
await handleOrQueueSourcemaps();
}
},
async buildReport(report) {
buildReport = report;

if (gitInfo || !shouldGetGitInfo(options)) {
await handleSourcemaps();
await handleOrQueueSourcemaps();
}
},
async asyncTrueEnd() {
Expand Down
Loading
Loading