diff --git a/packages/bundler-plugin-core/src/index.ts b/packages/bundler-plugin-core/src/index.ts index b4c4db51..7873f943 100644 --- a/packages/bundler-plugin-core/src/index.ts +++ b/packages/bundler-plugin-core/src/index.ts @@ -12,6 +12,7 @@ import { allowedToSendTelemetry, createSentryInstance } from "./sentry/telemetry import { Options } from "./types"; import { generateGlobalInjectorCode, + generateModuleMetadataInjectorCode, getDependencies, getPackageJson, parseMajorVersion, @@ -22,6 +23,7 @@ import * as dotenv from "dotenv"; interface SentryUnpluginFactoryOptions { releaseInjectionPlugin: (injectionCode: string) => UnpluginOptions; + moduleMetadataInjectionPlugin?: (injectionCode: string) => UnpluginOptions; debugIdInjectionPlugin: () => UnpluginOptions; debugIdUploadPlugin: (upload: (buildArtifacts: string[]) => Promise) => UnpluginOptions; } @@ -55,6 +57,7 @@ interface SentryUnpluginFactoryOptions { */ export function sentryUnpluginFactory({ releaseInjectionPlugin, + moduleMetadataInjectionPlugin, debugIdInjectionPlugin, debugIdUploadPlugin, }: SentryUnpluginFactoryOptions) { @@ -173,6 +176,27 @@ export function sentryUnpluginFactory({ plugins.push(releaseInjectionPlugin(injectionCode)); } + if (moduleMetadataInjectionPlugin && options._experiments.moduleMetadata) { + let metadata: object; + if (typeof options._experiments.moduleMetadata === "function") { + const args = { + org: options.org, + project: options.project, + release: options.release.name, + }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call + metadata = options._experiments.moduleMetadata(args); + } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + metadata = options._experiments.moduleMetadata; + } + + const injectionCode = generateModuleMetadataInjectorCode(metadata); + plugins.push(moduleMetadataInjectionPlugin(injectionCode)); + } else if (options._experiments.moduleMetadata) { + logger.warn("'moduleMetadata' is currently only supported by '@sentry/webpack-plugin'"); + } + if (!options.release.name) { logger.warn( "No release name provided. Will not create release. Please set the `release.name` option to identifiy your release." diff --git a/packages/bundler-plugin-core/src/types.ts b/packages/bundler-plugin-core/src/types.ts index d67ffa81..339dc32a 100644 --- a/packages/bundler-plugin-core/src/types.ts +++ b/packages/bundler-plugin-core/src/types.ts @@ -241,9 +241,34 @@ export interface Options { * Defaults to `false`. */ injectBuildInformation?: boolean; + + /** + * Metadata associated with this module. + * + * The metadata is serialized and can be looked up at runtime by filename. + * + * Metadata can either be passed directly or alternatively a callback can be provided that will be + * called with the following arguments: + * - `org`: The organization slug. + * - `project`: The project slug. + * - `release`: The release name. + * + * + * Note: This option is currently only supported by `@sentry/webpack-plugin`. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + moduleMetadata?: any | ModuleMetadataCallback; }; } +export interface ModuleMetadataCallbackArgs { + org?: string; + project?: string; + release?: string; +} + +export type ModuleMetadataCallback = (args: ModuleMetadataCallbackArgs) => object; + export type IncludeEntry = { /** * One or more paths to scan for files to upload. diff --git a/packages/bundler-plugin-core/src/utils.ts b/packages/bundler-plugin-core/src/utils.ts index dc37f0fb..479e470c 100644 --- a/packages/bundler-plugin-core/src/utils.ts +++ b/packages/bundler-plugin-core/src/utils.ts @@ -264,6 +264,24 @@ export function generateGlobalInjectorCode({ return code; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function generateModuleMetadataInjectorCode(metadata: any) { + // The code below is mostly ternary operators because it saves bundle size. + // The checks are to support as many environments as possible. (Node.js, Browser, webworkers, etc.) + return ` + var _global2 = + typeof window !== 'undefined' ? + window : + typeof global !== 'undefined' ? + global : + typeof self !== 'undefined' ? + self : + {}; + + _global2._sentryModuleMetadata = _global2._sentryModuleMetadata || {}; + _global2._sentryModuleMetadata[new Error().stack] = ${JSON.stringify(metadata)};`; +} + function getBuildInformation() { const packageJson = getPackageJson(); diff --git a/packages/integration-tests/fixtures/metadata-injection/input/bundle.js b/packages/integration-tests/fixtures/metadata-injection/input/bundle.js new file mode 100644 index 00000000..bf138963 --- /dev/null +++ b/packages/integration-tests/fixtures/metadata-injection/input/bundle.js @@ -0,0 +1,3 @@ +// Simply output the metadata to the console so it can be checked in a test +// eslint-disable-next-line no-console +console.log(JSON.stringify(global._sentryModuleMetadata)); diff --git a/packages/integration-tests/fixtures/metadata-injection/metadata-injection.test.ts b/packages/integration-tests/fixtures/metadata-injection/metadata-injection.test.ts new file mode 100644 index 00000000..83640d1b --- /dev/null +++ b/packages/integration-tests/fixtures/metadata-injection/metadata-injection.test.ts @@ -0,0 +1,26 @@ +/* eslint-disable jest/no-standalone-expect */ +/* eslint-disable jest/expect-expect */ +import { execSync } from "child_process"; +import path from "path"; +import { testIfNodeMajorVersionIsLessThan18 } from "../../utils/testIf"; + +function checkBundle(bundlePath: string): void { + const output = execSync(`node ${bundlePath}`, { encoding: "utf-8" }); + + const map = JSON.parse(output) as Record; + + // There should be only one key in the map + expect(Object.values(map)).toHaveLength(1); + // The value should be the expected metadata + expect(Object.values(map)).toEqual([{ team: "frontend" }]); +} + +describe("metadata injection", () => { + testIfNodeMajorVersionIsLessThan18("webpack 4 bundle", () => { + checkBundle(path.join(__dirname, "out", "webpack4", "bundle.js")); + }); + + test("webpack 5 bundle", () => { + checkBundle(path.join(__dirname, "out", "webpack5", "bundle.js")); + }); +}); diff --git a/packages/integration-tests/fixtures/metadata-injection/setup.ts b/packages/integration-tests/fixtures/metadata-injection/setup.ts new file mode 100644 index 00000000..1a7fc221 --- /dev/null +++ b/packages/integration-tests/fixtures/metadata-injection/setup.ts @@ -0,0 +1,17 @@ +import * as path from "path"; +import { createCjsBundles } from "../../utils/create-cjs-bundles"; + +const outputDir = path.resolve(__dirname, "out"); + +createCjsBundles( + { + bundle: path.resolve(__dirname, "input", "bundle.js"), + }, + outputDir, + { + _experiments: { + moduleMetadata: { team: "frontend" }, + }, + }, + ["webpack4", "webpack5"] +); diff --git a/packages/integration-tests/utils/create-cjs-bundles.ts b/packages/integration-tests/utils/create-cjs-bundles.ts index 5f85d8ec..1569b3ea 100644 --- a/packages/integration-tests/utils/create-cjs-bundles.ts +++ b/packages/integration-tests/utils/create-cjs-bundles.ts @@ -16,47 +16,53 @@ const nodejsMajorversion = process.version.split(".")[0]!.slice(1); export function createCjsBundles( entrypoints: { [name: string]: string }, outFolder: string, - sentryUnpluginOptions: Options + sentryUnpluginOptions: Options, + plugins: string[] = [] ): void { - void vite.build({ - clearScreen: false, - build: { - outDir: path.join(outFolder, "vite"), - rollupOptions: { - input: entrypoints, - output: { - format: "cjs", - entryFileNames: "[name].js", + if (plugins.length === 0 || plugins.includes("vite")) { + void vite.build({ + clearScreen: false, + build: { + outDir: path.join(outFolder, "vite"), + rollupOptions: { + input: entrypoints, + output: { + format: "cjs", + entryFileNames: "[name].js", + }, }, }, - }, - plugins: [sentryVitePlugin(sentryUnpluginOptions)], - }); - - void rollup - .rollup({ - input: entrypoints, - plugins: [sentryRollupPlugin(sentryUnpluginOptions)], - }) - .then((bundle) => - bundle.write({ - dir: path.join(outFolder, "rollup"), - format: "cjs", - exports: "named", + plugins: [sentryVitePlugin(sentryUnpluginOptions)], + }); + } + if (plugins.length === 0 || plugins.includes("rollup")) { + void rollup + .rollup({ + input: entrypoints, + plugins: [sentryRollupPlugin(sentryUnpluginOptions)], }) - ); + .then((bundle) => + bundle.write({ + dir: path.join(outFolder, "rollup"), + format: "cjs", + exports: "named", + }) + ); + } - void esbuild.build({ - entryPoints: entrypoints, - outdir: path.join(outFolder, "esbuild"), - plugins: [sentryEsbuildPlugin(sentryUnpluginOptions)], - minify: true, - bundle: true, - format: "cjs", - }); + if (plugins.length === 0 || plugins.includes("esbuild")) { + void esbuild.build({ + entryPoints: entrypoints, + outdir: path.join(outFolder, "esbuild"), + plugins: [sentryEsbuildPlugin(sentryUnpluginOptions)], + minify: true, + bundle: true, + format: "cjs", + }); + } // Webpack 4 doesn't work on Node.js versions >= 18 - if (parseInt(nodejsMajorversion) < 18) { + if (parseInt(nodejsMajorversion) < 18 && (plugins.length === 0 || plugins.includes("webpack4"))) { webpack4( { mode: "production", @@ -77,23 +83,25 @@ export function createCjsBundles( ); } - webpack5( - { - cache: false, - entry: entrypoints, - output: { - path: path.join(outFolder, "webpack5"), - library: { - type: "commonjs", + if (plugins.length === 0 || plugins.includes("webpack5")) { + webpack5( + { + cache: false, + entry: entrypoints, + output: { + path: path.join(outFolder, "webpack5"), + library: { + type: "commonjs", + }, }, + mode: "production", + plugins: [sentryWebpackPlugin(sentryUnpluginOptions)], }, - mode: "production", - plugins: [sentryWebpackPlugin(sentryUnpluginOptions)], - }, - (err) => { - if (err) { - throw err; + (err) => { + if (err) { + throw err; + } } - } - ); + ); + } } diff --git a/packages/webpack-plugin/src/index.ts b/packages/webpack-plugin/src/index.ts index 36b2ddf3..5e98be0f 100644 --- a/packages/webpack-plugin/src/index.ts +++ b/packages/webpack-plugin/src/index.ts @@ -84,8 +84,30 @@ function webpackDebugIdUploadPlugin( }; } +function webpackModuleMetadataInjectionPlugin(injectionCode: string): UnpluginOptions { + return { + name: "sentry-webpack-module-metadata-injection-plugin", + webpack(compiler) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore webpack version compatibility shenanigans + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const BannerPlugin = compiler?.webpack?.BannerPlugin || webback4or5?.BannerPlugin; + compiler.options.plugins = compiler.options.plugins || []; + compiler.options.plugins.push( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-call + new BannerPlugin({ + raw: true, + include: /\.(js|ts|jsx|tsx|mjs|cjs)$/, + banner: injectionCode, + }) + ); + }, + }; +} + const sentryUnplugin = sentryUnpluginFactory({ releaseInjectionPlugin: webpackReleaseInjectionPlugin, + moduleMetadataInjectionPlugin: webpackModuleMetadataInjectionPlugin, debugIdInjectionPlugin: webpackDebugIdInjectionPlugin, debugIdUploadPlugin: webpackDebugIdUploadPlugin, });