From 42ed39030fd4443f404aa073560b18ab369b1887 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 8 Jul 2026 14:49:49 +0200 Subject: [PATCH] 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 | 104 +++++++++----- packages/solid/test/tanstackrouter.test.ts | 130 ++++++++++++++++++ 9 files changed, 349 insertions(+), 50 deletions(-) create mode 100644 packages/solid/test/tanstackrouter.test.ts 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({ + ...(match && { [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,12 +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.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - pageloadSpan.setAttributes(routeMatchToParamSpanAttributes(resolvedMatch)); - } + const { toLocation } = onResolvedArgs; + const resolvedMatch = resolveRouteMatch(toLocation.pathname, toLocation.search); + applyRouteMatch(pageloadSpan, resolvedMatch, toLocation, toLocation.pathname); }); } @@ -87,46 +108,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 +146,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 +157,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 +180,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..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', + }), + ); + }); +});