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. * @@ -47,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({ + [URL_TEMPLATE]: match?.routeId, + ...locationToSpanUrlAttributes(router, toLocation), + ...routeMatchToParamSpanAttributes(match), + }); + }; + const initialWindowLocation = WINDOW.location; if (instrumentPageLoad && initialWindowLocation) { const routeMatch = resolveRouteMatch( @@ -60,6 +83,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), }, }); @@ -71,11 +95,10 @@ 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.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - pageloadSpan.setAttributes(routeMatchToParamSpanAttributes(resolvedMatch)); + const { toLocation } = onResolvedArgs; + const resolvedMatch = resolveRouteMatch(toLocation.pathname, toLocation.search); + if (resolvedMatch) { + applyRouteMatch(pageloadSpan, resolvedMatch, toLocation, toLocation.pathname); } }); } @@ -87,46 +110,36 @@ 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, - fallbackName: string, - ): void => { - span.updateName(match ? match.routeId : fallbackName); - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, match ? 'route' : 'url'); - span.setAttributes(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 +148,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 +159,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 +182,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; diff --git a/packages/solid/test/tanstackrouter.test.ts b/packages/solid/test/tanstackrouter.test.ts new file mode 100644 index 000000000000..b51200dd0eda --- /dev/null +++ b/packages/solid/test/tanstackrouter.test.ts @@ -0,0 +1,198 @@ +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 startBrowserTracingNavigationSpanSpy = vi.spyOn(SentryBrowser, 'startBrowserTracingNavigationSpan'); + +const mockPageloadSpan = { + updateName: vi.fn(), + setAttribute: vi.fn(), + setAttributes: vi.fn(), +}; + +const mockNavigationSpan = { + 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); + startBrowserTracingNavigationSpanSpy.mockReturnValue(mockNavigationSpan 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', + }), + ); + }); + + 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), + }), + ); + }); +});