/* * 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; /** * @class mw.mmv.Performance * Measures the network performance * See https://meta.wikimedia.org/wiki/Schema:MultimediaViewerNetworkPerformance * @constructor */ function Performance() {} P = Performance.prototype; 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 ) );