From be4bcdd0b8281963b85c1feb29edc66a63f3031c Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 1 Sep 2026 16:09:37 +0200 Subject: [PATCH 1/2] test(e2e): Port the React Router instrumentation API E2E app to span streaming Removes the `traceLifecycle: 'static'` pin from `react-router-7-framework-instrumentation` and rewrites its specs against streamed span v2. Loader, action, middleware and fetcher assertions now walk the streamed trace instead of a transaction's `spans` array. The orchestrion mysql spec matches on `db.query.text`, since a streamed mysql span is named after its query summary. --- .../app/entry.client.tsx | 1 - .../instrument.mjs | 1 - .../tests/errors/errors.server.test.ts | 91 +++--- .../tests/performance/db.server.test.ts | 121 +++---- .../tests/performance/fetcher.client.test.ts | 86 ++--- .../tests/performance/lazy.server.test.ts | 89 +++-- .../performance/middleware.server.test.ts | 101 +++--- .../performance/navigation.client.test.ts | 305 +++++++----------- .../tests/performance/pageload.client.test.ts | 91 ++---- .../performance/performance.server.test.ts | 212 ++++++------ 10 files changed, 459 insertions(+), 639 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..fce459bc18e9 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, spans => { + return spans.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..ce7e63a12518 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, spans => { + return spans.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'); + + const childSpans = spans.filter(span => !span.is_segment); - expect(spans).toContainEqual( + 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, spans => { + return spans.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..da4b1d921a05 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,85 @@ 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 `rootSpan`, following `parent_span_id` down the tree. */ +function descendantsOf(spans: SerializedStreamedSpan[], rootSpan: SerializedStreamedSpan): SerializedStreamedSpan[] { + const descendants: SerializedStreamedSpan[] = []; + const parentIds = new Set([rootSpan.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 && descendantsOf(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 childSpans = descendantsOf(spans, fetcherSpan); + expect(childSpans.some(span => span.attributes['code.function.name']?.value === 'clientAction')).toBe(true); + expect(childSpans.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..79c8fb8d9e03 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, spans => { + return spans.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, spans => { + return spans.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, spans => { + return spans.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..8166446425ee 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, spans => { + return spans.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), + }); + + 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 OTEL patching of createRequestHandler - expect(middlewareSpan!.data?.['react_router.middleware.name']).toBe('authMiddleware'); - expect(middlewareSpan!.description).toBe('middleware authMiddleware'); + // 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, spans => { + return spans.some(span => span.name === 'GET /performance/with-middleware' && span.is_segment); }); await page.goto(`/performance/with-middleware`); - const transaction = await txPromise; - - const middlewareSpan = transaction?.spans?.find(span => span.data?.['code.function.name'] === 'middleware'); + const spans = await spansPromise; - 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, spans => { + return spans.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..77c7a0b309f5 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,168 @@ 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, spans => { + return ( + spans.some(span => span.name === segmentName && span.is_segment) && + (!childFunctionName || spans.some(span => span.attributes['code.function.name']?.value === childFunctionName)) + ); + }); +} + test.describe('server - instrumentation API performance', () => { - test('should send server transaction on pageload with instrumentation API origin', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance'; + test('should send server span on pageload with instrumentation API origin', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === 'GET /performance' && span.is_segment; }); await page.goto(`/performance`); - const transaction = await txPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.react_router.instrumentation_api', - 'sentry.segment.name.source': 'route', - }, - op: 'http.server', - origin: 'auto.http.react_router.instrumentation_api', - }, - }, - spans: expect.any(Array), + const span = await spanPromise; + + expect(span).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: 'GET /performance', - type: 'transaction', - transaction_info: { source: 'route' }, - platform: 'node', - request: { - url: expect.stringContaining('/performance'), - headers: expect.any(Object), - }, - event_id: expect.any(String), - environment: 'qa', - sdk: { - integrations: expect.arrayContaining([expect.any(String)]), - name: 'sentry.javascript.react-router', - version: expect.any(String), - packages: [ - { name: 'npm:@sentry/react-router', version: expect.any(String) }, - { name: 'npm:@sentry/node', version: expect.any(String) }, - ], - }, - tags: { - runtime: 'node', - }, + end_timestamp: expect.any(Number), + is_segment: true, + }); + + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.react_router.instrumentation_api', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.react-router', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.sdk.integrations': { value: expect.arrayContaining([expect.any(String)]), type: 'array' }, + 'url.full': { value: expect.stringContaining('/performance'), type: 'string' }, }); }); - test('should send server transaction on parameterized route with instrumentation API origin', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance/with/:param'; + test('should send server span on parameterized route with instrumentation API origin', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === 'GET /performance/with/:param' && span.is_segment; }); await page.goto(`/performance/with/some-param`); - const transaction = await txPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.react_router.instrumentation_api', - 'sentry.segment.name.source': 'route', - }, - op: 'http.server', - origin: 'auto.http.react_router.instrumentation_api', - }, - }, - spans: expect.any(Array), + const span = await spanPromise; + + expect(span).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: 'GET /performance/with/:param', - type: 'transaction', - transaction_info: { source: 'route' }, - platform: 'node', - request: { - url: expect.stringContaining('/performance/with/some-param'), - headers: expect.any(Object), - }, - event_id: expect.any(String), - environment: 'qa', + end_timestamp: expect.any(Number), + is_segment: true, + }); + + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.react_router.instrumentation_api', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'url.full': { value: expect.stringContaining('/performance/with/some-param'), type: 'string' }, }); }); test('should instrument server loader with instrumentation API origin', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance/server-loader'; - }); + const spansPromise = collectUntilSegment('GET /performance/server-loader', 'loader'); await page.goto(`/performance/server-loader`); - const transaction = await txPromise; + const spans = await spansPromise; - // Find the loader span - const loaderSpan = transaction?.spans?.find(span => span.data?.['code.function.name'] === 'loader'); + const loaderSpan = spans.find(span => span.attributes['code.function.name']?.value === 'loader')!; expect(loaderSpan).toMatchObject({ span_id: expect.any(String), trace_id: expect.any(String), - data: { - 'sentry.origin': 'auto.function.react_router.instrumentation_api', - 'sentry.op': 'function', - 'code.function.name': 'loader', - }, - description: '/performance/server-loader', + name: '/performance/server-loader', parent_span_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), + end_timestamp: expect.any(Number), status: 'ok', - op: 'function', - origin: 'auto.function.react_router.instrumentation_api', + }); + + expect(loaderSpan.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.function.react_router.instrumentation_api', type: 'string' }, + 'sentry.op': { value: 'function', type: 'string' }, + 'code.function.name': { value: 'loader', type: 'string' }, }); }); test('should instrument server action with instrumentation API origin', async ({ page }) => { - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'POST /performance/server-action'; - }); + const spansPromise = collectUntilSegment('POST /performance/server-action', 'action'); await page.goto(`/performance/server-action`); await page.getByRole('button', { name: 'Submit' }).click(); - const transaction = await txPromise; + const spans = await spansPromise; - // Find the action span - const actionSpan = transaction?.spans?.find(span => span.data?.['code.function.name'] === 'action'); + const actionSpan = spans.find(span => span.attributes['code.function.name']?.value === 'action')!; expect(actionSpan).toMatchObject({ span_id: expect.any(String), trace_id: expect.any(String), - data: { - 'sentry.origin': 'auto.function.react_router.instrumentation_api', - 'sentry.op': 'function', - 'code.function.name': 'action', - }, - description: '/performance/server-action', + name: '/performance/server-action', parent_span_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), + end_timestamp: expect.any(Number), status: 'ok', - op: 'function', - origin: 'auto.function.react_router.instrumentation_api', + }); + + expect(actionSpan.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.function.react_router.instrumentation_api', type: 'string' }, + 'sentry.op': { value: 'function', type: 'string' }, + 'code.function.name': { value: 'action', type: 'string' }, }); }); // Prod-only: the dev server (Vite) serves source modules (`/@vite/client`, `/app/*`) as separate - // requests, each producing its own http.server transaction, so "exactly one" only holds in prod. - test('sends exactly one http.server transaction per request (no double-instrumentation)', async ({ page }) => { + // requests, each producing its own http.server segment, so "exactly one" only holds in prod. + test('sends exactly one http.server segment per request (no double-instrumentation)', async ({ page }) => { test.skip( process.env.TEST_ENV === 'development', - 'Dev server emits extra http.server transactions for module requests', + 'Dev server emits extra http.server segments for module requests', ); - const httpServerTransactions: Array = []; - void waitForTransaction(APP_NAME, async transactionEvent => { - if (transactionEvent.contexts?.trace?.op === 'http.server') { - httpServerTransactions.push(transactionEvent.transaction); + const httpServerSpanNames: string[] = []; + void waitForStreamedSpans(APP_NAME, spans => { + for (const span of spans) { + if (getSpanOp(span) === 'http.server' && span.is_segment) { + httpServerSpanNames.push(span.name); + } } return false; }); await page.goto(`/performance`); - // Give any (erroneous) duplicate transaction time to arrive before asserting. + // Give any (erroneous) duplicate span time to arrive before asserting. await page.waitForTimeout(3000); - expect(httpServerTransactions).toEqual(['GET /performance']); + expect(httpServerSpanNames).toEqual(['GET /performance']); }); test('resolves a real http.route on routes without a loader/action', async ({ page }) => { // Regression guard for the server OTel removal: routes without a loader/action must still get a // proper `http.route` (not the catch-all `*` placeholder) from the underlying HTTP instrumentation. - const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /performance/ssr'; + const spanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === 'GET /performance/ssr' && span.is_segment; }); await page.goto(`/performance/ssr`); - const transaction = await txPromise; + const span = await spanPromise; - expect(transaction.contexts?.trace?.op).toBe('http.server'); - expect(transaction.contexts?.trace?.data?.['http.route']).toBe('/performance/ssr'); + expect(getSpanOp(span)).toBe('http.server'); + expect(span.attributes['http.route']?.value).toBe('/performance/ssr'); }); }); From 7d446867d423b19c3220e1e5542bd2c64012ec3b Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 1 Sep 2026 18:46:08 +0200 Subject: [PATCH 2/2] Use collectStreamedTrace for the instrumentation API span lookups Left the fetcher test on collectStreamedSpans: it already scopes children by walking down from the fetcher span, and whether that span is a segment under streaming is not something to assume. --- .../tests/errors/errors.server.test.ts | 6 ++---- .../tests/performance/db.server.test.ts | 10 +++------- .../tests/performance/lazy.server.test.ts | 14 ++++---------- .../tests/performance/middleware.server.test.ts | 14 ++++---------- .../tests/performance/performance.server.test.ts | 15 ++++++++------- 5 files changed, 21 insertions(+), 38 deletions(-) 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 fce459bc18e9..f0f896a9090a 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 { collectStreamedSpans, getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { collectStreamedTrace, getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; test.describe('server - instrumentation API error capture', () => { @@ -42,9 +42,7 @@ test.describe('server - instrumentation API error capture', () => { }); test('should include loader span in the segment even when loader throws', async ({ page }) => { - const spansPromise = collectStreamedSpans(APP_NAME, spans => { - return spans.some(span => span.name === 'GET /performance/error-loader' && span.is_segment); - }); + const spansPromise = collectStreamedTrace(APP_NAME, span => span.name === 'GET /performance/error-loader'); await page.goto(`/performance/error-loader`).catch(() => { // Expected to fail due to loader error 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 ce7e63a12518..76948dd04c5f 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,14 +1,12 @@ import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { collectStreamedTrace, 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 spansPromise = collectStreamedSpans(APP_NAME, spans => { - return spans.some(span => span.name === 'GET /performance/db-ioredis' && span.is_segment); - }); + const spansPromise = collectStreamedTrace(APP_NAME, span => span.name === 'GET /performance/db-ioredis'); await page.goto('/performance/db-ioredis'); @@ -64,9 +62,7 @@ test.describe('server - orchestrion db instrumentation', () => { // 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 spansPromise = collectStreamedSpans(APP_NAME, spans => { - return spans.some(span => span.name === 'GET /performance/db-mysql' && span.is_segment); - }); + const spansPromise = collectStreamedTrace(APP_NAME, span => span.name === 'GET /performance/db-mysql'); await page.goto('/performance/db-mysql'); 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 79c8fb8d9e03..dea61c069516 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 { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { collectStreamedTrace, 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,9 +8,7 @@ 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 spansPromise = collectStreamedSpans(APP_NAME, spans => { - return spans.some(span => span.name === 'GET /performance/lazy-route' && span.is_segment); - }); + const spansPromise = collectStreamedTrace(APP_NAME, span => span.name === 'GET /performance/lazy-route'); await page.goto(`/performance/lazy-route`); @@ -50,9 +48,7 @@ test.describe('server - instrumentation API lazy loading', () => { }); test('should include loader span after lazy loading completes', async ({ page }) => { - const spansPromise = collectStreamedSpans(APP_NAME, spans => { - return spans.some(span => span.name === 'GET /performance/lazy-route' && span.is_segment); - }); + const spansPromise = collectStreamedTrace(APP_NAME, span => span.name === 'GET /performance/lazy-route'); await page.goto(`/performance/lazy-route`); @@ -75,9 +71,7 @@ test.describe('server - instrumentation API lazy loading', () => { }); test.fail('should have correct span ordering: lazy before loader', async ({ page }) => { - const spansPromise = collectStreamedSpans(APP_NAME, spans => { - return spans.some(span => span.name === 'GET /performance/lazy-route' && span.is_segment); - }); + const spansPromise = collectStreamedTrace(APP_NAME, span => span.name === 'GET /performance/lazy-route'); await page.goto(`/performance/lazy-route`); 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 8166446425ee..dabbd300df51 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,12 +1,10 @@ import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { collectStreamedTrace, 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 spansPromise = collectStreamedSpans(APP_NAME, spans => { - return spans.some(span => span.name === 'GET /performance/with-middleware' && span.is_segment); - }); + const spansPromise = collectStreamedTrace(APP_NAME, span => span.name === 'GET /performance/with-middleware'); await page.goto(`/performance/with-middleware`); @@ -57,9 +55,7 @@ test.describe('server - instrumentation API middleware', () => { }); test('should have middleware span run before loader span', async ({ page }) => { - const spansPromise = collectStreamedSpans(APP_NAME, spans => { - return spans.some(span => span.name === 'GET /performance/with-middleware' && span.is_segment); - }); + const spansPromise = collectStreamedTrace(APP_NAME, span => span.name === 'GET /performance/with-middleware'); await page.goto(`/performance/with-middleware`); @@ -76,9 +72,7 @@ test.describe('server - instrumentation API middleware', () => { }); test('should track multiple middlewares with correct indices', async ({ page }) => { - const spansPromise = collectStreamedSpans(APP_NAME, spans => { - return spans.some(span => span.name === 'GET /performance/multi-middleware' && span.is_segment); - }); + const spansPromise = collectStreamedTrace(APP_NAME, span => span.name === 'GET /performance/multi-middleware'); await page.goto(`/performance/multi-middleware`); 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 77c7a0b309f5..32e72f99513a 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,7 +1,7 @@ import { expect, test } from '@playwright/test'; import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; import { - collectStreamedSpans, + collectStreamedTrace, getSpanOp, waitForStreamedSpan, waitForStreamedSpans, @@ -9,12 +9,13 @@ import { import { APP_NAME } from '../constants'; function collectUntilSegment(segmentName: string, childFunctionName?: string): Promise { - return collectStreamedSpans(APP_NAME, spans => { - return ( - spans.some(span => span.name === segmentName && span.is_segment) && - (!childFunctionName || spans.some(span => span.attributes['code.function.name']?.value === childFunctionName)) - ); - }); + return collectStreamedTrace( + APP_NAME, + span => span.name === segmentName, + spansOfTrace => + !childFunctionName || + spansOfTrace.some(span => span.attributes['code.function.name']?.value === childFunctionName), + ); } test.describe('server - instrumentation API performance', () => {