diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/pageload-tracing/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/pageload-tracing/page.tsx new file mode 100644 index 000000000000..d0a7c77e38c8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/pageload-tracing/page.tsx @@ -0,0 +1,37 @@ +import { Suspense } from 'react'; +import { headers } from 'next/headers'; +import * as Sentry from '@sentry/nextjs'; + +async function CachedContent() { + const getTodos = async () => { + return Sentry.startSpan({ name: 'getTodos', op: 'get.todos' }, async () => { + 'use cache'; + await new Promise(resolve => setTimeout(resolve, 100)); + return [1, 2, 3, 4, 5]; + }); + }; + + const todos = await getTodos(); + + return
Todos fetched: {todos.length}
; +} + +async function DynamicContent() { + await headers(); + return ( + <> + + + ); +} + +export default function Page() { + return ( + <> +

Cache Pageload Tracing

+ Loading...}> + + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts index 9a60ac59cd8f..19cb544bd941 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts @@ -52,3 +52,33 @@ test('Should generate metadata async', async ({ page }) => { await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); await expect(page).toHaveTitle('Product: 1'); }); + +test('Metatag injection produces exactly one pageload trace with cache components', async ({ page }) => { + const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /pageload-tracing' + ); + }); + + const pageloadTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/pageload-tracing'; + }); + + await page.goto('/pageload-tracing'); + + await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); + + const [serverTx, pageloadTx] = await Promise.all([serverTxPromise, pageloadTxPromise]); + + const serverTraceId = serverTx.contexts?.trace?.trace_id; + const pageloadTraceId = pageloadTx.contexts?.trace?.trace_id; + + // Server and client pageload must share the same trace via metatag injection + expect(pageloadTraceId).toBeTruthy(); + expect(serverTraceId).toBe(pageloadTraceId); + + // Exactly one set of trace meta tags — no duplicates that would cause multiple pageloads + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(1); + expect(await page.locator('meta[name="baggage"]').count()).toBe(1); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/.gitignore new file mode 100644 index 000000000000..dd146b53d966 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Sentry Config File +.env.sentry-build-plugin diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/cache/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/cache/page.tsx new file mode 100644 index 000000000000..6cf490b75430 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/cache/page.tsx @@ -0,0 +1,25 @@ +import { Suspense } from 'react'; +import * as Sentry from '@sentry/nextjs'; + +export default function Page() { + return ( + <> +

This will be pre-rendered

