From 79dcebb82a56e6e51bb60e3874ae25e87962b162 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 10 Sep 2024 14:23:30 +0200 Subject: [PATCH 1/6] feat: Update Sentry telemetry to v8 --- packages/bundler-plugin-core/package.json | 5 +- .../src/debug-id-upload.ts | 274 +++++++++--------- packages/bundler-plugin-core/src/index.ts | 24 +- .../src/plugins/release-management.ts | 11 +- .../src/plugins/sourcemap-deletion.ts | 11 +- .../src/plugins/telemetry.ts | 13 +- .../src/sentry/telemetry.ts | 87 +++--- .../src/sentry/transports.ts | 135 +++++++++ packages/bundler-plugin-core/src/types.ts | 1 + .../test/sentry/telemetry.test.ts | 56 ++-- .../fixtures/telemetry/input/bundle1.js | 2 + .../fixtures/telemetry/telemetry.test.ts | 148 ++++++++++ yarn.lock | 57 ++-- 13 files changed, 549 insertions(+), 275 deletions(-) create mode 100644 packages/bundler-plugin-core/src/sentry/transports.ts create mode 100644 packages/integration-tests/fixtures/telemetry/input/bundle1.js create mode 100644 packages/integration-tests/fixtures/telemetry/telemetry.test.ts diff --git a/packages/bundler-plugin-core/package.json b/packages/bundler-plugin-core/package.json index 6ce97d6c..bbe7e6e9 100644 --- a/packages/bundler-plugin-core/package.json +++ b/packages/bundler-plugin-core/package.json @@ -70,8 +70,9 @@ "@rollup/plugin-replace": "^4.0.0", "@sentry-internal/eslint-config": "2.22.4", "@sentry-internal/sentry-bundler-plugin-tsconfig": "2.22.4", - "@sentry/node": "7.102.0", - "@sentry/utils": "7.102.0", + "@sentry/core": "8.28.0", + "@sentry/types": "8.28.0", + "@sentry/utils": "8.28.0", "@swc/core": "^1.2.205", "@swc/jest": "^0.2.21", "@types/jest": "^28.1.3", diff --git a/packages/bundler-plugin-core/src/debug-id-upload.ts b/packages/bundler-plugin-core/src/debug-id-upload.ts index 3dd846e5..b962e7df 100644 --- a/packages/bundler-plugin-core/src/debug-id-upload.ts +++ b/packages/bundler-plugin-core/src/debug-id-upload.ts @@ -5,13 +5,16 @@ import path from "path"; import * as util from "util"; import { Logger } from "./sentry/logger"; import { promisify } from "util"; -import { Hub, NodeClient } from "@sentry/node"; import SentryCli from "@sentry/cli"; import { dynamicSamplingContextToSentryBaggageHeader } from "@sentry/utils"; import { safeFlushTelemetry } from "./sentry/telemetry"; import { stripQueryAndHashFromPath } from "./utils"; +import { spanToTraceHeader, startSpan } from "@sentry/core"; +import { getDynamicSamplingContextFromSpan, Scope } from "@sentry/core"; +import { Client } from "@sentry/types"; interface RewriteSourcesHook { + // eslint-disable-next-line @typescript-eslint/no-explicit-any (source: string, map: any): string; } @@ -23,8 +26,8 @@ interface DebugIdUploadPluginOptions { dist?: string; rewriteSourcesHook?: RewriteSourcesHook; handleRecoverableError: (error: unknown) => void; - sentryHub: Hub; - sentryClient: NodeClient; + sentryScope: Scope; + sentryClient: Client; sentryCliOptions: { url: string; authToken: string; @@ -44,7 +47,7 @@ export function createDebugIdUploadFunction({ releaseName, dist, handleRecoverableError, - sentryHub, + sentryScope, sentryClient, sentryCliOptions, rewriteSourcesHook, @@ -53,155 +56,146 @@ export function createDebugIdUploadFunction({ const freeGlobalDependencyOnSourcemapFiles = createDependencyOnSourcemapFiles(); return async (buildArtifactPaths: string[]) => { - const artifactBundleUploadTransaction = sentryHub.startTransaction({ - name: "debug-id-sourcemap-upload", - }); - - 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(); + await startSpan({ name: "debug-id-sourcemap-upload", scope: sentryScope }, async () => { + let folderToCleanUp: string | undefined; - try { - const mkdtempSpan = artifactBundleUploadTransaction.startChild({ description: "mkdtemp" }); - const tmpUploadFolder = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "sentry-bundler-plugin-upload-") - ); - mkdtempSpan.finish(); - - folderToCleanUp = tmpUploadFolder; - - let globAssets; - if (assets) { - globAssets = assets; - } else { - logger.debug( - "No `sourcemaps.assets` option provided, falling back to uploading detected build artifacts." - ); - globAssets = buildArtifactPaths; - } + // 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(); - const globSpan = artifactBundleUploadTransaction.startChild({ description: "glob" }); - const globResult = await glob(globAssets, { - absolute: true, - nodir: true, - ignore: ignore, - }); - globSpan.finish(); - - 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 { - const prepareSpan = artifactBundleUploadTransaction.startChild({ - description: "prepare-bundles", - }); - - // 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 + try { + const tmpUploadFolder = await startSpan( + { name: "mkdtemp", scope: sentryScope }, + async () => { + return await fs.promises.mkdtemp( + path.join(os.tmpdir(), "sentry-bundler-plugin-upload-") ); } ); - 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); - prepareSpan.finish(); + folderToCleanUp = tmpUploadFolder; - 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 - ); - - artifactBundleUploadTransaction.setMeasurement("files", files.length, "none"); - artifactBundleUploadTransaction.setMeasurement("upload_size", uploadSize, "byte"); + 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 uploadSpan = artifactBundleUploadTransaction.startChild({ - description: "upload", - }); + const globResult = await startSpan( + { name: "glob", scope: sentryScope }, + async () => await glob(globAssets, { absolute: true, nodir: true, ignore: ignore }) + ); - const cliInstance = new SentryCli(null, { - ...sentryCliOptions, - headers: { - "sentry-trace": uploadSpan.toTraceparent(), - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - baggage: dynamicSamplingContextToSentryBaggageHeader( - artifactBundleUploadTransaction.getDynamicSamplingContext() - )!, - ...sentryCliOptions.headers, - }, + const debugIdChunkFilePaths = globResult.filter((debugIdChunkFilePath) => { + return !!stripQueryAndHashFromPath(debugIdChunkFilePath).match(/\.(js|mjs|cjs)$/); }); - await cliInstance.releases.uploadSourceMaps( - releaseName ?? "undefined", // unfortunetly this needs a value for now but it will not matter since debug IDs overpower releases anyhow - { - include: [ - { - paths: [tmpUploadFolder], - rewrite: false, - dist: dist, - }, - ], - useArtifactBundle: true, - } - ); + // 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 () => { + // 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()); + } - uploadSpan.finish(); - logger.info("Successfully uploaded source maps to Sentry"); - } - } catch (e) { - sentryHub.withScope((scope) => { - scope.setSpan(artifactBundleUploadTransaction); - sentryHub.captureException('Error in "debugIdUploadPlugin" writeBundle hook'); - }); - handleRecoverableError(e); - } finally { - if (folderToCleanUp) { - const cleanupSpan = artifactBundleUploadTransaction.startChild({ - description: "cleanup", - }); - void fs.promises.rm(folderToCleanUp, { recursive: true, force: true }); - cleanupSpan.finish(); + await Promise.all(workers); + + // TODO: Bring back measurements + // There's no easy way to get the root span when not using a global client. + + // 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 + // ); + + // artifactBundleUploadTransaction.setMeasurement("files", files.length, "none"); + // artifactBundleUploadTransaction.setMeasurement("upload_size", uploadSize, "byte"); + + 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", // unfortunetly this needs a value for now but it will not matter since debug IDs overpower releases anyhow + { + include: [ + { + paths: [tmpUploadFolder], + rewrite: false, + dist: dist, + }, + ], + useArtifactBundle: true, + } + ); + }); + }); + + logger.info("Successfully uploaded source maps to Sentry"); + } + } catch (e) { + sentryScope.captureException('Error in "debugIdUploadPlugin" writeBundle hook'); + handleRecoverableError(e); + } 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); } - artifactBundleUploadTransaction.finish(); - freeGlobalDependencyOnSourcemapFiles(); - freeUploadDependencyOnSourcemapFiles(); - await safeFlushTelemetry(sentryClient); - } + }); }; } diff --git a/packages/bundler-plugin-core/src/index.ts b/packages/bundler-plugin-core/src/index.ts index 01716376..5e8dc56c 100644 --- a/packages/bundler-plugin-core/src/index.ts +++ b/packages/bundler-plugin-core/src/index.ts @@ -26,6 +26,7 @@ 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; @@ -117,19 +118,24 @@ export function sentryUnpluginFactory({ } const shouldSendTelemetry = allowedToSendTelemetry(options); - const { sentryHub, sentryClient } = createSentryInstance( + const { sentryScope, sentryClient } = createSentryInstance( options, shouldSendTelemetry, unpluginMetaContext.framework ); - const sentrySession = sentryHub.startSession(); - sentryHub.captureSession(); + + const { release, environment = DEFAULT_ENVIRONMENT } = sentryClient.getOptions(); + + const sentrySession = makeSession({ release, environment }); + sentryScope.setSession(sentrySession); + sentryClient.captureSession(sentrySession); let sentEndSession = false; // Just to prevent infinite loops with beforeExit, which is called whenever the event loop empties out // We also need to manually end sesisons on errors because beforeExit is not called on crashes process.on("beforeExit", () => { if (!sentEndSession) { - sentryHub.endSession(); + closeSession(sentrySession); + sentryClient.captureSession(sentrySession); sentEndSession = true; } }); @@ -158,7 +164,7 @@ export function sentryUnpluginFactory({ throw unknownError; } } finally { - sentryHub.endSession(); + closeSession(sentrySession); } } @@ -179,7 +185,7 @@ export function sentryUnpluginFactory({ plugins.push( telemetryPlugin({ sentryClient, - sentryHub, + sentryScope, logger, shouldSendTelemetry, }) @@ -335,7 +341,7 @@ export function sentryUnpluginFactory({ deployOptions: options.release.deploy, dist: options.release.dist, handleRecoverableError: handleRecoverableError, - sentryHub, + sentryScope, sentryClient, sentryCliOptions: { authToken: options.authToken, @@ -383,7 +389,7 @@ export function sentryUnpluginFactory({ logger: logger, handleRecoverableError: handleRecoverableError, rewriteSourcesHook: options.sourcemaps?.rewriteSources, - sentryHub, + sentryScope, sentryClient, sentryCliOptions: { authToken: options.authToken, @@ -421,7 +427,7 @@ export function sentryUnpluginFactory({ options.sourcemaps?.deleteFilesAfterUpload, logger, handleRecoverableError, - sentryHub, + sentryScope, sentryClient, }) ); diff --git a/packages/bundler-plugin-core/src/plugins/release-management.ts b/packages/bundler-plugin-core/src/plugins/release-management.ts index f088ea04..b84999a7 100644 --- a/packages/bundler-plugin-core/src/plugins/release-management.ts +++ b/packages/bundler-plugin-core/src/plugins/release-management.ts @@ -1,10 +1,11 @@ import SentryCli, { SentryCliCommitsOptions, SentryCliNewDeployOptions } from "@sentry/cli"; -import { Hub, NodeClient } from "@sentry/node"; +import { Scope } from "@sentry/core"; import { UnpluginOptions } from "unplugin"; import { Logger } from "../sentry/logger"; import { safeFlushTelemetry } from "../sentry/telemetry"; import { IncludeEntry } from "../types"; import { arrayify } from "../utils"; +import { Client } from "@sentry/types"; interface ReleaseManagementPluginOptions { logger: Logger; @@ -16,8 +17,8 @@ interface ReleaseManagementPluginOptions { deployOptions?: SentryCliNewDeployOptions; dist?: string; handleRecoverableError: (error: unknown) => void; - sentryHub: Hub; - sentryClient: NodeClient; + sentryScope: Scope; + sentryClient: Client; sentryCliOptions: { url: string; authToken: string; @@ -39,7 +40,7 @@ export function releaseManagementPlugin({ shouldFinalizeRelease, deployOptions, handleRecoverableError, - sentryHub, + sentryScope, sentryClient, sentryCliOptions, createDependencyOnSourcemapFiles, @@ -92,7 +93,7 @@ export function releaseManagementPlugin({ await cliInstance.releases.newDeploy(releaseName, deployOptions); } } catch (e) { - sentryHub.captureException('Error in "releaseManagementPlugin" writeBundle hook'); + sentryScope.captureException('Error in "releaseManagementPlugin" writeBundle hook'); await safeFlushTelemetry(sentryClient); handleRecoverableError(e); } finally { diff --git a/packages/bundler-plugin-core/src/plugins/sourcemap-deletion.ts b/packages/bundler-plugin-core/src/plugins/sourcemap-deletion.ts index 97d743f2..f1418896 100644 --- a/packages/bundler-plugin-core/src/plugins/sourcemap-deletion.ts +++ b/packages/bundler-plugin-core/src/plugins/sourcemap-deletion.ts @@ -1,22 +1,23 @@ -import { Hub, NodeClient } from "@sentry/node"; 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"; interface FileDeletionPlugin { handleRecoverableError: (error: unknown) => void; waitUntilSourcemapFileDependenciesAreFreed: () => Promise; - sentryHub: Hub; - sentryClient: NodeClient; + sentryScope: Scope; + sentryClient: Client; filesToDeleteAfterUpload: string | string[] | undefined; logger: Logger; } export function fileDeletionPlugin({ handleRecoverableError, - sentryHub, + sentryScope, sentryClient, filesToDeleteAfterUpload, waitUntilSourcemapFileDependenciesAreFreed, @@ -55,7 +56,7 @@ export function fileDeletionPlugin({ ); } } catch (e) { - sentryHub.captureException('Error in "sentry-file-deletion-plugin" buildEnd hook'); + sentryScope.captureException('Error in "sentry-file-deletion-plugin" buildEnd hook'); await safeFlushTelemetry(sentryClient); handleRecoverableError(e); } diff --git a/packages/bundler-plugin-core/src/plugins/telemetry.ts b/packages/bundler-plugin-core/src/plugins/telemetry.ts index 7f2a7ebf..cbff196b 100644 --- a/packages/bundler-plugin-core/src/plugins/telemetry.ts +++ b/packages/bundler-plugin-core/src/plugins/telemetry.ts @@ -1,18 +1,19 @@ -import { Hub, NodeClient } from "@sentry/node"; +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"; interface TelemetryPluginOptions { - sentryHub: Hub; - sentryClient: NodeClient; + sentryClient: Client; + sentryScope: Scope; shouldSendTelemetry: Promise; logger: Logger; } export function telemetryPlugin({ - sentryHub, sentryClient, + sentryScope, shouldSendTelemetry, logger, }: TelemetryPluginOptions): UnpluginOptions { @@ -23,7 +24,9 @@ export function telemetryPlugin({ logger.info( "Sending telemetry data on issues and performance to Sentry. To disable telemetry, set `options.telemetry` to `false`." ); - sentryHub.startTransaction({ name: "Sentry Bundler Plugin execution" }).finish(); + startSpan({ name: "Sentry Bundler Plugin execution", scope: sentryScope }, () => { + // + }); await safeFlushTelemetry(sentryClient); } }, diff --git a/packages/bundler-plugin-core/src/sentry/telemetry.ts b/packages/bundler-plugin-core/src/sentry/telemetry.ts index 452f605c..e702c453 100644 --- a/packages/bundler-plugin-core/src/sentry/telemetry.ts +++ b/packages/bundler-plugin-core/src/sentry/telemetry.ts @@ -1,15 +1,24 @@ import SentryCli from "@sentry/cli"; -import { defaultStackParser, Hub, makeNodeTransport, NodeClient } from "@sentry/node"; +import { Client } from "@sentry/types"; +import { applySdkMetadata, ServerRuntimeClient, ServerRuntimeClientOptions } from "@sentry/core"; import { NormalizedOptions, SENTRY_SAAS_URL } from "../options-mapping"; +import { Scope } from "@sentry/core"; +import { createStackParser, nodeStackLineParser } from "@sentry/utils"; +import { makeOptionallyEnabledNodeTransport } from "./transports"; const SENTRY_SAAS_HOSTNAME = "sentry.io"; +const stackParser = createStackParser(nodeStackLineParser()); + export function createSentryInstance( options: NormalizedOptions, shouldSendTelemetry: Promise, bundler: string -) { - const client = new NodeClient({ +): { sentryScope: Scope; sentryClient: Client } { + const clientOptions: ServerRuntimeClientOptions = { + platform: "node", + runtime: { name: "node", version: global.process.version }, + dsn: "https://4c2bae7d9fbc413e8f7385f55c515d51@o1.ingest.sentry.io/6690737", tracesSampleRate: 1, @@ -19,7 +28,7 @@ export function createSentryInstance( integrations: [], tracePropagationTargets: ["sentry.io/api"], - stackParser: defaultStackParser, + stackParser, beforeSend: (event) => { event.exception?.values?.forEach((exception) => { @@ -37,76 +46,68 @@ export function createSentryInstance( // We create a transport that stalls sending events until we know that we're allowed to (i.e. when Sentry CLI told // us that the upload URL is the Sentry SaaS URL) - transport: (nodeTransportOptions) => { - const nodeTransport = makeNodeTransport(nodeTransportOptions); - return { - flush: (timeout) => nodeTransport.flush(timeout), - send: async (request) => { - if (await shouldSendTelemetry) { - return nodeTransport.send(request); - } else { - return undefined; - } - }, - }; - }, - }); + transport: makeOptionallyEnabledNodeTransport(shouldSendTelemetry), + }; + + applySdkMetadata(clientOptions, "node"); - const hub = new Hub(client); + const client = new ServerRuntimeClient(clientOptions); + const scope = new Scope(); + scope.setClient(client); - setTelemetryDataOnHub(options, hub, bundler); + setTelemetryDataOnScope(options, scope, bundler); - return { sentryHub: hub, sentryClient: client }; + return { sentryScope: scope, sentryClient: client }; } -export function setTelemetryDataOnHub(options: NormalizedOptions, hub: Hub, bundler: string) { +export function setTelemetryDataOnScope(options: NormalizedOptions, scope: Scope, bundler: string) { const { org, project, release, errorHandler, sourcemaps, reactComponentAnnotation } = options; - hub.setTag("upload-legacy-sourcemaps", !!release.uploadLegacySourcemaps); + scope.setTag("upload-legacy-sourcemaps", !!release.uploadLegacySourcemaps); if (release.uploadLegacySourcemaps) { - hub.setTag( + scope.setTag( "uploadLegacySourcemapsEntries", Array.isArray(release.uploadLegacySourcemaps) ? release.uploadLegacySourcemaps.length : 1 ); } - hub.setTag("module-metadata", !!options.moduleMetadata); - hub.setTag("inject-build-information", !!options._experiments.injectBuildInformation); + scope.setTag("module-metadata", !!options.moduleMetadata); + scope.setTag("inject-build-information", !!options._experiments.injectBuildInformation); // Optional release pipeline steps if (release.setCommits) { - hub.setTag("set-commits", release.setCommits.auto === true ? "auto" : "manual"); + scope.setTag("set-commits", release.setCommits.auto === true ? "auto" : "manual"); } else { - hub.setTag("set-commits", "undefined"); + scope.setTag("set-commits", "undefined"); } - hub.setTag("finalize-release", release.finalize); - hub.setTag("deploy-options", !!release.deploy); + scope.setTag("finalize-release", release.finalize); + scope.setTag("deploy-options", !!release.deploy); - // Miscelaneous options - hub.setTag("custom-error-handler", !!errorHandler); - hub.setTag("sourcemaps-assets", !!sourcemaps?.assets); - hub.setTag( + // Miscellaneous options + scope.setTag("custom-error-handler", !!errorHandler); + scope.setTag("sourcemaps-assets", !!sourcemaps?.assets); + scope.setTag( "delete-after-upload", !!sourcemaps?.deleteFilesAfterUpload || !!sourcemaps?.filesToDeleteAfterUpload ); - hub.setTag("sourcemaps-disabled", !!sourcemaps?.disable); + scope.setTag("sourcemaps-disabled", !!sourcemaps?.disable); - hub.setTag("react-annotate", !!reactComponentAnnotation?.enabled); + scope.setTag("react-annotate", !!reactComponentAnnotation?.enabled); - hub.setTag("node", process.version); - hub.setTag("platform", process.platform); + scope.setTag("node", process.version); + scope.setTag("platform", process.platform); - hub.setTag("meta-framework", options._metaOptions.telemetry.metaFramework ?? "none"); + scope.setTag("meta-framework", options._metaOptions.telemetry.metaFramework ?? "none"); - hub.setTag("application-key-set", options.applicationKey !== undefined); + scope.setTag("application-key-set", options.applicationKey !== undefined); - hub.setTags({ + scope.setTags({ organization: org, project, bundler, }); - hub.setUser({ id: org }); + scope.setUser({ id: org }); } export async function allowedToSendTelemetry(options: NormalizedOptions): Promise { @@ -157,7 +158,7 @@ export async function allowedToSendTelemetry(options: NormalizedOptions): Promis /** * Flushing the SDK client can fail. We never want to crash the plugin because of telemetry. */ -export async function safeFlushTelemetry(sentryClient: NodeClient) { +export async function safeFlushTelemetry(sentryClient: Client) { try { await sentryClient.flush(2000); } catch { diff --git a/packages/bundler-plugin-core/src/sentry/transports.ts b/packages/bundler-plugin-core/src/sentry/transports.ts new file mode 100644 index 00000000..2b7b78dc --- /dev/null +++ b/packages/bundler-plugin-core/src/sentry/transports.ts @@ -0,0 +1,135 @@ +/** + * This is a simplified version of the Sentry Node SDK's HTTP transport. + */ +import * as https from "node:https"; +import { Readable } from "node:stream"; +import { createGzip } from "node:zlib"; +import { createTransport, suppressTracing } from "@sentry/core"; +import type { + BaseTransportOptions, + Transport, + TransportMakeRequestResponse, + TransportRequest, + TransportRequestExecutor, +} from "@sentry/types"; + +// Estimated maximum size for reasonable standalone event +const GZIP_THRESHOLD = 1024 * 32; + +/** + * Gets a stream from a Uint8Array or string + * Readable.from is ideal but was added in node.js v12.3.0 and v10.17.0 + */ +function streamFromBody(body: Uint8Array | string): Readable { + return new Readable({ + read() { + this.push(body); + this.push(null); + }, + }); +} + +/** + * Creates a RequestExecutor to be used with `createTransport`. + */ +function createRequestExecutor(options: BaseTransportOptions): TransportRequestExecutor { + const { hostname, pathname, port, protocol, search } = new URL(options.url); + return function makeRequest(request: TransportRequest): Promise { + return new Promise((resolve, reject) => { + suppressTracing(() => { + let body = streamFromBody(request.body); + + const headers: Record = {}; + + if (request.body.length > GZIP_THRESHOLD) { + headers["content-encoding"] = "gzip"; + body = body.pipe(createGzip()); + } + + const req = https.request( + { + method: "POST", + headers, + hostname, + path: `${pathname}${search}`, + port, + protocol, + }, + (res) => { + res.on("data", () => { + // Drain socket + }); + + res.on("end", () => { + // Drain socket + }); + + res.setEncoding("utf8"); + + // "Key-value pairs of header names and values. Header names are lower-cased." + // https://nodejs.org/api/http.html#http_message_headers + const retryAfterHeader = res.headers["retry-after"] ?? null; + const rateLimitsHeader = res.headers["x-sentry-rate-limits"] ?? null; + + resolve({ + statusCode: res.statusCode, + headers: { + "retry-after": retryAfterHeader, + "x-sentry-rate-limits": Array.isArray(rateLimitsHeader) + ? rateLimitsHeader[0] || null + : rateLimitsHeader, + }, + }); + } + ); + + req.on("error", reject); + body.pipe(req); + }); + }); + }; +} + +/** + * Creates a Transport that uses native the native 'http' and 'https' modules to send events to Sentry. + */ +function makeNodeTransport(options: BaseTransportOptions) { + const requestExecutor = createRequestExecutor(options); + return createTransport(options, requestExecutor); +} + +/** + * global.__SENTRY_INTERCEPT_TRANSPORT__ is used for testing purposes. If it is + * set as an array, the transport will push envelopes to it instead of sending them. + */ +function getGlobalWithInterceptor(): typeof global & { + __SENTRY_INTERCEPT_TRANSPORT__?: unknown[]; +} { + return global; +} + +/** A transports that does nothing */ +export function makeOptionallyEnabledNodeTransport( + shouldSendTelemetry: Promise +): (options: BaseTransportOptions) => Transport { + return (nodeTransportOptions) => { + const nodeTransport = makeNodeTransport(nodeTransportOptions); + return { + flush: (timeout) => nodeTransport.flush(timeout), + send: async (request) => { + // Allow intercepting enveloped for testing + const gbl = getGlobalWithInterceptor(); + if (gbl.__SENTRY_INTERCEPT_TRANSPORT__) { + gbl.__SENTRY_INTERCEPT_TRANSPORT__.push(request); + return { statusCode: 200 }; + } + + if (await shouldSendTelemetry) { + return nodeTransport.send(request); + } + + return { statusCode: 200 }; + }, + }; + }; +} diff --git a/packages/bundler-plugin-core/src/types.ts b/packages/bundler-plugin-core/src/types.ts index aa521145..c42e5b10 100644 --- a/packages/bundler-plugin-core/src/types.ts +++ b/packages/bundler-plugin-core/src/types.ts @@ -124,6 +124,7 @@ export interface Options { * * Defaults to making all sources relative to `process.cwd()` while building. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any rewriteSources?: (source: string, map: any) => string; /** diff --git a/packages/bundler-plugin-core/test/sentry/telemetry.test.ts b/packages/bundler-plugin-core/test/sentry/telemetry.test.ts index 213775a8..77b977a1 100644 --- a/packages/bundler-plugin-core/test/sentry/telemetry.test.ts +++ b/packages/bundler-plugin-core/test/sentry/telemetry.test.ts @@ -1,6 +1,6 @@ -import { Hub } from "@sentry/node"; +import { Scope } from "@sentry/core"; import { NormalizedOptions, normalizeUserOptions } from "../../src/options-mapping"; -import { allowedToSendTelemetry, setTelemetryDataOnHub } from "../../src/sentry/telemetry"; +import { allowedToSendTelemetry, setTelemetryDataOnScope } from "../../src/sentry/telemetry"; const mockCliExecute = jest.fn(); jest.mock( @@ -31,8 +31,8 @@ describe("shouldSendTelemetry", () => { }); }); -describe("addPluginOptionTagsToHub", () => { - const mockedHub = { +describe("addPluginOptionTagsToScope", () => { + const mockedScope = { setTag: jest.fn(), setTags: jest.fn(), setUser: jest.fn(), @@ -47,40 +47,40 @@ describe("addPluginOptionTagsToHub", () => { }); it("should set include tag according to number of entries (single entry)", () => { - setTelemetryDataOnHub( + setTelemetryDataOnScope( normalizeUserOptions(defaultOptions), - mockedHub as unknown as Hub, + mockedScope as unknown as Scope, "rollup" ); - expect(mockedHub.setTag).toHaveBeenCalledWith("uploadLegacySourcemapsEntries", 0); + expect(mockedScope.setTag).toHaveBeenCalledWith("uploadLegacySourcemapsEntries", 0); }); it("should set include tag according to number of entries (multiple entries)", () => { - setTelemetryDataOnHub( + setTelemetryDataOnScope( normalizeUserOptions({ release: { uploadLegacySourcemaps: ["", "", ""] } }), - mockedHub as unknown as Hub, + mockedScope as unknown as Scope, "rollup" ); - expect(mockedHub.setTag).toHaveBeenCalledWith("uploadLegacySourcemapsEntries", 3); + expect(mockedScope.setTag).toHaveBeenCalledWith("uploadLegacySourcemapsEntries", 3); }); it("should set deploy tag to true if the deploy option is specified", () => { - setTelemetryDataOnHub( + setTelemetryDataOnScope( normalizeUserOptions({ ...defaultOptions, release: { deploy: { env: "production" } } }), - mockedHub as unknown as Hub, + mockedScope as unknown as Scope, "rollup" ); - expect(mockedHub.setTag).toHaveBeenCalledWith("deploy-options", true); + expect(mockedScope.setTag).toHaveBeenCalledWith("deploy-options", true); }); it("should set errorHandler tag to `custom` if the errorHandler option is specified", () => { - setTelemetryDataOnHub( + setTelemetryDataOnScope( // eslint-disable-next-line @typescript-eslint/no-empty-function normalizeUserOptions({ ...defaultOptions, errorHandler: () => {} }), - mockedHub as unknown as Hub, + mockedScope as unknown as Scope, "rollup" ); - expect(mockedHub.setTag).toHaveBeenCalledWith("custom-error-handler", true); + expect(mockedScope.setTag).toHaveBeenCalledWith("custom-error-handler", true); }); it.each([ @@ -89,40 +89,40 @@ describe("addPluginOptionTagsToHub", () => { ])( `should set setCommits tag to %s if the setCommits option is %s`, (expectedValue, commitOptions) => { - setTelemetryDataOnHub( - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + setTelemetryDataOnScope( + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any normalizeUserOptions({ ...defaultOptions, release: { setCommits: commitOptions as any } }), - mockedHub as unknown as Hub, + mockedScope as unknown as Scope, "rollup" ); - expect(mockedHub.setTag).toHaveBeenCalledWith("set-commits", expectedValue); + expect(mockedScope.setTag).toHaveBeenCalledWith("set-commits", expectedValue); } ); it("sets all simple tags correctly", () => { - setTelemetryDataOnHub( + setTelemetryDataOnScope( normalizeUserOptions({ ...defaultOptions, release: { finalize: true, }, }), - mockedHub as unknown as Hub, + mockedScope as unknown as Scope, "rollup" ); - expect(mockedHub.setTag).toHaveBeenCalledWith("finalize-release", true); + expect(mockedScope.setTag).toHaveBeenCalledWith("finalize-release", true); }); it("shouldn't set any tags other than include if no opional options are specified", () => { - setTelemetryDataOnHub( + setTelemetryDataOnScope( normalizeUserOptions(defaultOptions), - mockedHub as unknown as Hub, + mockedScope as unknown as Scope, "rollup" ); - expect(mockedHub.setTag).toHaveBeenCalledWith("uploadLegacySourcemapsEntries", 0); - expect(mockedHub.setTag).toHaveBeenCalledWith("finalize-release", true); - expect(mockedHub.setTag).toHaveBeenCalledWith("node", expect.any(String)); + expect(mockedScope.setTag).toHaveBeenCalledWith("uploadLegacySourcemapsEntries", 0); + expect(mockedScope.setTag).toHaveBeenCalledWith("finalize-release", true); + expect(mockedScope.setTag).toHaveBeenCalledWith("node", expect.any(String)); }); }); diff --git a/packages/integration-tests/fixtures/telemetry/input/bundle1.js b/packages/integration-tests/fixtures/telemetry/input/bundle1.js new file mode 100644 index 00000000..fbf083a4 --- /dev/null +++ b/packages/integration-tests/fixtures/telemetry/input/bundle1.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line no-console +console.log("bundle1"); diff --git a/packages/integration-tests/fixtures/telemetry/telemetry.test.ts b/packages/integration-tests/fixtures/telemetry/telemetry.test.ts new file mode 100644 index 00000000..5015cfdf --- /dev/null +++ b/packages/integration-tests/fixtures/telemetry/telemetry.test.ts @@ -0,0 +1,148 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable jest/no-standalone-expect */ +/* eslint-disable jest/expect-expect */ +import path from "path"; +import * as rollup from "rollup"; +import { sentryRollupPlugin } from "@sentry/rollup-plugin"; + +function getGlobalWithInterceptor(): typeof global & { + __SENTRY_INTERCEPT_TRANSPORT__?: unknown[]; +} { + return global; +} + +test("rollup bundle telemetry", async () => { + const gbl = getGlobalWithInterceptor(); + gbl.__SENTRY_INTERCEPT_TRANSPORT__ = []; + + await rollup + .rollup({ + input: { bundle1: path.resolve(__dirname, "input", "bundle1.js") }, + plugins: [ + sentryRollupPlugin({ + release: { + inject: false, + }, + telemetry: true, + }), + ], + }) + .then((bundle) => + bundle.write({ + sourcemap: true, + dir: path.join(path.resolve(__dirname, "out"), "rollup"), + format: "cjs", + exports: "named", + }) + ); + + // Ensure the session gets closed + process.emit("beforeExit", 0); + + expect(gbl.__SENTRY_INTERCEPT_TRANSPORT__).toEqual([ + // 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, + }), + ], + ], + ]), + // 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, + }, + origin: "manual", + }, + 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": "undefined", + "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) }], + }), + }), + ], + ], + ], + // 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" }, + }, + ], + ], + ], + ]); +}); diff --git a/yarn.lock b/yarn.lock index d8755c92..e7a4e41a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2716,15 +2716,6 @@ estree-walker "^2.0.2" picomatch "^2.3.1" -"@sentry-internal/tracing@7.102.0": - version "7.102.0" - resolved "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.102.0.tgz#24cf662e1eb5623f6d5197e78c66d7b257560eb8" - integrity sha512-BlE33HWL1IzkGa0W+pwTiyu01MUIfYf+WnO9UC8qkDW3jxVvg2zhoSjXSxikT+KPCOgoZpQHspaTzwjnI1LCvw== - dependencies: - "@sentry/core" "7.102.0" - "@sentry/types" "7.102.0" - "@sentry/utils" "7.102.0" - "@sentry-internal/tracing@7.50.0": version "7.50.0" resolved "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.50.0.tgz#74454af99a03d81762993835d2687c881e14f41e" @@ -2789,14 +2780,6 @@ "@sentry/cli-win32-i686" "2.33.1" "@sentry/cli-win32-x64" "2.33.1" -"@sentry/core@7.102.0": - version "7.102.0" - resolved "https://registry.npmjs.org/@sentry/core/-/core-7.102.0.tgz#da5e04a5fe97ed91464944dac40b813e6f8aa453" - integrity sha512-GO9eLOSBK1waW4AD0wDXAreaNqXFQ1MPQZrkKcN+GJYEFhJK1+u+MSV7vO5Fs/rIfaTZIZ2jtEkxSSAOucE8EQ== - dependencies: - "@sentry/types" "7.102.0" - "@sentry/utils" "7.102.0" - "@sentry/core@7.50.0": version "7.50.0" resolved "https://registry.npmjs.org/@sentry/core/-/core-7.50.0.tgz#88bc9cbfc0cb429a28489ece6f0be7a7006436c4" @@ -2806,6 +2789,14 @@ "@sentry/utils" "7.50.0" tslib "^1.9.3" +"@sentry/core@8.28.0": + version "8.28.0" + resolved "https://registry.npmjs.org/@sentry/core/-/core-8.28.0.tgz#dd28fa913c296b443d4070f147c63e81edf429c8" + integrity sha512-+If9uubvpZpvaQQw4HLiKPhrSS9/KcoA/AcdQkNm+5CVwAoOmDPtyYfkPBgfo2hLZnZQqR1bwkz/PrNoOm+gqA== + dependencies: + "@sentry/types" "8.28.0" + "@sentry/utils" "8.28.0" + "@sentry/integrations@7.50": version "7.50.0" resolved "https://registry.npmjs.org/@sentry/integrations/-/integrations-7.50.0.tgz#82616f34ddba3c1f3e17b54900dfa7d8e0a0c537" @@ -2816,16 +2807,6 @@ localforage "^1.8.1" tslib "^1.9.3" -"@sentry/node@7.102.0": - version "7.102.0" - resolved "https://registry.npmjs.org/@sentry/node/-/node-7.102.0.tgz#f2853bad8650b1f94a57ae3bafad3440740f98ab" - integrity sha512-ZS1s2uO/+K4rHkmWjyqm5Jtl6dT7klbZSMvn4tfIpkfWuqrs7pP0jaATyvmF+96z3lpq6fRAJliV5tRqPy7w5Q== - dependencies: - "@sentry-internal/tracing" "7.102.0" - "@sentry/core" "7.102.0" - "@sentry/types" "7.102.0" - "@sentry/utils" "7.102.0" - "@sentry/node@7.50": version "7.50.0" resolved "https://registry.npmjs.org/@sentry/node/-/node-7.50.0.tgz#d6adab136d87f7dca614ea0d77944f902fa45626" @@ -2840,22 +2821,15 @@ lru_map "^0.3.3" tslib "^1.9.3" -"@sentry/types@7.102.0": - version "7.102.0" - resolved "https://registry.npmjs.org/@sentry/types/-/types-7.102.0.tgz#b31e9faa54036053ab82c09c3c855035a4889c59" - integrity sha512-FPfFBP0x3LkPARw1/6cWySLq1djIo8ao3Qo2KNBeE9CHdq8bsS1a8zzjJLuWG4Ww+wieLP8/lY3WTgrCz4jowg== - "@sentry/types@7.50.0": version "7.50.0" resolved "https://registry.npmjs.org/@sentry/types/-/types-7.50.0.tgz#52a035cad83a80ca26fa53c09eb1241250c3df3e" integrity sha512-Zo9vyI98QNeYT0K0y57Rb4JRWDaPEgmp+QkQ4CRQZFUTWetO5fvPZ4Gb/R7TW16LajuHZlbJBHmvmNj2pkL2kw== -"@sentry/utils@7.102.0": - version "7.102.0" - resolved "https://registry.npmjs.org/@sentry/utils/-/utils-7.102.0.tgz#66325f2567986cc3fd12fbdb980fb8ada170342b" - integrity sha512-cp5KCRe0slOVMwG4iP2Z4UajQkjryRTiFskZ5H7Q3X9R5voM8+DAhiDcIW88GL9NxqyUrAJOjmKdeLK2vM+bdA== - dependencies: - "@sentry/types" "7.102.0" +"@sentry/types@8.28.0": + version "8.28.0" + resolved "https://registry.npmjs.org/@sentry/types/-/types-8.28.0.tgz#a1cfc004d5714679cb3fed06c27298b0275d13b5" + integrity sha512-hOfqfd92/AzBrEdMgmmV1VfOXJbIfleFTnerRl0mg/+CcNgP/6+Fdonp354TD56ouWNF2WkOM6sEKSXMWp6SEQ== "@sentry/utils@7.50.0": version "7.50.0" @@ -2865,6 +2839,13 @@ "@sentry/types" "7.50.0" tslib "^1.9.3" +"@sentry/utils@8.28.0": + version "8.28.0" + resolved "https://registry.npmjs.org/@sentry/utils/-/utils-8.28.0.tgz#0feb46015033879b2a3cee4c0661386610025f47" + integrity sha512-smhk7PJpvDMQ2DB5p2qn9UeoUHdU41IgjMmS2xklZpa8tjzBTxDeWpGvrX2fuH67D9bAJuLC/XyZjJCHLoEW5g== + dependencies: + "@sentry/types" "8.28.0" + "@sigstore/protobuf-specs@^0.1.0": version "0.1.0" resolved "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.1.0.tgz#957cb64ea2f5ce527cc9cf02a096baeb0d2b99b4" From e4bc4c74bb743d129a52dd52e5a2935e4937b70e Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 10 Sep 2024 14:29:55 +0200 Subject: [PATCH 2/6] Fix sessions --- packages/bundler-plugin-core/src/index.ts | 24 +++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/bundler-plugin-core/src/index.ts b/packages/bundler-plugin-core/src/index.ts index 5e8dc56c..e03e7bee 100644 --- a/packages/bundler-plugin-core/src/index.ts +++ b/packages/bundler-plugin-core/src/index.ts @@ -128,16 +128,24 @@ export function sentryUnpluginFactory({ const sentrySession = makeSession({ release, environment }); sentryScope.setSession(sentrySession); + // Send the start of the session sentryClient.captureSession(sentrySession); - let sentEndSession = false; // Just to prevent infinite loops with beforeExit, which is called whenever the event loop empties out - // We also need to manually end sesisons on errors because beforeExit is not called on crashes - process.on("beforeExit", () => { - if (!sentEndSession) { - closeSession(sentrySession); - sentryClient.captureSession(sentrySession); - sentEndSession = true; + 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 @@ -164,7 +172,7 @@ export function sentryUnpluginFactory({ throw unknownError; } } finally { - closeSession(sentrySession); + endSession(); } } From 4de287a949d62bed0fec74bad88787939dcb035d Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 10 Sep 2024 14:33:15 +0200 Subject: [PATCH 3/6] Simplify transport intercept --- .../src/sentry/transports.ts | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/bundler-plugin-core/src/sentry/transports.ts b/packages/bundler-plugin-core/src/sentry/transports.ts index 2b7b78dc..a93daeb6 100644 --- a/packages/bundler-plugin-core/src/sentry/transports.ts +++ b/packages/bundler-plugin-core/src/sentry/transports.ts @@ -98,16 +98,6 @@ function makeNodeTransport(options: BaseTransportOptions) { return createTransport(options, requestExecutor); } -/** - * global.__SENTRY_INTERCEPT_TRANSPORT__ is used for testing purposes. If it is - * set as an array, the transport will push envelopes to it instead of sending them. - */ -function getGlobalWithInterceptor(): typeof global & { - __SENTRY_INTERCEPT_TRANSPORT__?: unknown[]; -} { - return global; -} - /** A transports that does nothing */ export function makeOptionallyEnabledNodeTransport( shouldSendTelemetry: Promise @@ -117,10 +107,13 @@ export function makeOptionallyEnabledNodeTransport( return { flush: (timeout) => nodeTransport.flush(timeout), send: async (request) => { - // Allow intercepting enveloped for testing - const gbl = getGlobalWithInterceptor(); - if (gbl.__SENTRY_INTERCEPT_TRANSPORT__) { - gbl.__SENTRY_INTERCEPT_TRANSPORT__.push(request); + // If global.__SENTRY_INTERCEPT_TRANSPORT__ is an array, we push the + // envelope into it for testing purposes. + if ( + "__SENTRY_INTERCEPT_TRANSPORT__" in global && + Array.isArray(global.__SENTRY_INTERCEPT_TRANSPORT__) + ) { + global.__SENTRY_INTERCEPT_TRANSPORT__.push(request); return { statusCode: 200 }; } From 0620ad81c0d0341d573d8a89c746e71328cae461 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 10 Sep 2024 14:35:08 +0200 Subject: [PATCH 4/6] correct jsdoc --- packages/bundler-plugin-core/src/sentry/transports.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/bundler-plugin-core/src/sentry/transports.ts b/packages/bundler-plugin-core/src/sentry/transports.ts index a93daeb6..d85923bc 100644 --- a/packages/bundler-plugin-core/src/sentry/transports.ts +++ b/packages/bundler-plugin-core/src/sentry/transports.ts @@ -98,7 +98,8 @@ function makeNodeTransport(options: BaseTransportOptions) { return createTransport(options, requestExecutor); } -/** A transports that does nothing */ +/** A transport that can be optionally enabled as a later time than it's + * creation */ export function makeOptionallyEnabledNodeTransport( shouldSendTelemetry: Promise ): (options: BaseTransportOptions) => Transport { From be192138ba5b211994851b57ccb260b3b42062a9 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 12 Sep 2024 17:48:26 +0200 Subject: [PATCH 5/6] Update to latest SDK and re-add measurements --- packages/bundler-plugin-core/package.json | 6 +- .../src/debug-id-upload.ts | 132 +++++++++--------- yarn.lock | 30 ++-- 3 files changed, 84 insertions(+), 84 deletions(-) diff --git a/packages/bundler-plugin-core/package.json b/packages/bundler-plugin-core/package.json index bbe7e6e9..fbc0a354 100644 --- a/packages/bundler-plugin-core/package.json +++ b/packages/bundler-plugin-core/package.json @@ -70,9 +70,9 @@ "@rollup/plugin-replace": "^4.0.0", "@sentry-internal/eslint-config": "2.22.4", "@sentry-internal/sentry-bundler-plugin-tsconfig": "2.22.4", - "@sentry/core": "8.28.0", - "@sentry/types": "8.28.0", - "@sentry/utils": "8.28.0", + "@sentry/core": "8.30.0", + "@sentry/types": "8.30.0", + "@sentry/utils": "8.30.0", "@swc/core": "^1.2.205", "@swc/jest": "^0.2.21", "@types/jest": "^28.1.3", diff --git a/packages/bundler-plugin-core/src/debug-id-upload.ts b/packages/bundler-plugin-core/src/debug-id-upload.ts index b962e7df..9ce1747d 100644 --- a/packages/bundler-plugin-core/src/debug-id-upload.ts +++ b/packages/bundler-plugin-core/src/debug-id-upload.ts @@ -9,7 +9,7 @@ import SentryCli from "@sentry/cli"; import { dynamicSamplingContextToSentryBaggageHeader } from "@sentry/utils"; import { safeFlushTelemetry } from "./sentry/telemetry"; import { stripQueryAndHashFromPath } from "./utils"; -import { spanToTraceHeader, startSpan } from "@sentry/core"; +import { setMeasurement, spanToTraceHeader, startSpan } from "@sentry/core"; import { getDynamicSamplingContextFromSpan, Scope } from "@sentry/core"; import { Client } from "@sentry/types"; @@ -107,76 +107,76 @@ export function createDebugIdUploadFunction({ "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 () => { - // 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(); + 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()); } - }; - for (let workerIndex = 0; workerIndex < 16; workerIndex++) { - workers.push(worker()); - } - await Promise.all(workers); - - // TODO: Bring back measurements - // There's no easy way to get the root span when not using a global client. - - // 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 - // ); - - // artifactBundleUploadTransaction.setMeasurement("files", files.length, "none"); - // artifactBundleUploadTransaction.setMeasurement("upload_size", uploadSize, "byte"); - - 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 Promise.all(workers); - await cliInstance.releases.uploadSourceMaps( - releaseName ?? "undefined", // unfortunetly this needs a value for now but it will not matter since debug IDs overpower releases anyhow - { - include: [ - { - paths: [tmpUploadFolder], - rewrite: false, - dist: dist, - }, - ], - useArtifactBundle: true, - } + 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", // unfortunetly this needs a value for now but it will not matter since debug IDs overpower releases anyhow + { + include: [ + { + paths: [tmpUploadFolder], + rewrite: false, + dist: dist, + }, + ], + useArtifactBundle: true, + } + ); + }); + } + ); logger.info("Successfully uploaded source maps to Sentry"); } diff --git a/yarn.lock b/yarn.lock index e7a4e41a..dde7ac07 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2789,13 +2789,13 @@ "@sentry/utils" "7.50.0" tslib "^1.9.3" -"@sentry/core@8.28.0": - version "8.28.0" - resolved "https://registry.npmjs.org/@sentry/core/-/core-8.28.0.tgz#dd28fa913c296b443d4070f147c63e81edf429c8" - integrity sha512-+If9uubvpZpvaQQw4HLiKPhrSS9/KcoA/AcdQkNm+5CVwAoOmDPtyYfkPBgfo2hLZnZQqR1bwkz/PrNoOm+gqA== +"@sentry/core@8.30.0": + version "8.30.0" + resolved "https://registry.npmjs.org/@sentry/core/-/core-8.30.0.tgz#f929e42e9a537bfa3eb6024082714e9ab98d822b" + integrity sha512-CJ/FuWLw0QEKGKXGL/nm9eaOdajEcmPekLuHAuOCxID7N07R9l9laz3vFbAkUZ97GGDv3sYrJZgywfY3Moropg== dependencies: - "@sentry/types" "8.28.0" - "@sentry/utils" "8.28.0" + "@sentry/types" "8.30.0" + "@sentry/utils" "8.30.0" "@sentry/integrations@7.50": version "7.50.0" @@ -2826,10 +2826,10 @@ resolved "https://registry.npmjs.org/@sentry/types/-/types-7.50.0.tgz#52a035cad83a80ca26fa53c09eb1241250c3df3e" integrity sha512-Zo9vyI98QNeYT0K0y57Rb4JRWDaPEgmp+QkQ4CRQZFUTWetO5fvPZ4Gb/R7TW16LajuHZlbJBHmvmNj2pkL2kw== -"@sentry/types@8.28.0": - version "8.28.0" - resolved "https://registry.npmjs.org/@sentry/types/-/types-8.28.0.tgz#a1cfc004d5714679cb3fed06c27298b0275d13b5" - integrity sha512-hOfqfd92/AzBrEdMgmmV1VfOXJbIfleFTnerRl0mg/+CcNgP/6+Fdonp354TD56ouWNF2WkOM6sEKSXMWp6SEQ== +"@sentry/types@8.30.0": + version "8.30.0" + resolved "https://registry.npmjs.org/@sentry/types/-/types-8.30.0.tgz#5f5011f5b16bafd30a039ca5e8c337e948c703fb" + integrity sha512-kgWW2BCjBmVlSQRG32GonHEVyeDbys74xf9mLPvynwHTgw3+NUlNAlEdu05xnb2ow4bCTHfbkS5G1zRgyv5k4Q== "@sentry/utils@7.50.0": version "7.50.0" @@ -2839,12 +2839,12 @@ "@sentry/types" "7.50.0" tslib "^1.9.3" -"@sentry/utils@8.28.0": - version "8.28.0" - resolved "https://registry.npmjs.org/@sentry/utils/-/utils-8.28.0.tgz#0feb46015033879b2a3cee4c0661386610025f47" - integrity sha512-smhk7PJpvDMQ2DB5p2qn9UeoUHdU41IgjMmS2xklZpa8tjzBTxDeWpGvrX2fuH67D9bAJuLC/XyZjJCHLoEW5g== +"@sentry/utils@8.30.0": + version "8.30.0" + resolved "https://registry.npmjs.org/@sentry/utils/-/utils-8.30.0.tgz#2343dd8593ea83890b3e0d792ed3fa257955a26b" + integrity sha512-wZxU2HWlzsnu8214Xy7S7cRIuD6h8Z5DnnkojJfX0i0NLooepZQk2824el1Q13AakLb7/S8CHSHXOMnCtoSduw== dependencies: - "@sentry/types" "8.28.0" + "@sentry/types" "8.30.0" "@sigstore/protobuf-specs@^0.1.0": version "0.1.0" From dfbbc78f7a4562a714e8c37afacaae88ec5ed6d3 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Mon, 23 Sep 2024 11:24:28 +0200 Subject: [PATCH 6/6] forceTransaction `debug-id-sourcemap-upload` --- .../src/debug-id-upload.ts | 250 +++++++++--------- 1 file changed, 128 insertions(+), 122 deletions(-) diff --git a/packages/bundler-plugin-core/src/debug-id-upload.ts b/packages/bundler-plugin-core/src/debug-id-upload.ts index 9ce1747d..d5c7358d 100644 --- a/packages/bundler-plugin-core/src/debug-id-upload.ts +++ b/packages/bundler-plugin-core/src/debug-id-upload.ts @@ -56,146 +56,152 @@ export function createDebugIdUploadFunction({ const freeGlobalDependencyOnSourcemapFiles = createDependencyOnSourcemapFiles(); return async (buildArtifactPaths: string[]) => { - await startSpan({ name: "debug-id-sourcemap-upload", scope: sentryScope }, async () => { - let folderToCleanUp: string | undefined; + 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-") + ); + } + ); - // 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(); + folderToCleanUp = tmpUploadFolder; - try { - const tmpUploadFolder = await startSpan( - { name: "mkdtemp", scope: sentryScope }, - async () => { - return await fs.promises.mkdtemp( - path.join(os.tmpdir(), "sentry-bundler-plugin-upload-") + 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; } - ); - - 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." + const globResult = await startSpan( + { name: "glob", scope: sentryScope }, + async () => await glob(globAssets, { absolute: true, nodir: true, ignore: ignore }) ); - 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)$/); - }); + 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(); + // 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(); + 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()); } - }; - for (let workerIndex = 0; workerIndex < 16; workerIndex++) { - workers.push(worker()); - } - await Promise.all(workers); + 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 - ); + 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, - }, + 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", // unfortunetly this needs a value for now but it will not matter since debug IDs overpower releases anyhow + { + include: [ + { + paths: [tmpUploadFolder], + rewrite: false, + dist: dist, + }, + ], + useArtifactBundle: true, + } + ); }); + } + ); - await cliInstance.releases.uploadSourceMaps( - releaseName ?? "undefined", // unfortunetly this needs a value for now but it will not matter since debug IDs overpower releases anyhow - { - include: [ - { - paths: [tmpUploadFolder], - rewrite: false, - dist: dist, - }, - ], - useArtifactBundle: true, - } - ); - }); - } - ); - - logger.info("Successfully uploaded source maps to Sentry"); - } - } catch (e) { - sentryScope.captureException('Error in "debugIdUploadPlugin" writeBundle hook'); - handleRecoverableError(e); - } finally { - if (folderToCleanUp) { - void startSpan({ name: "cleanup", scope: sentryScope }, async () => { - if (folderToCleanUp) { - await fs.promises.rm(folderToCleanUp, { recursive: true, force: true }); - } - }); + logger.info("Successfully uploaded source maps to Sentry"); + } + } catch (e) { + sentryScope.captureException('Error in "debugIdUploadPlugin" writeBundle hook'); + handleRecoverableError(e); + } 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); } - freeGlobalDependencyOnSourcemapFiles(); - freeUploadDependencyOnSourcemapFiles(); - await safeFlushTelemetry(sentryClient); } - }); + ); }; }