1
1
/* eslint-disable max-lines */
2
2
/* eslint-disable @typescript-eslint/no-explicit-any */
3
- import { BrowserClient , BrowserOptions , defaultIntegrations } from '@sentry/browser' ;
3
+ import { BrowserClient , BrowserOptions , defaultIntegrations , getCurrentHub } from '@sentry/browser' ;
4
4
import { initAndBind } from '@sentry/core' ;
5
- import { Hub , Integration , IntegrationClass , Scope , Span , Transaction } from '@sentry/types' ;
6
- import { getGlobalObject } from '@sentry/utils' ;
5
+ import { Hub , Scope , Span , Transaction } from '@sentry/types' ;
6
+ import { basename , getGlobalObject , logger , timestampWithMs } from '@sentry/utils' ;
7
7
8
8
export interface VueOptions extends BrowserOptions {
9
+ /** Vue instance to be used inside the integration */
10
+ Vue : VueInstance ;
11
+
9
12
/**
10
13
* When set to `false`, Sentry will suppress reporting of all props data
11
14
* from your Vue components for privacy concerns.
@@ -32,14 +35,10 @@ export interface VueOptions extends BrowserOptions {
32
35
* Based on https://vuejs.org/v2/api/#Options-Lifecycle-Hooks
33
36
*/
34
37
hooks ?: Operation [ ] ;
35
- }
36
38
37
- /**
38
- * Used to extract BrowserTracing integration from @sentry/tracing
39
- */
40
- const BROWSER_TRACING_GETTER = ( {
41
- id : 'BrowserTracing' ,
42
- } as any ) as IntegrationClass < Integration > ;
39
+ /** {@link TracingOptions } */
40
+ tracingOptions : TracingOptions ;
41
+ }
43
42
44
43
/** Global Vue object limited to the methods/attributes we require */
45
44
interface VueInstance {
@@ -104,10 +103,30 @@ const COMPONENT_NAME_REGEXP = /(?:^|[-_/])(\w)/g;
104
103
const ROOT_COMPONENT_NAME = 'root' ;
105
104
const ANONYMOUS_COMPONENT_NAME = 'anonymous component' ;
106
105
106
+ /** Vue specific configuration for Tracing Integration */
107
+ interface TracingOptions {
108
+ /**
109
+ * Decides whether to track components by hooking into its lifecycle methods.
110
+ * Can be either set to `boolean` to enable/disable tracking for all of them.
111
+ * Or to an array of specific component names (case-sensitive).
112
+ */
113
+ trackComponents : boolean | string [ ] ;
114
+ /** How long to wait until the tracked root activity is marked as finished and sent of to Sentry */
115
+ timeout : number ;
116
+ /**
117
+ * List of hooks to keep track of during component lifecycle.
118
+ * Available hooks: 'activate' | 'create' | 'destroy' | 'mount' | 'update'
119
+ * Based on https://vuejs.org/v2/api/#Options-Lifecycle-Hooks
120
+ */
121
+ hooks : Operation [ ] ;
122
+ }
123
+
107
124
/**
108
125
* Inits the Vue SDK
109
126
*/
110
- export function init ( options : VueOptions = { } ) : void {
127
+ export function init (
128
+ options : Partial < Omit < VueOptions , 'tracingOptions' > & { tracingOptions : Partial < TracingOptions > } > = { } ,
129
+ ) : void {
111
130
if ( options . defaultIntegrations === undefined ) {
112
131
options . defaultIntegrations = defaultIntegrations ;
113
132
}
@@ -118,5 +137,277 @@ export function init(options: VueOptions = {}): void {
118
137
options . release = window . SENTRY_RELEASE . id ;
119
138
}
120
139
}
121
- initAndBind ( BrowserClient , options ) ;
140
+
141
+ const finalOptions = {
142
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
143
+ Vue : getGlobalObject < any > ( ) . Vue as VueInstance ,
144
+ attachProps : true ,
145
+ logErrors : false ,
146
+ tracing : false ,
147
+ ...options ,
148
+ tracingOptions : {
149
+ hooks : [ 'mount' , 'update' ] ,
150
+ timeout : 2000 ,
151
+ trackComponents : false ,
152
+ ...options . tracingOptions ,
153
+ } ,
154
+ } as VueOptions ;
155
+
156
+ initAndBind ( BrowserClient , finalOptions ) ;
157
+ const client = getCurrentHub ( ) . getClient ( ) ;
158
+ if ( client ) {
159
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
160
+ // @ts -ignore
161
+ client . __vueHelper = new VueHelper ( finalOptions ) ;
162
+ }
163
+ }
164
+
165
+ /** JSDoc */
166
+ export class VueHelper {
167
+ /**
168
+ * Cache holding already processed component names
169
+ */
170
+ private readonly _componentsCache : { [ key : string ] : string } = { } ;
171
+ private _rootSpan ?: Span ;
172
+ private _rootSpanTimer ?: ReturnType < typeof setTimeout > ;
173
+ private _options : VueOptions ;
174
+
175
+ /**
176
+ * @inheritDoc
177
+ */
178
+ public constructor ( options : VueOptions ) {
179
+ this . _options = options ;
180
+
181
+ this . _attachErrorHandler ( ) ;
182
+
183
+ // TODO: Use other check to determine if tracing is enabled
184
+ if ( this . _options . tracesSampleRate ) {
185
+ this . _startTracing ( ) ;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Extract component name from the ViewModel
191
+ */
192
+ private _getComponentName ( vm : ViewModel ) : string {
193
+ // Such level of granularity is most likely not necessary, but better safe than sorry. — Kamil
194
+ if ( ! vm ) {
195
+ return ANONYMOUS_COMPONENT_NAME ;
196
+ }
197
+
198
+ if ( vm . $root === vm ) {
199
+ return ROOT_COMPONENT_NAME ;
200
+ }
201
+
202
+ if ( ! vm . $options ) {
203
+ return ANONYMOUS_COMPONENT_NAME ;
204
+ }
205
+
206
+ if ( vm . $options . name ) {
207
+ return vm . $options . name ;
208
+ }
209
+
210
+ if ( vm . $options . _componentTag ) {
211
+ return vm . $options . _componentTag ;
212
+ }
213
+
214
+ // injected by vue-loader
215
+ if ( vm . $options . __file ) {
216
+ const unifiedFile = vm . $options . __file . replace ( / ^ [ a - z A - Z ] : / , '' ) . replace ( / \\ / g, '/' ) ;
217
+ const filename = basename ( unifiedFile , '.vue' ) ;
218
+ return (
219
+ this . _componentsCache [ filename ] ||
220
+ ( this . _componentsCache [ filename ] = filename . replace ( COMPONENT_NAME_REGEXP , ( _ , c : string ) =>
221
+ c ? c . toUpperCase ( ) : '' ,
222
+ ) )
223
+ ) ;
224
+ }
225
+
226
+ return ANONYMOUS_COMPONENT_NAME ;
227
+ }
228
+
229
+ /** Keep it as attribute function, to keep correct `this` binding inside the hooks callbacks */
230
+ // eslint-disable-next-line @typescript-eslint/typedef
231
+ private readonly _applyTracingHooks = ( vm : ViewModel ) : void => {
232
+ // Don't attach twice, just in case
233
+ if ( vm . $options . $_sentryPerfHook ) {
234
+ return ;
235
+ }
236
+ vm . $options . $_sentryPerfHook = true ;
237
+
238
+ const name = this . _getComponentName ( vm ) ;
239
+ const rootMount = name === ROOT_COMPONENT_NAME ;
240
+ const spans : { [ key : string ] : Span } = { } ;
241
+
242
+ // Render hook starts after once event is emitted,
243
+ // but it ends before the second event of the same type.
244
+ //
245
+ // Because of this, we start measuring inside the first event,
246
+ // but finish it before it triggers, to skip the event emitter timing itself.
247
+ const rootHandler = ( hook : Hook ) : void => {
248
+ const now = timestampWithMs ( ) ;
249
+
250
+ // On the first handler call (before), it'll be undefined, as `$once` will add it in the future.
251
+ // However, on the second call (after), it'll be already in place.
252
+ if ( this . _rootSpan ) {
253
+ this . _finishRootSpan ( now ) ;
254
+ } else {
255
+ vm . $once ( `hook:${ hook } ` , ( ) => {
256
+ // Create an activity on the first event call. There'll be no second call, as rootSpan will be in place,
257
+ // thus new event handler won't be attached.
258
+ const activeTransaction = getActiveTransaction ( getCurrentHub ( ) ) ;
259
+ if ( activeTransaction ) {
260
+ this . _rootSpan = activeTransaction . startChild ( {
261
+ description : 'Application Render' ,
262
+ op : 'Vue' ,
263
+ } ) ;
264
+ }
265
+ } ) ;
266
+ }
267
+ } ;
268
+
269
+ const childHandler = ( hook : Hook , operation : Operation ) : void => {
270
+ // Skip components that we don't want to track to minimize the noise and give a more granular control to the user
271
+ const shouldTrack = Array . isArray ( this . _options . tracingOptions . trackComponents )
272
+ ? this . _options . tracingOptions . trackComponents . indexOf ( name ) > - 1
273
+ : this . _options . tracingOptions . trackComponents ;
274
+
275
+ if ( ! this . _rootSpan || ! shouldTrack ) {
276
+ return ;
277
+ }
278
+
279
+ const now = timestampWithMs ( ) ;
280
+ const span = spans [ operation ] ;
281
+
282
+ // On the first handler call (before), it'll be undefined, as `$once` will add it in the future.
283
+ // However, on the second call (after), it'll be already in place.
284
+ if ( span ) {
285
+ span . finish ( ) ;
286
+ this . _finishRootSpan ( now ) ;
287
+ } else {
288
+ vm . $once ( `hook:${ hook } ` , ( ) => {
289
+ if ( this . _rootSpan ) {
290
+ spans [ operation ] = this . _rootSpan . startChild ( {
291
+ description : `Vue <${ name } >` ,
292
+ op : operation ,
293
+ } ) ;
294
+ }
295
+ } ) ;
296
+ }
297
+ } ;
298
+
299
+ // Each component has it's own scope, so all activities are only related to one of them
300
+ this . _options . tracingOptions . hooks . forEach ( operation => {
301
+ // Retrieve corresponding hooks from Vue lifecycle.
302
+ // eg. mount => ['beforeMount', 'mounted']
303
+ const internalHooks = HOOKS [ operation ] ;
304
+
305
+ if ( ! internalHooks ) {
306
+ logger . warn ( `Unknown hook: ${ operation } ` ) ;
307
+ return ;
308
+ }
309
+
310
+ internalHooks . forEach ( internalHook => {
311
+ const handler = rootMount
312
+ ? rootHandler . bind ( this , internalHook )
313
+ : childHandler . bind ( this , internalHook , operation ) ;
314
+ const currentValue = vm . $options [ internalHook ] ;
315
+
316
+ if ( Array . isArray ( currentValue ) ) {
317
+ vm . $options [ internalHook ] = [ handler , ...currentValue ] ;
318
+ } else if ( typeof currentValue === 'function' ) {
319
+ vm . $options [ internalHook ] = [ handler , currentValue ] ;
320
+ } else {
321
+ vm . $options [ internalHook ] = [ handler ] ;
322
+ }
323
+ } ) ;
324
+ } ) ;
325
+ } ;
326
+
327
+ /** Finish top-level span and activity with a debounce configured using `timeout` option */
328
+ private _finishRootSpan ( timestamp : number ) : void {
329
+ if ( this . _rootSpanTimer ) {
330
+ clearTimeout ( this . _rootSpanTimer ) ;
331
+ }
332
+
333
+ this . _rootSpanTimer = setTimeout ( ( ) => {
334
+ // We should always finish the span, only should pop activity if using @sentry/apm
335
+ if ( this . _rootSpan ) {
336
+ this . _rootSpan . finish ( timestamp ) ;
337
+ }
338
+ } , this . _options . tracingOptions . timeout ) ;
339
+ }
340
+
341
+ /** Inject configured tracing hooks into Vue's component lifecycles */
342
+ private _startTracing ( ) : void {
343
+ const applyTracingHooks = this . _applyTracingHooks ;
344
+
345
+ this . _options . Vue . mixin ( {
346
+ beforeCreate ( this : ViewModel ) : void {
347
+ applyTracingHooks ( this ) ;
348
+ } ,
349
+ } ) ;
350
+ }
351
+
352
+ /** Inject Sentry's handler into owns Vue's error handler */
353
+ private _attachErrorHandler ( ) : void {
354
+ // eslint-disable-next-line @typescript-eslint/unbound-method
355
+ const currentErrorHandler = this . _options . Vue . config . errorHandler ;
356
+
357
+ this . _options . Vue . config . errorHandler = ( error : Error , vm ?: ViewModel , info ?: string ) : void => {
358
+ const metadata : Metadata = { } ;
359
+
360
+ if ( vm ) {
361
+ try {
362
+ metadata . componentName = this . _getComponentName ( vm ) ;
363
+
364
+ if ( this . _options . attachProps ) {
365
+ metadata . propsData = vm . $options . propsData ;
366
+ }
367
+ } catch ( _oO ) {
368
+ logger . warn ( 'Unable to extract metadata from Vue component.' ) ;
369
+ }
370
+ }
371
+
372
+ if ( info ) {
373
+ metadata . lifecycleHook = info ;
374
+ }
375
+
376
+ // Capture exception in the next event loop, to make sure that all breadcrumbs are recorded in time.
377
+ setTimeout ( ( ) => {
378
+ getCurrentHub ( ) . withScope ( scope => {
379
+ scope . setContext ( 'vue' , metadata ) ;
380
+ getCurrentHub ( ) . captureException ( error ) ;
381
+ } ) ;
382
+ } ) ;
383
+
384
+ if ( typeof currentErrorHandler === 'function' ) {
385
+ currentErrorHandler . call ( this . _options . Vue , error , vm , info ) ;
386
+ }
387
+
388
+ if ( this . _options . logErrors ) {
389
+ if ( this . _options . Vue . util ) {
390
+ this . _options . Vue . util . warn ( `Error in ${ info } : "${ error . toString ( ) } "` , vm ) ;
391
+ }
392
+ // eslint-disable-next-line no-console
393
+ console . error ( error ) ;
394
+ }
395
+ } ;
396
+ }
397
+ }
398
+
399
+ interface HubType extends Hub {
400
+ getScope ?( ) : Scope | undefined ;
401
+ }
402
+
403
+ /** Grabs active transaction off scope */
404
+ export function getActiveTransaction < T extends Transaction > ( hub : HubType ) : T | undefined {
405
+ if ( hub && hub . getScope ) {
406
+ const scope = hub . getScope ( ) as Scope ;
407
+ if ( scope ) {
408
+ return scope . getTransaction ( ) as T | undefined ;
409
+ }
410
+ }
411
+
412
+ return undefined ;
122
413
}
0 commit comments