diff --git a/.eslintrc.js b/.eslintrc.js index 19bddc01..0dd888b7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -214,7 +214,7 @@ module.exports = { "no-useless-rename": "error", "no-useless-return": "error", "no-var": "warn", - "no-void": "error", + "no-void": ["error", { "allowAsStatement": true }], "no-warning-comments": "off", "no-whitespace-before-property": "error", "nonblock-statement-body-position": "error", diff --git a/packages/lib/prometheus.js b/packages/lib/prometheus.js index a9f4650a..f6140044 100644 --- a/packages/lib/prometheus.js +++ b/packages/lib/prometheus.js @@ -1,6 +1,6 @@ "use strict"; class Metric { - constructor(type, name, help, labels = []) { + constructor(type, name, help, labels = [], _reserved_labels = []) { if (typeof name !== "string") { throw new Error("Expected name to be a string"); } @@ -16,6 +16,8 @@ class Metric { for (let label of labels) { if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(label)) { throw new Error(`Invalid label '${label}'`); + } else if (_reserved_labels.includes(label)) { + throw new Error(`Reserved label '${label}'`); } } @@ -120,14 +122,17 @@ function removeMatchingLabels(mapping, labels) { } } -class ValueCollector extends Collector { +class LabeledCollector extends Collector { constructor(type, name, help, options, childClass) { let labels = []; + let reservedLabels = []; let register = true; let callback = null; for (let [key, value] of Object.entries(options)) { if (key === "labels") { labels = value; + } else if (key === "_reservedLabels") { + reservedLabels = value; } else if (key === "register") { register = value; } else if (key === "callback") { @@ -138,32 +143,21 @@ class ValueCollector extends Collector { } // Make sure we don't register to the default registry if metric throws. - let metric = new Metric(type, name, help, labels); + let metric = new Metric(type, name, help, labels, reservedLabels); 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); + child = new this._childClass(this, key); this._children.set(key, child); } return child; @@ -175,7 +169,7 @@ class ValueCollector extends Collector { throw new Error("labels cannot be empty"); } this._children.delete(key); - this._values.delete(key); + return key; } removeAll(labels) { @@ -183,7 +177,6 @@ class ValueCollector extends Collector { throw new Error("labels cannot be empty"); } - removeMatchingLabels(this._values, labels); removeMatchingLabels(this._children, labels); } @@ -192,13 +185,45 @@ class ValueCollector extends Collector { throw new Error("Cannot clear unlabeled metric"); } this._children = new Map(); + } +} + +class ValueCollector extends LabeledCollector { + constructor(type, name, help, options, childClass) { + super(type, name, help, options, childClass); + + this._values = new Map(); + this._defaultChild = this.metric.labels.length ? null : this.labels({}); + } + + async* collect() { + if (this.callback) { await this.callback(this); } + yield { metric: this.metric, samples: new Map([["", this._values]]) }; + } + + get() { + return this._defaultChild.get(); + } + + remove(...labels) { + let key = super.remove(...labels); + this._values.delete(key); + } + + removeAll(labels) { + super.removeAll(labels); + removeMatchingLabels(this._values, labels); + } + + clear() { + super.clear(); this._values = new Map(); } } class ValueCollectorChild { - constructor(values, key) { - this._values = values; + constructor(collector, key) { + this._values = collector._values; this._key = key; this._values.set(key, 0); @@ -270,6 +295,181 @@ class Gauge extends ValueCollector { } } +function formatValue(value) { + if (value === Infinity) { + return "+Inf"; + } + if (value === -Infinity) { + return "-Inf"; + } + return value.toString(); +} + +function formatBucketKey(bucket, key) { + return `${key === "" ? "" : `${key},`}le="${formatValue(bucket)}"`; +} + +class HistogramChild { + constructor(collector, key) { + this._bucketValues = collector._bucketValues; + this._sumValues = collector._sumValues; + this._countValues = collector._countValues; + this._key = key; + + this._bucketKeys = new Map(); + for (let bucket of collector._buckets) { + let bucketKey = formatBucketKey(bucket, this._key); + + this._bucketKeys.set(bucket, bucketKey); + this._bucketValues.set(bucketKey, 0); + } + + this._sumValues.set(key, 0); + this._countValues.set(key, 0); + } + + get buckets() { + return new Map( + [...this._bucketKeys].map( + ([bucket, key]) => [bucket, this._bucketValues.get(key)] + ) + ); + } + + get sum() { + return this._sumValues.get(this._key); + } + + get count() { + return this._countValues.get(this._key); + } + + observe(value) { + for (let [bound, key] of this._bucketKeys) { + if (value <= bound) { + this._bucketValues.set(key, this._bucketValues.get(key) + 1); + } + } + + this._sumValues.set(this._key, this._sumValues.get(this._key) + value); + this._countValues.set(this._key, this._countValues.get(this._key) + 1); + } + + startTimer() { + let start = process.hrtime.bigint(); + return () => { + let end = process.hrtime.bigint(); + this.observe(Number(end - start) / 1e9); + }; + } +} + +class Histogram extends LabeledCollector { + constructor(name, help, options = {}) { + // These defaults are taken from the Python Prometheus client + let buckets = [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10, Infinity]; + + let parentOptions = { _reservedLabels: ["le"] }; + for (let [key, value] of Object.entries(options)) { + if (key === "buckets") { + buckets = value; + } else { + parentOptions[key] = value; + } + } + + super("histogram", name, help, parentOptions, HistogramChild); + + if (buckets.slice(-1)[0] !== Infinity) { + buckets = [...buckets, Infinity]; + } + + this._buckets = buckets; + this._bucketValues = new Map(); + this._sumValues = new Map(); + this._countValues = new Map(); + this._defaultChild = this.metric.labels.length ? null : this.labels({}); + } + + async* collect() { + if (this.callback) { await this.callback(this); } + yield { + metric: this.metric, + samples: new Map([ + ["_bucket", this._bucketValues], + ["_sum", this._sumValues], + ["_count", this._countValues], + ]), + }; + } + + get buckets() { + return this._defaultChild.buckets; + } + + get sum() { + return this._defaultChild.sum; + } + + get count() { + return this._defaultChild.count; + } + + observe(value) { + this._defaultChild.observe(value); + } + + startTimer() { + return this._defaultChild.startTimer(); + } + + remove(...labels) { + let key = super.remove(...labels); + for (let bucket of this._buckets) { + let bucketKey = formatBucketKey(bucket, key); + this._bucketValues.delete(bucketKey); + } + this._sumValues.delete(key); + this._countValues.delete(key); + } + + removeAll(labels) { + super.removeAll(labels); + for (let bucket of this._buckets) { + removeMatchingLabels( + this._bucketValues, { ...labels, le: formatValue(bucket) } + ); + } + removeMatchingLabels(this._sumValues, labels); + removeMatchingLabels(this._countValues, labels); + } + + clear() { + super.clear(); + this._bucketValues = new Map(); + this._sumValues = new Map(); + this._countValues = new Map(); + } +} + +Histogram.linear = function linear(start, width, count) { + let buckets = []; + for (let i = 0; i < count; i++) { + buckets.push(start); + start += width; + } + return buckets; +}; + +Histogram.exponential = function exponential(start, factor, count) { + let buckets = []; + for (let i = 0; i < count; i++) { + buckets.push(start); + start *= factor; + } + return buckets; +}; + class CollectorRegistry { constructor() { this.collectors = []; @@ -315,16 +515,6 @@ function escapeHelp(help) { ; } -function formatValue(value) { - if (value === Infinity) { - return "+Inf"; - } - if (value === -Infinity) { - return "-Inf"; - } - return value.toString(); -} - async function* expositionLines(resultsIterator) { let first = true; for await (let result of resultsIterator) { @@ -337,11 +527,13 @@ async function* expositionLines(resultsIterator) { 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`; + for (let [suffix, samples] of result.samples) { + for (let [key, value] of samples) { + if (key === "") { + yield `${result.metric.name}${suffix} ${formatValue(value)}\n`; + } else { + yield `${result.metric.name}${suffix}{${key}} ${formatValue(value)}\n`; + } } } } @@ -382,12 +574,16 @@ function serializeResult(result, options = {}) { } 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); + for (let [suffix, suffixSamples] of result.samples) { + let labeledSamples = new Map(); + for (let [labels, value] of suffixSamples) { + if (labels === "") { + labeledSamples.set(key, value); + } else { + labeledSamples.set(`${labels},${key}`, value); + } } + samples.set(suffix, labeledSamples); } } @@ -398,7 +594,7 @@ function serializeResult(result, options = {}) { help: metricHelp, labels: [...result.metric.labels, ...Object.keys(addLabels)], }, - samples: [...samples.entries()], + samples: [...samples].map(([name, metricSamples]) => [name, [...metricSamples]]), }; } @@ -410,7 +606,11 @@ function deserializeResult(serializedResult) { serializedResult.metric.help, serializedResult.metric.labels, ), - samples: new Map(serializedResult.samples), + samples: new Map( + serializedResult.samples.map( + ([suffix, suffixSamples]) => [suffix, new Map(suffixSamples)] + ) + ), }; } @@ -456,6 +656,7 @@ defaultCollectors.processStartTimeSeconds.setToCurrentTime(); module.exports = { Counter, Gauge, + Histogram, CollectorRegistry, Collector, ValueCollector, diff --git a/packages/master/master.js b/packages/master/master.js index 9aa24e2c..78b27d07 100755 --- a/packages/master/master.js +++ b/packages/master/master.js @@ -168,8 +168,17 @@ async function getMetrics(req, res, next) { } else { // Merge metrics received by multiple slaves - let stored = resultMap.get(result.metric.name); - stored.samples.push(...result.samples); + let receivedSamples = new Map(result.samples); + for (let [suffix, suffixSamples] of resultMap.get(result.metric.name).samples) { + if (receivedSamples.has(suffix)) { + suffixSamples.push(...receivedSamples.get(suffix)); + receivedSamples.delete(suffix); + } + } + + for (let entry of receivedSamples) { + result.samples.push(entry); + } } } } diff --git a/test/lib/prometheus.js b/test/lib/prometheus.js index adef36c5..6507c197 100644 --- a/test/lib/prometheus.js +++ b/test/lib/prometheus.js @@ -200,7 +200,194 @@ describe("lib/prometheus", function() { }); }); + describe("class Histogram", function() { + let histogram; + let labeledHistogram; + beforeEach(function() { + histogram = new prometheus.Histogram( + "test", "Help", { register: false, buckets: [1, 2, 3, 4, Infinity] } + ); + labeledHistogram = new prometheus.Histogram( + "test", "Help", { register: false, buckets: [1, Infinity], labels: ["a", "b"] } + ); + }); + + describe("constructor", function() { + it("should have buckets, sum and count initialized to 0", function() { + assert.deepEqual( + histogram.buckets, + new Map([[1, 0], [2, 0], [3, 0], [4, 0], [Infinity, 0]]) + ); + assert.equal(histogram.sum, 0); + assert.equal(histogram.count, 0); + }); + it("should provide a default for buckets", function() { + histogram = new prometheus.Histogram( + "test", "help", { register: false } + ); + assert(histogram._buckets.length, "no default buckets were made"); + }); + it("should add inifinity bucket if not present", function() { + histogram = new prometheus.Histogram( + "test", "help", { buckets: [1], register: false } + ); + assert.deepEqual(histogram._buckets, [1, Infinity]); + }); + it("should not mutate bucket option passed to it", function() { + let buckets = [1]; + histogram = new prometheus.Histogram( + "test", "help", { buckets, register: false } + ); + assert.deepEqual(buckets, [1]); + }); + it("should throw on unrecoginzed option", function() { + assert.throws( + () => new prometheus.Histogram("test", "help", { invalid: 1 }), + new Error("Unrecognized option 'invalid'") + ); + }); + it("should throw on using le as label", function() { + assert.throws(() => { + let test = new prometheus.Histogram( + "test", "Help", { register: false, labels: ["le", "test"] } + ); + }, new Error("Reserved label 'le'")); + }); + }); + + describe(".observe()", function() { + it("should increment the appropriate bucket", function() { + histogram.observe(2); + assert.deepEqual( + histogram.buckets, + new Map([[1, 0], [2, 1], [3, 1], [4, 1], [Infinity, 1]]) + ); + assert.equal(histogram.sum, 2); + assert.equal(histogram.count, 1); + + histogram.observe(4); + assert.deepEqual( + histogram.buckets, + new Map([[1, 0], [2, 1], [3, 1], [4, 2], [Infinity, 2]]) + ); + assert.equal(histogram.sum, 6); + assert.equal(histogram.count, 2); + }); + }); + + describe(".startTimer()", function() { + it("should add observations when closure is called", function() { + let observeDuration = histogram.startTimer(); + assert.equal(histogram.count, 0); + observeDuration(); + assert.equal(histogram.count, 1); + observeDuration(); + assert.equal(histogram.count, 2); + }); + }); + + describe("static .linear()", function() { + it("should give back the appropriate buckets", function() { + let linear = prometheus.Histogram.linear; + assert.deepEqual(linear(1, 2, 3), [1, 3, 5]); + assert.deepEqual(linear(-1, 4, 3), [-1, 3, 7]); + assert.deepEqual(linear(1, 1, 4), [1, 2, 3, 4]); + }); + }); + + describe("static .exponential()", function() { + it("should give back the appropriate buckets", function() { + let exponential = prometheus.Histogram.exponential; + assert.deepEqual(exponential(1, 2, 3), [1, 2, 4]); + assert.deepEqual(exponential(2, 10, 3), [2, 20, 200]); + assert.deepEqual(exponential(1, 1.5, 4), [1, 1.5, 2.25, 3.375]); + }); + }); + + describe(".labels()", function() { + it("should initialize a value for the label set", function() { + labeledHistogram.labels({ a: "1", b: "2" }); + assert(labeledHistogram._bucketValues.has('a="1",b="2",le="1"'), "bucket value not set"); + assert(labeledHistogram._bucketValues.has('a="1",b="2",le="+Inf"'), "bucket value not set"); + assert(labeledHistogram._sumValues.has('a="1",b="2"'), "sum value not set"); + assert(labeledHistogram._countValues.has('a="1",b="2"'), "count value not set"); + + labeledHistogram.labels("4", "5"); + assert(labeledHistogram._bucketValues.has('a="4",b="5",le="1"'), "bucket value not set"); + assert(labeledHistogram._bucketValues.has('a="4",b="5",le="+Inf"'), "bucket value not set"); + assert(labeledHistogram._sumValues.has('a="4",b="5"'), "sum value not set"); + assert(labeledHistogram._countValues.has('a="4",b="5"'), "count value not set"); + }); + it("should return a child supporting the histogram methods", function() { + let child = labeledHistogram.labels({ a: "1", b: "2" }); + void child.buckets; + void child.sum; + void child.count; + child.observe(2); + let observeDuration = child.startTimer(); + observeDuration(); + child = labeledHistogram.labels("7", "8"); + void child.buckets; + void child.sum; + void child.count; + child.observe(2); + observeDuration = child.startTimer(); + observeDuration(); + }); + it("should return the same child for the same labels", function() { + let child = labeledHistogram.labels("1", "2"); + assert.equal(labeledHistogram.labels("1", "2"), child); + assert.equal(labeledHistogram.labels({ a: "1", b: "2" }), child); + }); + }); + describe(".remove()", function() { + it("should remove a label set created with .labels()", function() { + labeledHistogram.labels("1", "2"); + labeledHistogram.remove("1", "2"); + assert(!labeledHistogram._bucketValues.has('a="1",b="2",le="1"'), "bucket value not removed"); + assert(!labeledHistogram._bucketValues.has('a="1",b="2",le="+Inf"'), "bucket value not removed"); + assert(!labeledHistogram._sumValues.has('a="1",b="2"'), "sum value not removed"); + assert(!labeledHistogram._countValues.has('a="1",b="2"'), "count value not removed"); + }); + }); + describe(".removeAll()", function() { + it("should remove a label set created with .labels()", function() { + labeledHistogram.labels("1", "2"); + labeledHistogram.labels("1", "3"); + labeledHistogram.labels("4", "6"); + labeledHistogram.removeAll({ a: "1" }); + assert.deepEqual( + labeledHistogram._bucketValues, + new Map([['a="4",b="6",le="1"', 0], ['a="4",b="6",le="+Inf"', 0]]) + ); + assert.deepEqual(labeledHistogram._sumValues, new Map([['a="4",b="6"', 0]])); + assert.deepEqual(labeledHistogram._countValues, new Map([['a="4",b="6"', 0]])); + }); + }); + describe(".clear()", function() { + it("should remove all label sets created with .labels()", function() { + labeledHistogram.labels("1", "2"); + labeledHistogram.labels("1", "3"); + labeledHistogram.labels("4", "6"); + labeledHistogram.clear(); + assert(labeledHistogram._bucketValues.size === 0, "buckets were not cleared"); + assert(labeledHistogram._sumValues.size === 0, "sums were not cleared"); + assert(labeledHistogram._countValues.size === 0, "counts were not cleared"); + }); + }); + }); + describe("class ValueCollector", function() { + 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("class LabeledCollector", function() { describe("constructor", function() { it("should throw on invalid metric name", function() { for (let name of ["", " ", "0a", "$a"]) { @@ -242,14 +429,6 @@ describe("lib/prometheus", function() { }); }); - 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() { @@ -310,6 +489,7 @@ describe("lib/prometheus", function() { it("should return the same child for the same labels", function() { let child = gauge.labels("1", "2"); assert.equal(gauge.labels("1", "2"), child); + assert.equal(gauge.labels({ a: "1", b: "2" }), child); }); }); describe(".remove()", function() { @@ -393,8 +573,9 @@ describe("lib/prometheus", function() { "collector did not give exactly one result" ); + let value = results[0].samples.get("").get(""); assert( - Math.abs(results[0].samples.get("") - Date.now() / 1000) < 1, + Math.abs(value - Date.now() / 1000) < 1, "value set is not close to the current unix time" ); }); @@ -413,7 +594,7 @@ describe("lib/prometheus", function() { "collector did not give exactly one result" ); - let value = results[0].samples.get(""); + let value = results[0].samples.get("").get(""); assert( value < 5 && value > 0, `process time is not between 0 and 5 seconds (${value})` @@ -434,7 +615,7 @@ describe("lib/prometheus", function() { "collector did not give exactly one result" ); - let value = results[0].samples.get(""); + let value = results[0].samples.get("").get(""); assert( value < 200e6 && value > 0, `resident memory is not between 0 and 200 MB (${value})` @@ -455,7 +636,7 @@ describe("lib/prometheus", function() { "collector did not give exactly one result" ); - let value = results[0].samples.get(""); + let value = results[0].samples.get("").get(""); assert( value < 200e6 && value > 0, `heap is not between 0 and 200 MB (${value})` @@ -500,6 +681,59 @@ describe("lib/prometheus", function() { 'test{a="x",b="y"} 0\n' ); }); + it("should format histogram metrics", async function() { + let registry = new prometheus.CollectorRegistry(); + let histogram = new prometheus.Histogram( + "test", "Help", { register: false, buckets: [1, 2, 3, 4] } + ); + for (let value of [2, 2, 5, 2]) { + histogram.observe(value); + } + registry.register(histogram); + let exposition = await prometheus.exposition(registry.collect()); + assert.equal( + exposition, + "# HELP test Help\n" + + "# TYPE test histogram\n" + + 'test_bucket{le="1"} 0\n' + + 'test_bucket{le="2"} 3\n' + + 'test_bucket{le="3"} 3\n' + + 'test_bucket{le="4"} 3\n' + + 'test_bucket{le="+Inf"} 4\n' + + "test_sum 11\n" + + "test_count 4\n" + ); + }); + it("should format histogram metrics with labels", async function() { + let registry = new prometheus.CollectorRegistry(); + let histogram = new prometheus.Histogram( + "test", "Help", { register: false, buckets: [1], labels: ["l"] } + ); + for (let [value, label] of [[1, "a"], [2, "b"], [3, "b"]]) { + histogram.labels(label).observe(value); + } + histogram.labels("c"); + + registry.register(histogram); + let exposition = await prometheus.exposition(registry.collect()); + assert.equal( + exposition, + "# HELP test Help\n" + + "# TYPE test histogram\n" + + 'test_bucket{l="a",le="1"} 1\n' + + 'test_bucket{l="a",le="+Inf"} 1\n' + + 'test_bucket{l="b",le="1"} 0\n' + + 'test_bucket{l="b",le="+Inf"} 2\n' + + 'test_bucket{l="c",le="1"} 0\n' + + 'test_bucket{l="c",le="+Inf"} 0\n' + + 'test_sum{l="a"} 1\n' + + 'test_sum{l="b"} 5\n' + + 'test_sum{l="c"} 0\n' + + 'test_count{l="a"} 1\n' + + 'test_count{l="b"} 2\n' + + 'test_count{l="c"} 0\n' + ); + }); it("should format Infinity and NaN", async function() { let registry = new prometheus.CollectorRegistry(); let gauge = new prometheus.Gauge( @@ -523,38 +757,64 @@ describe("lib/prometheus", function() { let serializedResult = { metric: { type: "type", name: "name", help: "Help", labels: ["a", "b"] }, - samples: [['a="1",b="2"', 1], ['a="3",b="4"', 5]], + 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]]), + samples: new Map([["", new Map([['a="1",b="2"', 1], ['a="3",b="4"', 5]])]]), }; describe("serializeResult()", function() { - it("should convert a result into plain object", function() { + it("should convert real results into the reference serialization", async function() { + let counter = new prometheus.Counter("a_counter", "Counter", { register: false }); + let gauge = new prometheus.Gauge("a_gauge", "Gauge", { register: false, labels: ["a", "b"] }); + let registry = new prometheus.CollectorRegistry(); + registry.register(counter); + registry.register(gauge); + + counter.inc(3); + gauge.labels("1", "2").set(1); + gauge.labels("3", "4").set(5); + + let results = []; + for await (let result of registry.collect()) { + results.push(result); + } + + let referenceSerialization = [ + { + metric: { type: "counter", name: "a_counter", help: "Counter", labels: [] }, + samples: [["", [["", 3]]]], + }, + { + metric: { type: "gauge", name: "a_gauge", help: "Gauge", labels: ["a", "b"] }, + samples: [["", [['a="1",b="2"', 1], ['a="3",b="4"', 5]]]], + }, + ]; + assert.deepEqual( - prometheus.serializeResult(deserializedResult), - serializedResult + results.map(result => prometheus.serializeResult(result)), + referenceSerialization ); }); it("should support adding labels", function() { assert.deepEqual( prometheus.serializeResult({ metric: new prometheus.Metric("type", "name", "Help"), - samples: new Map([["", 2]]), + samples: new Map([["name", new Map([["", 2]])]]), }, { addLabels: { c: "8" }}), { metric: { type: "type", name: "name", help: "Help", labels: ["c"] }, - samples: [['c="8"', 2]], + samples: [["name", [['c="8"', 2]]]], } ); assert.deepEqual( prometheus.serializeResult({ metric: new prometheus.Metric("type", "name", "Help", ["a"]), - samples: new Map([['a="4"', 3]]), + samples: new Map([["", new Map([['a="4"', 3]])]]), }, { addLabels: { c: "8" }}), { metric: { type: "type", name: "name", help: "Help", labels: ["a", "c"] }, - samples: [['a="4",c="8"', 3]], + samples: [["", [['a="4",c="8"', 3]]]], } ); }); @@ -562,21 +822,21 @@ describe("lib/prometheus", function() { assert.deepEqual( prometheus.serializeResult({ metric: new prometheus.Metric("type", "name", "Help"), - samples: new Map([["", 2]]), + samples: new Map([["", new Map([["", 2]])]]), }, { metricName: "new" }), { metric: { type: "type", name: "new", help: "Help", labels: [] }, - samples: [["", 2]], + samples: [["", [["", 2]]]], } ); assert.deepEqual( prometheus.serializeResult({ metric: new prometheus.Metric("type", "name", "Help"), - samples: new Map([["", 2]]), + samples: new Map([["", new Map([["", 2]])]]), }, { metricHelp: "old" }), { metric: { type: "type", name: "name", help: "old", labels: [] }, - samples: [["", 2]], + samples: [["", [["", 2]]]], } ); }); @@ -584,7 +844,7 @@ describe("lib/prometheus", function() { assert.throws( () => prometheus.serializeResult({ metric: new prometheus.Metric("type", "name", "Help"), - samples: new Map([["", 3]]), + samples: new Map([["", new Map([["", 3]])]]), }, { invalid: true }), new Error("Unrecognized option 'invalid'") ); @@ -600,6 +860,34 @@ describe("lib/prometheus", function() { }); }); + describe("integration", function() { + it("should round trip serialize histogram results", async function() { + let registry = new prometheus.CollectorRegistry(); + let histogram = new prometheus.Histogram( + "test", "Help", { register: false, buckets: [1], labels: ["l"] } + ); + for (let [value, label] of [[1, "a"], [2, "b"], [3, "b"]]) { + histogram.labels(label).observe(value); + } + histogram.labels("c"); + + registry.register(histogram); + let results = []; + for await (let result of registry.collect()) { + results.push(result); + } + let exposition = await prometheus.exposition(results); + + let serialized = results.map(res => prometheus.serializeResult(res)); + let jsonRoundtrip = JSON.parse(JSON.stringify(serialized)); + let deserialized = jsonRoundtrip.map(res => prometheus.deserializeResult(res)); + assert.deepEqual(deserialized, results); + let roundTripExposition = await prometheus.exposition(deserialized); + + assert.equal(roundTripExposition, exposition); + }); + }); + describe("post test checks", function() { it("should not have any collectors in the default registry", function() { this.skip(); // XXX Doesn't work in Clusterio's shared testing env