Skip to content

Commit a1394dd

Browse files
committed
feat(vue): Register a route provider read off the Vue app
`vue-router` sets `app.config.globalProperties.$router` when it installs, and the SDK is already given the app, so the provider needs no new option and no router passed to the tracing integration. Registered from `init()`, so route parameterization no longer depends on tracing being enabled and works for users who never pass `router` to `browserTracingIntegration`. The router is looked up per call rather than captured, because `app.use(router)` may run either side of `Sentry.init()`. Returns the matched path rather than `route.name` even under `routeLabel: 'name'`: callers set `url.template` from this and a route name is an identifier, not a template. The navigation instrumentation still names the span after the route name when the user asked for it.
1 parent 0ba7e49 commit a1394dd

4 files changed

Lines changed: 125 additions & 1 deletion

File tree

‎packages/vue/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ export { browserTracingIntegration } from './browserTracingIntegration';
88
export { attachErrorHandler } from './errorhandler';
99
export { createTracingMixins } from './tracing';
1010
export { vueIntegration } from './integration';
11+
export { createVueRouteProvider } from './routeProvider';
1112
export type { VueIntegrationOptions } from './integration';
1213
export { createSentryPiniaPlugin } from './pinia';

‎packages/vue/src/routeProvider.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { RouteProvider } from '@sentry/core';
2+
import { createUrlRouteProvider } from '@sentry/core';
3+
import type { Route } from './router';
4+
5+
// Vue Router 3 resolves to `{ route }`, Vue Router 4+ returns the route itself.
6+
type ResolvedLocation = Route | { route: Route };
7+
8+
interface InstalledRouter {
9+
resolve?: (to: string) => ResolvedLocation;
10+
}
11+
12+
interface AppWithRouter {
13+
config?: { globalProperties?: { $router?: InstalledRouter } };
14+
}
15+
16+
/**
17+
* Builds a route provider from a `vue-router` instance, however the SDK got hold of one.
18+
*
19+
* The router is looked up per call rather than captured once, because `app.use(router)` may run
20+
* either side of `Sentry.init()` and only the app itself is guaranteed to exist by then.
21+
*/
22+
export function createVueRouteProvider(getRouter: () => InstalledRouter | undefined): RouteProvider {
23+
return createUrlRouteProvider(url => {
24+
const resolved = getRouter()?.resolve?.(`${url.pathname}${url.search}${url.hash}`);
25+
if (!resolved) {
26+
return undefined;
27+
}
28+
29+
const route = 'matched' in resolved ? resolved : resolved.route;
30+
31+
// Always the matched path, never `route.name`. Callers set `url.template` from this, and a route
32+
// name is an identifier rather than a template.
33+
return route.matched[route.matched.length - 1]?.path;
34+
});
35+
}
36+
37+
/**
38+
* Reads the router `vue-router` installed onto a Vue app.
39+
*/
40+
export function getRouterFromApp(app: unknown): InstalledRouter | undefined {
41+
const firstApp: AppWithRouter | undefined = Array.isArray(app) ? app[0] : (app as AppWithRouter | undefined);
42+
43+
return firstApp?.config?.globalProperties?.$router;
44+
}

‎packages/vue/src/sdk.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { getDefaultIntegrations, init as browserInit } from '@sentry/browser';
22
import type { Client } from '@sentry/core';
3-
import { applySdkMetadata, setNormalizeStringifier } from '@sentry/core/browser';
3+
import { applySdkMetadata, setNormalizeStringifier, setRouteProvider } from '@sentry/core/browser';
44

55
import { vueIntegration } from './integration';
66
import type { Options } from './types';
77
import { normalizeStringifyValue } from './normalizeStringifyValue';
8+
import { createVueRouteProvider, getRouterFromApp } from './routeProvider';
89

910
/**
1011
* Inits the Vue SDK
@@ -19,6 +20,14 @@ export function init(options: Partial<Omit<Options, 'tracingOptions'>> = {}): Cl
1920

2021
const client = browserInit(opts);
2122

23+
// Registered here rather than from `browserTracingIntegration` so route parameterization does not
24+
// depend on tracing. The router is read off the app the SDK is already given, so users who never
25+
// pass `router` to the tracing integration still get parameterized routes.
26+
setRouteProvider(
27+
createVueRouteProvider(() => getRouterFromApp(opts.app)),
28+
client,
29+
);
30+
2231
// Add vue-specific stringification
2332
setNormalizeStringifier(normalizeStringifyValue);
2433

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { createVueRouteProvider, getRouterFromApp } from '../src/routeProvider';
3+
import type { Route } from '../src/router';
4+
5+
function makeRoute(overrides: Partial<Route> = {}): Route {
6+
return { path: '/users/42', query: {}, params: {}, matched: [{ path: '/users/:id' }], ...overrides };
7+
}
8+
9+
/** Vue Router 4+ returns the route itself. */
10+
const v4Router = (route: Route | undefined) => ({ resolve: () => route as Route });
11+
/** Vue Router 3 wraps it in `{ route }`. */
12+
const v3Router = (route: Route) => ({ resolve: () => ({ route }) });
13+
14+
/** A Vue 3 app with `vue-router` installed, which sets `config.globalProperties.$router`. */
15+
const appWithRouter = (router: unknown) => ({ config: { globalProperties: { $router: router } } });
16+
17+
describe('getRouterFromApp', () => {
18+
it('reads the router vue-router installed on the app', () => {
19+
const router = v4Router(makeRoute());
20+
21+
expect(getRouterFromApp(appWithRouter(router))).toBe(router);
22+
});
23+
24+
it('reads from the first app when several were passed', () => {
25+
const router = v4Router(makeRoute());
26+
27+
expect(getRouterFromApp([appWithRouter(router), appWithRouter(undefined)])).toBe(router);
28+
});
29+
30+
it('returns undefined when no router is installed yet', () => {
31+
expect(getRouterFromApp({ config: { globalProperties: {} } })).toBeUndefined();
32+
expect(getRouterFromApp(undefined)).toBeUndefined();
33+
});
34+
});
35+
36+
describe('createVueRouteProvider', () => {
37+
it('resolves the matched path for Vue Router 4+', () => {
38+
const provider = createVueRouteProvider(() => v4Router(makeRoute()));
39+
40+
expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id');
41+
});
42+
43+
it('unwraps the `{ route }` shape Vue Router 3 resolves to', () => {
44+
const provider = createVueRouteProvider(() => v3Router(makeRoute()));
45+
46+
expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id');
47+
});
48+
49+
it('returns the matched path even for a named route, since a name is not a template', () => {
50+
const provider = createVueRouteProvider(() => v4Router(makeRoute({ name: 'UserProfile' })));
51+
52+
expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id');
53+
});
54+
55+
it('picks the router up late, since `app.use(router)` may run after `Sentry.init`', () => {
56+
let router: ReturnType<typeof v4Router> | undefined;
57+
const provider = createVueRouteProvider(() => router);
58+
59+
expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBeUndefined();
60+
61+
router = v4Router(makeRoute());
62+
expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id');
63+
});
64+
65+
it('returns undefined when nothing matched', () => {
66+
const provider = createVueRouteProvider(() => v4Router(makeRoute({ matched: [] })));
67+
68+
expect(provider.resolveRoute(new URL('https://example.com/nope'))).toBeUndefined();
69+
});
70+
});

0 commit comments

Comments
 (0)