diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation-client.ts index 97cc19287aeb..6dd7cce7b75e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation-client.ts @@ -1,7 +1,6 @@ import * as Sentry from '@sentry/nextjs'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, tunnel: `http://localhost:3031/`, // proxy server diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation.ts index c2e7ea1b1e2d..0d67d33d4a94 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation.ts @@ -3,7 +3,6 @@ import * as Sentry from '@sentry/nextjs'; export function register() { if (process.env.NEXT_RUNTIME === 'nodejs' || process.env.NEXT_RUNTIME === 'edge') { Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, tunnel: `http://localhost:3031/`, // proxy server diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/api-route-transaction.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/api-route-transaction.test.ts index 70062b318d81..b2473e6087dc 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/api-route-transaction.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/api-route-transaction.test.ts @@ -1,17 +1,19 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; -// A pages-router API route sees both Next.js's own `BaseServer.handleRequest` OTEL transaction and the -// transaction created by `wrapApiHandlerWithSentry`. Exactly one of them must be sent for a request, never -// both. This guards against regressing back to duplicate root transactions for the same API route. -test('Sends exactly one transaction for a pages-router API route', async ({ request }) => { - const apiRouteTransactions: string[] = []; +// A pages-router API route sees both Next.js's own `BaseServer.handleRequest` OTEL span and the span +// created by `wrapApiHandlerWithSentry`. Exactly one of them must be sent for a request, never both. +// This guards against regressing back to duplicate segment spans for the same API route. +test('Sends exactly one segment span for a pages-router API route', async ({ request }) => { + const apiRouteSegmentSpans: string[] = []; - // Accumulate every matching transaction and assert on the total after a grace period. This predicate never + // Accumulate every matching span and assert on the total after a grace period. This predicate never // returns true, so the promise never resolves; we just let it collect while we wait out the grace period. - void waitForTransaction('nextjs-pages-dir', transactionEvent => { - if (transactionEvent?.transaction === 'GET /api/endpoint') { - apiRouteTransactions.push(transactionEvent.contexts?.trace?.trace_id ?? ''); + void waitForStreamedSpans('nextjs-pages-dir', spans => { + for (const span of spans) { + if (span.name === 'GET /api/endpoint' && span.is_segment) { + apiRouteSegmentSpans.push(span.trace_id); + } } return false; }); @@ -21,21 +23,21 @@ test('Sends exactly one transaction for a pages-router API route', async ({ requ await new Promise(resolve => setTimeout(resolve, 6000)); - expect(apiRouteTransactions).toHaveLength(1); + expect(apiRouteSegmentSpans).toHaveLength(1); }); -test('Sends a well-formed transaction for a node-runtime pages-router API route', async ({ request }) => { - const transactionPromise = waitForTransaction('nextjs-pages-dir', transactionEvent => { - return transactionEvent?.transaction === 'GET /api/endpoint' && transactionEvent.contexts?.runtime?.name === 'node'; +test('Sends a well-formed span for a node-runtime pages-router API route', async ({ request }) => { + const spanPromise = waitForStreamedSpan('nextjs-pages-dir', span => { + return span.name === 'GET /api/endpoint' && span.is_segment; }); const response = await request.get('/api/endpoint'); expect(await response.json()).toStrictEqual({ name: 'John Doe' }); - const transaction = await transactionPromise; + const span = await spanPromise; - expect(transaction.contexts?.trace?.op).toBe('http.server'); - expect(transaction.contexts?.trace?.status).toBe('ok'); - expect(transaction.transaction_info?.source).toBe('route'); - expect(transaction.contexts?.trace?.data?.['http.route']).toBe('/api/endpoint'); + expect(getSpanOp(span)).toBe('http.server'); + expect(span.status).toBe('ok'); + expect(span.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(span.attributes['http.route']?.value).toBe('/api/endpoint'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/async-context-edge.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/async-context-edge.test.ts index d823a1cf5605..c44d2bc2cb83 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/async-context-edge.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/async-context-edge.test.ts @@ -1,20 +1,21 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans } from '@sentry-internal/test-utils'; test('Should allow for async context isolation in the edge SDK', async ({ request }) => { - const edgerouteTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { - return ( - transactionEvent?.transaction === 'GET /api/async-context-edge-endpoint' && - transactionEvent.contexts?.runtime?.name === 'vercel-edge' - ); - }); + // The inner and outer spans are children of the segment span, which ends last, so accumulate until + // the segment arrives to be sure both children are in hand. + const spansPromise = collectStreamedSpans('nextjs-pages-dir', spans => + spans.some(span => span.name === 'GET /api/async-context-edge-endpoint' && span.is_segment), + ); await request.get('/api/async-context-edge-endpoint'); - const asyncContextEdgerouteTransaction = await edgerouteTransactionPromise; + const spans = await spansPromise; - const outerSpan = asyncContextEdgerouteTransaction.spans?.find(span => span.description === 'outer-span'); - const innerSpan = asyncContextEdgerouteTransaction.spans?.find(span => span.description === 'inner-span'); + const outerSpan = spans.find(span => span.name === 'outer-span'); + const innerSpan = spans.find(span => span.name === 'inner-span'); + expect(outerSpan).toBeDefined(); + expect(innerSpan).toBeDefined(); expect(outerSpan?.parent_span_id).toStrictEqual(innerSpan?.parent_span_id); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/edge-route.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/edge-route.test.ts index 8401c6a5f5d2..6c00c8a94e64 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/edge-route.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/edge-route.test.ts @@ -1,12 +1,11 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('Should create a transaction for edge routes', async ({ request }) => { - const edgerouteTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { - return ( - transactionEvent?.transaction === 'GET /api/edge-endpoint' && - transactionEvent.contexts?.runtime?.name === 'vercel-edge' - ); +test('Should create a span for edge routes', async ({ request }) => { + // The route is only served by the edge runtime, so the span name identifies it on its own. The + // transaction-based test additionally matched on `contexts.runtime.name`, which span v2 does not carry. + const edgerouteSpanPromise = waitForStreamedSpan('nextjs-pages-dir', span => { + return span.name === 'GET /api/edge-endpoint' && span.is_segment; }); const response = await request.get('/api/edge-endpoint', { @@ -16,19 +15,17 @@ test('Should create a transaction for edge routes', async ({ request }) => { }); expect(await response.json()).toStrictEqual({ name: 'Jim Halpert' }); - const edgerouteTransaction = await edgerouteTransactionPromise; + const edgerouteSpan = await edgerouteSpanPromise; - expect(edgerouteTransaction.contexts?.trace?.status).toBe('ok'); - expect(edgerouteTransaction.contexts?.trace?.op).toBe('http.server'); - expect(edgerouteTransaction.request?.headers?.['x-yeet']).toBe('test-value'); + expect(edgerouteSpan.status).toBe('ok'); + expect(getSpanOp(edgerouteSpan)).toBe('http.server'); + // The `x-yeet` request header is not asserted here: the edge runtime emits this segment span without + // request headers, and they land on a sibling Node-side span in a separate trace. }); test('Faulty edge routes', async ({ request }) => { - const edgerouteTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { - return ( - transactionEvent?.transaction === 'GET /api/error-edge-endpoint' && - transactionEvent.contexts?.runtime?.name === 'vercel-edge' - ); + const edgerouteSpanPromise = waitForStreamedSpan('nextjs-pages-dir', span => { + return span.name === 'GET /api/error-edge-endpoint' && span.is_segment; }); const errorEventPromise = waitForError('nextjs-pages-dir', errorEvent => { @@ -42,19 +39,19 @@ test('Faulty edge routes', async ({ request }) => { // Noop }); - const [edgerouteTransaction, errorEvent] = await Promise.all([ - test.step('should create a transaction', () => edgerouteTransactionPromise), + const [edgerouteSpan, errorEvent] = await Promise.all([ + test.step('should create a span', () => edgerouteSpanPromise), test.step('should create an error event', () => errorEventPromise), ]); - test.step('should create transactions with the right fields', () => { - expect(edgerouteTransaction.contexts?.trace?.status).toBe('internal_error'); - expect(edgerouteTransaction.contexts?.trace?.op).toBe('http.server'); + test.step('should create spans with the right fields', () => { + expect(edgerouteSpan.status).toBe('error'); + expect(getSpanOp(edgerouteSpan)).toBe('http.server'); }); test.step('should have scope isolation', () => { - expect(edgerouteTransaction.tags?.['my-isolated-tag']).toBe(true); - expect(edgerouteTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + // Span v2 carries no scope tags, so isolation is only asserted on the error event; the span-side + // assertions were dropped in the streaming port. expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts index c7d55186fe86..3d0d3a2fad06 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts @@ -1,29 +1,24 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('Should create a transaction for middleware', async ({ request }) => { - const middlewareTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { - return transactionEvent?.transaction === 'middleware GET'; +test('Should create a span for middleware', async ({ request }) => { + const middlewareSpanPromise = waitForStreamedSpan('nextjs-pages-dir', span => { + return span.name === 'middleware GET' && span.is_segment; }); const response = await request.get('/api/endpoint-behind-middleware'); expect(await response.json()).toStrictEqual({ name: 'John Doe' }); - const middlewareTransaction = await middlewareTransactionPromise; + const middlewareSpan = await middlewareSpanPromise; - expect(middlewareTransaction.contexts?.trace?.status).toBe('ok'); - expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware'); - expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); - expect(middlewareTransaction.transaction_info?.source).toBe('route'); - - // Assert that isolation scope works properly - expect(middlewareTransaction.tags?.['my-isolated-tag']).toBe(true); - expect(middlewareTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + expect(middlewareSpan.status).toBe('ok'); + expect(getSpanOp(middlewareSpan)).toBe('middleware'); + expect(middlewareSpan.attributes['sentry.segment.name.source']?.value).toBe('route'); }); test('Faulty middlewares', async ({ request }) => { - const middlewareTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { - return transactionEvent?.transaction === 'middleware GET'; + const middlewareSpanPromise = waitForStreamedSpan('nextjs-pages-dir', span => { + return span.name === 'middleware GET' && span.is_segment; }); const errorEventPromise = waitForError('nextjs-pages-dir', errorEvent => { @@ -34,12 +29,11 @@ test('Faulty middlewares', async ({ request }) => { // 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('vercel-edge'); - 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'); }); await test.step('should record exceptions', async () => { @@ -52,54 +46,37 @@ test('Faulty middlewares', async ({ request }) => { }); }); -test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => { - const middlewareTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { - return ( - transactionEvent?.transaction === 'middleware GET' && - !!transactionEvent.spans?.find(span => span.op === 'http.client') - ); - }); +test('Should trace outgoing fetch requests inside middleware', async ({ request }) => { + // The fetch span is a child of the middleware segment span, which ends last, so accumulate until + // the segment arrives. + const spansPromise = collectStreamedSpans( + 'nextjs-pages-dir', + spans => + spans.some(span => span.name === 'middleware GET' && span.is_segment) && + spans.some(span => getSpanOp(span) === 'http.client'), + ); request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-make-request': '1' } }).catch(() => { // Noop }); - const middlewareTransaction = await middlewareTransactionPromise; + const spans = await spansPromise; + const fetchSpan = spans.find(span => getSpanOp(span) === 'http.client')!; - expect(middlewareTransaction.spans).toEqual( - expect.arrayContaining([ - { - data: { - 'http.request.method': 'GET', - 'http.response.status_code': 200, - type: 'fetch', - 'url.full': 'http://localhost:3030/', - 'url.domain': 'localhost', - 'server.address': 'localhost', - 'server.port': 3030, - 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.wintercg_fetch', - }, - description: 'GET http://localhost:3030/', - op: 'http.client', - origin: 'auto.http.wintercg_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}/), - }, - ]), - ); - expect(middlewareTransaction.breadcrumbs).toEqual( - expect.arrayContaining([ - { - category: 'fetch', - data: { method: 'GET', status_code: 200, url: 'http://localhost:3030/' }, - timestamp: expect.any(Number), - type: 'http', - }, - ]), - ); + // `http.client` span names are low cardinality under span streaming, so the name is the method and + // host rather than the full URL. The URL itself is still asserted below via `url.full`. + expect(fetchSpan.name).toBe('GET localhost'); + expect(fetchSpan.status).toBe('ok'); + expect(fetchSpan.parent_span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/)); + expect(fetchSpan.attributes).toMatchObject({ + 'http.request.method': { value: 'GET', type: 'string' }, + 'http.response.status_code': { value: 200, type: 'integer' }, + type: { value: 'fetch', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/', type: 'string' }, + 'url.domain': { value: 'localhost', type: 'string' }, + 'server.address': { value: 'localhost', type: 'string' }, + 'server.port': { value: 3030, type: 'integer' }, + 'sentry.op': { value: 'http.client', type: 'string' }, + 'sentry.origin': { value: 'auto.http.wintercg_fetch', type: 'string' }, + }); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/pages-ssr-errors.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/pages-ssr-errors.test.ts index e5539244570f..ef2fed723f8b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/pages-ssr-errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/pages-ssr-errors.test.ts @@ -1,22 +1,23 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Will capture error for SSR rendering error with a connected trace (Class Component)', async ({ page }) => { const errorEventPromise = waitForError('nextjs-pages-dir', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Pages SSR Error Class'; }); - const serverComponentTransaction = waitForTransaction('nextjs-pages-dir', async transactionEvent => { + const serverComponentSpanPromise = waitForStreamedSpan('nextjs-pages-dir', async span => { return ( - transactionEvent?.transaction === 'GET /pages-router/ssr-error-class' && - (await errorEventPromise).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id + span.name === 'GET /pages-router/ssr-error-class' && + span.is_segment && + (await errorEventPromise).contexts?.trace?.trace_id === span.trace_id ); }); await page.goto('/pages-router/ssr-error-class'); expect(await errorEventPromise).toBeDefined(); - expect(await serverComponentTransaction).toBeDefined(); + expect(await serverComponentSpanPromise).toBeDefined(); }); test('Will capture error for SSR rendering error with a connected trace (Functional Component)', async ({ page }) => { @@ -24,26 +25,24 @@ test('Will capture error for SSR rendering error with a connected trace (Functio return errorEvent?.exception?.values?.[0]?.value === 'Pages SSR Error FC'; }); - const ssrTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { + const ssrSpanPromise = waitForStreamedSpan('nextjs-pages-dir', async span => { return ( - transactionEvent?.transaction === 'GET /pages-router/ssr-error-fc' && - (await errorEventPromise).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id + span.name === 'GET /pages-router/ssr-error-fc' && + span.is_segment && + (await errorEventPromise).contexts?.trace?.trace_id === span.trace_id ); }); await page.goto('/pages-router/ssr-error-fc'); const errorEvent = await errorEventPromise; - const ssrTransaction = await ssrTransactionPromise; + await ssrSpanPromise; - // Assert that isolation scope works properly + // Assert that isolation scope works properly. Span v2 carries no scope tags, so this is only + // asserted on the error event. expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); - // TODO(lforst): Reuse SSR request span isolation scope to fix the following two assertions - // expect(ssrTransaction.tags?.['my-isolated-tag']).toBe(true); - // expect(ssrTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); - expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ handled: false, type: 'auto.function.nextjs.page_function', diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/request-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/request-instrumentation.test.ts index a532841b8540..964fc10f3a50 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/request-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/request-instrumentation.test.ts @@ -1,24 +1,24 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans } from '@sentry-internal/test-utils'; // Note(lforst): I officially declare bancruptcy on this test. I tried a million ways to make it work but it kept flaking. // Sometimes the request span was included in the handler span, more often it wasn't. I have no idea why. Maybe one day we will // figure it out. Today is not that day. -test.skip('Should send a transaction with a http span', async ({ request }) => { - const transactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { - return transactionEvent?.transaction === 'GET /api/request-instrumentation'; - }); +test.skip('Should send a http span', async ({ request }) => { + const spansPromise = collectStreamedSpans('nextjs-pages-dir', spans => + spans.some(span => span.name === 'GET /api/request-instrumentation' && span.is_segment), + ); await request.get('/api/request-instrumentation'); - expect((await transactionPromise).spans).toContainEqual( + expect(await spansPromise).toContainEqual( expect.objectContaining({ - data: expect.objectContaining({ - 'http.request.method': 'GET', - 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.client', + name: 'GET https://example.com/', + attributes: expect.objectContaining({ + 'http.request.method': { value: 'GET', type: 'string' }, + 'sentry.op': { value: 'http.client', type: 'string' }, + 'sentry.origin': { value: 'auto.http.client', type: 'string' }, }), - description: 'GET https://example.com/', }), ); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/transactions.test.ts index 6e507903ed26..7ed43357d430 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/transactions.test.ts @@ -1,116 +1,81 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { isDevMode } from './isDevMode'; const packageJson = require('../package.json'); -test('Sends a pageload transaction', async ({ page }) => { +test('Sends a pageload span', async ({ page }) => { const nextjsVersion = packageJson.dependencies.next; const nextjsMajor = Number(nextjsVersion.split('.')[0]); - const pageloadTransactionEventPromise = waitForTransaction('nextjs-pages-dir', transactionEvent => { - return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; + const pageloadSpanPromise = waitForStreamedSpan('nextjs-pages-dir', span => { + return getSpanOp(span) === 'pageload' && span.name === '/' && span.is_segment; }); await page.goto('/'); - const transactionEvent = await pageloadTransactionEventPromise; + const span = await pageloadSpanPromise; - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: '/', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: expect.objectContaining({ - react: { - version: expect.any(String), - }, - trace: { - // Next.js >= 15 propagates a trace ID to the client via a meta tag. Also, only dev mode emits a meta tag because - // the requested page is static and only in dev mode SSR is kicked off. - parent_span_id: nextjsMajor >= 15 && isDevMode ? expect.any(String) : undefined, - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - op: 'pageload', - origin: 'auto.pageload.nextjs.pages_router_instrumentation', - status: 'ok', - data: expect.objectContaining({ - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.nextjs.pages_router_instrumentation', - 'sentry.segment.name.source': 'route', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - 'url.path': '/', - 'url.template': '/', - }), - }, - }), - request: { - headers: { - 'User-Agent': expect.any(String), - }, - url: 'http://localhost:3030/', - }, - }), - ); + // Next.js >= 15 propagates a trace ID to the client via a meta tag. Also, only dev mode emits a meta tag because + // the requested page is static and only in dev mode SSR is kicked off. + if (nextjsMajor >= 15 && isDevMode) { + expect(span.parent_span_id).toEqual(expect.any(String)); + } else { + expect(span.parent_span_id).toBeUndefined(); + } + + expect(span.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/)); + expect(span.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/)); + expect(span.status).toBe('ok'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.nextjs.pages_router_instrumentation', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'react.version': { value: expect.any(String), type: 'string' }, + 'http.request.header.user_agent': { value: expect.any(String), type: 'string' }, + }); + expect(String(span.attributes['url.full']?.value)).toMatch(/^https?:\/\/localhost:\d+\/$/); }); -test('Sends a navigation transaction', async ({ page }) => { - // Skip in dev mode - flaky due to slow compilation affecting transaction timing +test('Sends a navigation span', async ({ page }) => { + // Skip in dev mode - flaky due to slow compilation affecting span timing test.skip(isDevMode, 'Skipped in dev mode due to flakiness from slow compilation'); await page.goto('/'); - const clientNavigationTxnEventPromise = waitForTransaction('nextjs-pages-dir', txnEvent => { - return txnEvent?.contexts?.trace?.op === 'navigation' && txnEvent?.transaction === '/user/[id]'; + const clientNavigationSpanPromise = waitForStreamedSpan('nextjs-pages-dir', span => { + return getSpanOp(span) === 'navigation' && span.name === '/user/[id]' && span.is_segment; }); await page.getByText('navigate').click(); - const clientTxnEvent = await clientNavigationTxnEventPromise; + const span = await clientNavigationSpanPromise; + + expect(span.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/)); + expect(span.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/)); + expect(span.status).toBe('ok'); + expect(span.attributes).toMatchObject({ + 'sentry.idle_span_finish_reason': { value: 'idleTimeout', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.nextjs.pages_router_instrumentation', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.template': { value: '/user/[id]', type: 'string' }, + 'react.version': { value: expect.any(String), type: 'string' }, + 'http.request.header.user_agent': { value: expect.any(String), type: 'string' }, + }); + expect(String(span.attributes['url.full']?.value)).toMatch(/^https?:\/\/localhost:\d+\/user\/5$/); - expect(clientTxnEvent).toEqual( + expect(span.links).toEqual([ expect.objectContaining({ - transaction: '/user/[id]', - transaction_info: { source: 'route' }, - type: 'transaction', - contexts: expect.objectContaining({ - react: { - version: expect.any(String), - }, - trace: { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - op: 'navigation', - origin: 'auto.navigation.nextjs.pages_router_instrumentation', - status: 'ok', - data: expect.objectContaining({ - 'sentry.idle_span_finish_reason': 'idleTimeout', - 'sentry.op': 'navigation', - 'sentry.origin': 'auto.navigation.nextjs.pages_router_instrumentation', - 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'route', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), - 'url.path': '/user/5', - 'url.template': '/user/[id]', - }), - links: [ - { - attributes: { - 'sentry.link.type': 'previous_trace', - }, - sampled: true, - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - ], - }, + attributes: expect.objectContaining({ + 'sentry.link.type': { value: 'previous_trace', type: 'string' }, }), - request: { - headers: { - 'User-Agent': expect.any(String), - }, - url: 'http://localhost:3030/user/5', - }, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), }), - ); + ]); });