static/js/stats/manager.js (476 lines of code) (raw):
import $ from 'jquery';
import _ from 'underscore';
import Highcharts from 'highcharts';
import csv_keys from './csv_keys';
import { normalizeRange, forEachISODate } from './dateutils';
import { Storage, SessionStorage } from '../zamboni/storage';
function getStatsManager() {
// The version of the stats localStorage we are using.
// If you increment this number, you cache-bust everyone!
var STATS_VERSION = '2020-10-08';
var PRECISION = 2;
var $primary = $('.primary');
var storage = Storage('stats'),
storageCache = SessionStorage('statscache'),
dataStore = {},
currentView = {},
siteEvents = [],
addonId = parseInt($primary.data('addon_id'), 10),
baseURL = $primary.data('base_url'),
pendingFetches = 0,
siteEventsEnabled = true,
writeInterval = false,
lookup = {},
msDay = 24 * 60 * 60 * 1000; // One day in milliseconds.
// NaN is a poor choice for a storage key
if (isNaN(addonId)) addonId = 'globalstats';
// It's a bummer, but we need to know which metrics have breakdown fields.
// check by saying `if (metric in breakdownMetrics)`
var breakdownMetrics = {
apps: true,
locales: true,
os: true,
sources: true,
versions: true,
statuses: true,
overview: true,
site: true,
countries: true,
contents: true,
mediums: true,
campaigns: true,
};
// is a metric an average or a sum?
var metricTypes = {
usage: 'mean',
apps: 'mean',
locales: 'mean',
os: 'mean',
versions: 'mean',
statuses: 'mean',
downloads: 'sum',
sources: 'sum',
contributions: 'sum',
countries: 'mean',
contents: 'sum',
mediums: 'sum',
campaigns: 'sum',
};
// Initialize from localStorage when dom is ready.
function init() {
if (verifyLocalStorage()) {
var cacheObject = storageCache.get(addonId);
if (cacheObject) {
cacheObject = JSON.parse(cacheObject);
if (cacheObject) {
dataStore = cacheObject;
}
}
}
}
$(init);
// These functions deal with our localStorage cache.
function writeLocalStorage() {
try {
storageCache.set(addonId, JSON.stringify(dataStore));
storage.set('version', STATS_VERSION);
} catch (e) {
console.log(e);
}
}
function clearLocalStorage() {
storageCache.remove(addonId);
storage.remove('version');
}
function verifyLocalStorage() {
if (storage.get('version') == STATS_VERSION) {
return true;
} else {
clearLocalStorage();
return false;
}
}
document.onbeforeunload = writeLocalStorage;
// Runs when 'changeview' event is detected.
function processView(e, newView) {
console.debug('manager:window:changeview', newView);
// Update our internal view state.
currentView = $.extend(currentView, newView);
// On custom ranges request a range greater by 1 day. (bug 737910)
if (currentView.range.custom && typeof currentView.range.end == 'object') {
currentView.range.end = new Date(currentView.range.end.getTime() + msDay);
}
// Fetch the data from the server or storage, and notify other components.
$.when(getDataRange(currentView)).then(function (data) {
setTimeout(function () {
$(window).trigger('dataready', {
view: currentView,
fields: getAvailableFields(currentView),
data: data,
});
}, 0);
});
}
$(window).on('changeview', processView);
function annotateData(data, events) {
var i, ev, sd, ed;
for (i = 0; i < events.length; i++) {
ev = events[i];
if (ev.end) {
sd = Date.iso(ev.start);
ed = Date.iso(ev.end);
forEachISODate({ start: sd, end: ed }, '1 day', data, function (row) {
if (row) {
row.event = ev;
}
});
} else {
if (data[ev.start]) {
data[ev.start].event = ev;
}
}
}
return data;
}
// Returns a list of field names for a given data set.
function getAvailableFields(view) {
var metric = view.metric,
range = normalizeRange(view.range),
start = range.start,
end = range.end,
ds,
row,
numRows = 0,
fields = {};
// Non-breakdown metrics only have one field.
if (metric == 'contributions') return ['count', 'total', 'average'];
if (!(metric in breakdownMetrics)) return ['count'];
ds = dataStore[metric];
if (!ds) throw 'Expected metric with valid data!';
// Locate all unique fields.
forEachISODate(
range,
'1 day',
ds,
function (row) {
if (row) {
if (metric == 'apps') {
row = collapseVersions(row, PRECISION);
}
if (metric == 'sources') {
row = collapseSources(row);
}
_.each(row.data, function (v, k) {
fields[k] = fields[k] ? fields[k] + v : v;
});
_.extend(fields, row.data);
}
},
this,
);
// sort the fields, make them proper field identifiers, and return.
return _.map(
_.sortBy(_.keys(fields), function (f) {
return -fields[f];
}),
function (f) {
return 'data|' + f;
},
);
}
// getDataRange: ensures we have all the data from the server we need,
// and queues up requests to the server if the requested data is outside
// the range currently stored locally. Once all server requests return,
// we move on.
function getDataRange(view) {
var range = normalizeRange(view.range),
metric = view.metric,
ds = dataStore[metric],
reqs = [],
$def = $.Deferred();
function finished() {
var ds = dataStore[metric],
ret = {},
row,
firstIndex;
if (ds) {
forEachISODate(
range,
'1 day',
ds,
function (row, date) {
var d = date.iso();
if (row) {
if (!firstIndex) {
firstIndex = range.start;
}
if (metric == 'apps') {
row = collapseVersions(row, PRECISION);
}
if (metric == 'sources') {
row = collapseSources(row);
}
ret[d] = row;
}
},
this,
);
if (_.isEmpty(ret)) {
ret.empty = true;
} else {
ret.firstIndex = firstIndex;
ret = groupData(ret, view);
ret.metric = metric;
}
$def.resolve(ret);
} else {
$def.fail({ empty: true });
}
}
if (ds) {
if (ds.maxdate < range.end.iso()) {
reqs.push(fetchData(metric, Date.iso(ds.maxdate), range.end));
}
if (ds.mindate > range.start.iso()) {
reqs.push(fetchData(metric, range.start, Date.iso(ds.mindate)));
}
} else {
reqs.push(fetchData(metric, range.start, range.end));
}
$.when.apply(null, reqs).then(finished);
return $def;
}
// Aggregate data based on view's `group` setting.
function groupData(data, view) {
var metric = view.metric,
range = normalizeRange(view.range),
group = view.group || 'day',
groupedData = {};
// If grouping doesn't fit into custom date range, force group to day.
var dayMsecs = 24 * 3600 * 1000;
var date_range_days =
(range.end.getTime() - range.start.getTime()) / dayMsecs;
if (
(group == 'week' && date_range_days <= 8) ||
(group == 'month' && date_range_days <= 31)
) {
view.group = 'day';
group = 'day';
}
// if grouping is by day, do nothing.
if (group == 'day') return data;
var groupKey = false,
groupVal = false,
groupCount = 0,
d,
row,
firstIndex;
if (group == 'all') {
groupKey = firstIndex = range.start.iso();
groupCount = 0;
groupVal = {
date: groupKey,
count: 0,
data: {},
empty: true,
};
if (metric == 'contributions') {
_.extend(groupVal, {
average: 0,
total: 0,
});
}
}
function performAggregation() {
// we drop the some days of data from the result set
// if they are not a complete grouping.
if (groupKey && groupVal && !groupVal.empty) {
// average `count` for mean metrics
if (metricTypes[metric] == 'mean') {
groupVal.count /= groupCount;
}
if (!firstIndex) firstIndex = groupKey;
// overview gets special treatment. Only average ADUs.
if (metric == 'overview') {
groupVal.data.updates /= groupCount;
} else if (metric == 'contributions') {
groupVal.average /= groupCount;
} else if (metric in breakdownMetrics) {
// average for mean metrics.
_.each(groupVal.data, function (val, field) {
if (metricTypes[metric] == 'mean') {
groupVal.data[field] /= groupCount;
}
});
}
groupedData[groupKey] = groupVal;
}
}
// big loop!
forEachISODate(
range,
'1 day',
data,
function (row, d) {
// Here's where grouping points are caluculated.
if (
(group == 'week' && d.getDay() === 0) ||
(group == 'month' && d.getDate() == 1)
) {
performAggregation();
// set the new group date to the current iteration.
groupKey = d.iso();
// reset our aggregates.
groupCount = 0;
groupVal = {
date: groupKey,
count: 0,
data: {},
empty: true,
};
if (metric == 'contributions') {
_.extend(groupVal, {
average: 0,
total: 0,
});
}
}
// add the current row to our aggregates.
if (row && groupVal) {
groupVal.empty = false;
groupVal.count += row.count;
if (metric == 'contributions') {
groupVal.total += parseFloat(row.total);
groupVal.average += parseFloat(row.average);
}
if (metric in breakdownMetrics) {
_.each(row.data, function (val, field) {
if (!groupVal.data[field]) {
groupVal.data[field] = 0;
}
groupVal.data[field] += val;
});
}
}
groupCount++;
},
this,
);
if (group == 'all') performAggregation();
groupedData.empty = _.isEmpty(groupedData);
groupedData.firstIndex = firstIndex;
return groupedData;
}
// The beef. Negotiates with the server for data.
function fetchData(metric, start, end) {
var seriesStart = start,
seriesEnd = end,
$def = $.Deferred();
var seriesURLStart = Highcharts.dateFormat('%Y%m%d', seriesStart),
seriesURLEnd = Highcharts.dateFormat('%Y%m%d', seriesEnd),
seriesURL =
baseURL +
[metric, 'day', seriesURLStart, seriesURLEnd].join('-') +
'.json';
$.ajax({
url: seriesURL,
dataType: 'text',
success: fetchHandler,
error: errorHandler,
});
function errorHandler(response, unused1, unused2) {
if (response.status === 503) {
// The API returns a 503 with no data when we disable BigQuery so let's
// handle this error using the normal flow, which will display "no data
// available".
return fetchHandler(response.responseText, response.status, response);
}
$def.fail();
}
function fetchHandler(raw_data, status, xhr) {
var maxdate = '1970-01-01',
mindate = new Date().iso();
if ([200, 503].includes(xhr.status)) {
if (!dataStore[metric]) {
dataStore[metric] = {
mindate: new Date().iso(),
maxdate: '1970-01-01',
};
}
var ds = dataStore[metric],
data = JSON.parse(raw_data);
var i, datekey;
for (i = 0; i < data.length; i++) {
datekey = data[i].date;
maxdate = String.max(datekey, maxdate);
mindate = String.min(datekey, mindate);
ds[datekey] = data[i];
}
ds.maxdate = String.max(maxdate, ds.maxdate);
ds.mindate = String.min(mindate, ds.mindate);
clearTimeout(writeInterval);
writeInterval = setTimeout(writeLocalStorage, 1000);
$def.resolve();
} else if (xhr.status == 202) {
//Handle a successful fetch but with no response
var retry_delay = 30000;
if (xhr.getResponseHeader('Retry-After')) {
retry_delay =
parseInt(xhr.getResponseHeader('Retry-After'), 10) * 1000;
}
setTimeout(function () {
fetchData(metric, start, end);
}, retry_delay);
}
}
return $def;
}
function collapseSources(row) {
var out = {
count: row.count,
date: row.date,
end: row.end,
},
data = row.data,
pretty,
key,
ret = {};
_.each(data, function (val, source) {
pretty = $.trim(getPrettyName('sources', source));
if (!lookup[pretty]) {
lookup[pretty] = source;
}
key = lookup[pretty];
if (!ret[key]) ret[key] = 0;
ret[key] += parseFloat(val);
});
out.data = ret;
return out;
}
// Rounds application version strings to a given precision.
// Passing `0` will truncate versions entirely.
function collapseVersions(row, precision) {
var out = {
count: row.count,
date: row.date,
end: row.end,
},
apps = row.data,
key,
ret = {};
_.each(apps, function (set, app) {
_.each(set, function (val, ver) {
key = app + '_' + ver.split('.').slice(0, precision).join('.');
if (!ret[key]) {
ret[key] = 0;
}
ret[key] += parseFloat(val);
});
});
out.data = ret;
return out;
}
// Takes a data row and a field identifier and returns the value.
function getField(row, field) {
var parts = field.split('|'),
val = row;
// give up if the row is falsy.
if (!val) return null;
// drill into the row object for a nested key.
// `data|api` means row['data']['api']
for (var i = 0; i < parts.length; i++) {
val = val[parts[i]];
if (!_.isNumber(val) && !_.isObject(val)) {
return null;
}
}
return val;
}
function getPrettyName(metric, field) {
var parts = field.split('_'),
key = parts[0];
parts = parts.slice(1);
if (metric in csv_keys) {
if (key in csv_keys[metric]) {
return csv_keys[metric][key] + ' ' + parts.join(' ');
}
}
return field;
}
// Expose some functionality to the StatsManager api.
return {
getDataRange: getDataRange,
fetchData: fetchData,
dataStore: dataStore,
getPrettyName: getPrettyName,
getField: getField,
clearLocalStorage: clearLocalStorage,
getAvailableFields: getAvailableFields,
getCurrentView: function () {
return currentView;
},
};
}
export const StatsManager = getStatsManager();