From 3ea1d9b2a80d8f1a6008843979985593e8a5974e Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 1 Sep 2026 16:03:48 +0200 Subject: [PATCH] test(e2e): Port React Router E2E test apps to span streaming Removes the `traceLifecycle: 'static'` pin from the 17 React Router E2E test applications and rewrites their specs against streamed span v2 payloads. Adds `react-router-7-framework-static` as a copy of `react-router-7-framework` that keeps the static trace lifecycle, so that path stays covered. Co-Authored-By: Claude Opus 5 --- .../react-create-browser-router/src/index.tsx | 1 - .../tests/spans.test.ts | 164 ++++ .../tests/transactions.test.ts | 218 ----- .../react-create-hash-router/src/index.tsx | 1 - .../tests/spans.test.ts | 342 +++++++ .../tests/transactions.test.ts | 502 ----------- .../react-create-memory-router/src/index.tsx | 1 - .../tests/spans.test.ts | 70 ++ .../tests/transactions.test.ts | 93 -- .../react-router-5/src/index.tsx | 1 - .../react-router-5/tests/errors.test.ts | 14 +- .../react-router-5/tests/spans.test.ts | 50 ++ .../react-router-5/tests/transactions.test.ts | 68 -- .../src/index.tsx | 1 - .../tests/spans.test.ts | 293 ++++++ .../tests/transactions.test.ts | 391 -------- .../react-router-6-use-routes/src/index.tsx | 1 - .../tests/errors.test.ts | 14 +- .../tests/spans.test.ts | 50 ++ .../tests/transactions.test.ts | 68 -- .../react-router-6/src/index.tsx | 1 - .../react-router-6/tests/errors.test.ts | 14 +- .../navigation-trace-propagation.test.ts | 18 +- .../{transactions.test.ts => spans.test.ts} | 78 +- .../react-router-6/tests/sse.test.ts | 116 ++- .../react-router-7-cross-usage/src/index.tsx | 1 - .../tests/spans.test.ts | 132 +++ .../tests/transactions.test.ts | 174 ---- .../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 ++--- .../app/entry.client.tsx | 1 - .../performance/navigation.client.test.ts | 78 +- .../tests/performance/pageload.client.test.ts | 133 +-- .../.gitignore | 32 + .../app/app.css | 6 + .../app/context.ts | 8 + .../app/entry.client.tsx | 24 + .../app/entry.server.tsx | 18 + .../app/root.tsx | 67 ++ .../app/routes.ts | 24 + .../app/routes/errors/client-action.tsx | 18 + .../app/routes/errors/client-loader.tsx | 16 + .../app/routes/errors/client-param.tsx | 17 + .../app/routes/errors/client.tsx | 15 + .../app/routes/errors/server-action.tsx | 18 + .../app/routes/errors/server-loader.tsx | 16 + .../app/routes/home.tsx | 9 + .../app/routes/performance/dynamic-param.tsx | 17 + .../app/routes/performance/index.tsx | 17 + .../app/routes/performance/redis.tsx | 22 + .../app/routes/performance/server-action.tsx | 24 + .../app/routes/performance/server-loader.tsx | 16 + .../app/routes/performance/ssr.tsx | 14 + .../app/routes/performance/static.tsx | 3 + .../routes/performance/with-middleware.tsx | 38 + .../app/routes/sentry-flush.tsx | 6 + .../docker-compose.yml | 12 + .../global-setup.mjs | 21 + .../instrument.mjs | 9 + .../package.json | 59 ++ .../playwright.config.mjs | 13 + .../public/favicon.ico | Bin 0 -> 15086 bytes .../react-router.config.ts | 9 + .../start-event-proxy.mjs | 6 + .../tests/constants.ts | 1 + .../tests/errors/errors.client.test.ts | 140 +++ .../tests/errors/errors.server.test.ts | 100 +++ .../low-quality-filter.server.test.ts | 34 + .../performance/middleware.server.test.ts | 38 + .../performance/navigation.client.test.ts | 243 +++++ .../tests/performance/pageload.client.test.ts | 148 ++++ .../performance/performance.server.test.ts | 107 +++ .../tests/performance/redis.server.test.ts | 35 + .../performance/trace-propagation.test.ts | 47 + .../tsconfig.json | 20 + .../vite.config.ts | 6 + .../app/entry.client.tsx | 1 - .../react-router-7-framework/instrument.mjs | 1 - .../low-quality-filter.server.test.ts | 30 +- .../performance/middleware.server.test.ts | 41 +- .../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 | 24 +- .../performance/trace-propagation.test.ts | 24 +- .../react-router-7-lazy-routes/src/index.tsx | 1 - .../{transactions.test.ts => spans.test.ts} | 832 ++++++++---------- .../tests/timeout-behaviour.test.ts | 94 +- .../react-router-7-spa/src/main.tsx | 1 - .../react-router-7-spa/tests/errors.test.ts | 14 +- .../navigation-trace-propagation.test.ts | 18 +- .../{transactions.test.ts => spans.test.ts} | 78 +- .../app/entry.client.tsx | 1 - .../tests/db.test.ts | 51 +- .../react-router-8-cloudflare/workers/app.ts | 1 - .../react-router-8-cross-usage/src/index.tsx | 1 - .../tests/spans.test.ts | 132 +++ .../tests/transactions.test.ts | 174 ---- .../app/entry.client.tsx | 1 - .../react-router-8-framework/instrument.mjs | 1 - .../low-quality-filter.server.test.ts | 30 +- .../performance/middleware.server.test.ts | 41 +- .../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 | 24 +- .../performance/trace-propagation.test.ts | 24 +- .../react-router-8-spa/src/main.tsx | 1 - .../react-router-8-spa/tests/errors.test.ts | 14 +- .../navigation-trace-propagation.test.ts | 18 +- .../{transactions.test.ts => spans.test.ts} | 78 +- dev-packages/test-utils/src/index.ts | 1 + 120 files changed, 4422 insertions(+), 4198 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/spans.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/spans.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/spans.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-5/tests/spans.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/react-router-5/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/spans.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/spans.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/transactions.test.ts rename dev-packages/e2e-tests/test-applications/react-router-6/tests/{transactions.test.ts => spans.test.ts} (57%) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/spans.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/app.css create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/context.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/entry.client.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/entry.server.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/root.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-action.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-loader.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-param.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-action.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-loader.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/home.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/dynamic-param.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/redis.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/server-action.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/server-loader.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/ssr.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/static.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/with-middleware.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/sentry-flush.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/docker-compose.yml create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/global-setup.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/instrument.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/package.json create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/public/favicon.ico create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/react-router.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/constants.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.client.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/low-quality-filter.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/middleware.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/navigation.client.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/pageload.client.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/performance.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/redis.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/vite.config.ts rename dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/{transactions.test.ts => spans.test.ts} (54%) rename dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/{transactions.test.ts => spans.test.ts} (57%) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/tests/spans.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/tests/transactions.test.ts rename dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/{transactions.test.ts => spans.test.ts} (57%) diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/index.tsx index bec2f5b24481..c7ad16eebcf7 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/index.tsx @@ -15,7 +15,6 @@ import User from './pages/User'; const replay = Sentry.replayIntegration(); Sentry.init({ - traceLifecycle: 'static', // environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/spans.test.ts new file mode 100644 index 000000000000..ae813861ff87 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/spans.test.ts @@ -0,0 +1,164 @@ +import { expect, test } from '@playwright/test'; +import { + collectStreamedSpans, + getSpanOp, + waitForStreamedSpan, + waitForStreamedSpans, +} from '@sentry-internal/test-utils'; + +const PREVIOUS_TRACE_LINK = [ + { + attributes: { + 'sentry.link.type': { value: 'previous_trace', type: 'string' }, + }, + sampled: true, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, +]; + +test('Captures a pageload span', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-browser-router', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/'); + + const span = await spanPromise; + + expect(span.name).toBe('/'); + expect(span.status).toBe('ok'); + expect(span.span_id).toMatch(/[a-f0-9]{16}/); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + + expect(span.attributes).toMatchObject({ + 'device.memory.estimated_capacity': { value: expect.any(Number), type: expect.any(String) }, + 'device.processor_count': { value: expect.any(Number), type: 'integer' }, + 'network.connection.effective_type': { value: expect.any(String), type: 'string' }, + 'sentry.idle_span_finish_reason': { value: 'idleTimeout', type: 'string' }, + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); +}); + +test('Captures a navigation span', async ({ page }) => { + const spansPromise = waitForStreamedSpans('react-create-browser-router', spans => { + return spans.some(span => getSpanOp(span) === 'navigation' && span.is_segment); + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation'); + await linkElement.click(); + + const spans = await spansPromise; + const navigationSpan = spans.find(span => span.is_segment)!; + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.status).toBe('ok'); + expect(navigationSpan.span_id).toMatch(/[a-f0-9]{16}/); + expect(navigationSpan.trace_id).toMatch(/[a-f0-9]{32}/); + + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.idle_span_finish_reason': { value: 'idleTimeout', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, + }); + + expect(navigationSpan.links).toEqual(PREVIOUS_TRACE_LINK); + + // Filter out favicon spans which may or may not be present depending on the browser version + const childSpans = spans.filter( + span => !span.is_segment && !(span.attributes['url.full']?.value as string | undefined)?.includes('favicon'), + ); + expect(childSpans).toEqual([]); +}); + +test('Captures a lazy pageload span', async ({ page }) => { + const spansPromise = collectStreamedSpans('react-create-browser-router', spans => { + return spans.some(span => getSpanOp(span) === 'pageload' && span.is_segment); + }); + + await page.goto('/lazy-loaded-user/5/foo'); + + const spans = await spansPromise; + const pageloadSpan = spans.find(span => getSpanOp(span) === 'pageload' && span.is_segment)!; + + expect(pageloadSpan.name).toBe('/lazy-loaded-user/:id/:innerId'); + expect(pageloadSpan.status).toBe('ok'); + + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.idle_span_finish_reason': { value: 'idleTimeout', type: 'string' }, + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/lazy-loaded-user/:id/:innerId', type: 'string' }, + 'url.path': { value: '/lazy-loaded-user/5/foo', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/lazy-loaded-user\/5\/foo$/), + type: 'string', + }, + }); + + expect(await page.innerText('id=content')).toContain('I am a lazy loaded user'); + + // One span for the outer lazy route, one for the inner one + const resourceSpans = spans.filter( + span => + getSpanOp(span) === 'resource.script' && + span.attributes['sentry.origin']?.value === 'auto.resource.browser.metrics', + ); + expect(resourceSpans.length).toBeGreaterThanOrEqual(2); +}); + +test('Captures a lazy navigation span', async ({ page }) => { + const spansPromise = collectStreamedSpans('react-create-browser-router', spans => { + return spans.some(span => getSpanOp(span) === 'navigation' && span.is_segment); + }); + + await page.goto('/'); + const linkElement = page.locator('id=lazy-navigation'); + await linkElement.click(); + + const spans = await spansPromise; + const navigationSpan = spans.find(span => getSpanOp(span) === 'navigation' && span.is_segment)!; + + expect(navigationSpan.name).toBe('/lazy-loaded-user/:id/:innerId'); + expect(navigationSpan.status).toBe('ok'); + + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.idle_span_finish_reason': { value: 'idleTimeout', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/lazy-loaded-user/:id/:innerId', type: 'string' }, + 'url.path': { value: '/lazy-loaded-user/5/foo', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/lazy-loaded-user\/5\/foo$/), + type: 'string', + }, + }); + + expect(navigationSpan.links).toEqual(PREVIOUS_TRACE_LINK); + + expect(await page.innerText('id=content')).toContain('I am a lazy loaded user'); + + // One span for the outer lazy route, one for the inner one + const resourceSpans = spans.filter( + span => + getSpanOp(span) === 'resource.script' && + span.attributes['sentry.origin']?.value === 'auto.resource.browser.metrics', + ); + expect(resourceSpans.length).toBeGreaterThanOrEqual(2); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts deleted file mode 100644 index 79737d920682..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('Captures a pageload transaction', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-browser-router', event => { - return event.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/'); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: '/', - type: 'transaction', - transaction_info: { - source: 'route', - }, - }), - ); - - expect(transactionEvent.contexts?.trace).toEqual( - expect.objectContaining({ - data: expect.objectContaining({ - deviceMemory: expect.any(String), - effectiveConnectionType: expect.any(String), - hardwareConcurrency: expect.any(String), - 'sentry.idle_span_finish_reason': 'idleTimeout', - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.react.reactrouter_v6', - 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }), - op: 'pageload', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.pageload.react.reactrouter_v6', - status: 'ok', - }), - ); -}); - -test('Captures a navigation transaction', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-browser-router', event => { - return event.contexts?.trace?.op === 'navigation'; - }); - - await page.goto('/'); - const linkElement = page.locator('id=navigation'); - await linkElement.click(); - - const transactionEvent = await transactionEventPromise; - expect(transactionEvent.contexts?.trace).toEqual({ - data: expect.objectContaining({ - 'sentry.idle_span_finish_reason': 'idleTimeout', - 'sentry.op': 'navigation', - 'sentry.origin': 'auto.navigation.react.reactrouter_v6', - 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'route', - 'url.template': '/user/:id', - 'url.path': '/user/5', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), - }), - links: [ - { - attributes: { - 'sentry.link.type': 'previous_trace', - }, - sampled: true, - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - ], - op: 'navigation', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.navigation.react.reactrouter_v6', - status: 'ok', - }); - - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: '/user/:id', - type: 'transaction', - transaction_info: { - source: 'route', - }, - }), - ); - - // Filter out favicon spans which may or may not be present depending on the browser version - const spans = (transactionEvent.spans || []).filter(span => !span.description?.includes('favicon')); - expect(spans).toEqual([]); -}); - -test('Captures a lazy pageload transaction', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-browser-router', event => { - return event.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/lazy-loaded-user/5/foo'); - - const transactionEvent = await transactionEventPromise; - expect(transactionEvent.contexts?.trace).toEqual({ - data: expect.objectContaining({ - 'sentry.idle_span_finish_reason': 'idleTimeout', - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.react.reactrouter_v6', - 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'route', - 'url.template': '/lazy-loaded-user/:id/:innerId', - 'url.path': '/lazy-loaded-user/5/foo', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/lazy-loaded-user\/5\/foo$/), - }), - op: 'pageload', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.pageload.react.reactrouter_v6', - status: 'ok', - }); - - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: '/lazy-loaded-user/:id/:innerId', - type: 'transaction', - transaction_info: { - source: 'route', - }, - }), - ); - - expect(await page.innerText('id=content')).toContain('I am a lazy loaded user'); - - expect(transactionEvent.spans).toEqual( - expect.arrayContaining([ - // This one is the outer lazy route - expect.objectContaining({ - op: 'resource.script', - origin: 'auto.resource.browser.metrics', - }), - // This one is the inner lazy route - expect.objectContaining({ - op: 'resource.script', - origin: 'auto.resource.browser.metrics', - }), - ]), - ); -}); - -test('Captures a lazy navigation transaction', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-browser-router', event => { - return event.contexts?.trace?.op === 'navigation'; - }); - - await page.goto('/'); - const linkElement = page.locator('id=lazy-navigation'); - await linkElement.click(); - - const transactionEvent = await transactionEventPromise; - expect(transactionEvent.contexts?.trace).toEqual({ - data: expect.objectContaining({ - 'sentry.idle_span_finish_reason': 'idleTimeout', - 'sentry.op': 'navigation', - 'sentry.origin': 'auto.navigation.react.reactrouter_v6', - 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'route', - 'url.template': '/lazy-loaded-user/:id/:innerId', - 'url.path': '/lazy-loaded-user/5/foo', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/lazy-loaded-user\/5\/foo$/), - }), - links: [ - { - attributes: { - 'sentry.link.type': 'previous_trace', - }, - sampled: true, - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - ], - op: 'navigation', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.navigation.react.reactrouter_v6', - status: 'ok', - }); - - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: '/lazy-loaded-user/:id/:innerId', - type: 'transaction', - transaction_info: { - source: 'route', - }, - }), - ); - - expect(await page.innerText('id=content')).toContain('I am a lazy loaded user'); - - expect(transactionEvent.spans).toEqual( - expect.arrayContaining([ - // This one is the outer lazy route - expect.objectContaining({ - op: 'resource.script', - origin: 'auto.resource.browser.metrics', - }), - // This one is the inner lazy route - expect.objectContaining({ - op: 'resource.script', - origin: 'auto.resource.browser.metrics', - }), - ]), - ); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx index 277600d3b7c6..86de5f20378d 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx @@ -16,7 +16,6 @@ import Group from './pages/Group'; const replay = Sentry.replayIntegration(); Sentry.init({ - traceLifecycle: 'static', // environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/spans.test.ts new file mode 100644 index 000000000000..a2847c534480 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/spans.test.ts @@ -0,0 +1,342 @@ +import { expect, test } from '@playwright/test'; +import { + collectStreamedSpans, + getSpanOp, + waitForStreamedSpan, + waitForStreamedSpans, +} from '@sentry-internal/test-utils'; + +const BROWSER_TIMING_OPS = [ + 'browser.dom_content_loaded_event', + 'browser.connect', + 'browser.request', + 'browser.response', +]; + +test('Captures a pageload span', async ({ page }) => { + const spansPromise = collectStreamedSpans('react-create-hash-router', spans => { + return spans.some(span => getSpanOp(span) === 'pageload' && span.is_segment); + }); + + await page.goto('/'); + + const spans = await spansPromise; + const pageloadSpan = spans.find(span => getSpanOp(span) === 'pageload' && span.is_segment)!; + + expect(pageloadSpan.name).toBe('/'); + expect(pageloadSpan.status).toBe('ok'); + expect(pageloadSpan.span_id).toMatch(/[a-f0-9]{16}/); + expect(pageloadSpan.trace_id).toMatch(/[a-f0-9]{32}/); + + // LCP is streamed as its own `ui.webvital.lcp` span once the page is hidden, so it is no longer + // part of the pageload span's attributes. + expect(pageloadSpan.attributes).toEqual({ + 'device.memory.estimated_capacity': { value: expect.any(Number), type: expect.any(String) }, + 'device.processor_count': { value: expect.any(Number), type: 'integer' }, + 'network.connection.effective_type': { value: expect.any(String), type: 'string' }, + 'browser.performance.time_origin': { value: expect.any(Number), type: expect.any(String) }, + 'browser.performance.navigation.activation_start': { value: expect.any(Number), type: expect.any(String) }, + 'sentry.idle_span_finish_reason': { value: 'idleTimeout', type: 'string' }, + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + }); + + for (const op of BROWSER_TIMING_OPS) { + expect(spans).toContainEqual( + expect.objectContaining({ + name: page.url(), + is_segment: false, + status: 'ok', + parent_span_id: pageloadSpan.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: pageloadSpan.trace_id, + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + attributes: { + 'sentry.origin': { value: 'auto.ui.browser.metrics', type: 'string' }, + 'sentry.op': { value: op, type: 'string' }, + }, + }), + ); + } +}); + +test('Captures a navigation span', async ({ page }) => { + const spansPromise = waitForStreamedSpans('react-create-hash-router', spans => { + return spans.some(span => getSpanOp(span) === 'navigation' && span.is_segment); + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation'); + await linkElement.click(); + + const spans = await spansPromise; + const navigationSpan = spans.find(span => span.is_segment)!; + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.status).toBe('ok'); + + expect(navigationSpan.attributes).toMatchObject({ + 'device.memory.estimated_capacity': { value: expect.any(Number), type: expect.any(String) }, + 'device.processor_count': { value: expect.any(Number), type: 'integer' }, + 'network.connection.effective_type': { value: expect.any(String), type: 'string' }, + 'sentry.idle_span_finish_reason': { value: 'idleTimeout', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^http:\/\/localhost:3030\/#\/user\/5$/), type: 'string' }, + }); + + expect(navigationSpan.links).toEqual([ + { + attributes: { + 'sentry.link.type': { value: 'previous_trace', type: 'string' }, + }, + sampled: true, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + ]); + + // Filter out favicon spans which may or may not be present depending on the browser version + const childSpans = spans.filter( + span => !span.is_segment && !(span.attributes['url.full']?.value as string | undefined)?.includes('favicon'), + ); + expect(childSpans).toEqual([]); +}); + +test('Captures a parameterized path pageload span', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/#/v2/post/1'); + + const span = await spanPromise; + + expect(span.name).toBe('/v2/post/:post'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/v2/post/:post', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/v2/post/1', type: 'string' }, + }); +}); + +test('Captures a parameterized path pageload span for nested route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/#/v2/post/1/featured'); + + const span = await spanPromise; + + expect(span.name).toBe('/v2/post/:post/featured'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/v2/post/:post/featured', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/v2/post/1/featured', type: 'string' }, + }); +}); + +test('Captures a parameterized path pageload span for deeply nested route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/#/v1/post/1/edit'); + + const span = await spanPromise; + + expect(span.name).toBe('/v1/post/:post/edit'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/v1/post/:post/edit', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/v1/post/1/edit', type: 'string' }, + }); +}); + +test('Captures a parameterized path pageload span for nested route with absolute path', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/#/v2/post/1/related'); + + const span = await spanPromise; + + expect(span.name).toBe('/v2/post/:post/related'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/v2/post/:post/related', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/v2/post/1/related', type: 'string' }, + }); +}); + +test('Captures a parameterized path navigation span', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-post-1'); + await linkElement.click(); + + const span = await spanPromise; + + expect(span.name).toBe('/v2/post/:post'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/v2/post/:post', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/v2/post/1', type: 'string' }, + }); +}); + +test('Captures a parameterized path navigation span for nested route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-post-1-featured'); + await linkElement.click(); + + const span = await spanPromise; + + expect(span.name).toBe('/v2/post/:post/featured'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/v2/post/:post/featured', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/v2/post/1/featured', type: 'string' }, + }); +}); + +test('Captures a parameterized path navigation span for deeply nested route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-post-1-edit'); + await linkElement.click(); + + const span = await spanPromise; + + expect(span.name).toBe('/v1/post/:post/edit'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/v1/post/:post/edit', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/v1/post/1/edit', type: 'string' }, + }); +}); + +test('Captures a parameterized path navigation span for nested route with absolute path', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-post-1-related'); + await linkElement.click(); + + const span = await spanPromise; + + expect(span.name).toBe('/v2/post/:post/related'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/v2/post/:post/related', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/v2/post/1/related', type: 'string' }, + }); +}); + +test('Captures a parameterized path pageload span for group route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/#/group/1'); + + const span = await spanPromise; + + expect(span.name).toBe('/group/:group/:user?'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/group/:group/:user?', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/group/1', type: 'string' }, + }); +}); + +test('Captures a parameterized path navigation span for group route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-group-1'); + await linkElement.click(); + + const span = await spanPromise; + + expect(span.name).toBe('/group/:group/:user?'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/group/:group/:user?', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/group/1', type: 'string' }, + }); +}); + +test('Captures a parameterized path pageload span for nested group route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/#/group/1/5'); + + const span = await spanPromise; + + expect(span.name).toBe('/group/:group/:user?'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/group/:group/:user?', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/group/1/5', type: 'string' }, + }); +}); + +test('Captures a parameterized path navigation span for nested group route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-group-1-user-5'); + await linkElement.click(); + + const span = await spanPromise; + + expect(span.name).toBe('/group/:group/:user?'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/group/:group/:user?', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/group/1/5', type: 'string' }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts deleted file mode 100644 index 5479f1935c83..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts +++ /dev/null @@ -1,502 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('Captures a pageload transaction', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/'); - - const transactionEvent = await transactionEventPromise; - expect(transactionEvent.contexts?.trace).toEqual({ - data: { - deviceMemory: expect.any(String), - effectiveConnectionType: expect.any(String), - hardwareConcurrency: expect.any(String), - 'lcp.element': expect.any(String), - 'lcp.id': expect.any(String), - 'lcp.size': expect.any(Number), - 'sentry.idle_span_finish_reason': 'idleTimeout', - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.react.reactrouter_v6', - 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'route', - 'performance.timeOrigin': expect.any(Number), - 'performance.activationStart': expect.any(Number), - 'lcp.renderTime': expect.any(Number), - 'lcp.loadTime': expect.any(Number), - 'url.template': '/', - 'url.full': 'http://localhost:3030/', - 'url.path': '/', - }, - op: 'pageload', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.pageload.react.reactrouter_v6', - status: 'ok', - }); - - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: '/', - type: 'transaction', - transaction_info: { - source: 'route', - }, - }), - ); - - expect(transactionEvent.spans).toContainEqual({ - data: { - 'sentry.origin': 'auto.ui.browser.metrics', - 'sentry.op': 'browser.dom_content_loaded_event', - }, - description: page.url(), - op: 'browser.dom_content_loaded_event', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.ui.browser.metrics', - status: 'ok', - }); - expect(transactionEvent.spans).toContainEqual({ - data: { - 'sentry.origin': 'auto.ui.browser.metrics', - 'sentry.op': 'browser.connect', - }, - description: page.url(), - op: 'browser.connect', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.ui.browser.metrics', - status: 'ok', - }); - expect(transactionEvent.spans).toContainEqual({ - data: { - 'sentry.origin': 'auto.ui.browser.metrics', - 'sentry.op': 'browser.request', - }, - description: page.url(), - op: 'browser.request', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.ui.browser.metrics', - status: 'ok', - }); - expect(transactionEvent.spans).toContainEqual({ - data: { - 'sentry.origin': 'auto.ui.browser.metrics', - 'sentry.op': 'browser.response', - }, - description: page.url(), - op: 'browser.response', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.ui.browser.metrics', - status: 'ok', - }); -}); - -test('Captures a navigation transaction', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'navigation'; - }); - - await page.goto('/'); - const linkElement = page.locator('id=navigation'); - await linkElement.click(); - - const transactionEvent = await transactionEventPromise; - expect(transactionEvent.contexts?.trace).toEqual({ - data: expect.objectContaining({ - deviceMemory: expect.any(String), - effectiveConnectionType: expect.any(String), - hardwareConcurrency: expect.any(String), - 'sentry.idle_span_finish_reason': 'idleTimeout', - 'sentry.op': 'navigation', - 'sentry.origin': 'auto.navigation.react.reactrouter_v6', - 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'route', - 'url.template': '/user/:id', - 'url.path': '/', - 'url.full': expect.stringMatching(/^http:\/\/localhost:3030\/#\/user\/5$/), - }), - links: [ - { - attributes: { - 'sentry.link.type': 'previous_trace', - }, - sampled: true, - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - ], - op: 'navigation', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.navigation.react.reactrouter_v6', - status: 'ok', - }); - - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: '/user/:id', - type: 'transaction', - transaction_info: { - source: 'route', - }, - }), - ); - - // Filter out favicon spans which may or may not be present depending on the browser version - const spans = (transactionEvent.spans || []).filter(span => !span.description?.includes('favicon')); - expect(spans).toEqual([]); -}); - -test('Captures a parameterized path pageload transaction', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/#/v2/post/1'); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/v2/post/:post', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/v2/post/:post', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/v2/post/1', - }, - }, - }, - }); -}); - -test('Captures a parameterized path pageload transaction for nested route', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/#/v2/post/1/featured'); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/v2/post/:post/featured', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/v2/post/:post/featured', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/v2/post/1/featured', - }, - }, - }, - }); -}); - -test('Captures a parameterized path pageload transaction for deeply nested route', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/#/v1/post/1/edit'); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/v1/post/:post/edit', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/v1/post/:post/edit', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/v1/post/1/edit', - }, - }, - }, - }); -}); - -test('Captures a parameterized path pageload transaction for nested route with absolute path', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/#/v2/post/1/related'); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/v2/post/:post/related', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/v2/post/:post/related', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/v2/post/1/related', - }, - }, - }, - }); -}); - -test('Captures a parameterized path navigation transaction', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'navigation'; - }); - - await page.goto('/'); - const linkElement = page.locator('id=navigation-post-1'); - await linkElement.click(); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/v2/post/:post', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/v2/post/:post', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/v2/post/1', - }, - }, - }, - }); -}); - -test('Captures a parameterized path navigation transaction for nested route', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'navigation'; - }); - - await page.goto('/'); - const linkElement = page.locator('id=navigation-post-1-featured'); - await linkElement.click(); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/v2/post/:post/featured', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/v2/post/:post/featured', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/v2/post/1/featured', - }, - }, - }, - }); -}); - -test('Captures a parameterized path navigation transaction for deeply nested route', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'navigation'; - }); - - await page.goto('/'); - const linkElement = page.locator('id=navigation-post-1-edit'); - await linkElement.click(); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/v1/post/:post/edit', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/v1/post/:post/edit', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/v1/post/1/edit', - }, - }, - }, - }); -}); - -test('Captures a parameterized path navigation transaction for nested route with absolute path', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'navigation'; - }); - - await page.goto('/'); - const linkElement = page.locator('id=navigation-post-1-related'); - await linkElement.click(); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/v2/post/:post/related', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/v2/post/:post/related', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/v2/post/1/related', - }, - }, - }, - }); -}); - -test('Captures a parameterized path pageload transaction for group route', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/#/group/1'); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/group/:group/:user?', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/group/:group/:user?', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/group/1', - }, - }, - }, - }); -}); - -test('Captures a parameterized path navigation transaction for group route', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'navigation'; - }); - - await page.goto('/'); - const linkElement = page.locator('id=navigation-group-1'); - await linkElement.click(); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/group/:group/:user?', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/group/:group/:user?', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/group/1', - }, - }, - }, - }); -}); - -test('Captures a parameterized path pageload transaction for nested group route', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/#/group/1/5'); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/group/:group/:user?', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/group/:group/:user?', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/group/1/5', - }, - }, - }, - }); -}); - -test('Captures a parameterized path navigation transaction for nested group route', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'navigation'; - }); - - await page.goto('/'); - const linkElement = page.locator('id=navigation-group-1-user-5'); - await linkElement.click(); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/group/:group/:user?', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/group/:group/:user?', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/group/1/5', - }, - }, - }, - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/index.tsx index 8cd283650abd..f71572f9dc1f 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/index.tsx @@ -15,7 +15,6 @@ import User from './pages/User'; const replay = Sentry.replayIntegration(); Sentry.init({ - traceLifecycle: 'static', // environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/spans.test.ts new file mode 100644 index 000000000000..16aef9df1e4e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/spans.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; + +test('Captures a pageload span', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-memory-router', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/'); + + const span = await spanPromise; + + expect(span.name).toBe('/user/:id'); + expect(span.status).toBe('ok'); + expect(span.span_id).toMatch(/[a-f0-9]{16}/); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + + expect(span.attributes).toMatchObject({ + 'sentry.idle_span_finish_reason': { value: 'idleTimeout', type: 'string' }, + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); +}); + +test('Captures a navigation span', async ({ page }) => { + const spansPromise = waitForStreamedSpans('react-create-memory-router', spans => { + return spans.some(span => getSpanOp(span) === 'navigation' && span.is_segment); + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-button'); + await linkElement.click(); + + const spans = await spansPromise; + const span = spans.find(span => span.is_segment)!; + + expect(span.name).toBe('/user/:id'); + expect(span.status).toBe('ok'); + expect(span.span_id).toMatch(/[a-f0-9]{16}/); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + + expect(span.attributes).toMatchObject({ + 'sentry.idle_span_finish_reason': { value: 'idleTimeout', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); + + expect(span.links).toEqual([ + { + attributes: { + 'sentry.link.type': { value: 'previous_trace', type: 'string' }, + }, + sampled: true, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + ]); + + expect(spans.filter(span => !span.is_segment)).toEqual([]); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/transactions.test.ts deleted file mode 100644 index 32e2d36cae8c..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/transactions.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('Captures a pageload transaction', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-memory-router', event => { - return event.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/'); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: '/user/:id', - type: 'transaction', - transaction_info: { - source: 'route', - }, - }), - ); - - expect(transactionEvent.contexts?.trace).toEqual( - expect.objectContaining({ - data: expect.objectContaining({ - 'sentry.idle_span_finish_reason': 'idleTimeout', - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.react.reactrouter_v6', - 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'route', - 'url.template': '/user/:id', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }), - op: 'pageload', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.pageload.react.reactrouter_v6', - status: 'ok', - }), - ); -}); - -test('Captures a navigation transaction', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-memory-router', event => { - return event.contexts?.trace?.op === 'navigation'; - }); - - await page.goto('/'); - const linkElement = page.locator('id=navigation-button'); - await linkElement.click(); - - const transactionEvent = await transactionEventPromise; - expect(transactionEvent.contexts?.trace).toEqual({ - data: expect.objectContaining({ - 'sentry.idle_span_finish_reason': 'idleTimeout', - 'sentry.op': 'navigation', - 'sentry.origin': 'auto.navigation.react.reactrouter_v6', - 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'route', - 'url.template': '/user/:id', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }), - links: [ - { - attributes: { - 'sentry.link.type': 'previous_trace', - }, - sampled: true, - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - ], - op: 'navigation', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.navigation.react.reactrouter_v6', - status: 'ok', - }); - - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: '/user/:id', - type: 'transaction', - transaction_info: { - source: 'route', - }, - }), - ); - - expect(transactionEvent.spans).toEqual([]); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-5/src/index.tsx index f4fb98246390..cc9b307e620a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-5/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-5/src/index.tsx @@ -11,7 +11,6 @@ const replay = Sentry.replayIntegration(); const history = createBrowserHistory(); Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN || diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-5/tests/errors.test.ts index 96b4a042c08b..17ca866ea821 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-5/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-5/tests/errors.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Sends correct error event', async ({ page }) => { const errorEventPromise = waitForError('react-router-5', event => { @@ -30,8 +30,8 @@ test('Sends correct error event', async ({ page }) => { }); test('Sets correct transactionName', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-5', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-5', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); const errorEventPromise = waitForError('react-router-5', event => { @@ -39,9 +39,9 @@ test('Sets correct transactionName', async ({ page }) => { }); await page.goto('/'); - const transactionEvent = await transactionPromise; + const pageloadSpan = await pageloadSpanPromise; - // Only capture error once transaction was sent + // Only capture error once the pageload span was sent const exceptionButton = page.locator('id=exception-button'); await exceptionButton.click(); @@ -53,7 +53,7 @@ test('Sets correct transactionName', async ({ page }) => { expect(errorEvent.transaction).toEqual('/'); expect(errorEvent.contexts?.trace).toEqual({ - trace_id: transactionEvent.contexts?.trace?.trace_id, - span_id: expect.not.stringContaining(transactionEvent.contexts?.trace?.span_id || ''), + trace_id: pageloadSpan.trace_id, + span_id: expect.not.stringContaining(pageloadSpan.span_id), }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-5/tests/spans.test.ts new file mode 100644 index 000000000000..d9ee28ee6689 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-5/tests/spans.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('sends a pageload span with a parameterized URL', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-5', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/`); + + const span = await spanPromise; + + expect(span.name).toBe('/'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v5', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); +}); + +test('sends a navigation span with a parameterized URL', async ({ page }) => { + page.on('console', msg => console.log(msg.text())); + const pageloadSpanPromise = waitForStreamedSpan('react-router-5', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-5', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + await pageloadSpanPromise; + + const linkElement = page.locator('id=navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v5', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-5/tests/transactions.test.ts deleted file mode 100644 index 7e053e5c9fb2..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-5/tests/transactions.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('sends a pageload transaction with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-5', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/`); - - const rootSpan = await transactionPromise; - - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v5', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, - }); -}); - -test('sends a navigation transaction with a parameterized URL', async ({ page }) => { - page.on('console', msg => console.log(msg.text())); - const pageloadTxnPromise = waitForTransaction('react-router-5', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-5', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - await pageloadTxnPromise; - - const linkElement = page.locator('id=navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v5', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/user/:id', - 'url.path': '/user/5', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), - }, - }, - }, - transaction: '/user/:id', - transaction_info: { - source: 'route', - }, - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx index aca20a716203..58fadb60f61c 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx @@ -16,7 +16,6 @@ import Index from './pages/Index'; const replay = Sentry.replayIntegration(); Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/spans.test.ts new file mode 100644 index 000000000000..fcd4a06766b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/spans.test.ts @@ -0,0 +1,293 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('sends a pageload span with a parameterized URL', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/projects/123/views/234/567`); + + const span = await spanPromise; + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(span.name).toBe('/projects/:projectId/views/:viewId/:detailId'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/projects/:projectId/views/:viewId/:detailId', type: 'string' }, + 'url.path': { value: '/projects/123/views/234/567', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/views\/234\/567$/), + type: 'string', + }, + }); +}); + +test('sends a pageload span with a parameterized URL - alternative route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/projects/234/old-views/234/567`); + + const span = await spanPromise; + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(span.name).toBe('/projects/:projectId/old-views/:viewId/:detailId'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/projects/:projectId/old-views/:viewId/:detailId', type: 'string' }, + 'url.path': { value: '/projects/234/old-views/234/567', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/234\/old-views\/234\/567$/), + type: 'string', + }, + }); +}); + +test('keeps the parent path prefix for a descendant route with non-wildcard nested children - pageload', async ({ + page, +}) => { + const spanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/child/abc123`); + + const span = await spanPromise; + + expect((await page.innerHTML('#root')).includes('Child')).toBe(true); + expect(span.name).toBe('/child/:id'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/child/:id', type: 'string' }, + 'url.path': { value: '/child/abc123', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/child\/abc123$/), type: 'string' }, + }); +}); + +test('sends a navigation span with a parameterized URL', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + const pageloadSpan = await pageloadSpanPromise; + + expect(pageloadSpan.name).toBe('/'); + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); + + const linkElement = page.locator('id=navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(navigationSpan.name).toBe('/projects/:projectId/views/:viewId/:detailId'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/projects/:projectId/views/:viewId/:detailId', type: 'string' }, + 'url.path': { value: '/projects/123/views/456/789', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/views\/456\/789$/), + type: 'string', + }, + }); +}); + +test('sends a navigation span with a parameterized URL - alternative route', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + const pageloadSpan = await pageloadSpanPromise; + + expect(pageloadSpan.name).toBe('/'); + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); + + const linkElement = page.locator('id=old-navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(navigationSpan.name).toBe('/projects/:projectId/old-views/:viewId/:detailId'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/projects/:projectId/old-views/:viewId/:detailId', type: 'string' }, + 'url.path': { value: '/projects/123/old-views/345/654', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/old-views\/345\/654$/), + type: 'string', + }, + }); +}); + +test('keeps the parent path prefix for a descendant route with non-wildcard nested children - navigation', async ({ + page, +}) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + const pageloadSpan = await pageloadSpanPromise; + + expect(pageloadSpan.name).toBe('/'); + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); + + const linkElement = page.locator('id=child-navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect((await page.innerHTML('#root')).includes('Child')).toBe(true); + expect(navigationSpan.name).toBe('/child/:id'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/child/:id', type: 'string' }, + 'url.path': { value: '/child/abc123', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/child\/abc123$/), type: 'string' }, + }); +}); + +test('resolves deep wildcard chain with three levels of nesting - pageload', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/workspace/team/u123`); + + const span = await spanPromise; + + expect((await page.innerHTML('#root')).includes('Deep Member')).toBe(true); + expect(span.name).toBe('/workspace/:teamId/:memberId'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/workspace/:teamId/:memberId', type: 'string' }, + 'url.path': { value: '/workspace/team/u123', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/workspace\/team\/u123$/), type: 'string' }, + }); +}); + +test('does not mix param names across independent descendant routers', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const fooNavigationSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.attributes['url.path']?.value === '/foo/123'; + }); + + const barNavigationSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.attributes['url.path']?.value === '/bar/456'; + }); + + await page.goto(`/`); + await pageloadSpanPromise; + + // Mount the first descendant router (`foo/*` -> `:fooId`), which populates the shared `allRoutes` set. + const [, fooNavigationSpan] = await Promise.all([ + page.locator('id=foo-navigation').click(), + fooNavigationSpanPromise, + ]); + + expect((await page.innerHTML('#root')).includes('Foo')).toBe(true); + expect(fooNavigationSpan.name).toBe('/foo/:fooId'); + expect(fooNavigationSpan.attributes['sentry.segment.name.source']?.value).toBe('route'); + + // Return to the index so we can navigate into the second, unrelated descendant router client-side. + // A fresh page load would reset the module-level `allRoutes` and hide the bug. + await page.goBack(); + await page.locator('id=bar-navigation').waitFor(); + + // Now mount the second descendant router (`bar/*` -> `:barId`). With the accumulation bug, the name + // comes out as the hybrid `/bar/:fooId`. + const [, barNavigationSpan] = await Promise.all([ + page.locator('id=bar-navigation').click(), + barNavigationSpanPromise, + ]); + + expect((await page.innerHTML('#root')).includes('Bar')).toBe(true); + expect(barNavigationSpan.name).toBe('/bar/:barId'); + expect(barNavigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/bar/:barId', type: 'string' }, + 'url.path': { value: '/bar/456', type: 'string' }, + }); +}); + +test('resolves deep wildcard chain with three levels of nesting - navigation', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + await pageloadSpanPromise; + + const linkElement = page.locator('id=deep-member-navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect((await page.innerHTML('#root')).includes('Deep Member')).toBe(true); + expect(navigationSpan.name).toBe('/workspace/:teamId/:memberId'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/workspace/:teamId/:memberId', type: 'string' }, + 'url.path': { value: '/workspace/team/u123', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/workspace\/team\/u123$/), type: 'string' }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts deleted file mode 100644 index 61ee41d33301..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('sends a pageload transaction with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/projects/123/views/234/567`); - - const rootSpan = await transactionPromise; - - expect((await page.innerHTML('#root')).includes('Details')).toBe(true); - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/projects/:projectId/views/:viewId/:detailId', - 'url.path': '/projects/123/views/234/567', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/views\/234\/567$/), - }, - }, - }, - transaction: '/projects/:projectId/views/:viewId/:detailId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('sends a pageload transaction with a parameterized URL - alternative route', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/projects/234/old-views/234/567`); - - const rootSpan = await transactionPromise; - - expect((await page.innerHTML('#root')).includes('Details')).toBe(true); - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/projects/:projectId/old-views/:viewId/:detailId', - 'url.path': '/projects/234/old-views/234/567', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/234\/old-views\/234\/567$/), - }, - }, - }, - transaction: '/projects/:projectId/old-views/:viewId/:detailId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('keeps the parent path prefix for a descendant route with non-wildcard nested children - pageload', async ({ - page, -}) => { - const transactionPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/child/abc123`); - - const rootSpan = await transactionPromise; - - expect((await page.innerHTML('#root')).includes('Child')).toBe(true); - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/child/:id', - 'url.path': '/child/abc123', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/child\/abc123$/), - }, - }, - }, - transaction: '/child/:id', - transaction_info: { - source: 'route', - }, - }); -}); - -test('sends a navigation transaction with a parameterized URL', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - const pageloadTxn = await pageloadTxnPromise; - - expect(pageloadTxn).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, - }); - - const linkElement = page.locator('id=navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Details')).toBe(true); - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/projects/:projectId/views/:viewId/:detailId', - 'url.path': '/projects/123/views/456/789', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/views\/456\/789$/), - }, - }, - }, - transaction: '/projects/:projectId/views/:viewId/:detailId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('sends a navigation transaction with a parameterized URL - alternative route', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - const pageloadTxn = await pageloadTxnPromise; - - expect(pageloadTxn).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, - }); - - const linkElement = page.locator('id=old-navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Details')).toBe(true); - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/projects/:projectId/old-views/:viewId/:detailId', - 'url.path': '/projects/123/old-views/345/654', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/old-views\/345\/654$/), - }, - }, - }, - transaction: '/projects/:projectId/old-views/:viewId/:detailId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('keeps the parent path prefix for a descendant route with non-wildcard nested children - navigation', async ({ - page, -}) => { - const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - const pageloadTxn = await pageloadTxnPromise; - - expect(pageloadTxn).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, - }); - - const linkElement = page.locator('id=child-navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Child')).toBe(true); - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/child/:id', - 'url.path': '/child/abc123', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/child\/abc123$/), - }, - }, - }, - transaction: '/child/:id', - transaction_info: { - source: 'route', - }, - }); -}); - -test('resolves deep wildcard chain with three levels of nesting - pageload', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/workspace/team/u123`); - - const rootSpan = await transactionPromise; - - expect((await page.innerHTML('#root')).includes('Deep Member')).toBe(true); - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/workspace/:teamId/:memberId', - 'url.path': '/workspace/team/u123', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/workspace\/team\/u123$/), - }, - }, - }, - transaction: '/workspace/:teamId/:memberId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('does not mix param names across independent descendant routers', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const fooNavigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.contexts?.trace?.data?.['url.path'] === '/foo/123' - ); - }); - - const barNavigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.contexts?.trace?.data?.['url.path'] === '/bar/456' - ); - }); - - await page.goto(`/`); - await pageloadTxnPromise; - - // Mount the first descendant router (`foo/*` -> `:fooId`), which populates the shared `allRoutes` set. - const [, fooNavigationTxn] = await Promise.all([page.locator('id=foo-navigation').click(), fooNavigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Foo')).toBe(true); - expect(fooNavigationTxn).toMatchObject({ - transaction: '/foo/:fooId', - transaction_info: { source: 'route' }, - }); - - // Return to the index so we can navigate into the second, unrelated descendant router client-side. - // A fresh page load would reset the module-level `allRoutes` and hide the bug. - await page.goBack(); - await page.locator('id=bar-navigation').waitFor(); - - // Now mount the second descendant router (`bar/*` -> `:barId`). With the accumulation bug, the name - // comes out as the hybrid `/bar/:fooId`. - const [, barNavigationTxn] = await Promise.all([page.locator('id=bar-navigation').click(), barNavigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Bar')).toBe(true); - expect(barNavigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/bar/:barId', - 'url.path': '/bar/456', - }, - }, - }, - transaction: '/bar/:barId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('resolves deep wildcard chain with three levels of nesting - navigation', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - await pageloadTxnPromise; - - const linkElement = page.locator('id=deep-member-navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Deep Member')).toBe(true); - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/workspace/:teamId/:memberId', - 'url.path': '/workspace/team/u123', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/workspace\/team\/u123$/), - }, - }, - }, - transaction: '/workspace/:teamId/:memberId', - transaction_info: { - source: 'route', - }, - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/src/index.tsx index a049e56eee74..0ac33b9f6c5f 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/src/index.tsx @@ -15,7 +15,6 @@ import User from './pages/User'; const replay = Sentry.replayIntegration(); Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/errors.test.ts index 272025d95877..707a6af5df7a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/errors.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Sends correct error event', async ({ page }) => { const errorEventPromise = waitForError('react-router-6-use-routes', event => { @@ -30,8 +30,8 @@ test('Sends correct error event', async ({ page }) => { }); test('Sets correct transactionName', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6-use-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-use-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); const errorEventPromise = waitForError('react-router-6-use-routes', event => { @@ -39,9 +39,9 @@ test('Sets correct transactionName', async ({ page }) => { }); await page.goto('/'); - const transactionEvent = await transactionPromise; + const pageloadSpan = await pageloadSpanPromise; - // Only capture error once transaction was sent + // Only capture error once the pageload span was sent const exceptionButton = page.locator('id=exception-button'); await exceptionButton.click(); @@ -53,7 +53,7 @@ test('Sets correct transactionName', async ({ page }) => { expect(errorEvent.transaction).toEqual('/'); expect(errorEvent.contexts?.trace).toEqual({ - trace_id: transactionEvent.contexts?.trace?.trace_id, - span_id: expect.not.stringContaining(transactionEvent.contexts?.trace?.span_id || ''), + trace_id: pageloadSpan.trace_id, + span_id: expect.not.stringContaining(pageloadSpan.span_id), }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/spans.test.ts new file mode 100644 index 000000000000..8fe66736d026 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/spans.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('sends a pageload span with a parameterized URL', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-6-use-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/`); + + const span = await spanPromise; + + expect(span.name).toBe('/'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); +}); + +test('sends a navigation span with a parameterized URL', async ({ page }) => { + page.on('console', msg => console.log(msg.text())); + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-use-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-6-use-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + await pageloadSpanPromise; + + const linkElement = page.locator('id=navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/transactions.test.ts deleted file mode 100644 index 4c5e65b25e4e..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/transactions.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('sends a pageload transaction with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6-use-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/`); - - const rootSpan = await transactionPromise; - - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, - }); -}); - -test('sends a navigation transaction with a parameterized URL', async ({ page }) => { - page.on('console', msg => console.log(msg.text())); - const pageloadTxnPromise = waitForTransaction('react-router-6-use-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-6-use-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - await pageloadTxnPromise; - - const linkElement = page.locator('id=navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/user/:id', - 'url.path': '/user/5', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), - }, - }, - }, - transaction: '/user/:id', - transaction_info: { - source: 'route', - }, - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6/src/index.tsx index b569aa9aedd1..58dc4614f650 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-6/src/index.tsx @@ -18,7 +18,6 @@ import User from './pages/User'; const replay = Sentry.replayIntegration(); Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6/tests/errors.test.ts index e34699420ccf..cbb90ddac056 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6/tests/errors.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Sends correct error event', async ({ page }) => { const errorEventPromise = waitForError('react-router-6', event => { @@ -30,8 +30,8 @@ test('Sends correct error event', async ({ page }) => { }); test('Sets correct transactionName', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-6', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); const errorEventPromise = waitForError('react-router-6', event => { @@ -39,9 +39,9 @@ test('Sets correct transactionName', async ({ page }) => { }); await page.goto('/'); - const transactionEvent = await transactionPromise; + const pageloadSpan = await pageloadSpanPromise; - // Only capture error once transaction was sent + // Only capture error once the pageload span was sent const exceptionButton = page.locator('id=exception-button'); await exceptionButton.click(); @@ -53,7 +53,7 @@ test('Sets correct transactionName', async ({ page }) => { expect(errorEvent.transaction).toEqual('/'); expect(errorEvent.contexts?.trace).toEqual({ - trace_id: transactionEvent.contexts?.trace?.trace_id, - span_id: expect.not.stringContaining(transactionEvent.contexts?.trace?.span_id || ''), + trace_id: pageloadSpan.trace_id, + span_id: expect.not.stringContaining(pageloadSpan.span_id), }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/tests/navigation-trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6/tests/navigation-trace-propagation.test.ts index 477b27fd40f5..53a6cbc2fd6d 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/tests/navigation-trace-propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6/tests/navigation-trace-propagation.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('propagates the navigation trace (not the stale pageload trace) for a fetch in a route mount effect', async ({ page, @@ -15,22 +15,22 @@ test('propagates the navigation trace (not the stale pageload trace) for a fetch }); }); - const pageloadTxnPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-6', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const navigationTxnPromise = waitForTransaction('react-router-6', async transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/products'; + const navigationSpanPromise = waitForStreamedSpan('react-router-6', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/products'; }); await page.goto('/'); - const pageloadTxn = await pageloadTxnPromise; + const pageloadSpan = await pageloadSpanPromise; await page.locator('id=navigation-products').click(); - const navigationTxn = await navigationTxnPromise; + const navigationSpan = await navigationSpanPromise; - const pageloadTraceId = pageloadTxn.contexts?.trace?.trace_id; - const navigationTraceId = navigationTxn.contexts?.trace?.trace_id; + const pageloadTraceId = pageloadSpan.trace_id; + const navigationTraceId = navigationSpan.trace_id; const propagatedTraceId = productsRequestSentryTrace?.split('-')[0]; expect(pageloadTraceId).toBeDefined(); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts similarity index 57% rename from dev-packages/e2e-tests/test-applications/react-router-6/tests/transactions.test.ts rename to dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts index 22f79766c400..d4303fc9ab72 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts @@ -1,69 +1,51 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpan, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('sends a pageload transaction with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; +test('sends a pageload span with a parameterized URL', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-6', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/`); - const rootSpan = await transactionPromise; - - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, + const span = await spanPromise; + + expect(span.name).toBe('/'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, }); }); -test('sends a navigation transaction with a parameterized URL', async ({ page }) => { +test('sends a navigation span with a parameterized URL', async ({ page }) => { page.on('console', msg => console.log(msg.text())); - const pageloadTxnPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-6', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const navigationTxnPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + const navigationSpanPromise = waitForStreamedSpan('react-router-6', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; }); await page.goto(`/`); - await pageloadTxnPromise; + await pageloadSpanPromise; const linkElement = page.locator('id=navigation'); - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/user/:id', - 'url.path': '/user/5', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), - }, - }, - }, - transaction: '/user/:id', - transaction_info: { - source: 'route', - }, + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/tests/sse.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6/tests/sse.test.ts index a73642b20b6f..59cc18ce1485 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/tests/sse.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6/tests/sse.test.ts @@ -1,107 +1,99 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; + +// Under span streaming the http.client name is only ` `, so the request URL has to +// come from the `url.full` attribute. +function findHttpClientSpan(spans: SerializedStreamedSpan[], op: string, urlFull: string): SerializedStreamedSpan { + return spans.find(span => getSpanOp(span) === op && span.attributes['url.full']?.value === urlFull)!; +} + +function durationInSeconds(span: SerializedStreamedSpan): number { + return Math.round(span.end_timestamp - span.start_timestamp); +} test('Waits for sse streaming when creating spans', async ({ page }) => { await page.goto('/sse'); - const transactionPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const spansPromise = collectStreamedSpans('react-router-6', spans => { + return spans.some(span => getSpanOp(span) === 'pageload' && span.is_segment); }); const fetchButton = page.locator('id=fetch-button'); await fetchButton.click(); - const rootSpan = await transactionPromise; - const sseFetchCall = rootSpan.spans?.filter(span => span.description === 'sse fetch call')[0]!; - const httpGet = rootSpan.spans?.filter(span => span.description === 'GET http://localhost:8080/sse')[0]!; - const httpStream = rootSpan.spans?.filter(span => span.op === 'http.client.stream')[0]!; + const spans = await spansPromise; + + const sseFetchCall = spans.find(span => span.name === 'sse fetch call')!; + const httpGet = findHttpClientSpan(spans, 'http.client', 'http://localhost:8080/sse'); + const httpStream = findHttpClientSpan(spans, 'http.client.stream', 'http://localhost:8080/sse'); expect(sseFetchCall).toBeDefined(); expect(httpGet).toBeDefined(); expect(httpStream).toBeDefined(); // http headers get sent instantly from the server - const resolveDuration = Math.round((sseFetchCall.timestamp as number) - sseFetchCall.start_timestamp); - - // http.client span ends at header arrival (~0s) - const httpGetDuration = Math.round((httpGet.timestamp as number) - httpGet.start_timestamp); - - // body streaming duration is captured in the sibling http.client.stream span (~2s) - const streamDuration = Math.round((httpStream.timestamp as number) - httpStream.start_timestamp); - - expect(resolveDuration).toBe(0); - expect(httpGetDuration).toBe(0); - expect(streamDuration).toBe(2); + // http.client span ends at header arrival (~0s), body streaming duration is captured in the + // sibling http.client.stream span (~2s) + expect(durationInSeconds(sseFetchCall)).toBe(0); + expect(durationInSeconds(httpGet)).toBe(0); + expect(durationInSeconds(httpStream)).toBe(2); }); test('Waits for sse streaming when sse has been explicitly aborted', async ({ page }) => { await page.goto('/sse'); - const transactionPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const consoleMessages: string[] = []; + page.on('console', msg => consoleMessages.push(msg.text())); + + const spansPromise = collectStreamedSpans('react-router-6', spans => { + return spans.some(span => getSpanOp(span) === 'pageload' && span.is_segment); }); const fetchButton = page.locator('id=fetch-sse-abort'); await fetchButton.click(); - const rootSpan = await transactionPromise; - const sseFetchCall = rootSpan.spans?.filter(span => span.description === 'sse fetch call')[0]!; - const httpGet = rootSpan.spans?.filter(span => span.description === 'GET http://localhost:8080/sse')[0]!; + const spans = await spansPromise; + + const sseFetchCall = spans.find(span => span.name === 'sse fetch call')!; + const httpGet = findHttpClientSpan(spans, 'http.client', 'http://localhost:8080/sse'); expect(sseFetchCall).toBeDefined(); expect(httpGet).toBeDefined(); - expect(sseFetchCall?.timestamp).toBeDefined(); - expect(sseFetchCall?.start_timestamp).toBeDefined(); - expect(httpGet?.timestamp).toBeDefined(); - expect(httpGet?.start_timestamp).toBeDefined(); - - // http headers get sent instantly from the server - const resolveDuration = Math.round((sseFetchCall.timestamp as number) - sseFetchCall.start_timestamp); - - // body streams after 0s because it has been aborted - const resolveBodyDuration = Math.round((httpGet.timestamp as number) - httpGet.start_timestamp); - - expect(resolveDuration).toBe(0); - expect(resolveBodyDuration).toBe(0); - - // validate abort error was thrown by inspecting console - expect(rootSpan.breadcrumbs).toContainEqual( - expect.objectContaining({ - category: 'console', - message: 'Could not fetch sse AbortError: BodyStreamBuffer was aborted', - }), - ); + // http headers get sent instantly from the server, and the body streams after 0s because it has + // been aborted + expect(durationInSeconds(sseFetchCall)).toBe(0); + expect(durationInSeconds(httpGet)).toBe(0); + + // Spans carry no breadcrumbs, so the abort error is validated on the console directly + expect( + consoleMessages.some( + message => message.includes('Could not fetch sse') && message.includes('BodyStreamBuffer was aborted'), + ), + ).toBe(true); }); test('Aborts when stream takes longer than 5s, by not updating the span duration', async ({ page }) => { await page.goto('/sse'); - const transactionPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const spansPromise = collectStreamedSpans('react-router-6', spans => { + return spans.some(span => getSpanOp(span) === 'pageload' && span.is_segment); }); const fetchButton = page.locator('id=fetch-timeout-button'); await fetchButton.click(); - const rootSpan = await transactionPromise; - const sseFetchCall = rootSpan.spans?.filter(span => span.description === 'sse fetch call')[0]!; - const httpGet = rootSpan.spans?.filter(span => span.description === 'GET http://localhost:8080/sse-timeout')[0]!; + const spans = await spansPromise; + + const sseFetchCall = spans.find(span => span.name === 'sse fetch call')!; + const httpGet = findHttpClientSpan(spans, 'http.client', 'http://localhost:8080/sse-timeout'); expect(sseFetchCall).toBeDefined(); expect(httpGet).toBeDefined(); - expect(sseFetchCall?.timestamp).toBeDefined(); - expect(sseFetchCall?.start_timestamp).toBeDefined(); - expect(httpGet?.timestamp).toBeDefined(); - expect(httpGet?.start_timestamp).toBeDefined(); - - // http headers get sent instantly from the server - const resolveDuration = Math.round((sseFetchCall.timestamp as number) - sseFetchCall.start_timestamp); - - // body streams after 10s but client should abort reading after 5s - const resolveBodyDuration = Math.round((httpGet.timestamp as number) - httpGet.start_timestamp); - - expect(resolveDuration).toBe(0); - expect(resolveBodyDuration).toBe(0); + // http headers get sent instantly from the server, and the body streams after 10s but the client + // aborts reading after 5s + expect(durationInSeconds(sseFetchCall)).toBe(0); + expect(durationInSeconds(httpGet)).toBe(0); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx index fe7c390d792a..6dd70edf6622 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx @@ -18,7 +18,6 @@ import Index from './pages/Index'; const replay = Sentry.replayIntegration(); Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/spans.test.ts new file mode 100644 index 000000000000..139ee1bb2c08 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/spans.test.ts @@ -0,0 +1,132 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('sends a pageload span with a parameterized URL', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-7-cross-usage', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/projects/123/views/234/567`); + + const span = await spanPromise; + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(span.name).toBe('/projects/:projectId/views/:viewId/:detailId'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/projects/:projectId/views/:viewId/:detailId', type: 'string' }, + 'url.path': { value: '/projects/123/views/234/567', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/views\/234\/567$/), + type: 'string', + }, + }); +}); + +test('sends a pageload span with a parameterized URL - alternative route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-7-cross-usage', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/projects/234/old-views/234/567`); + + const span = await spanPromise; + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(span.name).toBe('/projects/:projectId/old-views/:viewId/:detailId'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/projects/:projectId/old-views/:viewId/:detailId', type: 'string' }, + 'url.path': { value: '/projects/234/old-views/234/567', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/234\/old-views\/234\/567$/), + type: 'string', + }, + }); +}); + +test('sends a navigation span with a parameterized URL', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-cross-usage', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-7-cross-usage', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + const pageloadSpan = await pageloadSpanPromise; + + expect(pageloadSpan.name).toBe('/'); + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); + + const linkElement = page.locator('id=navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(navigationSpan.name).toBe('/projects/:projectId/views/:viewId/:detailId'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/projects/:projectId/views/:viewId/:detailId', type: 'string' }, + 'url.path': { value: '/projects/123/views/456/789', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/views\/456\/789$/), + type: 'string', + }, + }); +}); + +test('sends a navigation span with a parameterized URL - alternative route', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-cross-usage', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-7-cross-usage', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + const pageloadSpan = await pageloadSpanPromise; + + expect(pageloadSpan.name).toBe('/'); + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); + + const linkElement = page.locator('id=old-navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(navigationSpan.name).toBe('/projects/:projectId/old-views/:viewId/:detailId'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/projects/:projectId/old-views/:viewId/:detailId', type: 'string' }, + 'url.path': { value: '/projects/123/old-views/345/654', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/old-views\/345\/654$/), + type: 'string', + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/transactions.test.ts deleted file mode 100644 index b9365417ce69..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/transactions.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('sends a pageload transaction with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-7-cross-usage', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/projects/123/views/234/567`); - - const rootSpan = await transactionPromise; - - expect((await page.innerHTML('#root')).includes('Details')).toBe(true); - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/projects/:projectId/views/:viewId/:detailId', - 'url.path': '/projects/123/views/234/567', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/views\/234\/567$/), - }, - }, - }, - transaction: '/projects/:projectId/views/:viewId/:detailId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('sends a pageload transaction with a parameterized URL - alternative route', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-7-cross-usage', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/projects/234/old-views/234/567`); - - const rootSpan = await transactionPromise; - - expect((await page.innerHTML('#root')).includes('Details')).toBe(true); - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/projects/:projectId/old-views/:viewId/:detailId', - 'url.path': '/projects/234/old-views/234/567', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/234\/old-views\/234\/567$/), - }, - }, - }, - transaction: '/projects/:projectId/old-views/:viewId/:detailId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('sends a navigation transaction with a parameterized URL', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('react-router-7-cross-usage', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-7-cross-usage', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - const pageloadTxn = await pageloadTxnPromise; - - expect(pageloadTxn).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, - }); - - const linkElement = page.locator('id=navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Details')).toBe(true); - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/projects/:projectId/views/:viewId/:detailId', - 'url.path': '/projects/123/views/456/789', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/views\/456\/789$/), - }, - }, - }, - transaction: '/projects/:projectId/views/:viewId/:detailId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('sends a navigation transaction with a parameterized URL - alternative route', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('react-router-7-cross-usage', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-7-cross-usage', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - const pageloadTxn = await pageloadTxnPromise; - - expect(pageloadTxn).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, - }); - - const linkElement = page.locator('id=old-navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Details')).toBe(true); - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/projects/:projectId/old-views/:viewId/:detailId', - 'url.path': '/projects/123/old-views/345/654', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/old-views\/345\/654$/), - }, - }, - }, - transaction: '/projects/:projectId/old-views/:viewId/:detailId', - transaction_info: { - source: 'route', - }, - }); -}); 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 cbc6c0686d19..ad5c8fb3b624 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,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'; // Orchestrion injects `diagnostics_channel` publishers at build time, so these spans only exist in the @@ -11,106 +11,89 @@ test.describe('server - orchestrion build-time db instrumentation', () => { test.skip(isDev, 'orchestrion only injects into the bundled server build, not the dev server'); 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'); }); }); 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-static/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/.gitignore new file mode 100644 index 000000000000..ebb991370034 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/.gitignore @@ -0,0 +1,32 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +/test-results/ +/playwright-report/ +/playwright/.cache/ + +!*.d.ts + +# react router +.react-router diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/app.css b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/app.css new file mode 100644 index 000000000000..b31c3a9d0ddf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/app.css @@ -0,0 +1,6 @@ +html, +body { + @media (prefers-color-scheme: dark) { + color-scheme: dark; + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/context.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/context.ts new file mode 100644 index 000000000000..a15189e5bed8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/context.ts @@ -0,0 +1,8 @@ +import { createContext } from 'react-router'; + +export type User = { + id: string; + name: string; +}; + +export const userContext = createContext(null); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/entry.client.tsx new file mode 100644 index 000000000000..d4a0e1d0900b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/entry.client.tsx @@ -0,0 +1,24 @@ +import * as Sentry from '@sentry/react-router'; +import { StrictMode, startTransition } from 'react'; +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', + tunnel: `http://localhost:3031/`, // proxy server + integrations: [Sentry.reactRouterTracingIntegration()], + tracesSampleRate: 1.0, + tracePropagationTargets: [/^\//], +}); + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/entry.server.tsx new file mode 100644 index 000000000000..738cd1515a4d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/entry.server.tsx @@ -0,0 +1,18 @@ +import { createReadableStreamFromReadable } from '@react-router/node'; +import * as Sentry from '@sentry/react-router'; +import { renderToPipeableStream } from 'react-dom/server'; +import { ServerRouter } from 'react-router'; +import { type HandleErrorFunction } from 'react-router'; + +const ABORT_DELAY = 5_000; + +const handleRequest = Sentry.createSentryHandleRequest({ + streamTimeout: ABORT_DELAY, + ServerRouter, + renderToPipeableStream, + createReadableStreamFromReadable, +}); + +export default handleRequest; + +export const handleError: HandleErrorFunction = Sentry.createSentryHandleError({ logErrors: true }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/root.tsx new file mode 100644 index 000000000000..bc1b8f1236c0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/root.tsx @@ -0,0 +1,67 @@ +import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } from 'react-router'; +import type { Route } from './+types/root'; +import stylesheet from './app.css?url'; + +export const links: Route.LinksFunction = () => [ + { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, + { + rel: 'preconnect', + href: 'https://fonts.gstatic.com', + crossOrigin: 'anonymous', + }, + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap', + }, + { rel: 'stylesheet', href: stylesheet }, +]; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +} + +export default function App() { + return ; +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = 'Oops!'; + let details = 'An unexpected error occurred.'; + let stack: string | undefined; + + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? '404' : 'Error'; + details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details; + } else if (error && error instanceof Error) { + if (import.meta.env.DEV) { + details = error.message; + stack = error.stack; + } + } + + return ( +
+

{message}

+

{details}

+ {stack && ( +
+          {stack}
+        
+ )} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes.ts new file mode 100644 index 000000000000..3e3839295b1d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes.ts @@ -0,0 +1,24 @@ +import { type RouteConfig, index, prefix, route } from '@react-router/dev/routes'; + +export default [ + index('routes/home.tsx'), + route('__sentry-flush', 'routes/sentry-flush.tsx'), + ...prefix('errors', [ + route('client', 'routes/errors/client.tsx'), + route('client/:client-param', 'routes/errors/client-param.tsx'), + route('client-loader', 'routes/errors/client-loader.tsx'), + route('server-loader', 'routes/errors/server-loader.tsx'), + route('client-action', 'routes/errors/client-action.tsx'), + route('server-action', 'routes/errors/server-action.tsx'), + ]), + ...prefix('performance', [ + index('routes/performance/index.tsx'), + route('ssr', 'routes/performance/ssr.tsx'), + route('with/:param', 'routes/performance/dynamic-param.tsx'), + route('static', 'routes/performance/static.tsx'), + route('server-loader', 'routes/performance/server-loader.tsx'), + route('server-action', 'routes/performance/server-action.tsx'), + route('with-middleware', 'routes/performance/with-middleware.tsx'), + route('redis', 'routes/performance/redis.tsx'), + ]), +] satisfies RouteConfig; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-action.tsx new file mode 100644 index 000000000000..d3b2d08eef2e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-action.tsx @@ -0,0 +1,18 @@ +import { Form } from 'react-router'; + +export function clientAction() { + throw new Error('Madonna mia! Che casino nella Client Action!'); +} + +export default function ClientActionErrorPage() { + return ( +
+

Client Error Action Page

+
+ +
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-loader.tsx new file mode 100644 index 000000000000..72d9e62a99dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-loader.tsx @@ -0,0 +1,16 @@ +import type { Route } from './+types/server-loader'; + +export function clientLoader() { + throw new Error('¡Madre mía del client loader!'); + return { data: 'sad' }; +} + +export default function ClientLoaderErrorPage({ loaderData }: Route.ComponentProps) { + const { data } = loaderData; + return ( +
+

Client Loader Error Page

+
{data}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-param.tsx new file mode 100644 index 000000000000..a2e423391f03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-param.tsx @@ -0,0 +1,17 @@ +import type { Route } from './+types/client-param'; + +export default function ClientErrorParamPage({ params }: Route.ComponentProps) { + return ( +
+

Client Error Param Page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client.tsx new file mode 100644 index 000000000000..190074a5ef09 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client.tsx @@ -0,0 +1,15 @@ +export default function ClientErrorPage() { + return ( +
+

Client Error Page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-action.tsx new file mode 100644 index 000000000000..863c320f3557 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-action.tsx @@ -0,0 +1,18 @@ +import { Form } from 'react-router'; + +export function action() { + throw new Error('Madonna mia! Che casino nella Server Action!'); +} + +export default function ServerActionErrorPage() { + return ( +
+

Server Error Action Page

+
+ +
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-loader.tsx new file mode 100644 index 000000000000..cb777686d540 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-loader.tsx @@ -0,0 +1,16 @@ +import type { Route } from './+types/server-loader'; + +export function loader() { + throw new Error('¡Madre mía del server!'); + return { data: 'sad' }; +} + +export default function ServerLoaderErrorPage({ loaderData }: Route.ComponentProps) { + const { data } = loaderData; + return ( +
+

Server Error Page

+
{data}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/home.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/home.tsx new file mode 100644 index 000000000000..4498e7a0d017 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/home.tsx @@ -0,0 +1,9 @@ +import type { Route } from './+types/home'; + +export function meta({}: Route.MetaArgs) { + return [{ title: 'New React Router App' }, { name: 'description', content: 'Welcome to React Router!' }]; +} + +export default function Home() { + return
home
; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/dynamic-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/dynamic-param.tsx new file mode 100644 index 000000000000..1ac02775f2ff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/dynamic-param.tsx @@ -0,0 +1,17 @@ +import type { Route } from './+types/dynamic-param'; + +export async function loader() { + await new Promise(resolve => setTimeout(resolve, 500)); + return { data: 'burritos' }; +} + +export default function DynamicParamPage({ params }: Route.ComponentProps) { + const { param } = params; + + return ( +
+

Dynamic Parameter Page

+

The parameter value is: {param}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/index.tsx new file mode 100644 index 000000000000..4c086645603e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/index.tsx @@ -0,0 +1,17 @@ +import { Link } from 'react-router'; + +export default function PerformancePage() { + return ( +
+

Performance Page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/redis.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/redis.tsx new file mode 100644 index 000000000000..cba8275fcf63 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/redis.tsx @@ -0,0 +1,22 @@ +import Redis from 'ioredis'; +import type { Route } from './+types/redis'; + +const redis = new Redis(); + +export async function loader() { + const key = 'cache:greeting'; + await redis.set(key, 'hello from react-router'); + const value = await redis.get(key); + + return { value }; +} + +export default function RedisPage({ loaderData }: Route.ComponentProps) { + const { value } = loaderData; + return ( +
+

Redis Page

+
{value}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/server-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/server-action.tsx new file mode 100644 index 000000000000..462fc6fbf54c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/server-action.tsx @@ -0,0 +1,24 @@ +import { Form } from 'react-router'; +import type { Route } from './+types/server-action'; + +export async function action({ request }: Route.ActionArgs) { + let formData = await request.formData(); + let name = formData.get('name'); + await new Promise(resolve => setTimeout(resolve, 1000)); + return { + greeting: `Hola ${name}`, + }; +} + +export default function Project({ actionData }: Route.ComponentProps) { + return ( +
+

Server action page

+
+ + +
+ {actionData ?

{actionData.greeting}

: null} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/server-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/server-loader.tsx new file mode 100644 index 000000000000..e5c222ff4c05 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/server-loader.tsx @@ -0,0 +1,16 @@ +import type { Route } from './+types/server-loader'; + +export async function loader() { + await new Promise(resolve => setTimeout(resolve, 500)); + return { data: 'burritos' }; +} + +export default function ServerLoaderPage({ loaderData }: Route.ComponentProps) { + const { data } = loaderData; + return ( +
+

Server Loader Page

+
{data}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/ssr.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/ssr.tsx new file mode 100644 index 000000000000..8226e68f3be0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/ssr.tsx @@ -0,0 +1,14 @@ +import { useNavigate } from 'react-router'; + +export default function SsrPage() { + const navigate = useNavigate(); + + return ( +
+

SSR Page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/static.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/static.tsx new file mode 100644 index 000000000000..3dea24381fdc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/static.tsx @@ -0,0 +1,3 @@ +export default function StaticPage() { + return

Static Page

; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/with-middleware.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/with-middleware.tsx new file mode 100644 index 000000000000..c86f78e17164 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/with-middleware.tsx @@ -0,0 +1,38 @@ +import type { Route } from './+types/with-middleware'; +import type { User } from '../../context'; +import { userContext } from '../../context'; +import * as Sentry from '@sentry/react-router'; + +async function getUser() { + await new Promise(resolve => setTimeout(resolve, 500)); + return { + id: '1', + name: 'Carlos Gomez', + }; +} + +const authMiddleware: Route.MiddlewareFunction = async ({ request, context }, next) => { + Sentry.startSpan({ name: 'authMiddleware', op: 'middleware.auth' }, async () => { + const user: User = await getUser(); + context.set(userContext, user); + await next(); + }); +}; + +export const middleware: Route.MiddlewareFunction[] = [authMiddleware]; + +export const loader = async ({ context }: Route.LoaderArgs) => { + const user = context.get(userContext); + return { user }; +}; + +export default function WithMiddlewarePage({ loaderData }: Route.ComponentProps) { + const { user } = loaderData; + + return ( +
+

With Middleware Page

+

User: {user?.name}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/sentry-flush.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/sentry-flush.tsx new file mode 100644 index 000000000000..c72024185046 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/sentry-flush.tsx @@ -0,0 +1,6 @@ +import * as Sentry from '@sentry/react-router'; + +export async function loader() { + await Sentry.flush(2000); + return new Response(null, { status: 204 }); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/docker-compose.yml b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/docker-compose.yml new file mode 100644 index 000000000000..d2193ccad07b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/docker-compose.yml @@ -0,0 +1,12 @@ +services: + redis: + image: redis:8 + restart: always + container_name: e2e-tests-react-router-7-static-redis + ports: + - '6379:6379' + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 1s + timeout: 3s + retries: 30 diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/global-setup.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/global-setup.mjs new file mode 100644 index 000000000000..1e6210bfdc35 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/global-setup.mjs @@ -0,0 +1,21 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Boot Redis here (rather than in the `start` script) so the cold `redis:8` image +// pull happens outside Playwright's webServer startup-timeout window. `--wait` +// blocks until the healthcheck passes; +export default async function globalSetup() { + // Each run copies this app to a fresh temp dir, so `docker compose` doesn't + // recognize a leftover container from a previous (e.g. interrupted) run as + // part of the same project - but the container name is fixed, so the daemon + // still refuses to create a new one. Force-remove any stale leftover first. + try { + execSync('docker rm -f e2e-tests-react-router-7-static-redis', { stdio: 'ignore' }); + } catch { + // no stale container to remove + } + execSync('docker compose up -d --wait', { cwd: __dirname, stdio: 'inherit' }); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/instrument.mjs new file mode 100644 index 000000000000..00a6d2952286 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/instrument.mjs @@ -0,0 +1,9 @@ +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, + tunnel: `http://localhost:3031/`, // proxy server +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/package.json new file mode 100644 index 000000000000..b17cf84b46c0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/package.json @@ -0,0 +1,59 @@ +{ + "name": "react-router-7-framework-static", + "version": "0.1.0", + "type": "module", + "private": true, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router": "^7.13.0", + "@react-router/node": "^7.13.0", + "@react-router/serve": "^7.13.0", + "@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz", + "ioredis": "^5.4.1", + "isbot": "^5.1.17" + }, + "devDependencies": { + "@types/react": "18.3.1", + "@types/react-dom": "18.3.1", + "@types/node": "^20", + "@react-router/dev": "^7.13.0", + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "^5.6.3", + "vite": "^5.4.11" + }, + "scripts": { + "build": "react-router build", + "dev": "NODE_OPTIONS='--import ./instrument.mjs' react-router dev", + "start": "NODE_OPTIONS='--import ./instrument.mjs' react-router-serve ./build/server/index.js", + "proxy": "node start-event-proxy.mjs", + "typecheck": "react-router typegen && tsc", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:ts && pnpm test:playwright", + "test:ts": "pnpm typecheck", + "test:playwright": "playwright test" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/playwright.config.mjs new file mode 100644 index 000000000000..70e62c2b9e3b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/playwright.config.mjs @@ -0,0 +1,13 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +import { fileURLToPath } from 'url'; + +const config = getPlaywrightConfig( + { + startCommand: `PORT=3030 pnpm start`, + port: 3030, + }, + // Boot Redis before the tests run, outside the webServer startup-timeout window. + { globalSetup: fileURLToPath(new URL('./global-setup.mjs', import.meta.url)) }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/public/favicon.ico b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5dbdfcddcb14182535f6d32d1c900681321b1aa3 GIT binary patch literal 15086 zcmeI33v3ic7{|AFEmuJ-;v>ep_G*NPi6KM`qNryCe1PIJ8siIN1WZ(7qVa)RVtmC% z)Ch?tN+afMKm;5@rvorJk zcXnoOc4q51HBQnQH_jn!cAg&XI1?PlX>Kl^k8qq0;zkha`kY$Fxt#=KNJAE9CMdpW zqr4#g8`nTw191(+H4xW8Tmyru2I^3=J1G3emPxkPXA=3{vvuvse_WWSshqaqls^-m zgB7q8&Vk*aYRe?sn$n53dGH#%3y%^vxv{pL*-h0Z4bmb_(k6{FL7HWIz(V*HT#IcS z-wE{)+0x1U!RUPt3gB97%p}@oHxF4|6S*+Yw=_tLtxZ~`S=z6J?O^AfU>7qOX`JNBbV&8+bO0%@fhQitKIJ^O^ zpgIa__qD_y07t@DFlBJ)8SP_#^j{6jpaXt{U%=dx!qu=4u7^21lWEYHPPY5U3TcoQ zX_7W+lvZi>TapNk_X>k-KO%MC9iZp>1E`N34gHKd9tK&){jq2~7OsJ>!G0FzxQFw6G zm&Vb(2#-T|rM|n3>uAsG_hnbvUKFf3#ay@u4uTzia~NY%XgCHfx4^To4BDU@)HlV? z@EN=g^ymETa1sQK{kRwyE4Ax8?wT&GvaG@ASO}{&a17&^v`y z!oPdiSiia^oov(Z)QhG2&|FgE{M9_4hJROGbnj>#$~ZF$-G^|zPj*QApltKe?;u;uKHJ~-V!=VLkg7Kgct)l7u39f@%VG8e3f$N-B zAu3a4%ZGf)r+jPAYCSLt73m_J3}p>}6Tx0j(wg4vvKhP!DzgiWANiE;Ppvp}P2W@m z-VbYn+NXFF?6ngef5CfY6ZwKnWvNV4z6s^~yMXw2i5mv}jC$6$46g?G|CPAu{W5qF zDobS=zb2ILX9D827g*NtGe5w;>frjanY{f)hrBP_2ehBt1?`~ypvg_Ot4x1V+43P@Ve8>qd)9NX_jWdLo`Zfy zoeam9)@Dpym{4m@+LNxXBPjPKA7{3a&H+~xQvr>C_A;7=JrfK~$M2pCh>|xLz>W6SCs4qC|#V`)# z)0C|?$o>jzh<|-cpf

K7osU{Xp5PG4-K+L2G=)c3f&}H&M3wo7TlO_UJjQ-Oq&_ zjAc9=nNIYz{c3zxOiS5UfcE1}8#iI4@uy;$Q7>}u`j+OU0N<*Ezx$k{x_27+{s2Eg z`^=rhtIzCm!_UcJ?Db~Lh-=_))PT3{Q0{Mwdq;0>ZL%l3+;B&4!&xm#%HYAK|;b456Iv&&f$VQHf` z>$*K9w8T+paVwc7fLfMlhQ4)*zL_SG{~v4QR;IuX-(oRtYAhWOlh`NLoX0k$RUYMi z2Y!bqpdN}wz8q`-%>&Le@q|jFw92ErW-hma-le?S z-@OZt2EEUm4wLsuEMkt4zlyy29_3S50JAcQHTtgTC{P~%-mvCTzrjXOc|{}N`Cz`W zSj7CrXfa7lcsU0J(0uSX6G`54t^7}+OLM0n(|g4waOQ}bd3%!XLh?NX9|8G_|06Ie zD5F1)w5I~!et7lA{G^;uf7aqT`KE&2qx9|~O;s6t!gb`+zVLJyT2T)l*8l(j literal 0 HcmV?d00001 diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/react-router.config.ts new file mode 100644 index 000000000000..72f2eef3b0f5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/react-router.config.ts @@ -0,0 +1,9 @@ +import type { Config } from '@react-router/dev/config'; + +export default { + ssr: true, + prerender: ['/performance/static'], + future: { + v8_middleware: true, + }, +} satisfies Config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/start-event-proxy.mjs new file mode 100644 index 000000000000..9efc50b2c508 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-7-framework-static', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/constants.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/constants.ts new file mode 100644 index 000000000000..e0420ef6daf2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/constants.ts @@ -0,0 +1 @@ +export const APP_NAME = 'react-router-7-framework-static'; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.client.test.ts new file mode 100644 index 000000000000..c1a7de46f1b6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.client.test.ts @@ -0,0 +1,140 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('client-side errors', () => { + const errorMessage = '¡Madre mía!'; + test('captures error thrown on click', async ({ page }) => { + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto(`/errors/client`); + await page.locator('#throw-on-click').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: false, + }, + }, + ], + }, + transaction: '/errors/client', + request: { + url: expect.stringContaining('errors/client'), + headers: expect.any(Object), + }, + level: 'error', + platform: 'javascript', + environment: 'qa', + sdk: { + integrations: expect.any(Array), + name: 'sentry.javascript.react-router', + version: expect.any(String), + }, + tags: { runtime: 'browser' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + breadcrumbs: [ + { + category: 'ui.click', + message: 'body > div > button#throw-on-click', + }, + ], + }); + }); + + test('captures error thrown on click from a parameterized route', async ({ page }) => { + const errorMessage = '¡Madre mía de churros!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto('/errors/client/churros'); + await page.locator('#throw-on-click').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: '¡Madre mía de churros!', + mechanism: { + handled: false, + }, + }, + ], + }, + // todo: should be '/errors/client/:client-param' + transaction: '/errors/client/churros', + }); + }); + + test('captures error thrown in a clientLoader', async ({ page }) => { + const errorMessage = '¡Madre mía del client loader!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto('/errors/client-loader'); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: false, + type: 'auto.function.react_router.on_error', + }, + }, + ], + }, + transaction: '/errors/client-loader', + }); + }); + + test('captures error thrown in a clientAction', async ({ page }) => { + const errorMessage = 'Madonna mia! Che casino nella Client Action!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto('/errors/client-action'); + await page.locator('#submit').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: false, + type: 'auto.function.react_router.on_error', + }, + }, + ], + }, + transaction: '/errors/client-action', + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.server.test.ts new file mode 100644 index 000000000000..2759bfecb67e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.server.test.ts @@ -0,0 +1,100 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('server-side errors', () => { + test('captures error thrown in server loader', async ({ page }) => { + const errorMessage = '¡Madre mía del server!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto(`/errors/server-loader`); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: false, + type: 'react-router', + }, + }, + ], + }, + // todo: should be 'GET /errors/server-loader' + transaction: 'GET *', + request: { + url: expect.stringContaining('errors/server-loader'), + headers: expect.any(Object), + }, + level: 'error', + platform: 'node', + environment: 'qa', + sdk: { + integrations: expect.any(Array), + name: 'sentry.javascript.react-router', + version: expect.any(String), + }, + tags: { runtime: 'node' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + }); + }); + + test('captures error thrown in server action', async ({ page }) => { + const errorMessage = 'Madonna mia! Che casino nella Server Action!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto(`/errors/server-action`); + await page.locator('#submit').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: false, + type: 'react-router', + }, + }, + ], + }, + // todo: should be 'POST /errors/server-action' + transaction: 'POST *', + request: { + url: expect.stringContaining('errors/server-action'), + headers: expect.any(Object), + }, + level: 'error', + platform: 'node', + environment: 'qa', + sdk: { + integrations: expect.any(Array), + name: 'sentry.javascript.react-router', + version: expect.any(String), + }, + tags: { runtime: 'node' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/low-quality-filter.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/low-quality-filter.server.test.ts new file mode 100644 index 000000000000..0664ac5c99e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/low-quality-filter.server.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } 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 } } }> = []; + + const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + waitForTransaction(APP_NAME, async evt => { + serverTxns.push(evt); + return false; + }); + + await page.goto('/performance'); + await page.waitForTimeout(1000); + await page.getByRole('link', { name: 'SSR Page' }).click(); + + await navigationPromise; + + // Force the server to flush any in-flight transactions 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); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/middleware.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/middleware.server.test.ts new file mode 100644 index 000000000000..dbce05350ad9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/middleware.server.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } 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'; + }); + + await page.goto(`/performance/with-middleware`); + + const serverTx = await serverTxPromise; + const pageloadTx = await pageloadTxPromise; + const customMiddlewareTx = await customMiddlewareTxPromise; + + const traceIds = { + server: serverTx?.contexts?.trace?.trace_id, + pageload: pageloadTx?.contexts?.trace?.trace_id, + customMiddleware: customMiddlewareTx?.contexts?.trace?.trace_id, + }; + + expect(pageloadTx).toBeDefined(); + expect(customMiddlewareTx).toBeDefined(); + + // Assert that all transactions belong to the same trace + expect(traceIds.server).toBe(traceIds.pageload); + expect(traceIds.server).toBe(traceIds.customMiddleware); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/navigation.client.test.ts new file mode 100644 index 000000000000..59b2f8a9e038 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/navigation.client.test.ts @@ -0,0 +1,243 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } 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' + ); + }); + + const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/performance`); // pageload + await pageloadTxPromise; + 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), + 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' }, + }); + }); + + 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' + ); + }); + + const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/performance`); // pageload + await pageloadTxPromise; + 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$/), + }, + }, + }, + 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'; + }); + + const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/performance`); // pageload + await pageloadTxPromise; + 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$/), + }, + }, + }, + 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' + ); + }); + + const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/performance`); // pageload + await pageloadTxPromise; + 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), + 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) }, + ], + }, + 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'; + }); + + await page.goto(`/performance`); + await pageloadTxPromise; + + const forwardNavPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + 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'; + }); + + 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' }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/pageload.client.test.ts new file mode 100644 index 000000000000..e3bea05a36ef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/pageload.client.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } 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'; + }); + + 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.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), + 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' }, + }); + }); + + 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' + ); + }); + + await page.goto(`/performance/with/sentry`); + + 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) }, + ], + }, + 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' + ); + }); + + await page.goto(`/performance/static`); + + const transaction = await txPromise; + + 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', + }, + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/performance.server.test.ts new file mode 100644 index 000000000000..bcfe15e49c34 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/performance.server.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } 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'; + }); + + 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.request_handler', + 'sentry.segment.name.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.react_router.request_handler', + }, + }, + spans: expect.any(Array), + 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', + }, + }); + }); + + test('should send server transaction on parameterized route', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance/with/:param'; + }); + + 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.request_handler', + 'sentry.segment.name.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.react_router.request_handler', + }, + }, + spans: expect.any(Array), + 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', + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/redis.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/redis.server.test.ts new file mode 100644 index 000000000000..1b82a709ab7b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/redis.server.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } 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) + ); + }); + + await page.goto('/performance/redis'); + + const transaction = await txPromise; + + expect(transaction.contexts?.trace?.op).toBe('http.server'); + + // 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)]); + + const redisSpans = transaction.spans!.filter(span => span.op === '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) + const allNested = redisSpans.every( + span => typeof span.parent_span_id === 'string' && spanIds.has(span.parent_span_id), + ); + expect(allNested).toBe(true); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts new file mode 100644 index 000000000000..b62e784ff3e3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('Trace propagation', () => { + test('should inject metatags in ssr pageload', async ({ page }) => { + await page.goto(`/`); + const sentryTraceContent = await page.getAttribute('meta[name="sentry-trace"]', 'content'); + expect(sentryTraceContent).toBeDefined(); + expect(sentryTraceContent).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/); + const baggageContent = await page.getAttribute('meta[name="baggage"]', 'content'); + expect(baggageContent).toBeDefined(); + expect(baggageContent).toContain('sentry-environment=qa'); + expect(baggageContent).toContain('sentry-public_key='); + expect(baggageContent).toContain('sentry-trace_id='); + expect(baggageContent).toContain('sentry-transaction='); + expect(baggageContent).toContain('sentry-sampled='); + }); + + 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 === '/'; + }); + + await page.goto(`/`); + const serverTx = await serverTxPromise; + const clientTx = await clientTxPromise; + + expect(clientTx.contexts?.trace?.trace_id).toEqual(serverTx.contexts?.trace?.trace_id); + + const requestHandlerSpan = serverTx.spans?.find(span => span.op === 'handler'); + + expect(requestHandlerSpan).toBeDefined(); + expect(clientTx.contexts?.trace?.parent_span_id).toBe(requestHandlerSpan?.span_id); + }); + + test('should not have trace connection for prerendered pages', async ({ page }) => { + await page.goto('/performance/static'); + + const sentryTraceElement = await page.$('meta[name="sentry-trace"]'); + expect(sentryTraceElement).toBeNull(); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tsconfig.json new file mode 100644 index 000000000000..a16df276e8bc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["node", "vite/client"], + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "rootDirs": [".", "./.react-router/types"], + "baseUrl": ".", + + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true + }, + "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"] +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/vite.config.ts new file mode 100644 index 000000000000..68ba30d69397 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/vite.config.ts @@ -0,0 +1,6 @@ +import { reactRouter } from '@react-router/dev/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [reactRouter()], +}); 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..5a703eed6735 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'; + test('should send middleware span on pageload', async ({ page }) => { + const serverSpansPromise = collectStreamedSpans(APP_NAME, spans => { + return ( + spans.some(span => span.name === 'GET /performance/with-middleware' && span.is_segment) && + spans.some(span => span.name === 'authMiddleware') + ); }); - const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with-middleware'; - }); - - const customMiddlewareTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '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.name === 'GET /performance/with-middleware' && 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..7d94e147f228 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,32 @@ 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 => { + test('server loader emits db.query child spans on the http.server segment', async ({ page }) => { + const spansPromise = collectStreamedSpans(APP_NAME, spans => { return ( - transactionEvent.transaction === 'GET /performance/redis' && - (transactionEvent.spans?.some(span => span.op === 'db.query') ?? false) + spans.some(span => span.name === 'GET /performance/redis' && span.is_segment) && + spans.filter(span => getSpanOp(span) === 'db.query').length >= 2 ); }); await page.goto('/performance/redis'); - const transaction = await txPromise; + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.name === 'GET /performance/redis' && span.is_segment)!; - expect(transaction.contexts?.trace?.op).toBe('http.server'); + expect(getSpanOp(segmentSpan)).toBe('http.server'); - // 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)]); + // Collect every span id in the trace (segment + children) so we can verify nesting. + const spanIds = new Set(spans.filter(span => span.trace_id === segmentSpan.trace_id).map(span => span.span_id)); - const redisSpans = transaction.spans!.filter(span => span.op === 'db.query'); + 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..bce66ef50fdf 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,5 @@ 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('Trace propagation', () => { @@ -18,24 +18,28 @@ test.describe('Trace propagation', () => { }); test('should have trace connection', async ({ page }) => { - const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET *'; + // The `handler` child span arrives in the same envelope as its server segment, so collect until + // the segment shows up rather than snapshotting a single envelope. + const serverSpansPromise = collectStreamedSpans(APP_NAME, spans => { + return spans.some(span => getSpanOp(span) === 'http.server' && span.is_segment); }); - const clientTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/'; + const clientSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/`); - const serverTx = await serverTxPromise; - const clientTx = await clientTxPromise; + const serverSpans = await serverSpansPromise; + const clientSpan = await clientSpanPromise; - expect(clientTx.contexts?.trace?.trace_id).toEqual(serverTx.contexts?.trace?.trace_id); + const serverSegmentSpan = serverSpans.find(span => getSpanOp(span) === 'http.server' && span.is_segment)!; - const requestHandlerSpan = serverTx.spans?.find(span => span.op === 'handler'); + expect(clientSpan.trace_id).toEqual(serverSegmentSpan.trace_id); + + const requestHandlerSpan = serverSpans.find(span => getSpanOp(span) === 'handler'); expect(requestHandlerSpan).toBeDefined(); - expect(clientTx.contexts?.trace?.parent_span_id).toBe(requestHandlerSpan?.span_id); + expect(clientSpan.parent_span_id).toBe(requestHandlerSpan?.span_id); }); test('should not have trace connection for prerendered pages', async ({ page }) => { diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx index f51e5f9dd5a6..c1acf56da8ac 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx @@ -78,7 +78,6 @@ const lazyRouteManifest = [ ]; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/spans.test.ts similarity index 54% rename from dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts rename to dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/spans.test.ts index 59f33c1ac8cc..7e11ce22584e 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/spans.test.ts @@ -1,12 +1,28 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('Creates a pageload transaction with parameterized route', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { + collectStreamedSpans, + getSpanOp, + waitForStreamedSpan, + waitForStreamedSpans, +} from '@sentry-internal/test-utils'; + +// Streamed `http.client` names are only ` `, so the request URL has to come from +// the `url.full` attribute. +function hasUrlPart(span: SerializedStreamedSpan, part: string): boolean { + const urlFull = span.attributes['url.full']?.value; + return typeof urlFull === 'string' && urlFull.includes(part); +} + +/** All spans of the trace `segmentSpan` belongs to, minus the segment span itself. */ +function childSpansOf(spans: SerializedStreamedSpan[], segmentSpan: SerializedStreamedSpan): SerializedStreamedSpan[] { + return spans.filter(span => span.trace_id === segmentSpan.trace_id && !span.is_segment); +} + +test('Creates a pageload span with parameterized route', async ({ page }) => { + const transactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/lazy/inner/:id/:anotherId/:someAnotherId' ); }); @@ -18,22 +34,20 @@ test('Creates a pageload transaction with parameterized route', async ({ page }) await expect(lazyRouteContent).toBeVisible(); // Validate the transaction event - expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); - expect(event.type).toBe('transaction'); - expect(event.contexts?.trace?.op).toBe('pageload'); - expect(event.contexts?.trace?.status).toBe('ok'); + expect(event.name).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(event.is_segment).toBe(true); + expect(getSpanOp(event)).toBe('pageload'); + expect(event.status).toBe('ok'); }); -test('Does not create a navigation transaction on initial load to deep lazy route', async ({ page }) => { - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; +test('Does not create a navigation span on initial load to deep lazy route', async ({ page }) => { + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; }); - const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + const pageloadPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/lazy/inner/:id/:anotherId/:someAnotherId' ); }); @@ -41,7 +55,7 @@ test('Does not create a navigation transaction on initial load to deep lazy rout const pageloadEvent = await pageloadPromise; - expect(pageloadEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(pageloadEvent.name).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); const lazyRouteContent = page.locator('id=innermost-lazy-route'); await expect(lazyRouteContent).toBeVisible(); @@ -55,12 +69,10 @@ test('Does not create a navigation transaction on initial load to deep lazy rout expect(result).toBe('timeout'); }); -test('Creates a navigation transaction inside a lazy route', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { +test('Creates a navigation span inside a lazy route', async ({ page }) => { + const transactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy/inner/:id/:anotherId/:someAnotherId' ); }); @@ -80,27 +92,21 @@ test('Creates a navigation transaction inside a lazy route', async ({ page }) => await expect(lazyRouteContent).toBeVisible(); // Validate the transaction event - expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); - expect(event.type).toBe('transaction'); - expect(event.contexts?.trace?.op).toBe('navigation'); - expect(event.contexts?.trace?.status).toBe('ok'); + expect(event.name).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(event.is_segment).toBe(true); + expect(getSpanOp(event)).toBe('navigation'); + expect(event.status).toBe('ok'); }); -test('Creates navigation transactions between two different lazy routes', async ({ page }) => { +test('Creates navigation spans between two different lazy routes', async ({ page }) => { // Set up transaction listeners for both navigations - const firstTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/another-lazy/sub/:id/:subId' - ); + const firstTransactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/another-lazy/sub/:id/:subId'; }); - const secondTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + const secondTransactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy/inner/:id/:anotherId/:someAnotherId' ); }); @@ -118,9 +124,9 @@ test('Creates navigation transactions between two different lazy routes', async await expect(anotherLazyContent).toBeVisible(); // Validate the first transaction event - expect(firstEvent.transaction).toBe('/another-lazy/sub/:id/:subId'); - expect(firstEvent.type).toBe('transaction'); - expect(firstEvent.contexts?.trace?.op).toBe('navigation'); + expect(firstEvent.name).toBe('/another-lazy/sub/:id/:subId'); + expect(firstEvent.is_segment).toBe(true); + expect(getSpanOp(firstEvent)).toBe('navigation'); // Now navigate from the first lazy route to the second lazy route // Click the navigation link from within the first lazy route to the second lazy route @@ -135,12 +141,12 @@ test('Creates navigation transactions between two different lazy routes', async await expect(innerLazyContent).toBeVisible(); // Validate the second transaction event - expect(secondEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); - expect(secondEvent.type).toBe('transaction'); - expect(secondEvent.contexts?.trace?.op).toBe('navigation'); + expect(secondEvent.name).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(secondEvent.is_segment).toBe(true); + expect(getSpanOp(secondEvent)).toBe('navigation'); }); -test('Creates navigation transactions from inner lazy route to another lazy route with history navigation', async ({ +test('Creates navigation spans from inner lazy route to another lazy route with history navigation', async ({ page, }) => { await page.goto('/'); @@ -150,11 +156,9 @@ test('Creates navigation transactions from inner lazy route to another lazy rout await expect(navigationToInner).toBeVisible(); // First, navigate to the inner lazy route - const firstTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + const firstTransactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy/inner/:id/:anotherId/:someAnotherId' ); }); @@ -167,21 +171,17 @@ test('Creates navigation transactions from inner lazy route to another lazy rout await expect(innerLazyContent).toBeVisible(); // Validate the first transaction event - expect(firstEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); - expect(firstEvent.type).toBe('transaction'); - expect(firstEvent.contexts?.trace?.op).toBe('navigation'); + expect(firstEvent.name).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(firstEvent.is_segment).toBe(true); + expect(getSpanOp(firstEvent)).toBe('navigation'); // Click the navigation link from within the inner lazy route to another lazy route const navigationToAnotherFromInner = page.locator('id=navigate-to-another-from-inner'); await expect(navigationToAnotherFromInner).toBeVisible(); // Now navigate from the inner lazy route to another lazy route - const secondTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/another-lazy/sub/:id/:subId' - ); + const secondTransactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/another-lazy/sub/:id/:subId'; }); await navigationToAnotherFromInner.click(); @@ -193,16 +193,14 @@ test('Creates navigation transactions from inner lazy route to another lazy rout await expect(anotherLazyContent).toBeVisible(); // Validate the second transaction event - expect(secondEvent.transaction).toBe('/another-lazy/sub/:id/:subId'); - expect(secondEvent.type).toBe('transaction'); - expect(secondEvent.contexts?.trace?.op).toBe('navigation'); + expect(secondEvent.name).toBe('/another-lazy/sub/:id/:subId'); + expect(secondEvent.is_segment).toBe(true); + expect(getSpanOp(secondEvent)).toBe('navigation'); // Go back to the previous page to ensure history navigation works as expected - const goBackTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + const goBackTransactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy/inner/:id/:anotherId/:someAnotherId' ); }); @@ -211,17 +209,13 @@ test('Creates navigation transactions from inner lazy route to another lazy rout const goBackEvent = await goBackTransactionPromise; // Validate the second go back transaction event - expect(goBackEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); - expect(goBackEvent.type).toBe('transaction'); - expect(goBackEvent.contexts?.trace?.op).toBe('navigation'); + expect(goBackEvent.name).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(goBackEvent.is_segment).toBe(true); + expect(getSpanOp(goBackEvent)).toBe('navigation'); // Navigate to the upper route - const goUpperRouteTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId' - ); + const goUpperRouteTransactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy/inner/:id/:anotherId'; }); const navigationToUpper = page.locator('id=navigate-to-upper'); @@ -231,18 +225,18 @@ test('Creates navigation transactions from inner lazy route to another lazy rout const goUpperRouteEvent = await goUpperRouteTransactionPromise; // Validate the go upper route transaction event - expect(goUpperRouteEvent.transaction).toBe('/lazy/inner/:id/:anotherId'); - expect(goUpperRouteEvent.type).toBe('transaction'); - expect(goUpperRouteEvent.contexts?.trace?.op).toBe('navigation'); + expect(goUpperRouteEvent.name).toBe('/lazy/inner/:id/:anotherId'); + expect(goUpperRouteEvent.is_segment).toBe(true); + expect(getSpanOp(goUpperRouteEvent)).toBe('navigation'); }); -test('Does not send any duplicate navigation transaction names browsing between different routes', async ({ page }) => { +test('Does not send any duplicate navigation span names browsing between different routes', async ({ page }) => { const transactionNamesList: string[] = []; // Monitor and add all transaction names sent to Sentry for the navigations - const allTransactionsPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - if (transactionEvent?.transaction) { - transactionNamesList.push(transactionEvent.transaction); + const allTransactionsPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + if (span.is_segment) { + transactionNamesList.push(span.name); } if (transactionNamesList.length >= 5) { @@ -297,29 +291,21 @@ test('Does not send any duplicate navigation transaction names browsing between ]); }); -test('Does not create premature navigation transaction during long-running lazy route pageload', async ({ page }) => { - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction.includes('long-running') - ); +test('Does not create premature navigation span during long-running lazy route pageload', async ({ page }) => { + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name.includes('long-running'); }); - const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction === '/long-running/slow/:id' - ); + const pageloadPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/long-running/slow/:id'; }); await page.goto('/long-running/slow/12345'); const pageloadEvent = await pageloadPromise; - expect(pageloadEvent.transaction).toBe('/long-running/slow/:id'); - expect(pageloadEvent.contexts?.trace?.op).toBe('pageload'); + expect(pageloadEvent.name).toBe('/long-running/slow/:id'); + expect(getSpanOp(pageloadEvent)).toBe('pageload'); const slowLoadingContent = page.locator('id=slow-loading-content'); await expect(slowLoadingContent).toBeVisible({ timeout: 5000 }); @@ -340,20 +326,12 @@ test('Allows legitimate POP navigation (back/forward) after pageload completes', await expect(navigationToLongRunning).toBeVisible(); // Set up transaction listeners for both navigations - const firstNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/long-running/slow/:id' - ); + const firstNavigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/long-running/slow/:id'; }); - const backNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/' - ); + const backNavigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/'; }); await navigationToLongRunning.click(); @@ -363,8 +341,8 @@ test('Allows legitimate POP navigation (back/forward) after pageload completes', const firstNavigationEvent = await firstNavigationPromise; - expect(firstNavigationEvent.transaction).toBe('/long-running/slow/:id'); - expect(firstNavigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(firstNavigationEvent.name).toBe('/long-running/slow/:id'); + expect(getSpanOp(firstNavigationEvent)).toBe('navigation'); // Now navigate back using browser back button (POP event) // This should create a navigation transaction since pageload is complete @@ -377,18 +355,16 @@ test('Allows legitimate POP navigation (back/forward) after pageload completes', const backNavigationEvent = await backNavigationPromise; // Validate that the back navigation (POP) was properly tracked - expect(backNavigationEvent.transaction).toBe('/'); - expect(backNavigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(backNavigationEvent.name).toBe('/'); + expect(getSpanOp(backNavigationEvent)).toBe('navigation'); }); -test('Updates pageload transaction name correctly when span is cancelled early (document.hidden simulation)', async ({ +test('Updates pageload span name correctly when span is cancelled early (document.hidden simulation)', async ({ page, }) => { - const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + const transactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/lazy/inner/:id/:anotherId/:someAnotherId' ); }); @@ -420,29 +396,27 @@ test('Updates pageload transaction name correctly when span is cancelled early ( // Validate that the transaction event has the correct parameterized route name // even though the span was cancelled early due to document.hidden - expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); - expect(event.type).toBe('transaction'); - expect(event.contexts?.trace?.op).toBe('pageload'); + expect(event.name).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(event.is_segment).toBe(true); + expect(getSpanOp(event)).toBe('pageload'); // Check if the span was indeed cancelled (should have idle_span_finish_reason attribute) - const idleSpanFinishReason = event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']; + const idleSpanFinishReason = event.attributes['sentry.idle_span_finish_reason']?.value; if (idleSpanFinishReason) { // If the span was cancelled due to visibility change, verify it still got the right name expect(['externalFinish', 'cancelled']).toContain(idleSpanFinishReason); } }); -test('Updates navigation transaction name correctly when span is cancelled early (document.hidden simulation)', async ({ +test('Updates navigation span name correctly when span is cancelled early (document.hidden simulation)', async ({ page, }) => { // First go to home page await page.goto('/'); - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy/inner/:id/:anotherId/:someAnotherId' ); }); @@ -484,13 +458,13 @@ test('Updates navigation transaction name correctly when span is cancelled early // Validate that the transaction event has the correct parameterized route name // even though the span was cancelled early due to document.hidden - expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); - expect(event.type).toBe('transaction'); - expect(event.contexts?.trace?.op).toBe('navigation'); + expect(event.name).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(event.is_segment).toBe(true); + expect(getSpanOp(event)).toBe('navigation'); // Check if the span was indeed cancelled (should have cancellation_reason attribute or idle_span_finish_reason) - const cancellationReason = event.contexts?.trace?.data?.['sentry.cancellation_reason']; - const idleSpanFinishReason = event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']; + const cancellationReason = event.attributes['sentry.cancellation_reason']?.value; + const idleSpanFinishReason = event.attributes['sentry.idle_span_finish_reason']?.value; // Verify that the span was cancelled due to document.hidden if (cancellationReason) { @@ -502,33 +476,27 @@ test('Updates navigation transaction name correctly when span is cancelled early } }); -test('Creates separate transactions for rapid consecutive navigations', async ({ page }) => { +test('Creates separate spans for rapid consecutive navigations', async ({ page }) => { await page.goto('/'); // Set up transaction listeners - const firstTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + const firstTransactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy/inner/:id/:anotherId/:someAnotherId' ); }); - const secondTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/another-lazy/sub/:id/:subId' - ); + const secondTransactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/another-lazy/sub/:id/:subId'; }); // Third navigation promise - using counter to match second occurrence of same route let innerRouteMatchCount = 0; - const thirdTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + const thirdTransactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { if ( - transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + span.is_segment && + getSpanOp(span) === 'navigation' && + span.name === '/lazy/inner/:id/:anotherId/:someAnotherId' ) { innerRouteMatchCount++; return innerRouteMatchCount === 2; // Match the second occurrence @@ -553,25 +521,25 @@ test('Creates separate transactions for rapid consecutive navigations', async ({ const thirdEvent = await thirdTransactionPromise; // Verify transactions - expect(firstEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); - expect(firstEvent.contexts?.trace?.op).toBe('navigation'); - const firstTraceId = firstEvent.contexts?.trace?.trace_id; - const firstSpanId = firstEvent.contexts?.trace?.span_id; + expect(firstEvent.name).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(getSpanOp(firstEvent)).toBe('navigation'); + const firstTraceId = firstEvent.trace_id; + const firstSpanId = firstEvent.span_id; - expect(secondEvent.transaction).toBe('/another-lazy/sub/:id/:subId'); - expect(secondEvent.contexts?.trace?.op).toBe('navigation'); - expect(secondEvent.contexts?.trace?.status).toBe('ok'); + expect(secondEvent.name).toBe('/another-lazy/sub/:id/:subId'); + expect(getSpanOp(secondEvent)).toBe('navigation'); + expect(secondEvent.status).toBe('ok'); - const secondTraceId = secondEvent.contexts?.trace?.trace_id; - const secondSpanId = secondEvent.contexts?.trace?.span_id; + const secondTraceId = secondEvent.trace_id; + const secondSpanId = secondEvent.span_id; // Verify third transaction - expect(thirdEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); - expect(thirdEvent.contexts?.trace?.op).toBe('navigation'); - expect(thirdEvent.contexts?.trace?.status).toBe('ok'); + expect(thirdEvent.name).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(getSpanOp(thirdEvent)).toBe('navigation'); + expect(thirdEvent.status).toBe('ok'); - const thirdTraceId = thirdEvent.contexts?.trace?.trace_id; - const thirdSpanId = thirdEvent.contexts?.trace?.span_id; + const thirdTraceId = thirdEvent.trace_id; + const thirdSpanId = thirdEvent.span_id; // Verify each navigation created a separate transaction with unique trace and span IDs expect(firstTraceId).toBeDefined(); @@ -589,13 +557,9 @@ test('Creates separate transactions for rapid consecutive navigations', async ({ expect(firstSpanId).not.toBe(thirdSpanId); }); -test('Creates pageload transaction with parameterized route for delayed lazy route', async ({ page }) => { - const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction === '/delayed-lazy/:id' - ); +test('Creates pageload span with parameterized route for delayed lazy route', async ({ page }) => { + const pageloadPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/delayed-lazy/:id'; }); await page.goto('/delayed-lazy/123'); @@ -607,20 +571,16 @@ test('Creates pageload transaction with parameterized route for delayed lazy rou await expect(page.locator('id=delayed-lazy-id')).toHaveText('ID: 123'); await expect(page.locator('id=delayed-lazy-path')).toHaveText('/delayed-lazy/123'); - expect(pageloadEvent.transaction).toBe('/delayed-lazy/:id'); - expect(pageloadEvent.contexts?.trace?.op).toBe('pageload'); - expect(pageloadEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(pageloadEvent.name).toBe('/delayed-lazy/:id'); + expect(getSpanOp(pageloadEvent)).toBe('pageload'); + expect(pageloadEvent.attributes['sentry.segment.name.source']?.value).toBe('route'); }); -test('Creates navigation transaction with parameterized route for delayed lazy route', async ({ page }) => { +test('Creates navigation span with parameterized route for delayed lazy route', async ({ page }) => { await page.goto('/'); - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/delayed-lazy/:id' - ); + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/delayed-lazy/:id'; }); const navigationLink = page.locator('id=navigation-to-delayed-lazy'); @@ -634,22 +594,18 @@ test('Creates navigation transaction with parameterized route for delayed lazy r await expect(page.locator('id=delayed-lazy-id')).toHaveText('ID: 123'); await expect(page.locator('id=delayed-lazy-path')).toHaveText('/delayed-lazy/123'); - expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); - expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); - expect(navigationEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(navigationEvent.name).toBe('/delayed-lazy/:id'); + expect(getSpanOp(navigationEvent)).toBe('navigation'); + expect(navigationEvent.attributes['sentry.segment.name.source']?.value).toBe('route'); }); -test('Creates navigation transaction when navigating with query parameters from home to route', async ({ page }) => { +test('Creates navigation span when navigating with query parameters from home to route', async ({ page }) => { await page.goto('/'); // Navigate from / to /delayed-lazy/123?source=homepage // This should create a navigation transaction with the parameterized route name - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/delayed-lazy/:id' - ); + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/delayed-lazy/:id'; }); const navigationLink = page.locator('id=navigation-to-delayed-lazy-with-query'); @@ -667,13 +623,13 @@ test('Creates navigation transaction when navigating with query parameters from // Verify the navigation transaction has the correct parameterized route name // Query parameters don't affect the transaction name (still /delayed-lazy/:id) - expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); - expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); - expect(navigationEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); - expect(navigationEvent.contexts?.trace?.status).toBe('ok'); + expect(navigationEvent.name).toBe('/delayed-lazy/:id'); + expect(getSpanOp(navigationEvent)).toBe('navigation'); + expect(navigationEvent.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(navigationEvent.status).toBe('ok'); }); -test('Creates separate navigation transaction when changing only query parameters on same route', async ({ page }) => { +test('Creates separate navigation span when changing only query parameters on same route', async ({ page }) => { await page.goto('/delayed-lazy/123'); // Wait for the page to fully load @@ -682,12 +638,8 @@ test('Creates separate navigation transaction when changing only query parameter // Navigate from /delayed-lazy/123 to /delayed-lazy/123?view=detailed // This is a query-only change on the same route - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/delayed-lazy/:id' - ); + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/delayed-lazy/:id'; }); const queryLink = page.locator('id=link-to-query-view-detailed'); @@ -701,25 +653,21 @@ test('Creates separate navigation transaction when changing only query parameter await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: detailed'); // Query-only navigation should create a navigation transaction - expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); - expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); - expect(navigationEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); - expect(navigationEvent.contexts?.trace?.status).toBe('ok'); + expect(navigationEvent.name).toBe('/delayed-lazy/:id'); + expect(getSpanOp(navigationEvent)).toBe('navigation'); + expect(navigationEvent.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(navigationEvent.status).toBe('ok'); }); -test('Creates separate navigation transactions for multiple query parameter changes', async ({ page }) => { +test('Creates separate navigation spans for multiple query parameter changes', async ({ page }) => { await page.goto('/delayed-lazy/123'); const delayedReady = page.locator('id=delayed-lazy-ready'); await expect(delayedReady).toBeVisible(); // First query change: /delayed-lazy/123 -> /delayed-lazy/123?view=detailed - const firstNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/delayed-lazy/:id' - ); + const firstNavigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/delayed-lazy/:id'; }); const firstQueryLink = page.locator('id=link-to-query-view-detailed'); @@ -727,17 +675,17 @@ test('Creates separate navigation transactions for multiple query parameter chan await firstQueryLink.click(); const firstNavigationEvent = await firstNavigationPromise; - const firstTraceId = firstNavigationEvent.contexts?.trace?.trace_id; + const firstTraceId = firstNavigationEvent.trace_id; await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: detailed'); // Second query change: /delayed-lazy/123?view=detailed -> /delayed-lazy/123?view=list - const secondNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + const secondNavigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/delayed-lazy/:id' && - transactionEvent.contexts?.trace?.trace_id !== firstTraceId + span.is_segment && + getSpanOp(span) === 'navigation' && + span.name === '/delayed-lazy/:id' && + span.trace_id !== firstTraceId ); }); @@ -746,15 +694,15 @@ test('Creates separate navigation transactions for multiple query parameter chan await secondQueryLink.click(); const secondNavigationEvent = await secondNavigationPromise; - const secondTraceId = secondNavigationEvent.contexts?.trace?.trace_id; + const secondTraceId = secondNavigationEvent.trace_id; await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: list'); // Both navigations should have created separate transactions - expect(firstNavigationEvent.transaction).toBe('/delayed-lazy/:id'); - expect(firstNavigationEvent.contexts?.trace?.op).toBe('navigation'); - expect(secondNavigationEvent.transaction).toBe('/delayed-lazy/:id'); - expect(secondNavigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(firstNavigationEvent.name).toBe('/delayed-lazy/:id'); + expect(getSpanOp(firstNavigationEvent)).toBe('navigation'); + expect(secondNavigationEvent.name).toBe('/delayed-lazy/:id'); + expect(getSpanOp(secondNavigationEvent)).toBe('navigation'); // Trace IDs should be different (separate transactions) expect(firstTraceId).toBeDefined(); @@ -762,7 +710,7 @@ test('Creates separate navigation transactions for multiple query parameter chan expect(firstTraceId).not.toBe(secondTraceId); }); -test('Creates navigation transaction when changing only hash on same route', async ({ page }) => { +test('Creates navigation span when changing only hash on same route', async ({ page }) => { await page.goto('/delayed-lazy/123'); const delayedReady = page.locator('id=delayed-lazy-ready'); @@ -770,12 +718,8 @@ test('Creates navigation transaction when changing only hash on same route', asy // Navigate from /delayed-lazy/123 to /delayed-lazy/123#section1 // This is a hash-only change on the same route - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/delayed-lazy/:id' - ); + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/delayed-lazy/:id'; }); const hashLink = page.locator('id=link-to-hash-section1'); @@ -788,25 +732,21 @@ test('Creates navigation transaction when changing only hash on same route', asy await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#section1'); // Hash-only navigation should create a navigation transaction - expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); - expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); - expect(navigationEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); - expect(navigationEvent.contexts?.trace?.status).toBe('ok'); + expect(navigationEvent.name).toBe('/delayed-lazy/:id'); + expect(getSpanOp(navigationEvent)).toBe('navigation'); + expect(navigationEvent.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(navigationEvent.status).toBe('ok'); }); -test('Creates separate navigation transactions for multiple hash changes', async ({ page }) => { +test('Creates separate navigation spans for multiple hash changes', async ({ page }) => { await page.goto('/delayed-lazy/123'); const delayedReady = page.locator('id=delayed-lazy-ready'); await expect(delayedReady).toBeVisible(); // First hash change: /delayed-lazy/123 -> /delayed-lazy/123#section1 - const firstNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/delayed-lazy/:id' - ); + const firstNavigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/delayed-lazy/:id'; }); const firstHashLink = page.locator('id=link-to-hash-section1'); @@ -814,17 +754,17 @@ test('Creates separate navigation transactions for multiple hash changes', async await firstHashLink.click(); const firstNavigationEvent = await firstNavigationPromise; - const firstTraceId = firstNavigationEvent.contexts?.trace?.trace_id; + const firstTraceId = firstNavigationEvent.trace_id; await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#section1'); // Second hash change: /delayed-lazy/123#section1 -> /delayed-lazy/123#section2 - const secondNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + const secondNavigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/delayed-lazy/:id' && - transactionEvent.contexts?.trace?.trace_id !== firstTraceId + span.is_segment && + getSpanOp(span) === 'navigation' && + span.name === '/delayed-lazy/:id' && + span.trace_id !== firstTraceId ); }); @@ -833,15 +773,15 @@ test('Creates separate navigation transactions for multiple hash changes', async await secondHashLink.click(); const secondNavigationEvent = await secondNavigationPromise; - const secondTraceId = secondNavigationEvent.contexts?.trace?.trace_id; + const secondTraceId = secondNavigationEvent.trace_id; await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#section2'); // Both navigations should have created separate transactions - expect(firstNavigationEvent.transaction).toBe('/delayed-lazy/:id'); - expect(firstNavigationEvent.contexts?.trace?.op).toBe('navigation'); - expect(secondNavigationEvent.transaction).toBe('/delayed-lazy/:id'); - expect(secondNavigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(firstNavigationEvent.name).toBe('/delayed-lazy/:id'); + expect(getSpanOp(firstNavigationEvent)).toBe('navigation'); + expect(secondNavigationEvent.name).toBe('/delayed-lazy/:id'); + expect(getSpanOp(secondNavigationEvent)).toBe('navigation'); // Trace IDs should be different (separate transactions) expect(firstTraceId).toBeDefined(); @@ -849,7 +789,7 @@ test('Creates separate navigation transactions for multiple hash changes', async expect(firstTraceId).not.toBe(secondTraceId); }); -test('Creates navigation transaction when changing both query and hash on same route', async ({ page }) => { +test('Creates navigation span when changing both query and hash on same route', async ({ page }) => { await page.goto('/delayed-lazy/123?view=list'); const delayedReady = page.locator('id=delayed-lazy-ready'); @@ -858,12 +798,8 @@ test('Creates navigation transaction when changing both query and hash on same r // Navigate from /delayed-lazy/123?view=list to /delayed-lazy/123?view=grid#results // This changes both query and hash - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/delayed-lazy/:id' - ); + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/delayed-lazy/:id'; }); const queryAndHashLink = page.locator('id=link-to-query-and-hash'); @@ -878,25 +814,21 @@ test('Creates navigation transaction when changing both query and hash on same r await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: grid'); // Combined query + hash navigation should create a navigation transaction - expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); - expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); - expect(navigationEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); - expect(navigationEvent.contexts?.trace?.status).toBe('ok'); + expect(navigationEvent.name).toBe('/delayed-lazy/:id'); + expect(getSpanOp(navigationEvent)).toBe('navigation'); + expect(navigationEvent.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(navigationEvent.status).toBe('ok'); }); -test('Creates navigation transaction with correct name for slow lazy route', async ({ page }) => { +test('Creates navigation span with correct name for slow lazy route', async ({ page }) => { // This test verifies that navigating to a slow lazy route (with top-level await) // creates a correctly named navigation transaction. // The route uses handle.lazyChildren with a 500ms delay. await page.goto('/'); - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/slow-fetch/:id' - ); + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/slow-fetch/:id'; }); // Navigate to slow-fetch route (500ms delay) @@ -912,21 +844,21 @@ test('Creates navigation transaction with correct name for slow lazy route', asy await expect(page.locator('id=slow-fetch-id')).toHaveText('ID: 123'); // Verify the transaction has the correct parameterized route name - expect(navigationEvent.transaction).toBe('/slow-fetch/:id'); - expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); - expect(navigationEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(navigationEvent.name).toBe('/slow-fetch/:id'); + expect(getSpanOp(navigationEvent)).toBe('navigation'); + expect(navigationEvent.attributes['sentry.segment.name.source']?.value).toBe('route'); }); -test('Rapid navigation does not corrupt transaction names when lazy handlers resolve late', async ({ page }) => { +test('Rapid navigation does not corrupt span names when lazy handlers resolve late', async ({ page }) => { await page.goto('/'); const allTransactions: Array<{ name: string; op: string }> = []; - const collectorPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - if (transactionEvent?.transaction && transactionEvent.contexts?.trace?.op) { + const collectorPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + if (span.is_segment && getSpanOp(span)) { allTransactions.push({ - name: transactionEvent.transaction, - op: transactionEvent.contexts.trace.op, + name: span.name, + op: getSpanOp(span) ?? '', }); } return allTransactions.length >= 2; @@ -969,59 +901,49 @@ test('Rapid navigation does not corrupt transaction names when lazy handlers res expect(hasValidRouteName).toBe(true); }); -test('Correctly names pageload transaction for slow lazy route with fetch', async ({ page }) => { +test('Correctly names pageload span for slow lazy route with fetch', async ({ page }) => { // This test verifies that a slow lazy route (with top-level await and fetch) - // creates a correctly named pageload transaction + // creates a correctly named pageload span - const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction === '/slow-fetch/:id' - ); + const spansPromise = collectStreamedSpans('react-router-7-lazy-routes', spans => { + return spans.some(span => getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/slow-fetch/:id'); }); await page.goto('/slow-fetch/123'); - const pageloadEvent = await pageloadPromise; + const spans = await spansPromise; + const pageloadSpan = spans.find(span => getSpanOp(span) === 'pageload' && span.is_segment)!; // Wait for the component to render (after the 500ms delay) const slowFetchContent = page.locator('id=slow-fetch-content'); await expect(slowFetchContent).toBeVisible({ timeout: 5000 }); await expect(page.locator('id=slow-fetch-id')).toHaveText('ID: 123'); - // Verify the transaction has the correct parameterized route name - expect(pageloadEvent.transaction).toBe('/slow-fetch/:id'); - expect(pageloadEvent.contexts?.trace?.op).toBe('pageload'); - expect(pageloadEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); - - // Verify the transaction contains a fetch span - const spans = pageloadEvent.spans || []; - const fetchSpan = spans.find( - (span: { op?: string; description?: string }) => - span.op === 'http.client' && span.description?.includes('/api/slow-data'), - ); + // Verify the span has the correct parameterized route name + expect(pageloadSpan.name).toBe('/slow-fetch/:id'); + expect(getSpanOp(pageloadSpan)).toBe('pageload'); + expect(pageloadSpan.attributes['sentry.segment.name.source']?.value).toBe('route'); + + // Verify the trace contains a fetch span. Streamed http.client names are only ` `, + // so the request URL comes from `url.full`. + const fetchSpan = spans.find(span => getSpanOp(span) === 'http.client' && hasUrlPart(span, '/api/slow-data')); // The fetch span should exist (even if the fetch failed, the span is created) expect(fetchSpan).toBeDefined(); }); -test('Three-route rapid navigation preserves distinct transaction names', async ({ page }) => { +test('Three-route rapid navigation preserves distinct span names', async ({ page }) => { const navigationTransactions: Array<{ name: string }> = []; - const navigationCollector = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - if (transactionEvent.contexts?.trace?.op === 'navigation') { - navigationTransactions.push({ name: transactionEvent.transaction || '' }); + const navigationCollector = waitForStreamedSpan('react-router-7-lazy-routes', span => { + if (getSpanOp(span) === 'navigation') { + navigationTransactions.push({ name: span.name }); } return false; }); - const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction === '/delayed-lazy/:id' - ); + const pageloadPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/delayed-lazy/:id'; }); // Pageload to delayed-lazy route @@ -1057,12 +979,12 @@ test('Three-route rapid navigation preserves distinct transaction names', async expect(corruptedToRoot.length).toBe(0); }); -test('Zero-wait rapid navigation does not corrupt transaction names', async ({ page }) => { +test('Zero-wait rapid navigation does not corrupt span names', async ({ page }) => { const navigationTransactions: Array<{ name: string }> = []; - const collector = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - if (transactionEvent.contexts?.trace?.op === 'navigation') { - navigationTransactions.push({ name: transactionEvent.transaction || '' }); + const collector = waitForStreamedSpan('react-router-7-lazy-routes', span => { + if (getSpanOp(span) === 'navigation') { + navigationTransactions.push({ name: span.name }); } return false; }); @@ -1095,11 +1017,11 @@ test('Zero-wait rapid navigation does not corrupt transaction names', async ({ p test('Browser back during lazy handler resolution does not corrupt', async ({ page }) => { const allTransactions: Array<{ name: string; op: string }> = []; - const collector = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - if (transactionEvent?.transaction && transactionEvent.contexts?.trace?.op) { + const collector = waitForStreamedSpan('react-router-7-lazy-routes', span => { + if (span.is_segment && getSpanOp(span)) { allTransactions.push({ - name: transactionEvent.transaction, - op: transactionEvent.contexts.trace.op, + name: span.name, + op: getSpanOp(span) ?? '', }); } return false; @@ -1132,9 +1054,9 @@ test('Browser back during lazy handler resolution does not corrupt', async ({ pa test('Multiple overlapping lazy handlers do not corrupt each other', async ({ page }) => { const navigationTransactions: Array<{ name: string }> = []; - const collector = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - if (transactionEvent.contexts?.trace?.op === 'navigation') { - navigationTransactions.push({ name: transactionEvent.transaction || '' }); + const collector = waitForStreamedSpan('react-router-7-lazy-routes', span => { + if (getSpanOp(span) === 'navigation') { + navigationTransactions.push({ name: span.name }); } return false; }); @@ -1176,12 +1098,12 @@ test('Multiple overlapping lazy handlers do not corrupt each other', async ({ pa } }); -test('Query/hash navigation does not corrupt transaction name', async ({ page }) => { +test('Query/hash navigation does not corrupt span name', async ({ page }) => { const navigationTransactions: Array<{ name: string }> = []; - const collectorPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - if (transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation') { - navigationTransactions.push({ name: transactionEvent.transaction }); + const collectorPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + if (span.is_segment && getSpanOp(span) === 'navigation') { + navigationTransactions.push({ name: span.name }); } return navigationTransactions.length >= 1; }); @@ -1233,12 +1155,8 @@ test('Query/hash navigation does not corrupt transaction name', async ({ page }) test('Slow lazy route pageload with early span end still gets parameterized route name (regression)', async ({ page, }) => { - const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - (transactionEvent.transaction?.startsWith('/slow-fetch') ?? false) - ); + const transactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.name.startsWith('/slow-fetch'); }); // idleTimeout=300 ends span before 500ms lazy route loads, timeout=1000 waits for lazy routes @@ -1246,33 +1164,29 @@ test('Slow lazy route pageload with early span end still gets parameterized rout const event = await transactionPromise; - expect(event.transaction).toBe('/slow-fetch/:id'); - expect(event.type).toBe('transaction'); - expect(event.contexts?.trace?.op).toBe('pageload'); - expect(event.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(event.name).toBe('/slow-fetch/:id'); + expect(event.is_segment).toBe(true); + expect(getSpanOp(event)).toBe('pageload'); + expect(event.attributes['sentry.segment.name.source']?.value).toBe('route'); - const idleSpanFinishReason = event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']; + const idleSpanFinishReason = event.attributes['sentry.idle_span_finish_reason']?.value; expect(['idleTimeout', 'externalFinish']).toContain(idleSpanFinishReason); }); // Regression: Wildcard route names should be upgraded to parameterized routes when lazy routes load test('Wildcard route pageload gets upgraded to parameterized route name (regression)', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - (transactionEvent.transaction?.startsWith('/wildcard-lazy') ?? false) - ); + const transactionPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.name.startsWith('/wildcard-lazy'); }); await page.goto('/wildcard-lazy/456?idleTimeout=300&timeout=1000'); const event = await transactionPromise; - expect(event.transaction).toBe('/wildcard-lazy/:id'); - expect(event.type).toBe('transaction'); - expect(event.contexts?.trace?.op).toBe('pageload'); - expect(event.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(event.name).toBe('/wildcard-lazy/:id'); + expect(event.is_segment).toBe(true); + expect(getSpanOp(event)).toBe('pageload'); + expect(event.attributes['sentry.segment.name.source']?.value).toBe('route'); }); // Regression: Navigation to slow lazy route should get parameterized name even if span ends early. @@ -1286,12 +1200,8 @@ test('Slow lazy route navigation with early span end still gets parameterized ro // Wait for pageload to complete await page.waitForTimeout(500); - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - (transactionEvent.transaction?.startsWith('/wildcard-lazy') ?? false) - ); + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name.startsWith('/wildcard-lazy'); }); // Navigate to wildcard-lazy route (500ms delay in module via top-level await) @@ -1303,10 +1213,10 @@ test('Slow lazy route navigation with early span end still gets parameterized ro const event = await navigationPromise; // The navigation transaction should have the parameterized route name - expect(event.transaction).toBe('/wildcard-lazy/:id'); - expect(event.type).toBe('transaction'); - expect(event.contexts?.trace?.op).toBe('navigation'); - expect(event.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(event.name).toBe('/wildcard-lazy/:id'); + expect(event.is_segment).toBe(true); + expect(getSpanOp(event)).toBe('navigation'); + expect(event.attributes['sentry.segment.name.source']?.value).toBe('route'); }); test('Captured navigation context is used instead of stale window.location during rapid navigation', async ({ @@ -1318,11 +1228,11 @@ test('Captured navigation context is used instead of stale window.location durin const allNavigationTransactions: Array<{ name: string; traceId: string }> = []; - const collectorPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - if (transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation') { + const collectorPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + if (span.is_segment && getSpanOp(span) === 'navigation') { allNavigationTransactions.push({ - name: transactionEvent.transaction, - traceId: transactionEvent.contexts.trace.trace_id || '', + name: span.name, + traceId: span.trace_id || '', }); } return allNavigationTransactions.length >= 2; @@ -1365,21 +1275,14 @@ test('Captured navigation context is used instead of stale window.location durin test('Second navigation span is not corrupted by first slow lazy handler completing late', async ({ page }) => { // Validates fix for race condition where slow lazy handler would update the wrong span. // Navigate to slow route (which fetches /api/slow-data), then quickly to fast route. - // Without fix: second transaction gets wrong name and/or contains leaked spans. + // Without fix: the second segment gets the wrong name and/or contains leaked spans. await page.goto('/'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const allNavigationTransactions: Array<{ name: string; traceId: string; spans: any[] }> = []; + const streamedSpans: SerializedStreamedSpan[] = []; - const collectorPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - if (transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation') { - allNavigationTransactions.push({ - name: transactionEvent.transaction, - traceId: transactionEvent.contexts.trace.trace_id || '', - spans: transactionEvent.spans || [], - }); - } + const collectorPromise = waitForStreamedSpans('react-router-7-lazy-routes', spans => { + streamedSpans.push(...spans); return false; }); @@ -1396,7 +1299,7 @@ test('Second navigation span is not corrupted by first slow lazy handler complet await expect(page.locator('id=another-lazy-route')).toBeVisible({ timeout: 10000 }); - // Wait for slow-fetch lazy handler to complete and transactions to be sent + // Wait for slow-fetch lazy handler to complete and spans to be sent await page.waitForTimeout(2000); await Promise.race([ @@ -1404,31 +1307,27 @@ test('Second navigation span is not corrupted by first slow lazy handler complet new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 3000)), ]).catch(() => {}); - expect(allNavigationTransactions.length).toBeGreaterThanOrEqual(1); + const navigationSegmentSpans = streamedSpans.filter(span => getSpanOp(span) === 'navigation' && span.is_segment); + expect(navigationSegmentSpans.length).toBeGreaterThanOrEqual(1); - // /another-lazy transaction must have correct name, not "/slow-fetch/:id" - const anotherLazyTransaction = allNavigationTransactions.find(t => t.name.startsWith('/another-lazy/sub')); - expect(anotherLazyTransaction).toBeDefined(); + // /another-lazy segment must have the correct name, not "/slow-fetch/:id" + const anotherLazySegment = navigationSegmentSpans.find(span => span.name.startsWith('/another-lazy/sub')); + expect(anotherLazySegment).toBeDefined(); - // Key assertion 2: /another-lazy transaction must NOT contain spans from /slow-fetch route - // The /api/slow-data fetch is triggered by the slow-fetch route's lazy loading - if (anotherLazyTransaction) { - const leakedSpans = anotherLazyTransaction.spans.filter( - span => span.description?.includes('slow-data') || span.data?.['url.full'].includes('slow-data'), - ); + // Key assertion 2: the /another-lazy trace must NOT contain spans from the /slow-fetch route. + // The /api/slow-data fetch is triggered by the slow-fetch route's lazy loading. + if (anotherLazySegment) { + const leakedSpans = childSpansOf(streamedSpans, anotherLazySegment).filter(span => hasUrlPart(span, 'slow-data')); expect(leakedSpans.length).toBe(0); } - // Key assertion 3: If slow-fetch transaction exists, verify it has the correct name + // Key assertion 3: If a slow-fetch segment exists, verify it has the correct name // (not corrupted to /another-lazy) - const slowFetchTransaction = allNavigationTransactions.find(t => t.name.includes('slow-fetch')); - if (slowFetchTransaction) { - expect(slowFetchTransaction.name).toMatch(/\/slow-fetch/); - // Verify slow-fetch transaction doesn't contain spans that belong to /another-lazy - const wrongSpans = slowFetchTransaction.spans.filter( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (span: any) => span.description?.includes('another-lazy') || span.data?.['url.full'].includes('another-lazy'), - ); + const slowFetchSegment = navigationSegmentSpans.find(span => span.name.includes('slow-fetch')); + if (slowFetchSegment) { + expect(slowFetchSegment.name).toMatch(/\/slow-fetch/); + // Verify the slow-fetch trace doesn't contain spans that belong to /another-lazy + const wrongSpans = childSpansOf(streamedSpans, slowFetchSegment).filter(span => hasUrlPart(span, 'another-lazy')); expect(wrongSpans.length).toBe(0); } }); @@ -1441,12 +1340,8 @@ test('Route manifest provides correct name when navigation span ends before lazy // Wait for pageload to complete await page.waitForTimeout(200); - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - (transactionEvent.transaction?.startsWith('/wildcard-lazy') ?? false) - ); + const navigationPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name.startsWith('/wildcard-lazy'); }); // Navigate to wildcard-lazy route (500ms delay in module via top-level await) @@ -1457,20 +1352,16 @@ test('Route manifest provides correct name when navigation span ends before lazy const event = await navigationPromise; // Should have parameterized name from manifest, not wildcard (/wildcard-lazy/*) - expect(event.transaction).toBe('/wildcard-lazy/:id'); - expect(event.type).toBe('transaction'); - expect(event.contexts?.trace?.op).toBe('navigation'); - expect(event.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(event.name).toBe('/wildcard-lazy/:id'); + expect(event.is_segment).toBe(true); + expect(getSpanOp(event)).toBe('navigation'); + expect(event.attributes['sentry.segment.name.source']?.value).toBe('route'); }); test('Route manifest provides correct name when pageload span ends before lazy route resolves', async ({ page }) => { // Short idle timeout (50ms) ensures span ends before lazy route (500ms) resolves - const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - (transactionEvent.transaction?.startsWith('/wildcard-lazy') ?? false) - ); + const pageloadPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.name.startsWith('/wildcard-lazy'); }); await page.goto('/wildcard-lazy/123?idleTimeout=50&timeout=0'); @@ -1478,151 +1369,124 @@ test('Route manifest provides correct name when pageload span ends before lazy r const event = await pageloadPromise; // Should have parameterized name from manifest, not wildcard (/wildcard-lazy/*) - expect(event.transaction).toBe('/wildcard-lazy/:id'); - expect(event.type).toBe('transaction'); - expect(event.contexts?.trace?.op).toBe('pageload'); - expect(event.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(event.name).toBe('/wildcard-lazy/:id'); + expect(event.is_segment).toBe(true); + expect(getSpanOp(event)).toBe('pageload'); + expect(event.attributes['sentry.segment.name.source']?.value).toBe('route'); }); -test('GQL fetch span is attributed to the correct navigation transaction when navigating from index to lazy GQL page', async ({ +test('GQL fetch span is attributed to the correct navigation segment when navigating from index to lazy GQL page', async ({ page, }) => { - const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction === '/' - ); - }); - - const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy-gql-a/fetch' - ); + const pageloadSpansPromise = collectStreamedSpans('react-router-7-lazy-routes', spans => { + return spans.some(span => getSpanOp(span) === 'pageload' && span.is_segment && span.name === '/'); }); await page.goto('/'); - const pageloadEvent = await pageloadPromise; + const pageloadSpans = await pageloadSpansPromise; + const pageloadSegment = pageloadSpans.find(span => getSpanOp(span) === 'pageload' && span.is_segment)!; // Pageload should NOT contain any /api/graphql spans (neither UserAQuery nor UserBQuery) - const pageloadSpans = pageloadEvent.spans || []; - const pageloadGqlSpans = pageloadSpans.filter( - (span: { op?: string; description?: string; data?: { url?: string } }) => - span.op === 'http.client' && - (span.description?.includes('/api/graphql') || span.data?.['url.full'].includes('/api/graphql')), + const pageloadGqlSpans = childSpansOf(pageloadSpans, pageloadSegment).filter( + span => getSpanOp(span) === 'http.client' && hasUrlPart(span, '/api/graphql'), ); expect(pageloadGqlSpans.length).toBe(0); + const navigationSpansPromise = collectStreamedSpans('react-router-7-lazy-routes', spans => { + return spans.some(span => getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy-gql-a/fetch'); + }); + // Navigate to lazy GQL page A const gqlLink = page.locator('id=navigation-to-gql-a'); await expect(gqlLink).toBeVisible(); await gqlLink.click(); - const navigationEvent = await navigationPromise; + const navigationSpans = await navigationSpansPromise; + const navigationSegment = navigationSpans.find( + span => getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy-gql-a/fetch', + )!; // Verify the lazy GQL page rendered await expect(page.locator('id=gql-page-a')).toBeVisible(); - // Verify the navigation transaction has the correct name - expect(navigationEvent.transaction).toBe('/lazy-gql-a/fetch'); - expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); + // Verify the navigation segment has the correct name + expect(navigationSegment.name).toBe('/lazy-gql-a/fetch'); + expect(getSpanOp(navigationSegment)).toBe('navigation'); - // Verify the UserAQuery GQL fetch span is inside this navigation transaction - const navSpans = navigationEvent.spans || []; - const userASpans = navSpans.filter( - (span: { op?: string; description?: string; data?: { url?: string } }) => - span.op === 'http.client' && - (span.description?.includes('UserAQuery') || span.data?.['url.full'].includes('UserAQuery')), - ); + // Verify the UserAQuery GQL fetch span is inside this navigation segment's trace + const navChildSpans = childSpansOf(navigationSpans, navigationSegment); + const userASpans = navChildSpans.filter(span => getSpanOp(span) === 'http.client' && hasUrlPart(span, 'UserAQuery')); expect(userASpans.length).toBe(1); - // Verify NO UserBQuery spans leaked into this transaction - const userBSpans = navSpans.filter( - (span: { op?: string; description?: string; data?: { url?: string } }) => - span.op === 'http.client' && - (span.description?.includes('UserBQuery') || span.data?.['url.full'].includes('UserBQuery')), - ); + // Verify NO UserBQuery spans leaked into this trace + const userBSpans = navChildSpans.filter(span => getSpanOp(span) === 'http.client' && hasUrlPart(span, 'UserBQuery')); expect(userBSpans.length).toBe(0); }); -test('GQL fetch spans are attributed to correct navigation transactions when navigating between two lazy GQL pages', async ({ +test('GQL fetch spans are attributed to correct navigation segments when navigating between two lazy GQL pages', async ({ page, }) => { await page.goto('/'); await page.waitForTimeout(500); // Navigate to GQL page A - const firstNavPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy-gql-a/fetch' - ); + const firstNavSpansPromise = collectStreamedSpans('react-router-7-lazy-routes', spans => { + return spans.some(span => getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy-gql-a/fetch'); }); const gqlALink = page.locator('id=navigation-to-gql-a'); await expect(gqlALink).toBeVisible(); await gqlALink.click(); - const firstNavEvent = await firstNavPromise; + const firstNavSpans = await firstNavSpansPromise; + const firstNavSegment = firstNavSpans.find( + span => getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy-gql-a/fetch', + )!; await expect(page.locator('id=gql-page-a')).toBeVisible(); // First navigation should have exactly the UserAQuery span - const firstNavSpans = firstNavEvent.spans || []; - const firstUserASpans = firstNavSpans.filter( - (span: { op?: string; description?: string; data?: { url?: string } }) => - span.op === 'http.client' && - (span.description?.includes('UserAQuery') || span.data?.['url.full'].includes('UserAQuery')), + const firstNavChildSpans = childSpansOf(firstNavSpans, firstNavSegment); + const firstUserASpans = firstNavChildSpans.filter( + span => getSpanOp(span) === 'http.client' && hasUrlPart(span, 'UserAQuery'), ); expect(firstUserASpans.length).toBe(1); // First navigation must NOT contain UserBQuery spans - const firstUserBSpans = firstNavSpans.filter( - (span: { op?: string; description?: string; data?: { url?: string } }) => - span.op === 'http.client' && - (span.description?.includes('UserBQuery') || span.data?.['url.full'].includes('UserBQuery')), + const firstUserBSpans = firstNavChildSpans.filter( + span => getSpanOp(span) === 'http.client' && hasUrlPart(span, 'UserBQuery'), ); expect(firstUserBSpans.length).toBe(0); // Now navigate from GQL page A to GQL page B - const secondNavPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction === '/lazy-gql-b/fetch' - ); + const secondNavSpansPromise = collectStreamedSpans('react-router-7-lazy-routes', spans => { + return spans.some(span => getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy-gql-b/fetch'); }); const gqlBLink = page.locator('id=navigate-to-gql-b'); await expect(gqlBLink).toBeVisible(); await gqlBLink.click(); - const secondNavEvent = await secondNavPromise; + const secondNavSpans = await secondNavSpansPromise; + const secondNavSegment = secondNavSpans.find( + span => getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/lazy-gql-b/fetch', + )!; await expect(page.locator('id=gql-page-b')).toBeVisible(); // Second navigation should have exactly the UserBQuery span - const secondNavSpans = secondNavEvent.spans || []; - const secondUserBSpans = secondNavSpans.filter( - (span: { op?: string; description?: string; data?: { url?: string } }) => - span.op === 'http.client' && - (span.description?.includes('UserBQuery') || span.data?.['url.full'].includes('UserBQuery')), + const secondNavChildSpans = childSpansOf(secondNavSpans, secondNavSegment); + const secondUserBSpans = secondNavChildSpans.filter( + span => getSpanOp(span) === 'http.client' && hasUrlPart(span, 'UserBQuery'), ); expect(secondUserBSpans.length).toBe(1); // Second navigation must NOT contain UserAQuery spans (no leaking from first nav) - const secondUserASpans = secondNavSpans.filter( - (span: { op?: string; description?: string; data?: { url?: string } }) => - span.op === 'http.client' && - (span.description?.includes('UserAQuery') || span.data?.['url.full'].includes('UserAQuery')), + const secondUserASpans = secondNavChildSpans.filter( + span => getSpanOp(span) === 'http.client' && hasUrlPart(span, 'UserAQuery'), ); expect(secondUserASpans.length).toBe(0); - // Verify the two transactions have different trace IDs - const firstTraceId = firstNavEvent.contexts?.trace?.trace_id; - const secondTraceId = secondNavEvent.contexts?.trace?.trace_id; - expect(firstTraceId).toBeDefined(); - expect(secondTraceId).toBeDefined(); - expect(firstTraceId).not.toBe(secondTraceId); + // Verify the two segments have different trace IDs + expect(firstNavSegment.trace_id).toBeDefined(); + expect(secondNavSegment.trace_id).toBeDefined(); + expect(firstNavSegment.trace_id).not.toBe(secondNavSegment.trace_id); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behaviour.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behaviour.test.ts index 7826d7ced00e..25a12bb14ea1 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behaviour.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behaviour.test.ts @@ -1,13 +1,9 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('lazyRouteTimeout: Routes load within timeout window', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction.includes('deep') - ); + const spanPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name.includes('deep'); }); // Route takes ~900ms, timeout allows 1050ms (50 + 1000) @@ -18,21 +14,17 @@ test('lazyRouteTimeout: Routes load within timeout window', async ({ page }) => await expect(navigationLink).toBeVisible(); await navigationLink.click(); - const event = await transactionPromise; + const span = await spanPromise; // Should get full parameterized route - expect(event.transaction).toBe('/deep/level2/level3/:id'); - expect(event.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); - expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); + expect(span.name).toBe('/deep/level2/level3/:id'); + expect(span.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(span.attributes['sentry.idle_span_finish_reason']?.value).toBe('idleTimeout'); }); test('lazyRouteTimeout: Infinity timeout always waits for routes', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction.includes('deep') - ); + const spanPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name.includes('deep'); }); // Infinity timeout → waits as long as possible (capped at finalTimeout to prevent indefinite hangs) @@ -42,85 +34,73 @@ test('lazyRouteTimeout: Infinity timeout always waits for routes', async ({ page await expect(navigationLink).toBeVisible(); await navigationLink.click(); - const event = await transactionPromise; + const span = await spanPromise; // Should wait for routes to load (up to finalTimeout) and get full route - expect(event.transaction).toBe('/deep/level2/level3/:id'); - expect(event.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); - expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); + expect(span.name).toBe('/deep/level2/level3/:id'); + expect(span.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(span.attributes['sentry.idle_span_finish_reason']?.value).toBe('idleTimeout'); }); test('idleTimeout: Captures all activity with increased timeout', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction.includes('deep') - ); + const spanPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name.includes('deep'); }); - // High idleTimeout (5000ms) ensures transaction captures all lazy loading activity + // High idleTimeout (5000ms) ensures the span captures all lazy loading activity await page.goto('/?idleTimeout=5000'); const navigationLink = page.locator('id=navigation-to-deep'); await expect(navigationLink).toBeVisible(); await navigationLink.click(); - const event = await transactionPromise; + const span = await spanPromise; - expect(event.transaction).toBe('/deep/level2/level3/:id'); - expect(event.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); - expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); + expect(span.name).toBe('/deep/level2/level3/:id'); + expect(span.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(span.attributes['sentry.idle_span_finish_reason']?.value).toBe('idleTimeout'); - // Transaction should wait for full idle timeout (5+ seconds) - const duration = event.timestamp! - event.start_timestamp; + // The span should wait for the full idle timeout (5+ seconds) + const duration = span.end_timestamp - span.start_timestamp; expect(duration).toBeGreaterThan(5.0); expect(duration).toBeLessThan(7.0); }); test('idleTimeout: Finishes prematurely with low timeout', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.transaction.includes('deep') - ); + const spanPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name.includes('deep'); }); // Very low idleTimeout (50ms) and lazyRouteTimeout (100ms) - // Transaction finishes quickly, but still gets parameterized route name + // The span finishes quickly, but still gets a parameterized route name await page.goto('/?idleTimeout=50&timeout=100'); const navigationLink = page.locator('id=navigation-to-deep'); await expect(navigationLink).toBeVisible(); await navigationLink.click(); - const event = await transactionPromise; + const span = await spanPromise; - expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); - expect(event.transaction).toBe('/deep/level2/level3/:id'); - expect(event.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); + expect(span.attributes['sentry.idle_span_finish_reason']?.value).toBe('idleTimeout'); + expect(span.name).toBe('/deep/level2/level3/:id'); + expect(span.attributes['sentry.segment.name.source']?.value).toBe('route'); - // Transaction should finish quickly (< 200ms) - const duration = event.timestamp! - event.start_timestamp; + // The span should finish quickly (< 200ms) + const duration = span.end_timestamp - span.start_timestamp; expect(duration).toBeLessThan(0.2); }); test('idleTimeout: Pageload on deeply nested route', async ({ page }) => { - const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { - return ( - !!transactionEvent?.transaction && - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.transaction.includes('deep') - ); + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-lazy-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment && span.name.includes('deep'); }); // Direct pageload to deeply nested route (not navigation) await page.goto('/deep/level2/level3/12345'); - const pageloadEvent = await pageloadPromise; + const pageloadSpan = await pageloadSpanPromise; - expect(pageloadEvent.transaction).toBe('/deep/level2/level3/:id'); - expect(pageloadEvent.contexts?.trace?.data?.['sentry.segment.name.source']).toBe('route'); - expect(pageloadEvent.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); + expect(pageloadSpan.name).toBe('/deep/level2/level3/:id'); + expect(pageloadSpan.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(pageloadSpan.attributes['sentry.idle_span_finish_reason']?.value).toBe('idleTimeout'); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/main.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/main.tsx index eb0b44122b62..87131036ce76 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/main.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/main.tsx @@ -18,7 +18,6 @@ import User from './pages/User'; const replay = Sentry.replayIntegration(); Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/errors.test.ts index e31d3c4066d4..7736abdbad64 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/errors.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Sends correct error event', async ({ page, baseURL }) => { const errorEventPromise = waitForError('react-router-7-spa', event => { @@ -30,8 +30,8 @@ test('Sends correct error event', async ({ page, baseURL }) => { }); test('Sets correct transactionName', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-7-spa', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-spa', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); const errorEventPromise = waitForError('react-router-7-spa', event => { @@ -39,9 +39,9 @@ test('Sets correct transactionName', async ({ page }) => { }); await page.goto('/'); - const transactionEvent = await transactionPromise; + const pageloadSpan = await pageloadSpanPromise; - // Only capture error once transaction was sent + // Only capture error once the pageload span was sent const exceptionButton = page.locator('id=exception-button'); await exceptionButton.click(); @@ -53,7 +53,7 @@ test('Sets correct transactionName', async ({ page }) => { expect(errorEvent.transaction).toEqual('/'); expect(errorEvent.contexts?.trace).toEqual({ - trace_id: transactionEvent.contexts?.trace?.trace_id, - span_id: expect.not.stringContaining(transactionEvent.contexts?.trace?.span_id || ''), + trace_id: pageloadSpan.trace_id, + span_id: expect.not.stringContaining(pageloadSpan.span_id), }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/navigation-trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/navigation-trace-propagation.test.ts index e9867237903a..da621a5729e9 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/navigation-trace-propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/navigation-trace-propagation.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('propagates the navigation trace (not the stale pageload trace) for a fetch in a route mount effect', async ({ page, @@ -15,22 +15,22 @@ test('propagates the navigation trace (not the stale pageload trace) for a fetch }); }); - const pageloadTxnPromise = waitForTransaction('react-router-7-spa', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-spa', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const navigationTxnPromise = waitForTransaction('react-router-7-spa', async transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/products'; + const navigationSpanPromise = waitForStreamedSpan('react-router-7-spa', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/products'; }); await page.goto('/'); - const pageloadTxn = await pageloadTxnPromise; + const pageloadSpan = await pageloadSpanPromise; await page.locator('id=navigation-products').click(); - const navigationTxn = await navigationTxnPromise; + const navigationSpan = await navigationSpanPromise; - const pageloadTraceId = pageloadTxn.contexts?.trace?.trace_id; - const navigationTraceId = navigationTxn.contexts?.trace?.trace_id; + const pageloadTraceId = pageloadSpan.trace_id; + const navigationTraceId = navigationSpan.trace_id; const propagatedTraceId = productsRequestSentryTrace?.split('-')[0]; expect(pageloadTraceId).toBeDefined(); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/spans.test.ts similarity index 57% rename from dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/transactions.test.ts rename to dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/spans.test.ts index 81dc4d25d274..ed4a0832515e 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/spans.test.ts @@ -1,69 +1,51 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpan, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('sends a pageload transaction with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-7-spa', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; +test('sends a pageload span with a parameterized URL', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-7-spa', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/`); - const rootSpan = await transactionPromise; - - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, + const span = await spanPromise; + + expect(span.name).toBe('/'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, }); }); -test('sends a navigation transaction with a parameterized URL', async ({ page }) => { +test('sends a navigation span with a parameterized URL', async ({ page }) => { page.on('console', msg => console.log(msg.text())); - const pageloadTxnPromise = waitForTransaction('react-router-7-spa', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-spa', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const navigationTxnPromise = waitForTransaction('react-router-7-spa', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + const navigationSpanPromise = waitForStreamedSpan('react-router-7-spa', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; }); await page.goto(`/`); - await pageloadTxnPromise; + await pageloadSpanPromise; const linkElement = page.locator('id=navigation'); - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/user/:id', - 'url.path': '/user/5', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), - }, - }, - }, - transaction: '/user/:id', - transaction_info: { - source: 'route', - }, + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/entry.client.tsx index 8fc9f5510160..0d928d68760a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/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 dsn: 'https://username@domain/123', tunnel: `http://localhost:3031/`, // proxy server diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tests/db.test.ts index 345ae42d0629..b2566fcf1d12 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tests/db.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tests/db.test.ts @@ -1,43 +1,52 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +// Under span streaming the mysql span name is the query summary, so both queries here are named +// `SELECT`. `db.query.text` is what tells them apart. test('a real mysql query emits a db span with orchestrion-channel attributes', async ({ request }) => { - const transactionPromise = waitForTransaction('react-router-8-cloudflare', transactionEvent => { + const spansPromise = collectStreamedSpans('react-router-8-cloudflare', spans => { return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - (transactionEvent.spans?.some(span => span.op === 'db') ?? false) + spans.some(span => getSpanOp(span) === 'http.server' && span.is_segment) && + spans.some(span => getSpanOp(span) === 'db') ); }); const res = await request.get('/performance/db-mysql'); expect(res.status()).toBe(200); - const transactionEvent = await transactionPromise; - const dbSpans = transactionEvent.spans!.filter(span => span.op === 'db'); + const spans = await spansPromise; + const dbSpans = spans.filter(span => getSpanOp(span) === 'db'); - const firstQuery = dbSpans.find(span => span.description === 'SELECT 1 + 1 AS solution'); + const firstQuery = dbSpans.find(span => span.attributes['db.query.text']?.value === 'SELECT 1 + 1 AS solution'); expect(firstQuery).toBeDefined(); - expect(firstQuery!.data?.['sentry.origin']).toBe('auto.db.mysql'); - expect(firstQuery!.data?.['db.system.name']).toBe('mysql'); - expect(firstQuery!.data?.['db.query.text']).toBe('SELECT 1 + 1 AS solution'); - expect(firstQuery!.data?.['server.address']).toBe('127.0.0.1'); - expect(firstQuery!.data?.['server.port']).toBe(3306); - expect(firstQuery!.data?.['db.user']).toBe('root'); + expect(firstQuery!.name).toBe('SELECT'); + expect(firstQuery!.attributes['sentry.origin']?.value).toBe('auto.db.mysql'); + expect(firstQuery!.attributes['db.system.name']?.value).toBe('mysql'); + expect(firstQuery!.attributes['server.address']?.value).toBe('127.0.0.1'); + expect(firstQuery!.attributes['server.port']?.value).toBe(3306); + expect(firstQuery!.attributes['db.user']?.value).toBe('root'); }); -test('a nested query lands on the same transaction (async context restored)', async ({ request }) => { - const transactionPromise = waitForTransaction('react-router-8-cloudflare', transactionEvent => { +test('a nested query lands on the same segment (async context restored)', async ({ request }) => { + const spansPromise = collectStreamedSpans('react-router-8-cloudflare', spans => { return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - (transactionEvent.spans?.filter(span => span.op === 'db').length ?? 0) >= 2 + spans.some(span => getSpanOp(span) === 'http.server' && span.is_segment) && + spans.filter(span => getSpanOp(span) === 'db').length >= 2 ); }); const res = await request.get('/performance/db-mysql'); expect(res.status()).toBe(200); - const transactionEvent = await transactionPromise; - const descriptions = transactionEvent.spans!.filter(span => span.op === 'db').map(span => span.description); - expect(descriptions).toContain('SELECT 1 + 1 AS solution'); - expect(descriptions).toContain('SELECT NOW()'); + const spans = await spansPromise; + const segmentSpan = spans.find(span => getSpanOp(span) === 'http.server' && span.is_segment)!; + const dbSpans = spans.filter(span => getSpanOp(span) === 'db'); + + const queryTexts = dbSpans.map(span => span.attributes['db.query.text']?.value); + expect(queryTexts).toContain('SELECT 1 + 1 AS solution'); + expect(queryTexts).toContain('SELECT NOW()'); + + for (const dbSpan of dbSpans) { + expect(dbSpan.trace_id).toBe(segmentSpan.trace_id); + } }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/workers/app.ts b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/workers/app.ts index d80e3741947c..d9a6c5451d72 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/workers/app.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/workers/app.ts @@ -11,7 +11,6 @@ interface Env { // `diagnostics_channel` publishers would fire with nobody subscribed. export default Sentry.withSentry( (env: Env) => ({ - traceLifecycle: 'static', dsn: env.E2E_TEST_DSN, tunnel: 'http://localhost:3031/', tracesSampleRate: 1.0, diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/src/index.tsx index dd76f1229e98..8bc0a6023c55 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/src/index.tsx @@ -18,7 +18,6 @@ import Index from './pages/Index'; const replay = Sentry.replayIntegration(); Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/tests/spans.test.ts new file mode 100644 index 000000000000..04a164b1736f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/tests/spans.test.ts @@ -0,0 +1,132 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('sends a pageload span with a parameterized URL', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-8-cross-usage', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/projects/123/views/234/567`); + + const span = await spanPromise; + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(span.name).toBe('/projects/:projectId/views/:viewId/:detailId'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/projects/:projectId/views/:viewId/:detailId', type: 'string' }, + 'url.path': { value: '/projects/123/views/234/567', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/views\/234\/567$/), + type: 'string', + }, + }); +}); + +test('sends a pageload span with a parameterized URL - alternative route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-8-cross-usage', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/projects/234/old-views/234/567`); + + const span = await spanPromise; + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(span.name).toBe('/projects/:projectId/old-views/:viewId/:detailId'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/projects/:projectId/old-views/:viewId/:detailId', type: 'string' }, + 'url.path': { value: '/projects/234/old-views/234/567', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/234\/old-views\/234\/567$/), + type: 'string', + }, + }); +}); + +test('sends a navigation span with a parameterized URL', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-8-cross-usage', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-8-cross-usage', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + const pageloadSpan = await pageloadSpanPromise; + + expect(pageloadSpan.name).toBe('/'); + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); + + const linkElement = page.locator('id=navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(navigationSpan.name).toBe('/projects/:projectId/views/:viewId/:detailId'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/projects/:projectId/views/:viewId/:detailId', type: 'string' }, + 'url.path': { value: '/projects/123/views/456/789', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/views\/456\/789$/), + type: 'string', + }, + }); +}); + +test('sends a navigation span with a parameterized URL - alternative route', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-8-cross-usage', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-8-cross-usage', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + const pageloadSpan = await pageloadSpanPromise; + + expect(pageloadSpan.name).toBe('/'); + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); + + const linkElement = page.locator('id=old-navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(navigationSpan.name).toBe('/projects/:projectId/old-views/:viewId/:detailId'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/projects/:projectId/old-views/:viewId/:detailId', type: 'string' }, + 'url.path': { value: '/projects/123/old-views/345/654', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/old-views\/345\/654$/), + type: 'string', + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/tests/transactions.test.ts deleted file mode 100644 index 1865b8ca16c5..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/tests/transactions.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('sends a pageload transaction with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-8-cross-usage', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/projects/123/views/234/567`); - - const rootSpan = await transactionPromise; - - expect((await page.innerHTML('#root')).includes('Details')).toBe(true); - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/projects/:projectId/views/:viewId/:detailId', - 'url.path': '/projects/123/views/234/567', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/views\/234\/567$/), - }, - }, - }, - transaction: '/projects/:projectId/views/:viewId/:detailId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('sends a pageload transaction with a parameterized URL - alternative route', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-8-cross-usage', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/projects/234/old-views/234/567`); - - const rootSpan = await transactionPromise; - - expect((await page.innerHTML('#root')).includes('Details')).toBe(true); - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/projects/:projectId/old-views/:viewId/:detailId', - 'url.path': '/projects/234/old-views/234/567', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/234\/old-views\/234\/567$/), - }, - }, - }, - transaction: '/projects/:projectId/old-views/:viewId/:detailId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('sends a navigation transaction with a parameterized URL', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('react-router-8-cross-usage', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-8-cross-usage', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - const pageloadTxn = await pageloadTxnPromise; - - expect(pageloadTxn).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, - }); - - const linkElement = page.locator('id=navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Details')).toBe(true); - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/projects/:projectId/views/:viewId/:detailId', - 'url.path': '/projects/123/views/456/789', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/views\/456\/789$/), - }, - }, - }, - transaction: '/projects/:projectId/views/:viewId/:detailId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('sends a navigation transaction with a parameterized URL - alternative route', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('react-router-8-cross-usage', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-8-cross-usage', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - const pageloadTxn = await pageloadTxnPromise; - - expect(pageloadTxn).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, - }); - - const linkElement = page.locator('id=old-navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Details')).toBe(true); - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/projects/:projectId/old-views/:viewId/:detailId', - 'url.path': '/projects/123/old-views/345/654', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/old-views\/345\/654$/), - }, - }, - }, - transaction: '/projects/:projectId/old-views/:viewId/:detailId', - transaction_info: { - source: 'route', - }, - }); -}); 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..5a703eed6735 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'; + test('should send middleware span on pageload', async ({ page }) => { + const serverSpansPromise = collectStreamedSpans(APP_NAME, spans => { + return ( + spans.some(span => span.name === 'GET /performance/with-middleware' && span.is_segment) && + spans.some(span => span.name === 'authMiddleware') + ); }); - const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with-middleware'; - }); - - const customMiddlewareTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '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.name === 'GET /performance/with-middleware' && 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..7d94e147f228 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,32 @@ 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 => { + test('server loader emits db.query child spans on the http.server segment', async ({ page }) => { + const spansPromise = collectStreamedSpans(APP_NAME, spans => { return ( - transactionEvent.transaction === 'GET /performance/redis' && - (transactionEvent.spans?.some(span => span.op === 'db.query') ?? false) + spans.some(span => span.name === 'GET /performance/redis' && span.is_segment) && + spans.filter(span => getSpanOp(span) === 'db.query').length >= 2 ); }); await page.goto('/performance/redis'); - const transaction = await txPromise; + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.name === 'GET /performance/redis' && span.is_segment)!; - expect(transaction.contexts?.trace?.op).toBe('http.server'); + expect(getSpanOp(segmentSpan)).toBe('http.server'); - // 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)]); + // Collect every span id in the trace (segment + children) so we can verify nesting. + const spanIds = new Set(spans.filter(span => span.trace_id === segmentSpan.trace_id).map(span => span.span_id)); - const redisSpans = transaction.spans!.filter(span => span.op === 'db.query'); + 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..bce66ef50fdf 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,5 @@ 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('Trace propagation', () => { @@ -18,24 +18,28 @@ test.describe('Trace propagation', () => { }); test('should have trace connection', async ({ page }) => { - const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET /{*splat}'; + // The `handler` child span arrives in the same envelope as its server segment, so collect until + // the segment shows up rather than snapshotting a single envelope. + const serverSpansPromise = collectStreamedSpans(APP_NAME, spans => { + return spans.some(span => getSpanOp(span) === 'http.server' && span.is_segment); }); - const clientTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/'; + const clientSpanPromise = waitForStreamedSpan(APP_NAME, span => { + return span.name === '/' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/`); - const serverTx = await serverTxPromise; - const clientTx = await clientTxPromise; + const serverSpans = await serverSpansPromise; + const clientSpan = await clientSpanPromise; - expect(clientTx.contexts?.trace?.trace_id).toEqual(serverTx.contexts?.trace?.trace_id); + const serverSegmentSpan = serverSpans.find(span => getSpanOp(span) === 'http.server' && span.is_segment)!; - const requestHandlerSpan = serverTx.spans?.find(span => span.op === 'handler'); + expect(clientSpan.trace_id).toEqual(serverSegmentSpan.trace_id); + + const requestHandlerSpan = serverSpans.find(span => getSpanOp(span) === 'handler'); expect(requestHandlerSpan).toBeDefined(); - expect(clientTx.contexts?.trace?.parent_span_id).toBe(requestHandlerSpan?.span_id); + expect(clientSpan.parent_span_id).toBe(requestHandlerSpan?.span_id); }); test('should not have trace connection for prerendered pages', async ({ page }) => { diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/main.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/main.tsx index eb0b44122b62..87131036ce76 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/main.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/main.tsx @@ -18,7 +18,6 @@ import User from './pages/User'; const replay = Sentry.replayIntegration(); Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/errors.test.ts index 97a147f065e0..32b1f1e0a5fb 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/errors.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Sends correct error event', async ({ page, baseURL }) => { const errorEventPromise = waitForError('react-router-8-spa', event => { @@ -30,8 +30,8 @@ test('Sends correct error event', async ({ page, baseURL }) => { }); test('Sets correct transactionName', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-8-spa', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-8-spa', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); const errorEventPromise = waitForError('react-router-8-spa', event => { @@ -39,9 +39,9 @@ test('Sets correct transactionName', async ({ page }) => { }); await page.goto('/'); - const transactionEvent = await transactionPromise; + const pageloadSpan = await pageloadSpanPromise; - // Only capture error once transaction was sent + // Only capture error once the pageload span was sent const exceptionButton = page.locator('id=exception-button'); await exceptionButton.click(); @@ -53,7 +53,7 @@ test('Sets correct transactionName', async ({ page }) => { expect(errorEvent.transaction).toEqual('/'); expect(errorEvent.contexts?.trace).toEqual({ - trace_id: transactionEvent.contexts?.trace?.trace_id, - span_id: expect.not.stringContaining(transactionEvent.contexts?.trace?.span_id || ''), + trace_id: pageloadSpan.trace_id, + span_id: expect.not.stringContaining(pageloadSpan.span_id), }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/navigation-trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/navigation-trace-propagation.test.ts index 921c20dee117..a20c67db4438 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/navigation-trace-propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/navigation-trace-propagation.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('propagates the navigation trace (not the stale pageload trace) for a fetch in a route mount effect', async ({ page, @@ -15,22 +15,22 @@ test('propagates the navigation trace (not the stale pageload trace) for a fetch }); }); - const pageloadTxnPromise = waitForTransaction('react-router-8-spa', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-8-spa', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const navigationTxnPromise = waitForTransaction('react-router-8-spa', async transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/products'; + const navigationSpanPromise = waitForStreamedSpan('react-router-8-spa', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/products'; }); await page.goto('/'); - const pageloadTxn = await pageloadTxnPromise; + const pageloadSpan = await pageloadSpanPromise; await page.locator('id=navigation-products').click(); - const navigationTxn = await navigationTxnPromise; + const navigationSpan = await navigationSpanPromise; - const pageloadTraceId = pageloadTxn.contexts?.trace?.trace_id; - const navigationTraceId = navigationTxn.contexts?.trace?.trace_id; + const pageloadTraceId = pageloadSpan.trace_id; + const navigationTraceId = navigationSpan.trace_id; const propagatedTraceId = productsRequestSentryTrace?.split('-')[0]; expect(pageloadTraceId).toBeDefined(); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/spans.test.ts similarity index 57% rename from dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/transactions.test.ts rename to dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/spans.test.ts index d4f229597f6e..bf401e9f9ac2 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/spans.test.ts @@ -1,69 +1,51 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpan, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('sends a pageload transaction with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-8-spa', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; +test('sends a pageload span with a parameterized URL', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-8-spa', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/`); - const rootSpan = await transactionPromise; - - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, + const span = await spanPromise; + + expect(span.name).toBe('/'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, }); }); -test('sends a navigation transaction with a parameterized URL', async ({ page }) => { +test('sends a navigation span with a parameterized URL', async ({ page }) => { page.on('console', msg => console.log(msg.text())); - const pageloadTxnPromise = waitForTransaction('react-router-8-spa', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-8-spa', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const navigationTxnPromise = waitForTransaction('react-router-8-spa', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + const navigationSpanPromise = waitForStreamedSpan('react-router-8-spa', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; }); await page.goto(`/`); - await pageloadTxnPromise; + await pageloadSpanPromise; const linkElement = page.locator('id=navigation'); - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/user/:id', - 'url.path': '/user/5', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), - }, - }, - }, - transaction: '/user/:id', - transaction_info: { - source: 'route', - }, + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, }); }); diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index cff6d479d00d..ebb89fd08e5f 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -14,6 +14,7 @@ export { collectStreamedSpans, getSpanOp, } from './event-proxy-server'; +export type { SerializedStreamedSpan } from '@sentry/core'; export { findAbsolutePathImports,