diff --git a/packages/esbuild-plugin/.eslintrc.js b/packages/esbuild-plugin/.eslintrc.js index 42b35d84..66fc5593 100644 --- a/packages/esbuild-plugin/.eslintrc.js +++ b/packages/esbuild-plugin/.eslintrc.js @@ -11,6 +11,7 @@ module.exports = { }, env: { node: true, + es6: true, }, settings: { jest: { diff --git a/packages/esbuild-plugin/src/index.ts b/packages/esbuild-plugin/src/index.ts index 4e8373f6..29918afa 100644 --- a/packages/esbuild-plugin/src/index.ts +++ b/packages/esbuild-plugin/src/index.ts @@ -42,6 +42,18 @@ function esbuildReleaseInjectionPlugin(injectionCode: string): UnpluginOptions { }; } +/** + * Shared set to track entry points that have been wrapped by the metadata plugin + * This allows the debug ID plugin to know when an import is coming from a metadata proxy + */ +const metadataProxyEntryPoints = new Set(); + +/** + * Set to track which paths have already been wrapped with debug ID injection + * This prevents the debug ID plugin from wrapping the same module multiple times + */ +const debugIdWrappedPaths = new Set(); + function esbuildDebugIdInjectionPlugin(logger: Logger): UnpluginOptions { const pluginName = "sentry-esbuild-debug-id-injection-plugin"; const stubNamespace = "sentry-debug-id-stub"; @@ -51,6 +63,13 @@ function esbuildDebugIdInjectionPlugin(logger: Logger): UnpluginOptions { esbuild: { setup({ initialOptions, onLoad, onResolve }) { + // Clear state from previous builds (important for watch mode and test suites) + debugIdWrappedPaths.clear(); + // Also clear metadataProxyEntryPoints here because if moduleMetadataInjectionPlugin + // is not instantiated in this build (e.g., moduleMetadata was disabled), we don't + // want stale entries from a previous build to affect the current one. + metadataProxyEntryPoints.clear(); + if (!initialOptions.bundle) { logger.warn( "The Sentry esbuild plugin only supports esbuild with `bundle: true` being set in the esbuild build options. Esbuild will probably crash now. Sorry about that. If you need to upload sourcemaps without `bundle: true`, it is recommended to use Sentry CLI instead: https://docs.sentry.io/platforms/javascript/sourcemaps/uploading/cli/" @@ -58,33 +77,56 @@ function esbuildDebugIdInjectionPlugin(logger: Logger): UnpluginOptions { } onResolve({ filter: /.*/ }, (args) => { - if (args.kind !== "entry-point") { + // Inject debug IDs into entry points and into imports from metadata proxy modules + const isEntryPoint = args.kind === "entry-point"; + + // Check if this import is coming from a metadata proxy module + // The metadata plugin registers entry points it wraps in the shared Set + // We need to strip the query string suffix because esbuild includes the suffix + // (e.g., ?sentryMetadataProxyModule=true) in args.importer + const importerPath = args.importer?.split("?")[0]; + const isImportFromMetadataProxy = + args.kind === "import-statement" && + importerPath !== undefined && + metadataProxyEntryPoints.has(importerPath); + + if (!isEntryPoint && !isImportFromMetadataProxy) { return; - } else { - // Injected modules via the esbuild `inject` option do also have `kind == "entry-point"`. - // We do not want to inject debug IDs into those files because they are already bundled into the entrypoints - if (initialOptions.inject?.includes(args.path)) { - return; - } + } - return { - pluginName, - // needs to be an abs path, otherwise esbuild will complain - path: path.isAbsolute(args.path) ? args.path : path.join(args.resolveDir, args.path), - pluginData: { - isProxyResolver: true, - originalPath: args.path, - originalResolveDir: args.resolveDir, - }, - // We need to add a suffix here, otherwise esbuild will mark the entrypoint as resolved and won't traverse - // the module tree any further down past the proxy module because we're essentially creating a dependency - // loop back to the proxy module. - // By setting a suffix we're telling esbuild that the entrypoint and proxy module are two different things, - // making it re-resolve the entrypoint when it is imported from the proxy module. - // Super confusing? Yes. Works? Apparently... Let's see. - suffix: "?sentryProxyModule=true", - }; + // Skip injecting debug IDs into modules specified in the esbuild `inject` option + // since they're already part of the entry points + if (initialOptions.inject?.includes(args.path)) { + return; + } + + const resolvedPath = path.isAbsolute(args.path) + ? args.path + : path.join(args.resolveDir, args.path); + + // Skip injecting debug IDs into paths that have already been wrapped + if (debugIdWrappedPaths.has(resolvedPath)) { + return; } + debugIdWrappedPaths.add(resolvedPath); + + return { + pluginName, + // needs to be an abs path, otherwise esbuild will complain + path: resolvedPath, + pluginData: { + isProxyResolver: true, + originalPath: args.path, + originalResolveDir: args.resolveDir, + }, + // We need to add a suffix here, otherwise esbuild will mark the entrypoint as resolved and won't traverse + // the module tree any further down past the proxy module because we're essentially creating a dependency + // loop back to the proxy module. + // By setting a suffix we're telling esbuild that the entrypoint and proxy module are two different things, + // making it re-resolve the entrypoint when it is imported from the proxy module. + // Super confusing? Yes. Works? Apparently... Let's see. + suffix: "?sentryProxyModule=true", + }; }); onLoad({ filter: /.*/ }, (args) => { @@ -142,6 +184,9 @@ function esbuildModuleMetadataInjectionPlugin(injectionCode: string): UnpluginOp esbuild: { setup({ initialOptions, onLoad, onResolve }) { + // Clear state from previous builds (important for watch mode and test suites) + metadataProxyEntryPoints.clear(); + onResolve({ filter: /.*/ }, (args) => { if (args.kind !== "entry-point") { return; @@ -152,10 +197,18 @@ function esbuildModuleMetadataInjectionPlugin(injectionCode: string): UnpluginOp return; } + const resolvedPath = path.isAbsolute(args.path) + ? args.path + : path.join(args.resolveDir, args.path); + + // Register this entry point so the debug ID plugin knows to wrap imports from + // this proxy module, this because the debug ID may run after the metadata plugin + metadataProxyEntryPoints.add(resolvedPath); + return { pluginName, // needs to be an abs path, otherwise esbuild will complain - path: path.isAbsolute(args.path) ? args.path : path.join(args.resolveDir, args.path), + path: resolvedPath, pluginData: { isMetadataProxyResolver: true, originalPath: args.path, diff --git a/packages/integration-tests/fixtures/application-key-with-debug-id/application-key-with-debug-id.test.ts b/packages/integration-tests/fixtures/application-key-with-debug-id/application-key-with-debug-id.test.ts new file mode 100644 index 00000000..57eb798a --- /dev/null +++ b/packages/integration-tests/fixtures/application-key-with-debug-id/application-key-with-debug-id.test.ts @@ -0,0 +1,57 @@ +/* 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"; + +interface BundleOutput { + debugIds: Record | undefined; + metadata: Record | undefined; +} + +function checkBundle(bundlePath: string): void { + const output = execSync(`node ${bundlePath}`, { encoding: "utf-8" }); + const result = JSON.parse(output) as BundleOutput; + + // Check that debug IDs are present + expect(result.debugIds).toBeDefined(); + const debugIds = Object.values(result.debugIds ?? {}); + expect(debugIds.length).toBeGreaterThan(0); + // Verify debug ID format (UUID v4) + expect(debugIds).toContainEqual( + expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + ); + // The key should be a stack trace + expect(Object.keys(result.debugIds ?? {})[0]).toContain("Error"); + + // Check that applicationKey metadata is present + expect(result.metadata).toBeDefined(); + const metadataValues = Object.values(result.metadata ?? {}); + expect(metadataValues).toHaveLength(1); + // applicationKey sets a special key in the metadata + expect(metadataValues[0]).toEqual({ "_sentryBundlerPluginAppKey:my-app-key": true }); + // The key should be a stack trace + expect(Object.keys(result.metadata ?? {})[0]).toContain("Error"); +} + +describe("applicationKey with debug ID 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")); + }); + + test("esbuild bundle", () => { + checkBundle(path.join(__dirname, "out", "esbuild", "bundle.js")); + }); + + test("rollup bundle", () => { + checkBundle(path.join(__dirname, "out", "rollup", "bundle.js")); + }); + + test("vite bundle", () => { + checkBundle(path.join(__dirname, "out", "vite", "bundle.js")); + }); +}); diff --git a/packages/integration-tests/fixtures/application-key-with-debug-id/input/bundle.js b/packages/integration-tests/fixtures/application-key-with-debug-id/input/bundle.js new file mode 100644 index 00000000..b419f9d0 --- /dev/null +++ b/packages/integration-tests/fixtures/application-key-with-debug-id/input/bundle.js @@ -0,0 +1,9 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ +// Output both debug IDs and metadata to verify applicationKey works with debug ID injection +// eslint-disable-next-line no-console +console.log( + JSON.stringify({ + debugIds: global._sentryDebugIds, + metadata: global._sentryModuleMetadata, + }) +); diff --git a/packages/integration-tests/fixtures/application-key-with-debug-id/setup.ts b/packages/integration-tests/fixtures/application-key-with-debug-id/setup.ts new file mode 100644 index 00000000..8b33efec --- /dev/null +++ b/packages/integration-tests/fixtures/application-key-with-debug-id/setup.ts @@ -0,0 +1,18 @@ +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, + { + // Enable applicationKey AND debug ID injection (sourcemaps enabled by default) + applicationKey: "my-app-key", + telemetry: false, + release: { name: "test-release", create: false }, + }, + ["webpack4", "webpack5", "esbuild", "rollup", "vite"] +); diff --git a/packages/integration-tests/fixtures/metadata-with-debug-id/input/bundle.js b/packages/integration-tests/fixtures/metadata-with-debug-id/input/bundle.js new file mode 100644 index 00000000..13a0b516 --- /dev/null +++ b/packages/integration-tests/fixtures/metadata-with-debug-id/input/bundle.js @@ -0,0 +1,9 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ +// Output both debug IDs and metadata to verify both features work together +// eslint-disable-next-line no-console +console.log( + JSON.stringify({ + debugIds: global._sentryDebugIds, + metadata: global._sentryModuleMetadata, + }) +); diff --git a/packages/integration-tests/fixtures/metadata-with-debug-id/metadata-with-debug-id.test.ts b/packages/integration-tests/fixtures/metadata-with-debug-id/metadata-with-debug-id.test.ts new file mode 100644 index 00000000..534397a9 --- /dev/null +++ b/packages/integration-tests/fixtures/metadata-with-debug-id/metadata-with-debug-id.test.ts @@ -0,0 +1,56 @@ +/* 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"; + +interface BundleOutput { + debugIds: Record | undefined; + metadata: Record | undefined; +} + +function checkBundle(bundlePath: string): void { + const output = execSync(`node ${bundlePath}`, { encoding: "utf-8" }); + const result = JSON.parse(output) as BundleOutput; + + // Check that debug IDs are present + expect(result.debugIds).toBeDefined(); + const debugIds = Object.values(result.debugIds ?? {}); + expect(debugIds.length).toBeGreaterThan(0); + // Verify debug ID format (UUID v4) + expect(debugIds).toContainEqual( + expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + ); + // The key should be a stack trace + expect(Object.keys(result.debugIds ?? {})[0]).toContain("Error"); + + // Check that metadata is present + expect(result.metadata).toBeDefined(); + const metadataValues = Object.values(result.metadata ?? {}); + expect(metadataValues).toHaveLength(1); + expect(metadataValues).toEqual([{ team: "frontend" }]); + // The key should be a stack trace + expect(Object.keys(result.metadata ?? {})[0]).toContain("Error"); +} + +describe("metadata with debug ID 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")); + }); + + test("esbuild bundle", () => { + checkBundle(path.join(__dirname, "out", "esbuild", "bundle.js")); + }); + + test("rollup bundle", () => { + checkBundle(path.join(__dirname, "out", "rollup", "bundle.js")); + }); + + test("vite bundle", () => { + checkBundle(path.join(__dirname, "out", "vite", "bundle.js")); + }); +}); diff --git a/packages/integration-tests/fixtures/metadata-with-debug-id/setup.ts b/packages/integration-tests/fixtures/metadata-with-debug-id/setup.ts new file mode 100644 index 00000000..75d1fc4b --- /dev/null +++ b/packages/integration-tests/fixtures/metadata-with-debug-id/setup.ts @@ -0,0 +1,18 @@ +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, + { + // Enable both moduleMetadata AND debug ID injection (sourcemaps enabled by default) + moduleMetadata: { team: "frontend" }, + telemetry: false, + release: { name: "test-release", create: false }, + }, + ["webpack4", "webpack5", "esbuild", "rollup", "vite"] +);