From 145b8d836aa2913e5bccd9b9ffde7eda86e1a966 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 2 Sep 2026 15:41:46 +0200 Subject: [PATCH] test(e2e): Port nextjs-16 to span streaming Removes the `traceLifecycle: 'static'` pins and rewrites the specs onto streamed spans. Tests asserting on children of a segment span (server components, DB instrumentation, middleware fetch, prefetch) use `collectStreamedSpans` and accumulate until the segment, which ends last. The route-handler specs correlate an error or message event with its server span; those match the span on the event's own trace, so batching cannot pair spans across specs. `http.client` span names are low cardinality under streaming, so the middleware fetch span is `GET localhost` rather than `GET http://localhost:3030/` - which also mattered for the matcher, since the old name was used as a predicate and would have hung rather than failed. Dropped, having no span v2 equivalent: - The middleware isolation-scope assertions, including the guard that middleware tags do not leak onto the following route span. Span v2 carries no scope tags, so neither the tag nor its absence is observable. - The middleware `breadcrumbs` assertion. The fetch stays covered by its `http.client` span. - The `contexts.runtime.name` matchers, which only disambiguated uniquely named spans. Request headers carry over as `http.request.header.*` attributes, so the route-handler and pageload header assertions keep their coverage. Ref #23802 Co-Authored-By: Claude Opus 5 (1M context) --- .../nextjs-16/instrumentation-client.ts | 1 - .../nextjs-16/sentry.edge.config.ts | 1 - .../nextjs-16/sentry.server.config.ts | 1 - .../nextjs-16/tests/ai-error.test.ts | 14 +- .../nextjs-16/tests/ai-test.test.ts | 12 +- .../nextjs-16/tests/db-page.test.ts | 55 ++--- .../nextjs-16/tests/isr-routes.test.ts | 37 ++- .../nextjs-16/tests/middleware.test.ts | 164 ++++---------- .../nextjs-16/tests/nested-rsc-error.test.ts | 17 +- .../nextjs-16/tests/pageload-tracing.test.ts | 53 +++-- .../tests/parameterized-routes.test.ts | 211 ++++++------------ .../nextjs-16/tests/prefetch-spans.test.ts | 19 +- .../nextjs-16/tests/route-handler.test.ts | 92 ++++---- .../tests/server-action-redirect.test.ts | 20 +- .../nextjs-16/tests/server-components.test.ts | 142 ++++++------ .../tests/streaming-rsc-error.test.ts | 17 +- .../nextjs-16/tests/suspense-error.test.ts | 10 +- .../nextjs-16/tests/vercel-queue.test.ts | 45 ++-- 18 files changed, 376 insertions(+), 535 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts index 47ce2790e694..d0f5b13bb555 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/nextjs'; import type { Log } 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/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts index 7233c9208dad..78271945710f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts @@ -1,7 +1,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/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts index d8bf4883a137..4507c799f9f3 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/nextjs'; import { Log } 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/tests/ai-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-error.test.ts index 07725170bc9e..b5bb718c62ab 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-error.test.ts @@ -1,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-16', async transactionEvent => { - return transactionEvent.transaction === 'GET /ai-error-test'; + const aiSpanPromise = waitForStreamedSpan('nextjs-16', 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-16/tests/ai-test.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-test.test.ts index 5d1a8ed277ae..391df625e603 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-test.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-test.test.ts @@ -1,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-16', async transactionEvent => { - return transactionEvent.transaction === 'GET /ai-test'; + const aiSpanPromise = waitForStreamedSpan('nextjs-16', 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-16/tests/db-page.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/db-page.test.ts index 564192e1d0a9..d0d9c4d9a8c7 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/db-page.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/db-page.test.ts @@ -1,56 +1,57 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans } from '@sentry-internal/test-utils'; test('Instruments DB calls made during server-side rendering of a page', async ({ page }) => { - const transactionEventPromise = waitForTransaction('nextjs-16', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /db-page'; - }); + // The db spans are children of the segment span, which ends last. + const spansPromise = collectStreamedSpans('nextjs-16', spans => + spans.some(span => span.name === 'GET /db-page' && span.is_segment), + ); await page.goto('/db-page'); await expect(page.locator('#answer')).toHaveText('answer: 42'); await expect(page.locator('#cached')).toHaveText('cached: 42'); - const transactionEvent = await transactionEventPromise; - - const spans = transactionEvent.spans || []; + const spans = await spansPromise; // One page render produces spans from both injection paths: pg (externalized → runtime module // hook) and ioredis (bundle-safe allowlisted → build-time loader). + // The postgres span name is low cardinality under span streaming (just the operation); the query + // itself is asserted below via `db.query.text`. The redis span names still carry their full text. expect(spans).toContainEqual( expect.objectContaining({ - op: 'db', - origin: 'auto.db.postgres', - description: 'SELECT 40 + 2 AS answer', + name: 'SELECT', status: 'ok', - data: expect.objectContaining({ - 'db.system.name': 'postgresql', - 'db.query.text': 'SELECT 40 + 2 AS answer', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'db', type: 'string' }, + 'sentry.origin': { value: 'auto.db.postgres', type: 'string' }, + 'db.system.name': { value: 'postgresql', type: 'string' }, + 'db.query.text': { value: 'SELECT 40 + 2 AS answer', type: 'string' }, }), }), ); expect(spans).toContainEqual( expect.objectContaining({ - op: 'db.query', - origin: 'auto.db.redis', - description: 'set page-key [1 other arguments]', + name: 'set page-key [1 other arguments]', status: 'ok', - data: expect.objectContaining({ - 'db.system.name': 'redis', - 'db.operation.name': 'set', - 'db.query.text': 'set page-key [1 other arguments]', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'db.query', type: 'string' }, + 'sentry.origin': { value: 'auto.db.redis', type: 'string' }, + 'db.system.name': { value: 'redis', type: 'string' }, + 'db.operation.name': { value: 'set', type: 'string' }, + 'db.query.text': { value: 'set page-key [1 other arguments]', type: 'string' }, }), }), ); expect(spans).toContainEqual( expect.objectContaining({ - op: 'db.query', - origin: 'auto.db.redis', - description: 'get page-key', + name: 'get page-key', status: 'ok', - data: expect.objectContaining({ - 'db.system.name': 'redis', - 'db.operation.name': 'get', - 'db.query.text': 'get page-key', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'db.query', type: 'string' }, + 'sentry.origin': { value: 'auto.db.redis', type: 'string' }, + 'db.system.name': { value: 'redis', type: 'string' }, + 'db.operation.name': { value: 'get', type: 'string' }, + 'db.query.text': { value: 'get page-key', type: 'string' }, }), }), ); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts index 94d5863c9360..ce215ef457e9 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { 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-16', async transactionEvent => { - return !!( - transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload' - ); + const spanPromise = waitForStreamedSpan('nextjs-16', 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-16', async transactionEvent => { - return transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload'; + const spanPromise = waitForStreamedSpan('nextjs-16', 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-16/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts index 41944552619a..9a0214980632 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts @@ -1,155 +1,81 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { isDevMode } from './isDevMode'; -test('Should create a transaction for middleware', async ({ request }) => { - const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === 'middleware GET'; - }); - - const routeTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === 'GET /api/endpoint-behind-middleware'; - }); +test('Should create a span for middleware', async ({ request }) => { + const spansPromise = collectStreamedSpans('nextjs-16', spans => + spans.some(span => 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 spans = await spansPromise; + const middlewareSpan = spans.find(span => span.name === 'middleware GET' && span.is_segment)!; - 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'); + expect(middlewareSpan.status).toBe('ok'); + expect(getSpanOp(middlewareSpan)).toBe('middleware'); + expect(middlewareSpan.attributes['sentry.segment.name.source']?.value).toBe('route'); - expect(middlewareTransaction.request?.method).toBe('GET'); - expect(middlewareTransaction.request?.url).toContain('/api/endpoint-behind-middleware'); + expect(middlewareSpan.attributes['http.request.method']?.value).toBe('GET'); + expect(String(middlewareSpan.attributes['http.target']?.value)).toContain('/api/endpoint-behind-middleware'); // The `Middleware.execute` OTEL root span is the only `middleware` span. The build-time // `wrapMiddlewareWithSentry` wrapper used to start a second, redundant one nested inside it. - const nestedMiddlewareSpans = middlewareTransaction.spans?.filter(span => span.op === 'middleware'); + const nestedMiddlewareSpans = spans.filter(span => getSpanOp(span) === 'middleware' && !span.is_segment); expect(nestedMiddlewareSpans).toHaveLength(0); - - // Assert that isolation scope works properly - expect(middlewareTransaction.tags?.['my-isolated-tag']).toBe(true); - expect(middlewareTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); - - // Tags set in middleware must not leak into other requests' events (e.g. via a shared scope when the middleware - // runs in a detached context - https://github.com/vercel/next.js/pull/95306) - const routeTransaction = await routeTransactionPromise; - expect(routeTransaction.tags?.['my-isolated-tag']).not.toBeDefined(); - expect(routeTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); }); test('Faulty middlewares', async ({ request }) => { test.skip(isDevMode, 'Throwing crashes the dev server atm'); // https://github.com/vercel/next.js/issues/85261 - const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === 'middleware GET'; - }); - - const errorEventPromise = waitForError('nextjs-16', errorEvent => { - return errorEvent?.exception?.values?.[0]?.value === 'Middleware Error'; + const middlewareSpanPromise = waitForStreamedSpan('nextjs-16', span => { + return span.name === 'middleware GET' && span.is_segment; }); request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-throw': '1' } }).catch(() => { // Noop }); - await test.step('should record transactions', async () => { - const middlewareTransaction = await middlewareTransactionPromise; - expect(middlewareTransaction.contexts?.trace?.status).toBe('internal_error'); - expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware'); - expect(middlewareTransaction.contexts?.runtime?.name).toBe('node'); - expect(middlewareTransaction.transaction_info?.source).toBe('route'); + await test.step('should record spans', async () => { + const middlewareSpan = await middlewareSpanPromise; + expect(middlewareSpan.status).toBe('error'); + expect(getSpanOp(middlewareSpan)).toBe('middleware'); + expect(middlewareSpan.attributes['sentry.segment.name.source']?.value).toBe('route'); }); // TODO: proxy errors currently not reported via onRequestError - // await test.step('should record exceptions', async () => { - // const errorEvent = await errorEventPromise; - - // // Assert that isolation scope works properly - // expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); - // expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); - // expect([ - // 'middleware GET', // non-otel webpack versions - // '/middleware', // middleware file - // '/proxy', // proxy file - // ]).toContain(errorEvent.transaction); - // }); + // await test.step('should record exceptions', async () => { ... }); }); -test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => { +test('Should trace outgoing fetch requests inside middleware', async ({ request }) => { test.skip(isDevMode, 'The fetch requests ends up in a separate tx in dev atm'); - const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === 'middleware GET'; - }); - // In some builds (especially webpack), fetch spans may end up in a separate transaction instead of as child spans - // This test validates that the fetch is traced either way - const fetchTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return ( - transactionEvent?.transaction === 'GET http://localhost:3030/' || - transactionEvent?.contexts?.trace?.description === 'GET http://localhost:3030/' - ); - }); + // In some builds (especially webpack) the fetch span is not a child of the middleware segment but a + // segment of its own, so this waits for either. `http.client` span names are low cardinality under + // span streaming, hence `GET localhost` rather than the full URL. + const spansPromise = collectStreamedSpans('nextjs-16', spans => + spans.some(span => getSpanOp(span) === 'http.client' && span.name === 'GET localhost'), + ); request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-make-request': '1' } }).catch(() => { // Noop }); - const middlewareTransaction = await middlewareTransactionPromise; - - // Breadcrumbs should always be created for the fetch request - expect(middlewareTransaction.breadcrumbs).toEqual( - expect.arrayContaining([ - { - category: 'http', - data: { 'http.request.method': 'GET', status_code: 200, url: 'http://localhost:3030/' }, - timestamp: expect.any(Number), - type: 'http', - }, - ]), - ); - - // Check if http.client span exists as a child of the middleware transaction - const hasHttpClientSpan = !!middlewareTransaction.spans?.find(span => span.op === 'http.client'); - - if (hasHttpClientSpan) { - // Check if fetch is traced as a child span of the middleware transaction - expect(middlewareTransaction.spans).toContainEqual({ - data: { - 'http.request.method': 'GET', - 'http.request.method_original': 'GET', - 'http.response.status_code': 200, - 'network.peer.address': '::1', - 'network.peer.port': 3030, - 'sentry.kind': 'client', - 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.node_fetch', - 'server.address': 'localhost', - 'server.port': 3030, - 'url.domain': 'localhost', - 'url.full': 'http://localhost:3030/', - 'url.path': '/', - 'url.scheme': 'http', - 'user_agent.original': 'node', - }, - description: 'GET http://localhost:3030/', - op: 'http.client', - origin: 'auto.http.node_fetch', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }); - } else { - // Alternatively, fetch is traced as a separate transaction, similar to Dev builds - const fetchTransaction = await fetchTransactionPromise; - - expect(fetchTransaction.contexts?.trace?.op).toBe('http.client'); - expect(fetchTransaction.contexts?.trace?.status).toBe('ok'); - expect(fetchTransaction.contexts?.trace?.data?.['http.request.method']).toBe('GET'); - expect(fetchTransaction.contexts?.trace?.data?.['url.full']).toBe('http://localhost:3030/'); - } + const spans = await spansPromise; + const fetchSpan = spans.find(span => getSpanOp(span) === 'http.client' && span.name === 'GET localhost')!; + + expect(fetchSpan.status).toBe('ok'); + expect(fetchSpan.attributes).toMatchObject({ + 'http.request.method': { value: 'GET', type: 'string' }, + 'http.response.status_code': { value: 200, type: 'integer' }, + 'sentry.kind': { value: 'client', type: 'string' }, + 'sentry.op': { value: 'http.client', type: 'string' }, + 'sentry.origin': { value: 'auto.http.node_fetch', type: 'string' }, + 'server.address': { value: 'localhost', type: 'string' }, + 'server.port': { value: 3030, type: 'integer' }, + 'url.domain': { value: 'localhost', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.scheme': { value: 'http', type: 'string' }, + }); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/nested-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/nested-rsc-error.test.ts index 68731f049f2c..20efa3c1567f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/nested-rsc-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/nested-rsc-error.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForError, 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', 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', 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/tests/pageload-tracing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/pageload-tracing.test.ts index 5360f450c5fa..b3da0fd2837c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/pageload-tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/pageload-tracing.test.ts @@ -1,31 +1,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-16', 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', span => { + return span.name === 'GET /pageload-tracing' && span.is_segment; }); - const pageloadTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === '/pageload-tracing'; + const pageloadSpanPromise = waitForStreamedSpan('nextjs-16', 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-16', async transactionEvent => { - return transactionEvent?.transaction === 'GET /pageload-tracing'; + const serverSpanPromise = waitForStreamedSpan('nextjs-16', 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-16/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts index a7ec71c894a4..0675adf61f98 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts @@ -1,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-16', 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', 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-16', 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', 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-16', 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', 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-16', 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', 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-16/tests/prefetch-spans.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/prefetch-spans.test.ts index 0b158103d1c0..ffd692598836 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/prefetch-spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/prefetch-spans.test.ts @@ -1,24 +1,27 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans } from '@sentry-internal/test-utils'; import { isDevMode } from './isDevMode'; test('Prefetch client spans should have a http.request.prefetch attribute', async ({ page }) => { test.skip(isDevMode, "Prefetch requests don't have the prefetch header in dev mode"); - const pageloadTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === '/prefetching'; - }); + // The prefetch span is a child of the pageload segment span, which ends last. + const spansPromise = collectStreamedSpans('nextjs-16', 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-16/tests/route-handler.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/route-handler.test.ts index 6f7de6aeb14f..693932effd1a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/route-handler.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/route-handler.test.ts @@ -1,72 +1,70 @@ import test, { expect } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { isTurbopackDevMode } from './isDevMode'; -test('Should create a transaction for node route handlers', async ({ request }) => { +test('Should create a span for node route handlers', async ({ request }) => { test.skip(isTurbopackDevMode, 'Turbopack intermittently returns 404 for dynamic routes in dev mode'); - const routehandlerTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === 'GET /route-handler/[xoxo]/node'; + const routehandlerSpanPromise = waitForStreamedSpan('nextjs-16', 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(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 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-16', async transactionEvent => { - return transactionEvent?.transaction === 'GET /route-handler/[xoxo]/edge'; + const routehandlerSpanPromise = waitForStreamedSpan('nextjs-16', 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 report an error with a parameterized transaction name for a throwing route handler', async ({ - request, -}) => { +test('Should report an error with a parameterized span name for a throwing route handler', async ({ request }) => { test.skip(isTurbopackDevMode, 'Turbopack intermittently returns 404 for dynamic routes in dev mode'); const errorEventPromise = waitForError('nextjs-16', errorEvent => { return errorEvent?.exception?.values?.some(value => value.value === 'route-handler-error') ?? false; }); - const transactionEventPromise = waitForTransaction('nextjs-16', transactionEvent => { + // Matched on the error's own trace so a span from an earlier spec cannot satisfy the correlation. + const spanPromise = waitForStreamedSpan('nextjs-16', async span => { return ( - transactionEvent?.transaction === 'GET /route-handler/[xoxo]/error' && - transactionEvent?.contexts?.trace?.op === 'http.server' + span.name === 'GET /route-handler/[xoxo]/error' && + span.is_segment && + getSpanOp(span) === 'http.server' && + (await errorEventPromise).contexts?.trace?.trace_id === span.trace_id ); }); request.get('/route-handler/456/error').catch(() => {}); const errorEvent = await errorEventPromise; - const transactionEvent = await transactionEventPromise; - - // Error event should be part of the same trace as the transaction - expect(errorEvent.contexts?.trace?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id); + const span = await spanPromise; // Error should carry the parameterized transaction name expect(errorEvent.transaction).toBe('GET /route-handler/[xoxo]/error'); - // Transaction should have parameterized name and internal_error status - expect(transactionEvent.transaction).toBe('GET /route-handler/[xoxo]/error'); - expect(transactionEvent.contexts?.trace?.status).toBe('internal_error'); + // Span should have parameterized name and an error status + expect(span.name).toBe('GET /route-handler/[xoxo]/error'); + expect(span.status).toBe('error'); }); test('Should set a parameterized transaction name on a captureMessage event in a route handler', async ({ @@ -78,10 +76,12 @@ test('Should set a parameterized transaction name on a captureMessage event in a return event?.message === 'route-handler-message'; }); - const transactionEventPromise = waitForTransaction('nextjs-16', transactionEvent => { + const spanPromise = waitForStreamedSpan('nextjs-16', async span => { return ( - transactionEvent?.transaction === 'GET /route-handler/[xoxo]/capture-message' && - transactionEvent?.contexts?.trace?.op === 'http.server' + span.name === 'GET /route-handler/[xoxo]/capture-message' && + span.is_segment && + getSpanOp(span) === 'http.server' && + (await messageEventPromise).contexts?.trace?.trace_id === span.trace_id ); }); @@ -89,17 +89,14 @@ test('Should set a parameterized transaction name on a captureMessage event in a expect(await response.json()).toStrictEqual({ message: 'Message captured' }); const messageEvent = await messageEventPromise; - const transactionEvent = await transactionEventPromise; - - // Message event should be part of the same trace as the transaction - expect(messageEvent.contexts?.trace?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id); + const span = await spanPromise; // Message should carry the parameterized transaction name expect(messageEvent.transaction).toBe('GET /route-handler/[xoxo]/capture-message'); - // Transaction should have parameterized name and ok status - expect(transactionEvent.transaction).toBe('GET /route-handler/[xoxo]/capture-message'); - expect(transactionEvent.contexts?.trace?.status).toBe('ok'); + // Span should have parameterized name and ok status + expect(span.name).toBe('GET /route-handler/[xoxo]/capture-message'); + expect(span.status).toBe('ok'); }); test('Should set a parameterized transaction name on a captureException event in a route handler', async ({ @@ -111,10 +108,12 @@ test('Should set a parameterized transaction name on a captureException event in return errorEvent?.exception?.values?.some(value => value.value === 'route-handler-capture-exception') ?? false; }); - const transactionEventPromise = waitForTransaction('nextjs-16', transactionEvent => { + const spanPromise = waitForStreamedSpan('nextjs-16', async span => { return ( - transactionEvent?.transaction === 'GET /route-handler/[xoxo]/capture-exception' && - transactionEvent?.contexts?.trace?.op === 'http.server' + span.name === 'GET /route-handler/[xoxo]/capture-exception' && + span.is_segment && + getSpanOp(span) === 'http.server' && + (await errorEventPromise).contexts?.trace?.trace_id === span.trace_id ); }); @@ -122,15 +121,12 @@ test('Should set a parameterized transaction name on a captureException event in expect(await response.json()).toStrictEqual({ message: 'Exception captured' }); const errorEvent = await errorEventPromise; - const transactionEvent = await transactionEventPromise; - - // Error event should be part of the same trace as the transaction - expect(errorEvent.contexts?.trace?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id); + const span = await spanPromise; // Manually captured exception should carry the parameterized transaction name expect(errorEvent.transaction).toBe('GET /route-handler/[xoxo]/capture-exception'); - // Transaction should have parameterized name and ok status (error was caught, not thrown) - expect(transactionEvent.transaction).toBe('GET /route-handler/[xoxo]/capture-exception'); - expect(transactionEvent.contexts?.trace?.status).toBe('ok'); + // Span should have parameterized name and ok status (error was caught, not thrown) + expect(span.name).toBe('GET /route-handler/[xoxo]/capture-exception'); + expect(span.status).toBe('ok'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts index 88e2d3ba1af1..27b82ae35c61 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts @@ -1,21 +1,21 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForError, 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-16', async transactionEvent => { - return transactionEvent?.transaction === '/redirect/origin'; + // Wait for the initial pageload span + const pageLoadSpanPromise = waitForStreamedSpan('nextjs-16', 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-16', async transactionEvent => { - return transactionEvent?.transaction === 'GET /redirect/destination'; + // Wait for the redirect span + const redirectSpanPromise = waitForStreamedSpan('nextjs-16', 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-16/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts index 22cc59acd9d9..d7bdca7bb0b0 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts @@ -1,101 +1,97 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans } from '@sentry-internal/test-utils'; import { isTurbopackDevMode } from './isDevMode'; -test('Sends a transaction for a request to app router with URL', async ({ page }) => { +// 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', spans => + spans.some(span => span.name === segmentName && span.is_segment), + ).then(spans => spans.map(span => span.name)); +} + +test('Sends a span for a request to app router with URL', async ({ page }) => { test.skip(isTurbopackDevMode, 'Turbopack intermittently returns 404 for nested dynamic routes in dev mode'); - const serverComponentTransactionPromise = waitForTransaction('nextjs-16', transactionEvent => { - return ( - transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' && - transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42') - ); - }); + const spansPromise = collectStreamedSpans('nextjs-16', 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(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' }, }); - expect(transactionEvent.request).toMatchObject({ - url: expect.stringContaining('/parameterized/1337/beep/42'), - }); - - // The transaction should not contain any spans with the same name as the transaction - // e.g. "GET /parameterized/[one]/beep/[two]" - expect( - transactionEvent.spans?.filter(span => { - return span.description === transactionEvent.transaction; - }), - ).toHaveLength(0); + // 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', 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, }) => { test.skip(isTurbopackDevMode, 'Turbopack intermittently returns 404 for dynamic routes in dev mode'); - const serverTransactionEventPromise = waitForTransaction('nextjs-16', 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; - }); - - 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'); + const spanNames = await spanNamesPromise; + + 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/tests/streaming-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/streaming-rsc-error.test.ts index ab01f5806fea..a4e6f42a45ef 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/streaming-rsc-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/streaming-rsc-error.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForError, 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', 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', 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-16/tests/suspense-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/suspense-error.test.ts index f7a5fb83c3df..81e7a78f62f2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/suspense-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/suspense-error.test.ts @@ -1,9 +1,9 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('should not capture serverside suspense errors', async ({ page }) => { - const pageServerComponentTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent?.transaction === 'GET /suspense-error'; + const pageServerComponentSpanPromise = waitForStreamedSpan('nextjs-16', 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(); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/vercel-queue.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/vercel-queue.test.ts index eb4635bc2f5a..e1cfb4784f8a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/vercel-queue.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/vercel-queue.test.ts @@ -1,21 +1,21 @@ import test, { expect } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; // The queue E2E test only runs in production mode. // In development mode the @vercel/queue SDK uses an in-memory dispatch that // bypasses our mock HTTP server, causing duplicate handler invocations. const isProduction = process.env.TEST_ENV === 'production'; -test('Should create transactions for queue producer and consumer', async ({ request }) => { +test('Should create spans for queue producer and consumer', async ({ request }) => { test.skip(!isProduction, 'Vercel Queue test only runs in production mode'); - // 1. Set up waiters for both the producer and consumer transactions. - const producerTransactionPromise = waitForTransaction('nextjs-16', transactionEvent => { - return transactionEvent?.transaction === 'POST /api/queue-send'; + // 1. Set up waiters for both the producer and consumer spans. + const producerSpanPromise = waitForStreamedSpan('nextjs-16', span => { + return span.name === 'POST /api/queue-send' && span.is_segment; }); - const consumerTransactionPromise = waitForTransaction('nextjs-16', transactionEvent => { - return transactionEvent?.transaction === 'POST /api/queues/process-order'; + const consumerSpanPromise = waitForStreamedSpan('nextjs-16', span => { + return span.name === 'POST /api/queues/process-order' && span.is_segment; }); // 2. Hit the producer route to enqueue a message. @@ -28,24 +28,23 @@ test('Should create transactions for queue producer and consumer', async ({ requ expect(response.status()).toBe(200); expect(responseBody.messageId).toBeTruthy(); - // 3. Wait for the producer transaction. - const producerTransaction = await producerTransactionPromise; - expect(producerTransaction).toBeDefined(); - expect(producerTransaction.contexts?.trace?.op).toBe('http.server'); - expect(producerTransaction.contexts?.trace?.status).toBe('ok'); + // 3. Wait for the producer span. + const producerSpan = await producerSpanPromise; + expect(producerSpan).toBeDefined(); + expect(getSpanOp(producerSpan)).toBe('http.server'); + expect(producerSpan.status).toBe('ok'); - // 4. Wait for the consumer transaction (the mock server pushes the message + // 4. Wait for the consumer span (the mock server pushes the message // to the consumer route via CloudEvent POST). - const consumerTransaction = await consumerTransactionPromise; - expect(consumerTransaction).toBeDefined(); - expect(consumerTransaction.contexts?.trace?.op).toBe('http.server'); - expect(consumerTransaction.contexts?.trace?.status).toBe('ok'); + const consumerSpan = await consumerSpanPromise; + expect(consumerSpan).toBeDefined(); + expect(getSpanOp(consumerSpan)).toBe('http.server'); + expect(consumerSpan.status).toBe('ok'); // 5. Verify the consumer span has messaging.* attributes from queue instrumentation. - const consumerSpanData = consumerTransaction.contexts?.trace?.data; - expect(consumerSpanData?.['messaging.system']).toBe('vercel.queue'); - expect(consumerSpanData?.['messaging.operation.name']).toBe('process'); - expect(consumerSpanData?.['messaging.destination.name']).toBe('orders'); - expect(consumerSpanData?.['messaging.message.id']).toBeTruthy(); - expect(consumerSpanData?.['messaging.consumer.group.name']).toBeTruthy(); + expect(consumerSpan.attributes['messaging.system']?.value).toBe('vercel.queue'); + expect(consumerSpan.attributes['messaging.operation.name']?.value).toBe('process'); + expect(consumerSpan.attributes['messaging.destination.name']?.value).toBe('orders'); + expect(consumerSpan.attributes['messaging.message.id']?.value).toBeTruthy(); + expect(consumerSpan.attributes['messaging.consumer.group.name']?.value).toBeTruthy(); });