Skip to content

Commit c5ec5db

Browse files
committed
feat: Vue performance monitoring
1 parent 03d9ef0 commit c5ec5db

File tree

1 file changed

+265
-56
lines changed
  • packages/integrations/src

1 file changed

+265
-56
lines changed

packages/integrations/src/vue.ts

Lines changed: 265 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,103 +1,298 @@
11
import { EventProcessor, Hub, Integration } from '@sentry/types';
2-
import { getGlobalObject, isPlainObject, logger } from '@sentry/utils';
2+
import { basename, getGlobalObject, logger, timestampWithMs } from '@sentry/utils';
3+
import { Integrations as APMIntegrations, Span as SpanClass } from '@sentry/apm';
4+
5+
interface IntegrationOptions {
6+
Vue: any;
7+
/**
8+
* When set to false, Sentry will suppress reporting of all props data
9+
* from your Vue components for privacy concerns.
10+
*/
11+
attachProps: boolean;
12+
/**
13+
* When set to true, original Vue's `logError` will be called as well.
14+
* https://github.com/vuejs/vue/blob/c2b1cfe9ccd08835f2d99f6ce60f67b4de55187f/src/core/util/error.js#L38-L48
15+
*/
16+
logErrors: boolean;
17+
tracing: boolean;
18+
tracingOptions: TracingOptions;
19+
}
20+
21+
interface TracingOptions {
22+
track: boolean | Array<string>;
23+
timeout: number;
24+
hooks: Array<Hook>;
25+
}
26+
27+
interface ViewModel {
28+
[key: string]: any;
29+
$root: object;
30+
$options: {
31+
[key: string]: any;
32+
name?: string;
33+
propsData?: { [key: string]: any };
34+
_componentTag?: string;
35+
__file?: string;
36+
$_sentryPerfHook?: boolean;
37+
};
38+
$once: (hook: string, cb: () => void) => void;
39+
}
340

441
/** JSDoc */
542
interface Metadata {
643
[key: string]: any;
744
componentName?: string;
8-
propsData?: {
9-
[key: string]: any;
10-
};
45+
propsData?: { [key: string]: any };
1146
lifecycleHook?: string;
1247
}
1348

