Skip to content

Commit 7129dc6

Browse files
author
Luca Forstner
authored
feat(core): Add experimental debug ID based source map upload to Rollup and Vite plugins (#192)
1 parent 0607188 commit 7129dc6

File tree

11 files changed

+379
-31
lines changed

11 files changed

+379
-31
lines changed

packages/bundler-plugin-core/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,11 @@
3636
"fix": "eslint ./src ./test --format stylish --fix"
3737
},
3838
"dependencies": {
39-
"@sentry/cli": "^2.10.0",
39+
"@sentry/cli": "^2.17.0",
4040
"@sentry/node": "^7.19.0",
4141
"@sentry/tracing": "^7.19.0",
4242
"find-up": "5.0.0",
43+
"glob": "9.3.2",
4344
"magic-string": "0.27.0",
4445
"unplugin": "1.0.1"
4546
},
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import * as fs from "fs";
2+
import MagicString from "magic-string";
3+
import * as path from "path";
4+
import * as util from "util";
5+
import { Logger } from "./sentry/logger";
6+
import { stringToUUID } from "./utils";
7+
8+
// TODO: Find a more elaborate process to generate this. (Maybe with type checking and built-in minification)
9+
const DEBUG_ID_INJECTOR_SNIPPET =
10+
';!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{},n=(new Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="__SENTRY_DEBUG_ID__",e._sentryDebugIdIdentifier="sentry-dbid-__SENTRY_DEBUG_ID__")}catch(e){}}();';
11+
12+
export function injectDebugIdSnippetIntoChunk(code: string) {
13+
const debugId = stringToUUID(code); // generate a deterministic debug ID
14+
const ms = new MagicString(code);
15+
16+
const codeToInject = DEBUG_ID_INJECTOR_SNIPPET.replace(/__SENTRY_DEBUG_ID__/g, debugId);
17+
18+
// We need to be careful not to inject the snippet before any `"use strict";`s.
19+
// As an additional complication `"use strict";`s may come after any number of comments.
20+
const commentUseStrictRegex =
21+
/^(?:\s*|\/\*(.|\r|\n)*?\*\/|\/\/.*?[\n\r])*(?:"use strict";|'use strict';)?/;
22+
23+
if (code.match(commentUseStrictRegex)?.[0]) {
24+
// Add injected code after any comments or "use strict" at the beginning of the bundle.
25+
ms.replace(commentUseStrictRegex, (match) => `${match}${codeToInject}`);
26+
} else {
27+
// ms.replace() doesn't work when there is an empty string match (which happens if
28+
// there is neither, a comment, nor a "use strict" at the top of the chunk) so we
29+
// need this special case here.
30+
ms.prepend(codeToInject);
31+
}
32+
33+
return {
34+
code: ms.toString(),
35+
map: ms.generateMap(),
36+
};
37+
}
38+
39+
export async function prepareBundleForDebugIdUpload(
40+
bundleFilePath: string,
41+
uploadFolder: string,
42+
uniqueUploadName: string,
43+
logger: Logger
44+
) {
45+
let bundleContent;
46+
try {
47+
bundleContent = await util.promisify(fs.readFile)(bundleFilePath, "utf8");
48+
} catch (e) {
49+
logger.warn(`Could not read bundle to determine debug ID and source map: ${bundleFilePath}`);
50+
return;
51+
}
52+
53+
const debugId = determineDebugIdFromBundleSource(bundleContent);
54+
if (debugId === undefined) {
55+
logger.warn(`Could not determine debug ID from bundle: ${bundleFilePath}`);
56+
return;
57+
}
58+
59+
bundleContent += `\n//# debugId=${debugId}`;
60+
const writeSourceFilePromise = util.promisify(fs.writeFile)(
61+
path.join(uploadFolder, `${uniqueUploadName}.js`),
62+
bundleContent,
63+
"utf-8"
64+
);
65+
66+
const writeSourceMapFilePromise = determineSourceMapPathFromBundle(
67+
bundleFilePath,
68+
bundleContent,
69+
logger
70+
).then(async (sourceMapPath): Promise<void> => {
71+
if (sourceMapPath) {
72+
return await prepareSourceMapForDebugIdUpload(
73+
sourceMapPath,
74+
path.join(uploadFolder, `${uniqueUploadName}.js.map`),
75+
debugId,
76+
logger
77+
);
78+
}
79+
});
80+
81+
return Promise.all([writeSourceFilePromise, writeSourceMapFilePromise]);
82+
}
83+
84+
/**
85+
* Looks for a particular string pattern (`sdbid-[debug ID]`) in the bundle
86+
* source and extracts the bundle's debug ID from it.
87+
*
88+
* The string pattern is injected via the debug ID injection snipped.
89+
*/
90+
function determineDebugIdFromBundleSource(code: string): string | undefined {
91+
const match = code.match(
92+
/sentry-dbid-([0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})/
93+
);
94+
95+
if (match) {
96+
return match[1];
97+
} else {
98+
return undefined;
99+
}
100+
}
101+
102+
/**
103+
* Applies a set of heuristics to find the source map for a particular bundle.
104+
*
105+
* @returns the path to the bundle's source map or `undefined` if none could be found.
106+
*/
107+
async function determineSourceMapPathFromBundle(
108+
bundlePath: string,
109+
bundleSource: string,
110+
logger: Logger
111+
): Promise<string | undefined> {
112+
// 1. try to find source map at `sourceMappingURL` location
113+
const sourceMappingUrlMatch = bundleSource.match(/^\/\/# sourceMappingURL=(.*)$/);
114+
if (sourceMappingUrlMatch) {
115+
const sourceMappingUrl = path.normalize(sourceMappingUrlMatch[1] as string);
116+
if (path.isAbsolute(sourceMappingUrl)) {
117+
return sourceMappingUrl;
118+
} else {
119+
return path.join(path.dirname(bundlePath), sourceMappingUrl);
120+
}
121+
}
122+
123+
// 2. try to find source map at path adjacent to chunk source, but with `.map` appended
124+
try {
125+
const adjacentSourceMapFilePath = bundlePath + ".map";
126+
await util.promisify(fs.access)(adjacentSourceMapFilePath);
127+
return adjacentSourceMapFilePath;
128+
} catch (e) {
129+
// noop
130+
}
131+
132+
logger.warn(`Could not determine source map path for bundle: ${bundlePath}`);
133+
return undefined;
134+
}
135+
136+
/**
137+
* Reads a source map, injects debug ID fields, and writes the source map to the target path.
138+
*/
139+
async function prepareSourceMapForDebugIdUpload(
140+
sourceMapPath: string,
141+
targetPath: string,
142+
debugId: string,
143+
logger: Logger
144+
): Promise<void> {
145+
try {
146+
const sourceMapFileContent = await util.promisify(fs.readFile)(sourceMapPath, {
147+
encoding: "utf8",
148+
});
149+
150+
const map = JSON.parse(sourceMapFileContent) as Record<string, string>;
151+
152+
// For now we write both fields until we know what will become the standard - if ever.
153+
map["debug_id"] = debugId;
154+
map["debugId"] = debugId;
155+
156+
await util.promisify(fs.writeFile)(targetPath, JSON.stringify(map), {
157+
encoding: "utf8",
158+
});
159+
} catch (e) {
160+
logger.warn(`Failed to prepare source map for debug ID upload: ${sourceMapPath}`);
161+
}
162+
}

packages/bundler-plugin-core/src/index.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
finalizeRelease,
99
setCommits,
1010
uploadSourceMaps,
11+
uploadDebugIdSourcemaps,
1112
} from "./sentry/releasePipeline";
1213
import "@sentry/tracing";
1314
import SentryCli from "@sentry/cli";
@@ -22,15 +23,20 @@ import { createLogger, Logger } from "./sentry/logger";
2223
import { InternalOptions, normalizeUserOptions, validateOptions } from "./options-mapping";
2324
import { getSentryCli } from "./sentry/cli";
2425
import { makeMain } from "@sentry/node";
26+
import os from "os";
2527
import path from "path";
2628
import fs from "fs";
29+
import util from "util";
2730
import { getDependencies, getPackageJson, parseMajorVersion } from "./utils";
31+
import { glob } from "glob";
32+
import { injectDebugIdSnippetIntoChunk, prepareBundleForDebugIdUpload } from "./debug-id";
2833

