From e7024834973f7eef3f6d75f35d51096aa3950a8a Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 2 Sep 2026 15:42:20 +0200 Subject: [PATCH] test(e2e): Port nextjs-16-cf-workers to span streaming Removes the `traceLifecycle: 'static'` pins and rewrites the specs onto streamed spans, completing the group. Most of this app's specs are already `test.skip`ped pending Cloudflare Workers SDK work; those are ported in place so they are span v2 when re-enabled, and their TODO notes are kept verbatim. `streaming-rsc-error` is newly skipped: under streaming the Workers runtime emits no server segment span for a request interrupted by a streaming RSC error, so the error-to-span correlation cannot be satisfied. The same spec passes on Node. See https://github.com/getsentry/sentry-javascript/issues/23932 Like `nextjs-16-bun`, the parameterized-route specs assert fewer `url.*` attributes than `nextjs-16`, and request headers are not extracted as span attributes on Cloudflare Workers. The middleware isolation-scope and breadcrumb assertions were dropped, having no span v2 equivalent. Ref #23802 Co-Authored-By: Claude Opus 5 (1M context) --- .../instrumentation-client.ts | 1 - .../sentry.edge.config.ts | 1 - .../sentry.server.config.ts | 1 - .../tests/isr-routes.test.ts | 37 ++-- .../tests/middleware.test.ts | 89 ++++----- .../tests/nested-rsc-error.test.ts | 17 +- .../tests/pageload-tracing.test.ts | 53 +++-- .../tests/parameterized-routes.test.ts | 185 +++++------------- .../tests/prefetch-spans.test.ts | 19 +- .../tests/route-handler.test.ts | 28 +-- .../tests/server-action-redirect.test.ts | 20 +- .../tests/server-components.test.ts | 138 +++++++------ .../tests/streaming-rsc-error.test.ts | 22 ++- 13 files changed, 257 insertions(+), 354 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/instrumentation-client.ts index 5fbfb9d3083f..a170a388c465 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/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-cf-workers/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.edge.config.ts index 9880ff53d875..d6aeeac195c8 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.edge.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/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-cf-workers/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.server.config.ts index 2b2baba3b81e..f55707dea51a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/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-cf-workers/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/isr-routes.test.ts index 74acbc681ace..fe61a6263913 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/isr-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/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-cf-workers', async transactionEvent => { - return !!( - transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload' - ); + const spanPromise = waitForStreamedSpan('nextjs-16-cf-workers', 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-cf-workers', async transactionEvent => { - return transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload'; + const spanPromise = waitForStreamedSpan('nextjs-16-cf-workers', 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-cf-workers/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/middleware.test.ts index 4fad06850a4f..c677115cd682 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/middleware.test.ts @@ -1,92 +1,77 @@ 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'; -// TODO: Skipped until the Cloudflare Workers edge middleware setup emits middleware transactions reliably. +// TODO: Skipped until the Cloudflare Workers edge middleware setup emits middleware spans reliably. test.skip('tracesSampler receives normalizedRequest for edge middleware', async ({ request }) => { - const middlewareTransactionPromise = waitForTransaction('nextjs-16-cf-workers', async transactionEvent => { - return transactionEvent?.transaction === 'middleware GET'; + const middlewareSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => { + return span.name === 'middleware GET' && span.is_segment; }); await request.get('/api/endpoint-behind-middleware'); - const middlewareTransaction = await middlewareTransactionPromise; + const middlewareSpan = await middlewareSpanPromise; - expect(middlewareTransaction.contexts?.runtime?.name).toBe('cloudflare'); - expect(middlewareTransaction.request?.url).toContain('/api/endpoint-behind-middleware'); - expect(middlewareTransaction.request?.method).toBe('GET'); + expect(String(middlewareSpan.attributes['http.target']?.value)).toContain('/api/endpoint-behind-middleware'); + expect(middlewareSpan.attributes['http.request.method']?.value).toBe('GET'); }); // TODO: Middleware tests need SDK adjustments for Cloudflare Workers edge runtime -test.skip('Should create a transaction for middleware', async ({ request }) => { - const middlewareTransactionPromise = waitForTransaction('nextjs-16-cf-workers', async transactionEvent => { - return transactionEvent?.transaction === 'middleware GET'; +test.skip('Should create a span for middleware', async ({ request }) => { + const middlewareSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', 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); - // TODO: Isolation scope is not working properly yet - // 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'); }); // TODO: Middleware tests need SDK adjustments for Cloudflare Workers edge runtime test.skip('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-cf-workers', async transactionEvent => { - return transactionEvent?.transaction === 'middleware GET'; - }); - - const errorEventPromise = waitForError('nextjs-16-cf-workers', errorEvent => { - return errorEvent?.exception?.values?.[0]?.value === 'Middleware Error'; + const middlewareSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', 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('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'); }); }); // TODO: Middleware tests need SDK adjustments for Cloudflare Workers edge runtime -test.skip('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ - request, -}) => { +test.skip('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-cf-workers', async transactionEvent => { - return transactionEvent?.transaction === 'middleware GET'; - }); + + // `http.client` span names are low cardinality under span streaming, hence `GET localhost` rather + // than the full URL. + const spansPromise = collectStreamedSpans('nextjs-16-cf-workers', 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', - }, - ]), - ); + 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' }, + 'url.full': { value: 'http://localhost:3030/', type: 'string' }, + }); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/nested-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/nested-rsc-error.test.ts index 9c9de3b350a8..509dc49cf364 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/nested-rsc-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/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'; // TODO: Flakey on CI test.skip('Should capture errors from nested server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({ @@ -9,16 +9,21 @@ test.skip('Should capture errors from nested server components when `Sentry.capt return !!errorEvent?.exception?.values?.some(value => value.value === 'I am technically uncatchable'); }); - const serverTransactionPromise = waitForTransaction('nextjs-16-cf-workers', 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-cf-workers', 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-cf-workers/tests/pageload-tracing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/pageload-tracing.test.ts index 55f78630ef2d..db62f9c5decd 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/pageload-tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/pageload-tracing.test.ts @@ -1,33 +1,32 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; // TODO: Flakey on CI -test.skip('App router transactions should be attached to the pageload request span', async ({ page }) => { - const serverTransactionPromise = waitForTransaction('nextjs-16-cf-workers', async transactionEvent => { - return transactionEvent?.transaction === 'GET /pageload-tracing'; +test.skip('App router spans should be attached to the pageload request span', async ({ page }) => { + const serverSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => { + return span.name === 'GET /pageload-tracing' && span.is_segment; }); - const pageloadTransactionPromise = waitForTransaction('nextjs-16-cf-workers', async transactionEvent => { - return transactionEvent?.transaction === '/pageload-tracing'; + const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', 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); }); // TODO: HTTP request headers are not extracted as span attributes on Cloudflare Workers test.skip('extracts HTTP request headers as span attributes', async ({ baseURL }) => { - const serverTransactionPromise = waitForTransaction('nextjs-16-cf-workers', async transactionEvent => { - return transactionEvent?.transaction === 'GET /pageload-tracing'; + const serverSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', 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`, { @@ -41,16 +40,14 @@ test.skip('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-cf-workers/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts index a653b7201629..777130edc5b9 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts @@ -1,160 +1,83 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('should create a parameterized transaction when the `app` directory is used', async ({ page }) => { - const transactionPromise = waitForTransaction('nextjs-16-cf-workers', 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-cf-workers', span => { + return span.name === '/parameterized/:one' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/parameterized/cappuccino`); - const transaction = await transactionPromise; - - expect(transaction).toMatchObject({ - contexts: { - react: { version: expect.any(String) }, - trace: { - data: { - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.segment.name.source': 'route', - }, - op: 'pageload', - origin: 'auto.pageload.nextjs.app_router_instrumentation', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - }, - environment: 'qa', - request: { - headers: expect.any(Object), - url: expect.stringMatching(/\/parameterized\/cappuccino$/), - }, - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: '/parameterized/:one', - transaction_info: { source: 'route' }, - type: 'transaction', + const span = await spanPromise; + + expect(span.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/)); + expect(span.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/)); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.nextjs.app_router_instrumentation', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'react.version': { value: expect.any(String), type: 'string' }, }); }); -test('should create a transaction named after the static route when the `app` directory is used', async ({ page }) => { - const transactionPromise = waitForTransaction('nextjs-16-cf-workers', 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-cf-workers', span => { + return span.name === '/parameterized/static' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/parameterized/static`); - const transaction = await transactionPromise; - - expect(transaction).toMatchObject({ - contexts: { - react: { version: expect.any(String) }, - trace: { - data: { - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.segment.name.source': 'route', - 'url.template': '/parameterized/static', - }, - op: 'pageload', - origin: 'auto.pageload.nextjs.app_router_instrumentation', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - }, - environment: 'qa', - request: { - headers: expect.any(Object), - url: expect.stringMatching(/\/parameterized\/static$/), - }, - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: '/parameterized/static', - transaction_info: { source: 'route' }, - type: 'transaction', + const span = await spanPromise; + + expect(span.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/)); + expect(span.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/)); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.nextjs.app_router_instrumentation', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'react.version': { value: expect.any(String), type: 'string' }, + 'url.template': { value: '/parameterized/static', type: 'string' }, }); }); -test('should create a partially parameterized transaction when the `app` directory is used', async ({ page }) => { - const transactionPromise = waitForTransaction('nextjs-16-cf-workers', 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-cf-workers', span => { + return span.name === '/parameterized/:one/beep' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/parameterized/cappuccino/beep`); - const transaction = await transactionPromise; - - expect(transaction).toMatchObject({ - contexts: { - react: { version: expect.any(String) }, - trace: { - data: { - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.segment.name.source': 'route', - }, - op: 'pageload', - origin: 'auto.pageload.nextjs.app_router_instrumentation', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - }, - environment: 'qa', - request: { - headers: expect.any(Object), - url: expect.stringMatching(/\/parameterized\/cappuccino\/beep$/), - }, - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: '/parameterized/:one/beep', - transaction_info: { source: 'route' }, - type: 'transaction', + const span = await spanPromise; + + expect(span.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/)); + expect(span.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/)); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.nextjs.app_router_instrumentation', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'react.version': { value: expect.any(String), type: 'string' }, }); }); -test('should create a nested parameterized transaction when the `app` directory is used.', async ({ page }) => { - const transactionPromise = waitForTransaction('nextjs-16-cf-workers', 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-cf-workers', span => { + return span.name === '/parameterized/:one/beep/:two' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/parameterized/cappuccino/beep/espresso`); - const transaction = await transactionPromise; - - expect(transaction).toMatchObject({ - contexts: { - react: { version: expect.any(String) }, - trace: { - data: { - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.segment.name.source': 'route', - }, - op: 'pageload', - origin: 'auto.pageload.nextjs.app_router_instrumentation', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - }, - environment: 'qa', - request: { - headers: expect.any(Object), - url: expect.stringMatching(/\/parameterized\/cappuccino\/beep\/espresso$/), - }, - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: '/parameterized/:one/beep/:two', - transaction_info: { source: 'route' }, - type: 'transaction', + const span = await spanPromise; + + expect(span.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/)); + expect(span.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/)); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.nextjs.app_router_instrumentation', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'react.version': { value: expect.any(String), type: 'string' }, }); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/prefetch-spans.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/prefetch-spans.test.ts index f48158a54697..81659848d63c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/prefetch-spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/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-cf-workers', 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-cf-workers', 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-cf-workers/tests/route-handler.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/route-handler.test.ts index 16368e5be57b..afaa0fe899c3 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/route-handler.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/route-handler.test.ts @@ -1,37 +1,37 @@ import test, { expect } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test.skip('Should create a transaction for node route handlers', async ({ request }) => { - const routehandlerTransactionPromise = waitForTransaction('nextjs-16-cf-workers', async transactionEvent => { - return transactionEvent?.transaction === 'GET /route-handler/[xoxo]/node'; +test.skip('Should create a span for node route handlers', async ({ request }) => { + const routehandlerSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => { + return span.name === 'GET /route-handler/[xoxo]/node' && span.is_segment; }); const response = await request.get('/route-handler/123/node', { headers: { 'x-charly': 'gomez' } }); expect(await response.json()).toStrictEqual({ message: 'Hello Node Route Handler' }); - const routehandlerTransaction = await routehandlerTransactionPromise; + const routehandlerSpan = await routehandlerSpanPromise; - expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok'); - expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); + expect(routehandlerSpan.status).toBe('ok'); + expect(getSpanOp(routehandlerSpan)).toBe('http.server'); // Custom headers are not captured on Cloudflare Workers // This assertion is skipped for CF Workers environment }); -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-cf-workers', async transactionEvent => { - return transactionEvent?.transaction === 'GET /route-handler/[xoxo]/edge'; + const routehandlerSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', 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'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-action-redirect.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-action-redirect.test.ts index 09ae79cc60a7..7d7f75e5eb7b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-action-redirect.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/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.skip('Should handle server action redirect without capturing errors', async ({ page }) => { - // Wait for the initial page load transaction - const pageLoadTransactionPromise = waitForTransaction('nextjs-16-cf-workers', async transactionEvent => { - return transactionEvent?.transaction === '/redirect/origin'; + // Wait for the initial pageload span + const pageLoadSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', 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-cf-workers', async transactionEvent => { - return transactionEvent?.transaction === 'GET /redirect/destination'; + // Wait for the redirect span + const redirectSpanPromise = waitForStreamedSpan('nextjs-16-cf-workers', span => { + return span.name === 'GET /redirect/destination' && span.is_segment; }); // No error should be captured @@ -26,7 +26,7 @@ test.skip('Should handle server action redirect without capturing errors', async // 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-cf-workers/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-components.test.ts index 69b83669a630..c4e94f494ab6 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-components.test.ts @@ -1,99 +1,95 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans } from '@sentry-internal/test-utils'; + +// 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-cf-workers', spans => + spans.some(span => span.name === segmentName && span.is_segment), + ).then(spans => spans.map(span => span.name)); +} // TODO: Server component tests need SDK adjustments for Cloudflare Workers -test.skip('Sends a transaction for a request to app router with URL', async ({ page }) => { - const serverComponentTransactionPromise = waitForTransaction('nextjs-16-cf-workers', transactionEvent => { - return ( - transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' && - transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42') - ); - }); +test.skip('Sends a span for a request to app router with URL', async ({ page }) => { + const spansPromise = collectStreamedSpans('nextjs-16-cf-workers', 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(transactionEvent.request).toMatchObject({ - url: expect.stringContaining('/parameterized/1337/beep/42'), + expect(segmentSpan.span_id).toEqual(expect.stringMatching(/[a-f0-9]{16}/)); + expect(segmentSpan.trace_id).toEqual(expect.stringMatching(/[a-f0-9]{32}/)); + expect(segmentSpan.status).toBe('ok'); + expect(segmentSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'http.method': { value: 'GET', type: 'string' }, + 'http.response.status_code': { value: 200, type: 'integer' }, + 'http.route': { value: '/parameterized/[one]/beep/[two]', type: 'string' }, + 'http.status_code': { value: 200, type: 'integer' }, + 'http.target': { value: '/parameterized/1337/beep/42', type: 'string' }, + 'sentry.kind': { value: 'server', type: 'string' }, + 'next.route': { value: '/parameterized/[one]/beep/[two]', type: 'string' }, }); - // The transaction should not contain any spans with the same name as the transaction - // e.g. "GET /parameterized/[one]/beep/[two]" - expect( - transactionEvent.spans?.filter(span => { - return span.description === transactionEvent.transaction; - }), - ).toHaveLength(0); + // No child span should share the segment span's name + expect(spans.filter(span => !span.is_segment && span.name === segmentSpan.name)).toHaveLength(0); }); // TODO: Server component span tests need SDK adjustments for Cloudflare Workers -test.skip('Will create a transaction with spans for every server component and metadata generation functions when visiting a page', async ({ +test.skip('Will create spans for every server component and metadata generation functions when visiting a page', async ({ page, }) => { - const serverTransactionEventPromise = waitForTransaction('nextjs-16-cf-workers', 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'); }); // TODO: Server component span tests need SDK adjustments for Cloudflare Workers -test.skip('Will create a transaction with spans for every server component and metadata generation functions when visiting a dynamic page', async ({ +test.skip('Will create spans for every server component and metadata generation functions when visiting a dynamic page', async ({ page, }) => { - const serverTransactionEventPromise = waitForTransaction('nextjs-16-cf-workers', async transactionEvent => { - return transactionEvent?.transaction === 'GET /nested-layout/[dynamic]'; - }); + const spanNamesPromise = collectSpanNamesUntilSegment('GET /nested-layout/[dynamic]'); await page.goto('/nested-layout/123'); - const spanDescriptions = (await serverTransactionEventPromise).spans?.map(span => { - return span.description; - }); + const spanNames = await spanNamesPromise; - expect(spanDescriptions).toContainEqual('resolve page components'); - expect(spanDescriptions).toContainEqual('render route (app) /nested-layout/[dynamic]'); - expect(spanDescriptions).toContainEqual('build component tree'); - expect(spanDescriptions).toContainEqual('resolve root layout server component'); - expect(spanDescriptions).toContainEqual('resolve layout server component "(nested-layout)"'); - expect(spanDescriptions).toContainEqual('resolve layout server component "nested-layout"'); - expect(spanDescriptions).toContainEqual('resolve layout server component "[dynamic]"'); - expect(spanDescriptions).toContainEqual('resolve page server component "/nested-layout/[dynamic]"'); - expect(spanDescriptions).toContainEqual('generateMetadata /(nested-layout)/nested-layout/[dynamic]/page'); - expect(spanDescriptions).toContainEqual('start response'); + expect(spanNames).toContainEqual('resolve page components'); + expect(spanNames).toContainEqual('render route (app) /nested-layout/[dynamic]'); + expect(spanNames).toContainEqual('build component tree'); + expect(spanNames).toContainEqual('resolve root layout server component'); + expect(spanNames).toContainEqual('resolve layout server component "(nested-layout)"'); + expect(spanNames).toContainEqual('resolve layout server component "nested-layout"'); + expect(spanNames).toContainEqual('resolve layout server component "[dynamic]"'); + expect(spanNames).toContainEqual('resolve page server component "/nested-layout/[dynamic]"'); + expect(spanNames).toContainEqual('generateMetadata /(nested-layout)/nested-layout/[dynamic]/page'); + expect(spanNames).toContainEqual('start response'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/streaming-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/streaming-rsc-error.test.ts index 6819554087b8..296d485e8644 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/streaming-rsc-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/streaming-rsc-error.test.ts @@ -1,25 +1,33 @@ 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 ({ +// TODO: Under span streaming the Workers runtime emits no server segment span for a request +// interrupted by a streaming RSC error, so the correlation below cannot be satisfied. +// See https://github.com/getsentry/sentry-javascript/issues/23932 +test.skip('Should capture errors for crashing streaming promises in server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({ page, }) => { const errorEventPromise = waitForError('nextjs-16-cf-workers', errorEvent => { return !!errorEvent?.exception?.values?.some(value => value.value === 'I am a data streaming error'); }); - const serverTransactionPromise = waitForTransaction('nextjs-16-cf-workers', 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-cf-workers', 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),