diff --git a/dev-packages/e2e-tests/test-applications/create-next-app/tests/client-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-next-app/tests/client-transactions.test.ts index cbb2cae29265..a539216efee7 100644 --- a/dev-packages/e2e-tests/test-applications/create-next-app/tests/client-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-next-app/tests/client-transactions.test.ts @@ -24,6 +24,7 @@ test('Sends a pageload transaction to Sentry', async ({ page }) => { trace_id: expect.stringMatching(/[a-f0-9]{32}/), op: 'pageload', origin: 'auto.pageload.nextjs.pages_router_instrumentation', + status: 'ok', data: expect.objectContaining({ 'sentry.idle_span_finish_reason': 'idleTimeout', 'sentry.op': 'pageload', @@ -69,6 +70,7 @@ test('captures a navigation transaction to Sentry', async ({ page }) => { trace_id: expect.stringMatching(/[a-f0-9]{32}/), op: 'navigation', origin: 'auto.navigation.nextjs.pages_router_instrumentation', + status: 'ok', data: expect.objectContaining({ 'sentry.idle_span_finish_reason': 'idleTimeout', 'sentry.op': 'navigation', diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts index 9819507f5cb9..c938817e2642 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts @@ -33,6 +33,7 @@ test('Sends a pageload transaction', async ({ page }) => { trace_id: expect.stringMatching(/[a-f0-9]{32}/), op: 'pageload', origin: 'auto.pageload.nextjs.app_router_instrumentation', + status: 'ok', data: expect.objectContaining({ 'sentry.op': 'pageload', 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/transactions.test.ts index 3569789ed995..5b648065e45a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/transactions.test.ts @@ -33,6 +33,7 @@ test('Sends a pageload transaction', async ({ page }) => { trace_id: expect.stringMatching(/[a-f0-9]{32}/), op: 'pageload', origin: 'auto.pageload.nextjs.pages_router_instrumentation', + status: 'ok', data: expect.objectContaining({ 'sentry.op': 'pageload', 'sentry.origin': 'auto.pageload.nextjs.pages_router_instrumentation', 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 index ee0c507076fa..4af30d8e139d 100644 --- 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 @@ -36,6 +36,7 @@ test('Captures a pageload transaction', async ({ page }) => { span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: expect.stringMatching(/[a-f0-9]{32}/), origin: 'auto.pageload.react.reactrouter_v6', + status: 'ok', }), ); }); @@ -72,6 +73,7 @@ test('Captures a navigation transaction', async ({ page }) => { 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( @@ -107,6 +109,7 @@ test('Captures a lazy pageload transaction', async ({ page }) => { 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( @@ -169,6 +172,7 @@ test('Captures a lazy navigation transaction', async ({ page }) => { 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( 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 index 36e6d0c18ee2..15cab5e8569e 100644 --- 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 @@ -31,6 +31,7 @@ test('Captures a pageload transaction', async ({ page }) => { 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( @@ -136,6 +137,7 @@ test('Captures a navigation transaction', async ({ page }) => { 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( 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 index 61a583a7bf55..5c60b704bb7a 100644 --- 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 @@ -33,6 +33,7 @@ test('Captures a pageload transaction', async ({ page }) => { span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: expect.stringMatching(/[a-f0-9]{32}/), origin: 'auto.pageload.react.reactrouter_v6', + status: 'ok', }), ); }); @@ -69,6 +70,7 @@ test('Captures a navigation transaction', async ({ page }) => { 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( 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/transactions.test.ts index 3901b0938ca5..a57199c52633 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/transactions.test.ts @@ -21,6 +21,7 @@ test('Creates a pageload transaction with parameterized route', async ({ page }) 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'); }); test('Does not create a navigation transaction on initial load to deep lazy route', async ({ page }) => { @@ -82,6 +83,7 @@ test('Creates a navigation transaction inside a lazy route', async ({ page }) => 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'); }); test('Creates navigation transactions between two different lazy routes', async ({ page }) => { @@ -498,3 +500,90 @@ test('Updates navigation transaction name correctly when span is cancelled early expect(['externalFinish', 'cancelled']).toContain(idleSpanFinishReason); } }); + +test('Creates separate transactions for rapid consecutive navigations', async ({ page }) => { + await page.goto('/'); + + // First navigation: / -> /lazy/inner/:id/:anotherId/:someAnotherId + const firstTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + ); + }); + + const navigationToInner = page.locator('id=navigation'); + await expect(navigationToInner).toBeVisible(); + await navigationToInner.click(); + + const firstEvent = await firstTransactionPromise; + + // Verify first transaction + expect(firstEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(firstEvent.contexts?.trace?.op).toBe('navigation'); + expect(firstEvent.contexts?.trace?.status).toBe('ok'); + const firstTraceId = firstEvent.contexts?.trace?.trace_id; + const firstSpanId = firstEvent.contexts?.trace?.span_id; + + // Second navigation: /lazy/inner -> /another-lazy/sub/:id/:subId + 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 navigationToAnother = page.locator('id=navigate-to-another-from-inner'); + await expect(navigationToAnother).toBeVisible(); + await navigationToAnother.click(); + + const secondEvent = await secondTransactionPromise; + + // Verify second transaction + expect(secondEvent.transaction).toBe('/another-lazy/sub/:id/:subId'); + expect(secondEvent.contexts?.trace?.op).toBe('navigation'); + expect(secondEvent.contexts?.trace?.status).toBe('ok'); + const secondTraceId = secondEvent.contexts?.trace?.trace_id; + const secondSpanId = secondEvent.contexts?.trace?.span_id; + + // Third navigation: /another-lazy -> /lazy/inner/:id/:anotherId/:someAnotherId (back to same route as first) + const thirdTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' && + // Ensure we're not matching the first transaction again + transactionEvent.contexts?.trace?.trace_id !== firstTraceId + ); + }); + + const navigationBackToInner = page.locator('id=navigate-to-inner-from-deep'); + await expect(navigationBackToInner).toBeVisible(); + await navigationBackToInner.click(); + + const thirdEvent = await thirdTransactionPromise; + + // 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'); + const thirdTraceId = thirdEvent.contexts?.trace?.trace_id; + const thirdSpanId = thirdEvent.contexts?.trace?.span_id; + + // Verify each navigation created a separate transaction with unique trace and span IDs + expect(firstTraceId).toBeDefined(); + expect(secondTraceId).toBeDefined(); + expect(thirdTraceId).toBeDefined(); + + // All trace IDs should be unique + expect(firstTraceId).not.toBe(secondTraceId); + expect(secondTraceId).not.toBe(thirdTraceId); + expect(firstTraceId).not.toBe(thirdTraceId); + + // All span IDs should be unique + expect(firstSpanId).not.toBe(secondSpanId); + expect(secondSpanId).not.toBe(thirdSpanId); + expect(firstSpanId).not.toBe(thirdSpanId); +}); diff --git a/packages/core/src/tracing/idleSpan.ts b/packages/core/src/tracing/idleSpan.ts index b35e31322ecd..53848a9c9191 100644 --- a/packages/core/src/tracing/idleSpan.ts +++ b/packages/core/src/tracing/idleSpan.ts @@ -19,7 +19,7 @@ import { timestampInSeconds } from '../utils/time'; import { freezeDscOnSpan, getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; import { SentryNonRecordingSpan } from './sentryNonRecordingSpan'; import { SentrySpan } from './sentrySpan'; -import { SPAN_STATUS_ERROR } from './spanstatus'; +import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from './spanstatus'; import { startInactiveSpan } from './trace'; export const TRACING_DEFAULTS = { @@ -302,6 +302,12 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, _finishReason); } + // Set span status to 'ok' if it hasn't been explicitly set to an error status + const currentStatus = spanJSON.status; + if (!currentStatus || currentStatus === 'unknown') { + span.setStatus({ code: SPAN_STATUS_OK }); + } + debug.log(`[Tracing] Idle span "${spanJSON.op}" finished`); const childSpans = getSpanDescendants(span).filter(child => child !== span);