From f60133eecef3228b2fe41a0994fb54a8ce041fd7 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 31 Aug 2026 15:44:29 -0400 Subject: [PATCH 1/7] 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. --- packages/vue/src/index.ts | 1 + packages/vue/src/routeProvider.ts | 44 ++++++++++++++++ packages/vue/src/sdk.ts | 10 ++++ packages/vue/test/routeProvider.test.ts | 70 +++++++++++++++++++++++++ 4 files changed, 125 insertions(+) create mode 100644 packages/vue/src/routeProvider.ts create mode 100644 packages/vue/test/routeProvider.test.ts diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 3e870ff1062b..b122aa918956 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -8,5 +8,6 @@ export { browserTracingIntegration } from './browserTracingIntegration'; export { attachErrorHandler } from './errorhandler'; export { createTracingMixins } from './tracing'; export { vueIntegration } from './integration'; +export { createVueRouteProvider } from './routeProvider'; export type { VueIntegrationOptions } from './integration'; export { createSentryPiniaPlugin } from './pinia'; diff --git a/packages/vue/src/routeProvider.ts b/packages/vue/src/routeProvider.ts new file mode 100644 index 000000000000..9762174cc299 --- /dev/null +++ b/packages/vue/src/routeProvider.ts @@ -0,0 +1,44 @@ +import type { RouteProvider } from '@sentry/core'; +import { createUrlRouteProvider } from '@sentry/core'; +import type { Route } from './router'; + +// Vue Router 3 resolves to `{ route }`, Vue Router 4+ returns the route itself. +type ResolvedLocation = Route | { route: Route }; + +interface InstalledRouter { + resolve?: (to: string) => ResolvedLocation; +} + +interface AppWithRouter { + config?: { globalProperties?: { $router?: InstalledRouter } }; +} + +/** + * Builds a route provider from a `vue-router` instance, however the SDK got hold of one. + * + * The router is looked up per call rather than captured once, because `app.use(router)` may run + * either side of `Sentry.init()` and only the app itself is guaranteed to exist by then. + */ +export function createVueRouteProvider(getRouter: () => InstalledRouter | undefined): RouteProvider { + return createUrlRouteProvider(url => { + const resolved = getRouter()?.resolve?.(`${url.pathname}${url.search}${url.hash}`); + if (!resolved) { + return undefined; + } + + const route = 'matched' in resolved ? resolved : resolved.route; + + // Always the matched path, never `route.name`. Callers set `url.template` from this, and a route + // name is an identifier rather than a template. + return route.matched[route.matched.length - 1]?.path; + }); +} + +/** + * Reads the router `vue-router` installed onto a Vue app. + */ +export function getRouterFromApp(app: unknown): InstalledRouter | undefined { + const firstApp: AppWithRouter | undefined = Array.isArray(app) ? app[0] : (app as AppWithRouter | undefined); + + return firstApp?.config?.globalProperties?.$router; +} diff --git a/packages/vue/src/sdk.ts b/packages/vue/src/sdk.ts index b6ce9ff99b89..76e85ca97ec8 100644 --- a/packages/vue/src/sdk.ts +++ b/packages/vue/src/sdk.ts @@ -1,10 +1,12 @@ import { getDefaultIntegrations, init as browserInit } from '@sentry/browser'; import type { Client } from '@sentry/core'; import { applySdkMetadata, setNormalizeStringifier } from '@sentry/core'; +import { setRouteProvider } from '@sentry/core/browser'; import { vueIntegration } from './integration'; import type { Options } from './types'; import { normalizeStringifyValue } from './normalizeStringifyValue'; +import { createVueRouteProvider, getRouterFromApp } from './routeProvider'; /** * Inits the Vue SDK @@ -19,6 +21,14 @@ export function init(options: Partial> = {}): Cl const client = browserInit(opts); + // Registered here rather than from `browserTracingIntegration` so route parameterization does not + // depend on tracing. The router is read off the app the SDK is already given, so users who never + // pass `router` to the tracing integration still get parameterized routes. + setRouteProvider( + createVueRouteProvider(() => getRouterFromApp(opts.app)), + client, + ); + // Add vue-specific stringification setNormalizeStringifier(normalizeStringifyValue); diff --git a/packages/vue/test/routeProvider.test.ts b/packages/vue/test/routeProvider.test.ts new file mode 100644 index 000000000000..996d133f0aae --- /dev/null +++ b/packages/vue/test/routeProvider.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { createVueRouteProvider, getRouterFromApp } from '../src/routeProvider'; +import type { Route } 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. */ +const v4Router = (route: Route | undefined) => ({ resolve: () => route as Route }); +/** Vue Router 3 wraps it in `{ route }`. */ +const v3Router = (route: Route) => ({ resolve: () => ({ route }) }); + +/** A Vue 3 app with `vue-router` installed, which sets `config.globalProperties.$router`. */ +const appWithRouter = (router: unknown) => ({ config: { globalProperties: { $router: router } } }); + +describe('getRouterFromApp', () => { + it('reads the router vue-router installed on the app', () => { + const router = v4Router(makeRoute()); + + expect(getRouterFromApp(appWithRouter(router))).toBe(router); + }); + + it('reads from the first app when several were passed', () => { + const router = v4Router(makeRoute()); + + expect(getRouterFromApp([appWithRouter(router), appWithRouter(undefined)])).toBe(router); + }); + + it('returns undefined when no router is installed yet', () => { + expect(getRouterFromApp({ config: { globalProperties: {} } })).toBeUndefined(); + expect(getRouterFromApp(undefined)).toBeUndefined(); + }); +}); + +describe('createVueRouteProvider', () => { + it('resolves the matched path for Vue Router 4+', () => { + const provider = createVueRouteProvider(() => v4Router(makeRoute())); + + 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(() => v3Router(makeRoute())); + + expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id'); + }); + + it('returns the matched path even for a named route, since a name is not a template', () => { + const provider = createVueRouteProvider(() => v4Router(makeRoute({ name: 'UserProfile' }))); + + expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id'); + }); + + it('picks the router up late, since `app.use(router)` may run after `Sentry.init`', () => { + let router: ReturnType | undefined; + const provider = createVueRouteProvider(() => router); + + expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBeUndefined(); + + router = v4Router(makeRoute()); + expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id'); + }); + + it('returns undefined when nothing matched', () => { + const provider = createVueRouteProvider(() => v4Router(makeRoute({ matched: [] }))); + + expect(provider.resolveRoute(new URL('https://example.com/nope'))).toBeUndefined(); + }); +}); From 0d5127dd65cb58e32b5cda59d4c33513aeb3a492 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 24 Sep 2026 14:03:38 -0400 Subject: [PATCH 2/7] ref(vue): Import the route provider API from `@sentry/core/browser` --- packages/vue/src/routeProvider.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vue/src/routeProvider.ts b/packages/vue/src/routeProvider.ts index 9762174cc299..e530730caf61 100644 --- a/packages/vue/src/routeProvider.ts +++ b/packages/vue/src/routeProvider.ts @@ -1,5 +1,5 @@ -import type { RouteProvider } from '@sentry/core'; -import { createUrlRouteProvider } from '@sentry/core'; +import type { RouteProvider } from '@sentry/core/browser'; +import { createUrlRouteProvider } from '@sentry/core/browser'; import type { Route } from './router'; // Vue Router 3 resolves to `{ route }`, Vue Router 4+ returns the route itself. From 01844810624a7d788f9489d7f9d04d39e13705c2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 24 Sep 2026 14:49:40 -0400 Subject: [PATCH 3/7] ref(vue): Import the route provider API from `@sentry/browser` --- packages/vue/src/routeProvider.ts | 4 ++-- packages/vue/src/sdk.ts | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/vue/src/routeProvider.ts b/packages/vue/src/routeProvider.ts index e530730caf61..49db76e4617c 100644 --- a/packages/vue/src/routeProvider.ts +++ b/packages/vue/src/routeProvider.ts @@ -1,5 +1,5 @@ -import type { RouteProvider } from '@sentry/core/browser'; -import { createUrlRouteProvider } from '@sentry/core/browser'; +import type { RouteProvider } from '@sentry/browser'; +import { createUrlRouteProvider } from '@sentry/browser'; import type { Route } from './router'; // Vue Router 3 resolves to `{ route }`, Vue Router 4+ returns the route itself. diff --git a/packages/vue/src/sdk.ts b/packages/vue/src/sdk.ts index 76e85ca97ec8..cd2d6f93726f 100644 --- a/packages/vue/src/sdk.ts +++ b/packages/vue/src/sdk.ts @@ -1,7 +1,6 @@ -import { getDefaultIntegrations, init as browserInit } from '@sentry/browser'; +import { getDefaultIntegrations, init as browserInit, setRouteProvider } from '@sentry/browser'; import type { Client } from '@sentry/core'; import { applySdkMetadata, setNormalizeStringifier } from '@sentry/core'; -import { setRouteProvider } from '@sentry/core/browser'; import { vueIntegration } from './integration'; import type { Options } from './types'; From 0429302025bd1e9f0176553eff8e90af74f052cb Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 24 Sep 2026 15:58:42 -0400 Subject: [PATCH 4/7] ref(vue): Pass the route provider as the `routeProvider` option --- packages/vue/src/sdk.ts | 13 ++++------- packages/vue/test/sdk.test.ts | 43 +++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 9 deletions(-) create mode 100644 packages/vue/test/sdk.test.ts diff --git a/packages/vue/src/sdk.ts b/packages/vue/src/sdk.ts index cd2d6f93726f..8bc2f184e8f8 100644 --- a/packages/vue/src/sdk.ts +++ b/packages/vue/src/sdk.ts @@ -1,4 +1,4 @@ -import { getDefaultIntegrations, init as browserInit, setRouteProvider } from '@sentry/browser'; +import { getDefaultIntegrations, init as browserInit } from '@sentry/browser'; import type { Client } from '@sentry/core'; import { applySdkMetadata, setNormalizeStringifier } from '@sentry/core'; @@ -13,6 +13,9 @@ import { createVueRouteProvider, getRouterFromApp } from './routeProvider'; export function init(options: Partial> = {}): Client | undefined { const opts = { defaultIntegrations: [...getDefaultIntegrations(options), vueIntegration()], + // The router is read off the app on each call, so `app.use(router)` can run either side of `init`, and + // users who never pass `router` to the tracing integration still get parameterized routes. + ...(options.app && { routeProvider: createVueRouteProvider(() => getRouterFromApp(options.app)) }), ...options, }; @@ -20,14 +23,6 @@ export function init(options: Partial> = {}): Cl const client = browserInit(opts); - // Registered here rather than from `browserTracingIntegration` so route parameterization does not - // depend on tracing. The router is read off the app the SDK is already given, so users who never - // pass `router` to the tracing integration still get parameterized routes. - setRouteProvider( - createVueRouteProvider(() => getRouterFromApp(opts.app)), - client, - ); - // Add vue-specific stringification setNormalizeStringifier(normalizeStringifyValue); diff --git a/packages/vue/test/sdk.test.ts b/packages/vue/test/sdk.test.ts new file mode 100644 index 000000000000..f98ff40c1b8d --- /dev/null +++ b/packages/vue/test/sdk.test.ts @@ -0,0 +1,43 @@ +import * as SentryBrowser from '@sentry/browser'; +import { getMainCarrier } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { init } from '../src/sdk'; + +const browserInit = vi.spyOn(SentryBrowser, 'init'); + +const DSN = 'https://public@dsn.ingest.sentry.io/1337'; + +const app = { + config: { + globalProperties: { + $router: { resolve: () => ({ matched: [{ path: '/users/:id' }] }) }, + }, + }, +}; + +describe('init', () => { + afterEach(() => { + vi.clearAllMocks(); + getMainCarrier().__SENTRY__ = undefined; + }); + + it('passes a route provider that reads the router off the app', () => { + init({ dsn: DSN, app: app as never, defaultIntegrations: false }); + + const { routeProvider } = browserInit.mock.lastCall![0]!; + expect(routeProvider?.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id'); + }); + + it('does not pass a route provider without an app to read the router from', () => { + init({ dsn: DSN, defaultIntegrations: false }); + + expect(browserInit).toHaveBeenLastCalledWith(expect.not.objectContaining({ routeProvider: expect.anything() })); + }); + + it('keeps a route provider passed by the user', () => { + const routeProvider = { resolveRoute: () => '/custom', resolveCurrentRoute: () => '/custom' }; + init({ dsn: DSN, app: app as never, defaultIntegrations: false, routeProvider }); + + expect(browserInit).toHaveBeenLastCalledWith(expect.objectContaining({ routeProvider })); + }); +}); From ba19db59107ad1ead702ac4a6aaf11514c256363 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 24 Sep 2026 17:15:35 -0400 Subject: [PATCH 5/7] test(vue): Cover route resolution through the route provider --- .../test-applications/vue-3/src/router/index.ts | 4 ++++ .../vue-3/src/views/RouteProviderView.vue | 14 ++++++++++++++ .../vue-3/tests/route-provider.test.ts | 9 +++++++++ 3 files changed, 27 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/vue-3/src/views/RouteProviderView.vue create mode 100644 dev-packages/e2e-tests/test-applications/vue-3/tests/route-provider.test.ts diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts b/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts index 030d75dffb23..3f718ae945b4 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts @@ -23,6 +23,10 @@ const router = createRouter({ path: '/users/:id', component: () => import('../views/UserIdView.vue'), }, + { + path: '/route-provider/:id', + component: () => import('../views/RouteProviderView.vue'), + }, { path: '/users-error/:id', component: () => import('../views/UserIdErrorView.vue'), diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/views/RouteProviderView.vue b/dev-packages/e2e-tests/test-applications/vue-3/src/views/RouteProviderView.vue new file mode 100644 index 000000000000..3f7d4dacf895 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/views/RouteProviderView.vue @@ -0,0 +1,14 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tests/route-provider.test.ts b/dev-packages/e2e-tests/test-applications/vue-3/tests/route-provider.test.ts new file mode 100644 index 000000000000..55e890e7398e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/tests/route-provider.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from '@playwright/test'; + +// The route provider reads the router off the app passed to `Sentry.init`, so this fails if it can't +// find it there. +test('resolves the parameterized route through the route provider', async ({ page }) => { + await page.goto('/route-provider/123'); + + await expect(page.locator('#resolved-route')).toHaveText('/route-provider/:id'); +}); From d3a44e0da6c9ccd137bb38d0ce7a5014fd5630b3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 30 Sep 2026 19:08:23 +0200 Subject: [PATCH 6/7] fix(vue): Resolve hash and base routes, register the provider from `vueIntegration` --- packages/vue/src/index.ts | 2 +- packages/vue/src/integration.ts | 11 +++++ packages/vue/src/routeProvider.ts | 36 ++++++++++++++-- packages/vue/src/sdk.ts | 4 -- packages/vue/test/routeProvider.test.ts | 57 ++++++++++++++++++++++--- packages/vue/test/sdk.test.ts | 34 +++++++++------ 6 files changed, 115 insertions(+), 29 deletions(-) diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index b122aa918956..00d44693e193 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -8,6 +8,6 @@ export { browserTracingIntegration } from './browserTracingIntegration'; export { attachErrorHandler } from './errorhandler'; export { createTracingMixins } from './tracing'; export { vueIntegration } from './integration'; -export { createVueRouteProvider } from './routeProvider'; +export { createVueRouteProvider as _INTERNAL_createVueRouteProvider } from './routeProvider'; export type { VueIntegrationOptions } from './integration'; export { createSentryPiniaPlugin } from './pinia'; diff --git a/packages/vue/src/integration.ts b/packages/vue/src/integration.ts index 8bdac3d5db44..6c5dea934e81 100644 --- a/packages/vue/src/integration.ts +++ b/packages/vue/src/integration.ts @@ -1,8 +1,10 @@ +import { getRouteProvider, setRouteProvider } from '@sentry/browser'; import { consoleSandbox, defineIntegration, GLOBAL_OBJ, hasSpansEnabled } from '@sentry/core'; import { DEFAULT_HOOKS, DEFAULT_ROOT_SPAN_TIMEOUT } from './constants'; import { DEBUG_BUILD } from './debug-build'; import { attachErrorHandler } from './errorhandler'; import { instrumentAppMountWithoutMixin } from './rootInstrumentation'; +import { createVueRouteProvider, getRouterFromApp } from './routeProvider'; import { createTracingMixins } from './tracing'; import type { Options, TracingOptions, Vue, VueOptions } from './types'; @@ -39,6 +41,15 @@ export const vueIntegration = defineIntegration((integrationOptions: Partial getRouterFromApp(app)), + client, + ); + } + const apps = Array.isArray(options.app) ? options.app : [options.app]; apps.forEach(app => vueInit(app, options)); } else if (options.Vue) { diff --git a/packages/vue/src/routeProvider.ts b/packages/vue/src/routeProvider.ts index 49db76e4617c..1ca9ecd93b9c 100644 --- a/packages/vue/src/routeProvider.ts +++ b/packages/vue/src/routeProvider.ts @@ -1,12 +1,20 @@ import type { RouteProvider } from '@sentry/browser'; import { createUrlRouteProvider } from '@sentry/browser'; import type { Route } from './router'; +import type { Vue } from './types'; // Vue Router 3 resolves to `{ route }`, Vue Router 4+ returns the route itself. type ResolvedLocation = Route | { route: Route }; +type RouteUrl = Parameters[0]; + interface InstalledRouter { resolve?: (to: string) => ResolvedLocation; + /** Vue Router 4+ */ + options?: { history?: { base?: string } }; + /** Vue Router 3 */ + mode?: string; + history?: { base?: string }; } interface AppWithRouter { @@ -21,7 +29,8 @@ interface AppWithRouter { */ export function createVueRouteProvider(getRouter: () => InstalledRouter | undefined): RouteProvider { return createUrlRouteProvider(url => { - const resolved = getRouter()?.resolve?.(`${url.pathname}${url.search}${url.hash}`); + const router = getRouter(); + const resolved = router?.resolve?.(getRouterLocation(router, url)); if (!resolved) { return undefined; } @@ -37,8 +46,29 @@ export function createVueRouteProvider(getRouter: () => InstalledRouter | undefi /** * Reads the router `vue-router` installed onto a Vue app. */ -export function getRouterFromApp(app: unknown): InstalledRouter | undefined { - const firstApp: AppWithRouter | undefined = Array.isArray(app) ? app[0] : (app as AppWithRouter | undefined); +export function getRouterFromApp(app: Vue | Vue[] | undefined): InstalledRouter | undefined { + const firstApp = (Array.isArray(app) ? app[0] : app) as AppWithRouter | undefined; return firstApp?.config?.globalProperties?.$router; } + +/** + * `resolve` matches the router's own location rather than the browser's: the hash in hash mode, and + * the path without the router's base otherwise. Mirrors vue-router's `createCurrentLocation`. + */ +function getRouterLocation(router: InstalledRouter, { pathname, search, hash }: RouteUrl): string { + const base = router.options?.history?.base ?? router.history?.base ?? ''; + const hashPos = base.indexOf('#'); + + if (hashPos > -1 || router.mode === 'hash') { + const hashBase = hashPos > -1 ? base.slice(hashPos) : '#'; + const path = hash.slice(hash.startsWith(hashBase) ? hashBase.length : 1); + + return path.startsWith('/') ? path : `/${path}`; + } + + const path = + base && pathname.toLowerCase().startsWith(base.toLowerCase()) ? pathname.slice(base.length) || '/' : pathname; + + return `${path}${search}${hash}`; +} diff --git a/packages/vue/src/sdk.ts b/packages/vue/src/sdk.ts index 8bc2f184e8f8..b6ce9ff99b89 100644 --- a/packages/vue/src/sdk.ts +++ b/packages/vue/src/sdk.ts @@ -5,7 +5,6 @@ import { applySdkMetadata, setNormalizeStringifier } from '@sentry/core'; import { vueIntegration } from './integration'; import type { Options } from './types'; import { normalizeStringifyValue } from './normalizeStringifyValue'; -import { createVueRouteProvider, getRouterFromApp } from './routeProvider'; /** * Inits the Vue SDK @@ -13,9 +12,6 @@ import { createVueRouteProvider, getRouterFromApp } from './routeProvider'; export function init(options: Partial> = {}): Client | undefined { const opts = { defaultIntegrations: [...getDefaultIntegrations(options), vueIntegration()], - // The router is read off the app on each call, so `app.use(router)` can run either side of `init`, and - // users who never pass `router` to the tracing integration still get parameterized routes. - ...(options.app && { routeProvider: createVueRouteProvider(() => getRouterFromApp(options.app)) }), ...options, }; diff --git a/packages/vue/test/routeProvider.test.ts b/packages/vue/test/routeProvider.test.ts index 996d133f0aae..cc641d64edc4 100644 --- a/packages/vue/test/routeProvider.test.ts +++ b/packages/vue/test/routeProvider.test.ts @@ -1,18 +1,26 @@ import { describe, expect, it } from 'vitest'; import { createVueRouteProvider, getRouterFromApp } from '../src/routeProvider'; import type { Route } from '../src/router'; +import type { Vue } from '../src/types'; function makeRoute(overrides: Partial = {}): Route { return { path: '/users/42', query: {}, params: {}, matched: [{ path: '/users/:id' }], ...overrides }; } -/** Vue Router 4+ returns the route itself. */ -const v4Router = (route: Route | undefined) => ({ resolve: () => route as Route }); +/** Vue Router 4+ returns the route itself. Only `/users/42` matches, so a wrong location resolves to nothing. */ +const v4Router = (route: Route | undefined, base = '') => ({ + options: { history: { base } }, + resolve: (to: string) => (to.split('?')[0] === '/users/42' ? route : makeRoute({ matched: [] })) as Route, +}); /** Vue Router 3 wraps it in `{ route }`. */ -const v3Router = (route: Route) => ({ resolve: () => ({ route }) }); +const v3Router = (route: Route, mode = 'history', base = '') => ({ + mode, + history: { base }, + resolve: (to: string) => ({ route: to.split('?')[0] === '/users/42' ? route : makeRoute({ matched: [] }) }), +}); /** A Vue 3 app with `vue-router` installed, which sets `config.globalProperties.$router`. */ -const appWithRouter = (router: unknown) => ({ config: { globalProperties: { $router: router } } }); +const appWithRouter = (router: unknown) => ({ config: { globalProperties: { $router: router } } }) as unknown as Vue; describe('getRouterFromApp', () => { it('reads the router vue-router installed on the app', () => { @@ -28,7 +36,7 @@ describe('getRouterFromApp', () => { }); it('returns undefined when no router is installed yet', () => { - expect(getRouterFromApp({ config: { globalProperties: {} } })).toBeUndefined(); + expect(getRouterFromApp(appWithRouter(undefined))).toBeUndefined(); expect(getRouterFromApp(undefined)).toBeUndefined(); }); }); @@ -37,7 +45,7 @@ describe('createVueRouteProvider', () => { it('resolves the matched path for Vue Router 4+', () => { const provider = createVueRouteProvider(() => v4Router(makeRoute())); - expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id'); + expect(provider.resolveRoute(new URL('https://example.com/users/42?tab=1'))).toBe('/users/:id'); }); it('unwraps the `{ route }` shape Vue Router 3 resolves to', () => { @@ -63,8 +71,43 @@ describe('createVueRouteProvider', () => { }); it('returns undefined when nothing matched', () => { - const provider = createVueRouteProvider(() => v4Router(makeRoute({ matched: [] }))); + const provider = createVueRouteProvider(() => v4Router(makeRoute())); expect(provider.resolveRoute(new URL('https://example.com/nope'))).toBeUndefined(); }); + + describe('Vue Router 4+ history base', () => { + it('strips a non-root base', () => { + const provider = createVueRouteProvider(() => v4Router(makeRoute(), '/app')); + + expect(provider.resolveRoute(new URL('https://example.com/app/users/42'))).toBe('/users/:id'); + }); + + it('resolves from the hash with hash history', () => { + const provider = createVueRouteProvider(() => v4Router(makeRoute(), '/#')); + + expect(provider.resolveRoute(new URL('https://example.com/#/users/42?tab=1'))).toBe('/users/:id'); + expect(provider.resolveRoute(new URL('https://example.com/'))).toBeUndefined(); + }); + + it('resolves from the hash with hash history under a base', () => { + const provider = createVueRouteProvider(() => v4Router(makeRoute(), '/app/#')); + + expect(provider.resolveRoute(new URL('https://example.com/app/#/users/42'))).toBe('/users/:id'); + }); + }); + + describe('Vue Router 3 mode', () => { + it('strips a non-root base in history mode', () => { + const provider = createVueRouteProvider(() => v3Router(makeRoute(), 'history', '/app')); + + expect(provider.resolveRoute(new URL('https://example.com/app/users/42'))).toBe('/users/:id'); + }); + + it('resolves from the hash in hash mode', () => { + const provider = createVueRouteProvider(() => v3Router(makeRoute(), 'hash')); + + expect(provider.resolveRoute(new URL('https://example.com/#/users/42'))).toBe('/users/:id'); + }); + }); }); diff --git a/packages/vue/test/sdk.test.ts b/packages/vue/test/sdk.test.ts index f98ff40c1b8d..9261b5c2a21b 100644 --- a/packages/vue/test/sdk.test.ts +++ b/packages/vue/test/sdk.test.ts @@ -1,9 +1,9 @@ -import * as SentryBrowser from '@sentry/browser'; +import { getRouteProvider, resolveRoute } from '@sentry/browser'; import { getMainCarrier } from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { vueIntegration } from '../src/integration'; import { init } from '../src/sdk'; - -const browserInit = vi.spyOn(SentryBrowser, 'init'); +import type { Vue } from '../src/types'; const DSN = 'https://public@dsn.ingest.sentry.io/1337'; @@ -13,31 +13,37 @@ const app = { $router: { resolve: () => ({ matched: [{ path: '/users/:id' }] }) }, }, }, -}; + mixin: vi.fn(), +} as unknown as Vue; -describe('init', () => { +describe('route provider registration', () => { afterEach(() => { vi.clearAllMocks(); getMainCarrier().__SENTRY__ = undefined; }); - it('passes a route provider that reads the router off the app', () => { - init({ dsn: DSN, app: app as never, defaultIntegrations: false }); + it('registers a route provider that reads the router off the app passed to `init`', () => { + const client = init({ dsn: DSN, app, defaultIntegrations: false, integrations: [vueIntegration()] }); + + expect(resolveRoute('https://example.com/users/42', client)).toBe('/users/:id'); + }); + + it('registers a route provider when the app is passed to `vueIntegration`', () => { + const client = init({ dsn: DSN, defaultIntegrations: false, integrations: [vueIntegration({ app })] }); - const { routeProvider } = browserInit.mock.lastCall![0]!; - expect(routeProvider?.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id'); + expect(resolveRoute('https://example.com/users/42', client)).toBe('/users/:id'); }); - it('does not pass a route provider without an app to read the router from', () => { - init({ dsn: DSN, defaultIntegrations: false }); + it('does not register a route provider without an app to read the router from', () => { + const client = init({ dsn: DSN, defaultIntegrations: false, integrations: [vueIntegration()] }); - expect(browserInit).toHaveBeenLastCalledWith(expect.not.objectContaining({ routeProvider: expect.anything() })); + expect(getRouteProvider(client)).toBeUndefined(); }); it('keeps a route provider passed by the user', () => { const routeProvider = { resolveRoute: () => '/custom', resolveCurrentRoute: () => '/custom' }; - init({ dsn: DSN, app: app as never, defaultIntegrations: false, routeProvider }); + const client = init({ dsn: DSN, app, routeProvider, defaultIntegrations: false, integrations: [vueIntegration()] }); - expect(browserInit).toHaveBeenLastCalledWith(expect.objectContaining({ routeProvider })); + expect(getRouteProvider(client)).toBe(routeProvider); }); }); From 3b4b45ed90139c5a571f1421f32b7947a7dd54ef Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 30 Sep 2026 19:11:15 +0200 Subject: [PATCH 7/7] feat(vue): Register the route provider for Vue 2 --- packages/vue/src/integration.ts | 18 +++++++------- packages/vue/src/routeProvider.ts | 15 ++++++++++++ .../integration/mixinRegistration.test.ts | 3 ++- packages/vue/test/sdk.test.ts | 24 +++++++++++++++---- 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/packages/vue/src/integration.ts b/packages/vue/src/integration.ts index 6c5dea934e81..21835f8234dc 100644 --- a/packages/vue/src/integration.ts +++ b/packages/vue/src/integration.ts @@ -4,7 +4,7 @@ import { DEFAULT_HOOKS, DEFAULT_ROOT_SPAN_TIMEOUT } from './constants'; import { DEBUG_BUILD } from './debug-build'; import { attachErrorHandler } from './errorhandler'; import { instrumentAppMountWithoutMixin } from './rootInstrumentation'; -import { createVueRouteProvider, getRouterFromApp } from './routeProvider'; +import { captureRouterFromVue, createVueRouteProvider, getRouterFromApp } from './routeProvider'; import { createTracingMixins } from './tracing'; import type { Options, TracingOptions, Vue, VueOptions } from './types'; @@ -40,16 +40,14 @@ export const vueIntegration = defineIntegration((integrationOptions: Partial getRouterFromApp(app)), - client, - ); - } + // The router is looked up lazily because it may be installed after `init`. A user's `routeProvider` wins. + if (!getRouteProvider(client)) { + const { app, Vue } = options; + const getRouter = app ? () => getRouterFromApp(app) : captureRouterFromVue(Vue!); + setRouteProvider(createVueRouteProvider(getRouter), client); + } + if (options.app) { const apps = Array.isArray(options.app) ? options.app : [options.app]; apps.forEach(app => vueInit(app, options)); } else if (options.Vue) { diff --git a/packages/vue/src/routeProvider.ts b/packages/vue/src/routeProvider.ts index 1ca9ecd93b9c..7b9e6faf4a30 100644 --- a/packages/vue/src/routeProvider.ts +++ b/packages/vue/src/routeProvider.ts @@ -52,6 +52,21 @@ export function getRouterFromApp(app: Vue | Vue[] | undefined): InstalledRouter return firstApp?.config?.globalProperties?.$router; } +/** + * Vue 2 only exposes the router on instances, not on the constructor passed to `init`, so this picks it + * up from the root instance (`new Vue({ router })`) as it is created. + */ +export function captureRouterFromVue(Vue: Vue): () => InstalledRouter | undefined { + let router: InstalledRouter | undefined; + Vue.mixin({ + beforeCreate(this: { $options: { router?: InstalledRouter } }) { + router = router || this.$options.router; + }, + }); + + return () => router; +} + /** * `resolve` matches the router's own location rather than the browser's: the hash in hash mode, and * the path without the router's base otherwise. Mirrors vue-router's `createCurrentLocation`. diff --git a/packages/vue/test/integration/mixinRegistration.test.ts b/packages/vue/test/integration/mixinRegistration.test.ts index 8b5eb9de4048..90abef41e74b 100644 --- a/packages/vue/test/integration/mixinRegistration.test.ts +++ b/packages/vue/test/integration/mixinRegistration.test.ts @@ -160,7 +160,8 @@ describe('tracing mixin registration', () => { it('registers the tracing mixin on the constructor passed as `Vue` (Vue 2 setup)', ({ app, initSentry }) => { initSentry({ sdk: { app: undefined, Vue: app } }); - expect(getRegisteredMixins(app)).toHaveLength(1); + // The tracing mixin, plus the one that picks up the router for the route provider. + expect(getRegisteredMixins(app)).toHaveLength(2); }); }); diff --git a/packages/vue/test/sdk.test.ts b/packages/vue/test/sdk.test.ts index 9261b5c2a21b..4473ea1175d2 100644 --- a/packages/vue/test/sdk.test.ts +++ b/packages/vue/test/sdk.test.ts @@ -7,12 +7,10 @@ import type { Vue } from '../src/types'; const DSN = 'https://public@dsn.ingest.sentry.io/1337'; +const router = { resolve: () => ({ matched: [{ path: '/users/:id' }] }) }; + const app = { - config: { - globalProperties: { - $router: { resolve: () => ({ matched: [{ path: '/users/:id' }] }) }, - }, - }, + config: { globalProperties: { $router: router } }, mixin: vi.fn(), } as unknown as Vue; @@ -34,6 +32,22 @@ describe('route provider registration', () => { expect(resolveRoute('https://example.com/users/42', client)).toBe('/users/:id'); }); + it('registers a route provider that picks the router up from the Vue 2 root instance', () => { + let beforeCreate: (this: unknown) => void = () => {}; + const Vue2 = { + config: {}, + mixin: (mixin: { beforeCreate: typeof beforeCreate }) => { + beforeCreate = mixin.beforeCreate; + }, + } as unknown as Vue; + const client = init({ dsn: DSN, Vue: Vue2, defaultIntegrations: false, integrations: [vueIntegration()] }); + + expect(resolveRoute('https://example.com/users/42', client)).toBeUndefined(); + + beforeCreate.call({ $options: { router } }); + expect(resolveRoute('https://example.com/users/42', client)).toBe('/users/:id'); + }); + it('does not register a route provider without an app to read the router from', () => { const client = init({ dsn: DSN, defaultIntegrations: false, integrations: [vueIntegration()] });