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 @@
+
+
+
+ {{ route }}
+
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 }));
+ });
+});