From 161ad310fa6bd9054a35cc55e07b10a41bed5e5d Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 2 Sep 2026 11:29:36 +0200 Subject: [PATCH 1/4] test(e2e): Port React Router framework E2E apps to span streaming Removes the `traceLifecycle: 'static'` pin from `react-router-7-framework`, `react-router-8-framework` and `react-router-7-framework-spa`, and rewrites their specs against streamed span v2. Envelope-level metadata has no span equivalent, so the SDK assertions read the `sentry.sdk.*` and `sentry.environment` attributes instead, and the `runtime` tag assertions are dropped. The trace connection spec is keyed off the `sentry-trace` meta tag. Spans are buffered before they flush, so an earlier page load on `/` can still be arriving here, and matching the two halves independently paired spans from different loads. The document advertises its own trace, and the client span is selected by the parent it continues, so a pageload that failed to continue the trace still fails the test rather than passing vacuously. --- .../app/entry.client.tsx | 1 - .../performance/navigation.client.test.ts | 78 ++--- .../tests/performance/pageload.client.test.ts | 133 +++----- .../app/entry.client.tsx | 1 - .../react-router-7-framework/instrument.mjs | 1 - .../low-quality-filter.server.test.ts | 30 +- .../performance/middleware.server.test.ts | 45 ++- .../performance/navigation.client.test.ts | 287 +++++++----------- .../tests/performance/pageload.client.test.ts | 179 ++++------- .../performance/performance.server.test.ts | 128 +++----- .../tests/performance/redis.server.test.ts | 33 +- .../performance/trace-propagation.test.ts | 41 ++- .../app/entry.client.tsx | 1 - .../react-router-8-framework/instrument.mjs | 1 - .../low-quality-filter.server.test.ts | 30 +- .../performance/middleware.server.test.ts | 45 ++- .../performance/navigation.client.test.ts | 287 +++++++----------- .../tests/performance/pageload.client.test.ts | 179 ++++------- .../performance/performance.server.test.ts | 128 +++----- .../tests/performance/redis.server.test.ts | 33 +- .../performance/trace-propagation.test.ts | 41 ++- 21 files changed, 657 insertions(+), 1045 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/app/entry.client.tsx index fb6b19851227..249e18d27c08 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/app/entry.client.tsx @@ -4,7 +4,6 @@ import { hydrateRoot } from 'react-dom/client'; import { HydratedRouter } from 'react-router/dom'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions // todo: get this from env dsn: 'https://username@domain/123', diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/navigation.client.test.ts index bbe7bf9a4907..91f21bc32ec7 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/navigation.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/navigation.client.test.ts @@ -1,66 +1,46 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; test.describe('client - navigation performance', () => { - test('should update navigation transaction for dynamic routes', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/with/:param' && - transactionEvent.contexts?.trace?.op === 'navigation' - ); + test('should update navigation span for dynamic routes', async ({ page }) => { + const navigationSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/with/:param' && getSpanOp(span) === 'navigation' && span.is_segment; }); - const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance`); // pageload - await pageloadTxPromise; + await pageloadSpanPromise; await page.getByRole('link', { name: 'With Param Page' }).click(); // navigation - const transaction = await txPromise; + const span = await navigationSpanPromise; - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.origin': 'auto.navigation.react_router', - 'sentry.op': 'navigation', - 'sentry.segment.name.source': 'route', - 'url.template': '/performance/with/:param', - 'url.path': '/performance/with/sentry', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/sentry$/), - }, - op: 'navigation', - origin: 'auto.navigation.react_router', - }, - }, - spans: expect.any(Array), + expect(span).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: '/performance/with/:param', - type: 'transaction', - transaction_info: { source: 'route' }, - platform: 'javascript', - request: { - url: expect.stringContaining('/performance/with/sentry'), - headers: expect.any(Object), - }, - event_id: expect.any(String), - environment: 'qa', - sdk: { - integrations: expect.arrayContaining([expect.any(String)]), - name: 'sentry.javascript.react-router', - version: expect.any(String), - packages: [ - { name: 'npm:@sentry/react-router', version: expect.any(String) }, - { name: 'npm:@sentry/browser', version: expect.any(String) }, - ], + end_timestamp: expect.any(Number), + is_segment: true, + status: 'ok', + }); + + expect(span.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.navigation.react_router', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.react-router', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.sdk.integrations': { value: expect.arrayContaining([expect.any(String)]), type: 'array' }, + 'url.template': { value: '/performance/with/:param', type: 'string' }, + 'url.path': { value: '/performance/with/sentry', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/sentry$/), + type: 'string', }, - tags: { runtime: 'browser' }, }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts index 0c37841fcada..10a270928ece 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts @@ -1,116 +1,63 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; test.describe('client - pageload performance', () => { - test('should send pageload transaction', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + test('should send pageload span', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance`); await page.getByRole('heading', { name: 'Performance Page' }).waitFor(); - const transaction = await txPromise; + const span = await spanPromise; - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.origin': 'auto.pageload.react_router', - 'sentry.op': 'pageload', - 'sentry.segment.name.source': 'route', - 'url.template': '/performance', - 'url.path': '/performance', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance$/), - }, - op: 'pageload', - origin: 'auto.pageload.react_router', - }, - }, - spans: expect.any(Array), + expect(span).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: '/performance', - type: 'transaction', - transaction_info: { source: 'route' }, - measurements: expect.any(Object), - platform: 'javascript', - request: { - url: expect.stringContaining('/performance'), - headers: expect.any(Object), - }, - event_id: expect.any(String), - environment: 'qa', - sdk: { - integrations: expect.arrayContaining([expect.any(String)]), - name: 'sentry.javascript.react-router', - version: expect.any(String), - packages: [ - { name: 'npm:@sentry/react-router', version: expect.any(String) }, - { name: 'npm:@sentry/browser', version: expect.any(String) }, - ], - }, - tags: { runtime: 'browser' }, + end_timestamp: expect.any(Number), + is_segment: true, + status: 'ok', + }); + + expect(span.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.pageload.react_router', type: 'string' }, + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.react-router', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.sdk.integrations': { value: expect.arrayContaining([expect.any(String)]), type: 'array' }, + 'url.template': { value: '/performance', type: 'string' }, + 'url.path': { value: '/performance', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance$/), type: 'string' }, }); }); - test('should update pageload transaction for dynamic routes', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/with/:param' && - transactionEvent.contexts?.trace?.op === 'pageload' - ); + test('should update pageload span for dynamic routes', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/with/:param' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance/with/sentry`); await page.getByRole('heading', { name: 'Dynamic Parameter Page' }).waitFor(); - const transaction = await txPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.origin': 'auto.pageload.react_router', - 'sentry.op': 'pageload', - 'sentry.segment.name.source': 'route', - 'url.template': '/performance/with/:param', - 'url.path': '/performance/with/sentry', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/sentry$/), - }, - op: 'pageload', - origin: 'auto.pageload.react_router', - }, - }, - spans: expect.any(Array), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: '/performance/with/:param', - type: 'transaction', - transaction_info: { source: 'route' }, - measurements: expect.any(Object), - platform: 'javascript', - request: { - url: expect.stringContaining('/performance/with/sentry'), - headers: expect.any(Object), - }, - event_id: expect.any(String), - environment: 'qa', - sdk: { - integrations: expect.arrayContaining([expect.any(String)]), - name: 'sentry.javascript.react-router', - version: expect.any(String), - packages: [ - { name: 'npm:@sentry/react-router', version: expect.any(String) }, - { name: 'npm:@sentry/browser', version: expect.any(String) }, - ], + const span = await spanPromise; + + expect(span.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.pageload.react_router', type: 'string' }, + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.react-router', type: 'string' }, + 'url.template': { value: '/performance/with/:param', type: 'string' }, + 'url.path': { value: '/performance/with/sentry', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/sentry$/), + type: 'string', }, - tags: { runtime: 'browser' }, }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.client.tsx index d4a0e1d0900b..005268b40ad0 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.client.tsx @@ -4,7 +4,6 @@ import { hydrateRoot } from 'react-dom/client'; import { HydratedRouter } from 'react-router/dom'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions // todo: get this from env dsn: 'https://username@domain/123', diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework/instrument.mjs index 00a6d2952286..c16240141b6d 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/instrument.mjs @@ -1,7 +1,6 @@ import * as Sentry from '@sentry/react-router'; Sentry.init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/low-quality-filter.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/low-quality-filter.server.test.ts index 0664ac5c99e1..a1f44a541709 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/low-quality-filter.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/low-quality-filter.server.test.ts @@ -1,19 +1,18 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; -test.describe('low-quality transaction filter', () => { - test('does not send a server transaction for /__manifest? requests', async ({ page }) => { - const serverTxns: Array<{ contexts?: { trace?: { data?: Record } } }> = []; +test.describe('low-quality span filter', () => { + test('does not send a server span for /__manifest? requests', async ({ page }) => { + const streamedSpans: SerializedStreamedSpan[] = []; - const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' - ); + const navigationPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/ssr' && getSpanOp(span) === 'navigation' && span.is_segment; }); - waitForTransaction(APP_NAME, async evt => { - serverTxns.push(evt); + waitForStreamedSpans(APP_NAME, spans => { + streamedSpans.push(...spans); return false; }); @@ -23,12 +22,13 @@ test.describe('low-quality transaction filter', () => { await navigationPromise; - // Force the server to flush any in-flight transactions before we assert + // Force the server to flush any in-flight spans before we assert await page.evaluate(() => fetch('/__sentry-flush')); - const targetIsManifest = (t: (typeof serverTxns)[number]) => - typeof t.contexts?.trace?.data?.['url.path'] === 'string' && - (t.contexts.trace.data['url.path'] as string).includes('/__manifest'); - expect(serverTxns.some(targetIsManifest)).toBe(false); + const targetIsManifest = (span: SerializedStreamedSpan) => { + const urlPath = span.attributes['url.path']?.value; + return typeof urlPath === 'string' && urlPath.includes('/__manifest'); + }; + expect(streamedSpans.some(targetIsManifest)).toBe(false); }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/middleware.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/middleware.server.test.ts index dbce05350ad9..f3022fede097 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/middleware.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/middleware.server.test.ts @@ -1,38 +1,33 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; test.describe('server - middleware', () => { - test('should send middleware transaction on pageload', async ({ page }) => { - const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance/with-middleware'; - }); - - const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with-middleware'; - }); - - const customMiddlewareTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'authMiddleware'; + test('should send middleware span on pageload', async ({ page }) => { + const serverSpansPromise = collectStreamedSpans( + APP_NAME, + spansOfTrace => + spansOfTrace.some(span => span.name === 'GET /performance/with-middleware' && span.is_segment) && + spansOfTrace.some(span => span.name === 'authMiddleware'), + ); + + const pageloadSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/with-middleware' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance/with-middleware`); - const serverTx = await serverTxPromise; - const pageloadTx = await pageloadTxPromise; - const customMiddlewareTx = await customMiddlewareTxPromise; + const serverSpans = await serverSpansPromise; + const pageloadSpan = await pageloadSpanPromise; - const traceIds = { - server: serverTx?.contexts?.trace?.trace_id, - pageload: pageloadTx?.contexts?.trace?.trace_id, - customMiddleware: customMiddlewareTx?.contexts?.trace?.trace_id, - }; + const serverSpan = serverSpans.find(span => span.is_segment)!; + const customMiddlewareSpan = serverSpans.find(span => span.name === 'authMiddleware')!; - expect(pageloadTx).toBeDefined(); - expect(customMiddlewareTx).toBeDefined(); + expect(pageloadSpan).toBeDefined(); + expect(customMiddlewareSpan).toBeDefined(); - // Assert that all transactions belong to the same trace - expect(traceIds.server).toBe(traceIds.pageload); - expect(traceIds.server).toBe(traceIds.customMiddleware); + // Assert that all spans belong to the same trace + expect(serverSpan.trace_id).toBe(pageloadSpan.trace_id); + expect(serverSpan.trace_id).toBe(customMiddlewareSpan.trace_id); }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts index 59b2f8a9e038..e203c87b9664 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts @@ -1,243 +1,174 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; test.describe('client - navigation performance', () => { - test('should create navigation transaction', async ({ page }) => { - const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' - ); + test('should create navigation span', async ({ page }) => { + const navigationSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/ssr' && getSpanOp(span) === 'navigation' && span.is_segment; }); - const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance`); // pageload - await pageloadTxPromise; + await pageloadSpanPromise; await page.getByRole('link', { name: 'SSR Page' }).click(); // navigation - const transaction = await navigationPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.origin': 'auto.navigation.react_router', - 'sentry.op': 'navigation', - 'sentry.segment.name.source': 'route', - 'url.template': '/performance/ssr', - 'url.path': '/performance/ssr', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/ssr$/), - }, - op: 'navigation', - origin: 'auto.navigation.react_router', - }, - }, - spans: expect.any(Array), + const span = await navigationSpanPromise; + + expect(span).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: '/performance/ssr', - type: 'transaction', - transaction_info: { source: 'route' }, - platform: 'javascript', - request: { - url: expect.stringContaining('/performance/ssr'), - headers: expect.any(Object), - }, - event_id: expect.any(String), - environment: 'qa', - sdk: { - integrations: expect.arrayContaining([expect.any(String)]), - name: 'sentry.javascript.react-router', - version: expect.any(String), - packages: [ - { name: 'npm:@sentry/react-router', version: expect.any(String) }, - { name: 'npm:@sentry/browser', version: expect.any(String) }, - ], - }, - tags: { runtime: 'browser' }, + end_timestamp: expect.any(Number), + is_segment: true, + status: 'ok', + }); + + expect(span.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.navigation.react_router', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.react-router', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.sdk.integrations': { value: expect.arrayContaining([expect.any(String)]), type: 'array' }, + 'url.template': { value: '/performance/ssr', type: 'string' }, + 'url.path': { value: '/performance/ssr', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/ssr$/), type: 'string' }, }); }); - test('should create navigation transaction when navigating with object `to` prop', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/with/:param' && - transactionEvent.contexts?.trace?.op === 'navigation' - ); + test('should create navigation span when navigating with object `to` prop', async ({ page }) => { + const navigationSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/with/:param' && getSpanOp(span) === 'navigation' && span.is_segment; }); - const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance`); // pageload - await pageloadTxPromise; + await pageloadSpanPromise; await page.getByRole('link', { name: 'Object Navigate' }).click(); // navigation with object to - const transaction = await txPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react_router', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/performance/with/:param', - 'url.path': '/performance/with/object-nav', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/object-nav\?foo=bar$/), - }, - }, + const span = await navigationSpanPromise; + + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react_router', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/performance/with/:param', type: 'string' }, + 'url.path': { value: '/performance/with/object-nav', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/object-nav\?foo=bar$/), + type: 'string', }, - transaction: '/performance/with/:param', - type: 'transaction', - transaction_info: { source: 'route' }, }); }); - test('should create navigation transaction when navigating with search-only object `to` prop', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'navigation'; + test('should create navigation span when navigating with search-only object `to` prop', async ({ page }) => { + const navigationSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'navigation' && span.is_segment; }); - const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance`); // pageload - await pageloadTxPromise; + await pageloadSpanPromise; await page.getByRole('link', { name: 'Search Only Navigate' }).click(); // navigation with search-only object to - const transaction = await txPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react_router', - data: { - 'url.template': '/performance', - // the initial pageload to `/performance` gets 301-redirected to a trailing slash by react-router-serve - 'url.path': '/performance/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/\?query=test$/), - }, - }, + const span = await navigationSpanPromise; + + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react_router', type: 'string' }, + 'url.template': { value: '/performance', type: 'string' }, + // the initial pageload to `/performance` gets 301-redirected to a trailing slash by react-router-serve + 'url.path': { value: '/performance/', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/\?query=test$/), + type: 'string', }, - transaction: '/performance', - type: 'transaction', }); }); - test('should update navigation transaction for dynamic routes', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/with/:param' && - transactionEvent.contexts?.trace?.op === 'navigation' - ); + test('should update navigation span for dynamic routes', async ({ page }) => { + const navigationSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/with/:param' && getSpanOp(span) === 'navigation' && span.is_segment; }); - const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance`); // pageload - await pageloadTxPromise; + await pageloadSpanPromise; await page.getByRole('link', { name: 'With Param Page' }).click(); // navigation - const transaction = await txPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.origin': 'auto.navigation.react_router', - 'sentry.op': 'navigation', - 'sentry.segment.name.source': 'route', - 'url.template': '/performance/with/:param', - 'url.path': '/performance/with/sentry', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/sentry$/), - }, - op: 'navigation', - origin: 'auto.navigation.react_router', - }, - }, - spans: expect.any(Array), + const span = await navigationSpanPromise; + + expect(span).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: '/performance/with/:param', - type: 'transaction', - transaction_info: { source: 'route' }, - platform: 'javascript', - request: { - url: expect.stringContaining('/performance/with/sentry'), - headers: expect.any(Object), - }, - event_id: expect.any(String), - environment: 'qa', - sdk: { - integrations: expect.arrayContaining([expect.any(String)]), - name: 'sentry.javascript.react-router', - version: expect.any(String), - packages: [ - { name: 'npm:@sentry/react-router', version: expect.any(String) }, - { name: 'npm:@sentry/browser', version: expect.any(String) }, - ], + end_timestamp: expect.any(Number), + is_segment: true, + status: 'ok', + }); + + expect(span.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.navigation.react_router', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.react-router', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.sdk.integrations': { value: expect.arrayContaining([expect.any(String)]), type: 'array' }, + 'url.template': { value: '/performance/with/:param', type: 'string' }, + 'url.path': { value: '/performance/with/sentry', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/sentry$/), + type: 'string', }, - tags: { runtime: 'browser' }, }); }); - test('should create navigation transaction for navigate(-1) with correct url attributes', async ({ page }) => { - const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + test('should create navigation span for navigate(-1) with correct url attributes', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance`); - await pageloadTxPromise; + await pageloadSpanPromise; - const forwardNavPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' - ); + const forwardNavPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/ssr' && getSpanOp(span) === 'navigation' && span.is_segment; }); await page.getByRole('link', { name: 'SSR Page' }).click(); await forwardNavPromise; - const backNavPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'navigation'; + const backNavPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'navigation' && span.is_segment; }); await page.getByRole('button', { name: 'History Back Navigate' }).click(); - const transaction = await backNavPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react_router', - data: { - 'sentry.segment.name.source': 'route', - 'sentry.op': 'navigation', - 'sentry.origin': 'auto.navigation.react_router', - 'url.template': '/performance', - // react-router-serve 301-redirects the bare index route to a trailing slash - 'url.path': '/performance/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/$/), - }, - }, - }, - transaction: '/performance', - type: 'transaction', - transaction_info: { source: 'route' }, + const span = await backNavPromise; + + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react_router', type: 'string' }, + 'url.template': { value: '/performance', type: 'string' }, + // react-router-serve 301-redirects the bare index route to a trailing slash + 'url.path': { value: '/performance/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/$/), type: 'string' }, }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts index e3bea05a36ef..15ece1778f40 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts @@ -1,148 +1,93 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; test.describe('client - pageload performance', () => { - test('should send pageload transaction', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + test('should send pageload span', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance`); - const transaction = await txPromise; + const span = await spanPromise; - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.origin': 'auto.pageload.react_router', - 'sentry.op': 'pageload', - 'sentry.segment.name.source': 'route', - 'url.template': '/performance', - // react-router-serve 301-redirects the bare index route to a trailing slash - 'url.path': '/performance/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/$/), - }, - op: 'pageload', - origin: 'auto.pageload.react_router', - }, - }, - spans: expect.any(Array), + expect(span).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: '/performance', - type: 'transaction', - transaction_info: { source: 'route' }, - measurements: expect.any(Object), - platform: 'javascript', - request: { - url: expect.stringContaining('/performance'), - headers: expect.any(Object), - }, - event_id: expect.any(String), - environment: 'qa', - sdk: { - integrations: expect.arrayContaining([expect.any(String)]), - name: 'sentry.javascript.react-router', - version: expect.any(String), - packages: [ - { name: 'npm:@sentry/react-router', version: expect.any(String) }, - { name: 'npm:@sentry/browser', version: expect.any(String) }, - ], - }, - tags: { runtime: 'browser' }, + end_timestamp: expect.any(Number), + is_segment: true, + status: 'ok', + }); + + expect(span.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.pageload.react_router', type: 'string' }, + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.react-router', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.sdk.integrations': { value: expect.arrayContaining([expect.any(String)]), type: 'array' }, + 'url.template': { value: '/performance', type: 'string' }, + // react-router-serve 301-redirects the bare index route to a trailing slash + 'url.path': { value: '/performance/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/$/), type: 'string' }, }); }); - test('should update pageload transaction for dynamic routes', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/with/:param' && - transactionEvent.contexts?.trace?.op === 'pageload' - ); + test('should update pageload span for dynamic routes', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/with/:param' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance/with/sentry`); - const transaction = await txPromise; + const span = await spanPromise; - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.origin': 'auto.pageload.react_router', - 'sentry.op': 'pageload', - 'sentry.segment.name.source': 'route', - 'url.template': '/performance/with/:param', - 'url.path': '/performance/with/sentry', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/sentry$/), - }, - op: 'pageload', - origin: 'auto.pageload.react_router', - }, - }, - spans: expect.any(Array), + expect(span).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: '/performance/with/:param', - type: 'transaction', - transaction_info: { source: 'route' }, - measurements: expect.any(Object), - platform: 'javascript', - request: { - url: expect.stringContaining('/performance/with/sentry'), - headers: expect.any(Object), - }, - event_id: expect.any(String), - environment: 'qa', - sdk: { - integrations: expect.arrayContaining([expect.any(String)]), - name: 'sentry.javascript.react-router', - version: expect.any(String), - packages: [ - { name: 'npm:@sentry/react-router', version: expect.any(String) }, - { name: 'npm:@sentry/browser', version: expect.any(String) }, - ], + end_timestamp: expect.any(Number), + is_segment: true, + status: 'ok', + }); + + expect(span.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.pageload.react_router', type: 'string' }, + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.react-router', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.sdk.integrations': { value: expect.arrayContaining([expect.any(String)]), type: 'array' }, + 'url.template': { value: '/performance/with/:param', type: 'string' }, + 'url.path': { value: '/performance/with/sentry', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/sentry$/), + type: 'string', }, - tags: { runtime: 'browser' }, }); }); - test('should send pageload transaction for prerendered pages', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/static' && transactionEvent.contexts?.trace?.op === 'pageload' - ); + test('should send pageload span for prerendered pages', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/static' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance/static`); - const transaction = await txPromise; + const span = await spanPromise; - expect(transaction).toMatchObject({ - transaction: '/performance/static', - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.origin': 'auto.pageload.react_router', - 'sentry.op': 'pageload', - 'sentry.segment.name.source': 'route', - 'url.template': '/performance/static', - // react-router-serve 301-redirects prerendered routes to a trailing slash - 'url.path': '/performance/static/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/static\/$/), - }, - op: 'pageload', - origin: 'auto.pageload.react_router', - }, - }, + expect(span.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.pageload.react_router', type: 'string' }, + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/performance/static', type: 'string' }, + // react-router-serve 301-redirects prerendered routes to a trailing slash + 'url.path': { value: '/performance/static/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/static\/$/), type: 'string' }, }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts index bcfe15e49c34..432b7c60f5f0 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts @@ -1,107 +1,65 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; test.describe('server - performance', () => { - test('should send server transaction on pageload', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance'; + test('should send server span on pageload', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === 'GET /performance' && span.is_segment; }); await page.goto(`/performance`); - const transaction = await txPromise; + const span = await spanPromise; - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.react_router.request_handler', - 'sentry.segment.name.source': 'route', - }, - op: 'http.server', - origin: 'auto.http.react_router.request_handler', - }, - }, - spans: expect.any(Array), + expect(span).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: 'GET /performance', - type: 'transaction', - transaction_info: { source: 'route' }, - platform: 'node', - request: { - url: expect.stringContaining('/performance'), - headers: expect.any(Object), - }, - event_id: expect.any(String), - environment: 'qa', - sdk: { - integrations: expect.arrayContaining([expect.any(String)]), - name: 'sentry.javascript.react-router', - version: expect.any(String), - packages: [ - { name: 'npm:@sentry/react-router', version: expect.any(String) }, - { name: 'npm:@sentry/node', version: expect.any(String) }, - ], - }, - tags: { - runtime: 'node', - }, + end_timestamp: expect.any(Number), + is_segment: true, + }); + + expect(getSpanOp(span)).toBe('http.server'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.react_router.request_handler', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.react-router', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.sdk.integrations': { value: expect.arrayContaining([expect.any(String)]), type: 'array' }, + 'url.full': { value: expect.stringContaining('/performance'), type: 'string' }, }); }); - test('should send server transaction on parameterized route', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance/with/:param'; + test('should send server span on parameterized route', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === 'GET /performance/with/:param' && span.is_segment; }); await page.goto(`/performance/with/some-param`); - const transaction = await txPromise; + const span = await spanPromise; - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.react_router.request_handler', - 'sentry.segment.name.source': 'route', - }, - op: 'http.server', - origin: 'auto.http.react_router.request_handler', - }, - }, - spans: expect.any(Array), + expect(span).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: 'GET /performance/with/:param', - type: 'transaction', - transaction_info: { source: 'route' }, - platform: 'node', - request: { - url: expect.stringContaining('/performance/with/some-param'), - headers: expect.any(Object), - }, - event_id: expect.any(String), - environment: 'qa', - sdk: { - integrations: expect.arrayContaining([expect.any(String)]), - name: 'sentry.javascript.react-router', - version: expect.any(String), - packages: [ - { name: 'npm:@sentry/react-router', version: expect.any(String) }, - { name: 'npm:@sentry/node', version: expect.any(String) }, - ], - }, - tags: { - runtime: 'node', - }, + end_timestamp: expect.any(Number), + is_segment: true, + }); + + expect(getSpanOp(span)).toBe('http.server'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.react_router.request_handler', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.react-router', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.sdk.integrations': { value: expect.arrayContaining([expect.any(String)]), type: 'array' }, + 'url.full': { value: expect.stringContaining('/performance/with/some-param'), type: 'string' }, }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/redis.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/redis.server.test.ts index 1b82a709ab7b..9872dfc534c7 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/redis.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/redis.server.test.ts @@ -1,32 +1,31 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; test.describe('server - redis db spans', () => { - test('server loader emits db.query child spans on the http.server transaction', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === 'GET /performance/redis' && - (transactionEvent.spans?.some(span => span.op === 'db.query') ?? false) - ); - }); + test('server loader emits db.query child spans on the http.server segment', async ({ page }) => { + const spansPromise = collectStreamedSpans( + APP_NAME, + spansOfTrace => + spansOfTrace.some(span => span.name === 'GET /performance/redis' && span.is_segment) && + // loader runs SET then GET => at least two redis command spans + spansOfTrace.filter(span => getSpanOp(span) === 'db.query').length >= 2, + ); await page.goto('/performance/redis'); - const transaction = await txPromise; - - expect(transaction.contexts?.trace?.op).toBe('http.server'); + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment)!; - // Collect every span id in the transaction (root + children) so we can verify nesting. - const rootSpanId = transaction.contexts?.trace?.span_id; - const spanIds = new Set([rootSpanId, ...(transaction.spans ?? []).map(span => span.span_id)]); + expect(getSpanOp(segmentSpan)).toBe('http.server'); - const redisSpans = transaction.spans!.filter(span => span.op === 'db.query'); + // Collect every span id in the trace (segment + children) so we can verify nesting. + const spanIds = new Set(spans.map(span => span.span_id)); + const redisSpans = spans.filter(span => getSpanOp(span) === 'db.query'); - // loader runs SET then GET => at least two redis command spans expect(redisSpans.length).toBeGreaterThanOrEqual(2); - // every redis span nests under the http.server transaction (its parent is part of the same span tree) + // every redis span nests under the http.server segment (its parent is part of the same span tree) const allNested = redisSpans.every( span => typeof span.parent_span_id === 'string' && spanIds.has(span.parent_span_id), ); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/trace-propagation.test.ts index b62e784ff3e3..9ac6ccb807b1 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/trace-propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/trace-propagation.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpans } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; test.describe('Trace propagation', () => { @@ -18,24 +19,40 @@ test.describe('Trace propagation', () => { }); test('should have trace connection', async ({ page }) => { - const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET *'; - }); - - const clientTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/'; + // Streamed spans are buffered before they flush, so spans from an earlier page load can still be + // arriving here. The document advertises its own trace in the `sentry-trace` meta tag, so that is + // what tells this page load's spans apart rather than the op or the URL. + const streamedSpans: SerializedStreamedSpan[] = []; + void waitForStreamedSpans(APP_NAME, spans => { + streamedSpans.push(...spans); + return false; }); await page.goto(`/`); - const serverTx = await serverTxPromise; - const clientTx = await clientTxPromise; - expect(clientTx.contexts?.trace?.trace_id).toEqual(serverTx.contexts?.trace?.trace_id); + const sentryTrace = await page.getAttribute('meta[name="sentry-trace"]', 'content'); + const [traceId, handlerSpanId] = (sentryTrace ?? '').split('-'); + expect(traceId).toMatch(/^[a-f0-9]{32}$/); + expect(handlerSpanId).toMatch(/^[a-f0-9]{16}$/); + + // The client continues the server trace, so its pageload span hangs off the span the meta tag + // names. Selecting it that way, rather than by op, is what makes the trace assertion below mean + // something: a pageload that failed to continue the trace would have no parent at all. + const findClientSpan = () => + streamedSpans.find( + span => getSpanOp(span) === 'pageload' && span.is_segment && span.parent_span_id === handlerSpanId, + ); + await expect.poll(findClientSpan).toBeDefined(); + expect(findClientSpan()!.trace_id).toBe(traceId); - const requestHandlerSpan = serverTx.spans?.find(span => span.op === 'handler'); + const findServerSegmentSpan = () => + streamedSpans.find(span => getSpanOp(span) === 'http.server' && span.is_segment && span.trace_id === traceId); + await expect.poll(findServerSegmentSpan).toBeDefined(); + const requestHandlerSpan = streamedSpans.find(span => span.span_id === handlerSpanId); expect(requestHandlerSpan).toBeDefined(); - expect(clientTx.contexts?.trace?.parent_span_id).toBe(requestHandlerSpan?.span_id); + expect(getSpanOp(requestHandlerSpan!)).toBe('handler'); + expect(requestHandlerSpan!.trace_id).toBe(traceId); }); test('should not have trace connection for prerendered pages', async ({ page }) => { diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx index d4a0e1d0900b..005268b40ad0 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.client.tsx @@ -4,7 +4,6 @@ import { hydrateRoot } from 'react-dom/client'; import { HydratedRouter } from 'react-router/dom'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions // todo: get this from env dsn: 'https://username@domain/123', diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs index 00a6d2952286..c16240141b6d 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/instrument.mjs @@ -1,7 +1,6 @@ import * as Sentry from '@sentry/react-router'; Sentry.init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/low-quality-filter.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/low-quality-filter.server.test.ts index 0664ac5c99e1..a1f44a541709 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/low-quality-filter.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/low-quality-filter.server.test.ts @@ -1,19 +1,18 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; -test.describe('low-quality transaction filter', () => { - test('does not send a server transaction for /__manifest? requests', async ({ page }) => { - const serverTxns: Array<{ contexts?: { trace?: { data?: Record } } }> = []; +test.describe('low-quality span filter', () => { + test('does not send a server span for /__manifest? requests', async ({ page }) => { + const streamedSpans: SerializedStreamedSpan[] = []; - const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' - ); + const navigationPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/ssr' && getSpanOp(span) === 'navigation' && span.is_segment; }); - waitForTransaction(APP_NAME, async evt => { - serverTxns.push(evt); + waitForStreamedSpans(APP_NAME, spans => { + streamedSpans.push(...spans); return false; }); @@ -23,12 +22,13 @@ test.describe('low-quality transaction filter', () => { await navigationPromise; - // Force the server to flush any in-flight transactions before we assert + // Force the server to flush any in-flight spans before we assert await page.evaluate(() => fetch('/__sentry-flush')); - const targetIsManifest = (t: (typeof serverTxns)[number]) => - typeof t.contexts?.trace?.data?.['url.path'] === 'string' && - (t.contexts.trace.data['url.path'] as string).includes('/__manifest'); - expect(serverTxns.some(targetIsManifest)).toBe(false); + const targetIsManifest = (span: SerializedStreamedSpan) => { + const urlPath = span.attributes['url.path']?.value; + return typeof urlPath === 'string' && urlPath.includes('/__manifest'); + }; + expect(streamedSpans.some(targetIsManifest)).toBe(false); }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/middleware.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/middleware.server.test.ts index dbce05350ad9..f3022fede097 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/middleware.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/middleware.server.test.ts @@ -1,38 +1,33 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; test.describe('server - middleware', () => { - test('should send middleware transaction on pageload', async ({ page }) => { - const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance/with-middleware'; - }); - - const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with-middleware'; - }); - - const customMiddlewareTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'authMiddleware'; + test('should send middleware span on pageload', async ({ page }) => { + const serverSpansPromise = collectStreamedSpans( + APP_NAME, + spansOfTrace => + spansOfTrace.some(span => span.name === 'GET /performance/with-middleware' && span.is_segment) && + spansOfTrace.some(span => span.name === 'authMiddleware'), + ); + + const pageloadSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/with-middleware' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance/with-middleware`); - const serverTx = await serverTxPromise; - const pageloadTx = await pageloadTxPromise; - const customMiddlewareTx = await customMiddlewareTxPromise; + const serverSpans = await serverSpansPromise; + const pageloadSpan = await pageloadSpanPromise; - const traceIds = { - server: serverTx?.contexts?.trace?.trace_id, - pageload: pageloadTx?.contexts?.trace?.trace_id, - customMiddleware: customMiddlewareTx?.contexts?.trace?.trace_id, - }; + const serverSpan = serverSpans.find(span => span.is_segment)!; + const customMiddlewareSpan = serverSpans.find(span => span.name === 'authMiddleware')!; - expect(pageloadTx).toBeDefined(); - expect(customMiddlewareTx).toBeDefined(); + expect(pageloadSpan).toBeDefined(); + expect(customMiddlewareSpan).toBeDefined(); - // Assert that all transactions belong to the same trace - expect(traceIds.server).toBe(traceIds.pageload); - expect(traceIds.server).toBe(traceIds.customMiddleware); + // Assert that all spans belong to the same trace + expect(serverSpan.trace_id).toBe(pageloadSpan.trace_id); + expect(serverSpan.trace_id).toBe(customMiddlewareSpan.trace_id); }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/navigation.client.test.ts index 59b2f8a9e038..e203c87b9664 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/navigation.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/navigation.client.test.ts @@ -1,243 +1,174 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; test.describe('client - navigation performance', () => { - test('should create navigation transaction', async ({ page }) => { - const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' - ); + test('should create navigation span', async ({ page }) => { + const navigationSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/ssr' && getSpanOp(span) === 'navigation' && span.is_segment; }); - const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance`); // pageload - await pageloadTxPromise; + await pageloadSpanPromise; await page.getByRole('link', { name: 'SSR Page' }).click(); // navigation - const transaction = await navigationPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.origin': 'auto.navigation.react_router', - 'sentry.op': 'navigation', - 'sentry.segment.name.source': 'route', - 'url.template': '/performance/ssr', - 'url.path': '/performance/ssr', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/ssr$/), - }, - op: 'navigation', - origin: 'auto.navigation.react_router', - }, - }, - spans: expect.any(Array), + const span = await navigationSpanPromise; + + expect(span).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: '/performance/ssr', - type: 'transaction', - transaction_info: { source: 'route' }, - platform: 'javascript', - request: { - url: expect.stringContaining('/performance/ssr'), - headers: expect.any(Object), - }, - event_id: expect.any(String), - environment: 'qa', - sdk: { - integrations: expect.arrayContaining([expect.any(String)]), - name: 'sentry.javascript.react-router', - version: expect.any(String), - packages: [ - { name: 'npm:@sentry/react-router', version: expect.any(String) }, - { name: 'npm:@sentry/browser', version: expect.any(String) }, - ], - }, - tags: { runtime: 'browser' }, + end_timestamp: expect.any(Number), + is_segment: true, + status: 'ok', + }); + + expect(span.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.navigation.react_router', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.react-router', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.sdk.integrations': { value: expect.arrayContaining([expect.any(String)]), type: 'array' }, + 'url.template': { value: '/performance/ssr', type: 'string' }, + 'url.path': { value: '/performance/ssr', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/ssr$/), type: 'string' }, }); }); - test('should create navigation transaction when navigating with object `to` prop', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/with/:param' && - transactionEvent.contexts?.trace?.op === 'navigation' - ); + test('should create navigation span when navigating with object `to` prop', async ({ page }) => { + const navigationSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/with/:param' && getSpanOp(span) === 'navigation' && span.is_segment; }); - const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance`); // pageload - await pageloadTxPromise; + await pageloadSpanPromise; await page.getByRole('link', { name: 'Object Navigate' }).click(); // navigation with object to - const transaction = await txPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react_router', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/performance/with/:param', - 'url.path': '/performance/with/object-nav', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/object-nav\?foo=bar$/), - }, - }, + const span = await navigationSpanPromise; + + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react_router', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/performance/with/:param', type: 'string' }, + 'url.path': { value: '/performance/with/object-nav', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/object-nav\?foo=bar$/), + type: 'string', }, - transaction: '/performance/with/:param', - type: 'transaction', - transaction_info: { source: 'route' }, }); }); - test('should create navigation transaction when navigating with search-only object `to` prop', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'navigation'; + test('should create navigation span when navigating with search-only object `to` prop', async ({ page }) => { + const navigationSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'navigation' && span.is_segment; }); - const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance`); // pageload - await pageloadTxPromise; + await pageloadSpanPromise; await page.getByRole('link', { name: 'Search Only Navigate' }).click(); // navigation with search-only object to - const transaction = await txPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react_router', - data: { - 'url.template': '/performance', - // the initial pageload to `/performance` gets 301-redirected to a trailing slash by react-router-serve - 'url.path': '/performance/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/\?query=test$/), - }, - }, + const span = await navigationSpanPromise; + + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react_router', type: 'string' }, + 'url.template': { value: '/performance', type: 'string' }, + // the initial pageload to `/performance` gets 301-redirected to a trailing slash by react-router-serve + 'url.path': { value: '/performance/', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/\?query=test$/), + type: 'string', }, - transaction: '/performance', - type: 'transaction', }); }); - test('should update navigation transaction for dynamic routes', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/with/:param' && - transactionEvent.contexts?.trace?.op === 'navigation' - ); + test('should update navigation span for dynamic routes', async ({ page }) => { + const navigationSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/with/:param' && getSpanOp(span) === 'navigation' && span.is_segment; }); - const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance`); // pageload - await pageloadTxPromise; + await pageloadSpanPromise; await page.getByRole('link', { name: 'With Param Page' }).click(); // navigation - const transaction = await txPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.origin': 'auto.navigation.react_router', - 'sentry.op': 'navigation', - 'sentry.segment.name.source': 'route', - 'url.template': '/performance/with/:param', - 'url.path': '/performance/with/sentry', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/sentry$/), - }, - op: 'navigation', - origin: 'auto.navigation.react_router', - }, - }, - spans: expect.any(Array), + const span = await navigationSpanPromise; + + expect(span).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: '/performance/with/:param', - type: 'transaction', - transaction_info: { source: 'route' }, - platform: 'javascript', - request: { - url: expect.stringContaining('/performance/with/sentry'), - headers: expect.any(Object), - }, - event_id: expect.any(String), - environment: 'qa', - sdk: { - integrations: expect.arrayContaining([expect.any(String)]), - name: 'sentry.javascript.react-router', - version: expect.any(String), - packages: [ - { name: 'npm:@sentry/react-router', version: expect.any(String) }, - { name: 'npm:@sentry/browser', version: expect.any(String) }, - ], + end_timestamp: expect.any(Number), + is_segment: true, + status: 'ok', + }); + + expect(span.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.navigation.react_router', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.react-router', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.sdk.integrations': { value: expect.arrayContaining([expect.any(String)]), type: 'array' }, + 'url.template': { value: '/performance/with/:param', type: 'string' }, + 'url.path': { value: '/performance/with/sentry', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/sentry$/), + type: 'string', }, - tags: { runtime: 'browser' }, }); }); - test('should create navigation transaction for navigate(-1) with correct url attributes', async ({ page }) => { - const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + test('should create navigation span for navigate(-1) with correct url attributes', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance`); - await pageloadTxPromise; + await pageloadSpanPromise; - const forwardNavPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' - ); + const forwardNavPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/ssr' && getSpanOp(span) === 'navigation' && span.is_segment; }); await page.getByRole('link', { name: 'SSR Page' }).click(); await forwardNavPromise; - const backNavPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'navigation'; + const backNavPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'navigation' && span.is_segment; }); await page.getByRole('button', { name: 'History Back Navigate' }).click(); - const transaction = await backNavPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react_router', - data: { - 'sentry.segment.name.source': 'route', - 'sentry.op': 'navigation', - 'sentry.origin': 'auto.navigation.react_router', - 'url.template': '/performance', - // react-router-serve 301-redirects the bare index route to a trailing slash - 'url.path': '/performance/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/$/), - }, - }, - }, - transaction: '/performance', - type: 'transaction', - transaction_info: { source: 'route' }, + const span = await backNavPromise; + + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react_router', type: 'string' }, + 'url.template': { value: '/performance', type: 'string' }, + // react-router-serve 301-redirects the bare index route to a trailing slash + 'url.path': { value: '/performance/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/$/), type: 'string' }, }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/pageload.client.test.ts index e3bea05a36ef..15ece1778f40 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/pageload.client.test.ts @@ -1,148 +1,93 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; test.describe('client - pageload performance', () => { - test('should send pageload transaction', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + test('should send pageload span', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance`); - const transaction = await txPromise; + const span = await spanPromise; - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.origin': 'auto.pageload.react_router', - 'sentry.op': 'pageload', - 'sentry.segment.name.source': 'route', - 'url.template': '/performance', - // react-router-serve 301-redirects the bare index route to a trailing slash - 'url.path': '/performance/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/$/), - }, - op: 'pageload', - origin: 'auto.pageload.react_router', - }, - }, - spans: expect.any(Array), + expect(span).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: '/performance', - type: 'transaction', - transaction_info: { source: 'route' }, - measurements: expect.any(Object), - platform: 'javascript', - request: { - url: expect.stringContaining('/performance'), - headers: expect.any(Object), - }, - event_id: expect.any(String), - environment: 'qa', - sdk: { - integrations: expect.arrayContaining([expect.any(String)]), - name: 'sentry.javascript.react-router', - version: expect.any(String), - packages: [ - { name: 'npm:@sentry/react-router', version: expect.any(String) }, - { name: 'npm:@sentry/browser', version: expect.any(String) }, - ], - }, - tags: { runtime: 'browser' }, + end_timestamp: expect.any(Number), + is_segment: true, + status: 'ok', + }); + + expect(span.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.pageload.react_router', type: 'string' }, + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.react-router', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.sdk.integrations': { value: expect.arrayContaining([expect.any(String)]), type: 'array' }, + 'url.template': { value: '/performance', type: 'string' }, + // react-router-serve 301-redirects the bare index route to a trailing slash + 'url.path': { value: '/performance/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/$/), type: 'string' }, }); }); - test('should update pageload transaction for dynamic routes', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/with/:param' && - transactionEvent.contexts?.trace?.op === 'pageload' - ); + test('should update pageload span for dynamic routes', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/with/:param' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance/with/sentry`); - const transaction = await txPromise; + const span = await spanPromise; - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.origin': 'auto.pageload.react_router', - 'sentry.op': 'pageload', - 'sentry.segment.name.source': 'route', - 'url.template': '/performance/with/:param', - 'url.path': '/performance/with/sentry', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/sentry$/), - }, - op: 'pageload', - origin: 'auto.pageload.react_router', - }, - }, - spans: expect.any(Array), + expect(span).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: '/performance/with/:param', - type: 'transaction', - transaction_info: { source: 'route' }, - measurements: expect.any(Object), - platform: 'javascript', - request: { - url: expect.stringContaining('/performance/with/sentry'), - headers: expect.any(Object), - }, - event_id: expect.any(String), - environment: 'qa', - sdk: { - integrations: expect.arrayContaining([expect.any(String)]), - name: 'sentry.javascript.react-router', - version: expect.any(String), - packages: [ - { name: 'npm:@sentry/react-router', version: expect.any(String) }, - { name: 'npm:@sentry/browser', version: expect.any(String) }, - ], + end_timestamp: expect.any(Number), + is_segment: true, + status: 'ok', + }); + + expect(span.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.pageload.react_router', type: 'string' }, + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.react-router', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.sdk.integrations': { value: expect.arrayContaining([expect.any(String)]), type: 'array' }, + 'url.template': { value: '/performance/with/:param', type: 'string' }, + 'url.path': { value: '/performance/with/sentry', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/sentry$/), + type: 'string', }, - tags: { runtime: 'browser' }, }); }); - test('should send pageload transaction for prerendered pages', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/static' && transactionEvent.contexts?.trace?.op === 'pageload' - ); + test('should send pageload span for prerendered pages', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/static' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance/static`); - const transaction = await txPromise; + const span = await spanPromise; - expect(transaction).toMatchObject({ - transaction: '/performance/static', - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.origin': 'auto.pageload.react_router', - 'sentry.op': 'pageload', - 'sentry.segment.name.source': 'route', - 'url.template': '/performance/static', - // react-router-serve 301-redirects prerendered routes to a trailing slash - 'url.path': '/performance/static/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/static\/$/), - }, - op: 'pageload', - origin: 'auto.pageload.react_router', - }, - }, + expect(span.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.pageload.react_router', type: 'string' }, + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/performance/static', type: 'string' }, + // react-router-serve 301-redirects prerendered routes to a trailing slash + 'url.path': { value: '/performance/static/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/static\/$/), type: 'string' }, }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/performance.server.test.ts index bcfe15e49c34..432b7c60f5f0 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/performance.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/performance.server.test.ts @@ -1,107 +1,65 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; test.describe('server - performance', () => { - test('should send server transaction on pageload', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance'; + test('should send server span on pageload', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === 'GET /performance' && span.is_segment; }); await page.goto(`/performance`); - const transaction = await txPromise; + const span = await spanPromise; - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.react_router.request_handler', - 'sentry.segment.name.source': 'route', - }, - op: 'http.server', - origin: 'auto.http.react_router.request_handler', - }, - }, - spans: expect.any(Array), + expect(span).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: 'GET /performance', - type: 'transaction', - transaction_info: { source: 'route' }, - platform: 'node', - request: { - url: expect.stringContaining('/performance'), - headers: expect.any(Object), - }, - event_id: expect.any(String), - environment: 'qa', - sdk: { - integrations: expect.arrayContaining([expect.any(String)]), - name: 'sentry.javascript.react-router', - version: expect.any(String), - packages: [ - { name: 'npm:@sentry/react-router', version: expect.any(String) }, - { name: 'npm:@sentry/node', version: expect.any(String) }, - ], - }, - tags: { - runtime: 'node', - }, + end_timestamp: expect.any(Number), + is_segment: true, + }); + + expect(getSpanOp(span)).toBe('http.server'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.react_router.request_handler', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.react-router', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.sdk.integrations': { value: expect.arrayContaining([expect.any(String)]), type: 'array' }, + 'url.full': { value: expect.stringContaining('/performance'), type: 'string' }, }); }); - test('should send server transaction on parameterized route', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance/with/:param'; + test('should send server span on parameterized route', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === 'GET /performance/with/:param' && span.is_segment; }); await page.goto(`/performance/with/some-param`); - const transaction = await txPromise; + const span = await spanPromise; - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.react_router.request_handler', - 'sentry.segment.name.source': 'route', - }, - op: 'http.server', - origin: 'auto.http.react_router.request_handler', - }, - }, - spans: expect.any(Array), + expect(span).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: 'GET /performance/with/:param', - type: 'transaction', - transaction_info: { source: 'route' }, - platform: 'node', - request: { - url: expect.stringContaining('/performance/with/some-param'), - headers: expect.any(Object), - }, - event_id: expect.any(String), - environment: 'qa', - sdk: { - integrations: expect.arrayContaining([expect.any(String)]), - name: 'sentry.javascript.react-router', - version: expect.any(String), - packages: [ - { name: 'npm:@sentry/react-router', version: expect.any(String) }, - { name: 'npm:@sentry/node', version: expect.any(String) }, - ], - }, - tags: { - runtime: 'node', - }, + end_timestamp: expect.any(Number), + is_segment: true, + }); + + expect(getSpanOp(span)).toBe('http.server'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.react_router.request_handler', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.react-router', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.sdk.integrations': { value: expect.arrayContaining([expect.any(String)]), type: 'array' }, + 'url.full': { value: expect.stringContaining('/performance/with/some-param'), type: 'string' }, }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/redis.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/redis.server.test.ts index 1b82a709ab7b..9872dfc534c7 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/redis.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/redis.server.test.ts @@ -1,32 +1,31 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; test.describe('server - redis db spans', () => { - test('server loader emits db.query child spans on the http.server transaction', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === 'GET /performance/redis' && - (transactionEvent.spans?.some(span => span.op === 'db.query') ?? false) - ); - }); + test('server loader emits db.query child spans on the http.server segment', async ({ page }) => { + const spansPromise = collectStreamedSpans( + APP_NAME, + spansOfTrace => + spansOfTrace.some(span => span.name === 'GET /performance/redis' && span.is_segment) && + // loader runs SET then GET => at least two redis command spans + spansOfTrace.filter(span => getSpanOp(span) === 'db.query').length >= 2, + ); await page.goto('/performance/redis'); - const transaction = await txPromise; - - expect(transaction.contexts?.trace?.op).toBe('http.server'); + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment)!; - // Collect every span id in the transaction (root + children) so we can verify nesting. - const rootSpanId = transaction.contexts?.trace?.span_id; - const spanIds = new Set([rootSpanId, ...(transaction.spans ?? []).map(span => span.span_id)]); + expect(getSpanOp(segmentSpan)).toBe('http.server'); - const redisSpans = transaction.spans!.filter(span => span.op === 'db.query'); + // Collect every span id in the trace (segment + children) so we can verify nesting. + const spanIds = new Set(spans.map(span => span.span_id)); + const redisSpans = spans.filter(span => getSpanOp(span) === 'db.query'); - // loader runs SET then GET => at least two redis command spans expect(redisSpans.length).toBeGreaterThanOrEqual(2); - // every redis span nests under the http.server transaction (its parent is part of the same span tree) + // every redis span nests under the http.server segment (its parent is part of the same span tree) const allNested = redisSpans.every( span => typeof span.parent_span_id === 'string' && spanIds.has(span.parent_span_id), ); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/trace-propagation.test.ts index 2c888675429e..9ac6ccb807b1 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/trace-propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/trace-propagation.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpans } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; test.describe('Trace propagation', () => { @@ -18,24 +19,40 @@ test.describe('Trace propagation', () => { }); test('should have trace connection', async ({ page }) => { - const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /{*splat}'; - }); - - const clientTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/'; + // Streamed spans are buffered before they flush, so spans from an earlier page load can still be + // arriving here. The document advertises its own trace in the `sentry-trace` meta tag, so that is + // what tells this page load's spans apart rather than the op or the URL. + const streamedSpans: SerializedStreamedSpan[] = []; + void waitForStreamedSpans(APP_NAME, spans => { + streamedSpans.push(...spans); + return false; }); await page.goto(`/`); - const serverTx = await serverTxPromise; - const clientTx = await clientTxPromise; - expect(clientTx.contexts?.trace?.trace_id).toEqual(serverTx.contexts?.trace?.trace_id); + const sentryTrace = await page.getAttribute('meta[name="sentry-trace"]', 'content'); + const [traceId, handlerSpanId] = (sentryTrace ?? '').split('-'); + expect(traceId).toMatch(/^[a-f0-9]{32}$/); + expect(handlerSpanId).toMatch(/^[a-f0-9]{16}$/); + + // The client continues the server trace, so its pageload span hangs off the span the meta tag + // names. Selecting it that way, rather than by op, is what makes the trace assertion below mean + // something: a pageload that failed to continue the trace would have no parent at all. + const findClientSpan = () => + streamedSpans.find( + span => getSpanOp(span) === 'pageload' && span.is_segment && span.parent_span_id === handlerSpanId, + ); + await expect.poll(findClientSpan).toBeDefined(); + expect(findClientSpan()!.trace_id).toBe(traceId); - const requestHandlerSpan = serverTx.spans?.find(span => span.op === 'handler'); + const findServerSegmentSpan = () => + streamedSpans.find(span => getSpanOp(span) === 'http.server' && span.is_segment && span.trace_id === traceId); + await expect.poll(findServerSegmentSpan).toBeDefined(); + const requestHandlerSpan = streamedSpans.find(span => span.span_id === handlerSpanId); expect(requestHandlerSpan).toBeDefined(); - expect(clientTx.contexts?.trace?.parent_span_id).toBe(requestHandlerSpan?.span_id); + expect(getSpanOp(requestHandlerSpan!)).toBe('handler'); + expect(requestHandlerSpan!.trace_id).toBe(traceId); }); test('should not have trace connection for prerendered pages', async ({ page }) => { From be21d076ced5987a072a272fc02c462464565237 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:25:33 +0200 Subject: [PATCH 2/4] test(e2e): Port the React Router instrumentation API E2E app to span streaming (#23845) ## What Ports `react-router-7-framework-instrumentation` to span streaming. ## Why Span streaming is the default now, so the E2E suite has to exercise it. Loader, action, middleware and fetcher assertions walk the streamed trace instead of a transaction's `spans` array, and the orchestrion mysql spec matches on `db.query.text`, since a streamed mysql span is named after its query summary. Part of #23798 --- .../app/entry.client.tsx | 1 - .../instrument.mjs | 1 - .../tests/errors/errors.server.test.ts | 93 +++--- .../tests/performance/db.server.test.ts | 125 ++++--- .../tests/performance/fetcher.client.test.ts | 92 +++--- .../tests/performance/lazy.server.test.ts | 95 +++--- .../performance/middleware.server.test.ts | 107 +++--- .../performance/navigation.client.test.ts | 305 +++++++----------- .../tests/performance/pageload.client.test.ts | 91 ++---- .../performance/performance.server.test.ts | 213 ++++++------ 10 files changed, 475 insertions(+), 648 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.client.tsx index 856bb4b3208a..97539b6fd7cd 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.client.tsx @@ -8,7 +8,6 @@ import { HydratedRouter } from 'react-router/dom'; const tracing = Sentry.reactRouterTracingIntegration({ useInstrumentationAPI: true }); Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: 'https://username@domain/123', tunnel: `http://localhost:3031/`, // proxy server diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs index 00a6d2952286..c16240141b6d 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs @@ -1,7 +1,6 @@ import * as Sentry from '@sentry/react-router'; Sentry.init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/errors/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/errors/errors.server.test.ts index d4eadfdf0797..1cbbc96db7e3 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/errors/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/errors/errors.server.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; test.describe('server - instrumentation API error capture', () => { @@ -8,15 +8,15 @@ test.describe('server - instrumentation API error capture', () => { return errorEvent.exception?.values?.[0]?.value === 'Loader error for testing'; }); - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance/error-loader'; + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === 'GET /performance/error-loader' && span.is_segment; }); await page.goto(`/performance/error-loader`).catch(() => { // Expected to fail due to loader error }); - const [error, transaction] = await Promise.all([errorPromise, txPromise]); + const [error, span] = await Promise.all([errorPromise, spanPromise]); // Verify the error was captured with correct mechanism and transaction name expect(error).toMatchObject({ @@ -36,58 +36,51 @@ test.describe('server - instrumentation API error capture', () => { }); // Verify the transaction was also created with correct attributes - expect(transaction).toMatchObject({ - transaction: 'GET /performance/error-loader', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.react_router.instrumentation_api', - }, - }, - }); + expect(span.name).toBe('GET /performance/error-loader'); + expect(getSpanOp(span)).toBe('http.server'); + expect(span.attributes['sentry.origin']?.value).toBe('auto.http.react_router.instrumentation_api'); }); - test('should include loader span in transaction even when loader throws', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance/error-loader'; - }); + test('should include loader span in the segment even when loader throws', async ({ page }) => { + const spansPromise = collectStreamedSpans(APP_NAME, spansOfTrace => + spansOfTrace.some(span => span.name === 'GET /performance/error-loader' && span.is_segment), + ); await page.goto(`/performance/error-loader`).catch(() => { // Expected to fail due to loader error }); - const transaction = await txPromise; + const spans = await spansPromise; // Find the loader span - const loaderSpan = transaction?.spans?.find(span => span.data?.['code.function.name'] === 'loader'); + const loaderSpan = spans.find(span => span.attributes['code.function.name']?.value === 'loader'); - expect(loaderSpan).toMatchObject({ - data: { - 'sentry.origin': 'auto.function.react_router.instrumentation_api', - 'sentry.op': 'function', - 'code.function.name': 'loader', - }, - op: 'function', + expect(loaderSpan).toBeDefined(); + expect(getSpanOp(loaderSpan!)).toBe('function'); + expect(loaderSpan!.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.function.react_router.instrumentation_api', type: 'string' }, + 'sentry.op': { value: 'function', type: 'string' }, + 'code.function.name': { value: 'loader', type: 'string' }, }); }); - test('error and transaction should share the same trace', async ({ page }) => { + test('error and segment span should share the same trace', async ({ page }) => { const errorPromise = waitForError(APP_NAME, async errorEvent => { return errorEvent.exception?.values?.[0]?.value === 'Loader error for testing'; }); - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance/error-loader'; + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === 'GET /performance/error-loader' && span.is_segment; }); await page.goto(`/performance/error-loader`).catch(() => { // Expected to fail due to loader error }); - const [error, transaction] = await Promise.all([errorPromise, txPromise]); + const [error, span] = await Promise.all([errorPromise, spanPromise]); - // Error and transaction should have the same trace_id - expect(error.contexts?.trace?.trace_id).toBe(transaction.contexts?.trace?.trace_id); + // Error and segment span should have the same trace_id + expect(error.contexts?.trace?.trace_id).toBe(span.trace_id); }); // Skipped in dev: the action error is sometimes captured via the client instrumentation path @@ -101,14 +94,14 @@ test.describe('server - instrumentation API error capture', () => { return errorEvent.exception?.values?.[0]?.value === 'Action error for testing'; }); - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'POST /performance/error-action'; + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === 'POST /performance/error-action' && span.is_segment; }); await page.goto(`/performance/error-action`); await page.getByRole('button', { name: 'Trigger Error' }).click(); - const [error, transaction] = await Promise.all([errorPromise, txPromise]); + const [error, span] = await Promise.all([errorPromise, spanPromise]); expect(error).toMatchObject({ exception: { @@ -126,15 +119,9 @@ test.describe('server - instrumentation API error capture', () => { transaction: 'POST /performance/error-action', }); - expect(transaction).toMatchObject({ - transaction: 'POST /performance/error-action', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.react_router.instrumentation_api', - }, - }, - }); + expect(span.name).toBe('POST /performance/error-action'); + expect(getSpanOp(span)).toBe('http.server'); + expect(span.attributes['sentry.origin']?.value).toBe('auto.http.react_router.instrumentation_api'); }); test('should capture middleware errors with instrumentation API mechanism', async ({ page }) => { @@ -142,15 +129,15 @@ test.describe('server - instrumentation API error capture', () => { return errorEvent.exception?.values?.[0]?.value === 'Middleware error for testing'; }); - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance/error-middleware'; + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === 'GET /performance/error-middleware' && span.is_segment; }); await page.goto(`/performance/error-middleware`).catch(() => { // Expected to fail due to middleware error }); - const [error, transaction] = await Promise.all([errorPromise, txPromise]); + const [error, span] = await Promise.all([errorPromise, spanPromise]); expect(error).toMatchObject({ exception: { @@ -168,14 +155,8 @@ test.describe('server - instrumentation API error capture', () => { transaction: 'GET /performance/error-middleware', }); - expect(transaction).toMatchObject({ - transaction: 'GET /performance/error-middleware', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.react_router.instrumentation_api', - }, - }, - }); + expect(span.name).toBe('GET /performance/error-middleware'); + expect(getSpanOp(span)).toBe('http.server'); + expect(span.attributes['sentry.origin']?.value).toBe('auto.http.react_router.instrumentation_api'); }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/db.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/db.server.test.ts index a5db182f4eb4..7ce9ef5bda26 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/db.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/db.server.test.ts @@ -1,111 +1,94 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; // Same spans in both runs, from two injectors: the build-time transform in the server bundle, and // the runtime hook in `react-router dev`, where the drivers stay on Node's own loader. test.describe('server - orchestrion db instrumentation', () => { test('instruments ioredis automatically via orchestrion', async ({ page }) => { - const transactionEventPromise = waitForTransaction(APP_NAME, transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - transactionEvent.transaction === 'GET /performance/db-ioredis' - ); - }); + const spansPromise = collectStreamedSpans(APP_NAME, spansOfTrace => + spansOfTrace.some(span => span.name === 'GET /performance/db-ioredis' && span.is_segment), + ); await page.goto('/performance/db-ioredis'); - const transactionEvent = await transactionEventPromise; - const spans = transactionEvent.spans || []; + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.name === 'GET /performance/db-ioredis' && span.is_segment)!; - // The server transaction must come from the native instrumentation API (not the legacy handler), + // The server segment must come from the native instrumentation API (not the legacy handler), // proving the orchestrion-injected db spans share context with the React Router server span. - expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.react_router.instrumentation_api'); + expect(getSpanOp(segmentSpan)).toBe('http.server'); + expect(segmentSpan.attributes['sentry.origin']?.value).toBe('auto.http.react_router.instrumentation_api'); - expect(spans).toContainEqual( + const childSpans = spans.filter(span => !span.is_segment); + + expect(childSpans).toContainEqual( expect.objectContaining({ - op: 'db.query', - origin: 'auto.db.redis', - description: 'set test-key [1 other arguments]', + name: 'set test-key [1 other arguments]', status: 'ok', - data: expect.objectContaining({ - 'db.system.name': 'redis', - 'db.operation.name': 'set', - 'db.query.text': 'set test-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 test-key [1 other arguments]', type: 'string' }, }), }), ); - expect(spans).toContainEqual( + expect(childSpans).toContainEqual( expect.objectContaining({ - op: 'db.query', - origin: 'auto.db.redis', - description: 'get test-key', + name: 'get test-key', status: 'ok', - data: expect.objectContaining({ - 'db.system.name': 'redis', - 'db.operation.name': 'get', - 'db.query.text': 'get test-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 test-key', type: 'string' }, }), }), ); // Each command maps to exactly one span (no offline-queue duplicate). - const setSpans = spans.filter(span => span.description === 'set test-key [1 other arguments]'); + const setSpans = spans.filter(span => span.name === 'set test-key [1 other arguments]'); expect(setSpans).toHaveLength(1); - // Every db span nests under the native instrumentation-API http.server transaction. - const rootSpanId = transactionEvent.contexts?.trace?.span_id; - const spanIds = new Set([rootSpanId, ...spans.map(span => span.span_id)]); - const dbSpans = spans.filter(span => span.origin === 'auto.db.redis'); + // Every db span nests under the native instrumentation-API http.server segment. + const spanIds = new Set(spans.filter(span => span.trace_id === segmentSpan.trace_id).map(span => span.span_id)); + const dbSpans = spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.db.redis'); expect(dbSpans.every(span => typeof span.parent_span_id === 'string' && spanIds.has(span.parent_span_id))).toBe( true, ); }); + // Under span streaming the mysql span name is the query summary, so both queries below are named + // `SELECT`. `db.query.text` is what tells them apart. test('instruments mysql automatically via orchestrion', async ({ page }) => { - const transactionEventPromise = waitForTransaction(APP_NAME, transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - transactionEvent.transaction === 'GET /performance/db-mysql' - ); - }); + const spansPromise = collectStreamedSpans(APP_NAME, spansOfTrace => + spansOfTrace.some(span => span.name === 'GET /performance/db-mysql' && span.is_segment), + ); await page.goto('/performance/db-mysql'); - const transactionEvent = await transactionEventPromise; - const spans = transactionEvent.spans || []; + const spans = await spansPromise; - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.mysql', - description: 'SELECT 1 + 1 AS solution', - status: 'ok', - data: expect.objectContaining({ - 'db.system.name': 'mysql', - 'db.query.text': 'SELECT 1 + 1 AS solution', - 'db.user': 'root', - 'db.connection_string': expect.any(String), - 'server.address': expect.any(String), - 'server.port': 3306, + for (const queryText of ['SELECT 1 + 1 AS solution', 'SELECT NOW()']) { + expect(spans).toContainEqual( + expect.objectContaining({ + name: 'SELECT', + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'db', type: 'string' }, + 'sentry.origin': { value: 'auto.db.mysql', type: 'string' }, + 'db.system.name': { value: 'mysql', type: 'string' }, + 'db.query.text': { value: queryText, type: 'string' }, + 'db.user': { value: 'root', type: 'string' }, + 'db.connection_string': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + 'server.port': { value: 3306, type: 'integer' }, + }), }), - }), - ); - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.mysql', - description: 'SELECT NOW()', - status: 'ok', - data: expect.objectContaining({ - 'db.system.name': 'mysql', - 'db.query.text': 'SELECT NOW()', - 'db.user': 'root', - 'db.connection_string': expect.any(String), - 'server.address': expect.any(String), - 'server.port': 3306, - }), - }), - ); + ); + } }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/fetcher.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/fetcher.client.test.ts index 28a3fb042145..5f1b8d964aac 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/fetcher.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/fetcher.client.test.ts @@ -1,77 +1,91 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; // As of React Router 7.15+, HydratedRouter invokes the client `fetch` hook in Framework Mode. -// A fetcher submission produces a `function` transaction (origin +// A fetcher submission produces a `function` span (origin // `auto.function.react_router.instrumentation_api`, `code.function.name` `fetcher`) that nests the // client action/loader spans and the `http.client` spans for the underlying `.data` requests. // See: https://github.com/remix-run/react-router/discussions/13749 +/** + * Every span below `parentSpan`, following `parent_span_id` down the tree. Transitive, so a span + * nested two levels under `parentSpan` is included too. + */ +function getDescendantSpansOf( + spans: SerializedStreamedSpan[], + parentSpan: SerializedStreamedSpan, +): SerializedStreamedSpan[] { + const descendants: SerializedStreamedSpan[] = []; + const parentIds = new Set([parentSpan.span_id]); + + // Streamed spans arrive parents-last, so keep sweeping until no new descendant is found. + let foundNew = true; + while (foundNew) { + foundNew = false; + for (const span of spans) { + if (span.parent_span_id && parentIds.has(span.parent_span_id) && !parentIds.has(span.span_id)) { + parentIds.add(span.span_id); + descendants.push(span); + foundNew = true; + } + } + } + + return descendants; +} + test.describe('client - instrumentation API fetcher', () => { test('should instrument fetcher with instrumentation API origin', async ({ page }) => { // Wait for the client pageload to finish so HydratedRouter is hydrated and the fetcher // submission goes through the instrumented client `fetch` path (not a full-document POST). - const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/fetcher-test' && - transactionEvent.contexts?.trace?.op === 'pageload' - ); + const pageloadSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/fetcher-test' && getSpanOp(span) === 'pageload' && span.is_segment; }); - const fetcherTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.contexts?.trace?.data?.['code.function.name'] === 'fetcher'; + const spansPromise = collectStreamedSpans(APP_NAME, spans => { + const fetcherSpan = spans.find(span => span.attributes['code.function.name']?.value === 'fetcher'); + return !!fetcherSpan && getDescendantSpansOf(spans, fetcherSpan).some(span => getSpanOp(span) === 'http.client'); }); await page.goto(`/performance/fetcher-test`); - await pageloadTxPromise; + await pageloadSpanPromise; await page.locator('#fetcher-submit').click(); - const fetcherTx = await fetcherTxPromise; + const spans = await spansPromise; + const fetcherSpan = spans.find(span => span.attributes['code.function.name']?.value === 'fetcher')!; - expect(fetcherTx.contexts?.trace?.origin).toBe('auto.function.react_router.instrumentation_api'); + expect(fetcherSpan.attributes['sentry.origin']?.value).toBe('auto.function.react_router.instrumentation_api'); - // The fetcher transaction nests the client action span and the http.client span(s) for the - // underlying `.data` request(s) - i.e. the OTel/browser fetch span is parented by the fetcher - // span, not emitted standalone. - const spans = fetcherTx.spans ?? []; - expect(spans.some(span => span.data?.['code.function.name'] === 'clientAction')).toBe(true); - expect(spans.map(span => span.op)).toContain('http.client'); + // The fetcher span nests the client action span and the http.client span(s) for the underlying + // `.data` request(s) - i.e. the browser fetch span is parented by the fetcher span, not emitted + // standalone. + const descendantSpans = getDescendantSpansOf(spans, fetcherSpan); + expect(descendantSpans.some(span => span.attributes['code.function.name']?.value === 'clientAction')).toBe(true); + expect(descendantSpans.map(span => getSpanOp(span))).toContain('http.client'); }); - test('should still send server action transaction when fetcher submits', async ({ page }) => { - const serverPageloadPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === 'GET /performance/fetcher-test' && - transactionEvent.contexts?.trace?.op === 'http.server' - ); + test('should still send server action span when fetcher submits', async ({ page }) => { + const serverPageloadPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === 'GET /performance/fetcher-test' && getSpanOp(span) === 'http.server' && span.is_segment; }); await page.goto(`/performance/fetcher-test`); await serverPageloadPromise; // Fetcher submit triggers a server action - const serverActionPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === 'POST /performance/fetcher-test' && - transactionEvent.contexts?.trace?.op === 'http.server' - ); + const serverActionPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === 'POST /performance/fetcher-test' && getSpanOp(span) === 'http.server' && span.is_segment; }); await page.locator('#fetcher-submit').click(); - const serverAction = await serverActionPromise; + const serverActionSpan = await serverActionPromise; - expect(serverAction).toMatchObject({ - transaction: 'POST /performance/fetcher-test', - contexts: { - trace: { - op: 'http.server', - origin: 'auto.http.react_router.instrumentation_api', - }, - }, - }); + expect(serverActionSpan.name).toBe('POST /performance/fetcher-test'); + expect(serverActionSpan.attributes['sentry.origin']?.value).toBe('auto.http.react_router.instrumentation_api'); // Verify fetcher result is displayed await expect(page.locator('#fetcher-result')).toHaveText('Fetcher result: test-value'); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/lazy.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/lazy.server.test.ts index 956d45a6104e..ef9412297d83 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/lazy.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/lazy.server.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; // Known React Router limitation: route.lazy hooks only work in Data Mode (createBrowserRouter). @@ -8,96 +8,83 @@ import { APP_NAME } from '../constants'; // Using test.fail() to auto-detect when React Router fixes this upstream. test.describe('server - instrumentation API lazy loading', () => { test.fail('should instrument lazy route loading with instrumentation API origin', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance/lazy-route'; - }); + const spansPromise = collectStreamedSpans(APP_NAME, spansOfTrace => + spansOfTrace.some(span => span.name === 'GET /performance/lazy-route' && span.is_segment), + ); await page.goto(`/performance/lazy-route`); - const transaction = await txPromise; + const spans = await spansPromise; // Verify the lazy route content is rendered await expect(page.locator('#lazy-route-title')).toBeVisible(); await expect(page.locator('#lazy-route-content')).toHaveText('This route was lazily loaded'); - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.react_router.instrumentation_api', - 'sentry.segment.name.source': 'route', - }, - op: 'http.server', - origin: 'auto.http.react_router.instrumentation_api', - }, - }, - spans: expect.any(Array), - transaction: 'GET /performance/lazy-route', - type: 'transaction', - transaction_info: { source: 'route' }, + const segmentSpan = spans.find(span => span.name === 'GET /performance/lazy-route' && span.is_segment)!; + + expect(segmentSpan.span_id).toEqual(expect.any(String)); + expect(segmentSpan.trace_id).toEqual(expect.any(String)); + expect(getSpanOp(segmentSpan)).toBe('http.server'); + expect(segmentSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.react_router.instrumentation_api', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, }); - // Find the lazy span - const lazySpan = transaction?.spans?.find(span => span.data?.['code.function.name'] === 'lazy'); + const lazySpan = spans.find(span => span.attributes['code.function.name']?.value === 'lazy'); expect(lazySpan).toMatchObject({ span_id: expect.any(String), trace_id: expect.any(String), - data: { - 'sentry.origin': 'auto.function.react_router.instrumentation_api', - 'sentry.op': 'function', - 'code.function.name': 'lazy', - }, - description: 'Lazy Route Load', + name: 'Lazy Route Load', parent_span_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - op: 'function', - origin: 'auto.function.react_router.instrumentation_api', + end_timestamp: expect.any(Number), + }); + + expect(lazySpan!.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.function.react_router.instrumentation_api', type: 'string' }, + 'sentry.op': { value: 'function', type: 'string' }, + 'code.function.name': { value: 'lazy', type: 'string' }, }); }); test('should include loader span after lazy loading completes', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance/lazy-route'; - }); + const spansPromise = collectStreamedSpans(APP_NAME, spansOfTrace => + spansOfTrace.some(span => span.name === 'GET /performance/lazy-route' && span.is_segment), + ); await page.goto(`/performance/lazy-route`); - const transaction = await txPromise; + const spans = await spansPromise; // Find the loader span that runs after lazy loading - const loaderSpan = transaction?.spans?.find(span => span.data?.['code.function.name'] === 'loader'); + const loaderSpan = spans.find(span => span.attributes['code.function.name']?.value === 'loader'); expect(loaderSpan).toMatchObject({ span_id: expect.any(String), trace_id: expect.any(String), - data: { - 'sentry.origin': 'auto.function.react_router.instrumentation_api', - 'sentry.op': 'function', - 'code.function.name': 'loader', - }, - description: '/performance/lazy-route', - op: 'function', - origin: 'auto.function.react_router.instrumentation_api', + name: '/performance/lazy-route', + }); + + expect(loaderSpan!.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.function.react_router.instrumentation_api', type: 'string' }, + 'sentry.op': { value: 'function', type: 'string' }, + 'code.function.name': { value: 'loader', type: 'string' }, }); }); test.fail('should have correct span ordering: lazy before loader', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance/lazy-route'; - }); + const spansPromise = collectStreamedSpans(APP_NAME, spansOfTrace => + spansOfTrace.some(span => span.name === 'GET /performance/lazy-route' && span.is_segment), + ); await page.goto(`/performance/lazy-route`); - const transaction = await txPromise; - - const lazySpan = transaction?.spans?.find(span => span.data?.['code.function.name'] === 'lazy'); + const spans = await spansPromise; - const loaderSpan = transaction?.spans?.find(span => span.data?.['code.function.name'] === 'loader'); + const lazySpan = spans.find(span => span.attributes['code.function.name']?.value === 'lazy'); + const loaderSpan = spans.find(span => span.attributes['code.function.name']?.value === 'loader'); expect(lazySpan).toBeDefined(); expect(loaderSpan).toBeDefined(); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/middleware.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/middleware.server.test.ts index 8409d9f18b8f..8ce2a929dec6 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/middleware.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/middleware.server.test.ts @@ -1,111 +1,104 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; test.describe('server - instrumentation API middleware', () => { test('should instrument server middleware with instrumentation API origin', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance/with-middleware'; - }); + const spansPromise = collectStreamedSpans(APP_NAME, spansOfTrace => + spansOfTrace.some(span => span.name === 'GET /performance/with-middleware' && span.is_segment), + ); await page.goto(`/performance/with-middleware`); - const transaction = await txPromise; + const spans = await spansPromise; // Verify the middleware route content is rendered await expect(page.locator('#middleware-route-title')).toBeVisible(); await expect(page.locator('#middleware-route-content')).toHaveText('This route has middleware'); - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.react_router.instrumentation_api', - 'sentry.segment.name.source': 'route', - }, - op: 'http.server', - origin: 'auto.http.react_router.instrumentation_api', - }, - }, - spans: expect.any(Array), - transaction: 'GET /performance/with-middleware', - type: 'transaction', - transaction_info: { source: 'route' }, + const segmentSpan = spans.find(span => span.name === 'GET /performance/with-middleware' && span.is_segment)!; + + expect(segmentSpan).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), + is_segment: true, + }); + + expect(getSpanOp(segmentSpan)).toBe('http.server'); + expect(segmentSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.react_router.instrumentation_api', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, }); - // Find the middleware span - const middlewareSpan = transaction?.spans?.find(span => span.data?.['code.function.name'] === 'middleware'); + const middlewareSpan = spans.find(span => span.attributes['code.function.name']?.value === 'middleware'); expect(middlewareSpan).toBeDefined(); expect(middlewareSpan).toMatchObject({ span_id: expect.any(String), trace_id: expect.any(String), - data: expect.objectContaining({ - 'sentry.origin': 'auto.function.react_router.instrumentation_api', - 'sentry.op': 'middleware', - 'code.function.name': 'middleware', - 'react_router.route.id': 'routes/performance/with-middleware', - 'http.route': '/performance/with-middleware', - 'react_router.middleware.index': 0, - }), parent_span_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - op: 'middleware', - origin: 'auto.function.react_router.instrumentation_api', + end_timestamp: expect.any(Number), }); - // Middleware name is available via OTEL patching of createRequestHandler - expect(middlewareSpan!.data?.['react_router.middleware.name']).toBe('authMiddleware'); - expect(middlewareSpan!.description).toBe('middleware authMiddleware'); + expect(middlewareSpan!.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.function.react_router.instrumentation_api', type: 'string' }, + 'sentry.op': { value: 'middleware', type: 'string' }, + 'code.function.name': { value: 'middleware', type: 'string' }, + 'react_router.route.id': { value: 'routes/performance/with-middleware', type: 'string' }, + 'http.route': { value: '/performance/with-middleware', type: 'string' }, + 'react_router.middleware.index': { value: 0, type: 'integer' }, + }); + + // Middleware name is available via the instrumentation API patching of createRequestHandler + expect(middlewareSpan!.attributes['react_router.middleware.name']?.value).toBe('authMiddleware'); + expect(middlewareSpan!.name).toBe('middleware authMiddleware'); }); test('should have middleware span run before loader span', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance/with-middleware'; - }); + const spansPromise = collectStreamedSpans(APP_NAME, spansOfTrace => + spansOfTrace.some(span => span.name === 'GET /performance/with-middleware' && span.is_segment), + ); await page.goto(`/performance/with-middleware`); - const transaction = await txPromise; + const spans = await spansPromise; - const middlewareSpan = transaction?.spans?.find(span => span.data?.['code.function.name'] === 'middleware'); - - const loaderSpan = transaction?.spans?.find(span => span.data?.['code.function.name'] === 'loader'); + const middlewareSpan = spans.find(span => span.attributes['code.function.name']?.value === 'middleware'); + const loaderSpan = spans.find(span => span.attributes['code.function.name']?.value === 'loader'); expect(middlewareSpan).toBeDefined(); expect(loaderSpan).toBeDefined(); // Middleware should start before loader - expect(middlewareSpan!.start_timestamp).toBeLessThanOrEqual(loaderSpan!.start_timestamp!); + expect(middlewareSpan!.start_timestamp).toBeLessThanOrEqual(loaderSpan!.start_timestamp); }); test('should track multiple middlewares with correct indices', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance/multi-middleware'; - }); + const spansPromise = collectStreamedSpans(APP_NAME, spansOfTrace => + spansOfTrace.some(span => span.name === 'GET /performance/multi-middleware' && span.is_segment), + ); await page.goto(`/performance/multi-middleware`); - const transaction = await txPromise; + const spans = await spansPromise; await expect(page.locator('#multi-middleware-title')).toBeVisible(); await expect(page.locator('#multi-middleware-content')).toHaveText('This route has 3 middlewares'); - const middlewareSpans = transaction?.spans?.filter(span => span.data?.['code.function.name'] === 'middleware'); + const middlewareSpans = spans.filter(span => span.attributes['code.function.name']?.value === 'middleware'); expect(middlewareSpans).toHaveLength(3); - const sortedSpans = [...middlewareSpans!].sort( - (a: any, b: any) => - (a.data?.['react_router.middleware.index'] ?? 0) - (b.data?.['react_router.middleware.index'] ?? 0), + const sortedSpans = [...middlewareSpans].sort( + (a, b) => + Number(a.attributes['react_router.middleware.index']?.value ?? 0) - + Number(b.attributes['react_router.middleware.index']?.value ?? 0), ); - expect(sortedSpans.map((s: any) => s.data?.['react_router.middleware.index'])).toEqual([0, 1, 2]); - expect(sortedSpans.map((s: any) => s.data?.['react_router.middleware.name'])).toEqual([ + expect(sortedSpans.map(span => span.attributes['react_router.middleware.index']?.value)).toEqual([0, 1, 2]); + expect(sortedSpans.map(span => span.attributes['react_router.middleware.name']?.value)).toEqual([ 'multiAuthMiddleware', 'multiLoggingMiddleware', 'multiValidationMiddleware', diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/navigation.client.test.ts index 4e88fb275953..fe00cb0eb88f 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/navigation.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/navigation.client.test.ts @@ -1,12 +1,12 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; // When `useInstrumentationAPI: true` is set and the instrumentations array is passed to // HydratedRouter, React Router invokes the navigate hook on the client and the navigation span // is created via the instrumentation API (origin: `auto.navigation.react_router.instrumentation_api`). // The legacy `instrumentHydratedRouter()` subscribe callback still runs and updates the span -// name to its parameterized form (so `sentry.source` ends up as `route`). +// name to its parameterized form (so `sentry.segment.name.source` ends up as `route`). // // See: https://github.com/remix-run/react-router/discussions/13749 @@ -14,298 +14,215 @@ test.describe('client - hybrid navigation (instrumentation API span + legacy par test('should create navigation span via instrumentation API and parameterize via legacy subscribe', async ({ page, }) => { - const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance`); - await pageloadTxPromise; + await pageloadSpanPromise; - const navigationTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' - ); + const navigationSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/ssr' && getSpanOp(span) === 'navigation' && span.is_segment; }); // Click on the SSR link to navigate await page.getByRole('link', { name: 'SSR Page' }).click(); - const transaction = await navigationTxPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react_router.instrumentation_api', - data: { - 'sentry.segment.name.source': 'route', - 'sentry.op': 'navigation', - 'sentry.origin': 'auto.navigation.react_router.instrumentation_api', - 'navigation.type': 'router.navigate', - 'url.template': '/performance/ssr', - 'url.path': '/performance/ssr', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/ssr$/), - }, - }, - }, - transaction: '/performance/ssr', - type: 'transaction', + const span = await navigationSpanPromise; + + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react_router.instrumentation_api', type: 'string' }, + 'navigation.type': { value: 'router.navigate', type: 'string' }, + 'url.template': { value: '/performance/ssr', type: 'string' }, + 'url.path': { value: '/performance/ssr', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/ssr$/), type: 'string' }, }); }); test('should resolve relative navigate targets against the current URL', async ({ page }) => { - // Wait for the pageload transaction so we know the client has hydrated and the router is + // Wait for the pageload span so we know the client has hydrated and the router is // instrumented before triggering the relative navigation (avoids a brittle fixed sleep). - const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance`); - await pageloadTxPromise; + await pageloadSpanPromise; - const navigationTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' - ); + const navigationSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/ssr' && getSpanOp(span) === 'navigation' && span.is_segment; }); await page.getByRole('button', { name: 'Relative SSR Navigate' }).click(); - const transaction = await navigationTxPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react_router.instrumentation_api', - data: { - 'sentry.segment.name.source': 'route', - 'sentry.op': 'navigation', - 'sentry.origin': 'auto.navigation.react_router.instrumentation_api', - 'navigation.type': 'router.navigate', - 'url.template': '/performance/ssr', - 'url.path': '/performance/ssr', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/ssr$/), - }, - }, - }, - transaction: '/performance/ssr', - type: 'transaction', + const span = await navigationSpanPromise; + + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react_router.instrumentation_api', type: 'string' }, + 'navigation.type': { value: 'router.navigate', type: 'string' }, + 'url.template': { value: '/performance/ssr', type: 'string' }, + 'url.path': { value: '/performance/ssr', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/ssr$/), type: 'string' }, }); }); - test('should parameterize navigation transaction for dynamic routes', async ({ page }) => { - const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + test('should parameterize navigation span for dynamic routes', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance`); - await pageloadTxPromise; + await pageloadSpanPromise; - const navigationTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/with/:param' && - transactionEvent.contexts?.trace?.op === 'navigation' - ); + const navigationSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/with/:param' && getSpanOp(span) === 'navigation' && span.is_segment; }); await page.getByRole('link', { name: 'With Param Page' }).click(); - const transaction = await navigationTxPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react_router.instrumentation_api', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/performance/with/:param', - 'url.path': '/performance/with/sentry', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/sentry$/), - }, - }, + const span = await navigationSpanPromise; + + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react_router.instrumentation_api', type: 'string' }, + 'url.template': { value: '/performance/with/:param', type: 'string' }, + 'url.path': { value: '/performance/with/sentry', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/sentry$/), + type: 'string', }, - transaction: '/performance/with/:param', - type: 'transaction', - transaction_info: { source: 'route' }, }); }); - test('should send multiple navigation transactions in sequence', async ({ page }) => { - const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + test('should send multiple navigation spans in sequence', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance`); - await pageloadTxPromise; + await pageloadSpanPromise; // First navigation: /performance -> /performance/ssr - const firstNavPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' - ); + const firstNavPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/ssr' && getSpanOp(span) === 'navigation' && span.is_segment; }); await page.getByRole('link', { name: 'SSR Page' }).click(); const firstNav = await firstNavPromise; - expect(firstNav).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react_router.instrumentation_api', - }, - }, - transaction: '/performance/ssr', - type: 'transaction', - }); + expect(firstNav.name).toBe('/performance/ssr'); + expect(firstNav.attributes['sentry.origin']?.value).toBe('auto.navigation.react_router.instrumentation_api'); // Second navigation: /performance/ssr -> /performance - const secondNavPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'navigation'; + const secondNavPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'navigation' && span.is_segment; }); await page.getByRole('link', { name: 'Back to Performance' }).click(); const secondNav = await secondNavPromise; - expect(secondNav).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react_router.instrumentation_api', - }, - }, - transaction: '/performance', - type: 'transaction', - }); + expect(secondNav.name).toBe('/performance'); + expect(secondNav.attributes['sentry.origin']?.value).toBe('auto.navigation.react_router.instrumentation_api'); }); - test('should create navigation transaction for navigate(-1) with correct url attributes', async ({ page }) => { - const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + test('should create navigation span for navigate(-1) with correct url attributes', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance`); - await pageloadTxPromise; + await pageloadSpanPromise; - const forwardNavPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' - ); + const forwardNavPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/ssr' && getSpanOp(span) === 'navigation' && span.is_segment; }); await page.getByRole('link', { name: 'SSR Page' }).click(); await forwardNavPromise; - const backNavPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'navigation'; + const backNavPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'navigation' && span.is_segment; }); await page.getByRole('button', { name: 'History Back Navigate' }).click(); - const transaction = await backNavPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react_router.instrumentation_api', - data: { - 'sentry.segment.name.source': 'route', - 'sentry.op': 'navigation', - 'sentry.origin': 'auto.navigation.react_router.instrumentation_api', - 'navigation.type': 'router.back', - 'url.template': '/performance', - // react-router-serve 301-redirects the bare index route to a trailing slash in prod, while - // the dev server serves it without - accept both. - 'url.path': expect.stringMatching(/^\/performance\/?$/), - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/?$/), - }, - }, - }, - transaction: '/performance', - type: 'transaction', - transaction_info: { source: 'route' }, + const span = await backNavPromise; + + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react_router.instrumentation_api', type: 'string' }, + 'navigation.type': { value: 'router.back', type: 'string' }, + 'url.template': { value: '/performance', type: 'string' }, + // react-router-serve 301-redirects the bare index route to a trailing slash in prod, while + // the dev server serves it without - accept both. + 'url.path': { value: expect.stringMatching(/^\/performance\/?$/), type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/?$/), type: 'string' }, }); }); }); // Tests for instrumentation API navigation - expected to fail until React Router fixes upstream test.describe('client - instrumentation API navigation (upstream limitation)', () => { - test.fixme('should send navigation transaction with instrumentation API origin', async ({ page }) => { + test.fixme('should send navigation span with instrumentation API origin', async ({ page }) => { // First load the performance page await page.goto(`/performance`); - // Wait for the navigation transaction - const navigationTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + // Wait for the navigation span. Without the parameterization the streamed name falls back to + // the low-cardinality `Navigation`. + const navigationSpanPromise = waitForStreamedSpan(APP_NAME, span => { return ( - transactionEvent.transaction === '/performance/ssr' && - transactionEvent.contexts?.trace?.data?.['sentry.origin'] === 'auto.navigation.react_router.instrumentation_api' + span.name === 'Navigation' && + span.attributes['sentry.origin']?.value === 'auto.navigation.react_router.instrumentation_api' ); }); // Click on the SSR link to navigate await page.getByRole('link', { name: 'SSR Page' }).click(); - const transaction = await navigationTxPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.op': 'navigation', - 'sentry.origin': 'auto.navigation.react_router.instrumentation_api', - 'sentry.segment.name.source': 'url', - }, - op: 'navigation', - origin: 'auto.navigation.react_router.instrumentation_api', - }, - }, - transaction: '/performance/ssr', - type: 'transaction', - transaction_info: { source: 'url' }, + const span = await navigationSpanPromise; + + expect(span.span_id).toEqual(expect.any(String)); + expect(span.trace_id).toEqual(expect.any(String)); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react_router.instrumentation_api', type: 'string' }, + 'sentry.segment.name.source': { value: 'url', type: 'string' }, + 'url.path': { value: '/performance/ssr', type: 'string' }, }); }); - test.fixme('should send navigation transaction on parameterized route', async ({ page }) => { + test.fixme('should send navigation span on parameterized route', async ({ page }) => { // First load the performance page await page.goto(`/performance`); - // Wait for the navigation transaction - const navigationTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + // Wait for the navigation span. Without the parameterization the streamed name falls back to + // the low-cardinality `Navigation`. + const navigationSpanPromise = waitForStreamedSpan(APP_NAME, span => { return ( - transactionEvent.transaction === '/performance/with/sentry' && - transactionEvent.contexts?.trace?.data?.['sentry.origin'] === 'auto.navigation.react_router.instrumentation_api' + span.name === 'Navigation' && + span.attributes['sentry.origin']?.value === 'auto.navigation.react_router.instrumentation_api' ); }); // Click on the With Param link to navigate await page.getByRole('link', { name: 'With Param Page' }).click(); - const transaction = await navigationTxPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.op': 'navigation', - 'sentry.origin': 'auto.navigation.react_router.instrumentation_api', - 'sentry.segment.name.source': 'url', - }, - op: 'navigation', - origin: 'auto.navigation.react_router.instrumentation_api', - }, - }, - transaction: '/performance/with/sentry', - type: 'transaction', - transaction_info: { source: 'url' }, + const span = await navigationSpanPromise; + + expect(span.span_id).toEqual(expect.any(String)); + expect(span.trace_id).toEqual(expect.any(String)); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react_router.instrumentation_api', type: 'string' }, + 'sentry.segment.name.source': { value: 'url', type: 'string' }, + 'url.path': { value: '/performance/with/sentry', type: 'string' }, }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/pageload.client.test.ts index cea034a2de01..aae6cceae4ba 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/pageload.client.test.ts @@ -1,88 +1,67 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; test.describe('client - instrumentation API pageload', () => { - test('should send pageload transaction', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + test('should send pageload span', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance`); - const transaction = await txPromise; + const span = await spanPromise; - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - op: 'pageload', - data: { - 'url.template': '/performance', - // react-router-serve 301-redirects the bare index route to a trailing slash in prod, while - // the dev server serves it without - accept both. - 'url.path': expect.stringMatching(/^\/performance\/?$/), - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/?$/), - }, - }, - }, - transaction: '/performance', - type: 'transaction', + expect(span.span_id).toEqual(expect.any(String)); + expect(span.trace_id).toEqual(expect.any(String)); + expect(span.attributes).toMatchObject({ + 'url.template': { value: '/performance', type: 'string' }, + // react-router-serve 301-redirects the bare index route to a trailing slash in prod, while + // the dev server serves it without - accept both. + 'url.path': { value: expect.stringMatching(/^\/performance\/?$/), type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/?$/), type: 'string' }, }); }); - test('parameterizes the pageload transaction for dynamic routes', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === '/performance/with/:param' && - transactionEvent.contexts?.trace?.op === 'pageload' - ); + test('parameterizes the pageload span for dynamic routes', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance/with/:param' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance/with/some-param`); - const transaction = await txPromise; + const span = await spanPromise; - expect(transaction).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/performance/with/:param', - 'url.path': '/performance/with/some-param', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/some-param$/), - }, - }, + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/performance/with/:param', type: 'string' }, + 'url.path': { value: '/performance/with/some-param', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/some-param$/), + type: 'string', }, - transaction: '/performance/with/:param', - type: 'transaction', - transaction_info: { source: 'route' }, }); }); - test('should link server and client transactions with same trace_id', async ({ page }) => { - const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return ( - transactionEvent.transaction === 'GET /performance' && transactionEvent.contexts?.trace?.op === 'http.server' - ); + test('should link server and client spans with same trace_id', async ({ page }) => { + const serverSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === 'GET /performance' && getSpanOp(span) === 'http.server' && span.is_segment; }); - const clientTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + const clientSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/performance' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/performance`); - const [serverTx, clientTx] = await Promise.all([serverTxPromise, clientTxPromise]); + const [serverSpan, clientSpan] = await Promise.all([serverSpanPromise, clientSpanPromise]); - // Both transactions should share the same trace_id - expect(serverTx.contexts?.trace?.trace_id).toBeDefined(); - expect(clientTx.contexts?.trace?.trace_id).toBeDefined(); - expect(serverTx.contexts?.trace?.trace_id).toBe(clientTx.contexts?.trace?.trace_id); + // Both segments should share the same trace_id + expect(serverSpan.trace_id).toBeDefined(); + expect(clientSpan.trace_id).toBeDefined(); + expect(serverSpan.trace_id).toBe(clientSpan.trace_id); // But have different span_ids - expect(serverTx.contexts?.trace?.span_id).not.toBe(clientTx.contexts?.trace?.span_id); + expect(serverSpan.span_id).not.toBe(clientSpan.span_id); }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/performance.server.test.ts index 3d4c96e2b570..2b83599e2560 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/performance.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/performance.server.test.ts @@ -1,194 +1,169 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { + collectStreamedSpans, + getSpanOp, + waitForStreamedSpan, + waitForStreamedSpans, +} from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; +function collectUntilSegment(segmentName: string, childFunctionName?: string): Promise { + return collectStreamedSpans( + APP_NAME, + spansOfTrace => + spansOfTrace.some(span => span.name === segmentName && span.is_segment) && + (!childFunctionName || + spansOfTrace.some(span => span.attributes['code.function.name']?.value === childFunctionName)), + ); +} + test.describe('server - instrumentation API performance', () => { - test('should send server transaction on pageload with instrumentation API origin', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance'; + test('should send server span on pageload with instrumentation API origin', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === 'GET /performance' && span.is_segment; }); await page.goto(`/performance`); - const transaction = await txPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.react_router.instrumentation_api', - 'sentry.segment.name.source': 'route', - }, - op: 'http.server', - origin: 'auto.http.react_router.instrumentation_api', - }, - }, - spans: expect.any(Array), + const span = await spanPromise; + + expect(span).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: 'GET /performance', - type: 'transaction', - transaction_info: { source: 'route' }, - platform: 'node', - request: { - url: expect.stringContaining('/performance'), - headers: expect.any(Object), - }, - event_id: expect.any(String), - environment: 'qa', - sdk: { - integrations: expect.arrayContaining([expect.any(String)]), - name: 'sentry.javascript.react-router', - version: expect.any(String), - packages: [ - { name: 'npm:@sentry/react-router', version: expect.any(String) }, - { name: 'npm:@sentry/node', version: expect.any(String) }, - ], - }, - tags: { - runtime: 'node', - }, + end_timestamp: expect.any(Number), + is_segment: true, + }); + + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.react_router.instrumentation_api', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.react-router', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.sdk.integrations': { value: expect.arrayContaining([expect.any(String)]), type: 'array' }, + 'url.full': { value: expect.stringContaining('/performance'), type: 'string' }, }); }); - test('should send server transaction on parameterized route with instrumentation API origin', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance/with/:param'; + test('should send server span on parameterized route with instrumentation API origin', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === 'GET /performance/with/:param' && span.is_segment; }); await page.goto(`/performance/with/some-param`); - const transaction = await txPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.react_router.instrumentation_api', - 'sentry.segment.name.source': 'route', - }, - op: 'http.server', - origin: 'auto.http.react_router.instrumentation_api', - }, - }, - spans: expect.any(Array), + const span = await spanPromise; + + expect(span).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: 'GET /performance/with/:param', - type: 'transaction', - transaction_info: { source: 'route' }, - platform: 'node', - request: { - url: expect.stringContaining('/performance/with/some-param'), - headers: expect.any(Object), - }, - event_id: expect.any(String), - environment: 'qa', + end_timestamp: expect.any(Number), + is_segment: true, + }); + + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.react_router.instrumentation_api', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'url.full': { value: expect.stringContaining('/performance/with/some-param'), type: 'string' }, }); }); test('should instrument server loader with instrumentation API origin', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance/server-loader'; - }); + const spansPromise = collectUntilSegment('GET /performance/server-loader', 'loader'); await page.goto(`/performance/server-loader`); - const transaction = await txPromise; + const spans = await spansPromise; - // Find the loader span - const loaderSpan = transaction?.spans?.find(span => span.data?.['code.function.name'] === 'loader'); + const loaderSpan = spans.find(span => span.attributes['code.function.name']?.value === 'loader')!; expect(loaderSpan).toMatchObject({ span_id: expect.any(String), trace_id: expect.any(String), - data: { - 'sentry.origin': 'auto.function.react_router.instrumentation_api', - 'sentry.op': 'function', - 'code.function.name': 'loader', - }, - description: '/performance/server-loader', + name: '/performance/server-loader', parent_span_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), + end_timestamp: expect.any(Number), status: 'ok', - op: 'function', - origin: 'auto.function.react_router.instrumentation_api', + }); + + expect(loaderSpan.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.function.react_router.instrumentation_api', type: 'string' }, + 'sentry.op': { value: 'function', type: 'string' }, + 'code.function.name': { value: 'loader', type: 'string' }, }); }); test('should instrument server action with instrumentation API origin', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'POST /performance/server-action'; - }); + const spansPromise = collectUntilSegment('POST /performance/server-action', 'action'); await page.goto(`/performance/server-action`); await page.getByRole('button', { name: 'Submit' }).click(); - const transaction = await txPromise; + const spans = await spansPromise; - // Find the action span - const actionSpan = transaction?.spans?.find(span => span.data?.['code.function.name'] === 'action'); + const actionSpan = spans.find(span => span.attributes['code.function.name']?.value === 'action')!; expect(actionSpan).toMatchObject({ span_id: expect.any(String), trace_id: expect.any(String), - data: { - 'sentry.origin': 'auto.function.react_router.instrumentation_api', - 'sentry.op': 'function', - 'code.function.name': 'action', - }, - description: '/performance/server-action', + name: '/performance/server-action', parent_span_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), + end_timestamp: expect.any(Number), status: 'ok', - op: 'function', - origin: 'auto.function.react_router.instrumentation_api', + }); + + expect(actionSpan.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.function.react_router.instrumentation_api', type: 'string' }, + 'sentry.op': { value: 'function', type: 'string' }, + 'code.function.name': { value: 'action', type: 'string' }, }); }); // Prod-only: the dev server (Vite) serves source modules (`/@vite/client`, `/app/*`) as separate - // requests, each producing its own http.server transaction, so "exactly one" only holds in prod. - test('sends exactly one http.server transaction per request (no double-instrumentation)', async ({ page }) => { + // requests, each producing its own http.server segment, so "exactly one" only holds in prod. + test('sends exactly one http.server segment per request (no double-instrumentation)', async ({ page }) => { test.skip( process.env.TEST_ENV === 'development', - 'Dev server emits extra http.server transactions for module requests', + 'Dev server emits extra http.server segments for module requests', ); - const httpServerTransactions: Array = []; - void waitForTransaction(APP_NAME, async transactionEvent => { - if (transactionEvent.contexts?.trace?.op === 'http.server') { - httpServerTransactions.push(transactionEvent.transaction); + const httpServerSpanNames: string[] = []; + void waitForStreamedSpans(APP_NAME, spans => { + for (const span of spans) { + if (getSpanOp(span) === 'http.server' && span.is_segment) { + httpServerSpanNames.push(span.name); + } } return false; }); await page.goto(`/performance`); - // Give any (erroneous) duplicate transaction time to arrive before asserting. + // Give any (erroneous) duplicate span time to arrive before asserting. await page.waitForTimeout(3000); - expect(httpServerTransactions).toEqual(['GET /performance']); + expect(httpServerSpanNames).toEqual(['GET /performance']); }); test('resolves a real http.route on routes without a loader/action', async ({ page }) => { // Regression guard for the server OTel removal: routes without a loader/action must still get a // proper `http.route` (not the catch-all `*` placeholder) from the underlying HTTP instrumentation. - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance/ssr'; + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === 'GET /performance/ssr' && span.is_segment; }); await page.goto(`/performance/ssr`); - const transaction = await txPromise; + const span = await spanPromise; - expect(transaction.contexts?.trace?.op).toBe('http.server'); - expect(transaction.contexts?.trace?.data?.['http.route']).toBe('/performance/ssr'); + expect(getSpanOp(span)).toBe('http.server'); + expect(span.attributes['http.route']?.value).toBe('/performance/ssr'); }); }); From 2a6f1060a593355e0b4ced7ee8f78cd4757c0ac9 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 2 Sep 2026 11:29:37 +0200 Subject: [PATCH 3/4] test(e2e): Port the React Router lazy routes E2E app to span streaming Removes the `traceLifecycle: 'static'` pin from `react-router-7-lazy-routes` and rewrites its specs against streamed span v2. The span-leaking and GQL attribution specs group streamed spans by trace instead of reading a transaction's `spans` array, and they match fetch spans on `url.full`, since a streamed `http.client` span carries only the domain in its name. --- .../react-router-7-lazy-routes/src/index.tsx | 1 - .../{transactions.test.ts => spans.test.ts} | 834 ++++++++---------- .../tests/timeout-behaviour.test.ts | 94 +- 3 files changed, 386 insertions(+), 543 deletions(-) rename dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/{transactions.test.ts => spans.test.ts} (54%) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx index f51e5f9dd5a6..c1acf56da8ac 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx @@ -78,7 +78,6 @@ const lazyRouteManifest = [ ]; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/spans.test.ts similarity index 54% rename from dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts rename to dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/spans.test.ts index 59f33c1ac8cc..500bb3ff9c8f 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/spans.test.ts @@ -1,12 +1,28 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('Creates a pageload transaction with parameterized route', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { + collectStreamedSpans, + getSpanOp, + waitForStreamedSpan, + waitForStreamedSpans, +} from '@sentry-internal/test-utils'; + +// Streamed `http.client` names are only ` `, so the request URL has to come from +// the `url.full` attribute. +function hasUrlPart(span: SerializedStreamedSpan, part: string): boolean { + const urlFull = span.attributes['url.full']?.value; + return typeof urlFull === 'string' && urlFull.includes(part); +} + +/** All spans of the trace `segmentSpan` belongs to, minus the segment span itself. */ +function childSpansOf(spans: SerializedStreamedSpan[], segmentSpan: SerializedStreamedSpan): SerializedStreamedSpan[] { + return spans.filter(span => span.trace_id === segmentSpan.trace_id && !span.is_segment); +} + +test('Creates a pageload span with parameterized route', async ({ page }) => { + const transactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/lazy/inner/:id/:anotherId/:someAnotherId' ); }); @@ -18,22 +34,20 @@ test('Creates a pageload transaction with parameterized route', async ({ page }) await expect(lazyRouteContent).toBeVisible(); // Validate the transaction event - expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); - expect(event.type).toBe('transaction'); - expect(event.contexts?.trace?.op).toBe('pageload'); - expect(event.contexts?.trace?.status).toBe('ok'); + expect(event.name).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(event.is_segment).toBe(true); + expect(getSpanOp(event)).toBe('pageload'); + expect(event.status).toBe('ok'); }); -test('Does not create a navigation transaction on initial load to deep lazy route', async ({ page }) => { - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; +test('Does not create a navigation span on initial load to deep lazy route', async ({ page }) => { + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; }); - const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + const pageloadPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/lazy/inner/:id/:anotherId/:someAnotherId' ); }); @@ -41,7 +55,7 @@ test('Does not create a navigation transaction on initial load to deep lazy rout const pageloadEvent = await pageloadPromise; - expect(pageloadEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(pageloadEvent.name).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); const lazyRouteContent = page.locator('id=innermost-lazy-route'); await expect(lazyRouteContent).toBeVisible(); @@ -55,12 +69,10 @@ test('Does not create a navigation transaction on initial load to deep lazy rout expect(result).toBe('timeout'); }); -test('Creates a navigation transaction inside a lazy route', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { +test('Creates a navigation span inside a lazy route', async ({ page }) => { + const transactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy/inner/:id/:anotherId/:someAnotherId' ); }); @@ -80,27 +92,21 @@ test('Creates a navigation transaction inside a lazy route', async ({ page }) => await expect(lazyRouteContent).toBeVisible(); // Validate the transaction event - expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); - expect(event.type).toBe('transaction'); - expect(event.contexts?.trace?.op).toBe('navigation'); - expect(event.contexts?.trace?.status).toBe('ok'); + expect(event.name).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(event.is_segment).toBe(true); + expect(getSpanOp(event)).toBe('navigation'); + expect(event.status).toBe('ok'); }); -test('Creates navigation transactions between two different lazy routes', async ({ page }) => { +test('Creates navigation spans between two different lazy routes', async ({ page }) => { // Set up transaction listeners for both navigations - const firstTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/another-lazy/sub/:id/:subId' - ); + const firstTransactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/another-lazy/sub/:id/:subId'; }); - const secondTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + const secondTransactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy/inner/:id/:anotherId/:someAnotherId' ); }); @@ -118,9 +124,9 @@ test('Creates navigation transactions between two different lazy routes', async await expect(anotherLazyContent).toBeVisible(); // Validate the first transaction event - expect(firstEvent.transaction).toBe('/another-lazy/sub/:id/:subId'); - expect(firstEvent.type).toBe('transaction'); - expect(firstEvent.contexts?.trace?.op).toBe('navigation'); + expect(firstEvent.name).toBe('/another-lazy/sub/:id/:subId'); + expect(firstEvent.is_segment).toBe(true); + expect(getSpanOp(firstEvent)).toBe('navigation'); // Now navigate from the first lazy route to the second lazy route // Click the navigation link from within the first lazy route to the second lazy route @@ -135,12 +141,12 @@ test('Creates navigation transactions between two different lazy routes', async await expect(innerLazyContent).toBeVisible(); // Validate the second transaction event - expect(secondEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); - expect(secondEvent.type).toBe('transaction'); - expect(secondEvent.contexts?.trace?.op).toBe('navigation'); + expect(secondEvent.name).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(secondEvent.is_segment).toBe(true); + expect(getSpanOp(secondEvent)).toBe('navigation'); }); -test('Creates navigation transactions from inner lazy route to another lazy route with history navigation', async ({ +test('Creates navigation spans from inner lazy route to another lazy route with history navigation', async ({ page, }) => { await page.goto('/'); @@ -150,11 +156,9 @@ test('Creates navigation transactions from inner lazy route to another lazy rout await expect(navigationToInner).toBeVisible(); // First, navigate to the inner lazy route - const firstTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + const firstTransactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy/inner/:id/:anotherId/:someAnotherId' ); }); @@ -167,21 +171,17 @@ test('Creates navigation transactions from inner lazy route to another lazy rout await expect(innerLazyContent).toBeVisible(); // Validate the first transaction event - expect(firstEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); - expect(firstEvent.type).toBe('transaction'); - expect(firstEvent.contexts?.trace?.op).toBe('navigation'); + expect(firstEvent.name).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(firstEvent.is_segment).toBe(true); + expect(getSpanOp(firstEvent)).toBe('navigation'); // Click the navigation link from within the inner lazy route to another lazy route const navigationToAnotherFromInner = page.locator('id=navigate-to-another-from-inner'); await expect(navigationToAnotherFromInner).toBeVisible(); // Now navigate from the inner lazy route to another lazy route - const secondTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/another-lazy/sub/:id/:subId' - ); + const secondTransactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/another-lazy/sub/:id/:subId'; }); await navigationToAnotherFromInner.click(); @@ -193,16 +193,14 @@ test('Creates navigation transactions from inner lazy route to another lazy rout await expect(anotherLazyContent).toBeVisible(); // Validate the second transaction event - expect(secondEvent.transaction).toBe('/another-lazy/sub/:id/:subId'); - expect(secondEvent.type).toBe('transaction'); - expect(secondEvent.contexts?.trace?.op).toBe('navigation'); + expect(secondEvent.name).toBe('/another-lazy/sub/:id/:subId'); + expect(secondEvent.is_segment).toBe(true); + expect(getSpanOp(secondEvent)).toBe('navigation'); // Go back to the previous page to ensure history navigation works as expected - const goBackTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + const goBackTransactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy/inner/:id/:anotherId/:someAnotherId' ); }); @@ -211,17 +209,13 @@ test('Creates navigation transactions from inner lazy route to another lazy rout const goBackEvent = await goBackTransactionPromise; // Validate the second go back transaction event - expect(goBackEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); - expect(goBackEvent.type).toBe('transaction'); - expect(goBackEvent.contexts?.trace?.op).toBe('navigation'); + expect(goBackEvent.name).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(goBackEvent.is_segment).toBe(true); + expect(getSpanOp(goBackEvent)).toBe('navigation'); // Navigate to the upper route - const goUpperRouteTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId' - ); + const goUpperRouteTransactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy/inner/:id/:anotherId'; }); const navigationToUpper = page.locator('id=navigate-to-upper'); @@ -231,18 +225,18 @@ test('Creates navigation transactions from inner lazy route to another lazy rout const goUpperRouteEvent = await goUpperRouteTransactionPromise; // Validate the go upper route transaction event - expect(goUpperRouteEvent.transaction).toBe('/lazy/inner/:id/:anotherId'); - expect(goUpperRouteEvent.type).toBe('transaction'); - expect(goUpperRouteEvent.contexts?.trace?.op).toBe('navigation'); + expect(goUpperRouteEvent.name).toBe('/lazy/inner/:id/:anotherId'); + expect(goUpperRouteEvent.is_segment).toBe(true); + expect(getSpanOp(goUpperRouteEvent)).toBe('navigation'); }); -test('Does not send any duplicate navigation transaction names browsing between different routes', async ({ page }) => { +test('Does not send any duplicate navigation span names browsing between different routes', async ({ page }) => { const transactionNamesList: string[] = []; // Monitor and add all transaction names sent to Sentry for the navigations - const allTransactionsPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - if (transactionEvent?.transaction) { - transactionNamesList.push(transactionEvent.transaction); + const allTransactionsPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + if (span.is_segment) { + transactionNamesList.push(span.name); } if (transactionNamesList.length >= 5) { @@ -297,29 +291,21 @@ test('Does not send any duplicate navigation transaction names browsing between ]); }); -test('Does not create premature navigation transaction during long-running lazy route pageload', async ({ page }) => { - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction.includes('long-running') - ); +test('Does not create premature navigation span during long-running lazy route pageload', async ({ page }) => { + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name.includes('long-running'); }); - const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction === '/long-running/slow/:id' - ); + const pageloadPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/long-running/slow/:id'; }); await page.goto('/long-running/slow/12345'); const pageloadEvent = await pageloadPromise; - expect(pageloadEvent.transaction).toBe('/long-running/slow/:id'); - expect(pageloadEvent.contexts?.trace?.op).toBe('pageload'); + expect(pageloadEvent.name).toBe('/long-running/slow/:id'); + expect(getSpanOp(pageloadEvent)).toBe('pageload'); const slowLoadingContent = page.locator('id=slow-loading-content'); await expect(slowLoadingContent).toBeVisible({ timeout: 5000 }); @@ -340,20 +326,12 @@ test('Allows legitimate POP navigation (back/forward) after pageload completes', await expect(navigationToLongRunning).toBeVisible(); // Set up transaction listeners for both navigations - const firstNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/long-running/slow/:id' - ); + const firstNavigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/long-running/slow/:id'; }); - const backNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/' - ); + const backNavigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/'; }); await navigationToLongRunning.click(); @@ -363,8 +341,8 @@ test('Allows legitimate POP navigation (back/forward) after pageload completes', const firstNavigationEvent = await firstNavigationPromise; - expect(firstNavigationEvent.transaction).toBe('/long-running/slow/:id'); - expect(firstNavigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(firstNavigationEvent.name).toBe('/long-running/slow/:id'); + expect(getSpanOp(firstNavigationEvent)).toBe('navigation'); // Now navigate back using browser back button (POP event) // This should create a navigation transaction since pageload is complete @@ -377,18 +355,16 @@ test('Allows legitimate POP navigation (back/forward) after pageload completes', const backNavigationEvent = await backNavigationPromise; // Validate that the back navigation (POP) was properly tracked - expect(backNavigationEvent.transaction).toBe('/'); - expect(backNavigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(backNavigationEvent.name).toBe('/'); + expect(getSpanOp(backNavigationEvent)).toBe('navigation'); }); -test('Updates pageload transaction name correctly when span is cancelled early (document.hidden simulation)', async ({ +test('Updates pageload span name correctly when span is cancelled early (document.hidden simulation)', async ({ page, }) => { - const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + const transactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/lazy/inner/:id/:anotherId/:someAnotherId' ); }); @@ -420,29 +396,27 @@ test('Updates pageload transaction name correctly when span is cancelled early ( // Validate that the transaction event has the correct parameterized route name // even though the span was cancelled early due to document.hidden - expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); - expect(event.type).toBe('transaction'); - expect(event.contexts?.trace?.op).toBe('pageload'); + expect(event.name).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(event.is_segment).toBe(true); + expect(getSpanOp(event)).toBe('pageload'); // Check if the span was indeed cancelled (should have idle_span_finish_reason attribute) - const idleSpanFinishReason = event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']; + const idleSpanFinishReason = event.attributes['sentry.idle_span_finish_reason']?.value; if (idleSpanFinishReason) { // If the span was cancelled due to visibility change, verify it still got the right name expect(['externalFinish', 'cancelled']).toContain(idleSpanFinishReason); } }); -test('Updates navigation transaction name correctly when span is cancelled early (document.hidden simulation)', async ({ +test('Updates navigation span name correctly when span is cancelled early (document.hidden simulation)', async ({ page, }) => { // First go to home page await page.goto('/'); - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy/inner/:id/:anotherId/:someAnotherId' ); }); @@ -484,13 +458,13 @@ test('Updates navigation transaction name correctly when span is cancelled early // Validate that the transaction event has the correct parameterized route name // even though the span was cancelled early due to document.hidden - expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); - expect(event.type).toBe('transaction'); - expect(event.contexts?.trace?.op).toBe('navigation'); + expect(event.name).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(event.is_segment).toBe(true); + expect(getSpanOp(event)).toBe('navigation'); // Check if the span was indeed cancelled (should have cancellation_reason attribute or idle_span_finish_reason) - const cancellationReason = event.contexts?.trace?.data?.['sentry.cancellation_reason']; - const idleSpanFinishReason = event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']; + const cancellationReason = event.attributes['sentry.cancellation_reason']?.value; + const idleSpanFinishReason = event.attributes['sentry.idle_span_finish_reason']?.value; // Verify that the span was cancelled due to document.hidden if (cancellationReason) { @@ -502,33 +476,27 @@ test('Updates navigation transaction name correctly when span is cancelled early } }); -test('Creates separate transactions for rapid consecutive navigations', async ({ page }) => { +test('Creates separate spans for rapid consecutive navigations', async ({ page }) => { await page.goto('/'); // Set up transaction listeners - const firstTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + const firstTransactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy/inner/:id/:anotherId/:someAnotherId' ); }); - const secondTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/another-lazy/sub/:id/:subId' - ); + const secondTransactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/another-lazy/sub/:id/:subId'; }); // Third navigation promise - using counter to match second occurrence of same route let innerRouteMatchCount = 0; - const thirdTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + const thirdTransactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { if ( - transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + span.is_segment && + getSpanOp(span) === 'navigation' && + span.name === '/lazy/inner/:id/:anotherId/:someAnotherId' ) { innerRouteMatchCount++; return innerRouteMatchCount === 2; // Match the second occurrence @@ -553,25 +521,25 @@ test('Creates separate transactions for rapid consecutive navigations', async ({ const thirdEvent = await thirdTransactionPromise; // Verify transactions - expect(firstEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); - expect(firstEvent.contexts?.trace?.op).toBe('navigation'); - const firstTraceId = firstEvent.contexts?.trace?.trace_id; - const firstSpanId = firstEvent.contexts?.trace?.span_id; + expect(firstEvent.name).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(getSpanOp(firstEvent)).toBe('navigation'); + const firstTraceId = firstEvent.trace_id; + const firstSpanId = firstEvent.span_id; - expect(secondEvent.transaction).toBe('/another-lazy/sub/:id/:subId'); - expect(secondEvent.contexts?.trace?.op).toBe('navigation'); - expect(secondEvent.contexts?.trace?.status).toBe('ok'); + expect(secondEvent.name).toBe('/another-lazy/sub/:id/:subId'); + expect(getSpanOp(secondEvent)).toBe('navigation'); + expect(secondEvent.status).toBe('ok'); - const secondTraceId = secondEvent.contexts?.trace?.trace_id; - const secondSpanId = secondEvent.contexts?.trace?.span_id; + const secondTraceId = secondEvent.trace_id; + const secondSpanId = secondEvent.span_id; // Verify third transaction - expect(thirdEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); - expect(thirdEvent.contexts?.trace?.op).toBe('navigation'); - expect(thirdEvent.contexts?.trace?.status).toBe('ok'); + expect(thirdEvent.name).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(getSpanOp(thirdEvent)).toBe('navigation'); + expect(thirdEvent.status).toBe('ok'); - const thirdTraceId = thirdEvent.contexts?.trace?.trace_id; - const thirdSpanId = thirdEvent.contexts?.trace?.span_id; + const thirdTraceId = thirdEvent.trace_id; + const thirdSpanId = thirdEvent.span_id; // Verify each navigation created a separate transaction with unique trace and span IDs expect(firstTraceId).toBeDefined(); @@ -589,13 +557,9 @@ test('Creates separate transactions for rapid consecutive navigations', async ({ expect(firstSpanId).not.toBe(thirdSpanId); }); -test('Creates pageload transaction with parameterized route for delayed lazy route', async ({ page }) => { - const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction === '/delayed-lazy/:id' - ); +test('Creates pageload span with parameterized route for delayed lazy route', async ({ page }) => { + const pageloadPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/delayed-lazy/:id'; }); await page.goto('/delayed-lazy/123'); @@ -607,20 +571,16 @@ test('Creates pageload transaction with parameterized route for delayed lazy rou await expect(page.locator('id=delayed-lazy-id')).toHaveText('ID: 123'); await expect(page.locator('id=delayed-lazy-path')).toHaveText('/delayed-lazy/123'); - expect(pageloadEvent.transaction).toBe('/delayed-lazy/:id'); - expect(pageloadEvent.contexts?.trace?.op).toBe('pageload'); - expect(pageloadEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(pageloadEvent.name).toBe('/delayed-lazy/:id'); + expect(getSpanOp(pageloadEvent)).toBe('pageload'); + expect(pageloadEvent.attributes['sentry.segment.name.source']?.value).toBe('route'); }); -test('Creates navigation transaction with parameterized route for delayed lazy route', async ({ page }) => { +test('Creates navigation span with parameterized route for delayed lazy route', async ({ page }) => { await page.goto('/'); - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/delayed-lazy/:id' - ); + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/delayed-lazy/:id'; }); const navigationLink = page.locator('id=navigation-to-delayed-lazy'); @@ -634,22 +594,18 @@ test('Creates navigation transaction with parameterized route for delayed lazy r await expect(page.locator('id=delayed-lazy-id')).toHaveText('ID: 123'); await expect(page.locator('id=delayed-lazy-path')).toHaveText('/delayed-lazy/123'); - expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); - expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); - expect(navigationEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(navigationEvent.name).toBe('/delayed-lazy/:id'); + expect(getSpanOp(navigationEvent)).toBe('navigation'); + expect(navigationEvent.attributes['sentry.segment.name.source']?.value).toBe('route'); }); -test('Creates navigation transaction when navigating with query parameters from home to route', async ({ page }) => { +test('Creates navigation span when navigating with query parameters from home to route', async ({ page }) => { await page.goto('/'); // Navigate from / to /delayed-lazy/123?source=homepage // This should create a navigation transaction with the parameterized route name - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/delayed-lazy/:id' - ); + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/delayed-lazy/:id'; }); const navigationLink = page.locator('id=navigation-to-delayed-lazy-with-query'); @@ -667,13 +623,13 @@ test('Creates navigation transaction when navigating with query parameters from // Verify the navigation transaction has the correct parameterized route name // Query parameters don't affect the transaction name (still /delayed-lazy/:id) - expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); - expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); - expect(navigationEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); - expect(navigationEvent.contexts?.trace?.status).toBe('ok'); + expect(navigationEvent.name).toBe('/delayed-lazy/:id'); + expect(getSpanOp(navigationEvent)).toBe('navigation'); + expect(navigationEvent.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(navigationEvent.status).toBe('ok'); }); -test('Creates separate navigation transaction when changing only query parameters on same route', async ({ page }) => { +test('Creates separate navigation span when changing only query parameters on same route', async ({ page }) => { await page.goto('/delayed-lazy/123'); // Wait for the page to fully load @@ -682,12 +638,8 @@ test('Creates separate navigation transaction when changing only query parameter // Navigate from /delayed-lazy/123 to /delayed-lazy/123?view=detailed // This is a query-only change on the same route - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/delayed-lazy/:id' - ); + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/delayed-lazy/:id'; }); const queryLink = page.locator('id=link-to-query-view-detailed'); @@ -701,25 +653,21 @@ test('Creates separate navigation transaction when changing only query parameter await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: detailed'); // Query-only navigation should create a navigation transaction - expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); - expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); - expect(navigationEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); - expect(navigationEvent.contexts?.trace?.status).toBe('ok'); + expect(navigationEvent.name).toBe('/delayed-lazy/:id'); + expect(getSpanOp(navigationEvent)).toBe('navigation'); + expect(navigationEvent.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(navigationEvent.status).toBe('ok'); }); -test('Creates separate navigation transactions for multiple query parameter changes', async ({ page }) => { +test('Creates separate navigation spans for multiple query parameter changes', async ({ page }) => { await page.goto('/delayed-lazy/123'); const delayedReady = page.locator('id=delayed-lazy-ready'); await expect(delayedReady).toBeVisible(); // First query change: /delayed-lazy/123 -> /delayed-lazy/123?view=detailed - const firstNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/delayed-lazy/:id' - ); + const firstNavigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/delayed-lazy/:id'; }); const firstQueryLink = page.locator('id=link-to-query-view-detailed'); @@ -727,17 +675,17 @@ test('Creates separate navigation transactions for multiple query parameter chan await firstQueryLink.click(); const firstNavigationEvent = await firstNavigationPromise; - const firstTraceId = firstNavigationEvent.contexts?.trace?.trace_id; + const firstTraceId = firstNavigationEvent.trace_id; await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: detailed'); // Second query change: /delayed-lazy/123?view=detailed -> /delayed-lazy/123?view=list - const secondNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + const secondNavigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/delayed-lazy/:id' && - transactionEvent.contexts?.trace?.trace_id !== firstTraceId + span.is_segment && + getSpanOp(span) === 'navigation' && + span.name === '/delayed-lazy/:id' && + span.trace_id !== firstTraceId ); }); @@ -746,15 +694,15 @@ test('Creates separate navigation transactions for multiple query parameter chan await secondQueryLink.click(); const secondNavigationEvent = await secondNavigationPromise; - const secondTraceId = secondNavigationEvent.contexts?.trace?.trace_id; + const secondTraceId = secondNavigationEvent.trace_id; await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: list'); // Both navigations should have created separate transactions - expect(firstNavigationEvent.transaction).toBe('/delayed-lazy/:id'); - expect(firstNavigationEvent.contexts?.trace?.op).toBe('navigation'); - expect(secondNavigationEvent.transaction).toBe('/delayed-lazy/:id'); - expect(secondNavigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(firstNavigationEvent.name).toBe('/delayed-lazy/:id'); + expect(getSpanOp(firstNavigationEvent)).toBe('navigation'); + expect(secondNavigationEvent.name).toBe('/delayed-lazy/:id'); + expect(getSpanOp(secondNavigationEvent)).toBe('navigation'); // Trace IDs should be different (separate transactions) expect(firstTraceId).toBeDefined(); @@ -762,7 +710,7 @@ test('Creates separate navigation transactions for multiple query parameter chan expect(firstTraceId).not.toBe(secondTraceId); }); -test('Creates navigation transaction when changing only hash on same route', async ({ page }) => { +test('Creates navigation span when changing only hash on same route', async ({ page }) => { await page.goto('/delayed-lazy/123'); const delayedReady = page.locator('id=delayed-lazy-ready'); @@ -770,12 +718,8 @@ test('Creates navigation transaction when changing only hash on same route', asy // Navigate from /delayed-lazy/123 to /delayed-lazy/123#section1 // This is a hash-only change on the same route - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/delayed-lazy/:id' - ); + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/delayed-lazy/:id'; }); const hashLink = page.locator('id=link-to-hash-section1'); @@ -788,25 +732,21 @@ test('Creates navigation transaction when changing only hash on same route', asy await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#section1'); // Hash-only navigation should create a navigation transaction - expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); - expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); - expect(navigationEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); - expect(navigationEvent.contexts?.trace?.status).toBe('ok'); + expect(navigationEvent.name).toBe('/delayed-lazy/:id'); + expect(getSpanOp(navigationEvent)).toBe('navigation'); + expect(navigationEvent.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(navigationEvent.status).toBe('ok'); }); -test('Creates separate navigation transactions for multiple hash changes', async ({ page }) => { +test('Creates separate navigation spans for multiple hash changes', async ({ page }) => { await page.goto('/delayed-lazy/123'); const delayedReady = page.locator('id=delayed-lazy-ready'); await expect(delayedReady).toBeVisible(); // First hash change: /delayed-lazy/123 -> /delayed-lazy/123#section1 - const firstNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/delayed-lazy/:id' - ); + const firstNavigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/delayed-lazy/:id'; }); const firstHashLink = page.locator('id=link-to-hash-section1'); @@ -814,17 +754,17 @@ test('Creates separate navigation transactions for multiple hash changes', async await firstHashLink.click(); const firstNavigationEvent = await firstNavigationPromise; - const firstTraceId = firstNavigationEvent.contexts?.trace?.trace_id; + const firstTraceId = firstNavigationEvent.trace_id; await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#section1'); // Second hash change: /delayed-lazy/123#section1 -> /delayed-lazy/123#section2 - const secondNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + const secondNavigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/delayed-lazy/:id' && - transactionEvent.contexts?.trace?.trace_id !== firstTraceId + span.is_segment && + getSpanOp(span) === 'navigation' && + span.name === '/delayed-lazy/:id' && + span.trace_id !== firstTraceId ); }); @@ -833,15 +773,15 @@ test('Creates separate navigation transactions for multiple hash changes', async await secondHashLink.click(); const secondNavigationEvent = await secondNavigationPromise; - const secondTraceId = secondNavigationEvent.contexts?.trace?.trace_id; + const secondTraceId = secondNavigationEvent.trace_id; await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#section2'); // Both navigations should have created separate transactions - expect(firstNavigationEvent.transaction).toBe('/delayed-lazy/:id'); - expect(firstNavigationEvent.contexts?.trace?.op).toBe('navigation'); - expect(secondNavigationEvent.transaction).toBe('/delayed-lazy/:id'); - expect(secondNavigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(firstNavigationEvent.name).toBe('/delayed-lazy/:id'); + expect(getSpanOp(firstNavigationEvent)).toBe('navigation'); + expect(secondNavigationEvent.name).toBe('/delayed-lazy/:id'); + expect(getSpanOp(secondNavigationEvent)).toBe('navigation'); // Trace IDs should be different (separate transactions) expect(firstTraceId).toBeDefined(); @@ -849,7 +789,7 @@ test('Creates separate navigation transactions for multiple hash changes', async expect(firstTraceId).not.toBe(secondTraceId); }); -test('Creates navigation transaction when changing both query and hash on same route', async ({ page }) => { +test('Creates navigation span when changing both query and hash on same route', async ({ page }) => { await page.goto('/delayed-lazy/123?view=list'); const delayedReady = page.locator('id=delayed-lazy-ready'); @@ -858,12 +798,8 @@ test('Creates navigation transaction when changing both query and hash on same r // Navigate from /delayed-lazy/123?view=list to /delayed-lazy/123?view=grid#results // This changes both query and hash - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/delayed-lazy/:id' - ); + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/delayed-lazy/:id'; }); const queryAndHashLink = page.locator('id=link-to-query-and-hash'); @@ -878,25 +814,21 @@ test('Creates navigation transaction when changing both query and hash on same r await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: grid'); // Combined query + hash navigation should create a navigation transaction - expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); - expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); - expect(navigationEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); - expect(navigationEvent.contexts?.trace?.status).toBe('ok'); + expect(navigationEvent.name).toBe('/delayed-lazy/:id'); + expect(getSpanOp(navigationEvent)).toBe('navigation'); + expect(navigationEvent.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(navigationEvent.status).toBe('ok'); }); -test('Creates navigation transaction with correct name for slow lazy route', async ({ page }) => { +test('Creates navigation span with correct name for slow lazy route', async ({ page }) => { // This test verifies that navigating to a slow lazy route (with top-level await) // creates a correctly named navigation transaction. // The route uses handle.lazyChildren with a 500ms delay. await page.goto('/'); - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/slow-fetch/:id' - ); + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/slow-fetch/:id'; }); // Navigate to slow-fetch route (500ms delay) @@ -912,21 +844,21 @@ test('Creates navigation transaction with correct name for slow lazy route', asy await expect(page.locator('id=slow-fetch-id')).toHaveText('ID: 123'); // Verify the transaction has the correct parameterized route name - expect(navigationEvent.transaction).toBe('/slow-fetch/:id'); - expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); - expect(navigationEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(navigationEvent.name).toBe('/slow-fetch/:id'); + expect(getSpanOp(navigationEvent)).toBe('navigation'); + expect(navigationEvent.attributes['sentry.segment.name.source']?.value).toBe('route'); }); -test('Rapid navigation does not corrupt transaction names when lazy handlers resolve late', async ({ page }) => { +test('Rapid navigation does not corrupt span names when lazy handlers resolve late', async ({ page }) => { await page.goto('/'); const allTransactions: Array<{ name: string; op: string }> = []; - const collectorPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - if (transactionEvent?.transaction && transactionEvent.contexts?.trace?.op) { + const collectorPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + if (span.is_segment && getSpanOp(span)) { allTransactions.push({ - name: transactionEvent.transaction, - op: transactionEvent.contexts.trace.op, + name: span.name, + op: getSpanOp(span) ?? '', }); } return allTransactions.length >= 2; @@ -969,59 +901,49 @@ test('Rapid navigation does not corrupt transaction names when lazy handlers res expect(hasValidRouteName).toBe(true); }); -test('Correctly names pageload transaction for slow lazy route with fetch', async ({ page }) => { +test('Correctly names pageload span for slow lazy route with fetch', async ({ page }) => { // This test verifies that a slow lazy route (with top-level await and fetch) - // creates a correctly named pageload transaction + // creates a correctly named pageload span - const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction === '/slow-fetch/:id' - ); - }); + const spansPromise = collectStreamedSpans('react-router-7-lazy-routes', spansOfTrace => + spansOfTrace.some(span => span.name === '/slow-fetch/:id' && span.is_segment), + ); await page.goto('/slow-fetch/123'); - const pageloadEvent = await pageloadPromise; + const spans = await spansPromise; + const pageloadSpan = spans.find(span => span.is_segment)!; // Wait for the component to render (after the 500ms delay) const slowFetchContent = page.locator('id=slow-fetch-content'); await expect(slowFetchContent).toBeVisible({ timeout: 5000 }); await expect(page.locator('id=slow-fetch-id')).toHaveText('ID: 123'); - // Verify the transaction has the correct parameterized route name - expect(pageloadEvent.transaction).toBe('/slow-fetch/:id'); - expect(pageloadEvent.contexts?.trace?.op).toBe('pageload'); - expect(pageloadEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); - - // Verify the transaction contains a fetch span - const spans = pageloadEvent.spans || []; - const fetchSpan = spans.find( - (span: { op?: string; description?: string }) => - span.op === 'http.client' && span.description?.includes('/api/slow-data'), - ); + // Verify the span has the correct parameterized route name + expect(pageloadSpan.name).toBe('/slow-fetch/:id'); + expect(getSpanOp(pageloadSpan)).toBe('pageload'); + expect(pageloadSpan.attributes['sentry.segment.name.source']?.value).toBe('route'); + + // Verify the trace contains a fetch span. Streamed http.client names are only ` `, + // so the request URL comes from `url.full`. + const fetchSpan = spans.find(span => getSpanOp(span) === 'http.client' && hasUrlPart(span, '/api/slow-data')); // The fetch span should exist (even if the fetch failed, the span is created) expect(fetchSpan).toBeDefined(); }); -test('Three-route rapid navigation preserves distinct transaction names', async ({ page }) => { +test('Three-route rapid navigation preserves distinct span names', async ({ page }) => { const navigationTransactions: Array<{ name: string }> = []; - const navigationCollector = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - if (transactionEvent.contexts?.trace?.op === 'navigation') { - navigationTransactions.push({ name: transactionEvent.transaction || '' }); + const navigationCollector = waitForStreamedSpan('react-router-7-lazy-routes', span => { + if (getSpanOp(span) === 'navigation') { + navigationTransactions.push({ name: span.name }); } return false; }); - const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction === '/delayed-lazy/:id' - ); + const pageloadPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/delayed-lazy/:id'; }); // Pageload to delayed-lazy route @@ -1057,12 +979,12 @@ test('Three-route rapid navigation preserves distinct transaction names', async expect(corruptedToRoot.length).toBe(0); }); -test('Zero-wait rapid navigation does not corrupt transaction names', async ({ page }) => { +test('Zero-wait rapid navigation does not corrupt span names', async ({ page }) => { const navigationTransactions: Array<{ name: string }> = []; - const collector = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - if (transactionEvent.contexts?.trace?.op === 'navigation') { - navigationTransactions.push({ name: transactionEvent.transaction || '' }); + const collector = waitForStreamedSpan('react-router-7-lazy-routes', span => { + if (getSpanOp(span) === 'navigation') { + navigationTransactions.push({ name: span.name }); } return false; }); @@ -1095,11 +1017,11 @@ test('Zero-wait rapid navigation does not corrupt transaction names', async ({ p test('Browser back during lazy handler resolution does not corrupt', async ({ page }) => { const allTransactions: Array<{ name: string; op: string }> = []; - const collector = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - if (transactionEvent?.transaction && transactionEvent.contexts?.trace?.op) { + const collector = waitForStreamedSpan('react-router-7-lazy-routes', span => { + if (span.is_segment && getSpanOp(span)) { allTransactions.push({ - name: transactionEvent.transaction, - op: transactionEvent.contexts.trace.op, + name: span.name, + op: getSpanOp(span) ?? '', }); } return false; @@ -1132,9 +1054,9 @@ test('Browser back during lazy handler resolution does not corrupt', async ({ pa test('Multiple overlapping lazy handlers do not corrupt each other', async ({ page }) => { const navigationTransactions: Array<{ name: string }> = []; - const collector = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - if (transactionEvent.contexts?.trace?.op === 'navigation') { - navigationTransactions.push({ name: transactionEvent.transaction || '' }); + const collector = waitForStreamedSpan('react-router-7-lazy-routes', span => { + if (getSpanOp(span) === 'navigation') { + navigationTransactions.push({ name: span.name }); } return false; }); @@ -1176,12 +1098,12 @@ test('Multiple overlapping lazy handlers do not corrupt each other', async ({ pa } }); -test('Query/hash navigation does not corrupt transaction name', async ({ page }) => { +test('Query/hash navigation does not corrupt span name', async ({ page }) => { const navigationTransactions: Array<{ name: string }> = []; - const collectorPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - if (transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation') { - navigationTransactions.push({ name: transactionEvent.transaction }); + const collectorPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + if (span.is_segment && getSpanOp(span) === 'navigation') { + navigationTransactions.push({ name: span.name }); } return navigationTransactions.length >= 1; }); @@ -1233,12 +1155,8 @@ test('Query/hash navigation does not corrupt transaction name', async ({ page }) test('Slow lazy route pageload with early span end still gets parameterized route name (regression)', async ({ page, }) => { - const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - (transactionEvent.transaction?.startsWith('/slow-fetch') ?? false) - ); + const transactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.name.startsWith('/slow-fetch'); }); // idleTimeout=300 ends span before 500ms lazy route loads, timeout=1000 waits for lazy routes @@ -1246,33 +1164,29 @@ test('Slow lazy route pageload with early span end still gets parameterized rout const event = await transactionPromise; - expect(event.transaction).toBe('/slow-fetch/:id'); - expect(event.type).toBe('transaction'); - expect(event.contexts?.trace?.op).toBe('pageload'); - expect(event.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(event.name).toBe('/slow-fetch/:id'); + expect(event.is_segment).toBe(true); + expect(getSpanOp(event)).toBe('pageload'); + expect(event.attributes['sentry.segment.name.source']?.value).toBe('route'); - const idleSpanFinishReason = event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']; + const idleSpanFinishReason = event.attributes['sentry.idle_span_finish_reason']?.value; expect(['idleTimeout', 'externalFinish']).toContain(idleSpanFinishReason); }); // Regression: Wildcard route names should be upgraded to parameterized routes when lazy routes load test('Wildcard route pageload gets upgraded to parameterized route name (regression)', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - (transactionEvent.transaction?.startsWith('/wildcard-lazy') ?? false) - ); + const transactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.name.startsWith('/wildcard-lazy'); }); await page.goto('/wildcard-lazy/456?idleTimeout=300&timeout=1000'); const event = await transactionPromise; - expect(event.transaction).toBe('/wildcard-lazy/:id'); - expect(event.type).toBe('transaction'); - expect(event.contexts?.trace?.op).toBe('pageload'); - expect(event.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(event.name).toBe('/wildcard-lazy/:id'); + expect(event.is_segment).toBe(true); + expect(getSpanOp(event)).toBe('pageload'); + expect(event.attributes['sentry.segment.name.source']?.value).toBe('route'); }); // Regression: Navigation to slow lazy route should get parameterized name even if span ends early. @@ -1286,12 +1200,8 @@ test('Slow lazy route navigation with early span end still gets parameterized ro // Wait for pageload to complete await page.waitForTimeout(500); - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - (transactionEvent.transaction?.startsWith('/wildcard-lazy') ?? false) - ); + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name.startsWith('/wildcard-lazy'); }); // Navigate to wildcard-lazy route (500ms delay in module via top-level await) @@ -1303,10 +1213,10 @@ test('Slow lazy route navigation with early span end still gets parameterized ro const event = await navigationPromise; // The navigation transaction should have the parameterized route name - expect(event.transaction).toBe('/wildcard-lazy/:id'); - expect(event.type).toBe('transaction'); - expect(event.contexts?.trace?.op).toBe('navigation'); - expect(event.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(event.name).toBe('/wildcard-lazy/:id'); + expect(event.is_segment).toBe(true); + expect(getSpanOp(event)).toBe('navigation'); + expect(event.attributes['sentry.segment.name.source']?.value).toBe('route'); }); test('Captured navigation context is used instead of stale window.location during rapid navigation', async ({ @@ -1318,11 +1228,11 @@ test('Captured navigation context is used instead of stale window.location durin const allNavigationTransactions: Array<{ name: string; traceId: string }> = []; - const collectorPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - if (transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation') { + const collectorPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + if (span.is_segment && getSpanOp(span) === 'navigation') { allNavigationTransactions.push({ - name: transactionEvent.transaction, - traceId: transactionEvent.contexts.trace.trace_id || '', + name: span.name, + traceId: span.trace_id || '', }); } return allNavigationTransactions.length >= 2; @@ -1365,21 +1275,14 @@ test('Captured navigation context is used instead of stale window.location durin test('Second navigation span is not corrupted by first slow lazy handler completing late', async ({ page }) => { // Validates fix for race condition where slow lazy handler would update the wrong span. // Navigate to slow route (which fetches /api/slow-data), then quickly to fast route. - // Without fix: second transaction gets wrong name and/or contains leaked spans. + // Without fix: the second segment gets the wrong name and/or contains leaked spans. await page.goto('/'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const allNavigationTransactions: Array<{ name: string; traceId: string; spans: any[] }> = []; + const streamedSpans: SerializedStreamedSpan[] = []; - const collectorPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - if (transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation') { - allNavigationTransactions.push({ - name: transactionEvent.transaction, - traceId: transactionEvent.contexts.trace.trace_id || '', - spans: transactionEvent.spans || [], - }); - } + const collectorPromise = waitForStreamedSpans('react-router-7-lazy-routes', spans => { + streamedSpans.push(...spans); return false; }); @@ -1396,7 +1299,7 @@ test('Second navigation span is not corrupted by first slow lazy handler complet await expect(page.locator('id=another-lazy-route')).toBeVisible({ timeout: 10000 }); - // Wait for slow-fetch lazy handler to complete and transactions to be sent + // Wait for slow-fetch lazy handler to complete and spans to be sent await page.waitForTimeout(2000); await Promise.race([ @@ -1404,31 +1307,27 @@ test('Second navigation span is not corrupted by first slow lazy handler complet new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 3000)), ]).catch(() => {}); - expect(allNavigationTransactions.length).toBeGreaterThanOrEqual(1); + const navigationSegmentSpans = streamedSpans.filter(span => getSpanOp(span) === 'navigation' && span.is_segment); + expect(navigationSegmentSpans.length).toBeGreaterThanOrEqual(1); - // /another-lazy transaction must have correct name, not "/slow-fetch/:id" - const anotherLazyTransaction = allNavigationTransactions.find(t => t.name.startsWith('/another-lazy/sub')); - expect(anotherLazyTransaction).toBeDefined(); + // /another-lazy segment must have the correct name, not "/slow-fetch/:id" + const anotherLazySegment = navigationSegmentSpans.find(span => span.name.startsWith('/another-lazy/sub')); + expect(anotherLazySegment).toBeDefined(); - // Key assertion 2: /another-lazy transaction must NOT contain spans from /slow-fetch route - // The /api/slow-data fetch is triggered by the slow-fetch route's lazy loading - if (anotherLazyTransaction) { - const leakedSpans = anotherLazyTransaction.spans.filter( - span => span.description?.includes('slow-data') || span.data?.['url.full'].includes('slow-data'), - ); + // Key assertion 2: the /another-lazy trace must NOT contain spans from the /slow-fetch route. + // The /api/slow-data fetch is triggered by the slow-fetch route's lazy loading. + if (anotherLazySegment) { + const leakedSpans = childSpansOf(streamedSpans, anotherLazySegment).filter(span => hasUrlPart(span, 'slow-data')); expect(leakedSpans.length).toBe(0); } - // Key assertion 3: If slow-fetch transaction exists, verify it has the correct name + // Key assertion 3: If a slow-fetch segment exists, verify it has the correct name // (not corrupted to /another-lazy) - const slowFetchTransaction = allNavigationTransactions.find(t => t.name.includes('slow-fetch')); - if (slowFetchTransaction) { - expect(slowFetchTransaction.name).toMatch(/\/slow-fetch/); - // Verify slow-fetch transaction doesn't contain spans that belong to /another-lazy - const wrongSpans = slowFetchTransaction.spans.filter( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (span: any) => span.description?.includes('another-lazy') || span.data?.['url.full'].includes('another-lazy'), - ); + const slowFetchSegment = navigationSegmentSpans.find(span => span.name.includes('slow-fetch')); + if (slowFetchSegment) { + expect(slowFetchSegment.name).toMatch(/\/slow-fetch/); + // Verify the slow-fetch trace doesn't contain spans that belong to /another-lazy + const wrongSpans = childSpansOf(streamedSpans, slowFetchSegment).filter(span => hasUrlPart(span, 'another-lazy')); expect(wrongSpans.length).toBe(0); } }); @@ -1441,12 +1340,8 @@ test('Route manifest provides correct name when navigation span ends before lazy // Wait for pageload to complete await page.waitForTimeout(200); - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - (transactionEvent.transaction?.startsWith('/wildcard-lazy') ?? false) - ); + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name.startsWith('/wildcard-lazy'); }); // Navigate to wildcard-lazy route (500ms delay in module via top-level await) @@ -1457,20 +1352,16 @@ test('Route manifest provides correct name when navigation span ends before lazy const event = await navigationPromise; // Should have parameterized name from manifest, not wildcard (/wildcard-lazy/*) - expect(event.transaction).toBe('/wildcard-lazy/:id'); - expect(event.type).toBe('transaction'); - expect(event.contexts?.trace?.op).toBe('navigation'); - expect(event.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(event.name).toBe('/wildcard-lazy/:id'); + expect(event.is_segment).toBe(true); + expect(getSpanOp(event)).toBe('navigation'); + expect(event.attributes['sentry.segment.name.source']?.value).toBe('route'); }); test('Route manifest provides correct name when pageload span ends before lazy route resolves', async ({ page }) => { // Short idle timeout (50ms) ensures span ends before lazy route (500ms) resolves - const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - (transactionEvent.transaction?.startsWith('/wildcard-lazy') ?? false) - ); + const pageloadPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.name.startsWith('/wildcard-lazy'); }); await page.goto('/wildcard-lazy/123?idleTimeout=50&timeout=0'); @@ -1478,151 +1369,124 @@ test('Route manifest provides correct name when pageload span ends before lazy r const event = await pageloadPromise; // Should have parameterized name from manifest, not wildcard (/wildcard-lazy/*) - expect(event.transaction).toBe('/wildcard-lazy/:id'); - expect(event.type).toBe('transaction'); - expect(event.contexts?.trace?.op).toBe('pageload'); - expect(event.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(event.name).toBe('/wildcard-lazy/:id'); + expect(event.is_segment).toBe(true); + expect(getSpanOp(event)).toBe('pageload'); + expect(event.attributes['sentry.segment.name.source']?.value).toBe('route'); }); -test('GQL fetch span is attributed to the correct navigation transaction when navigating from index to lazy GQL page', async ({ +test('GQL fetch span is attributed to the correct navigation segment when navigating from index to lazy GQL page', async ({ page, }) => { - const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction === '/' - ); - }); - - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy-gql-a/fetch' - ); + const pageloadSpansPromise = collectStreamedSpans('react-router-7-lazy-routes', spans => { + return spans.some(span => getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/'); }); await page.goto('/'); - const pageloadEvent = await pageloadPromise; + const pageloadSpans = await pageloadSpansPromise; + const pageloadSegment = pageloadSpans.find(span => getSpanOp(span) === 'pageload' && span.is_segment)!; // Pageload should NOT contain any /api/graphql spans (neither UserAQuery nor UserBQuery) - const pageloadSpans = pageloadEvent.spans || []; - const pageloadGqlSpans = pageloadSpans.filter( - (span: { op?: string; description?: string; data?: { url?: string } }) => - span.op === 'http.client' && - (span.description?.includes('/api/graphql') || span.data?.['url.full'].includes('/api/graphql')), + const pageloadGqlSpans = childSpansOf(pageloadSpans, pageloadSegment).filter( + span => getSpanOp(span) === 'http.client' && hasUrlPart(span, '/api/graphql'), ); expect(pageloadGqlSpans.length).toBe(0); + const navigationSpansPromise = collectStreamedSpans('react-router-7-lazy-routes', spans => { + return spans.some(span => getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy-gql-a/fetch'); + }); + // Navigate to lazy GQL page A const gqlLink = page.locator('id=navigation-to-gql-a'); await expect(gqlLink).toBeVisible(); await gqlLink.click(); - const navigationEvent = await navigationPromise; + const navigationSpans = await navigationSpansPromise; + const navigationSegment = navigationSpans.find( + span => getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy-gql-a/fetch', + )!; // Verify the lazy GQL page rendered await expect(page.locator('id=gql-page-a')).toBeVisible(); - // Verify the navigation transaction has the correct name - expect(navigationEvent.transaction).toBe('/lazy-gql-a/fetch'); - expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); + // Verify the navigation segment has the correct name + expect(navigationSegment.name).toBe('/lazy-gql-a/fetch'); + expect(getSpanOp(navigationSegment)).toBe('navigation'); - // Verify the UserAQuery GQL fetch span is inside this navigation transaction - const navSpans = navigationEvent.spans || []; - const userASpans = navSpans.filter( - (span: { op?: string; description?: string; data?: { url?: string } }) => - span.op === 'http.client' && - (span.description?.includes('UserAQuery') || span.data?.['url.full'].includes('UserAQuery')), - ); + // Verify the UserAQuery GQL fetch span is inside this navigation segment's trace + const navChildSpans = childSpansOf(navigationSpans, navigationSegment); + const userASpans = navChildSpans.filter(span => getSpanOp(span) === 'http.client' && hasUrlPart(span, 'UserAQuery')); expect(userASpans.length).toBe(1); - // Verify NO UserBQuery spans leaked into this transaction - const userBSpans = navSpans.filter( - (span: { op?: string; description?: string; data?: { url?: string } }) => - span.op === 'http.client' && - (span.description?.includes('UserBQuery') || span.data?.['url.full'].includes('UserBQuery')), - ); + // Verify NO UserBQuery spans leaked into this trace + const userBSpans = navChildSpans.filter(span => getSpanOp(span) === 'http.client' && hasUrlPart(span, 'UserBQuery')); expect(userBSpans.length).toBe(0); }); -test('GQL fetch spans are attributed to correct navigation transactions when navigating between two lazy GQL pages', async ({ +test('GQL fetch spans are attributed to correct navigation segments when navigating between two lazy GQL pages', async ({ page, }) => { await page.goto('/'); await page.waitForTimeout(500); // Navigate to GQL page A - const firstNavPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy-gql-a/fetch' - ); + const firstNavSpansPromise = collectStreamedSpans('react-router-7-lazy-routes', spans => { + return spans.some(span => getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy-gql-a/fetch'); }); const gqlALink = page.locator('id=navigation-to-gql-a'); await expect(gqlALink).toBeVisible(); await gqlALink.click(); - const firstNavEvent = await firstNavPromise; + const firstNavSpans = await firstNavSpansPromise; + const firstNavSegment = firstNavSpans.find( + span => getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy-gql-a/fetch', + )!; await expect(page.locator('id=gql-page-a')).toBeVisible(); // First navigation should have exactly the UserAQuery span - const firstNavSpans = firstNavEvent.spans || []; - const firstUserASpans = firstNavSpans.filter( - (span: { op?: string; description?: string; data?: { url?: string } }) => - span.op === 'http.client' && - (span.description?.includes('UserAQuery') || span.data?.['url.full'].includes('UserAQuery')), + const firstNavChildSpans = childSpansOf(firstNavSpans, firstNavSegment); + const firstUserASpans = firstNavChildSpans.filter( + span => getSpanOp(span) === 'http.client' && hasUrlPart(span, 'UserAQuery'), ); expect(firstUserASpans.length).toBe(1); // First navigation must NOT contain UserBQuery spans - const firstUserBSpans = firstNavSpans.filter( - (span: { op?: string; description?: string; data?: { url?: string } }) => - span.op === 'http.client' && - (span.description?.includes('UserBQuery') || span.data?.['url.full'].includes('UserBQuery')), + const firstUserBSpans = firstNavChildSpans.filter( + span => getSpanOp(span) === 'http.client' && hasUrlPart(span, 'UserBQuery'), ); expect(firstUserBSpans.length).toBe(0); // Now navigate from GQL page A to GQL page B - const secondNavPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy-gql-b/fetch' - ); + const secondNavSpansPromise = collectStreamedSpans('react-router-7-lazy-routes', spans => { + return spans.some(span => getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy-gql-b/fetch'); }); const gqlBLink = page.locator('id=navigate-to-gql-b'); await expect(gqlBLink).toBeVisible(); await gqlBLink.click(); - const secondNavEvent = await secondNavPromise; + const secondNavSpans = await secondNavSpansPromise; + const secondNavSegment = secondNavSpans.find( + span => getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy-gql-b/fetch', + )!; await expect(page.locator('id=gql-page-b')).toBeVisible(); // Second navigation should have exactly the UserBQuery span - const secondNavSpans = secondNavEvent.spans || []; - const secondUserBSpans = secondNavSpans.filter( - (span: { op?: string; description?: string; data?: { url?: string } }) => - span.op === 'http.client' && - (span.description?.includes('UserBQuery') || span.data?.['url.full'].includes('UserBQuery')), + const secondNavChildSpans = childSpansOf(secondNavSpans, secondNavSegment); + const secondUserBSpans = secondNavChildSpans.filter( + span => getSpanOp(span) === 'http.client' && hasUrlPart(span, 'UserBQuery'), ); expect(secondUserBSpans.length).toBe(1); // Second navigation must NOT contain UserAQuery spans (no leaking from first nav) - const secondUserASpans = secondNavSpans.filter( - (span: { op?: string; description?: string; data?: { url?: string } }) => - span.op === 'http.client' && - (span.description?.includes('UserAQuery') || span.data?.['url.full'].includes('UserAQuery')), + const secondUserASpans = secondNavChildSpans.filter( + span => getSpanOp(span) === 'http.client' && hasUrlPart(span, 'UserAQuery'), ); expect(secondUserASpans.length).toBe(0); - // Verify the two transactions have different trace IDs - const firstTraceId = firstNavEvent.contexts?.trace?.trace_id; - const secondTraceId = secondNavEvent.contexts?.trace?.trace_id; - expect(firstTraceId).toBeDefined(); - expect(secondTraceId).toBeDefined(); - expect(firstTraceId).not.toBe(secondTraceId); + // Verify the two segments have different trace IDs + expect(firstNavSegment.trace_id).toBeDefined(); + expect(secondNavSegment.trace_id).toBeDefined(); + expect(firstNavSegment.trace_id).not.toBe(secondNavSegment.trace_id); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behaviour.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behaviour.test.ts index 7826d7ced00e..25a12bb14ea1 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behaviour.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behaviour.test.ts @@ -1,13 +1,9 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('lazyRouteTimeout: Routes load within timeout window', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction.includes('deep') - ); + const spanPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name.includes('deep'); }); // Route takes ~900ms, timeout allows 1050ms (50 + 1000) @@ -18,21 +14,17 @@ test('lazyRouteTimeout: Routes load within timeout window', async ({ page }) => await expect(navigationLink).toBeVisible(); await navigationLink.click(); - const event = await transactionPromise; + const span = await spanPromise; // Should get full parameterized route - expect(event.transaction).toBe('/deep/level2/level3/:id'); - expect(event.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); - expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); + expect(span.name).toBe('/deep/level2/level3/:id'); + expect(span.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(span.attributes['sentry.idle_span_finish_reason']?.value).toBe('idleTimeout'); }); test('lazyRouteTimeout: Infinity timeout always waits for routes', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction.includes('deep') - ); + const spanPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name.includes('deep'); }); // Infinity timeout → waits as long as possible (capped at finalTimeout to prevent indefinite hangs) @@ -42,85 +34,73 @@ test('lazyRouteTimeout: Infinity timeout always waits for routes', async ({ page await expect(navigationLink).toBeVisible(); await navigationLink.click(); - const event = await transactionPromise; + const span = await spanPromise; // Should wait for routes to load (up to finalTimeout) and get full route - expect(event.transaction).toBe('/deep/level2/level3/:id'); - expect(event.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); - expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); + expect(span.name).toBe('/deep/level2/level3/:id'); + expect(span.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(span.attributes['sentry.idle_span_finish_reason']?.value).toBe('idleTimeout'); }); test('idleTimeout: Captures all activity with increased timeout', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction.includes('deep') - ); + const spanPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name.includes('deep'); }); - // High idleTimeout (5000ms) ensures transaction captures all lazy loading activity + // High idleTimeout (5000ms) ensures the span captures all lazy loading activity await page.goto('/?idleTimeout=5000'); const navigationLink = page.locator('id=navigation-to-deep'); await expect(navigationLink).toBeVisible(); await navigationLink.click(); - const event = await transactionPromise; + const span = await spanPromise; - expect(event.transaction).toBe('/deep/level2/level3/:id'); - expect(event.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); - expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); + expect(span.name).toBe('/deep/level2/level3/:id'); + expect(span.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(span.attributes['sentry.idle_span_finish_reason']?.value).toBe('idleTimeout'); - // Transaction should wait for full idle timeout (5+ seconds) - const duration = event.timestamp! - event.start_timestamp; + // The span should wait for the full idle timeout (5+ seconds) + const duration = span.end_timestamp - span.start_timestamp; expect(duration).toBeGreaterThan(5.0); expect(duration).toBeLessThan(7.0); }); test('idleTimeout: Finishes prematurely with low timeout', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction.includes('deep') - ); + const spanPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name.includes('deep'); }); // Very low idleTimeout (50ms) and lazyRouteTimeout (100ms) - // Transaction finishes quickly, but still gets parameterized route name + // The span finishes quickly, but still gets a parameterized route name await page.goto('/?idleTimeout=50&timeout=100'); const navigationLink = page.locator('id=navigation-to-deep'); await expect(navigationLink).toBeVisible(); await navigationLink.click(); - const event = await transactionPromise; + const span = await spanPromise; - expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); - expect(event.transaction).toBe('/deep/level2/level3/:id'); - expect(event.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(span.attributes['sentry.idle_span_finish_reason']?.value).toBe('idleTimeout'); + expect(span.name).toBe('/deep/level2/level3/:id'); + expect(span.attributes['sentry.segment.name.source']?.value).toBe('route'); - // Transaction should finish quickly (< 200ms) - const duration = event.timestamp! - event.start_timestamp; + // The span should finish quickly (< 200ms) + const duration = span.end_timestamp - span.start_timestamp; expect(duration).toBeLessThan(0.2); }); test('idleTimeout: Pageload on deeply nested route', async ({ page }) => { - const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction.includes('deep') - ); + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.name.includes('deep'); }); // Direct pageload to deeply nested route (not navigation) await page.goto('/deep/level2/level3/12345'); - const pageloadEvent = await pageloadPromise; + const pageloadSpan = await pageloadSpanPromise; - expect(pageloadEvent.transaction).toBe('/deep/level2/level3/:id'); - expect(pageloadEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); - expect(pageloadEvent.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); + expect(pageloadSpan.name).toBe('/deep/level2/level3/:id'); + expect(pageloadSpan.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(pageloadSpan.attributes['sentry.idle_span_finish_reason']?.value).toBe('idleTimeout'); }); From b811d6b4bf144542aa6ac076313f3d1f9d3b6b61 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 2 Sep 2026 13:24:16 +0200 Subject: [PATCH 4/4] Drop the redundant trace filtering from the lazy route span lookups collectStreamedSpans scopes its result to one trace now, so the GQL and slow-fetch lookups no longer need to filter children by the segment's trace id, and three segment lookups that only existed to supply it are gone. The corruption test keeps the trace-scoped helper, since it collects across traces on purpose. --- .../tests/spans.test.ts | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/spans.test.ts index 500bb3ff9c8f..08d17d4baeb1 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/spans.test.ts @@ -14,11 +14,23 @@ function hasUrlPart(span: SerializedStreamedSpan, part: string): boolean { return typeof urlFull === 'string' && urlFull.includes(part); } -/** All spans of the trace `segmentSpan` belongs to, minus the segment span itself. */ -function childSpansOf(spans: SerializedStreamedSpan[], segmentSpan: SerializedStreamedSpan): SerializedStreamedSpan[] { +/** + * Child spans of `segmentSpan`'s trace, picked out of a collection that spans several traces. + * `collectStreamedSpans` already scopes its result to one trace, so this is only for the spans + * gathered by the cross-trace collector below. + */ +function childSpansInTraceOf( + spans: SerializedStreamedSpan[], + segmentSpan: SerializedStreamedSpan, +): SerializedStreamedSpan[] { return spans.filter(span => span.trace_id === segmentSpan.trace_id && !span.is_segment); } +/** Non-segment spans of a single-trace collection. */ +function childSpans(spans: SerializedStreamedSpan[]): SerializedStreamedSpan[] { + return spans.filter(span => !span.is_segment); +} + test('Creates a pageload span with parameterized route', async ({ page }) => { const transactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( @@ -1317,7 +1329,9 @@ test('Second navigation span is not corrupted by first slow lazy handler complet // Key assertion 2: the /another-lazy trace must NOT contain spans from the /slow-fetch route. // The /api/slow-data fetch is triggered by the slow-fetch route's lazy loading. if (anotherLazySegment) { - const leakedSpans = childSpansOf(streamedSpans, anotherLazySegment).filter(span => hasUrlPart(span, 'slow-data')); + const leakedSpans = childSpansInTraceOf(streamedSpans, anotherLazySegment).filter(span => + hasUrlPart(span, 'slow-data'), + ); expect(leakedSpans.length).toBe(0); } @@ -1327,7 +1341,9 @@ test('Second navigation span is not corrupted by first slow lazy handler complet if (slowFetchSegment) { expect(slowFetchSegment.name).toMatch(/\/slow-fetch/); // Verify the slow-fetch trace doesn't contain spans that belong to /another-lazy - const wrongSpans = childSpansOf(streamedSpans, slowFetchSegment).filter(span => hasUrlPart(span, 'another-lazy')); + const wrongSpans = childSpansInTraceOf(streamedSpans, slowFetchSegment).filter(span => + hasUrlPart(span, 'another-lazy'), + ); expect(wrongSpans.length).toBe(0); } }); @@ -1384,10 +1400,9 @@ test('GQL fetch span is attributed to the correct navigation segment when naviga await page.goto('/'); const pageloadSpans = await pageloadSpansPromise; - const pageloadSegment = pageloadSpans.find(span => getSpanOp(span) === 'pageload' && span.is_segment)!; // Pageload should NOT contain any /api/graphql spans (neither UserAQuery nor UserBQuery) - const pageloadGqlSpans = childSpansOf(pageloadSpans, pageloadSegment).filter( + const pageloadGqlSpans = childSpans(pageloadSpans).filter( span => getSpanOp(span) === 'http.client' && hasUrlPart(span, '/api/graphql'), ); expect(pageloadGqlSpans.length).toBe(0); @@ -1414,7 +1429,7 @@ test('GQL fetch span is attributed to the correct navigation segment when naviga expect(getSpanOp(navigationSegment)).toBe('navigation'); // Verify the UserAQuery GQL fetch span is inside this navigation segment's trace - const navChildSpans = childSpansOf(navigationSpans, navigationSegment); + const navChildSpans = childSpans(navigationSpans); const userASpans = navChildSpans.filter(span => getSpanOp(span) === 'http.client' && hasUrlPart(span, 'UserAQuery')); expect(userASpans.length).toBe(1); @@ -1445,7 +1460,7 @@ test('GQL fetch spans are attributed to correct navigation segments when navigat await expect(page.locator('id=gql-page-a')).toBeVisible(); // First navigation should have exactly the UserAQuery span - const firstNavChildSpans = childSpansOf(firstNavSpans, firstNavSegment); + const firstNavChildSpans = childSpans(firstNavSpans); const firstUserASpans = firstNavChildSpans.filter( span => getSpanOp(span) === 'http.client' && hasUrlPart(span, 'UserAQuery'), ); @@ -1473,7 +1488,7 @@ test('GQL fetch spans are attributed to correct navigation segments when navigat await expect(page.locator('id=gql-page-b')).toBeVisible(); // Second navigation should have exactly the UserBQuery span - const secondNavChildSpans = childSpansOf(secondNavSpans, secondNavSegment); + const secondNavChildSpans = childSpans(secondNavSpans); const secondUserBSpans = secondNavChildSpans.filter( span => getSpanOp(span) === 'http.client' && hasUrlPart(span, 'UserBQuery'), );