+ + + ); +} + +async function DynamicContent() { + const getTodos = async () => { + return Sentry.startSpan({ name: 'getTodos', op: 'get.todos' }, async () => { + 'use cache'; + await new Promise(resolve => setTimeout(resolve, 100)); + return [1, 2, 3, 4, 5]; + }); + }; + + const todos = await getTodos(); + + return
Todos fetched: {todos.length}
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/favicon.ico b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/favicon.ico new file mode 100644 index 000000000000..718d6fea4835 Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/favicon.ico differ diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/global-error.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/global-error.tsx new file mode 100644 index 000000000000..20c175015b03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/global-error.tsx @@ -0,0 +1,23 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; +import NextError from 'next/error'; +import { useEffect } from 'react'; + +export default function GlobalError({ error }: { error: Error & { digest?: string } }) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + + {/* `NextError` is the default Next.js error page component. Its type + definition requires a `statusCode` prop. However, since the App Router + does not expose status codes for errors, we simply pass 0 to render a + generic error message. */} + + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/layout.tsx new file mode 100644 index 000000000000..c8f9cee0b787 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/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-streaming-cacheComponents/app/metadata-async/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/metadata-async/page.tsx new file mode 100644 index 000000000000..03201cdccf60 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/metadata-async/page.tsx @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/nextjs'; + +function fetchPost() { + return Promise.resolve({ id: '1', title: 'Post 1' }); +} + +export async function generateMetadata() { + const { id } = await fetchPost(); + const product = `Product: ${id}`; + + return { + title: product, + }; +} + +export default function Page() { + return ( + <> +

This will be pre-rendered

+ + + ); +} + +async function DynamicContent() { + const getTodos = async () => { + return Sentry.startSpan({ name: 'getTodos', op: 'get.todos' }, async () => { + 'use cache'; + await new Promise(resolve => setTimeout(resolve, 100)); + return [1, 2, 3, 4, 5]; + }); + }; + + const todos = await getTodos(); + + return
Todos fetched: {todos.length}
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/metadata/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/metadata/page.tsx new file mode 100644 index 000000000000..7bcdbd0474e0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/metadata/page.tsx @@ -0,0 +1,35 @@ +import * as Sentry from '@sentry/nextjs'; + +/** + * Tests generateMetadata function with cache components, this calls the propagation context to be set + * Which will generate and set a trace id in the propagation context, which should trigger the random API error if unpatched + * See: https://github.com/getsentry/sentry-javascript/issues/18392 + */ +export function generateMetadata() { + return { + title: 'Cache Components Metadata Test', + }; +} + +export default function Page() { + return ( + <> +

This will be pre-rendered

+ + + ); +} + +async function DynamicContent() { + const getTodos = async () => { + return Sentry.startSpan({ name: 'getTodos', op: 'get.todos' }, async () => { + 'use cache'; + await new Promise(resolve => setTimeout(resolve, 100)); + return [1, 2, 3, 4, 5]; + }); + }; + + const todos = await getTodos(); + + return
Todos fetched: {todos.length}
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/page.tsx new file mode 100644 index 000000000000..433db8283beb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Next 16 CacheComponents test app

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/pageload-tracing/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/pageload-tracing/page.tsx new file mode 100644 index 000000000000..d0a7c77e38c8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/pageload-tracing/page.tsx @@ -0,0 +1,37 @@ +import { Suspense } from 'react'; +import { headers } from 'next/headers'; +import * as Sentry from '@sentry/nextjs'; + +async function CachedContent() { + const getTodos = async () => { + return Sentry.startSpan({ name: 'getTodos', op: 'get.todos' }, async () => { + 'use cache'; + await new Promise(resolve => setTimeout(resolve, 100)); + return [1, 2, 3, 4, 5]; + }); + }; + + const todos = await getTodos(); + + return
Todos fetched: {todos.length}
; +} + +async function DynamicContent() { + await headers(); + return ( + <> + + + ); +} + +export default function Page() { + return ( + <> +

Cache Pageload Tracing

+ Loading...}> + + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/suspense/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/suspense/page.tsx new file mode 100644 index 000000000000..32e5a73afc14 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/suspense/page.tsx @@ -0,0 +1,26 @@ +import { Suspense } from 'react'; +import * as Sentry from '@sentry/nextjs'; + +export default function Page() { + return ( + <> +

This will be pre-rendered

+ Loading...}> + + + + ); +} + +async function DynamicContent() { + const getTodos = async () => { + return Sentry.startSpan({ name: 'getTodos', op: 'get.todos' }, async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + return [1, 2, 3, 4, 5]; + }); + }; + + const todos = await getTodos(); + + return
Todos fetched: {todos.length}
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/eslint.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/eslint.config.mjs new file mode 100644 index 000000000000..60f7af38f6c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/eslint.config.mjs @@ -0,0 +1,19 @@ +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { FlatCompat } from '@eslint/eslintrc'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends('next/core-web-vitals', 'next/typescript'), + { + ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts'], + }, +]; + +export default eslintConfig; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/instrumentation-client.ts new file mode 100644 index 000000000000..e2009fc21abb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/instrumentation-client.ts @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + 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, + sendDefaultPii: true, + integrations: [Sentry.spanStreamingIntegration()], +}); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/instrumentation.ts new file mode 100644 index 000000000000..964f937c439a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/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-streaming-cacheComponents/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/next.config.ts new file mode 100644 index 000000000000..2841f1c0c5da --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/next.config.ts @@ -0,0 +1,10 @@ +import { withSentryConfig } from '@sentry/nextjs'; +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + cacheComponents: true, +}; + +export default withSentryConfig(nextConfig, { + silent: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json new file mode 100644 index 000000000000..a9111dd07271 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/package.json @@ -0,0 +1,51 @@ +{ + "name": "nextjs-16-streaming-cacheComponents", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", + "dev:webpack": "next dev --webpack", + "build-webpack": "next build --webpack", + "start": "next start", + "lint": "eslint", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:dev-webpack": "TEST_ENV=development-webpack playwright test", + "test:build": "pnpm install && pnpm build", + "test:build-webpack": "pnpm install && pnpm build-webpack", + "test:build-canary": "pnpm install && pnpm add next@canary && pnpm build", + "test:build-latest": "pnpm install && pnpm add next@latest && pnpm build", + "test:build-latest-webpack": "pnpm install && pnpm add next@latest && pnpm build-webpack", + "test:build-canary-webpack": "pnpm install && pnpm add next@canary && pnpm build-webpack", + "test:assert": "pnpm test:prod && pnpm test:dev", + "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack" + }, + "dependencies": { + "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz", + "import-in-the-middle": "^1", + "next": "16.2.3", + "react": "19.1.0", + "react-dom": "19.1.0", + "require-in-the-middle": "^7", + "zod": "^3.22.4" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "^16", + "typescript": "^5" + }, + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "//": "TODO: Add variants for webpack once supported" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/playwright.config.mjs new file mode 100644 index 000000000000..797418b8cf7d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/playwright.config.mjs @@ -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-webpack') { + return 'pnpm next dev -p 3030 --webpack 2>&1 | tee .tmp_dev_server_logs'; + } + + 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-streaming-cacheComponents/proxy.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/proxy.ts new file mode 100644 index 000000000000..60722f329fa0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/proxy.ts @@ -0,0 +1,24 @@ +import { getDefaultIsolationScope } from '@sentry/core'; +import * as Sentry from '@sentry/nextjs'; +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export async function proxy(request: NextRequest) { + Sentry.setTag('my-isolated-tag', true); + Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope + + if (request.headers.has('x-should-throw')) { + throw new Error('Middleware Error'); + } + + if (request.headers.has('x-should-make-request')) { + await fetch('http://localhost:3030/'); + } + + return NextResponse.next(); +} + +// See "Matching Paths" below to learn more +export const config = { + matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware'], +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/public/file.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/public/file.svg new file mode 100644 index 000000000000..004145cddf3f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/public/globe.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/public/globe.svg new file mode 100644 index 000000000000..567f17b0d7c7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/public/next.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/public/next.svg new file mode 100644 index 000000000000..5174b28c565c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/public/vercel.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/public/vercel.svg new file mode 100644 index 000000000000..77053960334e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/public/window.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/public/window.svg new file mode 100644 index 000000000000..b2b2a44f6ebc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/sentry.edge.config.ts new file mode 100644 index 000000000000..f2e946f81728 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/sentry.edge.config.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + 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, + sendDefaultPii: true, + traceLifecycle: 'stream', + integrations: [Sentry.spanStreamingIntegration()], +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/sentry.server.config.ts new file mode 100644 index 000000000000..d370665d7dfd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/sentry.server.config.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + 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, + sendDefaultPii: true, + traceLifecycle: 'stream', + integrations: [Sentry.vercelAIIntegration(), Sentry.spanStreamingIntegration()], +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/start-event-proxy.mjs new file mode 100644 index 000000000000..493d4fff94fe --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/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-streaming-cacheComponents', + envelopeDumpPath: path.join( + process.cwd(), + `event-dumps/next-16-cacheComponents-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`, + ), +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts new file mode 100644 index 000000000000..a9caa316a26b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts @@ -0,0 +1,90 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, waitForStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; + +test('Should render cached component', async ({ page }) => { + const spansPromise = waitForStreamedSpans('nextjs-16-streaming-cacheComponents', spans => { + return spans.some( + span => span.name.startsWith('GET /cache') && getSpanOp(span) === 'http.server' && span.is_segment, + ); + }); + + await page.goto('/cache'); + + const spans = await spansPromise; + + // we want to skip creating spans in cached environments + expect(spans.filter(span => getSpanOp(span) === 'get.todos')).toHaveLength(0); + await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); +}); + +test('Should render suspense component', async ({ page }) => { + const spansPromise = waitForStreamedSpans('nextjs-16-streaming-cacheComponents', spans => { + return spans.some( + span => span.name.startsWith('GET /suspense') && getSpanOp(span) === 'http.server' && span.is_segment, + ); + }); + + await page.goto('/suspense'); + + const spans = await spansPromise; + + // this will be called several times in development mode, so we need to check for at least one span + expect(spans.filter(span => getSpanOp(span) === 'get.todos').length).toBeGreaterThan(0); + await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); +}); + +test('Should generate metadata', async ({ page }) => { + const spansPromise = waitForStreamedSpans('nextjs-16-streaming-cacheComponents', spans => { + return spans.some( + span => span.name.startsWith('GET /metadata') && getSpanOp(span) === 'http.server' && span.is_segment, + ); + }); + + await page.goto('/metadata'); + + const spans = await spansPromise; + + expect(spans.filter(span => getSpanOp(span) === 'get.todos')).toHaveLength(0); + await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); + await expect(page).toHaveTitle('Cache Components Metadata Test'); +}); + +test('Should generate metadata async', async ({ page }) => { + const spansPromise = waitForStreamedSpans('nextjs-16-streaming-cacheComponents', spans => { + return spans.some( + span => span.name.startsWith('GET /metadata-async') && getSpanOp(span) === 'http.server' && span.is_segment, + ); + }); + + await page.goto('/metadata-async'); + + const spans = await spansPromise; + + expect(spans.filter(span => getSpanOp(span) === 'get.todos')).toHaveLength(0); + await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); + await expect(page).toHaveTitle('Product: 1'); +}); + +test('Metatag injection produces exactly one pageload trace with cache components', async ({ page }) => { + const serverSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => { + return span.name === 'GET /pageload-tracing' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + + const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => { + return span.name === '/pageload-tracing' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/pageload-tracing'); + + await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); + + const [serverSpan, pageloadSpan] = await Promise.all([serverSpanPromise, pageloadSpanPromise]); + + // Server and client pageload spans must share the same trace via metatag injection + expect(pageloadSpan.trace_id).toBeTruthy(); + expect(serverSpan.trace_id).toBe(pageloadSpan.trace_id); + + // Exactly one set of trace meta tags — no duplicates that would cause multiple pageloads + expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(1); + expect(await page.locator('meta[name="baggage"]').count()).toBe(1); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tsconfig.json new file mode 100644 index 000000000000..cc9ed39b5aa2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/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"] +}