diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9a5e056f3..4cba88cb7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -148,7 +148,8 @@ jobs: - NextJS-15 - Remix - React-Native - - Sveltekit + - Sveltekit-Hooks + - Sveltekit-Tracing - Help - Cloudflare-Wrangler-Sourcemaps os: diff --git a/CHANGELOG.md b/CHANGELOG.md index 34f2fe56f..90180db33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- feat(sveltekit): Add support for SDK setup with `instrumentation.server.ts` ([#1077](https://github.com/getsentry/sentry-wizard/pull/1077)) + + This release adds support for setting up the SvelteKit SDK in SvelteKit versions 2.31.0 or higher. + ## 6.3.0 - feat(nextjs,remix,sveltekit,react-native,flutter,ios,angular,android,nuxt): add support to add MCP server during wizard based installlations ([#1063](https://github.com/getsentry/sentry-wizard/pull/1063)) diff --git a/e2e-tests/test-applications/sveltekit-test-app/.gitignore b/e2e-tests/test-applications/sveltekit-hooks-test-app/.gitignore similarity index 100% rename from e2e-tests/test-applications/sveltekit-test-app/.gitignore rename to e2e-tests/test-applications/sveltekit-hooks-test-app/.gitignore diff --git a/e2e-tests/test-applications/sveltekit-test-app/.npmrc b/e2e-tests/test-applications/sveltekit-hooks-test-app/.npmrc similarity index 100% rename from e2e-tests/test-applications/sveltekit-test-app/.npmrc rename to e2e-tests/test-applications/sveltekit-hooks-test-app/.npmrc diff --git a/e2e-tests/test-applications/sveltekit-test-app/package.json b/e2e-tests/test-applications/sveltekit-hooks-test-app/package.json similarity index 100% rename from e2e-tests/test-applications/sveltekit-test-app/package.json rename to e2e-tests/test-applications/sveltekit-hooks-test-app/package.json diff --git a/e2e-tests/test-applications/sveltekit-test-app/src/app.d.ts b/e2e-tests/test-applications/sveltekit-hooks-test-app/src/app.d.ts similarity index 100% rename from e2e-tests/test-applications/sveltekit-test-app/src/app.d.ts rename to e2e-tests/test-applications/sveltekit-hooks-test-app/src/app.d.ts diff --git a/e2e-tests/test-applications/sveltekit-test-app/src/app.html b/e2e-tests/test-applications/sveltekit-hooks-test-app/src/app.html similarity index 100% rename from e2e-tests/test-applications/sveltekit-test-app/src/app.html rename to e2e-tests/test-applications/sveltekit-hooks-test-app/src/app.html diff --git a/e2e-tests/test-applications/sveltekit-test-app/src/lib/index.ts b/e2e-tests/test-applications/sveltekit-hooks-test-app/src/lib/index.ts similarity index 100% rename from e2e-tests/test-applications/sveltekit-test-app/src/lib/index.ts rename to e2e-tests/test-applications/sveltekit-hooks-test-app/src/lib/index.ts diff --git a/e2e-tests/test-applications/sveltekit-test-app/src/routes/+page.svelte b/e2e-tests/test-applications/sveltekit-hooks-test-app/src/routes/+page.svelte similarity index 100% rename from e2e-tests/test-applications/sveltekit-test-app/src/routes/+page.svelte rename to e2e-tests/test-applications/sveltekit-hooks-test-app/src/routes/+page.svelte diff --git a/e2e-tests/test-applications/sveltekit-test-app/svelte.config.js b/e2e-tests/test-applications/sveltekit-hooks-test-app/svelte.config.js similarity index 100% rename from e2e-tests/test-applications/sveltekit-test-app/svelte.config.js rename to e2e-tests/test-applications/sveltekit-hooks-test-app/svelte.config.js diff --git a/e2e-tests/test-applications/sveltekit-test-app/tsconfig.json b/e2e-tests/test-applications/sveltekit-hooks-test-app/tsconfig.json similarity index 100% rename from e2e-tests/test-applications/sveltekit-test-app/tsconfig.json rename to e2e-tests/test-applications/sveltekit-hooks-test-app/tsconfig.json diff --git a/e2e-tests/test-applications/sveltekit-test-app/vite.config.ts b/e2e-tests/test-applications/sveltekit-hooks-test-app/vite.config.ts similarity index 100% rename from e2e-tests/test-applications/sveltekit-test-app/vite.config.ts rename to e2e-tests/test-applications/sveltekit-hooks-test-app/vite.config.ts diff --git a/e2e-tests/test-applications/sveltekit-tracing-test-app/.gitignore b/e2e-tests/test-applications/sveltekit-tracing-test-app/.gitignore new file mode 100644 index 000000000..79518f716 --- /dev/null +++ b/e2e-tests/test-applications/sveltekit-tracing-test-app/.gitignore @@ -0,0 +1,21 @@ +node_modules + +# Output +.output +.vercel +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/e2e-tests/test-applications/sveltekit-tracing-test-app/.npmrc b/e2e-tests/test-applications/sveltekit-tracing-test-app/.npmrc new file mode 100644 index 000000000..b6f27f135 --- /dev/null +++ b/e2e-tests/test-applications/sveltekit-tracing-test-app/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/e2e-tests/test-applications/sveltekit-tracing-test-app/package.json b/e2e-tests/test-applications/sveltekit-tracing-test-app/package.json new file mode 100644 index 000000000..d24686f24 --- /dev/null +++ b/e2e-tests/test-applications/sveltekit-tracing-test-app/package.json @@ -0,0 +1,21 @@ +{ + "name": "sveltekit-test-app", + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.3.1", + "@sveltejs/kit": "2.31.0", + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.0.0", + "vite": "^5.0.3" + } +} diff --git a/e2e-tests/test-applications/sveltekit-tracing-test-app/src/app.d.ts b/e2e-tests/test-applications/sveltekit-tracing-test-app/src/app.d.ts new file mode 100644 index 000000000..c316018cf --- /dev/null +++ b/e2e-tests/test-applications/sveltekit-tracing-test-app/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/e2e-tests/test-applications/sveltekit-tracing-test-app/src/app.html b/e2e-tests/test-applications/sveltekit-tracing-test-app/src/app.html new file mode 100644 index 000000000..f273cc58f --- /dev/null +++ b/e2e-tests/test-applications/sveltekit-tracing-test-app/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/e2e-tests/test-applications/sveltekit-tracing-test-app/src/hooks.server.ts b/e2e-tests/test-applications/sveltekit-tracing-test-app/src/hooks.server.ts new file mode 100644 index 000000000..0710a0993 --- /dev/null +++ b/e2e-tests/test-applications/sveltekit-tracing-test-app/src/hooks.server.ts @@ -0,0 +1,4 @@ +export const handle = async ({ event, resolve }) => { + const response = await resolve(event); + return response; +}; diff --git a/e2e-tests/test-applications/sveltekit-tracing-test-app/src/lib/index.ts b/e2e-tests/test-applications/sveltekit-tracing-test-app/src/lib/index.ts new file mode 100644 index 000000000..856f2b6c3 --- /dev/null +++ b/e2e-tests/test-applications/sveltekit-tracing-test-app/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/e2e-tests/test-applications/sveltekit-tracing-test-app/src/routes/+page.svelte b/e2e-tests/test-applications/sveltekit-tracing-test-app/src/routes/+page.svelte new file mode 100644 index 000000000..cc88df0ea --- /dev/null +++ b/e2e-tests/test-applications/sveltekit-tracing-test-app/src/routes/+page.svelte @@ -0,0 +1,2 @@ +

Welcome to SvelteKit

+

Visit svelte.dev/docs/kit to read the documentation

diff --git a/e2e-tests/test-applications/sveltekit-tracing-test-app/svelte.config.js b/e2e-tests/test-applications/sveltekit-tracing-test-app/svelte.config.js new file mode 100644 index 000000000..ddece5af1 --- /dev/null +++ b/e2e-tests/test-applications/sveltekit-tracing-test-app/svelte.config.js @@ -0,0 +1,21 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://svelte.dev/docs/kit/integrations#preprocessors + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. + // See https://svelte.dev/docs/kit/adapters for more information about adapters. + adapter: adapter(), + experimental: { + remoteFunctions: true, + }, + }, +}; + +export default config; diff --git a/e2e-tests/test-applications/sveltekit-tracing-test-app/tsconfig.json b/e2e-tests/test-applications/sveltekit-tracing-test-app/tsconfig.json new file mode 100644 index 000000000..0b2d8865f --- /dev/null +++ b/e2e-tests/test-applications/sveltekit-tracing-test-app/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} diff --git a/e2e-tests/test-applications/sveltekit-tracing-test-app/vite.config.ts b/e2e-tests/test-applications/sveltekit-tracing-test-app/vite.config.ts new file mode 100644 index 000000000..bbf8c7da4 --- /dev/null +++ b/e2e-tests/test-applications/sveltekit-tracing-test-app/vite.config.ts @@ -0,0 +1,6 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()] +}); diff --git a/e2e-tests/tests/sveltekit.test.ts b/e2e-tests/tests/sveltekit-hooks.test.ts similarity index 92% rename from e2e-tests/tests/sveltekit.test.ts rename to e2e-tests/tests/sveltekit-hooks.test.ts index d69f0faee..8a01bdb7d 100644 --- a/e2e-tests/tests/sveltekit.test.ts +++ b/e2e-tests/tests/sveltekit-hooks.test.ts @@ -50,7 +50,7 @@ async function runWizardOnSvelteKitProject( ) => unknown, ) { const wizardInstance = startWizardInstance(integration, projectDir); - let packageManagerPrompted = false; + let kitVersionPrompted = false; if (fileModificationFn) { fileModificationFn(projectDir, integration); @@ -58,16 +58,24 @@ async function runWizardOnSvelteKitProject( // As we modified project, we have a warning prompt before we get the package manager prompt await wizardInstance.waitForOutput('Do you want to continue anyway?'); - packageManagerPrompted = await wizardInstance.sendStdinAndWaitForOutput( + kitVersionPrompted = await wizardInstance.sendStdinAndWaitForOutput( [KEYS.ENTER], - 'Please select your package manager.', + "It seems you're using a SvelteKit version", ); } else { - packageManagerPrompted = await wizardInstance.waitForOutput( - 'Please select your package manager', + kitVersionPrompted = await wizardInstance.waitForOutput( + "It seems you're using a SvelteKit version", ); } + const packageManagerPrompted = + kitVersionPrompted && + (await wizardInstance.sendStdinAndWaitForOutput( + // Select "Yes, Continue" to perform hooks-based SDK setup + [KEYS.DOWN, KEYS.DOWN, KEYS.ENTER], + 'Please select your package manager.', + )); + const tracingOptionPrompted = packageManagerPrompted && (await wizardInstance.sendStdinAndWaitForOutput( @@ -111,7 +119,7 @@ async function runWizardOnSvelteKitProject( const mcpPrompted = examplePagePrompted && (await wizardInstance.sendStdinAndWaitForOutput( - [KEYS.ENTER], // This ENTER is for accepting the example page + [KEYS.ENTER], // This ENTER is for accepting the example page 'Optionally add a project-scoped MCP server configuration for the Sentry MCP?', { optional: true, @@ -198,7 +206,7 @@ describe('Sveltekit', () => { const integration = Integration.sveltekit; const projectDir = path.resolve( __dirname, - '../test-applications/sveltekit-test-app', + '../test-applications/sveltekit-hooks-test-app', ); beforeAll(async () => { @@ -261,7 +269,7 @@ describe('Sveltekit', () => { const integration = Integration.sveltekit; const projectDir = path.resolve( __dirname, - '../test-applications/sveltekit-test-app', + '../test-applications/sveltekit-hooks-test-app', ); beforeAll(async () => { diff --git a/e2e-tests/tests/sveltekit-tracing.test.ts b/e2e-tests/tests/sveltekit-tracing.test.ts new file mode 100644 index 000000000..f5469bc54 --- /dev/null +++ b/e2e-tests/tests/sveltekit-tracing.test.ts @@ -0,0 +1,235 @@ +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { Integration } from '../../lib/Constants'; +import { + checkEnvBuildPlugin, + checkFileExists, + checkIfBuilds, + checkIfRunsOnDevMode, + checkIfRunsOnProdMode, + checkPackageJson, + cleanupGit, + getWizardCommand, + initGit, + revertLocalChanges, + TEST_ARGS, +} from '../utils'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +//@ts-expect-error - clifty is ESM only +import { KEYS, withEnv } from 'clifty'; + +describe('Sveltekit with instrumentation and tracing', () => { + describe('without existing files', () => { + const projectDir = path.resolve( + __dirname, + '../test-applications/sveltekit-tracing-test-app', + ); + + const integration = Integration.sveltekit; + let wizardExitCode: number; + + beforeAll(async () => { + initGit(projectDir); + revertLocalChanges(projectDir); + + wizardExitCode = await withEnv({ + cwd: projectDir, + }) + .defineInteraction() + .expectOutput( + 'The Sentry SvelteKit Wizard will help you set up Sentry for your application', + ) + .step('package installation', ({ expectOutput, whenAsked }) => { + whenAsked('Please select your package manager.').respondWith( + KEYS.DOWN, + KEYS.ENTER, + ); + expectOutput('Installing @sentry/sveltekit'); + }) + .step('SDK setup', ({ whenAsked }) => { + whenAsked('Do you want to enable Tracing', { + timeout: 90_000, // package installation can take a while in CI + }).respondWith(KEYS.ENTER); + whenAsked('Do you want to enable Session Replay').respondWith( + KEYS.ENTER, + ); + whenAsked('Do you want to enable Logs').respondWith(KEYS.ENTER); + }) + .whenAsked('Do you want to create an example page') + .respondWith(KEYS.ENTER) + .whenAsked( + 'Optionally add a project-scoped MCP server configuration for the Sentry MCP?', + ) + .respondWith(KEYS.DOWN, KEYS.ENTER) + .expectOutput('Successfully installed the Sentry SvelteKit SDK!') + .run(getWizardCommand(integration)); + }); + + afterAll(() => { + revertLocalChanges(projectDir); + cleanupGit(projectDir); + }); + + it('exits with exit code 0', () => { + expect(wizardExitCode).toBe(0); + }); + + it('adds the SDK dependency to package.json', () => { + checkPackageJson(projectDir, integration); + }); + + it('adds the .env.sentry-build-plugin', () => { + checkEnvBuildPlugin(projectDir); + }); + + it('adds the example page', () => { + checkFileExists( + path.resolve(projectDir, 'src/routes/sentry-example-page/+page.svelte'), + ); + checkFileExists( + path.resolve(projectDir, 'src/routes/sentry-example-page/+server.js'), + ); + }); + + it('adds the sentry plugin to vite.config.ts', () => { + const viteConfig = fs.readFileSync( + path.resolve(projectDir, 'vite.config.ts'), + ); + expect(viteConfig.toString()).toMatchInlineSnapshot(` + "import { sentrySvelteKit } from "@sentry/sveltekit"; + import { sveltekit } from '@sveltejs/kit/vite'; + import { defineConfig } from 'vite'; + + export default defineConfig({ + plugins: [sentrySvelteKit({ + sourceMapsUploadOptions: { + org: "${TEST_ARGS.ORG_SLUG}", + project: "${TEST_ARGS.PROJECT_SLUG}" + } + }), sveltekit()] + });" + `); + }); + + it('creates the hook files', () => { + const clientHooks = fs.readFileSync( + path.resolve(projectDir, 'src/hooks.client.ts'), + ); + const serverHooks = fs.readFileSync( + path.resolve(projectDir, 'src/hooks.server.ts'), + ); + + expect(clientHooks.toString()).toMatchInlineSnapshot(` + "import { handleErrorWithSentry, replayIntegration } from "@sentry/sveltekit"; + import * as Sentry from '@sentry/sveltekit'; + + Sentry.init({ + dsn: '${TEST_ARGS.PROJECT_DSN}', + + tracesSampleRate: 1.0, + + // Enable logs to be sent to Sentry + enableLogs: true, + + // 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: [replayIntegration()], + }); + + // If you have a custom error handler, pass it to \`handleErrorWithSentry\` + export const handleError = handleErrorWithSentry(); + " + `); + + expect(serverHooks.toString()).toMatchInlineSnapshot(` + "import {sequence} from "@sveltejs/kit/hooks"; + import * as Sentry from "@sentry/sveltekit"; + export const handle = sequence(Sentry.sentryHandle(), async ({ event, resolve }) => { + const response = await resolve(event); + return response; + }); + export const handleError = Sentry.handleErrorWithSentry();" + `); + }); + + it('creates the insturmentation.server file', () => { + const instrumentationServer = fs.readFileSync( + path.resolve(projectDir, 'src/instrumentation.server.ts'), + ); + + expect(instrumentationServer.toString()).toMatchInlineSnapshot(` + "import * as Sentry from '@sentry/sveltekit'; + + Sentry.init({ + dsn: '${TEST_ARGS.PROJECT_DSN}', + + tracesSampleRate: 1.0, + + // Enable logs to be sent to Sentry + enableLogs: true, + + // uncomment the line below to enable Spotlight (https://spotlightjs.com) + // spotlight: import.meta.env.DEV, + });" + `); + }); + + it('enables tracing and instrumentation in svelte.config.js', () => { + const svelteConfig = fs.readFileSync( + path.resolve(projectDir, 'svelte.config.js'), + ); + expect(svelteConfig.toString()).toMatchInlineSnapshot(` + "import adapter from '@sveltejs/adapter-node'; + import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + + /** @type {import('@sveltejs/kit').Config} */ + const config = { + // Consult https://svelte.dev/docs/kit/integrations#preprocessors + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. + // See https://svelte.dev/docs/kit/adapters for more information about adapters. + adapter: adapter(), + experimental: { + remoteFunctions: true, + + tracing: { + server: true, + }, + + instrumentation: { + server: true, + }, + }, + }, + }; + + export default config;" + `); + }); + + // checkSvelteKitProject(projectDir, integration); + it('builds successfully', async () => { + await checkIfBuilds(projectDir); + }); + + it('runs on dev mode correctly', async () => { + await checkIfRunsOnDevMode(projectDir, 'ready in'); + }); + + it('runs on prod mode correctly', async () => { + await checkIfRunsOnProdMode(projectDir, 'to expose', 'preview'); + }); + }); +}); diff --git a/src/sveltekit/sdk-example.ts b/src/sveltekit/sdk-example.ts index fc5d01dbe..1b309d5ef 100644 --- a/src/sveltekit/sdk-example.ts +++ b/src/sveltekit/sdk-example.ts @@ -3,7 +3,7 @@ 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 { PartialSvelteConfig } from './sdk-setup'; +import { PartialBackwardsForwardsCompatibleSvelteConfig } from './sdk-setup/svelte-config'; import { getSentryExampleApiRoute, getSentryExampleSveltePage, @@ -13,7 +13,7 @@ import { * Creates example page and API route to test Sentry */ export async function createExamplePage( - svelteConfig: PartialSvelteConfig, + svelteConfig: PartialBackwardsForwardsCompatibleSvelteConfig, projectProps: { selfHosted: boolean; url: string; diff --git a/src/sveltekit/sdk-setup.ts b/src/sveltekit/sdk-setup/setup.ts similarity index 69% rename from src/sveltekit/sdk-setup.ts rename to src/sveltekit/sdk-setup/setup.ts index dacacabd9..ae3fac83d 100644 --- a/src/sveltekit/sdk-setup.ts +++ b/src/sveltekit/sdk-setup/setup.ts @@ -1,7 +1,6 @@ import type { ExportNamedDeclaration, Program } from '@babel/types'; import * as fs from 'fs'; import * as path from 'path'; -import * as url from 'url'; import chalk from 'chalk'; import * as Sentry from '@sentry/node'; @@ -11,48 +10,35 @@ import clack from '@clack/prompts'; // @ts-expect-error - magicast is ESM and TS complains about that. It works though import type { ProxifiedModule } from 'magicast'; // @ts-expect-error - magicast is ESM and TS complains about that. It works though -import { builders, generateCode, loadFile, parseModule } from 'magicast'; -// @ts-expect-error - magicast is ESM and TS complains about that. It works though -import { addVitePlugin } from 'magicast/helpers'; -import { getClientHooksTemplate, getServerHooksTemplate } from './templates'; +import { builders, generateCode, loadFile } from 'magicast'; +import { + getClientHooksTemplate, + getInstrumentationServerTemplate, + getServerHooksTemplate, +} from '../templates'; import { - abortIfCancelled, featureSelectionPrompt, isUsingTypeScript, -} from '../utils/clack'; -import { debug } from '../utils/debug'; -import { findFile, hasSentryContent } from '../utils/ast-utils'; + showCopyPasteInstructions, +} from '../../utils/clack'; +import { findFile, hasSentryContent } from '../../utils/ast-utils'; import * as recast from 'recast'; import x = recast.types; import t = x.namedTypes; -import { traceStep } from '../telemetry'; - -const SVELTE_CONFIG_FILE = 'svelte.config.js'; - -export type PartialSvelteConfig = { - kit?: { - files?: { - hooks?: { - client?: string; - server?: string; - }; - routes?: string; - }; - }; -}; - -type ProjectInfo = { - dsn: string; - org: string; - project: string; - selfHosted: boolean; - url: string; -}; +import { + enableTracingAndInstrumentation, + type PartialBackwardsForwardsCompatibleSvelteConfig, +} from './svelte-config'; +import { ProjectInfo } from './types'; +import { modifyViteConfig } from './vite'; +import { modifyAndRecordFail } from './utils'; +import { debug } from '../../utils/debug'; export async function createOrMergeSvelteKitFiles( projectInfo: ProjectInfo, - svelteConfig: PartialSvelteConfig, + svelteConfig: PartialBackwardsForwardsCompatibleSvelteConfig, + setupForSvelteKitTracing: boolean, ): Promise { const selectedFeatures = await featureSelectionPrompt([ { @@ -83,6 +69,9 @@ export async function createOrMergeSvelteKitFiles( // full file paths with correct file ending (or undefined if not found) const originalClientHooksFile = findFile(clientHooksPath); const originalServerHooksFile = findFile(serverHooksPath); + const originalInstrumentationServerFile = findFile( + path.resolve(process.cwd(), 'src', 'instrumentation.server'), + ); const viteConfig = findFile(path.resolve(process.cwd(), 'vite.config')); @@ -90,45 +79,85 @@ export async function createOrMergeSvelteKitFiles( const { dsn } = projectInfo; + if (setupForSvelteKitTracing) { + await enableTracingAndInstrumentation( + svelteConfig, + selectedFeatures.performance, + ); + + try { + if (!originalInstrumentationServerFile) { + await createNewInstrumentationServerFile(dsn, selectedFeatures); + } else { + await mergeInstrumentationServerFile( + originalInstrumentationServerFile, + dsn, + selectedFeatures, + ); + } + } catch (e) { + clack.log.warn( + `Failed to automatically set up ${chalk.cyan( + `instrumentation.server.${ + fileEnding ?? isUsingTypeScript() ? 'ts' : 'js' + }`, + )}.`, + ); + debug(e); + + await showCopyPasteInstructions({ + codeSnippet: getInstrumentationServerTemplate(dsn, selectedFeatures), + filename: `instrumentation.server.${ + fileEnding ?? isUsingTypeScript() ? 'ts' : 'js' + }`, + }); + + Sentry.setTag('created-instrumentation-server', 'fail'); + } + } + Sentry.setTag( - 'client-hooks-file-strategy', - originalClientHooksFile ? 'merge' : 'create', + 'server-hooks-file-strategy', + originalServerHooksFile ? 'merge' : 'create', ); - if (!originalClientHooksFile) { - clack.log.info('No client hooks file found, creating a new one.'); + + if (!originalServerHooksFile) { await createNewHooksFile( - `${clientHooksPath}.${fileEnding}`, - 'client', + `${serverHooksPath}.${fileEnding}`, + 'server', dsn, selectedFeatures, + !setupForSvelteKitTracing, ); } else { await mergeHooksFile( - originalClientHooksFile, - 'client', + originalServerHooksFile, + 'server', dsn, selectedFeatures, + !setupForSvelteKitTracing, ); } Sentry.setTag( - 'server-hooks-file-strategy', - originalServerHooksFile ? 'merge' : 'create', + 'client-hooks-file-strategy', + originalClientHooksFile ? 'merge' : 'create', ); - if (!originalServerHooksFile) { - clack.log.info('No server hooks file found, creating a new one.'); + if (!originalClientHooksFile) { await createNewHooksFile( - `${serverHooksPath}.${fileEnding}`, - 'server', + `${clientHooksPath}.${fileEnding}`, + 'client', dsn, selectedFeatures, + true, ); } else { await mergeHooksFile( - originalServerHooksFile, - 'server', + originalClientHooksFile, + 'client', dsn, selectedFeatures, + true, ); } @@ -141,7 +170,9 @@ export async function createOrMergeSvelteKitFiles( * Attempts to read the svelte.config.js file to find the location of the hooks files. * If users specified a custom location, we'll use that. Otherwise, we'll use the default. */ -function getHooksConfigDirs(svelteConfig: PartialSvelteConfig): { +function getHooksConfigDirs( + svelteConfig: PartialBackwardsForwardsCompatibleSvelteConfig, +): { clientHooksPath: string; serverHooksPath: string; } { @@ -176,11 +207,12 @@ async function createNewHooksFile( replay: boolean; logs: boolean; }, + setupForSvelteKitTracing: boolean, ): Promise { const filledTemplate = hooktype === 'client' ? getClientHooksTemplate(dsn, selectedFeatures) - : getServerHooksTemplate(dsn, selectedFeatures); + : getServerHooksTemplate(dsn, selectedFeatures, setupForSvelteKitTracing); await fs.promises.mkdir(path.dirname(hooksFileDest), { recursive: true }); await fs.promises.writeFile(hooksFileDest, filledTemplate); @@ -189,6 +221,38 @@ async function createNewHooksFile( Sentry.setTag(`created-${hooktype}-hooks`, 'success'); } +async function createNewInstrumentationServerFile( + dsn: string, + selectedFeatures: { + performance: boolean; + logs: boolean; + }, +): Promise { + const filledTemplate = getInstrumentationServerTemplate( + dsn, + selectedFeatures, + ); + + const fileEnding = isUsingTypeScript() ? 'ts' : 'js'; + + const instrumentationServerFile = path.resolve( + process.cwd(), + 'src', + `instrumentation.server.${fileEnding}`, + ); + + await fs.promises.mkdir(path.dirname(instrumentationServerFile), { + recursive: true, + }); + + await fs.promises.writeFile(instrumentationServerFile, filledTemplate); + + clack.log.success( + `Created ${chalk.cyan(path.basename(instrumentationServerFile))}`, + ); + Sentry.setTag('created-instrumentation-server', 'success'); +} + /** * Merges the users' hooks file with Sentry-related code. * @@ -209,6 +273,7 @@ async function mergeHooksFile( replay: boolean; logs: boolean; }, + includeSentryInit: boolean, ): Promise { const originalHooksMod = await loadFile(hooksFile); @@ -239,17 +304,19 @@ Skipping adding Sentry functionality to.`, file, ); - await modifyAndRecordFail( - () => { - if (hookType === 'client') { - insertClientInitCall(dsn, originalHooksMod, selectedFeatures); - } else { - insertServerInitCall(dsn, originalHooksMod, selectedFeatures); - } - }, - 'init-call-injection', - file, - ); + if (hookType === 'client' || includeSentryInit) { + await modifyAndRecordFail( + () => { + if (hookType === 'client') { + insertClientInitCall(dsn, originalHooksMod, selectedFeatures); + } else { + insertServerInitCall(dsn, originalHooksMod, selectedFeatures); + } + }, + 'init-call-injection', + file, + ); + } await modifyAndRecordFail( () => wrapHandleError(originalHooksMod), @@ -278,6 +345,79 @@ Skipping adding Sentry functionality to.`, Sentry.setTag(`modified-${hookType}-hooks`, 'success'); } +/** + * Merges the users' instrumentation.server file with Sentry-related code. + * + * Both hooks: + * - add import * as Sentry + * - add Sentry.init + * - add handleError hook wrapper + * + * Additionally in Server hook: + * - add handle hook handler + */ +async function mergeInstrumentationServerFile( + instrumentationServerFilePath: string, + dsn: string, + selectedFeatures: { + performance: boolean; + replay: boolean; + logs: boolean; + }, +): Promise { + const originalInstrumentationServerMod = await loadFile( + instrumentationServerFilePath, + ); + const filename = path.basename(instrumentationServerFilePath); + + if (hasSentryContent(originalInstrumentationServerMod.$ast as t.Program)) { + // We don't want to mess with files that already have Sentry content. + // Let's just bail out at this point. + clack.log.warn( + `File ${chalk.cyan(filename)} already contains Sentry code. +Skipping adding Sentry functionality to it.`, + ); + Sentry.setTag(`modified-instrumentation-server`, 'fail'); + Sentry.setTag(`instrumentation-server-fail-reason`, 'has-sentry-content'); + return; + } + + await modifyAndRecordFail( + () => + originalInstrumentationServerMod.imports.$add({ + from: '@sentry/sveltekit', + imported: '*', + local: 'Sentry', + }), + 'import-injection', + 'instrumentation-server', + ); + + await modifyAndRecordFail( + () => { + insertServerInitCall( + dsn, + originalInstrumentationServerMod, + selectedFeatures, + ); + }, + 'init-call-injection', + 'instrumentation-server', + ); + + await modifyAndRecordFail( + async () => { + const modifiedCode = originalInstrumentationServerMod.generate().code; + await fs.promises.writeFile(instrumentationServerFilePath, modifiedCode); + }, + 'write-file', + 'instrumentation-server', + ); + + clack.log.success(`Added Sentry.init code to ${chalk.cyan(filename)}`); + Sentry.setTag(`modified-instrumentation-server`, 'success'); +} + function insertClientInitCall( dsn: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -343,7 +483,7 @@ function insertClientInitCall( function insertServerInitCall( dsn: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any - originalHooksMod: ProxifiedModule, + originalMod: ProxifiedModule, selectedFeatures: { performance: boolean; logs: boolean; @@ -369,11 +509,11 @@ function insertServerInitCall( // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const initCall = builders.functionCall('Sentry.init', initArgs); - const originalHooksModAST = originalHooksMod.$ast as Program; + const originalModAST = originalMod.$ast as Program; - const initCallInsertionIndex = getInitCallInsertionIndex(originalHooksModAST); + const initCallInsertionIndex = getInitCallInsertionIndex(originalModAST); - originalHooksModAST.body.splice( + originalModAST.body.splice( initCallInsertionIndex, 0, // @ts-expect-error - string works here because the AST is proxified by magicast @@ -501,191 +641,18 @@ function wrapHandle(mod: ProxifiedModule): void { } } -export async function loadSvelteConfig(): Promise { - const configFilePath = path.join(process.cwd(), SVELTE_CONFIG_FILE); - - try { - if (!fs.existsSync(configFilePath)) { - return {}; - } - - const configUrl = url.pathToFileURL(configFilePath).href; - const svelteConfigModule = (await import(configUrl)) as { - default: PartialSvelteConfig; - }; - - return svelteConfigModule?.default || {}; - } catch (e: unknown) { - clack.log.error(`Couldn't load ${SVELTE_CONFIG_FILE}. -Please make sure, you're running this wizard with Node 16 or newer`); - clack.log.info( - chalk.dim( - typeof e === 'object' && e != null && 'toString' in e - ? e.toString() - : typeof e === 'string' - ? e - : 'Unknown error', - ), - ); - - return {}; - } -} - -async function modifyViteConfig( - viteConfigPath: string, - projectInfo: ProjectInfo, -): Promise { - const viteConfigContent = ( - await fs.promises.readFile(viteConfigPath, 'utf-8') - ).toString(); - - const { org, project, url, selfHosted } = projectInfo; - - const prettyViteConfigFilename = chalk.cyan(path.basename(viteConfigPath)); - - try { - const viteModule = parseModule(viteConfigContent); - - if (hasSentryContent(viteModule.$ast as t.Program)) { - clack.log.warn( - `File ${prettyViteConfigFilename} already contains Sentry code. -Skipping adding Sentry functionality to.`, - ); - Sentry.setTag(`modified-vite-cfg`, 'fail'); - Sentry.setTag(`vite-cfg-fail-reason`, 'has-sentry-content'); - return; - } - - await modifyAndRecordFail( - () => - addVitePlugin(viteModule, { - imported: 'sentrySvelteKit', - from: '@sentry/sveltekit', - constructor: 'sentrySvelteKit', - options: { - sourceMapsUploadOptions: { - org, - project, - ...(selfHosted && { url }), - }, - }, - index: 0, - }), - 'add-vite-plugin', - 'vite-cfg', - ); - - await modifyAndRecordFail( - async () => { - const code = generateCode(viteModule.$ast).code; - await fs.promises.writeFile(viteConfigPath, code); - }, - 'write-file', - 'vite-cfg', - ); - } catch (e) { - debug(e); - await showFallbackViteCopyPasteSnippet( - viteConfigPath, - getViteConfigCodeSnippet(org, project, selfHosted, url), - ); - Sentry.captureException('Sveltekit Vite Config Modification Fail'); - } - - clack.log.success(`Added Sentry code to ${prettyViteConfigFilename}`); - Sentry.setTag(`modified-vite-cfg`, 'success'); -} - -async function showFallbackViteCopyPasteSnippet( - viteConfigPath: string, - codeSnippet: string, -) { - const viteConfigFilename = path.basename(viteConfigPath); - - clack.log.warning( - `Couldn't automatically modify your ${chalk.cyan(viteConfigFilename)} -${chalk.dim(`This sometimes happens when we encounter more complex vite configs. -It may not seem like it but sometimes our magical powers are limited ;)`)}`, - ); - - clack.log.info("But don't worry - it's super easy to do this yourself!"); - - clack.log.step( - `Add the following code to your ${chalk.cyan(viteConfigFilename)}:`, - ); - - // Intentionally logging to console here for easier copy/pasting - // eslint-disable-next-line no-console - console.log(codeSnippet); - - await abortIfCancelled( - clack.select({ - message: 'Did you copy the snippet above?', - options: [ - { label: 'Yes!', value: true, hint: "Great, that's already it!" }, - ], - initialValue: true, - }), - ); -} - -const getViteConfigCodeSnippet = ( - org: string, - project: string, - selfHosted: boolean, - url: string, -) => - chalk.gray(` -import { sveltekit } from '@sveltejs/kit/vite'; -import { defineConfig } from 'vite'; -${chalk.greenBright("import { sentrySvelteKit } from '@sentry/sveltekit'")} - -export default defineConfig({ - plugins: [ - // Make sure \`sentrySvelteKit\` is registered before \`sveltekit\` - ${chalk.greenBright(`sentrySvelteKit({ - sourceMapsUploadOptions: { - org: '${org}', - project: '${project}',${selfHosted ? `\n url: '${url}',` : ''} - } - }),`)} - sveltekit(), - ] -}); -`); - /** * We want to insert the init call on top of the file but after all import statements */ -function getInitCallInsertionIndex(originalHooksModAST: Program): number { +function getInitCallInsertionIndex(originalModAST: Program): number { // We need to deep-copy here because reverse mutates in place - const copiedBodyNodes = [...originalHooksModAST.body]; + const copiedBodyNodes = [...originalModAST.body]; const lastImportDeclaration = copiedBodyNodes .reverse() .find((node) => node.type === 'ImportDeclaration'); const initCallInsertionIndex = lastImportDeclaration - ? originalHooksModAST.body.indexOf(lastImportDeclaration) + 1 + ? originalModAST.body.indexOf(lastImportDeclaration) + 1 : 0; return initCallInsertionIndex; } - -/** - * Applies the @param modifyCallback and records Sentry tags if the call failed. - * In case of a failure, a tag is set with @param reason as a fail reason - * and the error is rethrown. - */ -async function modifyAndRecordFail( - modifyCallback: () => T | Promise, - reason: string, - fileType: 'server-hooks' | 'client-hooks' | 'vite-cfg', -): Promise { - try { - await traceStep(`${fileType}-${reason}`, modifyCallback); - } catch (e) { - Sentry.setTag(`modified-${fileType}`, 'fail'); - Sentry.setTag(`${fileType}-mod-fail-reason`, reason); - throw e; - } -} diff --git a/src/sveltekit/sdk-setup/svelte-config.ts b/src/sveltekit/sdk-setup/svelte-config.ts new file mode 100644 index 000000000..1b539d872 --- /dev/null +++ b/src/sveltekit/sdk-setup/svelte-config.ts @@ -0,0 +1,397 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as url from 'url'; +import chalk from 'chalk'; +import * as Sentry from '@sentry/node'; +import * as recast from 'recast'; +import x = recast.types; +import t = x.namedTypes; + +//@ts-expect-error - clack is ESM and TS complains about that. It works though +import clack from '@clack/prompts'; +import { makeCodeSnippet, showCopyPasteInstructions } from '../../utils/clack'; +// @ts-expect-error - magicast is ESM and TS complains about that. It works though +import { generateCode, parseModule, ProxifiedModule } from 'magicast'; +import { debug } from '../../utils/debug'; + +const SVELTE_CONFIG_FILE = 'svelte.config.js'; + +const b = recast.types.builders; + +export type PartialBackwardsForwardsCompatibleSvelteConfig = { + kit?: { + files?: { + hooks?: { + client?: string; + server?: string; + }; + routes?: string; + }; + experimental?: { + tracing?: { + server?: boolean; + }; + instrumentation?: { + server?: boolean; + }; + }; + }; +}; + +export async function loadSvelteConfig(): Promise { + const configFilePath = path.join(process.cwd(), SVELTE_CONFIG_FILE); + + try { + if (!fs.existsSync(configFilePath)) { + return {}; + } + + const configUrl = url.pathToFileURL(configFilePath).href; + const svelteConfigModule = (await import(configUrl)) as { + default: PartialBackwardsForwardsCompatibleSvelteConfig; + }; + + return svelteConfigModule?.default || {}; + } catch (e: unknown) { + clack.log.error(`Couldn't load ${chalk.cyan(SVELTE_CONFIG_FILE)}. +Are you running this wizard from the root of your SvelteKit project?`); + clack.log.info( + chalk.dim( + typeof e === 'object' && e != null && 'toString' in e + ? e.toString() + : typeof e === 'string' + ? e + : 'Unknown error', + ), + ); + + return {}; + } +} + +export async function enableTracingAndInstrumentation( + originalSvelteConfig: PartialBackwardsForwardsCompatibleSvelteConfig, + enableTracing: boolean, +) { + const hasTracingEnabled = originalSvelteConfig.kit?.experimental?.tracing; + const hasInstrumentationEnabled = + originalSvelteConfig.kit?.experimental?.instrumentation; + + if (hasTracingEnabled && hasInstrumentationEnabled) { + clack.log.info('Tracing and instrumentation are already enabled.'); + return; + } + + if (hasTracingEnabled || hasInstrumentationEnabled) { + clack.log.info( + 'Tracing and instrumentation are partially enabled. Make sure both options are enabled.', + ); + await showFallbackConfigSnippet(); + return; + } else { + try { + const configPath = path.join(process.cwd(), SVELTE_CONFIG_FILE); + const svelteConfigContent = await fs.promises.readFile( + configPath, + 'utf-8', + ); + + const { error, result } = _enableTracingAndInstrumentationInConfig( + svelteConfigContent, + enableTracing, + ); + + if (error) { + clack.log.warning( + 'Failed to automatically enable SvelteKit tracing and instrumentation.', + ); + debug(error); + Sentry.captureException(error); + await showFallbackConfigSnippet(); + return; + } + + if (result) { + await fs.promises.writeFile(configPath, result); + } + + clack.log.success( + `Enabled tracing and instrumentation in ${chalk.cyan( + SVELTE_CONFIG_FILE, + )}`, + ); + } catch (e) { + clack.log.error( + `Failed to enable tracing and instrumentation in ${chalk.cyan( + SVELTE_CONFIG_FILE, + )}.`, + ); + debug(e); + Sentry.captureException( + `Failed to enable tracing and instrumentation in ${SVELTE_CONFIG_FILE}`, + ); + await showFallbackConfigSnippet(); + return; + } + } +} + +export function _enableTracingAndInstrumentationInConfig( + config: string, + enableTracing: boolean, +): { + result?: string; + error?: string; +} { + let svelteConfig: ProxifiedModule; + try { + svelteConfig = parseModule(config); + } catch (e) { + return { + error: 'Failed to parse Svelte config', + }; + } + + let configObject: t.ObjectExpression | undefined = undefined; + + // Cases to handle for finding the config object: + // 1. default export is named object + // 2. default export is in-place object + // 3. default export is an identifier, so look up the variable declaration + recast.visit(svelteConfig.$ast, { + visitExportDefaultDeclaration(path) { + const exportDeclarationNode = path.node; + if ( + exportDeclarationNode.declaration.type === 'AssignmentExpression' && + exportDeclarationNode.declaration.right.type === 'ObjectExpression' + ) { + configObject = exportDeclarationNode.declaration.right; + return false; + } + + if (exportDeclarationNode.declaration.type === 'ObjectExpression') { + configObject = exportDeclarationNode.declaration; + return false; + } + + if (exportDeclarationNode.declaration.type === 'Identifier') { + const identifierName = exportDeclarationNode.declaration.name; + recast.visit(svelteConfig.$ast, { + visitVariableDeclarator(path) { + if ( + path.node.id?.type === 'Identifier' && + path.node.id.name === identifierName && + path.node.init?.type === 'ObjectExpression' + ) { + configObject = path.node.init; + return false; + } + + this.traverse(path); + }, + }); + } + + this.traverse(path); + }, + }); + + if (!_isValidConfigObject(configObject)) { + return { + error: "Couldn't find the config object", + }; + } + + // This type cast is safe. For some reason, TS still assumes that `configObject` + // is `undefined` so we have to tell it that it's not (see check above) + const validatedConfigObject = + configObject as recast.types.namedTypes.ObjectExpression; + + const kitProp = validatedConfigObject.properties.find( + (prop) => + prop.type === 'ObjectProperty' && + prop.key.type === 'Identifier' && + prop.key.name === 'kit', + ); + + if (!kitProp || kitProp.type !== 'ObjectProperty') { + return { + error: "Couldn't find the `kit` property", + }; + } + + if (kitProp.value.type !== 'ObjectExpression') { + return { + error: `\`kit\` property has unexpected type: ${kitProp.value.type}`, + }; + } + + // 1. find or add `kit.experimental` property + // type-cast because TS can't infer the type in `.find` :( + const kitExperimentalProp = kitProp.value.properties.find( + (prop) => + prop.type === 'ObjectProperty' && + prop.key.type === 'Identifier' && + prop.key.name === 'experimental', + ) as t.ObjectProperty | undefined; + + let experimentalObject: t.ObjectExpression; + + if (kitExperimentalProp) { + if (kitExperimentalProp.value.type !== 'ObjectExpression') { + return { + error: `Property \`kit.experimental\` has unexpected type: ${kitExperimentalProp.value.type}`, + }; + } + + experimentalObject = kitExperimentalProp.value; + } else { + experimentalObject = b.objectExpression([]); + kitProp.value.properties.push( + b.objectProperty(b.identifier('experimental'), experimentalObject), + ); + } + + // 2. find or add `kit.experimental.tracing` property + // find or add `kit.experimental.instrumentation` property + const kitExperimentalTraingProp = experimentalObject.properties.find( + (prop) => + prop.type === 'ObjectProperty' && + prop.key.type === 'Identifier' && + prop.key.name === 'tracing', + ) as t.ObjectProperty | undefined; + + const kitExperimentalInstrumentationProp = experimentalObject.properties.find( + (prop) => + prop.type === 'ObjectProperty' && + prop.key.type === 'Identifier' && + prop.key.name === 'instrumentation', + ) as t.ObjectProperty | undefined; + + let experimentalTracingObject: t.ObjectExpression; + let experimentalInstrumentationObject: t.ObjectExpression; + + if (kitExperimentalTraingProp) { + if (kitExperimentalTraingProp.value.type !== 'ObjectExpression') { + return { + error: `Property \`kit.experimental.tracing\` has unexpected type: ${kitExperimentalTraingProp.value.type}`, + }; + } + + experimentalTracingObject = kitExperimentalTraingProp.value; + } else { + experimentalTracingObject = b.objectExpression([]); + experimentalObject.properties.push( + b.objectProperty(b.identifier('tracing'), experimentalTracingObject), + ); + } + + if (kitExperimentalInstrumentationProp) { + if (kitExperimentalInstrumentationProp.value.type !== 'ObjectExpression') { + return { + error: `Property \`kit.experimental.instrumentation\` has unexpected type: ${kitExperimentalInstrumentationProp.value.type}`, + }; + } + + experimentalInstrumentationObject = + kitExperimentalInstrumentationProp.value; + } else { + experimentalInstrumentationObject = b.objectExpression([]); + experimentalObject.properties.push( + b.objectProperty( + b.identifier('instrumentation'), + experimentalInstrumentationObject, + ), + ); + } + + // 3. find or add `kit.experimental.tracing.server` property + // find or add `kit.experimental.instrumentation.server` property + const kitExperimentalTracingSeverProp = + experimentalTracingObject.properties.find( + (prop) => + prop.type === 'ObjectProperty' && + prop.key.type === 'Identifier' && + prop.key.name === 'server', + ) as t.ObjectProperty | undefined; + + const kitExperimentalInstrumentationSeverProp = + experimentalInstrumentationObject.properties.find( + (prop) => + prop.type === 'ObjectProperty' && + prop.key.type === 'Identifier' && + prop.key.name === 'server', + ) as t.ObjectProperty | undefined; + + if (kitExperimentalTracingSeverProp) { + if (kitExperimentalTracingSeverProp.value.type !== 'BooleanLiteral') { + return { + error: `Property \`kit.experimental.tracing.server\` has unexpected type: ${kitExperimentalTracingSeverProp.value.type}`, + }; + } + + kitExperimentalTracingSeverProp.value = b.booleanLiteral(enableTracing); + } else { + experimentalTracingObject.properties.push( + b.objectProperty(b.identifier('server'), b.booleanLiteral(enableTracing)), + ); + } + + if (kitExperimentalInstrumentationSeverProp) { + if ( + kitExperimentalInstrumentationSeverProp.value.type !== 'BooleanLiteral' + ) { + return { + error: `Property \`kit.experimental.instrumentation.server\` has unexpected type: ${kitExperimentalInstrumentationSeverProp.value.type}`, + }; + } + kitExperimentalInstrumentationSeverProp.value = b.booleanLiteral(true); + } else { + experimentalInstrumentationObject.properties.push( + b.objectProperty(b.identifier('server'), b.booleanLiteral(true)), + ); + } + + try { + return { + result: generateCode(svelteConfig).code, + }; + } catch (e) { + debug(e); + return { + error: 'Failed to generate code for Svelte config', + }; + } +} + +function _isValidConfigObject( + o: t.ObjectExpression | undefined, +): o is t.ObjectExpression { + return !!o && o.type === 'ObjectExpression'; +} + +async function showFallbackConfigSnippet(): Promise { + const codeSnippet = makeCodeSnippet(true, (unchanged, plus) => + unchanged(`const config = { +preprocess: vitePreprocess(), + +kit: { + adapter: adapter(), + ${plus(`experimental: { + instrumentation: { + server: true, + }, + tracing: { + server: true, + }, + },`)} +}, +}; +`), + ); + + await showCopyPasteInstructions({ + filename: 'svelte.config.js', + codeSnippet, + }); +} diff --git a/src/sveltekit/sdk-setup/types.ts b/src/sveltekit/sdk-setup/types.ts new file mode 100644 index 000000000..cf606cbd9 --- /dev/null +++ b/src/sveltekit/sdk-setup/types.ts @@ -0,0 +1,7 @@ +export type ProjectInfo = { + dsn: string; + org: string; + project: string; + selfHosted: boolean; + url: string; +}; diff --git a/src/sveltekit/sdk-setup/utils.ts b/src/sveltekit/sdk-setup/utils.ts new file mode 100644 index 000000000..178b8fcba --- /dev/null +++ b/src/sveltekit/sdk-setup/utils.ts @@ -0,0 +1,25 @@ +import { traceStep } from '../../telemetry'; +import * as Sentry from '@sentry/node'; + +/** + * Applies the @param modifyCallback and records Sentry tags if the call failed. + * In case of a failure, a tag is set with @param reason as a fail reason + * and the error is rethrown. + */ +export async function modifyAndRecordFail( + modifyCallback: () => T | Promise, + reason: string, + fileType: + | 'server-hooks' + | 'client-hooks' + | 'vite-cfg' + | 'instrumentation-server', +): Promise { + try { + await traceStep(`${fileType}-${reason}`, modifyCallback); + } catch (e) { + Sentry.setTag(`modified-${fileType}`, 'fail'); + Sentry.setTag(`${fileType}-mod-fail-reason`, reason); + throw e; + } +} diff --git a/src/sveltekit/sdk-setup/vite.ts b/src/sveltekit/sdk-setup/vite.ts new file mode 100644 index 000000000..ad0ce30f4 --- /dev/null +++ b/src/sveltekit/sdk-setup/vite.ts @@ -0,0 +1,144 @@ +import * as Sentry from '@sentry/node'; +import * as fs from 'fs'; +import * as path from 'path'; +import chalk from 'chalk'; + +//@ts-expect-error - clack is ESM and TS complains about that. It works though +import clack from '@clack/prompts'; +// @ts-expect-error - magicast is ESM and TS complains about that. It works though +import { generateCode, parseModule } from 'magicast'; +// @ts-expect-error - magicast is ESM and TS complains about that. It works though +import { addVitePlugin } from 'magicast/helpers'; + +import * as recast from 'recast'; +import x = recast.types; +import t = x.namedTypes; + +import { hasSentryContent } from '../../utils/ast-utils'; +import { debug } from '../../utils/debug'; +import { abortIfCancelled } from '../../utils/clack'; +import type { ProjectInfo } from './types'; +import { modifyAndRecordFail } from './utils'; + +export async function modifyViteConfig( + viteConfigPath: string, + projectInfo: ProjectInfo, +): Promise { + const viteConfigContent = ( + await fs.promises.readFile(viteConfigPath, 'utf-8') + ).toString(); + + const { org, project, url, selfHosted } = projectInfo; + + const prettyViteConfigFilename = chalk.cyan(path.basename(viteConfigPath)); + + try { + const viteModule = parseModule(viteConfigContent); + + if (hasSentryContent(viteModule.$ast as t.Program)) { + clack.log.warn( + `File ${prettyViteConfigFilename} already contains Sentry code. +Skipping adding Sentry functionality to.`, + ); + Sentry.setTag(`modified-vite-cfg`, 'fail'); + Sentry.setTag(`vite-cfg-fail-reason`, 'has-sentry-content'); + return; + } + + await modifyAndRecordFail( + () => + addVitePlugin(viteModule, { + imported: 'sentrySvelteKit', + from: '@sentry/sveltekit', + constructor: 'sentrySvelteKit', + options: { + sourceMapsUploadOptions: { + org, + project, + ...(selfHosted && { url }), + }, + }, + index: 0, + }), + 'add-vite-plugin', + 'vite-cfg', + ); + + await modifyAndRecordFail( + async () => { + const code = generateCode(viteModule.$ast).code; + await fs.promises.writeFile(viteConfigPath, code); + }, + 'write-file', + 'vite-cfg', + ); + } catch (e) { + debug(e); + await showFallbackViteCopyPasteSnippet( + viteConfigPath, + getViteConfigCodeSnippet(org, project, selfHosted, url), + ); + Sentry.captureException('Sveltekit Vite Config Modification Fail'); + } + + clack.log.success(`Added Sentry code to ${prettyViteConfigFilename}`); + Sentry.setTag(`modified-vite-cfg`, 'success'); +} + +async function showFallbackViteCopyPasteSnippet( + viteConfigPath: string, + codeSnippet: string, +) { + const viteConfigFilename = path.basename(viteConfigPath); + + clack.log.warning( + `Couldn't automatically modify your ${chalk.cyan(viteConfigFilename)} +${chalk.dim(`This sometimes happens when we encounter more complex vite configs. +It may not seem like it but sometimes our magical powers are limited ;)`)}`, + ); + + clack.log.info("But don't worry - it's super easy to do this yourself!"); + + clack.log.step( + `Add the following code to your ${chalk.cyan(viteConfigFilename)}:`, + ); + + // Intentionally logging to console here for easier copy/pasting + // eslint-disable-next-line no-console + console.log(codeSnippet); + + await abortIfCancelled( + clack.select({ + message: 'Did you copy the snippet above?', + options: [ + { label: 'Yes!', value: true, hint: "Great, that's already it!" }, + ], + initialValue: true, + }), + ); +} + +const getViteConfigCodeSnippet = ( + org: string, + project: string, + selfHosted: boolean, + url: string, +) => + chalk.gray(` +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; +${chalk.greenBright("import { sentrySvelteKit } from '@sentry/sveltekit'")} + +export default defineConfig({ + plugins: [ + // Make sure \`sentrySvelteKit\` is registered before \`sveltekit\` + ${chalk.greenBright(`sentrySvelteKit({ + sourceMapsUploadOptions: { + org: '${org}', + project: '${project}',${selfHosted ? `\n url: '${url}',` : ''} + } + }),`)} + sveltekit(), + ] +}); +`); diff --git a/src/sveltekit/sveltekit-wizard.ts b/src/sveltekit/sveltekit-wizard.ts index 95564afec..55cb488f0 100644 --- a/src/sveltekit/sveltekit-wizard.ts +++ b/src/sveltekit/sveltekit-wizard.ts @@ -24,7 +24,8 @@ import { NPM } from '../utils/package-manager'; import type { WizardOptions } from '../utils/types'; import { offerProjectScopedMcpConfig } from '../utils/clack/mcp-config'; import { createExamplePage } from './sdk-example'; -import { createOrMergeSvelteKitFiles, loadSvelteConfig } from './sdk-setup'; +import { createOrMergeSvelteKitFiles } from './sdk-setup/setup'; +import { loadSvelteConfig } from './sdk-setup/svelte-config'; import { getKitVersionBucket, getSvelteVersionBucket } from './utils'; export async function runSvelteKitWizard( @@ -87,6 +88,58 @@ export async function runSvelteKitWizardWithTelemetry( } } + let setupForSvelteKitTracing = kitVersionBucket === '>=2.31.0'; + + if (kitVersionBucket !== '>=2.31.0') { + clack.log.warn( + `It seems you're using a SvelteKit version ${chalk.cyan( + '<2.31.0', + )} (detected ${chalk.cyan(kitVersion ?? 'unknown')}). + +We recommend upgrading SvelteKit to version ${chalk.cyan( + '>=2.31.0', + )} to use SvelteKit's builtin observability: +${chalk.cyan('https://svelte.dev/docs/kit/observability')} + +Sentry works best with SvelteKit versions ${chalk.cyan('>=2.31.0')}. + +If you prefer, you can stay on your current version and use the Sentry SDK +without SvelteKit's builtin observability.`, + ); + + const decision = await abortIfCancelled( + clack.select({ + message: 'Do you want to continue anyway?', + options: [ + { + label: "No, I'll upgrade SvelteKit first", + hint: 'Recommended', + value: 'exit-to-upgrade', + }, + { + label: "I'm already on SvelteKit >=2.31.0", + hint: 'Sorry, my bad!', + value: 'install-with-kit-tracing', + }, + { + label: 'Yes, continue', + hint: 'No Problem!', + value: 'install-without-kit-tracing', + }, + ], + }), + ); + + if (decision === 'install-with-kit-tracing') { + setupForSvelteKitTracing = true; + } + + if (decision === 'exit-to-upgrade') { + await abort('Exiting Wizard', 0); + return; + } + } + Sentry.setTag( 'svelte-version', getSvelteVersionBucket(getPackageVersion('svelte', packageJson)), @@ -123,6 +176,7 @@ export async function runSvelteKitWizardWithTelemetry( url: sentryUrl, }, svelteConfig, + setupForSvelteKitTracing, ), ); } catch (e: unknown) { @@ -190,7 +244,7 @@ async function buildOutroMessage( ): Promise { const packageManager = await getPackageManager(NPM); - let msg = chalk.green('\nSuccessfully installed the Sentry SvelteKit SDK!'); + let msg = chalk.green('Successfully installed the Sentry SvelteKit SDK!'); if (shouldCreateExamplePage) { msg += `\n\nYou can validate your setup by starting your dev environment (${chalk.cyan( diff --git a/src/sveltekit/templates.ts b/src/sveltekit/templates.ts index 5303a2a12..2f0c51c93 100644 --- a/src/sveltekit/templates.ts +++ b/src/sveltekit/templates.ts @@ -53,10 +53,10 @@ export function getServerHooksTemplate( replay: boolean; logs: boolean; }, + includeSentryInit: boolean, ) { - return `import { sequence } from "@sveltejs/kit/hooks"; -import { handleErrorWithSentry, sentryHandle } from "@sentry/sveltekit"; -import * as Sentry from '@sentry/sveltekit'; + const sentryInit = includeSentryInit + ? `import * as Sentry from '@sentry/sveltekit'; Sentry.init({ dsn: '${dsn}', @@ -76,7 +76,12 @@ ${ } // uncomment the line below to enable Spotlight (https://spotlightjs.com) // spotlight: import.meta.env.DEV, -}); +});` + : ``; + + return `import { sequence } from "@sveltejs/kit/hooks"; +import { handleErrorWithSentry, sentryHandle } from "@sentry/sveltekit"; +${sentryInit} // If you have custom handlers, make sure to place them after \`sentryHandle()\` in the \`sequence\` function. export const handle = sequence(sentryHandle()); @@ -86,6 +91,36 @@ export const handleError = handleErrorWithSentry(); `; } +export function getInstrumentationServerTemplate( + dsn: string, + selectedFeatures: { + performance: boolean; + logs: boolean; + }, +) { + return `import * as Sentry from '@sentry/sveltekit'; + +Sentry.init({ + dsn: '${dsn}', +${ + selectedFeatures.performance + ? ` + tracesSampleRate: 1.0, +` + : '' +} +${ + selectedFeatures.logs + ? ` // Enable logs to be sent to Sentry + enableLogs: true, +` + : '' +} + // uncomment the line below to enable Spotlight (https://spotlightjs.com) + // spotlight: import.meta.env.DEV, +});`; +} + /** * +page.svelte with Sentry example */ diff --git a/src/sveltekit/utils.ts b/src/sveltekit/utils.ts index 54798dae7..1127dd9af 100644 --- a/src/sveltekit/utils.ts +++ b/src/sveltekit/utils.ts @@ -1,8 +1,16 @@ import { lt, minVersion } from 'semver'; +export type KitVersionBucket = + | 'none' + | 'invalid' + | '0.x' + | '>=1.0.0 <1.24.0' + | '>=1.24.0 <2.31.0' + | '>=2.31.0'; + export function getKitVersionBucket( version: string | undefined, -): 'none' | 'invalid' | '0.x' | '>=1.0.0 <1.24.0' | '>=1.24.0' { +): KitVersionBucket { if (!version) { return 'none'; } @@ -16,11 +24,15 @@ export function getKitVersionBucket( return '0.x'; } else if (lt(minVer, '1.24.0')) { return '>=1.0.0 <1.24.0'; - } else { + } else if (lt(minVer, '2.31.0')) { // This is the version when the client-side invalidation fix was released // https://github.com/sveltejs/kit/releases/tag/%40sveltejs%2Fkit%401.24.0 // https://github.com/sveltejs/kit/pull/10576 - return '>=1.24.0'; + return '>=1.24.0 <2.31.0'; + } else { + // This is the version where sveltekit-native tracing and instrumentation was + // introduced as an experimental feature. + return '>=2.31.0'; } } diff --git a/test/sveltekit/sdk-setup/svelte-config.test.ts b/test/sveltekit/sdk-setup/svelte-config.test.ts new file mode 100644 index 000000000..6c24c83b5 --- /dev/null +++ b/test/sveltekit/sdk-setup/svelte-config.test.ts @@ -0,0 +1,664 @@ +import { describe, it, expect } from 'vitest'; +import { _enableTracingAndInstrumentationInConfig } from '../../../src/sveltekit/sdk-setup/svelte-config'; + +describe('_enableTracingAndInstrumentationInConfig', () => { + it('leaves already correct config unchanged', () => { + const originalConfig = `export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + experimental: { + tracing: { + server: true, + }, + instrumentation: { + server: true, + }, + }, + }, +};`; + + const modifiedConfig = _enableTracingAndInstrumentationInConfig( + originalConfig, + true, + ); + + expect(modifiedConfig.result).toBe(originalConfig); + }); + + describe('successfully handles', () => { + it('default config as variable declaration', () => { + const originalConfig = `/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + }, +}; + +export default config; +`; + + const modifiedConfig = _enableTracingAndInstrumentationInConfig( + originalConfig, + true, + ); + + expect(modifiedConfig.result).toMatchInlineSnapshot(` + "/** @type {import('@sveltejs/kit').Config} */ + const config = { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + + experimental: { + tracing: { + server: true, + }, + + instrumentation: { + server: true, + }, + }, + }, + }; + + export default config;" + `); + }); + + it('default config named declaration object', () => { + const originalConfig = ` +export default config = { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + }, +}; +`; + + const modifiedConfig = _enableTracingAndInstrumentationInConfig( + originalConfig, + true, + ); + + expect(modifiedConfig.result).toMatchInlineSnapshot(` + "export default config = { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + + experimental: { + tracing: { + server: true, + }, + + instrumentation: { + server: true, + }, + }, + }, + };" + `); + }); + + it('default config as in-place object', () => { + const originalConfig = ` +export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + }, +}; +`; + + const modifiedConfig = _enableTracingAndInstrumentationInConfig( + originalConfig, + true, + ); + + expect(modifiedConfig.result).toMatchInlineSnapshot(` + "export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + + experimental: { + tracing: { + server: true, + }, + + instrumentation: { + server: true, + }, + }, + }, + };" + `); + }); + + it('config with tracing disabled', () => { + const originalConfig = ` +export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + }, +}; +`; + + const modifiedConfig = _enableTracingAndInstrumentationInConfig( + originalConfig, + false, + ); + + expect(modifiedConfig.result).toMatchInlineSnapshot(` + "export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + + experimental: { + tracing: { + server: false, + }, + + instrumentation: { + server: true, + }, + }, + }, + };" + `); + }); + + it('config with pre-existing `kit.experimental` property', () => { + const originalConfig = ` +export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + experimental: { + remoteFunctions: true, + } + }, +}; +`; + + const modifiedConfig = _enableTracingAndInstrumentationInConfig( + originalConfig, + true, + ); + + expect(modifiedConfig.result).toMatchInlineSnapshot(` + "export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + experimental: { + remoteFunctions: true, + + tracing: { + server: true, + }, + + instrumentation: { + server: true, + }, + } + }, + };" + `); + }); + + it('config with pre-existing and empty `kit.experimental.tracing` property', () => { + const originalConfig = ` +export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + experimental: { + remoteFunctions: true, + tracing: { + }, + } + }, +}; +`; + + const modifiedConfig = _enableTracingAndInstrumentationInConfig( + originalConfig, + true, + ); + + expect(modifiedConfig.result).toMatchInlineSnapshot(` + "export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + experimental: { + remoteFunctions: true, + + tracing: { + server: true, + }, + + instrumentation: { + server: true, + }, + } + }, + };" + `); + }); + + it('config with pre-existing and empty `kit.experimental.instrumentation` property', () => { + const originalConfig = ` +export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + experimental: { + remoteFunctions: true, + tracing: { + }, + instrumentation: { + }, + } + }, +}; +`; + + const modifiedConfig = _enableTracingAndInstrumentationInConfig( + originalConfig, + true, + ); + + expect(modifiedConfig.result).toMatchInlineSnapshot(` + "export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + experimental: { + remoteFunctions: true, + tracing: { + server: true, + }, + instrumentation: { + server: true, + }, + } + }, + };" + `); + }); + + it('config with pre-existing and filled `kit.experimental.(instrumentation|tracing).server` properties', () => { + const originalConfig = ` +export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + experimental: { + remoteFunctions: true, + tracing: { + server: false, + }, + instrumentation: { + server: false, + }, + } + }, +}; +`; + + const modifiedConfig = _enableTracingAndInstrumentationInConfig( + originalConfig, + true, + ); + + expect(modifiedConfig.result).toMatchInlineSnapshot(` + "export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + experimental: { + remoteFunctions: true, + tracing: { + server: true, + }, + instrumentation: { + server: true, + }, + } + }, + };" + `); + }); + + it('config with pre-existing and filled `kit.experimental.(instrumentation|tracing).server` properties with instrumentation disabled', () => { + const originalConfig = ` +export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + experimental: { + remoteFunctions: true, + tracing: { + server: true, + }, + instrumentation: { + server: false, + }, + } + }, +}; +`; + + const modifiedConfig = _enableTracingAndInstrumentationInConfig( + originalConfig, + true, + ); + + expect(modifiedConfig.result).toMatchInlineSnapshot(` + "export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + experimental: { + remoteFunctions: true, + tracing: { + server: true, + }, + instrumentation: { + server: true, + }, + } + }, + };" + `); + }); + + it('config with pre-existing and filled `kit.experimental.(instrumentation|tracing).server` properties with tracing disabled', () => { + const originalConfig = ` +export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + experimental: { + remoteFunctions: true, + tracing: { + server: false, + }, + instrumentation: { + server: true, + }, + } + }, +}; +`; + + const modifiedConfig = _enableTracingAndInstrumentationInConfig( + originalConfig, + true, + ); + + expect(modifiedConfig.result).toMatchInlineSnapshot(` + "export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + experimental: { + remoteFunctions: true, + tracing: { + server: true, + }, + instrumentation: { + server: true, + }, + } + }, + };" + `); + }); + }); + + describe('gracefully errors if', () => { + it('config object not found', () => { + const originalConfig = `console.log('hello')`; + + const modifiedConfig = _enableTracingAndInstrumentationInConfig( + originalConfig, + true, + ); + + expect(modifiedConfig.error).toBe("Couldn't find the config object"); + }); + + it('config is not an object', () => { + const originalConfig = ` + export default getSvelteConfig(); + `; + + const modifiedConfig = _enableTracingAndInstrumentationInConfig( + originalConfig, + true, + ); + + expect(modifiedConfig.error).toBe("Couldn't find the config object"); + }); + + it('`kit` property is missing', () => { + const originalConfig = ` + export default { + preprocess: vitePreprocess(), + }; + `; + + const modifiedConfig = _enableTracingAndInstrumentationInConfig( + originalConfig, + true, + ); + + expect(modifiedConfig.error).toBe("Couldn't find the `kit` property"); + }); + + it('`kit` property has unexpected type', () => { + const originalConfig = ` + export default { + preprocess: vitePreprocess(), + + kit: getKitConfig(), + }; + `; + + const modifiedConfig = _enableTracingAndInstrumentationInConfig( + originalConfig, + true, + ); + + expect(modifiedConfig.error).toBe( + '`kit` property has unexpected type: CallExpression', + ); + }); + + it('`kit.experimental` property has unexpected type', () => { + const originalConfig = ` + export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + experimental: 'hello', + }, + }; + `; + + const modifiedConfig = _enableTracingAndInstrumentationInConfig( + originalConfig, + true, + ); + + expect(modifiedConfig.error).toBe( + 'Property `kit.experimental` has unexpected type: StringLiteral', + ); + }); + + it('`kit.experimental.tracing` property has unexpected type', () => { + const originalConfig = ` + export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + experimental: { + tracing: true, + }, + }, + }; + `; + + const modifiedConfig = _enableTracingAndInstrumentationInConfig( + originalConfig, + true, + ); + + expect(modifiedConfig.error).toBe( + 'Property `kit.experimental.tracing` has unexpected type: BooleanLiteral', + ); + }); + + it('`kit.experimental.instrumentation` property has unexpected type', () => { + const originalConfig = ` + export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + experimental: { + tracing: { + server: true, + }, + instrumentation: 'server', + }, + }, + }; + `; + + const modifiedConfig = _enableTracingAndInstrumentationInConfig( + originalConfig, + true, + ); + + expect(modifiedConfig.error).toBe( + 'Property `kit.experimental.instrumentation` has unexpected type: StringLiteral', + ); + }); + + it('`kit.experimental.tracing.server` property has unexpected type', () => { + const originalConfig = ` + export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + experimental: { + tracing: { + server: !!process.env['ENABLE_TRACING'], + }, + instrumentation: { + server: true, + }, + }, + }, + }; + `; + + const modifiedConfig = _enableTracingAndInstrumentationInConfig( + originalConfig, + true, + ); + + expect(modifiedConfig.error).toBe( + 'Property `kit.experimental.tracing.server` has unexpected type: UnaryExpression', + ); + }); + + it('`kit.experimental.instrumentation.server` property has unexpected type', () => { + const originalConfig = ` + export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + experimental: { + tracing: { + server: true, + }, + instrumentation: { + server: 'hello', + }, + }, + }, + }; + `; + + const modifiedConfig = _enableTracingAndInstrumentationInConfig( + originalConfig, + true, + ); + + expect(modifiedConfig.error).toBe( + 'Property `kit.experimental.instrumentation.server` has unexpected type: StringLiteral', + ); + }); + + it('config parsing fails', () => { + const originalConfig = ` + export default { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter(), + experimental: { + tracing: { + server: true, + } + instrumentation: { + server: 'hello', + }, + }, + }, + }; + `; + + const modifiedConfig = _enableTracingAndInstrumentationInConfig( + originalConfig, + true, + ); + + expect(modifiedConfig.error).toBe('Failed to parse Svelte config'); + }); + }); +}); diff --git a/test/sveltekit/templates.test.ts b/test/sveltekit/templates.test.ts index 4f5329d06..3d4b3bed2 100644 --- a/test/sveltekit/templates.test.ts +++ b/test/sveltekit/templates.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { getClientHooksTemplate, + getInstrumentationServerTemplate, getServerHooksTemplate, } from '../../src/sveltekit/templates'; @@ -9,7 +10,7 @@ vi.mock('../../src/utils/clack/mcp-config', () => ({ })); describe('getClientHooksTemplate', () => { - it('should generate client hooks template with all features enabled', () => { + it('generates client hooks template with all features enabled', () => { const result = getClientHooksTemplate('https://sentry.io/123', { performance: true, replay: true, @@ -46,7 +47,7 @@ describe('getClientHooksTemplate', () => { `); }); - it('should generate client hooks template when performance disabled', () => { + it('generates client hooks template when performance disabled', () => { const result = getClientHooksTemplate('https://sentry.io/123', { performance: false, replay: true, @@ -79,7 +80,7 @@ describe('getClientHooksTemplate', () => { `); }); - it('should generate client hooks template when replay disabled', () => { + it('generates client hooks template when replay disabled', () => { const result = getClientHooksTemplate('https://sentry.io/123', { performance: true, replay: false, @@ -105,7 +106,7 @@ describe('getClientHooksTemplate', () => { `); }); - it('should generate client hooks template with only logs enabled', () => { + it('generates client hooks template with only logs enabled', () => { const result = getClientHooksTemplate('https://sentry.io/123', { performance: false, replay: false, @@ -133,12 +134,16 @@ describe('getClientHooksTemplate', () => { }); describe('getServerHooksTemplate', () => { - it('should generate server hooks template with all features enabled', () => { - const result = getServerHooksTemplate('https://sentry.io/123', { - performance: true, - replay: true, - logs: true, - }); + it('generates server hooks template with all features enabled', () => { + const result = getServerHooksTemplate( + 'https://sentry.io/123', + { + performance: true, + replay: true, + logs: true, + }, + true, + ); expect(result).toMatchInlineSnapshot(` "import { sequence } from "@sveltejs/kit/hooks"; @@ -166,12 +171,16 @@ describe('getServerHooksTemplate', () => { `); }); - it('should generate server hooks template when performance disabled', () => { - const result = getServerHooksTemplate('https://sentry.io/123', { - performance: false, - replay: true, - logs: false, - }); + it('generates server hooks template when performance disabled', () => { + const result = getServerHooksTemplate( + 'https://sentry.io/123', + { + performance: false, + replay: true, + logs: false, + }, + true, + ); expect(result).toMatchInlineSnapshot(` "import { sequence } from "@sveltejs/kit/hooks"; @@ -195,12 +204,16 @@ describe('getServerHooksTemplate', () => { `); }); - it('should generate server hooks template with only logs enabled', () => { - const result = getServerHooksTemplate('https://sentry.io/123', { - performance: false, - replay: false, - logs: true, - }); + it('generates server hooks template with only logs enabled', () => { + const result = getServerHooksTemplate( + 'https://sentry.io/123', + { + performance: false, + replay: false, + logs: true, + }, + true, + ); expect(result).toMatchInlineSnapshot(` "import { sequence } from "@sveltejs/kit/hooks"; @@ -225,4 +238,111 @@ describe('getServerHooksTemplate', () => { " `); }); + + it('generates server hooks template without Sentry.init if includeSentryInit is false', () => { + const result = getServerHooksTemplate( + 'https://sentry.io/123', + { + performance: false, + replay: false, + logs: true, + }, + false, + ); + + expect(result).toMatchInlineSnapshot(` + "import { sequence } from "@sveltejs/kit/hooks"; + import { handleErrorWithSentry, sentryHandle } from "@sentry/sveltekit"; + + + // If you have custom handlers, make sure to place them after \`sentryHandle()\` in the \`sequence\` function. + export const handle = sequence(sentryHandle()); + + // If you have a custom error handler, pass it to \`handleErrorWithSentry\` + export const handleError = handleErrorWithSentry(); + " + `); + }); +}); + +describe('getInstrumentationServerTemplate', () => { + it('generates instrumentation.server template with all features enabled', () => { + const result = getInstrumentationServerTemplate('https://sentry.io/123', { + performance: true, + logs: true, + }); + + expect(result).toMatchInlineSnapshot(` + "import * as Sentry from '@sentry/sveltekit'; + + Sentry.init({ + dsn: 'https://sentry.io/123', + + tracesSampleRate: 1.0, + + // Enable logs to be sent to Sentry + enableLogs: true, + + // uncomment the line below to enable Spotlight (https://spotlightjs.com) + // spotlight: import.meta.env.DEV, + });"`); + }); + + it('generates instrumentation.server template with only logs enabled', () => { + const result = getInstrumentationServerTemplate('https://sentry.io/123', { + performance: false, + logs: true, + }); + + expect(result).toMatchInlineSnapshot(` + "import * as Sentry from '@sentry/sveltekit'; + + Sentry.init({ + dsn: 'https://sentry.io/123', + + // Enable logs to be sent to Sentry + enableLogs: true, + + // uncomment the line below to enable Spotlight (https://spotlightjs.com) + // spotlight: import.meta.env.DEV, + });"`); + }); + + it('generates instrumentation.server template with only tracesSampleRate enabled', () => { + const result = getInstrumentationServerTemplate('https://sentry.io/123', { + performance: true, + logs: false, + }); + + expect(result).toMatchInlineSnapshot(` + "import * as Sentry from '@sentry/sveltekit'; + + Sentry.init({ + dsn: 'https://sentry.io/123', + + tracesSampleRate: 1.0, + + + // uncomment the line below to enable Spotlight (https://spotlightjs.com) + // spotlight: import.meta.env.DEV, + });"`); + }); + + it('generates instrumentation.server template without any extra features enabled', () => { + const result = getInstrumentationServerTemplate('https://sentry.io/123', { + performance: false, + logs: false, + }); + + expect(result).toMatchInlineSnapshot(` + "import * as Sentry from '@sentry/sveltekit'; + + Sentry.init({ + dsn: 'https://sentry.io/123', + + + // uncomment the line below to enable Spotlight (https://spotlightjs.com) + // spotlight: import.meta.env.DEV, + });"`); + }); });