From 79502ab872be09a8f21966fda359e6afc93defa3 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 8 Jul 2026 11:27:21 +0200 Subject: [PATCH 1/3] feat(vue): Set url.template and URL attributes on router spans Adds url.template to Vue Router and TanStack Vue Router pageload and navigation spans, passes destination URLs when starting navigation spans, and adds e2e test coverage for url.full, url.path, and url.template. part of https://github.com/getsentry/sentry-javascript/issues/21921 Co-Authored-By: Cursor Agent Co-authored-by: Cursor --- .../vue-3/tests/performance.test.ts | 14 ++++ .../tests/routing-instrumentation.test.ts | 23 ++++++ packages/vue/package.json | 3 +- packages/vue/src/browserTracingIntegration.ts | 4 +- packages/vue/src/router.ts | 34 ++++++--- packages/vue/src/tanstackrouter.ts | 74 +++++++++++++++---- packages/vue/test/router.test.ts | 68 ++++++++++------- packages/vue/test/tanstackrouter.test.ts | 41 +++++++--- 8 files changed, 192 insertions(+), 69 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts index 03c2cdbb01e5..291c611d4ca4 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts @@ -18,6 +18,9 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = 'sentry.origin': 'auto.pageload.vue', 'sentry.op': 'pageload', 'params.id': '456', + 'url.template': '/users/:id', + 'url.path': '/users/456', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/456$/), }, op: 'pageload', origin: 'auto.pageload.vue', @@ -54,6 +57,9 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) 'sentry.origin': 'auto.navigation.vue', 'sentry.op': 'navigation', 'params.id': '123', + 'url.template': '/users/:id', + 'url.path': '/users/123', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/users\/123$/), }, op: 'navigation', origin: 'auto.navigation.vue', @@ -83,6 +89,9 @@ test('sends a pageload transaction with a nested route URL', async ({ page }) => 'sentry.origin': 'auto.pageload.vue', 'sentry.op': 'pageload', 'params.id': '123', + 'url.template': '/categories/:id', + 'url.path': '/categories/123', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/categories\/123$/), }, op: 'pageload', origin: 'auto.pageload.vue', @@ -111,6 +120,8 @@ test('sends a pageload transaction with a route name as transaction name if avai 'sentry.source': 'custom', 'sentry.origin': 'auto.pageload.vue', 'sentry.op': 'pageload', + 'url.path': '/about', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/about$/), }, op: 'pageload', origin: 'auto.pageload.vue', @@ -139,6 +150,9 @@ test('sends a lifecycle span for each tracked components', async ({ page }) => { 'sentry.source': 'route', 'sentry.origin': 'auto.pageload.vue', 'sentry.op': 'pageload', + 'url.template': '/components', + 'url.path': '/components', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/components$/), }, op: 'pageload', origin: 'auto.pageload.vue', diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tests/routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tests/routing-instrumentation.test.ts index 2a47d5a67d62..7ede26b00295 100644 --- a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tests/routing-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tests/routing-instrumentation.test.ts @@ -15,6 +15,14 @@ test('sends pageload transaction with web vitals measurements', async ({ page }) trace: { op: 'pageload', origin: 'auto.pageload.vue.tanstack_router', + data: { + 'sentry.origin': 'auto.pageload.vue.tanstack_router', + 'sentry.source': 'route', + 'sentry.op': 'pageload', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, }, }, transaction: '/', @@ -59,6 +67,9 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = 'sentry.origin': 'auto.pageload.vue.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.vue.tanstack_router', @@ -101,6 +112,9 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) 'sentry.origin': 'auto.navigation.vue.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.vue.tanstack_router', @@ -132,6 +146,9 @@ test('sends a pageload transaction named after the resolved route when a redirec 'sentry.origin': 'auto.pageload.vue.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.vue.tanstack_router', @@ -172,6 +189,9 @@ test('sends a navigation transaction when a redirect is thrown in beforeLoad', a 'sentry.origin': 'auto.navigation.vue.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.vue.tanstack_router', @@ -217,6 +237,9 @@ test('sends a navigation transaction for a normal navigation that happens after 'sentry.origin': 'auto.navigation.vue.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.vue.tanstack_router', diff --git a/packages/vue/package.json b/packages/vue/package.json index fe5297d157a1..5e32b4b9e5fb 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -52,7 +52,8 @@ }, "dependencies": { "@sentry/browser": "10.64.0", - "@sentry/core": "10.64.0" + "@sentry/core": "10.64.0", + "@sentry/conventions": "^0.15.1" }, "peerDependencies": { "@tanstack/vue-router": "^1.64.0", diff --git a/packages/vue/src/browserTracingIntegration.ts b/packages/vue/src/browserTracingIntegration.ts index 8f542ddf1b1d..ce6fdf734717 100644 --- a/packages/vue/src/browserTracingIntegration.ts +++ b/packages/vue/src/browserTracingIntegration.ts @@ -64,8 +64,8 @@ export function browserTracingIntegration(options: VueBrowserTracingIntegrationO afterAllSetup(client) { integration.afterAllSetup(client); - const startNavigationSpan = (options: StartSpanOptions): void => { - startBrowserTracingNavigationSpan(client, options); + const startNavigationSpan = (options: StartSpanOptions, destinationUrl: string): void => { + startBrowserTracingNavigationSpan(client, options, { url: destinationUrl }); }; instrumentVueRouter(router, { routeLabel, instrumentNavigation, instrumentPageLoad }, startNavigationSpan); diff --git a/packages/vue/src/router.ts b/packages/vue/src/router.ts index 8d4100e9b6c8..91d4a5b6c4f8 100644 --- a/packages/vue/src/router.ts +++ b/packages/vue/src/router.ts @@ -1,4 +1,5 @@ -import { captureException } from '@sentry/browser'; +import { captureException, getAbsoluteUrl } from '@sentry/browser'; +import { PARAMS_KEY, URL_PATH_PARAMETER_KEY, URL_TEMPLATE } from '@sentry/conventions/attributes'; import type { Span, SpanAttributes, StartSpanOptions, TransactionSource } from '@sentry/core'; import { getActiveSpan, @@ -14,6 +15,8 @@ import { export type Route = { /** Unparameterized URL */ path: string; + /** Resolved URL including query and hash */ + fullPath?: string; /** * Query params (keys map to null when there is no value associated, e.g. "?foo" and to an array when there are * multiple query params that have the same key, e.g. "?foo&foo=bar") @@ -51,7 +54,7 @@ export function instrumentVueRouter( instrumentPageLoad: boolean; instrumentNavigation: boolean; }, - startNavigationSpanFn: (context: StartSpanOptions) => void, + startNavigationSpanFn: (context: StartSpanOptions, destinationUrl: string) => void, ): void { let hasHandledFirstPageLoad = false; @@ -71,8 +74,8 @@ export function instrumentVueRouter( const attributes: SpanAttributes = {}; for (const key of Object.keys(to.params)) { - attributes[`url.path.parameter.${key}`] = to.params[key]; - attributes[`params.${key}`] = to.params[key]; // params.[key] is an alias + attributes[URL_PATH_PARAMETER_KEY.replace('', key)] = to.params[key]; + attributes[PARAMS_KEY.replace('', key)] = to.params[key]; // params.[key] is an alias } for (const key of Object.keys(to.query)) { const value = to.query[key]; @@ -94,6 +97,10 @@ export function instrumentVueRouter( transactionSource = 'route'; } + if (transactionSource === 'route') { + attributes[URL_TEMPLATE] = spanName; + } + getCurrentScope().setTransactionName(spanName); // Update the existing page load span with parametrized route information @@ -115,15 +122,18 @@ export function instrumentVueRouter( } if (options.instrumentNavigation && !activePageLoadSpan) { - startNavigationSpanFn({ - name: spanName, - op: 'navigation', - attributes: { - ...attributes, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: transactionSource, + startNavigationSpanFn( + { + name: spanName, + op: 'navigation', + attributes: { + ...attributes, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: transactionSource, + }, }, - }); + getAbsoluteUrl(to.fullPath ?? to.path), + ); } // Vue Router 3 requires `next()` to be called to resolve the navigation guard. diff --git a/packages/vue/src/tanstackrouter.ts b/packages/vue/src/tanstackrouter.ts index 6b2700db0bb2..0dc45b576f9e 100644 --- a/packages/vue/src/tanstackrouter.ts +++ b/packages/vue/src/tanstackrouter.ts @@ -1,9 +1,11 @@ import { browserTracingIntegration as originalBrowserTracingIntegration, + getAbsoluteUrl, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, WINDOW, } from '@sentry/browser'; +import { PARAMS_KEY, URL_FULL, URL_PATH, URL_PATH_PARAMETER_KEY, URL_TEMPLATE } from '@sentry/conventions/attributes'; import type { Integration } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -14,8 +16,14 @@ import type { AnyRouter } from '@tanstack/vue-router'; type RouteMatch = ReturnType[number]; +interface TanstackRouterLocation { + pathname: string; + search: Record; + state?: unknown; +} + interface TanstackRouterSubscribeArgs { - toLocation: { pathname: string; search: Record; state: unknown }; + toLocation: TanstackRouterLocation; fromLocation?: { state: unknown }; } @@ -66,6 +74,7 @@ export function tanstackRouterBrowserTracingIntegration( [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue.tanstack_router', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeMatch ? 'route' : 'url', + ...(routeMatch && { [URL_TEMPLATE]: routeMatch.routeId }), ...routeMatchToParamSpanAttributes(routeMatch), }, }); @@ -82,8 +91,12 @@ export function tanstackRouterBrowserTracingIntegration( const resolvedMatch = resolveRouteMatch(toLocation.pathname, 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, toLocation), + ...routeMatchToParamSpanAttributes(resolvedMatch), + }); } }); } @@ -98,11 +111,16 @@ 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({ + ...(match && { [URL_TEMPLATE]: match.routeId }), + ...locationToSpanUrlAttributes(router, toLocation), + ...routeMatchToParamSpanAttributes(match), + }); }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -119,19 +137,24 @@ export function tanstackRouterBrowserTracingIntegration( 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.vue.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.vue.tanstack_router', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeMatch ? 'route' : 'url', + ...(routeMatch && { [URL_TEMPLATE]: routeMatch.routeId }), + ...routeMatchToParamSpanAttributes(routeMatch), + }, }, - }); + { url: locationToAbsoluteUrl(router, toLocation) }, + ); }); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -144,7 +167,7 @@ export function tanstackRouterBrowserTracingIntegration( const { toLocation } = onResolvedArgs as TanstackRouterSubscribeArgs; const resolvedMatch = resolveRouteMatch(toLocation.pathname, toLocation.search); if (resolvedMatch) { - applyRouteMatch(span, resolvedMatch, WINDOW.location?.pathname || toLocation.pathname); + applyRouteMatch(span, resolvedMatch, toLocation, WINDOW.location?.pathname || toLocation.pathname); } }); } @@ -152,6 +175,25 @@ export function tanstackRouterBrowserTracingIntegration( }; } +function locationToAbsoluteUrl(router: R, location: TanstackRouterLocation): string { + const search = router.options.stringifySearch(location.search); + const pathWithSearch = `${location.pathname}${search && search !== '?' ? search : ''}`; + + return getAbsoluteUrl(pathWithSearch); +} + +function locationToSpanUrlAttributes( + router: R, + 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 {}; @@ -159,8 +201,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/vue/test/router.test.ts b/packages/vue/test/router.test.ts index 763f3e51d6f7..498c41f31ed3 100644 --- a/packages/vue/test/router.test.ts +++ b/packages/vue/test/router.test.ts @@ -2,6 +2,7 @@ import * as SentryBrowser from '@sentry/browser'; import type { Span, SpanAttributes } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { URL_TEMPLATE } from '@sentry/conventions/attributes'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Route } from '../src/router'; import { instrumentVueRouter } from '../src/router'; @@ -120,15 +121,18 @@ describe('instrumentVueRouter()', () => { beforeEachCallback(to, from); expect(mockStartSpan).toHaveBeenCalledTimes(2); - expect(mockStartSpan).toHaveBeenLastCalledWith({ - name: transactionName, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: transactionSource, - ...getAttributesForRoute(to), + expect(mockStartSpan).toHaveBeenLastCalledWith( + { + name: transactionName, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: transactionSource, + ...getAttributesForRoute(to, transactionSource === 'route' ? transactionName : undefined), + }, + op: 'navigation', }, - op: 'navigation', - }); + expect.any(String), + ); }, ); @@ -174,7 +178,7 @@ describe('instrumentVueRouter()', () => { expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, transactionSource); expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue', - ...getAttributesForRoute(to), + ...getAttributesForRoute(to, transactionSource === 'route' ? transactionName : undefined), }); }, ); @@ -196,15 +200,18 @@ describe('instrumentVueRouter()', () => { beforeEachCallback(to, from); // first startTx call happens when the instrumentation is initialized (for pageloads) - expect(mockStartSpan).toHaveBeenLastCalledWith({ - name: '/login', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - ...getAttributesForRoute(to), + expect(mockStartSpan).toHaveBeenLastCalledWith( + { + name: '/login', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + ...getAttributesForRoute(to, '/login'), + }, + op: 'navigation', }, - op: 'navigation', - }); + expect.any(String), + ); }); it('allows to configure routeLabel=name', () => { @@ -224,15 +231,18 @@ describe('instrumentVueRouter()', () => { beforeEachCallback(to, from); // first startTx call happens when the instrumentation is initialized (for pageloads) - expect(mockStartSpan).toHaveBeenLastCalledWith({ - name: 'login-screen', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', - ...getAttributesForRoute(to), + expect(mockStartSpan).toHaveBeenLastCalledWith( + { + name: 'login-screen', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + ...getAttributesForRoute(to), + }, + op: 'navigation', }, - op: 'navigation', - }); + expect.any(String), + ); }); it("doesn't overwrite a pageload transaction name it was set to custom before the router resolved the route", () => { @@ -286,7 +296,7 @@ describe('instrumentVueRouter()', () => { expect(mockRootSpan.setAttribute).not.toHaveBeenCalled(); expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue', - ...getAttributesForRoute(to), + ...getAttributesForRoute(to, '/books/:bookId/chapter/:chapterId'), }); expect(mockRootSpan.name).toEqual('customTxnName'); }); @@ -435,7 +445,7 @@ describe('instrumentVueRouter()', () => { }); // Small helper function to get flattened attributes for test comparison -function getAttributesForRoute(route: Route): SpanAttributes { +function getAttributesForRoute(route: Route, urlTemplate?: string): SpanAttributes { const { params, query } = route; const attributes: SpanAttributes = {}; @@ -451,5 +461,9 @@ function getAttributesForRoute(route: Route): SpanAttributes { } } + if (urlTemplate) { + attributes[URL_TEMPLATE] = urlTemplate; + } + return attributes; } diff --git a/packages/vue/test/tanstackrouter.test.ts b/packages/vue/test/tanstackrouter.test.ts index d7ecb3c8ac61..294d8d75404c 100644 --- a/packages/vue/test/tanstackrouter.test.ts +++ b/packages/vue/test/tanstackrouter.test.ts @@ -45,6 +45,17 @@ describe('tanstackRouterBrowserTracingIntegration', () => { }); return result; }), + stringifySearch: vi.fn((search: Record) => { + const params = new URLSearchParams(); + Object.entries(search).forEach(([key, value]) => { + if (value != null) { + params.set(key, String(value)); + } + }); + const serialized = params.toString(); + + return serialized ? `?${serialized}` : ''; + }), } as AnyRouter['options'], matchRoutes: vi.fn(() => mockMatchedRoutes), subscribe: vi.fn(() => vi.fn()), // Return an unsubscribe function @@ -175,13 +186,17 @@ describe('tanstackRouterBrowserTracingIntegration', () => { }); expect(startBrowserTracingNavigationSpanSpy).toHaveBeenCalledTimes(1); - expect(startBrowserTracingNavigationSpanSpy).toHaveBeenCalledWith(mockClient, { - name: '/test/:id', - attributes: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue.tanstack_router', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - }), - }); + expect(startBrowserTracingNavigationSpanSpy).toHaveBeenCalledWith( + mockClient, + { + name: '/test/:id', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue.tanstack_router', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + }), + }, + { url: expect.any(String) }, + ); }); it('skips navigation span creation on the initial pageload (no fromLocation)', () => { @@ -281,10 +296,14 @@ describe('tanstackRouterBrowserTracingIntegration', () => { expect(mockNavigationSpan.updateName).toHaveBeenCalledWith('/redirected/:id'); expect(mockNavigationSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - expect(mockNavigationSpan.setAttributes).toHaveBeenCalledWith({ - 'url.path.parameter.id': '789', - 'params.id': '789', - }); + expect(mockNavigationSpan.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + 'url.path': '/redirected/789', + 'url.full': expect.any(String), + 'url.path.parameter.id': '789', + 'params.id': '789', + }), + ); }); it('reuses the in-flight span across a redirect chain instead of starting a second span', () => { From 388675f6ca52652ba8c306e8bf0a9dbba72b46ed Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 8 Jul 2026 18:16:17 +0200 Subject: [PATCH 2/3] add nuxt e2e tests --- .../tests/tracing.client.test.ts | 3 ++ .../nuxt-3-min/tests/tracing.client.test.ts | 3 ++ .../tests/tracing.client.test.ts | 3 ++ .../nuxt-3/tests/tracing.client.test.ts | 3 ++ .../nuxt-4/tests/tracing.client.test.ts | 38 +++++++++++++++++++ .../nuxt-5/tests/tracing.client.test.ts | 38 +++++++++++++++++++ 6 files changed, 88 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.client.test.ts index f87949e32527..d55a7ccadc8a 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.client.test.ts @@ -19,6 +19,9 @@ test('sends a pageload root span with a parameterized URL', async ({ page }) => 'sentry.origin': 'auto.pageload.vue', 'sentry.op': 'pageload', 'params.param': '1234', + 'url.template': '/test-param/:param()', + 'url.path': '/test-param/1234', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/), }, op: 'pageload', origin: 'auto.pageload.vue', diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.client.test.ts index 252e0daf1933..8e4ecab9bb89 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.client.test.ts @@ -20,6 +20,9 @@ test('sends a pageload root span with a parameterized URL', async ({ page }) => 'sentry.origin': 'auto.pageload.vue', 'sentry.op': 'pageload', 'params.param': '1234', + 'url.template': '/test-param/:param()', + 'url.path': '/test-param/1234', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/), }, op: 'pageload', origin: 'auto.pageload.vue', diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.client.test.ts index 9945cc11da5f..971d3682c9d2 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.client.test.ts @@ -19,6 +19,9 @@ test('sends a pageload root span with a parameterized URL', async ({ page }) => 'sentry.origin': 'auto.pageload.vue', 'sentry.op': 'pageload', 'params.param': '1234', + 'url.template': '/test-param/:param()', + 'url.path': '/test-param/1234', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/), }, op: 'pageload', origin: 'auto.pageload.vue', diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.client.test.ts index abeed374a426..50c396192177 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.client.test.ts @@ -19,6 +19,9 @@ test('sends a pageload root span with a parameterized URL', async ({ page }) => 'sentry.origin': 'auto.pageload.vue', 'sentry.op': 'pageload', 'params.param': '1234', + 'url.template': '/test-param/:param()', + 'url.path': '/test-param/1234', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/), }, op: 'pageload', origin: 'auto.pageload.vue', diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.client.test.ts index d1f2b4fdea47..3e0aac67ac9c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.client.test.ts @@ -19,6 +19,9 @@ test('sends a pageload root span with a parameterized URL', async ({ page }) => 'sentry.origin': 'auto.pageload.vue', 'sentry.op': 'pageload', 'params.param': '1234', + 'url.template': '/test-param/:param()', + 'url.path': '/test-param/1234', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/), }, op: 'pageload', origin: 'auto.pageload.vue', @@ -31,6 +34,41 @@ test('sends a pageload root span with a parameterized URL', async ({ page }) => }); }); +test('sends a navigation root span with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-4', async transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/test-param/:param()' + ); + }); + + await page.goto(`/`); + await page.getByText('Fetch Param').click(); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.navigation.vue', + 'sentry.op': 'navigation', + 'params.param': '1234', + 'url.template': '/test-param/:param()', + 'url.path': '/test-param/1234', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/), + }, + op: 'navigation', + origin: 'auto.navigation.vue', + }, + }, + transaction: '/test-param/:param()', + transaction_info: { + source: 'route', + }, + }); +}); + test('sends component tracking spans when `trackComponents` is enabled', async ({ page }) => { const transactionPromise = waitForTransaction('nuxt-4', async transactionEvent => { return transactionEvent.transaction === '/client-error'; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts index d4d4b141fa16..f4a53b1b4fb7 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.client.test.ts @@ -19,6 +19,9 @@ test('sends a pageload root span with a parameterized URL', async ({ page }) => 'sentry.origin': 'auto.pageload.vue', 'sentry.op': 'pageload', 'params.param': '1234', + 'url.template': '/test-param/:param()', + 'url.path': '/test-param/1234', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/), }, op: 'pageload', origin: 'auto.pageload.vue', @@ -31,6 +34,41 @@ test('sends a pageload root span with a parameterized URL', async ({ page }) => }); }); +test('sends a navigation root span with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/test-param/:param()' + ); + }); + + await page.goto(`/`); + await page.getByText('Fetch Param').click(); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.navigation.vue', + 'sentry.op': 'navigation', + 'params.param': '1234', + 'url.template': '/test-param/:param()', + 'url.path': '/test-param/1234', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/test-param\/1234$/), + }, + op: 'navigation', + origin: 'auto.navigation.vue', + }, + }, + transaction: '/test-param/:param()', + transaction_info: { + source: 'route', + }, + }); +}); + test('sends component tracking spans when `trackComponents` is enabled', async ({ page }) => { const transactionPromise = waitForTransaction('nuxt-5', async transactionEvent => { return transactionEvent.transaction === '/client-error'; From f63fcec885b138048f6554ac9e77f1f3b638a386 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 9 Jul 2026 11:40:18 +0200 Subject: [PATCH 3/3] fix incorrect handling of only partially resolved matches --- packages/vue/src/tanstackrouter.ts | 6 +-- packages/vue/test/tanstackrouter.test.ts | 67 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/packages/vue/src/tanstackrouter.ts b/packages/vue/src/tanstackrouter.ts index 0dc45b576f9e..e7f6c6efaef7 100644 --- a/packages/vue/src/tanstackrouter.ts +++ b/packages/vue/src/tanstackrouter.ts @@ -117,7 +117,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), }); @@ -166,9 +166,7 @@ export function tanstackRouterBrowserTracingIntegration( } const { toLocation } = onResolvedArgs as TanstackRouterSubscribeArgs; const resolvedMatch = resolveRouteMatch(toLocation.pathname, toLocation.search); - if (resolvedMatch) { - applyRouteMatch(span, resolvedMatch, toLocation, WINDOW.location?.pathname || toLocation.pathname); - } + applyRouteMatch(span, resolvedMatch, toLocation, WINDOW.location?.pathname || toLocation.pathname); }); } }, diff --git a/packages/vue/test/tanstackrouter.test.ts b/packages/vue/test/tanstackrouter.test.ts index 294d8d75404c..7f6700098d2d 100644 --- a/packages/vue/test/tanstackrouter.test.ts +++ b/packages/vue/test/tanstackrouter.test.ts @@ -1,4 +1,5 @@ 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 type { AnyRouter } from '@tanstack/vue-router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -306,6 +307,72 @@ describe('tanstackRouterBrowserTracingIntegration', () => { ); }); + 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'); + + // First hop matches a parameterized route and sets url.template. + onBeforeLoadCallback({ + toLocation: { pathname: '/test/456', search: {}, state: 'state-1' }, + fromLocation: { pathname: '/test/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: '/test/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), + }), + ); + }); + + it('clears url.template on onResolved when the final destination does not match a route', () => { + const integration = tanstackRouterBrowserTracingIntegration(mockRouter, { + instrumentNavigation: true, + instrumentPageLoad: false, + }); + + integration.afterAllSetup(mockClient as any); + + const onBeforeLoadCallback = getSubscribeCallback('onBeforeLoad'); + const onResolvedCallback = getSubscribeCallback('onResolved'); + + onBeforeLoadCallback({ + toLocation: { pathname: '/test/456', search: {}, state: 'state-1' }, + fromLocation: { pathname: '/test/123', search: {}, state: 'state-0' }, + }); + + (mockRouter.matchRoutes as any).mockReturnValueOnce([{ routeId: '__root__', params: {} }]); + + onResolvedCallback({ + toLocation: { pathname: '/unknown/path', search: {} }, + }); + + 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), + }), + ); + }); + it('reuses the in-flight span across a redirect chain instead of starting a second span', () => { const integration = tanstackRouterBrowserTracingIntegration(mockRouter, { instrumentNavigation: true,