From 0ff21fb2a325104a6eba1d31024d93cc75bcb208 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 2 Sep 2026 15:42:09 +0200 Subject: [PATCH] test(e2e): Port nextjs-16-bun to span streaming Removes the `traceLifecycle: 'static'` pins and rewrites the specs onto streamed spans. Server-component and propagation specs use `collectStreamedSpans`; the propagation spec matches the inbound span, outbound span and the `http.client` span between them within one trace, since all three share it. The middleware isolation-scope assertions were dropped, having no span v2 equivalent and no error event to assert them on instead. The app's existing Bun-specific gaps are preserved: it asserts fewer `url.*` attributes than `nextjs-16`, and the note about Bun not populating HTTP request headers as span attributes still applies, so no header assertions were added here. Ref #23802 Co-Authored-By: Claude Opus 5 (1M context) --- .../nextjs-16-bun/instrumentation-client.ts | 1 - .../nextjs-16-bun/sentry.edge.config.ts | 1 - .../nextjs-16-bun/sentry.server.config.ts | 1 - .../nextjs-16-bun/tests/middleware.test.ts | 21 +- .../tests/nested-rsc-error.test.ts | 17 +- .../tests/pageload-tracing.test.ts | 23 +-- .../tests/parameterized-routes.test.ts | 185 +++++------------- .../nextjs-16-bun/tests/propagation.test.ts | 58 +++--- .../nextjs-16-bun/tests/route-handler.test.ts | 14 +- .../tests/server-components.test.ts | 138 +++++++------ .../tests/streaming-rsc-error.test.ts | 17 +- 11 files changed, 200 insertions(+), 276 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/instrumentation-client.ts index a024284c197b..9c0366b0cea2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/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-16-bun/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/sentry.edge.config.ts index ee00e6481622..ad780407a5b7 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/sentry.edge.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/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-16-bun/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/sentry.server.config.ts index e9a4d3e81ffd..e683c5362818 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/sentry.server.config.ts @@ -3,7 +3,6 @@ import * as Sentry from '@sentry/nextjs'; import { bunServerIntegration, fetchIntegration } from '@sentry/bun'; 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-16-bun/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/middleware.test.ts index 22dcf4c8e83d..068c5454176e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/middleware.test.ts @@ -1,22 +1,17 @@ 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 transaction for middleware', async ({ request }) => { - const middlewareTransactionPromise = waitForTransaction('nextjs-16-bun', async transactionEvent => { - return transactionEvent?.transaction === 'middleware GET'; +test('Should create a span for middleware', async ({ request }) => { + const middlewareSpanPromise = waitForStreamedSpan('nextjs-16-bun', 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?.trace?.status).toBe('ok'); - expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware'); - expect(middlewareTransaction.contexts?.runtime?.name).toBe('node'); - expect(middlewareTransaction.transaction_info?.source).toBe('route'); - - // Assert that isolation scope works properly - expect(middlewareTransaction.tags?.['my-isolated-tag']).toBe(true); - expect(middlewareTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + expect(middlewareSpan.status).toBe('ok'); + expect(getSpanOp(middlewareSpan)).toBe('middleware'); + expect(middlewareSpan.attributes['sentry.segment.name.source']?.value).toBe('route'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/nested-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/nested-rsc-error.test.ts index 10cf1e353c32..7ca749c0b181 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/nested-rsc-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/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'; test('Should capture errors from nested server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({ page, @@ -8,16 +8,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-16-bun', 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-16-bun', 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-16-bun/tests/pageload-tracing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/pageload-tracing.test.ts index c1c103b5a11e..89ac76eadc1c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/pageload-tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/pageload-tracing.test.ts @@ -1,26 +1,21 @@ 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-16-bun', 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-16-bun', span => { + return span.name === 'GET /pageload-tracing' && span.is_segment; }); - const pageloadTransactionPromise = waitForTransaction('nextjs-16-bun', async transactionEvent => { - return transactionEvent?.transaction === '/pageload-tracing'; + const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-bun', 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); }); // Bun runtime does not populate HTTP request headers as span attributes diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/parameterized-routes.test.ts index 8d709a6534ed..27afcc6c3cf3 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/parameterized-routes.test.ts @@ -1,160 +1,83 @@ 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-16-bun', 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-16-bun', 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', - }, - 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' }, + 'react.version': { value: expect.any(String), type: 'string' }, }); }); -test('should create a transaction named after the static route when the `app` directory is used', async ({ page }) => { - const transactionPromise = waitForTransaction('nextjs-16-bun', 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-16-bun', 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.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' }, + 'react.version': { value: expect.any(String), type: 'string' }, + 'url.template': { value: '/parameterized/static', type: 'string' }, }); }); -test('should create a partially parameterized transaction when the `app` directory is used', async ({ page }) => { - const transactionPromise = waitForTransaction('nextjs-16-bun', 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-16-bun', 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', - }, - 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' }, + 'react.version': { value: expect.any(String), type: 'string' }, }); }); -test('should create a nested parameterized transaction when the `app` directory is used.', async ({ page }) => { - const transactionPromise = waitForTransaction('nextjs-16-bun', 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-16-bun', 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', - }, - 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' }, + 'react.version': { value: expect.any(String), type: 'string' }, }); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/propagation.test.ts index 231c233591b4..e82fb6453474 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/propagation.test.ts @@ -1,34 +1,41 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Propagates trace for outgoing fetch requests', async ({ baseURL, request }) => { - const inboundTransactionPromise = waitForTransaction('nextjs-16-bun', transactionEvent => { - return transactionEvent.transaction === 'GET /propagation/test-outgoing-fetch/check'; - }); - - const outboundTransactionPromise = waitForTransaction('nextjs-16-bun', transactionEvent => { - return transactionEvent.transaction === 'GET /propagation/test-outgoing-fetch'; + // Inbound span, outbound span and the http.client span in between all share one trace, and + // `collectStreamedSpans` evaluates a single trace at a time, so requiring all three together + // keeps them paired. + const spansPromise = collectStreamedSpans('nextjs-16-bun', spans => { + return ( + spans.some(span => span.name === 'GET /propagation/test-outgoing-fetch' && span.is_segment) && + spans.some(span => span.name === 'GET /propagation/test-outgoing-fetch/check' && span.is_segment) && + spans.some( + span => getSpanOp(span) === 'http.client' && span.attributes['sentry.origin']?.value === 'auto.http.fetch', + ) + ); }); const { headers } = await (await request.get(`${baseURL}/propagation/test-outgoing-fetch`)).json(); - const inboundTransaction = await inboundTransactionPromise; - const outboundTransaction = await outboundTransactionPromise; - - expect(inboundTransaction.contexts?.trace?.trace_id).toStrictEqual(expect.any(String)); - expect(inboundTransaction.contexts?.trace?.trace_id).toBe(outboundTransaction.contexts?.trace?.trace_id); - - const httpClientSpan = outboundTransaction.spans?.find( - span => span.op === 'http.client' && span.data?.['sentry.origin'] === 'auto.http.fetch', + const spans = await spansPromise; + const outboundSpan = spans.find(span => span.name === 'GET /propagation/test-outgoing-fetch' && span.is_segment)!; + const inboundSpan = spans.find( + span => span.name === 'GET /propagation/test-outgoing-fetch/check' && span.is_segment, + )!; + const httpClientSpan = spans.find( + span => getSpanOp(span) === 'http.client' && span.attributes['sentry.origin']?.value === 'auto.http.fetch', ); + expect(inboundSpan.trace_id).toStrictEqual(expect.any(String)); + expect(inboundSpan.trace_id).toBe(outboundSpan.trace_id); + expect(httpClientSpan).toBeDefined(); expect(httpClientSpan?.span_id).toStrictEqual(expect.any(String)); - expect(inboundTransaction.contexts?.trace?.parent_span_id).toBe(httpClientSpan?.span_id); + expect(inboundSpan.parent_span_id).toBe(httpClientSpan?.span_id); expect(headers).toMatchObject({ baggage: expect.any(String), - 'sentry-trace': `${outboundTransaction.contexts?.trace?.trace_id}-${httpClientSpan?.span_id}-1`, + 'sentry-trace': `${outboundSpan.trace_id}-${httpClientSpan?.span_id}-1`, }); }); @@ -36,12 +43,13 @@ test('Does not propagate outgoing fetch requests not covered by tracePropagation baseURL, request, }) => { - const inboundTransactionPromise = waitForTransaction('nextjs-16-bun', transactionEvent => { - return transactionEvent.transaction === 'GET /propagation/test-outgoing-fetch-external-disallowed/check'; + // These two spans are deliberately in different traces, so they are matched by their unique names. + const inboundSpanPromise = waitForStreamedSpan('nextjs-16-bun', span => { + return span.name === 'GET /propagation/test-outgoing-fetch-external-disallowed/check' && span.is_segment; }); - const outboundTransactionPromise = waitForTransaction('nextjs-16-bun', transactionEvent => { - return transactionEvent.transaction === 'GET /propagation/test-outgoing-fetch-external-disallowed'; + const outboundSpanPromise = waitForStreamedSpan('nextjs-16-bun', span => { + return span.name === 'GET /propagation/test-outgoing-fetch-external-disallowed' && span.is_segment; }); const { headers } = await ( @@ -51,9 +59,9 @@ test('Does not propagate outgoing fetch requests not covered by tracePropagation expect(headers.baggage).toBeUndefined(); expect(headers['sentry-trace']).toBeUndefined(); - const inboundTransaction = await inboundTransactionPromise; - const outboundTransaction = await outboundTransactionPromise; + const inboundSpan = await inboundSpanPromise; + const outboundSpan = await outboundSpanPromise; - expect(typeof outboundTransaction.contexts?.trace?.trace_id).toBe('string'); - expect(inboundTransaction.contexts?.trace?.trace_id).not.toBe(outboundTransaction.contexts?.trace?.trace_id); + expect(typeof outboundSpan.trace_id).toBe('string'); + expect(inboundSpan.trace_id).not.toBe(outboundSpan.trace_id); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/route-handler.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/route-handler.test.ts index ba967c3f9bf2..da51193cb345 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/route-handler.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/route-handler.test.ts @@ -1,16 +1,16 @@ import test, { expect } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('Should create a transaction for node route handlers', async ({ request }) => { - const routehandlerTransactionPromise = waitForTransaction('nextjs-16-bun', 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-16-bun', 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'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/server-components.test.ts index 5d1c580bc601..224278629d84 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/server-components.test.ts @@ -1,96 +1,92 @@ 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-16-bun', transactionEvent => { - return ( - transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' && - transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42') - ); - }); +// Streamed spans are flushed across multiple envelopes as they end, so the server-component child spans +// can arrive in a different (earlier) envelope than the `is_segment` root span. Accumulate spans across +// envelopes until the root span (which ends last) is seen. +function collectSpanNamesUntilSegment(segmentName: string): Promise { + return collectStreamedSpans('nextjs-16-bun', spans => + spans.some(span => span.name === segmentName && span.is_segment), + ).then(spans => spans.map(span => span.name)); +} - await page.goto('/parameterized/1337/beep/42'); +test('Sends a span for a request to app router with URL', async ({ page }) => { + const spansPromise = collectStreamedSpans('nextjs-16-bun', 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'), + ), + ); - const transactionEvent = await serverComponentTransactionPromise; + await page.goto('/parameterized/1337/beep/42'); - 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); }); -test('Will create a transaction with spans for every server component and metadata generation functions when visiting a page', async ({ +test('Will create spans for every server component and metadata generation functions when visiting a page', async ({ page, }) => { - const serverTransactionEventPromise = waitForTransaction('nextjs-16-bun', async transactionEvent => { - return transactionEvent?.transaction === 'GET /nested-layout'; - }); + const spanNamesPromise = collectSpanNamesUntilSegment('GET /nested-layout'); await page.goto('/nested-layout'); - const spanDescriptions = (await serverTransactionEventPromise).spans?.map(span => { - return span.description; - }); + const spanNames = await spanNamesPromise; - 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(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'); }); -test('Will create a transaction with spans for every server component and metadata generation functions when visiting a dynamic page', async ({ +test('Will create spans for every server component and metadata generation functions when visiting a dynamic page', async ({ page, }) => { - const serverTransactionEventPromise = waitForTransaction('nextjs-16-bun', async transactionEvent => { - return transactionEvent?.transaction === 'GET /nested-layout/[dynamic]'; - }); + const spanNamesPromise = collectSpanNamesUntilSegment('GET /nested-layout/[dynamic]'); await page.goto('/nested-layout/123'); - const spanDescriptions = (await serverTransactionEventPromise).spans?.map(span => { - return span.description; - }); + const spanNames = await spanNamesPromise; - 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(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'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/streaming-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/streaming-rsc-error.test.ts index 230ab730ed83..7b5482d4d8e2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/streaming-rsc-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/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'; test('Should capture errors for crashing streaming promises in server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({ page, @@ -8,18 +8,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-16-bun', 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-16-bun', 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),