1
1
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
+ }
3
40
4
41
/** JSDoc */
5
42
interface Metadata {
6
43
[ key : string ] : any ;
7
44
componentName ?: string ;
8
- propsData ?: {
9
- [ key : string ] : any ;
10
- } ;
45
+ propsData ?: { [ key : string ] : any } ;
11
46
lifecycleHook ?: string ;
12
47
}
13
48
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
+
14
81
/** JSDoc */
15
82
export class Vue implements Integration {
16
83
/**
17
84
* @inheritDoc
18
85
*/
19
86
public name : string = Vue . id ;
87
+
20
88
/**
21
89
* @inheritDoc
22
90
*/
23
91
public static id : string = 'Vue' ;
24
92
25
- /**
26
- * @inheritDoc
27
- */
28
- private readonly _Vue : any ; // tslint:disable-line:variable-name
93
+ private _options : IntegrationOptions ;
29
94
30
95
/**
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
33
97
*/
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 ;
41
102
42
103
/**
43
104
* @inheritDoc
44
105
*/
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
+ }
48
131
49
- if ( options . logErrors !== undefined ) {
50
- this . _logErrors = options . logErrors ;
132
+ if ( ! vm . $options ) {
133
+ return ANONYMOUS_COMPONENT_NAME ;
51
134
}
52
- if ( options . attachProps === false ) {
53
- this . _attachProps = false ;
135
+
136
+ if ( vm . $options . name ) {
137
+ return vm . $options . name ;
54
138
}
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 - z A - 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 ;
55
155
}
56
156
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 ;
60
161
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 ) ;
63
240
}
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 ) ;
69
250
}
70
251
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
+ }
76
267
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' ) ;
80
271
}
81
272
82
- const oldOnError = this . _Vue . config . errorHandler ;
273
+ const currentErrorHandler = this . _options . Vue . config . errorHandler ;
83
274
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 => {
85
276
const metadata : Metadata = { } ;
86
277
87
- if ( isPlainObject ( vm ) ) {
88
- metadata . componentName = this . _formatComponentName ( vm ) ;
278
+ if ( vm ) {
279
+ try {
280
+ metadata . componentName = this . getComponentName ( vm ) ;
89
281
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.' ) ;
92
287
}
93
288
}
94
289
95
- if ( info !== void 0 ) {
290
+ if ( info ) {
96
291
metadata . lifecycleHook = info ;
97
292
}
98
293
99
294
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.
101
296
setTimeout ( ( ) => {
102
297
getCurrentHub ( ) . withScope ( scope => {
103
298
scope . setContext ( 'vue' , metadata ) ;
@@ -106,15 +301,29 @@ export class Vue implements Integration {
106
301
} ) ;
107
302
}
108
303
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 ) ;
111
306
}
112
307
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
117
311
}
118
312
} ;
119
313
}
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
+ }
120
329
}
0 commit comments