Skip to content

Commit

Permalink
feat(vue): Implement vue browserTracingIntegration() (#10477)
Browse files Browse the repository at this point in the history
This replaces the `vueRouterInstrumentation` and allows to deprecate
browser tracing in the vue package.

Waiting for #10476,
then we should put these changes on top of the E2E test to verify it
still works.
  • Loading branch information
mydea committed Feb 5, 2024
1 parent 99d1114 commit 810cb70
Show file tree
Hide file tree
Showing 7 changed files with 549 additions and 126 deletions.
5 changes: 3 additions & 2 deletions dev-packages/e2e-tests/test-applications/vue-3/src/main.ts
Expand Up @@ -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);

Expand All @@ -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
Expand Down
Expand Up @@ -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',
Expand Down Expand Up @@ -52,23 +50,18 @@ 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',
},
},
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',
},
});
});
Expand Down
12 changes: 10 additions & 2 deletions packages/vue/README.md
Expand Up @@ -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 }),
],
});
```

Expand All @@ -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: '<App/>'
})
});
```
74 changes: 74 additions & 0 deletions 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<string, string | null | (string | null)[]>;
/** Route name (VueRouter provides a way to give routes individual names) */
name?: string | symbol | null | undefined;
/** Evaluated parameters */
params: Record<string, string | string[]>;
/** 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<typeof originalBrowserTracingIntegration>[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);
},
};
}
2 changes: 2 additions & 0 deletions 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 {
Expand Down
164 changes: 96 additions & 68 deletions 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';

Expand Down Expand Up @@ -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,
Expand All @@ -60,88 +62,114 @@ 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.
if (startTransactionOnPageLoad && WINDOW && WINDOW.location) {
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<string, unknown> = {
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();
}
});
}

0 comments on commit 810cb70

Please sign in to comment.