From 2ab555bb435412e434d6ac7a1830945cb4acfd8b Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 8 Jul 2026 14:49:49 +0200 Subject: [PATCH 1/3] feat(solid): Set url attributes on solid router and tanstack router spans Adds url.template, url.path, and url.full to pageload and navigation spans for Solid Router and TanStack Solid Router, including redirect and back-button navigation cases. Updates solidstart and solid-tanstack-router e2e tests. part of https://github.com/getsentry/sentry-javascript/issues/21921 Co-Authored-By: Cursor Agent Co-authored-by: Cursor --- .../tests/routing-instrumentation.test.ts | 21 +++++ .../tests/performance.client.test.ts | 24 +++++ .../tests/performance.client.test.ts | 24 +++++ .../tests/performance.client.test.ts | 24 +++++ .../tests/performance.client.test.ts | 24 +++++ packages/solid/package.json | 3 +- packages/solid/src/solidrouter.ts | 45 +++++++--- packages/solid/src/tanstackrouter.ts | 87 +++++++++++++------ 8 files changed, 214 insertions(+), 38 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/tests/routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/tests/routing-instrumentation.test.ts index 9d835b847f9b..7f140995199a 100644 --- a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/tests/routing-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/solid-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.solid.tanstack_router', 'sentry.op': 'pageload', 'url.path.parameter.postId': '456', + 'url.template': '/posts/$postId', + 'url.path': '/posts/456', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/posts\/456$/), }, op: 'pageload', origin: 'auto.pageload.solid.tanstack_router', @@ -59,6 +62,9 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) 'sentry.origin': 'auto.navigation.solid.tanstack_router', 'sentry.op': 'navigation', 'url.path.parameter.postId': '2', + 'url.template': '/posts/$postId', + 'url.path': '/posts/2', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/posts\/2$/), }, op: 'navigation', origin: 'auto.navigation.solid.tanstack_router', @@ -90,6 +96,9 @@ test('sends a pageload transaction named after the resolved route when a redirec 'sentry.origin': 'auto.pageload.solid.tanstack_router', 'sentry.op': 'pageload', 'url.path.parameter.postId': '1', + 'url.template': '/posts/$postId', + 'url.path': '/posts/1', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/posts\/1$/), }, op: 'pageload', origin: 'auto.pageload.solid.tanstack_router', @@ -130,6 +139,9 @@ test('sends a navigation transaction when a redirect is thrown in beforeLoad', a 'sentry.origin': 'auto.navigation.solid.tanstack_router', 'sentry.op': 'navigation', 'url.path.parameter.postId': '1', + 'url.template': '/posts/$postId', + 'url.path': '/posts/1', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/posts\/1$/), }, op: 'navigation', origin: 'auto.navigation.solid.tanstack_router', @@ -175,6 +187,9 @@ test('sends a navigation transaction for a normal navigation that happens after 'sentry.origin': 'auto.navigation.solid.tanstack_router', 'sentry.op': 'navigation', 'url.path.parameter.postId': '2', + 'url.template': '/posts/$postId', + 'url.path': '/posts/2', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/posts\/2$/), }, op: 'navigation', origin: 'auto.navigation.solid.tanstack_router', @@ -201,6 +216,12 @@ test('sends pageload transaction with web vitals measurements', async ({ page }) trace: { op: 'pageload', origin: 'auto.pageload.solid.tanstack_router', + data: { + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: '/', diff --git a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/tests/performance.client.test.ts b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/tests/performance.client.test.ts index 0cdc2465c065..e0e395049468 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/tests/performance.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/tests/performance.client.test.ts @@ -14,6 +14,12 @@ test('sends a pageload transaction', async ({ page }) => { trace: { op: 'pageload', origin: 'auto.pageload.browser', + data: { + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: '/', @@ -37,6 +43,12 @@ test('sends a navigation transaction with parametrized route', async ({ page }) trace: { op: 'navigation', origin: 'auto.navigation.solidstart.solidrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/users/:id', + 'url.path': '/users/5', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/5$/), + }, }, }, transaction: '/users/:id', @@ -63,6 +75,12 @@ test('updates the transaction when using the back button', async ({ page }) => { trace: { op: 'navigation', origin: 'auto.navigation.solidstart.solidrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/users/:id', + 'url.path': '/users/6', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/6$/), + }, }, }, transaction: '/users/:id', @@ -85,6 +103,12 @@ test('updates the transaction when using the back button', async ({ page }) => { trace: { op: 'navigation', origin: 'auto.navigation.solidstart.solidrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/back-navigation', + 'url.path': '/back-navigation', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/back-navigation$/), + }, }, }, transaction: '/back-navigation', diff --git a/dev-packages/e2e-tests/test-applications/solidstart-spa/tests/performance.client.test.ts b/dev-packages/e2e-tests/test-applications/solidstart-spa/tests/performance.client.test.ts index f54318bf171c..335179ad2e1c 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-spa/tests/performance.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart-spa/tests/performance.client.test.ts @@ -14,6 +14,12 @@ test('sends a pageload transaction', async ({ page }) => { trace: { op: 'pageload', origin: 'auto.pageload.browser', + data: { + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: '/', @@ -37,6 +43,12 @@ test('sends a navigation transaction with parametrized route', async ({ page }) trace: { op: 'navigation', origin: 'auto.navigation.solidstart.solidrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/users/:id', + 'url.path': '/users/5', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/5$/), + }, }, }, transaction: '/users/:id', @@ -63,6 +75,12 @@ test('updates the transaction when using the back button', async ({ page }) => { trace: { op: 'navigation', origin: 'auto.navigation.solidstart.solidrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/users/:id', + 'url.path': '/users/6', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/6$/), + }, }, }, transaction: '/users/:id', @@ -85,6 +103,12 @@ test('updates the transaction when using the back button', async ({ page }) => { trace: { op: 'navigation', origin: 'auto.navigation.solidstart.solidrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/back-navigation', + 'url.path': '/back-navigation', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/back-navigation$/), + }, }, }, transaction: '/back-navigation', diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/performance.client.test.ts b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/performance.client.test.ts index 55eeb5a5c757..6d3618161e93 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/performance.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/performance.client.test.ts @@ -14,6 +14,12 @@ test('sends a pageload transaction', async ({ page }) => { trace: { op: 'pageload', origin: 'auto.pageload.browser', + data: { + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: '/', @@ -37,6 +43,12 @@ test('sends a navigation transaction with parametrized route', async ({ page }) trace: { op: 'navigation', origin: 'auto.navigation.solidstart.solidrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/users/:id', + 'url.path': '/users/5', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/5$/), + }, }, }, transaction: '/users/:id', @@ -63,6 +75,12 @@ test('updates the transaction when using the back button', async ({ page }) => { trace: { op: 'navigation', origin: 'auto.navigation.solidstart.solidrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/users/:id', + 'url.path': '/users/6', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/6$/), + }, }, }, transaction: '/users/:id', @@ -85,6 +103,12 @@ test('updates the transaction when using the back button', async ({ page }) => { trace: { op: 'navigation', origin: 'auto.navigation.solidstart.solidrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/back-navigation', + 'url.path': '/back-navigation', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/back-navigation$/), + }, }, }, transaction: '/back-navigation', diff --git a/dev-packages/e2e-tests/test-applications/solidstart/tests/performance.client.test.ts b/dev-packages/e2e-tests/test-applications/solidstart/tests/performance.client.test.ts index 068fdc9b0cc2..aec390512674 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/tests/performance.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/tests/performance.client.test.ts @@ -14,6 +14,12 @@ test('sends a pageload transaction', async ({ page }) => { trace: { op: 'pageload', origin: 'auto.pageload.browser', + data: { + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: '/', @@ -37,6 +43,12 @@ test('sends a navigation transaction with parametrized route', async ({ page }) trace: { op: 'navigation', origin: 'auto.navigation.solidstart.solidrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/users/:id', + 'url.path': '/users/5', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/5$/), + }, }, }, transaction: '/users/:id', @@ -63,6 +75,12 @@ test('updates the transaction when using the back button', async ({ page }) => { trace: { op: 'navigation', origin: 'auto.navigation.solidstart.solidrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/users/:id', + 'url.path': '/users/6', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/6$/), + }, }, }, transaction: '/users/:id', @@ -85,6 +103,12 @@ test('updates the transaction when using the back button', async ({ page }) => { trace: { op: 'navigation', origin: 'auto.navigation.solidstart.solidrouter', + data: { + 'sentry.source': 'route', + 'url.template': '/back-navigation', + 'url.path': '/back-navigation', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/back-navigation$/), + }, }, }, transaction: '/back-navigation', diff --git a/packages/solid/package.json b/packages/solid/package.json index 12a0a7619625..a9eabc0c0a7d 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -55,7 +55,8 @@ }, "dependencies": { "@sentry/browser": "10.64.0", - "@sentry/core": "10.64.0" + "@sentry/core": "10.64.0", + "@sentry/conventions": "^0.15.1" }, "peerDependencies": { "@solidjs/router": "^0.13.4 || ^0.14.0 || ^0.15.0", diff --git a/packages/solid/src/solidrouter.ts b/packages/solid/src/solidrouter.ts index 0424183aea18..5b609af79ed5 100644 --- a/packages/solid/src/solidrouter.ts +++ b/packages/solid/src/solidrouter.ts @@ -1,10 +1,12 @@ import { browserTracingIntegration, + getAbsoluteUrl, getActiveSpan, getRootSpan, spanToJSON, startBrowserTracingNavigationSpan, } from '@sentry/browser'; +import { PARAMS_KEY, URL_FULL, URL_PATH, URL_PATH_PARAMETER_KEY, URL_TEMPLATE } from '@sentry/conventions/attributes'; import type { Client, Integration, Span } from '@sentry/core'; import { getClient, @@ -27,6 +29,15 @@ import { createComponent } from 'solid-js/web'; const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet(); +function locationToSpanUrlAttributes(pathname: string, search: string = '', hash: string = ''): Record { + const pathWithSearch = `${pathname}${search}${hash}`; + + return { + [URL_PATH]: pathname, + [URL_FULL]: getAbsoluteUrl(pathWithSearch), + }; +} + function handleNavigation(location: string): void { const client = getClient(); if (!client || !CLIENTS_WITH_INSTRUMENT_NAVIGATION.has(client)) { @@ -40,14 +51,20 @@ function handleNavigation(location: string): void { const { name } = metaData?.sdk || {}; const framework = name?.includes('solidstart') ? 'solidstart' : 'solid'; - startBrowserTracingNavigationSpan(client, { - name: location, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.${framework}.solidrouter`, - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + const isBackNavigation = location === '-1'; + + startBrowserTracingNavigationSpan( + client, + { + name: location, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.${framework}.solidrouter`, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, }, - }); + isBackNavigation ? undefined : { url: getAbsoluteUrl(location) }, + ); } function getActiveRootSpan(): Span | undefined { @@ -101,16 +118,22 @@ function withSentryRouterRoot(Root: Component): Component', key), value); + rootSpan.setAttribute(PARAMS_KEY.replace('', key), value); } } } else { @@ -119,7 +142,7 @@ function withSentryRouterRoot(Root: Component): Component[number]; +interface TanstackRouterLocation { + pathname: string; + search: Record; + state?: unknown; +} + /** * A custom browser tracing integration for TanStack Router. * @@ -60,6 +68,7 @@ export function tanstackRouterBrowserTracingIntegration( [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.solid.tanstack_router', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeMatch ? 'route' : 'url', + ...(routeMatch && { [URL_TEMPLATE]: routeMatch.routeId }), ...routeMatchToParamSpanAttributes(routeMatch), }, }); @@ -74,8 +83,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(router, onResolvedArgs.toLocation), + ...routeMatchToParamSpanAttributes(resolvedMatch), + }); } }); } @@ -90,43 +103,48 @@ export function tanstackRouterBrowserTracingIntegration( 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(routeMatchToParamSpanAttributes(match)); + span.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: match ? 'route' : 'url', + ...(match && { [URL_TEMPLATE]: match.routeId }), + ...locationToSpanUrlAttributes(router, toLocation), + ...routeMatchToParamSpanAttributes(match), + }); }; router.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.solid.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.solid.tanstack_router', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeMatch ? 'route' : 'url', + ...(routeMatch && { [URL_TEMPLATE]: routeMatch.routeId }), + ...routeMatchToParamSpanAttributes(routeMatch), + }, }, - }); + { url: locationToAbsoluteUrl(router, toLocation) }, + ); }); router.subscribe('onResolved', onResolvedArgs => { @@ -135,9 +153,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); } }); } @@ -145,6 +164,22 @@ export function tanstackRouterBrowserTracingIntegration( }; } +function locationToAbsoluteUrl(router: AnyRouter, location: TanstackRouterLocation): string { + const search = router.options.stringifySearch?.(location.search) ?? ''; + const pathWithSearch = `${location.pathname}${search && search !== '?' ? search : ''}`; + + return getAbsoluteUrl(pathWithSearch); +} + +function locationToSpanUrlAttributes(router: AnyRouter, location: TanstackRouterLocation): Record { + const absoluteUrl = locationToAbsoluteUrl(router, location); + + return { + [URL_PATH]: location.pathname, + [URL_FULL]: absoluteUrl, + }; +} + function routeMatchToParamSpanAttributes(match: RouteMatch | undefined): Record { if (!match) { return {}; @@ -152,8 +187,8 @@ function routeMatchToParamSpanAttributes(match: RouteMatch | undefined): Record< const paramAttributes: Record = {}; Object.entries(match.params as Record).forEach(([key, value]) => { - 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; From cf15b60eb4a08af64ff3632ce480b986e08e63b1 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 9 Jul 2026 15:57:56 +0200 Subject: [PATCH 2/3] fix navigation bug --- packages/solid/src/tanstackrouter.ts | 43 +++---- packages/solid/test/tanstackrouter.test.ts | 130 +++++++++++++++++++++ 2 files changed, 148 insertions(+), 25 deletions(-) create mode 100644 packages/solid/test/tanstackrouter.test.ts diff --git a/packages/solid/src/tanstackrouter.ts b/packages/solid/src/tanstackrouter.ts index 01d8bc90bf21..a4a903c0a294 100644 --- a/packages/solid/src/tanstackrouter.ts +++ b/packages/solid/src/tanstackrouter.ts @@ -55,6 +55,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({ + ...(match && { [URL_TEMPLATE]: match.routeId }), + ...locationToSpanUrlAttributes(router, toLocation), + ...routeMatchToParamSpanAttributes(match), + }); + }; + const initialWindowLocation = WINDOW.location; if (instrumentPageLoad && initialWindowLocation) { const routeMatch = resolveRouteMatch( @@ -80,16 +95,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(router, onResolvedArgs.toLocation), - ...routeMatchToParamSpanAttributes(resolvedMatch), - }); - } + const { toLocation } = onResolvedArgs; + const resolvedMatch = resolveRouteMatch(toLocation.pathname, toLocation.search); + applyRouteMatch(pageloadSpan, resolvedMatch, toLocation, toLocation.pathname); }); } @@ -100,21 +108,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.setAttributes({ - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: match ? 'route' : 'url', - ...(match && { [URL_TEMPLATE]: match.routeId }), - ...locationToSpanUrlAttributes(router, toLocation), - ...routeMatchToParamSpanAttributes(match), - }); - }; - router.subscribe('onBeforeLoad', onBeforeLoadArgs => { const { toLocation, fromLocation } = onBeforeLoadArgs; // Skip the initial pageload (no fromLocation) and no-op reloads (same state). diff --git a/packages/solid/test/tanstackrouter.test.ts b/packages/solid/test/tanstackrouter.test.ts new file mode 100644 index 000000000000..f63a97520cfc --- /dev/null +++ b/packages/solid/test/tanstackrouter.test.ts @@ -0,0 +1,130 @@ +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'; +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.solid.tanstack_router', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/posts/$postId', + 'url.path.parameter.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.parameter.postId': '2', + 'params.postId': '2', + }), + ); + }); +}); From 22b9695439528661c75bfc1202cf3a111ddb48dc Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 9 Jul 2026 18:26:03 +0200 Subject: [PATCH 3/3] fix(solid): Preserve route info on unmatched TanStack redirect hops Guard the pageload `onResolved` handler so it only overwrites the span when a redirect resolves to a real route match, and always write `url.template` in `applyRouteMatch` so a stale parameterized template is cleared once a redirect hop lands on an unmatched path. Previously a redirect chain could downgrade a route-based span to `sentry.source: url` while keeping a stale `url.template`, misgrouping performance data. Co-Authored-By: Claude Co-authored-by: Cursor --- packages/solid/src/tanstackrouter.ts | 6 +- packages/solid/test/tanstackrouter.test.ts | 68 ++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/packages/solid/src/tanstackrouter.ts b/packages/solid/src/tanstackrouter.ts index a4a903c0a294..c02addfcfbaf 100644 --- a/packages/solid/src/tanstackrouter.ts +++ b/packages/solid/src/tanstackrouter.ts @@ -64,7 +64,7 @@ export function tanstackRouterBrowserTracingIntegration( span.updateName(match ? match.routeId : fallbackName); span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, match ? 'route' : 'url'); span.setAttributes({ - ...(match && { [URL_TEMPLATE]: match.routeId }), + [URL_TEMPLATE]: match?.routeId, ...locationToSpanUrlAttributes(router, toLocation), ...routeMatchToParamSpanAttributes(match), }); @@ -97,7 +97,9 @@ export function tanstackRouterBrowserTracingIntegration( } const { toLocation } = onResolvedArgs; const resolvedMatch = resolveRouteMatch(toLocation.pathname, toLocation.search); - applyRouteMatch(pageloadSpan, resolvedMatch, toLocation, toLocation.pathname); + if (resolvedMatch) { + applyRouteMatch(pageloadSpan, resolvedMatch, toLocation, toLocation.pathname); + } }); } diff --git a/packages/solid/test/tanstackrouter.test.ts b/packages/solid/test/tanstackrouter.test.ts index f63a97520cfc..b51200dd0eda 100644 --- a/packages/solid/test/tanstackrouter.test.ts +++ b/packages/solid/test/tanstackrouter.test.ts @@ -18,6 +18,7 @@ vi.mock('@sentry/browser', async () => { }); const startBrowserTracingPageLoadSpanSpy = vi.spyOn(SentryBrowser, 'startBrowserTracingPageLoadSpan'); +const startBrowserTracingNavigationSpanSpy = vi.spyOn(SentryBrowser, 'startBrowserTracingNavigationSpan'); const mockPageloadSpan = { updateName: vi.fn(), @@ -25,6 +26,12 @@ const mockPageloadSpan = { setAttributes: vi.fn(), }; +const mockNavigationSpan = { + updateName: vi.fn(), + setAttribute: vi.fn(), + setAttributes: vi.fn(), +}; + describe('tanstackRouterBrowserTracingIntegration', () => { const mockMatchedRoutes = [ { @@ -58,6 +65,7 @@ describe('tanstackRouterBrowserTracingIntegration', () => { beforeEach(() => { vi.clearAllMocks(); startBrowserTracingPageLoadSpanSpy.mockReturnValue(mockPageloadSpan as any); + startBrowserTracingNavigationSpanSpy.mockReturnValue(mockNavigationSpan as any); vi.stubGlobal('window', { location: { @@ -127,4 +135,64 @@ describe('tanstackRouterBrowserTracingIntegration', () => { }), ); }); + + it('preserves pageload route info when redirect resolves to an unmatched path', () => { + 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: '__root__', params: {} }]); + + onResolvedCallback({ + toLocation: { + pathname: '/unknown/path', + search: {}, + }, + }); + + expect(mockPageloadSpan.updateName).not.toHaveBeenCalled(); + expect(mockPageloadSpan.setAttribute).not.toHaveBeenCalled(); + expect(mockPageloadSpan.setAttributes).not.toHaveBeenCalled(); + }); + + it('clears url.template when a redirect hop no longer matches a route', () => { + const integration = tanstackRouterBrowserTracingIntegration(mockRouter, { + instrumentNavigation: true, + instrumentPageLoad: false, + }); + + integration.afterAllSetup!(mockClient as any); + + const onBeforeLoadCallback = getSubscribeCallback('onBeforeLoad'); + expect(onBeforeLoadCallback).toBeDefined(); + + // First hop matches a parameterized route and sets url.template. + onBeforeLoadCallback({ + toLocation: { pathname: '/posts/456', search: {}, state: 'state-1' }, + fromLocation: { pathname: '/posts/123', search: {}, state: 'state-0' }, + }); + + // Redirect continuation lands on a URL with no route match. + (mockRouter.matchRoutes as any).mockReturnValueOnce([{ routeId: '__root__', params: {} }]); + + onBeforeLoadCallback({ + toLocation: { pathname: '/unknown/path', search: {}, state: 'state-2' }, + fromLocation: { pathname: '/posts/456', search: {}, state: 'state-1' }, + }); + + expect(mockNavigationSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url'); + expect(mockNavigationSpan.setAttributes).toHaveBeenLastCalledWith( + expect.objectContaining({ + [URL_TEMPLATE]: undefined, + 'url.path': '/unknown/path', + 'url.full': expect.any(String), + }), + ); + }); });