49+
// https://vuejs.org/v2/api/#Options-Lifecycle-Hooks
50+
type Hook =
51+
| 'beforeCreate'
52+
| 'created'
53+
| 'beforeMount'
54+
| 'mounted'
55+
| 'beforeUpdate'
56+
| 'updated'
57+
| 'activated'
58+
| 'deactivated'
59+
| 'beforeDestroy'
60+
| 'destroyed';
61+
62+
// Mappings from lifecycle hook to corresponding operation,
63+
// used to track already started measurements.
64+
const OPERATIONS = {
65+
beforeCreate: 'create',
66+
created: 'create',
67+
beforeMount: 'mount',
68+
mounted: 'mount',
69+
beforeUpdate: 'update',
70+
updated: 'update',
71+
activated: 'activate',
72+
deactivated: 'activate',
73+
beforeDestroy: 'destroy',
74+
destroyed: 'destroy',
75+
};
76+
77+
const COMPONENT_NAME_REGEXP = /(?:^|[-_/])(\w)/g;
78+
const ROOT_COMPONENT_NAME = 'root';
79+
const ANONYMOUS_COMPONENT_NAME = 'anonymous component';
80+
1481
/** JSDoc */
1582
export class Vue implements Integration {
1683
/**
1784
* @inheritDoc
1885
*/
1986
public name: string = Vue.id;
87+
2088
/**
2189
* @inheritDoc
2290
*/
2391
public static id: string = 'Vue';
2492

25-
/**
26-
* @inheritDoc
27-
*/
28-
private readonly _Vue: any; // tslint:disable-line:variable-name
93+
private _options: IntegrationOptions;
2994

3095
/**
31-
* When set to false, Sentry will suppress reporting all props data
32-
* from your Vue components for privacy concerns.
96+
* Cache holding already processed component names
3397
*/
34-
private readonly _attachProps: boolean = true;
35-
36-
/**
37-
* When set to true, original Vue's `logError` will be called as well.
38-
* https://github.com/vuejs/vue/blob/c2b1cfe9ccd08835f2d99f6ce60f67b4de55187f/src/core/util/error.js#L38-L48
39-
*/
40-
private readonly _logErrors: boolean = false;
98+
private componentsCache = Object.create(null);
99+
private rootSpan?: SpanClass;
100+
private rootSpanTimer?: ReturnType<typeof setTimeout>;
101+
private tracingActivity?: number;
41102

42103
/**
43104
* @inheritDoc
44105
*/
45-
public constructor(options: { Vue?: any; attachProps?: boolean; logErrors?: boolean } = {}) {
46-
// tslint:disable-next-line: no-unsafe-any
47-
this._Vue = options.Vue || getGlobalObject<any>().Vue;
106+
public constructor(options: Partial<IntegrationOptions>) {
107+
this._options = {
108+
Vue: getGlobalObject<any>().Vue,
109+
attachProps: true,
110+
logErrors: false,
111+
tracing: false,
112+
...options,
113+
tracingOptions: {
114+
track: false,
115+
hooks: ['beforeMount', 'mounted', 'beforeUpdate', 'updated'],
116+
timeout: 2000,
117+
...options.tracingOptions,
118+
},
119+
};
120+
}
121+
122+
private getComponentName(vm: ViewModel): string {
123+
// Such level of granularity is most likely not necessary, but better safe than sorry. — Kamil
124+
if (!vm) {
125+
return ANONYMOUS_COMPONENT_NAME;
126+
}
127+
128+
if (vm.$root === vm) {
129+
return ROOT_COMPONENT_NAME;
130+
}
48131

49-
if (options.logErrors !== undefined) {
50-
this._logErrors = options.logErrors;
132+
if (!vm.$options) {
133+
return ANONYMOUS_COMPONENT_NAME;
51134
}
52-
if (options.attachProps === false) {
53-
this._attachProps = false;
135+
136+
if (vm.$options.name) {
137+
return vm.$options.name;
54138
}
139+
140+
if (vm.$options._componentTag) {
141+
return vm.$options._componentTag;
142+
}
143+
144+
// injected by vue-loader
145+
if (vm.$options.__file) {
146+
const unifiedFile = vm.$options.__file.replace(/^[a-zA-Z]:/, '').replace(/\\/g, '/');
147+
const filename = basename(unifiedFile, '.vue');
148+
return (
149+
this.componentsCache[filename] ||
150+
(this.componentsCache[filename] = filename.replace(COMPONENT_NAME_REGEXP, (_, c) => (c ? c.toUpperCase() : '')))
151+
);
152+
}
153+
154+
return ANONYMOUS_COMPONENT_NAME;
55155
}
56156

57-
/** JSDoc */
58-
private _formatComponentName(vm: any): string {
59-
// tslint:disable:no-unsafe-any
157+
private applyTracingHooks(vm: ViewModel, getCurrentHub: () => Hub): void {
158+
// Don't attach twice, just in case
159+
if (vm.$options.$_sentryPerfHook) return;
160+
vm.$options.$_sentryPerfHook = true;
60161

61-
if (vm.$root === vm) {
62-
return 'root instance';
162+
const name = this.getComponentName(vm);
163+
const rootMount = name === ROOT_COMPONENT_NAME;
164+
const spans: { [key: string]: any } = {};
165+
166+
// Render hook starts after once event is emitted,
167+
// but it ends before the second event of the same type.
168+
//
169+
// Because of this, we start measuring inside the first event,
170+
// but finish it before it triggers, to skip the event emitter timing itself.
171+
const rootHandler = (hook: Hook) => {
172+
const now = timestampWithMs();
173+
174+
// On the first handler call (before), it'll be undefined, as `$once` will add it in the future.
175+
// However, on the second call (after), it'll be already in place.
176+
if (this.rootSpan) {
177+
this.finishRootSpan(now);
178+
} else {
179+
vm.$once(`hook:${hook}`, () => {
180+
// Create an activity on the first event call. There'll be no second call, as rootSpan will be in place,
181+
// thus new event handler won't be attached.
182+
this.tracingActivity = APMIntegrations.Tracing.pushActivity('Vue Application Render');
183+
this.rootSpan = getCurrentHub().startSpan({
184+
description: 'Application Render',
185+
op: 'Vue',
186+
}) as SpanClass;
187+
});
188+
}
189+
};
190+
191+
const childHandler = (hook: Hook) => {
192+
// Skip components that we don't want to track to minimize the noise and give a more granular control to the user
193+
const shouldTrack = Array.isArray(this._options.tracingOptions.track)
194+
? this._options.tracingOptions.track.includes(name)
195+
: this._options.tracingOptions.track;
196+
197+
if (!this.rootSpan || !shouldTrack) {
198+
return;
199+
}
200+
201+
const now = timestampWithMs();
202+
const op = OPERATIONS[hook];
203+
const span = spans[op];
204+
205+
// On the first handler call (before), it'll be undefined, as `$once` will add it in the future.
206+
// However, on the second call (after), it'll be already in place.
207+
if (span) {
208+
span.finish();
209+
this.finishRootSpan(now);
210+
} else {
211+
vm.$once(`hook:${hook}`, () => {
212+
if (this.rootSpan) {
213+
spans[op] = this.rootSpan.child({
214+
description: `Vue <${name}>`,
215+
op,
216+
});
217+
}
218+
});
219+
}
220+
};
221+
222+
// Each compomnent has it's own scope, so all activities are only related to one of them
223+
this._options.tracingOptions.hooks.forEach(hook => {
224+
const handler = rootMount ? rootHandler.bind(this, hook) : childHandler.bind(this, hook);
225+
const currentValue = vm.$options[hook];
226+
227+
if (Array.isArray(currentValue)) {
228+
vm.$options[hook] = [handler, ...currentValue];
229+
} else if (typeof currentValue === 'function') {
230+
vm.$options[hook] = [handler, currentValue];
231+
} else {
232+
vm.$options[hook] = [handler];
233+
}
234+
});
235+
}
236+
237+
private finishRootSpan(timestamp: number): void {
238+
if (this.rootSpanTimer) {
239+
clearTimeout(this.rootSpanTimer);
63240
}
64-
const name = vm._isVue ? vm.$options.name || vm.$options._componentTag : vm.name;
65-
return (
66-
(name ? `component <${name}>` : 'anonymous component') +
67-
(vm._isVue && vm.$options.__file ? ` at ${vm.$options.__file}` : '')
68-
);
241+
242+
this.rootSpanTimer = setTimeout(() => {
243+
if (this.rootSpan) {
244+
this.rootSpan.timestamp = timestamp;
245+
}
246+
if (this.tracingActivity) {
247+
APMIntegrations.Tracing.popActivity(this.tracingActivity);
248+
}
249+
}, this._options.tracingOptions.timeout);
69250
}
70251

71-
/**
72-
* @inheritDoc
73-
*/
74-
public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
75-
// tslint:disable:no-unsafe-any
252+
private startTracing(getCurrentHub: () => Hub): void {
253+
const applyTracingHooks = this.applyTracingHooks.bind(this);
254+
255+
this._options.Vue.mixin({
256+
beforeCreate() {
257+
// TODO: Move this check to `setupOnce` when we rework integrations initialization in v6
258+
if (getCurrentHub().getIntegration(APMIntegrations.Tracing)) {
259+
// `this` points to currently rendered component
260+
applyTracingHooks(this, getCurrentHub);
261+
} else {
262+
logger.error('Vue integration has tracing enabled, but Tracing integration is not configured');
263+
}
264+
},
265+
});
266+
}
76267

77-
if (!this._Vue || !this._Vue.config) {
78-
logger.error('VueIntegration is missing a Vue instance');
79-
return;
268+
private attachErrorHandler(getCurrentHub: () => Hub): void {
269+
if (!this._options.Vue.config) {
270+
return logger.error('Vue instance is missing required `config` attribute');
80271
}
81272

82-
const oldOnError = this._Vue.config.errorHandler;
273+
const currentErrorHandler = this._options.Vue.config.errorHandler;
83274

84-
this._Vue.config.errorHandler = (error: Error, vm: { [key: string]: any }, info: string): void => {
275+
this._options.Vue.config.errorHandler = (error: Error, vm: ViewModel, info: string): void => {
85276
const metadata: Metadata = {};
86277

87-
if (isPlainObject(vm)) {
88-
metadata.componentName = this._formatComponentName(vm);
278+
if (vm) {
279+
try {
280+
metadata.componentName = this.getComponentName(vm);
89281

90-
if (this._attachProps) {
91-
metadata.propsData = vm.$options.propsData;
282+
if (this._options.attachProps) {
283+
metadata.propsData = vm.$options.propsData;
284+
}
285+
} catch (_oO) {
286+
logger.warn('Unable to extract metadata from Vue component.');
92287
}
93288
}
94289

95-
if (info !== void 0) {
290+
if (info) {
96291
metadata.lifecycleHook = info;
97292
}
98293

99294
if (getCurrentHub().getIntegration(Vue)) {
100-
// This timeout makes sure that any breadcrumbs are recorded before sending it off the sentry
295+
// Capture exception in the next event loop, to make sure that all breadcrumbs are recorded in time.
101296
setTimeout(() => {
102297
getCurrentHub().withScope(scope => {
103298
scope.setContext('vue', metadata);
@@ -106,15 +301,29 @@ export class Vue implements Integration {
106301
});
107302
}
108303

109-
if (typeof oldOnError === 'function') {
110-
oldOnError.call(this._Vue, error, vm, info);
304+
if (typeof currentErrorHandler === 'function') {
305+
currentErrorHandler.call(this._options.Vue, error, vm, info);
111306
}
112307

113-
if (this._logErrors) {
114-
this._Vue.util.warn(`Error in ${info}: "${error.toString()}"`, vm);
115-
// tslint:disable-next-line:no-console
116-
console.error(error);
308+
if (this._options.logErrors) {
309+
this._options.Vue.util.warn(`Error in ${info}: "${error.toString()}"`, vm);
310+
console.error(error); // tslint:disable-line:no-console
117311
}
118312
};
119313
}
314+
315+
/**
316+
* @inheritDoc
317+
*/
318+
public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
319+
if (!this._options.Vue) {
320+
return logger.error('Vue integration is missing a Vue instance');
321+
}
322+
323+
this.attachErrorHandler(getCurrentHub);
324+
325+
if (this._options.tracing) {
326+
this.startTracing(getCurrentHub);
327+
}
328+
}
120329
}

0 commit comments

Comments
 (0)