From ceaddfaa014b83a620b3cd20f0f709618ce4d3d0 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Tue, 1 Apr 2025 15:38:17 +0200 Subject: [PATCH 1/7] pain --- .../bundler-plugin-core/src/api-primitives.ts | 361 ++++++++++++++++++ packages/bundler-plugin-core/src/index.ts | 313 ++------------- .../src/plugins/release-management.ts | 112 +----- .../src/plugins/telemetry.ts | 25 +- packages/bundler-plugin-core/src/utils.ts | 7 + 5 files changed, 410 insertions(+), 408 deletions(-) create mode 100644 packages/bundler-plugin-core/src/api-primitives.ts diff --git a/packages/bundler-plugin-core/src/api-primitives.ts b/packages/bundler-plugin-core/src/api-primitives.ts new file mode 100644 index 00000000..048cef97 --- /dev/null +++ b/packages/bundler-plugin-core/src/api-primitives.ts @@ -0,0 +1,361 @@ +import { createLogger } from "./sentry/logger"; +import { + allowedToSendTelemetry, + createSentryInstance, + safeFlushTelemetry, +} from "./sentry/telemetry"; +import { Options, SentrySDKBuildFlags } from "./types"; +import * as fs from "fs"; +import * as path from "path"; +import * as dotenv from "dotenv"; +import { closeSession, DEFAULT_ENVIRONMENT, makeSession, startSpan } from "@sentry/core"; +import { normalizeUserOptions, validateOptions } from "./options-mapping"; +import SentryCli from "@sentry/cli"; +import { arrayify, getTurborepoEnvPassthroughWarning } from "./utils"; + +export type SentryBuildPluginManager = ReturnType; + +export function createSentryBuildPluginManager( + userOptions: Options, + bundlerPluginMetaContext: { buildTool: string; loggerPrefix: string } +) { + const logger = createLogger({ + prefix: bundlerPluginMetaContext.loggerPrefix, + silent: userOptions.silent ?? false, + debug: userOptions.debug ?? false, + }); + + try { + const dotenvFile = fs.readFileSync( + path.join(process.cwd(), ".env.sentry-build-plugin"), + "utf-8" + ); + // NOTE: Do not use the dotenv.config API directly to read the dotenv file! For some ungodly reason, it falls back to reading `${process.cwd()}/.env` which is absolutely not what we want. + const dotenvResult = dotenv.parse(dotenvFile); + + // Vite has a bug/behaviour where spreading into process.env will cause it to crash + // https://github.com/vitest-dev/vitest/issues/1870#issuecomment-1501140251 + Object.assign(process.env, dotenvResult); + + logger.info('Using environment variables configured in ".env.sentry-build-plugin".'); + } catch (e: unknown) { + // Ignore "file not found" errors but throw all others + if (typeof e === "object" && e && "code" in e && e.code !== "ENOENT") { + throw e; + } + } + + const options = normalizeUserOptions(userOptions); + + const shouldSendTelemetry = allowedToSendTelemetry(options); + const { sentryScope, sentryClient } = createSentryInstance( + options, + shouldSendTelemetry, + bundlerPluginMetaContext.buildTool + ); + + const { release, environment = DEFAULT_ENVIRONMENT } = sentryClient.getOptions(); + + const sentrySession = makeSession({ release, environment }); + sentryScope.setSession(sentrySession); + // Send the start of the session + sentryClient.captureSession(sentrySession); + + let sessionHasEnded = false; // Just to prevent infinite loops with beforeExit, which is called whenever the event loop empties out + + function endSession() { + if (sessionHasEnded) { + return; + } + + closeSession(sentrySession); + sentryClient.captureSession(sentrySession); + sessionHasEnded = true; + } + + // We also need to manually end sessions on errors because beforeExit is not called on crashes + process.on("beforeExit", () => { + endSession(); + }); + + // Set the User-Agent that Sentry CLI will use when interacting with Sentry + process.env[ + "SENTRY_PIPELINE" + ] = `${bundlerPluginMetaContext.buildTool}-plugin/${__PACKAGE_VERSION__}`; + + // Not a bulletproof check but should be good enough to at least sometimes determine + // if the plugin is called in dev/watch mode or for a prod build. The important part + // here is to avoid a false positive. False negatives are okay. + const isDevMode = process.env["NODE_ENV"] === "development"; + + /** + * Handles errors caught and emitted in various areas of the plugin. + * + * Also sets the sentry session status according to the error handling. + * + * If users specify their custom `errorHandler` we'll leave the decision to throw + * or continue up to them. By default, @param throwByDefault controls if the plugin + * should throw an error (which causes a build fail in most bundlers) or continue. + */ + function handleRecoverableError(unknownError: unknown, throwByDefault: boolean) { + sentrySession.status = "abnormal"; + try { + if (options.errorHandler) { + try { + if (unknownError instanceof Error) { + options.errorHandler(unknownError); + } else { + options.errorHandler(new Error("An unknown error occurred")); + } + } catch (e) { + sentrySession.status = "crashed"; + throw e; + } + } else { + // setting the session to "crashed" b/c from a plugin perspective this run failed. + // However, we're intentionally not rethrowing the error to avoid breaking the user build. + sentrySession.status = "crashed"; + if (throwByDefault) { + throw unknownError; + } + logger.error("An error occurred. Couldn't finish all operations:", unknownError); + } + } finally { + endSession(); + } + } + + if (!validateOptions(options, logger)) { + // Throwing by default to avoid a misconfigured plugin going unnoticed. + handleRecoverableError( + new Error("Options were not set correctly. See output above for more details."), + true + ); + } + + // We have multiple plugins depending on generated source map files. (debug ID upload, legacy upload) + // Additionally, we also want to have the functionality to delete files after uploading sourcemaps. + // All of these plugins and the delete functionality need to run in the same hook (`writeBundle`). + // Since the plugins among themselves are not aware of when they run and finish, we need a system to + // track their dependencies on the generated files, so that we can initiate the file deletion only after + // nothing depends on the files anymore. + const dependenciesOnBuildArtifacts = new Set(); + const buildArtifactsDependencySubscribers: (() => void)[] = []; + + function notifyBuildArtifactDependencySubscribers() { + buildArtifactsDependencySubscribers.forEach((subscriber) => { + subscriber(); + }); + } + + function createDependencyOnBuildArtifacts() { + const dependencyIdentifier = Symbol(); + dependenciesOnBuildArtifacts.add(dependencyIdentifier); + + return function freeDependencyOnBuildArtifacts() { + dependenciesOnBuildArtifacts.delete(dependencyIdentifier); + notifyBuildArtifactDependencySubscribers(); + }; + } + + /** + * Returns a Promise that resolves when all the currently active dependencies are freed again. + * + * It is very important that this function is called as late as possible before wanting to await the Promise to give + * the dependency producers as much time as possible to register themselves. + */ + function waitUntilBuildArtifactDependenciesAreFreed() { + return new Promise((resolve) => { + buildArtifactsDependencySubscribers.push(() => { + if (dependenciesOnBuildArtifacts.size === 0) { + resolve(); + } + }); + + if (dependenciesOnBuildArtifacts.size === 0) { + resolve(); + } + }); + } + + const bundleSizeOptimizationReplacementValues: SentrySDKBuildFlags = {}; + if (options.bundleSizeOptimizations) { + const { bundleSizeOptimizations } = options; + + if (bundleSizeOptimizations.excludeDebugStatements) { + bundleSizeOptimizationReplacementValues["__SENTRY_DEBUG__"] = false; + } + if (bundleSizeOptimizations.excludeTracing) { + bundleSizeOptimizationReplacementValues["__SENTRY_TRACING__"] = false; + } + if (bundleSizeOptimizations.excludeReplayCanvas) { + bundleSizeOptimizationReplacementValues["__RRWEB_EXCLUDE_CANVAS__"] = true; + } + if (bundleSizeOptimizations.excludeReplayIframe) { + bundleSizeOptimizationReplacementValues["__RRWEB_EXCLUDE_IFRAME__"] = true; + } + if (bundleSizeOptimizations.excludeReplayShadowDom) { + bundleSizeOptimizationReplacementValues["__RRWEB_EXCLUDE_SHADOW_DOM__"] = true; + } + if (bundleSizeOptimizations.excludeReplayWorker) { + bundleSizeOptimizationReplacementValues["__SENTRY_EXCLUDE_REPLAY_WORKER__"] = true; + } + } + + let bundleMetadata: Record = {}; + if (options.moduleMetadata || options.applicationKey) { + if (options.applicationKey) { + // We use different keys so that if user-code receives multiple bundling passes, we will store the application keys of all the passes. + // It is a bit unfortunate that we have to inject the metadata snippet at the top, because after multiple + // injections, the first injection will always "win" because it comes last in the code. We would generally be + // fine with making the last bundling pass win. But because it cannot win, we have to use a workaround of storing + // the app keys in different object keys. + // We can simply use the `_sentryBundlerPluginAppKey:` to filter for app keys in the SDK. + bundleMetadata[`_sentryBundlerPluginAppKey:${options.applicationKey}`] = true; + } + + if (typeof options.moduleMetadata === "function") { + const args = { + org: options.org, + project: options.project, + release: options.release.name, + }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + bundleMetadata = { ...bundleMetadata, ...options.moduleMetadata(args) }; + } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + bundleMetadata = { ...bundleMetadata, ...options.moduleMetadata }; + } + } + + return { + logger, + normalizedOptions: options, + bundleSizeOptimizationReplacementValues, + bundleMetadata, + telemetry: { + async emitBundlerPluginExecutionSignal() { + if (await shouldSendTelemetry) { + logger.info( + "Sending telemetry data on issues and performance to Sentry. To disable telemetry, set `options.telemetry` to `false`." + ); + startSpan({ name: "Sentry Bundler Plugin execution", scope: sentryScope }, () => { + // + }); + await safeFlushTelemetry(sentryClient); + } + }, + }, + async createRelease() { + if (!options.release.name) { + logger.debug( + "No release name provided. Will not create release. Please set the `release.name` option to identify your release." + ); + return; + } else if (isDevMode) { + logger.debug("Running in development mode. Will not create release."); + return; + } else if (!options.authToken) { + logger.warn( + "No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/" + + getTurborepoEnvPassthroughWarning("SENTRY_AUTH_TOKEN") + ); + return; + } else if (!options.org && !options.authToken.startsWith("sntrys_")) { + logger.warn( + "No organization slug provided. Will not create release. Please set the `org` option to your Sentry organization slug." + + getTurborepoEnvPassthroughWarning("SENTRY_ORG") + ); + return; + } else if (!options.project) { + logger.warn( + "No project provided. Will not create release. Please set the `project` option to your Sentry project slug." + + getTurborepoEnvPassthroughWarning("SENTRY_PROJECT") + ); + return; + } + + // It is possible that this writeBundle hook 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 freeWriteBundleInvocationDependencyOnSourcemapFiles = + createDependencyOnBuildArtifacts(); + + try { + const cliInstance = new SentryCli(null, { + authToken: options.authToken, + org: options.org, + project: options.project, + silent: options.silent, + url: options.url, + vcsRemote: options.release.vcsRemote, + headers: options.headers, + }); + + if (options.release.create) { + await cliInstance.releases.new(options.release.name); + } + + if (options.release.uploadLegacySourcemaps) { + const normalizedInclude = arrayify(options.release.uploadLegacySourcemaps) + .map((includeItem) => + typeof includeItem === "string" ? { paths: [includeItem] } : includeItem + ) + .map((includeEntry) => ({ + ...includeEntry, + validate: includeEntry.validate ?? false, + ext: includeEntry.ext + ? includeEntry.ext.map((extension) => `.${extension.replace(/^\./, "")}`) + : [".js", ".map", ".jsbundle", ".bundle"], + ignore: includeEntry.ignore ? arrayify(includeEntry.ignore) : undefined, + })); + + await cliInstance.releases.uploadSourceMaps(options.release.name, { + include: normalizedInclude, + dist: options.release.dist, + }); + } + + if (options.release.setCommits !== false) { + try { + await cliInstance.releases.setCommits( + options.release.name, + // set commits always exists due to the normalize function + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + options.release.setCommits! + ); + } catch (e) { + // shouldNotThrowOnFailure being present means that the plugin defaulted to `{ auto: true }` for the setCommitsOptions, meaning that wee should not throw when CLI throws because there is no repo + if ( + options.release.setCommits && + "shouldNotThrowOnFailure" in options.release.setCommits && + options.release.setCommits.shouldNotThrowOnFailure + ) { + logger.debug( + "An error occurred setting commits on release (this message can be ignored unless you commits on release are desired):", + e + ); + } else { + throw e; + } + } + } + + if (options.release.finalize) { + await cliInstance.releases.finalize(options.release.name); + } + + if (options.release.deploy) { + await cliInstance.releases.newDeploy(options.release.name, options.release.deploy); + } + } catch (e) { + sentryScope.captureException('Error in "releaseManagementPlugin" writeBundle hook'); + await safeFlushTelemetry(sentryClient); + handleRecoverableError(e, false); + } finally { + freeWriteBundleInvocationDependencyOnSourcemapFiles(); + } + }, + createDependencyOnBuildArtifacts, + waitUntilBuildArtifactDependenciesAreFreed, + }; +} diff --git a/packages/bundler-plugin-core/src/index.ts b/packages/bundler-plugin-core/src/index.ts index 0dcf2785..c783fc70 100644 --- a/packages/bundler-plugin-core/src/index.ts +++ b/packages/bundler-plugin-core/src/index.ts @@ -1,32 +1,30 @@ -import SentryCli from "@sentry/cli"; import { transformAsync } from "@babel/core"; import componentNameAnnotatePlugin from "@sentry/babel-plugin-component-annotate"; +import SentryCli from "@sentry/cli"; +import { logger } from "@sentry/utils"; import * as fs from "fs"; -import * as path from "path"; +import { glob } from "glob"; import MagicString from "magic-string"; +import * as path from "path"; import { createUnplugin, TransformResult, UnpluginOptions } from "unplugin"; -import { normalizeUserOptions, validateOptions } from "./options-mapping"; +import { createSentryBuildPluginManager } from "./api-primitives"; import { createDebugIdUploadFunction } from "./debug-id-upload"; import { releaseManagementPlugin } from "./plugins/release-management"; +import { fileDeletionPlugin } from "./plugins/sourcemap-deletion"; import { telemetryPlugin } from "./plugins/telemetry"; -import { createLogger, Logger } from "./sentry/logger"; -import { allowedToSendTelemetry, createSentryInstance } from "./sentry/telemetry"; +import { Logger } from "./sentry/logger"; import { Options, SentrySDKBuildFlags } from "./types"; import { generateGlobalInjectorCode, generateModuleMetadataInjectorCode, getDependencies, getPackageJson, + getTurborepoEnvPassthroughWarning, parseMajorVersion, replaceBooleanFlagsInCode, stringToUUID, stripQueryAndHashFromPath, } from "./utils"; -import * as dotenv from "dotenv"; -import { glob } from "glob"; -import { logger } from "@sentry/utils"; -import { fileDeletionPlugin } from "./plugins/sourcemap-deletion"; -import { closeSession, DEFAULT_ENVIRONMENT, makeSession } from "@sentry/core"; interface SentryUnpluginFactoryOptions { releaseInjectionPlugin: (injectionCode: string) => UnpluginOptions; @@ -76,40 +74,18 @@ export function sentryUnpluginFactory({ bundleSizeOptimizationsPlugin, }: SentryUnpluginFactoryOptions) { return createUnplugin((userOptions = {}, unpluginMetaContext) => { - const logger = createLogger({ - prefix: + const sentryBuildPluginManager = createSentryBuildPluginManager(userOptions, { + loggerPrefix: userOptions._metaOptions?.loggerPrefixOverride ?? `[sentry-${unpluginMetaContext.framework}-plugin]`, - silent: userOptions.silent ?? false, - debug: userOptions.debug ?? false, + buildTool: unpluginMetaContext.framework, }); - // Not a bulletproof check but should be good enough to at least sometimes determine - // if the plugin is called in dev/watch mode or for a prod build. The important part - // here is to avoid a false positive. False negatives are okay. - const isDevMode = process.env["NODE_ENV"] === "development"; - - try { - const dotenvFile = fs.readFileSync( - path.join(process.cwd(), ".env.sentry-build-plugin"), - "utf-8" - ); - // NOTE: Do not use the dotenv.config API directly to read the dotenv file! For some ungodly reason, it falls back to reading `${process.cwd()}/.env` which is absolutely not what we want. - const dotenvResult = dotenv.parse(dotenvFile); - - // Vite has a bug/behaviour where spreading into process.env will cause it to crash - // https://github.com/vitest-dev/vitest/issues/1870#issuecomment-1501140251 - Object.assign(process.env, dotenvResult); - - logger.info('Using environment variables configured in ".env.sentry-build-plugin".'); - } catch (e: unknown) { - // Ignore "file not found" errors but throw all others - if (typeof e === "object" && e && "code" in e && e.code !== "ENOENT") { - throw e; - } - } - - const options = normalizeUserOptions(userOptions); + const { + logger, + normalizedOptions: options, + bundleSizeOptimizationReplacementValues, + } = sentryBuildPluginManager; if (options.disable) { return [ @@ -119,87 +95,6 @@ export function sentryUnpluginFactory({ ]; } - const shouldSendTelemetry = allowedToSendTelemetry(options); - const { sentryScope, sentryClient } = createSentryInstance( - options, - shouldSendTelemetry, - unpluginMetaContext.framework - ); - - const { release, environment = DEFAULT_ENVIRONMENT } = sentryClient.getOptions(); - - const sentrySession = makeSession({ release, environment }); - sentryScope.setSession(sentrySession); - // Send the start of the session - sentryClient.captureSession(sentrySession); - - let sessionHasEnded = false; // Just to prevent infinite loops with beforeExit, which is called whenever the event loop empties out - - function endSession() { - if (sessionHasEnded) { - return; - } - - closeSession(sentrySession); - sentryClient.captureSession(sentrySession); - sessionHasEnded = true; - } - - // We also need to manually end sessions on errors because beforeExit is not called on crashes - process.on("beforeExit", () => { - endSession(); - }); - - // Set the User-Agent that Sentry CLI will use when interacting with Sentry - process.env[ - "SENTRY_PIPELINE" - ] = `${unpluginMetaContext.framework}-plugin/${__PACKAGE_VERSION__}`; - - /** - * Handles errors caught and emitted in various areas of the plugin. - * - * Also sets the sentry session status according to the error handling. - * - * If users specify their custom `errorHandler` we'll leave the decision to throw - * or continue up to them. By default, @param throwByDefault controls if the plugin - * should throw an error (which causes a build fail in most bundlers) or continue. - */ - function handleRecoverableError(unknownError: unknown, throwByDefault: boolean) { - sentrySession.status = "abnormal"; - try { - if (options.errorHandler) { - try { - if (unknownError instanceof Error) { - options.errorHandler(unknownError); - } else { - options.errorHandler(new Error("An unknown error occured")); - } - } catch (e) { - sentrySession.status = "crashed"; - throw e; - } - } else { - // setting the session to "crashed" b/c from a plugin perspective this run failed. - // However, we're intentionally not rethrowing the error to avoid breaking the user build. - sentrySession.status = "crashed"; - if (throwByDefault) { - throw unknownError; - } - logger.error("An error occurred. Couldn't finish all operations:", unknownError); - } - } finally { - endSession(); - } - } - - if (!validateOptions(options, logger)) { - // Throwing by default to avoid a misconfigured plugin going unnoticed. - handleRecoverableError( - new Error("Options were not set correctly. See output above for more details."), - true - ); - } - if (process.cwd().match(/\\node_modules\\|\/node_modules\//)) { logger.warn( "Running Sentry plugin from within a `node_modules` folder. Some features may not work." @@ -210,84 +105,12 @@ export function sentryUnpluginFactory({ plugins.push( telemetryPlugin({ - sentryClient, - sentryScope, - logger, - shouldSendTelemetry, + sentryBuildPluginManager, }) ); - // We have multiple plugins depending on generated source map files. (debug ID upload, legacy upload) - // Additionally, we also want to have the functionality to delete files after uploading sourcemaps. - // All of these plugins and the delete functionality need to run in the same hook (`writeBundle`). - // Since the plugins among themselves are not aware of when they run and finish, we need a system to - // track their dependencies on the generated files, so that we can initiate the file deletion only after - // nothing depends on the files anymore. - const dependenciesOnSourcemapFiles = new Set(); - const sourcemapFileDependencySubscribers: (() => void)[] = []; - - function notifySourcemapFileDependencySubscribers() { - sourcemapFileDependencySubscribers.forEach((subscriber) => { - subscriber(); - }); - } - - function createDependencyOnSourcemapFiles() { - const dependencyIdentifier = Symbol(); - dependenciesOnSourcemapFiles.add(dependencyIdentifier); - - return function freeDependencyOnSourcemapFiles() { - dependenciesOnSourcemapFiles.delete(dependencyIdentifier); - notifySourcemapFileDependencySubscribers(); - }; - } - - /** - * Returns a Promise that resolves when all the currently active dependencies are freed again. - * - * It is very important that this function is called as late as possible before wanting to await the Promise to give - * the dependency producers as much time as possible to register themselves. - */ - function waitUntilSourcemapFileDependenciesAreFreed() { - return new Promise((resolve) => { - sourcemapFileDependencySubscribers.push(() => { - if (dependenciesOnSourcemapFiles.size === 0) { - resolve(); - } - }); - - if (dependenciesOnSourcemapFiles.size === 0) { - resolve(); - } - }); - } - - if (options.bundleSizeOptimizations) { - const { bundleSizeOptimizations } = options; - const replacementValues: SentrySDKBuildFlags = {}; - - if (bundleSizeOptimizations.excludeDebugStatements) { - replacementValues["__SENTRY_DEBUG__"] = false; - } - if (bundleSizeOptimizations.excludeTracing) { - replacementValues["__SENTRY_TRACING__"] = false; - } - if (bundleSizeOptimizations.excludeReplayCanvas) { - replacementValues["__RRWEB_EXCLUDE_CANVAS__"] = true; - } - if (bundleSizeOptimizations.excludeReplayIframe) { - replacementValues["__RRWEB_EXCLUDE_IFRAME__"] = true; - } - if (bundleSizeOptimizations.excludeReplayShadowDom) { - replacementValues["__RRWEB_EXCLUDE_SHADOW_DOM__"] = true; - } - if (bundleSizeOptimizations.excludeReplayWorker) { - replacementValues["__SENTRY_EXCLUDE_REPLAY_WORKER__"] = true; - } - - if (Object.keys(replacementValues).length > 0) { - plugins.push(bundleSizeOptimizationsPlugin(replacementValues)); - } + if (Object.keys(bundleSizeOptimizationReplacementValues).length > 0) { + plugins.push(bundleSizeOptimizationsPlugin(bundleSizeOptimizationReplacementValues)); } if (!options.release.inject) { @@ -306,92 +129,19 @@ export function sentryUnpluginFactory({ plugins.push(releaseInjectionPlugin(injectionCode)); } - if (options.moduleMetadata || options.applicationKey) { - let metadata: Record = {}; - - if (options.applicationKey) { - // We use different keys so that if user-code receives multiple bundling passes, we will store the application keys of all the passes. - // It is a bit unfortunate that we have to inject the metadata snippet at the top, because after multiple - // injections, the first injection will always "win" because it comes last in the code. We would generally be - // fine with making the last bundling pass win. But because it cannot win, we have to use a workaround of storing - // the app keys in different object keys. - // We can simply use the `_sentryBundlerPluginAppKey:` to filter for app keys in the SDK. - metadata[`_sentryBundlerPluginAppKey:${options.applicationKey}`] = true; - } - - if (typeof options.moduleMetadata === "function") { - const args = { - org: options.org, - project: options.project, - release: options.release.name, - }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - metadata = { ...metadata, ...options.moduleMetadata(args) }; - } else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - metadata = { ...metadata, ...options.moduleMetadata }; - } - - const injectionCode = generateModuleMetadataInjectorCode(metadata); - plugins.push(moduleMetadataInjectionPlugin(injectionCode)); - } - // https://turbo.build/repo/docs/reference/system-environment-variables#environment-variables-in-tasks - const isRunningInTurborepo = Boolean(process.env["TURBO_HASH"]); - const getTurborepoEnvPassthroughWarning = (envVarName: string) => - isRunningInTurborepo - ? `\nYou seem to be using Turborepo, did you forget to put ${envVarName} in \`passThroughEnv\`? https://turbo.build/repo/docs/reference/configuration#passthroughenv` - : ""; - if (!options.release.name) { - logger.debug( - "No release name provided. Will not create release. Please set the `release.name` option to identify your release." - ); - } else if (isDevMode) { - logger.debug("Running in development mode. Will not create release."); - } else if (!options.authToken) { - logger.warn( - "No auth token provided. Will not create release. Please set the `authToken` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/" + - getTurborepoEnvPassthroughWarning("SENTRY_AUTH_TOKEN") - ); - } else if (!options.org && !options.authToken.startsWith("sntrys_")) { - logger.warn( - "No organization slug provided. Will not create release. Please set the `org` option to your Sentry organization slug." + - getTurborepoEnvPassthroughWarning("SENTRY_ORG") - ); - } else if (!options.project) { - logger.warn( - "No project provided. Will not create release. Please set the `project` option to your Sentry project slug." + - getTurborepoEnvPassthroughWarning("SENTRY_PROJECT") - ); - } else { - plugins.push( - releaseManagementPlugin({ - logger, - releaseName: options.release.name, - shouldCreateRelease: options.release.create, - shouldFinalizeRelease: options.release.finalize, - include: options.release.uploadLegacySourcemaps, - // setCommits has a default defined by the options mappings - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - setCommitsOption: options.release.setCommits!, - deployOptions: options.release.deploy, - dist: options.release.dist, - handleRecoverableError: handleRecoverableError, - sentryScope, - sentryClient, - sentryCliOptions: { - authToken: options.authToken, - org: options.org, - project: options.project, - silent: options.silent, - url: options.url, - vcsRemote: options.release.vcsRemote, - headers: options.headers, - }, - createDependencyOnSourcemapFiles, - }) + if (Object.keys(sentryBuildPluginManager.bundleMetadata).length > 0) { + const injectionCode = generateModuleMetadataInjectorCode( + sentryBuildPluginManager.bundleMetadata ); + plugins.push(moduleMetadataInjectionPlugin(injectionCode)); } + plugins.push( + releaseManagementPlugin({ + sentryBuildPluginManager, + }) + ); + if (!options.sourcemaps?.disable) { plugins.push(debugIdInjectionPlugin(logger)); } @@ -744,7 +494,6 @@ export function getDebugIdSnippet(debugId: string): string { return `;{try{let e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="${debugId}",e._sentryDebugIdIdentifier="sentry-dbid-${debugId}")}catch(e){}};`; } -export { stringToUUID, replaceBooleanFlagsInCode } from "./utils"; - -export type { Options, SentrySDKBuildFlags } from "./types"; export type { Logger } from "./sentry/logger"; +export type { Options, SentrySDKBuildFlags } from "./types"; +export { replaceBooleanFlagsInCode, stringToUUID } from "./utils"; diff --git a/packages/bundler-plugin-core/src/plugins/release-management.ts b/packages/bundler-plugin-core/src/plugins/release-management.ts index fd00157f..14fafb7b 100644 --- a/packages/bundler-plugin-core/src/plugins/release-management.ts +++ b/packages/bundler-plugin-core/src/plugins/release-management.ts @@ -1,34 +1,8 @@ -import SentryCli, { SentryCliCommitsOptions, SentryCliNewDeployOptions } from "@sentry/cli"; -import { Scope } from "@sentry/core"; import { UnpluginOptions } from "unplugin"; -import { Logger } from "../sentry/logger"; -import { safeFlushTelemetry } from "../sentry/telemetry"; -import { HandleRecoverableErrorFn, IncludeEntry } from "../types"; -import { arrayify } from "../utils"; -import { Client } from "@sentry/types"; +import { SentryBuildPluginManager } from "../api-primitives"; interface ReleaseManagementPluginOptions { - logger: Logger; - releaseName: string; - shouldCreateRelease: boolean; - shouldFinalizeRelease: boolean; - include?: string | IncludeEntry | Array; - setCommitsOption: SentryCliCommitsOptions | false | { auto: true; isDefault: true }; - deployOptions?: SentryCliNewDeployOptions; - dist?: string; - handleRecoverableError: HandleRecoverableErrorFn; - sentryScope: Scope; - sentryClient: Client; - sentryCliOptions: { - url: string; - authToken: string; - org?: string; - project: string; - vcsRemote: string; - silent: boolean; - headers?: Record; - }; - createDependencyOnSourcemapFiles: () => () => void; + sentryBuildPluginManager: SentryBuildPluginManager; } /** @@ -37,89 +11,17 @@ interface ReleaseManagementPluginOptions { * Additionally, if legacy upload options are set, it uploads source maps in the legacy (non-debugId) way. */ export function releaseManagementPlugin({ - logger, - releaseName, - include, - dist, - setCommitsOption, - shouldCreateRelease, - shouldFinalizeRelease, - deployOptions, - handleRecoverableError, - sentryScope, - sentryClient, - sentryCliOptions, - createDependencyOnSourcemapFiles, + sentryBuildPluginManager, }: ReleaseManagementPluginOptions): UnpluginOptions { - const freeGlobalDependencyOnSourcemapFiles = createDependencyOnSourcemapFiles(); + const freeGlobalDependencyOnBuildArtifacts = + sentryBuildPluginManager.createDependencyOnBuildArtifacts(); return { name: "sentry-release-management-plugin", async writeBundle() { - // It is possible that this writeBundle hook 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 freeWriteBundleInvocationDependencyOnSourcemapFiles = - createDependencyOnSourcemapFiles(); - try { - const cliInstance = new SentryCli(null, sentryCliOptions); - - if (shouldCreateRelease) { - await cliInstance.releases.new(releaseName); - } - - if (include) { - const normalizedInclude = arrayify(include) - .map((includeItem) => - typeof includeItem === "string" ? { paths: [includeItem] } : includeItem - ) - .map((includeEntry) => ({ - ...includeEntry, - validate: includeEntry.validate ?? false, - ext: includeEntry.ext - ? includeEntry.ext.map((extension) => `.${extension.replace(/^\./, "")}`) - : [".js", ".map", ".jsbundle", ".bundle"], - ignore: includeEntry.ignore ? arrayify(includeEntry.ignore) : undefined, - })); - - await cliInstance.releases.uploadSourceMaps(releaseName, { - include: normalizedInclude, - dist, - }); - } - - if (setCommitsOption !== false) { - try { - await cliInstance.releases.setCommits(releaseName, setCommitsOption); - } catch (e) { - // shouldNotThrowOnFailure being present means that the plugin defaulted to `{ auto: true }` for the setCommitsOptions, meaning that wee should not throw when CLI throws because there is no repo - if ( - "shouldNotThrowOnFailure" in setCommitsOption && - setCommitsOption.shouldNotThrowOnFailure - ) { - logger.debug( - "An error occurred setting commits on release (this message can be ignored unless you commits on release are desired):", - e - ); - } else { - throw e; - } - } - } - - if (shouldFinalizeRelease) { - await cliInstance.releases.finalize(releaseName); - } - - if (deployOptions) { - await cliInstance.releases.newDeploy(releaseName, deployOptions); - } - } catch (e) { - sentryScope.captureException('Error in "releaseManagementPlugin" writeBundle hook'); - await safeFlushTelemetry(sentryClient); - handleRecoverableError(e, false); + await sentryBuildPluginManager.createRelease(); } finally { - freeGlobalDependencyOnSourcemapFiles(); - freeWriteBundleInvocationDependencyOnSourcemapFiles(); + freeGlobalDependencyOnBuildArtifacts(); } }, }; diff --git a/packages/bundler-plugin-core/src/plugins/telemetry.ts b/packages/bundler-plugin-core/src/plugins/telemetry.ts index cbff196b..c3201d28 100644 --- a/packages/bundler-plugin-core/src/plugins/telemetry.ts +++ b/packages/bundler-plugin-core/src/plugins/telemetry.ts @@ -1,34 +1,17 @@ -import { Scope, startSpan } from "@sentry/core"; -import { Client } from "@sentry/types"; import { UnpluginOptions } from "unplugin"; -import { Logger } from "../sentry/logger"; -import { safeFlushTelemetry } from "../sentry/telemetry"; +import { SentryBuildPluginManager } from "../api-primitives"; interface TelemetryPluginOptions { - sentryClient: Client; - sentryScope: Scope; - shouldSendTelemetry: Promise; - logger: Logger; + sentryBuildPluginManager: SentryBuildPluginManager; } export function telemetryPlugin({ - sentryClient, - sentryScope, - shouldSendTelemetry, - logger, + sentryBuildPluginManager, }: TelemetryPluginOptions): UnpluginOptions { return { name: "sentry-telemetry-plugin", async buildStart() { - if (await shouldSendTelemetry) { - logger.info( - "Sending telemetry data on issues and performance to Sentry. To disable telemetry, set `options.telemetry` to `false`." - ); - startSpan({ name: "Sentry Bundler Plugin execution", scope: sentryScope }, () => { - // - }); - await safeFlushTelemetry(sentryClient); - } + await sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal(); }, }; } diff --git a/packages/bundler-plugin-core/src/utils.ts b/packages/bundler-plugin-core/src/utils.ts index 24e2049e..8293315c 100644 --- a/packages/bundler-plugin-core/src/utils.ts +++ b/packages/bundler-plugin-core/src/utils.ts @@ -411,3 +411,10 @@ export function replaceBooleanFlagsInCode( return null; } + +// https://turbo.build/repo/docs/reference/system-environment-variables#environment-variables-in-tasks +export function getTurborepoEnvPassthroughWarning(envVarName: string) { + return process.env["TURBO_HASH"] + ? `\nYou seem to be using Turborepo, did you forget to put ${envVarName} in \`passThroughEnv\`? https://turbo.build/repo/docs/reference/configuration#passthroughenv` + : ""; +} From 8f44ddd9e8f3e32d49e0b906ed082014ed4406fb Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Tue, 1 Apr 2025 16:35:20 +0200 Subject: [PATCH 2/7] . --- .../bundler-plugin-core/src/api-primitives.ts | 246 +++++++++++++++++- .../src/debug-id-upload.ts | 195 +------------- packages/bundler-plugin-core/src/index.ts | 138 ++++------ .../src/plugins/sourcemap-deletion.ts | 60 +---- packages/esbuild-plugin/src/index.ts | 13 +- packages/webpack-plugin/src/index.ts | 13 +- 6 files changed, 319 insertions(+), 346 deletions(-) diff --git a/packages/bundler-plugin-core/src/api-primitives.ts b/packages/bundler-plugin-core/src/api-primitives.ts index 048cef97..bbe7cff3 100644 --- a/packages/bundler-plugin-core/src/api-primitives.ts +++ b/packages/bundler-plugin-core/src/api-primitives.ts @@ -1,3 +1,18 @@ +import SentryCli from "@sentry/cli"; +import { + closeSession, + DEFAULT_ENVIRONMENT, + getDynamicSamplingContextFromSpan, + makeSession, + setMeasurement, + spanToTraceHeader, + startSpan, +} from "@sentry/core"; +import * as dotenv from "dotenv"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { normalizeUserOptions, validateOptions } from "./options-mapping"; import { createLogger } from "./sentry/logger"; import { allowedToSendTelemetry, @@ -5,13 +20,10 @@ import { safeFlushTelemetry, } from "./sentry/telemetry"; import { Options, SentrySDKBuildFlags } from "./types"; -import * as fs from "fs"; -import * as path from "path"; -import * as dotenv from "dotenv"; -import { closeSession, DEFAULT_ENVIRONMENT, makeSession, startSpan } from "@sentry/core"; -import { normalizeUserOptions, validateOptions } from "./options-mapping"; -import SentryCli from "@sentry/cli"; -import { arrayify, getTurborepoEnvPassthroughWarning } from "./utils"; +import { arrayify, getTurborepoEnvPassthroughWarning, stripQueryAndHashFromPath } from "./utils"; +import { glob } from "glob"; +import { defaultRewriteSourcesHook, prepareBundleForDebugIdUpload } from "./debug-id-upload"; +import { dynamicSamplingContextToSentryBaggageHeader } from "@sentry/utils"; export type SentryBuildPluginManager = ReturnType; @@ -355,7 +367,225 @@ export function createSentryBuildPluginManager( freeWriteBundleInvocationDependencyOnSourcemapFiles(); } }, + async uploadSourcemaps(buildArtifactPaths: string[]) { + if (options.sourcemaps?.disable) { + logger.debug( + "Source map upload was disabled. Will not upload sourcemaps using debug ID process." + ); + } else if (isDevMode) { + logger.debug("Running in development mode. Will not upload sourcemaps."); + } else if (!options.authToken) { + logger.warn( + "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/" + + getTurborepoEnvPassthroughWarning("SENTRY_AUTH_TOKEN") + ); + } else if (!options.org && !options.authToken.startsWith("sntrys_")) { + logger.warn( + "No org provided. Will not upload source maps. Please set the `org` option to your Sentry organization slug." + + getTurborepoEnvPassthroughWarning("SENTRY_ORG") + ); + } else if (!options.project) { + logger.warn( + "No project provided. Will not upload source maps. Please set the `project` option to your Sentry project slug." + + getTurborepoEnvPassthroughWarning("SENTRY_PROJECT") + ); + } + + 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 freeUploadDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); + + 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; + const assets = options.sourcemaps?.assets; + + 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: options.sourcemaps?.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, + options.sourcemaps?.rewriteSources ?? defaultRewriteSourcesHook + ); + } + ); + const workers: Promise[] = []; + 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, { + authToken: options.authToken, + org: options.org, + project: options.project, + silent: options.silent, + url: options.url, + vcsRemote: options.release.vcsRemote, + headers: { + "sentry-trace": spanToTraceHeader(uploadSpan), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + baggage: dynamicSamplingContextToSentryBaggageHeader( + getDynamicSamplingContextFromSpan(uploadSpan) + )!, + ...options.headers, + }, + }); + + await cliInstance.releases.uploadSourceMaps( + options.release.name ?? "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: options.release.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 }); + } + }); + } + freeUploadDependencyOnBuildArtifacts(); + await safeFlushTelemetry(sentryClient); + } + } + ); + }, + async deleteArtifacts() { + try { + const filesToDelete = await options.sourcemaps?.filesToDeleteAfterUpload; + if (filesToDelete !== undefined) { + const filePathsToDelete = await glob(filesToDelete, { + absolute: true, + nodir: true, + }); + + logger.debug( + "Waiting for dependencies on generated files to be freed before deleting..." + ); + + await waitUntilBuildArtifactDependenciesAreFreed(); + + filePathsToDelete.forEach((filePathToDelete) => { + logger.debug(`Deleting asset after upload: ${filePathToDelete}`); + }); + + await Promise.all( + filePathsToDelete.map((filePathToDelete) => + fs.promises.rm(filePathToDelete, { force: true }).catch((e) => { + // This is allowed to fail - we just don't do anything + logger.debug( + `An error occurred while attempting to delete asset: ${filePathToDelete}`, + e + ); + }) + ) + ); + } + } catch (e) { + sentryScope.captureException('Error in "sentry-file-deletion-plugin" buildEnd hook'); + await safeFlushTelemetry(sentryClient); + // We throw by default if we get here b/c not being able to delete + // source maps could leak them to production + handleRecoverableError(e, true); + } + }, createDependencyOnBuildArtifacts, - waitUntilBuildArtifactDependenciesAreFreed, }; } diff --git a/packages/bundler-plugin-core/src/debug-id-upload.ts b/packages/bundler-plugin-core/src/debug-id-upload.ts index 7b35c459..c894acf1 100644 --- a/packages/bundler-plugin-core/src/debug-id-upload.ts +++ b/packages/bundler-plugin-core/src/debug-id-upload.ts @@ -1,18 +1,9 @@ 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 @@ -20,188 +11,14 @@ interface RewriteSourcesHook { } 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; - }; - 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[] = []; - 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 { diff --git a/packages/bundler-plugin-core/src/index.ts b/packages/bundler-plugin-core/src/index.ts index c783fc70..e8a14a3e 100644 --- a/packages/bundler-plugin-core/src/index.ts +++ b/packages/bundler-plugin-core/src/index.ts @@ -19,7 +19,6 @@ import { generateModuleMetadataInjectorCode, getDependencies, getPackageJson, - getTurborepoEnvPassthroughWarning, parseMajorVersion, replaceBooleanFlagsInCode, stringToUUID, @@ -34,6 +33,7 @@ interface SentryUnpluginFactoryOptions { debugIdUploadPlugin: ( upload: (buildArtifacts: string[]) => Promise, logger: Logger, + createDependencyOnBuildArtifacts: () => () => void, webpack_forceExitOnBuildComplete?: boolean ) => UnpluginOptions; bundleSizeOptimizationsPlugin: (buildFlags: SentrySDKBuildFlags) => UnpluginOptions; @@ -146,62 +146,22 @@ export function sentryUnpluginFactory({ plugins.push(debugIdInjectionPlugin(logger)); } - if (options.sourcemaps?.disable) { - logger.debug( - "Source map upload was disabled. Will not upload sourcemaps using debug ID process." - ); - } else if (isDevMode) { - logger.debug("Running in development mode. Will not upload sourcemaps."); - } else if (!options.authToken) { - logger.warn( - "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/" + - getTurborepoEnvPassthroughWarning("SENTRY_AUTH_TOKEN") - ); - } else if (!options.org && !options.authToken.startsWith("sntrys_")) { - logger.warn( - "No org provided. Will not upload source maps. Please set the `org` option to your Sentry organization slug." + - getTurborepoEnvPassthroughWarning("SENTRY_ORG") - ); - } else if (!options.project) { - logger.warn( - "No project provided. Will not upload source maps. Please set the `project` option to your Sentry project slug." + - getTurborepoEnvPassthroughWarning("SENTRY_PROJECT") - ); - } else { - // This option is only strongly typed for the webpack plugin, where it is used. It has no effect on other plugins - const webpack_forceExitOnBuildComplete = - typeof options._experiments["forceExitOnBuildCompletion"] === "boolean" - ? options._experiments["forceExitOnBuildCompletion"] - : undefined; - - plugins.push( - debugIdUploadPlugin( - createDebugIdUploadFunction({ - assets: options.sourcemaps?.assets, - ignore: options.sourcemaps?.ignore, - createDependencyOnSourcemapFiles, - dist: options.release.dist, - releaseName: options.release.name, - logger: logger, - handleRecoverableError: handleRecoverableError, - rewriteSourcesHook: options.sourcemaps?.rewriteSources, - sentryScope, - sentryClient, - sentryCliOptions: { - authToken: options.authToken, - org: options.org, - project: options.project, - silent: options.silent, - url: options.url, - vcsRemote: options.release.vcsRemote, - headers: options.headers, - }, - }), - logger, - webpack_forceExitOnBuildComplete - ) - ); - } + // This option is only strongly typed for the webpack plugin, where it is used. It has no effect on other plugins + const webpack_forceExitOnBuildComplete = + typeof options._experiments["forceExitOnBuildCompletion"] === "boolean" + ? options._experiments["forceExitOnBuildCompletion"] + : undefined; + + plugins.push( + debugIdUploadPlugin( + createDebugIdUploadFunction({ + sentryBuildPluginManager, + }), + logger, + sentryBuildPluginManager.createDependencyOnBuildArtifacts, + webpack_forceExitOnBuildComplete + ) + ); if (options.reactComponentAnnotation) { if (!options.reactComponentAnnotation.enabled) { @@ -222,12 +182,7 @@ export function sentryUnpluginFactory({ plugins.push( fileDeletionPlugin({ - waitUntilSourcemapFileDependenciesAreFreed, - filesToDeleteAfterUpload: options.sourcemaps?.filesToDeleteAfterUpload, - logger, - handleRecoverableError, - sentryScope, - sentryClient, + sentryBuildPluginManager, }) ); @@ -401,36 +356,45 @@ export function createRollupModuleMetadataInjectionHooks(injectionCode: string) } export function createRollupDebugIdUploadHooks( - upload: (buildArtifacts: string[]) => Promise + upload: (buildArtifacts: string[]) => Promise, + _logger: Logger, + createDependencyOnBuildArtifacts: () => () => void ) { + const freeGlobalDependencyOnDebugIdSourcemapArtifacts = createDependencyOnBuildArtifacts(); return { async writeBundle( outputOptions: { dir?: string; file?: string }, bundle: { [fileName: string]: unknown } ) { - if (outputOptions.dir) { - const outputDir = outputOptions.dir; - const buildArtifacts = await glob( - [ - "/**/*.js", - "/**/*.mjs", - "/**/*.cjs", - "/**/*.js.map", - "/**/*.mjs.map", - "/**/*.cjs.map", - ].map((q) => `${q}?(\\?*)?(#*)`), // We want to allow query and hashes strings at the end of files - { - root: outputDir, - absolute: true, - nodir: true, - } - ); - await upload(buildArtifacts); - } else if (outputOptions.file) { - await upload([outputOptions.file]); - } else { - const buildArtifacts = Object.keys(bundle).map((asset) => path.join(path.resolve(), asset)); - await upload(buildArtifacts); + try { + if (outputOptions.dir) { + const outputDir = outputOptions.dir; + const buildArtifacts = await glob( + [ + "/**/*.js", + "/**/*.mjs", + "/**/*.cjs", + "/**/*.js.map", + "/**/*.mjs.map", + "/**/*.cjs.map", + ].map((q) => `${q}?(\\?*)?(#*)`), // We want to allow query and hashes strings at the end of files + { + root: outputDir, + absolute: true, + nodir: true, + } + ); + await upload(buildArtifacts); + } else if (outputOptions.file) { + await upload([outputOptions.file]); + } else { + const buildArtifacts = Object.keys(bundle).map((asset) => + path.join(path.resolve(), asset) + ); + await upload(buildArtifacts); + } + } finally { + freeGlobalDependencyOnDebugIdSourcemapArtifacts(); } }, }; diff --git a/packages/bundler-plugin-core/src/plugins/sourcemap-deletion.ts b/packages/bundler-plugin-core/src/plugins/sourcemap-deletion.ts index e6efd08d..8a34a8da 100644 --- a/packages/bundler-plugin-core/src/plugins/sourcemap-deletion.ts +++ b/packages/bundler-plugin-core/src/plugins/sourcemap-deletion.ts @@ -1,69 +1,17 @@ -import { glob } from "glob"; import { UnpluginOptions } from "unplugin"; -import { Logger } from "../sentry/logger"; -import { safeFlushTelemetry } from "../sentry/telemetry"; -import fs from "fs"; -import { Scope } from "@sentry/core"; -import { Client } from "@sentry/types"; -import { HandleRecoverableErrorFn } from "../types"; +import { SentryBuildPluginManager } from "../api-primitives"; interface FileDeletionPlugin { - handleRecoverableError: HandleRecoverableErrorFn; - waitUntilSourcemapFileDependenciesAreFreed: () => Promise; - sentryScope: Scope; - sentryClient: Client; - filesToDeleteAfterUpload: string | string[] | Promise | undefined; - logger: Logger; + sentryBuildPluginManager: SentryBuildPluginManager; } export function fileDeletionPlugin({ - handleRecoverableError, - sentryScope, - sentryClient, - filesToDeleteAfterUpload, - waitUntilSourcemapFileDependenciesAreFreed, - logger, + sentryBuildPluginManager, }: FileDeletionPlugin): UnpluginOptions { return { name: "sentry-file-deletion-plugin", async writeBundle() { - try { - const filesToDelete = await filesToDeleteAfterUpload; - if (filesToDelete !== undefined) { - const filePathsToDelete = await glob(filesToDelete, { - absolute: true, - nodir: true, - }); - - logger.debug( - "Waiting for dependencies on generated files to be freed before deleting..." - ); - - await waitUntilSourcemapFileDependenciesAreFreed(); - - filePathsToDelete.forEach((filePathToDelete) => { - logger.debug(`Deleting asset after upload: ${filePathToDelete}`); - }); - - await Promise.all( - filePathsToDelete.map((filePathToDelete) => - fs.promises.rm(filePathToDelete, { force: true }).catch((e) => { - // This is allowed to fail - we just don't do anything - logger.debug( - `An error occurred while attempting to delete asset: ${filePathToDelete}`, - e - ); - }) - ) - ); - } - } catch (e) { - sentryScope.captureException('Error in "sentry-file-deletion-plugin" buildEnd hook'); - await safeFlushTelemetry(sentryClient); - // We throw by default if we get here b/c not being able to delete - // source maps could leak them to production - handleRecoverableError(e, true); - } + await sentryBuildPluginManager.deleteArtifacts(); }, }; } diff --git a/packages/esbuild-plugin/src/index.ts b/packages/esbuild-plugin/src/index.ts index 08cc17c4..4e8373f6 100644 --- a/packages/esbuild-plugin/src/index.ts +++ b/packages/esbuild-plugin/src/index.ts @@ -222,16 +222,23 @@ function esbuildModuleMetadataInjectionPlugin(injectionCode: string): UnpluginOp } function esbuildDebugIdUploadPlugin( - upload: (buildArtifacts: string[]) => Promise + upload: (buildArtifacts: string[]) => Promise, + _logger: Logger, + createDependencyOnBuildArtifacts: () => () => void ): UnpluginOptions { + const freeGlobalDependencyOnDebugIdSourcemapArtifacts = createDependencyOnBuildArtifacts(); return { name: "sentry-esbuild-debug-id-upload-plugin", esbuild: { setup({ initialOptions, onEnd }) { initialOptions.metafile = true; onEnd(async (result) => { - const buildArtifacts = result.metafile ? Object.keys(result.metafile.outputs) : []; - await upload(buildArtifacts); + try { + const buildArtifacts = result.metafile ? Object.keys(result.metafile.outputs) : []; + await upload(buildArtifacts); + } finally { + freeGlobalDependencyOnDebugIdSourcemapArtifacts(); + } }); }, }, diff --git a/packages/webpack-plugin/src/index.ts b/packages/webpack-plugin/src/index.ts index 089d9cf4..1824ce58 100644 --- a/packages/webpack-plugin/src/index.ts +++ b/packages/webpack-plugin/src/index.ts @@ -124,21 +124,28 @@ function webpackDebugIdInjectionPlugin(): UnpluginOptions { function webpackDebugIdUploadPlugin( upload: (buildArtifacts: string[]) => Promise, logger: Logger, + createDependencyOnBuildArtifacts: () => () => void, forceExitOnBuildCompletion?: boolean ): UnpluginOptions { const pluginName = "sentry-webpack-debug-id-upload-plugin"; return { name: pluginName, webpack(compiler) { + const freeGlobalDependencyOnDebugIdSourcemapArtifacts = createDependencyOnBuildArtifacts(); + compiler.hooks.afterEmit.tapAsync(pluginName, (compilation, callback: () => void) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const outputPath = (compilation.outputOptions.path as string | undefined) ?? path.resolve(); const buildArtifacts = Object.keys(compilation.assets as Record).map( (asset) => path.join(outputPath, asset) ); - void upload(buildArtifacts).then(() => { - callback(); - }); + void upload(buildArtifacts) + .then(() => { + callback(); + }) + .finally(() => { + freeGlobalDependencyOnDebugIdSourcemapArtifacts(); + }); }); if (forceExitOnBuildCompletion && compiler.options.mode === "production") { From 4a20be72717b5d36a4f71df7468ca6adb31f7dfc Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Tue, 1 Apr 2025 16:40:05 +0200 Subject: [PATCH 3/7] cannot believe this compiles --- packages/rollup-plugin/src/index.ts | 7 +++++-- packages/vite-plugin/src/index.ts | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/rollup-plugin/src/index.ts b/packages/rollup-plugin/src/index.ts index de29484f..6ca2466f 100644 --- a/packages/rollup-plugin/src/index.ts +++ b/packages/rollup-plugin/src/index.ts @@ -8,6 +8,7 @@ import { SentrySDKBuildFlags, createRollupBundleSizeOptimizationHooks, createComponentNameAnnotateHooks, + Logger, } from "@sentry/bundler-plugin-core"; import type { UnpluginOptions } from "unplugin"; @@ -40,11 +41,13 @@ function rollupModuleMetadataInjectionPlugin(injectionCode: string): UnpluginOpt } function rollupDebugIdUploadPlugin( - upload: (buildArtifacts: string[]) => Promise + upload: (buildArtifacts: string[]) => Promise, + logger: Logger, + createDependencyOnBuildArtifacts: () => () => void ): UnpluginOptions { return { name: "sentry-rollup-debug-id-upload-plugin", - rollup: createRollupDebugIdUploadHooks(upload), + rollup: createRollupDebugIdUploadHooks(upload, logger, createDependencyOnBuildArtifacts), }; } diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index 11c77f00..fa13b02f 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -8,6 +8,7 @@ import { SentrySDKBuildFlags, createRollupBundleSizeOptimizationHooks, createComponentNameAnnotateHooks, + Logger, } from "@sentry/bundler-plugin-core"; import { UnpluginOptions } from "unplugin"; @@ -44,11 +45,13 @@ function viteModuleMetadataInjectionPlugin(injectionCode: string): UnpluginOptio } function viteDebugIdUploadPlugin( - upload: (buildArtifacts: string[]) => Promise + upload: (buildArtifacts: string[]) => Promise, + logger: Logger, + createDependencyOnBuildArtifacts: () => () => void ): UnpluginOptions { return { name: "sentry-vite-debug-id-upload-plugin", - vite: createRollupDebugIdUploadHooks(upload), + vite: createRollupDebugIdUploadHooks(upload, logger, createDependencyOnBuildArtifacts), }; } From ea898ffe25fb8cdd2d92dcdb8556bba662059930 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Tue, 1 Apr 2025 16:44:01 +0200 Subject: [PATCH 4/7] Remove tests that no longer apply --- .../rollup-plugin/test/public-api.test.ts | 37 ------------------- packages/vite-plugin/test/public-api.test.ts | 37 ------------------- 2 files changed, 74 deletions(-) diff --git a/packages/rollup-plugin/test/public-api.test.ts b/packages/rollup-plugin/test/public-api.test.ts index ba69f60e..3539c887 100644 --- a/packages/rollup-plugin/test/public-api.test.ts +++ b/packages/rollup-plugin/test/public-api.test.ts @@ -31,41 +31,4 @@ describe("sentryRollupPlugin", () => { "sentry-file-deletion-plugin", ]); }); - - it("doesn't include release management and debug id upload plugins if NODE_ENV is 'development'", () => { - const originalNodeEnv = process.env["NODE_ENV"]; - process.env["NODE_ENV"] = "development"; - - const consoleSpy = jest.spyOn(console, "debug").mockImplementation(() => { - /* avoid test output pollution */ - }); - - const plugins = sentryRollupPlugin({ - authToken: "test-token", - org: "test-org", - project: "test-project", - debug: true, - }) as Plugin[]; - - expect(Array.isArray(plugins)).toBe(true); - - const pluginNames = plugins.map((plugin) => plugin.name); - - expect(pluginNames).toEqual([ - "sentry-telemetry-plugin", - "sentry-rollup-release-injection-plugin", - "sentry-rollup-debug-id-injection-plugin", - "sentry-file-deletion-plugin", - ]); - - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("Running in development mode. Will not create release.") - ); - - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("Running in development mode. Will not upload sourcemaps.") - ); - - process.env["NODE_ENV"] = originalNodeEnv; - }); }); diff --git a/packages/vite-plugin/test/public-api.test.ts b/packages/vite-plugin/test/public-api.test.ts index ccf11af2..5413ddf8 100644 --- a/packages/vite-plugin/test/public-api.test.ts +++ b/packages/vite-plugin/test/public-api.test.ts @@ -31,41 +31,4 @@ describe("sentryVitePlugin", () => { "sentry-file-deletion-plugin", ]); }); - - it("doesn't include release management and debug id upload plugins if NODE_ENV is 'development'", () => { - const originalNodeEnv = process.env["NODE_ENV"]; - process.env["NODE_ENV"] = "development"; - - const consoleSpy = jest.spyOn(console, "debug").mockImplementation(() => { - /* avoid test output pollution */ - }); - - const plugins = sentryVitePlugin({ - authToken: "test-token", - org: "test-org", - project: "test-project", - debug: true, - }) as VitePlugin[]; - - expect(Array.isArray(plugins)).toBe(true); - - const pluginNames = plugins.map((plugin) => plugin.name); - - expect(pluginNames).toEqual([ - "sentry-telemetry-plugin", - "sentry-vite-release-injection-plugin", - "sentry-vite-debug-id-injection-plugin", - "sentry-file-deletion-plugin", - ]); - - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("Running in development mode. Will not create release.") - ); - - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("Running in development mode. Will not upload sourcemaps.") - ); - - process.env["NODE_ENV"] = originalNodeEnv; - }); }); From 94e6e73398c91e966c21e19be8831799cf7f6e04 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Tue, 1 Apr 2025 16:55:57 +0200 Subject: [PATCH 5/7] fix= --- .../fixtures/telemetry/telemetry.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/integration-tests/fixtures/telemetry/telemetry.test.ts b/packages/integration-tests/fixtures/telemetry/telemetry.test.ts index deb289e1..29c182c4 100644 --- a/packages/integration-tests/fixtures/telemetry/telemetry.test.ts +++ b/packages/integration-tests/fixtures/telemetry/telemetry.test.ts @@ -70,6 +70,18 @@ test("rollup bundle telemetry", async () => { sampled: "true", }), }, + { + event_id: expect.any(String), + sent_at: expect.any(String), + sdk: { name: "sentry.javascript.node", version: expect.any(String) }, + trace: expect.objectContaining({ + environment: "production", + release: expect.any(String), + sample_rate: "1", + transaction: "debug-id-sourcemap-upload", + sampled: "true", + }), + }, [ [ { type: "transaction" }, From b26727c0b9f92bcade078845bd8ab26d06c7308d Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Tue, 1 Apr 2025 17:00:20 +0200 Subject: [PATCH 6/7] . --- .../integration-tests/fixtures/telemetry/telemetry.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/integration-tests/fixtures/telemetry/telemetry.test.ts b/packages/integration-tests/fixtures/telemetry/telemetry.test.ts index 29c182c4..d3af6a96 100644 --- a/packages/integration-tests/fixtures/telemetry/telemetry.test.ts +++ b/packages/integration-tests/fixtures/telemetry/telemetry.test.ts @@ -57,7 +57,7 @@ test("rollup bundle telemetry", async () => { ], ]), // Then we should get a transaction for execution - [ + expect.arrayContaining([ { event_id: expect.any(String), sent_at: expect.any(String), @@ -133,7 +133,7 @@ test("rollup bundle telemetry", async () => { }), ], ], - ], + ]), // Then we should get a session exit [ { From 2c2b6249c5bd37acaee571812d97ac102ac769cf Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Tue, 1 Apr 2025 17:10:22 +0200 Subject: [PATCH 7/7] tests --- .../fixtures/telemetry/telemetry.test.ts | 198 +++++++++--------- 1 file changed, 94 insertions(+), 104 deletions(-) diff --git a/packages/integration-tests/fixtures/telemetry/telemetry.test.ts b/packages/integration-tests/fixtures/telemetry/telemetry.test.ts index d3af6a96..3f6d8963 100644 --- a/packages/integration-tests/fixtures/telemetry/telemetry.test.ts +++ b/packages/integration-tests/fixtures/telemetry/telemetry.test.ts @@ -39,122 +39,112 @@ test("rollup bundle telemetry", async () => { // Ensure the session gets closed process.emit("beforeExit", 0); - expect(gbl.__SENTRY_INTERCEPT_TRANSPORT__).toEqual([ - // Fist we should have a session start + expect(gbl.__SENTRY_INTERCEPT_TRANSPORT__).toEqual( expect.arrayContaining([ - [ + // Fist we should have a session start + expect.arrayContaining([ [ - { type: "session" }, - expect.objectContaining({ - sid: expect.any(String), - init: true, - started: expect.any(String), - timestamp: expect.any(String), - status: "ok", - errors: 0, - }), + [ + { type: "session" }, + expect.objectContaining({ + sid: expect.any(String), + init: true, + started: expect.any(String), + timestamp: expect.any(String), + status: "ok", + errors: 0, + }), + ], ], - ], - ]), - // Then we should get a transaction for execution - expect.arrayContaining([ - { - event_id: expect.any(String), - sent_at: expect.any(String), - sdk: { name: "sentry.javascript.node", version: expect.any(String) }, - trace: expect.objectContaining({ - environment: "production", - release: expect.any(String), - sample_rate: "1", - transaction: "Sentry Bundler Plugin execution", - sampled: "true", - }), - }, - { - event_id: expect.any(String), - sent_at: expect.any(String), - sdk: { name: "sentry.javascript.node", version: expect.any(String) }, - trace: expect.objectContaining({ - environment: "production", - release: expect.any(String), - sample_rate: "1", - transaction: "debug-id-sourcemap-upload", - sampled: "true", - }), - }, + ]), + // Then we should get a transaction for execution [ + { + event_id: expect.any(String), + sent_at: expect.any(String), + sdk: { name: "sentry.javascript.node", version: expect.any(String) }, + trace: expect.objectContaining({ + environment: "production", + release: expect.any(String), + sample_rate: "1", + transaction: "Sentry Bundler Plugin execution", + sampled: "true", + }), + }, [ - { type: "transaction" }, - expect.objectContaining({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - "sentry.origin": "manual", - "sentry.source": "custom", - "sentry.sample_rate": 1, + [ + { type: "transaction" }, + expect.objectContaining({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + "sentry.origin": "manual", + "sentry.source": "custom", + "sentry.sample_rate": 1, + }, + origin: "manual", }, - origin: "manual", + runtime: { name: "node", version: expect.any(String) }, }, - runtime: { name: "node", version: expect.any(String) }, - }, - spans: [], - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: "Sentry Bundler Plugin execution", - type: "transaction", - transaction_info: { source: "custom" }, - platform: "node", - event_id: expect.any(String), - environment: "production", - release: expect.any(String), - tags: expect.objectContaining({ - "upload-legacy-sourcemaps": false, - "module-metadata": false, - "inject-build-information": false, - "set-commits": "auto", - "finalize-release": true, - "deploy-options": false, - "custom-error-handler": false, - "sourcemaps-assets": false, - "delete-after-upload": false, - "sourcemaps-disabled": false, - "react-annotate": false, - "meta-framework": "none", - "application-key-set": false, - bundler: "rollup", + spans: [], + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: "Sentry Bundler Plugin execution", + type: "transaction", + transaction_info: { source: "custom" }, + platform: "node", + event_id: expect.any(String), + environment: "production", + release: expect.any(String), + tags: expect.objectContaining({ + "upload-legacy-sourcemaps": false, + "module-metadata": false, + "inject-build-information": false, + "set-commits": "auto", + "finalize-release": true, + "deploy-options": false, + "custom-error-handler": false, + "sourcemaps-assets": false, + "delete-after-upload": false, + "sourcemaps-disabled": false, + "react-annotate": false, + "meta-framework": "none", + "application-key-set": false, + bundler: "rollup", + }), + sdk: expect.objectContaining({ + name: "sentry.javascript.node", + version: expect.any(String), + packages: [{ name: "npm:@sentry/node", version: expect.any(String) }], + }), }), - sdk: expect.objectContaining({ - name: "sentry.javascript.node", - version: expect.any(String), - packages: [{ name: "npm:@sentry/node", version: expect.any(String) }], - }), - }), + ], ], ], - ]), - // Then we should get a session exit - [ - { - sent_at: expect.any(String), - sdk: { name: "sentry.javascript.node", version: expect.any(String) }, - }, + // Then we should get a session exit [ + { + sent_at: expect.any(String), + sdk: { name: "sentry.javascript.node", version: expect.any(String) }, + }, [ - { type: "session" }, - { - sid: expect.any(String), - init: false, - started: expect.any(String), - timestamp: expect.any(String), - status: "exited", - errors: 0, - duration: expect.any(Number), - attrs: { release: expect.any(String), environment: "production" }, - }, + [ + { type: "session" }, + { + sid: expect.any(String), + init: false, + started: expect.any(String), + timestamp: expect.any(String), + status: "exited", + errors: 0, + duration: expect.any(Number), + attrs: { release: expect.any(String), environment: "production" }, + }, + ], ], ], - ], - ]); + ]) + ); });