Skip to content

feat(core): Expose bundler plugin primitives via createSentryBuildPluginManager #713

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
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
591 changes: 591 additions & 0 deletions packages/bundler-plugin-core/src/api-primitives.ts

Large diffs are not rendered by default.

195 changes: 6 additions & 189 deletions packages/bundler-plugin-core/src/debug-id-upload.ts
Original file line number Diff line number Diff line change
@@ -1,207 +1,24 @@
import fs from "fs";
import { glob } from "glob";
import os from "os";
import path from "path";
import * as util from "util";
import { Logger } from "./sentry/logger";
import { promisify } from "util";
import SentryCli from "@sentry/cli";
import { dynamicSamplingContextToSentryBaggageHeader } from "@sentry/utils";
import { safeFlushTelemetry } from "./sentry/telemetry";
import { stripQueryAndHashFromPath } from "./utils";
import { setMeasurement, spanToTraceHeader, startSpan } from "@sentry/core";
import { getDynamicSamplingContextFromSpan, Scope } from "@sentry/core";
import { Client } from "@sentry/types";
import { HandleRecoverableErrorFn } from "./types";
import { SentryBuildPluginManager } from "./api-primitives";
import { Logger } from "./sentry/logger";

interface RewriteSourcesHook {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(source: string, map: any): string;
}

interface DebugIdUploadPluginOptions {
logger: Logger;
assets?: string | string[];
ignore?: string | string[];
releaseName?: string;
dist?: string;
rewriteSourcesHook?: RewriteSourcesHook;
handleRecoverableError: HandleRecoverableErrorFn;
sentryScope: Scope;
sentryClient: Client;
sentryCliOptions: {
url: string;
authToken: string;
org?: string;
project: string;
vcsRemote: string;
silent: boolean;
headers?: Record<string, string>;
};
createDependencyOnSourcemapFiles: () => () => void;
sentryBuildPluginManager: SentryBuildPluginManager;
}

export function createDebugIdUploadFunction({
assets,
ignore,
logger,
releaseName,
dist,
handleRecoverableError,
sentryScope,
sentryClient,
sentryCliOptions,
rewriteSourcesHook,
createDependencyOnSourcemapFiles,
sentryBuildPluginManager,
}: DebugIdUploadPluginOptions) {
const freeGlobalDependencyOnSourcemapFiles = createDependencyOnSourcemapFiles();

return async (buildArtifactPaths: string[]) => {
await startSpan(
// This is `forceTransaction`ed because this span is used in dashboards in the form of indexed transactions.
{ name: "debug-id-sourcemap-upload", scope: sentryScope, forceTransaction: true },
async () => {
let folderToCleanUp: string | undefined;

// It is possible that this writeBundle hook (which calls this function) is called multiple times in one build (for example when reusing the plugin, or when using build tooling like `@vitejs/plugin-legacy`)
// Therefore we need to actually register the execution of this hook as dependency on the sourcemap files.
const freeUploadDependencyOnSourcemapFiles = createDependencyOnSourcemapFiles();

try {
const tmpUploadFolder = await startSpan(
{ name: "mkdtemp", scope: sentryScope },
async () => {
return await fs.promises.mkdtemp(
path.join(os.tmpdir(), "sentry-bundler-plugin-upload-")
);
}
);

folderToCleanUp = tmpUploadFolder;

let globAssets: string | string[];
if (assets) {
globAssets = assets;
} else {
logger.debug(
"No `sourcemaps.assets` option provided, falling back to uploading detected build artifacts."
);
globAssets = buildArtifactPaths;
}

const globResult = await startSpan(
{ name: "glob", scope: sentryScope },
async () => await glob(globAssets, { absolute: true, nodir: true, ignore: ignore })
);

const debugIdChunkFilePaths = globResult.filter((debugIdChunkFilePath) => {
return !!stripQueryAndHashFromPath(debugIdChunkFilePath).match(/\.(js|mjs|cjs)$/);
});

// The order of the files output by glob() is not deterministic
// Ensure order within the files so that {debug-id}-{chunkIndex} coupling is consistent
debugIdChunkFilePaths.sort();

if (Array.isArray(assets) && assets.length === 0) {
logger.debug(
"Empty `sourcemaps.assets` option provided. Will not upload sourcemaps with debug ID."
);
} else if (debugIdChunkFilePaths.length === 0) {
logger.warn(
"Didn't find any matching sources for debug ID upload. Please check the `sourcemaps.assets` option."
);
} else {
await startSpan(
{ name: "prepare-bundles", scope: sentryScope },
async (prepBundlesSpan) => {
// Preparing the bundles can be a lot of work and doing it all at once has the potential of nuking the heap so
// instead we do it with a maximum of 16 concurrent workers
const preparationTasks = debugIdChunkFilePaths.map(
(chunkFilePath, chunkIndex) => async () => {
await prepareBundleForDebugIdUpload(
chunkFilePath,
tmpUploadFolder,
chunkIndex,
logger,
rewriteSourcesHook ?? defaultRewriteSourcesHook
);
}
);
const workers: Promise<void>[] = [];
const worker = async () => {
while (preparationTasks.length > 0) {
const task = preparationTasks.shift();
if (task) {
await task();
}
}
};
for (let workerIndex = 0; workerIndex < 16; workerIndex++) {
workers.push(worker());
}

await Promise.all(workers);

const files = await fs.promises.readdir(tmpUploadFolder);
const stats = files.map((file) =>
fs.promises.stat(path.join(tmpUploadFolder, file))
);
const uploadSize = (await Promise.all(stats)).reduce(
(accumulator, { size }) => accumulator + size,
0
);

setMeasurement("files", files.length, "none", prepBundlesSpan);
setMeasurement("upload_size", uploadSize, "byte", prepBundlesSpan);

await startSpan({ name: "upload", scope: sentryScope }, async (uploadSpan) => {
const cliInstance = new SentryCli(null, {
...sentryCliOptions,
headers: {
"sentry-trace": spanToTraceHeader(uploadSpan),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
baggage: dynamicSamplingContextToSentryBaggageHeader(
getDynamicSamplingContextFromSpan(uploadSpan)
)!,
...sentryCliOptions.headers,
},
});

await cliInstance.releases.uploadSourceMaps(
releaseName ?? "undefined", // unfortunately this needs a value for now but it will not matter since debug IDs overpower releases anyhow
{
include: [
{
paths: [tmpUploadFolder],
rewrite: false,
dist: dist,
},
],
}
);
});
}
);

logger.info("Successfully uploaded source maps to Sentry");
}
} catch (e) {
sentryScope.captureException('Error in "debugIdUploadPlugin" writeBundle hook');
handleRecoverableError(e, false);
} finally {
if (folderToCleanUp) {
void startSpan({ name: "cleanup", scope: sentryScope }, async () => {
if (folderToCleanUp) {
await fs.promises.rm(folderToCleanUp, { recursive: true, force: true });
}
});
}
freeGlobalDependencyOnSourcemapFiles();
freeUploadDependencyOnSourcemapFiles();
await safeFlushTelemetry(sentryClient);
}
}
);
await sentryBuildPluginManager.uploadSourcemaps(buildArtifactPaths);
};
}

@@ -388,7 +205,7 @@ async function prepareSourceMapForDebugIdUpload(
}

const PROTOCOL_REGEX = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//;
function defaultRewriteSourcesHook(source: string): string {
export function defaultRewriteSourcesHook(source: string): string {
if (source.match(PROTOCOL_REGEX)) {
return source.replace(PROTOCOL_REGEX, "");
} else {
Loading
Loading