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'); +}); 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..49db76e4617c --- /dev/null +++ b/packages/vue/src/routeProvider.ts @@ -0,0 +1,44 @@ +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. +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..8bc2f184e8f8 100644 --- a/packages/vue/src/sdk.ts +++ b/packages/vue/src/sdk.ts @@ -5,6 +5,7 @@ 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 @@ -12,6 +13,9 @@ import { normalizeStringifyValue } from './normalizeStringifyValue'; 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 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(); + }); +}); 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 })); + }); +});