From d39e2d29ecad64f16dbda71ea3222193223e111b Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 2 Sep 2026 12:47:39 +0200 Subject: [PATCH] test(e2e): Port nextjs-15 to span streaming Removes the `traceLifecycle: 'static'` pins and rewrites the specs onto streamed spans. Tests asserting on children of a segment span use `collectStreamedSpans` and accumulate until the segment, which ends last. The RSC error specs correlate an error event with its server span. Those match the span on the error's own trace, so a span from an earlier spec cannot satisfy the correlation once streamed spans are flushed in batches. Request headers carry over as `http.request.header.*` span attributes, and the release as `sentry.release`, so the header-extraction and release-injection specs keep their coverage. The middleware specs asserted `request.url` / `request.method`; those become the `http.target` and `http.request.method` attributes. The `contexts.runtime.name === 'vercel-edge'` matcher was dropped, having no span v2 equivalent and only ever disambiguating a uniquely named span. Ref #23802 Co-Authored-By: Claude Opus 5 (1M context) --- .../nextjs-15/instrumentation-client.ts | 1 - .../nextjs-15/sentry.edge.config.ts | 1 - .../nextjs-15/sentry.server.config.ts | 1 - .../nextjs-15/tests/ai-error.test.ts | 14 +- .../nextjs-15/tests/ai-test.test.ts | 12 +- .../tests/client-trace-propagation.test.ts | 26 ++- .../nextjs-15/tests/i18n-routing.test.ts | 65 ++---- .../nextjs-15/tests/isr-routes.test.ts | 37 ++- .../nextjs-15/tests/middleware.test.ts | 66 +++--- .../nextjs-15/tests/nested-rsc-error.test.ts | 17 +- .../nextjs-15/tests/pageload-tracing.test.ts | 53 +++-- .../tests/parameterized-routes.test.ts | 211 ++++++------------ .../nextjs-15/tests/prefetch-spans.test.ts | 19 +- .../nextjs-15/tests/route-handler.test.ts | 69 +++--- .../tests/server-action-redirect.test.ts | 20 +- .../nextjs-15/tests/server-components.test.ts | 73 +++--- .../tests/streaming-rsc-error.test.ts | 17 +- .../nextjs-15/tests/suspense-error.test.ts | 10 +- 18 files changed, 300 insertions(+), 412 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation-client.ts index 97cc19287aeb..6dd7cce7b75e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation-client.ts @@ -1,7 +1,6 @@ import * as Sentry from '@sentry/nextjs'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, tunnel: `http://localhost:3031/`, // proxy server diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.edge.config.ts index b45f15d46f24..44ad0cb8e157 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.edge.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.edge.config.ts @@ -1,7 +1,6 @@ import * as Sentry from '@sentry/nextjs'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, tunnel: `http://localhost:3031/`, // proxy server diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.server.config.ts index 7182b7399e7c..8b2e37730190 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.server.config.ts @@ -1,7 +1,6 @@ import * as Sentry from '@sentry/nextjs'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, tunnel: `http://localhost:3031/`, // proxy server diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ai-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ai-error.test.ts index 7aed8bd3135a..f4cc147042e4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ai-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ai-error.test.ts @@ -1,13 +1,13 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForError, waitForStreamedSpans, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; // FIXME: This app uses `ai@^3`, which the channel-based Vercel AI integration doesn't instrument // (it supports v4-v6 via the orchestrion transform and v7 via the native `ai:telemetry` channel). // With channel-based instrumentation now the default, no gen_ai spans are produced. Re-enable once // the app is upgraded to `ai@v7` (or v3 support is restored). test.fixme('should create AI spans with correct attributes and error linking', async ({ page }) => { - const aiTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent.transaction === 'GET /ai-error-test'; + const aiSpanPromise = waitForStreamedSpan('nextjs-15', span => { + return span.name === 'GET /ai-error-test' && span.is_segment; }); // gen_ai spans are extracted into a separate span v2 envelope item @@ -21,12 +21,12 @@ test.fixme('should create AI spans with correct attributes and error linking', a await page.goto('/ai-error-test'); - const aiTransaction = await aiTransactionPromise; + const aiSpan = await aiSpanPromise; const genAiSpans = await genAiSpansPromise; const errorEvent = await errorEventPromise; - expect(aiTransaction).toBeDefined(); - expect(aiTransaction.transaction).toBe('GET /ai-error-test'); + expect(aiSpan).toBeDefined(); + expect(aiSpan.name).toBe('GET /ai-error-test'); // Each generateText call should create 2 spans: one for the pipeline and one for doGenerate // Plus a span for the tool call @@ -44,5 +44,5 @@ test.fixme('should create AI spans with correct attributes and error linking', a 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); + expect(errorEvent?.contexts?.trace?.trace_id).toBe(aiSpan.trace_id); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ai-test.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ai-test.test.ts index 36066e978727..982f00b7e42f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ai-test.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ai-test.test.ts @@ -1,13 +1,13 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpans, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; // FIXME: This app uses `ai@^3`, which the channel-based Vercel AI integration doesn't instrument // (it supports v4-v6 via the orchestrion transform and v7 via the native `ai:telemetry` channel). // With channel-based instrumentation now the default, no gen_ai spans are produced. Re-enable once // the app is upgraded to `ai@v7` (or v3 support is restored). test.fixme('should create AI spans with correct attributes', async ({ page }) => { - const aiTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent.transaction === 'GET /ai-test'; + const aiSpanPromise = waitForStreamedSpan('nextjs-15', span => { + return span.name === 'GET /ai-test' && span.is_segment; }); // gen_ai spans are extracted into a separate span v2 envelope item @@ -17,11 +17,11 @@ test.fixme('should create AI spans with correct attributes', async ({ page }) => await page.goto('/ai-test'); - const aiTransaction = await aiTransactionPromise; + const aiSpan = await aiSpanPromise; const genAiSpans = await genAiSpansPromise; - expect(aiTransaction).toBeDefined(); - expect(aiTransaction.transaction).toBe('GET /ai-test'); + expect(aiSpan).toBeDefined(); + expect(aiSpan.name).toBe('GET /ai-test'); // 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 diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/client-trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/client-trace-propagation.test.ts index 8f555d834a84..01fb5af62f05 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/client-trace-propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/client-trace-propagation.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { parseSemver } from '@sentry/core'; const packageJson = require('../package.json'); @@ -12,29 +12,33 @@ test('Should propagate traces from server to client in pages router', async ({ p 'Next.js version does not support clientside instrumentation', ); - const serverTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === 'GET /[locale]/pages-router-client-trace-propagation'; + const serverSpanPromise = waitForStreamedSpan('nextjs-15', span => { + return span.name === 'GET /[locale]/pages-router-client-trace-propagation' && span.is_segment; }); - const pageloadTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === '/[locale]/pages-router-client-trace-propagation'; + const pageloadSpanPromise = waitForStreamedSpan('nextjs-15', span => { + return ( + span.name === '/[locale]/pages-router-client-trace-propagation' && + getSpanOp(span) === 'pageload' && + span.is_segment + ); }); await page.goto(`/123/pages-router-client-trace-propagation`); - const serverTransaction = await serverTransactionPromise; - const pageloadTransaction = await pageloadTransactionPromise; + const serverSpan = await serverSpanPromise; + const pageloadSpan = await pageloadSpanPromise; - expect(serverTransaction.contexts?.trace?.trace_id).toBeDefined(); - expect(pageloadTransaction.contexts?.trace?.trace_id).toBe(serverTransaction.contexts?.trace?.trace_id); + expect(serverSpan.trace_id).toBeDefined(); + expect(pageloadSpan.trace_id).toBe(serverSpan.trace_id); await test.step('release was successfully injected on the serverside', () => { // Release as defined in next.config.js - expect(serverTransaction.release).toBe('foobar123'); + expect(serverSpan.attributes['sentry.release']?.value).toBe('foobar123'); }); await test.step('release was successfully injected on the clientside', () => { // Release as defined in next.config.js - expect(pageloadTransaction.release).toBe('foobar123'); + expect(pageloadSpan.attributes['sentry.release']?.value).toBe('foobar123'); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/i18n-routing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/i18n-routing.test.ts index 57db1e528845..864deee19f51 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/i18n-routing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/i18n-routing.test.ts @@ -1,56 +1,21 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('should create consistent parameterized transaction for i18n routes - locale: en', async ({ page }) => { - const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent.transaction === '/:locale/i18n-test' && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/en/i18n-test`); - - const transaction = await transactionPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - data: { - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.segment.name.source': 'route', - }, - op: 'pageload', - origin: 'auto.pageload.nextjs.app_router_instrumentation', - }, - }, - transaction: '/:locale/i18n-test', - transaction_info: { source: 'route' }, - type: 'transaction', - }); -}); - -test('should create consistent parameterized transaction for i18n routes - locale: ar', async ({ page }) => { - const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent.transaction === '/:locale/i18n-test' && transactionEvent.contexts?.trace?.op === 'pageload'; - }); +for (const locale of ['en', 'ar']) { + test(`should create consistent parameterized span for i18n routes - locale: ${locale}`, async ({ page }) => { + const spanPromise = waitForStreamedSpan('nextjs-15', span => { + return span.name === '/:locale/i18n-test' && getSpanOp(span) === 'pageload' && span.is_segment; + }); - await page.goto(`/ar/i18n-test`); + await page.goto(`/${locale}/i18n-test`); - const transaction = await transactionPromise; + const span = await spanPromise; - expect(transaction).toMatchObject({ - contexts: { - trace: { - data: { - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.segment.name.source': 'route', - }, - op: 'pageload', - origin: 'auto.pageload.nextjs.app_router_instrumentation', - }, - }, - transaction: '/:locale/i18n-test', - transaction_info: { source: 'route' }, - type: 'transaction', + expect(span.name).toBe('/:locale/i18n-test'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.nextjs.app_router_instrumentation', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + }); }); -}); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts index 7b4731bb8393..705b9208675e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } 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 @@ -41,15 +41,13 @@ test('should remove meta tags for different ISR dynamic route values', async ({ await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); }); -test('should create unique transactions for ISR pages on each visit', async ({ page }) => { +test('should create unique traces for ISR pages on each visit', async ({ page }) => { const traceIds: string[] = []; // 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-15', async transactionEvent => { - return !!( - transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload' - ); + const spanPromise = waitForStreamedSpan('nextjs-15', span => { + return span.name === '/isr-test/:product' && getSpanOp(span) === 'pageload' && span.is_segment; }); 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 span = await spanPromise; + const traceId = span.trace_id; expect(traceId).toBeDefined(); expect(traceId).toMatch(/[a-f0-9]{32}/); @@ -72,23 +70,14 @@ 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-15', async transactionEvent => { - return transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload'; + const spanPromise = waitForStreamedSpan('nextjs-15', span => { + return span.name === '/isr-test/:product' && getSpanOp(span) === 'pageload' && span.is_segment; }); 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.segment.name.source': 'route', - }, - }, - }, - }); + const span = await spanPromise; + + // Verify the span is properly parameterized + expect(span.name).toBe('/isr-test/:product'); + expect(span.attributes['sentry.segment.name.source']?.value).toBe('route'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/middleware.test.ts index 533af644c9d1..2fc5f77b3d25 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/middleware.test.ts @@ -1,80 +1,80 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; // The `tracesSampler` in `sentry.edge.config.ts` only samples `Middleware.execute` spans when `normalizedRequest` // is available at sampling time, so this test times out if the request data does not reach the sampler. test('tracesSampler receives normalizedRequest for edge middleware', async ({ request }) => { - const middlewareTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === 'middleware GET'; + const middlewareSpanPromise = waitForStreamedSpan('nextjs-15', span => { + return span.name === 'middleware GET' && span.is_segment; }); const response = await request.get('/api/endpoint-behind-middleware'); expect(await response.json()).toStrictEqual({ name: 'John Doe' }); - const middlewareTransaction = await middlewareTransactionPromise; + const middlewareSpan = await middlewareSpanPromise; - expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); - expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware'); - expect(middlewareTransaction.request?.url).toContain('/api/endpoint-behind-middleware'); - expect(middlewareTransaction.request?.method).toBe('GET'); + expect(getSpanOp(middlewareSpan)).toBe('middleware'); + expect(String(middlewareSpan.attributes['http.target']?.value)).toContain('/api/endpoint-behind-middleware'); + expect(middlewareSpan.attributes['http.request.method']?.value).toBe('GET'); }); // The `tracesSampler` additionally asserts that `normalizedRequest.url` matches the sampled span's own -// `http.target`, so a request leaking into the sampling context of a concurrent one drops that transaction +// `http.target`, so a request leaking into the sampling context of a concurrent one drops that span // and times this test out. test('does not leak normalizedRequest between concurrent middleware invocations', async ({ request }) => { - const firstTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + const firstSpanPromise = waitForStreamedSpan('nextjs-15', span => { return ( - transactionEvent?.transaction === 'middleware GET' && - transactionEvent.contexts?.trace?.data?.['http.target'] === '/api/endpoint-behind-middleware' + span.name === 'middleware GET' && + span.is_segment && + span.attributes['http.target']?.value === '/api/endpoint-behind-middleware' ); }); - const secondTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + const secondSpanPromise = waitForStreamedSpan('nextjs-15', span => { return ( - transactionEvent?.transaction === 'middleware GET' && - transactionEvent.contexts?.trace?.data?.['http.target'] === '/api/endpoint-behind-middleware-2' + span.name === 'middleware GET' && + span.is_segment && + span.attributes['http.target']?.value === '/api/endpoint-behind-middleware-2' ); }); await Promise.all([request.get('/api/endpoint-behind-middleware'), request.get('/api/endpoint-behind-middleware-2')]); - const [firstTransaction, secondTransaction] = await Promise.all([firstTransactionPromise, secondTransactionPromise]); + const [firstSpan, secondSpan] = await Promise.all([firstSpanPromise, secondSpanPromise]); - expect(firstTransaction.request?.url).toContain('/api/endpoint-behind-middleware'); - expect(firstTransaction.request?.url).not.toContain('/api/endpoint-behind-middleware-2'); - expect(secondTransaction.request?.url).toContain('/api/endpoint-behind-middleware-2'); + expect(String(firstSpan.attributes['http.target']?.value)).toContain('/api/endpoint-behind-middleware'); + expect(String(firstSpan.attributes['http.target']?.value)).not.toContain('/api/endpoint-behind-middleware-2'); + expect(String(secondSpan.attributes['http.target']?.value)).toContain('/api/endpoint-behind-middleware-2'); }); // Neither request sends inbound tracing headers, so each is the head of its own distributed trace. If concurrent // middleware invocations were to share an active span/scope (e.g. a leaked context on a warm edge worker), both -// transactions would inherit one trace id and collapse into a single trace - the production contamination we guard +// spans would inherit one trace id and collapse into a single trace - the production contamination we guard // against here. test('concurrent middleware invocations without inbound tracing headers get distinct trace ids', async ({ request, }) => { - const firstTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + const firstSpanPromise = waitForStreamedSpan('nextjs-15', span => { return ( - transactionEvent?.transaction === 'middleware GET' && - transactionEvent.contexts?.trace?.data?.['http.target'] === '/api/endpoint-behind-middleware' + span.name === 'middleware GET' && + span.is_segment && + span.attributes['http.target']?.value === '/api/endpoint-behind-middleware' ); }); - const secondTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + const secondSpanPromise = waitForStreamedSpan('nextjs-15', span => { return ( - transactionEvent?.transaction === 'middleware GET' && - transactionEvent.contexts?.trace?.data?.['http.target'] === '/api/endpoint-behind-middleware-2' + span.name === 'middleware GET' && + span.is_segment && + span.attributes['http.target']?.value === '/api/endpoint-behind-middleware-2' ); }); await Promise.all([request.get('/api/endpoint-behind-middleware'), request.get('/api/endpoint-behind-middleware-2')]); - const [firstTransaction, secondTransaction] = await Promise.all([firstTransactionPromise, secondTransactionPromise]); + const [firstSpan, secondSpan] = await Promise.all([firstSpanPromise, secondSpanPromise]); - const firstTraceId = firstTransaction.contexts?.trace?.trace_id; - const secondTraceId = secondTransaction.contexts?.trace?.trace_id; - - expect(firstTraceId).toMatch(/^[a-f0-9]{32}$/); - expect(secondTraceId).toMatch(/^[a-f0-9]{32}$/); - expect(firstTraceId).not.toBe(secondTraceId); + expect(firstSpan.trace_id).toMatch(/^[a-f0-9]{32}$/); + expect(secondSpan.trace_id).toMatch(/^[a-f0-9]{32}$/); + expect(firstSpan.trace_id).not.toBe(secondSpan.trace_id); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/nested-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/nested-rsc-error.test.ts index 68d03a00d601..30704d284b63 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/nested-rsc-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/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, waitForStreamedSpan } from '@sentry-internal/test-utils'; const packageJson = require('../package.json'); @@ -19,16 +19,21 @@ 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-15', async transactionEvent => { - return transactionEvent?.transaction === 'GET /nested-rsc-error/[param]'; + // Matched on the error's own trace so a span from an earlier spec cannot satisfy the correlation. + const serverSpanPromise = waitForStreamedSpan('nextjs-15', async span => { + return ( + span.name === 'GET /nested-rsc-error/[param]' && + span.is_segment && + (await errorEventPromise).contexts?.trace?.trace_id === span.trace_id + ); }); await page.goto(`/nested-rsc-error/123`); const errorEvent = await errorEventPromise; - const serverTransactionEvent = await serverTransactionPromise; + const serverSpan = await serverSpanPromise; - // error event is part of the transaction - expect(errorEvent.contexts?.trace?.trace_id).toBe(serverTransactionEvent.contexts?.trace?.trace_id); + // error event is part of the same trace as the server span + expect(errorEvent.contexts?.trace?.trace_id).toBe(serverSpan.trace_id); expect(errorEvent.request).toMatchObject({ headers: expect.any(Object), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts index c9a9fc44bdba..23077e6f8b9b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts @@ -1,31 +1,30 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('App router transactions should be attached to the pageload request span', async ({ page }) => { - const serverTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === 'GET /pageload-tracing'; +test('App router spans should be attached to the pageload request span', async ({ page }) => { + const serverSpanPromise = waitForStreamedSpan('nextjs-15', span => { + return span.name === 'GET /pageload-tracing' && span.is_segment; }); - const pageloadTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === '/pageload-tracing'; + const pageloadSpanPromise = waitForStreamedSpan('nextjs-15', span => { + return span.name === '/pageload-tracing' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/pageload-tracing`); - const [serverTransaction, pageloadTransaction] = await Promise.all([ - serverTransactionPromise, - pageloadTransactionPromise, - ]); + const [serverSpan, pageloadSpan] = await Promise.all([serverSpanPromise, pageloadSpanPromise]); - const pageloadTraceId = pageloadTransaction.contexts?.trace?.trace_id; - - expect(pageloadTraceId).toBeTruthy(); - expect(serverTransaction.contexts?.trace?.trace_id).toBe(pageloadTraceId); + expect(pageloadSpan.trace_id).toBeTruthy(); + expect(serverSpan.trace_id).toBe(pageloadSpan.trace_id); }); test('extracts HTTP request headers as span attributes', async ({ baseURL }) => { - const serverTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === 'GET /pageload-tracing'; + const serverSpanPromise = waitForStreamedSpan('nextjs-15', span => { + return ( + span.name === 'GET /pageload-tracing' && + span.is_segment && + span.attributes['http.request.header.x_request_id']?.value === 'nextjs-789' + ); }); await fetch(`${baseURL}/pageload-tracing`, { @@ -39,16 +38,14 @@ test('extracts HTTP request headers as span attributes', async ({ baseURL }) => }, }); - const serverTransaction = await serverTransactionPromise; - - 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', - 'http.request.header.x_nextjs_test': 'nextjs-header-value', - 'http.request.header.accept': 'text/html, application/xhtml+xml', - 'http.request.header.x_framework': 'Next.js', - 'http.request.header.x_request_id': 'nextjs-789', - }), - ); + const serverSpan = await serverSpanPromise; + + expect(serverSpan.attributes).toMatchObject({ + 'http.request.header.user_agent': { value: 'Custom-NextJS-Agent/15.0', type: 'string' }, + 'http.request.header.content_type': { value: 'text/html', type: 'string' }, + 'http.request.header.x_nextjs_test': { value: 'nextjs-header-value', type: 'string' }, + 'http.request.header.accept': { value: 'text/html, application/xhtml+xml', type: 'string' }, + 'http.request.header.x_framework': { value: 'Next.js', type: 'string' }, + 'http.request.header.x_request_id': { value: 'nextjs-789', type: 'string' }, + }); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts index e6da9120878e..c55075c2940c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts @@ -1,171 +1,98 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('should create a parameterized transaction when the `app` directory is used', async ({ page }) => { - const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return ( - transactionEvent.transaction === '/parameterized/:one' && transactionEvent.contexts?.trace?.op === 'pageload' - ); +test('should create a parameterized pageload span when the `app` directory is used', async ({ page }) => { + const spanPromise = waitForStreamedSpan('nextjs-15', span => { + return span.name === '/parameterized/:one' && getSpanOp(span) === 'pageload' && span.is_segment; }); 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.segment.name.source': 'route', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/parameterized\/cappuccino$/), - 'url.path': '/parameterized/cappuccino', - 'url.template': '/parameterized/:one', - }, - 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 span = await spanPromise; + + expect(span.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/)); + expect(span.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/)); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.nextjs.app_router_instrumentation', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'url.path': { value: '/parameterized/cappuccino', type: 'string' }, + 'url.template': { value: '/parameterized/:one', type: 'string' }, + 'react.version': { value: expect.any(String), type: 'string' }, }); + expect(String(span.attributes['url.full']?.value)).toMatch(/^https?:\/\/localhost:\d+\/parameterized\/cappuccino$/); }); -test('should create a transaction named after the static route when the `app` directory is used', async ({ page }) => { - const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return ( - transactionEvent.transaction === '/parameterized/static' && transactionEvent.contexts?.trace?.op === 'pageload' - ); +test('should create a span named after the static route when the `app` directory is used', async ({ page }) => { + const spanPromise = waitForStreamedSpan('nextjs-15', span => { + return span.name === '/parameterized/static' && getSpanOp(span) === 'pageload' && span.is_segment; }); 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.segment.name.source': 'route', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/parameterized\/static$/), - 'url.path': '/parameterized/static', - 'url.template': '/parameterized/static', - }, - 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: 'route' }, - type: 'transaction', + const span = await spanPromise; + + expect(span.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/)); + expect(span.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/)); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.nextjs.app_router_instrumentation', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'url.path': { value: '/parameterized/static', type: 'string' }, + 'url.template': { value: '/parameterized/static', type: 'string' }, + 'react.version': { value: expect.any(String), type: 'string' }, }); + expect(String(span.attributes['url.full']?.value)).toMatch(/^https?:\/\/localhost:\d+\/parameterized\/static$/); }); -test('should create a partially parameterized transaction when the `app` directory is used', async ({ page }) => { - const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return ( - transactionEvent.transaction === '/parameterized/:one/beep' && transactionEvent.contexts?.trace?.op === 'pageload' - ); +test('should create a partially parameterized pageload span when the `app` directory is used', async ({ page }) => { + const spanPromise = waitForStreamedSpan('nextjs-15', span => { + return span.name === '/parameterized/:one/beep' && getSpanOp(span) === 'pageload' && span.is_segment; }); 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.segment.name.source': 'route', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/parameterized\/cappuccino\/beep$/), - 'url.path': '/parameterized/cappuccino/beep', - 'url.template': '/parameterized/:one/beep', - }, - 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 span = await spanPromise; + + expect(span.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/)); + expect(span.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/)); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.nextjs.app_router_instrumentation', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'url.path': { value: '/parameterized/cappuccino/beep', type: 'string' }, + 'url.template': { value: '/parameterized/:one/beep', type: 'string' }, + 'react.version': { value: expect.any(String), type: 'string' }, }); + expect(String(span.attributes['url.full']?.value)).toMatch( + /^https?:\/\/localhost:\d+\/parameterized\/cappuccino\/beep$/, + ); }); -test('should create a nested parameterized transaction when the `app` directory is used.', async ({ page }) => { - const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return ( - transactionEvent.transaction === '/parameterized/:one/beep/:two' && - transactionEvent.contexts?.trace?.op === 'pageload' - ); +test('should create a nested parameterized pageload span when the `app` directory is used.', async ({ page }) => { + const spanPromise = waitForStreamedSpan('nextjs-15', span => { + return span.name === '/parameterized/:one/beep/:two' && getSpanOp(span) === 'pageload' && span.is_segment; }); 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.segment.name.source': 'route', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/parameterized\/cappuccino\/beep\/espresso$/), - 'url.path': '/parameterized/cappuccino/beep/espresso', - 'url.template': '/parameterized/:one/beep/:two', - }, - 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 span = await spanPromise; + + expect(span.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/)); + expect(span.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/)); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.nextjs.app_router_instrumentation', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'url.path': { value: '/parameterized/cappuccino/beep/espresso', type: 'string' }, + 'url.template': { value: '/parameterized/:one/beep/:two', type: 'string' }, + 'react.version': { value: expect.any(String), type: 'string' }, }); + expect(String(span.attributes['url.full']?.value)).toMatch( + /^https?:\/\/localhost:\d+\/parameterized\/cappuccino\/beep\/espresso$/, + ); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/prefetch-spans.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/prefetch-spans.test.ts index 550f6726e789..b79e8159737e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/prefetch-spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/prefetch-spans.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans } from '@sentry-internal/test-utils'; test('Prefetch client spans should have a http.request.prefetch attribute', async ({ page }) => { test.skip( @@ -7,20 +7,23 @@ test('Prefetch client spans should have a http.request.prefetch attribute', asyn "Prefetch requests don't have the prefetch header in dev mode", ); - const pageloadTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === '/prefetching'; - }); + // The prefetch span is a child of the pageload segment span, which ends last. + const spansPromise = collectStreamedSpans('nextjs-15', spans => + spans.some(span => span.name === '/prefetching' && span.is_segment), + ); await page.goto(`/prefetching`); // Make it more likely that nextjs prefetches await page.hover('#prefetch-link'); - expect((await pageloadTransactionPromise).spans).toContainEqual( + const spans = await spansPromise; + + expect(spans).toContainEqual( expect.objectContaining({ - op: 'http.client', - data: expect.objectContaining({ - 'http.request.prefetch': true, + attributes: expect.objectContaining({ + 'sentry.op': { value: 'http.client', type: 'string' }, + 'http.request.prefetch': { value: true, type: 'boolean' }, }), }), ); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/route-handler.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/route-handler.test.ts index 09def19c2af9..6a276032723f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/route-handler.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/route-handler.test.ts @@ -1,90 +1,91 @@ import test, { expect } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('Should create a transaction for node route handlers', async ({ request }) => { - const routehandlerTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === 'GET /route-handler/[xoxo]/node'; +test('Should create a span for node route handlers', async ({ request }) => { + const routehandlerSpanPromise = waitForStreamedSpan('nextjs-15', span => { + return span.name === 'GET /route-handler/[xoxo]/node' && 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 routehandlerTransaction = await routehandlerTransactionPromise; + const routehandlerSpan = await routehandlerSpanPromise; - expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok'); - expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); + expect(routehandlerSpan.status).toBe('ok'); + expect(getSpanOp(routehandlerSpan)).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(routehandlerSpan.attributes['http.request.header.x_charly']?.value).toBe('gomez'); } }); -test('Should create a transaction for edge route handlers', async ({ request }) => { +test('Should create a span for edge route handlers', async ({ request }) => { // This test only works for webpack builds on non-async param extraction // todo: check if we can set request headers for edge on sdkProcessingMetadata test.skip(); - const routehandlerTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === 'GET /route-handler/[xoxo]/edge'; + const routehandlerSpanPromise = waitForStreamedSpan('nextjs-15', span => { + return span.name === 'GET /route-handler/[xoxo]/edge' && span.is_segment; }); const response = await request.get('/route-handler/123/edge', { headers: { 'x-charly': 'gomez' } }); expect(await response.json()).toStrictEqual({ message: 'Hello Edge Route Handler' }); - const routehandlerTransaction = await routehandlerTransactionPromise; + const routehandlerSpan = await routehandlerSpanPromise; - expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok'); - expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); - expect(routehandlerTransaction.contexts?.trace?.data?.['http.request.header.x_charly']).toBe('gomez'); + expect(routehandlerSpan.status).toBe('ok'); + expect(getSpanOp(routehandlerSpan)).toBe('http.server'); + expect(routehandlerSpan.attributes['http.request.header.x_charly']?.value).toBe('gomez'); }); -test('Should create a transaction for static route handlers', async ({ request }) => { - const routehandlerTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === 'GET /route-handler/static'; +test('Should create a span for static route handlers', async ({ request }) => { + const routehandlerSpanPromise = waitForStreamedSpan('nextjs-15', span => { + return span.name === 'GET /route-handler/static' && span.is_segment; }); const response = await request.get('/route-handler/static'); expect(await response.json()).toStrictEqual({ name: 'Static' }); - const routehandlerTransaction = await routehandlerTransactionPromise; + const routehandlerSpan = await routehandlerSpanPromise; - expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok'); - expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); + expect(routehandlerSpan.status).toBe('ok'); + expect(getSpanOp(routehandlerSpan)).toBe('http.server'); }); -test('Should create a transaction for route handlers and correctly set span status depending on http status', async ({ +test('Should create a span for route handlers and correctly set span status depending on http status', async ({ request, }) => { - const routehandlerTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === 'POST /route-handler/[xoxo]/node'; + const routehandlerSpanPromise = waitForStreamedSpan('nextjs-15', span => { + return span.name === 'POST /route-handler/[xoxo]/node' && span.is_segment; }); const response = await request.post('/route-handler/123/node'); expect(await response.json()).toStrictEqual({ name: 'Boop' }); - const routehandlerTransaction = await routehandlerTransactionPromise; + const routehandlerSpan = await routehandlerSpanPromise; - expect(routehandlerTransaction.contexts?.trace?.status).toBe('invalid_argument'); - expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); + expect(routehandlerSpan.status).toBe('error'); + expect(routehandlerSpan.attributes['sentry.status.message']?.value).toBe('invalid_argument'); + expect(getSpanOp(routehandlerSpan)).toBe('http.server'); }); -test('Should record exceptions and transactions for faulty route handlers', async ({ request }) => { +test('Should record exceptions and spans for faulty route handlers', async ({ request }) => { const errorEventPromise = waitForError('nextjs-15', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Route handler error'; }); - const routehandlerTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === 'GET /route-handler/[xoxo]/error'; + const routehandlerSpanPromise = waitForStreamedSpan('nextjs-15', span => { + return span.name === 'GET /route-handler/[xoxo]/error' && span.is_segment; }); await request.get('/route-handler/123/error').catch(() => {}); - const routehandlerTransaction = await routehandlerTransactionPromise; + const routehandlerSpan = await routehandlerSpanPromise; const routehandlerError = await errorEventPromise; - expect(routehandlerTransaction.contexts?.trace?.status).toBe('internal_error'); - expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); - expect(routehandlerTransaction.contexts?.trace?.origin).toContain('auto'); + expect(routehandlerSpan.status).toBe('error'); + expect(getSpanOp(routehandlerSpan)).toBe('http.server'); + expect(String(routehandlerSpan.attributes['sentry.origin']?.value)).toContain('auto'); expect(routehandlerError.exception?.values?.[0].value).toBe('Route handler error'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/server-action-redirect.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/server-action-redirect.test.ts index d46936fa6b2f..81d808ee9a10 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/server-action-redirect.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/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, waitForStreamedSpan } 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-15', async transactionEvent => { - return transactionEvent?.transaction === '/redirect/origin'; + // Wait for the initial pageload span + const pageLoadSpanPromise = waitForStreamedSpan('nextjs-15', span => { + return span.name === '/redirect/origin' && span.is_segment; }); // Navigate to the origin page await page.goto('/redirect/origin'); - const pageLoadTransaction = await pageLoadTransactionPromise; - expect(pageLoadTransaction).toBeDefined(); + const pageLoadSpan = await pageLoadSpanPromise; + expect(pageLoadSpan).toBeDefined(); - // Wait for the redirect transaction - const redirectTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === 'GET /redirect/destination'; + // Wait for the redirect span + const redirectSpanPromise = waitForStreamedSpan('nextjs-15', span => { + return span.name === 'GET /redirect/destination' && span.is_segment; }); // 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 redirectSpanPromise; // Verify we got redirected to the destination page await expect(page).toHaveURL('/redirect/destination'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/server-components.test.ts index 3415e6705be9..4dd80c0dfbc8 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/server-components.test.ts @@ -1,48 +1,43 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans } from '@sentry-internal/test-utils'; -test('Sends a transaction for a request to app router with URL', async ({ page }) => { - const serverComponentTransactionPromise = waitForTransaction('nextjs-15', transactionEvent => { - return ( - transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' && - transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42') - ); - }); +test('Sends a span for a request to app router with URL', async ({ page }) => { + const spansPromise = collectStreamedSpans('nextjs-15', spans => + spans.some( + span => + span.name === 'GET /parameterized/[one]/beep/[two]' && + span.is_segment && + String(span.attributes['http.target']?.value).startsWith('/parameterized/1337/beep/42'), + ), + ); await page.goto('/parameterized/1337/beep/42'); - const transactionEvent = await serverComponentTransactionPromise; - - expect(transactionEvent.contexts?.trace).toEqual({ - data: expect.objectContaining({ - 'sentry.op': 'http.server', - 'sentry.origin': 'auto', - 'sentry.sample_rate': 1, - 'sentry.segment.name.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', - 'sentry.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}/), - }); + const spans = await spansPromise; + const segmentSpan = spans.find( + span => + span.name === 'GET /parameterized/[one]/beep/[two]' && + span.is_segment && + String(span.attributes['http.target']?.value).startsWith('/parameterized/1337/beep/42'), + )!; - expect(transactionEvent.request).toMatchObject({ - url: expect.stringContaining('/parameterized/1337/beep/42'), + expect(segmentSpan.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/)); + expect(segmentSpan.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/)); + expect(segmentSpan.status).toBe('ok'); + expect(segmentSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'http.method': { value: 'GET', type: 'string' }, + 'http.response.status_code': { value: 200, type: 'integer' }, + 'http.route': { value: '/parameterized/[one]/beep/[two]', type: 'string' }, + 'http.status_code': { value: 200, type: 'integer' }, + 'http.target': { value: '/parameterized/1337/beep/42', type: 'string' }, + 'sentry.kind': { value: 'server', type: 'string' }, + 'next.route': { value: '/parameterized/[one]/beep/[two]', type: 'string' }, }); - // 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); + // No child span should share the segment span's name + expect(spans.filter(span => !span.is_segment && span.name === segmentSpan.name)).toHaveLength(0); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/streaming-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/streaming-rsc-error.test.ts index d1bad46afe5f..5d2d8d2db9bf 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/streaming-rsc-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/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, waitForStreamedSpan } from '@sentry-internal/test-utils'; const packageJson = require('../package.json'); @@ -19,18 +19,23 @@ 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-15', async transactionEvent => { - return transactionEvent?.transaction === 'GET /streaming-rsc-error/[param]'; + // Matched on the error's own trace so a span from an earlier spec cannot satisfy the correlation. + const serverSpanPromise = waitForStreamedSpan('nextjs-15', async span => { + return ( + span.name === 'GET /streaming-rsc-error/[param]' && + span.is_segment && + (await errorEventPromise).contexts?.trace?.trace_id === span.trace_id + ); }); // The streaming RSC error can interrupt the HTTP response, causing the navigation to reject // (e.g. net::ERR_ABORTED) even though the error and transaction are still captured. await page.goto(`/streaming-rsc-error/123`).catch(() => {}); const errorEvent = await errorEventPromise; - const serverTransactionEvent = await serverTransactionPromise; + const serverSpan = await serverSpanPromise; - // error event is part of the transaction - expect(errorEvent.contexts?.trace?.trace_id).toBe(serverTransactionEvent.contexts?.trace?.trace_id); + // error event is part of the same trace as the server span + expect(errorEvent.contexts?.trace?.trace_id).toBe(serverSpan.trace_id); expect(errorEvent.request).toMatchObject({ headers: expect.any(Object), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/suspense-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/suspense-error.test.ts index e158e87ae19f..eaa7d1bbc92a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/suspense-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/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, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('should not capture serverside suspense errors', async ({ page }) => { - const pageServerComponentTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === 'GET /suspense-error'; + const pageServerComponentSpanPromise = waitForStreamedSpan('nextjs-15', span => { + return span.name === 'GET /suspense-error' && span.is_segment; }); 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 pageServerComponentSpan = await pageServerComponentSpanPromise; + expect(pageServerComponentSpan).toBeDefined(); expect(errorEvent).toBeUndefined(); });