Skip to content

Commit 8f44ddd

Browse files
author
Luca Forstner
committed
.
1 parent ceaddfa commit 8f44ddd

File tree

6 files changed

+319
-346
lines changed

6 files changed

+319
-346
lines changed

packages/bundler-plugin-core/src/api-primitives.ts

Lines changed: 238 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,29 @@
1+
import SentryCli from "@sentry/cli";
2+
import {
3+
closeSession,
4+
DEFAULT_ENVIRONMENT,
5+
getDynamicSamplingContextFromSpan,
6+
makeSession,
7+
setMeasurement,
8+
spanToTraceHeader,
9+
startSpan,
10+
} from "@sentry/core";
11+
import * as dotenv from "dotenv";
12+
import * as fs from "fs";
13+
import * as os from "os";
14+
import * as path from "path";
15+
import { normalizeUserOptions, validateOptions } from "./options-mapping";
116
import { createLogger } from "./sentry/logger";
217
import {
318
allowedToSendTelemetry,
419
createSentryInstance,
520
safeFlushTelemetry,
621
} from "./sentry/telemetry";
722
import { Options, SentrySDKBuildFlags } from "./types";
8-
import * as fs from "fs";
9-
import * as path from "path";
10-
import * as dotenv from "dotenv";
11-
import { closeSession, DEFAULT_ENVIRONMENT, makeSession, startSpan } from "@sentry/core";
12-
import { normalizeUserOptions, validateOptions } from "./options-mapping";
13-
import SentryCli from "@sentry/cli";
14-
import { arrayify, getTurborepoEnvPassthroughWarning } from "./utils";
23+
import { arrayify, getTurborepoEnvPassthroughWarning, stripQueryAndHashFromPath } from "./utils";
24+
import { glob } from "glob";
25+
import { defaultRewriteSourcesHook, prepareBundleForDebugIdUpload } from "./debug-id-upload";
26+
import { dynamicSamplingContextToSentryBaggageHeader } from "@sentry/utils";
1527

1628
export type SentryBuildPluginManager = ReturnType<typeof createSentryBuildPluginManager>;
1729

