Skip to content

Commit b97883e

Browse files
committed
feat(vue): Register a route provider backed by the Vue router matcher
Labels routes through the same helper the navigation instrumentation uses, so a route resolved by the provider can't disagree with the one on the span. Handles both resolve shapes: Vue Router 3 returns `{ route }`, Vue Router 4+ returns the route itself.
1 parent 464c7d0 commit b97883e

3 files changed

Lines changed: 113 additions & 13 deletions

File tree

packages/vue/src/browserTracingIntegration.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import {
33
startBrowserTracingNavigationSpan,
44
} from '@sentry/browser';
55
import type { Integration, StartSpanOptions } from '@sentry/core';
6-
import { instrumentVueRouter } from './router';
6+
import { setRouteProvider } from '@sentry/core';
7+
import { createVueRouteProvider, instrumentVueRouter } from './router';
78

89
// The following type is an intersection of the Route type from VueRouter v2, v3, and v4.
910
// 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
6162

6263
return {
6364
...integration,
65+
setup(client) {
66+
// Registered before `afterAllSetup` so the provider is in place by the time the pageload span
67+
// is named, rather than only once the router reports its first navigation.
68+
setRouteProvider(createVueRouteProvider(router, routeLabel), client);
69+
70+
integration.setup?.(client);
71+
},
6472
afterAllSetup(client) {
6573
integration.afterAllSetup(client);
6674

packages/vue/src/router.ts

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import {
66
URL_PATH_PARAMETER_KEY_BASE,
77
URL_TEMPLATE,
88
} from '@sentry/conventions/attributes';
9-
import type { Span, SpanAttributes, StartSpanOptions, TransactionSource } from '@sentry/core';
9+
import type { RouteProvider, Span, SpanAttributes, StartSpanOptions, TransactionSource } from '@sentry/core';
1010
import {
11+
createUrlRouteProvider,
1112
getActiveSpan,
1213
getClient,
1314
getCurrentScope,
@@ -45,6 +46,45 @@ interface VueRouter {
4546
// Vue Router 3 exposes a `mode` property ('hash' | 'history' | 'abstract').
4647
// Vue Router 4+ replaced it with `options.history`. Used for version detection.
4748
mode?: string;
49+
// Vue Router 3 resolves to `{ route }`, Vue Router 4+ returns the route itself. Optional because
50+
// this interface is hand-rolled across Vue Router 2, 3 and 4+ rather than taken from the library.
51+
resolve?: (to: string) => Route | { route: Route };
52+
}
53+
54+
/**
55+
* Builds a route provider backed by the Vue router's own matcher.
56+
*
57+
* Labels routes the same way the navigation instrumentation does, so a route resolved here can't
58+
* disagree with the one on the pageload or navigation span.
59+
*/
60+
export function createVueRouteProvider(router: VueRouter, routeLabel: 'name' | 'path'): RouteProvider {
61+
return createUrlRouteProvider(url => {
62+
const resolved = router.resolve?.(`${url.pathname}${url.search}${url.hash}`);
63+
if (!resolved) {
64+
return undefined;
65+
}
66+
67+
const route = 'matched' in resolved ? resolved : resolved.route;
68+
69+
return getRouteLabel(route, routeLabel)?.name;
70+
});
71+
}
72+
73+
/**
74+
* The label for a matched route and where it came from, or `undefined` when nothing matched and only
75+
* the raw path is left.
76+
*/
77+
function getRouteLabel(
78+
route: Route,
79+
routeLabel: 'name' | 'path',
80+
): { name: string; source: TransactionSource } | undefined {
81+
if (route.name && routeLabel !== 'path') {
82+
return { name: route.name.toString(), source: 'custom' };
83+
}
84+
85+
const matchedPath = route.matched[route.matched.length - 1]?.path;
86+
87+
return matchedPath ? { name: matchedPath, source: 'route' } : undefined;
4888
}
4989

5090
/**
@@ -94,17 +134,9 @@ export function instrumentVueRouter(
94134
}
95135

96136
// Determine a name for the routing transaction and where that name came from
97-
let spanName: string = to.path;
98-
let transactionSource: TransactionSource = 'url';
99-
if (to.name && options.routeLabel !== 'path') {
100-
spanName = to.name.toString();
101-
transactionSource = 'custom';
102-
} else if (to.matched.length > 0) {
103-
const lastIndex = to.matched.length - 1;
104-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
105-
spanName = to.matched[lastIndex]!.path;
106-
transactionSource = 'route';
107-
}
137+
const routeLabel = getRouteLabel(to, options.routeLabel);
138+
const spanName = routeLabel?.name ?? to.path;
139+
const transactionSource: TransactionSource = routeLabel?.source ?? 'url';
108140

109141
if (transactionSource === 'route') {
110142
attributes[URL_TEMPLATE] = spanName;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { GLOBAL_OBJ } from '@sentry/core';
2+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3+
import type { Route } from '../src/router';
4+
import { createVueRouteProvider } from '../src/router';
5+
6+
function makeRoute(overrides: Partial<Route> = {}): Route {
7+
return { path: '/users/42', query: {}, params: {}, matched: [{ path: '/users/:id' }], ...overrides };
8+
}
9+
10+
/** Vue Router 4+ returns the route itself. */
11+
function makeV4Router(route: Route | undefined) {
12+
return { onError: () => {}, beforeEach: () => {}, resolve: () => route as Route };
13+
}
14+
15+
/** Vue Router 3 wraps the route in `{ route }` and exposes `mode`. */
16+
function makeV3Router(route: Route) {
17+
return { onError: () => {}, beforeEach: () => {}, mode: 'history', resolve: () => ({ route }) };
18+
}
19+
20+
describe('createVueRouteProvider', () => {
21+
beforeEach(() => {
22+
(GLOBAL_OBJ as { document?: unknown }).document = { location: { href: 'https://example.com/users/42' } };
23+
});
24+
25+
afterEach(() => {
26+
delete (GLOBAL_OBJ as { document?: unknown }).document;
27+
});
28+
29+
it('resolves the matched route path for Vue Router 4+', () => {
30+
const provider = createVueRouteProvider(makeV4Router(makeRoute()), 'path');
31+
32+
expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id');
33+
});
34+
35+
it('unwraps the `{ route }` shape Vue Router 3 resolves to', () => {
36+
const provider = createVueRouteProvider(makeV3Router(makeRoute()), 'path');
37+
38+
expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id');
39+
});
40+
41+
it('returns undefined when the router cannot resolve', () => {
42+
const provider = createVueRouteProvider(makeV4Router(undefined), 'path');
43+
44+
expect(provider.resolveRoute(new URL('https://example.com/nope'))).toBeUndefined();
45+
});
46+
47+
// `VueRouter` is a hand-rolled structural interface spanning Vue Router 2, 3 and 4+, so `resolve`
48+
// is treated as optional rather than assumed present on every router the user passes in.
49+
it('returns undefined for a router that exposes no `resolve`', () => {
50+
const provider = createVueRouteProvider({ onError: () => {}, beforeEach: () => {} }, 'path');
51+
52+
expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBeUndefined();
53+
});
54+
55+
it('resolves the current route from the document location', () => {
56+
const provider = createVueRouteProvider(makeV4Router(makeRoute()), 'path');
57+
58+
expect(provider.getCurrentRoute()).toBe('/users/:id');
59+
});
60+
});

0 commit comments

Comments
 (0)