UNPKG

@clusterio/lib

Version:
1,766 lines (1,641 loc) 51.8 kB
/** * Asynchronous Prometheus client * * Prometheus client for exposing metrics to a Prometheus server based on * (possibly) asynchronous callbacks. This library was developed as a * replacement for prom-client in Clusterio due to the lack of callbacks on * metric collections in prom-client. * * The ordinary use case of instrumenting a code base should be covered by * {@link Counter}, {@link Gauge}, {@link Histogram} and * {@link exposition}. See documentation for each of * the listed interfaces for more information. * * @example * const { Counter, exposition } = require("@clusterio/lib"); * * // Collectors are by default registered to the default collector regitry. * const totalRequests = new Counter( * "app_request_count_total", * "Total requests handled.", * ); * * function handleRequest(...) { * // Do stuff * totalRequests.inc(); * } * * // Code handling the /metrics HTTP request, here shown for express * // but any http framework may be used * const app = require("express")(); * async function getMetrics(req, res) { * // By default exposition uses the default collector registry * let text = await exposition(); * res.set("Content-Type", exposition.contentType); * res.send(text); * } * app.get("/metrics", (req, res, next) => getMetrics(req, res).catch(next)); * app.listen(9100); * @module lib/prometheus */ import { Type, Static } from "@sinclair/typebox"; import { StringEnum } from "./data/composites"; /** * Result from collecting a {@link Collector} * * @example * Simple Metric * ```ts * let result = { * metric: new Metric("count", "simple_total_count", "A simple counter"), * samples: new Map([ * ["", new Map([ * ["", 123], * ])], * ]), * } * ``` * * @example * Labeled Metric * ```ts * let result = { * metric: new Metric( * "count", "labeled_total_count", "A labeled counter", ["a", "b"] * ), * samples: new Map([ * ["", new Map([ * ['a="1",b="3"', 123], * ['a="2",b="1"', 34], * ['a="2",b="9"', 7], * ])], * ]), * } * ``` * * @example * Histogram Metric * ```ts * let result = { * metric: new Metric( * "histogram", "histogram_size", "A histogram of sizes" * ), * samples: new Map([ * ["_bucket", new Map([ * ['le="1"', 1], * ['le="5"', 4], * ['le="+Inf"', 5], * ])], * ["_sum", new Map([ * ["", 48], * ])], * ["_count", new Map([ * ["", 5], * ])], * ]), * } */ export interface CollectorResult { /** Metric collected. */ metric: Metric; /** * Mapping of metric suffix to mapping of label keys to values * collected. For normal metrics the first level contains a single * entry under the empty string as key. If the metric does not have * labels the second level also contains a single entry under the empty * string as a key. */ samples: Map<string, Map<string, number>>; } export type MetricType = "counter" | "gauge" | "histogram" | "summary" | "untyped"; /** * Represents a collectable metric * * Used in implementing collectors in order to validate the name, help text * and labels attached to the collector. */ export class Metric { constructor( /** * Metric type, should be one of `counter`, `gauge`, `histogram`, * `summary` or `untyped`. */ public type: MetricType, /** Name of the metric */ public name: string, /** Help text for the metric */ public help: string, /** Labels for this metric. */ public labels: string[] = [], _reserved_labels: string[] = [] ) { 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 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}'`); } } } } /** * The default registry which collectors are regististered to. */ export let defaultRegistry: CollectorRegistry; /** * Base class for all collectors * * This servers mostly as a conceptual base for all collectors, the only * feature implemented here is registring to the default registry on * construction. If you want to implement a custom collector you most * likely want to base it on {@link LabeledCollector} * instead. */ export class Collector { /** * Create collector * * @param register - * Whether to register this collector to the default registry. */ constructor(register = true) { if (register) { defaultRegistry.register(this); } } /** * Retrieve metric data from this collctor. * * Called by {@link CollectorRegistry} to gather * the metric data this Collector exports. */ async* collect(): AsyncIterable<CollectorResult> { } } const labelEscapesChars = /[\\\n\"]/; /** * Escapes a label value in accordance with the text exposition format * * @param value - Value to escape. * @returns escaped value with \, ", and newline escaped. * @private */ function escapeLabelValue(value: string) { if (!labelEscapesChars.test(value)) { return value; } return value .replace(/\\/g, "\\\\") .replace(/\n/g, "\\n") .replace(/"/g, "\\\"") ; } export type LabelValues = string[] | [Record<string, string>]; /** * Convert label expression to unique string * * @param labels - * label values to compute key for. * @param metricLabels - labels defined for the metric. * @returns computed key * @private */ function labelsToKey(labels: LabelValues, metricLabels: string[]) { let items = []; if (labels.length === 1 && typeof labels[0] === "object") { let labelObj = labels[0]; for (let name of metricLabels) { if (!Object.hasOwnProperty.call(labelObj, name)) { throw new Error(`Missing label '${name}'`); } if (typeof labelObj[name] !== "string") { throw new Error( `Expected value for label '${name}' to be a string` ); } items.push(`${name}="${escapeLabelValue(labelObj[name])}"`); } for (let name of Object.keys(labelObj)) { 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) { let label = labels[i]; if (typeof label !== "string") { throw new Error( `Expected value for label '${metricLabels[i]}' to be a string` ); } items.push(`${metricLabels[i]}="${escapeLabelValue(label)}"`); } } if (items.length === 1) { return items[0]; } return items.join(","); } function keyToLabels(key: string) { let labels = new Map<string, string>(); 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; } function removeMatchingLabels(mapping: Map<string, unknown>, labels: Record<string, string>) { for (let key of mapping.keys()) { let candidate = keyToLabels(key); let hasLabels = Object.entries(labels).every(([name, value]) => ( candidate.get(name) === value )); if (hasLabels) { mapping.delete(key); } } } /** * Base class for implementing labeled collectors * * Provides the scaffolding for creating labled collectors where each label * set is a child to the collector that can be retrieved with the .labels() * method. * * @extends Collector */ export class LabeledCollector<Child> extends Collector { callback: ((collector: any) => (void | Promise<void>)) | undefined; metric: Metric; private _children: Map<string, Child>; private _childClass: { new(collector: any, key: string): Child }; /** * Create optionally labeled collector * * @param type - Type for metric. * @param name - Name of the metric. * @param help - Help text for metric. * @param options - options for collector. * @param options.labels - * Labels for this metric, defaults to no labels. * @param options._reservedLabels - * Labels which may not be used. Is passed to Metric constructor. * @param options.register - * If true registers this collector with the default registry. * Defaults to true. * @param options.callback - * Possibly async function that is called before the metric is * collected. The collector being collected is passed as the * argument. * @param childClass - * Constructor taking instance of collector and label key as * arguments and returns a child instance. */ constructor( type: MetricType, name: string, help: string, options: { labels?: string[], _reservedLabels?: string[], register?: boolean, callback?: (collector: any) => (void | Promise<void>), }, childClass: { new(collector: any, key: string): Child } ) { const { labels = [], _reservedLabels = [], register = true, callback, ...rest } = options; for (let key of Object.keys(rest)) { throw new Error(`Unrecognized option '${key}'`); } // Make sure we don't register to the default registry if metric throws. let metric = new Metric(type, name, help, labels, _reservedLabels); super(register); this.callback = callback; this.metric = metric; this._children = new Map(); this._childClass = childClass; } /** * Access child collector with the given labels. * * Creates a labeled child collector for this collector. The child * supports the methods of Collector for modifying and querying the * value of the metric. * * Once created, a value is initialized for the label values used and * exported from the collector until it's removed explicitly with * the .remove() or .removeAll() methods. * * @param labels - * A string value passed for each label defined on the metric in the * same order as the labels option given to the collector, or an * object mapping label name to label value. * @returns Child collector for the given labels. */ labels(...labels: LabelValues): Child { let key = labelsToKey(labels, this.metric.labels); let child = this._children.get(key); if (child === undefined) { child = new this._childClass(this, key); this._children.set(key, child); } return child; } /** * Remove child collector and data for a given label set * * Remove the child collector and the value it stores from the collector * itself. This will remove the entry exported for the given labels. * * @param labels - * A string value passed for each label defined on the metric in the * same order as the labels option given to the collector, or an * object mapping label name to label value. * @returns key for labels to remove (for use in subclasses). */ remove(...labels: LabelValues) { let key = labelsToKey(labels, this.metric.labels); if (key === "") { throw new Error("labels cannot be empty"); } this._children.delete(key); return key; } /** * Remove child collectors matching a partial label set * * Removes all child collecters which has labels matching the ones given * in labels parameter, Unline .remove this can be a partial set of * labels and all child collectors with their stored values that shares * this set of labels will be removed. * * @param {Object<string,string>} labels - * Object mapping with label name to label values that should be * matches. */ removeAll(labels: Record<string, string>) { if (!Object.keys(labels).length) { throw new Error("labels cannot be empty"); } removeMatchingLabels(this._children, labels); } /** * Clear all labeles on this collector * * Clears all child collectors from this collector along with their * stored values. This effectively removes all the exported values. */ clear() { if (!this.metric.labels.length) { throw new Error("Cannot clear unlabeled metric"); } this._children = new Map(); } } /** * Base class for implementing single value per label collectors * * @extends LabeledCollector */ export class ValueCollector<Child extends { get(): number }> extends LabeledCollector<Child> { _values: Map<string, number>; protected _defaultChild: Child | null; /** * Create optionally labeled value collector * * @param type - Type for metric. * @param name - Name of the metric. * @param help - Help text for metric. * @param options - options for collector. * @param options.labels - * Labels for this metric, defaults to no labels. * @param options._reservedLabels - * Labels which may not be used. Is passed to Metric constructor. * @param options.register - * If true registers this collector with the default registry. * Defaults to true. * @param options.callback - * Possibly async function that is called before the collector value * is collected. The collector being collected is passed as the * argument. * @param childClass - * Constructor taking instance of collector and label key as * arguments and returns a child instance. */ constructor( type: MetricType, name: string, help: string, options: { labels?: string[], _reservedLabels?: string[], register?: boolean, callback?: (collector: any) => (void | Promise<void>), }, childClass: { new(collector: ValueCollector<Child>, key: string): Child } ) { 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 the current value for this collector. * * Note: Only works if this is an unlabeled collector. * @returns value stored. */ get() { return this._defaultChild!.get(); } remove(...labels: LabelValues) { let key = super.remove(...labels); this._values.delete(key); return key; } removeAll(labels: Record<string, string>) { super.removeAll(labels); removeMatchingLabels(this._values, labels); } clear() { super.clear(); this._values = new Map(); } } /** * Child collector representing the value of a single label set */ class ValueCollectorChild { protected _values: Map<string, number>; protected _key: string; constructor(collector: ValueCollector<ValueCollectorChild>, key: string) { this._values = collector._values; this._key = key; this._values.set(key, 0); } /** * Returns the current value of label set * * @returns value stored. */ get() { return this._values.get(this._key)!; } } /** * Child counter holding the value for a single label set. * @extends ValueCollectorChild */ class CounterChild extends ValueCollectorChild { /** * Increment counter for label set * @param value - Positive number to increment by. */ 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); } } /** * Basic increasing counter * * Stores and exports an optionally labeled counter metric. This is one of * the two basic building blocks for instrumenting code and represents a * metric that can only increase in value, such as the total number of * requests processed or total time spent processing requests. * * Counters should be created at module load time and referenced in the * functions that increment them, for example: * * ```ts * const totalRequests = new Counter( * "app_request_count_total", * "Total requests handled.", * ); * * function handleRequest(...) { * // Do stuff * totalRequests.inc(); * } * ``` * * The `totalRequests` counter will register with the default registry and * provided exposition is set up (see {@link exposition}) the counter will * be exported to Prometheus starting out with a value of 0. And that is * all there is to it. * * It is sometimes useful however to divide a metric up into diffrent * sections, for example to have a different count for each endpoint handled * or one count for successfull requests and one for requests resulting in * an error. For this Prometheus provides labels, and to use them pass the * labels option as the third argument to Counter: * * ```ts * const totalRequests = new Counter( * "app_request_count_total", * "Total requests handled.", * { labels: ["endpoint", "status"] }, * ); * * function handleRequest(endpoint, ...) { * let status = "ok"; * try { * // Do stuff * } catch (err) { * status = "err"; * } finally { * totalRequests.labels(endpoint, status).inc(); * } * } * ``` * * When using labels the counter no longer gets a default value and it's no * longer possible to use the `.inc()` method on the counter itself, the * `.labels()` method has to be invoked with the values for the labels * defined. `.labels()` returns a child counter for the label values given * to it and this child can be operated on and cached for performance if * that is critical. * * The lack of a default value may cause issues with aggregating and * querying the counter values in Prometheus. It is therefore 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: * * ```ts * for (let endpoint of allEndpoints) { * for (let status of ["ok", "err"]) { * totalRequests.labels(endpoint, status); * } * } * ``` * * Note that every combination of label values used creates a new time * series 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. * * @extends ValueCollector */ export class Counter extends ValueCollector<CounterChild> { /** * Create optionally labeled counter * * @param name - Name of the metric. * @param help - Help text for metric. * @param options - options for collector. * @param {Array<string>=} 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: string, help: string, options: { labels?: string[], register?: boolean, callback?: (collector: Collector) => (void | Promise<void>), } = {}, ) { super("counter", name, help, options, CounterChild); } /** * Increment counter value * * Note: Only works if this is an unlabeled collector. * @param value - Positive number to increment by. */ inc(value = 1) { this._defaultChild!.inc(value); } } /** * Child gauge holding the value for a single label set. * @extends ValueCollectorChild */ class GaugeChild extends ValueCollectorChild { /** * Increment gague for label set * * @param value - number to increment by. */ inc(value = 1) { this._values.set(this._key, this._values.get(this._key)! + value); } /** * Decrement gague for label set * * @param value - number to decrease by. */ dec(value = 1) { this._values.set(this._key, this._values.get(this._key)! - value); } /** * Set gague for label set * * @param value - number to set gauge to. */ set(value: number) { this._values.set(this._key, value); } /** * Set to current Unix epoch time in seconds for label set */ setToCurrentTime() { this._values.set(this._key, Date.now() / 1000); } /** * Start a timer for setting a duration for label set * * @returns * function that when called will set the guage to the duration in * seconds from when the timer was started */ startTimer() { let startNs = process.hrtime.bigint(); return () => { let endNs = process.hrtime.bigint(); this.set(Number(endNs - startNs) / 1e9); }; } } /** * Basic value metric * * Stores and exports an optionally labeled value metric. This is one of * the two basic building blocks for instrumenting code and represents a * value that can both increase and decrease over time, such as the number * of requests in-flight or the number of users in a database. * * Gauges should be created at module load time and referenced in the * functions that modify them, for example: * * ```ts * const activeRequests = new Gauge( * "app_active_request_count", * "Number of requests in-flight.", * ); * * async function handleRequest(...) { * activeRequests.inc(); * try { * // Do async stuff * } finally { * activeRequests.dec(); * } * } * ``` * * The `activeRequests` gauge will register with the default registry and * provided exposition is set up (see {@link exposition}) the gauge will be * exported to Prometheus starting out with a value of 0. * * Sometimes keeping track of the value measured is impractical or * prohibitly expensive. In those cases you can update the value * of the collector as it's being collected for export with a callback * function passed as one of the options. * * ```ts * const userCount = new Gauge( * "app_user_count", * "Number of users in the app.", * { * callback: async function() { * // Make sure this request can not take a long time to * // complete as that will cause the metrics gathering * // to time out. * userCount.set(await someApi.getUserCount()); * }, * }, * ); * ``` * * Keep in mind the callbacks are executed one by one at the time the * collectors are collected for the exposition given the Prometheus. The * default timeout in Prometheus for a collection job is 10 seconds. This * means the callback completion time should be in the order of milliseconds * 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 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: * * ```ts * const userCount = new Gauge( * "app_user_count", * "Number of users in the app", * { * labels: ["role"], * callback: async function() { * userCount.labels("system").set(await someApi.getSystemUserCount()); * userCount.labels("admin").set(await someApi.getAdminUserCount()); * userCount.labels("normal").set(await someApi.getNormalUserCount()); * } * }, * ); * ``` * * When using labels the gauge no longer gets a default value, and this may * cause issues with aggregating and querying the gauge values in * Prometheus. This is not an issue with the example shown above as all * label combinations used are given a value when the gauge is collected. * But if these labeled values were calculated through some other means * dynamically there may be cases where values for timeseries that are * occasionally used are missing. 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: * * ```ts * for (let role of ["system", "admin", "normal") { * userCount.labels(role); * } * ``` * * Note that every combination of label values used creates a new time * series 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. * * @extends ValueCollector */ export class Gauge extends ValueCollector<GaugeChild> { /** * Create optionally labeled gauge * * @param name - Name of the metric. * @param help - Help text for metric. * @param options - options for collector. * @param options.labels - * Labels for this metric, defaults to no labels. * @param options.register - * If true registers this collector with the default registry. * Defaults to true. * @param options.callback - * Possibly async function that is called when the metric is * collected. The collector being collected is passed as the * argument. */ constructor( name: string, help: string, options: { labels?: string[], register?: boolean, callback?: (collector: Gauge) => (void | Promise<void>), } = {}, ) { super("gauge", name, help, options, GaugeChild); } /** * Increment gague value * * Note: Only works if this is an unlabeled collector. * @param value - number to increment by. */ inc(value = 1) { this._defaultChild!.inc(value); } /** * Decrement gague value * * Note: Only works if this is an unlabeled collector. * @param value - number to decrease by. */ dec(value = 1) { this._defaultChild!.dec(value); } /** * Set gague value * * Note: Only works if this is an unlabeled collector. * @param value - number to set gauge to. */ set(value: number) { this._defaultChild!.set(value); } /** * Set to current Unix epoch time in seconds * * Note: Only works if this is an unlabeled collector. */ setToCurrentTime() { this._defaultChild!.setToCurrentTime(); } /** * Start a timer for setting a duration * * Note: Only works if this is an unlabeled collector. * @returns * function that when called will set the guage to the duration in * seconds from when the timer was started */ startTimer() { return this._defaultChild!.startTimer(); } } /** * Child Summary holding the sum and count for a single label set. */ class SummaryChild { protected _sumValues: Map<string, number>; protected _countValues: Map<string, number>; protected _key: string; constructor(collector: Summary, key: string) { 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 */ get sum() { return this._sumValues.get(this._key); } /** * Count of observations for label set */ get count() { return this._countValues.get(this._key); } /** * Observe a given value and increment matching buckets for label set * * @param value - number to count into summary. */ observe(value: number) { 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 that when called will store the duration in seconds from * when the timer was started into the metric. */ startTimer() { let startNs = process.hrtime.bigint(); return () => { let endNs = process.hrtime.bigint(); this.observe(Number(endNs - startNs) / 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: * * ```ts * 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: * * ```ts * 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: * * ```ts * 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. */ export class Summary extends LabeledCollector<SummaryChild> { _sumValues: Map<string, number>; _countValues: Map<string, number>; protected _defaultChild: SummaryChild | null; /** * Create optionally labeled summary * * @param name - Name of the metric. * @param help - Help text for metric. * @param options - options for collector. * @param options.labels - * Labels for this metric, defaults to no labels. * @param options.register - * If true registers this collector with the default registry. * Defaults to true. * @param options.callback - * Possibly async function that is called when the metric is * collected. The collector being collected is passed as the * argument. */ constructor( name: string, help: string, options: { labels?: string[], register?: boolean, callback?: (collector: Summary) => (void | Promise<void>), } = {}, ) { 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 value - number to count into histogram buckets. */ observe(value: number) { this._defaultChild!.observe(value); } /** * Start a timer for observing a duration * * Note: Only works if this is an unlabeled collector. * @returns * 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: LabelValues) { let key = super.remove(...labels); this._sumValues.delete(key); this._countValues.delete(key); return key; } removeAll(labels: Record<string, string>) { 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: number) { if (value === Infinity) { return "+Inf"; } if (value === -Infinity) { return "-Inf"; } return value.toString(); } function formatBucketKey(bucket: number, key: string) { return `${key === "" ? "" : `${key},`}le="${formatValue(bucket)}"`; } /** * Child histogram holding the buckets for a single label set. */ class HistogramChild { protected _bucketValues: Map<string, number>; protected _sumValues: Map<string, number>; protected _countValues: Map<string, number>; protected _key: string; protected _bucketKeys: Map<number, string>; constructor(collector: Histogram, key: string) { 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); } /** * Mapping of bucket upper bounds to count of observations for label set */ get buckets() { return new Map( [...this._bucketKeys].map( ([bucket, key]) => [bucket, this._bucketValues.get(key)] ) ); } /** * Sum of all observations for label set */ get sum() { return this._sumValues.get(this._key); } /** * Count of observations for label set */ get count() { return this._countValues.get(this._key); } /** * Observe a given value and increment matching buckets for label set * * @param value - number to count into histogram buckets. */ observe(value: number) { 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); } /** * Start a timer for observing a duration for label set * * @returns * function that when called will store the duration in seconds from * when the timer was started into the metric. */ startTimer() { let startNs = process.hrtime.bigint(); return () => { let endNs = process.hrtime.bigint(); this.observe(Number(endNs - startNs) / 1e9); }; } } /** * Histogram metric * * Keeps track of observed values in a set of buckets in a similar vein to a * histogram. This is useful when you have a frequent operation that * reports a metric that you want insight into the distribution of values * for. A common case for this is request duration, for example: * * ```ts * const requestDuration = new Histogram( * "app_request_duration_seconds", * "Time to process app requests", * ); * * async function handleRequest(...) { * const observeDuration = requestDuration.startTimer(); * try { * // Do async stuff. * } finally { * observeDuration(); * } * } * ``` * * The default buckets used for the histogram is suitable for observing * HTTP requests durations in seconds, and the `.startTimer()` method * provides a convenient interface for adding observed durations to the * histogram. Other types values can be added to a histogram with the * `.observe()` method. * * Like with the Counter and Gauge the Histogram collector also supports * labels. When using labels the methods for observing values into the * 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: * * ```ts * const requestDuration = new Histogram( * "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 histogram is no longer initalized with a default value, and this may * cause issues with aggregating and querying the histogram 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: * * ```ts * for (let endpoint of ["/status", "/api", ...) { * requestDuration.labels(endpoint); * } * ``` * * Note that every combination of label values used creates a new time * series for each bucket in the histogram to be stored and processed. * (e.g., 10 buckets with 10 possible label combinations will result in 100 * time series being made.) You should carefully evaluate which labels you * actually need as resource usage for a metric increases exponentially with * the number of labels used. */ export class Histogram extends LabeledCollector<HistogramChild> { _bucketValues: Map<string, number>; _sumValues: Map<string, number>; _countValues: Map<string, number>; _buckets: number[]; protected _defaultChild: HistogramChild | null; /** * Create optionally labeled histogram * * @param name - Name of the metric. * @param help - Help text for metric. * @param options - options for collector. * @param options.buckets - * Buckets to use for the histogram. This is an array of inclusive * upper bounds. Values observed will increment a bucket if it is * less than or equal to the upper bound. * @param options.labels - * Labels for this metric, defaults to no labels. * @param options.register - * If true registers this collector with the default registry. * Defaults to true. * @param options.callback - * Possibly async function that is called when the metric is * collected. The collector being collected is passed as the * argument. */ constructor( name: string, help: string, options: { buckets?: number[], labels?: string[], register?: boolean, callback?: (collector: Histogram) => (void | Promise<void>), } = {}, ) { // 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], ...rest } = options; super("histogram", name, help, { ...rest, _reservedLabels: ["le"] }, 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], ]), }; } /** * Mapping of bucket upper bounds to count of observations for label set * * Note: Only available if this is an unlabeled collector. */ get buckets() { return this._defaultChild!.buckets; } /** * Sum of all observations for label set * * Note: Only available if this is an unlabeled collector. */ get sum() { return this._defaultChild!.sum; } /** * Count of observations for label set * * Note: Only available if this is an unlabeled collector. */ get count() { return this._defaultChild!.count; } /** * Observe a given value and increment matching buckets * * Note: Only works if this is an unlabeled collector. * @param value - number to count into histogram buckets. */ observe(value: number) { this._defaultChild!.observe(value); } /** * Start a timer for observing a duration * * Note: Only works if this is an unlabeled collector. * @returns * 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: LabelValues) { 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); return key; } removeAll(labels: Record<string, string>) { 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(); } /** * Helper function for generating linear bucket series * * Creates an array of numbers suitable for using as buckets to a * histogram. The array returned contains `count` numbers starting at * `start` and with each subsequent number being equal to the previous * one plus `width`. * * @param start - Number to start buckets at. * @param width - Distance between ecah bucket. * @param count - Number of buckets. * @returns array of buckets. */ static linear(start: number, width: number, count: number) { let buckets: number[] = []; for (let i = 0; i < count; i++) { buckets.push(start); start += width; } return buckets; } /** * Helper function for generating exponential bucket series * * Creates an array of numbers suitable for using as buckets to a * histogram. The array returned contains `count` numbers starting at * `start` and with each subsequent number being equal to the previous * multiplied with `factor`. * * @param start - Number to start buckets at. * @param factor - Ratio between each bucket. * @param count - Number of buckets. * @returns array of buckets. */ static exponential(start: number, factor: number, count: number) { let buckets: number[] = []; for (let i = 0; i < count; i++) { buckets.push(start); start *= factor; } return buckets; } } /** * Collection of collectors * * Provides convienece methods for grouping collectors together and * gathering the metrics from all of the contained collectors. By default * collectors are registered and collected from the default registry so * using this is usually not necessary. * * The basic way to use a custom registry is to create Collectors without * registring them to the default registry and then adding them to your own * registry, for example: * * ```ts * const myRegistry = new CollectorRegistry(); * const myCounter = new Counter( "a_counter", "A counter.", { register: false }); * myRegistry.register(myCounter); * * // In the /metrics HTTP request handler * let text = await exposition(myRegistry.collect()); * ``` * * The same collector can be registered to multiple registries. This may be * used to implement responding with different sets of metrics depending on * what is requested. */ export class CollectorRegistry { collectors: Collector[] = []; /** * Collect metrics from all registered collectors. */ async* collect(): AsyncIterable<CollectorResult> { for (let collector of this.collectors) { for await (let result of collector.collect()) { yield result; } } } /** * Add collector to the registry. * * @param collector - Collector to add. */ register(collector: Collector) { let index = this.collectors.lastIndexOf(collector); if (index !== -1) { throw new Error( "Collector is already registered in this registry." ); } this.collectors.push(collector); } /** * Remove collector from the registry. * * @param collector - Collector to remove. */ unregister(collector: Collector) { let index = this.collectors.lastIndexOf(collector); if (index === -1) { throw new Error( "Collector is not registered in this registry." ); } this.collectors.splice(index, 1); } } defaultRegistry = new CollectorRegistry(); function escapeHelp(help: string) { return help .replace(/\\/g, "\\\\") .replace(/\n/g, "\\n") ; } async function* expositionLines( resultsIterator: AsyncIterable<CollectorResult> | Iterable<CollectorResult> ) { 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 [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`; } } } } } /** * Serialize into Prometheus text exposition format * * Asynchronously collects metrics and converts them into the text based * exposition format for Prometheus. The collectors collected by default is * taken from the default registry, but this can be overidden by passing a * resultsIterator either from another registry or custom created. * * The resulting text from calling this method should be served to * prometheus, typically by hosting an HTTP server in the app and responding * to the /metrics endpoint. See example below for how this is done with * express.js. * * @example * const app = require("express")(); * async function getMetrics(req, res) { * // By default exposition uses the default collector registry * let text = await exposition(); * res.set("Content-Type", exposition.contentType); * res.send(text); * } * app.get("/metrics", (req, res, next) => getMetrics(req, res).catch(next)); * app.listen(9100); * * @param resultsIterator - * Asynchronously itreable of {@link CollectorResult} results to create * exposition for. Defaults to collecting results from {@link * defaultRegistry}. * @returns Prometheus exposition. */ export async function exposition( resultsIterator: AsyncIterable<CollectorResult> | Iterable<CollectorResult> = defaultRegistry.collect() ) { let lines = ""; for await (let line of expositionLines(resultsIterator)) { lines += line; } return lines; } /** * HTTP Content-Type for the exposition format that's implemented by {@link * exposition}. */ export const expositionContentType = "text/plain; version=0.0.4"; export const CollectorResultSerialized = Type.Object({ metric: Type.Object({ type: StringEnum(["counter", "gauge", "histogram", "summary", "untyped"]), name: Type.String(), help: Type.String(), labels: Type.Array(Type.String()), }), samples: Type.Array( Type.Tuple([ Type.String(), Type.Array( Type.Tuple([Type.String(), Type.Number()]) ), ]), ), }); export type CollectorResultSerialized = Static<typeof CollectorResultSerialized>; /** * Serialize CollectorResult into a plain object * * Converts a {@link CollectorResult} into a plain * object form that can be stringified to JSON. * * @param result - * Result to serialize into plain object form. * @param options - Options for controlling the serialization. * @param options.addLabels - * Additional labels to append to each value. This may be used if * multiple sources are combined have the same metric and a qualifier is * needed to make sure the label sets are unique. * @param options.metricName - Override metric name of the result. * @param options.metricHelp - Override metric help of the result. * @returns plain object form of the result. */ export function serializeResult( result: CollectorResult, options: { addLabels?: Record<string, string>, metricName?: string, metricHelp?: string, } = {} ): CollectorResultSerialized { let { addLabels, metricName = result.metric.name, metricHelp = result.metric.help, ...rest } = options; for (let name of Object.keys(rest)) { throw new Error(`Unrecognized option '${name}'`); } let samples: Map<string, Map<string, number>>; if (!addLabels) { samples = result.samples; addLabels = {}; } else { samples = new Map(); let key = labelsToKey([addLabels], [...Object.keys(addLabels)]); 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); } } return { metric: { type: result.metric.type, name: metricName, help: metricHelp, lab