diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/main.ts b/dev-packages/e2e-tests/test-applications/vue-3/src/main.ts
index 503a9e44d14f..997c74fa0740 100644
--- a/dev-packages/e2e-tests/test-applications/vue-3/src/main.ts
+++ b/dev-packages/e2e-tests/test-applications/vue-3/src/main.ts
@@ -5,6 +5,7 @@ import App from './App.vue';
import router from './router';
import * as Sentry from '@sentry/vue';
+import { browserTracingIntegration } from '@sentry/vue';
const app = createApp(App);
@@ -13,8 +14,8 @@ Sentry.init({
dsn: import.meta.env.PUBLIC_E2E_TEST_DSN,
tracesSampleRate: 1.0,
integrations: [
- new Sentry.BrowserTracing({
- routingInstrumentation: Sentry.vueRouterInstrumentation(router),
+ browserTracingIntegration({
+ router,
}),
],
tunnel: `http://localhost:3031/`, // proxy server
diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts
index 732ec98a54f4..2210c92e5dfd 100644
--- a/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts
+++ b/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts
@@ -14,12 +14,10 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) =
contexts: {
trace: {
data: {
- params: {
- id: '456',
- },
'sentry.source': 'route',
'sentry.origin': 'auto.pageload.vue',
'sentry.op': 'pageload',
+ 'params.id': '456',
},
op: 'pageload',
origin: 'auto.pageload.vue',
@@ -52,12 +50,10 @@ test('sends a navigation transaction with a parameterized URL', async ({ page })
contexts: {
trace: {
data: {
- params: {
- id: '123',
- },
'sentry.source': 'route',
'sentry.origin': 'auto.navigation.vue',
'sentry.op': 'navigation',
+ 'params.id': '456',
},
op: 'navigation',
origin: 'auto.navigation.vue',
@@ -65,10 +61,7 @@ test('sends a navigation transaction with a parameterized URL', async ({ page })
},
transaction: '/users/:id',
transaction_info: {
- // So this is weird. The source is set to custom although the route doesn't have a name.
- // This also only happens during a navigation. A pageload will set the source as 'route'.
- // TODO: Figure out what's going on here.
- source: 'custom',
+ source: 'route',
},
});
});
diff --git a/packages/vue/README.md b/packages/vue/README.md
index c8fd91af2b24..378b15eafd0d 100644
--- a/packages/vue/README.md
+++ b/packages/vue/README.md
@@ -28,6 +28,10 @@ const app = createApp({
Sentry.init({
app,
dsn: '__PUBLIC_DSN__',
+ integrations: [
+ // Or omit `router` if you're not using vue-router
+ Sentry.browserTracingIntegration({ router }),
+ ],
});
```
@@ -42,12 +46,16 @@ import * as Sentry from '@sentry/vue'
Sentry.init({
Vue: Vue,
dsn: '__PUBLIC_DSN__',
-})
+ integrations: [
+ // Or omit `router` if you're not using vue-router
+ Sentry.browserTracingIntegration({ router }),
+ ],
+});
new Vue({
el: '#app',
router,
components: { App },
template: ''
-})
+});
```
diff --git a/packages/vue/src/browserTracingIntegration.ts b/packages/vue/src/browserTracingIntegration.ts
new file mode 100644
index 000000000000..d78bdd992d6b
--- /dev/null
+++ b/packages/vue/src/browserTracingIntegration.ts
@@ -0,0 +1,74 @@
+import {
+ browserTracingIntegration as originalBrowserTracingIntegration,
+ startBrowserTracingNavigationSpan,
+} from '@sentry/browser';
+import type { Integration, StartSpanOptions } from '@sentry/types';
+import { 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.
+export type Route = {
+ /** Unparameterized URL */
+ path: string;
+ /**
+ * Query params (keys map to null when there is no value associated, e.g. "?foo" and to an array when there are
+ * multiple query params that have the same key, e.g. "?foo&foo=bar")
+ */
+ query: Record;
+ /** Route name (VueRouter provides a way to give routes individual names) */
+ name?: string | symbol | null | undefined;
+ /** Evaluated parameters */
+ params: Record;
+ /** All the matched route objects as defined in VueRouter constructor */
+ matched: { path: string }[];
+};
+
+interface VueRouter {
+ onError: (fn: (err: Error) => void) => void;
+ beforeEach: (fn: (to: Route, from: Route, next?: () => void) => void) => void;
+}
+
+type VueBrowserTracingIntegrationOptions = Parameters[0] & {
+ /**
+ * If a router is specified, navigation spans will be created based on the router.
+ */
+ router?: VueRouter;
+
+ /**
+ * What to use for route labels.
+ * By default, we use route.name (if set) and else the path.
+ *
+ * Default: 'name'
+ */
+ routeLabel?: 'name' | 'path';
+};
+
+/**
+ * A custom BrowserTracing integration for Vue.
+ */
+export function browserTracingIntegration(options: VueBrowserTracingIntegrationOptions = {}): Integration {
+ // If router is not passed, we just use the normal implementation
+ if (!options.router) {
+ return originalBrowserTracingIntegration(options);
+ }
+
+ const integration = originalBrowserTracingIntegration({
+ ...options,
+ instrumentNavigation: false,
+ });
+
+ const { router, instrumentNavigation = true, instrumentPageLoad = true, routeLabel = 'name' } = options;
+
+ return {
+ ...integration,
+ afterAllSetup(client) {
+ integration.afterAllSetup(client);
+
+ const startNavigationSpan = (options: StartSpanOptions): void => {
+ startBrowserTracingNavigationSpan(client, options);
+ };
+
+ instrumentVueRouter(router, { routeLabel, instrumentNavigation, instrumentPageLoad }, startNavigationSpan);
+ },
+ };
+}
diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts
index 030324af9430..0b9626ee185d 100644
--- a/packages/vue/src/index.ts
+++ b/packages/vue/src/index.ts
@@ -1,7 +1,9 @@
export * from '@sentry/browser';
export { init } from './sdk';
+// eslint-disable-next-line deprecation/deprecation
export { vueRouterInstrumentation } from './router';
+export { browserTracingIntegration } from './browserTracingIntegration';
export { attachErrorHandler } from './errorhandler';
export { createTracingMixins } from './tracing';
export {
diff --git a/packages/vue/src/router.ts b/packages/vue/src/router.ts
index 98c18ae80691..b7f3fd0466b0 100644
--- a/packages/vue/src/router.ts
+++ b/packages/vue/src/router.ts
@@ -1,6 +1,6 @@
import { WINDOW, captureException } from '@sentry/browser';
-import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON } from '@sentry/core';
-import type { Transaction, TransactionContext, TransactionSource } from '@sentry/types';
+import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON } from '@sentry/core';
+import type { SpanAttributes, Transaction, TransactionContext, TransactionSource } from '@sentry/types';
import { getActiveTransaction } from './tracing';
@@ -50,6 +50,8 @@ interface VueRouter {
* * `routeLabel`: Set this to `route` to opt-out of using `route.name` for transaction names.
*
* @param router The Vue Router instance that is used
+ *
+ * @deprecated Use `browserTracingIntegration()` from `@sentry/vue` instead - this includes the vue router instrumentation.
*/
export function vueRouterInstrumentation(
router: VueRouter,
@@ -60,10 +62,6 @@ export function vueRouterInstrumentation(
startTransactionOnPageLoad: boolean = true,
startTransactionOnLocationChange: boolean = true,
) => {
- const tags = {
- 'routing.instrumentation': 'vue-router',
- };
-
// We have to start the pageload transaction as early as possible (before the router's `beforeEach` hook
// is called) to not miss child spans of the pageload.
// We check that window & window.location exists in order to not run this code in SSR environments.
@@ -71,77 +69,107 @@ export function vueRouterInstrumentation(
startTransaction({
name: WINDOW.location.pathname,
op: 'pageload',
- origin: 'auto.pageload.vue',
- tags,
- data: {
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
},
});
}
- router.onError(error => captureException(error, { mechanism: { handled: false } }));
-
- router.beforeEach((to, from, next) => {
- // According to docs we could use `from === VueRouter.START_LOCATION` but I couldnt get it working for Vue 2
- // https://router.vuejs.org/api/#router-start-location
- // https://next.router.vuejs.org/api/#start-location
-
- // from.name:
- // - Vue 2: null
- // - Vue 3: undefined
- // hence only '==' instead of '===', because `undefined == null` evaluates to `true`
- const isPageLoadNavigation = from.name == null && from.matched.length === 0;
-
- const data: Record = {
- params: to.params,
- query: to.query,
- };
-
- // Determine a name for the routing transaction and where that name came from
- let transactionName: string = to.path;
- let transactionSource: TransactionSource = 'url';
- if (to.name && options.routeLabel !== 'path') {
- transactionName = to.name.toString();
- transactionSource = 'custom';
- } else if (to.matched[0] && to.matched[0].path) {
- transactionName = to.matched[0].path;
- transactionSource = 'route';
- }
+ instrumentVueRouter(
+ router,
+ {
+ routeLabel: options.routeLabel || 'name',
+ instrumentNavigation: startTransactionOnLocationChange,
+ instrumentPageLoad: startTransactionOnPageLoad,
+ },
+ startTransaction,
+ );
+ };
+}
- if (startTransactionOnPageLoad && isPageLoadNavigation) {
- // eslint-disable-next-line deprecation/deprecation
- const pageloadTransaction = getActiveTransaction();
- if (pageloadTransaction) {
- const attributes = spanToJSON(pageloadTransaction).data || {};
- if (attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] !== 'custom') {
- pageloadTransaction.updateName(transactionName);
- pageloadTransaction.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, transactionSource);
- }
- // TODO: We need to flatten these to make them attributes
- // eslint-disable-next-line deprecation/deprecation
- pageloadTransaction.setData('params', data.params);
- // eslint-disable-next-line deprecation/deprecation
- pageloadTransaction.setData('query', data.query);
- }
+/**
+ * Instrument the Vue router to create navigation spans.
+ */
+export function instrumentVueRouter(
+ router: VueRouter,
+ options: {
+ routeLabel: 'name' | 'path';
+ instrumentPageLoad: boolean;
+ instrumentNavigation: boolean;
+ },
+ startNavigationSpanFn: (context: TransactionContext) => void,
+): void {
+ router.onError(error => captureException(error, { mechanism: { handled: false } }));
+
+ router.beforeEach((to, from, next) => {
+ // According to docs we could use `from === VueRouter.START_LOCATION` but I couldnt get it working for Vue 2
+ // https://router.vuejs.org/api/#router-start-location
+ // https://next.router.vuejs.org/api/#start-location
+
+ // from.name:
+ // - Vue 2: null
+ // - Vue 3: undefined
+ // hence only '==' instead of '===', because `undefined == null` evaluates to `true`
+ const isPageLoadNavigation = from.name == null && from.matched.length === 0;
+
+ const attributes: SpanAttributes = {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue',
+ };
+
+ for (const key of Object.keys(to.params)) {
+ attributes[`params.${key}`] = to.params[key];
+ }
+ for (const key of Object.keys(to.query)) {
+ const value = to.query[key];
+ if (value) {
+ attributes[`query.${key}`] = value;
}
+ }
- if (startTransactionOnLocationChange && !isPageLoadNavigation) {
- data[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = transactionSource;
- startTransaction({
- name: transactionName,
- op: 'navigation',
- origin: 'auto.navigation.vue',
- tags,
- data,
+ // Determine a name for the routing transaction and where that name came from
+ let transactionName: string = to.path;
+ let transactionSource: TransactionSource = 'url';
+ if (to.name && options.routeLabel !== 'path') {
+ transactionName = to.name.toString();
+ transactionSource = 'custom';
+ } else if (to.matched[0] && to.matched[0].path) {
+ transactionName = to.matched[0].path;
+ transactionSource = 'route';
+ }
+
+ if (options.instrumentPageLoad && isPageLoadNavigation) {
+ // eslint-disable-next-line deprecation/deprecation
+ const pageloadTransaction = getActiveTransaction();
+ if (pageloadTransaction) {
+ const existingAttributes = spanToJSON(pageloadTransaction).data || {};
+ if (existingAttributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] !== 'custom') {
+ pageloadTransaction.updateName(transactionName);
+ pageloadTransaction.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, transactionSource);
+ }
+ // Set router attributes on the existing pageload transaction
+ // This will the origin, and add params & query attributes
+ pageloadTransaction.setAttributes({
+ ...attributes,
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue',
});
}
+ }
- // Vue Router 4 no longer exposes the `next` function, so we need to
- // check if it's available before calling it.
- // `next` needs to be called in Vue Router 3 so that the hook is resolved.
- if (next) {
- next();
- }
- });
- };
+ if (options.instrumentNavigation && !isPageLoadNavigation) {
+ attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = transactionSource;
+ startNavigationSpanFn({
+ name: transactionName,
+ op: 'navigation',
+ attributes,
+ });
+ }
+
+ // Vue Router 4 no longer exposes the `next` function, so we need to
+ // check if it's available before calling it.
+ // `next` needs to be called in Vue Router 3 so that the hook is resolved.
+ if (next) {
+ next();
+ }
+ });
}
diff --git a/packages/vue/test/router.test.ts b/packages/vue/test/router.test.ts
index 061bcdd3e1f9..7d45889be864 100644
--- a/packages/vue/test/router.test.ts
+++ b/packages/vue/test/router.test.ts
@@ -1,9 +1,9 @@
import * as SentryBrowser from '@sentry/browser';
-import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core';
-import type { Transaction } from '@sentry/types';
+import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core';
+import type { SpanAttributes, Transaction } from '@sentry/types';
-import { vueRouterInstrumentation } from '../src';
import type { Route } from '../src/router';
+import { instrumentVueRouter, vueRouterInstrumentation } from '../src/router';
import * as vueTracing from '../src/tracing';
const captureExceptionSpy = jest.spyOn(SentryBrowser, 'captureException');
@@ -13,7 +13,6 @@ const mockVueRouter = {
beforeEach: jest.fn void) => void]>(),
};
-const mockStartTransaction = jest.fn();
const mockNext = jest.fn();
const testRoutes: Record = {
@@ -52,7 +51,10 @@ const testRoutes: Record = {
},
};
+/* eslint-disable deprecation/deprecation */
describe('vueRouterInstrumentation()', () => {
+ const mockStartTransaction = jest.fn();
+
afterEach(() => {
jest.clearAllMocks();
});
@@ -101,16 +103,12 @@ describe('vueRouterInstrumentation()', () => {
expect(mockStartTransaction).toHaveBeenCalledTimes(2);
expect(mockStartTransaction).toHaveBeenCalledWith({
name: transactionName,
- data: {
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: transactionSource,
- params: to.params,
- query: to.query,
+ ...getAttributesForRoute(to),
},
op: 'navigation',
- origin: 'auto.navigation.vue',
- tags: {
- 'routing.instrumentation': 'vue-router',
- },
});
expect(mockNext).toHaveBeenCalledTimes(1);
@@ -128,6 +126,7 @@ describe('vueRouterInstrumentation()', () => {
updateName: jest.fn(),
setData: jest.fn(),
setAttribute: jest.fn(),
+ setAttributes: jest.fn(),
metadata: {},
};
const customMockStartTxn = { ...mockStartTransaction }.mockImplementation(_ => {
@@ -145,14 +144,11 @@ describe('vueRouterInstrumentation()', () => {
expect(customMockStartTxn).toHaveBeenCalledTimes(1);
expect(customMockStartTxn).toHaveBeenCalledWith({
name: '/',
- data: {
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
},
op: 'pageload',
- origin: 'auto.pageload.vue',
- tags: {
- 'routing.instrumentation': 'vue-router',
- },
});
const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0][0];
@@ -165,8 +161,10 @@ describe('vueRouterInstrumentation()', () => {
expect(mockedTxn.updateName).toHaveBeenCalledWith(transactionName);
expect(mockedTxn.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, transactionSource);
- expect(mockedTxn.setData).toHaveBeenNthCalledWith(1, 'params', to.params);
- expect(mockedTxn.setData).toHaveBeenNthCalledWith(2, 'query', to.query);
+ expect(mockedTxn.setAttributes).toHaveBeenCalledWith({
+ ...getAttributesForRoute(to),
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue',
+ });
expect(mockNext).toHaveBeenCalledTimes(1);
},
@@ -189,16 +187,12 @@ describe('vueRouterInstrumentation()', () => {
// first startTx call happens when the instrumentation is initialized (for pageloads)
expect(mockStartTransaction).toHaveBeenLastCalledWith({
name: '/login',
- data: {
+ attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
- params: to.params,
- query: to.query,
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue',
+ ...getAttributesForRoute(to),
},
op: 'navigation',
- origin: 'auto.navigation.vue',
- tags: {
- 'routing.instrumentation': 'vue-router',
- },
});
});
@@ -219,16 +213,12 @@ describe('vueRouterInstrumentation()', () => {
// first startTx call happens when the instrumentation is initialized (for pageloads)
expect(mockStartTransaction).toHaveBeenLastCalledWith({
name: 'login-screen',
- data: {
+ attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom',
- params: to.params,
- query: to.query,
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue',
+ ...getAttributesForRoute(to),
},
op: 'navigation',
- origin: 'auto.navigation.vue',
- tags: {
- 'routing.instrumentation': 'vue-router',
- },
});
});
@@ -237,6 +227,7 @@ describe('vueRouterInstrumentation()', () => {
updateName: jest.fn(),
setData: jest.fn(),
setAttribute: jest.fn(),
+ setAttributes: jest.fn(),
name: '',
toJSON: () => ({
data: {
@@ -259,14 +250,11 @@ describe('vueRouterInstrumentation()', () => {
expect(customMockStartTxn).toHaveBeenCalledTimes(1);
expect(customMockStartTxn).toHaveBeenCalledWith({
name: '/',
- data: {
+ attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue',
},
op: 'pageload',
- origin: 'auto.pageload.vue',
- tags: {
- 'routing.instrumentation': 'vue-router',
- },
});
// now we give the transaction a custom name, thereby simulating what would
@@ -278,13 +266,20 @@ describe('vueRouterInstrumentation()', () => {
},
});
+ const to = testRoutes['normalRoute1'];
+ const from = testRoutes['initialPageloadRoute'];
+
const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0][0];
- beforeEachCallback(testRoutes['normalRoute1'], testRoutes['initialPageloadRoute'], mockNext);
+ beforeEachCallback(to, from, mockNext);
expect(mockVueRouter.beforeEach).toHaveBeenCalledTimes(1);
expect(mockedTxn.updateName).not.toHaveBeenCalled();
expect(mockedTxn.setAttribute).not.toHaveBeenCalled();
+ expect(mockedTxn.setAttributes).toHaveBeenCalledWith({
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue',
+ ...getAttributesForRoute(to),
+ });
expect(mockedTxn.name).toEqual('customTxnName');
});
@@ -346,16 +341,338 @@ describe('vueRouterInstrumentation()', () => {
// first startTx call happens when the instrumentation is initialized (for pageloads)
expect(mockStartTransaction).toHaveBeenLastCalledWith({
name: '/login',
- data: {
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue',
+ ...getAttributesForRoute(to),
+ },
+ op: 'navigation',
+ });
+ });
+});
+/* eslint-enable deprecation/deprecation */
+
+describe('instrumentVueRouter()', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should return instrumentation that instruments VueRouter.onError', () => {
+ const mockStartSpan = jest.fn();
+ instrumentVueRouter(
+ mockVueRouter,
+ { routeLabel: 'name', instrumentPageLoad: true, instrumentNavigation: true },
+ mockStartSpan,
+ );
+
+ // check
+ expect(mockVueRouter.onError).toHaveBeenCalledTimes(1);
+
+ const onErrorCallback = mockVueRouter.onError.mock.calls[0][0];
+
+ const testError = new Error();
+ onErrorCallback(testError);
+
+ expect(captureExceptionSpy).toHaveBeenCalledTimes(1);
+ expect(captureExceptionSpy).toHaveBeenCalledWith(testError, { mechanism: { handled: false } });
+ });
+
+ it.each([
+ ['normalRoute1', 'normalRoute2', '/accounts/:accountId', 'route'],
+ ['normalRoute2', 'namedRoute', 'login-screen', 'custom'],
+ ['normalRoute2', 'unmatchedRoute', '/e8733846-20ac-488c-9871-a5cbcb647294', 'url'],
+ ])(
+ 'should return instrumentation that instruments VueRouter.beforeEach(%s, %s) for navigations',
+ (fromKey, toKey, transactionName, transactionSource) => {
+ const mockStartSpan = jest.fn();
+ instrumentVueRouter(
+ mockVueRouter,
+ { routeLabel: 'name', instrumentPageLoad: true, instrumentNavigation: true },
+ mockStartSpan,
+ );
+
+ // check
+ expect(mockVueRouter.beforeEach).toHaveBeenCalledTimes(1);
+ const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0][0];
+
+ const from = testRoutes[fromKey];
+ const to = testRoutes[toKey];
+ beforeEachCallback(to, from, mockNext);
+
+ expect(mockStartSpan).toHaveBeenCalledTimes(1);
+ expect(mockStartSpan).toHaveBeenCalledWith({
+ name: transactionName,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: transactionSource,
+ ...getAttributesForRoute(to),
+ },
+ op: 'navigation',
+ });
+
+ expect(mockNext).toHaveBeenCalledTimes(1);
+ },
+ );
+
+ it.each([
+ ['initialPageloadRoute', 'normalRoute1', '/books/:bookId/chapter/:chapterId', 'route'],
+ ['initialPageloadRoute', 'namedRoute', 'login-screen', 'custom'],
+ ['initialPageloadRoute', 'unmatchedRoute', '/e8733846-20ac-488c-9871-a5cbcb647294', 'url'],
+ ])(
+ 'should return instrumentation that instruments VueRouter.beforeEach(%s, %s) for pageloads',
+ (fromKey, toKey, transactionName, transactionSource) => {
+ const mockedTxn = {
+ updateName: jest.fn(),
+ setData: jest.fn(),
+ setAttribute: jest.fn(),
+ setAttributes: jest.fn(),
+ metadata: {},
+ };
+
+ jest.spyOn(vueTracing, 'getActiveTransaction').mockImplementation(() => mockedTxn as unknown as Transaction);
+
+ const mockStartSpan = jest.fn().mockImplementation(_ => {
+ return mockedTxn;
+ });
+ instrumentVueRouter(
+ mockVueRouter,
+ { routeLabel: 'name', instrumentPageLoad: true, instrumentNavigation: true },
+ mockStartSpan,
+ );
+
+ // no span is started for page load
+ expect(mockStartSpan).not.toHaveBeenCalled();
+
+ const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0][0];
+
+ const from = testRoutes[fromKey];
+ const to = testRoutes[toKey];
+
+ beforeEachCallback(to, from, mockNext);
+ expect(mockVueRouter.beforeEach).toHaveBeenCalledTimes(1);
+
+ expect(mockedTxn.updateName).toHaveBeenCalledWith(transactionName);
+ expect(mockedTxn.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, transactionSource);
+ expect(mockedTxn.setAttributes).toHaveBeenCalledWith({
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue',
+ ...getAttributesForRoute(to),
+ });
+
+ expect(mockNext).toHaveBeenCalledTimes(1);
+ },
+ );
+
+ it('allows to configure routeLabel=path', () => {
+ const mockStartSpan = jest.fn();
+ instrumentVueRouter(
+ mockVueRouter,
+ { routeLabel: 'path', instrumentPageLoad: true, instrumentNavigation: true },
+ mockStartSpan,
+ );
+
+ // check
+ const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0][0];
+
+ const from = testRoutes.normalRoute1;
+ const to = testRoutes.namedRoute;
+ beforeEachCallback(to, from, mockNext);
+
+ // first startTx call happens when the instrumentation is initialized (for pageloads)
+ expect(mockStartSpan).toHaveBeenLastCalledWith({
+ name: '/login',
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
- params: to.params,
- query: to.query,
+ ...getAttributesForRoute(to),
},
op: 'navigation',
- origin: 'auto.navigation.vue',
- tags: {
- 'routing.instrumentation': 'vue-router',
+ });
+ });
+
+ it('allows to configure routeLabel=name', () => {
+ const mockStartSpan = jest.fn();
+ instrumentVueRouter(
+ mockVueRouter,
+ { routeLabel: 'name', instrumentPageLoad: true, instrumentNavigation: true },
+ mockStartSpan,
+ );
+
+ // check
+ const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0][0];
+
+ const from = testRoutes.normalRoute1;
+ const to = testRoutes.namedRoute;
+ beforeEachCallback(to, from, mockNext);
+
+ // first startTx call happens when the instrumentation is initialized (for pageloads)
+ expect(mockStartSpan).toHaveBeenLastCalledWith({
+ name: 'login-screen',
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom',
+ ...getAttributesForRoute(to),
},
+ op: 'navigation',
+ });
+ });
+
+ it("doesn't overwrite a pageload transaction name it was set to custom before the router resolved the route", () => {
+ const mockedTxn = {
+ updateName: jest.fn(),
+ setData: jest.fn(),
+ setAttribute: jest.fn(),
+ setAttributes: jest.fn(),
+ name: '',
+ toJSON: () => ({
+ data: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
+ },
+ }),
+ };
+ const mockStartSpan = jest.fn().mockImplementation(_ => {
+ return mockedTxn;
+ });
+ jest.spyOn(vueTracing, 'getActiveTransaction').mockImplementation(() => mockedTxn as unknown as Transaction);
+
+ instrumentVueRouter(
+ mockVueRouter,
+ { routeLabel: 'name', instrumentPageLoad: true, instrumentNavigation: true },
+ mockStartSpan,
+ );
+
+ // check for transaction start
+ expect(mockStartSpan).not.toHaveBeenCalled();
+
+ // now we give the transaction a custom name, thereby simulating what would
+ // happen when users use the `beforeNavigate` hook
+ mockedTxn.name = 'customTxnName';
+ mockedTxn.toJSON = () => ({
+ data: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom',
+ },
+ });
+
+ const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0][0];
+
+ const to = testRoutes['normalRoute1'];
+ const from = testRoutes['initialPageloadRoute'];
+
+ beforeEachCallback(to, from, mockNext);
+
+ expect(mockVueRouter.beforeEach).toHaveBeenCalledTimes(1);
+
+ expect(mockedTxn.updateName).not.toHaveBeenCalled();
+ expect(mockedTxn.setAttribute).not.toHaveBeenCalled();
+ expect(mockedTxn.setAttributes).toHaveBeenCalledWith({
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue',
+ ...getAttributesForRoute(to),
+ });
+ expect(mockedTxn.name).toEqual('customTxnName');
+ });
+
+ test.each([
+ [false, 0],
+ [true, 1],
+ ])(
+ 'should return instrumentation that considers the instrumentPageLoad = %p',
+ (instrumentPageLoad, expectedCallsAmount) => {
+ const mockedTxn = {
+ updateName: jest.fn(),
+ setData: jest.fn(),
+ setAttribute: jest.fn(),
+ setAttributes: jest.fn(),
+ name: '',
+ toJSON: () => ({
+ data: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
+ },
+ }),
+ };
+ jest.spyOn(vueTracing, 'getActiveTransaction').mockImplementation(() => mockedTxn as unknown as Transaction);
+
+ const mockStartSpan = jest.fn();
+ instrumentVueRouter(
+ mockVueRouter,
+ { routeLabel: 'name', instrumentPageLoad, instrumentNavigation: true },
+ mockStartSpan,
+ );
+
+ // check
+ expect(mockVueRouter.beforeEach).toHaveBeenCalledTimes(1);
+
+ const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0][0];
+ beforeEachCallback(testRoutes['normalRoute1'], testRoutes['initialPageloadRoute'], mockNext);
+
+ expect(mockedTxn.updateName).toHaveBeenCalledTimes(expectedCallsAmount);
+ expect(mockStartSpan).not.toHaveBeenCalled();
+ },
+ );
+
+ test.each([
+ [false, 0],
+ [true, 1],
+ ])(
+ 'should return instrumentation that considers the instrumentNavigation = %p',
+ (instrumentNavigation, expectedCallsAmount) => {
+ const mockStartSpan = jest.fn();
+ instrumentVueRouter(
+ mockVueRouter,
+ { routeLabel: 'name', instrumentPageLoad: true, instrumentNavigation },
+ mockStartSpan,
+ );
+
+ // check
+ expect(mockVueRouter.beforeEach).toHaveBeenCalledTimes(1);
+
+ const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0][0];
+ beforeEachCallback(testRoutes['normalRoute2'], testRoutes['normalRoute1'], mockNext);
+
+ expect(mockStartSpan).toHaveBeenCalledTimes(expectedCallsAmount);
+ },
+ );
+
+ it("doesn't throw when `next` is not available in the beforeEach callback (Vue Router 4)", () => {
+ const mockStartSpan = jest.fn();
+ instrumentVueRouter(
+ mockVueRouter,
+ { routeLabel: 'path', instrumentPageLoad: true, instrumentNavigation: true },
+ mockStartSpan,
+ );
+
+ const beforeEachCallback = mockVueRouter.beforeEach.mock.calls[0][0];
+
+ const from = testRoutes.normalRoute1;
+ const to = testRoutes.namedRoute;
+ beforeEachCallback(to, from, undefined);
+
+ // first startTx call happens when the instrumentation is initialized (for pageloads)
+ expect(mockStartSpan).toHaveBeenLastCalledWith({
+ name: '/login',
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
+ ...getAttributesForRoute(to),
+ },
+ op: 'navigation',
});
});
});
+
+// Small helper function to get flattened attributes for test comparison
+function getAttributesForRoute(route: Route): SpanAttributes {
+ const { params, query } = route;
+
+ const attributes: SpanAttributes = {};
+
+ for (const key of Object.keys(params)) {
+ attributes[`params.${key}`] = params[key];
+ }
+ for (const key of Object.keys(query)) {
+ const value = query[key];
+ if (value) {
+ attributes[`query.${key}`] = value;
+ }
+ }
+
+ return attributes;
+}