Files
factorio-clusterio/packages/lib/src/prometheus.ts
T
Hornwitser 5dcaca4d0b Migrate to ECMAScript Modules
Migrate the usage of CommonJS specific code and imports to ESM.  Roughly
summarised this:
- Changes all require() calls to import statement or expressions.
- Changes usage of module.exports to export statements.
- Replaces require.resolve with import.meta.resolve.
- Replaces conditional imports with conditional subpath imports.
- Replaces module === require.main with import.meta.main.
- Adds the full filename with extention to all relative imports.
- Changes all usage of __dirname and __filename to their import.meta
  equivalents.
- Sets "type": "module" in package.json files.
- Removes usage of "use strict".
- Adds .ts an an alias of .js for the webpack build.

Due to differences in how require and import resolves files the plugin
entrypoints will have to include the filename and extension after this
migration in order to work.
2026-09-11 22:43:57 +02:00

1860 lines
51 KiB
TypeScript

/**
* 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
* import { Counter, exposition } from "@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
* import express from "express";
* const app = 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.js";
/**
* 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>();
let pos = 0;
let matchLabel = /([^=]+)="(([^\\"]|\\["n\\])+)"/y;
while (pos < key.length) {
if (pos !== 0) {
/* istanbul ignore if */
if (key[pos] !== ",") {
// Should be unreachable
throw Error(`Expected , at pos ${pos} in "${key}"`);
}
pos += 1;
}
matchLabel.lastIndex = pos;
const match = matchLabel.exec(key);
/* istanbul ignore else */
if (match) {
let [, name, value] = match;
labels.set(name, value
.replace(/\\"/g, "\"")
.replace(/\\n/g, "\n")
.replace(/\\\\/g, "\\")
);
pos += match[0].length;
} else {
// Should be unreachable
throw new Error(`Failed to parse label at pos ${pos} in "${key}"`);
}
}
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
* import express from "express";
* const app = 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,
labels: [...result.metric.labels, ...Object.keys(addLabels)],
},
samples: [...samples].map(
([name, metricSamples]) => [name, [...metricSamples]] as [string, [string, number][]]
),
};
}
/**
* Deserialize CollectorResult from plain object
*
* Reverse counterpart to {@link serializeResult}.
*
* @param serializedResult - Previously serialized result object.
* @returns deserialized result.
*/
export function deserializeResult(
serializedResult: CollectorResultSerialized
): CollectorResult {
return {
metric: new Metric(
serializedResult.metric.type,
serializedResult.metric.name,
serializedResult.metric.help,
serializedResult.metric.labels,
),
samples: new Map(
serializedResult.samples.map(
([suffix, suffixSamples]) => [suffix, new Map(suffixSamples)]
)
),
};
}
/**
* Default collectors provided by this library
*/
export const defaultCollectors = {
processCpuSecondsTotal: new Gauge(
"process_cpu_seconds_total",
"Total user and system CPU time spent in seconds.",
{
callback: function(collector) {
let usage = process.cpuUsage();
collector.set((usage.user + usage.system) / 1e6);
},
},
),
processResidentMemoryBytes: new Gauge(
"process_resident_memory_bytes",
"Resident memory size in bytes.",
{
callback: function(collector) {
collector.set(process.memoryUsage().rss);
},
},
),
processHeapBytes: new Gauge(
"process_heap_bytes",
"Process heap size in bytes.",
{
callback: function(collector) {
collector.set(process.memoryUsage().heapUsed);
},
},
),
processStartTimeSeconds: new Gauge(
"process_start_time_seconds",
"Start time of the process since unix epoch in seconds."
),
} as const;
defaultCollectors.processStartTimeSeconds.setToCurrentTime();