diff --git a/packages/unplugin/package.json b/packages/unplugin/package.json index 423c0837..c21f50a4 100644 --- a/packages/unplugin/package.json +++ b/packages/unplugin/package.json @@ -20,6 +20,8 @@ "lint": "eslint ./src ./test" }, "dependencies": { + "@sentry/node": "^7.11.1", + "@sentry/tracing": "^7.11.1", "axios": "^0.27.2", "form-data": "^4.0.0", "magic-string": "0.26.2", diff --git a/packages/unplugin/src/index.ts b/packages/unplugin/src/index.ts index 4976b703..2b8cbade 100644 --- a/packages/unplugin/src/index.ts +++ b/packages/unplugin/src/index.ts @@ -3,6 +3,9 @@ import MagicString from "magic-string"; import { getReleaseName } from "./getReleaseName"; import { Options } from "./types"; import { makeSentryFacade } from "./sentry/facade"; +import "@sentry/tracing"; +import { addSpanToTransaction, captureMinimalError, makeSentryClient } from "./sentry/telemetry"; +import { Span, Transaction } from "@sentry/types"; const defaultOptions: Omit = { //TODO: add default options here as we port over options from the webpack plugin @@ -13,6 +16,7 @@ const defaultOptions: Omit = { finalize: true, url: "https://sentry.io", ext: ["js", "map", "jsbundle", "bundle"], + telemetry: true, }; // We prefix the polyfill id with \0 to tell other plugins not to try to load or transform it. @@ -75,6 +79,30 @@ const RELEASE_INJECTOR_ID = "\0sentry-release-injector"; const unplugin = createUnplugin((originalOptions, unpluginMetaContext) => { const options = { ...defaultOptions, ...originalOptions }; + //TODO: We can get rid of this variable once we have internal plugin options + const telemetryEnabled = options.telemetry === true; + + const { hub: sentryHub } = makeSentryClient( + "https://4c2bae7d9fbc413e8f7385f55c515d51@o1.ingest.sentry.io/6690737", + telemetryEnabled, + options.org + ); + + if (options.telemetry) { + // eslint-disable-next-line no-console + console.log("[Sentry-plugin]", "Sending error and performance telemetry data to Sentry."); + // eslint-disable-next-line no-console + console.log("[Sentry-Plugin]", "To disable telemetry, set `options.telemetry` to `false`."); + } + + sentryHub.setTags({ + organization: options.org, + project: options.project, + bundler: unpluginMetaContext.framework, + }); + + sentryHub.setUser({ id: options.org }); + function debugLog(...args: unknown[]) { if (options?.debugLogging) { // eslint-disable-next-line no-console @@ -88,10 +116,29 @@ const unplugin = createUnplugin((originalOptions, unpluginMetaContext) // file is an entrypoint or a non-entrypoint. const nonEntrypointSet = new Set(); + let transaction: Transaction | undefined; + let releaseInjectionSpan: Span | undefined; + return { name: "sentry-plugin", enforce: "pre", // needed for Vite to call resolveId hook + /** + * Responsible for starting the plugin execution transaction and the release injection span + */ + buildStart() { + transaction = sentryHub.startTransaction({ + op: "sentry-unplugin", + name: "plugin-execution", + }); + releaseInjectionSpan = addSpanToTransaction( + sentryHub, + transaction, + "release-injection", + "release-injection" + ); + }, + /** * Responsible for returning the "sentry-release-injector" ID when we encounter it. We return the ID so load is * called and we can "virtually" load the module. See `load` hook for more info on why it's virtual. @@ -104,9 +151,11 @@ const unplugin = createUnplugin((originalOptions, unpluginMetaContext) * @returns `"sentry-release-injector"` when the imported file is called `"sentry-release-injector"`. Otherwise returns `undefined`. */ resolveId(id, importer, { isEntry }) { - debugLog( - `Called "resolveId": ${JSON.stringify({ id, importer: importer, options: { isEntry } })}` - ); + sentryHub.addBreadcrumb({ + category: "resolveId", + message: `isEntry: ${String(isEntry)}`, + level: "info", + }); if (!isEntry) { nonEntrypointSet.add(id); @@ -133,7 +182,10 @@ const unplugin = createUnplugin((originalOptions, unpluginMetaContext) * @returns The global injector code when we load the "sentry-release-injector" module. Otherwise returns `undefined`. */ load(id) { - debugLog(`Called "transform": ${JSON.stringify({ id })}`); + sentryHub.addBreadcrumb({ + category: "load", + level: "info", + }); if (id === RELEASE_INJECTOR_ID) { return generateGlobalInjectorCode({ release: getReleaseName(options.release) }); @@ -151,7 +203,10 @@ const unplugin = createUnplugin((originalOptions, unpluginMetaContext) * want to transform the release injector file. */ transformInclude(id) { - debugLog(`Called "transformInclude": ${JSON.stringify({ id })}`); + sentryHub.addBreadcrumb({ + category: "transformInclude", + level: "info", + }); if (options.entries) { // If there's an `entries` option transform (ie. inject the release varible) when the file path matches the option. @@ -184,8 +239,11 @@ const unplugin = createUnplugin((originalOptions, unpluginMetaContext) * @param id Always the absolute (fully resolved) path to the module. * @returns transformed code + source map */ - transform(code, id) { - debugLog(`Called "transform": ${JSON.stringify({ code, id })}`); + transform(code) { + sentryHub.addBreadcrumb({ + category: "transform", + level: "info", + }); // The MagicString library allows us to generate sourcemaps for the changes we make to the user code. const ms: MagicString = new MagicString(code); // Very stupid author's note: For some absurd reason, when we add a JSDoc to this hook, the TS language server starts complaining about `ms` and adding a type annotation helped so that's why it's here. (┛ಠ_ಠ)┛彡┻━┻ @@ -206,15 +264,37 @@ const unplugin = createUnplugin((originalOptions, unpluginMetaContext) }; } }, + + /** + * Responsible for executing the sentry release creation pipeline (i.e. creating a release on + * Sentry.io, uploading sourcemaps, associating commits and deploys and finalizing the release) + */ buildEnd() { + releaseInjectionSpan?.finish(); + const releasePipelineSpan = + sentryHub && + transaction && + addSpanToTransaction( + sentryHub, + transaction, + "release-creation", + "release-creation-pipeline" + ); + const release = getReleaseName(options.release); + + sentryHub.addBreadcrumb({ + category: "buildEnd:start", + level: "info", + }); + //TODO: // 1. validate options to see if we get a valid include property, release name, etc. // 2. normalize the include property: Users can pass string | string [] | IncludeEntry[]. // That's good for them but a hassle for us. Let's try to normalize this into one data type // (I vote IncludeEntry[]) and continue with that down the line - const sentryFacade = makeSentryFacade(release, options); + const sentryFacade = makeSentryFacade(release, options, sentryHub); sentryFacade .createNewRelease() @@ -226,7 +306,18 @@ const unplugin = createUnplugin((originalOptions, unpluginMetaContext) .catch((e) => { //TODO: invoke error handler here // https://github.com/getsentry/sentry-webpack-plugin/blob/137503f3ac6fe423b16c5c50379859c86e689017/src/index.js#L540-L547 + captureMinimalError(e, sentryHub); + transaction?.setStatus("cancelled"); debugLog(e); + }) + .finally(() => { + sentryHub.addBreadcrumb({ + category: "buildEnd:finish", + level: "info", + }); + releasePipelineSpan?.finish(); + transaction?.setStatus("ok"); + transaction?.finish(); }); }, }; diff --git a/packages/unplugin/src/sentry/api.ts b/packages/unplugin/src/sentry/api.ts index 3d6b1db9..9e2150c1 100644 --- a/packages/unplugin/src/sentry/api.ts +++ b/packages/unplugin/src/sentry/api.ts @@ -6,21 +6,14 @@ import FormData from "form-data"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import { version as unpluginVersion } from "../../package.json"; +import { captureMinimalError } from "./telemetry"; +import { Hub } from "@sentry/node"; const API_PATH = "/api/0"; // eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-member-access const USER_AGENT = `sentry-unplugin/${unpluginVersion}`; -const sentryApiAxiosInstance = axios.create(); -sentryApiAxiosInstance.interceptors.request.use((config) => { - return { - ...config, - headers: { - ...config.headers, - "User-Agent": USER_AGENT, - }, - }; -}); +const sentryApiAxiosInstance = axios.create({ headers: { "User-Agent": USER_AGENT } }); export async function createRelease({ org, @@ -28,12 +21,14 @@ export async function createRelease({ release, authToken, sentryUrl, + sentryHub, }: { release: string; project: string; org: string; authToken: string; sentryUrl: string; + sentryHub: Hub; }): Promise { const requestUrl = `${sentryUrl}${API_PATH}/organizations/${org}/releases/`; @@ -49,8 +44,8 @@ export async function createRelease({ headers: { Authorization: `Bearer ${authToken}` }, }); } catch (e) { - // TODO: Maybe do some more sopthisticated error handling here - throw new Error("Something went wrong while creating a release"); + captureMinimalError(e, sentryHub); + throw e; } } @@ -60,12 +55,14 @@ export async function deleteAllReleaseArtifacts({ release, authToken, sentryUrl, + sentryHub, }: { org: string; release: string; sentryUrl: string; authToken: string; project: string; + sentryHub: Hub; }): Promise { const requestUrl = `${sentryUrl}${API_PATH}/projects/${org}/${project}/files/source-maps/?name=${release}`; @@ -76,8 +73,8 @@ export async function deleteAllReleaseArtifacts({ }, }); } catch (e) { - // TODO: Maybe do some more sopthisticated error handling here - throw new Error("Something went wrong while cleaning previous release artifacts"); + captureMinimalError(e, sentryHub); + throw e; } } @@ -87,12 +84,14 @@ export async function updateRelease({ authToken, sentryUrl, project, + sentryHub, }: { release: string; org: string; authToken: string; sentryUrl: string; project: string; + sentryHub: Hub; }): Promise { const requestUrl = `${sentryUrl}${API_PATH}/projects/${org}/${project}/releases/${release}/`; @@ -105,8 +104,8 @@ export async function updateRelease({ headers: { Authorization: `Bearer ${authToken}` }, }); } catch (e) { - // TODO: Maybe do some more sopthisticated error handling here - throw new Error("Something went wrong while creating a release"); + captureMinimalError(e, sentryHub); + throw e; } } @@ -118,6 +117,7 @@ export async function uploadReleaseFile({ sentryUrl, filename, fileContent, + sentryHub, }: { org: string; release: string; @@ -126,6 +126,7 @@ export async function uploadReleaseFile({ project: string; filename: string; fileContent: string; + sentryHub: Hub; }) { const requestUrl = `${sentryUrl}${API_PATH}/projects/${org}/${project}/releases/${release}/files/`; @@ -141,7 +142,7 @@ export async function uploadReleaseFile({ }, }); } catch (e) { - // TODO: Maybe do some more sopthisticated error handling here - throw new Error(`Something went wrong while uploading file ${filename}`); + captureMinimalError(e, sentryHub); + throw e; } } diff --git a/packages/unplugin/src/sentry/facade.ts b/packages/unplugin/src/sentry/facade.ts index 46551a1e..105882f6 100644 --- a/packages/unplugin/src/sentry/facade.ts +++ b/packages/unplugin/src/sentry/facade.ts @@ -6,9 +6,12 @@ // - huge download // - unnecessary functionality +import { Hub } from "@sentry/node"; +import { Span } from "@sentry/types"; import { Options } from "../types"; import { createRelease, deleteAllReleaseArtifacts, uploadReleaseFile, updateRelease } from "./api"; import { getFiles } from "./sourcemaps"; +import { addSpanToTransaction } from "./telemetry"; export type SentryFacade = { createNewRelease: () => Promise; @@ -23,18 +26,26 @@ export type SentryFacade = { * Factory function that provides all necessary Sentry functionality for creating * a release on Sentry. This includes uploading source maps and finalizing the release */ -export function makeSentryFacade(release: string, options: Options): SentryFacade { +export function makeSentryFacade(release: string, options: Options, sentryHub: Hub): SentryFacade { + const span = sentryHub.getScope()?.getSpan(); return { - createNewRelease: () => createNewRelease(release, options), - cleanArtifacts: () => cleanArtifacts(release, options), - uploadSourceMaps: () => uploadSourceMaps(release, options), - setCommits: () => setCommits(/* release */), - finalizeRelease: () => finalizeRelease(release, options), - addDeploy: () => addDeploy(/* release */), + createNewRelease: () => createNewRelease(release, options, sentryHub, span), + cleanArtifacts: () => cleanArtifacts(release, options, sentryHub, span), + uploadSourceMaps: () => uploadSourceMaps(release, options, sentryHub, span), + setCommits: () => setCommits(/* release, */ sentryHub, span), + finalizeRelease: () => finalizeRelease(release, options, sentryHub, span), + addDeploy: () => addDeploy(/* release, */ sentryHub, span), }; } -async function createNewRelease(release: string, options: Options): Promise { +async function createNewRelease( + release: string, + options: Options, + sentryHub: Hub, + parentSpan?: Span +): Promise { + const span = addSpanToTransaction(sentryHub, parentSpan, "create-new-release"); + // TODO: pull these checks out of here and simplify them if (options.authToken === undefined) { // eslint-disable-next-line no-console @@ -60,15 +71,23 @@ async function createNewRelease(release: string, options: Options): Promise { +async function uploadSourceMaps( + release: string, + options: Options, + sentryHub: Hub, + parentSpan?: Span +): Promise { + const span = addSpanToTransaction(sentryHub, parentSpan, "upload-sourceMaps"); // This is what Sentry CLI does: // TODO: 0. Preprocess source maps // - (Out of scope for now) @@ -84,7 +103,6 @@ async function uploadSourceMaps(release: string, options: Options): Promise { // eslint-disable-next-line no-console console.log("[Sentry-plugin] Successfully uploaded sourcemaps."); + span?.finish(); return "done"; }); } -async function finalizeRelease(release: string, options: Options): Promise { +async function finalizeRelease( + release: string, + options: Options, + sentryHub: Hub, + parentSpan?: Span +): Promise { + const span = addSpanToTransaction(sentryHub, parentSpan, "finalize-release"); + if (options.finalize) { const { authToken, org, url, project } = options; if (!authToken || !org || !url || !project) { @@ -168,16 +195,24 @@ async function finalizeRelease(release: string, options: Options): Promise { +async function cleanArtifacts( + release: string, + options: Options, + sentryHub: Hub, + parentSpan?: Span +): Promise { + const span = addSpanToTransaction(sentryHub, parentSpan, "clean-artifacts"); + if (options.cleanArtifacts) { // TODO: pull these checks out of here and simplify them if (options.authToken === undefined) { @@ -212,20 +247,37 @@ async function cleanArtifacts(release: string, options: Options): Promise { +async function setCommits( + /* version: string, */ + sentryHub: Hub, + parentSpan?: Span +): Promise { + const span = addSpanToTransaction(sentryHub, parentSpan, "set-commits"); + + span?.finish(); return Promise.resolve("Noop"); } -async function addDeploy(/* version: string */): Promise { +async function addDeploy( + /* version: string, */ + sentryHub: Hub, + parentSpan?: Span +): Promise { + const span = addSpanToTransaction(sentryHub, parentSpan, "add-deploy"); + + span?.finish(); return Promise.resolve("Noop"); } diff --git a/packages/unplugin/src/sentry/telemetry.ts b/packages/unplugin/src/sentry/telemetry.ts new file mode 100644 index 00000000..9db7aee3 --- /dev/null +++ b/packages/unplugin/src/sentry/telemetry.ts @@ -0,0 +1,75 @@ +import { + defaultStackParser, + Hub, + Integrations, + makeMain, + makeNodeTransport, + NodeClient, +} from "@sentry/node"; +import { Span } from "@sentry/tracing"; +import { AxiosError } from "axios"; +import { version as unpluginVersion } from "../../package.json"; + +export function makeSentryClient( + dsn: string, + telemetryEnabled: boolean, + org?: string +): { client: NodeClient; hub: Hub } { + const client = new NodeClient({ + dsn, + + enabled: telemetryEnabled, + tracesSampleRate: telemetryEnabled ? 1.0 : 0.0, + sampleRate: telemetryEnabled ? 1.0 : 0.0, + + release: `${org ? `${org}@` : ""}${unpluginVersion}`, + integrations: [new Integrations.Http({ tracing: true })], + tracePropagationTargets: ["sentry.io/api"], + + stackParser: defaultStackParser, + transport: makeNodeTransport, + + debug: true, + }); + + const hub = new Hub(client); + + //TODO: This call is problematic because as soon as we set our hub as the current hub + // we might interfere with other plugins that use Sentry. However, for now, we'll + // leave it in because without it, we can't get distributed traces (which are pretty nice) + // Let's keep it until someone complains about interference. + // The ideal solution would be a code change in the JS SDK but it's not a straight-forward fix. + makeMain(hub); + + return { client, hub }; +} + +/** + * Adds a span to the passed parentSpan or to the current transaction that's on the passed hub's scope. + */ +export function addSpanToTransaction( + sentryHub: Hub, + parentSpan?: Span, + op?: string, + description?: string +): Span | undefined { + const actualSpan = parentSpan || sentryHub.getScope()?.getTransaction(); + const span = actualSpan?.startChild({ op, description }); + sentryHub.configureScope((scope) => scope.setSpan(span)); + + return span; +} + +export function captureMinimalError(error: unknown | Error | AxiosError, hub: Hub) { + const isAxiosError = error instanceof AxiosError; + const sentryError = + error instanceof Error + ? { + name: `${isAxiosError && error.status ? error.status : ""}: ${error.name}`, + message: error.message, + stack: error.stack, + } + : {}; + + hub.captureException(sentryError); +} diff --git a/packages/unplugin/src/types.ts b/packages/unplugin/src/types.ts index efe98860..e6e485d2 100644 --- a/packages/unplugin/src/types.ts +++ b/packages/unplugin/src/types.ts @@ -64,6 +64,18 @@ export type Options = { // name?: string, // url?: string, // } + + /** + * If set to true, internal plugin errors and performance data will be sent to Sentry. + * + * At Sentry we like to use Sentry ourselves to deliver faster and more stable products. + * We're very careful of what we're sending. We won't collect anything other than error + * and high-level performance data. We will never collect your code or any details of the + * projects in which you're using this plugin. + * + * Defaults to true + */ + telemetry?: boolean; }; /* diff --git a/yarn.lock b/yarn.lock index 3fd11dfb..d34d5050 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1635,6 +1635,16 @@ lru_map "^0.3.3" tslib "^1.9.3" +"@sentry/tracing@^7.11.1": + version "7.11.1" + resolved "https://registry.yarnpkg.com/@sentry/tracing/-/tracing-7.11.1.tgz#50cbe82dd5b9a1307b31761cdd4b0d71132cf5c7" + integrity sha512-ilgnHfpdYUWKG/5yAXIfIbPVsCfrC4ONFBR/wN25/hdAyVfXMa3AJx7NCCXxZBOPDWH3hMW8rl4La5yuDbXofg== + dependencies: + "@sentry/hub" "7.11.1" + "@sentry/types" "7.11.1" + "@sentry/utils" "7.11.1" + tslib "^1.9.3" + "@sentry/types@7.11.1": version "7.11.1" resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.11.1.tgz#06e2827f6ba37159c33644208a0453b86d25e232"