From 2c5cc83757fba2789e99090411052e5173e8fe2a Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 4 May 2026 14:08:29 +0200 Subject: [PATCH 1/5] draft --- .../nextjs-16/instrumentation-client.ts | 3 + .../test-applications/nextjs-16/package.json | 9 +- .../nextjs-16/sentry.edge.config.ts | 4 + .../nextjs-16/sentry.server.config.ts | 9 +- .../nextjs-16/tests/ai-error.test.ts | 29 +-- .../nextjs-16/tests/ai-test.test.ts | 53 ++--- .../nextjs-16/tests/isr-routes.test.ts | 34 ++-- .../nextjs-16/tests/middleware.test.ts | 59 +++--- .../nextjs-16/tests/nested-rsc-error.test.ts | 10 +- .../nextjs-16/tests/pageload-tracing.test.ts | 29 ++- .../tests/parameterized-routes.test.ts | 187 ++++++------------ .../nextjs-16/tests/prefetch-spans.test.ts | 12 +- .../nextjs-16/tests/route-handler.test.ts | 70 +++---- .../tests/server-action-redirect.test.ts | 20 +- .../nextjs-16/tests/server-components.test.ts | 98 ++++----- .../tests/streaming-rsc-error.test.ts | 10 +- .../nextjs-16/tests/suspense-error.test.ts | 10 +- .../nextjs-16/tests/vercel-queue.test.ts | 43 ++-- .../test-utils/src/event-proxy-server.ts | 115 +++++++++++ dev-packages/test-utils/src/index.ts | 2 + 20 files changed, 401 insertions(+), 405 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts index 991c6009ed02..c425bb403807 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts @@ -1,6 +1,8 @@ import * as Sentry from '@sentry/nextjs'; import type { Log } from '@sentry/nextjs'; +const enableSpanStreaming = process.env.NEXT_PUBLIC_E2E_NEXTJS_SPAN_STREAMING === '1'; + Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, @@ -12,6 +14,7 @@ Sentry.init({ filterKeys: ['nextjs-16-e2e'], behaviour: 'apply-tag-if-contains-third-party-frames', }), + ...(enableSpanStreaming ? [Sentry.spanStreamingIntegration()] : []), ], // Verify Log type is available beforeSendLog(log: Log) { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 944102e188b3..d89eddfe87d5 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -19,8 +19,10 @@ "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:build-span-streaming": "NEXT_PUBLIC_E2E_NEXTJS_SPAN_STREAMING=1 pnpm test:build", "test:assert": "pnpm test:prod && pnpm test:dev", - "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack" + "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack", + "test:assert-span-streaming": "NEXT_PUBLIC_E2E_NEXTJS_SPAN_STREAMING=1 pnpm test:assert" }, "dependencies": { "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", @@ -74,6 +76,11 @@ "build-command": "pnpm test:build-canary-webpack", "label": "nextjs-16 (canary, webpack)", "assert-command": "pnpm test:assert-webpack" + }, + { + "build-command": "pnpm test:build-span-streaming", + "label": "nextjs-16 (span streaming, turbopack)", + "assert-command": "pnpm test:assert-span-streaming" } ] } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts index 2199afc46eaf..fc1bc1756e08 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts @@ -1,5 +1,7 @@ import * as Sentry from '@sentry/nextjs'; +const enableSpanStreaming = process.env.NEXT_PUBLIC_E2E_NEXTJS_SPAN_STREAMING === '1'; + Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, @@ -7,4 +9,6 @@ Sentry.init({ tracesSampleRate: 1.0, sendDefaultPii: true, // debug: true, + ...(enableSpanStreaming && { traceLifecycle: 'stream' as const }), + integrations: enableSpanStreaming ? [Sentry.spanStreamingIntegration()] : [], }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts index d7015bce4a30..946d2938f165 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts @@ -1,6 +1,8 @@ import * as Sentry from '@sentry/nextjs'; import { Log } from '@sentry/nextjs'; +const enableSpanStreaming = process.env.NEXT_PUBLIC_E2E_NEXTJS_SPAN_STREAMING === '1'; + Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, @@ -8,7 +10,12 @@ Sentry.init({ tracesSampleRate: 1.0, sendDefaultPii: true, // debug: true, - integrations: [Sentry.vercelAIIntegration(), Sentry.nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 })], + ...(enableSpanStreaming && { traceLifecycle: 'stream' as const }), + integrations: [ + Sentry.vercelAIIntegration(), + Sentry.nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }), + ...(enableSpanStreaming ? [Sentry.spanStreamingIntegration()] : []), + ], // Verify Log type is available beforeSendLog(log: Log) { return log; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-error.test.ts index 39e76bab0dde..9791e4a43339 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-error.test.ts @@ -1,33 +1,36 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; +import { waitForError, waitForRootSpan } from '@sentry-internal/test-utils'; + +const isSpanStreaming = process.env.NEXT_PUBLIC_E2E_NEXTJS_SPAN_STREAMING === '1'; test('should create AI spans with correct attributes and error linking', async ({ page }) => { - const aiTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent.transaction === 'GET /ai-error-test'; + test.skip(isSpanStreaming, 'AI route segment span does not flush reliably in streaming mode'); + const aiRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === 'GET /ai-error-test'; }); const errorEventPromise = waitForError('nextjs-16', async errorEvent => { - return errorEvent.exception?.values?.[0]?.value?.includes('Tool call failed'); + return !!errorEvent.exception?.values?.[0]?.value?.includes('Tool call failed'); }); await page.goto('/ai-error-test'); - const aiTransaction = await aiTransactionPromise; + const aiRootSpan = await aiRootSpanPromise; const errorEvent = await errorEventPromise; - expect(aiTransaction).toBeDefined(); - expect(aiTransaction.transaction).toBe('GET /ai-error-test'); + expect(aiRootSpan).toBeDefined(); + expect(aiRootSpan.name).toBe('GET /ai-error-test'); - const spans = aiTransaction.spans || []; + const childSpans = aiRootSpan.childSpans; // Each generateText call should create 2 spans: one for the pipeline and one for doGenerate // Plus a span for the tool call // TODO: For now, this is sadly not fully working - the monkey patching of the ai package is not working // because of this, only spans that are manually opted-in at call time will be captured // this may be fixed by https://github.com/vercel/ai/pull/6716 in the future - const aiPipelineSpans = spans.filter(span => span.op === 'gen_ai.invoke_agent'); - const aiGenerateSpans = spans.filter(span => span.op === 'gen_ai.generate_content'); - const toolCallSpans = spans.filter(span => span.op === 'gen_ai.execute_tool'); + const aiPipelineSpans = childSpans.filter(span => span.op === 'gen_ai.invoke_agent'); + const aiGenerateSpans = childSpans.filter(span => span.op === 'gen_ai.generate_content'); + const toolCallSpans = childSpans.filter(span => span.op === 'gen_ai.execute_tool'); expect(aiPipelineSpans.length).toBeGreaterThanOrEqual(1); expect(aiGenerateSpans.length).toBeGreaterThanOrEqual(1); @@ -35,6 +38,6 @@ test('should create AI spans with correct attributes and error linking', async ( expect(errorEvent).toBeDefined(); - //Verify error is linked to the same trace as the transaction - expect(errorEvent?.contexts?.trace?.trace_id).toBe(aiTransaction.contexts?.trace?.trace_id); + // Verify error is linked to the same trace as the root span + expect(errorEvent?.contexts?.trace?.trace_id).toBe(aiRootSpan.traceId); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-test.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-test.test.ts index dcd129020035..c15dcae66b1a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-test.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-test.test.ts @@ -1,19 +1,22 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForRootSpan } from '@sentry-internal/test-utils'; + +const isSpanStreaming = process.env.NEXT_PUBLIC_E2E_NEXTJS_SPAN_STREAMING === '1'; test('should create AI spans with correct attributes', async ({ page }) => { - const aiTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent.transaction === 'GET /ai-test'; + test.skip(isSpanStreaming, 'AI route segment span does not flush reliably in streaming mode'); + const aiRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === 'GET /ai-test'; }); await page.goto('/ai-test'); - const aiTransaction = await aiTransactionPromise; + const aiRootSpan = await aiRootSpanPromise; - expect(aiTransaction).toBeDefined(); - expect(aiTransaction.transaction).toBe('GET /ai-test'); + expect(aiRootSpan).toBeDefined(); + expect(aiRootSpan.name).toBe('GET /ai-test'); - const spans = aiTransaction.spans || []; + const childSpans = aiRootSpan.childSpans; // We expect spans for the first 3 AI calls (4th is disabled) // Each generateText call should create 2 spans: one for the pipeline and one for doGenerate @@ -21,44 +24,22 @@ test('should create AI spans with correct attributes', async ({ page }) => { // TODO: For now, this is sadly not fully working - the monkey patching of the ai package is not working // because of this, only spans that are manually opted-in at call time will be captured // this may be fixed by https://github.com/vercel/ai/pull/6716 in the future - const aiPipelineSpans = spans.filter(span => span.op === 'gen_ai.invoke_agent'); - const aiGenerateSpans = spans.filter(span => span.op === 'gen_ai.generate_content'); - const toolCallSpans = spans.filter(span => span.op === 'gen_ai.execute_tool'); + const aiPipelineSpans = childSpans.filter(span => span.op === 'gen_ai.invoke_agent'); + const aiGenerateSpans = childSpans.filter(span => span.op === 'gen_ai.generate_content'); + const toolCallSpans = childSpans.filter(span => span.op === 'gen_ai.execute_tool'); expect(aiPipelineSpans.length).toBeGreaterThanOrEqual(1); expect(aiGenerateSpans.length).toBeGreaterThanOrEqual(1); expect(toolCallSpans.length).toBeGreaterThanOrEqual(0); - // First AI call - should have telemetry enabled and record inputs/outputs (sendDefaultPii: true) - /* const firstPipelineSpan = aiPipelineSpans[0]; - expect(firstPipelineSpan?.data?.['vercel.ai.model.id']).toBe('mock-model-id'); - expect(firstPipelineSpan?.data?.['vercel.ai.model.provider']).toBe('mock-provider'); - expect(firstPipelineSpan?.data?.['vercel.ai.prompt']).toContain('Where is the first span?'); - expect(firstPipelineSpan?.data?.['gen_ai.output.messages']).toContain('First span here!'); - expect(firstPipelineSpan?.data?.['gen_ai.usage.input_tokens']).toBe(10); - expect(firstPipelineSpan?.data?.['gen_ai.usage.output_tokens']).toBe(20); */ - // Second AI call - explicitly enabled telemetry const secondPipelineSpan = aiPipelineSpans[0]; - expect(secondPipelineSpan?.data?.['vercel.ai.prompt']).toContain('Where is the second span?'); - expect(secondPipelineSpan?.data?.['gen_ai.output.messages']).toContain('Second span here!'); - - // Third AI call - with tool calls - /* const thirdPipelineSpan = aiPipelineSpans[2]; - expect(thirdPipelineSpan?.data?.['vercel.ai.response.finishReason']).toBe('tool-calls'); - expect(thirdPipelineSpan?.data?.['gen_ai.usage.input_tokens']).toBe(15); - expect(thirdPipelineSpan?.data?.['gen_ai.usage.output_tokens']).toBe(25); */ - - // Tool call span - /* const toolSpan = toolCallSpans[0]; - expect(toolSpan?.data?.['vercel.ai.toolCall.name']).toBe('getWeather'); - expect(toolSpan?.data?.['vercel.ai.toolCall.id']).toBe('call-1'); - expect(toolSpan?.data?.['vercel.ai.toolCall.args']).toContain('San Francisco'); - expect(toolSpan?.data?.['vercel.ai.toolCall.result']).toContain('Sunny, 72°F'); */ + expect(secondPipelineSpan?.attributes['vercel.ai.prompt']).toContain('Where is the second span?'); + expect(secondPipelineSpan?.attributes['gen_ai.output.messages']).toContain('Second span here!'); // Verify the fourth call was not captured (telemetry disabled) - const promptsInSpans = spans - .map(span => span.data?.['vercel.ai.prompt']) + const promptsInSpans = childSpans + .map(span => span.attributes['vercel.ai.prompt'] as string | undefined) .filter((prompt): prompt is string => prompt !== undefined); const hasDisabledPrompt = promptsInSpans.some(prompt => prompt.includes('Where is the third span?')); expect(hasDisabledPrompt).toBe(false); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts index 541cff9c064c..7053a2744678 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForRootSpan } from '@sentry-internal/test-utils'; test('should remove sentry-trace and baggage meta tags on ISR dynamic route page load', async ({ page }) => { // Navigate to ISR page @@ -46,10 +46,8 @@ test('should create unique transactions for ISR pages on each visit', async ({ p // Load the same ISR page 5 times to ensure cached HTML meta tags are consistently removed for (let i = 0; i < 5; i++) { - const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return !!( - transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload' - ); + const rootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === '/isr-test/:product' && rootSpan.op === 'pageload'; }); if (i === 0) { @@ -58,8 +56,8 @@ test('should create unique transactions for ISR pages on each visit', async ({ p await page.reload(); } - const transaction = await transactionPromise; - const traceId = transaction.contexts?.trace?.trace_id; + const rootSpan = await rootSpanPromise; + const traceId = rootSpan.traceId; expect(traceId).toBeDefined(); expect(traceId).toMatch(/[a-f0-9]{32}/); @@ -72,23 +70,13 @@ test('should create unique transactions for ISR pages on each visit', async ({ p }); test('ISR route should be identified correctly in the route manifest', async ({ page }) => { - const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload'; + const rootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === '/isr-test/:product' && rootSpan.op === 'pageload'; }); await page.goto('/isr-test/laptop'); - const transaction = await transactionPromise; - - // Verify the transaction is properly parameterized - expect(transaction).toMatchObject({ - transaction: '/isr-test/:product', - transaction_info: { source: 'route' }, - contexts: { - trace: { - data: { - 'sentry.source': 'route', - }, - }, - }, - }); + const rootSpan = await rootSpanPromise; + + expect(rootSpan.name).toBe('/isr-test/:product'); + expect(rootSpan.attributes['sentry.source']).toBe('route'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts index 0bb957bbc466..103b8bc7ee3f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts @@ -1,31 +1,35 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForError, waitForRootSpan, waitForTransaction } from '@sentry-internal/test-utils'; import { isDevMode } from './isDevMode'; +const isSpanStreaming = process.env.NEXT_PUBLIC_E2E_NEXTJS_SPAN_STREAMING === '1'; + test('Should create a transaction for middleware', async ({ request }) => { - const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === 'middleware GET'; + const middlewareRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === 'middleware GET'; }); const response = await request.get('/api/endpoint-behind-middleware'); expect(await response.json()).toStrictEqual({ name: 'John Doe' }); - const middlewareTransaction = await middlewareTransactionPromise; + const middlewareRootSpan = await middlewareRootSpanPromise; - expect(middlewareTransaction.contexts?.trace?.status).toBe('ok'); - expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); - expect(middlewareTransaction.contexts?.runtime?.name).toBe('node'); - expect(middlewareTransaction.transaction_info?.source).toBe('route'); + expect(middlewareRootSpan.status).toBe('ok'); + expect(middlewareRootSpan.op).toBe('http.server.middleware'); - // Assert that isolation scope works properly - expect(middlewareTransaction.tags?.['my-isolated-tag']).toBe(true); - expect(middlewareTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + if (!isSpanStreaming) { + const raw = middlewareRootSpan.raw as Record; + expect((raw as { contexts?: { runtime?: { name?: string } } }).contexts?.runtime?.name).toBe('node'); + expect((raw as { transaction_info?: { source?: string } }).transaction_info?.source).toBe('route'); + expect((raw as { tags?: Record }).tags?.['my-isolated-tag']).toBe(true); + expect((raw as { tags?: Record }).tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + } }); test('Faulty middlewares', async ({ request }) => { test.skip(isDevMode, 'Throwing crashes the dev server atm'); // https://github.com/vercel/next.js/issues/85261 - const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === 'middleware GET'; + const middlewareRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === 'middleware GET'; }); const errorEventPromise = waitForError('nextjs-16', errorEvent => { @@ -37,29 +41,20 @@ test('Faulty middlewares', async ({ request }) => { }); await test.step('should record transactions', async () => { - const middlewareTransaction = await middlewareTransactionPromise; - expect(middlewareTransaction.contexts?.trace?.status).toBe('internal_error'); - expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); - expect(middlewareTransaction.contexts?.runtime?.name).toBe('node'); - expect(middlewareTransaction.transaction_info?.source).toBe('route'); + const middlewareRootSpan = await middlewareRootSpanPromise; + expect(middlewareRootSpan.status).toMatch(/^(internal_error|error)$/); + expect(middlewareRootSpan.op).toBe('http.server.middleware'); + + if (!isSpanStreaming) { + const raw = middlewareRootSpan.raw as Record; + expect((raw as { contexts?: { runtime?: { name?: string } } }).contexts?.runtime?.name).toBe('node'); + expect((raw as { transaction_info?: { source?: string } }).transaction_info?.source).toBe('route'); + } }); - - // TODO: proxy errors currently not reported via onRequestError - // await test.step('should record exceptions', async () => { - // const errorEvent = await errorEventPromise; - - // // Assert that isolation scope works properly - // expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); - // expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); - // expect([ - // 'middleware GET', // non-otel webpack versions - // '/middleware', // middleware file - // '/proxy', // proxy file - // ]).toContain(errorEvent.transaction); - // }); }); test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => { + test.skip(isSpanStreaming, 'Breadcrumb assertions require transaction envelope format'); test.skip(isDevMode, 'The fetch requests ends up in a separate tx in dev atm'); const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { return transactionEvent?.transaction === 'middleware GET'; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/nested-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/nested-rsc-error.test.ts index 68731f049f2c..20f403072f84 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/nested-rsc-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/nested-rsc-error.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForError, waitForRootSpan } from '@sentry-internal/test-utils'; test('Should capture errors from nested server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({ page, @@ -8,16 +8,16 @@ test('Should capture errors from nested server components when `Sentry.captureRe return !!errorEvent?.exception?.values?.some(value => value.value === 'I am technically uncatchable'); }); - const serverTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === 'GET /nested-rsc-error/[param]'; + const serverRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === 'GET /nested-rsc-error/[param]'; }); await page.goto(`/nested-rsc-error/123`); const errorEvent = await errorEventPromise; - const serverTransactionEvent = await serverTransactionPromise; + const serverRootSpan = await serverRootSpanPromise; // error event is part of the transaction - expect(errorEvent.contexts?.trace?.trace_id).toBe(serverTransactionEvent.contexts?.trace?.trace_id); + expect(errorEvent.contexts?.trace?.trace_id).toBe(serverRootSpan.traceId); expect(errorEvent.request).toMatchObject({ headers: expect.any(Object), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/pageload-tracing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/pageload-tracing.test.ts index 5360f450c5fa..2a1926fdbb9a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/pageload-tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/pageload-tracing.test.ts @@ -1,31 +1,26 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForRootSpan } from '@sentry-internal/test-utils'; test('App router transactions should be attached to the pageload request span', async ({ page }) => { - const serverTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === 'GET /pageload-tracing'; + const serverRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === 'GET /pageload-tracing'; }); - const pageloadTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === '/pageload-tracing'; + const pageloadRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === '/pageload-tracing'; }); await page.goto(`/pageload-tracing`); - const [serverTransaction, pageloadTransaction] = await Promise.all([ - serverTransactionPromise, - pageloadTransactionPromise, - ]); + const [serverRootSpan, pageloadRootSpan] = await Promise.all([serverRootSpanPromise, pageloadRootSpanPromise]); - const pageloadTraceId = pageloadTransaction.contexts?.trace?.trace_id; - - expect(pageloadTraceId).toBeTruthy(); - expect(serverTransaction.contexts?.trace?.trace_id).toBe(pageloadTraceId); + expect(pageloadRootSpan.traceId).toBeTruthy(); + expect(serverRootSpan.traceId).toBe(pageloadRootSpan.traceId); }); test('extracts HTTP request headers as span attributes', async ({ baseURL }) => { - const serverTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === 'GET /pageload-tracing'; + const serverRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === 'GET /pageload-tracing'; }); await fetch(`${baseURL}/pageload-tracing`, { @@ -39,9 +34,9 @@ test('extracts HTTP request headers as span attributes', async ({ baseURL }) => }, }); - const serverTransaction = await serverTransactionPromise; + const serverRootSpan = await serverRootSpanPromise; - expect(serverTransaction.contexts?.trace?.data).toEqual( + expect(serverRootSpan.attributes).toEqual( expect.objectContaining({ 'http.request.header.user_agent': 'Custom-NextJS-Agent/15.0', 'http.request.header.content_type': 'text/html', diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts index 43a2aa6191de..2a2b67bcfb35 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts @@ -1,161 +1,88 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForRootSpan } from '@sentry-internal/test-utils'; test('should create a parameterized transaction when the `app` directory is used', async ({ page }) => { - const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return ( - transactionEvent.transaction === '/parameterized/:one' && transactionEvent.contexts?.trace?.op === 'pageload' - ); + const rootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === '/parameterized/:one' && rootSpan.op === 'pageload'; }); await page.goto(`/parameterized/cappuccino`); - const transaction = await transactionPromise; - - expect(transaction).toMatchObject({ - contexts: { - react: { version: expect.any(String) }, - trace: { - data: { - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.source': 'route', - }, - op: 'pageload', - origin: 'auto.pageload.nextjs.app_router_instrumentation', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - }, - environment: 'qa', - request: { - headers: expect.any(Object), - url: expect.stringMatching(/\/parameterized\/cappuccino$/), - }, - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: '/parameterized/:one', - transaction_info: { source: 'route' }, - type: 'transaction', - }); + const rootSpan = await rootSpanPromise; + + expect(rootSpan.name).toBe('/parameterized/:one'); + expect(rootSpan.op).toBe('pageload'); + expect(rootSpan.traceId).toMatch(/[a-f0-9]{32}/); + expect(rootSpan.attributes).toEqual( + expect.objectContaining({ + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }), + ); }); test('should create a static transaction when the `app` directory is used and the route is not parameterized', async ({ page, }) => { - const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return ( - transactionEvent.transaction === '/parameterized/static' && transactionEvent.contexts?.trace?.op === 'pageload' - ); + const rootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === '/parameterized/static' && rootSpan.op === 'pageload'; }); await page.goto(`/parameterized/static`); - const transaction = await transactionPromise; - - expect(transaction).toMatchObject({ - contexts: { - react: { version: expect.any(String) }, - trace: { - data: { - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.source': 'url', - }, - op: 'pageload', - origin: 'auto.pageload.nextjs.app_router_instrumentation', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - }, - environment: 'qa', - request: { - headers: expect.any(Object), - url: expect.stringMatching(/\/parameterized\/static$/), - }, - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: '/parameterized/static', - transaction_info: { source: 'url' }, - type: 'transaction', - }); + const rootSpan = await rootSpanPromise; + + expect(rootSpan.name).toBe('/parameterized/static'); + expect(rootSpan.op).toBe('pageload'); + expect(rootSpan.traceId).toMatch(/[a-f0-9]{32}/); + expect(rootSpan.attributes).toEqual( + expect.objectContaining({ + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'url', + }), + ); }); test('should create a partially parameterized transaction when the `app` directory is used', async ({ page }) => { - const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return ( - transactionEvent.transaction === '/parameterized/:one/beep' && transactionEvent.contexts?.trace?.op === 'pageload' - ); + const rootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === '/parameterized/:one/beep' && rootSpan.op === 'pageload'; }); await page.goto(`/parameterized/cappuccino/beep`); - const transaction = await transactionPromise; - - expect(transaction).toMatchObject({ - contexts: { - react: { version: expect.any(String) }, - trace: { - data: { - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.source': 'route', - }, - op: 'pageload', - origin: 'auto.pageload.nextjs.app_router_instrumentation', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - }, - environment: 'qa', - request: { - headers: expect.any(Object), - url: expect.stringMatching(/\/parameterized\/cappuccino\/beep$/), - }, - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: '/parameterized/:one/beep', - transaction_info: { source: 'route' }, - type: 'transaction', - }); + const rootSpan = await rootSpanPromise; + + expect(rootSpan.name).toBe('/parameterized/:one/beep'); + expect(rootSpan.op).toBe('pageload'); + expect(rootSpan.traceId).toMatch(/[a-f0-9]{32}/); + expect(rootSpan.attributes).toEqual( + expect.objectContaining({ + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }), + ); }); test('should create a nested parameterized transaction when the `app` directory is used.', async ({ page }) => { - const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return ( - transactionEvent.transaction === '/parameterized/:one/beep/:two' && - transactionEvent.contexts?.trace?.op === 'pageload' - ); + const rootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === '/parameterized/:one/beep/:two' && rootSpan.op === 'pageload'; }); await page.goto(`/parameterized/cappuccino/beep/espresso`); - const transaction = await transactionPromise; - - expect(transaction).toMatchObject({ - contexts: { - react: { version: expect.any(String) }, - trace: { - data: { - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.source': 'route', - }, - op: 'pageload', - origin: 'auto.pageload.nextjs.app_router_instrumentation', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - }, - environment: 'qa', - request: { - headers: expect.any(Object), - url: expect.stringMatching(/\/parameterized\/cappuccino\/beep\/espresso$/), - }, - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: '/parameterized/:one/beep/:two', - transaction_info: { source: 'route' }, - type: 'transaction', - }); + const rootSpan = await rootSpanPromise; + + expect(rootSpan.name).toBe('/parameterized/:one/beep/:two'); + expect(rootSpan.op).toBe('pageload'); + expect(rootSpan.traceId).toMatch(/[a-f0-9]{32}/); + expect(rootSpan.attributes).toEqual( + expect.objectContaining({ + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }), + ); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/prefetch-spans.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/prefetch-spans.test.ts index 0b158103d1c0..70c845058363 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/prefetch-spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/prefetch-spans.test.ts @@ -1,12 +1,12 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForRootSpan } from '@sentry-internal/test-utils'; import { isDevMode } from './isDevMode'; test('Prefetch client spans should have a http.request.prefetch attribute', async ({ page }) => { test.skip(isDevMode, "Prefetch requests don't have the prefetch header in dev mode"); - const pageloadTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === '/prefetching'; + const pageloadRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === '/prefetching'; }); await page.goto(`/prefetching`); @@ -14,10 +14,12 @@ test('Prefetch client spans should have a http.request.prefetch attribute', asyn // Make it more likely that nextjs prefetches await page.hover('#prefetch-link'); - expect((await pageloadTransactionPromise).spans).toContainEqual( + const rootSpan = await pageloadRootSpanPromise; + + expect(rootSpan.childSpans).toContainEqual( expect.objectContaining({ op: 'http.client', - data: expect.objectContaining({ + attributes: expect.objectContaining({ 'http.request.prefetch': true, }), }), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/route-handler.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/route-handler.test.ts index e37c39eb4dba..ff3ccda39878 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/route-handler.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/route-handler.test.ts @@ -1,22 +1,22 @@ import test, { expect } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForError, waitForRootSpan } from '@sentry-internal/test-utils'; test('Should create a transaction for node route handlers', async ({ request }) => { - const routehandlerTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === 'GET /route-handler/[xoxo]/node'; + const rootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === 'GET /route-handler/[xoxo]/node'; }); const response = await request.get('/route-handler/123/node', { headers: { 'x-charly': 'gomez' } }); expect(await response.json()).toStrictEqual({ message: 'Hello Node Route Handler' }); - const routehandlerTransaction = await routehandlerTransactionPromise; + const rootSpan = await rootSpanPromise; - expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok'); - expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); + expect(rootSpan.status).toBe('ok'); + expect(rootSpan.op).toBe('http.server'); // This is flaking on dev mode if (process.env.TEST_ENV !== 'development' && process.env.TEST_ENV !== 'dev-turbopack') { - expect(routehandlerTransaction.contexts?.trace?.data?.['http.request.header.x_charly']).toBe('gomez'); + expect(rootSpan.attributes['http.request.header.x_charly']).toBe('gomez'); } }); @@ -45,20 +45,17 @@ test('Should report an error with a parameterized transaction name for a throwin return errorEvent?.exception?.values?.some(value => value.value === 'route-handler-error') ?? false; }); - const transactionEventPromise = waitForTransaction('nextjs-16', transactionEvent => { - return ( - transactionEvent?.transaction === 'GET /route-handler/[xoxo]/error' && - transactionEvent?.contexts?.trace?.op === 'http.server' - ); + const rootSpanPromise = waitForRootSpan('nextjs-16', rootSpan => { + return rootSpan.name === 'GET /route-handler/[xoxo]/error' && rootSpan.op === 'http.server'; }); request.get('/route-handler/456/error').catch(() => {}); const errorEvent = await errorEventPromise; - const transactionEvent = await transactionEventPromise; + const rootSpan = await rootSpanPromise; // Error event should be part of the same trace as the transaction - expect(errorEvent.contexts?.trace?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id); + expect(errorEvent.contexts?.trace?.trace_id).toBe(rootSpan.traceId); // Error should carry the parameterized transaction name expect(errorEvent.transaction).toBe('GET /route-handler/[xoxo]/error'); @@ -80,9 +77,10 @@ test('Should report an error with a parameterized transaction name for a throwin }); } - // Transaction should have parameterized name and internal_error status - expect(transactionEvent.transaction).toBe('GET /route-handler/[xoxo]/error'); - expect(transactionEvent.contexts?.trace?.status).toBe('internal_error'); + // Root span should have parameterized name and error status + // Streamed spans use 'error', transactions use 'internal_error' + expect(rootSpan.name).toBe('GET /route-handler/[xoxo]/error'); + expect(rootSpan.status).toMatch(/^(internal_error|error)$/); }); test('Should set a parameterized transaction name on a captureMessage event in a route handler', async ({ @@ -92,28 +90,25 @@ test('Should set a parameterized transaction name on a captureMessage event in a return event?.message === 'route-handler-message'; }); - const transactionEventPromise = waitForTransaction('nextjs-16', transactionEvent => { - return ( - transactionEvent?.transaction === 'GET /route-handler/[xoxo]/capture-message' && - transactionEvent?.contexts?.trace?.op === 'http.server' - ); + const rootSpanPromise = waitForRootSpan('nextjs-16', rootSpan => { + return rootSpan.name === 'GET /route-handler/[xoxo]/capture-message' && rootSpan.op === 'http.server'; }); const response = await request.get('/route-handler/789/capture-message'); expect(await response.json()).toStrictEqual({ message: 'Message captured' }); const messageEvent = await messageEventPromise; - const transactionEvent = await transactionEventPromise; + const rootSpan = await rootSpanPromise; - // Message event should be part of the same trace as the transaction - expect(messageEvent.contexts?.trace?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id); + // Message event should be part of the same trace as the root span + expect(messageEvent.contexts?.trace?.trace_id).toBe(rootSpan.traceId); // Message should carry the parameterized transaction name expect(messageEvent.transaction).toBe('GET /route-handler/[xoxo]/capture-message'); - // Transaction should have parameterized name and ok status - expect(transactionEvent.transaction).toBe('GET /route-handler/[xoxo]/capture-message'); - expect(transactionEvent.contexts?.trace?.status).toBe('ok'); + // Root span should have parameterized name and ok status + expect(rootSpan.name).toBe('GET /route-handler/[xoxo]/capture-message'); + expect(rootSpan.status).toBe('ok'); }); test('Should set a parameterized transaction name on a captureException event in a route handler', async ({ @@ -123,26 +118,23 @@ test('Should set a parameterized transaction name on a captureException event in return errorEvent?.exception?.values?.some(value => value.value === 'route-handler-capture-exception') ?? false; }); - const transactionEventPromise = waitForTransaction('nextjs-16', transactionEvent => { - return ( - transactionEvent?.transaction === 'GET /route-handler/[xoxo]/capture-exception' && - transactionEvent?.contexts?.trace?.op === 'http.server' - ); + const rootSpanPromise = waitForRootSpan('nextjs-16', rootSpan => { + return rootSpan.name === 'GET /route-handler/[xoxo]/capture-exception' && rootSpan.op === 'http.server'; }); const response = await request.get('/route-handler/321/capture-exception'); expect(await response.json()).toStrictEqual({ message: 'Exception captured' }); const errorEvent = await errorEventPromise; - const transactionEvent = await transactionEventPromise; + const rootSpan = await rootSpanPromise; - // Error event should be part of the same trace as the transaction - expect(errorEvent.contexts?.trace?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id); + // Error event should be part of the same trace as the root span + expect(errorEvent.contexts?.trace?.trace_id).toBe(rootSpan.traceId); // Manually captured exception should carry the parameterized transaction name expect(errorEvent.transaction).toBe('GET /route-handler/[xoxo]/capture-exception'); - // Transaction should have parameterized name and ok status (error was caught, not thrown) - expect(transactionEvent.transaction).toBe('GET /route-handler/[xoxo]/capture-exception'); - expect(transactionEvent.contexts?.trace?.status).toBe('ok'); + // Root span should have parameterized name and ok status (error was caught, not thrown) + expect(rootSpan.name).toBe('GET /route-handler/[xoxo]/capture-exception'); + expect(rootSpan.status).toBe('ok'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts index 88e2d3ba1af1..789813792679 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts @@ -1,21 +1,21 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForError, waitForRootSpan } from '@sentry-internal/test-utils'; test('Should handle server action redirect without capturing errors', async ({ page }) => { - // Wait for the initial page load transaction - const pageLoadTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === '/redirect/origin'; + // Wait for the initial page load root span (client-side) + const pageLoadRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === '/redirect/origin'; }); // Navigate to the origin page await page.goto('/redirect/origin'); - const pageLoadTransaction = await pageLoadTransactionPromise; - expect(pageLoadTransaction).toBeDefined(); + const pageLoadRootSpan = await pageLoadRootSpanPromise; + expect(pageLoadRootSpan).toBeDefined(); - // Wait for the redirect transaction - const redirectTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === 'GET /redirect/destination'; + // Wait for the redirect root span (server-side) + const redirectRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === 'GET /redirect/destination'; }); // No error should be captured @@ -26,7 +26,7 @@ test('Should handle server action redirect without capturing errors', async ({ p // Click the redirect button await page.click('button[type="submit"]'); - await redirectTransactionPromise; + await redirectRootSpanPromise; // Verify we got redirected to the destination page await expect(page).toHaveURL('/redirect/destination'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts index 81aa5c98288c..b9f08697435b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts @@ -1,98 +1,74 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForRootSpan } from '@sentry-internal/test-utils'; test('Sends a transaction for a request to app router with URL', async ({ page }) => { - const serverComponentTransactionPromise = waitForTransaction('nextjs-16', transactionEvent => { - return ( - transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' && - transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42') - ); + const serverRootSpanPromise = waitForRootSpan('nextjs-16', rootSpan => { + return rootSpan.name === 'GET /parameterized/[one]/beep/[two]'; }); await page.goto('/parameterized/1337/beep/42'); - const transactionEvent = await serverComponentTransactionPromise; + const rootSpan = await serverRootSpanPromise; - expect(transactionEvent.contexts?.trace).toEqual({ - data: expect.objectContaining({ + expect(rootSpan.op).toBe('http.server'); + expect(rootSpan.status).toBe('ok'); + expect(rootSpan.attributes).toEqual( + expect.objectContaining({ 'sentry.op': 'http.server', 'sentry.origin': 'auto', - 'sentry.sample_rate': 1, 'sentry.source': 'route', 'http.method': 'GET', - 'http.response.status_code': 200, 'http.route': '/parameterized/[one]/beep/[two]', - 'http.status_code': 200, - 'http.target': '/parameterized/1337/beep/42', - 'otel.kind': 'SERVER', 'next.route': '/parameterized/[one]/beep/[two]', }), - op: 'http.server', - origin: 'auto', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - status: 'ok', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }); - - expect(transactionEvent.request).toMatchObject({ - url: expect.stringContaining('/parameterized/1337/beep/42'), - }); + ); - // The transaction should not contain any spans with the same name as the transaction - // e.g. "GET /parameterized/[one]/beep/[two]" - expect( - transactionEvent.spans?.filter(span => { - return span.description === transactionEvent.transaction; - }), - ).toHaveLength(0); + // The root span should not contain any child spans with the same name as the root span + expect(rootSpan.childSpans.filter(span => span.name === rootSpan.name)).toHaveLength(0); }); test('Will create a transaction with spans for every server component and metadata generation functions when visiting a page', async ({ page, }) => { - const serverTransactionEventPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === 'GET /nested-layout'; + const serverRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === 'GET /nested-layout'; }); await page.goto('/nested-layout'); - const spanDescriptions = (await serverTransactionEventPromise).spans?.map(span => { - return span.description; - }); + const spanNames = (await serverRootSpanPromise).childSpans.map(span => span.name); - expect(spanDescriptions).toContainEqual('render route (app) /nested-layout'); - expect(spanDescriptions).toContainEqual('build component tree'); - expect(spanDescriptions).toContainEqual('resolve root layout server component'); - expect(spanDescriptions).toContainEqual('resolve layout server component "(nested-layout)"'); - expect(spanDescriptions).toContainEqual('resolve layout server component "nested-layout"'); - expect(spanDescriptions).toContainEqual('resolve page server component "/nested-layout"'); - expect(spanDescriptions).toContainEqual('generateMetadata /(nested-layout)/nested-layout/page'); - expect(spanDescriptions).toContainEqual('start response'); - expect(spanDescriptions).toContainEqual('NextNodeServer.clientComponentLoading'); + 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 a transaction with spans for every server component and metadata generation functions when visiting a dynamic page', async ({ page, }) => { - const serverTransactionEventPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === 'GET /nested-layout/[dynamic]'; + const serverRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === 'GET /nested-layout/[dynamic]'; }); await page.goto('/nested-layout/123'); - const spanDescriptions = (await serverTransactionEventPromise).spans?.map(span => { - return span.description; - }); + const spanNames = (await serverRootSpanPromise).childSpans.map(span => span.name); - expect(spanDescriptions).toContainEqual('resolve page components'); - expect(spanDescriptions).toContainEqual('render route (app) /nested-layout/[dynamic]'); - expect(spanDescriptions).toContainEqual('build component tree'); - expect(spanDescriptions).toContainEqual('resolve root layout server component'); - expect(spanDescriptions).toContainEqual('resolve layout server component "(nested-layout)"'); - expect(spanDescriptions).toContainEqual('resolve layout server component "nested-layout"'); - expect(spanDescriptions).toContainEqual('resolve layout server component "[dynamic]"'); - expect(spanDescriptions).toContainEqual('resolve page server component "/nested-layout/[dynamic]"'); - expect(spanDescriptions).toContainEqual('generateMetadata /(nested-layout)/nested-layout/[dynamic]/page'); - expect(spanDescriptions).toContainEqual('start response'); - expect(spanDescriptions).toContainEqual('NextNodeServer.clientComponentLoading'); + 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/tests/streaming-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/streaming-rsc-error.test.ts index f22932a0c65f..fe409d984836 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/streaming-rsc-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/streaming-rsc-error.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForError, waitForRootSpan } from '@sentry-internal/test-utils'; test('Should capture errors for crashing streaming promises in server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({ page, @@ -8,16 +8,16 @@ test('Should capture errors for crashing streaming promises in server components return !!errorEvent?.exception?.values?.some(value => value.value === 'I am a data streaming error'); }); - const serverTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === 'GET /streaming-rsc-error/[param]'; + const serverRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === 'GET /streaming-rsc-error/[param]'; }); await page.goto(`/streaming-rsc-error/123`); const errorEvent = await errorEventPromise; - const serverTransactionEvent = await serverTransactionPromise; + const serverRootSpan = await serverRootSpanPromise; // error event is part of the transaction - expect(errorEvent.contexts?.trace?.trace_id).toBe(serverTransactionEvent.contexts?.trace?.trace_id); + expect(errorEvent.contexts?.trace?.trace_id).toBe(serverRootSpan.traceId); expect(errorEvent.request).toMatchObject({ headers: expect.any(Object), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/suspense-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/suspense-error.test.ts index f7a5fb83c3df..834e51916a99 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/suspense-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/suspense-error.test.ts @@ -1,9 +1,9 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForError, waitForRootSpan } from '@sentry-internal/test-utils'; test('should not capture serverside suspense errors', async ({ page }) => { - const pageServerComponentTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === 'GET /suspense-error'; + const pageServerComponentRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { + return rootSpan.name === 'GET /suspense-error'; }); let errorEvent; @@ -18,8 +18,8 @@ test('should not capture serverside suspense errors', async ({ page }) => { // Just to be a little bit more sure await page.waitForTimeout(5000); - const pageServerComponentTransaction = await pageServerComponentTransactionPromise; - expect(pageServerComponentTransaction).toBeDefined(); + const pageServerComponentRootSpan = await pageServerComponentRootSpanPromise; + expect(pageServerComponentRootSpan).toBeDefined(); expect(errorEvent).toBeUndefined(); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/vercel-queue.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/vercel-queue.test.ts index eb4635bc2f5a..13bf35df8321 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/vercel-queue.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/vercel-queue.test.ts @@ -1,5 +1,5 @@ import test, { expect } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForRootSpan } from '@sentry-internal/test-utils'; // The queue E2E test only runs in production mode. // In development mode the @vercel/queue SDK uses an in-memory dispatch that @@ -9,13 +9,13 @@ const isProduction = process.env.TEST_ENV === 'production'; test('Should create transactions for queue producer and consumer', async ({ request }) => { test.skip(!isProduction, 'Vercel Queue test only runs in production mode'); - // 1. Set up waiters for both the producer and consumer transactions. - const producerTransactionPromise = waitForTransaction('nextjs-16', transactionEvent => { - return transactionEvent?.transaction === 'POST /api/queue-send'; + // 1. Set up waiters for both the producer and consumer root spans. + const producerRootSpanPromise = waitForRootSpan('nextjs-16', rootSpan => { + return rootSpan.name === 'POST /api/queue-send'; }); - const consumerTransactionPromise = waitForTransaction('nextjs-16', transactionEvent => { - return transactionEvent?.transaction === 'POST /api/queues/process-order'; + const consumerRootSpanPromise = waitForRootSpan('nextjs-16', rootSpan => { + return rootSpan.name === 'POST /api/queues/process-order'; }); // 2. Hit the producer route to enqueue a message. @@ -28,24 +28,23 @@ test('Should create transactions for queue producer and consumer', async ({ requ expect(response.status()).toBe(200); expect(responseBody.messageId).toBeTruthy(); - // 3. Wait for the producer transaction. - const producerTransaction = await producerTransactionPromise; - expect(producerTransaction).toBeDefined(); - expect(producerTransaction.contexts?.trace?.op).toBe('http.server'); - expect(producerTransaction.contexts?.trace?.status).toBe('ok'); + // 3. Wait for the producer root span. + const producerRootSpan = await producerRootSpanPromise; + expect(producerRootSpan).toBeDefined(); + expect(producerRootSpan.op).toBe('http.server'); + expect(producerRootSpan.status).toBe('ok'); - // 4. Wait for the consumer transaction (the mock server pushes the message + // 4. Wait for the consumer root span (the mock server pushes the message // to the consumer route via CloudEvent POST). - const consumerTransaction = await consumerTransactionPromise; - expect(consumerTransaction).toBeDefined(); - expect(consumerTransaction.contexts?.trace?.op).toBe('http.server'); - expect(consumerTransaction.contexts?.trace?.status).toBe('ok'); + const consumerRootSpan = await consumerRootSpanPromise; + expect(consumerRootSpan).toBeDefined(); + expect(consumerRootSpan.op).toBe('http.server'); + expect(consumerRootSpan.status).toBe('ok'); // 5. Verify the consumer span has messaging.* attributes from queue instrumentation. - const consumerSpanData = consumerTransaction.contexts?.trace?.data; - expect(consumerSpanData?.['messaging.system']).toBe('vercel.queue'); - expect(consumerSpanData?.['messaging.operation.name']).toBe('process'); - expect(consumerSpanData?.['messaging.destination.name']).toBe('orders'); - expect(consumerSpanData?.['messaging.message.id']).toBeTruthy(); - expect(consumerSpanData?.['messaging.consumer.group.name']).toBeTruthy(); + expect(consumerRootSpan.attributes['messaging.system']).toBe('vercel.queue'); + expect(consumerRootSpan.attributes['messaging.operation.name']).toBe('process'); + expect(consumerRootSpan.attributes['messaging.destination.name']).toBe('orders'); + expect(consumerRootSpan.attributes['messaging.message.id']).toBeTruthy(); + expect(consumerRootSpan.attributes['messaging.consumer.group.name']).toBeTruthy(); }); diff --git a/dev-packages/test-utils/src/event-proxy-server.ts b/dev-packages/test-utils/src/event-proxy-server.ts index 77effa924ff4..7f65e5a66bd8 100644 --- a/dev-packages/test-utils/src/event-proxy-server.ts +++ b/dev-packages/test-utils/src/event-proxy-server.ts @@ -400,6 +400,121 @@ export function waitForTransaction( }); } +/** + * A normalized child span, usable regardless of `traceLifecycle`. + */ +export interface ChildSpan { + name: string; + op: string | undefined; + status: string | undefined; + attributes: Record; +} + +/** + * A shape-agnostic representation of a root span (a transaction in static mode, + * or a segment span in streaming mode). + */ +export interface RootSpan { + /** Transaction name / segment span name. */ + name: string; + /** The `sentry.op` value, if present. */ + op: string | undefined; + /** Span status (e.g. `ok`, `error`). */ + status: string | undefined; + /** The trace ID for linking assertions. */ + traceId: string | undefined; + /** Flat map of attributes (`contexts.trace.data` for transactions, flattened typed attributes for streamed spans). */ + attributes: Record; + /** Normalized child spans (`transaction.spans` in static mode, non-segment siblings in streaming mode). */ + childSpans: ChildSpan[]; + /** The raw envelope item body for advanced assertions. */ + raw: Event | SerializedStreamedSpan; +} + +/** + * Wait for a root span to be sent, regardless of `traceLifecycle`. + * Matches `transaction` envelope items in static mode and segment spans + * (`is_segment === true`) within `span` envelope items in streaming mode. + */ +export function waitForRootSpan( + proxyServerName: string, + callback: (rootSpan: RootSpan) => Promise | boolean, +): Promise { + const timestamp = getNanosecondTimestamp(); + return new Promise((resolve, reject) => { + waitForRequest( + proxyServerName, + async eventData => { + const envelopeItems = eventData.envelope[1]; + + for (const envelopeItem of envelopeItems) { + const [header, body] = envelopeItem; + + if (header.type === 'transaction') { + const event = body as Event; + const rootSpan: RootSpan = { + name: event.transaction || '', + op: event.contexts?.trace?.op, + status: event.contexts?.trace?.status, + traceId: event.contexts?.trace?.trace_id, + attributes: { ...(event.contexts?.trace?.data ?? {}) } as Record, + childSpans: (event.spans || []).map(span => ({ + name: span.description || '', + op: span.op, + status: span.status, + attributes: { ...(span.data ?? {}) } as Record, + })), + raw: event, + }; + if (await callback(rootSpan)) { + resolve(rootSpan); + return true; + } + continue; + } + + if (isStreamedSpanEnvelopeItem(envelopeItem)) { + const allItems = envelopeItem[1].items; + for (const span of allItems) { + if (!span.is_segment) { + continue; + } + const flatAttributes: Record = {}; + for (const [key, attr] of Object.entries(span.attributes ?? {})) { + flatAttributes[key] = attr?.value; + } + const childSpans: ChildSpan[] = allItems + .filter(s => s !== span) + .map(s => { + const attrs: Record = {}; + for (const [key, attr] of Object.entries(s.attributes ?? {})) { + attrs[key] = attr?.value; + } + return { name: s.name, op: getSpanOp(s), status: s.status, attributes: attrs }; + }); + const rootSpan: RootSpan = { + name: span.name, + op: getSpanOp(span), + status: span.status, + traceId: span.trace_id, + attributes: flatAttributes, + childSpans, + raw: span, + }; + if (await callback(rootSpan)) { + resolve(rootSpan); + return true; + } + } + } + } + return false; + }, + timestamp, + ).catch(reject); + }); +} + /** * Wait for metric items to be sent. */ diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index 54e5d11749b4..302ed375637d 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -11,8 +11,10 @@ export { waitForStreamedSpan, waitForStreamedSpans, waitForStreamedSpanEnvelope, + waitForRootSpan, getSpanOp, } from './event-proxy-server'; +export type { ChildSpan, RootSpan } from './event-proxy-server'; export { getPlaywrightConfig } from './playwright-config'; export { createBasicSentryServer, createTestServer } from './server'; From ec0eb6f7163abf4d02cd83a5f4428dee523b3aeb Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 5 May 2026 10:55:10 +0200 Subject: [PATCH 2/5] copy app --- .../nextjs-16-streaming/.gitignore | 44 +++++++ .../app/(nested-layout)/layout.tsx | 12 ++ .../nested-layout/[dynamic]/layout.tsx | 12 ++ .../nested-layout/[dynamic]/page.tsx | 15 +++ .../(nested-layout)/nested-layout/layout.tsx | 12 ++ .../(nested-layout)/nested-layout/page.tsx | 11 ++ .../app/ai-error-test/page.tsx | 50 ++++++++ .../nextjs-16-streaming/app/ai-test/page.tsx | 98 +++++++++++++++ .../app/api/cron-test-error/route.ts | 5 + .../app/api/cron-test/route.ts | 9 ++ .../api/endpoint-behind-middleware/route.ts | 3 + .../app/api/queue-send/route.ts | 14 +++ .../app/api/queues/process-order/route.ts | 10 ++ .../app/api/v3/topic/[...params]/route.ts | 112 ++++++++++++++++++ .../app/component-annotation/page.tsx | 18 +++ .../nextjs-16-streaming/app/favicon.ico | Bin 0 -> 25931 bytes .../nextjs-16-streaming/app/global-error.tsx | 23 ++++ .../app/isr-test/[product]/page.tsx | 17 +++ .../app/isr-test/static/page.tsx | 15 +++ .../nextjs-16-streaming/app/layout.tsx | 7 ++ .../nextjs-16-streaming/app/metrics/page.tsx | 34 ++++++ .../app/metrics/route-handler/route.ts | 23 ++++ .../app/nested-rsc-error/[param]/page.tsx | 17 +++ .../app/non-isr-test/[item]/page.tsx | 11 ++ .../nextjs-16-streaming/app/page.tsx | 3 + .../app/pageload-tracing/layout.tsx | 8 ++ .../app/pageload-tracing/page.tsx | 14 +++ .../parameterized/[one]/beep/[two]/page.tsx | 3 + .../app/parameterized/[one]/beep/page.tsx | 3 + .../app/parameterized/[one]/page.tsx | 3 + .../app/parameterized/static/page.tsx | 3 + .../app/prefetching/page.tsx | 9 ++ .../app/prefetching/to-be-prefetched/page.tsx | 5 + .../app/redirect/destination/page.tsx | 7 ++ .../app/redirect/origin/page.tsx | 18 +++ .../[xoxo]/capture-exception/route.ts | 9 ++ .../[xoxo]/capture-message/route.ts | 9 ++ .../app/route-handler/[xoxo]/edge/route.ts | 8 ++ .../app/route-handler/[xoxo]/error/route.ts | 5 + .../app/route-handler/[xoxo]/node/route.ts | 7 ++ .../[param]/client-page.tsx | 8 ++ .../app/streaming-rsc-error/[param]/page.tsx | 18 +++ .../app/suspense-error/page.tsx | 15 +++ .../app/third-party-filter/page.tsx | 24 ++++ .../nextjs-16-streaming/eslint.config.mjs | 19 +++ .../instrumentation-client.ts | 22 ++++ .../nextjs-16-streaming/instrumentation.ts | 13 ++ .../nextjs-16-streaming/lib/queue.ts | 12 ++ .../nextjs-16-streaming/next.config.ts | 18 +++ .../nextjs-16-streaming/package.json | 41 +++++++ .../nextjs-16-streaming/playwright.config.mjs | 29 +++++ .../nextjs-16-streaming/proxy.ts | 24 ++++ .../nextjs-16-streaming/public/file.svg | 1 + .../nextjs-16-streaming/public/globe.svg | 1 + .../nextjs-16-streaming/public/next.svg | 1 + .../nextjs-16-streaming/public/vercel.svg | 1 + .../nextjs-16-streaming/public/window.svg | 1 + .../nextjs-16-streaming/sentry.edge.config.ts | 11 ++ .../sentry.server.config.ts | 19 +++ .../nextjs-16-streaming/start-event-proxy.mjs | 14 +++ .../nextjs-16-streaming/tests/isDevMode.ts | 1 + .../tests/nested-rsc-error.test.ts | 37 ++++++ .../tests/pageload-tracing.test.ts | 19 +++ .../tests/parameterized-routes.test.ts | 60 ++++++++++ .../tests/route-handler.test.ts | 59 +++++++++ .../tests/server-components.test.ts | 63 ++++++++++ .../nextjs-16-streaming/tsconfig.json | 27 +++++ .../nextjs-16-streaming/vercel.json | 17 +++ 68 files changed, 1261 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/[dynamic]/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/[dynamic]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/ai-error-test/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/ai-test/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test-error/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/endpoint-behind-middleware/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queue-send/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queues/process-order/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/v3/topic/[...params]/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/component-annotation/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/favicon.ico create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/global-error.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/isr-test/[product]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/isr-test/static/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/metrics/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/metrics/route-handler/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/nested-rsc-error/[param]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/non-isr-test/[item]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/pageload-tracing/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/pageload-tracing/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/beep/[two]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/beep/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/static/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/prefetching/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/prefetching/to-be-prefetched/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/redirect/destination/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/redirect/origin/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/capture-exception/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/capture-message/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/edge/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/error/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/node/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/streaming-rsc-error/[param]/client-page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/streaming-rsc-error/[param]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/suspense-error/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/third-party-filter/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/eslint.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/instrumentation-client.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/instrumentation.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/lib/queue.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/next.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/proxy.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/file.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/globe.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/next.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/vercel.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/public/window.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/sentry.edge.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/sentry.server.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/isDevMode.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/nested-rsc-error.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/pageload-tracing.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/parameterized-routes.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/route-handler.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/server-components.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-streaming/vercel.json 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} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/[dynamic]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/[dynamic]/layout.tsx new file mode 100644 index 000000000000..dbdc60adadc2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/[dynamic]/layout.tsx @@ -0,0 +1,12 @@ +import { PropsWithChildren } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default function Layout({ children }: PropsWithChildren<{}>) { + return ( +
+

