diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/.gitignore new file mode 100644 index 000000000000..dd146b53d966 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/.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/app/(nested-layout)/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/layout.tsx new file mode 100644 index 000000000000..ace0c2f086b7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/layout.tsx @@ -0,0 +1,12 @@ +import { PropsWithChildren } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default function Layout({ children }: PropsWithChildren<{}>) { + return ( +
Layout
+ {children} +DynamicLayout
+ {children} +Dynamic Page
+Layout
+ {children} +Hello World!
; +} + +export async function generateMetadata() { + return { + title: 'I am generated metadata', + }; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/ai-error-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/ai-error-test/page.tsx new file mode 100644 index 000000000000..bd75c0062228 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/ai-error-test/page.tsx @@ -0,0 +1,50 @@ +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; +import * as Sentry from '@sentry/nextjs'; + +export const dynamic = 'force-dynamic'; + +// Error trace handling in tool calls +async function runAITest() { + const result = await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + parameters: z.object({ location: z.string() }), + execute: async args => { + throw new Error('Tool call failed'); + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); +} + +export default async function Page() { + await Sentry.startSpan({ op: 'function', name: 'ai-error-test' }, async () => { + return await runAITest(); + }); + + return ( +{JSON.stringify(results, null, 2)}
+ unreachable
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/non-isr-test/[item]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/non-isr-test/[item]/page.tsx new file mode 100644 index 000000000000..e0bafdb24181 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/non-isr-test/[item]/page.tsx @@ -0,0 +1,11 @@ +// No generateStaticParams - this is NOT an ISR page +export default async function NonISRPage({ params }: { params: Promise<{ item: string }> }) { + const { item } = await params; + + return ( +Next 16 test app
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/pageload-tracing/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/pageload-tracing/layout.tsx new file mode 100644 index 000000000000..1f0cbe478f88 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/pageload-tracing/layout.tsx @@ -0,0 +1,8 @@ +import { PropsWithChildren } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default async function Layout({ children }: PropsWithChildrenI am page 2
; +} + +export async function generateMetadata() { + (await fetch('https://example.com/', { cache: 'no-store' })).text(); + + return { + title: 'my title', + }; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/beep/[two]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/beep/[two]/page.tsx new file mode 100644 index 000000000000..f34461c2bb07 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/beep/[two]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + returnHello
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/redirect/destination/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/redirect/destination/page.tsx new file mode 100644 index 000000000000..5583d36b04b0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/redirect/destination/page.tsx @@ -0,0 +1,7 @@ +export default function RedirectDestinationPage() { + return ( +test
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/third-party-filter/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/third-party-filter/page.tsx new file mode 100644 index 000000000000..b6b4bea80def --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/third-party-filter/page.tsx @@ -0,0 +1,24 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; + +function throwFirstPartyError(): void { + throw new Error('first-party-error'); +} + +export default function Page() { + return ( + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/eslint.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/eslint.config.mjs new file mode 100644 index 000000000000..60f7af38f6c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/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/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/instrumentation-client.ts new file mode 100644 index 000000000000..77e1e79967e4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/instrumentation-client.ts @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/nextjs'; +import type { Log } 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.thirdPartyErrorFilterIntegration({ + filterKeys: ['nextjs-16-streaming-e2e'], + behaviour: 'apply-tag-if-contains-third-party-frames', + }), + Sentry.spanStreamingIntegration(), + ], + beforeSendLog(log: Log) { + return log; + }, +}); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/instrumentation.ts new file mode 100644 index 000000000000..964f937c439a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/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/lib/queue.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/lib/queue.ts new file mode 100644 index 000000000000..8dc8ce0ad5ed --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/lib/queue.ts @@ -0,0 +1,12 @@ +import { QueueClient } from '@vercel/queue'; + +// For E2E testing, point the SDK at a local mock server running within Next.js. +// The mock API lives at app/api/v3/topic/[...params]/route.ts +const queue = new QueueClient({ + region: 'test1', + resolveBaseUrl: () => new URL(`http://localhost:${process.env.PORT || 3030}`), + token: 'mock-token', + deploymentId: null, +}); + +export const { send, handleCallback } = queue; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/next.config.ts new file mode 100644 index 000000000000..6067696c7d16 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/next.config.ts @@ -0,0 +1,18 @@ +import { withSentryConfig } from '@sentry/nextjs'; +import type { NextConfig } from 'next'; + +// Simulate Vercel environment for cron monitoring tests +process.env.VERCEL = '1'; + +const nextConfig: NextConfig = {}; + +export default withSentryConfig(nextConfig, { + silent: true, + _experimental: { + vercelCronsMonitoring: true, + turbopackApplicationKey: 'nextjs-16-streaming-e2e', + turbopackReactComponentAnnotation: { + enabled: true, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/package.json new file mode 100644 index 000000000000..8e254f4b4657 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/package.json @@ -0,0 +1,41 @@ +{ + "name": "nextjs-16-streaming", + "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", + "lint": "eslint", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:prod && pnpm test:dev" + }, + "dependencies": { + "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz", + "@vercel/queue": "^0.1.3", + "ai": "^3.0.0", + "import-in-the-middle": "^2", + "next": "16.2.4", + "react": "19.1.0", + "react-dom": "19.1.0", + "require-in-the-middle": "^8", + "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" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/playwright.config.mjs new file mode 100644 index 000000000000..797418b8cf7d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/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/proxy.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/proxy.ts new file mode 100644 index 000000000000..60722f329fa0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/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/public/file.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/file.svg new file mode 100644 index 000000000000..004145cddf3f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/globe.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/globe.svg new file mode 100644 index 000000000000..567f17b0d7c7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/next.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/next.svg new file mode 100644 index 000000000000..5174b28c565c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/vercel.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/vercel.svg new file mode 100644 index 000000000000..77053960334e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/window.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/window.svg new file mode 100644 index 000000000000..b2b2a44f6ebc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/sentry.edge.config.ts new file mode 100644 index 000000000000..f2e946f81728 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/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/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/sentry.server.config.ts new file mode 100644 index 000000000000..d44e4da73818 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/sentry.server.config.ts @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/nextjs'; +import { Log } 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.nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }), + Sentry.spanStreamingIntegration(), + ], + beforeSendLog(log: Log) { + return log; + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/start-event-proxy.mjs new file mode 100644 index 000000000000..9b3556d402af --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/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', + envelopeDumpPath: path.join( + process.cwd(), + `event-dumps/next-16-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`, + ), +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/isDevMode.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/isDevMode.ts new file mode 100644 index 000000000000..d2be94232110 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/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/nextjs-16-streaming/tests/nested-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/nested-rsc-error.test.ts new file mode 100644 index 000000000000..280f0ef4e33b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/nested-rsc-error.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('Should capture errors from nested server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({ + page, +}) => { + const errorEventPromise = waitForError('nextjs-16-streaming', errorEvent => { + return !!errorEvent?.exception?.values?.some(value => value.value === 'I am technically uncatchable'); + }); + + const rootSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === 'GET /nested-rsc-error/[param]' && span.is_segment; + }); + + await page.goto(`/nested-rsc-error/123`); + const errorEvent = await errorEventPromise; + const rootSpan = await rootSpanPromise; + + expect(errorEvent.contexts?.trace?.trace_id).toBe(rootSpan.trace_id); + + expect(errorEvent.request).toMatchObject({ + headers: expect.any(Object), + method: 'GET', + }); + + expect(errorEvent.contexts?.nextjs).toEqual({ + route_type: 'render', + router_kind: 'App Router', + router_path: '/nested-rsc-error/[param]', + request_path: '/nested-rsc-error/123', + }); + + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.function.nextjs.on_request_error', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/pageload-tracing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/pageload-tracing.test.ts new file mode 100644 index 000000000000..b64307ad9202 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/pageload-tracing.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; + +test('Server and client pageload spans should share the same trace', async ({ page }) => { + const serverSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === 'GET /pageload-tracing' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + + const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === '/pageload-tracing' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/pageload-tracing`); + + const [serverSpan, pageloadSpan] = await Promise.all([serverSpanPromise, pageloadSpanPromise]); + + expect(pageloadSpan.trace_id).toBeTruthy(); + expect(serverSpan.trace_id).toBe(pageloadSpan.trace_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/parameterized-routes.test.ts new file mode 100644 index 000000000000..7990953bf7a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/parameterized-routes.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; + +test('should create a parameterized streamed span when the `app` directory is used', async ({ page }) => { + const spanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === '/parameterized/:one' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/parameterized/cappuccino`); + + const span = await spanPromise; + + expect(span.name).toBe('/parameterized/:one'); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.attributes?.['sentry.source']?.value).toBe('route'); +}); + +test('should create a static streamed span when the `app` directory is used and the route is not parameterized', async ({ + page, +}) => { + const spanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === '/parameterized/static' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/parameterized/static`); + + const span = await spanPromise; + + expect(span.name).toBe('/parameterized/static'); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.attributes?.['sentry.source']?.value).toBe('url'); +}); + +test('should create a partially parameterized streamed span when the `app` directory is used', async ({ page }) => { + const spanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === '/parameterized/:one/beep' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/parameterized/cappuccino/beep`); + + const span = await spanPromise; + + expect(span.name).toBe('/parameterized/:one/beep'); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.attributes?.['sentry.source']?.value).toBe('route'); +}); + +test('should create a nested parameterized streamed span when the `app` directory is used.', async ({ page }) => { + const spanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === '/parameterized/:one/beep/:two' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/parameterized/cappuccino/beep/espresso`); + + const span = await spanPromise; + + expect(span.name).toBe('/parameterized/:one/beep/:two'); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.attributes?.['sentry.source']?.value).toBe('route'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/route-handler.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/route-handler.test.ts new file mode 100644 index 000000000000..be6be4c220b7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/route-handler.test.ts @@ -0,0 +1,59 @@ +import test, { expect } from '@playwright/test'; +import { waitForError, waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; + +test('Should create a streamed span for node route handlers', async ({ request }) => { + const rootSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === 'GET /route-handler/[xoxo]/node' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + + const response = await request.get('/route-handler/123/node', { headers: { 'x-charly': 'gomez' } }); + expect(await response.json()).toStrictEqual({ message: 'Hello Node Route Handler' }); + + const rootSpan = await rootSpanPromise; + + expect(rootSpan.status).toBe('ok'); + expect(getSpanOp(rootSpan)).toBe('http.server'); +}); + +test('Should report an error linked to the correct trace for a throwing route handler', async ({ request }) => { + const errorEventPromise = waitForError('nextjs-16-streaming', errorEvent => { + return errorEvent?.exception?.values?.some(value => value.value === 'route-handler-error') ?? false; + }); + + const rootSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === 'GET /route-handler/[xoxo]/error' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + + request.get('/route-handler/456/error').catch(() => {}); + + const errorEvent = await errorEventPromise; + const rootSpan = await rootSpanPromise; + + expect(errorEvent.contexts?.trace?.trace_id).toBe(rootSpan.trace_id); + expect(errorEvent.transaction).toBe('GET /route-handler/[xoxo]/error'); + expect(rootSpan.status).toBe('error'); +}); + +test('Should set a parameterized transaction name on a captureMessage event in a route handler', async ({ + request, +}) => { + const messageEventPromise = waitForError('nextjs-16-streaming', event => { + return event?.message === 'route-handler-message'; + }); + + const rootSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return ( + span.name === 'GET /route-handler/[xoxo]/capture-message' && getSpanOp(span) === 'http.server' && span.is_segment + ); + }); + + const response = await request.get('/route-handler/789/capture-message'); + expect(await response.json()).toStrictEqual({ message: 'Message captured' }); + + const messageEvent = await messageEventPromise; + const rootSpan = await rootSpanPromise; + + expect(messageEvent.contexts?.trace?.trace_id).toBe(rootSpan.trace_id); + expect(messageEvent.transaction).toBe('GET /route-handler/[xoxo]/capture-message'); + expect(rootSpan.status).toBe('ok'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/server-components.test.ts new file mode 100644 index 000000000000..ba64953678b3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/server-components.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, waitForStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; + +test('Sends a streamed span for a request to app router with URL', async ({ page }) => { + const rootSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === 'GET /parameterized/[one]/beep/[two]' && span.is_segment; + }); + + await page.goto('/parameterized/1337/beep/42'); + + const rootSpan = await rootSpanPromise; + + expect(getSpanOp(rootSpan)).toBe('http.server'); + expect(rootSpan.status).toBe('ok'); +}); + +test('Will create streamed spans for every server component and metadata generation functions when visiting a page', async ({ + page, +}) => { + const spansPromise = waitForStreamedSpans('nextjs-16-streaming', spans => { + return spans.some(span => span.name === 'GET /nested-layout' && span.is_segment); + }); + + await page.goto('/nested-layout'); + + const spans = await spansPromise; + const spanNames = spans.map(span => span.name); + + expect(spanNames).toContainEqual('render route (app) /nested-layout'); + expect(spanNames).toContainEqual('build component tree'); + expect(spanNames).toContainEqual('resolve root layout server component'); + expect(spanNames).toContainEqual('resolve layout server component "(nested-layout)"'); + expect(spanNames).toContainEqual('resolve layout server component "nested-layout"'); + expect(spanNames).toContainEqual('resolve page server component "/nested-layout"'); + expect(spanNames).toContainEqual('generateMetadata /(nested-layout)/nested-layout/page'); + expect(spanNames).toContainEqual('start response'); + expect(spanNames).toContainEqual('NextNodeServer.clientComponentLoading'); +}); + +test('Will create streamed spans for every server component and metadata generation functions when visiting a dynamic page', async ({ + page, +}) => { + const spansPromise = waitForStreamedSpans('nextjs-16-streaming', spans => { + return spans.some(span => span.name === 'GET /nested-layout/[dynamic]' && span.is_segment); + }); + + await page.goto('/nested-layout/123'); + + const spans = await spansPromise; + const spanNames = spans.map(span => span.name); + + expect(spanNames).toContainEqual('resolve page components'); + expect(spanNames).toContainEqual('render route (app) /nested-layout/[dynamic]'); + expect(spanNames).toContainEqual('build component tree'); + expect(spanNames).toContainEqual('resolve root layout server component'); + expect(spanNames).toContainEqual('resolve layout server component "(nested-layout)"'); + expect(spanNames).toContainEqual('resolve layout server component "nested-layout"'); + expect(spanNames).toContainEqual('resolve layout server component "[dynamic]"'); + expect(spanNames).toContainEqual('resolve page server component "/nested-layout/[dynamic]"'); + expect(spanNames).toContainEqual('generateMetadata /(nested-layout)/nested-layout/[dynamic]/page'); + expect(spanNames).toContainEqual('start response'); + expect(spanNames).toContainEqual('NextNodeServer.clientComponentLoading'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tsconfig.json new file mode 100644 index 000000000000..cc9ed39b5aa2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/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"] +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/vercel.json b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/vercel.json new file mode 100644 index 000000000000..58730a0978fb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/vercel.json @@ -0,0 +1,17 @@ +{ + "crons": [ + { + "path": "/api/cron-test", + "schedule": "0 * * * *" + }, + { + "path": "/api/cron-test-error", + "schedule": "30 * * * *" + } + ], + "functions": { + "app/api/queues/process-order/route.ts": { + "experimentalTriggers": [{ "type": "queue/v2beta", "topic": "orders" }] + } + } +}