From 91c14443ba73c487ebb2538620e9cf16067f05f0 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 8 Jul 2026 14:20:27 +0200 Subject: [PATCH 1/3] feat(react): Set url.template, url.path and url.full on routing spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds url.template to React Router v3–v6 and v4/v5 instrumentation, fixes TanStack React Router navigation url.path/url.full to use the target location, and updates unit and e2e tests. part of https://github.com/getsentry/sentry-javascript/issues/21921 Co-Authored-By: Cursor Agent Co-authored-by: Cursor --- .../react-17/tests/transactions.test.ts | 12 + .../tests/transactions.test.ts | 12 + .../tests/transactions.test.ts | 216 ++++++++++++------ .../tests/transactions.test.ts | 6 + .../react-router-5/tests/transactions.test.ts | 12 + .../tests/transactions.test.ts | 36 +++ .../tests/transactions.test.ts | 12 + .../react-router-6/tests/transactions.test.ts | 12 + .../tests/transactions.test.ts | 36 +++ .../tests/spans.test.ts | 6 + .../tests/transactions.test.ts | 12 + .../tests/transactions.test.ts | 36 +++ .../tests/transactions.test.ts | 12 + .../tests/routing-instrumentation.test.ts | 21 ++ packages/react/package.json | 3 +- .../instrumentation.tsx | 14 ++ packages/react/src/reactrouter.tsx | 5 +- packages/react/src/reactrouterv3.ts | 3 + packages/react/src/tanstackrouter.ts | 88 +++++-- .../react/src/vendor/tanstackrouter-types.ts | 1 + .../test/reactrouter-cross-usage.test.tsx | 16 +- .../reactrouter-descendant-routes.test.tsx | 10 +- packages/react/test/reactrouterv3.test.tsx | 6 + packages/react/test/reactrouterv4.test.tsx | 10 +- packages/react/test/reactrouterv5.test.tsx | 10 +- packages/react/test/reactrouterv6.test.tsx | 18 ++ 26 files changed, 524 insertions(+), 101 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-17/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-17/tests/transactions.test.ts index 14d8b8b21d65..cd67a97c1cef 100644 --- a/dev-packages/e2e-tests/test-applications/react-17/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-17/tests/transactions.test.ts @@ -15,6 +15,12 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = trace: { op: 'pageload', origin: 'auto.pageload.react.reactrouter_v6', + data: { + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: '/', @@ -46,6 +52,12 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) trace: { op: 'navigation', origin: 'auto.navigation.react.reactrouter_v6', + data: { + 'sentry.source': 'route', + 'url.template': '/user/:id', + 'url.path': '/user/5', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), + }, }, }, transaction: '/user/:id', 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 362119d1da13..e31cba74ecec 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 @@ -31,6 +31,9 @@ test('Captures a pageload transaction', async ({ page }) => { 'sentry.origin': 'auto.pageload.react.reactrouter_v6', 'sentry.sample_rate': 1, 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), }), op: 'pageload', span_id: expect.stringMatching(/[a-f0-9]{16}/), @@ -58,6 +61,9 @@ test('Captures a navigation transaction', async ({ page }) => { 'sentry.origin': 'auto.navigation.react.reactrouter_v6', 'sentry.sample_rate': 1, 'sentry.source': 'route', + 'url.template': '/user/:id', + 'url.path': '/user/5', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), }), links: [ { @@ -106,6 +112,9 @@ test('Captures a lazy pageload transaction', async ({ page }) => { 'sentry.origin': 'auto.pageload.react.reactrouter_v6', 'sentry.sample_rate': 1, 'sentry.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}/), @@ -159,6 +168,9 @@ test('Captures a lazy navigation transaction', async ({ page }) => { 'sentry.origin': 'auto.navigation.react.reactrouter_v6', 'sentry.sample_rate': 1, 'sentry.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: [ { 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 48ed51bc548b..f2bb708b8e88 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 @@ -26,6 +26,7 @@ test('Captures a pageload transaction', async ({ page }) => { 'performance.activationStart': expect.any(Number), 'lcp.renderTime': expect.any(Number), 'lcp.loadTime': expect.any(Number), + 'url.template': '/', 'url.full': 'http://localhost:3030/', 'url.path': '/', }, @@ -124,6 +125,9 @@ test('Captures a navigation transaction', async ({ page }) => { 'sentry.origin': 'auto.navigation.react.reactrouter_v6', 'sentry.sample_rate': 1, 'sentry.source': 'route', + 'url.template': '/user/:id', + 'url.path': '/', + 'url.full': expect.stringMatching(/^http:\/\/localhost:3030\/#\/user\/5$/), }), links: [ { @@ -166,15 +170,22 @@ test('Captures a parameterized path pageload transaction', async ({ page }) => { const transactionEvent = await transactionEventPromise; - expect(transactionEvent).toEqual( - expect.objectContaining({ + 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 }) => { @@ -186,15 +197,22 @@ test('Captures a parameterized path pageload transaction for nested route', asyn const transactionEvent = await transactionEventPromise; - expect(transactionEvent).toEqual( - expect.objectContaining({ + 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 }) => { @@ -206,15 +224,22 @@ test('Captures a parameterized path pageload transaction for deeply nested route const transactionEvent = await transactionEventPromise; - expect(transactionEvent).toEqual( - expect.objectContaining({ + 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 }) => { @@ -226,15 +251,22 @@ test('Captures a parameterized path pageload transaction for nested route with a const transactionEvent = await transactionEventPromise; - expect(transactionEvent).toEqual( - expect.objectContaining({ + 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 }) => { @@ -248,15 +280,22 @@ test('Captures a parameterized path navigation transaction', async ({ page }) => const transactionEvent = await transactionEventPromise; - expect(transactionEvent).toEqual( - expect.objectContaining({ + 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 }) => { @@ -270,15 +309,22 @@ test('Captures a parameterized path navigation transaction for nested route', as const transactionEvent = await transactionEventPromise; - expect(transactionEvent).toEqual( - expect.objectContaining({ + 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 }) => { @@ -292,15 +338,22 @@ test('Captures a parameterized path navigation transaction for deeply nested rou const transactionEvent = await transactionEventPromise; - expect(transactionEvent).toEqual( - expect.objectContaining({ + 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 }) => { @@ -314,15 +367,22 @@ test('Captures a parameterized path navigation transaction for nested route with const transactionEvent = await transactionEventPromise; - expect(transactionEvent).toEqual( - expect.objectContaining({ + 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 }) => { @@ -334,15 +394,22 @@ test('Captures a parameterized path pageload transaction for group route', async const transactionEvent = await transactionEventPromise; - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: '/group/:group/:user?', - type: 'transaction', - transaction_info: { - source: 'route', + 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 }) => { @@ -356,15 +423,22 @@ test('Captures a parameterized path navigation transaction for group route', asy const transactionEvent = await transactionEventPromise; - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: '/group/:group/:user?', - type: 'transaction', - transaction_info: { - source: 'route', + 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 }) => { @@ -376,15 +450,22 @@ test('Captures a parameterized path pageload transaction for nested group route' const transactionEvent = await transactionEventPromise; - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: '/group/:group/:user?', - type: 'transaction', - transaction_info: { - source: 'route', + 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 }) => { @@ -398,13 +479,20 @@ test('Captures a parameterized path navigation transaction for nested group rout const transactionEvent = await transactionEventPromise; - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: '/group/:group/:user?', - type: 'transaction', - transaction_info: { - source: 'route', + 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/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/transactions.test.ts index 5c60b704bb7a..8fff883df119 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 @@ -28,6 +28,9 @@ test('Captures a pageload transaction', async ({ page }) => { 'sentry.origin': 'auto.pageload.react.reactrouter_v6', 'sentry.sample_rate': 1, 'sentry.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}/), @@ -55,6 +58,9 @@ test('Captures a navigation transaction', async ({ page }) => { 'sentry.origin': 'auto.navigation.react.reactrouter_v6', 'sentry.sample_rate': 1, 'sentry.source': 'route', + 'url.template': '/user/:id', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), }), links: [ { 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 index 71808851a290..14c0c47ae5da 100644 --- 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 @@ -15,6 +15,12 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = trace: { op: 'pageload', origin: 'auto.pageload.react.reactrouter_v5', + data: { + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: '/', @@ -46,6 +52,12 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) trace: { op: 'navigation', origin: 'auto.navigation.react.reactrouter_v5', + data: { + 'sentry.source': 'route', + 'url.template': '/user/:id', + 'url.path': '/user/5', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), + }, }, }, transaction: '/user/:id', 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 index 2f13b7cc1eac..7c5a1bd7ac67 100644 --- 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 @@ -16,6 +16,12 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = trace: { op: 'pageload', origin: 'auto.pageload.react.reactrouter_v6', + data: { + 'sentry.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', @@ -40,6 +46,12 @@ test('sends a pageload transaction with a parameterized URL - alternative route' trace: { op: 'pageload', origin: 'auto.pageload.react.reactrouter_v6', + data: { + 'sentry.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', @@ -66,6 +78,12 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) trace: { op: 'pageload', origin: 'auto.pageload.react.reactrouter_v6', + data: { + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: '/', @@ -84,6 +102,12 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) trace: { op: 'navigation', origin: 'auto.navigation.react.reactrouter_v6', + data: { + 'sentry.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', @@ -110,6 +134,12 @@ test('sends a navigation transaction with a parameterized URL - alternative rout trace: { op: 'pageload', origin: 'auto.pageload.react.reactrouter_v6', + data: { + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: '/', @@ -128,6 +158,12 @@ test('sends a navigation transaction with a parameterized URL - alternative rout trace: { op: 'navigation', origin: 'auto.navigation.react.reactrouter_v6', + data: { + 'sentry.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', 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 index 160ab8a4909f..20a126396acf 100644 --- 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 @@ -15,6 +15,12 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = trace: { op: 'pageload', origin: 'auto.pageload.react.reactrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: '/', @@ -46,6 +52,12 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) trace: { op: 'navigation', origin: 'auto.navigation.react.reactrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/user/:id', + 'url.path': '/user/5', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), + }, }, }, transaction: '/user/:id', 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/transactions.test.ts index ae9ff366abd4..ad1f99199ed2 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/transactions.test.ts @@ -15,6 +15,12 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = trace: { op: 'pageload', origin: 'auto.pageload.react.reactrouter_v6', + data: { + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: '/', @@ -46,6 +52,12 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) trace: { op: 'navigation', origin: 'auto.navigation.react.reactrouter_v6', + data: { + 'sentry.source': 'route', + 'url.template': '/user/:id', + 'url.path': '/user/5', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), + }, }, }, transaction: '/user/:id', 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 index d889793aa73a..f0d82229770f 100644 --- 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 @@ -16,6 +16,12 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = trace: { op: 'pageload', origin: 'auto.pageload.react.reactrouter', + data: { + 'sentry.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', @@ -40,6 +46,12 @@ test('sends a pageload transaction with a parameterized URL - alternative route' trace: { op: 'pageload', origin: 'auto.pageload.react.reactrouter', + data: { + 'sentry.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', @@ -66,6 +78,12 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) trace: { op: 'pageload', origin: 'auto.pageload.react.reactrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: '/', @@ -84,6 +102,12 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) trace: { op: 'navigation', origin: 'auto.navigation.react.reactrouter', + data: { + 'sentry.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', @@ -110,6 +134,12 @@ test('sends a navigation transaction with a parameterized URL - alternative rout trace: { op: 'pageload', origin: 'auto.pageload.react.reactrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: '/', @@ -128,6 +158,12 @@ test('sends a navigation transaction with a parameterized URL - alternative rout trace: { op: 'navigation', origin: 'auto.navigation.react.reactrouter', + data: { + 'sentry.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', diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts index 6056d85f09e0..8da4f7a8ae39 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts @@ -15,6 +15,9 @@ test('sends a pageload span with a parameterized URL', async ({ page }) => { expect(span.status).toBe('ok'); expect(span.attributes['sentry.origin']?.value).toBe('auto.pageload.react.reactrouter_v7'); expect(span.attributes['sentry.source']?.value).toBe('route'); + expect(span.attributes['url.template']?.value).toBe('/'); + expect(span.attributes['url.path']?.value).toBe('/'); + expect(span.attributes['url.full']?.value).toMatch(/^https?:\/\/localhost:\d+\/$/); }); test('sends a navigation span with a parameterized URL', async ({ page }) => { @@ -39,6 +42,9 @@ test('sends a navigation span with a parameterized URL', async ({ page }) => { expect(navigationSpan.status).toBe('ok'); expect(navigationSpan.attributes['sentry.origin']?.value).toBe('auto.navigation.react.reactrouter_v7'); expect(navigationSpan.attributes['sentry.source']?.value).toBe('route'); + expect(navigationSpan.attributes['url.template']?.value).toBe('/user/:id'); + expect(navigationSpan.attributes['url.path']?.value).toBe('/user/5'); + expect(navigationSpan.attributes['url.full']?.value).toMatch(/^https?:\/\/localhost:\d+\/user\/5$/); }); test('sends an INP span', async ({ page }) => { 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/transactions.test.ts index 27554491019f..92cf76f5e181 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/transactions.test.ts @@ -15,6 +15,12 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = trace: { op: 'pageload', origin: 'auto.pageload.react.reactrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: '/', @@ -46,6 +52,12 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) trace: { op: 'navigation', origin: 'auto.navigation.react.reactrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/user/:id', + 'url.path': '/user/5', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), + }, }, }, transaction: '/user/:id', 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 index a1731af3e968..3aa5f002fe11 100644 --- 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 @@ -16,6 +16,12 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = trace: { op: 'pageload', origin: 'auto.pageload.react.reactrouter', + data: { + 'sentry.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', @@ -40,6 +46,12 @@ test('sends a pageload transaction with a parameterized URL - alternative route' trace: { op: 'pageload', origin: 'auto.pageload.react.reactrouter', + data: { + 'sentry.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', @@ -66,6 +78,12 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) trace: { op: 'pageload', origin: 'auto.pageload.react.reactrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: '/', @@ -84,6 +102,12 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) trace: { op: 'navigation', origin: 'auto.navigation.react.reactrouter', + data: { + 'sentry.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', @@ -110,6 +134,12 @@ test('sends a navigation transaction with a parameterized URL - alternative rout trace: { op: 'pageload', origin: 'auto.pageload.react.reactrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: '/', @@ -128,6 +158,12 @@ test('sends a navigation transaction with a parameterized URL - alternative rout trace: { op: 'navigation', origin: 'auto.navigation.react.reactrouter', + data: { + 'sentry.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', 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/transactions.test.ts index fcf012f4b51c..36ab03d87c3e 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/transactions.test.ts @@ -15,6 +15,12 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = trace: { op: 'pageload', origin: 'auto.pageload.react.reactrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: '/', @@ -46,6 +52,12 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) trace: { op: 'navigation', origin: 'auto.navigation.react.reactrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/user/:id', + 'url.path': '/user/5', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), + }, }, }, transaction: '/user/:id', 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 ee752f4b7cac..91ecba36e3f2 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 @@ -18,6 +18,9 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = 'sentry.origin': 'auto.pageload.react.tanstack_router', 'sentry.op': 'pageload', 'url.path.params.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', @@ -49,6 +52,12 @@ test('sends pageload transaction with web vitals measurements', async ({ page }) trace: { op: 'pageload', origin: 'auto.pageload.react.tanstack_router', + data: { + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: '/', @@ -101,6 +110,9 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) 'sentry.origin': 'auto.navigation.react.tanstack_router', 'sentry.op': 'navigation', 'url.path.params.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', @@ -139,6 +151,9 @@ test('sends a pageload transaction named after the resolved route when a redirec 'sentry.origin': 'auto.pageload.react.tanstack_router', 'sentry.op': 'pageload', 'url.path.params.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', @@ -177,6 +192,9 @@ test('sends a navigation transaction when a redirect is thrown in beforeLoad', a 'sentry.origin': 'auto.navigation.react.tanstack_router', 'sentry.op': 'navigation', 'url.path.params.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', @@ -226,6 +244,9 @@ test('sends a navigation transaction for a normal navigation that happens after 'sentry.origin': 'auto.navigation.react.tanstack_router', 'sentry.op': 'navigation', 'url.path.params.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', diff --git a/packages/react/package.json b/packages/react/package.json index 8fd7107944a7..eed0a949a151 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -44,7 +44,8 @@ }, "dependencies": { "@sentry/browser": "10.64.0", - "@sentry/core": "10.64.0" + "@sentry/core": "10.64.0", + "@sentry/conventions": "^0.15.1" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index 5bc58325f963..12b1efecbb07 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -47,6 +47,7 @@ import { setNavigationContext, transactionNameHasWildcard, } from './utils'; +import { URL_TEMPLATE } from '@sentry/conventions/attributes'; let _useEffect: UseEffect; let _useLocation: UseLocation; @@ -398,6 +399,9 @@ export function updateNavigationSpan( if (isImprovement) { activeRootSpan.updateName(name); activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); + if (source === 'route') { + activeRootSpan.setAttribute(URL_TEMPLATE, name); + } // Only mark as finalized for non-wildcard route names (allows URL→route upgrades). if (!transactionNameHasWildcard(name) && source === 'route') { @@ -997,6 +1001,9 @@ export function handleNavigation(opts: { // Update existing real span from wildcard to parameterized route name trackedNav.span.updateName(name); trackedNav.span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source as 'route' | 'url' | 'custom'); + if (source === 'route') { + trackedNav.span.setAttribute(URL_TEMPLATE, name); + } addNonEnumerableProperty( trackedNav.span as { __sentry_navigation_name_set__?: boolean }, '__sentry_navigation_name_set__', @@ -1032,6 +1039,7 @@ export function handleNavigation(opts: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter${version ? `_v${version}` : ''}`, + ...(source === 'route' && { [URL_TEMPLATE]: placeholderEntry.routeName }), }, }); } catch (e) { @@ -1120,6 +1128,9 @@ function updatePageloadTransaction({ if (activeRootSpan) { activeRootSpan.updateName(name); activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); + if (source === 'route') { + activeRootSpan.setAttribute(URL_TEMPLATE, name); + } // Patch span.end() to ensure we update the name one last time before the span is sent patchSpanEnd(activeRootSpan, location, routes, basename, 'pageload'); @@ -1216,6 +1227,9 @@ function tryUpdateSpanNameBeforeEnd( if (isImprovement && spanNotEnded) { span.updateName(name); span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); + if (source === 'route') { + span.setAttribute(URL_TEMPLATE, name); + } } } catch (error) { DEBUG_BUILD && debug.warn(`Error updating span details before ending: ${error}`); diff --git a/packages/react/src/reactrouter.tsx b/packages/react/src/reactrouter.tsx index 73cc390e4645..1ac4c7b265ee 100644 --- a/packages/react/src/reactrouter.tsx +++ b/packages/react/src/reactrouter.tsx @@ -18,6 +18,7 @@ import type { ReactElement } from 'react'; import * as React from 'react'; import { hoistNonReactStatics } from './hoist-non-react-statics'; import type { Action, Location } from './types'; +import { URL_TEMPLATE } from '@sentry/conventions/attributes'; // We need to disable eslint no-explicit-any because any is required for the // react-router typings. @@ -162,6 +163,7 @@ function instrumentReactRouter( [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.pageload.react.${instrumentationName}`, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, + ...(source === 'route' && { [URL_TEMPLATE]: name }), }, }); } @@ -177,6 +179,7 @@ function instrumentReactRouter( [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.${instrumentationName}`, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, + ...(source === 'route' && { [URL_TEMPLATE]: name }), }, }); } @@ -233,7 +236,7 @@ export function withSentryRouting