DynamicLayout

+ {children} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/[dynamic]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/[dynamic]/page.tsx new file mode 100644 index 000000000000..3eaddda2a1df --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/[dynamic]/page.tsx @@ -0,0 +1,15 @@ +export const dynamic = 'force-dynamic'; + +export default async function Page() { + return ( +
+

Dynamic Page

+
+ ); +} + +export async function generateMetadata() { + return { + title: 'I am dynamic page generated metadata', + }; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/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)/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} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/page.tsx new file mode 100644 index 000000000000..8077c14d23ca --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/(nested-layout)/nested-layout/page.tsx @@ -0,0 +1,11 @@ +export const dynamic = 'force-dynamic'; + +export default function Page() { + return

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 ( +
+

AI Test Results

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/ai-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/ai-test/page.tsx new file mode 100644 index 000000000000..d28a147eb88d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/ai-test/page.tsx @@ -0,0 +1,98 @@ +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; +import * as Sentry from '@sentry/nextjs'; + +export const dynamic = 'force-dynamic'; + +async function runAITest() { + // First span - telemetry should be enabled automatically but no input/output recorded when sendDefaultPii: true + const result1 = await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'First span here!', + }), + }), + prompt: 'Where is the first span?', + }); + + // Second span - explicitly enabled telemetry, should record inputs/outputs + const result2 = await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Second span here!', + }), + }), + prompt: 'Where is the second span?', + }); + + // Third span - with tool calls and tool results + const result3 = await generateText({ + 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 => { + return `Weather in ${args.location}: Sunny, 72°F`; + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); + + // Fourth span - explicitly disabled telemetry, should not be captured + const result4 = await generateText({ + experimental_telemetry: { isEnabled: false }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Third span here!', + }), + }), + prompt: 'Where is the third span?', + }); + + return { + result1: result1.text, + result2: result2.text, + result3: result3.text, + result4: result4.text, + }; +} + +export default async function Page() { + const results = await Sentry.startSpan({ op: 'function', name: 'ai-test' }, async () => { + return await runAITest(); + }); + + return ( +
+

AI Test Results

+
{JSON.stringify(results, null, 2)}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test-error/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test-error/route.ts new file mode 100644 index 000000000000..4826ffa16b15 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test-error/route.ts @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export async function GET() { + throw new Error('Cron job error'); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test/route.ts new file mode 100644 index 000000000000..e70938cbe491 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/cron-test/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + // Simulate some work + await new Promise(resolve => setTimeout(resolve, 100)); + return NextResponse.json({ message: 'Cron job executed successfully' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/endpoint-behind-middleware/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/endpoint-behind-middleware/route.ts new file mode 100644 index 000000000000..2733cc918f44 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/endpoint-behind-middleware/route.ts @@ -0,0 +1,3 @@ +export function GET() { + return Response.json({ name: 'John Doe' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queue-send/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queue-send/route.ts new file mode 100644 index 000000000000..fe49a990921b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queue-send/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { send } from '../../../lib/queue'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request) { + const body = await request.json(); + const topic = body.topic ?? 'orders'; + const payload = body.payload ?? body; + + const { messageId } = await send(topic, payload); + + return NextResponse.json({ messageId }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queues/process-order/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queues/process-order/route.ts new file mode 100644 index 000000000000..41cec36d5d8a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/queues/process-order/route.ts @@ -0,0 +1,10 @@ +import { handleCallback } from '../../../../lib/queue'; + +export const dynamic = 'force-dynamic'; + +// The @vercel/queue handleCallback return type (CallbackRequestInput) doesn't match +// Next.js's strict route handler type check with webpack builds, so we cast it. +export const POST = handleCallback(async (message, _metadata) => { + // Simulate some async work + await new Promise(resolve => setTimeout(resolve, 50)); +}) as unknown as (req: Request) => Promise; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/v3/topic/[...params]/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/v3/topic/[...params]/route.ts new file mode 100644 index 000000000000..51dfa2e656db --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/api/v3/topic/[...params]/route.ts @@ -0,0 +1,112 @@ +import { NextResponse } from 'next/server'; + +/** + * Mock Vercel Queues API server. + * + * This route handler simulates the Vercel Queues HTTP API so that the real + * @vercel/queue SDK can be used in E2E tests without Vercel infrastructure. + * + * Handled endpoints: + * POST /api/v3/topic/{topic} → SendMessage + * POST /api/v3/topic/{topic}/consumer/{consumer}/id/{messageId} → ReceiveMessageById + * DELETE /api/v3/topic/{topic}/consumer/{consumer}/lease/{handle} → AcknowledgeMessage + * PATCH /api/v3/topic/{topic}/consumer/{consumer}/lease/{handle} → ExtendLease + */ + +export const dynamic = 'force-dynamic'; + +let messageCounter = 0; + +function generateMessageId(): string { + return `msg_test_${++messageCounter}_${Date.now()}`; +} + +function generateReceiptHandle(): string { + return `rh_test_${Date.now()}_${Math.random().toString(36).slice(2)}`; +} + +// Encode a file path into a consumer-group name, matching the SDK's algorithm. +function filePathToConsumerGroup(filePath: string): string { + let result = ''; + for (const char of filePath) { + if (char === '_') result += '__'; + else if (char === '/') result += '_S'; + else if (char === '.') result += '_D'; + else if (/[A-Za-z0-9-]/.test(char)) result += char; + else result += '_' + char.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'); + } + return result; +} + +// Topic → consumer route path (mirrors vercel.json experimentalTriggers). +const TOPIC_ROUTES: Record = { + orders: '/api/queues/process-order', +}; + +// The file path key used in vercel.json for each consumer route. +const ROUTE_FILE_PATHS: Record = { + '/api/queues/process-order': 'app/api/queues/process-order/route.ts', +}; + +export async function POST(request: Request, { params }: { params: Promise<{ params: string[] }> }) { + const { params: segments } = await params; + + // POST /api/v3/topic/{topic} → SendMessage + if (segments.length === 1) { + const topic = segments[0]; + const body = await request.arrayBuffer(); + const messageId = generateMessageId(); + const receiptHandle = generateReceiptHandle(); + const now = new Date(); + const createdAt = now.toISOString(); + const expiresAt = new Date(now.getTime() + 86_400_000).toISOString(); + const visibilityDeadline = new Date(now.getTime() + 300_000).toISOString(); + + const consumerRoute = TOPIC_ROUTES[topic]; + if (consumerRoute) { + const filePath = ROUTE_FILE_PATHS[consumerRoute] ?? consumerRoute; + const consumerGroup = filePathToConsumerGroup(filePath); + const port = process.env.PORT || 3030; + + // Simulate Vercel infrastructure pushing the message to the consumer. + // Fire-and-forget so the SendMessage response returns immediately. + void fetch(`http://localhost:${port}${consumerRoute}`, { + method: 'POST', + headers: { + 'ce-type': 'com.vercel.queue.v2beta', + 'ce-vqsqueuename': topic, + 'ce-vqsconsumergroup': consumerGroup, + 'ce-vqsmessageid': messageId, + 'ce-vqsreceipthandle': receiptHandle, + 'ce-vqsdeliverycount': '1', + 'ce-vqscreatedat': createdAt, + 'ce-vqsexpiresat': expiresAt, + 'ce-vqsregion': 'test1', + 'ce-vqsvisibilitydeadline': visibilityDeadline, + 'content-type': request.headers.get('content-type') || 'application/json', + }, + body: Buffer.from(body), + }).catch(err => console.error('[mock-queue] Failed to push to consumer:', err)); + } + + return NextResponse.json({ messageId }, { status: 201, headers: { 'Vqs-Message-Id': messageId } }); + } + + // POST /api/v3/topic/{topic}/consumer/{consumer}/id/{messageId} → ReceiveMessageById + // Not used in binary-mode push flow, but handled for completeness. + if (segments.length >= 4 && segments[1] === 'consumer') { + return new Response(null, { status: 204 }); + } + + return NextResponse.json({ error: 'Unknown endpoint' }, { status: 404 }); +} + +// DELETE /api/v3/topic/{topic}/consumer/{consumer}/lease/{receiptHandle} → AcknowledgeMessage +export async function DELETE() { + return new Response(null, { status: 204 }); +} + +// PATCH /api/v3/topic/{topic}/consumer/{consumer}/lease/{receiptHandle} → ExtendLease +export async function PATCH() { + return NextResponse.json({ success: true }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/component-annotation/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/component-annotation/page.tsx new file mode 100644 index 000000000000..8ac6973dc5c8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/component-annotation/page.tsx @@ -0,0 +1,18 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; + +export default function ComponentAnnotationTestPage() { + return ( +
+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/favicon.ico b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/global-error.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/global-error.tsx new file mode 100644 index 000000000000..20c175015b03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/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/app/isr-test/[product]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/isr-test/[product]/page.tsx new file mode 100644 index 000000000000..cd1e085e2763 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/isr-test/[product]/page.tsx @@ -0,0 +1,17 @@ +export const revalidate = 60; // ISR: revalidate every 60 seconds +export const dynamicParams = true; // Allow dynamic params beyond generateStaticParams + +export async function generateStaticParams(): Promise> { + return [{ product: 'laptop' }, { product: 'phone' }, { product: 'tablet' }]; +} + +export default async function ISRProductPage({ params }: { params: Promise<{ product: string }> }) { + const { product } = await params; + + return ( +
+

ISR Product: {product}

+
{product}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/isr-test/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/isr-test/static/page.tsx new file mode 100644 index 000000000000..f49605bd9da4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/isr-test/static/page.tsx @@ -0,0 +1,15 @@ +export const revalidate = 60; // ISR: revalidate every 60 seconds +export const dynamicParams = true; + +export async function generateStaticParams(): Promise { + return []; +} + +export default function ISRStaticPage() { + return ( +
+

ISR Static Page

+
static-isr
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/layout.tsx new file mode 100644 index 000000000000..c8f9cee0b787 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/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/app/metrics/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/metrics/page.tsx new file mode 100644 index 000000000000..fdb7bc0a40a7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/metrics/page.tsx @@ -0,0 +1,34 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; + +export default function Page() { + const handleClick = async () => { + Sentry.metrics.count('test.page.count', 1, { + attributes: { + page: '/metrics', + 'random.attribute': 'Apples', + }, + }); + Sentry.metrics.distribution('test.page.distribution', 100, { + attributes: { + page: '/metrics', + 'random.attribute': 'Manzanas', + }, + }); + Sentry.metrics.gauge('test.page.gauge', 200, { + attributes: { + page: '/metrics', + 'random.attribute': 'Mele', + }, + }); + await fetch('/metrics/route-handler'); + }; + + return ( +
+

Metrics page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/metrics/route-handler/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/metrics/route-handler/route.ts new file mode 100644 index 000000000000..84e81960f9c9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/metrics/route-handler/route.ts @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/nextjs'; + +export const GET = async () => { + Sentry.metrics.count('test.route.handler.count', 1, { + attributes: { + endpoint: '/metrics/route-handler', + 'random.attribute': 'Potatoes', + }, + }); + Sentry.metrics.distribution('test.route.handler.distribution', 100, { + attributes: { + endpoint: '/metrics/route-handler', + 'random.attribute': 'Patatas', + }, + }); + Sentry.metrics.gauge('test.route.handler.gauge', 200, { + attributes: { + endpoint: '/metrics/route-handler', + 'random.attribute': 'Patate', + }, + }); + return Response.json({ message: 'Bueno' }); +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/nested-rsc-error/[param]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/nested-rsc-error/[param]/page.tsx new file mode 100644 index 000000000000..675b248026be --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/nested-rsc-error/[param]/page.tsx @@ -0,0 +1,17 @@ +import { Suspense } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default async function Page() { + return ( + Loading...

}> + {/* @ts-ignore */} + ; +
+ ); +} + +async function Crash() { + throw new Error('I am technically uncatchable'); + return

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 ( +
+

Non-ISR Dynamic Page: {item}

+
{item}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/page.tsx new file mode 100644 index 000000000000..2bc0a407a355 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + 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 }: PropsWithChildren) { + await new Promise(resolve => setTimeout(resolve, 500)); + return <>{children}; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/pageload-tracing/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/pageload-tracing/page.tsx new file mode 100644 index 000000000000..689735d61ddf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/pageload-tracing/page.tsx @@ -0,0 +1,14 @@ +export const dynamic = 'force-dynamic'; + +export default async function Page() { + await new Promise(resolve => setTimeout(resolve, 1000)); + return

I 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() { + return
Dynamic page two
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/beep/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/beep/page.tsx new file mode 100644 index 000000000000..a7d9164c8c03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/beep/page.tsx @@ -0,0 +1,3 @@ +export default function BeepPage() { + return
Beep
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/page.tsx new file mode 100644 index 000000000000..9fa617a22381 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/[one]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page one
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/static/page.tsx new file mode 100644 index 000000000000..16ef0482d53b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/parameterized/static/page.tsx @@ -0,0 +1,3 @@ +export default function StaticPage() { + return
Static page
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/prefetching/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/prefetching/page.tsx new file mode 100644 index 000000000000..4cb811ecf1b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/prefetching/page.tsx @@ -0,0 +1,9 @@ +import Link from 'next/link'; + +export default function Page() { + return ( + + link + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/prefetching/to-be-prefetched/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/prefetching/to-be-prefetched/page.tsx new file mode 100644 index 000000000000..83aac90d65cf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/prefetching/to-be-prefetched/page.tsx @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export default function Page() { + return

Hello

; +} 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 ( +
+

Redirect Destination

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/redirect/origin/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/redirect/origin/page.tsx new file mode 100644 index 000000000000..52615e0a054b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/redirect/origin/page.tsx @@ -0,0 +1,18 @@ +import { redirect } from 'next/navigation'; + +async function redirectAction() { + 'use server'; + + redirect('/redirect/destination'); +} + +export default function RedirectOriginPage() { + return ( + <> + {/* @ts-ignore */} +
+ +
+ + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/capture-exception/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/capture-exception/route.ts new file mode 100644 index 000000000000..2f8a8b84d9e6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/capture-exception/route.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/nextjs'; +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + Sentry.captureException(new Error('route-handler-capture-exception')); + return NextResponse.json({ message: 'Exception captured' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/capture-message/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/capture-message/route.ts new file mode 100644 index 000000000000..67015ec11b2f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/capture-message/route.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/nextjs'; +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + Sentry.captureMessage('route-handler-message'); + return NextResponse.json({ message: 'Message captured' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/edge/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/edge/route.ts new file mode 100644 index 000000000000..7cd1fc7e332c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/edge/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from 'next/server'; + +export const runtime = 'edge'; +export const dynamic = 'force-dynamic'; + +export async function GET() { + return NextResponse.json({ message: 'Hello Edge Route Handler' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/error/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/error/route.ts new file mode 100644 index 000000000000..064b9df86854 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/error/route.ts @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export async function GET() { + throw new Error('route-handler-error'); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/node/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/node/route.ts new file mode 100644 index 000000000000..5bc418f077aa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/route-handler/[xoxo]/node/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + return NextResponse.json({ message: 'Hello Node Route Handler' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/streaming-rsc-error/[param]/client-page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/streaming-rsc-error/[param]/client-page.tsx new file mode 100644 index 000000000000..7b66c3fbdeef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/streaming-rsc-error/[param]/client-page.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { use } from 'react'; + +export function RenderPromise({ stringPromise }: { stringPromise: Promise }) { + const s = use(stringPromise); + return <>{s}; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/streaming-rsc-error/[param]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/streaming-rsc-error/[param]/page.tsx new file mode 100644 index 000000000000..9531f9a42139 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/streaming-rsc-error/[param]/page.tsx @@ -0,0 +1,18 @@ +import { Suspense } from 'react'; +import { RenderPromise } from './client-page'; + +export const dynamic = 'force-dynamic'; + +export default async function Page() { + const crashingPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('I am a data streaming error')); + }, 100); + }); + + return ( + Loading...

}> + ; +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/suspense-error/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/suspense-error/page.tsx new file mode 100644 index 000000000000..ff49745d405b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/app/suspense-error/page.tsx @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/nextjs'; +import { use } from 'react'; +export const dynamic = 'force-dynamic'; + +export default async function Page() { + try { + use(fetch('https://example.com/')); + } catch (e) { + Sentry.captureException(e); // This error should not be reported + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for any async event processors to run + await Sentry.flush(); + } + + 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..eb0b5eae9373 --- /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.1.7", + "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" }] + } + } +} From 95f32529d28596eaa8ad834315d869fb25f5dd9f Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 5 May 2026 11:48:23 +0200 Subject: [PATCH 3/5] fix(e2e): Bump next to 16.2.4 in nextjs-16-streaming to fix CVE 16.1.7 has a high-severity DoS vulnerability in Server Components. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../test-applications/nextjs-16-streaming/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index eb0b5eae9373..8e254f4b4657 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/package.json @@ -19,7 +19,7 @@ "@vercel/queue": "^0.1.3", "ai": "^3.0.0", "import-in-the-middle": "^2", - "next": "16.1.7", + "next": "16.2.4", "react": "19.1.0", "react-dom": "19.1.0", "require-in-the-middle": "^8", From fc0d1969be90aa2d201d3fab4b73105a226fe57a Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 5 May 2026 13:20:26 +0200 Subject: [PATCH 4/5] fix(e2e): Revert nextjs-16 test app to original develop state The earlier draft commit accidentally modified the original nextjs-16 test files. This restores them to match develop exactly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../nextjs-16/instrumentation-client.ts | 3 - .../test-applications/nextjs-16/package.json | 11 +- .../nextjs-16/sentry.edge.config.ts | 4 - .../nextjs-16/sentry.server.config.ts | 9 +- .../nextjs-16/tests/ai-error.test.ts | 29 ++- .../nextjs-16/tests/ai-test.test.ts | 53 +++-- .../nextjs-16/tests/isr-routes.test.ts | 34 ++-- .../nextjs-16/tests/middleware.test.ts | 59 +++--- .../nextjs-16/tests/nested-rsc-error.test.ts | 10 +- .../tests/node-runtime-metrics.test.ts | 16 +- .../nextjs-16/tests/pageload-tracing.test.ts | 29 +-- .../tests/parameterized-routes.test.ts | 187 ++++++++++++------ .../nextjs-16/tests/prefetch-spans.test.ts | 12 +- .../nextjs-16/tests/route-handler.test.ts | 70 ++++--- .../tests/server-action-redirect.test.ts | 20 +- .../nextjs-16/tests/server-components.test.ts | 98 +++++---- .../tests/streaming-rsc-error.test.ts | 10 +- .../nextjs-16/tests/suspense-error.test.ts | 10 +- .../nextjs-16/tests/vercel-queue.test.ts | 43 ++-- 19 files changed, 414 insertions(+), 293 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts index c425bb403807..991c6009ed02 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts @@ -1,8 +1,6 @@ import * as Sentry from '@sentry/nextjs'; import type { Log } from '@sentry/nextjs'; -const enableSpanStreaming = process.env.NEXT_PUBLIC_E2E_NEXTJS_SPAN_STREAMING === '1'; - Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, @@ -14,7 +12,6 @@ Sentry.init({ filterKeys: ['nextjs-16-e2e'], behaviour: 'apply-tag-if-contains-third-party-frames', }), - ...(enableSpanStreaming ? [Sentry.spanStreamingIntegration()] : []), ], // Verify Log type is available beforeSendLog(log: Log) { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index d89eddfe87d5..beda2252d915 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -19,10 +19,8 @@ "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:build-span-streaming": "NEXT_PUBLIC_E2E_NEXTJS_SPAN_STREAMING=1 pnpm test:build", "test:assert": "pnpm test:prod && pnpm test:dev", - "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack", - "test:assert-span-streaming": "NEXT_PUBLIC_E2E_NEXTJS_SPAN_STREAMING=1 pnpm test:assert" + "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack" }, "dependencies": { "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", @@ -30,7 +28,7 @@ "@vercel/queue": "^0.1.3", "ai": "^3.0.0", "import-in-the-middle": "^2", - "next": "16.1.7", + "next": "16.2.3", "react": "19.1.0", "react-dom": "19.1.0", "require-in-the-middle": "^8", @@ -76,11 +74,6 @@ "build-command": "pnpm test:build-canary-webpack", "label": "nextjs-16 (canary, webpack)", "assert-command": "pnpm test:assert-webpack" - }, - { - "build-command": "pnpm test:build-span-streaming", - "label": "nextjs-16 (span streaming, turbopack)", - "assert-command": "pnpm test:assert-span-streaming" } ] } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts index fc1bc1756e08..2199afc46eaf 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts @@ -1,7 +1,5 @@ import * as Sentry from '@sentry/nextjs'; -const enableSpanStreaming = process.env.NEXT_PUBLIC_E2E_NEXTJS_SPAN_STREAMING === '1'; - Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, @@ -9,6 +7,4 @@ Sentry.init({ tracesSampleRate: 1.0, sendDefaultPii: true, // debug: true, - ...(enableSpanStreaming && { traceLifecycle: 'stream' as const }), - integrations: enableSpanStreaming ? [Sentry.spanStreamingIntegration()] : [], }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts index 946d2938f165..d7015bce4a30 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts @@ -1,8 +1,6 @@ import * as Sentry from '@sentry/nextjs'; import { Log } from '@sentry/nextjs'; -const enableSpanStreaming = process.env.NEXT_PUBLIC_E2E_NEXTJS_SPAN_STREAMING === '1'; - Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, @@ -10,12 +8,7 @@ Sentry.init({ tracesSampleRate: 1.0, sendDefaultPii: true, // debug: true, - ...(enableSpanStreaming && { traceLifecycle: 'stream' as const }), - integrations: [ - Sentry.vercelAIIntegration(), - Sentry.nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }), - ...(enableSpanStreaming ? [Sentry.spanStreamingIntegration()] : []), - ], + integrations: [Sentry.vercelAIIntegration(), Sentry.nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 })], // Verify Log type is available beforeSendLog(log: Log) { return log; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-error.test.ts index 9791e4a43339..39e76bab0dde 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-error.test.ts @@ -1,36 +1,33 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForRootSpan } from '@sentry-internal/test-utils'; - -const isSpanStreaming = process.env.NEXT_PUBLIC_E2E_NEXTJS_SPAN_STREAMING === '1'; +import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; test('should create AI spans with correct attributes and error linking', async ({ page }) => { - test.skip(isSpanStreaming, 'AI route segment span does not flush reliably in streaming mode'); - const aiRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === 'GET /ai-error-test'; + const aiTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent.transaction === 'GET /ai-error-test'; }); const errorEventPromise = waitForError('nextjs-16', async errorEvent => { - return !!errorEvent.exception?.values?.[0]?.value?.includes('Tool call failed'); + return errorEvent.exception?.values?.[0]?.value?.includes('Tool call failed'); }); await page.goto('/ai-error-test'); - const aiRootSpan = await aiRootSpanPromise; + const aiTransaction = await aiTransactionPromise; const errorEvent = await errorEventPromise; - expect(aiRootSpan).toBeDefined(); - expect(aiRootSpan.name).toBe('GET /ai-error-test'); + expect(aiTransaction).toBeDefined(); + expect(aiTransaction.transaction).toBe('GET /ai-error-test'); - const childSpans = aiRootSpan.childSpans; + const spans = aiTransaction.spans || []; // Each generateText call should create 2 spans: one for the pipeline and one for doGenerate // Plus a span for the tool call // TODO: For now, this is sadly not fully working - the monkey patching of the ai package is not working // because of this, only spans that are manually opted-in at call time will be captured // this may be fixed by https://github.com/vercel/ai/pull/6716 in the future - const aiPipelineSpans = childSpans.filter(span => span.op === 'gen_ai.invoke_agent'); - const aiGenerateSpans = childSpans.filter(span => span.op === 'gen_ai.generate_content'); - const toolCallSpans = childSpans.filter(span => span.op === 'gen_ai.execute_tool'); + const aiPipelineSpans = spans.filter(span => span.op === 'gen_ai.invoke_agent'); + const aiGenerateSpans = spans.filter(span => span.op === 'gen_ai.generate_content'); + const toolCallSpans = spans.filter(span => span.op === 'gen_ai.execute_tool'); expect(aiPipelineSpans.length).toBeGreaterThanOrEqual(1); expect(aiGenerateSpans.length).toBeGreaterThanOrEqual(1); @@ -38,6 +35,6 @@ test('should create AI spans with correct attributes and error linking', async ( expect(errorEvent).toBeDefined(); - // Verify error is linked to the same trace as the root span - expect(errorEvent?.contexts?.trace?.trace_id).toBe(aiRootSpan.traceId); + //Verify error is linked to the same trace as the transaction + expect(errorEvent?.contexts?.trace?.trace_id).toBe(aiTransaction.contexts?.trace?.trace_id); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-test.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-test.test.ts index c15dcae66b1a..dcd129020035 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-test.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-test.test.ts @@ -1,22 +1,19 @@ import { expect, test } from '@playwright/test'; -import { waitForRootSpan } from '@sentry-internal/test-utils'; - -const isSpanStreaming = process.env.NEXT_PUBLIC_E2E_NEXTJS_SPAN_STREAMING === '1'; +import { waitForTransaction } from '@sentry-internal/test-utils'; test('should create AI spans with correct attributes', async ({ page }) => { - test.skip(isSpanStreaming, 'AI route segment span does not flush reliably in streaming mode'); - const aiRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === 'GET /ai-test'; + const aiTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent.transaction === 'GET /ai-test'; }); await page.goto('/ai-test'); - const aiRootSpan = await aiRootSpanPromise; + const aiTransaction = await aiTransactionPromise; - expect(aiRootSpan).toBeDefined(); - expect(aiRootSpan.name).toBe('GET /ai-test'); + expect(aiTransaction).toBeDefined(); + expect(aiTransaction.transaction).toBe('GET /ai-test'); - const childSpans = aiRootSpan.childSpans; + const spans = aiTransaction.spans || []; // We expect spans for the first 3 AI calls (4th is disabled) // Each generateText call should create 2 spans: one for the pipeline and one for doGenerate @@ -24,22 +21,44 @@ test('should create AI spans with correct attributes', async ({ page }) => { // TODO: For now, this is sadly not fully working - the monkey patching of the ai package is not working // because of this, only spans that are manually opted-in at call time will be captured // this may be fixed by https://github.com/vercel/ai/pull/6716 in the future - const aiPipelineSpans = childSpans.filter(span => span.op === 'gen_ai.invoke_agent'); - const aiGenerateSpans = childSpans.filter(span => span.op === 'gen_ai.generate_content'); - const toolCallSpans = childSpans.filter(span => span.op === 'gen_ai.execute_tool'); + const aiPipelineSpans = spans.filter(span => span.op === 'gen_ai.invoke_agent'); + const aiGenerateSpans = spans.filter(span => span.op === 'gen_ai.generate_content'); + const toolCallSpans = spans.filter(span => span.op === 'gen_ai.execute_tool'); expect(aiPipelineSpans.length).toBeGreaterThanOrEqual(1); expect(aiGenerateSpans.length).toBeGreaterThanOrEqual(1); expect(toolCallSpans.length).toBeGreaterThanOrEqual(0); + // First AI call - should have telemetry enabled and record inputs/outputs (sendDefaultPii: true) + /* const firstPipelineSpan = aiPipelineSpans[0]; + expect(firstPipelineSpan?.data?.['vercel.ai.model.id']).toBe('mock-model-id'); + expect(firstPipelineSpan?.data?.['vercel.ai.model.provider']).toBe('mock-provider'); + expect(firstPipelineSpan?.data?.['vercel.ai.prompt']).toContain('Where is the first span?'); + expect(firstPipelineSpan?.data?.['gen_ai.output.messages']).toContain('First span here!'); + expect(firstPipelineSpan?.data?.['gen_ai.usage.input_tokens']).toBe(10); + expect(firstPipelineSpan?.data?.['gen_ai.usage.output_tokens']).toBe(20); */ + // Second AI call - explicitly enabled telemetry const secondPipelineSpan = aiPipelineSpans[0]; - expect(secondPipelineSpan?.attributes['vercel.ai.prompt']).toContain('Where is the second span?'); - expect(secondPipelineSpan?.attributes['gen_ai.output.messages']).toContain('Second span here!'); + expect(secondPipelineSpan?.data?.['vercel.ai.prompt']).toContain('Where is the second span?'); + expect(secondPipelineSpan?.data?.['gen_ai.output.messages']).toContain('Second span here!'); + + // Third AI call - with tool calls + /* const thirdPipelineSpan = aiPipelineSpans[2]; + expect(thirdPipelineSpan?.data?.['vercel.ai.response.finishReason']).toBe('tool-calls'); + expect(thirdPipelineSpan?.data?.['gen_ai.usage.input_tokens']).toBe(15); + expect(thirdPipelineSpan?.data?.['gen_ai.usage.output_tokens']).toBe(25); */ + + // Tool call span + /* const toolSpan = toolCallSpans[0]; + expect(toolSpan?.data?.['vercel.ai.toolCall.name']).toBe('getWeather'); + expect(toolSpan?.data?.['vercel.ai.toolCall.id']).toBe('call-1'); + expect(toolSpan?.data?.['vercel.ai.toolCall.args']).toContain('San Francisco'); + expect(toolSpan?.data?.['vercel.ai.toolCall.result']).toContain('Sunny, 72°F'); */ // Verify the fourth call was not captured (telemetry disabled) - const promptsInSpans = childSpans - .map(span => span.attributes['vercel.ai.prompt'] as string | undefined) + const promptsInSpans = spans + .map(span => span.data?.['vercel.ai.prompt']) .filter((prompt): prompt is string => prompt !== undefined); const hasDisabledPrompt = promptsInSpans.some(prompt => prompt.includes('Where is the third span?')); expect(hasDisabledPrompt).toBe(false); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts index 7053a2744678..541cff9c064c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForRootSpan } from '@sentry-internal/test-utils'; +import { waitForTransaction } from '@sentry-internal/test-utils'; test('should remove sentry-trace and baggage meta tags on ISR dynamic route page load', async ({ page }) => { // Navigate to ISR page @@ -46,8 +46,10 @@ test('should create unique transactions for ISR pages on each visit', async ({ p // Load the same ISR page 5 times to ensure cached HTML meta tags are consistently removed for (let i = 0; i < 5; i++) { - const rootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === '/isr-test/:product' && rootSpan.op === 'pageload'; + const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return !!( + transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload' + ); }); if (i === 0) { @@ -56,8 +58,8 @@ test('should create unique transactions for ISR pages on each visit', async ({ p await page.reload(); } - const rootSpan = await rootSpanPromise; - const traceId = rootSpan.traceId; + const transaction = await transactionPromise; + const traceId = transaction.contexts?.trace?.trace_id; expect(traceId).toBeDefined(); expect(traceId).toMatch(/[a-f0-9]{32}/); @@ -70,13 +72,23 @@ test('should create unique transactions for ISR pages on each visit', async ({ p }); test('ISR route should be identified correctly in the route manifest', async ({ page }) => { - const rootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === '/isr-test/:product' && rootSpan.op === 'pageload'; + const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload'; }); await page.goto('/isr-test/laptop'); - const rootSpan = await rootSpanPromise; - - expect(rootSpan.name).toBe('/isr-test/:product'); - expect(rootSpan.attributes['sentry.source']).toBe('route'); + const transaction = await transactionPromise; + + // Verify the transaction is properly parameterized + expect(transaction).toMatchObject({ + transaction: '/isr-test/:product', + transaction_info: { source: 'route' }, + contexts: { + trace: { + data: { + 'sentry.source': 'route', + }, + }, + }, + }); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts index 103b8bc7ee3f..0bb957bbc466 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts @@ -1,35 +1,31 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForRootSpan, waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; import { isDevMode } from './isDevMode'; -const isSpanStreaming = process.env.NEXT_PUBLIC_E2E_NEXTJS_SPAN_STREAMING === '1'; - test('Should create a transaction for middleware', async ({ request }) => { - const middlewareRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === 'middleware GET'; + const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'middleware GET'; }); const response = await request.get('/api/endpoint-behind-middleware'); expect(await response.json()).toStrictEqual({ name: 'John Doe' }); - const middlewareRootSpan = await middlewareRootSpanPromise; + const middlewareTransaction = await middlewareTransactionPromise; - expect(middlewareRootSpan.status).toBe('ok'); - expect(middlewareRootSpan.op).toBe('http.server.middleware'); + expect(middlewareTransaction.contexts?.trace?.status).toBe('ok'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('node'); + expect(middlewareTransaction.transaction_info?.source).toBe('route'); - if (!isSpanStreaming) { - const raw = middlewareRootSpan.raw as Record; - expect((raw as { contexts?: { runtime?: { name?: string } } }).contexts?.runtime?.name).toBe('node'); - expect((raw as { transaction_info?: { source?: string } }).transaction_info?.source).toBe('route'); - expect((raw as { tags?: Record }).tags?.['my-isolated-tag']).toBe(true); - expect((raw as { tags?: Record }).tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); - } + // Assert that isolation scope works properly + expect(middlewareTransaction.tags?.['my-isolated-tag']).toBe(true); + expect(middlewareTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); }); test('Faulty middlewares', async ({ request }) => { test.skip(isDevMode, 'Throwing crashes the dev server atm'); // https://github.com/vercel/next.js/issues/85261 - const middlewareRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === 'middleware GET'; + const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'middleware GET'; }); const errorEventPromise = waitForError('nextjs-16', errorEvent => { @@ -41,20 +37,29 @@ test('Faulty middlewares', async ({ request }) => { }); await test.step('should record transactions', async () => { - const middlewareRootSpan = await middlewareRootSpanPromise; - expect(middlewareRootSpan.status).toMatch(/^(internal_error|error)$/); - expect(middlewareRootSpan.op).toBe('http.server.middleware'); - - if (!isSpanStreaming) { - const raw = middlewareRootSpan.raw as Record; - expect((raw as { contexts?: { runtime?: { name?: string } } }).contexts?.runtime?.name).toBe('node'); - expect((raw as { transaction_info?: { source?: string } }).transaction_info?.source).toBe('route'); - } + const middlewareTransaction = await middlewareTransactionPromise; + expect(middlewareTransaction.contexts?.trace?.status).toBe('internal_error'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('node'); + expect(middlewareTransaction.transaction_info?.source).toBe('route'); }); + + // TODO: proxy errors currently not reported via onRequestError + // await test.step('should record exceptions', async () => { + // const errorEvent = await errorEventPromise; + + // // Assert that isolation scope works properly + // expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); + // expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + // expect([ + // 'middleware GET', // non-otel webpack versions + // '/middleware', // middleware file + // '/proxy', // proxy file + // ]).toContain(errorEvent.transaction); + // }); }); test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => { - test.skip(isSpanStreaming, 'Breadcrumb assertions require transaction envelope format'); test.skip(isDevMode, 'The fetch requests ends up in a separate tx in dev atm'); const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { return transactionEvent?.transaction === 'middleware GET'; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/nested-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/nested-rsc-error.test.ts index 20f403072f84..68731f049f2c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/nested-rsc-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/nested-rsc-error.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForRootSpan } from '@sentry-internal/test-utils'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('Should capture errors from nested server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({ page, @@ -8,16 +8,16 @@ test('Should capture errors from nested server components when `Sentry.captureRe return !!errorEvent?.exception?.values?.some(value => value.value === 'I am technically uncatchable'); }); - const serverRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === 'GET /nested-rsc-error/[param]'; + const serverTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'GET /nested-rsc-error/[param]'; }); await page.goto(`/nested-rsc-error/123`); const errorEvent = await errorEventPromise; - const serverRootSpan = await serverRootSpanPromise; + const serverTransactionEvent = await serverTransactionPromise; // error event is part of the transaction - expect(errorEvent.contexts?.trace?.trace_id).toBe(serverRootSpan.traceId); + expect(errorEvent.contexts?.trace?.trace_id).toBe(serverTransactionEvent.contexts?.trace?.trace_id); expect(errorEvent.request).toMatchObject({ headers: expect.any(Object), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts index 0efd0d8f7d79..5295101f1e20 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts @@ -8,7 +8,7 @@ const EXPECTED_ATTRIBUTES = { 'sentry.origin': { value: 'auto.node.runtime_metrics', type: 'string' }, }; -test('Should emit node runtime memory metrics', async ({ request }) => { +test('Should emit node runtime memory metrics', async ({ baseURL }) => { const rssPromise = waitForMetric('nextjs-16', metric => { return metric.name === 'node.runtime.mem.rss'; }); @@ -22,7 +22,7 @@ test('Should emit node runtime memory metrics', async ({ request }) => { }); // Trigger a request to ensure the server is running and metrics start being collected - await request.get('/'); + await fetch(`${baseURL}/`); const rss = await rssPromise; const heapUsed = await heapUsedPromise; @@ -59,12 +59,12 @@ test('Should emit node runtime memory metrics', async ({ request }) => { }); }); -test('Should emit node runtime CPU utilization metric', async ({ request }) => { +test('Should emit node runtime CPU utilization metric', async ({ baseURL }) => { const cpuUtilPromise = waitForMetric('nextjs-16', metric => { return metric.name === 'node.runtime.cpu.utilization'; }); - await request.get('/'); + await fetch(`${baseURL}/`); const cpuUtil = await cpuUtilPromise; @@ -78,7 +78,7 @@ test('Should emit node runtime CPU utilization metric', async ({ request }) => { }); }); -test('Should emit node runtime event loop metrics', async ({ request }) => { +test('Should emit node runtime event loop metrics', async ({ baseURL }) => { const elDelayP50Promise = waitForMetric('nextjs-16', metric => { return metric.name === 'node.runtime.event_loop.delay.p50'; }); @@ -91,7 +91,7 @@ test('Should emit node runtime event loop metrics', async ({ request }) => { return metric.name === 'node.runtime.event_loop.utilization'; }); - await request.get('/'); + await fetch(`${baseURL}/`); const elDelayP50 = await elDelayP50Promise; const elDelayP99 = await elDelayP99Promise; @@ -127,12 +127,12 @@ test('Should emit node runtime event loop metrics', async ({ request }) => { }); }); -test('Should emit node runtime uptime counter', async ({ request }) => { +test('Should emit node runtime uptime counter', async ({ baseURL }) => { const uptimePromise = waitForMetric('nextjs-16', metric => { return metric.name === 'node.runtime.process.uptime'; }); - await request.get('/'); + await fetch(`${baseURL}/`); const uptime = await uptimePromise; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/pageload-tracing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/pageload-tracing.test.ts index 2a1926fdbb9a..5360f450c5fa 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/pageload-tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/pageload-tracing.test.ts @@ -1,26 +1,31 @@ import { expect, test } from '@playwright/test'; -import { waitForRootSpan } from '@sentry-internal/test-utils'; +import { waitForTransaction } from '@sentry-internal/test-utils'; test('App router transactions should be attached to the pageload request span', async ({ page }) => { - const serverRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === 'GET /pageload-tracing'; + const serverTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'GET /pageload-tracing'; }); - const pageloadRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === '/pageload-tracing'; + const pageloadTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === '/pageload-tracing'; }); await page.goto(`/pageload-tracing`); - const [serverRootSpan, pageloadRootSpan] = await Promise.all([serverRootSpanPromise, pageloadRootSpanPromise]); + const [serverTransaction, pageloadTransaction] = await Promise.all([ + serverTransactionPromise, + pageloadTransactionPromise, + ]); - expect(pageloadRootSpan.traceId).toBeTruthy(); - expect(serverRootSpan.traceId).toBe(pageloadRootSpan.traceId); + const pageloadTraceId = pageloadTransaction.contexts?.trace?.trace_id; + + expect(pageloadTraceId).toBeTruthy(); + expect(serverTransaction.contexts?.trace?.trace_id).toBe(pageloadTraceId); }); test('extracts HTTP request headers as span attributes', async ({ baseURL }) => { - const serverRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === 'GET /pageload-tracing'; + const serverTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'GET /pageload-tracing'; }); await fetch(`${baseURL}/pageload-tracing`, { @@ -34,9 +39,9 @@ test('extracts HTTP request headers as span attributes', async ({ baseURL }) => }, }); - const serverRootSpan = await serverRootSpanPromise; + const serverTransaction = await serverTransactionPromise; - expect(serverRootSpan.attributes).toEqual( + expect(serverTransaction.contexts?.trace?.data).toEqual( expect.objectContaining({ 'http.request.header.user_agent': 'Custom-NextJS-Agent/15.0', 'http.request.header.content_type': 'text/html', diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts index 2a2b67bcfb35..43a2aa6191de 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts @@ -1,88 +1,161 @@ import { expect, test } from '@playwright/test'; -import { waitForRootSpan } from '@sentry-internal/test-utils'; +import { waitForTransaction } from '@sentry-internal/test-utils'; test('should create a parameterized transaction when the `app` directory is used', async ({ page }) => { - const rootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === '/parameterized/:one' && rootSpan.op === 'pageload'; + const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one' && transactionEvent.contexts?.trace?.op === 'pageload' + ); }); await page.goto(`/parameterized/cappuccino`); - const rootSpan = await rootSpanPromise; - - expect(rootSpan.name).toBe('/parameterized/:one'); - expect(rootSpan.op).toBe('pageload'); - expect(rootSpan.traceId).toMatch(/[a-f0-9]{32}/); - expect(rootSpan.attributes).toEqual( - expect.objectContaining({ - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.source': 'route', - }), - ); + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one', + transaction_info: { source: 'route' }, + type: 'transaction', + }); }); test('should create a static transaction when the `app` directory is used and the route is not parameterized', async ({ page, }) => { - const rootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === '/parameterized/static' && rootSpan.op === 'pageload'; + const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/static' && transactionEvent.contexts?.trace?.op === 'pageload' + ); }); await page.goto(`/parameterized/static`); - const rootSpan = await rootSpanPromise; - - expect(rootSpan.name).toBe('/parameterized/static'); - expect(rootSpan.op).toBe('pageload'); - expect(rootSpan.traceId).toMatch(/[a-f0-9]{32}/); - expect(rootSpan.attributes).toEqual( - expect.objectContaining({ - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.source': 'url', - }), - ); + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'url', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/static$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/static', + transaction_info: { source: 'url' }, + type: 'transaction', + }); }); test('should create a partially parameterized transaction when the `app` directory is used', async ({ page }) => { - const rootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === '/parameterized/:one/beep' && rootSpan.op === 'pageload'; + const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep' && transactionEvent.contexts?.trace?.op === 'pageload' + ); }); await page.goto(`/parameterized/cappuccino/beep`); - const rootSpan = await rootSpanPromise; - - expect(rootSpan.name).toBe('/parameterized/:one/beep'); - expect(rootSpan.op).toBe('pageload'); - expect(rootSpan.traceId).toMatch(/[a-f0-9]{32}/); - expect(rootSpan.attributes).toEqual( - expect.objectContaining({ - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.source': 'route', - }), - ); + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep', + transaction_info: { source: 'route' }, + type: 'transaction', + }); }); test('should create a nested parameterized transaction when the `app` directory is used.', async ({ page }) => { - const rootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === '/parameterized/:one/beep/:two' && rootSpan.op === 'pageload'; + const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep/:two' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); }); await page.goto(`/parameterized/cappuccino/beep/espresso`); - const rootSpan = await rootSpanPromise; - - expect(rootSpan.name).toBe('/parameterized/:one/beep/:two'); - expect(rootSpan.op).toBe('pageload'); - expect(rootSpan.traceId).toMatch(/[a-f0-9]{32}/); - expect(rootSpan.attributes).toEqual( - expect.objectContaining({ - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.source': 'route', - }), - ); + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep\/espresso$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep/:two', + transaction_info: { source: 'route' }, + type: 'transaction', + }); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/prefetch-spans.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/prefetch-spans.test.ts index 70c845058363..0b158103d1c0 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/prefetch-spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/prefetch-spans.test.ts @@ -1,12 +1,12 @@ import { expect, test } from '@playwright/test'; -import { waitForRootSpan } from '@sentry-internal/test-utils'; +import { waitForTransaction } from '@sentry-internal/test-utils'; import { isDevMode } from './isDevMode'; test('Prefetch client spans should have a http.request.prefetch attribute', async ({ page }) => { test.skip(isDevMode, "Prefetch requests don't have the prefetch header in dev mode"); - const pageloadRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === '/prefetching'; + const pageloadTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === '/prefetching'; }); await page.goto(`/prefetching`); @@ -14,12 +14,10 @@ test('Prefetch client spans should have a http.request.prefetch attribute', asyn // Make it more likely that nextjs prefetches await page.hover('#prefetch-link'); - const rootSpan = await pageloadRootSpanPromise; - - expect(rootSpan.childSpans).toContainEqual( + expect((await pageloadTransactionPromise).spans).toContainEqual( expect.objectContaining({ op: 'http.client', - attributes: expect.objectContaining({ + data: expect.objectContaining({ 'http.request.prefetch': true, }), }), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/route-handler.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/route-handler.test.ts index ff3ccda39878..e37c39eb4dba 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/route-handler.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/route-handler.test.ts @@ -1,22 +1,22 @@ import test, { expect } from '@playwright/test'; -import { waitForError, waitForRootSpan } from '@sentry-internal/test-utils'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('Should create a transaction for node route handlers', async ({ request }) => { - const rootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === 'GET /route-handler/[xoxo]/node'; + const routehandlerTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'GET /route-handler/[xoxo]/node'; }); 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; + const routehandlerTransaction = await routehandlerTransactionPromise; - expect(rootSpan.status).toBe('ok'); - expect(rootSpan.op).toBe('http.server'); + expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok'); + expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); // This is flaking on dev mode if (process.env.TEST_ENV !== 'development' && process.env.TEST_ENV !== 'dev-turbopack') { - expect(rootSpan.attributes['http.request.header.x_charly']).toBe('gomez'); + expect(routehandlerTransaction.contexts?.trace?.data?.['http.request.header.x_charly']).toBe('gomez'); } }); @@ -45,17 +45,20 @@ test('Should report an error with a parameterized transaction name for a throwin return errorEvent?.exception?.values?.some(value => value.value === 'route-handler-error') ?? false; }); - const rootSpanPromise = waitForRootSpan('nextjs-16', rootSpan => { - return rootSpan.name === 'GET /route-handler/[xoxo]/error' && rootSpan.op === 'http.server'; + const transactionEventPromise = waitForTransaction('nextjs-16', transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /route-handler/[xoxo]/error' && + transactionEvent?.contexts?.trace?.op === 'http.server' + ); }); request.get('/route-handler/456/error').catch(() => {}); const errorEvent = await errorEventPromise; - const rootSpan = await rootSpanPromise; + const transactionEvent = await transactionEventPromise; // Error event should be part of the same trace as the transaction - expect(errorEvent.contexts?.trace?.trace_id).toBe(rootSpan.traceId); + expect(errorEvent.contexts?.trace?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id); // Error should carry the parameterized transaction name expect(errorEvent.transaction).toBe('GET /route-handler/[xoxo]/error'); @@ -77,10 +80,9 @@ test('Should report an error with a parameterized transaction name for a throwin }); } - // Root span should have parameterized name and error status - // Streamed spans use 'error', transactions use 'internal_error' - expect(rootSpan.name).toBe('GET /route-handler/[xoxo]/error'); - expect(rootSpan.status).toMatch(/^(internal_error|error)$/); + // Transaction should have parameterized name and internal_error status + expect(transactionEvent.transaction).toBe('GET /route-handler/[xoxo]/error'); + expect(transactionEvent.contexts?.trace?.status).toBe('internal_error'); }); test('Should set a parameterized transaction name on a captureMessage event in a route handler', async ({ @@ -90,25 +92,28 @@ test('Should set a parameterized transaction name on a captureMessage event in a return event?.message === 'route-handler-message'; }); - const rootSpanPromise = waitForRootSpan('nextjs-16', rootSpan => { - return rootSpan.name === 'GET /route-handler/[xoxo]/capture-message' && rootSpan.op === 'http.server'; + const transactionEventPromise = waitForTransaction('nextjs-16', transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /route-handler/[xoxo]/capture-message' && + transactionEvent?.contexts?.trace?.op === 'http.server' + ); }); 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; + const transactionEvent = await transactionEventPromise; - // Message event should be part of the same trace as the root span - expect(messageEvent.contexts?.trace?.trace_id).toBe(rootSpan.traceId); + // Message event should be part of the same trace as the transaction + expect(messageEvent.contexts?.trace?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id); // Message should carry the parameterized transaction name expect(messageEvent.transaction).toBe('GET /route-handler/[xoxo]/capture-message'); - // Root span should have parameterized name and ok status - expect(rootSpan.name).toBe('GET /route-handler/[xoxo]/capture-message'); - expect(rootSpan.status).toBe('ok'); + // Transaction should have parameterized name and ok status + expect(transactionEvent.transaction).toBe('GET /route-handler/[xoxo]/capture-message'); + expect(transactionEvent.contexts?.trace?.status).toBe('ok'); }); test('Should set a parameterized transaction name on a captureException event in a route handler', async ({ @@ -118,23 +123,26 @@ test('Should set a parameterized transaction name on a captureException event in return errorEvent?.exception?.values?.some(value => value.value === 'route-handler-capture-exception') ?? false; }); - const rootSpanPromise = waitForRootSpan('nextjs-16', rootSpan => { - return rootSpan.name === 'GET /route-handler/[xoxo]/capture-exception' && rootSpan.op === 'http.server'; + const transactionEventPromise = waitForTransaction('nextjs-16', transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /route-handler/[xoxo]/capture-exception' && + transactionEvent?.contexts?.trace?.op === 'http.server' + ); }); const response = await request.get('/route-handler/321/capture-exception'); expect(await response.json()).toStrictEqual({ message: 'Exception captured' }); const errorEvent = await errorEventPromise; - const rootSpan = await rootSpanPromise; + const transactionEvent = await transactionEventPromise; - // Error event should be part of the same trace as the root span - expect(errorEvent.contexts?.trace?.trace_id).toBe(rootSpan.traceId); + // Error event should be part of the same trace as the transaction + expect(errorEvent.contexts?.trace?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id); // Manually captured exception should carry the parameterized transaction name expect(errorEvent.transaction).toBe('GET /route-handler/[xoxo]/capture-exception'); - // Root span should have parameterized name and ok status (error was caught, not thrown) - expect(rootSpan.name).toBe('GET /route-handler/[xoxo]/capture-exception'); - expect(rootSpan.status).toBe('ok'); + // Transaction should have parameterized name and ok status (error was caught, not thrown) + expect(transactionEvent.transaction).toBe('GET /route-handler/[xoxo]/capture-exception'); + expect(transactionEvent.contexts?.trace?.status).toBe('ok'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts index 789813792679..88e2d3ba1af1 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts @@ -1,21 +1,21 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForRootSpan } from '@sentry-internal/test-utils'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('Should handle server action redirect without capturing errors', async ({ page }) => { - // Wait for the initial page load root span (client-side) - const pageLoadRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === '/redirect/origin'; + // Wait for the initial page load transaction + const pageLoadTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === '/redirect/origin'; }); // Navigate to the origin page await page.goto('/redirect/origin'); - const pageLoadRootSpan = await pageLoadRootSpanPromise; - expect(pageLoadRootSpan).toBeDefined(); + const pageLoadTransaction = await pageLoadTransactionPromise; + expect(pageLoadTransaction).toBeDefined(); - // Wait for the redirect root span (server-side) - const redirectRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === 'GET /redirect/destination'; + // Wait for the redirect transaction + const redirectTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'GET /redirect/destination'; }); // No error should be captured @@ -26,7 +26,7 @@ test('Should handle server action redirect without capturing errors', async ({ p // Click the redirect button await page.click('button[type="submit"]'); - await redirectRootSpanPromise; + await redirectTransactionPromise; // Verify we got redirected to the destination page await expect(page).toHaveURL('/redirect/destination'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts index b9f08697435b..81aa5c98288c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts @@ -1,74 +1,98 @@ import { expect, test } from '@playwright/test'; -import { waitForRootSpan } from '@sentry-internal/test-utils'; +import { waitForTransaction } from '@sentry-internal/test-utils'; test('Sends a transaction for a request to app router with URL', async ({ page }) => { - const serverRootSpanPromise = waitForRootSpan('nextjs-16', rootSpan => { - return rootSpan.name === 'GET /parameterized/[one]/beep/[two]'; + const serverComponentTransactionPromise = waitForTransaction('nextjs-16', transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' && + transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42') + ); }); await page.goto('/parameterized/1337/beep/42'); - const rootSpan = await serverRootSpanPromise; + const transactionEvent = await serverComponentTransactionPromise; - expect(rootSpan.op).toBe('http.server'); - expect(rootSpan.status).toBe('ok'); - expect(rootSpan.attributes).toEqual( - expect.objectContaining({ + expect(transactionEvent.contexts?.trace).toEqual({ + data: expect.objectContaining({ 'sentry.op': 'http.server', 'sentry.origin': 'auto', + 'sentry.sample_rate': 1, 'sentry.source': 'route', 'http.method': 'GET', + 'http.response.status_code': 200, 'http.route': '/parameterized/[one]/beep/[two]', + 'http.status_code': 200, + 'http.target': '/parameterized/1337/beep/42', + 'otel.kind': 'SERVER', 'next.route': '/parameterized/[one]/beep/[two]', }), - ); + op: 'http.server', + origin: 'auto', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.request).toMatchObject({ + url: expect.stringContaining('/parameterized/1337/beep/42'), + }); - // The root span should not contain any child spans with the same name as the root span - expect(rootSpan.childSpans.filter(span => span.name === rootSpan.name)).toHaveLength(0); + // The transaction should not contain any spans with the same name as the transaction + // e.g. "GET /parameterized/[one]/beep/[two]" + expect( + transactionEvent.spans?.filter(span => { + return span.description === transactionEvent.transaction; + }), + ).toHaveLength(0); }); test('Will create a transaction with spans for every server component and metadata generation functions when visiting a page', async ({ page, }) => { - const serverRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === 'GET /nested-layout'; + const serverTransactionEventPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'GET /nested-layout'; }); await page.goto('/nested-layout'); - const spanNames = (await serverRootSpanPromise).childSpans.map(span => span.name); + const spanDescriptions = (await serverTransactionEventPromise).spans?.map(span => { + return span.description; + }); - 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'); + expect(spanDescriptions).toContainEqual('render route (app) /nested-layout'); + expect(spanDescriptions).toContainEqual('build component tree'); + expect(spanDescriptions).toContainEqual('resolve root layout server component'); + expect(spanDescriptions).toContainEqual('resolve layout server component "(nested-layout)"'); + expect(spanDescriptions).toContainEqual('resolve layout server component "nested-layout"'); + expect(spanDescriptions).toContainEqual('resolve page server component "/nested-layout"'); + expect(spanDescriptions).toContainEqual('generateMetadata /(nested-layout)/nested-layout/page'); + expect(spanDescriptions).toContainEqual('start response'); + expect(spanDescriptions).toContainEqual('NextNodeServer.clientComponentLoading'); }); test('Will create a transaction with spans for every server component and metadata generation functions when visiting a dynamic page', async ({ page, }) => { - const serverRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === 'GET /nested-layout/[dynamic]'; + const serverTransactionEventPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'GET /nested-layout/[dynamic]'; }); await page.goto('/nested-layout/123'); - const spanNames = (await serverRootSpanPromise).childSpans.map(span => span.name); + const spanDescriptions = (await serverTransactionEventPromise).spans?.map(span => { + return span.description; + }); - 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'); + expect(spanDescriptions).toContainEqual('resolve page components'); + expect(spanDescriptions).toContainEqual('render route (app) /nested-layout/[dynamic]'); + expect(spanDescriptions).toContainEqual('build component tree'); + expect(spanDescriptions).toContainEqual('resolve root layout server component'); + expect(spanDescriptions).toContainEqual('resolve layout server component "(nested-layout)"'); + expect(spanDescriptions).toContainEqual('resolve layout server component "nested-layout"'); + expect(spanDescriptions).toContainEqual('resolve layout server component "[dynamic]"'); + expect(spanDescriptions).toContainEqual('resolve page server component "/nested-layout/[dynamic]"'); + expect(spanDescriptions).toContainEqual('generateMetadata /(nested-layout)/nested-layout/[dynamic]/page'); + expect(spanDescriptions).toContainEqual('start response'); + expect(spanDescriptions).toContainEqual('NextNodeServer.clientComponentLoading'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/streaming-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/streaming-rsc-error.test.ts index fe409d984836..f22932a0c65f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/streaming-rsc-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/streaming-rsc-error.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForRootSpan } from '@sentry-internal/test-utils'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('Should capture errors for crashing streaming promises in server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({ page, @@ -8,16 +8,16 @@ test('Should capture errors for crashing streaming promises in server components return !!errorEvent?.exception?.values?.some(value => value.value === 'I am a data streaming error'); }); - const serverRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === 'GET /streaming-rsc-error/[param]'; + const serverTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'GET /streaming-rsc-error/[param]'; }); await page.goto(`/streaming-rsc-error/123`); const errorEvent = await errorEventPromise; - const serverRootSpan = await serverRootSpanPromise; + const serverTransactionEvent = await serverTransactionPromise; // error event is part of the transaction - expect(errorEvent.contexts?.trace?.trace_id).toBe(serverRootSpan.traceId); + expect(errorEvent.contexts?.trace?.trace_id).toBe(serverTransactionEvent.contexts?.trace?.trace_id); expect(errorEvent.request).toMatchObject({ headers: expect.any(Object), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/suspense-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/suspense-error.test.ts index 834e51916a99..f7a5fb83c3df 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/suspense-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/suspense-error.test.ts @@ -1,9 +1,9 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForRootSpan } from '@sentry-internal/test-utils'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('should not capture serverside suspense errors', async ({ page }) => { - const pageServerComponentRootSpanPromise = waitForRootSpan('nextjs-16', async rootSpan => { - return rootSpan.name === 'GET /suspense-error'; + const pageServerComponentTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'GET /suspense-error'; }); let errorEvent; @@ -18,8 +18,8 @@ test('should not capture serverside suspense errors', async ({ page }) => { // Just to be a little bit more sure await page.waitForTimeout(5000); - const pageServerComponentRootSpan = await pageServerComponentRootSpanPromise; - expect(pageServerComponentRootSpan).toBeDefined(); + const pageServerComponentTransaction = await pageServerComponentTransactionPromise; + expect(pageServerComponentTransaction).toBeDefined(); expect(errorEvent).toBeUndefined(); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/vercel-queue.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/vercel-queue.test.ts index 13bf35df8321..eb4635bc2f5a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/vercel-queue.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/vercel-queue.test.ts @@ -1,5 +1,5 @@ import test, { expect } from '@playwright/test'; -import { waitForRootSpan } from '@sentry-internal/test-utils'; +import { waitForTransaction } from '@sentry-internal/test-utils'; // The queue E2E test only runs in production mode. // In development mode the @vercel/queue SDK uses an in-memory dispatch that @@ -9,13 +9,13 @@ const isProduction = process.env.TEST_ENV === 'production'; test('Should create transactions for queue producer and consumer', async ({ request }) => { test.skip(!isProduction, 'Vercel Queue test only runs in production mode'); - // 1. Set up waiters for both the producer and consumer root spans. - const producerRootSpanPromise = waitForRootSpan('nextjs-16', rootSpan => { - return rootSpan.name === 'POST /api/queue-send'; + // 1. Set up waiters for both the producer and consumer transactions. + const producerTransactionPromise = waitForTransaction('nextjs-16', transactionEvent => { + return transactionEvent?.transaction === 'POST /api/queue-send'; }); - const consumerRootSpanPromise = waitForRootSpan('nextjs-16', rootSpan => { - return rootSpan.name === 'POST /api/queues/process-order'; + const consumerTransactionPromise = waitForTransaction('nextjs-16', transactionEvent => { + return transactionEvent?.transaction === 'POST /api/queues/process-order'; }); // 2. Hit the producer route to enqueue a message. @@ -28,23 +28,24 @@ test('Should create transactions for queue producer and consumer', async ({ requ expect(response.status()).toBe(200); expect(responseBody.messageId).toBeTruthy(); - // 3. Wait for the producer root span. - const producerRootSpan = await producerRootSpanPromise; - expect(producerRootSpan).toBeDefined(); - expect(producerRootSpan.op).toBe('http.server'); - expect(producerRootSpan.status).toBe('ok'); + // 3. Wait for the producer transaction. + const producerTransaction = await producerTransactionPromise; + expect(producerTransaction).toBeDefined(); + expect(producerTransaction.contexts?.trace?.op).toBe('http.server'); + expect(producerTransaction.contexts?.trace?.status).toBe('ok'); - // 4. Wait for the consumer root span (the mock server pushes the message + // 4. Wait for the consumer transaction (the mock server pushes the message // to the consumer route via CloudEvent POST). - const consumerRootSpan = await consumerRootSpanPromise; - expect(consumerRootSpan).toBeDefined(); - expect(consumerRootSpan.op).toBe('http.server'); - expect(consumerRootSpan.status).toBe('ok'); + const consumerTransaction = await consumerTransactionPromise; + expect(consumerTransaction).toBeDefined(); + expect(consumerTransaction.contexts?.trace?.op).toBe('http.server'); + expect(consumerTransaction.contexts?.trace?.status).toBe('ok'); // 5. Verify the consumer span has messaging.* attributes from queue instrumentation. - expect(consumerRootSpan.attributes['messaging.system']).toBe('vercel.queue'); - expect(consumerRootSpan.attributes['messaging.operation.name']).toBe('process'); - expect(consumerRootSpan.attributes['messaging.destination.name']).toBe('orders'); - expect(consumerRootSpan.attributes['messaging.message.id']).toBeTruthy(); - expect(consumerRootSpan.attributes['messaging.consumer.group.name']).toBeTruthy(); + const consumerSpanData = consumerTransaction.contexts?.trace?.data; + expect(consumerSpanData?.['messaging.system']).toBe('vercel.queue'); + expect(consumerSpanData?.['messaging.operation.name']).toBe('process'); + expect(consumerSpanData?.['messaging.destination.name']).toBe('orders'); + expect(consumerSpanData?.['messaging.message.id']).toBeTruthy(); + expect(consumerSpanData?.['messaging.consumer.group.name']).toBeTruthy(); }); From e3c1fe07fd4e4f57c52369f04d12af3c61766b2a Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 5 May 2026 13:22:07 +0200 Subject: [PATCH 5/5] fix(e2e): Revert test-utils to original develop state Remove waitForRootSpan/ChildSpan helpers that were accidentally included from the earlier draft iteration. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../test-utils/src/event-proxy-server.ts | 115 ------------------ dev-packages/test-utils/src/index.ts | 8 +- 2 files changed, 6 insertions(+), 117 deletions(-) diff --git a/dev-packages/test-utils/src/event-proxy-server.ts b/dev-packages/test-utils/src/event-proxy-server.ts index 7f65e5a66bd8..77effa924ff4 100644 --- a/dev-packages/test-utils/src/event-proxy-server.ts +++ b/dev-packages/test-utils/src/event-proxy-server.ts @@ -400,121 +400,6 @@ export function waitForTransaction( }); } -/** - * A normalized child span, usable regardless of `traceLifecycle`. - */ -export interface ChildSpan { - name: string; - op: string | undefined; - status: string | undefined; - attributes: Record; -} - -/** - * A shape-agnostic representation of a root span (a transaction in static mode, - * or a segment span in streaming mode). - */ -export interface RootSpan { - /** Transaction name / segment span name. */ - name: string; - /** The `sentry.op` value, if present. */ - op: string | undefined; - /** Span status (e.g. `ok`, `error`). */ - status: string | undefined; - /** The trace ID for linking assertions. */ - traceId: string | undefined; - /** Flat map of attributes (`contexts.trace.data` for transactions, flattened typed attributes for streamed spans). */ - attributes: Record; - /** Normalized child spans (`transaction.spans` in static mode, non-segment siblings in streaming mode). */ - childSpans: ChildSpan[]; - /** The raw envelope item body for advanced assertions. */ - raw: Event | SerializedStreamedSpan; -} - -/** - * Wait for a root span to be sent, regardless of `traceLifecycle`. - * Matches `transaction` envelope items in static mode and segment spans - * (`is_segment === true`) within `span` envelope items in streaming mode. - */ -export function waitForRootSpan( - proxyServerName: string, - callback: (rootSpan: RootSpan) => Promise | boolean, -): Promise { - const timestamp = getNanosecondTimestamp(); - return new Promise((resolve, reject) => { - waitForRequest( - proxyServerName, - async eventData => { - const envelopeItems = eventData.envelope[1]; - - for (const envelopeItem of envelopeItems) { - const [header, body] = envelopeItem; - - if (header.type === 'transaction') { - const event = body as Event; - const rootSpan: RootSpan = { - name: event.transaction || '', - op: event.contexts?.trace?.op, - status: event.contexts?.trace?.status, - traceId: event.contexts?.trace?.trace_id, - attributes: { ...(event.contexts?.trace?.data ?? {}) } as Record, - childSpans: (event.spans || []).map(span => ({ - name: span.description || '', - op: span.op, - status: span.status, - attributes: { ...(span.data ?? {}) } as Record, - })), - raw: event, - }; - if (await callback(rootSpan)) { - resolve(rootSpan); - return true; - } - continue; - } - - if (isStreamedSpanEnvelopeItem(envelopeItem)) { - const allItems = envelopeItem[1].items; - for (const span of allItems) { - if (!span.is_segment) { - continue; - } - const flatAttributes: Record = {}; - for (const [key, attr] of Object.entries(span.attributes ?? {})) { - flatAttributes[key] = attr?.value; - } - const childSpans: ChildSpan[] = allItems - .filter(s => s !== span) - .map(s => { - const attrs: Record = {}; - for (const [key, attr] of Object.entries(s.attributes ?? {})) { - attrs[key] = attr?.value; - } - return { name: s.name, op: getSpanOp(s), status: s.status, attributes: attrs }; - }); - const rootSpan: RootSpan = { - name: span.name, - op: getSpanOp(span), - status: span.status, - traceId: span.trace_id, - attributes: flatAttributes, - childSpans, - raw: span, - }; - if (await callback(rootSpan)) { - resolve(rootSpan); - return true; - } - } - } - } - return false; - }, - timestamp, - ).catch(reject); - }); -} - /** * Wait for metric items to be sent. */ diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index 302ed375637d..c47f46fcde5e 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -11,10 +11,8 @@ export { waitForStreamedSpan, waitForStreamedSpans, waitForStreamedSpanEnvelope, - waitForRootSpan, getSpanOp, } from './event-proxy-server'; -export type { ChildSpan, RootSpan } from './event-proxy-server'; export { getPlaywrightConfig } from './playwright-config'; export { createBasicSentryServer, createTestServer } from './server'; @@ -22,3 +20,9 @@ export { createBasicSentryServer, createTestServer } from './server'; export { startMockSentryServer } from './mock-sentry-server'; export type { MockSentryServerOptions, MockSentryServer } from './mock-sentry-server'; export * from './sourcemap-upload-utils'; + +export { CDPClient } from './cdp-client'; +export type { CDPClientOptions } from './cdp-client'; + +export { MemoryProfiler } from './memory-profiler'; +export type { MemoryProfilerOptions, SnapshotStats, SnapshotComparisonResult } from './memory-profiler';