/*
* 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 () {
var PL;
/**
* Measures the network performance
* See
*
* @class mw.mmv.logging.PerformanceLogger
* @extends mw.mmv.logging.Logger
* @constructor
*/
function PerformanceLogger() {}
OO.inheritClass( PerformanceLogger, mw.mmv.logging.Logger );
PL = PerformanceLogger.prototype;
/**
* @override
* @inheritdoc
*/
PL.samplingFactor = mw.config.get( 'wgMultimediaViewer' ).networkPerformanceSamplingFactor;
/**
* @override
* @inheritdoc
*/
PL.schema = 'MultimediaViewerNetworkPerformance';
/**
* Global setup that should be done while the page loads
*/
PL.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 && 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 {jQuery.Deferred.} [extraStatsDeferred] A promise which resolves to the extra stats.
* @return {jQuery.Promise} A promise that resolves when the contents of the URL have been fetched
*/
PL.record = function ( type, url, extraStatsDeferred ) {
var deferred = $.Deferred(),
request,
perf = this,
start;
try {
request = this.newXHR();
request.onprogress = function ( e ) {
var percent;
if ( e.lengthComputable ) {
percent = ( e.loaded / e.total ) * 100;
}
deferred.notify( request.response, percent );
};
request.onreadystatechange = function () {
var total = $.now() - start;
if ( request.readyState === 4 ) {
deferred.notify( request.response, 100 );
deferred.resolve( request.response );
perf.recordEntryDelayed( type, total, url, request, extraStatsDeferred );
}
};
start = $.now();
request.open( 'GET', url, true );
request.send();
} catch ( e ) {
// old browser not supporting XMLHttpRequest or CORS, or CORS is not permitted
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
* @param {jQuery.Deferred.} [extraStatsDeferred] A promise which resolves to extra stats to be included.
* @return {jQuery.Promise}
*/
PL.recordEntry = function ( type, total, url, request, extraStatsDeferred ) {
var matches,
logger = this,
stats = { type: type,
contentHost: window.location.host,
isHttps: window.location.protocol === 'https:',
total: total },
connection = this.getNavigatorConnection();
if ( !this.performanceChecked ) {
this.performanceChecked = {};
}
if ( url && url.length ) {
// There is no need to measure the same url more than once
if ( url in this.performanceChecked ) {
return $.Deferred().reject();
}
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 );
// 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;
}
}
return ( extraStatsDeferred || $.Deferred().reject() ).done( function ( extraStats ) {
stats = $.extend( stats, extraStats );
} ).always( function () {
logger.log( stats );
} );
};
/**
* Processes an XMLHttpRequest (or jqXHR) object
*
* @param {Object} stats stats object to extend with additional statistics fields
* @param {XMLHttpRequest} request
*/
PL.populateStatsFromXhr = function ( stats, request ) {
var age,
contentLength,
xcache,
xvarnish,
varnishXCache,
lastModified;
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 );
// eslint-disable-next-line jquery/no-each-util
$.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;
lastModified = request.getResponseHeader( 'Last-Modified' );
if ( lastModified ) {
stats.lastModified = new Date( lastModified ).getTime() / 1000;
}
};
/**
* Populates statistics based on the Request Timing API
*
* @param {Object} stats
* @param {string} url
*/
PL.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 && this.isEnabled() ) {
// 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
*/
PL.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
* @param {jQuery.Promise.} extraStatsDeferred A promise which resolves to extra stats.
*/
PL.recordEntryDelayed = function ( type, total, url, request, extraStatsDeferred ) {
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, extraStatsDeferred );
}, 0 );
};
/**
* 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
*/
PL.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
* @return {Object} The parsed X-Cache data
*/
PL.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
*
* @return {Object} The window's Performance object
*/
PL.getWindowPerformance = function () {
return window.performance;
};
/**
* Returns the navigator's Connection object
* Allows us to override for unit tests
*
* @return {Object} The navigator's Connection object
*/
PL.getNavigatorConnection = function () {
return navigator.connection || navigator.mozConnection || navigator.webkitConnection;
};
/**
* Returns a new XMLHttpRequest object
* Allows us to override for unit tests
*
* @return {XMLHttpRequest} New XMLHttpRequest
*/
PL.newXHR = function () {
return new XMLHttpRequest();
};
/**
* @override
* @inheritdoc
*/
PL.log = function ( data ) {
var trackedWidths = mw.mmv.ThumbnailWidthCalculator.prototype.defaultOptions.widthBuckets.slice( 0 );
trackedWidths.push( 600 ); // Most common non-bucket size
// Track thumbnail load time with statsv, sampled
if ( this.isInSample() &&
data.type === 'image' &&
data.imageWidth > 0 &&
data.total > 20 &&
trackedWidths.indexOf( data.imageWidth ) !== -1
) {
mw.track( 'timing.media.thumbnail.client.' + data.imageWidth, data.total );
}
if ( this.isEnabled() ) {
mw.log( 'mw.mmv.logging.PerformanceLogger', data );
}
return mw.mmv.logging.Logger.prototype.log.call( this, data );
};
new PerformanceLogger().init();
mw.mmv.logging.PerformanceLogger = PerformanceLogger;
}() );