Skip to content

Commit

Permalink
feat(vue): Implement vue browserTracingIntegration()
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.
  • Loading branch information
mydea committed Feb 2, 2024
1 parent 3b2b18c commit 1faa587
Show file tree
Hide file tree
Showing 4 changed files with 180 additions and 69 deletions.
12 changes: 10 additions & 2 deletions packages/vue/README.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
161 changes: 94 additions & 67 deletions packages/vue/src/router.ts
Original file line number Diff line number Diff line change
@@ -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,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.
Expand All @@ -72,76 +70,105 @@ export function vueRouterInstrumentation(
name: WINDOW.location.pathname,
op: 'pageload',
origin: 'auto.pageload.vue',
tags,
data: {
attributes: {
'routing.instrumentation': 'vue-router',
[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,
);
};
}

/**
* 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',
'routing.instrumentation': 'vue-router',
};

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 (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);
// 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);
}
}

if (startTransactionOnLocationChange && !isPageLoadNavigation) {
data[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = transactionSource;
startTransaction({
name: transactionName,
op: 'navigation',
origin: 'auto.navigation.vue',
tags,
data,
});
}
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();
}
});
};
// 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 1faa587

Please sign in to comment.