blob: bf0bde6c4a38453f88801f3f303eccbf43198e80 [file] [log] [blame]
/*
* Copyright 2013 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @fileoverview Code for running PSOL Console.
*
* Fetches JSON statistics data from server to draw graphs over time of
* various "notable issues".
*
* Note that for unit testing purposes, the initialization code here is not
* actually run. admin_site.cc injects the call to
* google.setOnLoadCallback(pagespeed.startConsole); to actually run the code
* here.
*
* PRECONDITIONS: pagespeedStatisticsUrl must be set in JavaScript and
* <script src='https://www.google.com/jsapi'></script> must be loaded in HTML.
*
* @author sligocki@google.com (Shawn Ligocki)
* @author sarahdw@google.com (Sarah Dapul-Weberman)
* @author bvb@google.com (Ben VanBerkum)
*/
'use strict';
goog.provide('pagespeed');
goog.provide('pagespeed.Console');
goog.provide('pagespeed.statistics');
goog.require('goog.structs.Set');
// Google Charts API.
// Requires <script src='https://www.google.com/jsapi'></script> loaded in HTML.
google.load('visualization', '1.0', {'packages': ['corechart']});
/**
* Report error.
* @param {string} error_message
*/
pagespeed.error = function(error_message) {
if (window.console) {
console.error(error_message);
}
// TODO(sligocki): Show error message in DOM somewhere as well.
};
/**
* @constructor
*/
pagespeed.Console = function() {
/**
* Specifications for graphs. Add a new graph with addGraph().
* @type {Array.<Object>}
* @private
*/
this.graphs_ = [];
/**
* Names of variables needed for loading all graphs. Set by addGraph(),
* used in request to pagespeed server.
* @type {goog.structs.Set.<string>}
* @private
*/
this.varsNeeded_ = new goog.structs.Set();
/**
* Mapping of variable names -> array of values over time.
* Each value is a snapshot of that variable at the timestamp in the
* parallel data structure this.timestamps_.
* @type {Object.<string, Array.<number> >}
* @private
*/
this.variables_ = null;
/**
* Array of timestamps, one for every set of variables.
* @type {Array.<number>}
* @private
*/
this.timestamps_ = null;
/**
* The options used for drawing google.visualization.LineChart graphs.
* @type {Object}
* @private
* @const
*/
this.lineChartOptions_ = {
'width': 900,
'height': 255,
'colors': ['#4ECDC4', '#556270', '#C7F464'],
'legend': {
'position': 'bottom'
},
'hAxis': {
// This looks awkward when all timestamps are in the last day.
// TODO(sligocki): Use a different format which looks better for range.
'format': 'MMM d, y hh:mma',
'gridlines': {
'color': '#F2F2F2'
},
'baselineColor': '#E5E5E5'
},
'vAxis': {
'format': '#.###%',
'minValue': 0,
// TODO(sligocki): Should we lock all graphs to be 0-100%? Currently
// the max value auto-scales leading to graphs with max values around
// 0.05%, etc. These don't really seem notable, it seems like it would
// be better not to draw too much attention to these.
//'maxValue': 1, // 100%
'viewWindowMode': 'explicit',
'viewWindow': {
'min': 0
},
'gridlines': {
'color': '#F2F2F2'
},
'baselineColor': '#E5E5E5'
},
'chartArea': {
'left': 60,
'top': 20,
'width': 800
},
'pointSize': 2
};
};
/**
* Namespace for statistics calculators.
*/
pagespeed.statistics = {};
/**
* Trivial statistic that just has the value of one variable.
* For example: Cache hits.
*
* TODO(sligocki): Type check the return value against a predefined type,
* pagespeed.Statistic, so that we can guarantee that the returned object
* has the appropriate members and type (s/Object/pagespeed.Statistic/).
*
* @param {string} name Variable name to attach to.
* @return {Object} Just this variable.
*/
pagespeed.statistics.variable = function(name) {
var stat = {};
stat.varsNeeded = new goog.structs.Set([name]);
/**
* @param {function(string): number} variableGetter
* @return {number}
*/
stat.evaluate = function(variableGetter) {
return variableGetter(name);
};
return stat;
};
/**
* Statistic which has the value of a sum of sub-statistics.
* For example: Total cache requests = cache hits + cache misses.
*
* @param {Array.<Object>} statArray Statistics to add up.
* @return {Object} Statistic representing summed result.
*/
pagespeed.statistics.sum = function(statArray) {
var stat = {};
stat.varsNeeded = new goog.structs.Set();
for (var i = 0; i < statArray.length; i++) {
stat.varsNeeded.addAll(statArray[i].varsNeeded);
}
/**
* @param {function(string): number} variableGetter
* @return {number}
*/
stat.evaluate = function(variableGetter) {
var value = 0;
for (var i = 0; i < statArray.length; i++) {
value += statArray[i].evaluate(variableGetter);
}
return value;
};
return stat;
};
/**
* Statistic which has the value of a percent of sub-statistics.
* For example: Cache hit percent = (cache hits) / (total cache requests).
*
* @param {Object} numStat Numerator statistic.
* @param {Object} denomStat Denominator statistic.
* @return {Object} Statistic representing percent result.
*/
pagespeed.statistics.percent = function(numStat, denomStat) {
var stat = {};
stat.varsNeeded = new goog.structs.Set();
stat.varsNeeded.addAll(numStat.varsNeeded);
stat.varsNeeded.addAll(denomStat.varsNeeded);
/**
* @param {function(string): number} variableGetter
* @return {number}
*/
stat.evaluate = function(variableGetter) {
var denom = denomStat.evaluate(variableGetter);
if (denom == 0) {
return 0.0;
} else {
return numStat.evaluate(variableGetter) / denom;
}
};
return stat;
};
/**
* Statistic for common pattern: bad / (bad + good)
* For example: Cache miss % = cache misses / (cache misses + cache hits)
*
* @param {Object} badStat Numerator statistic.
* @param {Object} goodStat Added to badStat to get denominator.
* @return {Object} Statistic representing percent result.
*/
pagespeed.statistics.percent_total = function(badStat, goodStat) {
return pagespeed.statistics.percent(
badStat,
pagespeed.statistics.sum([badStat, goodStat]));
};
/**
* Initialize and start the console for "notable issues" version, which
* displays a fixed set of graphs ordered by level of importance.
*
* @return {pagespeed.Console} The initialized console object.
* @export
*/
pagespeed.startConsole = function() {
var mpsConsole = new pagespeed.Console();
mpsConsole.initGraphs();
mpsConsole.startConsole();
return mpsConsole;
};
/**
* Initialize pre-determined set of "notable issues" graphs.
*/
pagespeed.Console.prototype.initGraphs = function() {
var v = pagespeed.statistics.variable;
var sum = pagespeed.statistics.sum;
var percent = pagespeed.statistics.percent;
var percent_total = pagespeed.statistics.percent_total;
this.addGraph('Resources not loaded because of fetch failures',
'fetch-failure',
percent(v('serf_fetch_failure_count'),
v('serf_fetch_request_count')));
this.addGraph("Resources not rewritten because domain wasn't authorized",
'not-authorized',
percent_total(v('resource_url_domain_rejections'),
v('resource_url_domain_acceptances')));
this.addGraph('Resources not rewritten because of restrictive ' +
'Cache-Control headers',
'cache-control',
percent_total(v('num_cache_control_not_rewritable_resources'),
v('num_cache_control_rewritable_resources')));
var totalCacheCalls = sum([v('cache_backend_misses'),
v('cache_backend_hits')]);
this.addGraph('Cache misses',
'cache-miss',
percent(v('cache_backend_misses'), totalCacheCalls));
this.addGraph('Cache lookups that were expired',
'cache-expired',
percent(v('cache_expirations'), totalCacheCalls));
this.addGraph('CSS files not rewritten because of parse errors',
'css-error',
percent_total(v('css_filter_parse_failures'),
v('css_filter_blocks_rewritten')));
this.addGraph('JavaScript minification failures',
'js-error',
percent_total(v('javascript_minification_failures'),
v('javascript_blocks_minified')));
var goodImageResults =
sum([v('image_rewrites'),
v('image_rewrites_dropped_nosaving_resize'),
v('image_rewrites_dropped_nosaving_noresize')]);
var badImageResults =
sum([v('image_norewrites_high_resolution'),
v('image_rewrites_dropped_decode_failure'),
v('image_rewrites_dropped_due_to_load'),
v('image_rewrites_dropped_mime_type_unknown'),
v('image_rewrites_dropped_server_write_fail')]);
this.addGraph('Image rewrite failures',
'image-error',
percent_total(badImageResults, goodImageResults));
/* TODO(sligocki): Get CSS combine stat working.
Note: This stat is also generally much higher than the rest and also
less important, we should de-prioritize it as well.
this.addGraph('CSS combine opportunities missed',
'css-combine-error',
percent(
v('css_combine_opportunities') - v('css_file_count_reduction'),
v('css_combine_opportunities')));
*/
};
/**
* Add a graph specification to our list. Also note which variables we will
* need to fetch.
*
* @param {string} title Name of the graph.
* @param {string} urlFragment Id on documentation page to link to.
* @param {Object} stat Statistic to graph.
* @return {Object} The graph spec (used in tests).
*/
pagespeed.Console.prototype.addGraph = function(title, urlFragment, stat) {
var graph = {};
graph.title = title;
graph.docUrl =
'https://developers.google.com/speed/pagespeed/module/console#' +
urlFragment;
graph.value = stat;
// Unique identifying number.
graph.num = this.graphs_.length;
// Added to title, only set once data is loaded.
graph.overallPercent = null;
graph.priority = null;
// Created once data is loaded.
graph.dataTable = null;
graph.lineChart = null;
this.graphs_.push(graph);
this.varsNeeded_.addAll(stat.varsNeeded);
return graph;
};
/**
* Load all graphs over default time period (last day).
*/
pagespeed.Console.prototype.startConsole = function() {
var endTime = new Date();
var durationMs = 24 * 60 * 60 * 1000; // 1 Day
var startTime = new Date(endTime - durationMs);
var granularityMs = 60 * 1000; // 1 Minute
this.loadJsonData(startTime, endTime, granularityMs);
};
/**
* Generate a URL which will request specific variables values snapshotted
* over a given timeframe with a specified granularity.
*
* @param {Array.<string>} varNames Array of variable names to request.
* @param {Date} startTime Begining of timeframe.
* @param {Date} endTime End of timeframe.
* @param {number} granularityMs Time between data points requested.
* @return {string} URL incorporating all these values.
*/
pagespeed.Console.prototype.createQueryUrl = function(
varNames, startTime, endTime, granularityMs) {
var queryString = pagespeedStatisticsUrl + '?json';
queryString += '&start_time=' + startTime.getTime();
queryString += '&end_time=' + endTime.getTime();
queryString += '&granularity=' + granularityMs;
queryString += '&var_titles=';
for (var i = 0; i < varNames.length; i++) {
queryString += varNames[i] + ',';
}
return queryString;
};
/**
* Request variable data from server. Parse the returned result and call
* updateGraphsFromJsonData() with resulting data.
*
* @param {Date} startTime Begining of timeframe.
* @param {Date} endTime End of timeframe.
* @param {number} granularityMs Time between data points requested.
*/
pagespeed.Console.prototype.loadJsonData = function(
startTime, endTime, granularityMs) {
var xhr = new XMLHttpRequest();
var mpsConsole = this;
// TODO(sligocki): varsNeeded list is getting long, change protocol so that
// JSON requests just return all vars tracked. That would (a) keep the URLs
// shorter and (b) remove our need to keep track of varsNeeded.
var queryString = this.createQueryUrl(
this.varsNeeded_.getValues(), startTime, endTime, granularityMs);
xhr.onreadystatechange = function() {
if (this.readyState != 4) {
return;
}
if (this.status != 200 || this.responseText.length < 1 ||
this.responseText[0] != '{') {
pagespeed.error('XHR request failed.');
return;
}
var json_data = JSON.parse(this.responseText);
mpsConsole.drawGraphsFromJsonData(json_data);
};
xhr.open('GET', queryString);
xhr.send();
};
/**
* Use JSON data to create and draw all graphs.
* TODO(sligocki): Allow updating graphs, not just adding new ones.
*
* @param {*} data Parsed JSON data from backend.
*/
pagespeed.Console.prototype.drawGraphsFromJsonData = function(data) {
this.variables_ = data['variables'];
// TODO(sligocki): Convert to {Array.<Date>}.
this.timestamps_ = data['timestamps'];
this.checkDataValidity(this.timestamps_, this.variables_);
for (var i = 0; i < this.graphs_.length; i++) {
// Each graph is a collection of (x, y) points where x is a timestamp
// and y is the stat at that time. statTimeSeries stores those y values.
// TODO(sligocki): Perhaps we should instead show the stat computed from
// diffs from the last timestamp. That way changes should be notable.
// Or maybe both.
var statTimeSeries = [];
for (var j = 0; j < this.timestamps_.length; j++) {
statTimeSeries.push(this.graphs_[i].value.evaluate(
function(variables) {
/**
* @param {string} name variable name.
* @return {number} Variable value at timestamp j.
*/
var fn = function(name) {
if (name in variables) {
return variables[name][j];
} else {
pagespeed.error('JSON data missing required variable.');
return 0;
}
};
return fn;
}(this.variables_)));
}
this.graphs_[i].overallPercent = statTimeSeries[statTimeSeries.length - 1];
// TODO(sligocki): This just sets the priority equal to the overall
// long-run stat average. But we may want to prioritize different
// issues different amounts.
this.graphs_[i].priority = this.graphs_[i].overallPercent;
this.graphs_[i].dataTable =
this.buildDataTable(this.graphs_[i].title,
this.timestamps_, statTimeSeries);
}
// Sort by priority (highest priority first).
this.graphs_.sort(function(a, b) { return b.priority - a.priority; });
for (var i = 0; i < this.graphs_.length; i++) {
this.drawGraph(this.graphs_[i]);
}
};
/**
* Error if any variables has an inconsistent number of data points.
* All variables should have the same number of values as there are timestamps.
*
* @param {Array} timestamps
* @param {Object.<Array>} variables
*/
pagespeed.Console.prototype.checkDataValidity = function(
timestamps, variables) {
for (var name in variables) {
if (timestamps.length != variables[name].length) {
pagespeed.error('JSON response is malformed. (' + timestamps.length +
' != ' + variables[name].length + ')');
}
}
};
/**
* Build the google.visualization.DataTable for given data.
*
* @param {string} title Label for values.
* @param {Array.<number>} timestamps x-coords of the graph.
* @param {Array.<number>} statTimeSeries y-coords of the graph.
* @return {Object} The data table.
*/
pagespeed.Console.prototype.buildDataTable = function(
title, timestamps, statTimeSeries) {
// Build data table.
var dataTable = this.createDataTable(title);
for (var i = 0; i < timestamps.length; i++) {
dataTable.addRow([new Date(timestamps[i]), statTimeSeries[i]]);
}
if (dataTable.getNumberOfRows() == 0) {
pagespeed.error('Data failed to load for graph ' + title);
}
return dataTable;
};
/**
* createDataTable creates a new google.visualization.DataTable
* and returns it. Each DataTable has two columns: a timestamp
* (represented as a number, time elapsed in s) and the value at that time,
* also a number. This DataTable is meant for a line graph DataView.
* @param {string} title The name of the statistic being measured.
* @return {Object} The data table.
*/
pagespeed.Console.prototype.createDataTable = function(title) {
var dataTable = new google.visualization.DataTable();
dataTable.addColumn('datetime', 'Time');
dataTable.addColumn('number', title);
return dataTable;
};
/**
* Add and draw a graph which has already had its dataTable set.
* TODO(sligocki): Allow updating graphs, not just adding new ones.
*
* @param {Object} graph The graph to add.
*/
pagespeed.Console.prototype.drawGraph = function(graph) {
graph.lineChart = new google.visualization.LineChart(
pagespeed.createGraphDiv(graph.title, graph.overallPercent, graph.docUrl,
graph.num));
// Draw graph.
graph.lineChart.draw(graph.dataTable, this.lineChartOptions_);
/* TODO(sligocki): Add auto-update functionality.
if (this.updatePaused_) {
this.updatePaused_ = false;
this.startAutoUpdate();
}
*/
};
// Methods for creating HTML content
/**
* createGraphDiv creates the necessary DOM elements for a graph, and returns
* the div in which the graph will be drawn.
*
* @param {string} title The title of the graph.
* @param {number} percent overall percent for summary.
* @param {string} docUrl Documentation URL.
* @param {number} graphNum Unique number for this graph.
* @return {Element} The div in which to draw the graph.
*/
pagespeed.createGraphDiv = function(title, percent, docUrl, graphNum) {
var wholeDiv = document.createElement('div');
wholeDiv.setAttribute('class', 'pagespeed-widgets');
wholeDiv.appendChild(pagespeed.createGraphTitleBar(title, percent, docUrl,
graphNum));
var graph = document.createElement('div');
graph.setAttribute('class', 'pagespeed-graph');
wholeDiv.appendChild(graph);
var container = document.getElementById('pagespeed-graphs-container');
container.appendChild(wholeDiv);
return graph;
};
/**
* Creates the title and dropdown menu of each graph.
*
* @param {string} title The title of the graph.
* @param {number} percent overall percent for summary.
* @param {string} docUrl Documentation URL.
* @param {number} graphNum Unique number for this graph.
* @return {Element} The full title bar div.
*/
pagespeed.createGraphTitleBar = function(
title, percent, docUrl, graphNum) {
var topBar = document.createElement('div');
topBar.setAttribute('class', 'pagespeed-widgets-topbar');
var titleSpan = document.createElement('span');
titleSpan.setAttribute('class', 'pagespeed-title');
titleSpan.setAttribute('id', 'pagespeed-title' + graphNum);
titleSpan.appendChild(document.createTextNode(
title + ': ' + (100 * percent).toFixed(2) + '% ('));
var a = document.createElement('a');
a.setAttribute('href', docUrl);
a.appendChild(document.createTextNode('doc'));
titleSpan.appendChild(a);
titleSpan.appendChild(document.createTextNode(')'));
topBar.appendChild(titleSpan);
// TODO(sligocki): Add other things here, like drop-down option menu.
return topBar;
};