@@ -355,7 +367,225 @@ export function createSentryBuildPluginManager(
355367
freeWriteBundleInvocationDependencyOnSourcemapFiles();
356368
}
357369
},
370+
async uploadSourcemaps(buildArtifactPaths: string[]) {
371+
if (options.sourcemaps?.disable) {
372+
logger.debug(
373+
"Source map upload was disabled. Will not upload sourcemaps using debug ID process."
374+
);
375+
} else if (isDevMode) {
376+
logger.debug("Running in development mode. Will not upload sourcemaps.");
377+
} else if (!options.authToken) {
378+
logger.warn(
379+
"No auth token provided. Will not upload source maps. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/" +
380+
getTurborepoEnvPassthroughWarning("SENTRY_AUTH_TOKEN")
381+
);
382+
} else if (!options.org && !options.authToken.startsWith("sntrys_")) {
383+
logger.warn(
384+
"No org provided. Will not upload source maps. Please set the `org` option to your Sentry organization slug." +
385+
getTurborepoEnvPassthroughWarning("SENTRY_ORG")
386+
);
387+
} else if (!options.project) {
388+
logger.warn(
389+
"No project provided. Will not upload source maps. Please set the `project` option to your Sentry project slug." +
390+
getTurborepoEnvPassthroughWarning("SENTRY_PROJECT")
391+
);
392+
}
393+
394+
await startSpan(
395+
// This is `forceTransaction`ed because this span is used in dashboards in the form of indexed transactions.
396+
{ name: "debug-id-sourcemap-upload", scope: sentryScope, forceTransaction: true },
397+
async () => {
398+
let folderToCleanUp: string | undefined;
399+
400+
// 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`)
401+
// Therefore we need to actually register the execution of this hook as dependency on the sourcemap files.
402+
const freeUploadDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts();
403+
404+
try {
405+
const tmpUploadFolder = await startSpan(
406+
{ name: "mkdtemp", scope: sentryScope },
407+
async () => {
408+
return await fs.promises.mkdtemp(
409+
path.join(os.tmpdir(), "sentry-bundler-plugin-upload-")
410+
);
411+
}
412+
);
413+
414+
folderToCleanUp = tmpUploadFolder;
415+
const assets = options.sourcemaps?.assets;
416+
417+
let globAssets: string | string[];
418+
if (assets) {
419+
globAssets = assets;
420+
} else {
421+
logger.debug(
422+
"No `sourcemaps.assets` option provided, falling back to uploading detected build artifacts."
423+
);
424+
globAssets = buildArtifactPaths;
425+
}
426+
427+
const globResult = await startSpan(
428+
{ name: "glob", scope: sentryScope },
429+
async () =>
430+
await glob(globAssets, {
431+
absolute: true,
432+
nodir: true,
433+
ignore: options.sourcemaps?.ignore,
434+
})
435+
);
436+
437+
const debugIdChunkFilePaths = globResult.filter((debugIdChunkFilePath) => {
438+
return !!stripQueryAndHashFromPath(debugIdChunkFilePath).match(/\.(js|mjs|cjs)$/);
439+
});
440+
441+
// The order of the files output by glob() is not deterministic
442+
// Ensure order within the files so that {debug-id}-{chunkIndex} coupling is consistent
443+
debugIdChunkFilePaths.sort();
444+
445+
if (Array.isArray(assets) && assets.length === 0) {
446+
logger.debug(
447+
"Empty `sourcemaps.assets` option provided. Will not upload sourcemaps with debug ID."
448+
);
449+
} else if (debugIdChunkFilePaths.length === 0) {
450+
logger.warn(
451+
"Didn't find any matching sources for debug ID upload. Please check the `sourcemaps.assets` option."
452+
);
453+
} else {
454+
await startSpan(
455+
{ name: "prepare-bundles", scope: sentryScope },
456+
async (prepBundlesSpan) => {
457+
// Preparing the bundles can be a lot of work and doing it all at once has the potential of nuking the heap so
458+
// instead we do it with a maximum of 16 concurrent workers
459+
const preparationTasks = debugIdChunkFilePaths.map(
460+
(chunkFilePath, chunkIndex) => async () => {
461+
await prepareBundleForDebugIdUpload(
462+
chunkFilePath,
463+
tmpUploadFolder,
464+
chunkIndex,
465+
logger,
466+
options.sourcemaps?.rewriteSources ?? defaultRewriteSourcesHook
467+
);
468+
}
469+
);
470+
const workers: Promise<void>[] = [];
471+
const worker = async () => {
472+
while (preparationTasks.length > 0) {
473+
const task = preparationTasks.shift();
474+
if (task) {
475+
await task();
476+
}
477+
}
478+
};
479+
for (let workerIndex = 0; workerIndex < 16; workerIndex++) {
480+
workers.push(worker());
481+
}
482+
483+
await Promise.all(workers);
484+
485+
const files = await fs.promises.readdir(tmpUploadFolder);
486+
const stats = files.map((file) =>
487+
fs.promises.stat(path.join(tmpUploadFolder, file))
488+
);
489+
const uploadSize = (await Promise.all(stats)).reduce(
490+
(accumulator, { size }) => accumulator + size,
491+
0
492+
);
493+
494+
setMeasurement("files", files.length, "none", prepBundlesSpan);
495+
setMeasurement("upload_size", uploadSize, "byte", prepBundlesSpan);
496+
497+
await startSpan({ name: "upload", scope: sentryScope }, async (uploadSpan) => {
498+
const cliInstance = new SentryCli(null, {
499+
authToken: options.authToken,
500+
org: options.org,
501+
project: options.project,
502+
silent: options.silent,
503+
url: options.url,
504+
vcsRemote: options.release.vcsRemote,
505+
headers: {
506+
"sentry-trace": spanToTraceHeader(uploadSpan),
507+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
508+
baggage: dynamicSamplingContextToSentryBaggageHeader(
509+
getDynamicSamplingContextFromSpan(uploadSpan)
510+
)!,
511+
...options.headers,
512+
},
513+
});
514+
515+
await cliInstance.releases.uploadSourceMaps(
516+
options.release.name ?? "undefined", // unfortunately this needs a value for now but it will not matter since debug IDs overpower releases anyhow
517+
{
518+
include: [
519+
{
520+
paths: [tmpUploadFolder],
521+
rewrite: false,
522+
dist: options.release.dist,
523+
},
524+
],
525+
}
526+
);
527+
});
528+
}
529+
);
530+
531+
logger.info("Successfully uploaded source maps to Sentry");
532+
}
533+
} catch (e) {
534+
sentryScope.captureException('Error in "debugIdUploadPlugin" writeBundle hook');
535+
handleRecoverableError(e, false);
536+
} finally {
537+
if (folderToCleanUp) {
538+
void startSpan({ name: "cleanup", scope: sentryScope }, async () => {
539+
if (folderToCleanUp) {
540+
await fs.promises.rm(folderToCleanUp, { recursive: true, force: true });
541+
}
542+
});
543+
}
544+
freeUploadDependencyOnBuildArtifacts();
545+
await safeFlushTelemetry(sentryClient);
546+
}
547+
}
548+
);
549+
},
550+
async deleteArtifacts() {
551+
try {
552+
const filesToDelete = await options.sourcemaps?.filesToDeleteAfterUpload;
553+
if (filesToDelete !== undefined) {
554+
const filePathsToDelete = await glob(filesToDelete, {
555+
absolute: true,
556+
nodir: true,
557+
});
558+
559+
logger.debug(
560+
"Waiting for dependencies on generated files to be freed before deleting..."
561+
);
562+
563+
await waitUntilBuildArtifactDependenciesAreFreed();
564+
565+
filePathsToDelete.forEach((filePathToDelete) => {
566+
logger.debug(`Deleting asset after upload: ${filePathToDelete}`);
567+
});
568+
569+
await Promise.all(
570+
filePathsToDelete.map((filePathToDelete) =>
571+
fs.promises.rm(filePathToDelete, { force: true }).catch((e) => {
572+
// This is allowed to fail - we just don't do anything
573+
logger.debug(
574+
`An error occurred while attempting to delete asset: ${filePathToDelete}`,
575+
e
576+
);
577+
})
578+
)
579+
);
580+
}
581+
} catch (e) {
582+
sentryScope.captureException('Error in "sentry-file-deletion-plugin" buildEnd hook');
583+
await safeFlushTelemetry(sentryClient);
584+
// We throw by default if we get here b/c not being able to delete
585+
// source maps could leak them to production
586+
handleRecoverableError(e, true);
587+
}
588+
},
358589
createDependencyOnBuildArtifacts,
359-
waitUntilBuildArtifactDependenciesAreFreed,
360590
};
361591
}

0 commit comments

Comments
 (0)