Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(vue): Implement vue browserTracingIntegration() #10477

Merged
merged 3 commits into from
Feb 5, 2024
Merged
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
5 changes: 3 additions & 2 deletions dev-packages/e2e-tests/test-applications/vue-3/src/main.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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',
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Lms24 I think I fixed the "issue" from this comment here 😅 (I check this both in the "old" format and in the new one, seems to be working in both now, hopefully.

},
});
});
Expand Down
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
164 changes: 96 additions & 68 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,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: {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say it's ok to stop sending this as a tag and start sending it as an attribute - I don't think anybody will depend on this too much...? Does not seem too relevant for a user.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed. I made the same change in the SvelteKit browserTracingIntegration. Realistically, we probably don't need it at all, given that we have sentry.origin but it doesn't hurt us to send it as an attribute.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO we can just remove it now that we have sentry.origin across the board, let's save some bundle size.

That was the purpose of having the routing.instrumentation tag in the first place

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll remove it 🚀

[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';
}
Comment on lines +132 to +139
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think something here is slightly off but it's also off in the original implementation. See #10476 (comment)

Should we just always send route? I mean even if we take the name instead of the path, it still represents a route ultimately. Just a thought. Maybe we do it in a separate PR to fix this atomically in case we need to revert.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, good point 🤔 do we have any place where we rely on some behavior that if source===route we expect it to be a literal route, as in a URL path? 🤔 if not, probably OK to keep this route always I think - cc @AbhiPrasad

Copy link
Member

@Lms24 Lms24 Feb 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

afaik the only important distinction in Relay is between source url (where we apply server side clustering/grouping) vs all other sources (like route and custom, where we don't apply this). But not 100% sure anymore.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I asked the ingest team if there's a difference.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess internally we also only attach transaction names if they are not URLs to baggage.

I don't know the Relay specifics either, it'll be good to double check them.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll merge this as is (as it is the same behavior as before) and as Lukas said, we can change it in a separate PR where we can be clear about the reasoning etc!


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