diff --git a/CHANGELOG.md b/CHANGELOG.md index 210e2c13ea4b..0a8f1a9538b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,50 @@ Work in this release was contributed by @aloisklink, @arturovt, @benjick and @maximepvrt. Thank you for your contributions! +- **feat(solidstart)!: Default to `--import` setup and add `autoInjectServerSentry` ([#14862](https://github.com/getsentry/sentry-javascript/pull/14862))** + +To enable the SolidStart SDK, wrap your SolidStart Config with `withSentry`. The `sentrySolidStartVite` plugin is now automatically +added by `withSentry` and you can pass the Sentry build-time options like this: + +```js +import { defineConfig } from '@solidjs/start/config'; +import { withSentry } from '@sentry/solidstart'; + +export default defineConfig( + withSentry( + { + /* Your SolidStart config options... */ + }, + { + // Options for setting up source maps + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + }, + ), +); +``` + +With the `withSentry` wrapper, the Sentry server config should not be added to the `public` directory anymore. +Add the Sentry server config in `src/instrument.server.ts`. Then, the server config will be placed inside the server build output as `instrument.server.mjs`. + +Now, there are two options to set up the SDK: + +1. **(recommended)** Provide an `--import` CLI flag to the start command like this (path depends on your server setup): + `node --import ./.output/server/instrument.server.mjs .output/server/index.mjs` +2. Add `autoInjectServerSentry: 'top-level-import'` and the Sentry config will be imported at the top of the server entry (comes with tracing limitations) + ```js + withSentry( + { + /* Your SolidStart config options... */ + }, + { + // Optional: Install Sentry with a top-level import + autoInjectServerSentry: 'top-level-import', + }, + ); + ``` + ## 8.45.0 - feat(core): Add `handled` option to `captureConsoleIntegration` ([#14664](https://github.com/getsentry/sentry-javascript/pull/14664)) diff --git a/dev-packages/e2e-tests/test-applications/solidstart-spa/app.config.ts b/dev-packages/e2e-tests/test-applications/solidstart-spa/app.config.ts index d329d6066fc7..103ecb09a469 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-spa/app.config.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart-spa/app.config.ts @@ -1,9 +1,9 @@ -import { sentrySolidStartVite } from '@sentry/solidstart'; +import { withSentry } from '@sentry/solidstart'; import { defineConfig } from '@solidjs/start/config'; -export default defineConfig({ - ssr: false, - vite: { - plugins: [sentrySolidStartVite()], - }, -}); +export default defineConfig( + withSentry({ + ssr: false, + middleware: './src/middleware.ts', + }), +); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json b/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json index f4ff0802e159..e0e2a04d0bd4 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json @@ -3,9 +3,9 @@ "version": "0.0.0", "scripts": { "clean": "pnpx rimraf node_modules pnpm-lock.yaml .vinxi .output", - "dev": "NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi dev", - "build": "vinxi build && sh ./post_build.sh", - "preview": "HOST=localhost PORT=3030 NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi start", + "build": "vinxi build && sh post_build.sh", + "preview": "HOST=localhost PORT=3030 vinxi start", + "start:import": "HOST=localhost PORT=3030 node --import ./.output/server/instrument.server.mjs .output/server/index.mjs", "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm build", "test:assert": "pnpm test:prod" diff --git a/dev-packages/e2e-tests/test-applications/solidstart-spa/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/solidstart-spa/playwright.config.mjs index 395acfc282f9..ee2ee42980b8 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-spa/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/solidstart-spa/playwright.config.mjs @@ -1,7 +1,7 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; const config = getPlaywrightConfig({ - startCommand: 'pnpm preview', + startCommand: 'pnpm start:import', port: 3030, }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-spa/src/instrument.server.mjs b/dev-packages/e2e-tests/test-applications/solidstart-spa/src/instrument.server.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/solidstart-spa/src/instrument.server.mjs rename to dev-packages/e2e-tests/test-applications/solidstart-spa/src/instrument.server.ts diff --git a/dev-packages/e2e-tests/test-applications/solidstart-spa/src/middleware.ts b/dev-packages/e2e-tests/test-applications/solidstart-spa/src/middleware.ts new file mode 100644 index 000000000000..88123a035fb6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-spa/src/middleware.ts @@ -0,0 +1,6 @@ +import { sentryBeforeResponseMiddleware } from '@sentry/solidstart'; +import { createMiddleware } from '@solidjs/start/middleware'; + +export default createMiddleware({ + onBeforeResponse: [sentryBeforeResponseMiddleware()], +}); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/.gitignore b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/.gitignore new file mode 100644 index 000000000000..a51ed3c20c8d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/.gitignore @@ -0,0 +1,46 @@ + +dist +.solid +.output +.vercel +.netlify +.vinxi + +# Environment +.env +.env*.local + +# dependencies +/node_modules +/.pnp +.pnp.js + +# IDEs and editors +/.idea +.project +.classpath +*.launch +.settings/ + +# Temp +gitignore + +# testing +/coverage + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +/test-results/ +/playwright-report/ +/playwright/.cache/ + +!*.d.ts diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/.npmrc b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/README.md b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/README.md new file mode 100644 index 000000000000..9a141e9c2f0d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/README.md @@ -0,0 +1,45 @@ +# SolidStart + +Everything you need to build a Solid project, powered by [`solid-start`](https://start.solidjs.com); + +## Creating a project + +```bash +# create a new project in the current directory +npm init solid@latest + +# create a new project in my-app +npm init solid@latest my-app +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a +development server: + +```bash +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +Solid apps are built with _presets_, which optimise your project for deployment to different environments. + +By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add +it to the `devDependencies` in `package.json` and specify in your `app.config.js`. + +## Testing + +Tests are written with `vitest`, `@solidjs/testing-library` and `@testing-library/jest-dom` to extend expect with some +helpful custom matchers. + +To run them, simply start: + +```sh +npm test +``` + +## This project was created with the [Solid CLI](https://solid-cli.netlify.app) diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/app.config.ts b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/app.config.ts new file mode 100644 index 000000000000..e4e73e9fc570 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/app.config.ts @@ -0,0 +1,11 @@ +import { withSentry } from '@sentry/solidstart'; +import { defineConfig } from '@solidjs/start/config'; + +export default defineConfig( + withSentry( + {}, + { + autoInjectServerSentry: 'top-level-import', + }, + ), +); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json new file mode 100644 index 000000000000..3df1995d6354 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json @@ -0,0 +1,37 @@ +{ + "name": "solidstart-top-level-import-e2e-testapp", + "version": "0.0.0", + "scripts": { + "clean": "pnpx rimraf node_modules pnpm-lock.yaml .vinxi .output", + "dev": "vinxi dev", + "build": "vinxi build && sh ./post_build.sh", + "preview": "HOST=localhost PORT=3030 vinxi start", + "test:prod": "TEST_ENV=production playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:prod" + }, + "type": "module", + "dependencies": { + "@sentry/solidstart": "latest || *" + }, + "devDependencies": { + "@playwright/test": "^1.44.1", + "@solidjs/meta": "^0.29.4", + "@solidjs/router": "^0.13.4", + "@solidjs/start": "^1.0.2", + "@solidjs/testing-library": "^0.8.7", + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/user-event": "^14.5.2", + "@vitest/ui": "^1.5.0", + "jsdom": "^24.0.0", + "solid-js": "1.8.17", + "typescript": "^5.4.5", + "vinxi": "^0.4.0", + "vite": "^5.4.10", + "vite-plugin-solid": "^2.10.2", + "vitest": "^1.5.0" + }, + "overrides": { + "@vercel/nft": "0.27.4" + } +} diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/playwright.config.mjs new file mode 100644 index 000000000000..395acfc282f9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: 'pnpm preview', + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/post_build.sh b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/post_build.sh new file mode 100644 index 000000000000..6ed67c9afb8a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/post_build.sh @@ -0,0 +1,8 @@ +# TODO: Investigate the need for this script periodically and remove once these modules are correctly resolved. + +# This script copies `import-in-the-middle` and `@sentry/solidstart` from the E2E test project root `node_modules` +# to the nitro server build output `node_modules` as these are not properly resolved in our yarn workspace/pnpm +# e2e structure. Some files like `hook.mjs` and `@sentry/solidstart/solidrouter.server.js` are missing. This is +# not reproducible in an external project (when pinning `@vercel/nft` to `v0.27.0` and higher). +cp -r node_modules/.pnpm/import-in-the-middle@1.*/node_modules/import-in-the-middle .output/server/node_modules +cp -rL node_modules/@sentry/solidstart .output/server/node_modules/@sentry diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/public/favicon.ico b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/public/favicon.ico new file mode 100644 index 000000000000..fb282da0719e Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/public/favicon.ico differ diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/app.tsx b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/app.tsx new file mode 100644 index 000000000000..3eb85218b575 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/app.tsx @@ -0,0 +1,22 @@ +import { withSentryRouterRouting } from '@sentry/solidstart/solidrouter'; +import { MetaProvider, Title } from '@solidjs/meta'; +import { Router } from '@solidjs/router'; +import { FileRoutes } from '@solidjs/start/router'; +import { Suspense } from 'solid-js'; + +const SentryRouter = withSentryRouterRouting(Router); + +export default function App() { + return ( + ( + + SolidStart - with Vitest + {props.children} + + )} + > + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/entry-client.tsx b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/entry-client.tsx new file mode 100644 index 000000000000..11087fbb5918 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/entry-client.tsx @@ -0,0 +1,18 @@ +// @refresh reload +import * as Sentry from '@sentry/solidstart'; +import { solidRouterBrowserTracingIntegration } from '@sentry/solidstart/solidrouter'; +import { StartClient, mount } from '@solidjs/start/client'; + +Sentry.init({ + // We can't use env variables here, seems like they are stripped + // out in production builds. + dsn: 'https://public@dsn.ingest.sentry.io/1337', + environment: 'qa', // dynamic sampling bias to keep transactions + integrations: [solidRouterBrowserTracingIntegration()], + tunnel: 'http://localhost:3031/', // proxy server + // Performance Monitoring + tracesSampleRate: 1.0, // Capture 100% of the transactions + debug: !!import.meta.env.DEBUG, +}); + +mount(() => , document.getElementById('app')!); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/entry-server.tsx b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/entry-server.tsx new file mode 100644 index 000000000000..276935366318 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/entry-server.tsx @@ -0,0 +1,21 @@ +// @refresh reload +import { StartServer, createHandler } from '@solidjs/start/server'; + +export default createHandler(() => ( + ( + + + + + + {assets} + + + {children} + {scripts} + + + )} + /> +)); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.mjs b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/instrument.server.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.mjs rename to dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/instrument.server.ts diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/routes/back-navigation.tsx b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/routes/back-navigation.tsx new file mode 100644 index 000000000000..ddd970944bf3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/routes/back-navigation.tsx @@ -0,0 +1,9 @@ +import { A } from '@solidjs/router'; + +export default function BackNavigation() { + return ( + + User 6 + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/routes/client-error.tsx b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/routes/client-error.tsx new file mode 100644 index 000000000000..5e405e8c4e40 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/routes/client-error.tsx @@ -0,0 +1,15 @@ +export default function ClientErrorPage() { + return ( + + { + throw new Error('Uncaught error thrown from Solid Start E2E test app'); + }} + > + Throw uncaught error + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/routes/error-boundary.tsx b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/routes/error-boundary.tsx new file mode 100644 index 000000000000..b22607667e7e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/routes/error-boundary.tsx @@ -0,0 +1,64 @@ +import * as Sentry from '@sentry/solidstart'; +import type { ParentProps } from 'solid-js'; +import { ErrorBoundary, createSignal, onMount } from 'solid-js'; + +const SentryErrorBoundary = Sentry.withSentryErrorBoundary(ErrorBoundary); + +const [count, setCount] = createSignal(1); +const [caughtError, setCaughtError] = createSignal(false); + +export default function ErrorBoundaryTestPage() { + return ( + + {caughtError() && ( + + )} + + + setCaughtError(true)} + > + Throw caught error + + + + + ); +} + +function Throw(props: { error: string }) { + onMount(() => { + throw new Error(props.error); + }); + return null; +} + +function SampleErrorBoundary(props: ParentProps) { + return ( + ( + + Error Boundary Fallback + + {error.message} + + { + setCount(count() + 1); + setCaughtError(false); + reset(); + }} + > + Reset + + + )} + > + {props.children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/routes/index.tsx b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/routes/index.tsx new file mode 100644 index 000000000000..9a0b22cc38c6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/routes/index.tsx @@ -0,0 +1,31 @@ +import { A } from '@solidjs/router'; + +export default function Home() { + return ( + <> + Welcome to Solid Start + + Visit docs.solidjs.com/solid-start to read the documentation + + + + Client error + + + Server error + + + Error Boundary + + + + User 5 + + + + Test back navigation + + + > + ); +} diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/routes/server-error.tsx b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/routes/server-error.tsx new file mode 100644 index 000000000000..05dce5e10a56 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/routes/server-error.tsx @@ -0,0 +1,17 @@ +import { withServerActionInstrumentation } from '@sentry/solidstart'; +import { createAsync } from '@solidjs/router'; + +const getPrefecture = async () => { + 'use server'; + return await withServerActionInstrumentation('getPrefecture', () => { + throw new Error('Error thrown from Solid Start E2E test app server route'); + + return { prefecture: 'Kanagawa' }; + }); +}; + +export default function ServerErrorPage() { + const data = createAsync(() => getPrefecture()); + + return Prefecture: {data()?.prefecture}; +} diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/routes/users/[id].tsx b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/routes/users/[id].tsx new file mode 100644 index 000000000000..22abd3ba8803 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/src/routes/users/[id].tsx @@ -0,0 +1,21 @@ +import { withServerActionInstrumentation } from '@sentry/solidstart'; +import { createAsync, useParams } from '@solidjs/router'; + +const getPrefecture = async () => { + 'use server'; + return await withServerActionInstrumentation('getPrefecture', () => { + return { prefecture: 'Ehime' }; + }); +}; +export default function User() { + const params = useParams(); + const userData = createAsync(() => getPrefecture()); + + return ( + + User ID: {params.id} + + Prefecture: {userData()?.prefecture} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/start-event-proxy.mjs new file mode 100644 index 000000000000..46cc8824da18 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'solidstart-top-level-import', +}); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/errorboundary.test.ts b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/errorboundary.test.ts new file mode 100644 index 000000000000..49f50f882b50 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/errorboundary.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('captures an exception', async ({ page }) => { + const errorEventPromise = waitForError('solidstart-top-level-import', errorEvent => { + return ( + !errorEvent.type && + errorEvent.exception?.values?.[0]?.value === + 'Error 1 thrown from Sentry ErrorBoundary in Solid Start E2E test app' + ); + }); + + await page.goto('/error-boundary'); + await page.locator('#caughtErrorBtn').click(); + const errorEvent = await errorEventPromise; + + expect(errorEvent).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error 1 thrown from Sentry ErrorBoundary in Solid Start E2E test app', + mechanism: { + type: 'generic', + handled: true, + }, + }, + ], + }, + transaction: '/error-boundary', + }); +}); + +test('captures a second exception after resetting the boundary', async ({ page }) => { + const firstErrorEventPromise = waitForError('solidstart-top-level-import', errorEvent => { + return ( + !errorEvent.type && + errorEvent.exception?.values?.[0]?.value === + 'Error 1 thrown from Sentry ErrorBoundary in Solid Start E2E test app' + ); + }); + + await page.goto('/error-boundary'); + await page.locator('#caughtErrorBtn').click(); + const firstErrorEvent = await firstErrorEventPromise; + + expect(firstErrorEvent).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error 1 thrown from Sentry ErrorBoundary in Solid Start E2E test app', + mechanism: { + type: 'generic', + handled: true, + }, + }, + ], + }, + transaction: '/error-boundary', + }); + + const secondErrorEventPromise = waitForError('solidstart-top-level-import', errorEvent => { + return ( + !errorEvent.type && + errorEvent.exception?.values?.[0]?.value === + 'Error 2 thrown from Sentry ErrorBoundary in Solid Start E2E test app' + ); + }); + + await page.locator('#errorBoundaryResetBtn').click(); + await page.locator('#caughtErrorBtn').click(); + const secondErrorEvent = await secondErrorEventPromise; + + expect(secondErrorEvent).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error 2 thrown from Sentry ErrorBoundary in Solid Start E2E test app', + mechanism: { + type: 'generic', + handled: true, + }, + }, + ], + }, + transaction: '/error-boundary', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/errors.client.test.ts new file mode 100644 index 000000000000..9e4a0269eee4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/errors.client.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test.describe('client-side errors', () => { + test('captures error thrown on click', async ({ page }) => { + const errorPromise = waitForError('solidstart-top-level-import', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Uncaught error thrown from Solid Start E2E test app'; + }); + + await page.goto(`/client-error`); + await page.locator('#errorBtn').click(); + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Uncaught error thrown from Solid Start E2E test app', + mechanism: { + handled: false, + }, + }, + ], + }, + transaction: '/client-error', + }); + expect(error.transaction).toEqual('/client-error'); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/errors.server.test.ts new file mode 100644 index 000000000000..682dd34e10f9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/errors.server.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test.describe('server-side errors', () => { + test('captures server action error', async ({ page }) => { + const errorEventPromise = waitForError('solidstart-top-level-import', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Solid Start E2E test app server route'; + }); + + await page.goto(`/server-error`); + + const error = await errorEventPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Solid Start E2E test app server route', + mechanism: { + type: 'solidstart', + handled: false, + }, + }, + ], + }, + // transaction: 'GET /server-error', --> only possible with `--import` CLI flag + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/performance.client.test.ts b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/performance.client.test.ts new file mode 100644 index 000000000000..bd5dece39b33 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/performance.client.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('sends a pageload transaction', async ({ page }) => { + const transactionPromise = waitForTransaction('solidstart-top-level-import', async transactionEvent => { + return transactionEvent?.transaction === '/' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/'); + const pageloadTransaction = await transactionPromise; + + expect(pageloadTransaction).toMatchObject({ + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.browser', + }, + }, + transaction: '/', + transaction_info: { + source: 'url', + }, + }); +}); + +test('sends a navigation transaction', async ({ page }) => { + const transactionPromise = waitForTransaction('solidstart-top-level-import', async transactionEvent => { + return transactionEvent?.transaction === '/users/5' && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + await page.locator('#navLink').click(); + const navigationTransaction = await transactionPromise; + + expect(navigationTransaction).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.solidstart.solidrouter', + }, + }, + transaction: '/users/5', + transaction_info: { + source: 'url', + }, + }); +}); + +test('updates the transaction when using the back button', async ({ page }) => { + // Solid Router sends a `-1` navigation when using the back button. + // The sentry solidRouterBrowserTracingIntegration tries to update such + // transactions with the proper name once the `useLocation` hook triggers. + const navigationTxnPromise = waitForTransaction('solidstart-top-level-import', async transactionEvent => { + return transactionEvent?.transaction === '/users/6' && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/back-navigation`); + await page.locator('#navLink').click(); + const navigationTxn = await navigationTxnPromise; + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.solidstart.solidrouter', + }, + }, + transaction: '/users/6', + transaction_info: { + source: 'url', + }, + }); + + const backNavigationTxnPromise = waitForTransaction('solidstart-top-level-import', async transactionEvent => { + return ( + transactionEvent?.transaction === '/back-navigation' && transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + await page.goBack(); + const backNavigationTxn = await backNavigationTxnPromise; + + expect(backNavigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.solidstart.solidrouter', + }, + }, + transaction: '/back-navigation', + transaction_info: { + source: 'url', + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/performance.server.test.ts new file mode 100644 index 000000000000..8072a7e75181 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/performance.server.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; + +test('sends a server action transaction on pageload', async ({ page }) => { + const transactionPromise = waitForTransaction('solidstart-top-level-import', transactionEvent => { + return transactionEvent?.transaction === 'GET /users/6'; + }); + + await page.goto('/users/6'); + + const transaction = await transactionPromise; + + expect(transaction.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'getPrefecture', + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.server_action', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + }, + }), + ]), + ); +}); + +test('sends a server action transaction on client navigation', async ({ page }) => { + const transactionPromise = waitForTransaction('solidstart-top-level-import', transactionEvent => { + return transactionEvent?.transaction === 'POST getPrefecture'; + }); + + await page.goto('/'); + await page.locator('#navLink').click(); + await page.waitForURL('/users/5'); + + const transaction = await transactionPromise; + + expect(transaction.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'getPrefecture', + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.server_action', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + }, + }), + ]), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tsconfig.json b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tsconfig.json new file mode 100644 index 000000000000..6f11292cc5d8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "allowJs": true, + "strict": true, + "noEmit": true, + "types": ["vinxi/types/client", "vitest/globals", "@testing-library/jest-dom"], + "isolatedModules": true, + "paths": { + "~/*": ["./src/*"] + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/vitest.config.ts b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/vitest.config.ts new file mode 100644 index 000000000000..6c2b639dc300 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/vitest.config.ts @@ -0,0 +1,10 @@ +import solid from 'vite-plugin-solid'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [solid()], + resolve: { + conditions: ['development', 'browser'], + }, + envPrefix: 'PUBLIC_', +}); 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..71061cf25d96 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,8 @@ -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({ + middleware: './src/middleware.ts', + }), +); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/package.json b/dev-packages/e2e-tests/test-applications/solidstart/package.json index 032a4af9058a..020bedb41806 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart/package.json @@ -3,9 +3,9 @@ "version": "0.0.0", "scripts": { "clean": "pnpx rimraf node_modules pnpm-lock.yaml .vinxi .output", - "dev": "NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi dev", - "build": "vinxi build && sh ./post_build.sh", - "preview": "HOST=localhost PORT=3030 NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi start", + "build": "vinxi build && sh post_build.sh", + "preview": "HOST=localhost PORT=3030 vinxi start", + "start:import": "HOST=localhost PORT=3030 node --import ./.output/server/instrument.server.mjs .output/server/index.mjs", "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm build", "test:assert": "pnpm test:prod" diff --git a/dev-packages/e2e-tests/test-applications/solidstart/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/solidstart/playwright.config.mjs index 395acfc282f9..ee2ee42980b8 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/solidstart/playwright.config.mjs @@ -1,7 +1,7 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; const config = getPlaywrightConfig({ - startCommand: 'pnpm preview', + startCommand: 'pnpm start:import', port: 3030, }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.ts b/dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.ts new file mode 100644 index 000000000000..3dd5d8933b7b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/solidstart'; + +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + environment: 'qa', // dynamic sampling bias to keep transactions + tracesSampleRate: 1.0, // Capture 100% of the transactions + tunnel: 'http://localhost:3031/', // proxy server + debug: !!process.env.DEBUG, +}); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/src/middleware.ts b/dev-packages/e2e-tests/test-applications/solidstart/src/middleware.ts new file mode 100644 index 000000000000..88123a035fb6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solidstart/src/middleware.ts @@ -0,0 +1,6 @@ +import { sentryBeforeResponseMiddleware } from '@sentry/solidstart'; +import { createMiddleware } from '@solidjs/start/middleware'; + +export default createMiddleware({ + onBeforeResponse: [sentryBeforeResponseMiddleware()], +}); diff --git a/packages/solidstart/.eslintrc.js b/packages/solidstart/.eslintrc.js index a22f9710cf6b..0fe78630b548 100644 --- a/packages/solidstart/.eslintrc.js +++ b/packages/solidstart/.eslintrc.js @@ -10,6 +10,13 @@ module.exports = { project: ['tsconfig.test.json'], }, }, + { + files: ['src/vite/**', 'src/server/**', 'src/config/**'], + rules: { + '@sentry-internal/sdk/no-optional-chaining': 'off', + '@sentry-internal/sdk/no-nullish-coalescing': 'off', + }, + }, ], extends: ['../../.eslintrc.js'], }; diff --git a/packages/solidstart/README.md b/packages/solidstart/README.md index c43ac54c7037..28127c336c0d 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 option `autoInjectServerSentry: 'top-level-import'` 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 + autoInjectServerSentry: 'top-level-import', + }, + ), +); +``` + +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..f0bca10ae3e3 --- /dev/null +++ b/packages/solidstart/src/config/addInstrumentation.ts @@ -0,0 +1,135 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { consoleSandbox } from '@sentry/core'; +import type { Nitro } from 'nitropack'; + +// 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. + * + * As Sentry also imports the release injection file, this needs to be copied over manually as well. + * TODO: The mechanism of manually copying those files could maybe be improved + * + * This will no-op if no `instrument.server.js` file was found in the + * build directory. + */ +export async function addInstrumentationFileToBuild(nitro: Nitro): Promise { + nitro.hooks.hook('close', async () => { + // 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; + + try { + // 1. Create assets directory first (for release-injection-file) + const assetsServerDir = path.join(serverDir, 'assets'); + if (!fs.existsSync(assetsServerDir)) { + await fs.promises.mkdir(assetsServerDir, { recursive: true }); + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.log(`[Sentry SolidStart withSentry] Successfully created directory ${assetsServerDir}.`); + }); + } + + // 2. Copy release injection file if available + try { + const ssrAssetsPath = path.resolve(buildDir, 'build', 'ssr', 'assets'); + const assetsBuildDir = await fs.promises.readdir(ssrAssetsPath); + const releaseInjectionFile = assetsBuildDir.find(file => file.startsWith('_sentry-release-injection-file-')); + + if (releaseInjectionFile) { + const releaseSource = path.resolve(ssrAssetsPath, releaseInjectionFile); + const releaseDestination = path.resolve(assetsServerDir, releaseInjectionFile); + + await fs.promises.copyFile(releaseSource, releaseDestination); + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.log(`[Sentry SolidStart withSentry] Successfully created ${releaseDestination}.`); + }); + } + } catch (err) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn('[Sentry SolidStart withSentry] Failed to copy release injection file.', err); + }); + } + + // 3. Copy Sentry server instrumentation file + const instrumentSource = path.resolve(buildDir, 'build', 'ssr', 'instrument.server.js'); + const instrumentDestination = path.resolve(serverDir, 'instrument.server.mjs'); + + await fs.promises.copyFile(instrumentSource, instrumentDestination); + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.log(`[Sentry SolidStart withSentry] Successfully created ${instrumentDestination}.`); + }); + } catch (error) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn('[Sentry SolidStart withSentry] Failed to add instrumentation file to build.', 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 addSentryTopImport(nitro: Nitro): Promise { + nitro.hooks.hook('close', async () => { + const buildPreset = nitro.options.preset; + const serverDir = nitro.options.output.serverDir; + + // Static file hosts have no server component so there's nothing to do + if (staticHostPresets.includes(buildPreset)) { + return; + } + + const instrumentationFile = path.resolve(serverDir, 'instrument.server.mjs'); + const serverEntryFileName = serverFilePresets.includes(buildPreset) ? '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..4949f4bdf523 --- /dev/null +++ b/packages/solidstart/src/config/index.ts @@ -0,0 +1 @@ +export * from './withSentry'; diff --git a/packages/solidstart/src/config/types.ts b/packages/solidstart/src/config/types.ts new file mode 100644 index 000000000000..0d6ea9bdf4f4 --- /dev/null +++ b/packages/solidstart/src/config/types.ts @@ -0,0 +1,16 @@ +import type { defineConfig } from '@solidjs/start/config'; +import type { Nitro } from 'nitropack'; + +// Nitro does not export this type +export type RollupConfig = { + plugins: unknown[]; +}; + +export type SolidStartInlineConfig = Parameters[0]; + +export type SolidStartInlineServerConfig = { + hooks?: { + close?: () => unknown; + 'rollup:before'?: (nitro: Nitro) => unknown; + }; +}; diff --git a/packages/solidstart/src/config/utils.ts b/packages/solidstart/src/config/utils.ts new file mode 100644 index 000000000000..fd4b70d508d0 --- /dev/null +++ b/packages/solidstart/src/config/utils.ts @@ -0,0 +1,82 @@ +export const SENTRY_WRAPPED_ENTRY = '?sentry-query-wrapped-entry'; +export const SENTRY_WRAPPED_FUNCTIONS = '?sentry-query-wrapped-functions='; +export const SENTRY_REEXPORTED_FUNCTIONS = '?sentry-query-reexported-functions='; +export const QUERY_END_INDICATOR = 'SENTRY-QUERY-END'; + +/** + * Strips the Sentry query part from a path. + * Example: example/path?sentry-query-wrapped-entry?sentry-query-functions-reexport=foo,SENTRY-QUERY-END -> /example/path + * + * Only exported for testing. + */ +export function removeSentryQueryFromPath(url: string): string { + // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor + const regex = new RegExp(`\\${SENTRY_WRAPPED_ENTRY}.*?\\${QUERY_END_INDICATOR}`); + return url.replace(regex, ''); +} + +/** + * Extracts and sanitizes function re-export and function wrap query parameters from a query string. + * If it is a default export, it is not considered for re-exporting. + * + * Only exported for testing. + */ +export function extractFunctionReexportQueryParameters(query: string): { wrap: string[]; reexport: string[] } { + // Regex matches the comma-separated params between the functions query + // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor + const wrapRegex = new RegExp( + `\\${SENTRY_WRAPPED_FUNCTIONS}(.*?)(\\${QUERY_END_INDICATOR}|\\${SENTRY_REEXPORTED_FUNCTIONS})`, + ); + // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor + const reexportRegex = new RegExp(`\\${SENTRY_REEXPORTED_FUNCTIONS}(.*?)(\\${QUERY_END_INDICATOR})`); + + const wrapMatch = query.match(wrapRegex); + const reexportMatch = query.match(reexportRegex); + + const wrap = + wrapMatch && wrapMatch[1] + ? wrapMatch[1] + .split(',') + .filter(param => param !== '') + // Sanitize, as code could be injected with another rollup plugin + .map((str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + : []; + + const reexport = + reexportMatch && reexportMatch[1] + ? reexportMatch[1] + .split(',') + .filter(param => param !== '' && param !== 'default') + // Sanitize, as code could be injected with another rollup plugin + .map((str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + : []; + + return { wrap, reexport }; +} + +/** + * Constructs a code snippet with function reexports (can be used in Rollup plugins as a return value for `load()`) + */ +export function constructFunctionReExport(pathWithQuery: string, entryId: string): string { + const { wrap: wrapFunctions, reexport: reexportFunctions } = extractFunctionReexportQueryParameters(pathWithQuery); + + return wrapFunctions + .reduce( + (functionsCode, currFunctionName) => + functionsCode.concat( + `async function ${currFunctionName}_sentryWrapped(...args) {\n` + + ` const res = await import(${JSON.stringify(entryId)});\n` + + ` return res.${currFunctionName}.call(this, ...args);\n` + + '}\n' + + `export { ${currFunctionName}_sentryWrapped as ${currFunctionName} };\n`, + ), + '', + ) + .concat( + reexportFunctions.reduce( + (functionsCode, currFunctionName) => + functionsCode.concat(`export { ${currFunctionName} } from ${JSON.stringify(entryId)};`), + '', + ), + ); +} diff --git a/packages/solidstart/src/config/withSentry.ts b/packages/solidstart/src/config/withSentry.ts new file mode 100644 index 000000000000..65d9f5100716 --- /dev/null +++ b/packages/solidstart/src/config/withSentry.ts @@ -0,0 +1,53 @@ +import type { Nitro } from 'nitropack'; +import { addSentryPluginToVite } from '../vite'; +import type { SentrySolidStartPluginOptions } from '../vite/types'; +import { addInstrumentationFileToBuild, addSentryTopImport } from './addInstrumentation'; +import type { 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 function withSentry( + solidStartConfig: SolidStartInlineConfig = {}, + sentrySolidStartPluginOptions: SentrySolidStartPluginOptions, +): SolidStartInlineConfig { + const sentryPluginOptions = { + ...sentrySolidStartPluginOptions, + }; + + const server = (solidStartConfig.server || {}) as SolidStartInlineServerConfig; + const hooks = server.hooks || {}; + const vite = + typeof solidStartConfig.vite === 'function' + ? (...args: unknown[]) => addSentryPluginToVite(solidStartConfig.vite(...args), sentryPluginOptions) + : addSentryPluginToVite(solidStartConfig.vite, sentryPluginOptions); + + return { + ...solidStartConfig, + vite, + server: { + ...server, + hooks: { + ...hooks, + async 'rollup:before'(nitro: Nitro) { + await addInstrumentationFileToBuild(nitro); + + if (sentrySolidStartPluginOptions?.autoInjectServerSentry === 'top-level-import') { + await addSentryTopImport(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 fb5b221086e7..39f9831c543c 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 { Client, Integration, Options, StackParser } from '@sentry/core'; diff --git a/packages/solidstart/src/vite/buildInstrumentationFile.ts b/packages/solidstart/src/vite/buildInstrumentationFile.ts new file mode 100644 index 000000000000..81bcef7a5bf7 --- /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/core'; +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..1bafc0cd07b5 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'; @@ -8,6 +9,8 @@ import type { SentrySolidStartPluginOptions } from './types'; export const sentrySolidStartVite = (options: SentrySolidStartPluginOptions = {}): Plugin[] => { const sentryPlugins: Plugin[] = []; + sentryPlugins.push(makeBuildInstrumentationFilePlugin(options)); + if (process.env.NODE_ENV !== 'development') { if (options.sourceMapsUploadOptions?.enabled ?? true) { sentryPlugins.push(...makeSourceMapsVitePlugin(options)); @@ -16,3 +19,16 @@ export const sentrySolidStartVite = (options: SentrySolidStartPluginOptions = {} 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/types.ts b/packages/solidstart/src/vite/types.ts index 4a64e4856b5d..5f34f0c4b2d8 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,30 @@ 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; + + /** + * + * Enables (partial) server tracing by automatically injecting Sentry for environments where modifying the node option `--import` is not possible. + * + * **DO NOT** add the node CLI flag `--import` in your node start script, when auto-injecting Sentry. + * This would initialize Sentry twice on the server-side and this leads to unexpected issues. + * + * --- + * + * **"top-level-import"** + * + * Enabling basic server tracing with top-level import 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 `"top-level-import"` is enabled, the Sentry SDK will import the Sentry server config at the top of the server entry file to load the SDK on the server. + */ + autoInjectServerSentry?: 'top-level-import'; }; diff --git a/packages/solidstart/test/config/addInstrumentation.test.ts b/packages/solidstart/test/config/addInstrumentation.test.ts new file mode 100644 index 000000000000..cddbd4821e3f --- /dev/null +++ b/packages/solidstart/test/config/addInstrumentation.test.ts @@ -0,0 +1,189 @@ +import type { Nitro } from 'nitropack'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { addInstrumentationFileToBuild, 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(); +const fsMkdirMock = vi.fn(); +const fsReaddirMock = vi.fn(); +const fsExistsSyncMock = vi.fn(); + +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + existsSync: (...args: unknown[]) => fsExistsSyncMock(...args), + 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), + mkdir: (...args: unknown[]) => fsMkdirMock(...args), + readdir: (...args: unknown[]) => fsReaddirMock(...args), + }, + }; +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('addInstrumentationFileToBuild()', () => { + const nitroOptions: Nitro = { + hooks: { + hook: vi.fn(), + }, + options: { + buildDir: '/path/to/buildDir', + output: { + serverDir: '/path/to/serverDir', + }, + preset: 'vercel', + }, + }; + + const callNitroCloseHook = async () => { + const hookCallback = nitroOptions.hooks.hook.mock.calls[0][1]; + await hookCallback(); + }; + + it('adds `instrument.server.mjs` to the server output directory', async () => { + fsCopyFileMock.mockResolvedValueOnce(true); + await addInstrumentationFileToBuild(nitroOptions); + + await callNitroCloseHook(); + + expect(fsCopyFileMock).toHaveBeenCalledWith( + '/path/to/buildDir/build/ssr/instrument.server.js', + '/path/to/serverDir/instrument.server.mjs', + ); + }); + + it('warns when `instrument.server.js` cannot be copied to the server output directory', async () => { + const error = new Error('Failed to copy file.'); + fsCopyFileMock.mockRejectedValueOnce(error); + await addInstrumentationFileToBuild(nitroOptions); + + await callNitroCloseHook(); + + 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 add instrumentation file to build.', + error, + ); + }); + + it.each(staticHostPresets)("doesn't add `instrument.server.mjs` for static host `%s`", async preset => { + const staticNitroOptions = { + ...nitroOptions, + options: { + ...nitroOptions.options, + preset, + }, + }; + + await addInstrumentationFileToBuild(staticNitroOptions); + + await callNitroCloseHook(); + + expect(fsCopyFileMock).not.toHaveBeenCalled(); + }); + + it('creates assets directory if it does not exist', async () => { + fsExistsSyncMock.mockReturnValue(false); + fsMkdirMock.mockResolvedValueOnce(true); + fsCopyFileMock.mockResolvedValueOnce(true); + await addInstrumentationFileToBuild(nitroOptions); + + await callNitroCloseHook(); + + expect(fsMkdirMock).toHaveBeenCalledWith('/path/to/serverDir/assets', { recursive: true }); + expect(consoleLogSpy).toHaveBeenCalledWith( + '[Sentry SolidStart withSentry] Successfully created directory /path/to/serverDir/assets.', + ); + }); + + it('does not create assets directory if it already exists', async () => { + fsExistsSyncMock.mockReturnValue(true); + await addInstrumentationFileToBuild(nitroOptions); + + await callNitroCloseHook(); + + expect(fsMkdirMock).not.toHaveBeenCalled(); + }); + + it('copies release injection file if available', async () => { + fsExistsSyncMock.mockReturnValue(true); + fsReaddirMock.mockResolvedValueOnce(['_sentry-release-injection-file-test.js']); + fsCopyFileMock.mockResolvedValueOnce(true); + await addInstrumentationFileToBuild(nitroOptions); + + await callNitroCloseHook(); + + expect(fsCopyFileMock).toHaveBeenCalledWith( + '/path/to/buildDir/build/ssr/assets/_sentry-release-injection-file-test.js', + '/path/to/serverDir/assets/_sentry-release-injection-file-test.js', + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + '[Sentry SolidStart withSentry] Successfully created /path/to/serverDir/assets/_sentry-release-injection-file-test.js.', + ); + }); + + it('warns when release injection file cannot be copied', async () => { + const error = new Error('Failed to copy release injection file.'); + fsExistsSyncMock.mockReturnValue(true); + fsReaddirMock.mockResolvedValueOnce(['_sentry-release-injection-file-test.js']); + fsCopyFileMock.mockRejectedValueOnce(error); + await addInstrumentationFileToBuild(nitroOptions); + + await callNitroCloseHook(); + + expect(fsCopyFileMock).toHaveBeenCalledWith( + '/path/to/buildDir/build/ssr/assets/_sentry-release-injection-file-test.js', + '/path/to/serverDir/assets/_sentry-release-injection-file-test.js', + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[Sentry SolidStart withSentry] Failed to copy release injection file.', + error, + ); + }); + + it('does not copy release injection file if not found', async () => { + fsExistsSyncMock.mockReturnValue(true); + fsReaddirMock.mockResolvedValueOnce([]); + await addInstrumentationFileToBuild(nitroOptions); + + await callNitroCloseHook(); + + expect(fsCopyFileMock).not.toHaveBeenCalledWith( + expect.stringContaining('_sentry-release-injection-file-'), + expect.any(String), + ); + }); + + it('warns when `instrument.server.js` is not found', async () => { + const error = new Error('File not found'); + fsCopyFileMock.mockRejectedValueOnce(error); + await addInstrumentationFileToBuild(nitroOptions); + + await callNitroCloseHook(); + + 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 add instrumentation file to build.', + error, + ); + }); +}); diff --git a/packages/solidstart/test/config/withSentry.test.ts b/packages/solidstart/test/config/withSentry.test.ts new file mode 100644 index 000000000000..e554db45124f --- /dev/null +++ b/packages/solidstart/test/config/withSentry.test.ts @@ -0,0 +1,152 @@ +import type { Nitro } from 'nitropack'; +import type { Plugin } from 'vite'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { withSentry } from '../../src/config'; + +const userDefinedNitroRollupBeforeHookMock = vi.fn(); +const userDefinedNitroCloseHookMock = vi.fn(); +const addInstrumentationFileToBuildMock = vi.fn(); +const addSentryTopImportMock = vi.fn(); + +vi.mock('../../src/config/addInstrumentation', () => ({ + addInstrumentationFileToBuild: (...args: unknown[]) => addInstrumentationFileToBuildMock(...args), + addSentryTopImport: (...args: unknown[]) => addSentryTopImportMock(...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 if no plugin options are provided', 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 when configured in autoInjectServerSentry', async () => { + const config = withSentry(solidStartConfig, { autoInjectServerSentry: 'top-level-import' }); + await config?.server.hooks['rollup:before'](nitroOptions); + await config?.server.hooks['close'](nitroOptions); + expect(addSentryTopImportMock).toHaveBeenCalledWith( + expect.objectContaining({ + options: { + buildDir: '/path/to/buildDir', + output: { + serverDir: '/path/to/serverDir', + }, + preset: 'vercel', + }, + }), + ); + expect(userDefinedNitroCloseHookMock).toHaveBeenCalled(); + }); + + it('does not add the instrumentation file as top level import if autoInjectServerSentry is undefined', async () => { + const config = withSentry(solidStartConfig, { autoInjectServerSentry: undefined }); + await config?.server.hooks['rollup:before'](nitroOptions); + await config?.server.hooks['close'](nitroOptions); + expect(addSentryTopImportMock).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-build-instrumentation-file', + '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', + ]); + }); + + 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-build-instrumentation-file', + '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', + '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-build-instrumentation-file', + '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', + '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..8915c5a70671 100644 --- a/packages/solidstart/test/vite/sentrySolidStartVite.test.ts +++ b/packages/solidstart/test/vite/sentrySolidStartVite.test.ts @@ -23,6 +23,7 @@ describe('sentrySolidStartVite()', () => { const plugins = getSentrySolidStartVitePlugins(); const names = plugins.map(plugin => plugin.name); expect(names).toEqual([ + 'sentry-solidstart-build-instrumentation-file', 'sentry-solidstart-source-maps', 'sentry-telemetry-plugin', 'sentry-vite-release-injection-plugin', @@ -33,17 +34,19 @@ describe('sentrySolidStartVite()', () => { ]); }); - 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; });
{error.message}
+ Visit docs.solidjs.com/solid-start to read the documentation +