polyfill-service
Version:
A polyfill combinator
213 lines (189 loc) • 6.47 kB
JavaScript
;
const MySQL = require('mysql2/promise');
const Stats = require('fast-stats').Stats;
const UA = require('../lib/UA');
const polyfillio = require('../lib/index');
function Perf(rawopts) {
const optionsSchema = {
metrics: {
type: 'array',
default:['perf_dns', 'perf_connect', 'perf_req', 'perf_resp', 'perf_total'],
valid:['perf_dns', 'perf_connect', 'perf_req', 'perf_resp', 'perf_total']
},
dimensions: {
type: 'array',
default: ['data_center'],
valid: ['data_center', 'country', 'refer_domain']
},
stats: {
type: 'array',
default: ['median', '95P'],
valid: ['mean', 'median', '95P', '99P', 'std', 'min', 'max', 'count']
},
period: { type:'number', default: 30, min: 1, max: 60 },
minSample: { type:'number', default: 500, min: 50 }
};
let dbconn;
if (!process.env.RUM_MYSQL_DSN) throw new Error('RUM disabled. See README for environment variables required for RUM reporting.');
const options = validateOptions(rawopts, optionsSchema);
const sqlFields = options.dimensions
.concat(options.metrics)
.map(fName => (fName === 'perf_total') ? '(perf_dns+perf_connect+perf_req+perf_resp) as perf_total' : fName)
.join(', ')
;
const sqlQuery = `SELECT ${sqlFields} FROM requests WHERE req_time BETWEEN (CURDATE() - INTERVAL ${options.period} DAY) AND CURDATE() AND data_center IS NOT NULL AND perf_req IS NOT NULL LIMIT 1000000`;
const dataPromise = MySQL.createConnection(process.env.RUM_MYSQL_DSN)
.then(conn => {
dbconn = conn;
return conn.query(sqlQuery);
})
.then(results => {
return dbconn.end().then(() => {
const objdata = results[0].reduce((out, row) => {
const key = options.dimensions.map(fieldName => row[fieldName]).join('-');
if (!(key in out)) {
const aggregateRow = {count:0};
options.dimensions.forEach(fieldName => {
aggregateRow[fieldName] = row[fieldName];
});
options.metrics.forEach(fieldName => {
aggregateRow[fieldName] = new Stats();
});
out[key] = aggregateRow;
}
options.metrics.forEach(fieldName => {
if (row[fieldName]) out[key][fieldName].push(row[fieldName]);
});
out[key].count++;
return out;
}, {});
return Object.keys(objdata)
// Convert to an array
.map(key => objdata[key])
// Remove any rows that don't have enough datapoints
.filter(row => row.count > options.minSample)
// Sort by number of datapoints
.sort((rowa, rowb) => rowa.count < rowb.count ? 1 : -1)
// Add derived data
.map(row => {
options.metrics.forEach(metric => {
if (options.stats.includes('95P')) {
row[metric+'_95P'] = row[metric].percentile(95);
}
if (options.stats.includes('99P')) {
row[metric+'_99P'] = row[metric].percentile(99);
}
if (options.stats.includes('mean')) {
row[metric+'_mean'] = row[metric].amean();
}
if (options.stats.includes('median')) {
row[metric+'_median'] = row[metric].median();
}
if (options.stats.includes('std')) {
row[metric+'_std'] = row[metric].stddev();
}
if (options.stats.includes('min')) {
row[metric+'_min'] = row[metric].range()[0];
}
if (options.stats.includes('max')) {
row[metric+'_max'] = row[metric].range()[1];
}
if (options.stats.includes('count')) {
row[metric+'_count'] = row[metric].length; // Number of non-zero values for this metric
}
if (options.stats.length) {
delete row[metric];
}
});
// Round numeric values
Object.keys(row).forEach(f => {
if (typeof row[f] === 'number') row[f] = Math.round(row[f]*100)/100;
});
return row;
})
;
});
})
.catch(err => {
console.log(err);
return [];
})
;
return {
getStats: () => dataPromise
};
}
function Compat() {
let dbconn;
if (!process.env.RUM_MYSQL_DSN) return;
const querySQL = `
SELECT dr.feature_name, r.ua_family, r.ua_version, ROUND((SUM(dr.result)/COUNT(*))*100) as pass_rate_pc, COUNT(DISTINCT r.ip) as ip_count, COUNT(DISTINCT refer_domain) as refer_source_count, COUNT(*) as total_count
FROM requests r INNER JOIN detect_results dr ON r.id=dr.request_id
WHERE req_time BETWEEN (CURDATE() - INTERVAL 30 DAY) AND CURDATE()
GROUP BY dr.feature_name, r.ua_family, r.ua_version
HAVING ip_count > 20 AND refer_source_count > 3
ORDER BY feature_name, ua_family
`;
const dataPromise = MySQL.createConnection(process.env.RUM_MYSQL_DSN)
.then(conn => {
dbconn = conn;
return conn.query(querySQL);
})
.then(results => {
return dbconn.end().then(() => {
return Promise.all(results[0].map(rec => {
return polyfillio.describePolyfill(rec.feature_name)
.then(polyfill => {
if (!polyfill) {
return null;
} else {
const ua = new UA(rec.ua_family + '/' + rec.ua_version);
const isTargeted = (polyfill.browsers && polyfill.browsers[rec.ua_family] && ua.satisfies(polyfill.browsers[rec.ua_family]));
rec.is_targeted = isTargeted ? 'Yes' : 'No';
if (isTargeted && rec.pass_rate_pc > 80) {
rec.targeting_status = 'False positive - remove targeting';
} else if (!isTargeted && rec.pass_rate_pc < 20) {
rec.targeting_status = 'False negative - add targeting';
} else {
return null; // Correctly targeted
}
return rec;
}
});
}));
});
})
.then(results => results.filter(rec => rec !== null))
;
return {
getStats: () => dataPromise
};
}
const validateOptions = (raw, schema) => {
return Object.keys(schema).reduce((out, k) => {
if (!(k in raw)) {
// Do nothing
} else if (schema[k].type === 'array') {
let candidate = Array.isArray(raw[k]) ? raw[k] : raw[k].split(',');
if ('valid' in schema[k]) candidate = candidate.filter(x => schema[k].valid.includes(x));
if (candidate.length) {
out[k] = candidate;
}
} else if (schema[k].type === 'number') {
let candidate = Number.parseFloat(raw[k]);
if (!Number.isNaN(candidate)) {
if ('min' in schema[k]) candidate = Math.max(candidate, schema[k].min);
if ('max' in schema[k]) candidate = Math.min(candidate, schema[k].max);
out[k] = candidate;
}
}
if (!(k in out) && ('default' in schema[k])) {
out[k] = schema[k].default;
}
return out;
}, {});
};
module.exports = {
Perf: Perf,
Compat: Compat
};