From 161ad310fa6bd9054a35cc55e07b10a41bed5e5d Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 2 Sep 2026 11:29:36 +0200 Subject: [PATCH 1/2] 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/2] 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'); }); });