diff --git a/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts b/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts index 0b9a5553fb0a..337068467558 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/app.config.ts @@ -1,8 +1,23 @@ -import { sentrySolidStartVite } from '@sentry/solidstart'; +import { withSentry } from '@sentry/solidstart'; import { defineConfig } from '@solidjs/start/config'; -export default defineConfig({ - vite: { - plugins: [sentrySolidStartVite()], - }, -}); +export default defineConfig( + withSentry( + {}, + { + // Typically we want to default to ./src/instrument.sever.ts + // `withSentry` would then build and copy the file over to + // the .output folder, but since we can't use the production + // server for our e2e tests, we have to delete the build folders + // prior to using the dev server for our tests. Which also gets + // rid of the instrument.server.mjs file that we need to --import. + // Therefore, we specify the .mjs file here and to ensure + // `withSentry` gets its file to build and we continue to reference + // the file from the `src` folder for --import without needing to + // transpile before. + // This can be removed once we get the production server to work + // with our e2e tests. + instrumentation: './src/instrument.server.mjs', + }, + ), +); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/tests/errorboundary.test.ts b/dev-packages/e2e-tests/test-applications/solidstart/tests/errorboundary.test.ts index b709760aab94..088f69df6380 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/tests/errorboundary.test.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/tests/errorboundary.test.ts @@ -11,6 +11,8 @@ test('captures an exception', async ({ page }) => { }); await page.goto('/error-boundary'); + // The first page load causes a hydration error on the dev server sometimes - a reload works around this + await page.reload(); await page.locator('#caughtErrorBtn').click(); const errorEvent = await errorEventPromise; diff --git a/packages/solidstart/.eslintrc.js b/packages/solidstart/.eslintrc.js index d567b12530d0..0fe78630b548 100644 --- a/packages/solidstart/.eslintrc.js +++ b/packages/solidstart/.eslintrc.js @@ -11,7 +11,7 @@ module.exports = { }, }, { - files: ['src/vite/**', 'src/server/**'], + files: ['src/vite/**', 'src/server/**', 'src/config/**'], rules: { '@sentry-internal/sdk/no-optional-chaining': 'off', '@sentry-internal/sdk/no-nullish-coalescing': 'off', diff --git a/packages/solidstart/README.md b/packages/solidstart/README.md index ceda55838e8d..2ec876b35c8c 100644 --- a/packages/solidstart/README.md +++ b/packages/solidstart/README.md @@ -60,7 +60,7 @@ mount(() => , document.getElementById('app')); ### 3. Server-side Setup -Create an instrument file named `instrument.server.mjs` and add your initialization code for the server-side SDK. +Create an instrument file named `src/instrument.server.ts` and add your initialization code for the server-side SDK. ```javascript import * as Sentry from '@sentry/solidstart'; @@ -101,16 +101,94 @@ export default defineConfig({ The Sentry middleware enhances the data collected by Sentry on the server side by enabling distributed tracing between the client and server. -### 5. Run your application +### 5. Configure your application + +For Sentry to work properly, SolidStart's `app.config.ts` has to be modified. Wrap your config with `withSentry` and +configure it to upload source maps. + +If your `instrument.server.ts` file is not located in the `src` folder, you can specify the path via the +`instrumentation` option to `withSentry`. + +To upload source maps, configure an auth token. Auth tokens can be passed explicitly with the `authToken` option, with a +`SENTRY_AUTH_TOKEN` environment variable, or with an `.env.sentry-build-plugin` file in the working directory when +building your project. We recommend adding the auth token to your CI/CD environment as an environment variable. + +Learn more about configuring the plugin in our +[Sentry Vite Plugin documentation](https://www.npmjs.com/package/@sentry/vite-plugin). + +```typescript +import { defineConfig } from '@solidjs/start/config'; +import { withSentry } from '@sentry/solidstart'; + +export default defineConfig( + withSentry( + { + // SolidStart config + middleware: './src/middleware.ts', + }, + { + // Sentry `withSentry` options + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + debug: true, + // optional: if your `instrument.server.ts` file is not located inside `src` + instrumentation: './mypath/instrument.server.ts', + }, + ), +); +``` + +### 6. Run your application Then run your app ```bash -NODE_OPTIONS='--import=./instrument.server.mjs' yarn start -# or -NODE_OPTIONS='--require=./instrument.server.js' yarn start +NODE_OPTIONS='--import=./.output/server/instrument.server.mjs' yarn start ``` +⚠️ **Note build presets** ⚠️ +Depending on [build preset](https://nitro.unjs.io/deploy), the location of `instrument.server.mjs` differs. To find out +where `instrument.server.mjs` is located, monitor the build log output for + +```bash +[Sentry SolidStart withSentry] Successfully created /my/project/path/.output/server/instrument.server.mjs. +``` + +⚠️ **Note for platforms without the ability to modify `NODE_OPTIONS` or use `--import`** ⚠️ +Depending on where the application is deployed to, it might not be possible to modify or use `NODE_OPTIONS` to import +`instrument.server.mjs`. + +For such platforms, we offer the `experimental_basicServerTracing` flag to add a top level import of +`instrument.server.mjs` to the server entry file. + +```typescript +import { defineConfig } from '@solidjs/start/config'; +import { withSentry } from '@sentry/solidstart'; + +export default defineConfig( + withSentry( + { + // ... + middleware: './src/middleware.ts', + }, + { + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + debug: true, + // optional: if your `instrument.server.ts` file is not located inside `src` + instrumentation: './mypath/instrument.server.ts', + // optional: if NODE_OPTIONS or --import is not avaiable + experimental_basicServerTracing: true, + }, + ), +); +``` + +This has a **fundamental restriction**: It only supports limited performance instrumentation. **Only basic http +instrumentation** will work, and no DB or framework-specific instrumentation will be available. + # Solid Router The Solid Router instrumentation uses the Solid Router library to create navigation spans to ensure you collect @@ -156,35 +234,3 @@ render( document.getElementById('root'), ); ``` - -## Uploading Source Maps - -To upload source maps, add the `sentrySolidStartVite` plugin from `@sentry/solidstart` to your `app.config.ts` and -configure an auth token. Auth tokens can be passed to the plugin explicitly with the `authToken` option, with a -`SENTRY_AUTH_TOKEN` environment variable, or with an `.env.sentry-build-plugin` file in the working directory when -building your project. We recommend you add the auth token to your CI/CD environment as an environment variable. - -Learn more about configuring the plugin in our -[Sentry Vite Plugin documentation](https://www.npmjs.com/package/@sentry/vite-plugin). - -```typescript -// app.config.ts -import { defineConfig } from '@solidjs/start/config'; -import { sentrySolidStartVite } from '@sentry/solidstart'; - -export default defineConfig({ - // ... - - vite: { - plugins: [ - sentrySolidStartVite({ - org: process.env.SENTRY_ORG, - project: process.env.SENTRY_PROJECT, - authToken: process.env.SENTRY_AUTH_TOKEN, - debug: true, - }), - ], - }, - // ... -}); -``` diff --git a/packages/solidstart/src/config/addInstrumentation.ts b/packages/solidstart/src/config/addInstrumentation.ts new file mode 100644 index 000000000000..1aab37cfc157 --- /dev/null +++ b/packages/solidstart/src/config/addInstrumentation.ts @@ -0,0 +1,95 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { consoleSandbox } from '@sentry/utils'; +import type { Nitro } from './types'; + +// Nitro presets for hosts that only host static files +export const staticHostPresets = ['github_pages']; +// Nitro presets for hosts that use `server.mjs` as opposed to `index.mjs` +export const serverFilePresets = ['netlify']; + +/** + * Adds the built `instrument.server.js` file to the output directory. + * + * This will no-op if no `instrument.server.js` file was found in the + * build directory. Make sure the `sentrySolidStartVite` plugin was + * added to `app.config.ts` to enable building the instrumentation file. + */ +export async function addInstrumentationFileToBuild(nitro: Nitro): Promise { + // Static file hosts have no server component so there's nothing to do + if (staticHostPresets.includes(nitro.options.preset)) { + return; + } + + const buildDir = nitro.options.buildDir; + const serverDir = nitro.options.output.serverDir; + const source = path.resolve(buildDir, 'build', 'ssr', 'instrument.server.js'); + const destination = path.resolve(serverDir, 'instrument.server.mjs'); + + try { + await fs.promises.copyFile(source, destination); + + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.log(`[Sentry SolidStart withSentry] Successfully created ${destination}.`); + }); + } catch (error) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn(`[Sentry SolidStart withSentry] Failed to create ${destination}.`, error); + }); + } +} + +/** + * Adds an `instrument.server.mjs` import to the top of the server entry file. + * + * This is meant as an escape hatch and should only be used in environments where + * it's not possible to `--import` the file instead as it comes with a limited + * tracing experience, only collecting http traces. + */ +export async function experimental_addInstrumentationFileTopLevelImportToServerEntry( + serverDir: string, + preset: string, +): Promise { + // Static file hosts have no server component so there's nothing to do + if (staticHostPresets.includes(preset)) { + return; + } + + const instrumentationFile = path.resolve(serverDir, 'instrument.server.mjs'); + const serverEntryFileName = serverFilePresets.includes(preset) ? 'server.mjs' : 'index.mjs'; + const serverEntryFile = path.resolve(serverDir, serverEntryFileName); + + try { + await fs.promises.access(instrumentationFile, fs.constants.F_OK); + } catch (error) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + `[Sentry SolidStart withSentry] Failed to add \`${instrumentationFile}\` as top level import to \`${serverEntryFile}\`.`, + error, + ); + }); + return; + } + + try { + const content = await fs.promises.readFile(serverEntryFile, 'utf-8'); + const updatedContent = `import './instrument.server.mjs';\n${content}`; + await fs.promises.writeFile(serverEntryFile, updatedContent); + + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.log( + `[Sentry SolidStart withSentry] Added \`${instrumentationFile}\` as top level import to \`${serverEntryFile}\`.`, + ); + }); + } catch (error) { + // eslint-disable-next-line no-console + console.warn( + `[Sentry SolidStart withSentry] An error occurred when trying to add \`${instrumentationFile}\` as top level import to \`${serverEntryFile}\`.`, + error, + ); + } +} diff --git a/packages/solidstart/src/config/index.ts b/packages/solidstart/src/config/index.ts new file mode 100644 index 000000000000..4cf4b985c18a --- /dev/null +++ b/packages/solidstart/src/config/index.ts @@ -0,0 +1,2 @@ +export * from './withSentry'; +export type { Nitro, SentrySolidStartConfigOptions } from './types'; diff --git a/packages/solidstart/src/config/types.ts b/packages/solidstart/src/config/types.ts new file mode 100644 index 000000000000..2c67942c8a4d --- /dev/null +++ b/packages/solidstart/src/config/types.ts @@ -0,0 +1,35 @@ +import type { defineConfig } from '@solidjs/start/config'; +// Types to avoid pulling in extra dependencies +// These are non-exhaustive +export type Nitro = { + options: { + buildDir: string; + output: { + serverDir: string; + }; + preset: string; + }; +}; + +export type SolidStartInlineConfig = Parameters[0]; + +export type SolidStartInlineServerConfig = { + hooks?: { + close?: () => unknown; + 'rollup:before'?: (nitro: Nitro) => unknown; + }; +}; + +export type SentrySolidStartConfigOptions = { + /** + * Enabling basic server tracing can be used for environments where modifying the node option `--import` is not possible. + * However, enabling this option only supports limited tracing instrumentation. Only http traces will be collected (but no database-specific traces etc.). + * + * If this option is `true`, the Sentry SDK will import the instrumentation.server.ts|js file at the top of the server entry file to load the SDK on the server. + * + * **DO NOT** enable this option if you've already added the node option `--import` in your node start script. This would initialize Sentry twice on the server-side and leads to unexpected issues. + * + * @default false + */ + experimental_basicServerTracing?: boolean; +}; diff --git a/packages/solidstart/src/config/withSentry.ts b/packages/solidstart/src/config/withSentry.ts new file mode 100644 index 000000000000..a91d7e4458ad --- /dev/null +++ b/packages/solidstart/src/config/withSentry.ts @@ -0,0 +1,63 @@ +import { addSentryPluginToVite } from '../vite'; +import type { SentrySolidStartPluginOptions } from '../vite/types'; +import { + addInstrumentationFileToBuild, + experimental_addInstrumentationFileTopLevelImportToServerEntry, +} from './addInstrumentation'; +import type { Nitro, SolidStartInlineConfig, SolidStartInlineServerConfig } from './types'; + +/** + * Modifies the passed in Solid Start configuration with build-time enhancements such as + * building the `instrument.server.ts` file into the appropriate build folder based on + * build preset. + * + * @param solidStartConfig A Solid Start configuration object, as usually passed to `defineConfig` in `app.config.ts|js` + * @param sentrySolidStartPluginOptions Options to configure the plugin + * @returns The modified config to be exported and passed back into `defineConfig` + */ +export const withSentry = ( + solidStartConfig: SolidStartInlineConfig = {}, + sentrySolidStartPluginOptions: SentrySolidStartPluginOptions = {}, +): SolidStartInlineConfig => { + const server = (solidStartConfig.server || {}) as SolidStartInlineServerConfig; + const hooks = server.hooks || {}; + const vite = + typeof solidStartConfig.vite === 'function' + ? (...args: unknown[]) => addSentryPluginToVite(solidStartConfig.vite(...args), sentrySolidStartPluginOptions) + : addSentryPluginToVite(solidStartConfig.vite, sentrySolidStartPluginOptions); + + let serverDir: string; + let buildPreset: string; + + return { + ...solidStartConfig, + vite, + server: { + ...server, + hooks: { + ...hooks, + async close() { + if (sentrySolidStartPluginOptions.experimental_basicServerTracing) { + await experimental_addInstrumentationFileTopLevelImportToServerEntry(serverDir, buildPreset); + } + + // Run user provided hook + if (hooks.close) { + hooks.close(); + } + }, + async 'rollup:before'(nitro: Nitro) { + serverDir = nitro.options.output.serverDir; + buildPreset = nitro.options.preset; + + await addInstrumentationFileToBuild(nitro); + + // Run user provided hook + if (hooks['rollup:before']) { + hooks['rollup:before'](nitro); + } + }, + }, + }, + }; +}; diff --git a/packages/solidstart/src/index.server.ts b/packages/solidstart/src/index.server.ts index d675a1c72820..a20a0367f557 100644 --- a/packages/solidstart/src/index.server.ts +++ b/packages/solidstart/src/index.server.ts @@ -1,2 +1,3 @@ export * from './server'; export * from './vite'; +export * from './config'; diff --git a/packages/solidstart/src/index.types.ts b/packages/solidstart/src/index.types.ts index 51adf848775a..13b9a6dd7432 100644 --- a/packages/solidstart/src/index.types.ts +++ b/packages/solidstart/src/index.types.ts @@ -4,6 +4,7 @@ export * from './client'; export * from './server'; export * from './vite'; +export * from './config'; import type { Integration, Options, StackParser } from '@sentry/types'; diff --git a/packages/solidstart/src/vite/buildInstrumentationFile.ts b/packages/solidstart/src/vite/buildInstrumentationFile.ts new file mode 100644 index 000000000000..abb02e8d03ce --- /dev/null +++ b/packages/solidstart/src/vite/buildInstrumentationFile.ts @@ -0,0 +1,55 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { consoleSandbox } from '@sentry/utils'; +import type { Plugin, UserConfig } from 'vite'; +import type { SentrySolidStartPluginOptions } from './types'; + +/** + * A Sentry plugin for SolidStart to build the server + * `instrument.server.ts` file. + */ +export function makeBuildInstrumentationFilePlugin(options: SentrySolidStartPluginOptions = {}): Plugin { + return { + name: 'sentry-solidstart-build-instrumentation-file', + apply: 'build', + enforce: 'post', + async config(config: UserConfig, { command }) { + const instrumentationFilePath = options.instrumentation || './src/instrument.server.ts'; + const router = (config as UserConfig & { router: { target: string; name: string; root: string } }).router; + const build = config.build || {}; + const rollupOptions = build.rollupOptions || {}; + const input = [...((rollupOptions.input || []) as string[])]; + + // plugin runs for client, server and sever-fns, we only want to run it for the server once. + if (command !== 'build' || router.target !== 'server' || router.name === 'server-fns') { + return config; + } + + try { + await fs.promises.access(instrumentationFilePath, fs.constants.F_OK); + } catch (error) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + `[Sentry SolidStart Plugin] Could not access \`${instrumentationFilePath}\`, please make sure it exists.`, + error, + ); + }); + return config; + } + + input.push(path.resolve(router.root, instrumentationFilePath)); + + return { + ...config, + build: { + ...build, + rollupOptions: { + ...rollupOptions, + input, + }, + }, + }; + }, + }; +} diff --git a/packages/solidstart/src/vite/sentrySolidStartVite.ts b/packages/solidstart/src/vite/sentrySolidStartVite.ts index 59435f919071..227a303b0ad4 100644 --- a/packages/solidstart/src/vite/sentrySolidStartVite.ts +++ b/packages/solidstart/src/vite/sentrySolidStartVite.ts @@ -1,4 +1,5 @@ -import type { Plugin } from 'vite'; +import type { Plugin, UserConfig } from 'vite'; +import { makeBuildInstrumentationFilePlugin } from './buildInstrumentationFile'; import { makeSourceMapsVitePlugin } from './sourceMaps'; import type { SentrySolidStartPluginOptions } from './types'; @@ -14,5 +15,26 @@ export const sentrySolidStartVite = (options: SentrySolidStartPluginOptions = {} } } + // TODO: Ensure this file is source mapped too. + // Placing this after the sentry vite plugin means this + // file won't get a sourcemap and won't have a debug id injected. + // Because the file is just copied over to the output server + // directory the release injection file from sentry vite plugin + // wouldn't resolve correctly otherwise. + sentryPlugins.push(makeBuildInstrumentationFilePlugin(options)); + return sentryPlugins; }; + +/** + * Helper to add the Sentry SolidStart vite plugin to a vite config. + */ +export const addSentryPluginToVite = (config: UserConfig = {}, options: SentrySolidStartPluginOptions): UserConfig => { + const plugins = Array.isArray(config.plugins) ? [...config.plugins] : []; + plugins.unshift(sentrySolidStartVite(options)); + + return { + ...config, + plugins, + }; +}; diff --git a/packages/solidstart/src/vite/sourceMaps.ts b/packages/solidstart/src/vite/sourceMaps.ts index 548038515e79..21dce8070c73 100644 --- a/packages/solidstart/src/vite/sourceMaps.ts +++ b/packages/solidstart/src/vite/sourceMaps.ts @@ -22,7 +22,7 @@ export function makeSourceMapsVitePlugin(options: SentrySolidStartPluginOptions) if (!sourceMapsUploadOptions?.filesToDeleteAfterUpload) { // eslint-disable-next-line no-console console.warn( - `[Sentry SolidStart PLugin] We recommend setting the \`sourceMapsUploadOptions.filesToDeleteAfterUpload\` option to clean up source maps after uploading. + `[Sentry SolidStart Plugin] We recommend setting the \`sourceMapsUploadOptions.filesToDeleteAfterUpload\` option to clean up source maps after uploading. [Sentry SolidStart Plugin] Otherwise, source maps might be deployed to production, depending on your configuration`, ); } diff --git a/packages/solidstart/src/vite/types.ts b/packages/solidstart/src/vite/types.ts index 4a64e4856b5d..c31e901efc2e 100644 --- a/packages/solidstart/src/vite/types.ts +++ b/packages/solidstart/src/vite/types.ts @@ -85,7 +85,7 @@ type BundleSizeOptimizationOptions = { }; /** - * Build options for the Sentry module. These options are used during build-time by the Sentry SDK. + * Build options for the Sentry plugin. These options are used during build-time by the Sentry SDK. */ export type SentrySolidStartPluginOptions = { /** @@ -125,4 +125,24 @@ export type SentrySolidStartPluginOptions = { * Enabling this will give you, for example logs about source maps. */ debug?: boolean; + + /** + * The path to your `instrument.server.ts|js` file. + * e.g. `./src/instrument.server.ts` + * + * Defaults to: `./src/instrument.server.ts` + */ + instrumentation?: string; + + /** + * Enabling basic server tracing can be used for environments where modifying the node option `--import` is not possible. + * However, enabling this option only supports limited tracing instrumentation. Only http traces will be collected (but no database-specific traces etc.). + * + * If this option is `true`, the Sentry SDK will import the instrumentation.server.ts|js file at the top of the server entry file to load the SDK on the server. + * + * **DO NOT** enable this option if you've already added the node option `--import` in your node start script. This would initialize Sentry twice on the server-side and leads to unexpected issues. + * + * @default false + */ + experimental_basicServerTracing?: boolean; }; diff --git a/packages/solidstart/test/config/addInstrumentation.test.ts b/packages/solidstart/test/config/addInstrumentation.test.ts new file mode 100644 index 000000000000..7f20911a70b3 --- /dev/null +++ b/packages/solidstart/test/config/addInstrumentation.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Nitro } from '../../build/types/config/types'; +import { + addInstrumentationFileToBuild, + experimental_addInstrumentationFileTopLevelImportToServerEntry, + serverFilePresets, + staticHostPresets, +} from '../../src/config/addInstrumentation'; + +const consoleLogSpy = vi.spyOn(console, 'log'); +const consoleWarnSpy = vi.spyOn(console, 'warn'); +const fsAccessMock = vi.fn(); +const fsCopyFileMock = vi.fn(); +const fsReadFile = vi.fn(); +const fsWriteFileMock = vi.fn(); + +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + promises: { + // @ts-expect-error this exists + ...actual.promises, + access: (...args: unknown[]) => fsAccessMock(...args), + copyFile: (...args: unknown[]) => fsCopyFileMock(...args), + readFile: (...args: unknown[]) => fsReadFile(...args), + writeFile: (...args: unknown[]) => fsWriteFileMock(...args), + }, + }; +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('addInstrumentationFileToBuild()', () => { + const nitroOptions: Nitro = { + options: { + buildDir: '/path/to/buildDir', + output: { + serverDir: '/path/to/serverDir', + }, + preset: 'vercel', + }, + }; + + it('adds `instrument.server.mjs` to the server output directory', async () => { + fsCopyFileMock.mockResolvedValueOnce(true); + await addInstrumentationFileToBuild(nitroOptions); + expect(fsCopyFileMock).toHaveBeenCalledWith( + '/path/to/buildDir/build/ssr/instrument.server.js', + '/path/to/serverDir/instrument.server.mjs', + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + '[Sentry SolidStart withSentry] Successfully created /path/to/serverDir/instrument.server.mjs.', + ); + }); + + it('warns when `instrument.server.js` can not be copied to the server output directory', async () => { + const error = new Error('Failed to copy file.'); + fsCopyFileMock.mockRejectedValueOnce(error); + await addInstrumentationFileToBuild(nitroOptions); + expect(fsCopyFileMock).toHaveBeenCalledWith( + '/path/to/buildDir/build/ssr/instrument.server.js', + '/path/to/serverDir/instrument.server.mjs', + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[Sentry SolidStart withSentry] Failed to create /path/to/serverDir/instrument.server.mjs.', + error, + ); + }); + + it.each([staticHostPresets])("doesn't add `instrument.server.mjs` for static host `%s`", async preset => { + await addInstrumentationFileToBuild({ + ...nitroOptions, + options: { + ...nitroOptions.options, + preset, + }, + }); + expect(fsCopyFileMock).not.toHaveBeenCalled(); + }); +}); + +describe('experimental_addInstrumentationFileTopLevelImportToServerEntry()', () => { + it('adds a top level import of `instrument.server.mjs` to the index.mjs entry file', async () => { + fsAccessMock.mockResolvedValueOnce(true); + fsReadFile.mockResolvedValueOnce("import process from 'node:process';"); + fsWriteFileMock.mockResolvedValueOnce(true); + await experimental_addInstrumentationFileTopLevelImportToServerEntry('/path/to/serverDir', 'node_server'); + expect(fsWriteFileMock).toHaveBeenCalledWith( + '/path/to/serverDir/index.mjs', + "import './instrument.server.mjs';\nimport process from 'node:process';", + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + '[Sentry SolidStart withSentry] Added `/path/to/serverDir/instrument.server.mjs` as top level import to `/path/to/serverDir/index.mjs`.', + ); + }); + + it.each([serverFilePresets])( + 'adds a top level import of `instrument.server.mjs` to the server.mjs entry file for preset `%s`', + async preset => { + fsAccessMock.mockResolvedValueOnce(true); + fsReadFile.mockResolvedValueOnce("import process from 'node:process';"); + fsWriteFileMock.mockResolvedValueOnce(true); + await experimental_addInstrumentationFileTopLevelImportToServerEntry('/path/to/serverDir', preset); + expect(fsWriteFileMock).toHaveBeenCalledWith( + '/path/to/serverDir/server.mjs', + "import './instrument.server.mjs';\nimport process from 'node:process';", + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + '[Sentry SolidStart withSentry] Added `/path/to/serverDir/instrument.server.mjs` as top level import to `/path/to/serverDir/server.mjs`.', + ); + }, + ); + + it("doesn't modify the sever entry file if `instrumentation.server.mjs` is not found", async () => { + const error = new Error('File not found.'); + fsAccessMock.mockRejectedValueOnce(error); + await experimental_addInstrumentationFileTopLevelImportToServerEntry('/path/to/serverDir', 'node_server'); + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[Sentry SolidStart withSentry] Failed to add `/path/to/serverDir/instrument.server.mjs` as top level import to `/path/to/serverDir/index.mjs`.', + error, + ); + }); + + it.each([staticHostPresets])( + "doesn't import `instrument.server.mjs` as top level import for host `%s`", + async preset => { + fsAccessMock.mockResolvedValueOnce(true); + await experimental_addInstrumentationFileTopLevelImportToServerEntry('/path/to/serverDir', preset); + expect(fsWriteFileMock).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/packages/solidstart/test/config/withSentry.test.ts b/packages/solidstart/test/config/withSentry.test.ts new file mode 100644 index 000000000000..52ebb2449c25 --- /dev/null +++ b/packages/solidstart/test/config/withSentry.test.ts @@ -0,0 +1,146 @@ +import type { Plugin } from 'vite'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Nitro } from '../../src/config'; +import { withSentry } from '../../src/config'; + +const userDefinedNitroRollupBeforeHookMock = vi.fn(); +const userDefinedNitroCloseHookMock = vi.fn(); +const addInstrumentationFileToBuildMock = vi.fn(); +const experimental_addInstrumentationFileTopLevelImportToServerEntryMock = vi.fn(); + +vi.mock('../../src/config/addInstrumentation', () => ({ + addInstrumentationFileToBuild: (...args: unknown[]) => addInstrumentationFileToBuildMock(...args), + experimental_addInstrumentationFileTopLevelImportToServerEntry: (...args: unknown[]) => + experimental_addInstrumentationFileTopLevelImportToServerEntryMock(...args), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('withSentry()', () => { + const solidStartConfig = { + middleware: './src/middleware.ts', + server: { + hooks: { + close: userDefinedNitroCloseHookMock, + 'rollup:before': userDefinedNitroRollupBeforeHookMock, + }, + }, + }; + const nitroOptions: Nitro = { + options: { + buildDir: '/path/to/buildDir', + output: { + serverDir: '/path/to/serverDir', + }, + preset: 'vercel', + }, + }; + + it('adds a nitro hook to add the instrumentation file to the build', async () => { + const config = withSentry(solidStartConfig); + await config?.server.hooks['rollup:before'](nitroOptions); + expect(addInstrumentationFileToBuildMock).toHaveBeenCalledWith(nitroOptions); + expect(userDefinedNitroRollupBeforeHookMock).toHaveBeenCalledWith(nitroOptions); + }); + + it('adds a nitro hook to add the instrumentation file as top level import to the server entry file', async () => { + const config = withSentry(solidStartConfig, { experimental_basicServerTracing: true }); + await config?.server.hooks['rollup:before'](nitroOptions); + await config?.server.hooks['close'](nitroOptions); + expect(experimental_addInstrumentationFileTopLevelImportToServerEntryMock).toHaveBeenCalledWith( + '/path/to/serverDir', + 'vercel', + ); + expect(userDefinedNitroCloseHookMock).toHaveBeenCalled(); + }); + + it('does not add the instrumentation file as top level import if experimental flag was not true', async () => { + const config = withSentry(solidStartConfig, { experimental_basicServerTracing: false }); + await config?.server.hooks['rollup:before'](nitroOptions); + await config?.server.hooks['close'](nitroOptions); + expect(experimental_addInstrumentationFileTopLevelImportToServerEntryMock).not.toHaveBeenCalled(); + expect(userDefinedNitroCloseHookMock).toHaveBeenCalled(); + }); + + it('adds the sentry solidstart vite plugin', () => { + const config = withSentry(solidStartConfig, { + project: 'project', + org: 'org', + authToken: 'token', + }); + const names = config?.vite.plugins.flat().map((plugin: Plugin) => plugin.name); + expect(names).toEqual([ + 'sentry-solidstart-source-maps', + 'sentry-telemetry-plugin', + 'sentry-vite-release-injection-plugin', + 'sentry-debug-id-upload-plugin', + 'sentry-vite-debug-id-injection-plugin', + 'sentry-vite-debug-id-upload-plugin', + 'sentry-file-deletion-plugin', + 'sentry-solidstart-build-instrumentation-file', + ]); + }); + + it('extends the passed in vite config object', () => { + const config = withSentry( + { + ...solidStartConfig, + vite: { + plugins: [{ name: 'my-test-plugin' }], + }, + }, + { + project: 'project', + org: 'org', + authToken: 'token', + }, + ); + + const names = config?.vite.plugins.flat().map((plugin: Plugin) => plugin.name); + expect(names).toEqual([ + 'sentry-solidstart-source-maps', + 'sentry-telemetry-plugin', + 'sentry-vite-release-injection-plugin', + 'sentry-debug-id-upload-plugin', + 'sentry-vite-debug-id-injection-plugin', + 'sentry-vite-debug-id-upload-plugin', + 'sentry-file-deletion-plugin', + 'sentry-solidstart-build-instrumentation-file', + 'my-test-plugin', + ]); + }); + + it('extends the passed in vite function config', () => { + const config = withSentry( + { + ...solidStartConfig, + vite() { + return { plugins: [{ name: 'my-test-plugin' }] }; + }, + }, + { + project: 'project', + org: 'org', + authToken: 'token', + }, + ); + + const names = config + ?.vite() + .plugins.flat() + .map((plugin: Plugin) => plugin.name); + expect(names).toEqual([ + 'sentry-solidstart-source-maps', + 'sentry-telemetry-plugin', + 'sentry-vite-release-injection-plugin', + 'sentry-debug-id-upload-plugin', + 'sentry-vite-debug-id-injection-plugin', + 'sentry-vite-debug-id-upload-plugin', + 'sentry-file-deletion-plugin', + 'sentry-solidstart-build-instrumentation-file', + 'my-test-plugin', + ]); + }); +}); diff --git a/packages/solidstart/test/vite/buildInstrumentation.test.ts b/packages/solidstart/test/vite/buildInstrumentation.test.ts new file mode 100644 index 000000000000..52378a668870 --- /dev/null +++ b/packages/solidstart/test/vite/buildInstrumentation.test.ts @@ -0,0 +1,130 @@ +import type { UserConfig } from 'vite'; +import { describe, expect, it, vi } from 'vitest'; +import { makeBuildInstrumentationFilePlugin } from '../../src/vite/buildInstrumentationFile'; + +const fsAccessMock = vi.fn(); + +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + promises: { + // @ts-expect-error this exists + ...actual.promises, + access: () => fsAccessMock(), + }, + }; +}); + +const consoleWarnSpy = vi.spyOn(console, 'warn'); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('makeBuildInstrumentationFilePlugin()', () => { + const viteConfig: UserConfig & { router: { target: string; name: string; root: string } } = { + router: { + target: 'server', + name: 'ssr', + root: '/some/project/path', + }, + build: { + rollupOptions: { + input: ['/path/to/entry1.js', '/path/to/entry2.js'], + }, + }, + }; + + it('returns a plugin to set `sourcemaps` to `true`', () => { + const buildInstrumentationFilePlugin = makeBuildInstrumentationFilePlugin(); + + expect(buildInstrumentationFilePlugin.name).toEqual('sentry-solidstart-build-instrumentation-file'); + expect(buildInstrumentationFilePlugin.apply).toEqual('build'); + expect(buildInstrumentationFilePlugin.enforce).toEqual('post'); + expect(buildInstrumentationFilePlugin.config).toEqual(expect.any(Function)); + }); + + it('adds the instrumentation file for server builds', async () => { + const buildInstrumentationFilePlugin = makeBuildInstrumentationFilePlugin(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - this is always defined and always a function + const config = await buildInstrumentationFilePlugin.config(viteConfig, { command: 'build' }); + expect(config.build.rollupOptions.input).toContain('/some/project/path/src/instrument.server.ts'); + }); + + it('adds the correct instrumentation file', async () => { + const buildInstrumentationFilePlugin = makeBuildInstrumentationFilePlugin({ + instrumentation: './src/myapp/instrument.server.ts', + }); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - this is always defined and always a function + const config = await buildInstrumentationFilePlugin.config(viteConfig, { command: 'build' }); + expect(config.build.rollupOptions.input).toContain('/some/project/path/src/myapp/instrument.server.ts'); + }); + + it("doesn't add the instrumentation file for server function builds", async () => { + const buildInstrumentationFilePlugin = makeBuildInstrumentationFilePlugin(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - this is always defined and always a function + const config = await buildInstrumentationFilePlugin.config( + { + ...viteConfig, + router: { + ...viteConfig.router, + name: 'server-fns', + }, + }, + { command: 'build' }, + ); + expect(config.build.rollupOptions.input).not.toContain('/some/project/path/src/instrument.server.ts'); + }); + + it("doesn't add the instrumentation file for client builds", async () => { + const buildInstrumentationFilePlugin = makeBuildInstrumentationFilePlugin(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - this is always defined and always a function + const config = await buildInstrumentationFilePlugin.config( + { + ...viteConfig, + router: { + ...viteConfig.router, + target: 'client', + }, + }, + { command: 'build' }, + ); + expect(config.build.rollupOptions.input).not.toContain('/some/project/path/src/instrument.server.ts'); + }); + + it("doesn't add the instrumentation file when serving", async () => { + const buildInstrumentationFilePlugin = makeBuildInstrumentationFilePlugin(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - this is always defined and always a function + const config = await buildInstrumentationFilePlugin.config(viteConfig, { command: 'serve' }); + expect(config.build.rollupOptions.input).not.toContain('/some/project/path/src/instrument.server.ts'); + }); + + it("doesn't modify the config if the instrumentation file doesn't exist", async () => { + fsAccessMock.mockRejectedValueOnce(undefined); + const buildInstrumentationFilePlugin = makeBuildInstrumentationFilePlugin(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - this is always defined and always a function + const config = await buildInstrumentationFilePlugin.config(viteConfig, { command: 'build' }); + expect(config).toEqual(viteConfig); + }); + + it("logs a warning if the instrumentation file doesn't exist", async () => { + const error = new Error("File doesn't exist."); + fsAccessMock.mockRejectedValueOnce(error); + const buildInstrumentationFilePlugin = makeBuildInstrumentationFilePlugin(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - this is always defined and always a function + const config = await buildInstrumentationFilePlugin.config(viteConfig, { command: 'build' }); + expect(config).toEqual(viteConfig); + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[Sentry SolidStart Plugin] Could not access `./src/instrument.server.ts`, please make sure it exists.', + error, + ); + }); +}); diff --git a/packages/solidstart/test/vite/sentrySolidStartVite.test.ts b/packages/solidstart/test/vite/sentrySolidStartVite.test.ts index d3f905313859..45faa8b797f9 100644 --- a/packages/solidstart/test/vite/sentrySolidStartVite.test.ts +++ b/packages/solidstart/test/vite/sentrySolidStartVite.test.ts @@ -30,20 +30,23 @@ describe('sentrySolidStartVite()', () => { 'sentry-vite-debug-id-injection-plugin', 'sentry-vite-debug-id-upload-plugin', 'sentry-file-deletion-plugin', + 'sentry-solidstart-build-instrumentation-file', ]); }); - it("returns an empty array if source maps upload isn't enabled", () => { + it("returns only build-instrumentation-file plugin if source maps upload isn't enabled", () => { const plugins = getSentrySolidStartVitePlugins({ sourceMapsUploadOptions: { enabled: false } }); - expect(plugins).toHaveLength(0); + const names = plugins.map(plugin => plugin.name); + expect(names).toEqual(['sentry-solidstart-build-instrumentation-file']); }); - it('returns an empty array if `NODE_ENV` is development', async () => { + it('returns only build-instrumentation-file plugin if `NODE_ENV` is development', async () => { const previousEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'development'; const plugins = getSentrySolidStartVitePlugins({ sourceMapsUploadOptions: { enabled: true } }); - expect(plugins).toHaveLength(0); + const names = plugins.map(plugin => plugin.name); + expect(names).toEqual(['sentry-solidstart-build-instrumentation-file']); process.env.NODE_ENV = previousEnv; });