diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/app/[...slug]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/app/[...slug]/page.tsx new file mode 100644 index 000000000000..477881513d3f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/app/[...slug]/page.tsx @@ -0,0 +1,3 @@ +export default function CatchAllPage() { + return
Catch-all page
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/app/layout.tsx new file mode 100644 index 000000000000..c8f9cee0b787 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/app/page.tsx new file mode 100644 index 000000000000..b55e84109d2c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/app/page.tsx @@ -0,0 +1,20 @@ +import Link from 'next/link'; + +export default function Page() { + return ( +
+

Next 16 trailing slash test app

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/app/parameterized/[param]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/app/parameterized/[param]/page.tsx new file mode 100644 index 000000000000..e3a3dafe45f2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/app/parameterized/[param]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic parameterized page
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/app/parameterized/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/app/parameterized/static/page.tsx new file mode 100644 index 000000000000..4cb7f5887fc4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/app/parameterized/static/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedStaticPage() { + return
Parameterized static page
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/app/static-page/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/app/static-page/page.tsx new file mode 100644 index 000000000000..16ef0482d53b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/app/static-page/page.tsx @@ -0,0 +1,3 @@ +export default function StaticPage() { + return
Static page
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/instrumentation-client.ts new file mode 100644 index 000000000000..ee12630ac228 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/instrumentation-client.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + // This app is the static counterpart of `nextjs-16-trailing-slash`, which streams spans. + // Do not port it: it exists to keep the static trace lifecycle covered end to end. + traceLifecycle: 'static', + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, +}); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/instrumentation.ts new file mode 100644 index 000000000000..964f937c439a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; + +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + await import('./sentry.server.config'); + } + + if (process.env.NEXT_RUNTIME === 'edge') { + await import('./sentry.edge.config'); + } +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/next.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/next.config.mjs new file mode 100644 index 000000000000..b1717b717341 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/next.config.mjs @@ -0,0 +1,13 @@ +// Deliberately `.mjs`: Next loads it through Node's own ESM loader rather than compiling it, which is the only +// config format that exercises `@sentry/nextjs/config` as a plain-Node ESM consumer. +// See https://github.com/getsentry/sentry-javascript/issues/22791 +import { withSentryConfig } from '@sentry/nextjs/config'; + +/** @type {import('next').NextConfig} */ +const nextConfig = { + trailingSlash: true, +}; + +export default withSentryConfig(nextConfig, { + silent: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/package.json new file mode 100644 index 000000000000..e91dc0f8d767 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/package.json @@ -0,0 +1,34 @@ +{ + "name": "nextjs-16-trailing-slash-static", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", + "start": "next start", + "test:prod": "TEST_ENV=production playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:prod" + }, + "dependencies": { + "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz", + "import-in-the-middle": "^2", + "next": "16.2.11", + "react": "19.1.0", + "react-dom": "19.1.0", + "require-in-the-middle": "^8" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "typescript": "^5" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/playwright.config.mjs new file mode 100644 index 000000000000..38548e975851 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/playwright.config.mjs @@ -0,0 +1,25 @@ +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 'pnpm next dev -p 3030 2>&1 | tee .tmp_dev_server_logs'; + } + + if (testEnv === 'production') { + return 'pnpm next start -p 3030'; + } + + throw new Error(`Unknown test env: ${testEnv}`); +}; + +const config = getPlaywrightConfig({ + startCommand: getStartCommand(), + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/sentry.edge.config.ts new file mode 100644 index 000000000000..898ff48d9d6f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/sentry.edge.config.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + // This app is the static counterpart of `nextjs-16-trailing-slash`, which streams spans. + // Do not port it: it exists to keep the static trace lifecycle covered end to end. + traceLifecycle: 'static', + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/sentry.server.config.ts new file mode 100644 index 000000000000..898ff48d9d6f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/sentry.server.config.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + // This app is the static counterpart of `nextjs-16-trailing-slash`, which streams spans. + // Do not port it: it exists to keep the static trace lifecycle covered end to end. + traceLifecycle: 'static', + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/start-event-proxy.mjs new file mode 100644 index 000000000000..5ff551125b03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/start-event-proxy.mjs @@ -0,0 +1,14 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'))); + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nextjs-16-trailing-slash-static', + envelopeDumpPath: path.join( + process.cwd(), + `event-dumps/next-16-trailing-slash-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`, + ), +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/tests/trailing-slash-parameterization.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/tests/trailing-slash-parameterization.test.ts new file mode 100644 index 000000000000..e6e627329102 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/tests/trailing-slash-parameterization.test.ts @@ -0,0 +1,153 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// These tests verify that pageload transactions are correctly named when +// trailingSlash: true is enabled in next.config.mjs, even when a catch-all +// route exists. See: https://github.com/getsentry/sentry-javascript/issues/19241 +// +// This is the static counterpart of `nextjs-16-trailing-slash`. That app asserts the same +// routes on streamed spans; this one keeps the transaction-based path covered. + +test('should create a correctly named pageload transaction for a static route', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-16-trailing-slash-static', async transactionEvent => { + return transactionEvent.transaction === '/static-page' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/static-page`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.segment.name.source': 'route', + 'url.template': '/static-page', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + }, + }, + transaction: '/static-page', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a correctly named pageload transaction for a parameterized route', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-16-trailing-slash-static', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:param' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/some-value`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.segment.name.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + }, + }, + transaction: '/parameterized/:param', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a correctly named pageload transaction for a static nested route under parameterized', async ({ + page, +}) => { + const transactionPromise = waitForTransaction('nextjs-16-trailing-slash-static', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/static' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/static`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.segment.name.source': 'route', + 'url.template': '/parameterized/static', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + }, + }, + transaction: '/parameterized/static', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a correctly named pageload transaction for the catch-all route', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-16-trailing-slash-static', async transactionEvent => { + return transactionEvent.transaction === '/:slug*' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/some/unmatched/path`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.segment.name.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + }, + }, + transaction: '/:slug*', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a correctly named pageload transaction for the home page', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-16-trailing-slash-static', async transactionEvent => { + return transactionEvent.transaction === '/' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.segment.name.source': 'route', + 'url.template': '/', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + }, + }, + transaction: '/', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/tsconfig.json new file mode 100644 index 000000000000..cc9ed39b5aa2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash-static/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"], + "exclude": ["node_modules"] +}