Language: s/error_log/wfWarn/
[lhc/web/wiklou.git] / resources / src / mediawiki / mediawiki.debug.profile.js
1 /*!
2 * JavaScript for the debug toolbar profiler, enabled through $wgDebugToolbar
3 * and StartProfiler.php.
4 *
5 * @author Erik Bernhardson
6 * @since 1.23
7 */
8
9 ( function ( mw, $ ) {
10 'use strict';
11
12 /**
13 * @singleton
14 * @class mw.Debug.profile
15 */
16 var profile = mw.Debug.profile = {
17 /**
18 * Object containing data for the debug toolbar
19 *
20 * @property ProfileData
21 */
22 data: null,
23
24 /**
25 * @property DOMElement
26 */
27 container: null,
28
29 /**
30 * Initializes the profiling pane.
31 */
32 init: function ( data, width, mergeThresholdPx, dropThresholdPx ) {
33 data = data || mw.config.get( 'debugInfo' ).profile;
34 profile.width = width || $(window).width() - 20;
35 // merge events from same pixel(some events are very granular)
36 mergeThresholdPx = mergeThresholdPx || 2;
37 // only drop events if requested
38 dropThresholdPx = dropThresholdPx || 0;
39
40 if ( !Array.prototype.map || !Array.prototype.reduce || !Array.prototype.filter ) {
41 profile.container = profile.buildRequiresES5();
42 } else if ( data.length === 0 ) {
43 profile.container = profile.buildNoData();
44 } else {
45 // generate a flyout
46 profile.data = new ProfileData( data, profile.width, mergeThresholdPx, dropThresholdPx );
47 // draw it
48 profile.container = profile.buildSvg( profile.container );
49 profile.attachFlyout();
50 }
51
52 return profile.container;
53 },
54
55 buildRequiresES5: function () {
56 return $( '<div>' )
57 .text( 'An ES5 compatible javascript engine is required for the profile visualization.' )
58 .get( 0 );
59 },
60
61 buildNoData: function () {
62 return $( '<div>' ).addClass( 'mw-debug-profile-no-data' )
63 .text( 'No events recorded, ensure profiling is enabled in StartProfiler.php.' )
64 .get( 0 );
65 },
66
67 /**
68 * Creates DOM nodes appropriately namespaced for SVG.
69 *
70 * @param string tag to create
71 * @return DOMElement
72 */
73 createSvgElement: document.createElementNS
74 ? document.createElementNS.bind( document, 'http://www.w3.org/2000/svg' )
75 // throw a error for browsers which does not support document.createElementNS (IE<8)
76 : function () { throw new Error( 'document.createElementNS not supported' ); },
77
78 /**
79 * @param DOMElement|undefined
80 */
81 buildSvg: function ( node ) {
82 var container, group, i, g,
83 timespan = profile.data.timespan,
84 gapPerEvent = 38,
85 space = 10.5,
86 currentHeight = space,
87 totalHeight = 0;
88
89 profile.ratio = ( profile.width - space * 2 ) / ( timespan.end - timespan.start );
90 totalHeight += gapPerEvent * profile.data.groups.length;
91
92 if ( node ) {
93 $( node ).empty();
94 } else {
95 node = profile.createSvgElement( 'svg' );
96 node.setAttribute( 'version', '1.2' );
97 node.setAttribute( 'baseProfile', 'tiny' );
98 }
99 node.style.height = totalHeight;
100 node.style.width = profile.width;
101
102 // use a container that can be transformed
103 container = profile.createSvgElement( 'g' );
104 node.appendChild( container );
105
106 for ( i = 0; i < profile.data.groups.length; i++ ) {
107 group = profile.data.groups[i];
108 g = profile.buildTimeline( group );
109
110 g.setAttribute( 'transform', 'translate( 0 ' + currentHeight + ' )' );
111 container.appendChild( g );
112
113 currentHeight += gapPerEvent;
114 }
115
116 return node;
117 },
118
119 /**
120 * @param Object group of periods to transform into graphics
121 */
122 buildTimeline: function ( group ) {
123 var text, tspan, line, i,
124 sum = group.timespan.sum,
125 ms = ' ~ ' + ( sum < 1 ? sum.toFixed( 2 ) : sum.toFixed( 0 ) ) + ' ms',
126 timeline = profile.createSvgElement( 'g' );
127
128 timeline.setAttribute( 'class', 'mw-debug-profile-timeline' );
129
130 // draw label
131 text = profile.createSvgElement( 'text' );
132 text.setAttribute( 'x', profile.xCoord( group.timespan.start ) );
133 text.setAttribute( 'y', 0 );
134 text.textContent = group.name;
135 timeline.appendChild( text );
136
137 // draw metadata
138 tspan = profile.createSvgElement( 'tspan' );
139 tspan.textContent = ms;
140 text.appendChild( tspan );
141
142 // draw timeline periods
143 for ( i = 0; i < group.periods.length; i++ ) {
144 timeline.appendChild( profile.buildPeriod( group.periods[i] ) );
145 }
146
147 // full-width line under each timeline
148 line = profile.createSvgElement( 'line' );
149 line.setAttribute( 'class', 'mw-debug-profile-underline' );
150 line.setAttribute( 'x1', 0 );
151 line.setAttribute( 'y1', 28 );
152 line.setAttribute( 'x2', profile.width );
153 line.setAttribute( 'y2', 28 );
154 timeline.appendChild( line );
155
156 return timeline;
157 },
158
159 /**
160 * @param Object period to transform into graphics
161 */
162 buildPeriod: function ( period ) {
163 var node,
164 head = profile.xCoord( period.start ),
165 tail = profile.xCoord( period.end ),
166 g = profile.createSvgElement( 'g' );
167
168 g.setAttribute( 'class', 'mw-debug-profile-period' );
169 $( g ).data( 'period', period );
170
171 if ( head + 16 > tail ) {
172 node = profile.createSvgElement( 'rect' );
173 node.setAttribute( 'x', head );
174 node.setAttribute( 'y', 8 );
175 node.setAttribute( 'width', 2 );
176 node.setAttribute( 'height', 9 );
177 g.appendChild( node );
178
179 node = profile.createSvgElement( 'rect' );
180 node.setAttribute( 'x', head );
181 node.setAttribute( 'y', 8 );
182 node.setAttribute( 'width', ( period.end - period.start ) * profile.ratio || 2 );
183 node.setAttribute( 'height', 6 );
184 g.appendChild( node );
185 } else {
186 node = profile.createSvgElement( 'polygon' );
187 node.setAttribute( 'points', pointList( [
188 [ head, 8 ],
189 [ head, 19 ],
190 [ head + 8, 8 ],
191 [ head, 8]
192 ] ) );
193 g.appendChild( node );
194
195 node = profile.createSvgElement( 'polygon' );
196 node.setAttribute( 'points', pointList( [
197 [ tail, 8 ],
198 [ tail, 19 ],
199 [ tail - 8, 8 ],
200 [ tail, 8 ]
201 ] ) );
202 g.appendChild( node );
203
204 node = profile.createSvgElement( 'line' );
205 node.setAttribute( 'x1', head );
206 node.setAttribute( 'y1', 9 );
207 node.setAttribute( 'x2', tail );
208 node.setAttribute( 'y2', 9 );
209 g.appendChild( node );
210 }
211
212 return g;
213 },
214
215 /**
216 * @param Object
217 */
218 buildFlyout: function ( period ) {
219 var contained, sum, ms, mem, i,
220 node = $( '<div>' );
221
222 for ( i = 0; i < period.contained.length; i++ ) {
223 contained = period.contained[i];
224 sum = contained.end - contained.start;
225 ms = '' + ( sum < 1 ? sum.toFixed( 2 ) : sum.toFixed( 0 ) ) + ' ms';
226 mem = formatBytes( contained.memory );
227
228 $( '<div>' ).text( contained.source.name )
229 .append( $( '<span>' ).text( ' ~ ' + ms + ' / ' + mem ).addClass( 'mw-debug-profile-meta' ) )
230 .appendTo( node );
231 }
232
233 return node;
234 },
235
236 /**
237 * Attach a hover flyout to all .mw-debug-profile-period groups.
238 */
239 attachFlyout: function () {
240 // for some reason addClass and removeClass from jQuery
241 // arn't working on svg elements in chrome <= 33.0 (possibly more)
242 var $container = $( profile.container ),
243 addClass = function ( node, value ) {
244 var current = node.getAttribute( 'class' ),
245 list = current ? current.split( ' ' ) : false,
246 idx = list ? list.indexOf( value ) : -1;
247
248 if ( idx === -1 ) {
249 node.setAttribute( 'class', current ? ( current + ' ' + value ) : value );
250 }
251 },
252 removeClass = function ( node, value ) {
253 var current = node.getAttribute( 'class' ),
254 list = current ? current.split( ' ' ) : false,
255 idx = list ? list.indexOf( value ) : -1;
256
257 if ( idx !== -1 ) {
258 list.splice( idx, 1 );
259 node.setAttribute( 'class', list.join( ' ' ) );
260 }
261 },
262 // hide all tipsy flyouts
263 hide = function () {
264 $container.find( '.mw-debug-profile-period.tipsy-visible' )
265 .each( function () {
266 removeClass( this, 'tipsy-visible' );
267 $( this ).tipsy( 'hide' );
268 } );
269 };
270
271 $container.find( '.mw-debug-profile-period' ).tipsy( {
272 fade: true,
273 gravity: function () {
274 return $.fn.tipsy.autoNS.call( this ) + $.fn.tipsy.autoWE.call( this );
275 },
276 className: 'mw-debug-profile-tipsy',
277 center: false,
278 html: true,
279 trigger: 'manual',
280 title: function () {
281 return profile.buildFlyout( $( this ).data( 'period' ) ).html();
282 }
283 } ).on( 'mouseenter', function () {
284 hide();
285 addClass( this, 'tipsy-visible' );
286 $( this ).tipsy( 'show' );
287 } );
288
289 $container.on( 'mouseleave', function ( event ) {
290 var $from = $( event.relatedTarget ),
291 $to = $( event.target );
292 // only close the tipsy if we are not
293 if ( $from.closest( '.tipsy' ).length === 0 &&
294 $to.closest( '.tipsy' ).length === 0 &&
295 $to.get( 0 ).namespaceURI !== 'http://www.w4.org/2000/svg'
296 ) {
297 hide();
298 }
299 } ).on( 'click', function () {
300 // convenience method for closing
301 hide();
302 } );
303 },
304
305 /**
306 * @return number the x co-ordinate for the specified timestamp
307 */
308 xCoord: function ( msTimestamp ) {
309 return ( msTimestamp - profile.data.timespan.start ) * profile.ratio;
310 }
311 };
312
313 function ProfileData( data, width, mergeThresholdPx, dropThresholdPx ) {
314 // validate input data
315 this.data = data.map( function ( event ) {
316 event.periods = event.periods.filter( function ( period ) {
317 return period.start && period.end
318 && period.start < period.end
319 // period start must be a reasonable ms timestamp
320 && period.start > 1000000;
321 } );
322 return event;
323 } ).filter( function ( event ) {
324 return event.name && event.periods.length > 0;
325 } );
326
327 // start and end time of the data
328 this.timespan = this.data.reduce( function ( result, event ) {
329 return event.periods.reduce( periodMinMax, result );
330 }, periodMinMax.initial() );
331
332 // transform input data
333 this.groups = this.collate( width, mergeThresholdPx, dropThresholdPx );
334
335 return this;
336 }
337
338 /**
339 * There are too many unique events to display a line for each,
340 * so this does a basic grouping.
341 */
342 ProfileData.groupOf = function ( label ) {
343 var pos, prefix = 'Profile section ended by close(): ';
344 if ( label.indexOf( prefix ) === 0 ) {
345 label = label.substring( prefix.length );
346 }
347
348 pos = [ '::', ':', '-' ].reduce( function ( result, separator ) {
349 var pos = label.indexOf( separator );
350 if ( pos === -1 ) {
351 return result;
352 } else if ( result === -1 ) {
353 return pos;
354 } else {
355 return Math.min( result, pos );
356 }
357 }, -1 );
358
359 if ( pos === -1 ) {
360 return label;
361 } else {
362 return label.substring( 0, pos );
363 }
364 };
365
366 /**
367 * @return Array list of objects with `name` and `events` keys
368 */
369 ProfileData.groupEvents = function ( events ) {
370 var group, i,
371 groups = {};
372
373 // Group events together
374 for ( i = events.length - 1; i >= 0; i-- ) {
375 group = ProfileData.groupOf( events[i].name );
376 if ( groups[group] ) {
377 groups[group].push( events[i] );
378 } else {
379 groups[group] = [events[i]];
380 }
381 }
382
383 // Return an array of groups
384 return Object.keys( groups ).map( function ( group ) {
385 return {
386 name: group,
387 events: groups[group]
388 };
389 } );
390 };
391
392 ProfileData.periodSorter = function ( a, b ) {
393 if ( a.start === b.start ) {
394 return a.end - b.end;
395 }
396 return a.start - b.start;
397 };
398
399 ProfileData.genMergePeriodReducer = function ( mergeThresholdMs ) {
400 return function ( result, period ) {
401 if ( result.length === 0 ) {
402 // period is first result
403 return [{
404 start: period.start,
405 end: period.end,
406 contained: [period]
407 }];
408 }
409 var last = result[result.length - 1];
410 if ( period.end < last.end ) {
411 // end is contained within previous
412 result[result.length - 1].contained.push( period );
413 } else if ( period.start - mergeThresholdMs < last.end ) {
414 // neighbors within merging distance
415 result[result.length - 1].end = period.end;
416 result[result.length - 1].contained.push( period );
417 } else {
418 // period is next result
419 result.push( {
420 start: period.start,
421 end: period.end,
422 contained: [period]
423 } );
424 }
425 return result;
426 };
427 };
428
429 /**
430 * Collect all periods from the grouped events and apply merge and
431 * drop transformations
432 */
433 ProfileData.extractPeriods = function ( events, mergeThresholdMs, dropThresholdMs ) {
434 // collect the periods from all events
435 return events.reduce( function ( result, event ) {
436 if ( !event.periods.length ) {
437 return result;
438 }
439 result.push.apply( result, event.periods.map( function ( period ) {
440 // maintain link from period to event
441 period.source = event;
442 return period;
443 } ) );
444 return result;
445 }, [] )
446 // sort combined periods
447 .sort( ProfileData.periodSorter )
448 // Apply merge threshold. Original periods
449 // are maintained in the `contained` property
450 .reduce( ProfileData.genMergePeriodReducer( mergeThresholdMs ), [] )
451 // Apply drop threshold
452 .filter( function ( period ) {
453 return period.end - period.start > dropThresholdMs;
454 } );
455 };
456
457 /**
458 * runs a callback on all periods in the group. Only valid after
459 * groups.periods[0..n].contained are populated. This runs against
460 * un-transformed data and is better suited to summing or other
461 * stat collection
462 */
463 ProfileData.reducePeriods = function ( group, callback, result ) {
464 return group.periods.reduce( function ( result, period ) {
465 return period.contained.reduce( callback, result );
466 }, result );
467 };
468
469 /**
470 * Transforms this.data grouping by labels, merging neighboring
471 * events in the groups, and drops events and groups below the
472 * display threshold. Groups are returned sorted by starting time.
473 */
474 ProfileData.prototype.collate = function ( width, mergeThresholdPx, dropThresholdPx ) {
475 // ms to pixel ratio
476 var ratio = ( this.timespan.end - this.timespan.start ) / width,
477 // transform thresholds to ms
478 mergeThresholdMs = mergeThresholdPx * ratio,
479 dropThresholdMs = dropThresholdPx * ratio;
480
481 return ProfileData.groupEvents( this.data )
482 // generate data about the grouped events
483 .map( function ( group ) {
484 // Cleaned periods from all events
485 group.periods = ProfileData.extractPeriods( group.events, mergeThresholdMs, dropThresholdMs );
486 // min and max timestamp per group
487 group.timespan = ProfileData.reducePeriods( group, periodMinMax, periodMinMax.initial() );
488 // ms from first call to end of last call
489 group.timespan.length = group.timespan.end - group.timespan.start;
490 // collect the un-transformed periods
491 group.timespan.sum = ProfileData.reducePeriods( group, function ( result, period ) {
492 result.push( period );
493 return result;
494 }, [] )
495 // sort by start time
496 .sort( ProfileData.periodSorter )
497 // merge overlapping
498 .reduce( ProfileData.genMergePeriodReducer( 0 ), [] )
499 // sum
500 .reduce( function ( result, period ) {
501 return result + period.end - period.start;
502 }, 0 );
503
504 return group;
505 }, this )
506 // remove groups that have had all their periods filtered
507 .filter( function ( group ) {
508 return group.periods.length > 0;
509 } )
510 // sort events by first start
511 .sort( function ( a, b ) {
512 return ProfileData.periodSorter( a.timespan, b.timespan );
513 } );
514 };
515
516 // reducer to find edges of period array
517 function periodMinMax( result, period ) {
518 if ( period.start < result.start ) {
519 result.start = period.start;
520 }
521 if ( period.end > result.end ) {
522 result.end = period.end;
523 }
524 return result;
525 }
526
527 periodMinMax.initial = function () {
528 return { start: Number.POSITIVE_INFINITY, end: Number.NEGATIVE_INFINITY };
529 };
530
531 function formatBytes( bytes ) {
532 var i, sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
533 if ( bytes === 0 ) {
534 return '0 Bytes';
535 }
536 i = parseInt( Math.floor( Math.log( bytes ) / Math.log( 1024 ) ), 10 );
537 return Math.round( bytes / Math.pow( 1024, i ), 2 ) + ' ' + sizes[i];
538 }
539
540 // turns a 2d array into a point list for svg
541 // polygon points attribute
542 // ex: [[1,2],[3,4],[4,2]] = '1,2 3,4 4,2'
543 function pointList( pairs ) {
544 return pairs.map( function ( pair ) {
545 return pair.join( ',' );
546 } ).join( ' ' );
547 }
548 }( mediaWiki, jQuery ) );