2934
const ALLOWED_TRANSFORMATION_FILE_ENDINGS = [".js", ".ts", ".jsx", ".tsx", ".mjs"];
3035

3136
const releaseInjectionFilePath = require.resolve(
3237
"@sentry/bundler-plugin-core/sentry-release-injection-file"
3338
);
39+
3440
/**
3541
* The sentry bundler plugin concerns itself with two things:
3642
* - Release injection
@@ -286,7 +292,37 @@ const unplugin = createUnplugin<Options>((options, unpluginMetaContext) => {
286292

287293
const releaseName = await releaseNamePromise;
288294

295+
let tmpUploadFolder: string | undefined;
296+
289297
try {
298+
if (internalOptions._experiments.debugIdUpload) {
299+
const debugIdChunkFilePaths = (
300+
await glob(internalOptions._experiments.debugIdUpload.include, {
301+
absolute: true,
302+
nodir: true,
303+
ignore: internalOptions._experiments.debugIdUpload.ignore,
304+
})
305+
).filter((p) => p.endsWith(".js") || p.endsWith(".mjs"));
306+
307+
const sourceFileUploadFolderPromise = util.promisify(fs.mkdtemp)(
308+
path.join(os.tmpdir(), "sentry-bundler-plugin-upload-")
309+
);
310+
311+
await Promise.all(
312+
debugIdChunkFilePaths.map(async (chunkFilePath, chunkIndex): Promise<void> => {
313+
await prepareBundleForDebugIdUpload(
314+
chunkFilePath,
315+
await sourceFileUploadFolderPromise,
316+
String(chunkIndex),
317+
logger
318+
);
319+
})
320+
);
321+
322+
tmpUploadFolder = await sourceFileUploadFolderPromise;
323+
await uploadDebugIdSourcemaps(internalOptions, ctx, tmpUploadFolder, releaseName);
324+
}
325+
290326
await createNewRelease(internalOptions, ctx, releaseName);
291327
await cleanArtifacts(internalOptions, ctx, releaseName);
292328
await uploadSourceMaps(internalOptions, ctx, releaseName);
@@ -302,6 +338,11 @@ const unplugin = createUnplugin<Options>((options, unpluginMetaContext) => {
302338
});
303339
handleError(e, logger, internalOptions.errorHandler);
304340
} finally {
341+
if (tmpUploadFolder) {
342+
fs.rm(tmpUploadFolder, { recursive: true, force: true }, () => {
343+
// We don't care if this errors
344+
});
345+
}
305346
releasePipelineSpan?.finish();
306347
transaction?.finish();
307348
await sentryClient.flush().then(null, () => {
@@ -314,6 +355,30 @@ const unplugin = createUnplugin<Options>((options, unpluginMetaContext) => {
314355
level: "info",
315356
});
316357
},
358+
rollup: {
359+
renderChunk(code, chunk) {
360+
if (
361+
options._experiments?.debugIdUpload &&
362+
[".js", ".mjs"].some((ending) => chunk.fileName.endsWith(ending)) // chunks could be any file (html, md, ...)
363+
) {
364+
return injectDebugIdSnippetIntoChunk(code);
365+
} else {
366+
return null; // returning null means not modifying the chunk at all
367+
}
368+
},
369+
},
370+
vite: {
371+
renderChunk(code, chunk) {
372+
if (
373+
options._experiments?.debugIdUpload &&
374+
[".js", ".mjs"].some((ending) => chunk.fileName.endsWith(ending)) // chunks could be any file (html, md, ...)
375+
) {
376+
return injectDebugIdSnippetIntoChunk(code);
377+
} else {
378+
return null; // returning null means not modifying the chunk at all
379+
}
380+
},
381+
},
317382
};
318383
});
319384

packages/bundler-plugin-core/src/sentry/releasePipeline.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,39 @@ export async function uploadSourceMaps(
8787
ctx.logger.info("Successfully uploaded source maps.");
8888
}
8989

90+
export async function uploadDebugIdSourcemaps(
91+
options: InternalOptions,
92+
ctx: BuildContext,
93+
folderPathToUpload: string,
94+
releaseName: string
95+
): Promise<void> {
96+
const span = addSpanToTransaction(ctx, "function.plugin.upload_debug_id_sourcemaps");
97+
ctx.logger.info("Uploading debug ID Sourcemaps.");
98+
99+
// Since our internal include entries contain all top-level sourcemaps options,
100+
// we only need to pass the include option here.
101+
try {
102+
await ctx.cli.releases.uploadSourceMaps(releaseName, {
103+
include: [
104+
{
105+
paths: [folderPathToUpload],
106+
rewrite: false,
107+
dist: options.dist,
108+
},
109+
],
110+
useArtifactBundle: true,
111+
});
112+
} catch (e) {
113+
ctx.hub.captureException(new Error("CLI Error: Uploading debug ID source maps failed"));
114+
throw e;
115+
} finally {
116+
span?.finish();
117+
}
118+
119+
ctx.hub.addBreadcrumb({ level: "info", message: "Successfully uploaded debug ID source maps." });
120+
ctx.logger.info("Successfully uploaded debug ID source maps.");
121+
}
122+
90123
export async function setCommits(
91124
options: InternalOptions,
92125
ctx: BuildContext,

packages/bundler-plugin-core/src/sentry/telemetry.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export function addPluginOptionInformationToHub(
9696
errorHandler,
9797
deploy,
9898
include,
99+
_experiments,
99100
} = options;
100101

101102
hub.setTag("include", include.length > 1 ? "multiple-entries" : "single-entry");
@@ -124,6 +125,9 @@ export function addPluginOptionInformationToHub(
124125
if (errorHandler) {
125126
hub.setTag("error-handler", "custom");
126127
}
128+
if (_experiments.debugIdUpload) {
129+
hub.setTag("debug-id-upload", true);
130+
}
127131

128132
hub.setTag("node", process.version);
129133

packages/bundler-plugin-core/src/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"esModuleInterop": true,
77
"resolveJsonModule": true, // needed to import package.json
88
"types": ["node"],
9+
"target": "ES6", // needed for some iterator features
910
"lib": ["ES2020", "DOM"] // es2020 needed for "new Set()", DOM needed for various bundler types
1011
}
1112
}

packages/bundler-plugin-core/src/types.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -214,17 +214,34 @@ export type Options = Omit<IncludeEntry, "paths"> & {
214214
uploadSourceMaps?: boolean;
215215

216216
/**
217-
* These options are considered experimental and subject to change.
218-
*
219-
* _experiments.injectBuildInformation:
220-
* If set to true, the plugin will inject an additional `SENTRY_BUILD_INFO` variable.
221-
* This contains information about the build, e.g. dependencies, node version and other useful data.
222-
*
223-
* Defaults to `false`.
224-
* @hidden
217+
* Options that are considered experimental and subject to change.
225218
*/
226219
_experiments?: {
220+
/**
221+
* If set to true, the plugin will inject an additional `SENTRY_BUILD_INFO` variable.
222+
* This contains information about the build, e.g. dependencies, node version and other useful data.
223+
*
224+
* Defaults to `false`.
225+
*/
227226
injectBuildInformation?: boolean;
227+
228+
/**
229+
* Configuration for debug ID upload.
230+
*
231+
* Note: Currently only functional for Vite and Rollup.
232+
*/
233+
debugIdUpload?: {
234+
/**
235+
* Glob paths to files that should get be injected with a debug ID and uploaded.
236+
*/
237+
include: string | string[];
238+
/**
239+
* Glob paths to files that should be ignored for debug ID injection and upload.
240+
*
241+
* Default: `[]`
242+
*/
243+
ignore?: string | string[];
244+
};
228245
};
229246
};
230247

0 commit comments

Comments
 (0)