diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 965233d08b76..ffce8a8b0641 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -45,10 +45,12 @@ export type NextConfigObject = { experimental?: { instrumentationHook?: boolean; clientTraceMetadata?: string[]; + serverComponentsExternalPackages?: string[]; // next < v15.0.0 }; productionBrowserSourceMaps?: boolean; // https://nextjs.org/docs/pages/api-reference/next-config-js/env env?: Record; + serverExternalPackages?: string[]; // next >= v15.0.0 }; export type SentryBuildOptions = { diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 543f271c1999..b94ce6187f97 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -17,6 +17,35 @@ import { constructWebpackConfigFunction } from './webpack'; let showedExportModeTunnelWarning = false; let showedExperimentalBuildModeWarning = false; +// Packages we auto-instrument need to be external for instrumentation to work +// Next.js externalizes some packages by default, see: https://nextjs.org/docs/app/api-reference/config/next-config-js/serverExternalPackages +// Others we need to add ourselves +export const DEFAULT_SERVER_EXTERNAL_PACKAGES = [ + 'ai', + 'amqplib', + 'connect', + 'dataloader', + 'express', + 'generic-pool', + 'graphql', + '@hapi/hapi', + 'ioredis', + 'kafkajs', + 'koa', + 'lru-memoizer', + 'mongodb', + 'mongoose', + 'mysql', + 'mysql2', + 'knex', + 'pg', + 'pg-pool', + '@node-redis/client', + '@redis/client', + 'redis', + 'tedious', +]; + /** * Modifies the passed in Next.js configuration with automatic build-time instrumentation and source map upload. * @@ -190,8 +219,10 @@ function getFinalConfigObject( ); } + let nextMajor: number | undefined; if (nextJsVersion) { const { major, minor, patch, prerelease } = parseSemver(nextJsVersion); + nextMajor = major; const isSupportedVersion = major !== undefined && minor !== undefined && @@ -229,6 +260,22 @@ function getFinalConfigObject( return { ...incomingUserNextConfigObject, + ...(nextMajor && nextMajor >= 15 + ? { + serverExternalPackages: [ + ...(incomingUserNextConfigObject.serverExternalPackages || []), + ...DEFAULT_SERVER_EXTERNAL_PACKAGES, + ], + } + : { + experimental: { + ...incomingUserNextConfigObject.experimental, + serverComponentsExternalPackages: [ + ...(incomingUserNextConfigObject.experimental?.serverComponentsExternalPackages || []), + ...DEFAULT_SERVER_EXTERNAL_PACKAGES, + ], + }, + }), webpack: constructWebpackConfigFunction(incomingUserNextConfigObject, userSentryOptions, releaseName), }; } diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts index f9db1a68771e..ee4b2d364c6a 100644 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ b/packages/nextjs/test/config/withSentryConfig.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as util from '../../src/config/util'; +import { DEFAULT_SERVER_EXTERNAL_PACKAGES } from '../../src/config/withSentryConfig'; import { defaultRuntimePhase, defaultsObject, exportedNextConfig, userNextConfig } from './fixtures'; import { materializeFinalNextConfig } from './testUtils'; @@ -22,10 +24,16 @@ describe('withSentryConfig', () => { it("works when user's overall config is an object", () => { const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig).toEqual( + const { webpack, experimental, ...restOfFinalConfig } = finalConfig; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { webpack: _userWebpack, experimental: _userExperimental, ...restOfUserConfig } = userNextConfig; + + expect(restOfFinalConfig).toEqual(restOfUserConfig); + expect(webpack).toBeInstanceOf(Function); + expect(experimental).toEqual( expect.objectContaining({ - ...userNextConfig, - webpack: expect.any(Function), // `webpack` is tested specifically elsewhere + instrumentationHook: true, + serverComponentsExternalPackages: expect.arrayContaining(DEFAULT_SERVER_EXTERNAL_PACKAGES), }), ); }); @@ -35,10 +43,21 @@ describe('withSentryConfig', () => { const finalConfig = materializeFinalNextConfig(exportedNextConfigFunction); - expect(finalConfig).toEqual( + const { webpack, experimental, ...restOfFinalConfig } = finalConfig; + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + webpack: _userWebpack, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + experimental: _userExperimental, + ...restOfUserConfig + } = exportedNextConfigFunction(); + + expect(restOfFinalConfig).toEqual(restOfUserConfig); + expect(webpack).toBeInstanceOf(Function); + expect(experimental).toEqual( expect.objectContaining({ - ...exportedNextConfigFunction(), - webpack: expect.any(Function), // `webpack` is tested specifically elsewhere + instrumentationHook: true, + serverComponentsExternalPackages: expect.arrayContaining(DEFAULT_SERVER_EXTERNAL_PACKAGES), }), ); }); @@ -75,4 +94,54 @@ describe('withSentryConfig', () => { consoleWarnSpy.mockRestore(); } }); + + describe('server packages configuration', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uses serverExternalPackages for Next.js 15+', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.0.0'); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.serverExternalPackages).toBeDefined(); + expect(finalConfig.serverExternalPackages).toEqual(expect.arrayContaining(DEFAULT_SERVER_EXTERNAL_PACKAGES)); + expect(finalConfig.experimental?.serverComponentsExternalPackages).toBeUndefined(); + }); + + it('uses experimental.serverComponentsExternalPackages for Next.js < 15', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('14.0.0'); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.serverExternalPackages).toBeUndefined(); + expect(finalConfig.experimental?.serverComponentsExternalPackages).toBeDefined(); + expect(finalConfig.experimental?.serverComponentsExternalPackages).toEqual( + expect.arrayContaining(DEFAULT_SERVER_EXTERNAL_PACKAGES), + ); + }); + + it('preserves existing packages in both versions', () => { + const existingPackages = ['@some/existing-package']; + + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.0.0'); + const config15 = materializeFinalNextConfig({ + ...exportedNextConfig, + serverExternalPackages: existingPackages, + }); + expect(config15.serverExternalPackages).toEqual( + expect.arrayContaining([...existingPackages, ...DEFAULT_SERVER_EXTERNAL_PACKAGES]), + ); + + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('14.0.0'); + const config14 = materializeFinalNextConfig({ + ...exportedNextConfig, + experimental: { + serverComponentsExternalPackages: existingPackages, + }, + }); + expect(config14.experimental?.serverComponentsExternalPackages).toEqual( + expect.arrayContaining([...existingPackages, ...DEFAULT_SERVER_EXTERNAL_PACKAGES]), + ); + }); + }); });