Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/vue/src/browserTracingIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import {
startBrowserTracingNavigationSpan,
} from '@sentry/browser';
import type { Integration, StartSpanOptions } from '@sentry/core';
import { instrumentVueRouter } from './router';
import { setRouteProvider } from '@sentry/core';
import { createVueRouteProvider, instrumentVueRouter } from './router';

// The following type is an intersection of the Route type from VueRouter v2, v3, and v4.
// This is not great, but kinda necessary to make it work with all versions at the same time.
Expand Down Expand Up @@ -61,6 +62,13 @@ export function browserTracingIntegration(options: VueBrowserTracingIntegrationO

return {
...integration,
setup(client) {
// Registered before `afterAllSetup` so the provider is in place by the time the pageload span
// is named, rather than only once the router reports its first navigation.
setRouteProvider(createVueRouteProvider(router, routeLabel), client);

integration.setup?.(client);
},
afterAllSetup(client) {
integration.afterAllSetup(client);

Expand Down
56 changes: 44 additions & 12 deletions packages/vue/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import {
URL_PATH_PARAMETER_KEY_BASE,
URL_TEMPLATE,
} from '@sentry/conventions/attributes';
import type { Span, SpanAttributes, StartSpanOptions, TransactionSource } from '@sentry/core';
import type { RouteProvider, Span, SpanAttributes, StartSpanOptions, TransactionSource } from '@sentry/core';
import {
createUrlRouteProvider,
getActiveSpan,
getClient,
getCurrentScope,
Expand Down Expand Up @@ -45,6 +46,45 @@ interface VueRouter {
// Vue Router 3 exposes a `mode` property ('hash' | 'history' | 'abstract').
// Vue Router 4+ replaced it with `options.history`. Used for version detection.
mode?: string;
// Vue Router 3 resolves to `{ route }`, Vue Router 4+ returns the route itself. Optional because
// this interface is hand-rolled across Vue Router 2, 3 and 4+ rather than taken from the library.
resolve?: (to: string) => Route | { route: Route };
}

/**
* Builds a route provider backed by the Vue router's own matcher.
*
* Labels routes the same way the navigation instrumentation does, so a route resolved here can't
* disagree with the one on the pageload or navigation span.
*/
export function createVueRouteProvider(router: VueRouter, routeLabel: 'name' | 'path'): RouteProvider {
return createUrlRouteProvider(url => {
const resolved = router.resolve?.(`${url.pathname}${url.search}${url.hash}`);
if (!resolved) {
return undefined;
}

const route = 'matched' in resolved ? resolved : resolved.route;

return getRouteLabel(route, routeLabel)?.name;
});
}

/**
* The label for a matched route and where it came from, or `undefined` when nothing matched and only
* the raw path is left.
*/
function getRouteLabel(
route: Route,
routeLabel: 'name' | 'path',
): { name: string; source: TransactionSource } | undefined {
if (route.name && routeLabel !== 'path') {
return { name: route.name.toString(), source: 'custom' };
}

const matchedPath = route.matched[route.matched.length - 1]?.path;

return matchedPath ? { name: matchedPath, source: 'route' } : undefined;
}

/**
Expand Down Expand Up @@ -94,17 +134,9 @@ export function instrumentVueRouter(
}

// Determine a name for the routing transaction and where that name came from
let spanName: string = to.path;
let transactionSource: TransactionSource = 'url';
if (to.name && options.routeLabel !== 'path') {
spanName = to.name.toString();
transactionSource = 'custom';
} else if (to.matched.length > 0) {
const lastIndex = to.matched.length - 1;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
spanName = to.matched[lastIndex]!.path;
transactionSource = 'route';
}
const routeLabel = getRouteLabel(to, options.routeLabel);
const spanName = routeLabel?.name ?? to.path;
const transactionSource: TransactionSource = routeLabel?.source ?? 'url';

if (transactionSource === 'route') {
attributes[URL_TEMPLATE] = spanName;
Expand Down
60 changes: 60 additions & 0 deletions packages/vue/test/routeProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { GLOBAL_OBJ } from '@sentry/core';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { Route } from '../src/router';
import { createVueRouteProvider } from '../src/router';

function makeRoute(overrides: Partial<Route> = {}): Route {
return { path: '/users/42', query: {}, params: {}, matched: [{ path: '/users/:id' }], ...overrides };
}

/** Vue Router 4+ returns the route itself. */
function makeV4Router(route: Route | undefined) {
return { onError: () => {}, beforeEach: () => {}, resolve: () => route as Route };
}

/** Vue Router 3 wraps the route in `{ route }` and exposes `mode`. */
function makeV3Router(route: Route) {
return { onError: () => {}, beforeEach: () => {}, mode: 'history', resolve: () => ({ route }) };
}

describe('createVueRouteProvider', () => {
beforeEach(() => {
(GLOBAL_OBJ as { document?: unknown }).document = { location: { href: 'https://example.com/users/42' } };
});

afterEach(() => {
delete (GLOBAL_OBJ as { document?: unknown }).document;
});

it('resolves the matched route path for Vue Router 4+', () => {
const provider = createVueRouteProvider(makeV4Router(makeRoute()), 'path');

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(makeV3Router(makeRoute()), 'path');

expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id');
});

it('returns undefined when the router cannot resolve', () => {
const provider = createVueRouteProvider(makeV4Router(undefined), 'path');

expect(provider.resolveRoute(new URL('https://example.com/nope'))).toBeUndefined();
});

// `VueRouter` is a hand-rolled structural interface spanning Vue Router 2, 3 and 4+, so `resolve`
// is treated as optional rather than assumed present on every router the user passes in.
it('returns undefined for a router that exposes no `resolve`', () => {
const provider = createVueRouteProvider({ onError: () => {}, beforeEach: () => {} }, 'path');

expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBeUndefined();
});

it('resolves the current route from the document location', () => {
const provider = createVueRouteProvider(makeV4Router(makeRoute()), 'path');

expect(provider.resolveCurrentRoute()).toBe('/users/:id');
});
});
Loading