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; +}