diff --git a/CHANGELOG.md b/CHANGELOG.md index 23a919ca84d1..f588cc39e23b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,27 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +### Important Changes + +- **feat(nuxt): Add option autoInjectServerSentry (no default import()) ([#14553](https://github.com/getsentry/sentry-javascript/pull/14553))** + +Using the dynamic `import()` as the default behavior for initializing the SDK on the server-side did not work for every project. +The default behavior of the SDK has been changed, and you now need to **use the `--import` flag to initialize Sentry on the server-side** to leverage full functionality. + +Example with `--import`: + +```bash +node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs +``` + +In case you are not able to use the `--import` flag, you can enable auto-injecting Sentry in the `nuxt.config.ts` (comes with limitations): + +```json + sentry: { + autoInjectServerSentry: 'top-level-import', // or 'experimental_dynamic-import' + }, +``` + Work in this release was contributed by @lsmurray. Thank you for your contribution! ## 8.42.0 diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/.gitignore b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/.gitignore new file mode 100644 index 000000000000..4a7f73a2ed0d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/.gitignore @@ -0,0 +1,24 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules + +# Logs +logs +*.log + +# Misc +.DS_Store +.fleet +.idea + +# Local env files +.env +.env.* +!.env.example diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/.npmrc b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-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/nuxt-3-dynamic-import/app.vue b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/app.vue new file mode 100644 index 000000000000..23283a522546 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/app.vue @@ -0,0 +1,17 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/components/ErrorButton.vue b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/components/ErrorButton.vue new file mode 100644 index 000000000000..92ea714ae489 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/components/ErrorButton.vue @@ -0,0 +1,22 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/copyIITM.bash b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/copyIITM.bash new file mode 100644 index 000000000000..0e04d001c968 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/copyIITM.bash @@ -0,0 +1,7 @@ +# This script copies the `import-in-the-middle` content of the E2E test project root `node_modules` to the build output `node_modules` +# For some reason, some files are missing in the output (like `hook.mjs`) and this is not reproducible in external, standalone projects. +# +# Things we tried (that did not fix the problem): +# - Adding a resolution for `@vercel/nft` v0.27.0 (this worked in the standalone project) +# - Also adding `@vercel/nft` v0.27.0 to pnpm `peerDependencyRules` +cp -r node_modules/.pnpm/import-in-the-middle@1.*/node_modules/import-in-the-middle .output/server/node_modules/import-in-the-middle diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/nuxt.config.ts new file mode 100644 index 000000000000..9379acaf978a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/nuxt.config.ts @@ -0,0 +1,23 @@ +// https://nuxt.com/docs/api/configuration/nuxt-config +export default defineNuxtConfig({ + modules: ['@sentry/nuxt/module'], + imports: { + autoImport: false, + }, + runtimeConfig: { + public: { + sentry: { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }, + }, + }, + nitro: { + rollupConfig: { + // @sentry/... is set external to prevent bundling all of Sentry into the `runtime.mjs` file in the build output + external: [/@sentry\/.*/], + }, + }, + sentry: { + autoInjectServerSentry: 'experimental_dynamic-import', + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/package.json new file mode 100644 index 000000000000..778a8515e0e4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/package.json @@ -0,0 +1,29 @@ +{ + "name": "nuxt-3-dynamic-import", + "private": true, + "type": "module", + "scripts": { + "build": "nuxt build && bash ./copyIITM.bash", + "dev": "nuxt dev", + "generate": "nuxt generate", + "preview": "nuxt preview", + "start": "node .output/server/index.mjs", + "clean": "npx nuxi cleanup", + "test": "playwright test", + "test:build": "pnpm install && npx playwright install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/nuxt": "latest || *", + "nuxt": "^3.14.0" + }, + "devDependencies": { + "@nuxt/test-utils": "^3.14.1", + "@playwright/test": "^1.44.1", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "overrides": { + "nitropack": "~2.9.7", + "ofetch": "^1.4.0" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/pages/client-error.vue b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/pages/client-error.vue new file mode 100644 index 000000000000..5e1a14931f84 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/pages/client-error.vue @@ -0,0 +1,11 @@ + + + + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/pages/fetch-server-error.vue b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/pages/fetch-server-error.vue new file mode 100644 index 000000000000..8cb2a9997e58 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/pages/fetch-server-error.vue @@ -0,0 +1,13 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/pages/index.vue b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/pages/index.vue new file mode 100644 index 000000000000..74513c5697f3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/pages/index.vue @@ -0,0 +1,3 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/pages/test-param/[param].vue b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/pages/test-param/[param].vue new file mode 100644 index 000000000000..e83392b37b5c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/pages/test-param/[param].vue @@ -0,0 +1,23 @@ + + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/playwright.config.ts new file mode 100644 index 000000000000..aa1ff8e9743c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/playwright.config.ts @@ -0,0 +1,19 @@ +import { fileURLToPath } from 'node:url'; +import type { ConfigOptions } from '@nuxt/test-utils/playwright'; +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const nuxtConfigOptions: ConfigOptions = { + nuxt: { + rootDir: fileURLToPath(new URL('.', import.meta.url)), + }, +}; + +/* Make sure to import from '@nuxt/test-utils/playwright' in the tests + * Like this: import { expect, test } from '@nuxt/test-utils/playwright' */ + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, + use: { ...nuxtConfigOptions }, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/public/favicon.ico b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/public/favicon.ico new file mode 100644 index 000000000000..18993ad91cfd Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/public/favicon.ico differ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/sentry.client.config.ts new file mode 100644 index 000000000000..9a9566051452 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/sentry.client.config.ts @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/nuxt'; +import { useRuntimeConfig } from '#imports'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: useRuntimeConfig().public.sentry.dsn, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + integrations: [ + Sentry.vueIntegration({ + tracingOptions: { + trackComponents: true, + }, + }), + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/sentry.server.config.ts new file mode 100644 index 000000000000..729b2296c683 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/sentry.server.config.ts @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/nuxt'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + environment: 'qa', // dynamic sampling bias to keep transactions + tracesSampleRate: 1.0, // Capture 100% of the transactions + tunnel: 'http://localhost:3031/', // proxy server +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/server/api/param-error/[param].ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/server/api/param-error/[param].ts new file mode 100644 index 000000000000..389d8ac4d633 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/server/api/param-error/[param].ts @@ -0,0 +1,5 @@ +import { defineEventHandler } from '#imports'; + +export default defineEventHandler(_e => { + throw new Error('Nuxt 3 Param Server error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/server/api/server-error.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/server/api/server-error.ts new file mode 100644 index 000000000000..ec961a010510 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/server/api/server-error.ts @@ -0,0 +1,5 @@ +import { defineEventHandler } from '#imports'; + +export default defineEventHandler(event => { + throw new Error('Nuxt 3 Server error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/server/api/test-param/[param].ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/server/api/test-param/[param].ts new file mode 100644 index 000000000000..1867874cd494 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/server/api/test-param/[param].ts @@ -0,0 +1,7 @@ +import { defineEventHandler, getRouterParam } from '#imports'; + +export default defineEventHandler(event => { + const param = getRouterParam(event, 'param'); + + return `Param: ${param}!`; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/server/tsconfig.json b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/server/tsconfig.json new file mode 100644 index 000000000000..b9ed69c19eaf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/server/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../.nuxt/tsconfig.server.json" +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/start-event-proxy.mjs new file mode 100644 index 000000000000..a54cf2320488 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nuxt-3-dynamic-import', +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.client.test.ts new file mode 100644 index 000000000000..2fdd4f79cc46 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.client.test.ts @@ -0,0 +1,105 @@ +import { expect, test } from '@nuxt/test-utils/playwright'; +import { waitForError } from '@sentry-internal/test-utils'; + +test.describe('client-side errors', async () => { + test('captures error thrown on click', async ({ page }) => { + const errorPromise = waitForError('nuxt-3-dynamic-import', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Nuxt-3 E2E test app'; + }); + + await page.goto(`/client-error`); + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('/client-error'); + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Nuxt-3 E2E test app', + mechanism: { + handled: false, + }, + }, + ], + }, + }); + }); + + test('shows parametrized route on button error', async ({ page }) => { + const errorPromise = waitForError('nuxt-3-dynamic-import', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Param Route Button'; + }); + + await page.goto(`/test-param/1234`); + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + expect(error.sdk.name).toEqual('sentry.javascript.nuxt'); + expect(error.transaction).toEqual('/test-param/:param()'); + expect(error.request.url).toMatch(/\/test-param\/1234/); + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Param Route Button', + mechanism: { + handled: false, + }, + }, + ], + }, + }); + }); + + test('page is still interactive after client error', async ({ page }) => { + const error1Promise = waitForError('nuxt-3-dynamic-import', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Nuxt-3 E2E test app'; + }); + + await page.goto(`/client-error`); + await page.locator('#errorBtn').click(); + + const error1 = await error1Promise; + + const error2Promise = waitForError('nuxt-3-dynamic-import', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Another Error thrown from Nuxt-3 E2E test app'; + }); + + await page.locator('#errorBtn2').click(); + + const error2 = await error2Promise; + + expect(error1).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Nuxt-3 E2E test app', + mechanism: { + handled: false, + }, + }, + ], + }, + }); + + expect(error2).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Another Error thrown from Nuxt-3 E2E test app', + mechanism: { + handled: false, + }, + }, + ], + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.server.test.ts new file mode 100644 index 000000000000..b781642c2b4f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.server.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test.describe('server-side errors', async () => { + test('captures api fetch error (fetched on click)', async ({ page }) => { + const errorPromise = waitForError('nuxt-3-dynamic-import', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 3 Server error'; + }); + + await page.goto(`/fetch-server-error`); + await page.getByText('Fetch Server Data', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('GET /api/server-error'); + + const exception = error.exception.values[0]; + expect(exception.type).toEqual('Error'); + expect(exception.value).toEqual('Nuxt 3 Server error'); + expect(exception.mechanism.handled).toBe(false); + }); + + test('captures api fetch error (fetched on click) with parametrized route', async ({ page }) => { + const errorPromise = waitForError('nuxt-3-dynamic-import', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 3 Param Server error'; + }); + + await page.goto(`/test-param/1234`); + await page.getByRole('button', { name: 'Fetch Server Error', exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('GET /api/param-error/1234'); + + const exception = error.exception.values[0]; + expect(exception.type).toEqual('Error'); + expect(exception.value).toEqual('Nuxt 3 Param Server error'); + expect(exception.mechanism.handled).toBe(false); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.client.test.ts new file mode 100644 index 000000000000..e4cc6d7b35b2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.client.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@nuxt/test-utils/playwright'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import type { Span } from '@sentry/nuxt'; + +test('sends a pageload root span with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-3-dynamic-import', async transactionEvent => { + return transactionEvent.transaction === '/test-param/:param()'; + }); + + await page.goto(`/test-param/1234`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.pageload.vue', + 'sentry.op': 'pageload', + 'params.param': '1234', + }, + op: 'pageload', + origin: 'auto.pageload.vue', + }, + }, + transaction: '/test-param/:param()', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends component tracking spans when `trackComponents` is enabled', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-3-dynamic-import', async transactionEvent => { + return transactionEvent.transaction === '/client-error'; + }); + + await page.goto(`/client-error`); + + const rootSpan = await transactionPromise; + const errorButtonSpan = rootSpan.spans.find((span: Span) => span.description === 'Vue '); + + const expected = { + data: { 'sentry.origin': 'auto.ui.vue', 'sentry.op': 'ui.vue.mount' }, + description: 'Vue ', + op: 'ui.vue.mount', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.ui.vue', + }; + + expect(errorButtonSpan).toMatchObject(expected); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.server.test.ts new file mode 100644 index 000000000000..fd7a12e5e15d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.server.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; + +test('sends a server action transaction on pageload', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-3-dynamic-import', transactionEvent => { + return transactionEvent.transaction.includes('GET /test-param/'); + }); + + await page.goto('/test-param/1234'); + + const transaction = await transactionPromise; + + expect(transaction.contexts.trace).toEqual( + expect.objectContaining({ + data: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.http', + }), + }), + ); +}); + +test('does not send transactions for build asset folder "_nuxt"', async ({ page }) => { + let buildAssetFolderOccurred = false; + + waitForTransaction('nuxt-3-dynamic-import', transactionEvent => { + if (transactionEvent.transaction?.match(/^GET \/_nuxt\//)) { + buildAssetFolderOccurred = true; + } + return false; // expects to return a boolean (but not relevant here) + }); + + const transactionEventPromise = waitForTransaction('nuxt-3-dynamic-import', transactionEvent => { + return transactionEvent.transaction.includes('GET /test-param/'); + }); + + await page.goto('/test-param/1234'); + + const transactionEvent = await transactionEventPromise; + + expect(buildAssetFolderOccurred).toBe(false); + + // todo: url not yet parametrized + expect(transactionEvent.transaction).toBe('GET /test-param/1234'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts new file mode 100644 index 000000000000..fc14335e0bd9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('distributed tracing', () => { + const PARAM = 's0me-param'; + + test('capture a distributed pageload trace', async ({ page }) => { + const clientTxnEventPromise = waitForTransaction('nuxt-3-dynamic-import', txnEvent => { + return txnEvent.transaction === '/test-param/:param()'; + }); + + const serverTxnEventPromise = waitForTransaction('nuxt-3-dynamic-import', txnEvent => { + return txnEvent.transaction.includes('GET /test-param/'); + }); + + const [_, clientTxnEvent, serverTxnEvent] = await Promise.all([ + page.goto(`/test-param/${PARAM}`), + clientTxnEventPromise, + serverTxnEventPromise, + expect(page.getByText(`Param: ${PARAM}`)).toBeVisible(), + ]); + + expect(clientTxnEvent).toMatchObject({ + transaction: '/test-param/:param()', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.vue', + }, + }, + }); + + expect(serverTxnEvent).toMatchObject({ + transaction: 'GET /test-param/s0me-param', // todo: parametrize (nitro) + transaction_info: { source: 'url' }, + type: 'transaction', + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.otel.http', + }, + }, + }); + + // connected trace + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tsconfig.json b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tsconfig.json new file mode 100644 index 000000000000..a746f2a70c28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tsconfig.json @@ -0,0 +1,4 @@ +{ + // https://nuxt.com/docs/guide/concepts/typescript + "extends": "./.nuxt/tsconfig.json" +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json index 34180346b252..a65fd06d2829 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json @@ -9,6 +9,7 @@ "generate": "nuxt generate", "preview": "nuxt preview", "start": "node .output/server/index.mjs", + "start:import": "node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs", "clean": "npx nuxi cleanup", "test": "playwright test", "test:build": "pnpm install && npx playwright install && pnpm build", diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-min/playwright.config.ts index aa1ff8e9743c..6cea405151bd 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/playwright.config.ts @@ -12,7 +12,7 @@ const nuxtConfigOptions: ConfigOptions = { * Like this: import { expect, test } from '@nuxt/test-utils/playwright' */ const config = getPlaywrightConfig({ - startCommand: `pnpm start`, + startCommand: `pnpm start:import`, use: { ...nuxtConfigOptions }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/.gitignore b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/.gitignore new file mode 100644 index 000000000000..4a7f73a2ed0d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/.gitignore @@ -0,0 +1,24 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules + +# Logs +logs +*.log + +# Misc +.DS_Store +.fleet +.idea + +# Local env files +.env +.env.* +!.env.example diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/.npmrc b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-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/nuxt-3-top-level-import/app.vue b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/app.vue new file mode 100644 index 000000000000..23283a522546 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/app.vue @@ -0,0 +1,17 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/components/ErrorButton.vue b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/components/ErrorButton.vue new file mode 100644 index 000000000000..92ea714ae489 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/components/ErrorButton.vue @@ -0,0 +1,22 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/copyIITM.bash b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/copyIITM.bash new file mode 100644 index 000000000000..0e04d001c968 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/copyIITM.bash @@ -0,0 +1,7 @@ +# This script copies the `import-in-the-middle` content of the E2E test project root `node_modules` to the build output `node_modules` +# For some reason, some files are missing in the output (like `hook.mjs`) and this is not reproducible in external, standalone projects. +# +# Things we tried (that did not fix the problem): +# - Adding a resolution for `@vercel/nft` v0.27.0 (this worked in the standalone project) +# - Also adding `@vercel/nft` v0.27.0 to pnpm `peerDependencyRules` +cp -r node_modules/.pnpm/import-in-the-middle@1.*/node_modules/import-in-the-middle .output/server/node_modules/import-in-the-middle diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/nuxt.config.ts new file mode 100644 index 000000000000..d5828016d034 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/nuxt.config.ts @@ -0,0 +1,23 @@ +// https://nuxt.com/docs/api/configuration/nuxt-config +export default defineNuxtConfig({ + modules: ['@sentry/nuxt/module'], + imports: { + autoImport: false, + }, + runtimeConfig: { + public: { + sentry: { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }, + }, + }, + nitro: { + rollupConfig: { + // @sentry/... is set external to prevent bundling all of Sentry into the `runtime.mjs` file in the build output + external: [/@sentry\/.*/], + }, + }, + sentry: { + autoInjectServerSentry: 'top-level-import', + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/package.json new file mode 100644 index 000000000000..3d6bac1c006a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/package.json @@ -0,0 +1,25 @@ +{ + "name": "nuxt-3-top-level-import", + "private": true, + "type": "module", + "scripts": { + "build": "nuxt build && bash ./copyIITM.bash", + "dev": "nuxt dev", + "generate": "nuxt generate", + "preview": "nuxt preview", + "start": "node .output/server/index.mjs", + "clean": "npx nuxi cleanup", + "test": "playwright test", + "test:build": "pnpm install && npx playwright install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/nuxt": "latest || *", + "nuxt": "^3.14.0" + }, + "devDependencies": { + "@nuxt/test-utils": "^3.14.1", + "@playwright/test": "^1.44.1", + "@sentry-internal/test-utils": "link:../../../test-utils" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/pages/client-error.vue b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/pages/client-error.vue new file mode 100644 index 000000000000..5e1a14931f84 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/pages/client-error.vue @@ -0,0 +1,11 @@ + + + + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/pages/fetch-server-error.vue b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/pages/fetch-server-error.vue new file mode 100644 index 000000000000..8cb2a9997e58 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/pages/fetch-server-error.vue @@ -0,0 +1,13 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/pages/index.vue b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/pages/index.vue new file mode 100644 index 000000000000..74513c5697f3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/pages/index.vue @@ -0,0 +1,3 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/pages/test-param/[param].vue b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/pages/test-param/[param].vue new file mode 100644 index 000000000000..e83392b37b5c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/pages/test-param/[param].vue @@ -0,0 +1,23 @@ + + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/playwright.config.ts new file mode 100644 index 000000000000..aa1ff8e9743c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/playwright.config.ts @@ -0,0 +1,19 @@ +import { fileURLToPath } from 'node:url'; +import type { ConfigOptions } from '@nuxt/test-utils/playwright'; +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const nuxtConfigOptions: ConfigOptions = { + nuxt: { + rootDir: fileURLToPath(new URL('.', import.meta.url)), + }, +}; + +/* Make sure to import from '@nuxt/test-utils/playwright' in the tests + * Like this: import { expect, test } from '@nuxt/test-utils/playwright' */ + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, + use: { ...nuxtConfigOptions }, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/public/favicon.ico b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/public/favicon.ico new file mode 100644 index 000000000000..18993ad91cfd Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/public/favicon.ico differ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/sentry.client.config.ts new file mode 100644 index 000000000000..9a9566051452 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/sentry.client.config.ts @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/nuxt'; +import { useRuntimeConfig } from '#imports'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: useRuntimeConfig().public.sentry.dsn, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + integrations: [ + Sentry.vueIntegration({ + tracingOptions: { + trackComponents: true, + }, + }), + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/sentry.server.config.ts new file mode 100644 index 000000000000..729b2296c683 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/sentry.server.config.ts @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/nuxt'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + environment: 'qa', // dynamic sampling bias to keep transactions + tracesSampleRate: 1.0, // Capture 100% of the transactions + tunnel: 'http://localhost:3031/', // proxy server +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/server/api/param-error/[param].ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/server/api/param-error/[param].ts new file mode 100644 index 000000000000..389d8ac4d633 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/server/api/param-error/[param].ts @@ -0,0 +1,5 @@ +import { defineEventHandler } from '#imports'; + +export default defineEventHandler(_e => { + throw new Error('Nuxt 3 Param Server error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/server/api/server-error.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/server/api/server-error.ts new file mode 100644 index 000000000000..ec961a010510 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/server/api/server-error.ts @@ -0,0 +1,5 @@ +import { defineEventHandler } from '#imports'; + +export default defineEventHandler(event => { + throw new Error('Nuxt 3 Server error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/server/api/test-param/[param].ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/server/api/test-param/[param].ts new file mode 100644 index 000000000000..1867874cd494 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/server/api/test-param/[param].ts @@ -0,0 +1,7 @@ +import { defineEventHandler, getRouterParam } from '#imports'; + +export default defineEventHandler(event => { + const param = getRouterParam(event, 'param'); + + return `Param: ${param}!`; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/server/tsconfig.json b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/server/tsconfig.json new file mode 100644 index 000000000000..b9ed69c19eaf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/server/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../.nuxt/tsconfig.server.json" +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/start-event-proxy.mjs new file mode 100644 index 000000000000..96222429339e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nuxt-3-top-level-import', +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.client.test.ts new file mode 100644 index 000000000000..667075693223 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.client.test.ts @@ -0,0 +1,105 @@ +import { expect, test } from '@nuxt/test-utils/playwright'; +import { waitForError } from '@sentry-internal/test-utils'; + +test.describe('client-side errors', async () => { + test('captures error thrown on click', async ({ page }) => { + const errorPromise = waitForError('nuxt-3-top-level-import', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Nuxt-3 E2E test app'; + }); + + await page.goto(`/client-error`); + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('/client-error'); + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Nuxt-3 E2E test app', + mechanism: { + handled: false, + }, + }, + ], + }, + }); + }); + + test('shows parametrized route on button error', async ({ page }) => { + const errorPromise = waitForError('nuxt-3-top-level-import', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Param Route Button'; + }); + + await page.goto(`/test-param/1234`); + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + expect(error.sdk.name).toEqual('sentry.javascript.nuxt'); + expect(error.transaction).toEqual('/test-param/:param()'); + expect(error.request.url).toMatch(/\/test-param\/1234/); + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Param Route Button', + mechanism: { + handled: false, + }, + }, + ], + }, + }); + }); + + test('page is still interactive after client error', async ({ page }) => { + const error1Promise = waitForError('nuxt-3-top-level-import', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Nuxt-3 E2E test app'; + }); + + await page.goto(`/client-error`); + await page.locator('#errorBtn').click(); + + const error1 = await error1Promise; + + const error2Promise = waitForError('nuxt-3-top-level-import', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Another Error thrown from Nuxt-3 E2E test app'; + }); + + await page.locator('#errorBtn2').click(); + + const error2 = await error2Promise; + + expect(error1).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Nuxt-3 E2E test app', + mechanism: { + handled: false, + }, + }, + ], + }, + }); + + expect(error2).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Another Error thrown from Nuxt-3 E2E test app', + mechanism: { + handled: false, + }, + }, + ], + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.server.test.ts new file mode 100644 index 000000000000..3066a736cf96 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.server.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test.describe('server-side errors', async () => { + test('captures api fetch error (fetched on click)', async ({ page }) => { + const errorPromise = waitForError('nuxt-3-top-level-import', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 3 Server error'; + }); + + await page.goto(`/fetch-server-error`); + await page.getByText('Fetch Server Data', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('GET /api/server-error'); + + const exception = error.exception.values[0]; + expect(exception.type).toEqual('Error'); + expect(exception.value).toEqual('Nuxt 3 Server error'); + expect(exception.mechanism.handled).toBe(false); + }); + + test('captures api fetch error (fetched on click) with parametrized route', async ({ page }) => { + const errorPromise = waitForError('nuxt-3-top-level-import', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 3 Param Server error'; + }); + + await page.goto(`/test-param/1234`); + await page.getByRole('button', { name: 'Fetch Server Error', exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('GET /api/param-error/1234'); + + const exception = error.exception.values[0]; + expect(exception.type).toEqual('Error'); + expect(exception.value).toEqual('Nuxt 3 Param Server error'); + expect(exception.mechanism.handled).toBe(false); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.client.test.ts new file mode 100644 index 000000000000..44b37a90f7a0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.client.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@nuxt/test-utils/playwright'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import type { Span } from '@sentry/nuxt'; + +test('sends a pageload root span with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-3-top-level-import', async transactionEvent => { + return transactionEvent.transaction === '/test-param/:param()'; + }); + + await page.goto(`/test-param/1234`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.pageload.vue', + 'sentry.op': 'pageload', + 'params.param': '1234', + }, + op: 'pageload', + origin: 'auto.pageload.vue', + }, + }, + transaction: '/test-param/:param()', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends component tracking spans when `trackComponents` is enabled', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-3-top-level-import', async transactionEvent => { + return transactionEvent.transaction === '/client-error'; + }); + + await page.goto(`/client-error`); + + const rootSpan = await transactionPromise; + const errorButtonSpan = rootSpan.spans.find((span: Span) => span.description === 'Vue '); + + const expected = { + data: { 'sentry.origin': 'auto.ui.vue', 'sentry.op': 'ui.vue.mount' }, + description: 'Vue ', + op: 'ui.vue.mount', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.ui.vue', + }; + + expect(errorButtonSpan).toMatchObject(expected); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.server.test.ts new file mode 100644 index 000000000000..748c7f25354b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.server.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; + +test('sends a server action transaction on pageload', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-3-top-level-import', transactionEvent => { + return transactionEvent.transaction.includes('GET /test-param/'); + }); + + await page.goto('/test-param/1234'); + + const transaction = await transactionPromise; + + expect(transaction.contexts.trace).toEqual( + expect.objectContaining({ + data: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.http', + }), + }), + ); +}); + +test('does not send transactions for build asset folder "_nuxt"', async ({ page }) => { + let buildAssetFolderOccurred = false; + + waitForTransaction('nuxt-3-top-level-import', transactionEvent => { + if (transactionEvent.transaction?.match(/^GET \/_nuxt\//)) { + buildAssetFolderOccurred = true; + } + return false; // expects to return a boolean (but not relevant here) + }); + + const transactionEventPromise = waitForTransaction('nuxt-3-top-level-import', transactionEvent => { + return transactionEvent.transaction.includes('GET /test-param/'); + }); + + await page.goto('/test-param/1234'); + + const transactionEvent = await transactionEventPromise; + + expect(buildAssetFolderOccurred).toBe(false); + + // todo: url not yet parametrized + expect(transactionEvent.transaction).toBe('GET /test-param/1234'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.test.ts new file mode 100644 index 000000000000..e8df55587799 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('distributed tracing', () => { + const PARAM = 's0me-param'; + + test('capture a distributed pageload trace', async ({ page }) => { + const clientTxnEventPromise = waitForTransaction('nuxt-3-top-level-import', txnEvent => { + return txnEvent.transaction === '/test-param/:param()'; + }); + + const serverTxnEventPromise = waitForTransaction('nuxt-3-top-level-import', txnEvent => { + return txnEvent.transaction.includes('GET /test-param/'); + }); + + const [_, clientTxnEvent, serverTxnEvent] = await Promise.all([ + page.goto(`/test-param/${PARAM}`), + clientTxnEventPromise, + serverTxnEventPromise, + expect(page.getByText(`Param: ${PARAM}`)).toBeVisible(), + ]); + + expect(clientTxnEvent).toMatchObject({ + transaction: '/test-param/:param()', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.vue', + }, + }, + }); + + expect(serverTxnEvent).toMatchObject({ + transaction: 'GET /test-param/s0me-param', // todo: parametrize (nitro) + transaction_info: { source: 'url' }, + type: 'transaction', + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.otel.http', + }, + }, + }); + + // connected trace + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); + expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tsconfig.json b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tsconfig.json new file mode 100644 index 000000000000..a746f2a70c28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tsconfig.json @@ -0,0 +1,4 @@ +{ + // https://nuxt.com/docs/guide/concepts/typescript + "extends": "./.nuxt/tsconfig.json" +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json index 8cc66d2d408e..99e197f35c45 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json @@ -8,6 +8,7 @@ "generate": "nuxt generate", "preview": "nuxt preview", "start": "node .output/server/index.mjs", + "start:import": "node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs", "clean": "npx nuxi cleanup", "test": "playwright test", "test:build": "pnpm install && npx playwright install && pnpm build", diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/playwright.config.ts index aa1ff8e9743c..6cea405151bd 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/playwright.config.ts @@ -12,7 +12,7 @@ const nuxtConfigOptions: ConfigOptions = { * Like this: import { expect, test } from '@nuxt/test-utils/playwright' */ const config = getPlaywrightConfig({ - startCommand: `pnpm start`, + startCommand: `pnpm start:import`, use: { ...nuxtConfigOptions }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index 178804768e87..7761d128a834 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -8,6 +8,7 @@ "generate": "nuxt generate", "preview": "nuxt preview", "start": "node .output/server/index.mjs", + "start:import": "node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs", "clean": "npx nuxi cleanup", "test": "playwright test", "test:build": "pnpm install && npx playwright install && pnpm build", diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/playwright.config.ts index aa1ff8e9743c..6cea405151bd 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/playwright.config.ts @@ -12,7 +12,7 @@ const nuxtConfigOptions: ConfigOptions = { * Like this: import { expect, test } from '@nuxt/test-utils/playwright' */ const config = getPlaywrightConfig({ - startCommand: `pnpm start`, + startCommand: `pnpm start:import`, use: { ...nuxtConfigOptions }, }); diff --git a/packages/nuxt/src/common/types.ts b/packages/nuxt/src/common/types.ts index 46f390120cfe..93ca94016924 100644 --- a/packages/nuxt/src/common/types.ts +++ b/packages/nuxt/src/common/types.ts @@ -103,22 +103,35 @@ export type SentryNuxtModuleOptions = { debug?: boolean; /** - * Wraps the server entry file with a dynamic `import()`. This will make it possible to preload Sentry and register - * necessary hooks before other code runs. (Node docs: https://nodejs.org/api/module.html#enabling) * - * If this option is `false`, the Sentry SDK won't wrap the server entry file with `import()`. Not wrapping the - * server entry file will disable Sentry on the server-side. When you set this option to `false`, make sure - * to add the Sentry server config with the node `--import` CLI flag to enable Sentry on the server-side. + * 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 `dynamicImportForServerEntry` is set to `true` (default). + * **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. * - * @default true + * --- + * + * **"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. + * + * --- + * **"experimental_dynamic-import"** + * + * Wraps the server entry file with a dynamic `import()`. This will make it possible to preload Sentry and register + * necessary hooks before other code runs. (Node docs: https://nodejs.org/api/module.html#enabling) + * + * If `"experimental_dynamic-import"` is enabled, the Sentry SDK wraps the server entry file with `import()`. + * + * @default undefined */ - dynamicImportForServerEntry?: boolean; + autoInjectServerSentry?: 'top-level-import' | 'experimental_dynamic-import'; /** - * By default—unless you configure `dynamicImportForServerEntry: false`—the SDK will try to wrap your Nitro server entrypoint + * When `autoInjectServerSentry` is set to `"experimental_dynamic-import"`, the SDK will wrap your Nitro server entrypoint * with a dynamic `import()` to ensure all dependencies can be properly instrumented. Any previous exports from the entrypoint are still exported. * Most exports of the server entrypoint are serverless functions and those are wrapped by Sentry. Other exports stay as-is. * @@ -128,7 +141,7 @@ export type SentryNuxtModuleOptions = { * * @default ['default', 'handler', 'server'] */ - entrypointWrappedFunctions?: string[]; + experimental_entrypointWrappedFunctions?: string[]; /** * Options to be passed directly to the Sentry Rollup Plugin (`@sentry/rollup-plugin`) and Sentry Vite Plugin (`@sentry/vite-plugin`) that ship with the Sentry Nuxt SDK. diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index bd6cb96122de..4f6fe76928cd 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -2,7 +2,7 @@ import * as path from 'path'; import { addPlugin, addPluginTemplate, addServerPlugin, createResolver, defineNuxtModule } from '@nuxt/kit'; import { consoleSandbox } from '@sentry/core'; import type { SentryNuxtModuleOptions } from './common/types'; -import { addDynamicImportEntryFileWrapper, addServerConfigToBuild } from './vite/addServerConfig'; +import { addDynamicImportEntryFileWrapper, addSentryTopImport, addServerConfigToBuild } from './vite/addServerConfig'; import { setupSourceMaps } from './vite/sourceMaps'; import { findDefaultSdkInitFile } from './vite/utils'; @@ -20,8 +20,12 @@ export default defineNuxtModule({ setup(moduleOptionsParam, nuxt) { const moduleOptions = { ...moduleOptionsParam, - dynamicImportForServerEntry: moduleOptionsParam.dynamicImportForServerEntry !== false, // default: true - entrypointWrappedFunctions: moduleOptionsParam.entrypointWrappedFunctions || ['default', 'handler', 'server'], + autoInjectServerSentry: moduleOptionsParam.autoInjectServerSentry, + experimental_entrypointWrappedFunctions: moduleOptionsParam.experimental_entrypointWrappedFunctions || [ + 'default', + 'handler', + 'server', + ], }; const moduleDirResolver = createResolver(import.meta.url); @@ -54,15 +58,15 @@ export default defineNuxtModule({ const serverConfigFile = findDefaultSdkInitFile('server'); if (serverConfigFile) { - if (moduleOptions.dynamicImportForServerEntry === false) { - // Inject the server-side Sentry config file with a side effect import + if (moduleOptions.autoInjectServerSentry !== 'experimental_dynamic-import') { addPluginTemplate({ mode: 'server', filename: 'sentry-server-config.mjs', getContents: () => - `import "${buildDirResolver.resolve(`/${serverConfigFile}`)}"\n` + - 'import { defineNuxtPlugin } from "#imports"\n' + - 'export default defineNuxtPlugin(() => {})', + // This won't actually import the server config in the build output (so no double init call). The import here is only needed for correctly resolving the Sentry release injection. + `import "${buildDirResolver.resolve(`/${serverConfigFile}`)}"; + import { defineNuxtPlugin } from "#imports"; + export default defineNuxtPlugin(() => {});`, }); } @@ -79,12 +83,12 @@ export default defineNuxtModule({ consoleSandbox(() => { // eslint-disable-next-line no-console console.log( - '[Sentry] Your application is running in development mode. Note: @sentry/nuxt is in beta and may not work as expected on the server-side (Nitro). Errors are reported, but tracing does not work.', + '[Sentry] Your application is running in development mode. Note: @sentry/nuxt does not work as expected on the server-side (Nitro). Errors are reported, but tracing does not work.', ); }); } - if (moduleOptions.dynamicImportForServerEntry === false) { + if (moduleOptions.autoInjectServerSentry !== 'experimental_dynamic-import') { addServerConfigToBuild(moduleOptions, nuxt, nitro, serverConfigFile); if (moduleOptions.debug) { @@ -101,7 +105,13 @@ export default defineNuxtModule({ ); }); } - } else { + } + + if (moduleOptions.autoInjectServerSentry === 'top-level-import') { + addSentryTopImport(moduleOptions, nitro); + } + + if (moduleOptions.autoInjectServerSentry === 'experimental_dynamic-import') { addDynamicImportEntryFileWrapper(nitro, serverConfigFile, moduleOptions); if (moduleOptions.debug) { diff --git a/packages/nuxt/src/vite/addServerConfig.ts b/packages/nuxt/src/vite/addServerConfig.ts index 5ac673b3dd20..b5577830396b 100644 --- a/packages/nuxt/src/vite/addServerConfig.ts +++ b/packages/nuxt/src/vite/addServerConfig.ts @@ -12,6 +12,7 @@ import { SENTRY_WRAPPED_FUNCTIONS, constructFunctionReExport, constructWrappedFunctionExportQuery, + getFilenameFromNodeStartCommand, removeSentryQueryFromPath, } from './utils'; @@ -38,41 +39,90 @@ export function addServerConfigToBuild( (viteInlineConfig.build.rollupOptions.input as { [entryName: string]: string })[SERVER_CONFIG_FILENAME] = createResolver(nuxt.options.srcDir).resolve(`/${serverConfigFile}`); } + }); + + /** + * When the build process is finished, copy the `sentry.server.config` file to the `.output` directory. + * This is necessary because we need to reference this file path in the node --import option. + */ + nitro.hooks.hook('close', async () => { + const buildDirResolver = createResolver(nitro.options.buildDir); + const serverDirResolver = createResolver(nitro.options.output.serverDir); + const source = buildDirResolver.resolve(`dist/server/${SERVER_CONFIG_FILENAME}.mjs`); + const destination = serverDirResolver.resolve(`${SERVER_CONFIG_FILENAME}.mjs`); + + try { + await fs.promises.access(source, fs.constants.F_OK); + await fs.promises.copyFile(source, destination); + + if (moduleOptions.debug) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.log( + `[Sentry] Successfully added the content of the \`${serverConfigFile}\` file to \`${destination}\``, + ); + }); + } + } catch (error) { + if (moduleOptions.debug) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + `[Sentry] An error occurred when trying to add the \`${serverConfigFile}\` file to the \`.output\` directory`, + error, + ); + }); + } + } + }); +} - /** - * When the build process is finished, copy the `sentry.server.config` file to the `.output` directory. - * This is necessary because we need to reference this file path in the node --import option. - */ - nitro.hooks.hook('close', async () => { - const buildDirResolver = createResolver(nitro.options.buildDir); - const serverDirResolver = createResolver(nitro.options.output.serverDir); - const source = buildDirResolver.resolve(`dist/server/${SERVER_CONFIG_FILENAME}.mjs`); - const destination = serverDirResolver.resolve(`${SERVER_CONFIG_FILENAME}.mjs`); - - try { - await fs.promises.access(source, fs.constants.F_OK); - await fs.promises.copyFile(source, destination); - - if (moduleOptions.debug) { - consoleSandbox(() => { +/** + * Adds the Sentry server config import at the top of the server entry file to load the SDK on the server. + * This is necessary for environments where modifying the node option `--import` is not possible. + * However, only limited tracing instrumentation is supported when doing this. + */ +export function addSentryTopImport(moduleOptions: SentryNuxtModuleOptions, nitro: Nitro): void { + nitro.hooks.hook('close', async () => { + const fileNameFromCommand = + nitro.options.commands.preview && getFilenameFromNodeStartCommand(nitro.options.commands.preview); + + // other presets ('node-server' or 'vercel') have an index.mjs + const presetsWithServerFile = ['netlify']; + + const entryFileName = fileNameFromCommand + ? fileNameFromCommand + : typeof nitro.options.rollupConfig?.output.entryFileNames === 'string' + ? nitro.options.rollupConfig?.output.entryFileNames + : presetsWithServerFile.includes(nitro.options.preset) + ? 'server.mjs' + : 'index.mjs'; + + const serverDirResolver = createResolver(nitro.options.output.serverDir); + const entryFilePath = serverDirResolver.resolve(entryFileName); + + try { + fs.readFile(entryFilePath, 'utf8', (err, data) => { + const updatedContent = `import './${SERVER_CONFIG_FILENAME}.mjs';\n${data}`; + + fs.writeFile(entryFilePath, updatedContent, 'utf8', () => { + if (moduleOptions.debug) { // eslint-disable-next-line no-console console.log( - `[Sentry] Successfully added the content of the \`${serverConfigFile}\` file to \`${destination}\``, - ); - }); - } - } catch (error) { - if (moduleOptions.debug) { - consoleSandbox(() => { - // eslint-disable-next-line no-console - console.warn( - `[Sentry] An error occurred when trying to add the \`${serverConfigFile}\` file to the \`.output\` directory`, - error, + `[Sentry] Successfully added the Sentry import to the server entry file "\`${entryFilePath}\`"`, ); - }); - } + } + }); + }); + } catch (err) { + if (moduleOptions.debug) { + // eslint-disable-next-line no-console + console.warn( + `[Sentry] An error occurred when trying to add the Sentry import to the server entry file "\`${entryFilePath}\`":`, + err, + ); } - }); + } }); } @@ -86,8 +136,8 @@ export function addServerConfigToBuild( export function addDynamicImportEntryFileWrapper( nitro: Nitro, serverConfigFile: string, - moduleOptions: Omit & - Required>, + moduleOptions: Omit & + Required>, ): void { if (!nitro.options.rollupConfig) { nitro.options.rollupConfig = { output: {} }; @@ -103,7 +153,7 @@ export function addDynamicImportEntryFileWrapper( nitro.options.rollupConfig.plugins.push( wrapEntryWithDynamicImport({ resolvedSentryConfigPath: createResolver(nitro.options.srcDir).resolve(`/${serverConfigFile}`), - entrypointWrappedFunctions: moduleOptions.entrypointWrappedFunctions, + experimental_entrypointWrappedFunctions: moduleOptions.experimental_entrypointWrappedFunctions, }), ); } @@ -115,9 +165,13 @@ export function addDynamicImportEntryFileWrapper( */ function wrapEntryWithDynamicImport({ resolvedSentryConfigPath, - entrypointWrappedFunctions, + experimental_entrypointWrappedFunctions, debug, -}: { resolvedSentryConfigPath: string; entrypointWrappedFunctions: string[]; debug?: boolean }): InputPluginOption { +}: { + resolvedSentryConfigPath: string; + experimental_entrypointWrappedFunctions: string[]; + debug?: boolean; +}): InputPluginOption { // In order to correctly import the server config file // and dynamically import the nitro runtime, we need to // mark the resolutionId with '\0raw' to fall into the @@ -156,7 +210,11 @@ function wrapEntryWithDynamicImport({ // Concatenates the query params to mark the file (also attaches names of re-exports - this is needed for serverless functions to re-export the handler) .concat(SENTRY_WRAPPED_ENTRY) .concat( - constructWrappedFunctionExportQuery(moduleInfo.exportedBindings, entrypointWrappedFunctions, debug), + constructWrappedFunctionExportQuery( + moduleInfo.exportedBindings, + experimental_entrypointWrappedFunctions, + debug, + ), ) .concat(QUERY_END_INDICATOR)}`; } diff --git a/packages/nuxt/src/vite/utils.ts b/packages/nuxt/src/vite/utils.ts index fff676a6ede1..85696f76f6ae 100644 --- a/packages/nuxt/src/vite/utils.ts +++ b/packages/nuxt/src/vite/utils.ts @@ -26,6 +26,15 @@ export function findDefaultSdkInitFile(type: 'server' | 'client'): string | unde return filePaths.find(filename => fs.existsSync(filename)); } +/** + * Extracts the filename from a node command with a path. + */ +export function getFilenameFromNodeStartCommand(nodeCommand: string): string | null { + const regex = /[^/\\]+$/; + const match = nodeCommand.match(regex); + return match ? match[0] : null; +} + 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='; @@ -113,7 +122,7 @@ export function constructWrappedFunctionExportQuery( consoleSandbox(() => // eslint-disable-next-line no-console console.warn( - "[Sentry] No functions found to wrap. In case the server needs to export async functions other than `handler` or `server`, consider adding the name(s) to Sentry's build options `sentry.entrypointWrappedFunctions` in `nuxt.config.ts`.", + "[Sentry] No functions found to wrap. In case the server needs to export async functions other than `handler` or `server`, consider adding the name(s) to Sentry's build options `sentry.experimental_entrypointWrappedFunctions` in `nuxt.config.ts`.", ), ); } diff --git a/packages/nuxt/test/vite/utils.test.ts b/packages/nuxt/test/vite/utils.test.ts index a35f9cf8ca34..f2f6b2b23c8d 100644 --- a/packages/nuxt/test/vite/utils.test.ts +++ b/packages/nuxt/test/vite/utils.test.ts @@ -9,6 +9,7 @@ import { constructWrappedFunctionExportQuery, extractFunctionReexportQueryParameters, findDefaultSdkInitFile, + getFilenameFromNodeStartCommand, removeSentryQueryFromPath, } from '../../src/vite/utils'; @@ -70,6 +71,44 @@ describe('findDefaultSdkInitFile', () => { }); }); +describe('getFilenameFromPath', () => { + it('should return the filename from a simple path', () => { + const path = 'node ./server/index.mjs'; + const filename = getFilenameFromNodeStartCommand(path); + expect(filename).toBe('index.mjs'); + }); + + it('should return the filename from a nested path', () => { + const path = 'node ./.output/whatever/path/server.js'; + const filename = getFilenameFromNodeStartCommand(path); + expect(filename).toBe('server.js'); + }); + + it('should return the filename from a Windows-style path', () => { + const path = '.\\Projects\\my-app\\src\\main.js'; + const filename = getFilenameFromNodeStartCommand(path); + expect(filename).toBe('main.js'); + }); + + it('should return null for an empty path', () => { + const path = ''; + const filename = getFilenameFromNodeStartCommand(path); + expect(filename).toBeNull(); + }); + + it('should return the filename when there are no directory separators', () => { + const path = 'index.mjs'; + const filename = getFilenameFromNodeStartCommand(path); + expect(filename).toBe('index.mjs'); + }); + + it('should return null for paths with trailing slashes', () => { + const path = 'node ./server/'; + const filename = getFilenameFromNodeStartCommand(path); + expect(filename).toBeNull(); + }); +}); + describe('removeSentryQueryFromPath', () => { it('strips the Sentry query part from the path', () => { const url = `/example/path${SENTRY_WRAPPED_ENTRY}${SENTRY_WRAPPED_FUNCTIONS}foo,${QUERY_END_INDICATOR}`; @@ -152,7 +191,7 @@ describe('constructWrappedFunctionExportQuery', () => { const result = constructWrappedFunctionExportQuery(exportedBindings, entrypointWrappedFunctions, debug); expect(result).toBe('?sentry-query-reexported-functions=handler'); expect(consoleWarnSpy).toHaveBeenCalledWith( - "[Sentry] No functions found to wrap. In case the server needs to export async functions other than `handler` or `server`, consider adding the name(s) to Sentry's build options `sentry.entrypointWrappedFunctions` in `nuxt.config.ts`.", + "[Sentry] No functions found to wrap. In case the server needs to export async functions other than `handler` or `server`, consider adding the name(s) to Sentry's build options `sentry.experimental_entrypointWrappedFunctions` in `nuxt.config.ts`.", ); consoleWarnSpy.mockRestore();