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 authored and samarara committed Apr 8, 2021
1 parent 96f7495 commit 198d9d7
Show file tree
Hide file tree
Showing 9 changed files with 372 additions and 27 deletions.
2 changes: 1 addition & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export class Registry {
getSingleMetricAsString(name: string): Promise<string>;

/**
* Gets the Content-Type of the metrics for use in the response headers.
* Gets or Sets the Content-Type of the metrics for use in the response headers.
*/
contentType: string;

Expand Down
50 changes: 39 additions & 11 deletions lib/counter.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,25 @@
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
* @param {Number} exemplarValue - Value to be set as the exemplar value
* @returns {void}
*/
inc(labels, value) {
inc(labels, value, exemplars, exemplarValue) {
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, exemplarValue)(value);
}

/**
Expand Down Expand Up @@ -50,9 +52,19 @@ 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) || {};
const exemplarValue = this.exemplarValue || 1;
validateExemplar(this.exemplarNames, exemplars, exemplarValue);
const hash = hashObject({}, exemplars);
return {
inc: inc.call(this, null, exemplars, hash, exemplarValue),
};
}

Expand All @@ -71,30 +83,46 @@ const reset = function () {
}
};

const inc = function (labels, hash) {
const inc = function (labels, exemplars, hash, exemplarValue) {
return value => {
if (value && !Number.isFinite(value)) {
throw new TypeError(`Value is not a valid number: ${util.format(value)}`);
}
if (exemplarValue && !Number.isFinite(exemplarValue)) {
throw new TypeError(
`Exemplar value is not a valid number: ${util.format(exemplarValue)}`,
);
}
if (value < 0) {
throw new Error('It is not possible to decrease a counter');
}

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

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

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

function setValue(hashMap, value, labels, hash) {
function setValue(hashMap, value, exemplarValue, labels, exemplars, hash) {
hash = hash || '';
if (hashMap[hash]) {
hashMap[hash].value += value;
} else {
hashMap[hash] = { value, labels: labels || {} };
hashMap[hash] = exemplarValue
? { value, labels: labels || {}, exemplars, exemplarValue }
: { value, labels: labels || {} };
}
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
82 changes: 77 additions & 5 deletions lib/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class Registry {
this._metrics = {};
this._collectors = [];
this._defaultLabels = {};
this._contentType = 'text/plain; version=0.0.4; charset=utf-8';
}

getMetricsAsArray() {
Expand Down Expand Up @@ -63,13 +64,79 @@ class Registry {
return `${help}\n${type}\n${values}`.trim();
}

async metrics() {
const promises = [];
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);

for (const metric of this.getMetricsAsArray()) {
promises.push(this.getMetricAsPrometheusString(metric));
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}}`;
}

const exemplarKeys = Object.keys(val.exemplars);
const exemplarSize = exemplarKeys.length;
let exemplars = '';
if (exemplarSize > 0) {
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 += ` ${getValueAsString(val.value)} # {${exemplars}}`;
}
values += `${metricName} ${getValueAsString(val.exemplarValue)}\n`;
}

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

async metrics() {
const promises = [];
if (this.contentType === 'application/openmetrics-text') {
for (const metric of this.getMetricsAsArray()) {
promises.push(this.getMetricAsPrometheusOpenMetricsString(metric));
}
} else {
for (const metric of this.getMetricsAsArray()) {
promises.push(this.getMetricAsPrometheusString(metric));
}
}
const resolves = await Promise.all(promises);

return `${resolves.join('\n\n')}\n`;
Expand All @@ -88,6 +155,7 @@ class Registry {
clear() {
this._metrics = {};
this._defaultLabels = {};
this._contentType = 'text/plain; version=0.0.4; charset=utf-8';
}

async getMetricsAsJSON() {
Expand Down Expand Up @@ -144,7 +212,11 @@ class Registry {
}

get contentType() {
return 'text/plain; version=0.0.4; charset=utf-8';
return this._contentType;
}

set contentType(type) {
this._contentType = type;
}

static merge(registers) {
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
35 changes: 35 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,27 @@ exports.validateLabel = function validateLabel(savedLabels, labels) {
}
});
};

exports.validateExemplar = function validateLabel(
savedExemplars,
exemplars,
exemplarValue,
) {
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,
)}`,
);
}
});

// exemplar length cannot be more than 128 UTF-8 characters
const exemplarLabelString = Object.entries(exemplars).flat().join('');
if ((exemplarLabelString + exemplarValue).length > 128) {
throw new Error(
`Exemplar label set cannot exceed 128 UTF-8 characters: current label set has ${exemplars.length} characters`,
);
}
};
4 changes: 4 additions & 0 deletions test/__snapshots__/counterTest.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

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 exemplars should throw error when exemplar and exemplar value combined is longer than 128 chars 1`] = `"Exemplar label set cannot exceed 128 UTF-8 characters: current label set has undefined characters"`;

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
Loading

0 comments on commit 198d9d7

Please sign in to comment.