diff --git a/dev-packages/e2e-tests/test-applications/tanstack-router/src/main.tsx b/dev-packages/e2e-tests/test-applications/tanstack-router/src/main.tsx index 3e79c090c563..de4fca6ff30a 100644 --- a/dev-packages/e2e-tests/test-applications/tanstack-router/src/main.tsx +++ b/dev-packages/e2e-tests/test-applications/tanstack-router/src/main.tsx @@ -110,7 +110,6 @@ const router = createRouter({ routeTree, ...(__APP_BASEPATH__ ? { basepath: __AP declare const __APP_DSN__: string; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: __APP_DSN__, integrations: [Sentry.tanstackRouterBrowserTracingIntegration(router)], diff --git a/dev-packages/e2e-tests/test-applications/tanstack-router/tests/basepath.test.ts b/dev-packages/e2e-tests/test-applications/tanstack-router/tests/basepath.test.ts index 3e0884bca01d..c639f76a9348 100644 --- a/dev-packages/e2e-tests/test-applications/tanstack-router/tests/basepath.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstack-router/tests/basepath.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'; // Only meaningful in the `tanstack-router (basepath)` variant, where the router is created with // `basepath: '/app'`. The rest of the suite runs in both variants. @@ -12,39 +12,38 @@ test.describe('router basepath', () => { // pageload against the raw browser path let the catch-all `/$a/$b/$c` route absorb `app` as a // param instead of matching `/posts/$postId`. test('does not leak the basepath into the matched route params', async ({ page }) => { - const transactionPromise = waitForTransaction('tanstack-router', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('tanstack-router', span => { + return span.is_segment && getSpanOp(span) === 'pageload'; }); await page.goto(`${BASE}/posts/456`); - const rootSpan = await transactionPromise; + const pageloadSpan = await pageloadSpanPromise; // `onResolved` later merges the correct params in, but never clears the ones the bad initial // match already set, so the stale `a`/`b`/`c` params survive on the span. Keys are passed as // arrays because `toHaveProperty` would otherwise read the dots as a nested lookup. - const traceData = rootSpan.contexts?.trace?.data; - expect(traceData).not.toHaveProperty(['url.path.parameter.a']); - expect(traceData).not.toHaveProperty(['url.path.parameter.b']); - expect(traceData).not.toHaveProperty(['url.path.parameter.c']); - expect(traceData).toHaveProperty(['url.path.parameter.postId'], '456'); - expect(traceData).toHaveProperty(['url.template'], '/posts/$postId'); + expect(pageloadSpan.attributes).not.toHaveProperty(['url.path.parameter.a']); + expect(pageloadSpan.attributes).not.toHaveProperty(['url.path.parameter.b']); + expect(pageloadSpan.attributes).not.toHaveProperty(['url.path.parameter.c']); + expect(pageloadSpan.attributes['url.path.parameter.postId']).toEqual({ type: 'string', value: '456' }); + expect(pageloadSpan.attributes['url.template']).toEqual({ type: 'string', value: '/posts/$postId' }); }); // The first test only checks the span. The scope transaction is a separate value: it is set once // when the pageload span starts, and the later `updateName` in `onResolved` does not rewrite it. - // So even when the sent transaction name is correct, errors captured after the pageload still + // So even when the sent span name is correct, errors captured after the pageload still // carry the name from the initial match. This test checks that scope transaction. test('attributes errors to the matched route for the whole page lifetime', async ({ page }) => { - const transactionPromise = waitForTransaction('tanstack-router', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('tanstack-router', span => { + return span.is_segment && getSpanOp(span) === 'pageload'; }); const errorPromise = waitForError('tanstack-router', async errorEvent => { return errorEvent.exception?.values?.[0]?.value === 'Error thrown after pageload'; }); await page.goto(`${BASE}/posts/456`); - await transactionPromise; + await pageloadSpanPromise; await page.evaluate(() => { setTimeout(() => { diff --git a/dev-packages/e2e-tests/test-applications/tanstack-router/tests/routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/tanstack-router/tests/routing-instrumentation.test.ts index a38ffa1e68c4..2f975118deaf 100644 --- a/dev-packages/e2e-tests/test-applications/tanstack-router/tests/routing-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstack-router/tests/routing-instrumentation.test.ts @@ -1,297 +1,228 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; const BASE = process.env.E2E_TEST_BASEPATH || ''; -test('sends a pageload transaction with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('tanstack-router', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; +test('sends a pageload span with a parameterized URL', async ({ page }) => { + const spansPromise = collectStreamedSpans('tanstack-router', spans => { + return ( + spans.some(span => span.is_segment && getSpanOp(span) === 'pageload' && span.name === '/posts/$postId') && + spans.some(span => span.name === 'loading-post-456') + ); }); await page.goto(`${BASE}/posts/456`); - const rootSpan = await transactionPromise; - - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - data: { - 'sentry.segment.name.source': 'route', - 'sentry.origin': 'auto.pageload.react.tanstack_router', - 'sentry.op': 'pageload', - 'url.path.parameter.postId': '456', - 'url.template': '/posts/$postId', - 'url.path': '/posts/456', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/posts\/456$/), - }, - op: 'pageload', - origin: 'auto.pageload.react.tanstack_router', - }, + const spans = await spansPromise; + const pageloadSpan = spans.find( + span => span.is_segment && getSpanOp(span) === 'pageload' && span.name === '/posts/$postId', + ); + + expect(pageloadSpan).toMatchObject({ + name: '/posts/$postId', + is_segment: true, + attributes: { + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + 'sentry.origin': { type: 'string', value: 'auto.pageload.react.tanstack_router' }, + 'sentry.op': { type: 'string', value: 'pageload' }, + 'url.path.parameter.postId': { type: 'string', value: '456' }, + 'url.template': { type: 'string', value: '/posts/$postId' }, + 'url.path': { type: 'string', value: '/posts/456' }, + 'url.full': { type: 'string', value: expect.stringMatching(/^https?:\/\/localhost:\d+\/posts\/456$/) }, }, - transaction: '/posts/$postId', - transaction_info: { - source: 'route', - }, - spans: expect.arrayContaining([ - expect.objectContaining({ - description: 'loading-post-456', - }), - ]), }); + + expect(spans.map(span => span.name)).toContain('loading-post-456'); }); -test('sends pageload transaction with web vitals measurements', async ({ page }) => { - const transactionPromise = waitForTransaction('tanstack-router', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; +test('sends a pageload span for the root route', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('tanstack-router', span => { + return span.is_segment && getSpanOp(span) === 'pageload'; }); await page.goto(`${BASE}/`); - const transaction = await transactionPromise; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.tanstack_router', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, + const pageloadSpan = await pageloadSpanPromise; + + expect(pageloadSpan).toMatchObject({ + name: '/', + is_segment: true, + attributes: { + 'sentry.op': { type: 'string', value: 'pageload' }, + 'sentry.origin': { type: 'string', value: 'auto.pageload.react.tanstack_router' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + 'url.template': { type: 'string', value: '/' }, + 'url.path': { type: 'string', value: '/' }, + 'url.full': { type: 'string', value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/) }, }, - transaction: '/', - transaction_info: { - source: 'route', - }, - measurements: expect.objectContaining({ - ttfb: expect.objectContaining({ - value: expect.any(Number), - unit: 'millisecond', - }), - lcp: expect.objectContaining({ - value: expect.any(Number), - unit: 'millisecond', - }), - fp: expect.objectContaining({ - value: expect.any(Number), - unit: 'millisecond', - }), - fcp: expect.objectContaining({ - value: expect.any(Number), - unit: 'millisecond', - }), - }), }); }); -test('sends a navigation transaction with a parameterized URL', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; +test('sends a navigation span with a parameterized URL', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('tanstack-router', span => { + return span.is_segment && getSpanOp(span) === 'pageload'; }); - const navigationTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + const spansPromise = collectStreamedSpans('tanstack-router', spans => { + return ( + spans.some(span => span.is_segment && getSpanOp(span) === 'navigation' && span.name === '/posts/$postId') && + spans.some(span => span.name === 'loading-post-2') + ); }); await page.goto(`${BASE}/`); - await pageloadTxnPromise; + await pageloadSpanPromise; await page.waitForTimeout(5000); await page.locator('#nav-link').click(); - const navigationTxn = await navigationTxnPromise; - - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - data: { - 'sentry.segment.name.source': 'route', - 'sentry.origin': 'auto.navigation.react.tanstack_router', - 'sentry.op': 'navigation', - 'url.path.parameter.postId': '2', - 'url.template': '/posts/$postId', - 'url.path': '/posts/2', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/posts\/2$/), - }, - op: 'navigation', - origin: 'auto.navigation.react.tanstack_router', - }, - }, - transaction: '/posts/$postId', - transaction_info: { - source: 'route', + const spans = await spansPromise; + const navigationSpan = spans.find( + span => span.is_segment && getSpanOp(span) === 'navigation' && span.name === '/posts/$postId', + ); + + expect(navigationSpan).toMatchObject({ + name: '/posts/$postId', + is_segment: true, + attributes: { + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + 'sentry.origin': { type: 'string', value: 'auto.navigation.react.tanstack_router' }, + 'sentry.op': { type: 'string', value: 'navigation' }, + 'url.path.parameter.postId': { type: 'string', value: '2' }, + 'url.template': { type: 'string', value: '/posts/$postId' }, + 'url.path': { type: 'string', value: '/posts/2' }, + 'url.full': { type: 'string', value: expect.stringMatching(/^https?:\/\/localhost:\d+\/posts\/2$/) }, }, - spans: expect.arrayContaining([ - expect.objectContaining({ - description: 'loading-post-2', - }), - ]), }); + + expect(spans.map(span => span.name)).toContain('loading-post-2'); }); -test('sends a pageload transaction with resolved URL attrs after same-route redirect on initial load', async ({ - page, -}) => { - const pageloadTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/posts/$postId'; +test('sends a pageload span with resolved URL attrs after same-route redirect on initial load', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('tanstack-router', span => { + return span.is_segment && getSpanOp(span) === 'pageload' && span.name === '/posts/$postId'; }); // `/posts/999` matches `/posts/$postId` initially, then `beforeLoad` redirects to `/posts/2`. await page.goto(`${BASE}/posts/999`); - const pageloadTxn = await pageloadTxnPromise; - - expect(pageloadTxn).toMatchObject({ - contexts: { - trace: { - data: { - 'sentry.segment.name.source': 'route', - 'sentry.origin': 'auto.pageload.react.tanstack_router', - 'sentry.op': 'pageload', - 'url.path.parameter.postId': '2', - 'url.template': '/posts/$postId', - 'url.path': '/posts/2', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/posts\/2$/), - }, - op: 'pageload', - origin: 'auto.pageload.react.tanstack_router', - }, - }, - transaction: '/posts/$postId', - transaction_info: { - source: 'route', + const pageloadSpan = await pageloadSpanPromise; + + expect(pageloadSpan).toMatchObject({ + name: '/posts/$postId', + is_segment: true, + attributes: { + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + 'sentry.origin': { type: 'string', value: 'auto.pageload.react.tanstack_router' }, + 'sentry.op': { type: 'string', value: 'pageload' }, + 'url.path.parameter.postId': { type: 'string', value: '2' }, + 'url.template': { type: 'string', value: '/posts/$postId' }, + 'url.path': { type: 'string', value: '/posts/2' }, + 'url.full': { type: 'string', value: expect.stringMatching(/^https?:\/\/localhost:\d+\/posts\/2$/) }, }, }); }); -test('sends a pageload transaction named after the resolved route when a redirect is thrown on initial load', async ({ +test('sends a pageload span named after the resolved route when a redirect is thrown on initial load', async ({ page, }) => { - const pageloadTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/posts/$postId'; + const pageloadSpanPromise = waitForStreamedSpan('tanstack-router', span => { + return span.is_segment && getSpanOp(span) === 'pageload' && span.name === '/posts/$postId'; }); // Visiting `/redirect` directly throws `redirect({ to: '/posts/$postId', params: { postId: '1' } })` // in `beforeLoad` during the initial pageload, so the pageload span must be renamed to the target route. await page.goto(`${BASE}/redirect`); - const pageloadTxn = await pageloadTxnPromise; - - expect(pageloadTxn).toMatchObject({ - contexts: { - trace: { - data: { - 'sentry.segment.name.source': 'route', - 'sentry.origin': 'auto.pageload.react.tanstack_router', - 'sentry.op': 'pageload', - 'url.path.parameter.postId': '1', - 'url.template': '/posts/$postId', - 'url.path': '/posts/1', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/posts\/1$/), - }, - op: 'pageload', - origin: 'auto.pageload.react.tanstack_router', - }, - }, - transaction: '/posts/$postId', - transaction_info: { - source: 'route', + const pageloadSpan = await pageloadSpanPromise; + + expect(pageloadSpan).toMatchObject({ + name: '/posts/$postId', + is_segment: true, + attributes: { + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + 'sentry.origin': { type: 'string', value: 'auto.pageload.react.tanstack_router' }, + 'sentry.op': { type: 'string', value: 'pageload' }, + 'url.path.parameter.postId': { type: 'string', value: '1' }, + 'url.template': { type: 'string', value: '/posts/$postId' }, + 'url.path': { type: 'string', value: '/posts/1' }, + 'url.full': { type: 'string', value: expect.stringMatching(/^https?:\/\/localhost:\d+\/posts\/1$/) }, }, }); }); -test('sends a navigation transaction when a redirect is thrown in beforeLoad', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; +test('sends a navigation span when a redirect is thrown in beforeLoad', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('tanstack-router', span => { + return span.is_segment && getSpanOp(span) === 'pageload'; }); - const navigationTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + const navigationSpanPromise = waitForStreamedSpan('tanstack-router', span => { + return span.is_segment && getSpanOp(span) === 'navigation'; }); await page.goto(`${BASE}/`); - await pageloadTxnPromise; + await pageloadSpanPromise; await page.locator('#redirect-link').click(); - const navigationTxn = await navigationTxnPromise; + const navigationSpan = await navigationSpanPromise; // The `/redirect` route throws `redirect({ to: '/posts/$postId', params: { postId: '1' } })` in // `beforeLoad`, so the navigation span must be named after the resolved target route, not `/redirect`. - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - data: { - 'sentry.segment.name.source': 'route', - 'sentry.origin': 'auto.navigation.react.tanstack_router', - 'sentry.op': 'navigation', - 'url.path.parameter.postId': '1', - 'url.template': '/posts/$postId', - 'url.path': '/posts/1', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/posts\/1$/), - }, - op: 'navigation', - origin: 'auto.navigation.react.tanstack_router', - }, - }, - transaction: '/posts/$postId', - transaction_info: { - source: 'route', + expect(navigationSpan).toMatchObject({ + name: '/posts/$postId', + is_segment: true, + attributes: { + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + 'sentry.origin': { type: 'string', value: 'auto.navigation.react.tanstack_router' }, + 'sentry.op': { type: 'string', value: 'navigation' }, + 'url.path.parameter.postId': { type: 'string', value: '1' }, + 'url.template': { type: 'string', value: '/posts/$postId' }, + 'url.path': { type: 'string', value: '/posts/1' }, + 'url.full': { type: 'string', value: expect.stringMatching(/^https?:\/\/localhost:\d+\/posts\/1$/) }, }, }); }); -test('sends a navigation transaction for a normal navigation that happens after a redirect', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; +test('sends a navigation span for a normal navigation that happens after a redirect', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('tanstack-router', span => { + return span.is_segment && getSpanOp(span) === 'pageload'; }); await page.goto(`${BASE}/`); - await pageloadTxnPromise; + await pageloadSpanPromise; // First trigger a redirect-driven navigation. Upstream (TanStack/router#3920) this leaves the // router in a state where `onBeforeNavigate` never fires again, which previously killed all // subsequent navigation spans. - const redirectTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/posts/$postId'; + const redirectSpanPromise = waitForStreamedSpan('tanstack-router', span => { + return span.is_segment && getSpanOp(span) === 'navigation' && span.name === '/posts/$postId'; }); await page.locator('#redirect-link').click(); - await redirectTxnPromise; + await redirectSpanPromise; // Now a plain navigation must still produce a navigation span. - const navigationTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => { + const navigationSpanPromise = waitForStreamedSpan('tanstack-router', span => { return ( - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.contexts?.trace?.data?.['url.path.parameter.postId'] === '2' + span.is_segment && getSpanOp(span) === 'navigation' && span.attributes['url.path.parameter.postId']?.value === '2' ); }); await page.locator('#nav-link').click(); - const navigationTxn = await navigationTxnPromise; - - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - data: { - 'sentry.segment.name.source': 'route', - 'sentry.origin': 'auto.navigation.react.tanstack_router', - 'sentry.op': 'navigation', - 'url.path.parameter.postId': '2', - 'url.template': '/posts/$postId', - 'url.path': '/posts/2', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/posts\/2$/), - }, - op: 'navigation', - origin: 'auto.navigation.react.tanstack_router', - }, - }, - transaction: '/posts/$postId', - transaction_info: { - source: 'route', + const navigationSpan = await navigationSpanPromise; + + expect(navigationSpan).toMatchObject({ + name: '/posts/$postId', + is_segment: true, + attributes: { + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + 'sentry.origin': { type: 'string', value: 'auto.navigation.react.tanstack_router' }, + 'sentry.op': { type: 'string', value: 'navigation' }, + 'url.path.parameter.postId': { type: 'string', value: '2' }, + 'url.template': { type: 'string', value: '/posts/$postId' }, + 'url.path': { type: 'string', value: '/posts/2' }, + 'url.full': { type: 'string', value: expect.stringMatching(/^https?:\/\/localhost:\d+\/posts\/2$/) }, }, }); });