diff --git a/index.d.ts b/index.d.ts index 264a98d5..9b92c344 100644 --- a/index.d.ts +++ b/index.d.ts @@ -62,7 +62,7 @@ export class Registry { getSingleMetricAsString(name: string): Promise; /** - * 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; diff --git a/lib/counter.js b/lib/counter.js index 5ab8c9e9..0a964377 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,17 @@ 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); } /** @@ -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), }; } @@ -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; } 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..3fc053d8 100644 --- a/lib/registry.js +++ b/lib/registry.js @@ -16,6 +16,7 @@ class Registry { this._metrics = {}; this._collectors = []; this._defaultLabels = {}; + this._contentType = 'text/plain; version=0.0.4; charset=utf-8'; } getMetricsAsArray() { @@ -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`; @@ -88,6 +155,7 @@ class Registry { clear() { this._metrics = {}; this._defaultLabels = {}; + this._contentType = 'text/plain; version=0.0.4; charset=utf-8'; } async getMetricsAsJSON() { @@ -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) { 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..9345c07e 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,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`, + ); + } +}; diff --git a/test/__snapshots__/counterTest.js.snap b/test/__snapshots__/counterTest.js.snap index e68024c3..a31c36ad 100644 --- a/test/__snapshots__/counterTest.js.snap +++ b/test/__snapshots__/counterTest.js.snap @@ -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"`; diff --git a/test/counterTest.js b/test/counterTest.js index ff16e512..1ed43493 100644 --- a/test/counterTest.js +++ b/test/counterTest.js @@ -95,6 +95,122 @@ describe('counter', () => { const values = (await instance.get()).values; expect(values[0].value).toEqual(100); }); + + it('should not have exemplars if they are not provided', async () => { + instance.labels('GET', '/test').inc(); + const values = (await instance.get()).values; + expect(values[0].exemplars).toEqual(undefined); + expect(values[0].exemplarValue).toEqual(undefined); + }); + }); + + 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('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', + }); + expect(values[0].exemplarValue).toEqual(1); + }); + + it('should handle exemplars which are provided as arguments to inc()', async () => { + instance.inc( + { method: 'GET', endpoint: '/test' }, + 1, + { + traceId: '12345', + spanId: '67890', + }, + 1, + ); + instance.inc( + { method: 'POST', endpoint: '/test' }, + 1, + { + traceId: '54321', + spanId: '09876', + }, + 1, + ); + + const values = (await instance.get()).values; + expect(values).toHaveLength(2); + expect(values[0].exemplarValue).toEqual(1); + expect(values[1].exemplarValue).toEqual(1); + }); + + 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); + }); + + it('should set exemplar value', async () => { + instance.inc( + { method: 'GET', endpoint: '/test' }, + 1, + { + traceId: '12345', + spanId: '67890', + }, + 2, + ); + const values = (await instance.get()).values; + expect(values).toHaveLength(1); + expect(values[0].exemplarValue).toEqual(2); + }); + + it('should be able to create exemplars with an empty exemplar label set', async () => { + instance.inc({ method: 'GET', endpoint: '/test' }, 1, {}, 0.74); + const values = (await instance.get()).values; + expect(values).toHaveLength(1); + expect(values[0].exemplars).toEqual({}); + expect(values[0].exemplarValue).toEqual(0.74); + }); + + it('should throw error when exemplar and exemplar value combined is longer than 128 chars', async () => { + const fn = function () { + instance.inc( + { method: 'GET', endpoint: '/test' }, + 1, + { + traceId: + '93iefj93eijf93eijf39ejf93eji9fjeijf93ejfosdjff9ij9sdfsdf', + spanId: + 'bfiodia03irjokdnbdofigi03ijgoajsfbvb0dfijgigoadib78sdfsdf', + }, + 100000000, + ); + }; + expect(fn).toThrowErrorMatchingSnapshot(); + }); }); }); @@ -207,6 +323,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 +335,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..5f8475a1 100644 --- a/test/registerTest.js +++ b/test/registerTest.js @@ -32,6 +32,35 @@ describe('register', () => { }); }); + describe('should output a counter metric in extended exposition format', () => { + let output; + beforeEach(async () => { + register.contentType = 'application/openmetrics-text'; + register.registerMetric(getOpenMetrics()); + output = (await register.metrics()).split('\n'); + }); + + it('with help as first item', () => { + expect(output[0]).toEqual('# HELP test_metric A test metric'); + }); + it('with type as second item', () => { + expect(output[1]).toEqual('# TYPE test_metric counter'); + }); + it('with first value of the metric as third item', () => { + expect(output[2]).toEqual( + 'test_metric{label="hello",code="303"} 12 # {traceId="12345"} 1', + ); + }); + it('with second value of the metric as fourth item', () => { + expect(output[3]).toEqual( + 'test_metric{label="bye",code="404"} 34 # {traceId="67890"} 2', + ); + }); + it('with eof as last item', () => { + expect(output[4]).toEqual('# EOF'); + }); + }); + it('should throw on more than one metric', () => { register.registerMetric(getMetric()); @@ -459,6 +488,9 @@ describe('register', () => { myCounter.inc(); const metrics = await r.getMetricsAsJSON(); + expect(r.contentType).toEqual( + 'text/plain; version=0.0.4; charset=utf-8', + ); expect(metrics).toContainEqual({ aggregator: 'sum', help: 'my counter', @@ -468,6 +500,8 @@ describe('register', () => { { labels: { env: 'development', type: 'myType' }, value: 1, + // exemplars: {}, + // exemplarValue: undefined, }, ], }); @@ -475,6 +509,9 @@ describe('register', () => { myCounter.inc(); const metrics2 = await r.getMetricsAsJSON(); + expect(r.contentType).toEqual( + 'text/plain; version=0.0.4; charset=utf-8', + ); expect(metrics2).toContainEqual({ aggregator: 'sum', help: 'my counter', @@ -484,6 +521,8 @@ describe('register', () => { { labels: { env: 'development', type: 'myType' }, value: 2, + // exemplars: {}, + // exemplarValue: 1, }, ], }); @@ -638,4 +677,42 @@ describe('register', () => { }, }; } + + function getOpenMetrics(name) { + name = name || 'test_metric'; + return { + name, + async get() { + return { + name, + type: 'counter', + help: 'A test metric', + values: [ + { + value: 12, + labels: { + label: 'hello', + code: '303', + }, + exemplars: { + traceId: '12345', + }, + exemplarValue: 1, + }, + { + value: 34, + labels: { + label: 'bye', + code: '404', + }, + exemplars: { + traceId: '67890', + }, + exemplarValue: 2, + }, + ], + }; + }, + }; + } });