Skip to content

Commit ceaddfa

Browse files
author
Luca Forstner
committed
pain
1 parent 95e5cca commit ceaddfa

File tree

5 files changed

+410
-408
lines changed

5 files changed

+410
-408
lines changed
Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
import { createLogger } from "./sentry/logger";
2+
import {
3+
allowedToSendTelemetry,
4+
createSentryInstance,
5+
safeFlushTelemetry,
6+
} from "./sentry/telemetry";
7+
import { Options, SentrySDKBuildFlags } from "./types";
8+
import * as fs from "fs";
9+
import * as path from "path";
10+
import * as dotenv from "dotenv";
11+
import { closeSession, DEFAULT_ENVIRONMENT, makeSession, startSpan } from "@sentry/core";
12+
import { normalizeUserOptions, validateOptions } from "./options-mapping";
13+
import SentryCli from "@sentry/cli";
14+
import { arrayify, getTurborepoEnvPassthroughWarning } from "./utils";
15+
16+
export type SentryBuildPluginManager = ReturnType<typeof createSentryBuildPluginManager>;
17+
18+
export function createSentryBuildPluginManager(
19+
userOptions: Options,
20+
bundlerPluginMetaContext: { buildTool: string; loggerPrefix: string }
21+
) {
22+
const logger = createLogger({
23+
prefix: bundlerPluginMetaContext.loggerPrefix,
24+
silent: userOptions.silent ?? false,
25+
debug: userOptions.debug ?? false,
26+
});
27+
28+
try {
29+
const dotenvFile = fs.readFileSync(
30+
path.join(process.cwd(), ".env.sentry-build-plugin"),
31+
"utf-8"
32+
);
33+
// 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.
34+
const dotenvResult = dotenv.parse(dotenvFile);
35+
36+
// Vite has a bug/behaviour where spreading into process.env will cause it to crash
37+
// https://github.com/vitest-dev/vitest/issues/1870#issuecomment-1501140251
38+
Object.assign(process.env, dotenvResult);
39+
40+
logger.info('Using environment variables configured in ".env.sentry-build-plugin".');
41+
} catch (e: unknown) {
42+
// Ignore "file not found" errors but throw all others
43+
if (typeof e === "object" && e && "code" in e && e.code !== "ENOENT") {
44+
throw e;
45+
}
46+
}
47+
48+
const options = normalizeUserOptions(userOptions);
49+
50+
const shouldSendTelemetry = allowedToSendTelemetry(options);
51+
const { sentryScope, sentryClient } = createSentryInstance(
52+
options,
53+
shouldSendTelemetry,
54+
bundlerPluginMetaContext.buildTool
55+
);
56+
57+
const { release, environment = DEFAULT_ENVIRONMENT } = sentryClient.getOptions();
58+
59+
const sentrySession = makeSession({ release, environment });
60+
sentryScope.setSession(sentrySession);
61+
// Send the start of the session
62+
sentryClient.captureSession(sentrySession);
63+
64+
let sessionHasEnded = false; // Just to prevent infinite loops with beforeExit, which is called whenever the event loop empties out
65+
66+
function endSession() {
67+
if (sessionHasEnded) {
68+
return;
69+
}
70+
71+
closeSession(sentrySession);
72+
sentryClient.captureSession(sentrySession);
73+
sessionHasEnded = true;
74+
}
75+
76+
// We also need to manually end sessions on errors because beforeExit is not called on crashes
77+
process.on("beforeExit", () => {
78+
endSession();
79+
});
80+
81+
// Set the User-Agent that Sentry CLI will use when interacting with Sentry
82+
process.env[
83+
"SENTRY_PIPELINE"
84+
] = `${bundlerPluginMetaContext.buildTool}-plugin/${__PACKAGE_VERSION__}`;
85+
86+
// Not a bulletproof check but should be good enough to at least sometimes determine
87+
// if the plugin is called in dev/watch mode or for a prod build. The important part
88+
// here is to avoid a false positive. False negatives are okay.
89+
const isDevMode = process.env["NODE_ENV"] === "development";
90+
91+
/**
92+
* Handles errors caught and emitted in various areas of the plugin.
93+
*
94+
* Also sets the sentry session status according to the error handling.
95+
*
96+
* If users specify their custom `errorHandler` we'll leave the decision to throw
97+
* or continue up to them. By default, @param throwByDefault controls if the plugin
98+
* should throw an error (which causes a build fail in most bundlers) or continue.
99+
*/
100+
function handleRecoverableError(unknownError: unknown, throwByDefault: boolean) {
101+
sentrySession.status = "abnormal";
102+
try {
103+
if (options.errorHandler) {
104+
try {
105+
if (unknownError instanceof Error) {
106+
options.errorHandler(unknownError);
107+
} else {
108+
options.errorHandler(new Error("An unknown error occurred"));
109+
}
110+
} catch (e) {
111+
sentrySession.status = "crashed";
112+
throw e;
113+
}
114+
} else {
115+
// setting the session to "crashed" b/c from a plugin perspective this run failed.
116+
// However, we're intentionally not rethrowing the error to avoid breaking the user build.
117+
sentrySession.status = "crashed";
118+
if (throwByDefault) {
119+
throw unknownError;
120+
}
121+
logger.error("An error occurred. Couldn't finish all operations:", unknownError);
122+
}
123+
} finally {
124+
endSession();
125+
}
126+
}
127+
128+
if (!validateOptions(options, logger)) {
129+
// Throwing by default to avoid a misconfigured plugin going unnoticed.
130+
handleRecoverableError(
131+
new Error("Options were not set correctly. See output above for more details."),
132+
true
133+
);
134+
}
135+
136+
// We have multiple plugins depending on generated source map files. (debug ID upload, legacy upload)
137+
// Additionally, we also want to have the functionality to delete files after uploading sourcemaps.
138+
// All of these plugins and the delete functionality need to run in the same hook (`writeBundle`).
139+
// Since the plugins among themselves are not aware of when they run and finish, we need a system to
140+
// track their dependencies on the generated files, so that we can initiate the file deletion only after
141+
// nothing depends on the files anymore.
142+
const dependenciesOnBuildArtifacts = new Set<symbol>();
143+
const buildArtifactsDependencySubscribers: (() => void)[] = [];
144+
145+
function notifyBuildArtifactDependencySubscribers() {
146+
buildArtifactsDependencySubscribers.forEach((subscriber) => {
147+
subscriber();
148+
});
149+
}
150+
151+
function createDependencyOnBuildArtifacts() {
152+
const dependencyIdentifier = Symbol();
153+
dependenciesOnBuildArtifacts.add(dependencyIdentifier);
154+
155+
return function freeDependencyOnBuildArtifacts() {
156+
dependenciesOnBuildArtifacts.delete(dependencyIdentifier);
157+
notifyBuildArtifactDependencySubscribers();
158+
};
159+
}
160+
161+
/**
162+
* Returns a Promise that resolves when all the currently active dependencies are freed again.
163+
*
164+
* It is very important that this function is called as late as possible before wanting to await the Promise to give
165+
* the dependency producers as much time as possible to register themselves.
166+
*/
167+
function waitUntilBuildArtifactDependenciesAreFreed() {
168+
return new Promise<void>((resolve) => {
169+
buildArtifactsDependencySubscribers.push(() => {
170+
if (dependenciesOnBuildArtifacts.size === 0) {
171+
resolve();
172+
}
173+
});
174+
175+
if (dependenciesOnBuildArtifacts.size === 0) {
176+
resolve();
177+
}
178+
});
179+
}
180+
181+
const bundleSizeOptimizationReplacementValues: SentrySDKBuildFlags = {};
182+
if (options.bundleSizeOptimizations) {
183+
const { bundleSizeOptimizations } = options;
184+
185+
if (bundleSizeOptimizations.excludeDebugStatements) {
186+
bundleSizeOptimizationReplacementValues["__SENTRY_DEBUG__"] = false;
187+
}
188+
if (bundleSizeOptimizations.excludeTracing) {
189+
bundleSizeOptimizationReplacementValues["__SENTRY_TRACING__"] = false;
190+
}
191+
if (bundleSizeOptimizations.excludeReplayCanvas) {
192+
bundleSizeOptimizationReplacementValues["__RRWEB_EXCLUDE_CANVAS__"] = true;
193+
}
194+
if (bundleSizeOptimizations.excludeReplayIframe) {
195+
bundleSizeOptimizationReplacementValues["__RRWEB_EXCLUDE_IFRAME__"] = true;
196+
}
197+
if (bundleSizeOptimizations.excludeReplayShadowDom) {
198+
bundleSizeOptimizationReplacementValues["__RRWEB_EXCLUDE_SHADOW_DOM__"] = true;
199+
}
200+
if (bundleSizeOptimizations.excludeReplayWorker) {
201+
bundleSizeOptimizationReplacementValues["__SENTRY_EXCLUDE_REPLAY_WORKER__"] = true;
202+
}
203+
}
204+
205+
let bundleMetadata: Record<string, unknown> = {};
206+
if (options.moduleMetadata || options.applicationKey) {
207+
if (options.applicationKey) {
208+
// We use different keys so that if user-code receives multiple bundling passes, we will store the application keys of all the passes.
209+
// It is a bit unfortunate that we have to inject the metadata snippet at the top, because after multiple
210+
// injections, the first injection will always "win" because it comes last in the code. We would generally be
211+
// fine with making the last bundling pass win. But because it cannot win, we have to use a workaround of storing
212+
// the app keys in different object keys.
213+
// We can simply use the `_sentryBundlerPluginAppKey:` to filter for app keys in the SDK.
214+
bundleMetadata[`_sentryBundlerPluginAppKey:${options.applicationKey}`] = true;
215+
}
216+
217+
if (typeof options.moduleMetadata === "function") {
218+
const args = {
219+
org: options.org,
220+
project: options.project,
221+
release: options.release.name,
222+
};
223+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
224+
bundleMetadata = { ...bundleMetadata, ...options.moduleMetadata(args) };
225+
} else {
226+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
227+
bundleMetadata = { ...bundleMetadata, ...options.moduleMetadata };
228+
}
229+
}
230+
231+
return {
232+
logger,
233+
normalizedOptions: options,
234+
bundleSizeOptimizationReplacementValues,
235+
bundleMetadata,
236+
telemetry: {
237+
async emitBundlerPluginExecutionSignal() {
238+
if (await shouldSendTelemetry) {
239+
logger.info(
240+
"Sending telemetry data on issues and performance to Sentry. To disable telemetry, set `options.telemetry` to `false`."
241+
);
242+
startSpan({ name: "Sentry Bundler Plugin execution", scope: sentryScope }, () => {
243+
//
244+
});
245+
await safeFlushTelemetry(sentryClient);
246+
}
247+
},
248+
},
249+
async createRelease() {
250+
if (!options.release.name) {
251+
logger.debug(
252+
"No release name provided. Will not create release. Please set the `release.name` option to identify your release."
253+
);
254+
return;
255+
} else if (isDevMode) {
256+
logger.debug("Running in development mode. Will not create release.");
257+
return;
258+
} else if (!options.authToken) {
259+
logger.warn(
260+
"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/" +
261+
getTurborepoEnvPassthroughWarning("SENTRY_AUTH_TOKEN")
262+
);
263+
return;
264+
} else if (!options.org && !options.authToken.startsWith("sntrys_")) {
265+
logger.warn(
266+
"No organization slug provided. Will not create release. Please set the `org` option to your Sentry organization slug." +
267+
getTurborepoEnvPassthroughWarning("SENTRY_ORG")
268+
);
269+
return;
270+
} else if (!options.project) {
271+
logger.warn(
272+
"No project provided. Will not create release. Please set the `project` option to your Sentry project slug." +
273+
getTurborepoEnvPassthroughWarning("SENTRY_PROJECT")
274+
);
275+
return;
276+
}
277+
278+
// 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`)
279+
// Therefore we need to actually register the execution of this hook as dependency on the sourcemap files.
280+
const freeWriteBundleInvocationDependencyOnSourcemapFiles =
281+
createDependencyOnBuildArtifacts();
282+
283+
try {
284+
const cliInstance = new SentryCli(null, {
285+
authToken: options.authToken,
286+
org: options.org,
287+
project: options.project,
288+
silent: options.silent,
289+
url: options.url,
290+
vcsRemote: options.release.vcsRemote,
291+
headers: options.headers,
292+
});
293+
294+
if (options.release.create) {
295+
await cliInstance.releases.new(options.release.name);
296+
}
297+
298+
if (options.release.uploadLegacySourcemaps) {
299+
const normalizedInclude = arrayify(options.release.uploadLegacySourcemaps)
300+
.map((includeItem) =>
301+
typeof includeItem === "string" ? { paths: [includeItem] } : includeItem
302+
)
303+
.map((includeEntry) => ({
304+
...includeEntry,
305+
validate: includeEntry.validate ?? false,
306+
ext: includeEntry.ext
307+
? includeEntry.ext.map((extension) => `.${extension.replace(/^\./, "")}`)
308+
: [".js", ".map", ".jsbundle", ".bundle"],
309+
ignore: includeEntry.ignore ? arrayify(includeEntry.ignore) : undefined,
310+
}));
311+
312+
await cliInstance.releases.uploadSourceMaps(options.release.name, {
313+
include: normalizedInclude,
314+
dist: options.release.dist,
315+
});
316+
}
317+
318+
if (options.release.setCommits !== false) {
319+
try {
320+
await cliInstance.releases.setCommits(
321+
options.release.name,
322+
// set commits always exists due to the normalize function
323+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
324+
options.release.setCommits!
325+
);
326+
} catch (e) {
327+
// 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
328+
if (
329+
options.release.setCommits &&
330+
"shouldNotThrowOnFailure" in options.release.setCommits &&
331+
options.release.setCommits.shouldNotThrowOnFailure
332+
) {
333+
logger.debug(
334+
"An error occurred setting commits on release (this message can be ignored unless you commits on release are desired):",
335+
e
336+
);
337+
} else {
338+
throw e;
339+
}
340+
}
341+
}
342+
343+
if (options.release.finalize) {
344+
await cliInstance.releases.finalize(options.release.name);
345+
}
346+
347+
if (options.release.deploy) {
348+
await cliInstance.releases.newDeploy(options.release.name, options.release.deploy);
349+
}
350+
} catch (e) {
351+
sentryScope.captureException('Error in "releaseManagementPlugin" writeBundle hook');
352+
await safeFlushTelemetry(sentryClient);
353+
handleRecoverableError(e, false);
354+
} finally {
355+
freeWriteBundleInvocationDependencyOnSourcemapFiles();
356+
}
357+
},
358+
createDependencyOnBuildArtifacts,
359+
waitUntilBuildArtifactDependenciesAreFreed,
360+
};
361+
}

0 commit comments

Comments
 (0)