Skip to content

Commit

Permalink
feat: add support for exemplars
Browse files Browse the repository at this point in the history
  • Loading branch information
Sandes de Silva committed Apr 1, 2021
1 parent 96f7495 commit e6f607b
Show file tree
Hide file tree
Showing 8 changed files with 195 additions and 21 deletions.
33 changes: 22 additions & 11 deletions lib/counter.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,24 @@
const util = require('util');
const type = 'counter';
const { hashObject, isObject, getLabels, removeLabels } = require('./util');
const { validateLabel } = require('./validation');
const { validateLabel, validateExemplar } = require('./validation');
const { Metric } = require('./metric');

class Counter extends Metric {
/**
* Increment counter
* @param {object} labels - What label you want to be incremented
* @param {Number} value - Value to increment, if omitted increment with 1
* @param {object} exemplars - What exemplars you want to pass to this metric
* @returns {void}
*/
inc(labels, value) {
inc(labels, value, exemplars = null) {
if (!isObject(labels)) {
return inc.call(this, null)(labels, value);
return inc.call(this, null, exemplars)(labels, value, exemplars);
}

const hash = hashObject(labels);
return inc.call(this, labels, hash)(value);
const hash = hashObject(labels, exemplars);
return inc.call(this, labels, exemplars, hash)(value);
}

/**
Expand Down Expand Up @@ -50,9 +51,18 @@ class Counter extends Metric {
labels() {
const labels = getLabels(this.labelNames, arguments) || {};
validateLabel(this.labelNames, labels);
const hash = hashObject(labels);
const hash = hashObject(labels, {});
return {
inc: inc.call(this, labels, hash),
inc: inc.call(this, labels, null, hash),
};
}

exemplars() {
const exemplars = getLabels(this.exemplarNames, arguments) || {};
validateExemplar(this.exemplarNames, exemplars);
const hash = hashObject({}, exemplars);
return {
inc: inc.call(this, null, exemplars, hash),
};
}

Expand All @@ -71,7 +81,7 @@ const reset = function () {
}
};

const inc = function (labels, hash) {
const inc = function (labels, exemplars, hash) {
return value => {
if (value && !Number.isFinite(value)) {
throw new TypeError(`Value is not a valid number: ${util.format(value)}`);
Expand All @@ -81,20 +91,21 @@ const inc = function (labels, hash) {
}

labels = labels || {};
exemplars = exemplars || {};
validateLabel(this.labelNames, labels);

const incValue = value === null || value === undefined ? 1 : value;

this.hashMap = setValue(this.hashMap, incValue, labels, hash);
this.hashMap = setValue(this.hashMap, incValue, labels, exemplars, hash);
};
};

function setValue(hashMap, value, labels, hash) {
function setValue(hashMap, value, labels, exemplars, hash) {
hash = hash || '';
if (hashMap[hash]) {
hashMap[hash].value += value;
} else {
hashMap[hash] = { value, labels: labels || {} };
hashMap[hash] = { value, labels: labels || {}, exemplars: exemplars || {} };
}
return hashMap;
}
Expand Down
10 changes: 9 additions & 1 deletion lib/metric.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

const { globalRegistry } = require('./registry');
const { isObject } = require('./util');
const { validateMetricName, validateLabelName } = require('./validation');
const {
validateMetricName,
validateLabelName,
validateExemplarName,
} = require('./validation');

/**
* @abstract
Expand All @@ -18,6 +22,7 @@ class Metric {
labelNames: [],
registers: [globalRegistry],
aggregator: 'sum',
exemplarNames: [],
},
defaults,
config,
Expand All @@ -38,6 +43,9 @@ class Metric {
if (!validateLabelName(this.labelNames)) {
throw new Error('Invalid label name');
}
if (!validateExemplarName(this.exemplarNames)) {
throw new Error('Invalid exemplar name');
}
if (this.collect && typeof this.collect !== 'function') {
throw new Error('Optional "collect" parameter must be a function');
}
Expand Down
65 changes: 65 additions & 0 deletions lib/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,71 @@ class Registry {
return `${help}\n${type}\n${values}`.trim();
}

async getMetricAsPrometheusOpenMetricsString(metric) {
const item = await metric.get();
const name = escapeString(item.name);
const help = `# HELP ${name} ${escapeString(item.help)}`;
const type = `# TYPE ${name} ${item.type}`;
const eof = '# EOF';
const defaultLabelNames = Object.keys(this._defaultLabels);

let values = '';
for (const val of item.values || []) {
val.labels = val.labels || {};
val.exemplars = val.exemplars || {};

if (defaultLabelNames.length > 0) {
// Make a copy before mutating
val.labels = Object.assign({}, val.labels);

for (const labelName of defaultLabelNames) {
val.labels[labelName] =
val.labels[labelName] || this._defaultLabels[labelName];
}
}

let metricName = val.metricName || item.name;

const labelKeys = Object.keys(val.labels);
const labelSize = labelKeys.length;
if (labelSize > 0) {
let labels = '';
let i = 0;
for (; i < labelSize - 1; i++) {
labels += `${labelKeys[i]}="${escapeLabelValue(
val.labels[labelKeys[i]],
)}",`;
}
labels += `${labelKeys[i]}="${escapeLabelValue(
val.labels[labelKeys[i]],
)}"`;
metricName += `{${labels}}`;
}

values += `${metricName} ${getValueAsString(val.value)}`;

const exemplarKeys = Object.keys(val.labels);
const exemplarSize = exemplarKeys.length;
if (exemplarSize > 0) {
let exemplars = '';
let i = 0;
for (; i < exemplarSize - 1; i++) {
exemplars += `${exemplarKeys[i]}="${escapeLabelValue(
val.exemplars[exemplarKeys[i]],
)}",`;
}
exemplars += `${exemplarKeys[i]}="${escapeLabelValue(
val.exemplars[exemplarKeys[i]],
)}"`;
metricName += `# {${exemplars}}`;
}

values += `${metricName} 1\n`;
}

return `${help}\n${type}\n${values}\n${eof}\n`.trim();
}

async metrics() {
const promises = [];

Expand Down
17 changes: 9 additions & 8 deletions lib/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ exports.getValueAsString = function getValueString(value) {
}
};

exports.removeLabels = function removeLabels(hashMap, labels) {
const hash = hashObject(labels);
exports.removeLabels = function removeLabels(hashMap, labels, exemplars) {
const hash = hashObject(labels, exemplars);
delete hashMap[hash];
};

exports.setValue = function setValue(hashMap, value, labels) {
const hash = hashObject(labels);
exports.setValue = function setValue(hashMap, value, labels, exemplars) {
const hash = hashObject(labels, exemplars);
hashMap[hash] = {
value: typeof value === 'number' ? value : 0,
labels: labels || {},
Expand All @@ -45,11 +45,12 @@ exports.getLabels = function (labelNames, args) {
}, {});
};

function hashObject(labels) {
function hashObject(labels, exemplars) {
// We don't actually need a hash here. We just need a string that
// is unique for each possible labels object and consistent across
// calls with equivalent labels objects.
let keys = Object.keys(labels);
const aggregatedMetada = { ...labels, ...exemplars };
let keys = Object.keys(aggregatedMetada);
if (keys.length === 0) {
return '';
}
Expand All @@ -62,9 +63,9 @@ function hashObject(labels) {
let i = 0;
const size = keys.length;
for (; i < size - 1; i++) {
hash += `${keys[i]}:${labels[keys[i]]},`;
hash += `${keys[i]}:${aggregatedMetada[keys[i]]},`;
}
hash += `${keys[i]}:${labels[keys[i]]}`;
hash += `${keys[i]}:${aggregatedMetada[keys[i]]}`;
return hash;
}
exports.hashObject = hashObject;
Expand Down
23 changes: 23 additions & 0 deletions lib/validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const util = require('util');
// These are from https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels
const metricRegexp = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/;
const labelRegexp = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
const exemplarRegexp = /^[a-zA-Z_][a-zA-Z0-9_]*$/;

exports.validateMetricName = function (name) {
return metricRegexp.test(name);
Expand All @@ -20,6 +21,16 @@ exports.validateLabelName = function (names) {
return valid;
};

exports.validateExemplarName = function (names) {
let valid = true;
(names || []).forEach(name => {
if (!exemplarRegexp.test(name)) {
valid = false;
}
});
return valid;
};

exports.validateLabel = function validateLabel(savedLabels, labels) {
Object.keys(labels).forEach(label => {
if (savedLabels.indexOf(label) === -1) {
Expand All @@ -31,3 +42,15 @@ exports.validateLabel = function validateLabel(savedLabels, labels) {
}
});
};

exports.validateExemplar = function validateLabel(savedExemplars, exemplars) {
Object.keys(exemplars).forEach(exemplar => {
if (savedExemplars.indexOf(exemplar) === -1) {
throw new Error(
`Added label "${exemplar}" is not included in initial exemplarSet: ${util.inspect(
savedExemplars,
)}`,
);
}
});
};
2 changes: 2 additions & 0 deletions test/__snapshots__/counterTest.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

exports[`counter remove should throw error if label lengths does not match 1`] = `"Invalid number of arguments"`;

exports[`counter with params as object exemplars should throw error if exemplar lengths does not match 1`] = `"Invalid number of arguments"`;

exports[`counter with params as object labels should throw error if label lengths does not match 1`] = `"Invalid number of arguments"`;

exports[`counter with params as object should not be possible to decrease a counter 1`] = `"It is not possible to decrease a counter"`;
Expand Down
64 changes: 63 additions & 1 deletion test/counterTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,64 @@ describe('counter', () => {
expect(values[0].value).toEqual(100);
});
});

describe('exemplars', () => {
beforeEach(() => {
instance = new Counter({
name: 'gauge_test_2',
help: 'help',
exemplarNames: ['traceId', 'spanId'],
labelNames: ['method', 'endpoint'],
});
});

it('should handle 1 value per exemplar', async () => {
instance.exemplars('12345', '67890').inc();
// instance.exemplars('12345', '67890').inc()
// instance.inc();
instance.exemplars('54321', '09876').inc();

const values = (await instance.get()).values;
expect(values).toHaveLength(2);
});

it('should handle exemplars provided as an object', async () => {
instance.exemplars({ traceId: '12345', spanId: '67890' }).inc();
const values = (await instance.get()).values;
expect(values).toHaveLength(1);
expect(values[0].exemplars).toEqual({
traceId: '12345',
spanId: '67890',
});
});

it('should handle exemplars which are provided as arguments to inc()', async () => {
instance.inc({ method: 'GET', endpoint: '/test' }, 1, {
traceId: '12345',
spanId: '67890',
});
instance.inc({ method: 'POST', endpoint: '/test' }, 1, {
traceId: '54321',
spanId: '09876',
});

const values = (await instance.get()).values;
expect(values).toHaveLength(2);
});

it('should throw error if exemplar lengths does not match', () => {
const fn = function () {
instance.exemplars('12345').inc();
};
expect(fn).toThrowErrorMatchingSnapshot();
});

it('should increment exemplar value with provided value', async () => {
instance.exemplars('12345', '67890').inc(100);
const values = (await instance.get()).values;
expect(values[0].value).toEqual(100);
});
});
});

describe('remove', () => {
Expand Down Expand Up @@ -207,6 +265,7 @@ describe('counter', () => {
name: 'test_metric',
help: 'Another test metric',
labelNames: ['serial', 'active'],
exemplarNames: ['traceId', 'spanId'],
});

instance.inc({ serial: '12345', active: 'yes' }, 12);
Expand All @@ -218,7 +277,10 @@ describe('counter', () => {

expect((await instance.get()).values).toEqual([]);

instance.inc({ serial: '12345', active: 'no' }, 10);
instance.inc({ serial: '12345', active: 'no' }, 10, {
traceId: '12345',
spanId: '67890',
});
expect((await instance.get()).values[0].value).toEqual(10);
expect((await instance.get()).values[0].labels.serial).toEqual('12345');
expect((await instance.get()).values[0].labels.active).toEqual('no');
Expand Down
2 changes: 2 additions & 0 deletions test/registerTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ describe('register', () => {
values: [
{
labels: { env: 'development', type: 'myType' },
exemplars: {},
value: 1,
},
],
Expand All @@ -483,6 +484,7 @@ describe('register', () => {
values: [
{
labels: { env: 'development', type: 'myType' },
exemplars: {},
value: 2,
},
],
Expand Down

0 comments on commit e6f607b

Please sign in to comment.