, R extends React if (activeRootSpan) { activeRootSpan.updateName(route); - activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + activeRootSpan.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [URL_TEMPLATE]: route }); } } diff --git a/packages/react/src/reactrouterv3.ts b/packages/react/src/reactrouterv3.ts index cdf97c02d03b..b08df8632f41 100644 --- a/packages/react/src/reactrouterv3.ts +++ b/packages/react/src/reactrouterv3.ts @@ -11,6 +11,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '@sentry/core/browser'; import type { Location } from './types'; +import { URL_TEMPLATE } from '@sentry/conventions/attributes'; // Many of the types below had to be mocked out to prevent typescript issues // these types are required for correct functionality. @@ -68,6 +69,7 @@ export function reactRouterV3BrowserTracingIntegration( [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v3', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, + ...(source === 'route' && { [URL_TEMPLATE]: localName }), }, }); }, @@ -88,6 +90,7 @@ export function reactRouterV3BrowserTracingIntegration( [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v3', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, + ...(source === 'route' && { [URL_TEMPLATE]: localName }), }, }); }, diff --git a/packages/react/src/tanstackrouter.ts b/packages/react/src/tanstackrouter.ts index 4473cb252512..e963e701efd0 100644 --- a/packages/react/src/tanstackrouter.ts +++ b/packages/react/src/tanstackrouter.ts @@ -1,5 +1,6 @@ import { browserTracingIntegration as originalBrowserTracingIntegration, + getAbsoluteUrl, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, WINDOW, @@ -11,6 +12,13 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '@sentry/core/browser'; import type { VendoredTanstackRouter, VendoredTanstackRouterRouteMatch } from './vendor/tanstackrouter-types'; +import { PARAMS_KEY, URL_FULL, URL_PATH, URL_PATH_PARAMETER_KEY, URL_TEMPLATE } from '@sentry/conventions/attributes'; + +interface TanstackRouterLocation { + pathname: string; + search: Record; + state?: unknown; +} /** * A custom browser tracing integration for TanStack Router. @@ -64,6 +72,7 @@ export function tanstackRouterBrowserTracingIntegration( [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.tanstack_router', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeMatch ? 'route' : 'url', + ...(routeMatch && { [URL_TEMPLATE]: routeMatch.routeId }), ...routeMatchToParamSpanAttributes(routeMatch), }, }); @@ -78,8 +87,12 @@ export function tanstackRouterBrowserTracingIntegration( const resolvedMatch = resolveRouteMatch(onResolvedArgs.toLocation.pathname, onResolvedArgs.toLocation.search); if (resolvedMatch && resolvedMatch.routeId !== routeMatch?.routeId) { pageloadSpan.updateName(resolvedMatch.routeId); - pageloadSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - pageloadSpan.setAttributes(routeMatchToParamSpanAttributes(resolvedMatch)); + pageloadSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: resolvedMatch.routeId, + ...locationToSpanUrlAttributes(castRouterInstance, onResolvedArgs.toLocation), + ...routeMatchToParamSpanAttributes(resolvedMatch), + }); } }); } @@ -94,43 +107,48 @@ export function tanstackRouterBrowserTracingIntegration( const applyRouteMatch = ( span: NonNullable, match: VendoredTanstackRouterRouteMatch | undefined, + toLocation: TanstackRouterLocation, fallbackName: string, ): void => { span.updateName(match ? match.routeId : fallbackName); span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, match ? 'route' : 'url'); - span.setAttributes(routeMatchToParamSpanAttributes(match)); + span.setAttributes({ + ...(match && { [URL_TEMPLATE]: match.routeId }), + ...locationToSpanUrlAttributes(castRouterInstance, toLocation), + ...routeMatchToParamSpanAttributes(match), + }); }; castRouterInstance.subscribe('onBeforeLoad', onBeforeLoadArgs => { + const { toLocation, fromLocation } = onBeforeLoadArgs; // Skip the initial pageload (no fromLocation) and no-op reloads (same state). - if ( - !onBeforeLoadArgs.fromLocation || - onBeforeLoadArgs.toLocation.state === onBeforeLoadArgs.fromLocation.state - ) { + if (!fromLocation || toLocation.state === fromLocation.state) { return; } - const routeMatch = resolveRouteMatch( - onBeforeLoadArgs.toLocation.pathname, - onBeforeLoadArgs.toLocation.search, - ); - const fallbackName = WINDOW.location.pathname; + const routeMatch = resolveRouteMatch(toLocation.pathname, toLocation.search); + const fallbackName = WINDOW.location?.pathname || toLocation.pathname; if (inFlightNavigationSpan) { // Redirect continuation within the same navigation: keep the span, update the target. - applyRouteMatch(inFlightNavigationSpan, routeMatch, fallbackName); + applyRouteMatch(inFlightNavigationSpan, routeMatch, toLocation, fallbackName); return; } - inFlightNavigationSpan = startBrowserTracingNavigationSpan(client, { - name: routeMatch ? routeMatch.routeId : fallbackName, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.tanstack_router', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeMatch ? 'route' : 'url', - ...routeMatchToParamSpanAttributes(routeMatch), + inFlightNavigationSpan = startBrowserTracingNavigationSpan( + client, + { + name: routeMatch ? routeMatch.routeId : fallbackName, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.tanstack_router', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeMatch ? 'route' : 'url', + ...(routeMatch && { [URL_TEMPLATE]: routeMatch.routeId }), + ...routeMatchToParamSpanAttributes(routeMatch), + }, }, - }); + { url: locationToAbsoluteUrl(castRouterInstance, toLocation) }, + ); }); castRouterInstance.subscribe('onResolved', onResolvedArgs => { @@ -139,9 +157,10 @@ export function tanstackRouterBrowserTracingIntegration( if (!span) { return; } - const resolvedMatch = resolveRouteMatch(onResolvedArgs.toLocation.pathname, onResolvedArgs.toLocation.search); + const { toLocation } = onResolvedArgs; + const resolvedMatch = resolveRouteMatch(toLocation.pathname, toLocation.search); if (resolvedMatch) { - applyRouteMatch(span, resolvedMatch, WINDOW.location.pathname); + applyRouteMatch(span, resolvedMatch, toLocation, WINDOW.location?.pathname || toLocation.pathname); } }); } @@ -149,6 +168,25 @@ export function tanstackRouterBrowserTracingIntegration( }; } +function locationToAbsoluteUrl(router: VendoredTanstackRouter, location: TanstackRouterLocation): string { + const search = router.options.stringifySearch?.(location.search) ?? ''; + const pathWithSearch = `${location.pathname}${search && search !== '?' ? search : ''}`; + + return getAbsoluteUrl(pathWithSearch); +} + +function locationToSpanUrlAttributes( + router: VendoredTanstackRouter, + location: TanstackRouterLocation, +): Record { + const absoluteUrl = locationToAbsoluteUrl(router, location); + + return { + [URL_PATH]: location.pathname, + [URL_FULL]: absoluteUrl, + }; +} + function routeMatchToParamSpanAttributes(match: VendoredTanstackRouterRouteMatch | undefined): Record { if (!match) { return {}; @@ -157,8 +195,8 @@ function routeMatchToParamSpanAttributes(match: VendoredTanstackRouterRouteMatch const paramAttributes: Record = {}; Object.entries(match.params).forEach(([key, value]) => { paramAttributes[`url.path.params.${key}`] = value; // TODO(v11): remove attribute which does not adhere to Sentry's semantic convention - paramAttributes[`url.path.parameter.${key}`] = value; - paramAttributes[`params.${key}`] = value; // params.[key] is an alias + paramAttributes[URL_PATH_PARAMETER_KEY.replace('', key)] = value; + paramAttributes[PARAMS_KEY.replace('', key)] = value; // params.[key] is an alias }); return paramAttributes; diff --git a/packages/react/src/vendor/tanstackrouter-types.ts b/packages/react/src/vendor/tanstackrouter-types.ts index aa5e70821c46..3936c429cd5d 100644 --- a/packages/react/src/vendor/tanstackrouter-types.ts +++ b/packages/react/src/vendor/tanstackrouter-types.ts @@ -11,6 +11,7 @@ export interface VendoredTanstackRouter { options: { // eslint-disable-next-line @typescript-eslint/no-explicit-any parseSearch: (search: string) => Record; + stringifySearch?: (search: Record) => string; }; matchRoutes: ( pathname: string, diff --git a/packages/react/test/reactrouter-cross-usage.test.tsx b/packages/react/test/reactrouter-cross-usage.test.tsx index a5c797af8bc9..05fa5d9411a1 100644 --- a/packages/react/test/reactrouter-cross-usage.test.tsx +++ b/packages/react/test/reactrouter-cross-usage.test.tsx @@ -9,6 +9,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCurrentClient, } from '@sentry/core'; +import { URL_TEMPLATE } from '@sentry/conventions/attributes'; import { render, waitFor } from '@testing-library/react'; import * as React from 'react'; import { act } from 'react'; @@ -156,7 +157,8 @@ describe('React Router cross usage of wrappers', () => { expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/second-level/:id/third-level/:id'); - expect(mockRootSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/second-level/:id/third-level/:id'); }); it('works with descendant wildcard routes - navigation', () => { @@ -278,7 +280,8 @@ describe('React Router cross usage of wrappers', () => { expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/second-level/:id/third-level/:id'); - expect(mockRootSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/second-level/:id/third-level/:id'); }); it('works with descendant wildcard routes - navigation', () => { @@ -393,7 +396,8 @@ describe('React Router cross usage of wrappers', () => { expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/second-level/:id/third-level/:id'); - expect(mockRootSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/second-level/:id/third-level/:id'); }); it('works with descendant wildcard routes - navigation', () => { @@ -521,7 +525,8 @@ describe('React Router cross usage of wrappers', () => { expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/second-level/:id/third-level/:id'); - expect(mockRootSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/second-level/:id/third-level/:id'); }); it('works with descendant wildcard routes - navigation', () => { @@ -642,6 +647,7 @@ describe('React Router cross usage of wrappers', () => { name: '/settings', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/settings', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, @@ -760,6 +766,7 @@ describe('React Router cross usage of wrappers', () => { name: '/user/:id', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/user/:id', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, @@ -781,6 +788,7 @@ describe('React Router cross usage of wrappers', () => { name: '/user/:id', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/user/:id', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, diff --git a/packages/react/test/reactrouter-descendant-routes.test.tsx b/packages/react/test/reactrouter-descendant-routes.test.tsx index 1172ee8d5d3e..9b7c4de53011 100644 --- a/packages/react/test/reactrouter-descendant-routes.test.tsx +++ b/packages/react/test/reactrouter-descendant-routes.test.tsx @@ -9,6 +9,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCurrentClient, } from '@sentry/core'; +import { URL_TEMPLATE } from '@sentry/conventions/attributes'; import { render } from '@testing-library/react'; import * as React from 'react'; import { @@ -134,7 +135,8 @@ describe('React Router Descendant Routes', () => { expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/projects/:projectId/views/:viewId/:detailId'); - expect(mockRootSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/projects/:projectId/views/:viewId/:detailId'); }); it('works with descendant wildcard routes - navigation', () => { @@ -187,6 +189,7 @@ describe('React Router Descendant Routes', () => { name: '/projects/:projectId/views/:viewId/:detailId', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/projects/:projectId/views/:viewId/:detailId', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, @@ -247,6 +250,7 @@ describe('React Router Descendant Routes', () => { name: '/projects/:projectId/views/:viewId/:detailId', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/projects/:projectId/views/:viewId/:detailId', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, @@ -320,7 +324,8 @@ describe('React Router Descendant Routes', () => { expect(container.innerHTML).toContain('Details'); expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/projects/:projectId/views/:viewId/:detailId'); - expect(mockRootSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/projects/:projectId/views/:viewId/:detailId'); }); it('works with descendant wildcard routes - navigation', () => { @@ -395,6 +400,7 @@ describe('React Router Descendant Routes', () => { name: '/projects/:projectId/views/:viewId/:detailId', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/projects/:projectId/views/:viewId/:detailId', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, diff --git a/packages/react/test/reactrouterv3.test.tsx b/packages/react/test/reactrouterv3.test.tsx index b398ba226be8..2679e6dd033b 100644 --- a/packages/react/test/reactrouterv3.test.tsx +++ b/packages/react/test/reactrouterv3.test.tsx @@ -10,6 +10,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCurrentClient, } from '@sentry/core'; +import { URL_TEMPLATE } from '@sentry/conventions/attributes'; import { render } from '@testing-library/react'; import * as React from 'react'; import { act } from 'react'; @@ -104,6 +105,7 @@ describe('browserTracingReactRouterV3', () => { name: '/', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v3', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', }, @@ -140,6 +142,7 @@ describe('browserTracingReactRouterV3', () => { name: '/about', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/about', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v3', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', }, @@ -153,6 +156,7 @@ describe('browserTracingReactRouterV3', () => { name: '/features', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/features', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v3', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', }, @@ -194,6 +198,7 @@ describe('browserTracingReactRouterV3', () => { name: '/users/:userid', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/users/:userid', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v3', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', }, @@ -209,6 +214,7 @@ describe('browserTracingReactRouterV3', () => { name: '/teams/:teamId/details', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/teams/:teamId/details', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v3', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', }, diff --git a/packages/react/test/reactrouterv4.test.tsx b/packages/react/test/reactrouterv4.test.tsx index 3ebd236e7a25..1754e0aea102 100644 --- a/packages/react/test/reactrouterv4.test.tsx +++ b/packages/react/test/reactrouterv4.test.tsx @@ -15,6 +15,7 @@ import * as React from 'react'; import { act } from 'react'; import { matchPath, Route, Router, Switch } from 'react-router-4'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { URL_TEMPLATE } from '@sentry/conventions/attributes'; import { BrowserClient, reactRouterV4BrowserTracingIntegration, withSentryRouting } from '../src'; import type { RouteConfig } from '../src/reactrouter'; @@ -243,6 +244,7 @@ describe('browserTracingReactRouterV4', () => { expect(mockRootSpan.updateName).toHaveBeenCalledTimes(2); expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/users/:userid'); expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/users/:userid'); }); it('normalizes nested transaction names with custom Route', () => { @@ -282,7 +284,8 @@ describe('browserTracingReactRouterV4', () => { }); expect(mockRootSpan.updateName).toHaveBeenCalledTimes(2); expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/organizations/:orgid/v1/:teamid'); - expect(mockRootSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/organizations/:orgid/v1/:teamid'); act(() => { history.push('/organizations/543'); @@ -300,7 +303,8 @@ describe('browserTracingReactRouterV4', () => { }); expect(mockRootSpan.updateName).toHaveBeenCalledTimes(3); expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/organizations/:orgid'); - expect(mockRootSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/organizations/:orgid'); }); it('matches with route object', () => { @@ -337,6 +341,7 @@ describe('browserTracingReactRouterV4', () => { name: '/organizations/:orgid/v1/:teamid', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/organizations/:orgid/v1/:teamid', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', }, @@ -350,6 +355,7 @@ describe('browserTracingReactRouterV4', () => { name: '/organizations/:orgid', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/organizations/:orgid', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v4', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', }, diff --git a/packages/react/test/reactrouterv5.test.tsx b/packages/react/test/reactrouterv5.test.tsx index 1351fe0d1806..0b25c19830cc 100644 --- a/packages/react/test/reactrouterv5.test.tsx +++ b/packages/react/test/reactrouterv5.test.tsx @@ -15,6 +15,7 @@ import * as React from 'react'; import { act } from 'react'; import { matchPath, Route, Router, Switch } from 'react-router-5'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { URL_TEMPLATE } from '@sentry/conventions/attributes'; import { BrowserClient, reactRouterV5BrowserTracingIntegration, withSentryRouting } from '../src'; import type { RouteConfig } from '../src/reactrouter'; @@ -243,6 +244,7 @@ describe('browserTracingReactRouterV5', () => { expect(mockRootSpan.updateName).toHaveBeenCalledTimes(2); expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/users/:userid'); expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/users/:userid'); }); it('normalizes nested transaction names with custom Route', () => { @@ -282,7 +284,8 @@ describe('browserTracingReactRouterV5', () => { }); expect(mockRootSpan.updateName).toHaveBeenCalledTimes(2); expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/organizations/:orgid/v1/:teamid'); - expect(mockRootSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/organizations/:orgid/v1/:teamid'); act(() => { history.push('/organizations/543'); @@ -300,7 +303,8 @@ describe('browserTracingReactRouterV5', () => { }); expect(mockRootSpan.updateName).toHaveBeenCalledTimes(3); expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/organizations/:orgid'); - expect(mockRootSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/organizations/:orgid'); }); it('matches with route object', () => { @@ -339,6 +343,7 @@ describe('browserTracingReactRouterV5', () => { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [URL_TEMPLATE]: '/organizations/:orgid/v1/:teamid', }, }); @@ -352,6 +357,7 @@ describe('browserTracingReactRouterV5', () => { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v5', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [URL_TEMPLATE]: '/organizations/:orgid', }, }); }); diff --git a/packages/react/test/reactrouterv6.test.tsx b/packages/react/test/reactrouterv6.test.tsx index 1cda4771672d..80afbedf22ec 100644 --- a/packages/react/test/reactrouterv6.test.tsx +++ b/packages/react/test/reactrouterv6.test.tsx @@ -9,6 +9,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCurrentClient, } from '@sentry/core'; +import { URL_TEMPLATE } from '@sentry/conventions/attributes'; import { fireEvent, render } from '@testing-library/react'; import * as React from 'react'; import type { RouteObject } from 'react-router-6'; @@ -330,6 +331,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { name: '/about', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/about', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, @@ -416,6 +418,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { name: '/about/us', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/about/us', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, @@ -453,6 +456,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { name: '/about/:page', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/about/:page', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, @@ -492,6 +496,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { name: '/stores/:storeId/products/:productId', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/stores/:storeId/products/:productId', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, @@ -539,6 +544,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { name: '/projects/:projectId/views/:viewId', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/projects/:projectId/views/:viewId', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, @@ -588,6 +594,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { name: '/projects/:projectId/views/:viewId', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/projects/:projectId/views/:viewId', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, @@ -639,6 +646,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { name: '/projects/:projectId/views/:viewId', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/projects/:projectId/views/:viewId', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, @@ -678,6 +686,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { name: '/issues/:groupId/', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/issues/:groupId/', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, @@ -717,6 +726,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { name: '/issues/:groupId/', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/issues/:groupId/', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, @@ -940,6 +950,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { name: '/about', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/about', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, @@ -991,6 +1002,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { name: '/about/us', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/about/us', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, @@ -1042,6 +1054,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { name: '/about/:page', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/about/:page', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, @@ -1099,6 +1112,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { name: '/stores/:storeId/products/:productId', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/stores/:storeId/products/:productId', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, @@ -1180,6 +1194,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { name: '/projects/:projectId/views/:viewId', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/projects/:projectId/views/:viewId', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, @@ -1259,6 +1274,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { name: '/param-page/:id/details/:superId', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/param-page/:id/details/:superId', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, @@ -1415,6 +1431,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { name: '/issues/:groupId/', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/issues/:groupId/', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, @@ -1454,6 +1471,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { name: '/issues/:groupId/', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/issues/:groupId/', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, From f60c2acec5ffddebce1c7160d0ecf60a5d656e6d Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 8 Jul 2026 14:27:00 +0200 Subject: [PATCH 2/3] format --- .../tests/transactions.test.ts | 208 +++++++++--------- .../reactrouter-descendant-routes.test.tsx | 10 +- 2 files changed, 112 insertions(+), 106 deletions(-) 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 f2bb708b8e88..e1086eb00959 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 @@ -171,21 +171,21 @@ test('Captures a parameterized path pageload transaction', async ({ page }) => { 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', - }, + 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 }) => { @@ -198,21 +198,21 @@ test('Captures a parameterized path pageload transaction for nested route', asyn 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', - }, + 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 }) => { @@ -225,21 +225,21 @@ test('Captures a parameterized path pageload transaction for deeply nested route 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', - }, + 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 }) => { @@ -252,21 +252,21 @@ test('Captures a parameterized path pageload transaction for nested route with a 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', - }, + 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 }) => { @@ -281,21 +281,21 @@ test('Captures a parameterized path navigation transaction', async ({ page }) => 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', - }, + 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 }) => { @@ -310,21 +310,21 @@ test('Captures a parameterized path navigation transaction for nested route', as 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', - }, + 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 }) => { @@ -339,21 +339,21 @@ test('Captures a parameterized path navigation transaction for deeply nested rou 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', - }, + 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 }) => { @@ -368,21 +368,21 @@ test('Captures a parameterized path navigation transaction for nested route with 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', - }, + 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 }) => { diff --git a/packages/react/test/reactrouter-descendant-routes.test.tsx b/packages/react/test/reactrouter-descendant-routes.test.tsx index 9b7c4de53011..e172b8b65f0b 100644 --- a/packages/react/test/reactrouter-descendant-routes.test.tsx +++ b/packages/react/test/reactrouter-descendant-routes.test.tsx @@ -136,7 +136,10 @@ describe('React Router Descendant Routes', () => { expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/projects/:projectId/views/:viewId/:detailId'); expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/projects/:projectId/views/:viewId/:detailId'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith( + URL_TEMPLATE, + '/projects/:projectId/views/:viewId/:detailId', + ); }); it('works with descendant wildcard routes - navigation', () => { @@ -325,7 +328,10 @@ describe('React Router Descendant Routes', () => { expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/projects/:projectId/views/:viewId/:detailId'); expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/projects/:projectId/views/:viewId/:detailId'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith( + URL_TEMPLATE, + '/projects/:projectId/views/:viewId/:detailId', + ); }); it('works with descendant wildcard routes - navigation', () => { From 1ccda079a3ae523b0c99d1769867085be2267362 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 9 Jul 2026 14:51:05 +0200 Subject: [PATCH 3/3] fix same-route redirects in tanstack react router --- .../tanstack-router/src/main.tsx | 5 + .../tests/routing-instrumentation.test.ts | 35 +++++ packages/react/src/tanstackrouter.ts | 43 +++--- packages/react/test/reactrouterv4.test.tsx | 25 +++- packages/react/test/reactrouterv5.test.tsx | 25 +++- packages/react/test/tanstackrouter.test.ts | 131 ++++++++++++++++++ 6 files changed, 227 insertions(+), 37 deletions(-) create mode 100644 packages/react/test/tanstackrouter.test.ts 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 47d112136049..3c2ed2905383 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 @@ -63,6 +63,11 @@ const postIdRoute = createRoute({ shouldReload() { return true; }, + beforeLoad: ({ params }) => { + if (params.postId === '999') { + throw redirect({ to: '/posts/$postId', params: { postId: '2' }, replace: true }); + } + }, loader: ({ params }) => { return Sentry.startSpan({ name: `loading-post-${params.postId}` }, async () => { await new Promise(resolve => setTimeout(resolve, 1000)); 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 91ecba36e3f2..06708292089e 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 @@ -130,6 +130,41 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) }); }); +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'; + }); + + // `/posts/999` matches `/posts/$postId` initially, then `beforeLoad` redirects to `/posts/2`. + await page.goto(`/posts/999`); + + const pageloadTxn = await pageloadTxnPromise; + + expect(pageloadTxn).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.pageload.react.tanstack_router', + 'sentry.op': 'pageload', + 'url.path.params.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', + }, + }); +}); + test('sends a pageload transaction named after the resolved route when a redirect is thrown on initial load', async ({ page, }) => { diff --git a/packages/react/src/tanstackrouter.ts b/packages/react/src/tanstackrouter.ts index e963e701efd0..42993182cd55 100644 --- a/packages/react/src/tanstackrouter.ts +++ b/packages/react/src/tanstackrouter.ts @@ -59,6 +59,21 @@ export function tanstackRouterBrowserTracingIntegration( return lastMatch?.routeId !== '__root__' ? lastMatch : undefined; }; + const applyRouteMatch = ( + span: NonNullable>, + match: VendoredTanstackRouterRouteMatch | undefined, + toLocation: TanstackRouterLocation, + fallbackName: string, + ): void => { + span.updateName(match ? match.routeId : fallbackName); + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, match ? 'route' : 'url'); + span.setAttributes({ + ...(match && { [URL_TEMPLATE]: match.routeId }), + ...locationToSpanUrlAttributes(castRouterInstance, toLocation), + ...routeMatchToParamSpanAttributes(match), + }); + }; + const initialWindowLocation = WINDOW.location; if (instrumentPageLoad && initialWindowLocation) { const routeMatch = resolveRouteMatch( @@ -84,16 +99,9 @@ export function tanstackRouterBrowserTracingIntegration( if (!pageloadSpan) { return; } - const resolvedMatch = resolveRouteMatch(onResolvedArgs.toLocation.pathname, onResolvedArgs.toLocation.search); - if (resolvedMatch && resolvedMatch.routeId !== routeMatch?.routeId) { - pageloadSpan.updateName(resolvedMatch.routeId); - pageloadSpan.setAttributes({ - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [URL_TEMPLATE]: resolvedMatch.routeId, - ...locationToSpanUrlAttributes(castRouterInstance, onResolvedArgs.toLocation), - ...routeMatchToParamSpanAttributes(resolvedMatch), - }); - } + const { toLocation } = onResolvedArgs; + const resolvedMatch = resolveRouteMatch(toLocation.pathname, toLocation.search); + applyRouteMatch(pageloadSpan, resolvedMatch, toLocation, toLocation.pathname); }); } @@ -104,21 +112,6 @@ export function tanstackRouterBrowserTracingIntegration( // span on the first `onBeforeLoad`, rename it on later ones, and clear it on `onResolved`. let inFlightNavigationSpan: ReturnType | undefined; - const applyRouteMatch = ( - span: NonNullable, - match: VendoredTanstackRouterRouteMatch | undefined, - toLocation: TanstackRouterLocation, - fallbackName: string, - ): void => { - span.updateName(match ? match.routeId : fallbackName); - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, match ? 'route' : 'url'); - span.setAttributes({ - ...(match && { [URL_TEMPLATE]: match.routeId }), - ...locationToSpanUrlAttributes(castRouterInstance, toLocation), - ...routeMatchToParamSpanAttributes(match), - }); - }; - castRouterInstance.subscribe('onBeforeLoad', onBeforeLoadArgs => { const { toLocation, fromLocation } = onBeforeLoadArgs; // Skip the initial pageload (no fromLocation) and no-op reloads (same state). diff --git a/packages/react/test/reactrouterv4.test.tsx b/packages/react/test/reactrouterv4.test.tsx index 1754e0aea102..779adfb31559 100644 --- a/packages/react/test/reactrouterv4.test.tsx +++ b/packages/react/test/reactrouterv4.test.tsx @@ -25,6 +25,7 @@ const mockStartBrowserTracingNavigationSpan = vi.fn(); const mockRootSpan = { updateName: vi.fn(), setAttribute: vi.fn(), + setAttributes: vi.fn(), getSpanJSON() { return { op: 'pageload' }; }, @@ -243,8 +244,12 @@ describe('browserTracingReactRouterV4', () => { }); expect(mockRootSpan.updateName).toHaveBeenCalledTimes(2); expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/users/:userid'); - expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/users/:userid'); + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/users/:userid', + }), + ); }); it('normalizes nested transaction names with custom Route', () => { @@ -284,8 +289,12 @@ describe('browserTracingReactRouterV4', () => { }); expect(mockRootSpan.updateName).toHaveBeenCalledTimes(2); expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/organizations/:orgid/v1/:teamid'); - expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/organizations/:orgid/v1/:teamid'); + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/organizations/:orgid/v1/:teamid', + }), + ); act(() => { history.push('/organizations/543'); @@ -303,8 +312,12 @@ describe('browserTracingReactRouterV4', () => { }); expect(mockRootSpan.updateName).toHaveBeenCalledTimes(3); expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/organizations/:orgid'); - expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/organizations/:orgid'); + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/organizations/:orgid', + }), + ); }); it('matches with route object', () => { diff --git a/packages/react/test/reactrouterv5.test.tsx b/packages/react/test/reactrouterv5.test.tsx index 0b25c19830cc..7841833fb6cc 100644 --- a/packages/react/test/reactrouterv5.test.tsx +++ b/packages/react/test/reactrouterv5.test.tsx @@ -25,6 +25,7 @@ const mockStartBrowserTracingNavigationSpan = vi.fn(); const mockRootSpan = { updateName: vi.fn(), setAttribute: vi.fn(), + setAttributes: vi.fn(), getSpanJSON() { return { op: 'pageload' }; }, @@ -243,8 +244,12 @@ describe('browserTracingReactRouterV5', () => { }); expect(mockRootSpan.updateName).toHaveBeenCalledTimes(2); expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/users/:userid'); - expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/users/:userid'); + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/users/:userid', + }), + ); }); it('normalizes nested transaction names with custom Route', () => { @@ -284,8 +289,12 @@ describe('browserTracingReactRouterV5', () => { }); expect(mockRootSpan.updateName).toHaveBeenCalledTimes(2); expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/organizations/:orgid/v1/:teamid'); - expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/organizations/:orgid/v1/:teamid'); + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/organizations/:orgid/v1/:teamid', + }), + ); act(() => { history.push('/organizations/543'); @@ -303,8 +312,12 @@ describe('browserTracingReactRouterV5', () => { }); expect(mockRootSpan.updateName).toHaveBeenCalledTimes(3); expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/organizations/:orgid'); - expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/organizations/:orgid'); + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/organizations/:orgid', + }), + ); }); it('matches with route object', () => { diff --git a/packages/react/test/tanstackrouter.test.ts b/packages/react/test/tanstackrouter.test.ts new file mode 100644 index 000000000000..c45bd83753c0 --- /dev/null +++ b/packages/react/test/tanstackrouter.test.ts @@ -0,0 +1,131 @@ +import * as SentryBrowser from '@sentry/browser'; +import { URL_TEMPLATE } from '@sentry/conventions/attributes'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core/browser'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { tanstackRouterBrowserTracingIntegration } from '../src/tanstackrouter'; + +vi.mock('@sentry/browser', async () => { + const actual = await vi.importActual('@sentry/browser'); + return { + ...actual, + WINDOW: { + location: { + pathname: '/posts/999', + search: '', + }, + }, + }; +}); + +const startBrowserTracingPageLoadSpanSpy = vi.spyOn(SentryBrowser, 'startBrowserTracingPageLoadSpan'); + +const mockPageloadSpan = { + updateName: vi.fn(), + setAttribute: vi.fn(), + setAttributes: vi.fn(), +}; + +describe('tanstackRouterBrowserTracingIntegration', () => { + const mockMatchedRoutes = [ + { + routeId: '/posts/$postId', + pathname: '/posts/999', + params: { postId: '999' }, + }, + ]; + + const mockRouter = { + options: { + parseSearch: vi.fn(() => ({})), + stringifySearch: vi.fn(() => ''), + }, + matchRoutes: vi.fn(() => mockMatchedRoutes), + subscribe: vi.fn(() => vi.fn()), + }; + + const mockClient = { + on: vi.fn(), + emit: vi.fn(), + getOptions: vi.fn(() => ({})), + addEventProcessor: vi.fn(), + }; + + const getSubscribeCallback = (eventType: string): ((...args: any[]) => void) => + (mockRouter.subscribe as any).mock.calls.find( + (call: [string, (...args: any[]) => void]) => call[0] === eventType, + )?.[1]; + + beforeEach(() => { + vi.clearAllMocks(); + startBrowserTracingPageLoadSpanSpy.mockReturnValue(mockPageloadSpan as any); + + vi.stubGlobal('window', { + location: { + pathname: '/posts/999', + search: '', + }, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it('instruments pageload on setup', () => { + const integration = tanstackRouterBrowserTracingIntegration(mockRouter, { + instrumentPageLoad: true, + instrumentNavigation: false, + }); + + integration.afterAllSetup!(mockClient as any); + + expect(startBrowserTracingPageLoadSpanSpy).toHaveBeenCalledWith(mockClient, { + name: '/posts/$postId', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.tanstack_router', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/posts/$postId', + 'url.path.params.postId': '999', + }), + }); + }); + + it('updates pageload span URL attributes on redirect to the same route template', () => { + const integration = tanstackRouterBrowserTracingIntegration(mockRouter, { + instrumentPageLoad: true, + instrumentNavigation: false, + }); + + integration.afterAllSetup!(mockClient as any); + + const onResolvedCallback = getSubscribeCallback('onResolved'); + expect(onResolvedCallback).toBeDefined(); + + (mockRouter.matchRoutes as any).mockReturnValueOnce([ + { + routeId: '/posts/$postId', + pathname: '/posts/2', + params: { postId: '2' }, + }, + ]); + + onResolvedCallback({ + toLocation: { + pathname: '/posts/2', + search: {}, + }, + }); + + expect(mockPageloadSpan.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + [URL_TEMPLATE]: '/posts/$postId', + 'url.path': '/posts/2', + 'url.full': expect.any(String), + 'url.path.params.postId': '2', + 'url.path.parameter.postId': '2', + 'params.postId': '2', + }), + ); + }); +});