diff --git a/CHANGELOG.md b/CHANGELOG.md index a03d59aa5..5bfe40448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- feat(nextjs): Switch to injecting `instrumentation-client.ts` ([#918](https://github.com/getsentry/sentry-wizard/pull/918)) - feat(remix): New Remix example page ([#917](https://github.com/getsentry/sentry-wizard/pull/917)) - feat(nuxt): New Nuxt example page ([#916](https://github.com/getsentry/sentry-wizard/pull/916)) - feat(sveltekit): New Sveltekit example page ([#913](https://github.com/getsentry/sentry-wizard/pull/913)) diff --git a/e2e-tests/tests/nextjs-14.test.ts b/e2e-tests/tests/nextjs-14.test.ts index 2f70a8b4e..fdb844ac9 100644 --- a/e2e-tests/tests/nextjs-14.test.ts +++ b/e2e-tests/tests/nextjs-14.test.ts @@ -105,7 +105,6 @@ describe('NextJS-14', () => { test('config files created', () => { checkFileExists(`${projectDir}/sentry.server.config.ts`); - checkFileExists(`${projectDir}/sentry.client.config.ts`); checkFileExists(`${projectDir}/sentry.edge.config.ts`); }); @@ -113,8 +112,9 @@ describe('NextJS-14', () => { checkFileExists(`${projectDir}/src/app/global-error.tsx`); }); - test('instrumentation file exists', () => { + test('instrumentation files exists', () => { checkFileExists(`${projectDir}/src/instrumentation.ts`); + checkFileExists(`${projectDir}/src/instrumentation-client.ts`); }); test('instrumentation file contains Sentry initialization', () => { diff --git a/e2e-tests/tests/nextjs-15.test.ts b/e2e-tests/tests/nextjs-15.test.ts index cd4baeb1d..2b340d9ae 100644 --- a/e2e-tests/tests/nextjs-15.test.ts +++ b/e2e-tests/tests/nextjs-15.test.ts @@ -104,7 +104,6 @@ describe('NextJS-15', () => { test('config files created', () => { checkFileExists(`${projectDir}/sentry.server.config.ts`); - checkFileExists(`${projectDir}/sentry.client.config.ts`); checkFileExists(`${projectDir}/sentry.edge.config.ts`); }); @@ -112,8 +111,9 @@ describe('NextJS-15', () => { checkFileExists(`${projectDir}/src/app/global-error.tsx`); }); - test('instrumentation file exists', () => { + test('instrumentation files exists', () => { checkFileExists(`${projectDir}/src/instrumentation.ts`); + checkFileExists(`${projectDir}/src/instrumentation-client.ts`); }); test('instrumentation file contains Sentry initialization', () => { diff --git a/src/nextjs/nextjs-wizard.ts b/src/nextjs/nextjs-wizard.ts index 85c013432..c4494e430 100644 --- a/src/nextjs/nextjs-wizard.ts +++ b/src/nextjs/nextjs-wizard.ts @@ -41,7 +41,8 @@ import { getNextjsConfigEsmCopyPasteSnippet, getNextjsConfigMjsTemplate, getRootLayout, - getSentryConfigContents, + getSentryServersideConfigContents, + getInstrumentationClientFileContents, getSentryDefaultGlobalErrorPage, getSentryDefaultUnderscoreErrorPage, getSentryExampleAppDirApiRoute, @@ -49,6 +50,7 @@ import { getSentryExamplePagesDirApiRoute, getSimpleUnderscoreErrorCopyPasteSnippet, getWithSentryConfigOptionsTemplate, + getInstrumentationClientHookCopyPasteSnippet, } from './templates'; import { getNextJsVersionBucket } from './utils'; @@ -99,7 +101,7 @@ export async function runNextjsWizardWithTelemetry( const { packageManager: packageManagerFromInstallStep } = await installPackage({ - packageName: '@sentry/nextjs@^9', + packageName: '@sentry/nextjs@latest', packageNameDisplayLabel: '@sentry/nextjs', alreadyInstalled: !!packageJson?.dependencies?.['@sentry/nextjs'], forceInstall, @@ -311,8 +313,8 @@ export async function runNextjsWizardWithTelemetry( if (isLikelyUsingTurbopack || isLikelyUsingTurbopack === null) { await abortIfCancelled( clack.select({ - message: `Warning: The Sentry SDK doesn't yet fully support Turbopack in dev mode. The SDK will not be loaded in the browser, and serverside instrumentation will be inaccurate or incomplete. Production builds will still fully work. ${chalk.bold( - `To continue this setup, if you are using Turbopack, temporarily remove \`--turbo\` or \`--turbopack\` from your dev command until you have verified the SDK is working as expected.`, + message: `Warning: The Sentry SDK is only compatible with Turbopack on Next.js version 15.3.0 (or 15.3.0-canary.8) or later. ${chalk.bold( + `If you are using Turbopack with an older Next.js version, temporarily remove \`--turbo\` or \`--turbopack\` from your dev command until you have verified the SDK is working as expected. Note that the SDK will continue to work for non-Turbopack production builds.`, )}`, options: [ { @@ -392,7 +394,7 @@ async function createOrMergeNextJsFiles( const typeScriptDetected = isUsingTypeScript(); - const configVariants = ['server', 'client', 'edge'] as const; + const configVariants = ['server', 'edge'] as const; for (const configVariant of configVariants) { await traceStep(`create-sentry-${configVariant}-config`, async () => { @@ -444,7 +446,7 @@ async function createOrMergeNextJsFiles( if (shouldWriteFile) { await fs.promises.writeFile( path.join(process.cwd(), typeScriptDetected ? tsConfig : jsConfig), - getSentryConfigContents( + getSentryServersideConfigContents( selectedProject.keys[0].dsn.public, configVariant, selectedFeatures, @@ -532,6 +534,7 @@ async function createOrMergeNextJsFiles( getInstrumentationHookCopyPasteSnippet( newInstrumentationHookLocation, ), + "create the file if it doesn't already exist", ); } } else { @@ -546,6 +549,102 @@ async function createOrMergeNextJsFiles( } }); + await traceStep('setup-instrumentation-client-hook', async () => { + const hasRootAppDirectory = hasDirectoryPathFromRoot('app'); + const hasRootPagesDirectory = hasDirectoryPathFromRoot('pages'); + const hasSrcDirectory = hasDirectoryPathFromRoot('src'); + + let instrumentationClientHookLocation: 'src' | 'root' | 'does-not-exist'; + + const instrumentationClientTsExists = fs.existsSync( + path.join(process.cwd(), 'instrumentation-client.ts'), + ); + const instrumentationClientJsExists = fs.existsSync( + path.join(process.cwd(), 'instrumentation-client.js'), + ); + const srcInstrumentationClientTsExists = fs.existsSync( + path.join(process.cwd(), 'src', 'instrumentation-client.ts'), + ); + const srcInstrumentationClientJsExists = fs.existsSync( + path.join(process.cwd(), 'src', 'instrumentation-client.js'), + ); + + // https://nextjs.org/docs/app/building-your-application/configuring/src-directory + // https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation + // The logic for where Next.js picks up the instrumentation file is as follows: + // - If there is either an `app` folder or a `pages` folder in the root directory of your Next.js app, Next.js looks + // for an `instrumentation.ts` file in the root of the Next.js app. + // - Otherwise, if there is neither an `app` folder or a `pages` folder in the rood directory of your Next.js app, + // AND if there is an `src` folder, Next.js will look for the `instrumentation.ts` file in the `src` folder. + if (hasRootPagesDirectory || hasRootAppDirectory) { + if (instrumentationClientJsExists || instrumentationClientTsExists) { + instrumentationClientHookLocation = 'root'; + } else { + instrumentationClientHookLocation = 'does-not-exist'; + } + } else { + if ( + srcInstrumentationClientTsExists || + srcInstrumentationClientJsExists + ) { + instrumentationClientHookLocation = 'src'; + } else { + instrumentationClientHookLocation = 'does-not-exist'; + } + } + + const newInstrumentationClientFileName = `instrumentation-client.${ + typeScriptDetected ? 'ts' : 'js' + }`; + + if (instrumentationClientHookLocation === 'does-not-exist') { + let newInstrumentationClientHookLocation: 'root' | 'src'; + if (hasRootPagesDirectory || hasRootAppDirectory) { + newInstrumentationClientHookLocation = 'root'; + } else if (hasSrcDirectory) { + newInstrumentationClientHookLocation = 'src'; + } else { + newInstrumentationClientHookLocation = 'root'; + } + + const newInstrumentationClientHookPath = + newInstrumentationClientHookLocation === 'root' + ? path.join(process.cwd(), newInstrumentationClientFileName) + : path.join(process.cwd(), 'src', newInstrumentationClientFileName); + + const successfullyCreated = await createNewConfigFile( + newInstrumentationClientHookPath, + getInstrumentationClientFileContents( + selectedProject.keys[0].dsn.public, + selectedFeatures, + ), + ); + + if (!successfullyCreated) { + await showCopyPasteInstructions( + newInstrumentationClientFileName, + getInstrumentationClientHookCopyPasteSnippet( + selectedProject.keys[0].dsn.public, + selectedFeatures, + ), + "create the file if it doesn't already exist", + ); + } + } else { + await showCopyPasteInstructions( + srcInstrumentationClientTsExists || instrumentationClientTsExists + ? 'instrumentation-client.ts' + : srcInstrumentationClientJsExists || instrumentationClientJsExists + ? 'instrumentation-client.js' + : newInstrumentationClientFileName, + getInstrumentationClientHookCopyPasteSnippet( + selectedProject.keys[0].dsn.public, + selectedFeatures, + ), + ); + } + }); + await traceStep('setup-next-config', async () => { const withSentryConfigOptionsTemplate = getWithSentryConfigOptionsTemplate({ orgSlug: selectedProject.organization.slug, diff --git a/src/nextjs/templates.ts b/src/nextjs/templates.ts index f6f990185..01c5f853e 100644 --- a/src/nextjs/templates.ts +++ b/src/nextjs/templates.ts @@ -127,9 +127,9 @@ function getClientIntegrationsSnippet(features: { replay: boolean }) { return ''; } -export function getSentryConfigContents( +export function getSentryServersideConfigContents( dsn: string, - config: 'server' | 'client' | 'edge', + config: 'server' | 'edge', selectedFeaturesMap: { replay: boolean; performance: boolean; @@ -139,10 +139,6 @@ export function getSentryConfigContents( if (config === 'server') { primer = `// This file configures the initialization of Sentry on the server. // The config you add here will be used whenever the server handles a request. -// https://docs.sentry.io/platforms/javascript/guides/nextjs/`; - } else if (config === 'client') { - primer = `// This file configures the initialization of Sentry on the client. -// The config you add here will be used whenever a users loads a page in their browser. // https://docs.sentry.io/platforms/javascript/guides/nextjs/`; } else if (config === 'edge') { primer = `// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). @@ -151,14 +147,43 @@ export function getSentryConfigContents( // https://docs.sentry.io/platforms/javascript/guides/nextjs/`; } + let performanceOptions = ''; + if (selectedFeaturesMap.performance) { + performanceOptions += ` + + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 1,`; + } + + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + return `${primer} + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: "${dsn}",${performanceOptions} + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, +}); +`; +} + +export function getInstrumentationClientFileContents( + dsn: string, + selectedFeaturesMap: { + replay: boolean; + performance: boolean; + }, +): string { const integrationsOptions = getClientIntegrationsSnippet({ - replay: config === 'client' && selectedFeaturesMap.replay, + replay: selectedFeaturesMap.replay, }); let replayOptions = ''; - if (config === 'client') { - if (selectedFeaturesMap.replay) { - replayOptions += ` + + if (selectedFeaturesMap.replay) { + replayOptions += ` // Define how likely Replay events are sampled. // This sets the sample rate to be 10%. You may want this to be 100% while @@ -167,7 +192,6 @@ export function getSentryConfigContents( // Define how likely Replay events are sampled when an error occurs. replaysOnErrorSampleRate: 1.0,`; - } } let performanceOptions = ''; @@ -178,8 +202,9 @@ export function getSentryConfigContents( tracesSampleRate: 1,`; } - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - return `${primer} + return `// This file configures the initialization of Sentry on the client. +// The added config here will be used whenever a users loads a page in their browser. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ import * as Sentry from "@sentry/nextjs"; @@ -188,8 +213,7 @@ Sentry.init({ // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, -}); -`; +});`; } export function getSentryExamplePageContents(options: { @@ -229,7 +253,7 @@ export default function Page() {
- Click the button below, and view the sample error on the Sentry Issues Page. + Click the button below, and view the sample error on the Sentry Issues Page. For more details about setting up Sentry, read our docs.
@@ -500,6 +524,18 @@ ${plus('export const onRequestError = Sentry.captureRequestError;')} }); } +export function getInstrumentationClientHookCopyPasteSnippet( + dsn: string, + selectedFeaturesMap: { + replay: boolean; + performance: boolean; + }, +) { + return makeCodeSnippet(true, (unchanged, plus) => { + return plus(getInstrumentationClientFileContents(dsn, selectedFeaturesMap)); + }); +} + export function getSentryDefaultGlobalErrorPage(isTs: boolean) { return isTs ? `"use client"; @@ -601,7 +637,7 @@ export default function GlobalError(${chalk.green('{ error }')}) { export const getRootLayout = ( isTs: boolean, ) => `// This file was generated by the Sentry wizard because we couldn't find a root layout file. -// You can delete this file at any time. +// You can delete this file at any time. export const metadata = { title: 'Sentry NextJS Example', diff --git a/test/nextjs/templates.test.ts b/test/nextjs/templates.test.ts index 4d5916ac8..170b5bd0a 100644 --- a/test/nextjs/templates.test.ts +++ b/test/nextjs/templates.test.ts @@ -1,117 +1,115 @@ import { getRootLayout, - getSentryConfigContents, + getSentryServersideConfigContents, + getInstrumentationClientFileContents, getWithSentryConfigOptionsTemplate, } from '../../src/nextjs/templates'; describe('Next.js code templates', () => { - describe('getSentryConfigContents', () => { - describe('client-side', () => { - it('generates client-side Sentry config with all features enabled', () => { - const template = getSentryConfigContents('my-dsn', 'client', { - performance: true, - replay: true, - }); + describe('getInstrumentationClientFileContents', () => { + it('generates client-side Sentry config with all features enabled', () => { + const template = getInstrumentationClientFileContents('my-dsn', { + performance: true, + replay: true, + }); - expect(template).toMatchInlineSnapshot(` - "// This file configures the initialization of Sentry on the client. - // The config you add here will be used whenever a users loads a page in their browser. - // https://docs.sentry.io/platforms/javascript/guides/nextjs/ + expect(template).toMatchInlineSnapshot(` + "// This file configures the initialization of Sentry on the client. + // The added config here will be used whenever a users loads a page in their browser. + // https://docs.sentry.io/platforms/javascript/guides/nextjs/ - import * as Sentry from "@sentry/nextjs"; + import * as Sentry from "@sentry/nextjs"; - Sentry.init({ - dsn: "my-dsn", + Sentry.init({ + dsn: "my-dsn", - // Add optional integrations for additional features - integrations: [ - Sentry.replayIntegration(), - ], + // Add optional integrations for additional features + integrations: [ + Sentry.replayIntegration(), + ], - // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. - tracesSampleRate: 1, + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 1, - // Define how likely Replay events are sampled. - // This sets the sample rate to be 10%. You may want this to be 100% while - // in development and sample at a lower rate in production - replaysSessionSampleRate: 0.1, + // Define how likely Replay events are sampled. + // This sets the sample rate to be 10%. You may want this to be 100% while + // in development and sample at a lower rate in production + replaysSessionSampleRate: 0.1, - // Define how likely Replay events are sampled when an error occurs. - replaysOnErrorSampleRate: 1.0, + // Define how likely Replay events are sampled when an error occurs. + replaysOnErrorSampleRate: 1.0, - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, - }); - " - `); + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + });" + `); + }); + + it('generates client-side Sentry config with performance monitoring disabled', () => { + const template = getInstrumentationClientFileContents('my-dsn', { + performance: false, + replay: true, }); - it('generates client-side Sentry config with performance monitoring disabled', () => { - const template = getSentryConfigContents('my-dsn', 'client', { - performance: false, - replay: true, - }); + expect(template).toMatchInlineSnapshot(` + "// This file configures the initialization of Sentry on the client. + // The added config here will be used whenever a users loads a page in their browser. + // https://docs.sentry.io/platforms/javascript/guides/nextjs/ - expect(template).toMatchInlineSnapshot(` - "// This file configures the initialization of Sentry on the client. - // The config you add here will be used whenever a users loads a page in their browser. - // https://docs.sentry.io/platforms/javascript/guides/nextjs/ + import * as Sentry from "@sentry/nextjs"; - import * as Sentry from "@sentry/nextjs"; + Sentry.init({ + dsn: "my-dsn", - Sentry.init({ - dsn: "my-dsn", + // Add optional integrations for additional features + integrations: [ + Sentry.replayIntegration(), + ], - // Add optional integrations for additional features - integrations: [ - Sentry.replayIntegration(), - ], + // Define how likely Replay events are sampled. + // This sets the sample rate to be 10%. You may want this to be 100% while + // in development and sample at a lower rate in production + replaysSessionSampleRate: 0.1, - // Define how likely Replay events are sampled. - // This sets the sample rate to be 10%. You may want this to be 100% while - // in development and sample at a lower rate in production - replaysSessionSampleRate: 0.1, + // Define how likely Replay events are sampled when an error occurs. + replaysOnErrorSampleRate: 1.0, - // Define how likely Replay events are sampled when an error occurs. - replaysOnErrorSampleRate: 1.0, + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + });" + `); + }); - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, - }); - " - `); + it('generates client-side Sentry config with session replay disabled', () => { + const template = getInstrumentationClientFileContents('my-dsn', { + performance: true, + replay: false, }); - it('generates client-side Sentry config with session replay disabled', () => { - const template = getSentryConfigContents('my-dsn', 'client', { - performance: true, - replay: false, - }); + expect(template).toMatchInlineSnapshot(` + "// This file configures the initialization of Sentry on the client. + // The added config here will be used whenever a users loads a page in their browser. + // https://docs.sentry.io/platforms/javascript/guides/nextjs/ - expect(template).toMatchInlineSnapshot(` - "// This file configures the initialization of Sentry on the client. - // The config you add here will be used whenever a users loads a page in their browser. - // https://docs.sentry.io/platforms/javascript/guides/nextjs/ + import * as Sentry from "@sentry/nextjs"; - import * as Sentry from "@sentry/nextjs"; + Sentry.init({ + dsn: "my-dsn", - Sentry.init({ - dsn: "my-dsn", + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 1, - // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. - tracesSampleRate: 1, - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, - }); - " - `); - }); + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + });" + `); }); + }); + describe('getSentryServersideConfigContents', () => { describe('server-side', () => { it('generates server-side Sentry config with all features enabled', () => { - const template = getSentryConfigContents('my-dsn', 'server', { + const template = getSentryServersideConfigContents('my-dsn', 'server', { performance: true, replay: true, }); @@ -137,7 +135,7 @@ describe('Next.js code templates', () => { }); it('generates server-side Sentry config with performance monitoring disabled', () => { - const template = getSentryConfigContents('my-dsn', 'server', { + const template = getSentryServersideConfigContents('my-dsn', 'server', { performance: false, replay: true, }); @@ -160,7 +158,7 @@ describe('Next.js code templates', () => { }); it('generates server-side Sentry config with spotlight disabled', () => { - const template = getSentryConfigContents('my-dsn', 'server', { + const template = getSentryServersideConfigContents('my-dsn', 'server', { performance: true, replay: true, }); @@ -188,7 +186,7 @@ describe('Next.js code templates', () => { describe('edge', () => { it('generates edge Sentry config with all features enabled', () => { - const template = getSentryConfigContents('my-dsn', 'edge', { + const template = getSentryServersideConfigContents('my-dsn', 'edge', { performance: true, replay: true, }); @@ -215,7 +213,7 @@ describe('Next.js code templates', () => { }); it('generates edge Sentry config with performance monitoring disabled', () => { - const template = getSentryConfigContents('my-dsn', 'edge', { + const template = getSentryServersideConfigContents('my-dsn', 'edge', { performance: false, replay: true, }); @@ -379,7 +377,7 @@ describe('Next.js code templates', () => { it('generates a root layout component with types', () => { expect(getRootLayout(true)).toMatchInlineSnapshot(` "// This file was generated by the Sentry wizard because we couldn't find a root layout file. - // You can delete this file at any time. + // You can delete this file at any time. export const metadata = { title: 'Sentry NextJS Example', @@ -403,7 +401,7 @@ describe('Next.js code templates', () => { it('generates a root layout component without types', () => { expect(getRootLayout(false)).toMatchInlineSnapshot(` "// This file was generated by the Sentry wizard because we couldn't find a root layout file. - // You can delete this file at any time. + // You can delete this file at any time. export const metadata = { title: 'Sentry NextJS Example',