From 1499f5027adbd52a155d6ed6caef256abb440cc4 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 2 Sep 2026 15:41:00 +0200 Subject: [PATCH] test(e2e): Port nextjs-14 to span streaming Removes the `traceLifecycle: 'static'` pins and rewrites the specs onto streamed spans. Tests asserting on children of a segment span (generation functions, request instrumentation, trace propagation) use `collectStreamedSpans` and accumulate until the segment span, which ends last. The propagation specs match the inbound span, outbound span and the `http.client` span between them within one trace, since all three share it. `http.client` span names are low cardinality under streaming, so the request-instrumentation spans are now named `GET github.com` rather than `GET https://github.com/`. The transaction-side `tags` assertions were dropped, having no span v2 equivalent; the error-side assertions still cover isolation scope. Ref #23802 Co-Authored-By: Claude Opus 5 (1M context) --- .../nextjs-14/instrumentation-client.ts | 1 - .../nextjs-14/instrumentation.ts | 1 - .../tests/generation-functions.test.ts | 98 ++++---- .../tests/parameterized-routes.test.ts | 211 ++++++------------ .../nextjs-14/tests/propagation.test.ts | 102 +++++---- .../tests/request-instrumentation.test.ts | 43 ++-- 6 files changed, 196 insertions(+), 260 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/instrumentation-client.ts index 97cc19287aeb..6dd7cce7b75e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/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-14/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/instrumentation.ts index 976e1461d1c4..18e5fc0e80a1 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/instrumentation.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/instrumentation.ts @@ -3,7 +3,6 @@ import * as Sentry from '@sentry/nextjs'; export function register() { if (process.env.NEXT_RUNTIME === 'nodejs' || process.env.NEXT_RUNTIME === 'edge') { 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-14/tests/generation-functions.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts index f0645c9fd8e5..ea81a6b76328 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts @@ -1,27 +1,35 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +// The generation-function spans are children of the segment span, which ends last, so accumulate +// spans until the segment for this request arrives. +function collectSpansForTarget(httpTarget: string) { + return collectStreamedSpans('nextjs-14', spans => + spans.some(span => span.is_segment && span.attributes['http.target']?.value === httpTarget), + ); +} test('Should emit a span for a generateMetadata() function invocation', async ({ page }) => { const testTitle = 'should-emit-span'; + const httpTarget = `/generation-functions?metadataTitle=${testTitle}`; - const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { - return ( - transactionEvent.contexts?.trace?.data?.['http.target'] === `/generation-functions?metadataTitle=${testTitle}` - ); - }); + const spansPromise = collectSpansForTarget(httpTarget); - await page.goto(`/generation-functions?metadataTitle=${testTitle}`); + await page.goto(httpTarget); - const transaction = await transactionPromise; + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment && span.attributes['http.target']?.value === httpTarget)!; - expect(transaction.spans).toContainEqual( + expect(spans).toContainEqual( expect.objectContaining({ - description: 'generateMetadata /generation-functions/page', - origin: 'auto', + name: 'generateMetadata /generation-functions/page', + status: 'ok', + trace_id: segmentSpan.trace_id, parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), - status: 'ok', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'auto', type: 'string' }, + }), }), ); @@ -29,16 +37,12 @@ test('Should emit a span for a generateMetadata() function invocation', async ({ expect(pageTitle).toBe(testTitle); }); -test('Should send a transaction and an error event for a faulty generateMetadata() function invocation', async ({ - page, -}) => { +test('Should send a span and an error event for a faulty generateMetadata() function invocation', async ({ page }) => { const testTitle = 'should-emit-error'; + const httpTarget = `/generation-functions?metadataTitle=${testTitle}&shouldThrowInGenerateMetadata=1`; - const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { - return ( - transactionEvent.contexts?.trace?.data?.['http.target'] === - `/generation-functions?metadataTitle=${testTitle}&shouldThrowInGenerateMetadata=1` - ); + const spanPromise = waitForStreamedSpan('nextjs-14', span => { + return span.is_segment && span.attributes['http.target']?.value === httpTarget; }); const errorEventPromise = waitForError('nextjs-14', errorEvent => { @@ -48,61 +52,57 @@ test('Should send a transaction and an error event for a faulty generateMetadata ); }); - await page.goto(`/generation-functions?metadataTitle=${testTitle}&shouldThrowInGenerateMetadata=1`); + await page.goto(httpTarget); const errorEvent = await errorEventPromise; - const transactionEvent = await transactionPromise; + expect(await spanPromise).toBeDefined(); - // Assert that isolation scope works properly + // Assert that isolation scope works properly. Span v2 carries no scope tags, so this is only + // asserted on the error event; the span-side assertions were dropped in the streaming port. expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); - expect(transactionEvent.tags?.['my-isolated-tag']).toBe(true); - expect(transactionEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); }); -test('Should send a transaction event for a generateViewport() function invocation', async ({ page }) => { +test('Should send a span for a generateViewport() function invocation', async ({ page }) => { const testTitle = 'floob'; + const httpTarget = `/generation-functions?viewportThemeColor=${testTitle}`; - const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { - return ( - transactionEvent.contexts?.trace?.data?.['http.target'] === - `/generation-functions?viewportThemeColor=${testTitle}` - ); - }); + const spansPromise = collectSpansForTarget(httpTarget); + + await page.goto(httpTarget); - await page.goto(`/generation-functions?viewportThemeColor=${testTitle}`); + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment && span.attributes['http.target']?.value === httpTarget)!; - expect((await transactionPromise).spans).toContainEqual( + expect(spans).toContainEqual( expect.objectContaining({ - description: 'generateViewport /generation-functions/page', - origin: 'auto', + name: 'generateViewport /generation-functions/page', + status: 'ok', + trace_id: segmentSpan.trace_id, parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), - status: 'ok', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'auto', type: 'string' }, + }), }), ); }); -test('Should send a transaction and an error event for a faulty generateViewport() function invocation', async ({ - page, -}) => { +test('Should send a span and an error event for a faulty generateViewport() function invocation', async ({ page }) => { const testTitle = 'blargh'; + const httpTarget = `/generation-functions?viewportThemeColor=${testTitle}&shouldThrowInGenerateViewport=1`; - const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { - return ( - transactionEvent.contexts?.trace?.data?.['http.target'] === - `/generation-functions?viewportThemeColor=${testTitle}&shouldThrowInGenerateViewport=1` - ); + const spanPromise = waitForStreamedSpan('nextjs-14', span => { + return span.is_segment && span.attributes['http.target']?.value === httpTarget; }); const errorEventPromise = waitForError('nextjs-14', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'generateViewport Error'; }); - await page.goto(`/generation-functions?viewportThemeColor=${testTitle}&shouldThrowInGenerateViewport=1`); + await page.goto(httpTarget); - expect(await transactionPromise).toBeDefined(); + expect(await spanPromise).toBeDefined(); expect(await errorEventPromise).toBeDefined(); const errorEvent = await errorEventPromise; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/parameterized-routes.test.ts index ce2ae23c075e..ba6ae3f6dae2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/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-14', 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-14', 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-14', 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-14', 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-14', 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-14', 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-14', 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-14', 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-14/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/propagation.test.ts index 92e60066a559..a8488fd9d20d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/propagation.test.ts @@ -1,64 +1,71 @@ 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 http requests', async ({ baseURL, request }) => { - const inboundTransactionPromise = waitForTransaction('nextjs-14', transactionEvent => { - return transactionEvent.transaction === 'GET /propagation/test-outgoing-http/check'; - }); - - const outboundTransactionPromise = waitForTransaction('nextjs-14', transactionEvent => { - return transactionEvent.transaction === 'GET /propagation/test-outgoing-http'; + // 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-14', spans => { + return ( + spans.some(span => span.name === 'GET /propagation/test-outgoing-http' && span.is_segment) && + spans.some(span => span.name === 'GET /propagation/test-outgoing-http/check' && span.is_segment) && + spans.some(span => getSpanOp(span) === 'http.client') + ); }); const { headers } = await (await request.get(`${baseURL}/propagation/test-outgoing-http`)).json(); - const inboundTransaction = await inboundTransactionPromise; - const outboundTransaction = await outboundTransactionPromise; + const spans = await spansPromise; + const outboundSpan = spans.find(span => span.name === 'GET /propagation/test-outgoing-http' && span.is_segment)!; + const inboundSpan = spans.find(span => span.name === 'GET /propagation/test-outgoing-http/check' && span.is_segment)!; + const httpClientSpan = spans.find(span => getSpanOp(span) === 'http.client'); - 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'); + 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`, }); }); test('Propagates trace for outgoing fetch requests', async ({ baseURL, request }) => { - const inboundTransactionPromise = waitForTransaction('nextjs-14', transactionEvent => { - return transactionEvent.transaction === 'GET /propagation/test-outgoing-fetch/check'; - }); - - const outboundTransactionPromise = waitForTransaction('nextjs-14', transactionEvent => { - return transactionEvent.transaction === 'GET /propagation/test-outgoing-fetch'; + const spansPromise = collectStreamedSpans('nextjs-14', 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.node_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.node_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.node_fetch', ); + expect(inboundSpan.trace_id).toStrictEqual(expect.any(String)); + expect(inboundSpan.trace_id).toBe(outboundSpan.trace_id); + // Right now we assert that the OTEL span is the last span before propagating 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`, }); }); @@ -66,12 +73,13 @@ test('Does not propagate outgoing http requests not covered by tracePropagationT baseURL, request, }) => { - const inboundTransactionPromise = waitForTransaction('nextjs-14', transactionEvent => { - return transactionEvent.transaction === 'GET /propagation/test-outgoing-http-external-disallowed/check'; + // These two spans are deliberately in different traces, so they are matched by their unique names. + const inboundSpanPromise = waitForStreamedSpan('nextjs-14', span => { + return span.name === 'GET /propagation/test-outgoing-http-external-disallowed/check' && span.is_segment; }); - const outboundTransactionPromise = waitForTransaction('nextjs-14', transactionEvent => { - return transactionEvent.transaction === 'GET /propagation/test-outgoing-http-external-disallowed'; + const outboundSpanPromise = waitForStreamedSpan('nextjs-14', span => { + return span.name === 'GET /propagation/test-outgoing-http-external-disallowed' && span.is_segment; }); const { headers } = await (await request.get(`${baseURL}/propagation/test-outgoing-http-external-disallowed`)).json(); @@ -79,23 +87,23 @@ test('Does not propagate outgoing http requests not covered by tracePropagationT 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); }); test('Does not propagate outgoing fetch requests not covered by tracePropagationTargets', async ({ baseURL, request, }) => { - const inboundTransactionPromise = waitForTransaction('nextjs-14', transactionEvent => { - return transactionEvent.transaction === 'GET /propagation/test-outgoing-fetch-external-disallowed/check'; + const inboundSpanPromise = waitForStreamedSpan('nextjs-14', span => { + return span.name === 'GET /propagation/test-outgoing-fetch-external-disallowed/check' && span.is_segment; }); - const outboundTransactionPromise = waitForTransaction('nextjs-14', transactionEvent => { - return transactionEvent.transaction === 'GET /propagation/test-outgoing-fetch-external-disallowed'; + const outboundSpanPromise = waitForStreamedSpan('nextjs-14', span => { + return span.name === 'GET /propagation/test-outgoing-fetch-external-disallowed' && span.is_segment; }); const { headers } = await ( @@ -105,9 +113,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-14/tests/request-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts index f1524ababc19..396f1e9e1605 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts @@ -1,36 +1,39 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans } from '@sentry-internal/test-utils'; -test('Should send a transaction with a fetch span', async ({ page }) => { - const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { - return transactionEvent?.transaction === 'GET /request-instrumentation'; - }); +test('Should send a fetch span', async ({ page }) => { + // The fetch spans are children of the segment span, which ends last. + const spansPromise = collectStreamedSpans('nextjs-14', spans => + spans.some(span => span.name === 'GET /request-instrumentation' && span.is_segment), + ); await page.goto(`/request-instrumentation`); - await expect(transactionPromise).resolves.toBeDefined(); - - const transactionEvent = await transactionPromise; + const spans = await spansPromise; - expect(transactionEvent.spans).toContainEqual( + // `http.client` span names are low cardinality under span streaming, so the name is the method and + // host rather than the full URL. + expect(spans).toContainEqual( expect.objectContaining({ - data: expect.objectContaining({ - 'http.request.method': 'GET', - 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.node_fetch', + name: 'GET github.com', + attributes: expect.objectContaining({ + 'http.request.method': { value: 'GET', type: 'string' }, + 'sentry.op': { value: 'http.client', type: 'string' }, + 'sentry.origin': { value: 'auto.http.node_fetch', type: 'string' }, + 'url.full': { value: 'https://github.com/', type: 'string' }, }), - description: 'GET https://github.com/', }), ); - expect(transactionEvent.spans).toContainEqual( + expect(spans).toContainEqual( expect.objectContaining({ - data: expect.objectContaining({ - 'http.request.method': 'GET', - 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.client', + name: 'GET github.com', + attributes: expect.objectContaining({ + 'http.request.method': { value: 'GET', type: 'string' }, + 'sentry.op': { value: 'http.client', type: 'string' }, + 'sentry.origin': { value: 'auto.http.client', type: 'string' }, + 'url.full': { value: 'https://github.com/', type: 'string' }, }), - description: 'GET https://github.com/', }), ); });