diff --git a/dev-packages/node-integration-tests/suites/modules/scenario-isolated.js b/dev-packages/node-integration-tests/suites/modules/scenario-isolated.js new file mode 100644 index 000000000000..29f9e62decca --- /dev/null +++ b/dev-packages/node-integration-tests/suites/modules/scenario-isolated.js @@ -0,0 +1,18 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +// `test.ts` writes these packages in the folders where the isolated layouts of pnpm and Bun keep them +require('pnpm-isolated-package'); +require('@pnpm-isolated/scoped-package'); +require('bun-isolated-package'); +require('@bun-isolated/scoped-package'); +// Another version of `pnpm-isolated-package`, loaded last as a dependency of the app would load it +require('./node_modules/.pnpm/pnpm-isolated-package@1.0.0/node_modules/pnpm-isolated-package'); + +Sentry.captureException(new Error('error_1')); diff --git a/dev-packages/node-integration-tests/suites/modules/test.ts b/dev-packages/node-integration-tests/suites/modules/test.ts index f47c4c1f8b09..57c221f9f7f0 100644 --- a/dev-packages/node-integration-tests/suites/modules/test.ts +++ b/dev-packages/node-integration-tests/suites/modules/test.ts @@ -1,5 +1,6 @@ import { SDK_VERSION } from '@sentry/core'; -import { join } from 'path'; +import { mkdirSync, rmSync, symlinkSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../utils/runner'; @@ -27,6 +28,46 @@ describe('modulesIntegration', () => { await runner.completed(); }); + test('CJS with the isolated layouts of pnpm and Bun', async () => { + const nodeModulesDir = join(__dirname, 'node_modules'); + for (const [storeDir, name, version, linked] of [ + ['.pnpm', 'pnpm-isolated-package', '1.2.3', true], + // Only a dependency of the app uses this version, so it is not linked into `node_modules` + ['.pnpm', 'pnpm-isolated-package', '1.0.0', false], + ['.pnpm', '@pnpm-isolated/scoped-package', '2.3.4', true], + ['.bun', 'bun-isolated-package', '4.5.6', true], + ['.bun', '@bun-isolated/scoped-package', '5.6.7', true], + ] as const) { + const packageDir = join(nodeModulesDir, storeDir, `${name.replace('/', '+')}@${version}`, 'node_modules', name); + mkdirSync(packageDir, { recursive: true }); + writeFileSync(join(packageDir, 'package.json'), JSON.stringify({ name, version })); + writeFileSync(join(packageDir, 'index.js'), 'module.exports = {};'); + + if (linked) { + mkdirSync(dirname(join(nodeModulesDir, name)), { recursive: true }); + symlinkSync(packageDir, join(nodeModulesDir, name), 'junction'); + } + } + + try { + await createRunner(__dirname, 'scenario-isolated.js') + .expect({ + event: { + modules: { + 'pnpm-isolated-package': '1.2.3', + '@pnpm-isolated/scoped-package': '2.3.4', + 'bun-isolated-package': '4.5.6', + '@bun-isolated/scoped-package': '5.6.7', + }, + }, + }) + .start() + .completed(); + } finally { + rmSync(nodeModulesDir, { recursive: true, force: true }); + } + }); + test('ESM', async () => { const runner = createRunner(__dirname, 'server.mjs') .withInstrument(join(__dirname, 'instrument.mjs')) diff --git a/packages/node/src/integrations/modules.ts b/packages/node/src/integrations/modules.ts index 508bae1beace..54842d0cd5a3 100644 --- a/packages/node/src/integrations/modules.ts +++ b/packages/node/src/integrations/modules.ts @@ -1,5 +1,5 @@ -import { existsSync, readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { existsSync, readFileSync, realpathSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; import { GLOBAL_OBJ, type IntegrationFn } from '@sentry/core'; type ModuleInfo = Record; @@ -73,6 +73,34 @@ function collectModules(): ModuleInfo { }; } +/** + * Whether `dir` is a node_modules folder of the main module, a folder + * `/<.pnpm or .bun>//node_modules`, where the isolated layouts of pnpm and Bun keep + * each package, or a scope folder (`@scope`) in one of these folders. + */ +function isMainModulesDir(dir: string, mainPaths: string[]): boolean { + if (basename(dir).startsWith('@')) { + return isMainModulesDir(dirname(dir), mainPaths); + } + + if (mainPaths.includes(dir)) { + return true; + } + + const storeDir = dirname(dirname(dir)); + return ( + basename(dir) === 'node_modules' && + ['.pnpm', '.bun'].includes(basename(storeDir)) && + mainPaths.includes(dirname(storeDir)) + ); +} + +/** Whether `require(name)` in the main module loads the package in `packageDir`. */ +function isResolvedFromMain(name: string, packageDir: string, mainPaths: string[]): boolean { + const resolvedDir = mainPaths.map(dir => join(dir, name)).find(dir => existsSync(dir)); + return !!resolvedDir && realpathSync(resolvedDir) === realpathSync(packageDir); +} + /** Extract information about package.json modules from require.cache */ function collectRequireModules(): ModuleInfo { const mainPaths = require.main?.paths || []; @@ -81,6 +109,7 @@ function collectRequireModules(): ModuleInfo { // We start with the modules from package.json (if possible) // These may be overwritten by more specific versions from the require.cache const infos: ModuleInfo = {}; + const packageDirs: Record = {}; const seen = new Set(); paths.forEach(path => { @@ -94,7 +123,7 @@ function collectRequireModules(): ModuleInfo { if (!dir || orig === dir || seen.has(orig)) { return undefined; } - if (mainPaths.indexOf(dir) < 0) { + if (!isMainModulesDir(dir, mainPaths)) { return updir(); } @@ -110,7 +139,12 @@ function collectRequireModules(): ModuleInfo { name: string; version: string; }; - infos[info.name] = info.version; + // The isolated layouts of pnpm and Bun keep every version of a package. Keep the one that the main module loads. + const previousDir = packageDirs[info.name]; + if (!previousDir || !isResolvedFromMain(info.name, previousDir, mainPaths)) { + infos[info.name] = info.version; + packageDirs[info.name] = orig; + } } catch { // no-empty }