diff --git a/packages/lib/prometheus.js b/packages/lib/prometheus.js index cc167625..aa96efa9 100644 --- a/packages/lib/prometheus.js +++ b/packages/lib/prometheus.js @@ -9,9 +9,8 @@ * The ordinary use case of instrumenting a code base should be covered by * {@link module:lib/prometheus.Counter}, {@link * module:lib/prometheus.Gauge}, {@link module:lib/prometheus.Histogram} and - * {@link module:lib/prometheus.exposition}. Note that the summary - * collector is not implemented. See documentation for each of the listed - * interfaces for more information. + * {@link module:lib/prometheus.exposition}. See documentation for each of + * the listed interfaces for more information. * * @example * const { Counter, exposition } = require("@clusterio/lib/prometheus"); @@ -802,7 +801,7 @@ class GaugeChild extends ValueCollectorChild { * to avoid having the time of many collectors add up to too much. * * Like with the Counter the Gauge also supports labels. When using labels - * the methods for changing the value of the counter is not longer usable + * the methods for changing the value of the counter is no longer usable * directly on the counter itself, instead a child counter with label values * set has to be retrieved with the `.labels()` method. For example: * @@ -919,6 +918,232 @@ class Gauge extends ValueCollector { } } +/** + * Child Summary holding the sum and count for a single label set. + */ +class SummaryChild { + constructor(collector, key) { + this._sumValues = collector._sumValues; + this._countValues = collector._countValues; + this._key = key; + + this._sumValues.set(key, 0); + this._countValues.set(key, 0); + } + + /** + * Sum of all observations for label set + * @type {number} + */ + get sum() { + return this._sumValues.get(this._key); + } + + /** + * Count of observations for label set + * @type {number} + */ + get count() { + return this._countValues.get(this._key); + } + + /** + * Observe a given value and increment matching buckets for label set + * + * @param {number} value - number to count into summary. + */ + observe(value) { + this._sumValues.set(this._key, this._sumValues.get(this._key) + value); + this._countValues.set(this._key, this._countValues.get(this._key) + 1); + } + + /** + * Start a timer for observing a duration for label set + * + * @returns {function()} + * function that when called will store the duration in seconds from + * when the timer was started into the metric. + */ + startTimer() { + let start = process.hrtime.bigint(); + return () => { + let end = process.hrtime.bigint(); + this.observe(Number(end - start) / 1e9); + }; + } +} + +/** + * Summary metric + * + * Sample observations into a count and sum. This is useful when you have + * an operation that reports a metric that you want insight into the rate + * and average size of values for. A common case for this is request + * duration, for example: + * + * ```js + * const requestDuration = new Summary( + * "app_request_duration_seconds", + * "Time to process app requests", + * ); + * + * async function handleRequest(...) { + * const observeDuration = requestDuration.startTimer(); + * try { + * // Do async stuff. + * } finally { + * observeDuration(); + * } + * } + * ``` + * + * Note that reporting quantiles is note supported. This makes the Summary + * less insightful than the Histogram while using less resources. + * + * The `.startTimer()` method provides a convenient interface for adding + * observed durations to the summary. Other types of values can be added to + * a summary with the `.observe()` method. + * + * Like with the Counter and Gauge the Summary collector also supports + * labels. When using labels the methods for observing values into the + * summary is no longer usable directly on the counter itself, instead a + * child summary with label values set has to be retrieved with the + * `.labels()` method. For example: + * + * ```js + * const requestDuration = new Summary( + * "app_request_duration_seconds", + * "Time to process app requests", + * { labels: ["endpoint"] } + * ); + * + * async function handleRequest(endpoint, ...) { + * const observeDuration = requestDuration.labels(endpoint).startTimer(); + * try { + * // Do async stuff. + * } finally { + * observeDuration(); + * } + * } + * ``` + * + * When using labels the summary is no longer initalized with a default + * value, and this may cause issues with aggregating and querying the + * summary in Prometheus. When dynamically setting labels it is recommended + * where possible to initialize all possible combinations of label values + * that will be used. This is done by calling `.labels()` for each + * combination, for example: + * + * ```js + * for (let endpoint of ["/status", "/api", ...) { + * requestDuration.labels(endpoint); + * } + * ``` + * + * Note that every combination of label values used creates two new time + * series that need to be stored and processed. You should carefully + * evaluate which labels you actually need as resource usage for a metric + * increases exponentially with the number of labels used. + * + * @static + */ +class Summary extends LabeledCollector { + /** + * Create optionally labeled summary + * + * @param {string} name - Name of the metric. + * @param {string} help - Help text for metric. + * @param {Object=} options - options for collector. + * @param {Array=} options.labels - + * Labels for this metric, defaults to no labels. + * @param {boolean=} options.register - + * If true registers this collector with the default registry. + * Defaults to true. + * @param {function()=} options.callback - + * Possibly async function that is called when the metric is + * collected. The collector being collected is passed as the + * argument. + */ + constructor(name, help, options = {}) { + super("summary", name, help, options, SummaryChild); + + 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([ + ["_sum", this._sumValues], + ["_count", this._countValues], + ]), + }; + } + + /** + * Sum of all observations for label set + * + * Note: Only available if this is an unlabeled collector. + * @type {number} + */ + get sum() { + return this._defaultChild.sum; + } + + /** + * Count of observations for label set + * + * Note: Only available if this is an unlabeled collector. + * @type {number} + */ + get count() { + return this._defaultChild.count; + } + + /** + * Observe a given value and increment matching buckets + * + * Note: Only works if this is an unlabeled collector. + * @param {number} value - number to count into histogram buckets. + */ + observe(value) { + this._defaultChild.observe(value); + } + + /** + * Start a timer for observing a duration + * + * Note: Only works if this is an unlabeled collector. + * @returns {function()} + * function that when called will store the duration in seconds from + * when the timer was started into the metric. + */ + startTimer() { + return this._defaultChild.startTimer(); + } + + remove(...labels) { + let key = super.remove(...labels); + this._sumValues.delete(key); + this._countValues.delete(key); + } + + removeAll(labels) { + super.removeAll(labels); + removeMatchingLabels(this._sumValues, labels); + removeMatchingLabels(this._countValues, labels); + } + + clear() { + super.clear(); + this._sumValues = new Map(); + this._countValues = new Map(); + } +} + function formatValue(value) { if (value === Infinity) { return "+Inf"; @@ -1047,7 +1272,7 @@ class HistogramChild { * * Like with the Counter and Gauge the Histogram collector also supports * labels. When using labels the methods for observing values into the - * histogram is not longer usable directly on the counter itself, instead a + * histogram is no longer usable directly on the counter itself, instead a * child histogram with label values set has to be retrieved with the * `.labels()` method. For example: * @@ -1567,6 +1792,7 @@ defaultCollectors.processStartTimeSeconds.setToCurrentTime(); module.exports = { Counter, Gauge, + Summary, Histogram, CollectorRegistry, Collector, diff --git a/test/lib/prometheus.js b/test/lib/prometheus.js index 12d75532..311a893c 100644 --- a/test/lib/prometheus.js +++ b/test/lib/prometheus.js @@ -210,6 +210,114 @@ describe("lib/prometheus", function() { }); }); + describe("class Summary", function() { + let summary; + let labeledSummary; + beforeEach(function() { + summary = new prometheus.Summary( + "test", "Help", { register: false } + ); + labeledSummary = new prometheus.Summary( + "test", "Help", { register: false, labels: ["a", "b"] } + ); + }); + + describe("constructor", function() { + it("should have sum and count initialized to 0", function() { + assert.equal(summary.sum, 0); + assert.equal(summary.count, 0); + }); + it("should throw on unrecoginzed option", function() { + assert.throws( + () => new prometheus.Summary("test", "help", { invalid: 1 }), + new Error("Unrecognized option 'invalid'") + ); + }); + }); + + describe(".observe()", function() { + it("should increment sum and count", function() { + summary.observe(2); + assert.equal(summary.sum, 2); + assert.equal(summary.count, 1); + + summary.observe(4); + assert.equal(summary.sum, 6); + assert.equal(summary.count, 2); + }); + }); + + describe(".startTimer()", function() { + it("should add observations when closure is called", function() { + let observeDuration = summary.startTimer(); + assert.equal(summary.count, 0); + observeDuration(); + assert.equal(summary.count, 1); + observeDuration(); + assert.equal(summary.count, 2); + }); + }); + + describe(".labels()", function() { + it("should initialize a value for the label set", function() { + labeledSummary.labels({ a: "1", b: "2" }); + assert(labeledSummary._sumValues.has('a="1",b="2"'), "sum value not set"); + assert(labeledSummary._countValues.has('a="1",b="2"'), "count value not set"); + + labeledSummary.labels("4", "5"); + assert(labeledSummary._sumValues.has('a="4",b="5"'), "sum value not set"); + assert(labeledSummary._countValues.has('a="4",b="5"'), "count value not set"); + }); + it("should return a child supporting the summary methods", function() { + let child = labeledSummary.labels({ a: "1", b: "2" }); + void child.sum; + void child.count; + child.observe(2); + let observeDuration = child.startTimer(); + observeDuration(); + child = labeledSummary.labels("7", "8"); + 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 = labeledSummary.labels("1", "2"); + assert.equal(labeledSummary.labels("1", "2"), child); + assert.equal(labeledSummary.labels({ a: "1", b: "2" }), child); + }); + }); + describe(".remove()", function() { + it("should remove a label set created with .labels()", function() { + labeledSummary.labels("1", "2"); + labeledSummary.remove("1", "2"); + assert(!labeledSummary._sumValues.has('a="1",b="2"'), "sum value not removed"); + assert(!labeledSummary._countValues.has('a="1",b="2"'), "count value not removed"); + }); + }); + describe(".removeAll()", function() { + it("should remove a label set created with .labels()", function() { + labeledSummary.labels("1", "2"); + labeledSummary.labels("1", "3"); + labeledSummary.labels("4", "6"); + labeledSummary.removeAll({ a: "1" }); + assert.deepEqual(labeledSummary._sumValues, new Map([['a="4",b="6"', 0]])); + assert.deepEqual(labeledSummary._countValues, new Map([['a="4",b="6"', 0]])); + }); + }); + describe(".clear()", function() { + it("should remove all label sets created with .labels()", function() { + labeledSummary.labels("1", "2"); + labeledSummary.labels("1", "3"); + labeledSummary.labels("4", "6"); + labeledSummary.clear(); + assert(labeledSummary._sumValues.size === 0, "sums were not cleared"); + assert(labeledSummary._countValues.size === 0, "counts were not cleared"); + }); + }); + }); + describe("class Histogram", function() { let histogram; let labeledHistogram;