@contrast/route-metrics
Version:
`route-metrics` allows server performance, exclusive of network time, to be compared on a route-by-route basis. It was created to compare server performance with and without `@contrast/agent` being loaded and active.
222 lines (194 loc) • 6.79 kB
JavaScript
;
const BaseReporter = require('./base-reporter.cjs');
class Reporter extends BaseReporter {
constructor(stream, overallInfo, options) {
super(stream, overallInfo, options);
this.percentiles = [0.50, 0.70, 0.80, 0.90, 0.95];
}
async report(options) {
// output informational text
this.reportOverall();
for (const s of this.runSummaries) {
/* eslint-disable no-console */
console.log(`[start ${iso(s.firstTimestamp)}, end ${iso(s.lastTimestamp)}]`);
const rc = s.metrics.length;
let totalObs = 0;
for (const [, timesByStatus] of s.metrics) {
for (const status in timesByStatus) {
totalObs += timesByStatus[status].length;
if (!options.microseconds) {
timesByStatus[status] = timesByStatus[status].map(t => Math.round(t / 1000));
}
}
}
console.log(`[total time measurements ${totalObs} across ${rc} routes]`);
// note the agent is loaded if it is
for (const p of s.patches) {
if (!p.entry.name.startsWith('http')) {
console.log(`[${p.entry.name} loaded]`);
break;
}
}
// and output time-series info
const count = this.getTimeSeriesCount(s.timeSeries);
if (count) {
let gcInfo = '';
if (s.timeSeries.gc.count !== 0) {
gcInfo = ` (gc count ${s.timeSeries.gc.gcCount} gc time ${s.timeSeries.gc.gcTime})`;
}
console.log(`[time-series lines processed ${count}${gcInfo}]`);
}
/* eslint-enable no-console */
// sort the metrics so the percentiles work correctly.
s.metrics.sort((a, b) => a === b ? 0 : a[0] < b[0] ? -1 : 1);
const percentiles = this.percentiles.join(', ');
this.stream.write(`route, status, n, mean, stddev, percentiles: ${percentiles}\n`);
// route, status, n, ...percentiles
if (!options.template) {
this.writeSimple(s);
} else {
this.writeTemplateDriven(s, options.template);
}
// console.log is synchronous and stream writes are not. informational text
// is always written to the console. if the report is also being written to
// the console, and there are multiple run summaries, that the output will
// be interleaved incorrectly. so
await new Promise(resolve => {
this.stream.write('\n', resolve);
});
}
}
/**
* Write the summary based on the raw route names.
*
* @param {object} summary a run summary.
*/
writeSimple(summary) {
const {metrics} = summary;
// for each collected metric
for (const [path, timesByStatus] of metrics) {
// for each status code
for (const status in timesByStatus) {
const lowToHigh = (a, b) => a === b ? 0 : a < b ? -1 : 1;
const times = timesByStatus[status].sort(lowToHigh);
const {n, mean, stddev} = stats(times);
const line = [
path, status, n, f2(mean), f2(stddev), ...percentile(this.percentiles, times)
].join(',');
//waitWriter(`${line}\n`);
this.stream.write(`${line}\n`);
}
}
}
/**
* Write the summary based grouped into buckets defined by the template.
*
* @param {object} summary a run summary
* @param {object} template a template definition
*/
writeTemplateDriven(summary, template) {
// create config-defined buckets
const buckets = {};
const rawBuckets = {};
const {metrics, meta} = summary;
// for each collected metric
for (const [key, timesByStatus] of metrics) {
// get the already parsed items
const {method, path} = meta.keyToProperties[key];
// now for each config-defined bucket
let matched = false;
for (const bucket of template.routes) {
if (bucket.method === method) {
if (bucket.startsWith && path.startsWith(bucket.startsWith)) {
// store in bucket
this.saveMatch(buckets, bucket.name, timesByStatus);
matched = true;
} else if (bucket.regex) {
const m = path.match(bucket.regex);
if (m) {
// store in bucket
this.saveMatch(buckets, bucket.name, timesByStatus);
matched = true;
} else if (bucket.pattern && path === bucket.pattern) {
this.saveMatch(buckets, bucket.name, timesByStatus);
matched = true;
} else {
// hmm. either at least one match type must be present. and that
// should have been verified already.
}
}
}
}
if (!matched) {
// it didn't match any of the template-defined route buckets.
// put it in its own bucket, like default behavior
this.saveMatch(rawBuckets, key, timesByStatus);
}
}
// order keys so the template-named buckets come before the default-named
// buckets (which are really just the raw routes).
for (const key in rawBuckets) {
buckets[key] = rawBuckets[key];
}
for (const bucketName in buckets) {
const timesByStatus = buckets[bucketName];
for (const status in timesByStatus) {
const lowToHigh = (a, b) => a === b ? 0 : a < b ? -1 : 1;
const times = timesByStatus[status].sort(lowToHigh);
const {n, mean, stddev} = stats(times);
const percentiles = percentile(this.percentiles, times);
const line = [
bucketName, status, n, f2(mean), f2(stddev), ...percentiles
].join(',');
this.stream.write(`${line}\n`);
}
}
}
saveMatch(buckets, name, timesByStatus) {
let bucket = buckets[name];
if (!bucket) {
bucket = buckets[name] = {};
}
for (const status in timesByStatus) {
if (!bucket[status]) {
bucket[status] = timesByStatus[status].slice();
} else {
bucket[status] = bucket[status].concat(timesByStatus[status]);
}
}
}
}
//
// formatting helpers
//
function f2(n) {
return n.toFixed(2);
}
function iso(ts) {
return new Date(ts).toISOString();
}
//
// summarization functions
//
function stats(array) {
const n = array.length;
const total = array.reduce((tot, v) => tot + v, 0);
const mean = total / n;
const stddev = variance(array) ** 0.5;
return {n, total, mean, stddev};
}
function mean(array) {
return array.reduce((tot, v) => tot + v, 0) / array.length;
}
function variance(array) {
const average = mean(array);
return mean(array.map((num) => (num - average) ** 2));
}
function percentile(percentiles, array) {
return percentiles.map(p => pctile(p, array));
}
function pctile(p, list) {
if (p === 0) return list[0];
return list[Math.ceil(list.length * p) - 1];
}
module.exports = Reporter;