From b4c782410101e8c23cba4b309336d5efdc93d50b Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Wed, 18 Dec 2019 23:08:33 +0100 Subject: [PATCH] Add homebrew Prometheus client Add an implementation of a Prometheus client. This is because the prom-client implementation seems to have missunderstood how Prometheus is supposed to work and doesn't allow updating collectors via asynchronous callback. --- lib/prometheus.js | 432 +++++++++++++++++++++++++++++++ test/lib/prometheus.js | 565 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 997 insertions(+) create mode 100644 lib/prometheus.js create mode 100644 test/lib/prometheus.js diff --git a/lib/prometheus.js b/lib/prometheus.js new file mode 100644 index 00000000..2f1386c8 --- /dev/null +++ b/lib/prometheus.js @@ -0,0 +1,432 @@ +class Metric { + constructor(type, name, help, labels = []) { + if (typeof name !== 'string') { + throw new Error("Expected name to be a string"); + } + + if (typeof help !== 'string') { + throw new Error("Expected help to be a string"); + } + + if (!/^[a-zA-Z_:][a-zA-Z0-9_:]*$/.test(name)) { + throw new Error(`Invalid name '${name}'`); + } + + for (let name of labels) { + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { + throw new Error(`Invalid label '${name}'`); + } + } + + this.name = name; + this.help = help; + this.type = type; + this.labels = labels; + } +} + +let defaultRegistry; + +class Collector { + constructor(register = true) { + if (register) { + defaultRegistry.register(this); + } + } + + async* collect() { } +} + +function escapeLabelValue(value) { + return value + .replace(/\\/g, "\\\\") + .replace(/\n/g, "\\n") + .replace(/"/g, "\\\"") + ; +} + +function labelsToKey(labels, metricLabels) { + let items = [] + if (labels.length === 1 && typeof labels[0] === "object") { + labels = labels[0]; + for (let name of metricLabels) { + if (!Object.hasOwnProperty.call(labels, name)) { + throw new Error(`Missing label '${name}'`); + } + if (typeof labels[name] !== "string") { + throw new Error( + `Expected value for label '${name}' to be a string` + ); + } + items.push(`${name}="${escapeLabelValue(labels[name])}"`); + } + + for (let name of Object.keys(labels)) { + if (!metricLabels.includes(name)) { + throw new Error(`Extra label '${name}'`); + } + } + + } else { + if (metricLabels.length > labels.length) { + throw new Error(`Missing label '${metricLabels[labels.length]}'`); + } + + if (labels.length > metricLabels.length) { + throw new Error(`Extra positional label`); + } + + for (let i=0; i < metricLabels.length; ++i) { + if (typeof labels[i] !== "string") { + throw new Error( + `Expected value for label '${metricLabels[i]}' to be a string` + ); + } + items.push(`${metricLabels[i]}="${escapeLabelValue(labels[i])}"`); + } + } + + return items.join(','); +} + +function keyToLabels(key) { + let labels = new Map(); + + if (key !== '') for (let pair of key.split(',')) { + let [name, value] = pair.split('=', 2); + labels.set(name, value + .slice(1, -1) + .replace(/\\"/g, "\"") + .replace(/\\n/g, "\n") + .replace(/\\\\/g, "\\") + ); + } + + return labels; +} + +class ValueCollector extends Collector { + constructor(type, name, help, options, childClass) { + let labels = []; + let register = true; + let callback = null; + for (let [name, value] of Object.entries(options)) { + if (name === 'labels') { labels = value; } + else if (name === 'register') { register = value; } + else if (name === 'callback') { callback = value; } + else { throw new Error(`Unrecognized option '${name}'`); } + } + + // Make sure we don't register to the default registry if metric throws. + let metric = new Metric(type, name, help, labels); + + super(register); + + this.callback = callback; + this.metric = metric; + this._values = new Map(); + this._children = new Map(); + this._childClass = childClass; + this._defaultChild = labels.length ? null : this.labels({}); + } + + async* collect() { + if (this.callback) { await this.callback(this); } + yield { metric: this.metric, samples: this._values }; + } + + get() { + return this._defaultChild.get(); + } + + labels(...labels) { + let key = labelsToKey(labels, this.metric.labels); + let child = this._children.get(key); + if (child === undefined) { + child = new this._childClass(this._values, key); + this._children.set(key, child); + } + return child; + } + + remove(...labels) { + let key = labelsToKey(labels, this.metric.labels); + if (key === '') { + throw new Error("labels cannot be empty"); + } + this._children.delete(key); + this._values.delete(key); + } + + removeAll(labels) { + if (!Object.keys(labels).length) { + throw new Error("labels cannot be empty"); + } + + function removeMatchingLabels(mapping, labels) { + for (let key of mapping.keys()) { + let candidate = keyToLabels(key); + let hasLabels = Object.entries(labels).every(([name, value]) => { + return candidate.get(name) === value; + }); + if (hasLabels) { + mapping.delete(key) + } + } + } + + removeMatchingLabels(this._values, labels); + removeMatchingLabels(this._children, labels); + } + + clear() { + if (!this.metric.labels.length) { + throw new Error("Cannot clear unlabeled metric"); + } + this._children = new Map(); + this._values = new Map(); + } +} + +class ValueCollectorChild { + constructor(values, key) { + this._values = values; + this._key = key; + + this._values.set(key, 0); + } + + get() { + return this._values.get(this._key); + } +} + +class CounterChild extends ValueCollectorChild { + inc(value = 1) { + // Note: Inverted to also catch NaN + if (!(value >= 0)) { + throw new Error("Expected value to be a positive number"); + } + + this._values.set(this._key, this._values.get(this._key) + value) + } +} + +class Counter extends ValueCollector { + constructor(name, help, options = {}) { + super("counter", name, help, options, CounterChild); + } + + inc(value = 1) { + this._defaultChild.inc(value); + } +} + +class GaugeChild extends ValueCollectorChild { + inc(value = 1) { + this._values.set(this._key, this._values.get(this._key) + value) + } + + dec(value = 1) { + this._values.set(this._key, this._values.get(this._key) - value) + } + + set(value) { + this._values.set(this._key, value) + } + + setToCurrentTime() { + this._values.set(this._key, Date.now() / 1000) + } +} + +class Gauge extends ValueCollector { + constructor(name, help, options = {}) { + super("gauge", name, help, options, GaugeChild); + } + + inc(value = 1) { + this._defaultChild.inc(value); + } + + dec(value = 1) { + this._defaultChild.dec(value); + } + + set(value) { + this._defaultChild.set(value); + } + + setToCurrentTime() { + this._defaultChild.setToCurrentTime(); + } +} + +class CollectorRegistry { + constructor() { + this.collectors = []; + } + + async* collect() { + for (let collector of this.collectors) { + for await (let result of collector.collect()) { + yield result; + } + } + } + + register(metric) { + let index = this.collectors.lastIndexOf(metric); + if (index !== -1) { + throw new Error( + "Collector is already registered in this registry." + ); + } + + this.collectors.push(metric); + } + + unregister(metric) { + let index = this.collectors.lastIndexOf(metric); + if (index === -1) { + throw new Error( + "Collector is not registered in this registry." + ); + } + + this.collectors.splice(index, 1); + } +} + +defaultRegistry = new CollectorRegistry(); + +function escapeHelp(help) { + return help + .replace(/\\/g, "\\\\") + .replace(/\n/g, "\\n") + ; +} + +function formatValue(value) { + if (value === Infinity) { + return '+Inf'; + } else if (value === -Infinity) { + return '-Inf'; + } else { + return value.toString(); + } +} + +async function* expositionLines(resultsIterator) { + let first = true; + for await (let result of resultsIterator) { + if (first) { + first = false; + } else { + yield `\n` + } + + yield `# HELP ${result.metric.name} ${escapeHelp(result.metric.help)}\n`; + yield `# TYPE ${result.metric.name} ${result.metric.type}\n`; + + for (let [key, value] of result.samples) { + if (key === '') { + yield `${result.metric.name} ${formatValue(value)}\n`; + } else { + yield `${result.metric.name}{${key}} ${formatValue(value)}\n`; + } + } + } +} + +async function exposition(resultsIterator = defaultRegistry.collect()) { + let exposition = ''; + for await (let line of expositionLines(resultsIterator)) { + exposition += line; + } + return exposition; +} + +// HTTP Content-Type for the exposition format that's implemented +exposition.contentType = 'text/plain; version=0.0.4'; + +function serializeResult(result, options = {}) { + let addLabels = null; + let metricName = result.metric.name; + let metricHelp = result.metric.help; + for (let [name, value] of Object.entries(options)) { + if (name === 'addLabels') { addLabels = value; } + else if (name === 'metricName') { metricName = value; } + else if (name === 'metricHelp') { metricHelp = value; } + else { throw new Error(`Unrecognized option '${name}'`); } + } + + let samples; + if (addLabels === null) { + samples = result.samples; + addLabels = {}; + + } else { + samples = new Map(); + let key = labelsToKey([addLabels], [...Object.keys(addLabels)]); + for (let [labels, value] of result.samples.entries()) { + if (labels === '') { samples.set(key, value); } + else { samples.set(`${labels},${key}`, value); } + } + } + + return { + metric: { + type: result.metric.type, + name: metricName, + help: metricHelp, + labels: [...result.metric.labels, ...Object.keys(addLabels)], + }, + samples: [...samples.entries()], + }; +} + +function deserializeResult(serializedResult) { + return { + metric: new Metric( + serializedResult.metric.type, + serializedResult.metric.name, + serializedResult.metric.help, + serializedResult.metric.labels, + ), + samples: new Map(serializedResult.samples), + } +} + +let defaultCollectors = {}; +defaultCollectors.processCpuSecondsTotal = new Gauge( + 'process_cpu_seconds', "Total user and system CPU time spent in seconds.", + { + callback: async function() { + let usage = process.cpuUsage(); + this.set((usage.user + usage.system) / 1e6); + }, + }, +); + +defaultCollectors.processStartTimeSeconds = new Gauge( + 'process_start_time_seconds', + "Start time of the process since unix epoch in seconds." +); +defaultCollectors.processStartTimeSeconds.setToCurrentTime(); + + +module.exports = { + Counter, + Gauge, + CollectorRegistry, + Collector, + ValueCollector, + Metric, + + exposition, + defaultRegistry, + defaultCollectors, + serializeResult, + deserializeResult, +}; diff --git a/test/lib/prometheus.js b/test/lib/prometheus.js new file mode 100644 index 00000000..4b2fd911 --- /dev/null +++ b/test/lib/prometheus.js @@ -0,0 +1,565 @@ +const assert = require('assert').strict; + +const prometheus = require('lib/prometheus'); + + +describe("lib/prometheus", function() { + describe("class CollectorRegistry", function() { + let registry; + beforeEach(function() { + registry = new prometheus.CollectorRegistry(); + }) + + describe(".register()", function() { + it("should register a collector", function() { + let collector = new prometheus.Collector(false); + registry.register(collector); + assert( + registry.collectors.includes(collector), + "Collector was not registered" + ); + }); + it("should throw when already registered", function() { + let collector = new prometheus.Collector(false); + registry.register(collector); + assert.throws( + () => registry.register(collector), + new Error("Collector is already registered in this registry.") + ); + }); + it("should allow being regitstered in another register", function() { + let otherCollectorRegistry = new prometheus.CollectorRegistry(); + let collector = new prometheus.Collector(false); + registry.register(collector); + otherCollectorRegistry.register(collector); + assert( + registry.collectors.includes(collector), + "Collector was not registered in first register" + ); + assert( + otherCollectorRegistry.collectors.includes(collector), + "Collector was not registered in second register" + ); + }); + }); + describe(".unregister()", function() { + it("should removed a registered collector", function() { + let collector = new prometheus.Collector(false); + registry.register(collector); + registry.unregister(collector); + assert( + !registry.collectors.includes(collector), + "Collector was not removed" + ); + }); + it("should throw when not registered", function() { + let collector = new prometheus.Collector(false); + assert.throws( + () => registry.unregister(collector), + new Error("Collector is not registered in this registry.") + ); + }); + }); + + describe("defaultRegistry", function() { + it("should have collectors registered to it by default", function() { + let collector = new prometheus.Collector(); + assert( + prometheus.defaultRegistry.collectors.includes(collector), + "collector was not registered to defaultRegistry" + ); + prometheus.defaultRegistry.unregister(collector); + }); + it("should have the default collectors registered to it", function() { + let collectors = Object.entries(prometheus.defaultCollectors); + for (let [name, collector] of collectors) { + assert( + prometheus.defaultRegistry.collectors.includes(collector), + `${name} is not registered` + ); + } + }); + it("should allow default collectors to be unregistered", function() { + let collectors = Object.values(prometheus.defaultCollectors); + for (let collector of collectors) { + prometheus.defaultRegistry.unregister(collector); + } + assert( + prometheus.defaultRegistry.collectors.length == 0, + "Collectors left over after removing the default ones" + ); + }); + }); + }); + + describe("class Collector", function() { + it("should implement .collect()", async function() { + let collector = new prometheus.Collector(false); + for await (let result of collector.collect()) { + assert.fail("Should not return any results"); + } + }); + }); + + describe("class Counter", function() { + let counter; + beforeEach(function() { + counter = new prometheus.Counter('test', "Help", { register: false }); + }); + + describe("constructor", function() { + it("should be initialized to 0", function() { + assert.equal(counter._values.get(''), 0); + }); + }); + describe(".inc()", function() { + it("should increment the counter by 1 by default", function() { + counter.inc(); + assert.equal(counter._values.get(''), 1); + }); + it("should increment the counter by specified value", function() { + counter.inc(3.2); + assert.equal(counter._values.get(''), 3.2); + }); + it("should throw if value is negative", function() { + assert.throws( + () => counter.inc(-1), + new Error("Expected value to be a positive number") + ); + }); + it("should throw if value is negative infinity", function() { + assert.throws( + () => counter.inc(-Infinity), + new Error("Expected value to be a positive number") + ); + }); + it("should throw if value is NaN", function() { + assert.throws( + () => counter.inc(NaN), + new Error("Expected value to be a positive number") + ); + }); + }); + }); + + describe("class Gauge", function() { + let gauge; + beforeEach(function() { + gauge = new prometheus.Gauge('test', "Help", { register: false }); + }); + + describe("constructor", function() { + it("should be initialized to 0", function() { + assert.equal(gauge._values.get(''), 0); + }); + }); + describe(".inc()", function() { + it("should increment the gauge by 1 by default", function() { + gauge.inc(); + assert.equal(gauge._values.get(''), 1); + }); + it("should increment the gauge by specified value", function() { + gauge.inc(3.2); + assert.equal(gauge._values.get(''), 3.2); + }); + it("should decrement on negative value", function() { + gauge.inc(-2); + assert.equal(gauge._values.get(''), -2); + }); + }); + describe(".dec()", function() { + it("should decrement the gauge by 1 by default", function() { + gauge.dec(); + assert.equal(gauge._values.get(''), -1); + }); + it("should decrement the gauge by specified value", function() { + gauge.dec(3.2); + assert.equal(gauge._values.get(''), -3.2); + }); + it("should increment on negative value", function() { + gauge.dec(-2); + assert.equal(gauge._values.get(''), 2); + }); + }); + describe(".set()", function() { + it("should set the gauge to the specified value", function() { + gauge.set(7); + assert.equal(gauge._values.get(''), 7); + }); + }); + describe(".setToCurrentTime()", function() { + it("should set the gauge to current unix time in seconds", function() { + gauge.setToCurrentTime(); + assert( + Math.abs(gauge._values.get('') - Date.now() / 1000) < 1, + "value set is not close to the current unix time" + ); + }); + }); + }); + + describe("class ValueCollector", function() { + describe("constructor", function() { + it("should throw on invalid metric name", function() { + for (let name of ["", " ", "0a", "$a"]) { + assert.throws( + () => new prometheus.Gauge( + name, "Help", { register: false } + ), + new Error(`Invalid name '${name}'`) + ) + } + }); + it("should throw on invalid label names", function() { + for (let name of ["", " ", "0a", "$a", "a:"]) { + assert.throws( + () => new prometheus.Gauge( + 'test', "Help", { register: false, labels: [name] } + ), + new Error(`Invalid label '${name}'`) + ) + } + }); + it("should throw on missing name", function() { + assert.throws( + () => new prometheus.Gauge(), + new Error("Expected name to be a string") + ); + }); + it("should throw on missing help", function() { + assert.throws( + () => new prometheus.Gauge("test"), + new Error("Expected help to be a string") + ); + }); + it("should throw on unrecoginzed option", function() { + assert.throws( + () => new prometheus.Gauge("test", "help", { invalid: 1 }), + new Error("Unrecognized option 'invalid'") + ); + }); + }); + + describe(".get()", function() { + it("should return the current value", function() { + let gauge = new prometheus.Gauge('test', "Help", { register: false }); + gauge.set(11); + assert.equal(gauge.get(), 11); + }); + }); + + describe(".labels()", function() { + let gauge; + beforeEach(function() { + gauge = new prometheus.Gauge( + 'test', "Help", { register: false, labels: ['a', 'b'] } + ); + }); + it("should require all labels set at creation", function() { + assert.throws( + () => gauge.labels({a: "1"}), + new Error("Missing label 'b'") + ); + assert.throws( + () => gauge.labels({b: "1"}), + new Error("Missing label 'a'") + ); + assert.throws( + () => gauge.labels({a: '1', b: '2', c: '3'}), + new Error("Extra label 'c'") + ); + assert.throws( + () => gauge.labels('1'), + new Error("Missing label 'b'") + ); + assert.throws( + () => gauge.labels('1', '2', '3'), + new Error("Extra positional label") + ); + }); + it("should throw if label value is not a string", function() { + assert.throws( + () => gauge.labels({a: "1", b: 2}), + new Error("Expected value for label 'b' to be a string") + ); + assert.throws( + () => gauge.labels("1", 2), + new Error("Expected value for label 'b' to be a string") + ); + }); + it("should initialize a value for the label set", function() { + gauge.labels({ a: '1', b: '2'}); + assert(gauge._values.has('a="1",b="2"'), "value was not set"); + gauge.labels('4', '5'); + assert(gauge._values.has('a="4",b="5"'), "value was not set"); + }); + it("should return a child supporting the usual methods", function() { + let child = gauge.labels({ a: '1', b: '2'}); + child.get(); + child.inc(); + child.dec(); + child.setToCurrentTime(); + child = gauge.labels('7', '8'); + child.get(); + child.inc(); + child.dec(); + child.setToCurrentTime(); + }); + it("should return the same child for the same labels", function() { + let child = gauge.labels('1', '2'); + assert.equal(gauge.labels('1', '2'), child); + }); + }); + describe(".remove()", function() { + it("should remove a label set created with .label()", function() { + let gauge = new prometheus.Gauge( + 'test', "Help", { register: false, labels: ['a', 'b'] } + ); + gauge.labels({ a: '1', b: '2'}); + gauge.remove({ a: '1', b: '2'}); + assert(!gauge._values.has('a="1",b="2"'), "value was not removed"); + }); + it("should throw when removing the empty label set", function() { + let gauge = new prometheus.Gauge('test', "Help", { register: false }); + assert.throws( + () => gauge.remove({}), new Error("labels cannot be empty") + ); + }); + }); + describe(".removeAll()", function() { + it("should remove a label set created with .label()", function() { + let gauge = new prometheus.Gauge( + 'test', "Help", { register: false, labels: ['a', 'b'] } + ); + gauge.labels('1', '2'); + gauge.labels('1', '3'); + gauge.labels('4', '6'); + gauge.removeAll({a: '1'}); + assert(gauge._values.size <= 1, "Matching labels remained") + assert( + gauge._values.has('a="4",b="6"'), + "Non-matching label was removed" + ); + }); + it("should throw when removing the empty label set", function() { + let gauge = new prometheus.Gauge('test', "Help", { register: false }); + assert.throws( + () => gauge.removeAll({}), new Error("labels cannot be empty") + ); + }); + it("should handle special characters", function() { + let gauge = new prometheus.Gauge( + 'test', "Help", { register: false, labels: ['a', 'b'] } + ) + gauge.labels('x\\\\\n\"\"\n\\', '2'); + gauge.removeAll({a: 'x\\\\\n\"\"\n\\'}); + assert(gauge._values.size === 0, "Matching labels remained") + }); + }); + describe(".clear()", function() { + it("should remove all label sets created with .label()", function() { + let gauge = new prometheus.Gauge( + 'test', "Help", { register: false, labels: ['a', 'b'] } + ); + gauge.labels({ a: '1', b: '2'}); + gauge.labels({ a: '3', b: '4'}); + gauge.labels({ a: '5', b: '6'}); + gauge.clear(); + assert(gauge._values.size == 0, "values was not cleared"); + }); + it("should throw when used on an unlabled metric", function() { + let gauge = new prometheus.Gauge('test', "Help", { register: false }); + assert.throws( + () => gauge.clear(), new Error("Cannot clear unlabeled metric") + ); + }); + }); + }); + + describe("defaultCollectors", function() { + describe("processStartTimeSeconds", function() { + it("should give a close point in time", async function() { + let results = [] + let collector = prometheus.defaultCollectors.processStartTimeSeconds; + for await (let result of collector.collect()) { + results.push(result); + } + + assert( + results.length == 1, + "collector did not give exactly one result" + ); + + assert( + Math.abs(results[0].samples.get('') - Date.now() / 1000) < 1, + "value set is not close to the current unix time" + ); + }); + }); + describe("processCpuSecondsTotal", function() { + it("should give a low value", async function() { + let results = [] + let collector = prometheus.defaultCollectors.processCpuSecondsTotal; + for await (let result of collector.collect()) { + results.push(result); + } + + assert( + results.length == 1, + "collector did not give exactly one result" + ); + + let value = results[0].samples.get('') + assert( + value < 5 && value > 0, + `process time is not between 0 and 5 seconds (${value})` + ); + }); + }); + }); + + describe("exposition()", function() { + it("should format simple metrics", async function() { + let registry = new prometheus.CollectorRegistry(); + let counter = new prometheus.Counter('a', "Help", { register: false }); + registry.register(counter); + let gauge = new prometheus.Gauge('b', "Help", { register: false }); + registry.register(gauge); + let exposition = await prometheus.exposition(registry.collect()); + assert.equal( + exposition, + '# HELP a Help\n' + + '# TYPE a counter\n' + + 'a 0\n' + + '\n' + + '# HELP b Help\n' + + '# TYPE b gauge\n' + + 'b 0\n' + ); + }); + it("should format labeled metrics", async function() { + let registry = new prometheus.CollectorRegistry(); + let gauge = new prometheus.Gauge( + 'test', "Help", { register: false, labels: ['a', 'b']} + ); + gauge.labels({a: '1', b: '2'}); + gauge.labels({b: 'y', a: 'x'}); + registry.register(gauge); + let exposition = await prometheus.exposition(registry.collect()); + assert.equal( + exposition, + '# HELP test Help\n' + + '# TYPE test gauge\n' + + 'test{a="1",b="2"} 0\n' + + 'test{a="x",b="y"} 0\n' + ); + }); + it("should format Infinity and NaN", async function() { + let registry = new prometheus.CollectorRegistry(); + let gauge = new prometheus.Gauge( + 'test', "Help", { register: false, labels: ['n']} + ); + gauge.labels({n: '1'}).set(Infinity); + gauge.labels({n: '2'}).set(-Infinity); + gauge.labels({n: '3'}).set(NaN); + registry.register(gauge); + let exposition = await prometheus.exposition(registry.collect()); + assert.equal( + exposition, + '# HELP test Help\n' + + '# TYPE test gauge\n' + + 'test{n="1"} +Inf\n' + + 'test{n="2"} -Inf\n' + + 'test{n="3"} NaN\n' + ); + }); + }); + + let serializedResult = { + metric: { type: 'type', name: 'name', help: "Help", labels: ['a', 'b'] }, + samples: [ ['a="1",b="2"', 1], ['a="3",b="4"', 5], ] + }; + let deserializedResult = { + metric: new prometheus.Metric('type', 'name', "Help", ['a', 'b']), + samples: new Map([['a="1",b="2"', 1], ['a="3",b="4"', 5]]), + }; + describe("serializeResult()", function() { + it("should convert a result into plain object", function() { + assert.deepEqual( + prometheus.serializeResult(deserializedResult), + serializedResult + ) + }); + it("should support adding labels", function() { + assert.deepEqual( + prometheus.serializeResult({ + metric: new prometheus.Metric('type', 'name', "Help"), + samples: new Map([['', 2]]), + }, { addLabels: { c: '8' }}), + { + metric: {type: 'type', name: 'name', help: "Help", labels: ['c']}, + samples: [['c="8"', 2]], + } + ); + assert.deepEqual( + prometheus.serializeResult({ + metric: new prometheus.Metric('type', 'name', "Help", ['a']), + samples: new Map([['a="4"', 3]]), + }, { addLabels: { c: '8' }}), + { + metric: {type: 'type', name: 'name', help: "Help", labels: ['a', 'c']}, + samples: [['a="4",c="8"', 3]], + } + ); + }); + it("should support redefining name and help", function() { + assert.deepEqual( + prometheus.serializeResult({ + metric: new prometheus.Metric('type', 'name', "Help"), + samples: new Map([['', 2]]), + }, { metricName: 'new', }), + { + metric: {type: 'type', name: 'new', help: "Help", labels: []}, + samples: [['', 2]], + } + ); + assert.deepEqual( + prometheus.serializeResult({ + metric: new prometheus.Metric('type', 'name', "Help"), + samples: new Map([['', 2]]), + }, { metricHelp: "old", }), + { + metric: {type: 'type', name: 'name', help: "old", labels: []}, + samples: [['', 2]], + } + ); + }); + it("should throw on unrecoginzed option", function() { + assert.throws( + () => new prometheus.serializeResult({ + metric: new prometheus.Metric('type', 'name', "Help"), + samples: new Map([['', 3]]), + }, { invalid: true }), + new Error("Unrecognized option 'invalid'") + ); + }); + }); + + describe("deserializeResult()", function() { + it("should convert a plain object into a result", function() { + assert.deepEqual( + prometheus.deserializeResult(serializedResult), + deserializedResult + ) + }); + }); + + describe("post test checks", function() { + it("should not have any collectors in the default registry", function() { + assert( + prometheus.defaultRegistry.collectors.length == 0, + "Collectors left over by test code" + ); + }); + }); +});