From 07ca085ea4e0fb469265060830b8153a334f2eaa Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 15 Nov 2024 16:24:54 +0100 Subject: [PATCH 1/2] feat(nuxt): Add sdk module to nuxt config and create client and server config files --- src/nuxt/nuxt-wizard.ts | 21 +++- src/nuxt/sdk-setup.ts | 175 ++++++++++++++++++++++++++++++ src/nuxt/templates.ts | 82 ++++++++++++++ test/nuxt/templates.test.ts | 206 ++++++++++++++++++++++++++++++++++++ 4 files changed, 479 insertions(+), 5 deletions(-) create mode 100644 src/nuxt/sdk-setup.ts create mode 100644 src/nuxt/templates.ts create mode 100644 test/nuxt/templates.test.ts diff --git a/src/nuxt/nuxt-wizard.ts b/src/nuxt/nuxt-wizard.ts index d0414811..e03cb9e8 100644 --- a/src/nuxt/nuxt-wizard.ts +++ b/src/nuxt/nuxt-wizard.ts @@ -3,7 +3,7 @@ import * as clack from '@clack/prompts'; import * as Sentry from '@sentry/node'; import { lt, minVersion } from 'semver'; import type { WizardOptions } from '../utils/types'; -import { withTelemetry } from '../telemetry'; +import { traceStep, withTelemetry } from '../telemetry'; import { abort, abortIfCancelled, @@ -16,6 +16,7 @@ import { printWelcome, } from '../utils/clack-utils'; import { getPackageVersion, hasPackageInstalled } from '../utils/package-json'; +import { addSDKModule, getNuxtConfig, createConfigFiles } from './sdk-setup'; export function runNuxtWizard(options: WizardOptions) { return withTelemetry( @@ -71,10 +72,8 @@ export async function runNuxtWizardWithTelemetry( } } - const { authToken } = await getOrAskForProjectData( - options, - 'javascript-nuxt', - ); + const { authToken, selectedProject, selfHosted, sentryUrl } = + await getOrAskForProjectData(options, 'javascript-nuxt'); const sdkAlreadyInstalled = hasPackageInstalled('@sentry/nuxt', packageJson); Sentry.setTag('sdk-already-installed', sdkAlreadyInstalled); @@ -85,4 +84,16 @@ export async function runNuxtWizardWithTelemetry( }); await addDotEnvSentryBuildPluginFile(authToken); + + const nuxtConfig = await traceStep('load-nuxt-config', getNuxtConfig); + + await traceStep('configure-sdk', async () => { + await addSDKModule(nuxtConfig, { + org: selectedProject.organization.slug, + project: selectedProject.slug, + url: selfHosted ? sentryUrl : undefined, + }); + + await createConfigFiles(selectedProject.keys[0].dsn.public); + }); } diff --git a/src/nuxt/sdk-setup.ts b/src/nuxt/sdk-setup.ts new file mode 100644 index 00000000..e7470583 --- /dev/null +++ b/src/nuxt/sdk-setup.ts @@ -0,0 +1,175 @@ +// @ts-expect-error - clack is ESM and TS complains about that. It works though +import clack from '@clack/prompts'; +import * as Sentry from '@sentry/node'; +import chalk from 'chalk'; +import fs from 'fs'; +// @ts-expect-error - magicast is ESM and TS complains about that. It works though +import { loadFile, generateCode } from 'magicast'; +// @ts-expect-error - magicast is ESM and TS complains about that. It works though +import { addNuxtModule } from 'magicast/helpers'; +import path from 'path'; +import { getDefaultNuxtConfig, getSentryConfigContents } from './templates'; +import { + abort, + abortIfCancelled, + featureSelectionPrompt, + isUsingTypeScript, +} from '../utils/clack-utils'; +import { traceStep } from '../telemetry'; + +const possibleNuxtConfig = [ + 'nuxt.config.js', + 'nuxt.config.mjs', + 'nuxt.config.cjs', + 'nuxt.config.ts', + 'nuxt.config.mts', + 'nuxt.config.cts', +]; + +export async function getNuxtConfig(): Promise { + let configFile = possibleNuxtConfig.find((fileName) => + fs.existsSync(path.join(process.cwd(), fileName)), + ); + + if (!configFile) { + clack.log.info('No Nuxt config file found, creating a new one.'); + Sentry.setTag('nuxt-config-strategy', 'create'); + // nuxt recommends its config to be .ts by default + configFile = 'nuxt.config.ts'; + + await fs.promises.writeFile( + path.join(process.cwd(), configFile), + getDefaultNuxtConfig(), + { encoding: 'utf-8', flag: 'w' }, + ); + + clack.log.success(`Created ${chalk.cyan('nuxt.config.ts')}.`); + } + + return path.join(process.cwd(), configFile); +} + +export async function addSDKModule( + config: string, + options: { org: string; project: string; url?: string }, +): Promise { + clack.log.info('Adding Sentry Nuxt Module to Nuxt config.'); + + try { + const mod = await loadFile(config); + + addNuxtModule(mod, '@sentry/nuxt/module', 'sentry', { + sourceMapsUploadOptions: { + org: options.org, + project: options.project, + ...(options.url && { url: options.url }), + }, + }); + addNuxtModule(mod, '@sentry/nuxt/module', 'sourcemap', { client: true }); + + const { code } = generateCode(mod); + + await fs.promises.writeFile(config, code, { encoding: 'utf-8', flag: 'w' }); + } catch (e: unknown) { + clack.log.error( + 'Error while adding the Sentry Nuxt Module to the Nuxt config.', + ); + clack.log.info( + chalk.dim( + typeof e === 'object' && e != null && 'toString' in e + ? e.toString() + : typeof e === 'string' + ? e + : 'Unknown error', + ), + ); + Sentry.captureException('Error while setting up the Nuxt SDK'); + await abort('Exiting Wizard'); + } +} + +export async function createConfigFiles(dsn: string) { + const selectedFeatures = await featureSelectionPrompt([ + { + id: 'performance', + prompt: `Do you want to enable ${chalk.bold( + 'Tracing', + )} to track the performance of your application?`, + enabledHint: 'recommended', + }, + { + id: 'replay', + prompt: `Do you want to enable ${chalk.bold( + 'Sentry Session Replay', + )} to get a video-like reproduction of errors during a user session?`, + enabledHint: 'recommended, but increases bundle size', + }, + ] as const); + + const typeScriptDetected = isUsingTypeScript(); + + const configVariants = ['server', 'client'] as const; + + for (const configVariant of configVariants) { + await traceStep(`create-sentry-${configVariant}-config`, async () => { + const jsConfig = `sentry.${configVariant}.config.js`; + const tsConfig = `sentry.${configVariant}.config.ts`; + + const jsConfigExists = fs.existsSync(path.join(process.cwd(), jsConfig)); + const tsConfigExists = fs.existsSync(path.join(process.cwd(), tsConfig)); + + let shouldWriteFile = true; + + if (jsConfigExists || tsConfigExists) { + const existingConfigs = []; + + if (jsConfigExists) { + existingConfigs.push(jsConfig); + } + + if (tsConfigExists) { + existingConfigs.push(tsConfig); + } + + const overwriteExistingConfigs = await abortIfCancelled( + clack.confirm({ + message: `Found existing Sentry ${configVariant} config (${existingConfigs.join( + ', ', + )}). Overwrite ${existingConfigs.length > 1 ? 'them' : 'it'}?`, + }), + ); + Sentry.setTag( + `overwrite-${configVariant}-config`, + overwriteExistingConfigs, + ); + + shouldWriteFile = overwriteExistingConfigs; + + if (overwriteExistingConfigs) { + if (jsConfigExists) { + fs.unlinkSync(path.join(process.cwd(), jsConfig)); + clack.log.warn(`Removed existing ${chalk.cyan(jsConfig)}.`); + } + if (tsConfigExists) { + fs.unlinkSync(path.join(process.cwd(), tsConfig)); + clack.log.warn(`Removed existing ${chalk.cyan(tsConfig)}.`); + } + } + } + + if (shouldWriteFile) { + await fs.promises.writeFile( + path.join(process.cwd(), typeScriptDetected ? tsConfig : jsConfig), + getSentryConfigContents(dsn, configVariant, selectedFeatures), + { encoding: 'utf8', flag: 'w' }, + ); + clack.log.success( + `Created fresh ${chalk.cyan( + typeScriptDetected ? tsConfig : jsConfig, + )}.`, + ); + Sentry.setTag(`created-${configVariant}-config`, true); + } + }); + } +} diff --git a/src/nuxt/templates.ts b/src/nuxt/templates.ts new file mode 100644 index 00000000..29dfb847 --- /dev/null +++ b/src/nuxt/templates.ts @@ -0,0 +1,82 @@ +type SelectedSentryFeatures = { + performance: boolean; + replay: boolean; +}; + +export function getDefaultNuxtConfig(): string { + return `// https://nuxt.com/docs/api/configuration/nuxt-config +export default defineNuxtConfig({ + compatibilityDate: '2024-04-03', + devtools: { enabled: true } +}) +`; +} + +export function getSentryConfigContents( + dsn: string, + config: 'client' | 'server', + selectedFeatures: SelectedSentryFeatures, +): string { + if (config === 'client') { + return getSentryClientConfigContents(dsn, selectedFeatures); + } + + return getSentryServerConfigContents(dsn, selectedFeatures); +} + +const performanceConfig = [ + '', + ' // We recommend adjusting this value in production, or using tracesSampler', + ' // for finer control', + ' tracesSampleRate: 1.0,', +]; + +const replayConfig = [ + '', + ' // 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,', + ' ', + ' // If the entire session is not sampled, use the below sample rate to sample', + ' // sessions when an error occurs.', + ' replaysOnErrorSampleRate: 1.0,', + ' ', + " // If you don't want to use Session Replay, just remove the line below:", + ' integrations: [Sentry.replayIntegration()],', +]; + +function getSentryClientConfigContents( + dsn: string, + selectedFeatures: SelectedSentryFeatures, +): string { + return `import * as Sentry from "@sentry/nuxt"; + +Sentry.init({ + // If set up, you can use your runtime config here + // dsn: useRuntimeConfig().public.sentry.dsn, + dsn: "${dsn}", + ${selectedFeatures.performance ? performanceConfig.join('\n') : ''}${ + selectedFeatures.performance && selectedFeatures.replay ? ' ' : '' + }${selectedFeatures.replay ? replayConfig.join('\n') : ''} + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, +}); +`; +} + +function getSentryServerConfigContents( + dsn: string, + selectedFeatures: Omit, +): string { + return `import * as Sentry from "@sentry/nuxt"; + +Sentry.init({ + dsn: "${dsn}", + ${selectedFeatures.performance ? performanceConfig.join('\n') : ''} + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, +}); +`; +} diff --git a/test/nuxt/templates.test.ts b/test/nuxt/templates.test.ts new file mode 100644 index 00000000..48fa9038 --- /dev/null +++ b/test/nuxt/templates.test.ts @@ -0,0 +1,206 @@ +import { + getDefaultNuxtConfig, + getSentryConfigContents, +} from '../../src/nuxt/templates'; + +describe('Nuxt code templates', () => { + describe('getDefaultNuxtConfig', () => { + it('returns a default nuxt config', () => { + expect(getDefaultNuxtConfig()).toMatchInlineSnapshot(` + "// https://nuxt.com/docs/api/configuration/nuxt-config + export default defineNuxtConfig({ + compatibilityDate: '2024-04-03', + devtools: { enabled: true } + }) + " +`); + }); + }); + + describe('getSentryConfigContents', () => { + describe('client config', () => { + it('generates Sentry config with all features enabled', () => { + const template = getSentryConfigContents( + 'https://sentry.io/123', + 'client', + { + performance: true, + replay: true, + }, + ); + + expect(template).toMatchInlineSnapshot(` + "import * as Sentry from "@sentry/nuxt"; + + Sentry.init({ + // If set up, you can use your runtime config here + // dsn: useRuntimeConfig().public.sentry.dsn, + dsn: "https://sentry.io/123", + + // We recommend adjusting this value in production, or using tracesSampler + // for finer control + tracesSampleRate: 1.0, + // 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, + + // If the entire session is not sampled, use the below sample rate to sample + // sessions when an error occurs. + replaysOnErrorSampleRate: 1.0, + + // If you don't want to use Session Replay, just remove the line below: + integrations: [Sentry.replayIntegration()], + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + }); + " + `); + }); + + it('generates Sentry config with performance monitoring disabled', () => { + const template = getSentryConfigContents( + 'https://sentry.io/123', + 'client', + { + performance: false, + replay: true, + }, + ); + + expect(template).toMatchInlineSnapshot(` + "import * as Sentry from "@sentry/nuxt"; + + Sentry.init({ + // If set up, you can use your runtime config here + // dsn: useRuntimeConfig().public.sentry.dsn, + dsn: "https://sentry.io/123", + + // 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, + + // If the entire session is not sampled, use the below sample rate to sample + // sessions when an error occurs. + replaysOnErrorSampleRate: 1.0, + + // If you don't want to use Session Replay, just remove the line below: + integrations: [Sentry.replayIntegration()], + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + }); + " + `); + }); + + it('generates Sentry config with session replay disabled', () => { + const template = getSentryConfigContents( + 'https://sentry.io/123', + 'client', + { + performance: true, + replay: false, + }, + ); + + expect(template).toMatchInlineSnapshot(` + "import * as Sentry from "@sentry/nuxt"; + + Sentry.init({ + // If set up, you can use your runtime config here + // dsn: useRuntimeConfig().public.sentry.dsn, + dsn: "https://sentry.io/123", + + // We recommend adjusting this value in production, or using tracesSampler + // for finer control + tracesSampleRate: 1.0, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + }); + " + `); + }); + + it('generates Sentry config with performance monitoring and session replay disabled', () => { + const template = getSentryConfigContents( + 'https://sentry.io/123', + 'client', + { + performance: false, + replay: false, + }, + ); + + expect(template).toMatchInlineSnapshot(` + "import * as Sentry from "@sentry/nuxt"; + + Sentry.init({ + // If set up, you can use your runtime config here + // dsn: useRuntimeConfig().public.sentry.dsn, + dsn: "https://sentry.io/123", + + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + }); + " + `); + }); + }); + + describe('server config', () => { + it('generates Sentry config with all features enabled', () => { + const template = getSentryConfigContents( + 'https://sentry.io/123', + 'server', + { + performance: true, + replay: true, + }, + ); + + expect(template).toMatchInlineSnapshot(` + "import * as Sentry from "@sentry/nuxt"; + + Sentry.init({ + dsn: "https://sentry.io/123", + + // We recommend adjusting this value in production, or using tracesSampler + // for finer control + tracesSampleRate: 1.0, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + }); + " + `); + }); + + it('generates Sentry config with performance monitoring disabled', () => { + const template = getSentryConfigContents( + 'https://sentry.io/123', + 'server', + { + performance: false, + replay: true, + }, + ); + + expect(template).toMatchInlineSnapshot(` + "import * as Sentry from "@sentry/nuxt"; + + Sentry.init({ + dsn: "https://sentry.io/123", + + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + }); + " + `); + }); + }); + }); +}); From 2598b72f814c204771231985880be48a206e7585 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 21 Nov 2024 19:00:23 +0100 Subject: [PATCH 2/2] Log snippets when user denies overwriting their configs --- lib/Constants.ts | 1 + src/nuxt/sdk-setup.ts | 18 ++++++++- src/nuxt/templates.ts | 77 ++++++++++++++++++++++++------------- test/nuxt/templates.test.ts | 17 ++++---- 4 files changed, 76 insertions(+), 37 deletions(-) diff --git a/lib/Constants.ts b/lib/Constants.ts index 27f515c0..4350d9a0 100644 --- a/lib/Constants.ts +++ b/lib/Constants.ts @@ -6,6 +6,7 @@ export enum Integration { cordova = 'cordova', electron = 'electron', nextjs = 'nextjs', + nuxt = 'nuxt', remix = 'remix', sveltekit = 'sveltekit', sourcemaps = 'sourcemaps', diff --git a/src/nuxt/sdk-setup.ts b/src/nuxt/sdk-setup.ts index e7470583..0d0ce285 100644 --- a/src/nuxt/sdk-setup.ts +++ b/src/nuxt/sdk-setup.ts @@ -8,7 +8,11 @@ import { loadFile, generateCode } from 'magicast'; // @ts-expect-error - magicast is ESM and TS complains about that. It works though import { addNuxtModule } from 'magicast/helpers'; import path from 'path'; -import { getDefaultNuxtConfig, getSentryConfigContents } from './templates'; +import { + getConfigBody, + getDefaultNuxtConfig, + getSentryConfigContents, +} from './templates'; import { abort, abortIfCancelled, @@ -169,6 +173,18 @@ export async function createConfigFiles(dsn: string) { )}.`, ); Sentry.setTag(`created-${configVariant}-config`, true); + } else { + clack.log.info( + `Okay, here are the changes your ${chalk.cyan( + typeScriptDetected ? tsConfig : jsConfig, + )} should contain:`, + ); + // eslint-disable-next-line no-console + console.log( + '\n\n ' + + getConfigBody(dsn, configVariant, selectedFeatures) + + '\n\n', + ); } }); } diff --git a/src/nuxt/templates.ts b/src/nuxt/templates.ts index 29dfb847..dd445ce8 100644 --- a/src/nuxt/templates.ts +++ b/src/nuxt/templates.ts @@ -24,26 +24,53 @@ export function getSentryConfigContents( return getSentryServerConfigContents(dsn, selectedFeatures); } -const performanceConfig = [ - '', - ' // We recommend adjusting this value in production, or using tracesSampler', - ' // for finer control', - ' tracesSampleRate: 1.0,', -]; +const featuresConfigMap: Record = { + performance: [ + ' // We recommend adjusting this value in production, or using tracesSampler', + ' // for finer control', + ' tracesSampleRate: 1.0,', + ].join('\n'), + replay: [ + ' // 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,', + ' ', + ' // If the entire session is not sampled, use the below sample rate to sample', + ' // sessions when an error occurs.', + ' replaysOnErrorSampleRate: 1.0,', + ' ', + " // If you don't want to use Session Replay, just remove the line below:", + ' integrations: [Sentry.replayIntegration()],', + ].join('\n'), +}; + +const featuresMap: Record< + 'client' | 'server', + Array +> = { + client: ['performance', 'replay'], + server: ['performance'], +}; -const replayConfig = [ - '', - ' // 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,', - ' ', - ' // If the entire session is not sampled, use the below sample rate to sample', - ' // sessions when an error occurs.', - ' replaysOnErrorSampleRate: 1.0,', - ' ', - " // If you don't want to use Session Replay, just remove the line below:", - ' integrations: [Sentry.replayIntegration()],', -]; +export function getConfigBody( + dsn: string, + variant: 'client' | 'server', + selectedFeatures: SelectedSentryFeatures, +) { + return [ + `dsn: "${dsn}",`, + Object.entries(selectedFeatures) + .map(([feature, activated]: [keyof SelectedSentryFeatures, boolean]) => { + return featuresMap[variant].includes(feature) && activated + ? featuresConfigMap[feature] + : null; + }) + .filter(Boolean) + .join('\n\n'), + ] + .filter(Boolean) + .join('\n\n'); +} function getSentryClientConfigContents( dsn: string, @@ -54,10 +81,7 @@ function getSentryClientConfigContents( Sentry.init({ // If set up, you can use your runtime config here // dsn: useRuntimeConfig().public.sentry.dsn, - dsn: "${dsn}", - ${selectedFeatures.performance ? performanceConfig.join('\n') : ''}${ - selectedFeatures.performance && selectedFeatures.replay ? ' ' : '' - }${selectedFeatures.replay ? replayConfig.join('\n') : ''} + ${getConfigBody(dsn, 'client', selectedFeatures)} // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, @@ -67,13 +91,12 @@ Sentry.init({ function getSentryServerConfigContents( dsn: string, - selectedFeatures: Omit, + selectedFeatures: SelectedSentryFeatures, ): string { return `import * as Sentry from "@sentry/nuxt"; -Sentry.init({ - dsn: "${dsn}", - ${selectedFeatures.performance ? performanceConfig.join('\n') : ''} +Sentry.init({ + ${getConfigBody(dsn, 'server', selectedFeatures)} // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, diff --git a/test/nuxt/templates.test.ts b/test/nuxt/templates.test.ts index 48fa9038..e73f90a0 100644 --- a/test/nuxt/templates.test.ts +++ b/test/nuxt/templates.test.ts @@ -36,10 +36,11 @@ describe('Nuxt code templates', () => { // If set up, you can use your runtime config here // dsn: useRuntimeConfig().public.sentry.dsn, dsn: "https://sentry.io/123", - + // We recommend adjusting this value in production, or using tracesSampler // for finer control - tracesSampleRate: 1.0, + tracesSampleRate: 1.0, + // 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, @@ -75,7 +76,7 @@ describe('Nuxt code templates', () => { // If set up, you can use your runtime config here // dsn: useRuntimeConfig().public.sentry.dsn, dsn: "https://sentry.io/123", - + // 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, @@ -111,7 +112,7 @@ describe('Nuxt code templates', () => { // If set up, you can use your runtime config here // dsn: useRuntimeConfig().public.sentry.dsn, dsn: "https://sentry.io/123", - + // We recommend adjusting this value in production, or using tracesSampler // for finer control tracesSampleRate: 1.0, @@ -141,7 +142,6 @@ describe('Nuxt code templates', () => { // dsn: useRuntimeConfig().public.sentry.dsn, dsn: "https://sentry.io/123", - // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, }); @@ -164,9 +164,9 @@ describe('Nuxt code templates', () => { expect(template).toMatchInlineSnapshot(` "import * as Sentry from "@sentry/nuxt"; - Sentry.init({ + Sentry.init({ dsn: "https://sentry.io/123", - + // We recommend adjusting this value in production, or using tracesSampler // for finer control tracesSampleRate: 1.0, @@ -191,10 +191,9 @@ describe('Nuxt code templates', () => { expect(template).toMatchInlineSnapshot(` "import * as Sentry from "@sentry/nuxt"; - Sentry.init({ + Sentry.init({ dsn: "https://sentry.io/123", - // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, });