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