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'; 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..e7f6c6efaef7 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({ + [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 @@ -143,15 +166,32 @@ 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); }); } }, }; } +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 +199,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..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'; @@ -45,6 +46,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 +187,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 +297,80 @@ 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('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', () => {