diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e34f455..b9c54d9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,13 @@ - feat: Pin JS SDK versions to v8 (#712) - Remove enableTracing for Cocoa ([#715](https://github.com/getsentry/sentry-wizard/pull/715)) +- feat(nuxt): Add nuxt wizard ([#719](https://github.com/getsentry/sentry-wizard/pull/719)) + +Set up the Sentry Nuxt SDK in your app with one command: +```sh +npx @sentry/wizard@latest -i nuxt +``` ## 3.34.4 diff --git a/e2e-tests/test-applications/nuxt-3-test-app/.gitignore b/e2e-tests/test-applications/nuxt-3-test-app/.gitignore new file mode 100644 index 00000000..4a7f73a2 --- /dev/null +++ b/e2e-tests/test-applications/nuxt-3-test-app/.gitignore @@ -0,0 +1,24 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules + +# Logs +logs +*.log + +# Misc +.DS_Store +.fleet +.idea + +# Local env files +.env +.env.* +!.env.example diff --git a/e2e-tests/test-applications/nuxt-3-test-app/README.md b/e2e-tests/test-applications/nuxt-3-test-app/README.md new file mode 100644 index 00000000..25b58212 --- /dev/null +++ b/e2e-tests/test-applications/nuxt-3-test-app/README.md @@ -0,0 +1,75 @@ +# Nuxt Minimal Starter + +Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. + +## Setup + +Make sure to install dependencies: + +```bash +# npm +npm install + +# pnpm +pnpm install + +# yarn +yarn install + +# bun +bun install +``` + +## Development Server + +Start the development server on `http://localhost:3000`: + +```bash +# npm +npm run dev + +# pnpm +pnpm dev + +# yarn +yarn dev + +# bun +bun run dev +``` + +## Production + +Build the application for production: + +```bash +# npm +npm run build + +# pnpm +pnpm build + +# yarn +yarn build + +# bun +bun run build +``` + +Locally preview production build: + +```bash +# npm +npm run preview + +# pnpm +pnpm preview + +# yarn +yarn preview + +# bun +bun run preview +``` + +Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. diff --git a/e2e-tests/test-applications/nuxt-3-test-app/nuxt.config.ts b/e2e-tests/test-applications/nuxt-3-test-app/nuxt.config.ts new file mode 100644 index 00000000..8ae12e6c --- /dev/null +++ b/e2e-tests/test-applications/nuxt-3-test-app/nuxt.config.ts @@ -0,0 +1,5 @@ +// https://nuxt.com/docs/api/configuration/nuxt-config +export default defineNuxtConfig({ + compatibilityDate: '2024-11-01', + devtools: { enabled: true } +}) diff --git a/e2e-tests/test-applications/nuxt-3-test-app/package.json b/e2e-tests/test-applications/nuxt-3-test-app/package.json new file mode 100644 index 00000000..3ca1ecf9 --- /dev/null +++ b/e2e-tests/test-applications/nuxt-3-test-app/package.json @@ -0,0 +1,18 @@ +{ + "name": "nuxt-app", + "private": true, + "type": "module", + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev", + "generate": "nuxt generate", + "preview": "nuxt preview", + "postinstall": "nuxt prepare", + "start": "node .output/server/index.mjs" + }, + "dependencies": { + "nuxt": "^3.14.1592", + "vue": "latest", + "vue-router": "latest" + } +} diff --git a/e2e-tests/test-applications/nuxt-3-test-app/public/favicon.ico b/e2e-tests/test-applications/nuxt-3-test-app/public/favicon.ico new file mode 100644 index 00000000..18993ad9 Binary files /dev/null and b/e2e-tests/test-applications/nuxt-3-test-app/public/favicon.ico differ diff --git a/e2e-tests/test-applications/nuxt-3-test-app/public/robots.txt b/e2e-tests/test-applications/nuxt-3-test-app/public/robots.txt new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/e2e-tests/test-applications/nuxt-3-test-app/public/robots.txt @@ -0,0 +1 @@ + diff --git a/e2e-tests/test-applications/nuxt-3-test-app/server/tsconfig.json b/e2e-tests/test-applications/nuxt-3-test-app/server/tsconfig.json new file mode 100644 index 00000000..b9ed69c1 --- /dev/null +++ b/e2e-tests/test-applications/nuxt-3-test-app/server/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../.nuxt/tsconfig.server.json" +} diff --git a/e2e-tests/test-applications/nuxt-3-test-app/tsconfig.json b/e2e-tests/test-applications/nuxt-3-test-app/tsconfig.json new file mode 100644 index 00000000..a746f2a7 --- /dev/null +++ b/e2e-tests/test-applications/nuxt-3-test-app/tsconfig.json @@ -0,0 +1,4 @@ +{ + // https://nuxt.com/docs/guide/concepts/typescript + "extends": "./.nuxt/tsconfig.json" +} diff --git a/e2e-tests/test-applications/nuxt-4-test-app b/e2e-tests/test-applications/nuxt-4-test-app new file mode 160000 index 00000000..c949347d --- /dev/null +++ b/e2e-tests/test-applications/nuxt-4-test-app @@ -0,0 +1 @@ +Subproject commit c949347d3e2b8c163e5da1ff7d7bdb5d2c5658ce diff --git a/e2e-tests/tests/nuxt-3.test.ts b/e2e-tests/tests/nuxt-3.test.ts new file mode 100644 index 00000000..7c9fa498 --- /dev/null +++ b/e2e-tests/tests/nuxt-3.test.ts @@ -0,0 +1,169 @@ +import * as path from 'path'; +import { + checkEnvBuildPlugin, + checkFileContents, + checkFileExists, + checkIfBuilds, + checkIfRunsOnProdMode, + checkPackageJson, + cleanupGit, + KEYS, + revertLocalChanges, + startWizardInstance, + TEST_ARGS, +} from '../utils'; +import { Integration } from '../../lib/Constants'; + +describe('Nuxt-3', () => { + const projectDir = path.resolve( + __dirname, + '../test-applications/nuxt-3-test-app', + ); + + beforeAll(async () => { + await runWizardOnNuxtProject(projectDir); + }); + + afterAll(() => { + revertLocalChanges(projectDir); + cleanupGit(projectDir); + }); + + testNuxtProjectSetup(projectDir); + + testNuxtProjectConfigs(projectDir); + + testNuxtProjectBuildsAndRuns(projectDir); +}); + +async function runWizardOnNuxtProject(projectDir: string): Promise { + const integration = Integration.nuxt; + + const wizardInstance = startWizardInstance(integration, projectDir); + const packageManagerPrompted = await wizardInstance.waitForOutput( + 'Please select your package manager.', + ); + + const tracingOptionPrompted = + packageManagerPrompted && + (await wizardInstance.sendStdinAndWaitForOutput( + // Selecting `yarn` as the package manager + [KEYS.DOWN, KEYS.ENTER], + // "Do you want to enable Tracing", sometimes doesn't work as `Tracing` can be printed in bold. + 'to track the performance of your application?', + { + timeout: 240_000, + }, + )); + + const replayOptionPrompted = + tracingOptionPrompted && + (await wizardInstance.sendStdinAndWaitForOutput( + [KEYS.ENTER], + // "Do you want to enable Sentry Session Replay", sometimes doesn't work as `Sentry Session Replay` can be printed in bold. + 'to get a video-like reproduction of errors during a user session?', + )); + + replayOptionPrompted && + (await wizardInstance.sendStdinAndWaitForOutput( + [KEYS.ENTER], + 'Do you want to create an example page', + { + optional: true, + }, + )); + + await wizardInstance.sendStdinAndWaitForOutput( + [KEYS.ENTER, KEYS.ENTER], + 'Successfully installed the Sentry Nuxt SDK!', + ); + + wizardInstance.kill(); +} + +function testNuxtProjectSetup(projectDir: string) { + const integration = Integration.nuxt; + + test('package.json is updated correctly', () => { + checkPackageJson(projectDir, integration); + }); + + test('.env-sentry-build-plugin is created and contains the auth token', () => { + checkEnvBuildPlugin(projectDir); + }); + + test('config files created', () => { + checkFileExists(`${projectDir}/sentry.server.config.ts`); + checkFileExists(`${projectDir}/sentry.client.config.ts`); + }); + + test('example page exists', () => { + checkFileExists(`${projectDir}/pages/sentry-example-page.vue`); + checkFileExists(`${projectDir}/server/api/sentry-example-api.ts`); + }); +} + +function testNuxtProjectConfigs(projectDir: string) { + test('nuxt config contains sentry module', () => { + checkFileContents(path.resolve(projectDir, 'nuxt.config.ts'), [ + "modules: ['@sentry/nuxt/module'],", + 'sentry: {', + ' sourceMapsUploadOptions: {', + ` org: '${TEST_ARGS.ORG_SLUG}',`, + ` project: '${TEST_ARGS.PROJECT_SLUG}'`, + ' }', + '},', + 'sourcemap: {', + ' client: true', + '}', + ]); + }); + + test('sentry.client.config.ts contents', () => { + checkFileContents(path.resolve(projectDir, 'sentry.client.config.ts'), [ + 'import * as Sentry from "@sentry/nuxt";', + 'Sentry.init({', + ' // If set up, you can use your runtime config here', + ' // dsn: useRuntimeConfig().public.sentry.dsn,', + ` dsn: "${TEST_ARGS.PROJECT_DSN}",`, + ' // 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,', + '});', + ]); + }); + + test('sentry.server.config.ts contents', () => { + checkFileContents(path.resolve(projectDir, 'sentry.server.config.ts'), [ + 'import * as Sentry from "@sentry/nuxt";', + 'Sentry.init({', + ` dsn: "${TEST_ARGS.PROJECT_DSN}",`, + ' // 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,', + '});', + ]); + }); +} + +function testNuxtProjectBuildsAndRuns(projectDir: string) { + test('builds successfully', async () => { + await checkIfBuilds(projectDir, 'preview this build'); + }); + + test('runs on prod mode correctly', async () => { + await checkIfRunsOnProdMode(projectDir, 'Listening on'); + }); +} diff --git a/e2e-tests/tests/nuxt-4.test.ts b/e2e-tests/tests/nuxt-4.test.ts new file mode 100644 index 00000000..cf30ea9c --- /dev/null +++ b/e2e-tests/tests/nuxt-4.test.ts @@ -0,0 +1,168 @@ +import * as path from 'path'; +import { Integration } from '../../lib/Constants'; +import { cleanupGit, revertLocalChanges } from '../utils'; +import { + checkEnvBuildPlugin, + checkFileContents, + checkFileExists, + checkIfBuilds, + checkIfRunsOnProdMode, + checkPackageJson, + KEYS, + startWizardInstance, + TEST_ARGS, +} from '../utils'; + +describe('Nuxt-4', () => { + const projectDir = path.resolve( + __dirname, + '../test-applications/nuxt-4-test-app', + ); + + beforeAll(async () => { + await runWizardOnNuxtProject(projectDir); + }); + + afterAll(() => { + revertLocalChanges(projectDir); + cleanupGit(projectDir); + }); + + testNuxtProjectSetup(projectDir); + + testNuxtProjectConfigs(projectDir); + + testNuxtProjectBuildsAndRuns(projectDir); +}); + +async function runWizardOnNuxtProject(projectDir: string): Promise { + const integration = Integration.nuxt; + + const wizardInstance = startWizardInstance(integration, projectDir); + const packageManagerPrompted = await wizardInstance.waitForOutput( + 'Please select your package manager.', + ); + + const tracingOptionPrompted = + packageManagerPrompted && + (await wizardInstance.sendStdinAndWaitForOutput( + // Selecting `yarn` as the package manager + [KEYS.DOWN, KEYS.ENTER], + // "Do you want to enable Tracing", sometimes doesn't work as `Tracing` can be printed in bold. + 'to track the performance of your application?', + { + timeout: 240_000, + }, + )); + + const replayOptionPrompted = + tracingOptionPrompted && + (await wizardInstance.sendStdinAndWaitForOutput( + [KEYS.ENTER], + // "Do you want to enable Sentry Session Replay", sometimes doesn't work as `Sentry Session Replay` can be printed in bold. + 'to get a video-like reproduction of errors during a user session?', + )); + + replayOptionPrompted && + (await wizardInstance.sendStdinAndWaitForOutput( + [KEYS.ENTER], + 'Do you want to create an example page', + { + optional: true, + }, + )); + + await wizardInstance.sendStdinAndWaitForOutput( + [KEYS.ENTER, KEYS.ENTER], + 'Successfully installed the Sentry Nuxt SDK!', + ); + + wizardInstance.kill(); +} + +function testNuxtProjectSetup(projectDir: string) { + const integration = Integration.nuxt; + + test('package.json is updated correctly', () => { + checkPackageJson(projectDir, integration); + }); + + test('.env-sentry-build-plugin is created and contains the auth token', () => { + checkEnvBuildPlugin(projectDir); + }); + + test('config files created', () => { + checkFileExists(`${projectDir}/sentry.server.config.ts`); + checkFileExists(`${projectDir}/sentry.client.config.ts`); + }); + + test('example page exists', () => { + checkFileExists(`${projectDir}'/app/pages/sentry-example-page.vue`); + checkFileExists(`${projectDir}/server/api/sentry-example-api.ts`); + }); +} + +function testNuxtProjectConfigs(projectDir: string) { + test('nuxt config contains sentry module', () => { + checkFileContents(path.resolve(projectDir, 'nuxt.config.ts'), [ + "modules: ['@sentry/nuxt/module'],", + 'sentry: {', + ' sourceMapsUploadOptions: {', + ` org: '${TEST_ARGS.ORG_SLUG}',`, + ` project: '${TEST_ARGS.PROJECT_SLUG}'`, + ' }', + '},', + 'sourcemap: {', + ' client: true', + '}', + ]); + }); + + test('sentry.client.config.ts contents', () => { + checkFileContents(path.resolve(projectDir, 'sentry.client.config.ts'), [ + 'import * as Sentry from "@sentry/nuxt";', + 'Sentry.init({', + ' // If set up, you can use your runtime config here', + ' // dsn: useRuntimeConfig().public.sentry.dsn,', + ` dsn: "${TEST_ARGS.PROJECT_DSN}",`, + ' // 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,', + '});', + ]); + }); + + test('sentry.server.config.ts contents', () => { + checkFileContents(path.resolve(projectDir, 'sentry.server.config.ts'), [ + 'import * as Sentry from "@sentry/nuxt";', + 'Sentry.init({', + ` dsn: "${TEST_ARGS.PROJECT_DSN}",`, + ' // 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,', + '});', + ]); + }); +} + +function testNuxtProjectBuildsAndRuns(projectDir: string) { + test('builds successfully', async () => { + await checkIfBuilds(projectDir, 'preview this build'); + }); + + test('runs on prod mode correctly', async () => { + await checkIfRunsOnProdMode(projectDir, 'Listening on'); + }); +} 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/nuxt-wizard.ts b/src/nuxt/nuxt-wizard.ts new file mode 100644 index 00000000..e4262747 --- /dev/null +++ b/src/nuxt/nuxt-wizard.ts @@ -0,0 +1,166 @@ +// @ts-ignore - clack is ESM and TS complains about that. It works though +import * as clack from '@clack/prompts'; +import * as Sentry from '@sentry/node'; +import { lt, minVersion } from 'semver'; +import type { WizardOptions } from '../utils/types'; +import { traceStep, withTelemetry } from '../telemetry'; +import { + abort, + abortIfCancelled, + addDotEnvSentryBuildPluginFile, + askShouldCreateExampleComponent, + askShouldCreateExamplePage, + confirmContinueIfNoOrDirtyGitRepo, + ensurePackageIsInstalled, + getOrAskForProjectData, + getPackageDotJson, + installPackage, + printWelcome, + runPrettierIfInstalled, +} from '../utils/clack-utils'; +import { getPackageVersion, hasPackageInstalled } from '../utils/package-json'; +import { addSDKModule, getNuxtConfig, createConfigFiles } from './sdk-setup'; +import { + createExampleComponent, + createExamplePage, + supportsExamplePage, +} from './sdk-example'; +import { isNuxtV4 } from './utils'; +import chalk from 'chalk'; + +export function runNuxtWizard(options: WizardOptions) { + return withTelemetry( + { + enabled: options.telemetryEnabled, + integration: 'nuxt', + wizardOptions: options, + }, + () => runNuxtWizardWithTelemetry(options), + ); +} + +export async function runNuxtWizardWithTelemetry( + options: WizardOptions, +): Promise { + printWelcome({ + wizardName: 'Sentry Nuxt Wizard', + promoCode: options.promoCode, + telemetryEnabled: options.telemetryEnabled, + }); + + await confirmContinueIfNoOrDirtyGitRepo(); + + const packageJson = await getPackageDotJson(); + + await ensurePackageIsInstalled(packageJson, 'nuxt', 'Nuxt'); + + const nuxtVersion = getPackageVersion('nuxt', packageJson); + Sentry.setTag('nuxt-version', nuxtVersion); + + const minVer = minVersion(nuxtVersion || '0.0.0'); + + if (!nuxtVersion || !minVer || lt(minVer, '3.7.0')) { + clack.log.warn( + "It seems you're using a Nuxt version <3.7.0 which is not supported by Sentry.\nWe recommend upgrading to the latest version before you continue.", + ); + const shouldContinue = await abortIfCancelled( + clack.select({ + message: 'Do you want to continue anyway?', + options: [ + { + label: 'Yes, continue', + hint: 'The SDK might not work correctly', + value: true, + }, + { label: "No, I'll upgrade first", value: false }, + ], + }), + ); + if (!shouldContinue) { + await abort('Exiting Wizard', 0); + return; + } + } + + const { authToken, selectedProject, selfHosted, sentryUrl } = + await getOrAskForProjectData(options, 'javascript-nuxt'); + + const sdkAlreadyInstalled = hasPackageInstalled('@sentry/nuxt', packageJson); + Sentry.setTag('sdk-already-installed', sdkAlreadyInstalled); + + await installPackage({ + packageName: '@sentry/nuxt', + alreadyInstalled: sdkAlreadyInstalled, + }); + + await addDotEnvSentryBuildPluginFile(authToken); + + const nuxtConfig = await traceStep('load-nuxt-config', getNuxtConfig); + + const projectData = { + org: selectedProject.organization.slug, + project: selectedProject.slug, + projectId: selectedProject.id, + url: sentryUrl, + selfHosted, + }; + + await traceStep('configure-sdk', async () => { + await addSDKModule(nuxtConfig, projectData); + await createConfigFiles(selectedProject.keys[0].dsn.public); + }); + + let shouldCreateExamplePage = false; + let shouldCreateExampleButton = false; + + const isV4 = await isNuxtV4(nuxtConfig, nuxtVersion); + const canCreateExamplePage = await supportsExamplePage(isV4); + Sentry.setTag('supports-example-page-creation', canCreateExamplePage); + + if (canCreateExamplePage) { + shouldCreateExamplePage = await askShouldCreateExamplePage(); + + if (shouldCreateExamplePage) { + await traceStep('create-example-page', async () => + createExamplePage(isV4, projectData), + ); + } + } else { + shouldCreateExampleButton = await askShouldCreateExampleComponent(); + + if (shouldCreateExampleButton) { + await traceStep('create-example-component', async () => + createExampleComponent(isV4), + ); + } + } + + await runPrettierIfInstalled(); + + clack.outro( + buildOutroMessage(shouldCreateExamplePage, shouldCreateExampleButton), + ); +} + +function buildOutroMessage( + shouldCreateExamplePage: boolean, + shouldCreateExampleButton: boolean, +): string { + let msg = chalk.green('\nSuccessfully installed the Sentry Nuxt SDK!'); + + if (shouldCreateExamplePage) { + msg += `\n\nYou can validate your setup by visiting ${chalk.cyan( + '"/sentry-example-page"', + )}.`; + } + if (shouldCreateExampleButton) { + msg += `\n\nYou can validate your setup by adding the ${chalk.cyan( + '`SentryExampleButton`', + )} component to a page and triggering it.`; + } + + msg += `\n\nCheck out the SDK documentation for further configuration: +https://docs.sentry.io/platforms/javascript/guides/nuxt/`; + + return msg; +} diff --git a/src/nuxt/sdk-example.ts b/src/nuxt/sdk-example.ts new file mode 100644 index 00000000..7f7ae1aa --- /dev/null +++ b/src/nuxt/sdk-example.ts @@ -0,0 +1,135 @@ +import * as fs from 'fs'; +import * as path from 'path'; +// @ts-expect-error - clack is ESM and TS complains about that. It works though +import clack from '@clack/prompts'; +import { + getIndexRouteTemplate, + getSentryExampleApiTemplate, + getSentryExamplePageTemplate, + getSentryErrorButtonTemplate, +} from './templates'; +import { abort, isUsingTypeScript } from '../utils/clack-utils'; +import chalk from 'chalk'; +import * as Sentry from '@sentry/node'; + +function getSrcDirectory(isNuxtV4: boolean) { + // In nuxt v4, the src directory is `app/` unless + // users already had a `pages` directory + return isNuxtV4 && !fs.existsSync(path.resolve('pages')) ? 'app' : '.'; +} + +export async function supportsExamplePage(isNuxtV4: boolean) { + // We currently only support creating an example page + // if users can reliably access it without having to + // add code changes themselves. + // + // If users have an `app.vue` layout without the + // needed component to render routes (), + // we bail out of creating an example page altogether. + const src = getSrcDirectory(isNuxtV4); + const app = path.join(src, 'app.vue'); + + // If there's no `app.vue` layout, nuxt automatically renders + // the routes. + if (!fs.existsSync(path.resolve(app))) { + return true; + } + + const content = await fs.promises.readFile(path.resolve(app), 'utf8'); + return !!content.match(/ { + 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; selfHosted: boolean }, +): Promise { + try { + const mod = await loadFile(config); + + addNuxtModule(mod, '@sentry/nuxt/module', 'sentry', { + sourceMapsUploadOptions: { + org: options.org, + project: options.project, + ...(options.selfHosted && { url: options.url }), + }, + }); + addNuxtModule(mod, '@sentry/nuxt/module', 'sourcemap', { + client: 'hidden', + }); + + const { code } = generateCode(mod); + + await fs.promises.writeFile(config, code, { encoding: 'utf-8', flag: 'w' }); + + clack.log.success( + `Added Sentry Nuxt Module to ${chalk.cyan(path.basename(config))}.`, + ); + } catch (e: unknown) { + // Cases where users spread options are not covered by magicast, + // so we fall back to showing how to configure the nuxt config + // manually. + if (e instanceof MagicastError) { + clack.log.warn( + `Automatic configuration of ${chalk.cyan( + path.basename(config), + )} failed, please add the following settings:`, + ); + // eslint-disable-next-line no-console + console.log(`\n\n${getNuxtModuleFallbackTemplate(options)}\n\n`); + } else { + 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 new ${chalk.cyan( + typeScriptDetected ? tsConfig : jsConfig, + )}.`, + ); + 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 new file mode 100644 index 00000000..a5172358 --- /dev/null +++ b/src/nuxt/templates.ts @@ -0,0 +1,296 @@ +import { getIssueStreamUrl } from '../utils/url'; + +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 getNuxtModuleFallbackTemplate(options: { + org: string; + project: string; + url: string; + selfHosted: boolean; +}): string { + return ` modules: ["@sentry/nuxt/module"], + sentry: { + sourceMapsUploadOptions: { + org: "${options.org}", + project: "${options.project}",${ + options.selfHosted ? `\n url: "${options.url}",` : '' + } + }, + }, + sourcemap: { client: "hidden" },`; +} + +export function getSentryConfigContents( + dsn: string, + config: 'client' | 'server', + selectedFeatures: SelectedSentryFeatures, +): string { + if (config === 'client') { + return getSentryClientConfigContents(dsn, selectedFeatures); + } + + return getSentryServerConfigContents(dsn, selectedFeatures); +} + +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'], +}; + +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, + 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, + ${getConfigBody(dsn, 'client', selectedFeatures)} + + // 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: SelectedSentryFeatures, +): string { + return `import * as Sentry from "@sentry/nuxt"; + +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, +}); +`; +} + +export function getIndexRouteTemplate(): string { + return ` + +`; +} + +export function getSentryExamplePageTemplate(options: { + url: string; + org: string; + projectId: string; +}): string { + const { url, org, projectId } = options; + const issuesPageLink = getIssueStreamUrl({ url, orgSlug: org, projectId }); + + return ` + + + + + + +`; +} + +export function getSentryExampleApiTemplate() { + return `// This is just a very simple API route that throws an example error. +// Feel free to delete this file. +import { defineEventHandler } from '#imports'; + +export default defineEventHandler(event => { + throw new Error("Sentry Example API Route Error"); +}); +`; +} + +export function getSentryErrorButtonTemplate() { + return ` + + + + + + +`; +} diff --git a/src/nuxt/utils.ts b/src/nuxt/utils.ts new file mode 100644 index 00000000..d90339e7 --- /dev/null +++ b/src/nuxt/utils.ts @@ -0,0 +1,32 @@ +import { gte, minVersion } from 'semver'; +// @ts-expect-error - magicast is ESM and TS complains about that. It works though +import { loadFile } from 'magicast'; + +export async function isNuxtV4( + nuxtConfig: string, + packageVersion: string | undefined, +) { + if (!packageVersion) { + return false; + } + + const minVer = minVersion(packageVersion); + if (minVer && gte(minVer, '4.0.0')) { + return true; + } + + // At the time of writing, nuxt 4 is not on its own + // major yet. We must read the `compatibilityVersion` + // from the nuxt config. + const mod = await loadFile(nuxtConfig); + const config = + mod.exports.default.$type === 'function-call' + ? mod.exports.default.$args[0] + : mod.exports.default; + + if (config && config.future && config.future.compatibilityVersion === 4) { + return true; + } + + return false; +} diff --git a/src/run.ts b/src/run.ts index 682a4bde..a0dc6192 100644 --- a/src/run.ts +++ b/src/run.ts @@ -8,6 +8,7 @@ import type { PreselectedProject, WizardOptions } from './utils/types'; import { runAndroidWizard } from './android/android-wizard'; import { runAppleWizard } from './apple/apple-wizard'; import { runNextjsWizard } from './nextjs/nextjs-wizard'; +import { runNuxtWizard } from './nuxt/nuxt-wizard'; import { runRemixWizard } from './remix/remix-wizard'; import { runSvelteKitWizard } from './sveltekit/sveltekit-wizard'; import { runSourcemapsWizard } from './sourcemaps/sourcemaps-wizard'; @@ -22,6 +23,7 @@ type WizardIntegration = | 'cordova' | 'electron' | 'nextjs' + | 'nuxt' | 'remix' | 'sveltekit' | 'sourcemaps'; @@ -103,6 +105,7 @@ export async function run(argv: Args) { { value: 'cordova', label: 'Cordova' }, { value: 'electron', label: 'Electron' }, { value: 'nextjs', label: 'Next.js' }, + { value: 'nuxt', label: 'Nuxt' }, { value: 'remix', label: 'Remix' }, { value: 'sveltekit', label: 'SvelteKit' }, { value: 'sourcemaps', label: 'Configure Source Maps Upload' }, @@ -148,6 +151,10 @@ export async function run(argv: Args) { await runNextjsWizard(wizardOptions); break; + case 'nuxt': + await runNuxtWizard(wizardOptions); + break; + case 'remix': await runRemixWizard(wizardOptions); break; diff --git a/src/utils/clack-utils.ts b/src/utils/clack-utils.ts index 7fd35ee3..5a398536 100644 --- a/src/utils/clack-utils.ts +++ b/src/utils/clack-utils.ts @@ -597,7 +597,7 @@ SENTRY_AUTH_TOKEN=${authToken} if (hasAuthToken) { clack.log.warn( - `${chalk.bold( + `${chalk.bold.cyan( SENTRY_DOT_ENV_FILE, )} already has auth token. Will not add one.`, ); @@ -612,11 +612,11 @@ SENTRY_AUTH_TOKEN=${authToken} }, ); clack.log.success( - `Added auth token to ${chalk.bold(SENTRY_DOT_ENV_FILE)}`, + `Added auth token to ${chalk.bold.cyan(SENTRY_DOT_ENV_FILE)}`, ); } catch { clack.log.warning( - `Failed to add auth token to ${chalk.bold( + `Failed to add auth token to ${chalk.bold.cyan( SENTRY_DOT_ENV_FILE, )}. Uploading source maps during build will likely not work locally.`, ); @@ -629,13 +629,13 @@ SENTRY_AUTH_TOKEN=${authToken} flag: 'w', }); clack.log.success( - `Created ${chalk.bold( + `Created ${chalk.bold.cyan( SENTRY_DOT_ENV_FILE, )} with auth token for you to test source map uploading locally.`, ); } catch { clack.log.warning( - `Failed to create ${chalk.bold( + `Failed to create ${chalk.bold.cyan( SENTRY_DOT_ENV_FILE, )} with auth token. Uploading source maps during build will likely not work locally.`, ); @@ -854,6 +854,7 @@ export async function getOrAskForProjectData( options: WizardOptions, platform?: | 'javascript-nextjs' + | 'javascript-nuxt' | 'javascript-remix' | 'javascript-sveltekit' | 'apple-ios' @@ -1015,6 +1016,7 @@ async function askForWizardLogin(options: { promoCode?: string; platform?: | 'javascript-nextjs' + | 'javascript-nuxt' | 'javascript-remix' | 'javascript-sveltekit' | 'apple-ios' @@ -1417,6 +1419,24 @@ export async function askShouldCreateExamplePage( ); } +export async function askShouldCreateExampleComponent(): Promise { + return traceStep('ask-create-example-component', () => + abortIfCancelled( + clack.select({ + message: `Do you want to create an example component to test your Sentry setup?`, + options: [ + { + value: true, + label: 'Yes', + hint: 'Recommended - Check your git status before committing!', + }, + { value: false, label: 'No' }, + ], + }), + ), + ); +} + export async function featureSelectionPrompt>( features: F, ): Promise<{ [key in F[number]['id']]: boolean }> { diff --git a/test/nuxt/templates.test.ts b/test/nuxt/templates.test.ts new file mode 100644 index 00000000..70a34dd6 --- /dev/null +++ b/test/nuxt/templates.test.ts @@ -0,0 +1,228 @@ +import { + getDefaultNuxtConfig, + getNuxtModuleFallbackTemplate, + 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, + }); + " + `); + }); + }); + }); + + describe('getNuxtModuleFallbackTemplate', () => { + it('generates configuration options for the nuxt config', () => { + const template = getNuxtModuleFallbackTemplate({ + org: 'my-org', + project: 'my-project', + url: 'https://sentry.io', + selfHosted: false, + }); + + expect(template).toMatchInlineSnapshot(` + " modules: ["@sentry/nuxt/module"], + sentry: { + sourceMapsUploadOptions: { + org: "my-org", + project: "my-project", + }, + }, + sourcemap: { client: "hidden" }," + `); + }); + }); +});