Skip to content

Commit 989396c

Browse files
authored
fix(react/solid/vue): Instrument TanStack Router navigations via onBeforeLoad/onResolved (#21975)
Currently we only instrument navigations via `onBeforeNavigate`. This hook is surpressed in TanStack after any `redirect` was thrown. This PR changes the mechanics to instrument via `onBeforeLoad`/`onResolved`, both are unaffected by redirects. Verified this in E2E. Note that currently the integration is duplicated in the specific SDKs, while the code is almost identical. We can follow up by moving this into browser maybe, but out of scope for this PR closes #21966
1 parent 1bc2608 commit 989396c

12 files changed

Lines changed: 703 additions & 188 deletions

File tree

dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/main.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { Link, Outlet, RouterProvider, createRootRoute, createRoute, createRouter } from '@tanstack/solid-router';
1+
import {
2+
Link,
3+
Outlet,
4+
RouterProvider,
5+
createRootRoute,
6+
createRoute,
7+
createRouter,
8+
redirect,
9+
} from '@tanstack/solid-router';
210
import * as Sentry from '@sentry/solid';
311
import { tanstackRouterBrowserTracingIntegration } from '@sentry/solid/tanstackrouter';
412
import { render } from 'solid-js/web';
@@ -24,6 +32,11 @@ const rootRoute = createRootRoute({
2432
Post 2
2533
</Link>
2634
</li>
35+
<li>
36+
<Link to="/redirect" id="redirect-link">
37+
Redirect
38+
</Link>
39+
</li>
2740
</ul>
2841
<hr />
2942
<Outlet />
@@ -59,7 +72,15 @@ const postIdRoute = createRoute({
5972
},
6073
});
6174

62-
const routeTree = rootRoute.addChildren([indexRoute, postsRoute.addChildren([postIdRoute])]);
75+
const redirectRoute = createRoute({
76+
getParentRoute: () => rootRoute,
77+
path: 'redirect',
78+
beforeLoad: () => {
79+
throw redirect({ to: '/posts/$postId', params: { postId: '1' }, replace: true });
80+
},
81+
});
82+
83+
const routeTree = rootRoute.addChildren([indexRoute, redirectRoute, postsRoute.addChildren([postIdRoute])]);
6384

6485
const router = createRouter({
6586
routeTree,

dev-packages/e2e-tests/test-applications/solid-tanstack-router/tests/routing-instrumentation.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,122 @@ test('sends a navigation transaction with a parameterized URL', async ({ page })
7171
});
7272
});
7373

74+
test('sends a pageload transaction named after the resolved route when a redirect is thrown on initial load', async ({
75+
page,
76+
}) => {
77+
const pageloadTxnPromise = waitForTransaction('solid-tanstack-router', async transactionEvent => {
78+
return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/posts/$postId';
79+
});
80+
81+
await page.goto(`/redirect`);
82+
83+
const pageloadTxn = await pageloadTxnPromise;
84+
85+
expect(pageloadTxn).toMatchObject({
86+
contexts: {
87+
trace: {
88+
data: {
89+
'sentry.source': 'route',
90+
'sentry.origin': 'auto.pageload.solid.tanstack_router',
91+
'sentry.op': 'pageload',
92+
'url.path.parameter.postId': '1',
93+
},
94+
op: 'pageload',
95+
origin: 'auto.pageload.solid.tanstack_router',
96+
},
97+
},
98+
transaction: '/posts/$postId',
99+
transaction_info: {
100+
source: 'route',
101+
},
102+
});
103+
});
104+
105+
test('sends a navigation transaction when a redirect is thrown in beforeLoad', async ({ page }) => {
106+
const pageloadTxnPromise = waitForTransaction('solid-tanstack-router', async transactionEvent => {
107+
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
108+
});
109+
110+
const navigationTxnPromise = waitForTransaction('solid-tanstack-router', async transactionEvent => {
111+
return (
112+
!!transactionEvent?.transaction &&
113+
transactionEvent.transaction === '/posts/$postId' &&
114+
transactionEvent.contexts?.trace?.op === 'navigation'
115+
);
116+
});
117+
118+
await page.goto(`/`);
119+
await pageloadTxnPromise;
120+
121+
await page.locator('#redirect-link').click();
122+
123+
const navigationTxn = await navigationTxnPromise;
124+
125+
expect(navigationTxn).toMatchObject({
126+
contexts: {
127+
trace: {
128+
data: {
129+
'sentry.source': 'route',
130+
'sentry.origin': 'auto.navigation.solid.tanstack_router',
131+
'sentry.op': 'navigation',
132+
'url.path.parameter.postId': '1',
133+
},
134+
op: 'navigation',
135+
origin: 'auto.navigation.solid.tanstack_router',
136+
},
137+
},
138+
transaction: '/posts/$postId',
139+
transaction_info: {
140+
source: 'route',
141+
},
142+
});
143+
});
144+
145+
test('sends a navigation transaction for a normal navigation that happens after a redirect', async ({ page }) => {
146+
const pageloadTxnPromise = waitForTransaction('solid-tanstack-router', async transactionEvent => {
147+
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
148+
});
149+
150+
await page.goto(`/`);
151+
await pageloadTxnPromise;
152+
153+
const redirectTxnPromise = waitForTransaction('solid-tanstack-router', async transactionEvent => {
154+
return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/posts/$postId';
155+
});
156+
await page.locator('#redirect-link').click();
157+
await redirectTxnPromise;
158+
159+
const navigationTxnPromise = waitForTransaction('solid-tanstack-router', async transactionEvent => {
160+
return (
161+
transactionEvent.contexts?.trace?.op === 'navigation' &&
162+
transactionEvent.contexts?.trace?.data?.['url.path.parameter.postId'] === '2'
163+
);
164+
});
165+
166+
await page.locator('#nav-link').click();
167+
168+
const navigationTxn = await navigationTxnPromise;
169+
170+
expect(navigationTxn).toMatchObject({
171+
contexts: {
172+
trace: {
173+
data: {
174+
'sentry.source': 'route',
175+
'sentry.origin': 'auto.navigation.solid.tanstack_router',
176+
'sentry.op': 'navigation',
177+
'url.path.parameter.postId': '2',
178+
},
179+
op: 'navigation',
180+
origin: 'auto.navigation.solid.tanstack_router',
181+
},
182+
},
183+
transaction: '/posts/$postId',
184+
transaction_info: {
185+
source: 'route',
186+
},
187+
});
188+
});
189+
74190
test('sends pageload transaction with web vitals measurements', async ({ page }) => {
75191
const transactionPromise = waitForTransaction('solid-tanstack-router', async transactionEvent => {
76192
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';

dev-packages/e2e-tests/test-applications/tanstack-router/src/main.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import * as Sentry from '@sentry/react';
2-
import { Link, Outlet, RouterProvider, createRootRoute, createRoute, createRouter } from '@tanstack/react-router';
2+
import {
3+
Link,
4+
Outlet,
5+
RouterProvider,
6+
createRootRoute,
7+
createRoute,
8+
createRouter,
9+
redirect,
10+
} from '@tanstack/react-router';
311
import { StrictMode } from 'react';
412
import ReactDOM from 'react-dom/client';
513

@@ -20,6 +28,11 @@ const rootRoute = createRootRoute({
2028
Post 2
2129
</Link>
2230
</li>
31+
<li>
32+
<Link to="/redirect" id="redirect-link">
33+
Redirect
34+
</Link>
35+
</li>
2336
</ul>
2437
<hr />
2538
<Outlet />
@@ -61,7 +74,15 @@ const postIdRoute = createRoute({
6174
},
6275
});
6376

64-
const routeTree = rootRoute.addChildren([indexRoute, postsRoute.addChildren([postIdRoute])]);
77+
const redirectRoute = createRoute({
78+
getParentRoute: () => rootRoute,
79+
path: 'redirect',
80+
beforeLoad: () => {
81+
throw redirect({ to: '/posts/$postId', params: { postId: '1' }, replace: true });
82+
},
83+
});
84+
85+
const routeTree = rootRoute.addChildren([indexRoute, redirectRoute, postsRoute.addChildren([postIdRoute])]);
6586

6687
const router = createRouter({ routeTree });
6788

dev-packages/e2e-tests/test-applications/tanstack-router/tests/routing-instrumentation.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,123 @@ test('sends a navigation transaction with a parameterized URL', async ({ page })
117117
]),
118118
});
119119
});
120+
121+
test('sends a pageload transaction named after the resolved route when a redirect is thrown on initial load', async ({
122+
page,
123+
}) => {
124+
const pageloadTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => {
125+
return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/posts/$postId';
126+
});
127+
128+
// Visiting `/redirect` directly throws `redirect({ to: '/posts/$postId', params: { postId: '1' } })`
129+
// in `beforeLoad` during the initial pageload, so the pageload span must be renamed to the target route.
130+
await page.goto(`/redirect`);
131+
132+
const pageloadTxn = await pageloadTxnPromise;
133+
134+
expect(pageloadTxn).toMatchObject({
135+
contexts: {
136+
trace: {
137+
data: {
138+
'sentry.source': 'route',
139+
'sentry.origin': 'auto.pageload.react.tanstack_router',
140+
'sentry.op': 'pageload',
141+
'url.path.params.postId': '1',
142+
},
143+
op: 'pageload',
144+
origin: 'auto.pageload.react.tanstack_router',
145+
},
146+
},
147+
transaction: '/posts/$postId',
148+
transaction_info: {
149+
source: 'route',
150+
},
151+
});
152+
});
153+
154+
test('sends a navigation transaction when a redirect is thrown in beforeLoad', async ({ page }) => {
155+
const pageloadTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => {
156+
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
157+
});
158+
159+
const navigationTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => {
160+
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation';
161+
});
162+
163+
await page.goto(`/`);
164+
await pageloadTxnPromise;
165+
166+
await page.locator('#redirect-link').click();
167+
168+
const navigationTxn = await navigationTxnPromise;
169+
170+
// The `/redirect` route throws `redirect({ to: '/posts/$postId', params: { postId: '1' } })` in
171+
// `beforeLoad`, so the navigation span must be named after the resolved target route, not `/redirect`.
172+
expect(navigationTxn).toMatchObject({
173+
contexts: {
174+
trace: {
175+
data: {
176+
'sentry.source': 'route',
177+
'sentry.origin': 'auto.navigation.react.tanstack_router',
178+
'sentry.op': 'navigation',
179+
'url.path.params.postId': '1',
180+
},
181+
op: 'navigation',
182+
origin: 'auto.navigation.react.tanstack_router',
183+
},
184+
},
185+
transaction: '/posts/$postId',
186+
transaction_info: {
187+
source: 'route',
188+
},
189+
});
190+
});
191+
192+
test('sends a navigation transaction for a normal navigation that happens after a redirect', async ({ page }) => {
193+
const pageloadTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => {
194+
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
195+
});
196+
197+
await page.goto(`/`);
198+
await pageloadTxnPromise;
199+
200+
// First trigger a redirect-driven navigation. Upstream (TanStack/router#3920) this leaves the
201+
// router in a state where `onBeforeNavigate` never fires again, which previously killed all
202+
// subsequent navigation spans.
203+
const redirectTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => {
204+
return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/posts/$postId';
205+
});
206+
await page.locator('#redirect-link').click();
207+
await redirectTxnPromise;
208+
209+
// Now a plain navigation must still produce a navigation span.
210+
const navigationTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => {
211+
return (
212+
transactionEvent.contexts?.trace?.op === 'navigation' &&
213+
transactionEvent.contexts?.trace?.data?.['url.path.params.postId'] === '2'
214+
);
215+
});
216+
217+
await page.locator('#nav-link').click();
218+
219+
const navigationTxn = await navigationTxnPromise;
220+
221+
expect(navigationTxn).toMatchObject({
222+
contexts: {
223+
trace: {
224+
data: {
225+
'sentry.source': 'route',
226+
'sentry.origin': 'auto.navigation.react.tanstack_router',
227+
'sentry.op': 'navigation',
228+
'url.path.params.postId': '2',
229+
},
230+
op: 'navigation',
231+
origin: 'auto.navigation.react.tanstack_router',
232+
},
233+
},
234+
transaction: '/posts/$postId',
235+
transaction_info: {
236+
source: 'route',
237+
},
238+
});
239+
});

dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/App.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
<li>
1212
<Link to="/posts/$postId" :params="{ postId: '2' }" id="nav-link"> Post 2 </Link>
1313
</li>
14+
<li>
15+
<Link to="/redirect" id="redirect-link">Redirect</Link>
16+
</li>
1417
</ul>
1518
</nav>
1619
<hr />

dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/main.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createApp } from 'vue';
2-
import { RouterProvider, createRoute, createRootRoute, createRouter } from '@tanstack/vue-router';
2+
import { RouterProvider, createRoute, createRootRoute, createRouter, redirect } from '@tanstack/vue-router';
33
import * as Sentry from '@sentry/vue';
44
import { tanstackRouterBrowserTracingIntegration } from '@sentry/vue/tanstackrouter';
55

@@ -33,7 +33,15 @@ const postIdRoute = createRoute({
3333
component: PostView,
3434
});
3535

36-
const routeTree = rootRoute.addChildren([indexRoute, postsRoute.addChildren([postIdRoute])]);
36+
const redirectRoute = createRoute({
37+
getParentRoute: () => rootRoute,
38+
path: 'redirect',
39+
beforeLoad: () => {
40+
throw redirect({ to: '/posts/$postId', params: { postId: '1' }, replace: true });
41+
},
42+
});
43+
44+
const routeTree = rootRoute.addChildren([indexRoute, redirectRoute, postsRoute.addChildren([postIdRoute])]);
3745

3846
const router = createRouter({
3947
routeTree,

0 commit comments

Comments
 (0)