Skip to content
Merged
1 change: 1 addition & 0 deletions packages/esbuild-plugin/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ module.exports = {
},
env: {
node: true,
es6: true,
},
settings: {
jest: {
Expand Down
103 changes: 78 additions & 25 deletions packages/esbuild-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

/**
* 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<string>();
Comment on lines +49 to +55

This comment was marked as outdated.


function esbuildDebugIdInjectionPlugin(logger: Logger): UnpluginOptions {
const pluginName = "sentry-esbuild-debug-id-injection-plugin";
const stubNamespace = "sentry-debug-id-stub";
Expand All @@ -51,40 +63,70 @@ 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/"
);
}

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) => {
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string> | undefined;
metadata: Record<string, unknown> | 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"));
});
});
Original file line number Diff line number Diff line change
@@ -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,
})
);
Original file line number Diff line number Diff line change
@@ -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"]
);
Original file line number Diff line number Diff line change
@@ -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,
})
);
Original file line number Diff line number Diff line change
@@ -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<string, string> | undefined;
metadata: Record<string, unknown> | 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"));
});
});
Original file line number Diff line number Diff line change
@@ -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"]
);
Loading