diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts index fb1b9f616aeb..33b53c7dd950 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts @@ -25,6 +25,7 @@ import { maybeSetUpTunnelRouteRewriteRules, resolveReleaseName, shouldReturnEarlyInExperimentalBuildMode, + warnIfMisplacedInstrumentationFile, warnIfMissingOnRouterTransitionStartHook, } from './getFinalConfigObjectUtils'; @@ -55,6 +56,7 @@ export function getFinalConfigObject( maybeSetClientTraceMetadataOption(incomingUserNextConfigObject, nextJsVersion, userSentryOptions.silent); maybeSetInstrumentationHookOption(incomingUserNextConfigObject, nextJsVersion, userSentryOptions.silent); warnIfMissingOnRouterTransitionStartHook(userSentryOptions); + warnIfMisplacedInstrumentationFile(incomingUserNextConfigObject, userSentryOptions); const bundlerInfo = getBundlerInfo(nextJsVersion); maybeWarnAboutUnsupportedTurbopack(nextJsVersion, bundlerInfo, userSentryOptions.silent); diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index 68520bc1e16d..5f70094efb3c 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -7,7 +7,7 @@ import { getBuildLogger } from '../buildLogger'; import { createRouteManifest } from '../manifest/createRouteManifest'; import type { RouteManifest } from '../manifest/types'; import type { NextConfigObject, SentryBuildOptions } from '../types'; -import { requiresInstrumentationHook } from '../util'; +import { DEFAULT_PAGE_EXTENSIONS, requiresInstrumentationHook } from '../util'; import { getGitRevision, getInstrumentationClientFileContents } from './buildTime'; import { findTunnelRouteHandlerFile, @@ -21,6 +21,8 @@ let showedUnusedTunnelRouteHandlerWarning = false; let showedTunnelRouteHandlerModeLog = false; let showedExperimentalBuildModeWarning = false; +const MISPLACED_INSTRUMENTATION_FILE_WARNING_SHOWN = '__SENTRY_MISPLACED_INSTRUMENTATION_FILE_WARNING_SHOWN__'; + /** * Resolves the Sentry release name to use for build-time behavior. * @@ -266,6 +268,48 @@ export function warnIfMissingOnRouterTransitionStartHook(userSentryOptions: Sent } } +/** + * Warns if the `instrumentation` file is not in the folder that contains the `app` or `pages` directory. + * `next build` only looks there, even though dev and Turbopack also find the file in other places. + */ +export function warnIfMisplacedInstrumentationFile( + incomingUserNextConfigObject: NextConfigObject, + userSentryOptions: SentryBuildOptions, +): void { + if ( + process.env[MISPLACED_INSTRUMENTATION_FILE_WARNING_SHOWN] || + process.env.SENTRY_SUPPRESS_INSTRUMENTATION_FILE_WARNING + ) { + return; + } + + const projectDir = process.cwd(); + const exists = (relativePath: string): boolean => fs.existsSync(path.join(projectDir, relativePath)); + + // Same lookup as Next.js' `findPagesDir`: `pages` before `app`, and `` before `src/`. + const routerDir = ['pages', 'app'].flatMap(name => [name, path.join('src', name)]).find(exists); + if (!routerDir) { + return; + } + + const loadedDir = path.dirname(routerDir); + const ignoredDir = loadedDir === 'src' ? '.' : 'src'; + const pageExtensions = incomingUserNextConfigObject.pageExtensions ?? DEFAULT_PAGE_EXTENSIONS; + const findInstrumentationFile = (dir: string): string | undefined => + pageExtensions.map(extension => path.join(dir, `instrumentation.${extension}`)).find(exists); + + const misplacedFile = findInstrumentationFile(ignoredDir); + if (!misplacedFile || findInstrumentationFile(loadedDir)) { + return; + } + + // Next.js may evaluate its config in child processes, which inherit this state from their parent. + process.env[MISPLACED_INSTRUMENTATION_FILE_WARNING_SHOWN] = '1'; + getBuildLogger(userSentryOptions.silent).warn( + `[@sentry/nextjs] Found ${misplacedFile}, but Next.js production builds only pick up the instrumentation file next to your ${routerDir} directory. Move it to ${path.join(loadedDir, path.basename(misplacedFile))}, or the Sentry SDK may not be initialized on the server in production.`, + ); +} + /** * Parses the major Next.js version number from a semver string. */ diff --git a/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts b/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts index cfda40eaa28f..d056ea8d1b24 100644 --- a/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts +++ b/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { NextConfigObject } from '../../../src/config/types'; +import { warnIfMisplacedInstrumentationFile } from '../../../src/config/withSentryConfig/getFinalConfigObjectUtils'; import { makeProject, removeProjects } from './projectFixture'; const HANDLER_REWRITE = { source: '/monitoring', destination: '/sentry-tunnel' }; @@ -121,3 +122,104 @@ describe('maybeSetUpTunnelRouteRewriteRules', () => { expect(warnSpy).not.toHaveBeenCalled(); }); }); + +describe('warnIfMisplacedInstrumentationFile', () => { + const ROOT_FILE_WITH_SRC_APP = ['src/app/page.tsx', 'instrumentation.ts']; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.stubEnv('__SENTRY_MISPLACED_INSTRUMENTATION_FILE_WARNING_SHOWN__', undefined); + }); + + function setUp(files: string[]) { + vi.spyOn(process, 'cwd').mockReturnValue(makeProject(files)); + return vi.spyOn(console, 'warn').mockImplementation(() => undefined); + } + + function misplacedFileWarning(found: string, routerDir: string, target: string): string { + return `[@sentry/nextjs] Found ${found}, but Next.js production builds only pick up the instrumentation file next to your ${routerDir} directory. Move it to ${target}, or the Sentry SDK may not be initialized on the server in production.`; + } + + it.each([ + { + found: 'instrumentation.ts', + routerDir: 'src/app', + target: 'src/instrumentation.ts', + files: ROOT_FILE_WITH_SRC_APP, + }, + { + found: 'instrumentation.js', + routerDir: 'src/pages', + target: 'src/instrumentation.js', + files: ['src/pages/index.tsx', 'instrumentation.js'], + }, + { + found: 'src/instrumentation.ts', + routerDir: 'app', + target: 'instrumentation.ts', + files: ['app/page.tsx', 'src/lib/db.ts', 'src/instrumentation.ts'], + }, + ])('warns about $found in a project with $routerDir', ({ found, routerDir, target, files }) => { + const warnSpy = setUp(files); + + warnIfMisplacedInstrumentationFile({}, {}); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith(misplacedFileWarning(found, routerDir, target)); + }); + + it('looks for the file with the configured `pageExtensions`', () => { + const warnSpy = setUp(['src/app/page.page.tsx', 'instrumentation.page.ts']); + + warnIfMisplacedInstrumentationFile({ pageExtensions: ['page.tsx', 'page.ts'] }, {}); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + misplacedFileWarning('instrumentation.page.ts', 'src/app', 'src/instrumentation.page.ts'), + ); + }); + + it('warns only once', () => { + const warnSpy = setUp(ROOT_FILE_WITH_SRC_APP); + + warnIfMisplacedInstrumentationFile({}, {}); + warnIfMisplacedInstrumentationFile({}, {}); + + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['the file is next to `src/app`', ['src/app/page.tsx', 'src/instrumentation.ts']], + ['the file is next to `app`', ['app/page.tsx', 'instrumentation.ts']], + ['a file is in both folders', ['src/app/page.tsx', 'src/instrumentation.ts', 'instrumentation.ts']], + [ + 'a root `pages` directory takes precedence over `src/app`', + ['pages/index.tsx', 'src/app/page.tsx', 'instrumentation.ts'], + ], + ['the project has no `app` or `pages` directory', ['instrumentation.ts']], + ])('does not warn when %s', (_setup, files) => { + const warnSpy = setUp(files); + + warnIfMisplacedInstrumentationFile({}, {}); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('does not warn with `silent`', () => { + const warnSpy = setUp(ROOT_FILE_WITH_SRC_APP); + + warnIfMisplacedInstrumentationFile({}, { silent: true }); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('does not warn when SENTRY_SUPPRESS_INSTRUMENTATION_FILE_WARNING is set', () => { + vi.stubEnv('SENTRY_SUPPRESS_INSTRUMENTATION_FILE_WARNING', '1'); + const warnSpy = setUp(ROOT_FILE_WITH_SRC_APP); + + warnIfMisplacedInstrumentationFile({}, {}); + + expect(warnSpy).not.toHaveBeenCalled(); + }); +});