diff --git a/packages/vue/src/browserTracingIntegration.ts b/packages/vue/src/browserTracingIntegration.ts index ce6fdf734717..0b3cf7cc3adc 100644 --- a/packages/vue/src/browserTracingIntegration.ts +++ b/packages/vue/src/browserTracingIntegration.ts @@ -3,7 +3,8 @@ import { startBrowserTracingNavigationSpan, } from '@sentry/browser'; import type { Integration, StartSpanOptions } from '@sentry/core'; -import { instrumentVueRouter } from './router'; +import { setRouteProvider } from '@sentry/core'; +import { createVueRouteProvider, instrumentVueRouter } from './router'; // The following type is an intersection of the Route type from VueRouter v2, v3, and v4. // This is not great, but kinda necessary to make it work with all versions at the same time. @@ -61,6 +62,13 @@ export function browserTracingIntegration(options: VueBrowserTracingIntegrationO return { ...integration, + setup(client) { + // Registered before `afterAllSetup` so the provider is in place by the time the pageload span + // is named, rather than only once the router reports its first navigation. + setRouteProvider(createVueRouteProvider(router, routeLabel), client); + + integration.setup?.(client); + }, afterAllSetup(client) { integration.afterAllSetup(client); diff --git a/packages/vue/src/router.ts b/packages/vue/src/router.ts index 7c01ea2b2d59..7d6893cef72e 100644 --- a/packages/vue/src/router.ts +++ b/packages/vue/src/router.ts @@ -6,8 +6,9 @@ import { URL_PATH_PARAMETER_KEY_BASE, URL_TEMPLATE, } from '@sentry/conventions/attributes'; -import type { Span, SpanAttributes, StartSpanOptions, TransactionSource } from '@sentry/core'; +import type { RouteProvider, Span, SpanAttributes, StartSpanOptions, TransactionSource } from '@sentry/core'; import { + createUrlRouteProvider, getActiveSpan, getClient, getCurrentScope, @@ -45,6 +46,45 @@ interface VueRouter { // Vue Router 3 exposes a `mode` property ('hash' | 'history' | 'abstract'). // Vue Router 4+ replaced it with `options.history`. Used for version detection. mode?: string; + // Vue Router 3 resolves to `{ route }`, Vue Router 4+ returns the route itself. Optional because + // this interface is hand-rolled across Vue Router 2, 3 and 4+ rather than taken from the library. + resolve?: (to: string) => Route | { route: Route }; +} + +/** + * Builds a route provider backed by the Vue router's own matcher. + * + * Labels routes the same way the navigation instrumentation does, so a route resolved here can't + * disagree with the one on the pageload or navigation span. + */ +export function createVueRouteProvider(router: VueRouter, routeLabel: 'name' | 'path'): RouteProvider { + return createUrlRouteProvider(url => { + const resolved = router.resolve?.(`${url.pathname}${url.search}${url.hash}`); + if (!resolved) { + return undefined; + } + + const route = 'matched' in resolved ? resolved : resolved.route; + + return getRouteLabel(route, routeLabel)?.name; + }); +} + +/** + * The label for a matched route and where it came from, or `undefined` when nothing matched and only + * the raw path is left. + */ +function getRouteLabel( + route: Route, + routeLabel: 'name' | 'path', +): { name: string; source: TransactionSource } | undefined { + if (route.name && routeLabel !== 'path') { + return { name: route.name.toString(), source: 'custom' }; + } + + const matchedPath = route.matched[route.matched.length - 1]?.path; + + return matchedPath ? { name: matchedPath, source: 'route' } : undefined; } /** @@ -94,17 +134,9 @@ export function instrumentVueRouter( } // Determine a name for the routing transaction and where that name came from - let spanName: string = to.path; - let transactionSource: TransactionSource = 'url'; - if (to.name && options.routeLabel !== 'path') { - spanName = to.name.toString(); - transactionSource = 'custom'; - } else if (to.matched.length > 0) { - const lastIndex = to.matched.length - 1; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - spanName = to.matched[lastIndex]!.path; - transactionSource = 'route'; - } + const routeLabel = getRouteLabel(to, options.routeLabel); + const spanName = routeLabel?.name ?? to.path; + const transactionSource: TransactionSource = routeLabel?.source ?? 'url'; if (transactionSource === 'route') { attributes[URL_TEMPLATE] = spanName; diff --git a/packages/vue/test/routeProvider.test.ts b/packages/vue/test/routeProvider.test.ts new file mode 100644 index 000000000000..8e6f89db587f --- /dev/null +++ b/packages/vue/test/routeProvider.test.ts @@ -0,0 +1,60 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { Route } from '../src/router'; +import { createVueRouteProvider } from '../src/router'; + +function makeRoute(overrides: Partial = {}): Route { + return { path: '/users/42', query: {}, params: {}, matched: [{ path: '/users/:id' }], ...overrides }; +} + +/** Vue Router 4+ returns the route itself. */ +function makeV4Router(route: Route | undefined) { + return { onError: () => {}, beforeEach: () => {}, resolve: () => route as Route }; +} + +/** Vue Router 3 wraps the route in `{ route }` and exposes `mode`. */ +function makeV3Router(route: Route) { + return { onError: () => {}, beforeEach: () => {}, mode: 'history', resolve: () => ({ route }) }; +} + +describe('createVueRouteProvider', () => { + beforeEach(() => { + (GLOBAL_OBJ as { document?: unknown }).document = { location: { href: 'https://example.com/users/42' } }; + }); + + afterEach(() => { + delete (GLOBAL_OBJ as { document?: unknown }).document; + }); + + it('resolves the matched route path for Vue Router 4+', () => { + const provider = createVueRouteProvider(makeV4Router(makeRoute()), 'path'); + + expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id'); + }); + + it('unwraps the `{ route }` shape Vue Router 3 resolves to', () => { + const provider = createVueRouteProvider(makeV3Router(makeRoute()), 'path'); + + expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id'); + }); + + it('returns undefined when the router cannot resolve', () => { + const provider = createVueRouteProvider(makeV4Router(undefined), 'path'); + + expect(provider.resolveRoute(new URL('https://example.com/nope'))).toBeUndefined(); + }); + + // `VueRouter` is a hand-rolled structural interface spanning Vue Router 2, 3 and 4+, so `resolve` + // is treated as optional rather than assumed present on every router the user passes in. + it('returns undefined for a router that exposes no `resolve`', () => { + const provider = createVueRouteProvider({ onError: () => {}, beforeEach: () => {} }, 'path'); + + expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBeUndefined(); + }); + + it('resolves the current route from the document location', () => { + const provider = createVueRouteProvider(makeV4Router(makeRoute()), 'path'); + + expect(provider.resolveCurrentRoute()).toBe('/users/:id'); + }); +});