/*
* This file is part of the MediaWiki extension MultimediaViewer.
*
* MultimediaViewer is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* MultimediaViewer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with MultimediaViewer. If not, see .
*/
( function ( mw, $ ) {
var P;
/**
* Measures the network performance
* See
* @class mw.mmv.Performance
* @constructor
*/
function Performance() {}
P = Performance.prototype;
/**
* How long to wait to ensure window.performance is populated
* @property {number}
*/
P.delay = 1000;
/**
* Global setup that should be done while the page loads
*/
P.init = function () {
var performance = this.getWindowPerformance();
// by default logging is cut off after 150 resources, which is not enough in debug mode
// only supported by IE
if ( mw.config.get( 'debug' ) && performance.setResourceTimingBufferSize ) {
performance.setResourceTimingBufferSize( 500 );
}
};
/**
* Gather network performance for a given URL
* Will only run on a sample of users/requests. Avoid using this on URLs that aren't
* cached by the browser, as it will consume unnecessary bandwidth for the user.
* @param {string} type the type of request to be measured
* @param {string} url URL to be measured
* @param {string} responseType responseType for the XHR options
* @returns {jQuery.Promise} A promise that resolves when the contents of the URL have been fetched
*/
P.record = function ( type, url, responseType ) {
var deferred = $.Deferred(),
request,
perf = this,
start;
request = this.newXHR();
request.onreadystatechange = function () {
var total = $.now() - start;
if ( request.readyState === 4 ) {
deferred.resolve( request.response );
perf.recordEntryDelayed( type, total, url, request );
}
};
start = $.now();
try {
request.open( 'GET', url, true );
if ( responseType !== undefined ) {
request.responseType = responseType;
}
request.send();
} catch ( e ) {
// This happens on old browsers that don't support CORS
return deferred.reject();
}
return deferred;
};
/**
* Records network performance results for a given url
* Will record if enough data is present and it's not a local cache hit
* @param {string} type the type of request to be measured
* @param {number} total the total load time tracked with a basic technique
* @param {string} url URL of that was measured
* @param {XMLHttpRequest} request HTTP request that just completed
*/
P.recordEntry = function ( type, total, url, request ) {
var matches,
stats = { type: type,
contentHost: window.location.host,
userAgent: navigator.userAgent,
isHttps: window.location.protocol === 'https:',
total: total },
connection = this.getNavigatorConnection();
// If eventLog isn't present there is nowhere to record to
if ( !mw.eventLog ) {
return;
}
if ( !this.performanceChecked ) {
this.performanceChecked = {};
}
// Don't record if we're not in the sample
if ( !this.isInSample() ) {
return;
}
// Don't record entries that hit the browser cache on undetailed requests
if ( total !== undefined && total < 1 ) {
return;
}
if ( url && url.length ) {
// There is no need to measure the same url more than once
if ( url in this.performanceChecked ) {
return;
}
this.performanceChecked[ url ] = true;
matches = url.match(/^https?\:\/\/([^\/?#]+)(?:[\/?#]|$)/i);
stats.isHttps = url.indexOf( 'https' ) === 0;
}
if ( !matches || matches.length !== 2 ) {
stats.urlHost = stats.contentHost;
} else {
stats.urlHost = matches[ 1 ];
}
this.populateStatsFromXhr( stats, request );
this.populateStatsFromPerformance( stats, url );
// Don't record entries that hit the browser cache
if ( stats.request === 0 ) {
return;
}
// Add connection information if there's any
if ( connection ) {
if ( connection.bandwidth ) {
if ( connection.bandwidth === Infinity ) {
stats.bandwidth = -1;
} else {
stats.bandwidth = Math.round( connection.bandwidth );
}
}
if ( connection.metered ) {
stats.metered = connection.metered;
}
}
// Add Geo information if there's any
if ( $.isPlainObject( window.Geo ) && typeof window.Geo.country === 'string' ) {
stats.country = window.Geo.country;
}
mw.eventLog.logEvent( 'MultimediaViewerNetworkPerformance', stats );
};
/**
* Processes an XMLHttpRequest (or jqXHR) object
* @param {Object} stats stats object to extend with additional statistics fields
* @param {XMLHttpRequest} request
*/
P.populateStatsFromXhr = function ( stats, request ) {
var age,
contentLength,
xcache,
xvarnish,
varnishXCache;
if ( !request ) {
return;
}
stats.status = request.status;
// Chrome disallows header access for CORS image requests, even if the responose has the
// proper header :-/
contentLength = request.getResponseHeader( 'Content-Length' );
if ( contentLength === null ) {
return;
}
xcache = request.getResponseHeader( 'X-Cache' );
if ( xcache ) {
stats.XCache = xcache;
varnishXCache = this.parseVarnishXCacheHeader( xcache );
$.each( varnishXCache, function( key, value ) {
stats[ key ] = value;
} );
}
xvarnish = request.getResponseHeader( 'X-Varnish' );
if ( xvarnish ) {
stats.XVarnish = xvarnish;
}
stats.contentLength = parseInt( contentLength, 10 );
age = parseInt( request.getResponseHeader( 'Age' ), 10 );
if ( !isNaN( age ) ) {
stats.age = age;
}
stats.timestamp = new Date( request.getResponseHeader( 'Date' ) ).getTime() / 1000;
};
/**
* Populates statistics based on the Request Timing API
* @param {Object} stats
* @param {string} url
*/
P.populateStatsFromPerformance = function( stats, url ) {
var performance = this.getWindowPerformance(),
timingEntries, timingEntry;
// If we're given an xhr and we have access to the Navigation Timing API, use it
if ( performance && performance.getEntriesByName ) {
// This could be tricky as we need to match encoding (the Request Timing API uses
// percent-encoded UTF-8). The main use case we are interested in is thumbnails and
// jQuery AJAX. jQuery uses encodeURIComponent to construct URL parameters, and
// thumbnail URLs come from MediaWiki API which also encodes them, so both should be
// all right.
timingEntries = performance.getEntriesByName( url );
if ( timingEntries.length ) {
// Let's hope it's the first request for the given URL we are interested in.
// This could fail in exotic cases (e.g. we send an AJAX request for a thumbnail,
// but it exists on the page as a normal thumbnail with the exact same size),
// but it's unlikely.
timingEntry = timingEntries[0];
stats.total = Math.round( timingEntry.duration );
stats.redirect = Math.round( timingEntry.redirectEnd - timingEntry.redirectStart );
stats.dns = Math.round( timingEntry.domainLookupEnd - timingEntry.domainLookupStart );
stats.tcp = Math.round( timingEntry.connectEnd - timingEntry.connectStart );
stats.request = Math.round( timingEntry.responseStart - timingEntry.requestStart );
stats.response = Math.round( timingEntry.responseEnd - timingEntry.responseStart );
stats.cache = Math.round( timingEntry.domainLookupStart - timingEntry.fetchStart );
} else if ( performance.getEntriesByType( 'resource' ).length === 150 ) {
// browser stops logging after 150 entries
mw.log( 'performance buffer full, results are probably incorrect' );
}
}
};
/**
* Like recordEntry, but takes a jqXHR argument instead of a normal XHR one.
* Due to the way some parameters are retrieved, this will work best if the context option
* for the ajax request was not used.
* @param {string} type the type of request to be measured
* @param {number} total the total load time tracked with a basic technique
* @param {jqXHR} jqxhr
*/
P.recordJQueryEntry = function ( type, total, jqxhr ) {
var perf = this;
// We take advantage of the fact that the context of the jqXHR deferred is the AJAX
// settings object. The deferred has already resolved so chaining to it does not influence
// the timing.
jqxhr.done( function () {
var url;
if ( !this.url ) {
mw.log.warn( 'Cannot find URL - did you use context option?' );
} else {
url = this.url;
// The performance API returns absolute URLs, but the one in the settings object is
// usually relative.
if ( !url.match( /^(\w+:)?\/\// ) ) {
url = location.protocol + '//' + location.host + url;
}
}
if ( this.crossDomain && this.dataType === 'jsonp' ) {
// Cross-domain jQuery requests return a fake jqXHR object which is useless and
// would only cause logging errors.
jqxhr = undefined;
}
// jQuery does not expose the original XHR object, but the jqXHR wrapper is similar
// enogh that we will probably get away by passing it instead.
perf.recordEntry( type, total, url, jqxhr );
} );
};
/**
* Records network performance results for a given url
* Will record if enough data is present and it's not a local cache hit
* Will run after a delay to make sure the window.performance entry is present
* @param {string} type the type of request to be measured
* @param {number} total the total load time tracked with a basic technique
* @param {string} url URL of that was measured
* @param {XMLHttpRequest} request HTTP request that just completed
*/
P.recordEntryDelayed = function ( type, total, url, request ) {
var perf = this;
// The timeout is necessary because if there's an entry in window.performance,
// it hasn't been added yet at this point
setTimeout( function() {
perf.recordEntry( type, total, url, request );
}, this.delay );
};
/**
* Like recordEntryDelayed, but for jQuery AJAX requests.
* @param {string} type the type of request to be measured
* @param {number} total the total load time tracked with a basic technique
* @param {jqXHR} jqxhr
*/
P.recordJQueryEntryDelayed = function ( type, total, jqxhr ) {
var perf = this;
// The timeout is necessary because if there's an entry in window.performance,
// it hasn't been added yet at this point
setTimeout( function() {
perf.recordJQueryEntry( type, total, jqxhr );
}, 0 );
};
/**
* Parses an X-Cache header from Varnish and extracts varnish information
* @param {string} header The X-Cache header from the request
* @returns {Object} The parsed X-Cache data
*/
P.parseVarnishXCacheHeader = function ( header ) {
var parts,
part,
subparts,
i,
results = {},
matches;
if ( !header || !header.length ) {
return results;
}
parts = header.split( ',' );
for ( i = 0; i < parts.length; i++ ) {
part = parts[ i ];
subparts = part.trim().split( ' ' );
// If the subparts aren't space-separated, it's an unknown format, skip
if ( subparts.length < 2 ) {
continue;
}
matches = part.match( /\(([0-9]+)\)/ );
// If there is no number between parenthesis for a given server
// it's an unknown format, skip
if ( !matches || matches.length !== 2 ) {
continue;
}
results[ 'varnish' + ( i + 1 ) ] = subparts[ 0 ];
results[ 'varnish' + ( i + 1 ) + 'hits' ] = parseInt( matches[ 1 ], 10 );
}
return results;
};
/**
* Returns the window's Performance object
* Allows us to override for unit tests
* @returns {Object} The window's Performance object
*/
P.getWindowPerformance = function () {
return window.performance;
};
/**
* Returns whether or not we should measure this request
* @returns {boolean} True if this request needs to be sampled
*/
P.isInSample = function () {
var factor = mw.config.get( 'wgNetworkPerformanceSamplingFactor' );
if ( !$.isNumeric( factor ) || factor < 1 ) {
return false;
}
return Math.floor( Math.random() * factor ) === 0;
};
/**
* Returns the navigator's Connection object
* Allows us to override for unit tests
* @returns {Object} The navigator's Connection object
*/
P.getNavigatorConnection = function () {
return navigator.connection || navigator.mozConnection || navigator.webkitConnection;
};
/**
* Returns a new XMLHttpRequest object
* Allows us to override for unit tests
* @returns {XMLHttpRequest} New XMLHttpRequest
*/
P.newXHR = function () {
return new XMLHttpRequest();
};
new Performance().init();
mw.mmv.Performance = Performance;
}( mediaWiki, jQuery ) );