Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for exemplars #432

Closed
wants to merge 2 commits into from
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ project adheres to [Semantic Versioning](http://semver.org/).
### Added

- feat: added `zero()` to `Histogram` for setting the metrics for a given label combination to zero
- feat: add support for exemplars

## [13.1.0] - 2021-01-24

Expand Down
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
38 changes: 38 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,30 @@ 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,
)}`,
);
}
});

// TODO both the value and the label set cannot be more than 128 chars
// exemplar length cannot be more than 128 UTF-8 characters
const exemplarLabelString = Object.entries(exemplars)
.join('')
.replace(/,/g, '');
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