-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat: Vue performance monitoring #2571
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
Changes from 1 commit
c5ec5db
a72bc6a
c4f46e0
4df15af
6b11742
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,103 +1,298 @@ | ||
import { EventProcessor, Hub, Integration } from '@sentry/types'; | ||
import { getGlobalObject, isPlainObject, logger } from '@sentry/utils'; | ||
import { basename, getGlobalObject, logger, timestampWithMs } from '@sentry/utils'; | ||
import { Integrations as APMIntegrations, Span as SpanClass } from '@sentry/apm'; | ||
|
||
interface IntegrationOptions { | ||
Vue: any; | ||
/** | ||
* When set to false, Sentry will suppress reporting of all props data | ||
* from your Vue components for privacy concerns. | ||
*/ | ||
attachProps: boolean; | ||
/** | ||
* When set to true, original Vue's `logError` will be called as well. | ||
* https://github.com/vuejs/vue/blob/c2b1cfe9ccd08835f2d99f6ce60f67b4de55187f/src/core/util/error.js#L38-L48 | ||
*/ | ||
logErrors: boolean; | ||
tracing: boolean; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we reasonably detect that tracing is enabled without this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sure, I'm already doing that. But what if someone wants to have error handling, xhr tracing, but skip Vue tracing? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We also cannot tell if |
||
tracingOptions: TracingOptions; | ||
} | ||
|
||
interface TracingOptions { | ||
track: boolean | Array<string>; | ||
rhcarvalho marked this conversation as resolved.
Show resolved
Hide resolved
|
||
timeout: number; | ||
rhcarvalho marked this conversation as resolved.
Show resolved
Hide resolved
|
||
hooks: Array<Hook>; | ||
rhcarvalho marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
interface ViewModel { | ||
[key: string]: any; | ||
$root: object; | ||
$options: { | ||
[key: string]: any; | ||
name?: string; | ||
propsData?: { [key: string]: any }; | ||
_componentTag?: string; | ||
__file?: string; | ||
$_sentryPerfHook?: boolean; | ||
}; | ||
$once: (hook: string, cb: () => void) => void; | ||
} | ||
|
||
/** JSDoc */ | ||
interface Metadata { | ||
[key: string]: any; | ||
componentName?: string; | ||
propsData?: { | ||
[key: string]: any; | ||
}; | ||
propsData?: { [key: string]: any }; | ||
lifecycleHook?: string; | ||
} | ||
|
||
// https://vuejs.org/v2/api/#Options-Lifecycle-Hooks | ||
rhcarvalho marked this conversation as resolved.
Show resolved
Hide resolved
|
||
type Hook = | ||
| 'beforeCreate' | ||
| 'created' | ||
| 'beforeMount' | ||
| 'mounted' | ||
| 'beforeUpdate' | ||
| 'updated' | ||
| 'activated' | ||
| 'deactivated' | ||
| 'beforeDestroy' | ||
| 'destroyed'; | ||
|
||
// Mappings from lifecycle hook to corresponding operation, | ||
// used to track already started measurements. | ||
const OPERATIONS = { | ||
beforeCreate: 'create', | ||
created: 'create', | ||
beforeMount: 'mount', | ||
mounted: 'mount', | ||
beforeUpdate: 'update', | ||
updated: 'update', | ||
activated: 'activate', | ||
deactivated: 'activate', | ||
beforeDestroy: 'destroy', | ||
destroyed: 'destroy', | ||
}; | ||
rhcarvalho marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
const COMPONENT_NAME_REGEXP = /(?:^|[-_/])(\w)/g; | ||
const ROOT_COMPONENT_NAME = 'root'; | ||
const ANONYMOUS_COMPONENT_NAME = 'anonymous component'; | ||
|
||
/** JSDoc */ | ||
export class Vue implements Integration { | ||
/** | ||
* @inheritDoc | ||
*/ | ||
public name: string = Vue.id; | ||
rhcarvalho marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
/** | ||
* @inheritDoc | ||
*/ | ||
public static id: string = 'Vue'; | ||
|
||
/** | ||
* @inheritDoc | ||
*/ | ||
private readonly _Vue: any; // tslint:disable-line:variable-name | ||
private _options: IntegrationOptions; | ||
|
||
/** | ||
* When set to false, Sentry will suppress reporting all props data | ||
* from your Vue components for privacy concerns. | ||
* Cache holding already processed component names | ||
*/ | ||
private readonly _attachProps: boolean = true; | ||
|
||
/** | ||
* When set to true, original Vue's `logError` will be called as well. | ||
* https://github.com/vuejs/vue/blob/c2b1cfe9ccd08835f2d99f6ce60f67b4de55187f/src/core/util/error.js#L38-L48 | ||
*/ | ||
private readonly _logErrors: boolean = false; | ||
private componentsCache = Object.create(null); | ||
private rootSpan?: SpanClass; | ||
private rootSpanTimer?: ReturnType<typeof setTimeout>; | ||
private tracingActivity?: number; | ||
|
||
/** | ||
* @inheritDoc | ||
*/ | ||
public constructor(options: { Vue?: any; attachProps?: boolean; logErrors?: boolean } = {}) { | ||
// tslint:disable-next-line: no-unsafe-any | ||
this._Vue = options.Vue || getGlobalObject<any>().Vue; | ||
public constructor(options: Partial<IntegrationOptions>) { | ||
this._options = { | ||
Vue: getGlobalObject<any>().Vue, | ||
attachProps: true, | ||
logErrors: false, | ||
tracing: false, | ||
...options, | ||
tracingOptions: { | ||
track: false, | ||
hooks: ['beforeMount', 'mounted', 'beforeUpdate', 'updated'], | ||
timeout: 2000, | ||
...options.tracingOptions, | ||
}, | ||
}; | ||
} | ||
|
||
private getComponentName(vm: ViewModel): string { | ||
// Such level of granularity is most likely not necessary, but better safe than sorry. — Kamil | ||
if (!vm) { | ||
return ANONYMOUS_COMPONENT_NAME; | ||
} | ||
|
||
if (vm.$root === vm) { | ||
return ROOT_COMPONENT_NAME; | ||
} | ||
|
||
if (options.logErrors !== undefined) { | ||
this._logErrors = options.logErrors; | ||
if (!vm.$options) { | ||
return ANONYMOUS_COMPONENT_NAME; | ||
} | ||
if (options.attachProps === false) { | ||
this._attachProps = false; | ||
|
||
if (vm.$options.name) { | ||
return vm.$options.name; | ||
} | ||
|
||
if (vm.$options._componentTag) { | ||
return vm.$options._componentTag; | ||
} | ||
|
||
// injected by vue-loader | ||
if (vm.$options.__file) { | ||
const unifiedFile = vm.$options.__file.replace(/^[a-zA-Z]:/, '').replace(/\\/g, '/'); | ||
const filename = basename(unifiedFile, '.vue'); | ||
return ( | ||
this.componentsCache[filename] || | ||
(this.componentsCache[filename] = filename.replace(COMPONENT_NAME_REGEXP, (_, c) => (c ? c.toUpperCase() : ''))) | ||
); | ||
} | ||
|
||
return ANONYMOUS_COMPONENT_NAME; | ||
} | ||
|
||
/** JSDoc */ | ||
private _formatComponentName(vm: any): string { | ||
// tslint:disable:no-unsafe-any | ||
private applyTracingHooks(vm: ViewModel, getCurrentHub: () => Hub): void { | ||
// Don't attach twice, just in case | ||
if (vm.$options.$_sentryPerfHook) return; | ||
vm.$options.$_sentryPerfHook = true; | ||
|
||
if (vm.$root === vm) { | ||
return 'root instance'; | ||
const name = this.getComponentName(vm); | ||
const rootMount = name === ROOT_COMPONENT_NAME; | ||
const spans: { [key: string]: any } = {}; | ||
|
||
// Render hook starts after once event is emitted, | ||
// but it ends before the second event of the same type. | ||
// | ||
// Because of this, we start measuring inside the first event, | ||
// but finish it before it triggers, to skip the event emitter timing itself. | ||
const rootHandler = (hook: Hook) => { | ||
const now = timestampWithMs(); | ||
|
||
// On the first handler call (before), it'll be undefined, as `$once` will add it in the future. | ||
// However, on the second call (after), it'll be already in place. | ||
if (this.rootSpan) { | ||
this.finishRootSpan(now); | ||
} else { | ||
vm.$once(`hook:${hook}`, () => { | ||
// Create an activity on the first event call. There'll be no second call, as rootSpan will be in place, | ||
// thus new event handler won't be attached. | ||
this.tracingActivity = APMIntegrations.Tracing.pushActivity('Vue Application Render'); | ||
this.rootSpan = getCurrentHub().startSpan({ | ||
description: 'Application Render', | ||
op: 'Vue', | ||
}) as SpanClass; | ||
}); | ||
} | ||
}; | ||
|
||
const childHandler = (hook: Hook) => { | ||
// Skip components that we don't want to track to minimize the noise and give a more granular control to the user | ||
const shouldTrack = Array.isArray(this._options.tracingOptions.track) | ||
? this._options.tracingOptions.track.includes(name) | ||
: this._options.tracingOptions.track; | ||
|
||
if (!this.rootSpan || !shouldTrack) { | ||
return; | ||
} | ||
|
||
const now = timestampWithMs(); | ||
const op = OPERATIONS[hook]; | ||
const span = spans[op]; | ||
|
||
// On the first handler call (before), it'll be undefined, as `$once` will add it in the future. | ||
// However, on the second call (after), it'll be already in place. | ||
if (span) { | ||
span.finish(); | ||
this.finishRootSpan(now); | ||
} else { | ||
vm.$once(`hook:${hook}`, () => { | ||
if (this.rootSpan) { | ||
spans[op] = this.rootSpan.child({ | ||
description: `Vue <${name}>`, | ||
op, | ||
}); | ||
} | ||
}); | ||
} | ||
}; | ||
|
||
// Each compomnent has it's own scope, so all activities are only related to one of them | ||
this._options.tracingOptions.hooks.forEach(hook => { | ||
const handler = rootMount ? rootHandler.bind(this, hook) : childHandler.bind(this, hook); | ||
const currentValue = vm.$options[hook]; | ||
|
||
if (Array.isArray(currentValue)) { | ||
vm.$options[hook] = [handler, ...currentValue]; | ||
} else if (typeof currentValue === 'function') { | ||
vm.$options[hook] = [handler, currentValue]; | ||
} else { | ||
vm.$options[hook] = [handler]; | ||
} | ||
}); | ||
} | ||
|
||
private finishRootSpan(timestamp: number): void { | ||
if (this.rootSpanTimer) { | ||
clearTimeout(this.rootSpanTimer); | ||
} | ||
const name = vm._isVue ? vm.$options.name || vm.$options._componentTag : vm.name; | ||
return ( | ||
(name ? `component <${name}>` : 'anonymous component') + | ||
(vm._isVue && vm.$options.__file ? ` at ${vm.$options.__file}` : '') | ||
); | ||
|
||
this.rootSpanTimer = setTimeout(() => { | ||
if (this.rootSpan) { | ||
this.rootSpan.timestamp = timestamp; | ||
} | ||
if (this.tracingActivity) { | ||
APMIntegrations.Tracing.popActivity(this.tracingActivity); | ||
} | ||
}, this._options.tracingOptions.timeout); | ||
} | ||
|
||
/** | ||
* @inheritDoc | ||
*/ | ||
public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { | ||
// tslint:disable:no-unsafe-any | ||
private startTracing(getCurrentHub: () => Hub): void { | ||
const applyTracingHooks = this.applyTracingHooks.bind(this); | ||
|
||
this._options.Vue.mixin({ | ||
beforeCreate() { | ||
// TODO: Move this check to `setupOnce` when we rework integrations initialization in v6 | ||
if (getCurrentHub().getIntegration(APMIntegrations.Tracing)) { | ||
// `this` points to currently rendered component | ||
applyTracingHooks(this, getCurrentHub); | ||
} else { | ||
logger.error('Vue integration has tracing enabled, but Tracing integration is not configured'); | ||
} | ||
}, | ||
}); | ||
} | ||
|
||
if (!this._Vue || !this._Vue.config) { | ||
logger.error('VueIntegration is missing a Vue instance'); | ||
return; | ||
private attachErrorHandler(getCurrentHub: () => Hub): void { | ||
if (!this._options.Vue.config) { | ||
return logger.error('Vue instance is missing required `config` attribute'); | ||
} | ||
|
||
const oldOnError = this._Vue.config.errorHandler; | ||
const currentErrorHandler = this._options.Vue.config.errorHandler; | ||
|
||
this._Vue.config.errorHandler = (error: Error, vm: { [key: string]: any }, info: string): void => { | ||
this._options.Vue.config.errorHandler = (error: Error, vm: ViewModel, info: string): void => { | ||
const metadata: Metadata = {}; | ||
|
||
if (isPlainObject(vm)) { | ||
metadata.componentName = this._formatComponentName(vm); | ||
if (vm) { | ||
try { | ||
metadata.componentName = this.getComponentName(vm); | ||
|
||
if (this._attachProps) { | ||
metadata.propsData = vm.$options.propsData; | ||
if (this._options.attachProps) { | ||
metadata.propsData = vm.$options.propsData; | ||
} | ||
} catch (_oO) { | ||
logger.warn('Unable to extract metadata from Vue component.'); | ||
} | ||
} | ||
|
||
if (info !== void 0) { | ||
if (info) { | ||
metadata.lifecycleHook = info; | ||
} | ||
|
||
if (getCurrentHub().getIntegration(Vue)) { | ||
// This timeout makes sure that any breadcrumbs are recorded before sending it off the sentry | ||
// Capture exception in the next event loop, to make sure that all breadcrumbs are recorded in time. | ||
setTimeout(() => { | ||
getCurrentHub().withScope(scope => { | ||
scope.setContext('vue', metadata); | ||
|
@@ -106,15 +301,29 @@ export class Vue implements Integration { | |
}); | ||
} | ||
|
||
if (typeof oldOnError === 'function') { | ||
oldOnError.call(this._Vue, error, vm, info); | ||
if (typeof currentErrorHandler === 'function') { | ||
currentErrorHandler.call(this._options.Vue, error, vm, info); | ||
} | ||
|
||
if (this._logErrors) { | ||
this._Vue.util.warn(`Error in ${info}: "${error.toString()}"`, vm); | ||
// tslint:disable-next-line:no-console | ||
console.error(error); | ||
if (this._options.logErrors) { | ||
this._options.Vue.util.warn(`Error in ${info}: "${error.toString()}"`, vm); | ||
console.error(error); // tslint:disable-line:no-console | ||
} | ||
}; | ||
} | ||
|
||
/** | ||
* @inheritDoc | ||
*/ | ||
public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { | ||
if (!this._options.Vue) { | ||
return logger.error('Vue integration is missing a Vue instance'); | ||
} | ||
|
||
this.attachErrorHandler(getCurrentHub); | ||
|
||
if (this._options.tracing) { | ||
this.startTracing(getCurrentHub); | ||
} | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.