From e6f607b9135544c0ec93229c9fbbc78ed365233c Mon Sep 17 00:00:00 2001 From: Sandes de Silva Date: Thu, 1 Apr 2021 15:03:52 -0400 Subject: [PATCH] feat: add support for exemplars --- lib/counter.js | 33 ++++++++----- lib/metric.js | 10 +++- lib/registry.js | 65 ++++++++++++++++++++++++++ lib/util.js | 17 +++---- lib/validation.js | 23 +++++++++ test/__snapshots__/counterTest.js.snap | 2 + test/counterTest.js | 64 ++++++++++++++++++++++++- test/registerTest.js | 2 + 8 files changed, 195 insertions(+), 21 deletions(-) diff --git a/lib/counter.js b/lib/counter.js index 5ab8c9e9..07220d42 100644 --- a/lib/counter.js +++ b/lib/counter.js @@ -6,7 +6,7 @@ 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 { @@ -14,15 +14,16 @@ 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); } /** @@ -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), }; } @@ -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)}`); @@ -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; } diff --git a/lib/metric.js b/lib/metric.js index 95b04321..043be068 100644 --- a/lib/metric.js +++ b/lib/metric.js @@ -2,7 +2,11 @@ const { globalRegistry } = require('./registry'); const { isObject } = require('./util'); -const { validateMetricName, validateLabelName } = require('./validation'); +const { + validateMetricName, + validateLabelName, + validateExemplarName, +} = require('./validation'); /** * @abstract @@ -18,6 +22,7 @@ class Metric { labelNames: [], registers: [globalRegistry], aggregator: 'sum', + exemplarNames: [], }, defaults, config, @@ -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'); } diff --git a/lib/registry.js b/lib/registry.js index 77670f43..830b46ce 100644 --- a/lib/registry.js +++ b/lib/registry.js @@ -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 = []; diff --git a/lib/util.js b/lib/util.js index e5774b9e..daf1a28b 100644 --- a/lib/util.js +++ b/lib/util.js @@ -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 || {}, @@ -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 ''; } @@ -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; diff --git a/lib/validation.js b/lib/validation.js index 2e6db488..b0429fd6 100644 --- a/lib/validation.js +++ b/lib/validation.js @@ -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); @@ -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) { @@ -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, + )}`, + ); + } + }); +}; diff --git a/test/__snapshots__/counterTest.js.snap b/test/__snapshots__/counterTest.js.snap index e68024c3..7139cb8c 100644 --- a/test/__snapshots__/counterTest.js.snap +++ b/test/__snapshots__/counterTest.js.snap @@ -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"`; diff --git a/test/counterTest.js b/test/counterTest.js index ff16e512..76725bb7 100644 --- a/test/counterTest.js +++ b/test/counterTest.js @@ -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', () => { @@ -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); @@ -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'); diff --git a/test/registerTest.js b/test/registerTest.js index c45ca072..cea537f0 100644 --- a/test/registerTest.js +++ b/test/registerTest.js @@ -467,6 +467,7 @@ describe('register', () => { values: [ { labels: { env: 'development', type: 'myType' }, + exemplars: {}, value: 1, }, ], @@ -483,6 +484,7 @@ describe('register', () => { values: [ { labels: { env: 'development', type: 'myType' }, + exemplars: {}, value: 2, }, ],