diff --git a/packages/vue/src/tanstackrouter.ts b/packages/vue/src/tanstackrouter.ts index e7f6c6efaef7..6521bbe201a5 100644 --- a/packages/vue/src/tanstackrouter.ts +++ b/packages/vue/src/tanstackrouter.ts @@ -61,6 +61,21 @@ export function tanstackRouterBrowserTracingIntegration( return lastMatch?.routeId !== '__root__' ? lastMatch : undefined; }; + const applyRouteMatch = ( + span: NonNullable>, + match: RouteMatch | undefined, + toLocation: TanstackRouterLocation, + fallbackName: string, + ): void => { + span.updateName(match ? match.routeId : fallbackName); + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, match ? 'route' : 'url'); + span.setAttributes({ + [URL_TEMPLATE]: match?.routeId, + ...locationToSpanUrlAttributes(router, toLocation), + ...routeMatchToParamSpanAttributes(match), + }); + }; + const initialWindowLocation = WINDOW.location; if (instrumentPageLoad && initialWindowLocation) { const routeMatch = resolveRouteMatch( @@ -89,15 +104,7 @@ export function tanstackRouterBrowserTracingIntegration( } const { toLocation } = onResolvedArgs as TanstackRouterSubscribeArgs; const resolvedMatch = resolveRouteMatch(toLocation.pathname, toLocation.search); - if (resolvedMatch && resolvedMatch.routeId !== routeMatch?.routeId) { - pageloadSpan.updateName(resolvedMatch.routeId); - pageloadSpan.setAttributes({ - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [URL_TEMPLATE]: resolvedMatch.routeId, - ...locationToSpanUrlAttributes(router, toLocation), - ...routeMatchToParamSpanAttributes(resolvedMatch), - }); - } + applyRouteMatch(pageloadSpan, resolvedMatch, toLocation, toLocation.pathname); }); } @@ -108,21 +115,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: RouteMatch | undefined, - toLocation: TanstackRouterLocation, - fallbackName: string, - ): void => { - span.updateName(match ? match.routeId : fallbackName); - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, match ? 'route' : 'url'); - span.setAttributes({ - [URL_TEMPLATE]: match?.routeId, - ...locationToSpanUrlAttributes(router, toLocation), - ...routeMatchToParamSpanAttributes(match), - }); - }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any router.subscribe('onBeforeLoad', (onBeforeLoadArgs: any) => { const { toLocation, fromLocation } = onBeforeLoadArgs as TanstackRouterSubscribeArgs; diff --git a/packages/vue/test/tanstackrouter.test.ts b/packages/vue/test/tanstackrouter.test.ts index 7f6700098d2d..e622a3e7ba33 100644 --- a/packages/vue/test/tanstackrouter.test.ts +++ b/packages/vue/test/tanstackrouter.test.ts @@ -27,6 +27,12 @@ const mockNavigationSpan = { setAttributes: vi.fn(), }; +const mockPageloadSpan = { + updateName: vi.fn(), + setAttribute: vi.fn(), + setAttributes: vi.fn(), +}; + describe('tanstackRouterBrowserTracingIntegration', () => { const mockMatchedRoutes = [ { @@ -72,6 +78,7 @@ describe('tanstackRouterBrowserTracingIntegration', () => { beforeEach(() => { vi.clearAllMocks(); startBrowserTracingNavigationSpanSpy.mockReturnValue(mockNavigationSpan as any); + startBrowserTracingPageLoadSpanSpy.mockReturnValue(mockPageloadSpan as any); // Mock window.location vi.stubGlobal('window', { @@ -130,6 +137,99 @@ describe('tanstackRouterBrowserTracingIntegration', () => { expect(startBrowserTracingPageLoadSpanSpy).not.toHaveBeenCalled(); }); + 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: '/test/:id', + pathname: '/test/456', + params: { id: '456' }, + }, + ]); + + onResolvedCallback({ + toLocation: { + pathname: '/test/456', + search: {}, + }, + }); + + expect(mockPageloadSpan.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + [URL_TEMPLATE]: '/test/:id', + 'url.path': '/test/456', + 'url.full': expect.any(String), + 'url.path.parameter.id': '456', + 'params.id': '456', + }), + ); + }); + + it('clears url.template on pageload onResolved when the final destination does not match a route', () => { + const integration = tanstackRouterBrowserTracingIntegration(mockRouter, { + instrumentPageLoad: true, + instrumentNavigation: false, + }); + + integration.afterAllSetup(mockClient as any); + + const onResolvedCallback = getSubscribeCallback('onResolved'); + expect(onResolvedCallback).toBeDefined(); + + // A redirect during pageload lands on a URL that no longer matches a route. + (mockRouter.matchRoutes as any).mockReturnValueOnce([{ routeId: '__root__', params: {} }]); + + onResolvedCallback({ + toLocation: { pathname: '/unknown/path', search: {} }, + }); + + expect(mockPageloadSpan.updateName).toHaveBeenCalledWith('/unknown/path'); + expect(mockPageloadSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url'); + expect(mockPageloadSpan.setAttributes).toHaveBeenLastCalledWith( + expect.objectContaining({ + [URL_TEMPLATE]: undefined, + 'url.path': '/unknown/path', + 'url.full': expect.any(String), + }), + ); + }); + + it('unsubscribes the pageload onResolved handler after the first resolution', () => { + const unsubscribe = vi.fn(); + (mockRouter.subscribe as any).mockImplementation((eventType: string) => { + if (eventType === 'onResolved') { + return unsubscribe; + } + return vi.fn(); + }); + + const integration = tanstackRouterBrowserTracingIntegration(mockRouter, { + instrumentPageLoad: true, + instrumentNavigation: false, + }); + + integration.afterAllSetup(mockClient as any); + + const onResolvedCallback = getSubscribeCallback('onResolved'); + expect(onResolvedCallback).toBeDefined(); + + onResolvedCallback({ toLocation: { pathname: '/test/456', search: {} } }); + + // The handler must detach itself so later resolutions (e.g. subsequent navigations) + // don't touch the pageload span again. + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(mockPageloadSpan.updateName).toHaveBeenCalledTimes(1); + }); + const getSubscribeCallback = (eventType: string): ((...args: any[]) => void) => (mockRouter.subscribe as any).mock.calls.find( (call: [string, (...args: any[]) => void]) => call[0] === eventType,