From f09630b22ebd97da56be64e77f3389c42b525ff8 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:08:42 +0200 Subject: [PATCH] test(e2e): Add nuxt-4-static app to keep static trace lifecycle coverage Co-Authored-By: Claude Fable 5 --- .../nuxt-4-static/.gitignore | 24 ++ .../app/components/ErrorButton.vue | 22 ++ .../app/composables/use-sentry-test-tag.ts | 8 + .../nuxt-4-static/app/pages/client-error.vue | 16 + .../app/pages/fetch-server-routes.vue | 18 + .../nuxt-4-static/app/pages/index.vue | 20 ++ .../nuxt-4-static/app/pages/pinia-cart.vue | 65 ++++ .../rendering-modes/client-side-only-page.vue | 1 + .../rendering-modes/isr-1h-cached-page.vue | 1 + .../pages/rendering-modes/isr-cached-page.vue | 1 + .../rendering-modes/pre-rendered-page.vue | 1 + .../rendering-modes/swr-1h-cached-page.vue | 1 + .../pages/rendering-modes/swr-cached-page.vue | 1 + .../app/pages/test-param/[param].vue | 22 ++ .../app/pages/test-param/user/[userId].vue | 16 + .../nuxt-4-static/docker-compose.yml | 31 ++ .../nuxt-4-static/global-setup.mjs | 14 + .../nuxt-4-static/global-teardown.mjs | 12 + .../nuxt-4-static/modules/another-module.ts | 9 + .../nuxt-4-static/nuxt-start-dev-server.bash | 55 +++ .../nuxt-4-static/nuxt.config.ts | 47 +++ .../nuxt-4-static/package.json | 50 +++ .../nuxt-4-static/playwright.config.ts | 29 ++ .../nuxt-4-static/public/favicon.ico | Bin 0 -> 4286 bytes .../nuxt-4-static/sentry.client.config.ts | 23 ++ .../nuxt-4-static/sentry.server.config.ts | 11 + .../nuxt-4-static/server/api/cache-test.ts | 84 +++++ .../nuxt-4-static/server/api/db-ioredis.ts | 17 + .../nuxt-4-static/server/api/db-multi-test.ts | 102 ++++++ .../nuxt-4-static/server/api/db-mysql.ts | 27 ++ .../nuxt-4-static/server/api/db-test.ts | 70 ++++ .../server/api/flush-regression.ts | 9 + .../server/api/middleware-test.ts | 15 + .../nuxt-4-static/server/api/nitro-fetch.ts | 5 + .../server/api/param-error/[param].ts | 5 + .../nuxt-4-static/server/api/server-error.ts | 5 + .../server/api/storage-aliases-test.ts | 46 +++ .../nuxt-4-static/server/api/storage-test.ts | 54 +++ .../server/api/test-param/[param].ts | 7 + .../nuxt-4-static/server/api/user/[userId].ts | 7 + .../server/middleware/01.first.ts | 6 + .../server/middleware/02.second.ts | 7 + .../server/middleware/03.auth.ts | 12 + .../server/middleware/04.hooks.ts | 36 ++ .../server/middleware/05.array-hooks.ts | 47 +++ .../nuxt-4-static/server/tsconfig.json | 3 + .../nuxt-4-static/start-event-proxy.mjs | 6 + .../nuxt-4-static/stores/cart.ts | 43 +++ .../tests/build-injection.test.ts | 45 +++ .../nuxt-4-static/tests/cache.test.ts | 157 +++++++++ .../tests/database-multi.test.ts | 156 +++++++++ .../nuxt-4-static/tests/database.test.ts | 201 +++++++++++ .../nuxt-4-static/tests/db-drivers.test.ts | 101 ++++++ .../nuxt-4-static/tests/environment.test.ts | 77 ++++ .../nuxt-4-static/tests/errors.client.test.ts | 151 ++++++++ .../nuxt-4-static/tests/errors.server.test.ts | 72 ++++ .../tests/flush-regression.test.ts | 34 ++ .../nuxt-4-static/tests/isDevMode.ts | 1 + .../nuxt-4-static/tests/middleware.test.ts | 331 ++++++++++++++++++ .../nuxt-4-static/tests/pinia.test.ts | 35 ++ .../tests/storage-aliases.test.ts | 108 ++++++ .../nuxt-4-static/tests/storage.test.ts | 151 ++++++++ .../tests/tracing.cached-html.test.ts | 206 +++++++++++ .../tests/tracing.client.test.ts | 95 +++++ .../tests/tracing.server.test.ts | 63 ++++ .../nuxt-4-static/tests/tracing.test.ts | 157 +++++++++ .../nuxt-4-static/tsconfig.json | 4 + 67 files changed, 3256 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/app/components/ErrorButton.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/app/composables/use-sentry-test-tag.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/client-error.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/fetch-server-routes.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/index.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/pinia-cart.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/client-side-only-page.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/isr-1h-cached-page.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/isr-cached-page.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/pre-rendered-page.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/swr-1h-cached-page.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/swr-cached-page.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/test-param/[param].vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/test-param/user/[userId].vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/docker-compose.yml create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/global-setup.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/global-teardown.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/modules/another-module.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/nuxt-start-dev-server.bash create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/nuxt.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/public/favicon.ico create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/sentry.client.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/sentry.server.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/cache-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-ioredis.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-multi-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-mysql.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/flush-regression.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/middleware-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/nitro-fetch.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/param-error/[param].ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/server-error.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/storage-aliases-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/storage-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/test-param/[param].ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/user/[userId].ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/01.first.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/02.second.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/03.auth.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/04.hooks.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/05.array-hooks.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/server/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/stores/cart.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/build-injection.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/cache.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/database-multi.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/database.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/db-drivers.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/environment.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.client.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/flush-regression.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/isDevMode.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/middleware.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/pinia.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/storage-aliases.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/storage.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.cached-html.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.client.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-static/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/.gitignore b/dev-packages/e2e-tests/test-applications/nuxt-4-static/.gitignore new file mode 100644 index 000000000000..4a7f73a2ed0d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/.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-4-static/app/components/ErrorButton.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/components/ErrorButton.vue new file mode 100644 index 000000000000..b686436bee17 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/components/ErrorButton.vue @@ -0,0 +1,22 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/composables/use-sentry-test-tag.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/composables/use-sentry-test-tag.ts new file mode 100644 index 000000000000..0d6642ca3d8c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/composables/use-sentry-test-tag.ts @@ -0,0 +1,8 @@ +// fixme: this needs to be imported from @sentry/core, not @sentry/nuxt in dev mode (because of import-in-the-middle error) +// This could also be a problem with the specific setup of the pnpm E2E test setup, because this could not be reproduced outside of the E2E test. +// Related to this: https://github.com/getsentry/sentry-javascript/issues/15204#issuecomment-2948908130 +import { setTag } from '@sentry/nuxt'; + +export default function useSentryTestTag(): void { + setTag('test-tag', null); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/client-error.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/client-error.vue new file mode 100644 index 000000000000..97f8abf00fb9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/client-error.vue @@ -0,0 +1,16 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/fetch-server-routes.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/fetch-server-routes.vue new file mode 100644 index 000000000000..089d77a2eee9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/fetch-server-routes.vue @@ -0,0 +1,18 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/index.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/index.vue new file mode 100644 index 000000000000..57a583eb43b1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/index.vue @@ -0,0 +1,20 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/pinia-cart.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/pinia-cart.vue new file mode 100644 index 000000000000..07464aa28ca3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/pinia-cart.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/client-side-only-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/client-side-only-page.vue new file mode 100644 index 000000000000..fb41b62b3308 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/client-side-only-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/isr-1h-cached-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/isr-1h-cached-page.vue new file mode 100644 index 000000000000..e702eca86715 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/isr-1h-cached-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/isr-cached-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/isr-cached-page.vue new file mode 100644 index 000000000000..780adc07de53 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/isr-cached-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/pre-rendered-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/pre-rendered-page.vue new file mode 100644 index 000000000000..25b423a4c442 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/pre-rendered-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/swr-1h-cached-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/swr-1h-cached-page.vue new file mode 100644 index 000000000000..24918924f4a9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/swr-1h-cached-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/swr-cached-page.vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/swr-cached-page.vue new file mode 100644 index 000000000000..d0d8e7241968 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/rendering-modes/swr-cached-page.vue @@ -0,0 +1 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/test-param/[param].vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/test-param/[param].vue new file mode 100644 index 000000000000..019404aaf460 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/test-param/[param].vue @@ -0,0 +1,22 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/test-param/user/[userId].vue b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/test-param/user/[userId].vue new file mode 100644 index 000000000000..41daf0460b05 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/app/pages/test-param/user/[userId].vue @@ -0,0 +1,16 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/docker-compose.yml b/dev-packages/e2e-tests/test-applications/nuxt-4-static/docker-compose.yml new file mode 100644 index 000000000000..a50a0097e5ae --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/docker-compose.yml @@ -0,0 +1,31 @@ +services: + db: + image: mysql:8.0 + restart: always + container_name: e2e-tests-nuxt-4-mysql + # The `mysql` 2.x driver doesn't speak MySQL 8's default + # `caching_sha2_password` auth, so force the legacy plugin. + command: ['--default-authentication-plugin=mysql_native_password'] + ports: + - '3306:3306' + environment: + MYSQL_ROOT_PASSWORD: docker + healthcheck: + test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -pdocker'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 10s + + redis: + image: redis:7 + restart: always + container_name: e2e-tests-nuxt-4-redis + ports: + - '6379:6379' + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/global-setup.mjs b/dev-packages/e2e-tests/test-applications/nuxt-4-static/global-setup.mjs new file mode 100644 index 000000000000..cb48d539c466 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/global-setup.mjs @@ -0,0 +1,14 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalSetup() { + // Start MySQL + Redis via Docker Compose. `--wait` blocks until the + // healthchecks in docker-compose.yml pass, so the app can connect immediately. + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/nuxt-4-static/global-teardown.mjs new file mode 100644 index 000000000000..2742279431ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/global-teardown.mjs @@ -0,0 +1,12 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalTeardown() { + execSync('docker compose down --volumes', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/modules/another-module.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/modules/another-module.ts new file mode 100644 index 000000000000..9c1a3ca80487 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/modules/another-module.ts @@ -0,0 +1,9 @@ +import { defineNuxtModule } from 'nuxt/kit'; + +// Just a fake module to check if the SDK works alongside other local Nuxt modules without breaking the build +export default defineNuxtModule({ + meta: { name: 'another-module' }, + setup() { + console.log('another-module setup called'); + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/nuxt-start-dev-server.bash b/dev-packages/e2e-tests/test-applications/nuxt-4-static/nuxt-start-dev-server.bash new file mode 100644 index 000000000000..4affbb553b0e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/nuxt-start-dev-server.bash @@ -0,0 +1,55 @@ +#!/bin/bash +# To enable Sentry in Nuxt dev, it needs the sentry.server.config.mjs file from the .nuxt folder. +# First, we need to start 'nuxt dev' to generate the file, and then start 'nuxt dev' again with the NODE_OPTIONS to have Sentry enabled. + +# Using a different port to avoid playwright already starting with the tests for port 3030 +TEMP_PORT=3035 + +# 1. Start dev in background - this generates .nuxt folder +pnpm dev -p $TEMP_PORT & +DEV_PID=$! + +# 2. Wait for the sentry.server.config.mjs file to appear +echo "Waiting for .nuxt/dev/sentry.server.config.mjs file..." +COUNTER=0 +while [ ! -f ".nuxt/dev/sentry.server.config.mjs" ] && [ $COUNTER -lt 30 ]; do + sleep 1 + ((COUNTER++)) +done + +if [ ! -f ".nuxt/dev/sentry.server.config.mjs" ]; then + echo "ERROR: .nuxt/dev/sentry.server.config.mjs file never appeared!" + echo "This usually means the Nuxt dev server failed to start or generate the file. Try to rerun the test." + pkill -P $DEV_PID || kill $DEV_PID + exit 1 +fi + +# 3. Cleanup +# `pkill -P` only kills direct children, so the grandchild dev server holding the +# port survives; newer Nuxt's directory-scoped dev lock then blocks the real start. +echo "Found .nuxt/dev/sentry.server.config.mjs, stopping 'nuxt dev' process..." +pkill -P $DEV_PID 2>/dev/null +kill $DEV_PID 2>/dev/null + +# Wait for port to be released +echo "Waiting for port $TEMP_PORT to be released..." +COUNTER=0 +# Check if port is still in use +while lsof -i :$TEMP_PORT > /dev/null 2>&1 && [ $COUNTER -lt 10 ]; do + sleep 1 + ((COUNTER++)) +done + +if lsof -i :$TEMP_PORT > /dev/null 2>&1; then + echo "Port $TEMP_PORT still in use, killing remaining processes bound to it..." + lsof -t -i :$TEMP_PORT | xargs -r kill -9 2>/dev/null + sleep 1 +fi + +if lsof -i :$TEMP_PORT > /dev/null 2>&1; then + echo "WARNING: Port $TEMP_PORT still in use, proceeding anyway..." +else + echo "Port $TEMP_PORT released successfully" +fi + +echo "Nuxt dev server can now be started with '--import ./.nuxt/dev/sentry.server.config.mjs'" diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/nuxt.config.ts new file mode 100644 index 000000000000..c7acd2b12328 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/nuxt.config.ts @@ -0,0 +1,47 @@ +// https://nuxt.com/docs/api/configuration/nuxt-config +export default defineNuxtConfig({ + compatibilityDate: '2025-06-06', + imports: { autoImport: false }, + + routeRules: { + '/rendering-modes/client-side-only-page': { ssr: false }, + '/rendering-modes/isr-cached-page': { isr: true }, + '/rendering-modes/isr-1h-cached-page': { isr: 3600 }, + '/rendering-modes/swr-cached-page': { swr: true }, + '/rendering-modes/swr-1h-cached-page': { swr: 3600 }, + '/rendering-modes/pre-rendered-page': { prerender: true }, + }, + + modules: ['@pinia/nuxt', '@sentry/nuxt/module'], + runtimeConfig: { + public: { + sentry: { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }, + }, + }, + nitro: { + experimental: { + database: true, + }, + database: { + default: { + connector: 'sqlite', + options: { name: 'db' }, + }, + users: { + connector: 'sqlite', + options: { name: 'users_db' }, + }, + analytics: { + connector: 'sqlite', + options: { name: 'analytics_db' }, + }, + }, + storage: { + 'test-storage': { + driver: 'memory', + }, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json new file mode 100644 index 000000000000..68d873b7f521 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json @@ -0,0 +1,50 @@ +{ + "name": "nuxt-4-static", + "private": true, + "type": "module", + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev", + "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:prod": "TEST_ENV=production playwright test", + "test:dev": "bash ./nuxt-start-dev-server.bash && TEST_ENV=development playwright test environment", + "test:build": "pnpm install && pnpm build", + "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitropack@npm:nitropack-nightly@latest && pnpm install --force && pnpm build", + "test:assert": "pnpm test:prod && pnpm test:dev" + }, + "dependencies": { + "@pinia/nuxt": "^0.5.5", + "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", + "ioredis": "5.10.1", + "mysql": "^2.18.1", + "nuxt": "^4.1.2" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json", + "node": "22.20.0" + }, + "sentryTest": { + "variants": [ + { + "build-command": "E2E_TEST_OTEL_SETUP=true pnpm test:build", + "assert-command": "E2E_TEST_OTEL_SETUP=true pnpm test:assert", + "label": "nuxt-4-static (tracer provider)" + } + ], + "optionalVariants": [ + { + "build-command": "pnpm test:build-canary", + "label": "nuxt-4-static (canary)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/playwright.config.ts new file mode 100644 index 000000000000..0b61db7cde11 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/playwright.config.ts @@ -0,0 +1,29 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const getStartCommand = () => { + if (testEnv === 'development') { + return "NODE_OPTIONS='--import ./.nuxt/dev/sentry.server.config.mjs' nuxt dev -p 3030"; + } + + if (testEnv === 'production') { + return 'pnpm start:import'; + } + + throw new Error(`Unknown test env: ${testEnv}`); +}; + +const config = getPlaywrightConfig({ + startCommand: getStartCommand(), +}); + +export default { + ...config, + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', +}; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/public/favicon.ico b/dev-packages/e2e-tests/test-applications/nuxt-4-static/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..18993ad91cfd43e03b074dd0b5cc3f37ab38e49c GIT binary patch literal 4286 zcmeHLOKuuL5PjK%MHWVi6lD zOGiREbCw`xmFozJ^aNatJY>w+g ze6a2@u~m#^BZm@8wco9#Crlli0uLb^3E$t2-WIc^#(?t)*@`UpuofJ(Uyh@F>b3Ph z$D^m8Xq~pTkGJ4Q`Q2)te3mgkWYZ^Ijq|hkiP^9`De={bQQ%heZC$QU2UpP(-tbl8 zPWD2abEew;oat@w`uP3J^YpsgT%~jT(Dk%oU}sa$7|n6hBjDj`+I;RX(>)%lm_7N{+B7Mu%H?422lE%MBJH!!YTN2oT7xr>>N-8OF$C&qU^ z>vLsa{$0X%q1fjOe3P1mCv#lN{xQ4_*HCSAZjTb1`}mlc+9rl8$B3OP%VT@mch_~G z7Y+4b{r>9e=M+7vSI;BgB?ryZDY4m>&wcHSn81VH1N~`0gvwH{ z8dv#hG|OK`>1;j7tM#B)Z7zDN?{6=dUal}$e `${action}.transformed`, + stateTransformer: state => ({ + transformed: true, + ...state, + }), + }), + Sentry.vueIntegration({ + tracingOptions: { + trackComponents: true, + }, + }), + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/sentry.server.config.ts new file mode 100644 index 000000000000..626a375fe574 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/sentry.server.config.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/nuxt'; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, // Capture 100% of the transactions + tunnel: 'http://localhost:3031/', // proxy server + // Opt into the Sentry OpenTelemetry tracer provider in the "(tracer provider)" e2e variant. + // Leaving it `undefined` otherwise keeps the SDK's default (no provider). + enableOpenTelemetrySetup: process.env.E2E_TEST_OTEL_SETUP === 'true' ? true : undefined, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/cache-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/cache-test.ts new file mode 100644 index 000000000000..0fb4ace46bd5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/cache-test.ts @@ -0,0 +1,84 @@ +import { cachedFunction, defineCachedEventHandler, defineEventHandler, getQuery } from '#imports'; + +// Test cachedFunction +const getCachedUser = cachedFunction( + async (userId: string) => { + return { + id: userId, + name: `User ${userId}`, + email: `user${userId}@example.com`, + timestamp: Date.now(), + }; + }, + { + maxAge: 60, + name: 'getCachedUser', + getKey: (userId: string) => `user:${userId}`, + }, +); + +// Test cachedFunction with different options +const getCachedData = cachedFunction( + async (key: string) => { + return { + key, + value: `cached-value-${key}`, + timestamp: Date.now(), + }; + }, + { + maxAge: 120, + name: 'getCachedData', + getKey: (key: string) => `data:${key}`, + }, +); + +// Test defineCachedEventHandler +const cachedHandler = defineCachedEventHandler( + async event => { + return { + message: 'This response is cached', + timestamp: Date.now(), + path: event.path, + }; + }, + { + maxAge: 60, + name: 'cachedHandler', + }, +); + +export default defineEventHandler(async event => { + const results: Record = {}; + const testKey = String(getQuery(event).user ?? '123'); + const dataKey = String(getQuery(event).data ?? 'test-key'); + + // Test cachedFunction - first call (cache miss) + const user1 = await getCachedUser(testKey); + results.cachedUser1 = user1; + + // Test cachedFunction - second call (cache hit) + const user2 = await getCachedUser(testKey); + results.cachedUser2 = user2; + + // Test cachedFunction with different key (cache miss) + const user3 = await getCachedUser(`${testKey}456`); + results.cachedUser3 = user3; + + // Test another cachedFunction + const data1 = await getCachedData(dataKey); + results.cachedData1 = data1; + + // Test cachedFunction - cache hit + const data2 = await getCachedData(dataKey); + results.cachedData2 = data2; + + // Test cachedEventHandler by calling it + const cachedResponse = await cachedHandler(event); + results.cachedResponse = cachedResponse; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-ioredis.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-ioredis.ts new file mode 100644 index 000000000000..d623fa65a27e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-ioredis.ts @@ -0,0 +1,17 @@ +import { defineEventHandler } from '#imports'; +import Redis from 'ioredis'; + +export default defineEventHandler(async () => { + const redis = new Redis({ + // Don't keep retrying forever if Redis goes away (e.g. on test teardown) + maxRetriesPerRequest: 1, + retryStrategy: () => null, + }); + + try { + await redis.set('test-key', 'test-value'); + return await redis.get('test-key'); + } finally { + redis.disconnect(); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-multi-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-multi-test.ts new file mode 100644 index 000000000000..53f110c1ce28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-multi-test.ts @@ -0,0 +1,102 @@ +import { useDatabase, defineEventHandler, getQuery } from '#imports'; + +export default defineEventHandler(async event => { + const query = getQuery(event); + const method = query.method as string; + + switch (method) { + case 'default-db': { + // Test default database instance + const db = useDatabase(); + await db.exec('CREATE TABLE IF NOT EXISTS default_table (id INTEGER PRIMARY KEY, data TEXT)'); + await db.exec(`INSERT OR REPLACE INTO default_table (id, data) VALUES (1, 'default data')`); + const stmt = db.prepare('SELECT * FROM default_table WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'default', result }; + } + + case 'users-db': { + // Test named database instance 'users' + const usersDb = useDatabase('users'); + await usersDb.exec( + 'CREATE TABLE IF NOT EXISTS user_profiles (id INTEGER PRIMARY KEY, username TEXT, email TEXT)', + ); + await usersDb.exec( + `INSERT OR REPLACE INTO user_profiles (id, username, email) VALUES (1, 'john_doe', 'john@example.com')`, + ); + const stmt = usersDb.prepare('SELECT * FROM user_profiles WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'users', result }; + } + + case 'analytics-db': { + // Test named database instance 'analytics' + const analyticsDb = useDatabase('analytics'); + await analyticsDb.exec( + 'CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, event_name TEXT, count INTEGER)', + ); + await analyticsDb.exec(`INSERT OR REPLACE INTO events (id, event_name, count) VALUES (1, 'page_view', 100)`); + const stmt = analyticsDb.prepare('SELECT * FROM events WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'analytics', result }; + } + + case 'multiple-dbs': { + // Test operations across multiple databases in a single request + const defaultDb = useDatabase(); + const usersDb = useDatabase('users'); + const analyticsDb = useDatabase('analytics'); + + // Create tables and insert data in all databases + await defaultDb.exec('CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY, token TEXT)'); + await defaultDb.exec(`INSERT OR REPLACE INTO sessions (id, token) VALUES (1, 'session-token-123')`); + + await usersDb.exec('CREATE TABLE IF NOT EXISTS accounts (id INTEGER PRIMARY KEY, account_name TEXT)'); + await usersDb.exec(`INSERT OR REPLACE INTO accounts (id, account_name) VALUES (1, 'Premium Account')`); + + await analyticsDb.exec( + 'CREATE TABLE IF NOT EXISTS metrics (id INTEGER PRIMARY KEY, metric_name TEXT, value REAL)', + ); + await analyticsDb.exec( + `INSERT OR REPLACE INTO metrics (id, metric_name, value) VALUES (1, 'conversion_rate', 0.25)`, + ); + + // Query from all databases + const sessionResult = await defaultDb.prepare('SELECT * FROM sessions WHERE id = ?').get(1); + const accountResult = await usersDb.prepare('SELECT * FROM accounts WHERE id = ?').get(1); + const metricResult = await analyticsDb.prepare('SELECT * FROM metrics WHERE id = ?').get(1); + + return { + success: true, + results: { + default: sessionResult, + users: accountResult, + analytics: metricResult, + }, + }; + } + + case 'sql-template-multi': { + // Test SQL template tag across multiple databases + const defaultDb = useDatabase(); + const usersDb = useDatabase('users'); + + await defaultDb.exec('CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY, message TEXT)'); + await usersDb.exec('CREATE TABLE IF NOT EXISTS audit_logs (id INTEGER PRIMARY KEY, action TEXT)'); + + const defaultResult = await defaultDb.sql`INSERT INTO logs (message) VALUES (${'test message'})`; + const usersResult = await usersDb.sql`INSERT INTO audit_logs (action) VALUES (${'user_login'})`; + + return { + success: true, + results: { + default: defaultResult, + users: usersResult, + }, + }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-mysql.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-mysql.ts new file mode 100644 index 000000000000..5c05d2062105 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-mysql.ts @@ -0,0 +1,27 @@ +import { defineEventHandler } from '#imports'; +import mysql from 'mysql'; + +const connection = mysql.createConnection({ + user: 'root', + password: 'docker', +}); + +export default defineEventHandler(() => { + return new Promise((resolve, reject) => { + connection.query('SELECT 1 + 1 AS solution', error => { + if (error) { + reject(error); + return; + } + + connection.query('SELECT NOW()', ['1', '2'], nestedError => { + if (nestedError) { + reject(nestedError); + return; + } + + resolve({ status: 'ok' }); + }); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-test.ts new file mode 100644 index 000000000000..4460758ab414 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/db-test.ts @@ -0,0 +1,70 @@ +import { useDatabase, defineEventHandler, getQuery } from '#imports'; + +export default defineEventHandler(async event => { + const db = useDatabase(); + const query = getQuery(event); + const method = query.method as string; + + switch (method) { + case 'prepare-get': { + await db.exec('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)'); + await db.exec(`INSERT OR REPLACE INTO users (id, name, email) VALUES (1, 'Test User', 'test@example.com')`); + const stmt = db.prepare('SELECT * FROM users WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, result }; + } + + case 'prepare-all': { + await db.exec('CREATE TABLE IF NOT EXISTS products (id INTEGER PRIMARY KEY, name TEXT, price REAL)'); + await db.exec(`INSERT OR REPLACE INTO products (id, name, price) VALUES + (1, 'Product A', 10.99), + (2, 'Product B', 20.50), + (3, 'Product C', 15.25)`); + const stmt = db.prepare('SELECT * FROM products WHERE price > ?'); + const results = await stmt.all(10); + return { success: true, count: results.length, results }; + } + + case 'prepare-run': { + await db.exec('CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, customer TEXT, amount REAL)'); + const stmt = db.prepare('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + const result = await stmt.run('John Doe', 99.99); + return { success: true, result }; + } + + case 'prepare-bind': { + await db.exec('CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, category TEXT, value INTEGER)'); + await db.exec(`INSERT OR REPLACE INTO items (id, category, value) VALUES + (1, 'electronics', 100), + (2, 'books', 50), + (3, 'electronics', 200)`); + const stmt = db.prepare('SELECT * FROM items WHERE category = ?'); + const boundStmt = stmt.bind('electronics'); + const results = await boundStmt.all(); + return { success: true, count: results.length, results }; + } + + case 'sql': { + await db.exec('CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, content TEXT, created_at TEXT)'); + const timestamp = new Date().toISOString(); + const results = await db.sql`INSERT INTO messages (content, created_at) VALUES (${'Hello World'}, ${timestamp})`; + return { success: true, results }; + } + + case 'exec': { + await db.exec('DROP TABLE IF EXISTS logs'); + await db.exec('CREATE TABLE logs (id INTEGER PRIMARY KEY, message TEXT, level TEXT)'); + const result = await db.exec(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + return { success: true, result }; + } + + case 'error': { + const stmt = db.prepare('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + await stmt.get(1); + return { success: false, message: 'Should have thrown an error' }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/flush-regression.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/flush-regression.ts new file mode 100644 index 000000000000..1a0b39097710 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/flush-regression.ts @@ -0,0 +1,9 @@ +import { defineEventHandler } from '#imports'; +import * as Sentry from '@sentry/nuxt'; + +export default defineEventHandler(() => { + // Seed a pending client-report outcome without sending an event, so the test can observe whether the + // SDK wrongly flushes (and ships) it per HTTP response instead of aggregating it. + Sentry.getClient()?.recordDroppedEvent('before_send', 'error'); + return { seeded: true }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/middleware-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/middleware-test.ts new file mode 100644 index 000000000000..8973690e6adb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/middleware-test.ts @@ -0,0 +1,15 @@ +import { defineEventHandler, getHeader } from '#imports'; + +export default defineEventHandler(async event => { + // Simple API endpoint that will trigger all server middleware + return { + message: 'Server middleware test endpoint', + path: event.path, + method: event.method, + headers: { + 'x-first-middleware': getHeader(event, 'x-first-middleware'), + 'x-second-middleware': getHeader(event, 'x-second-middleware'), + 'x-auth-middleware': getHeader(event, 'x-auth-middleware'), + }, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/nitro-fetch.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/nitro-fetch.ts new file mode 100644 index 000000000000..6864ce6efafc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/nitro-fetch.ts @@ -0,0 +1,5 @@ +import { defineEventHandler } from '#imports'; + +export default defineEventHandler(async () => { + return await $fetch('https://example.com'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/param-error/[param].ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/param-error/[param].ts new file mode 100644 index 000000000000..49c4cee110a0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/param-error/[param].ts @@ -0,0 +1,5 @@ +import { defineEventHandler } from '#imports'; + +export default defineEventHandler(_e => { + throw new Error('Nuxt 4 Param Server error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/server-error.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/server-error.ts new file mode 100644 index 000000000000..0bc3db14fb69 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/server-error.ts @@ -0,0 +1,5 @@ +import { defineEventHandler } from '#imports'; + +export default defineEventHandler(event => { + throw new Error('Nuxt 4 Server error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/storage-aliases-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/storage-aliases-test.ts new file mode 100644 index 000000000000..e204453d1000 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/storage-aliases-test.ts @@ -0,0 +1,46 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all alias methods (get, set, del, remove) + const results: Record = {}; + + // Test set (alias for setItem) + await storage.set('alias:user', { name: 'Jane Doe', role: 'admin' }); + results.set = 'success'; + + // Test get (alias for getItem) + const user = await storage.get('alias:user'); + results.get = user; + + // Test has (alias for hasItem) + const hasUser = await storage.has('alias:user'); + results.has = hasUser; + + // Setup for delete tests + await storage.set('alias:temp1', 'temp1'); + await storage.set('alias:temp2', 'temp2'); + + // Test del (alias for removeItem) + await storage.del('alias:temp1'); + results.del = 'success'; + + // Test remove (alias for removeItem) + await storage.remove('alias:temp2'); + results.remove = 'success'; + + // Verify deletions worked + const hasTemp1 = await storage.has('alias:temp1'); + const hasTemp2 = await storage.has('alias:temp2'); + results.verifyDeletions = !hasTemp1 && !hasTemp2; + + // Clean up + await storage.clear(); + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/storage-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/storage-test.ts new file mode 100644 index 000000000000..f051daf59422 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/storage-test.ts @@ -0,0 +1,54 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all instrumented methods + const results: Record = {}; + + // Test setItem + await storage.setItem('user:123', { name: 'John Doe', email: 'john@example.com' }); + results.setItem = 'success'; + + // Test setItemRaw + await storage.setItemRaw('raw:data', Buffer.from('raw data')); + results.setItemRaw = 'success'; + + // Manually set batch items (setItems not supported by memory driver) + await storage.setItem('batch:1', 'value1'); + await storage.setItem('batch:2', 'value2'); + + // Test hasItem + const hasUser = await storage.hasItem('user:123'); + results.hasItem = hasUser; + + // Test getItem + const user = await storage.getItem('user:123'); + results.getItem = user; + + // Test getItemRaw + const rawData = await storage.getItemRaw('raw:data'); + results.getItemRaw = rawData?.toString(); + + // Test getKeys + const keys = await storage.getKeys('batch:'); + results.getKeys = keys; + + // Test removeItem + await storage.removeItem('batch:1'); + results.removeItem = 'success'; + + // Test clear + await storage.clear(); + results.clear = 'success'; + + // Verify clear worked + const keysAfterClear = await storage.getKeys(); + results.keysAfterClear = keysAfterClear; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/test-param/[param].ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/test-param/[param].ts new file mode 100644 index 000000000000..1867874cd494 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/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-4-static/server/api/user/[userId].ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/user/[userId].ts new file mode 100644 index 000000000000..eb268d2f3e13 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/api/user/[userId].ts @@ -0,0 +1,7 @@ +import { defineEventHandler, getRouterParam } from '#imports'; + +export default defineEventHandler(event => { + const userId = getRouterParam(event, 'userId'); + + return `UserId Param: ${userId}!`; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/01.first.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/01.first.ts new file mode 100644 index 000000000000..b146c42e3483 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/01.first.ts @@ -0,0 +1,6 @@ +import { defineEventHandler, setHeader } from '#imports'; + +export default defineEventHandler(async event => { + // Set a header to indicate this middleware ran + setHeader(event, 'x-first-middleware', 'executed'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/02.second.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/02.second.ts new file mode 100644 index 000000000000..3b665d48fc5a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/02.second.ts @@ -0,0 +1,7 @@ +import { eventHandler, setHeader } from '#imports'; + +// tests out the eventHandler alias +export default eventHandler(async event => { + // Set a header to indicate this middleware ran + setHeader(event, 'x-second-middleware', 'executed'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/03.auth.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/03.auth.ts new file mode 100644 index 000000000000..6dcd9a075589 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/03.auth.ts @@ -0,0 +1,12 @@ +import { defineEventHandler, setHeader, getQuery } from '#imports'; + +export default defineEventHandler(async event => { + // Check if we should throw an error + const query = getQuery(event); + if (query.throwError === 'true') { + throw new Error('Auth middleware error'); + } + + // Set a header to indicate this middleware ran + setHeader(event, 'x-auth-middleware', 'executed'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/04.hooks.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/04.hooks.ts new file mode 100644 index 000000000000..1f9cf40a1c02 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/04.hooks.ts @@ -0,0 +1,36 @@ +import { defineEventHandler, setHeader, getQuery } from '#imports'; + +export default defineEventHandler({ + onRequest: async event => { + // Set a header to indicate the onRequest hook ran + setHeader(event, 'x-hooks-onrequest', 'executed'); + + // Check if we should throw an error in onRequest + const query = getQuery(event); + if (query.throwOnRequestError === 'true') { + throw new Error('OnRequest hook error'); + } + }, + + handler: async event => { + // Set a header to indicate the main handler ran + setHeader(event, 'x-hooks-handler', 'executed'); + + // Check if we should throw an error in handler + const query = getQuery(event); + if (query.throwHandlerError === 'true') { + throw new Error('Handler error'); + } + }, + + onBeforeResponse: async (event, response) => { + // Set a header to indicate the onBeforeResponse hook ran + setHeader(event, 'x-hooks-onbeforeresponse', 'executed'); + + // Check if we should throw an error in onBeforeResponse + const query = getQuery(event); + if (query.throwOnBeforeResponseError === 'true') { + throw new Error('OnBeforeResponse hook error'); + } + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/05.array-hooks.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/05.array-hooks.ts new file mode 100644 index 000000000000..cc815bfb2fbf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/middleware/05.array-hooks.ts @@ -0,0 +1,47 @@ +import { defineEventHandler, setHeader, getQuery } from '#imports'; + +export default defineEventHandler({ + // Array of onRequest handlers + onRequest: [ + async event => { + setHeader(event, 'x-array-onrequest-0', 'executed'); + + const query = getQuery(event); + if (query.throwOnRequest0Error === 'true') { + throw new Error('OnRequest[0] hook error'); + } + }, + async event => { + setHeader(event, 'x-array-onrequest-1', 'executed'); + + const query = getQuery(event); + if (query.throwOnRequest1Error === 'true') { + throw new Error('OnRequest[1] hook error'); + } + }, + ], + + handler: async event => { + setHeader(event, 'x-array-handler', 'executed'); + }, + + // Array of onBeforeResponse handlers + onBeforeResponse: [ + async (event, response) => { + setHeader(event, 'x-array-onbeforeresponse-0', 'executed'); + + const query = getQuery(event); + if (query.throwOnBeforeResponse0Error === 'true') { + throw new Error('OnBeforeResponse[0] hook error'); + } + }, + async (event, response) => { + setHeader(event, 'x-array-onbeforeresponse-1', 'executed'); + + const query = getQuery(event); + if (query.throwOnBeforeResponse1Error === 'true') { + throw new Error('OnBeforeResponse[1] hook error'); + } + }, + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/tsconfig.json b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/tsconfig.json new file mode 100644 index 000000000000..b9ed69c19eaf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/server/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../.nuxt/tsconfig.server.json" +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nuxt-4-static/start-event-proxy.mjs new file mode 100644 index 000000000000..c6547b7ab1f5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nuxt-4-static', +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/stores/cart.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/stores/cart.ts new file mode 100644 index 000000000000..cad52916ac25 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/stores/cart.ts @@ -0,0 +1,43 @@ +import { acceptHMRUpdate, defineStore } from '#imports'; + +export const useCartStore = defineStore({ + id: 'cart', + state: () => ({ + rawItems: [] as string[], + }), + getters: { + items: (state): Array<{ name: string; amount: number }> => + state.rawItems.reduce( + (items: any, item: any) => { + const existingItem = items.find((it: any) => it.name === item); + + if (!existingItem) { + items.push({ name: item, amount: 1 }); + } else { + existingItem.amount++; + } + + return items; + }, + [] as Array<{ name: string; amount: number }>, + ), + }, + actions: { + addItem(name: string) { + this.rawItems.push(name); + }, + + removeItem(name: string) { + const i = this.rawItems.lastIndexOf(name); + if (i > -1) this.rawItems.splice(i, 1); + }, + + throwError() { + throw new Error('error'); + }, + }, +}); + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useCartStore, import.meta.hot)); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/build-injection.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/build-injection.test.ts new file mode 100644 index 000000000000..796a7538c0ee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/build-injection.test.ts @@ -0,0 +1,45 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from '@playwright/test'; + +function readServerBundle(): string { + const serverDir = path.join(process.cwd(), '.output/server'); + return readdirSync(serverDir, { recursive: true }) + .filter(file => typeof file === 'string' && file.endsWith('.mjs')) + .map(file => readFileSync(path.join(serverDir, file), 'utf8')) + .join('\n'); +} + +function readClientBundle(): string { + const serverDir = path.join(process.cwd(), '.output/public'); + return readdirSync(serverDir, { recursive: true }) + .filter(file => typeof file === 'string' && file.endsWith('.js')) + .map(file => readFileSync(path.join(serverDir, file), 'utf8')) + .join('\n'); +} + +test.describe('Orchestrion build-time injection', () => { + const serverBundle = readServerBundle(); + const clientBundle = readClientBundle(); + + test('force-bundles instrumented dependencies', () => { + expect(serverBundle).not.toMatch(/(from\s*["']mysql["']|require\(["']mysql["']\))/); + expect(serverBundle).not.toMatch(/(from\s*["']ioredis["']|require\(["']ioredis["']\))/); + expect(serverBundle).not.toMatch(/(from\s*["']standard-as-callback["']|require\(["']standard-as-callback["']\))/); + }); + + test('injects diagnostics-channel publishers into the server build', () => { + expect(serverBundle).toContain('__SENTRY_ORCHESTRION__'); + // Each transformed module carries the module-injected snippet that records it + // on the global marker (and registers its subscriber factory) when evaluated. + expect(serverBundle).toContain('orchestrionModuleInjected'); + expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:mysql:query["']\)/); + expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:command["']\)/); + expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:connect["']\)/); + }); + + test('does not inject diagnostics-channel publishers into the client build', () => { + expect(clientBundle).not.toContain('__SENTRY_ORCHESTRION__'); + expect(clientBundle).not.toMatch(/orchestrion:/); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/cache.test.ts new file mode 100644 index 000000000000..d8ad57bca377 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/cache.test.ts @@ -0,0 +1,157 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Cache Instrumentation', () => { + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments cachedFunction and cachedEventHandler calls and creates spans with correct attributes', async ({ + request, + }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; + }); + + const response = await request.get('/api/cache-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByMethod = (method: string) => { + return transaction.spans?.filter(span => span.data?.['db.operation.name'] === method) || []; + }; + + // Test that we have cache operations from cachedFunction and cachedEventHandler + const allCacheSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // Test getItem spans for cachedFunction - should have both cache miss and cache hit + const getItemSpans = findSpansByMethod('getItem'); + expect(getItemSpans.length).toBeGreaterThan(0); + + // Find cache miss (first call to getCachedUser('123')) + const cacheMissSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + !span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + if (cacheMissSpan) { + expect(cacheMissSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: false, + 'db.operation.name': 'getItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Find cache hit (second call to getCachedUser('123')) + const cacheHitSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + if (cacheHitSpan) { + expect(cacheHitSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Test setItem spans for cachedFunction - when cache miss occurs, value is set + const setItemSpans = findSpansByMethod('setItem'); + expect(setItemSpans.length).toBeGreaterThan(0); + + const cacheSetSpan = setItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123'), + ); + if (cacheSetSpan) { + expect(cacheSetSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.put', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'setItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Test that we have spans for different cached functions + const dataKeySpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('data:test-key'), + ); + expect(dataKeySpans.length).toBeGreaterThan(0); + + // Test that we have spans for cachedEventHandler + const cachedHandlerSpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('cachedHandler'), + ); + expect(cachedHandlerSpans.length).toBeGreaterThan(0); + + // Verify all cache spans have OK status + allCacheSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + + // Verify cache spans are properly nested under the transaction + allCacheSpans?.forEach(span => { + expect(span.parent_span_id).toBeDefined(); + }); + }); + + test('correctly tracks cache hits and misses for cachedFunction', async ({ request }) => { + // Use a unique key for this test to ensure fresh cache state + const uniqueUser = `test-${Date.now()}`; + const uniqueData = `data-${Date.now()}`; + + const transactionPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; + }); + + await request.get(`/api/cache-test?user=${uniqueUser}&data=${uniqueData}`); + const transaction1 = await transactionPromise; + + // Get all cache-related spans + const allCacheSpans = transaction1.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + + // We should have cache operations + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // Get all getItem operations + const allGetItemSpans = allCacheSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.get'); + + // Get all setItem operations + const allSetItemSpans = allCacheSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.put'); + + // We should have both get and set operations + expect(allGetItemSpans?.length).toBeGreaterThan(0); + expect(allSetItemSpans?.length).toBeGreaterThan(0); + + // Check for cache misses (cache.hit = false) + const cacheMissSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === false); + + // Check for cache hits (cache.hit = true) + const cacheHitSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === true); + + // We should have at least one cache miss (first calls to getCachedUser and getCachedData) + expect(cacheMissSpans?.length).toBeGreaterThanOrEqual(1); + + // We should have at least one cache hit (second calls to getCachedUser and getCachedData) + expect(cacheHitSpans?.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/database-multi.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/database-multi.test.ts new file mode 100644 index 000000000000..86f177c155d0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/database-multi.test.ts @@ -0,0 +1,156 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('multiple database instances', () => { + test('instruments default database instance', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=default-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM default_table')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments named database instance (users)', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=users-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span from users database + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM user_profiles')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments named database instance (analytics)', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=analytics-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span from analytics database + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM events')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments multiple database instances in single request', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=multiple-dbs'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have spans from all three databases + const sessionSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM sessions')); + const accountSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM accounts')); + const metricSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM metrics')); + + expect(sessionSpan).toBeDefined(); + expect(sessionSpan?.op).toBe('db.query'); + expect(sessionSpan?.data?.['db.system.name']).toBe('sqlite'); + + expect(accountSpan).toBeDefined(); + expect(accountSpan?.op).toBe('db.query'); + expect(accountSpan?.data?.['db.system.name']).toBe('sqlite'); + + expect(metricSpan).toBeDefined(); + expect(metricSpan?.op).toBe('db.query'); + expect(metricSpan?.data?.['db.system.name']).toBe('sqlite'); + + // All should have the same origin + expect(sessionSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(accountSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(metricSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments SQL template tag across multiple databases', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=sql-template-multi'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have INSERT spans from both databases + const logsInsertSpan = dbSpans?.find(span => span.description?.includes('INSERT INTO logs')); + const auditLogsInsertSpan = dbSpans?.find(span => span.description?.includes('INSERT INTO audit_logs')); + + expect(logsInsertSpan).toBeDefined(); + expect(logsInsertSpan?.op).toBe('db.query'); + expect(logsInsertSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(logsInsertSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + + expect(auditLogsInsertSpan).toBeDefined(); + expect(auditLogsInsertSpan?.op).toBe('db.query'); + expect(auditLogsInsertSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(auditLogsInsertSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('creates correct span count for multiple database operations', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=multiple-dbs'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + // We should have multiple spans: + // - 3 CREATE TABLE (exec) spans + // - 3 INSERT (exec) spans + // - 3 SELECT (prepare + get) spans + // Total should be at least 9 spans + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThanOrEqual(9); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/database.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/database.test.ts new file mode 100644 index 000000000000..a046d2924bef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/database.test.ts @@ -0,0 +1,201 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('database integration', () => { + test('captures db.prepare().get() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-get'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find(span => span.op === 'db.query' && span.description?.includes('SELECT')); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM users WHERE id = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM users WHERE id = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().all() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-all'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM products'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM products WHERE price > ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM products WHERE price > ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().run() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-run'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO orders'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().bind().all() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-bind'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM items'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM items WHERE category = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM items WHERE category = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.sql template tag span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=sql'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO messages'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toContain('INSERT INTO messages'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toContain('INSERT INTO messages'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.exec() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=exec'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO logs'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures database error and marks span as failed', async ({ request }) => { + const errorPromise = waitForError('nuxt-4-static', errorEvent => { + return !!errorEvent?.exception?.values?.some( + value => value.mechanism?.type === 'auto.db.nuxt' && value.value?.includes('no such table'), + ); + }); + + const transactionPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=error').catch(() => { + // Expected to fail + }); + + const [error, transaction] = await Promise.all([errorPromise, transactionPromise]); + + const dbException = error.exception?.values?.find(value => value.mechanism?.type === 'auto.db.nuxt'); + + expect(dbException).toBeDefined(); + expect(dbException?.value).toContain('no such table'); + expect(dbException?.mechanism).toEqual({ + handled: false, + type: 'auto.db.nuxt', + }); + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM nonexistent_table'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(dbSpan?.status).toBe('internal_error'); + }); + + test('captures breadcrumb for db.exec() queries', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=exec'); + + const transaction = await transactionPromise; + + const dbBreadcrumb = transaction.breadcrumbs?.find( + breadcrumb => breadcrumb.category === 'query' && breadcrumb.message?.includes('INSERT INTO logs'), + ); + + expect(dbBreadcrumb).toBeDefined(); + expect(dbBreadcrumb?.category).toBe('query'); + expect(dbBreadcrumb?.message).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbBreadcrumb?.data?.['db.query.text']).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + }); + + test('multiple database operations in single request create multiple spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-get'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/db-drivers.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/db-drivers.test.ts new file mode 100644 index 000000000000..5ef0c290e946 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/db-drivers.test.ts @@ -0,0 +1,101 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// The Nuxt module auto-wires the orchestrion build-time transform, which injects +// `diagnostics_channel` publishers into these drivers as Nitro bundles them. That +// only happens in the production build, so these tests are excluded from the +// `test:dev` pass (which filters to `environment`). +test('Instruments ioredis automatically', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/db-ioredis' + ); + }); + + const response = await fetch(`${baseURL}/api/db-ioredis`); + expect(response.status).toBe(200); + expect(await response.text()).toBe('test-value'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /api/db-ioredis'); + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db.query', + origin: 'auto.db.redis', + description: 'set test-key [1 other arguments]', + status: 'ok', + data: expect.objectContaining({ + 'db.system.name': 'redis', + 'db.operation.name': 'set', + 'db.query.text': 'set test-key [1 other arguments]', + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db.query', + origin: 'auto.db.redis', + description: 'get test-key', + status: 'ok', + data: expect.objectContaining({ + 'db.system.name': 'redis', + 'db.operation.name': 'get', + 'db.query.text': 'get test-key', + }), + }), + ); +}); + +test('Instruments mysql automatically', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /api/db-mysql' + ); + }); + + const response = await fetch(`${baseURL}/api/db-mysql`); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ status: 'ok' }); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.mysql', + description: 'SELECT 1 + 1 AS solution', + status: 'ok', + data: expect.objectContaining({ + 'db.system.name': 'mysql', + 'db.query.text': 'SELECT 1 + 1 AS solution', + 'db.user': 'root', + 'db.connection_string': expect.any(String), + 'server.address': expect.any(String), + 'server.port': 3306, + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.mysql', + description: 'SELECT NOW()', + status: 'ok', + data: expect.objectContaining({ + 'db.system.name': 'mysql', + 'db.query.text': 'SELECT NOW()', + 'db.user': 'root', + 'db.connection_string': expect.any(String), + 'server.address': expect.any(String), + 'server.port': 3306, + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/environment.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/environment.test.ts new file mode 100644 index 000000000000..b293230cab44 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/environment.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { isDevMode } from './isDevMode'; + +test.describe('environment detection', async () => { + test('sets correct environment for client-side errors', async ({ page }) => { + const errorPromise = waitForError('nuxt-4-static', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Nuxt-4 E2E test app'; + }); + + // We have to wait for networkidle in dev mode because clicking the button is a no-op otherwise (network requests are blocked during page load) + await page.goto(`/client-error`, isDevMode ? { waitUntil: 'networkidle' } : {}); + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + if (isDevMode) { + expect(error.environment).toBe('development'); + } else { + expect(error.environment).toBe('production'); + } + }); + + test('sets correct environment for client-side transactions', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', async transactionEvent => { + return transactionEvent.transaction === '/test-param/:param()'; + }); + + await page.goto(`/test-param/1234`); + + const transaction = await transactionPromise; + + if (isDevMode) { + expect(transaction.environment).toBe('development'); + } else { + expect(transaction.environment).toBe('production'); + } + }); + + test('sets correct environment for server-side errors', async ({ page }) => { + const errorPromise = waitForError('nuxt-4-static', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Server error'; + }); + + await page.goto(`/fetch-server-routes`, isDevMode ? { waitUntil: 'networkidle' } : {}); + await page.getByText('Fetch Server API Error', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toBe('GET /api/server-error'); + + if (isDevMode) { + expect(error.environment).toBe('development'); + } else { + expect(error.environment).toBe('production'); + } + }); + + test('sets correct environment for server-side transactions', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', async transactionEvent => { + return transactionEvent.transaction === 'GET /api/nitro-fetch'; + }); + + await page.goto(`/fetch-server-routes`, isDevMode ? { waitUntil: 'networkidle' } : {}); + await page.getByText('Fetch Nitro $fetch', { exact: true }).click(); + + const transaction = await transactionPromise; + + expect(transaction.contexts.trace.op).toBe('http.server'); + + if (isDevMode) { + expect(transaction.environment).toBe('development'); + } else { + expect(transaction.environment).toBe('production'); + } + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.client.test.ts new file mode 100644 index 000000000000..1c747e73fe77 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.client.test.ts @@ -0,0 +1,151 @@ +import { expect, test } from '@playwright/test'; +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-4-static', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Nuxt-4 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-4 E2E test app', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + }); + + test('captures error thrown in NuxtErrorBoundary', async ({ page }) => { + const errorPromise = waitForError('nuxt-4-static', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown in Error Boundary'; + }); + + await page.goto(`/client-error`); + await page.locator('#error-in-error-boundary').click(); + + const error = await errorPromise; + + const expectedBreadcrumb = { + category: 'console', + message: 'Additional functionality in NuxtErrorBoundary', + }; + + const matchingBreadcrumb = error.breadcrumbs.find( + (breadcrumb: { category: string; message: string }) => + breadcrumb.category === expectedBreadcrumb.category && breadcrumb.message === expectedBreadcrumb.message, + ); + + expect(matchingBreadcrumb).toBeTruthy(); + expect(matchingBreadcrumb?.category).toBe(expectedBreadcrumb.category); + expect(matchingBreadcrumb?.message).toBe(expectedBreadcrumb.message); + + expect(error.transaction).toEqual('/client-error'); + expect(error.sdk.name).toEqual('sentry.javascript.nuxt'); + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown in Error Boundary', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + }); + + test('shows parametrized route on button error', async ({ page }) => { + const errorPromise = waitForError('nuxt-4-static', 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, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + }); + + test('page is still interactive after client error', async ({ page }) => { + const error1Promise = waitForError('nuxt-4-static', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Nuxt-4 E2E test app'; + }); + + await page.goto(`/client-error`); + await page.locator('#errorBtn').click(); + + const error1 = await error1Promise; + + const error2Promise = waitForError('nuxt-4-static', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Another Error thrown from Nuxt-4 E2E test app'; + }); + + await page.locator('#errorBtn2').click(); + + const error2 = await error2Promise; + + expect(error1).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Nuxt-4 E2E test app', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + + expect(error2).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Another Error thrown from Nuxt-4 E2E test app', + mechanism: { + handled: false, + type: 'auto.function.nuxt.vue-error', + }, + }, + ], + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.server.test.ts new file mode 100644 index 000000000000..b7dce20b0714 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/errors.server.test.ts @@ -0,0 +1,72 @@ +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-4-static', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Server error'; + }); + + await page.goto(`/fetch-server-routes`); + await page.getByText('Fetch Server API Error', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('GET /api/server-error'); + + const exception0 = error.exception.values[0]; + const exception1 = error.exception.values[1]; + + expect(exception0.type).toEqual('Error'); + expect(exception0.value).toEqual('Nuxt 4 Server error'); + expect(exception0.mechanism).toEqual({ + handled: true, + type: 'chained', + exception_id: 1, + parent_id: 0, + source: 'cause', + }); + + expect(exception1.type).toEqual('Error'); + expect(exception1.value).toEqual('Nuxt 4 Server error'); + expect(exception1.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 0, + }); + }); + + test('captures api fetch error (fetched on click) with parametrized route', async ({ page }) => { + const errorPromise = waitForError('nuxt-4-static', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Param Server error'; + }); + + await page.goto(`/test-param/1234`); + await page.getByRole('button', { name: 'Fetch Server API Error', exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('GET /api/param-error/1234'); + + const exception0 = error.exception.values[0]; + const exception1 = error.exception.values[1]; + + expect(exception0.type).toEqual('Error'); + expect(exception0.value).toEqual('Nuxt 4 Param Server error'); + expect(exception0.mechanism).toEqual({ + handled: true, + type: 'chained', + exception_id: 1, + parent_id: 0, + source: 'cause', + }); + + expect(exception1.type).toEqual('Error'); + expect(exception1.value).toEqual('Nuxt 4 Param Server error'); + expect(exception1.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 0, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/flush-regression.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/flush-regression.test.ts new file mode 100644 index 000000000000..831eb85c2ab3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/flush-regression.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem } from '@sentry-internal/test-utils'; + +// Regression test for https://github.com/getsentry/sentry-javascript/issues/23306 +// +// The httpIntegration `responseHook` used to call `vercelWaitUntil(flushSafelyWithTimeout())`, which +// evaluates the flush eagerly regardless of platform. On a long-running server that meant `flush()` ran +// on every HTTP response, shipping each pending outcome as its own `client_report` envelope instead of +// aggregating it on the flush interval. +test('does not ship a client_report per HTTP response', async ({ request }) => { + const requestCount = 5; + + // Resolve as soon as a second client_report is observed. A single one is tolerated because the 60s + // client-report interval may happen to flush the seeded outcomes once during the window. + let seen = 0; + const twoReportsSeen = waitForEnvelopeItem('nuxt-4-static', item => item[0].type === 'client_report' && ++seen >= 2) + .then(() => 'two-or-more' as const) + .catch(() => 'two-or-more' as const); + + // Each request seeds one pending outcome (see server/api/flush-regression.ts). + for (let i = 0; i < requestCount; i++) { + await request.get('/api/flush-regression'); + } + + // Buggy build: each response flushes -> one client_report per request, so a second arrives within ms. + // Fixed build: flushIfServerless no-ops on a long-running server, so the outcomes aggregate and at + // most the single interval flush ships within this window. + const result = await Promise.race([ + twoReportsSeen, + new Promise<'at-most-one'>(resolve => setTimeout(() => resolve('at-most-one'), 5000)), + ]); + + expect(result).toBe('at-most-one'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/isDevMode.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/isDevMode.ts new file mode 100644 index 000000000000..d2be94232110 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/isDevMode.ts @@ -0,0 +1 @@ +export const isDevMode = !!process.env.TEST_ENV && process.env.TEST_ENV.includes('development'); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/middleware.test.ts new file mode 100644 index 000000000000..5d3d1246d0e5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/middleware.test.ts @@ -0,0 +1,331 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; + +test.describe('Server Middleware Instrumentation', () => { + test('should create separate spans for each server middleware', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + // Make request to the API endpoint that will trigger all server middleware + const response = await request.get('/api/middleware-test'); + expect(response.status()).toBe(200); + + const responseData = await response.json(); + expect(responseData.message).toBe('Server middleware test endpoint'); + + const serverTxnEvent = await serverTxnEventPromise; + + // Verify that we have spans for each middleware + const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware') || []; + + // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse + expect(middlewareSpans).toHaveLength(11); + + // Check for specific middleware spans + const firstMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '01.first'); + const secondMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '02.second'); + const authMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '03.auth'); + const hooksOnRequestSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '04.hooks'); + const arrayHooksHandlerSpan = middlewareSpans.find( + span => span.data?.['nuxt.middleware.name'] === '05.array-hooks', + ); + + expect(firstMiddlewareSpan).toBeDefined(); + expect(secondMiddlewareSpan).toBeDefined(); + expect(authMiddlewareSpan).toBeDefined(); + expect(hooksOnRequestSpan).toBeDefined(); + expect(arrayHooksHandlerSpan).toBeDefined(); + + // Verify each span has the correct attributes + [firstMiddlewareSpan, secondMiddlewareSpan, authMiddlewareSpan].forEach(span => { + expect(span).toEqual( + expect.objectContaining({ + op: 'middleware', + data: expect.objectContaining({ + 'sentry.op': 'middleware', + 'sentry.origin': 'auto.middleware.nuxt', + 'http.request.method': 'GET', + 'http.route': '/api/middleware-test', + }), + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }), + ); + }); + + // Verify spans have different span IDs (each middleware gets its own span) + const spanIds = middlewareSpans.map(span => span.span_id); + const uniqueSpanIds = new Set(spanIds); + // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse) + expect(uniqueSpanIds.size).toBe(11); + + // Verify spans share the same trace ID + const traceIds = middlewareSpans.map(span => span.trace_id); + const uniqueTraceIds = new Set(traceIds); + expect(uniqueTraceIds.size).toBe(1); + }); + + test('middleware spans should have proper parent-child relationship', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + await request.get('/api/middleware-test'); + const serverTxnEvent = await serverTxnEventPromise; + + const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware') || []; + + // All middleware spans should be children of the main transaction + middlewareSpans.forEach(span => { + expect(span.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + }); + }); + + test('should capture errors thrown in middleware and associate them with the span', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + const errorEventPromise = waitForError('nuxt-4-static', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Auth middleware error'; + }); + + // Make request with query param to trigger error in auth middleware + const response = await request.get('/api/middleware-test?throwError=true'); + + // The request should fail due to the middleware error + expect(response.status()).toBe(500); + + const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + + // Find the auth middleware span + const authMiddlewareSpan = serverTxnEvent.spans?.find( + span => span.op === 'middleware' && span.data?.['nuxt.middleware.name'] === '03.auth', + ); + + expect(authMiddlewareSpan).toBeDefined(); + + // Verify the span has error status + expect(authMiddlewareSpan?.status).toBe('internal_error'); + + // Verify the error event is associated with the correct transaction + expect(errorEvent.transaction).toContain('GET /api/middleware-test'); + + // Verify the error has the correct mechanism + expect(errorEvent.exception?.values?.[0]).toEqual( + expect.objectContaining({ + value: 'Auth middleware error', + type: 'Error', + mechanism: expect.objectContaining({ + handled: false, + type: 'auto.middleware.nuxt', + }), + }), + ); + }); + + test('should create spans for onRequest and onBeforeResponse hooks', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + // Make request to trigger middleware with hooks + const response = await request.get('/api/middleware-test'); + expect(response.status()).toBe(200); + + const serverTxnEvent = await serverTxnEventPromise; + const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware') || []; + + // Find spans for the hooks middleware + const hooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '04.hooks'); + + // Should have spans for onRequest, handler, and onBeforeResponse + expect(hooksSpans).toHaveLength(3); + + // Find specific hook spans + const onRequestSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'onRequest'); + const handlerSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler'); + const onBeforeResponseSpan = hooksSpans.find( + span => span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', + ); + + expect(onRequestSpan).toBeDefined(); + expect(handlerSpan).toBeDefined(); + expect(onBeforeResponseSpan).toBeDefined(); + + // Verify span names include hook types + expect(onRequestSpan?.description).toBe('04.hooks.onRequest'); + expect(handlerSpan?.description).toBe('04.hooks'); + expect(onBeforeResponseSpan?.description).toBe('04.hooks.onBeforeResponse'); + + // Verify all spans have correct middleware name (without hook suffix) + [onRequestSpan, handlerSpan, onBeforeResponseSpan].forEach(span => { + expect(span?.data?.['nuxt.middleware.name']).toBe('04.hooks'); + }); + + // Verify hook-specific attributes + expect(onRequestSpan?.data?.['nuxt.middleware.hook.name']).toBe('onRequest'); + expect(handlerSpan?.data?.['nuxt.middleware.hook.name']).toBe('handler'); + expect(onBeforeResponseSpan?.data?.['nuxt.middleware.hook.name']).toBe('onBeforeResponse'); + + // Verify no index attributes for single hooks + expect(onRequestSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + expect(onBeforeResponseSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + }); + + test('should create spans with index attributes for array hooks', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + // Make request to trigger middleware with array hooks + const response = await request.get('/api/middleware-test'); + expect(response.status()).toBe(200); + + const serverTxnEvent = await serverTxnEventPromise; + const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware') || []; + + // Find spans for the array hooks middleware + const arrayHooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '05.array-hooks'); + + // Should have spans for 2 onRequest + 1 handler + 2 onBeforeResponse = 5 spans + expect(arrayHooksSpans).toHaveLength(5); + + // Find onRequest array spans + const onRequestSpans = arrayHooksSpans.filter(span => span.data?.['nuxt.middleware.hook.name'] === 'onRequest'); + expect(onRequestSpans).toHaveLength(2); + + // Find onBeforeResponse array spans + const onBeforeResponseSpans = arrayHooksSpans.filter( + span => span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', + ); + expect(onBeforeResponseSpans).toHaveLength(2); + + // Find handler span + const handlerSpan = arrayHooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler'); + expect(handlerSpan).toBeDefined(); + + // Verify index attributes for onRequest array + const onRequest0Span = onRequestSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0); + const onRequest1Span = onRequestSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1); + + expect(onRequest0Span).toBeDefined(); + expect(onRequest1Span).toBeDefined(); + + // Verify index attributes for onBeforeResponse array + const onBeforeResponse0Span = onBeforeResponseSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0); + const onBeforeResponse1Span = onBeforeResponseSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1); + + expect(onBeforeResponse0Span).toBeDefined(); + expect(onBeforeResponse1Span).toBeDefined(); + + // Verify span names for array handlers + expect(onRequest0Span?.description).toBe('05.array-hooks.onRequest'); + expect(onRequest1Span?.description).toBe('05.array-hooks.onRequest'); + expect(onBeforeResponse0Span?.description).toBe('05.array-hooks.onBeforeResponse'); + expect(onBeforeResponse1Span?.description).toBe('05.array-hooks.onBeforeResponse'); + + // Verify handler has no index + expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + }); + + test('should handle errors in onRequest hooks', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + const errorEventPromise = waitForError('nuxt-4-static', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'OnRequest hook error'; + }); + + // Make request with query param to trigger error in onRequest + const response = await request.get('/api/middleware-test?throwOnRequestError=true'); + expect(response.status()).toBe(500); + + const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + + // Find the onRequest span that should have error status + const onRequestSpan = serverTxnEvent.spans?.find( + span => + span.op === 'middleware' && + span.data?.['nuxt.middleware.name'] === '04.hooks' && + span.data?.['nuxt.middleware.hook.name'] === 'onRequest', + ); + + expect(onRequestSpan).toBeDefined(); + expect(onRequestSpan?.status).toBe('internal_error'); + expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest hook error'); + }); + + test('should handle errors in onBeforeResponse hooks', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + const errorEventPromise = waitForError('nuxt-4-static', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'OnBeforeResponse hook error'; + }); + + // Make request with query param to trigger error in onBeforeResponse + const response = await request.get('/api/middleware-test?throwOnBeforeResponseError=true'); + expect(response.status()).toBe(500); + + const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + + // Find the onBeforeResponse span that should have error status + const onBeforeResponseSpan = serverTxnEvent.spans?.find( + span => + span.op === 'middleware' && + span.data?.['nuxt.middleware.name'] === '04.hooks' && + span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', + ); + + expect(onBeforeResponseSpan).toBeDefined(); + expect(onBeforeResponseSpan?.status).toBe('internal_error'); + expect(errorEvent.exception?.values?.[0]?.value).toBe('OnBeforeResponse hook error'); + }); + + test('should handle errors in array hooks with proper index attribution', async ({ request }) => { + const serverTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; + }); + + const errorEventPromise = waitForError('nuxt-4-static', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'OnRequest[1] hook error'; + }); + + // Make request with query param to trigger error in second onRequest handler + const response = await request.get('/api/middleware-test?throwOnRequest1Error=true'); + expect(response.status()).toBe(500); + + const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); + + // Find the second onRequest span that should have error status + const onRequest1Span = serverTxnEvent.spans?.find( + span => + span.op === 'middleware' && + span.data?.['nuxt.middleware.name'] === '05.array-hooks' && + span.data?.['nuxt.middleware.hook.name'] === 'onRequest' && + span.data?.['nuxt.middleware.hook.index'] === 1, + ); + + expect(onRequest1Span).toBeDefined(); + expect(onRequest1Span?.status).toBe('internal_error'); + expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest[1] hook error'); + + // Verify the first onRequest handler still executed successfully + const onRequest0Span = serverTxnEvent.spans?.find( + span => + span.op === 'middleware' && + span.data?.['nuxt.middleware.name'] === '05.array-hooks' && + span.data?.['nuxt.middleware.hook.name'] === 'onRequest' && + span.data?.['nuxt.middleware.hook.index'] === 0, + ); + + expect(onRequest0Span).toBeDefined(); + expect(onRequest0Span?.status).not.toBe('internal_error'); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/pinia.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/pinia.test.ts new file mode 100644 index 000000000000..2af7a89544b5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/pinia.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('sends pinia action breadcrumbs and state context', async ({ page }) => { + await page.goto('/pinia-cart'); + + await page.locator('#item-input').fill('item'); + await page.locator('#item-add').click(); + + const errorPromise = waitForError('nuxt-4-static', async errorEvent => { + return errorEvent?.exception?.values?.[0].value === 'This is an error'; + }); + + await page.locator('#throw-error').click(); + + const error = await errorPromise; + + expect(error).toBeTruthy(); + expect(error.breadcrumbs?.length).toBeGreaterThan(0); + + const actionBreadcrumb = error.breadcrumbs?.find(breadcrumb => breadcrumb.category === 'pinia.action'); + + expect(actionBreadcrumb).toBeDefined(); + expect(actionBreadcrumb?.message).toBe('Store: cart | Action: addItem.transformed'); + expect(actionBreadcrumb?.level).toBe('info'); + + const stateContext = error.contexts?.state?.state; + + expect(stateContext).toBeDefined(); + expect(stateContext?.type).toBe('pinia'); + expect(stateContext?.value).toEqual({ + transformed: true, + cart: { rawItems: ['item'] }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/storage-aliases.test.ts new file mode 100644 index 000000000000..ee1950050482 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/storage-aliases.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation - Aliases', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments storage alias methods (get, set, has, del, remove) and creates spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-aliases-test') ?? false; + }); + + const response = await request.get('/api/storage-aliases-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByMethod = (method: string) => { + return transaction.spans?.filter(span => span.data?.['db.operation.name'] === method) || []; + }; + + // Test set (alias for setItem) + const setSpans = findSpansByMethod('setItem'); + expect(setSpans.length).toBeGreaterThanOrEqual(1); + const setSpan = setSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(setSpan).toBeDefined(); + expect(setSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.put', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + 'db.operation.name': 'setItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(setSpan?.description).toBe(prefixKey('alias:user')); + + // Test get (alias for getItem) + const getSpans = findSpansByMethod('getItem'); + expect(getSpans.length).toBeGreaterThanOrEqual(1); + const getSpan = getSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(getSpan).toBeDefined(); + expect(getSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(getSpan?.description).toBe(prefixKey('alias:user')); + + // Test has (alias for hasItem) + const hasSpans = findSpansByMethod('hasItem'); + expect(hasSpans.length).toBeGreaterThanOrEqual(1); + const hasSpan = hasSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(hasSpan).toBeDefined(); + expect(hasSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test del and remove (both aliases for removeItem) + const removeSpans = findSpansByMethod('removeItem'); + expect(removeSpans.length).toBeGreaterThanOrEqual(2); // Should have both del and remove calls + + const delSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp1')); + expect(delSpan).toBeDefined(); + expect(delSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp1'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(delSpan?.description).toBe(prefixKey('alias:temp1')); + + const removeSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp2')); + expect(removeSpan).toBeDefined(); + expect(removeSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp2'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(removeSpan?.description).toBe(prefixKey('alias:temp2')); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/storage.test.ts new file mode 100644 index 000000000000..b4138a8eb6e8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/storage.test.ts @@ -0,0 +1,151 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments all storage operations and creates spans with correct attributes', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-test') ?? false; + }); + + const response = await request.get('/api/storage-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByMethod = (method: string) => { + return transaction.spans?.filter(span => span.data?.['db.operation.name'] === method) || []; + }; + + // Test setItem spans + const setItemSpans = findSpansByMethod('setItem'); + expect(setItemSpans.length).toBeGreaterThanOrEqual(1); + const setItemSpan = setItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(setItemSpan).toBeDefined(); + expect(setItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.put', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + 'db.operation.name': 'setItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(setItemSpan?.description).toBe(prefixKey('user:123')); + + // Test setItemRaw spans + const setItemRawSpans = findSpansByMethod('setItemRaw'); + expect(setItemRawSpans.length).toBeGreaterThanOrEqual(1); + const setItemRawSpan = setItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(setItemRawSpan).toBeDefined(); + expect(setItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.put', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + 'db.operation.name': 'setItemRaw', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test hasItem spans - should have cache hit attribute + const hasItemSpans = findSpansByMethod('hasItem'); + expect(hasItemSpans.length).toBeGreaterThanOrEqual(1); + const hasItemSpan = hasItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(hasItemSpan).toBeDefined(); + expect(hasItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test getItem spans - should have cache hit attribute + const getItemSpans = findSpansByMethod('getItem'); + expect(getItemSpans.length).toBeGreaterThanOrEqual(1); + const getItemSpan = getItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(getItemSpan).toBeDefined(); + expect(getItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(getItemSpan?.description).toBe(prefixKey('user:123')); + + // Test getItemRaw spans - should have cache hit attribute + const getItemRawSpans = findSpansByMethod('getItemRaw'); + expect(getItemRawSpans.length).toBeGreaterThanOrEqual(1); + const getItemRawSpan = getItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(getItemRawSpan).toBeDefined(); + expect(getItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItemRaw', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test getKeys spans + const getKeysSpans = findSpansByMethod('getKeys'); + expect(getKeysSpans.length).toBeGreaterThanOrEqual(1); + expect(getKeysSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'getKeys', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test removeItem spans + const removeItemSpans = findSpansByMethod('removeItem'); + expect(removeItemSpans.length).toBeGreaterThanOrEqual(1); + const removeItemSpan = removeItemSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('batch:1'), + ); + expect(removeItemSpan).toBeDefined(); + expect(removeItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('batch:1'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test clear spans + const clearSpans = findSpansByMethod('clear'); + expect(clearSpans.length).toBeGreaterThanOrEqual(1); + expect(clearSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'clear', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.cached-html.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.cached-html.test.ts new file mode 100644 index 000000000000..ab5a31a7410a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.cached-html.test.ts @@ -0,0 +1,206 @@ +import { expect, test, type Page } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('Rendering Modes with Cached HTML', () => { + test('changes tracing meta tags with multiple requests on Client-Side only page', async ({ page }) => { + await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/client-side-only-page', 'Client Side Only Page'); + }); + + test('changes tracing meta tags with multiple requests on ISR-cached page', async ({ page }) => { + await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/isr-cached-page', 'ISR Cached Page'); + }); + + test('changes tracing meta tags with multiple requests on 1h ISR-cached page', async ({ page }) => { + await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/isr-1h-cached-page', 'ISR 1h Cached Page'); + }); + + test('exclude tracing meta tags on SWR-cached page', async ({ page }) => { + await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/swr-cached-page', 'SWR Cached Page'); + }); + + test('exclude tracing meta tags on SWR 1h cached page', async ({ page }) => { + await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/swr-1h-cached-page', 'SWR 1h Cached Page'); + }); + + test('exclude tracing meta tags on pre-rendered page', async ({ page }) => { + await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/pre-rendered-page', 'Pre-Rendered Page'); + }); +}); + +/** + * Tests that tracing meta-tags change with multiple requests on ISR-cached pages + * This utility handles the common pattern of: + * 1. Making two requests to an ISR-cached page + * 2. Verifying tracing meta-tags are present and change between requests + * 3. Verifying distributed tracing works correctly for both requests + * 4. Verifying trace IDs are different between requests + * + * @param page - Playwright page object + * @param routePath - The route path to test (e.g., '/rendering-modes/isr-cached-page') + * @param expectedPageText - The text to verify is visible on the page (e.g., 'ISR Cached Page') + */ +export async function testChangingTracingMetaTagsOnISRPage( + page: Page, + routePath: string, + expectedPageText: string, +): Promise { + // === 1. Request === + const clientTxnEventPromise1 = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction === routePath; + }); + + const serverTxnEventPromise1 = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; + }); + + const [_1, clientTxnEvent1, serverTxnEvent1] = await Promise.all([ + page.goto(routePath), + clientTxnEventPromise1, + serverTxnEventPromise1, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + + const baggageMetaTagContent1 = await page.locator('meta[name="baggage"]').getAttribute('content'); + const sentryTraceMetaTagContent1 = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); + const [htmlMetaTraceId1] = sentryTraceMetaTagContent1?.split('-') || []; + + // === 2. Request === + + const clientTxnEventPromise2 = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction === routePath; + }); + + const serverTxnEventPromise2 = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; + }); + + const [_2, clientTxnEvent2, serverTxnEvent2] = await Promise.all([ + page.goto(routePath), + clientTxnEventPromise2, + serverTxnEventPromise2, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + + const baggageMetaTagContent2 = await page.locator('meta[name="baggage"]').getAttribute('content'); + const sentryTraceMetaTagContent2 = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); + const [htmlMetaTraceId2] = sentryTraceMetaTagContent2?.split('-') || []; + + const serverTxnEvent1TraceId = serverTxnEvent1.contexts?.trace?.trace_id; + const serverTxnEvent2TraceId = serverTxnEvent2.contexts?.trace?.trace_id; + + await test.step('Test distributed trace from 1. request', () => { + expect(baggageMetaTagContent1).toContain(`sentry-trace_id=${serverTxnEvent1TraceId}`); + + expect(clientTxnEvent1.contexts?.trace?.trace_id).toBe(serverTxnEvent1TraceId); + expect(clientTxnEvent1.contexts?.trace?.parent_span_id).toBe(serverTxnEvent1.contexts?.trace?.span_id); + expect(serverTxnEvent1.contexts?.trace?.trace_id).toBe(htmlMetaTraceId1); + }); + + await test.step('Test distributed trace from 2. request', () => { + expect(baggageMetaTagContent2).toContain(`sentry-trace_id=${serverTxnEvent2TraceId}`); + + expect(clientTxnEvent2.contexts?.trace?.trace_id).toBe(serverTxnEvent2TraceId); + expect(clientTxnEvent2.contexts?.trace?.parent_span_id).toBe(serverTxnEvent2.contexts?.trace?.span_id); + expect(serverTxnEvent2.contexts?.trace?.trace_id).toBe(htmlMetaTraceId2); + }); + + await test.step('Test that trace IDs from subsequent requests are different', () => { + // Different trace IDs for the server transactions + expect(serverTxnEvent1TraceId).toBeDefined(); + expect(serverTxnEvent1TraceId).not.toBe(serverTxnEvent2TraceId); + expect(serverTxnEvent1TraceId).not.toBe(htmlMetaTraceId2); + }); +} + +/** + * Tests that tracing meta-tags are excluded on cached pages (SWR, pre-rendered, etc.) + * This utility handles the common pattern of: + * 1. Making two requests to a cached page + * 2. Verifying no tracing meta-tags are present + * 3. Verifying only the first request creates a server transaction + * 4. Verifying traces are not distributed + * + * @param page - Playwright page object + * @param routePath - The route path to test (e.g., '/rendering-modes/swr-cached-page') + * @param expectedPageText - The text to verify is visible on the page (e.g., 'SWR Cached Page') + * @returns Object containing transaction events for additional custom assertions + */ +export async function testExcludeTracingMetaTagsOnCachedPage( + page: Page, + routePath: string, + expectedPageText: string, +): Promise { + // === 1. Request === + const clientTxnEventPromise1 = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction === routePath; + }); + + // Only the 1. request creates a server transaction + const serverTxnEventPromise1 = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; + }); + + const [_1, clientTxnEvent1, serverTxnEvent1] = await Promise.all([ + page.goto(routePath), + clientTxnEventPromise1, + serverTxnEventPromise1, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + + // Verify no baggage and sentry-trace meta-tags are present on first request + expect(await page.locator('meta[name="baggage"]').count()).toBe(0); + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + + // === 2. Request === + + await page.goto(routePath); + + const clientTxnEventPromise2 = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction === routePath; + }); + + let serverTxnEvent2 = undefined; + const serverTxnEventPromise2 = Promise.race([ + waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes(`GET ${routePath}`) ?? false; + }), + new Promise((_, reject) => setTimeout(() => reject(new Error('No second server transaction expected')), 2000)), + ]); + + try { + serverTxnEvent2 = await serverTxnEventPromise2; + throw new Error('Second server transaction should not have been sent'); + } catch (error) { + expect(error.message).toBe('No second server transaction expected'); + } + + const [clientTxnEvent2] = await Promise.all([ + clientTxnEventPromise2, + expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(), + ]); + + const clientTxnEvent1TraceId = clientTxnEvent1.contexts?.trace?.trace_id; + const clientTxnEvent2TraceId = clientTxnEvent2.contexts?.trace?.trace_id; + + const serverTxnEvent1TraceId = serverTxnEvent1.contexts?.trace?.trace_id; + const serverTxnEvent2TraceId = serverTxnEvent2?.contexts?.trace?.trace_id; + + await test.step('No baggage and sentry-trace meta-tags are present on second request', async () => { + expect(await page.locator('meta[name="baggage"]').count()).toBe(0); + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0); + }); + + await test.step('1. Server Transaction and all Client Transactions are defined', () => { + expect(serverTxnEvent1TraceId).toBeDefined(); + expect(clientTxnEvent1TraceId).toBeDefined(); + expect(clientTxnEvent2TraceId).toBeDefined(); + expect(serverTxnEvent2).toBeUndefined(); + expect(serverTxnEvent2TraceId).toBeUndefined(); + }); + + await test.step('Trace is not distributed', () => { + // Cannot create distributed trace as HTML Meta Tags are not added (caching leads to multiple usages of the same server trace id) + expect(clientTxnEvent1TraceId).not.toBe(clientTxnEvent2TraceId); + expect(clientTxnEvent1TraceId).not.toBe(serverTxnEvent1TraceId); + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.client.test.ts new file mode 100644 index 000000000000..81121e0de396 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.client.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from '@playwright/test'; +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-4-static', 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.segment.name.source': 'route', + 'sentry.origin': 'auto.pageload.vue', + 'sentry.op': 'pageload', + 'params.param': '1234', + 'url.template': '/test-param/:param()', + 'url.path': '/test-param/1234', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/), + }, + op: 'pageload', + origin: 'auto.pageload.vue', + }, + }, + transaction: '/test-param/:param()', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation root span with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', async transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/test-param/:param()' + ); + }); + + await page.goto(`/`); + await page.getByText('Fetch Param').click(); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.segment.name.source': 'route', + 'sentry.origin': 'auto.navigation.vue', + 'sentry.op': 'navigation', + 'params.param': '1234', + 'url.template': '/test-param/:param()', + 'url.path': '/test-param/1234', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/), + }, + op: 'navigation', + origin: 'auto.navigation.vue', + }, + }, + transaction: '/test-param/:param()', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends component tracking spans when `trackComponents` is enabled', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', 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.mount' }, + description: 'Vue ', + op: 'ui.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-4-static/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.server.test.ts new file mode 100644 index 000000000000..2bc3c805f172 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.server.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test('sends a server action transaction on pageload', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', 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.http_server', + }), + }), + ); +}); + +test('does not send transactions for build asset folder "_nuxt"', async ({ page }) => { + let buildAssetFolderOccurred = false; + + waitForTransaction('nuxt-4-static', transactionEvent => { + if (transactionEvent.transaction?.match(/^GET \/_nuxt\//)) { + buildAssetFolderOccurred = true; + } + return false; // expects to return a boolean (but not relevant here) + }); + + const transactionEventPromise = waitForTransaction('nuxt-4-static', transactionEvent => { + return transactionEvent.transaction.includes('GET /test-param/'); + }); + + await page.goto('/test-param/1234'); + + const transactionEvent = await transactionEventPromise; + + expect(buildAssetFolderOccurred).toBe(false); + + expect(transactionEvent.transaction).toBe('GET /test-param/:param()'); +}); + +test('captures server API calls made with Nitro $fetch', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-4-static', async transactionEvent => { + return transactionEvent.transaction === 'GET /api/nitro-fetch'; + }); + + await page.goto(`/fetch-server-routes`); + await page.getByText('Fetch Nitro $fetch', { exact: true }).click(); + + const httpServerFetchSpan = await transactionPromise; + const httpClientSpan = httpServerFetchSpan.spans.find(span => span.description === 'GET https://example.com/'); + + expect(httpServerFetchSpan.transaction).toEqual('GET /api/nitro-fetch'); + expect(httpServerFetchSpan.contexts.trace.op).toEqual('http.server'); + + expect(httpClientSpan.parent_span_id).toEqual(httpServerFetchSpan.contexts.trace.span_id); + expect(httpClientSpan.op).toEqual('http.client'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.test.ts new file mode 100644 index 000000000000..e527e5555856 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tests/tracing.test.ts @@ -0,0 +1,157 @@ +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-4-static', txnEvent => { + return txnEvent.transaction === '/test-param/:param()'; + }); + + const serverTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /test-param/') ?? false; + }); + + const [_, clientTxnEvent, serverTxnEvent] = await Promise.all([ + page.goto(`/test-param/${PARAM}`), + clientTxnEventPromise, + serverTxnEventPromise, + expect(page.getByText(`Param: ${PARAM}`)).toBeVisible(), + ]); + + const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content'); + + // URL-encoded for parametrized 'GET /test-param/s0me-param' -> `GET /test-param/:param` + expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F%3Aparam`); + expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`); + expect(baggageMetaTagContent).toContain('sentry-sampled=true'); + expect(baggageMetaTagContent).toContain('sentry-sample_rate=1'); + + const sentryTraceMetaTagContent = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); + const [metaTraceId, metaParentSpanId, metaSampled] = sentryTraceMetaTagContent?.split('-') || []; + + expect(metaSampled).toBe('1'); + + expect(clientTxnEvent).toMatchObject({ + transaction: '/test-param/:param()', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.vue', + trace_id: metaTraceId, + parent_span_id: metaParentSpanId, + }, + }, + }); + + expect(serverTxnEvent).toMatchObject({ + transaction: `GET /test-param/:param()`, // parametrized route + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.http_server', + }, + }, + }); + + // connected trace + expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBeDefined(); + + 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); + expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId); + }); + + test('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => { + const clientTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction === '/test-param/user/:userId()'; + }); + const ssrTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /test-param/user') ?? false; + }); + const serverReqTxnEventPromise = waitForTransaction('nuxt-4-static', txnEvent => { + return txnEvent.transaction?.includes('GET /api/user/') ?? false; + }); + + // Navigate to the page which will trigger an API call from the client-side + await page.goto(`/test-param/user/${PARAM}`); + + const [clientTxnEvent, ssrTxnEvent, serverReqTxnEvent] = await Promise.all([ + clientTxnEventPromise, + ssrTxnEventPromise, + serverReqTxnEventPromise, + ]); + + const httpClientSpan = clientTxnEvent?.spans?.find(span => span.description === `GET /api/user/${PARAM}`); + + expect(clientTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: '/test-param/user/:userId()', // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'pageload', + origin: 'auto.pageload.vue', + }), + }), + }), + ); + + expect(httpClientSpan).toBeDefined(); + expect(httpClientSpan).toEqual( + expect.objectContaining({ + description: `GET /api/user/${PARAM}`, // fixme: parametrize + parent_span_id: clientTxnEvent.contexts?.trace?.span_id, // pageload span is parent + data: expect.objectContaining({ + 'url.full': expect.stringContaining(`/api/user/${PARAM}`), + type: 'fetch', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + 'http.request.method': 'GET', + }), + }), + ); + + expect(ssrTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: `GET /test-param/user/:userId()`, // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.http_server', + }), + }), + }), + ); + + expect(serverReqTxnEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: `GET /api/user/:userId`, // parametrized route + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.http_server', + parent_span_id: httpClientSpan?.span_id, // http.client span is parent + }), + }), + }), + ); + + // All 3 transactions and the http.client span should share the same trace_id + expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(httpClientSpan?.trace_id); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(ssrTxnEvent.contexts?.trace?.trace_id); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverReqTxnEvent.contexts?.trace?.trace_id); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/tsconfig.json b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tsconfig.json new file mode 100644 index 000000000000..a746f2a70c28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/tsconfig.json @@ -0,0 +1,4 @@ +{ + // https://nuxt.com/docs/guide/concepts/typescript + "extends": "./.nuxt/tsconfig.json" +}