Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
.DS_Store
node_modules
.idea/
*.log
coverage/

node_modules
coverage
output

package-lock.json
*.heapsnapshot
*.log
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ project adheres to [Semantic Versioning](http://semver.org/).
- ci: switch out deprecated benchmark-regression library for replacement
- AggregatorRegistry renamed to ClusterRegistry, old name deprecated
- chore: update faceoff to 1.1
- perf: Stat aggregation uses similar strategy to collection. 60% faster aggregation

### Added

Expand Down
48 changes: 44 additions & 4 deletions benchmarks/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const Path = require('path');
module.exports = setupUtilSuite;

function setupUtilSuite(suite) {
const skip = ['prom-client@latest', 'prom-client@trunk'];
const skip = ['prom-client@latest'];

suite.add(
'hashObject',
Expand All @@ -14,6 +14,9 @@ function setupUtilSuite(suite) {
foo: 'longish',
user_agent: 'Chrome',
gateway: 'lb04',
method: 'get',
status_code: 200,
phase: 'load',
});
},
{ setup: findUtil, skip },
Expand All @@ -23,9 +26,13 @@ function setupUtilSuite(suite) {
'LabelMap.validate()',
(client, labelMap) => {
labelMap.validate({
foo: 'longish:tag:goes:here',
foo: 'longish',
user_agent: 'Chrome',
status_code: 503,
gateway: 'lb04',
method: 'get',
status_code: 200,
phase: 'load',
label1: 4,
});
},
{ setup, skip },
Expand All @@ -37,11 +44,42 @@ function setupUtilSuite(suite) {
labelMap.keyFrom({
foo: 'longish',
user_agent: 'Chrome',
status_code: 503,
gateway: 'lb04',
method: 'get',
status_code: 301,
phase: 'load',
label1: 4,
});
},
{ setup, skip },
);

suite.add(
'LabelGrouper.keyFrom()',
(client, labelGrouper) => {
if (labelGrouper === undefined) {
return;
}

labelGrouper.keyFrom({
foo: 'longish',
user_agent: 'Chrome',
gateway: 'lb04',
method: 'get',
status_code: 503,
phase: 'load',
label1: 4,
});
},
{
setup: (client, location) => {
const Util = findUtil(client, location);

return new Util.LabelGrouper();
},
skip: ['prom-client@latest', 'prom-client@trunk'],
},
);
}

function setup(client, location) {
Expand All @@ -53,6 +91,8 @@ function setup(client, location) {
'gateway',
'method',
'status_code',
'phase',
'label1',
]);
}

Expand Down
17 changes: 10 additions & 7 deletions lib/metricAggregators.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use strict';

const { Grouper, hashObject } = require('./util');
const { LabelGrouper } = require('./util');

/**
* Returns a new function that applies the `aggregatorFn` to the values.
Expand All @@ -18,17 +18,20 @@ function AggregatorFactory(aggregatorFn) {
aggregator: metrics[0].aggregator,
};
// Gather metrics by metricName and labels.
const byLabels = new Grouper();
const byNames = new Map();
metrics.forEach(metric => {
metric.values.forEach(value => {
const key = value.metricName ?? '';
const group = byLabels.getOrAdd(key, () => new Grouper());

group.add(hashObject(value.labels), value);
const name = value.metricName ?? '';
let group = byNames.get(name);
if (group === undefined) {
group = new LabelGrouper();
byNames.set(name, group);
}
group.add(value);
});
});
// Apply aggregator function to gathered metrics.
byLabels.forEach(group => {
byNames.forEach(group => {
group.forEach(values => {
const valObj = {
value: aggregatorFn(values),
Expand Down
184 changes: 169 additions & 15 deletions lib/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -357,26 +357,180 @@ class Grouper extends Map {
this.set(key, [value]);
}
}
}

exports.Grouper = Grouper;

const GROWTH_RATE = 1.25;

/**
* For grouping metrics by labels for reporting purposes.
*/
class LabelGrouper {
/** @type {string[]} */
#labelNames = new Array(6).fill('');

/** @type {Map<string, Array<any>>} */
#map = new Map();

/**
* Returns a child record or creates one if it does not exist
*
* If there is no record at the location of the key, the init() function is
* called to create an object to put there. This allows for nested structures.
*
* @param {*} key Key to verify
* @param {[Function]} init function to generate an empty record
* @returns {undefined} undefined
* Current number of columns
* @type {number}
*/
getOrAdd(key, init = () => []) {
let entry = this.get(key);
if (entry === undefined) {
entry = init();
this.set(key, entry);
#count = 0;

/**
* Create a grouper.
*/
constructor() {}

/**
* Adds the `value` to the `key`'s array of values.
* @param {StatsEntry} value Value to add to `key`'s array.
* @returns {LabelGrouper} undefined.
*/
add(value) {
const labels = value.labels;
const key = this.keyFrom(labels);

const entry = this.#map.get(key);
if (entry !== undefined) {
entry.push(value);
} else {
this.#map.set(key, [value]);
}

return entry;
return this;
}

/**
* Look up an entry by labels.
* Note: This can end up modifying the store if labels are missing.
* @param {object} labels Key to retrieve.
* @returns {Array<StatsEntry>} undefined.
*/
get(labels) {
return this.#map.get(this.keyFrom(labels));
}

/**
* Return all of the entries in this collection.
* @returns {Array<StatsEntry>}
*/
values() {
return Array.from(this.#map.values()).filter(entry => entry.length > 0);
}

/**
* Loop over the entries.
* @param fn {Function}
*/
forEach(fn) {
return this.#map.forEach(fn);
}

/**
* Remove all values from the Grouper.
* Leaves the entries, but zeroes out the value arrays.
* @returns {LabelGrouper}
*/
clear() {
for (const entry of this.#map.values()) {
entry.length = 0;
}

return this;
}

/**
* Create a key for the given labels.
* Note: This can end up modifying the store if labels are missing.
* @param labels
* @returns {string}
*/
keyFrom(labels = {}) {
const keys = Object.keys(labels);

if (keys.length === 0) {
return '';
}

const arr = new Array(this.#labelNames.length);

let count = 0;
for (let i = 0; i < this.#count; i++) {
const name = this.#labelNames[i];
const value = labels[name];

if (value !== undefined) {
arr[i] = value;
count++;
}
}

if (count < keys.length) {
let pos = this.#count;
const missing = this.#expandLabels(labels);
for (const name of missing) {
arr[pos++] = labels[name];
}
}

return arr.join('|');
}

/**
* Size of the collection.
* @returns {number}
*/
get size() {
return this.#map.size;
}

/**
* Search the labels for missing values and expand the lookup table to handle them.
* @param labels
* @returns {*[]}
*/
#expandLabels(labels) {
const missing = [];

for (const name of Object.keys(labels)) {
if (this.#labelNames.indexOf(name) === -1) {
missing.push(name);
}
}

const target = missing.length + this.#count;
const current = this.#labelNames.length;
let width = current;

if (target > width) {
while (target > width) {
width = Math.ceil(width * GROWTH_RATE);
}

this.#labelNames.length = width;
this.#labelNames.fill('', current, width);

const extension = '|'.repeat(width - current);
const newMap = new Map();

for (const [key, value] of this.#map.entries()) {
newMap.set(`${key}${extension}`, value);
}

this.#map = newMap;
}

for (let i = this.#count, j = 0; j < missing.length; i++, j++) {
this.#labelNames[i] = missing[j];
}

this.#count = target;

return missing;
}
}

exports.Grouper = Grouper;
module.exports.LabelGrouper = LabelGrouper;
Loading