diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml b/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml index a50a0097e5ae..c87cae49aab6 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml @@ -17,6 +17,22 @@ services: retries: 30 start_period: 10s + mongo: + image: mongo:8 + restart: always + container_name: e2e-tests-nuxt-4-mongo + ports: + - '27017:27017' + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: docker + healthcheck: + test: ['CMD-SHELL', 'mongosh --quiet --eval "db.adminCommand(''ping'').ok"'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 10s + redis: image: redis:7 restart: always diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/global-setup.mjs b/dev-packages/e2e-tests/test-applications/nuxt-4/global-setup.mjs index cb48d539c466..4d0386045cb1 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/global-setup.mjs +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/global-setup.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); export default async function globalSetup() { - // Start MySQL + Redis via Docker Compose. `--wait` blocks until the + // Start MySQL, MongoDB, and Redis via Docker Compose. `--wait` blocks until the // healthchecks in docker-compose.yml pass, so the app can connect immediately. execSync('docker compose up -d --wait', { cwd: __dirname, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index 9eb23775a79b..ac1ea1939999 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -23,6 +23,7 @@ "@pinia/nuxt": "^0.5.5", "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", "ioredis": "5.10.1", + "mongoose": "^9.10.2", "mysql": "^2.18.1", "nuxt": "^4.1.2" }, @@ -31,8 +32,7 @@ "@sentry-internal/test-utils": "link:../../../test-utils" }, "volta": { - "extends": "../../package.json", - "node": "22.20.0" + "extends": "../../package.json" }, "sentryTest": { "variants": [ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-mongoose.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-mongoose.ts new file mode 100644 index 000000000000..6a203cceaa9e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-mongoose.ts @@ -0,0 +1,19 @@ +import { defineEventHandler } from '#imports'; +import mongoose from 'mongoose'; + +const BlogPost = mongoose.model('BlogPost', new mongoose.Schema({ title: String })); + +// SCRAM-SHA-1 on purpose: this handshake lazily `require()`s the `crypto` builtin inside a try +// block, which only works when the bundled driver gets a real interop for builtin requires (#24775). +const MONGO_URL = 'mongodb://root:docker@127.0.0.1:27017/test?authSource=admin&authMechanism=SCRAM-SHA-1'; + +export default defineEventHandler(async () => { + if (mongoose.connection.readyState !== mongoose.ConnectionStates.connected) { + await mongoose.connect(MONGO_URL); + } + + await new BlogPost({ title: 'test-post' }).save(); + const found = await BlogPost.findOne({ title: 'test-post' }); + + return { title: found?.title }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts index a093dd9e8d67..5b1bccedab4d 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts @@ -67,6 +67,46 @@ test('Instruments ioredis automatically', async ({ baseURL }) => { ); }); +// The Nitro bundle force-inlines the instrumented drivers while their CommonJS dependencies and +// Node builtins stay external, and every `require()` across that boundary needs working interop. +test('Instruments mongoose automatically', async ({ baseURL }) => { + const spansPromise = collectRequestSpans('/api/db-mongoose'); + + const response = await fetch(`${baseURL}/api/db-mongoose`); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ title: 'test-post' }); + + const spans = await spansPromise; + + const rootSpan = spans.find(span => span.is_segment); + expect(rootSpan).toBeDefined(); + expect(getSpanOp(rootSpan!)).toBe('http.server'); + + const mongooseSpans = spans.filter( + span => span.attributes['sentry.origin']?.value === 'auto.db.mongoose.diagnostic_channel', + ); + expect(mongooseSpans).toHaveLength(2); + + const saveSpan = mongooseSpans.find(span => span.name === 'save blogposts'); + expect(saveSpan?.status).toBe('ok'); + expect(saveSpan?.is_segment).toBe(false); + expect(saveSpan?.attributes['sentry.op']).toEqual({ type: 'string', value: 'db' }); + expect(saveSpan?.attributes['db.system.name']).toEqual({ type: 'string', value: 'mongodb' }); + expect(saveSpan?.attributes['db.namespace']).toEqual({ type: 'string', value: 'test' }); + expect(saveSpan?.attributes['db.collection.name']).toEqual({ type: 'string', value: 'blogposts' }); + expect(saveSpan?.attributes['db.operation.name']).toEqual({ type: 'string', value: 'save' }); + + const findOneSpan = mongooseSpans.find(span => span.name === 'findOne blogposts'); + expect(findOneSpan?.status).toBe('ok'); + expect(findOneSpan?.is_segment).toBe(false); + expect(findOneSpan?.attributes['sentry.op']).toEqual({ type: 'string', value: 'db' }); + expect(findOneSpan?.attributes['db.system.name']).toEqual({ type: 'string', value: 'mongodb' }); + expect(findOneSpan?.attributes['db.namespace']).toEqual({ type: 'string', value: 'test' }); + expect(findOneSpan?.attributes['db.collection.name']).toEqual({ type: 'string', value: 'blogposts' }); + expect(findOneSpan?.attributes['db.operation.name']).toEqual({ type: 'string', value: 'findOne' }); + expect(findOneSpan?.attributes['db.query.text']).toEqual({ type: 'string', value: '{"title":"?"}' }); +}); + test('Instruments mysql automatically', async ({ baseURL }) => { const spansPromise = collectRequestSpans('/api/db-mysql'); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml b/dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml new file mode 100644 index 000000000000..a392ebd433af --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml @@ -0,0 +1,16 @@ +services: + mongo: + image: mongo:8 + restart: always + container_name: e2e-tests-nuxt-5-mongo + ports: + - '27017:27017' + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: docker + healthcheck: + test: ['CMD-SHELL', 'mongosh --quiet --eval "db.adminCommand(''ping'').ok"'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 10s diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/global-setup.mjs b/dev-packages/e2e-tests/test-applications/nuxt-5/global-setup.mjs new file mode 100644 index 000000000000..fbc228dc4e55 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/global-setup.mjs @@ -0,0 +1,14 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalSetup() { + // Start MongoDB via Docker Compose. `--wait` blocks until the + // healthcheck in docker-compose.yml passes, so the app can connect immediately. + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/nuxt-5/global-teardown.mjs new file mode 100644 index 000000000000..2742279431ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/global-teardown.mjs @@ -0,0 +1,12 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalTeardown() { + execSync('docker compose down --volumes', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json index 36aea2e0b856..24801caafd04 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json @@ -30,6 +30,7 @@ "dependencies": { "@pinia/nuxt": "^0.11.3", "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", + "mongoose": "^9.10.2", "ofetch": "^2.0.0-alpha.3", "nitro": "latest", "nuxt": "npm:nuxt-nightly@5x", @@ -49,7 +50,6 @@ }, "volta": { "extends": "../../package.json", - "node": "22.20.0", "pnpm": "11.10.0" } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts index 80df201381ce..308691754a0b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts @@ -23,4 +23,8 @@ const config = getPlaywrightConfig({ startCommand: getStartCommand(), }); -export default config; +export default { + ...config, + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', +}; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-mongoose.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-mongoose.ts new file mode 100644 index 000000000000..5bed443ac90a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-mongoose.ts @@ -0,0 +1,19 @@ +import mongoose from 'mongoose'; +import { defineHandler } from 'nitro'; + +const BlogPost = mongoose.model('BlogPost', new mongoose.Schema({ title: String })); + +// SCRAM-SHA-1 on purpose: this handshake lazily `require()`s the `crypto` builtin inside a try +// block, which only works when the bundled driver gets a real interop for builtin requires (#24775). +const MONGO_URL = 'mongodb://root:docker@127.0.0.1:27017/test?authSource=admin&authMechanism=SCRAM-SHA-1'; + +export default defineHandler(async () => { + if (mongoose.connection.readyState !== mongoose.ConnectionStates.connected) { + await mongoose.connect(MONGO_URL); + } + + await new BlogPost({ title: 'test-post' }).save(); + const found = await BlogPost.findOne({ title: 'test-post' }); + + return { title: found?.title }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/db-mongoose.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/db-mongoose.test.ts new file mode 100644 index 000000000000..e48c97ed1af6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/db-mongoose.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; + +// The Nitro bundle force-inlines the instrumented drivers while their CommonJS dependencies and +// Node builtins stay external, and every `require()` across that boundary needs working interop +// (#24775). mongoose covers both: `new mquery()`, and SCRAM-SHA-1 auth lazily requiring `crypto`. +async function collectRequestSpans() { + const spans = await collectStreamedSpansUntilSegment( + 'nuxt-5', + span => span.attributes['url.path']?.value === '/api/db-mongoose', + ); + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/api/db-mongoose'); + + return spans.filter(span => span.trace_id === rootSpan?.trace_id); +} + +test('Instruments mongoose automatically', async ({ baseURL }) => { + const spansPromise = collectRequestSpans(); + + const response = await fetch(`${baseURL}/api/db-mongoose`); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ title: 'test-post' }); + + const spans = await spansPromise; + + const mongooseSpans = spans.filter( + span => span.attributes['sentry.origin']?.value === 'auto.db.mongoose.diagnostic_channel', + ); + expect(mongooseSpans).toHaveLength(2); + + const saveSpan = mongooseSpans.find(span => span.name === 'save blogposts'); + expect(saveSpan?.status).toBe('ok'); + expect(saveSpan?.is_segment).toBe(false); + expect(saveSpan?.attributes['sentry.op']).toEqual({ type: 'string', value: 'db' }); + expect(saveSpan?.attributes['db.system.name']).toEqual({ type: 'string', value: 'mongodb' }); + expect(saveSpan?.attributes['db.namespace']).toEqual({ type: 'string', value: 'test' }); + expect(saveSpan?.attributes['db.collection.name']).toEqual({ type: 'string', value: 'blogposts' }); + expect(saveSpan?.attributes['db.operation.name']).toEqual({ type: 'string', value: 'save' }); + + const findOneSpan = mongooseSpans.find(span => span.name === 'findOne blogposts'); + expect(findOneSpan?.status).toBe('ok'); + expect(findOneSpan?.is_segment).toBe(false); + expect(findOneSpan?.attributes['sentry.op']).toEqual({ type: 'string', value: 'db' }); + expect(findOneSpan?.attributes['db.system.name']).toEqual({ type: 'string', value: 'mongodb' }); + expect(findOneSpan?.attributes['db.namespace']).toEqual({ type: 'string', value: 'test' }); + expect(findOneSpan?.attributes['db.collection.name']).toEqual({ type: 'string', value: 'blogposts' }); + expect(findOneSpan?.attributes['db.operation.name']).toEqual({ type: 'string', value: 'findOne' }); + expect(findOneSpan?.attributes['db.query.text']).toEqual({ type: 'string', value: '{"title":"?"}' }); +}); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-static/package.json b/dev-packages/e2e-tests/test-applications/solidstart-static/package.json index 1077b195acb6..9aaf9e1de0ce 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-static/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-static/package.json @@ -34,7 +34,6 @@ "vitest": "^3.2.7" }, "volta": { - "node": "20.19.5", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart/package.json b/dev-packages/e2e-tests/test-applications/solidstart/package.json index ed907f5c5a47..5fa6f2bdb1ca 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart/package.json @@ -36,7 +36,6 @@ "vitest": "^3.2.7" }, "volta": { - "node": "20.19.5", "extends": "../../package.json" } } diff --git a/packages/nuxt/src/vite/orchestrion.ts b/packages/nuxt/src/vite/orchestrion.ts index 1a7f74ee62df..101545f018b9 100644 --- a/packages/nuxt/src/vite/orchestrion.ts +++ b/packages/nuxt/src/vite/orchestrion.ts @@ -1,6 +1,10 @@ import type { Nuxt } from '@nuxt/schema'; import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; -import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/rollup'; +import { + commonJSInteropOptions, + sentryCommonJSInteropPlugin, + sentryOrchestrionPlugin, +} from '@sentry/server-utils/orchestrion/rollup'; import type { NitroConfig } from 'nitropack'; import { isCloudflarePreset } from './utils'; @@ -49,11 +53,18 @@ export function setupOrchestrion(nuxt: Nuxt, hasServerConfig: boolean, buildTime nitroConfig.rollupConfig.plugins = [nitroConfig.rollupConfig.plugins]; } - nitroConfig.rollupConfig.plugins.push(sentryOrchestrionPlugin({})); + nitroConfig.rollupConfig.plugins.push(sentryOrchestrionPlugin({}), sentryCommonJSInteropPlugin()); const externals = (nitroConfig.externals ||= {}); const inline = externals.inline; const existingInline = Array.isArray(inline) ? inline : inline ? [inline] : []; externals.inline = [...new Set([...existingInline, ...INSTRUMENTED_MODULE_NAMES, ...IORedisDependencies])]; + + // The inlined drivers `require()` CommonJS dependencies that stay external. Fix the interop + // for those requires (see `commonJSInteropOptions`). User-provided options win. + const commonJS = (nitroConfig.commonJS ||= {}); + const interop = commonJSInteropOptions(); + commonJS.requireReturnsDefault ??= interop.requireReturnsDefault; + commonJS.ignoreTryCatch ??= interop.ignoreTryCatch; }); } diff --git a/packages/nuxt/test/vite/orchestrion.test.ts b/packages/nuxt/test/vite/orchestrion.test.ts index 9461699412b6..5ab7ffd794d2 100644 --- a/packages/nuxt/test/vite/orchestrion.test.ts +++ b/packages/nuxt/test/vite/orchestrion.test.ts @@ -2,6 +2,10 @@ import type { Nuxt } from '@nuxt/schema'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; const mockSentryOrchestrionPlugin = vi.fn(() => ({ name: 'sentry-orchestrion-plugin' })); +const mockCommonJSInteropOptions = { + requireReturnsDefault: vi.fn(), + ignoreTryCatch: vi.fn(), +}; function createMockNuxt(options: { _prepare?: boolean; dev?: boolean } = {}) { const hooks: Record void | Promise>> = {}; @@ -27,6 +31,8 @@ describe('setupOrchestrion', () => { })); vi.doMock('@sentry/server-utils/orchestrion/rollup', () => ({ sentryOrchestrionPlugin: mockSentryOrchestrionPlugin, + sentryCommonJSInteropPlugin: () => ({ name: 'sentry-commonjs-interop' }), + commonJSInteropOptions: () => mockCommonJSInteropOptions, })); // The module reaches `@sentry/core` and `@nuxt/kit` through `./utils`. Transforming those // charged the first test, which timed out on slower CI runners. The tests never reset the @@ -56,7 +62,11 @@ describe('setupOrchestrion', () => { await mockNuxt.triggerHook('nitro:config', nitroConfig); expect(mockSentryOrchestrionPlugin).toHaveBeenCalledOnce(); - expect(nitroConfig.rollupConfig.plugins).toEqual([existingPlugin, { name: 'sentry-orchestrion-plugin' }]); + expect(nitroConfig.rollupConfig.plugins).toEqual([ + existingPlugin, + { name: 'sentry-orchestrion-plugin' }, + { name: 'sentry-commonjs-interop' }, + ]); expect(nitroConfig.externals.inline).toEqual(['ioredis', 'custom-dependency', 'mysql', 'standard-as-callback']); }); @@ -103,8 +113,24 @@ describe('setupOrchestrion', () => { await mockNuxt.triggerHook('nitro:config', nitroConfig); expect(nitroConfig).toEqual({ - rollupConfig: { plugins: [{ name: 'sentry-orchestrion-plugin' }] }, + rollupConfig: { plugins: [{ name: 'sentry-orchestrion-plugin' }, { name: 'sentry-commonjs-interop' }] }, externals: { inline: ['mysql', 'ioredis', 'standard-as-callback'] }, + commonJS: mockCommonJSInteropOptions, + }); + }); + + it('preserves user-provided CommonJS options', async () => { + const { setupOrchestrion } = await import('../../src/vite/orchestrion'); + const mockNuxt = createMockNuxt(); + const userRequireReturnsDefault = vi.fn(); + const nitroConfig = { commonJS: { requireReturnsDefault: userRequireReturnsDefault } }; + + setupOrchestrion(mockNuxt as unknown as Nuxt, true); + await mockNuxt.triggerHook('nitro:config', nitroConfig); + + expect(nitroConfig.commonJS).toEqual({ + requireReturnsDefault: userRequireReturnsDefault, + ignoreTryCatch: mockCommonJSInteropOptions.ignoreTryCatch, }); }); diff --git a/packages/server-utils/src/orchestrion/bundler/rollup.ts b/packages/server-utils/src/orchestrion/bundler/rollup.ts index 87bac2e1e00c..fc0403927a11 100644 --- a/packages/server-utils/src/orchestrion/bundler/rollup.ts +++ b/packages/server-utils/src/orchestrion/bundler/rollup.ts @@ -1,3 +1,5 @@ +import { isBuiltin } from 'node:module'; + import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/rollup'; import type { ExternalOption, @@ -30,6 +32,53 @@ function rawExternalMatchesModule(external: ExternalOption, name: string): boole ); } +/** + * Structural subset of `@rollup/plugin-commonjs` options, so this package needs no dependency on + * the plugin for its types. + */ +export interface CommonJSInteropOptions { + requireReturnsDefault: (id: string) => boolean | 'auto' | 'preferred' | 'namespace'; + ignoreTryCatch: (id: string) => boolean; +} + +/** + * `@rollup/plugin-commonjs` options for builds that bundle CommonJS packages while their + * dependencies stay external. Builtin `require()`s unwrap to the module (a namespace breaks + * direct calls) and convert inside `try` blocks (a bare `require` throws in ESM output). + * Externals keep `'auto'`, which {@link sentryCommonJSInteropPlugin} fixes for Node >= 23. + */ +export function commonJSInteropOptions(): CommonJSInteropOptions { + return { + requireReturnsDefault: id => (isBuiltin(id) ? true : 'auto'), + ignoreTryCatch: id => !isBuiltin(id), + }; +} + +const COMMONJS_HELPERS_ID = '\0commonjsHelpers.js'; + +// The plugin's `'auto'` helper unwraps a required external module only when `default` is the +// sole key on its namespace. Node >= 23 adds a `'module.exports'` key to every CommonJS +// namespace (nodejs/node#53848), so the check never passes. Ignoring that key restores it. +const BROKEN_NAMESPACE_CHECK = 'Object.keys(n).length === 1'; +const FIXED_NAMESPACE_CHECK = "Object.keys(n).filter(k => k !== 'module.exports').length === 1"; + +/** + * Fixes `@rollup/plugin-commonjs`' `'auto'` interop for Node >= 23 by patching the broken + * namespace check in its helpers module. The patch applies only while the exact broken + * expression exists, so it retires itself once the plugin is fixed upstream. + */ +export function sentryCommonJSInteropPlugin(): Plugin { + return { + name: 'sentry-commonjs-interop', + transform(code: string, id: string) { + if (id !== COMMONJS_HELPERS_ID || !code.includes(BROKEN_NAMESPACE_CHECK)) { + return null; + } + return { code: code.replace(BROKEN_NAMESPACE_CHECK, FIXED_NAMESPACE_CHECK), map: null }; + }, + }; +} + /** * Rollup plugin that runs the orchestrion code transform on the bundled output. * diff --git a/packages/server-utils/test/orchestrion/rollup-plugin.test.ts b/packages/server-utils/test/orchestrion/rollup-plugin.test.ts index 6002e141a835..6f7ef28041bb 100644 --- a/packages/server-utils/test/orchestrion/rollup-plugin.test.ts +++ b/packages/server-utils/test/orchestrion/rollup-plugin.test.ts @@ -1,6 +1,10 @@ import type { InputOptions, NormalizedInputOptions, PluginContext } from 'rollup'; import { describe, expect, it, vi } from 'vitest'; -import { sentryOrchestrionPlugin } from '../../src/orchestrion/bundler/rollup'; +import { + commonJSInteropOptions, + sentryCommonJSInteropPlugin, + sentryOrchestrionPlugin, +} from '../../src/orchestrion/bundler/rollup'; type OptionsHook = (this: unknown, inputOptions: InputOptions) => null; type BuildStartHook = (this: Pick, rollupOptions: NormalizedInputOptions) => void; @@ -47,3 +51,55 @@ describe('sentryOrchestrionPlugin (rollup) externalized-modules warning', () => }); }); }); + +describe('commonJSInteropOptions', () => { + const { requireReturnsDefault, ignoreTryCatch } = commonJSInteropOptions(); + + it('resolves builtins to their default export directly', () => { + expect(requireReturnsDefault('crypto')).toBe(true); + expect(requireReturnsDefault('node:crypto')).toBe(true); + }); + + it('keeps auto for external packages', () => { + expect(requireReturnsDefault('mquery')).toBe('auto'); + expect(requireReturnsDefault('debug')).toBe('auto'); + }); + + it('converts only builtin requires inside try blocks', () => { + expect(ignoreTryCatch('crypto')).toBe(false); + expect(ignoreTryCatch('node:crypto')).toBe(false); + expect(ignoreTryCatch('kerberos')).toBe(true); + }); +}); + +describe('sentryCommonJSInteropPlugin', () => { + const transform = sentryCommonJSInteropPlugin().transform as ( + code: string, + id: string, + ) => { code: string; map: null } | null; + + const brokenHelper = + 'export function getDefaultExportFromNamespaceIfNotNamed (n) {\n' + + "\treturn n && Object.prototype.hasOwnProperty.call(n, 'default') && Object.keys(n).length === 1 ? n['default'] : n;\n" + + '}\n'; + + it('patches the namespace check in the commonjs helpers module', () => { + const result = transform(brokenHelper, '\0commonjsHelpers.js'); + + expect(result?.code).toContain("Object.keys(n).filter(k => k !== 'module.exports').length === 1"); + expect(result?.code).not.toContain('Object.keys(n).length === 1'); + }); + + it('ignores other modules', () => { + expect(transform(brokenHelper, '/app/node_modules/mongoose/lib/index.js')).toBeNull(); + }); + + it('leaves an already-fixed helpers module unchanged', () => { + const fixedHelper = brokenHelper.replace( + 'Object.keys(n).length === 1', + "Object.keys(n).filter(k => k !== 'module.exports').length === 1", + ); + + expect(transform(fixedHelper, '\0commonjsHelpers.js')).toBeNull(); + }); +}); diff --git a/packages/solidstart/src/config/withSentry.ts b/packages/solidstart/src/config/withSentry.ts index 3caef9cdab82..e4b0f6d08b49 100644 --- a/packages/solidstart/src/config/withSentry.ts +++ b/packages/solidstart/src/config/withSentry.ts @@ -1,7 +1,11 @@ import { debug } from '@sentry/core'; import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; -import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/rollup'; -import type { Nitro } from 'nitropack'; +import { + commonJSInteropOptions, + sentryCommonJSInteropPlugin, + sentryOrchestrionPlugin, +} from '@sentry/server-utils/orchestrion/rollup'; +import type { Nitro, NitroConfig } from 'nitropack'; import { addSentryPluginToVite } from '../vite/sentrySolidStartVite'; import type { SentrySolidStartPluginOptions } from '../vite/types'; import { @@ -60,6 +64,7 @@ export function withSentry( if (addBuildTimeInstrumentation) { sentryRollupConfig.plugins.push( sentryOrchestrionPlugin({ buildTimeInstrumentation: sentryPluginOptions.buildTimeInstrumentation }), + sentryCommonJSInteropPlugin(), ); } @@ -90,12 +95,17 @@ export function withSentry( // the instrumented modules. This has to be set statically on the Nitro config (not in a hook) // because externalization is a resolution-time decision made before Rollup normalizes `external`. let externals = (server as SolidStartInlineServerConfig & { externals?: { inline?: string[] } }).externals; + let commonJS = (server as SolidStartInlineServerConfig & { commonJS?: NitroConfig['commonJS'] }).commonJS; if (addBuildTimeInstrumentation) { const existingInline = externals?.inline || []; externals = { ...externals, inline: [...new Set([...existingInline, ...INSTRUMENTED_MODULE_NAMES, ...IORedisDependencies])], }; + + // The inlined drivers `require()` CommonJS dependencies that stay external. Fix the interop + // for those requires (see `commonJSInteropOptions`). User-provided options win. + commonJS = { ...commonJSInteropOptions(), ...commonJS }; } return { @@ -104,6 +114,7 @@ export function withSentry( server: { ...server, externals, + commonJS, modules: [...existingModules, sentryNitroModule], }, }; diff --git a/packages/solidstart/test/config/withSentry.test.ts b/packages/solidstart/test/config/withSentry.test.ts index c9b242962c53..3afba804811d 100644 --- a/packages/solidstart/test/config/withSentry.test.ts +++ b/packages/solidstart/test/config/withSentry.test.ts @@ -17,8 +17,14 @@ vi.mock('../../src/config/addInstrumentation', () => ({ const orchestrionRollupMock = vi.fn((options?: { buildTimeInstrumentation?: boolean }) => ({ name: options?.buildTimeInstrumentation === false ? 'sentry-orchestrion-disabled' : 'sentry-orchestrion-plugin', })); +const commonJSInteropOptionsMock = { + requireReturnsDefault: vi.fn(), + ignoreTryCatch: vi.fn(), +}; vi.mock('@sentry/server-utils/orchestrion/rollup', () => ({ sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean }) => orchestrionRollupMock(options), + sentryCommonJSInteropPlugin: () => ({ name: 'sentry-commonjs-interop' }), + commonJSInteropOptions: () => commonJSInteropOptionsMock, })); vi.mock('@sentry/server-utils/orchestrion/config', () => ({ INSTRUMENTED_MODULE_NAMES: ['mysql', 'ioredis'], @@ -192,7 +198,7 @@ describe('withSentry()', () => { const plugins: Array<{ name: string }> = []; await hookFn(nitroOptions, { plugins }); expect(orchestrionRollupMock).toHaveBeenCalledWith({ buildTimeInstrumentation: undefined }); - expect(plugins.map(plugin => plugin.name)).toContain('sentry-orchestrion-plugin'); + expect(plugins.map(plugin => plugin.name)).toEqual(['sentry-orchestrion-plugin', 'sentry-commonjs-interop']); }); it('force-inlines the instrumented modules into server.externals by default', () => { @@ -216,6 +222,31 @@ describe('withSentry()', () => { expect(externals?.inline).toEqual(['ioredis', 'custom-dependency', 'mysql', 'standard-as-callback']); }); + it('sets the CommonJS interop options for the inlined drivers by default', () => { + const config = withSentry(solidStartConfig, {}); + const commonJS = (config?.server as { commonJS?: Record })?.commonJS; + expect(commonJS).toEqual(commonJSInteropOptionsMock); + }); + + it('preserves user-provided CommonJS options', () => { + const userRequireReturnsDefault = vi.fn(); + const config = withSentry( + { + ...solidStartConfig, + server: { + ...solidStartConfig.server, + commonJS: { requireReturnsDefault: userRequireReturnsDefault }, + }, + } as Parameters[0], + {}, + ); + const commonJS = (config?.server as { commonJS?: Record })?.commonJS; + expect(commonJS).toEqual({ + requireReturnsDefault: userRequireReturnsDefault, + ignoreTryCatch: commonJSInteropOptionsMock.ignoreTryCatch, + }); + }); + it('adds an inert orchestrion plugin and skips externals when buildTimeInstrumentation is false', async () => { const config = withSentry(solidStartConfig, { buildTimeInstrumentation: false }); const { hookFn } = callSentryNitroModule(config); @@ -225,6 +256,8 @@ describe('withSentry()', () => { expect(plugins).toHaveLength(0); const externals = (config?.server as { externals?: { inline?: string[] } })?.externals; expect(externals).toBeUndefined(); + const commonJS = (config?.server as { commonJS?: Record })?.commonJS; + expect(commonJS).toBeUndefined(); }); }); });