243 lines
6.0 KiB
JavaScript
Executable File
243 lines
6.0 KiB
JavaScript
Executable File
'use strict';
|
|
|
|
const { getValueAsString } = require('./util');
|
|
|
|
class Registry {
|
|
static get PROMETHEUS_CONTENT_TYPE() {
|
|
return 'text/plain; version=0.0.4; charset=utf-8';
|
|
}
|
|
|
|
static get OPENMETRICS_CONTENT_TYPE() {
|
|
return 'application/openmetrics-text; version=1.0.0; charset=utf-8';
|
|
}
|
|
|
|
constructor(regContentType = Registry.PROMETHEUS_CONTENT_TYPE) {
|
|
this._metrics = {};
|
|
this._collectors = [];
|
|
this._defaultLabels = {};
|
|
if (
|
|
regContentType !== Registry.PROMETHEUS_CONTENT_TYPE &&
|
|
regContentType !== Registry.OPENMETRICS_CONTENT_TYPE
|
|
) {
|
|
throw new TypeError(`Content type ${regContentType} is unsupported`);
|
|
}
|
|
this._contentType = regContentType;
|
|
}
|
|
|
|
getMetricsAsArray() {
|
|
return Object.values(this._metrics);
|
|
}
|
|
|
|
async getMetricsAsString(metrics) {
|
|
const metric =
|
|
typeof metrics.getForPromString === 'function'
|
|
? await metrics.getForPromString()
|
|
: await metrics.get();
|
|
|
|
const name = escapeString(metric.name);
|
|
const help = `# HELP ${name} ${escapeString(metric.help)}`;
|
|
const type = `# TYPE ${name} ${metric.type}`;
|
|
const values = [help, type];
|
|
|
|
const defaultLabels =
|
|
Object.keys(this._defaultLabels).length > 0 ? this._defaultLabels : null;
|
|
|
|
const isOpenMetrics =
|
|
this.contentType === Registry.OPENMETRICS_CONTENT_TYPE;
|
|
|
|
for (const val of metric.values || []) {
|
|
let { metricName = name, labels = {} } = val;
|
|
const { sharedLabels = {} } = val;
|
|
if (isOpenMetrics && metric.type === 'counter') {
|
|
metricName = `${metricName}_total`;
|
|
}
|
|
|
|
if (defaultLabels) {
|
|
labels = { ...labels, ...defaultLabels, ...labels };
|
|
}
|
|
|
|
// We have to flatten these separately to avoid duplicate labels appearing
|
|
// between the base labels and the shared labels
|
|
const formattedLabels = formatLabels(labels, sharedLabels);
|
|
|
|
const flattenedShared = flattenSharedLabels(sharedLabels);
|
|
const labelParts = [...formattedLabels, flattenedShared].filter(Boolean);
|
|
const labelsString = labelParts.length ? `{${labelParts.join(',')}}` : '';
|
|
let fullMetricLine = `${metricName}${labelsString} ${getValueAsString(
|
|
val.value,
|
|
)}`;
|
|
|
|
const { exemplar } = val;
|
|
if (exemplar && isOpenMetrics) {
|
|
const formattedExemplars = formatLabels(exemplar.labelSet);
|
|
fullMetricLine += ` # {${formattedExemplars.join(
|
|
',',
|
|
)}} ${getValueAsString(exemplar.value)} ${exemplar.timestamp}`;
|
|
}
|
|
values.push(fullMetricLine);
|
|
}
|
|
|
|
return values.join('\n');
|
|
}
|
|
|
|
async metrics() {
|
|
const isOpenMetrics =
|
|
this.contentType === Registry.OPENMETRICS_CONTENT_TYPE;
|
|
|
|
const promises = this.getMetricsAsArray().map(metric => {
|
|
if (isOpenMetrics && metric.type === 'counter') {
|
|
metric.name = standardizeCounterName(metric.name);
|
|
}
|
|
return this.getMetricsAsString(metric);
|
|
});
|
|
|
|
const resolves = await Promise.all(promises);
|
|
|
|
return isOpenMetrics
|
|
? `${resolves.join('\n')}\n# EOF\n`
|
|
: `${resolves.join('\n\n')}\n`;
|
|
}
|
|
|
|
registerMetric(metric) {
|
|
if (this._metrics[metric.name] && this._metrics[metric.name] !== metric) {
|
|
throw new Error(
|
|
`A metric with the name ${metric.name} has already been registered.`,
|
|
);
|
|
}
|
|
|
|
this._metrics[metric.name] = metric;
|
|
}
|
|
|
|
clear() {
|
|
this._metrics = {};
|
|
this._defaultLabels = {};
|
|
}
|
|
|
|
async getMetricsAsJSON() {
|
|
const metrics = [];
|
|
const defaultLabelNames = Object.keys(this._defaultLabels);
|
|
|
|
const promises = [];
|
|
|
|
for (const metric of this.getMetricsAsArray()) {
|
|
promises.push(metric.get());
|
|
}
|
|
|
|
const resolves = await Promise.all(promises);
|
|
|
|
for (const item of resolves) {
|
|
if (item.values && defaultLabelNames.length > 0) {
|
|
for (const val of item.values) {
|
|
// Make a copy before mutating
|
|
val.labels = Object.assign({}, val.labels);
|
|
|
|
for (const labelName of defaultLabelNames) {
|
|
val.labels[labelName] =
|
|
val.labels[labelName] || this._defaultLabels[labelName];
|
|
}
|
|
}
|
|
}
|
|
|
|
metrics.push(item);
|
|
}
|
|
|
|
return metrics;
|
|
}
|
|
|
|
removeSingleMetric(name) {
|
|
delete this._metrics[name];
|
|
}
|
|
|
|
getSingleMetricAsString(name) {
|
|
return this.getMetricsAsString(this._metrics[name]);
|
|
}
|
|
|
|
getSingleMetric(name) {
|
|
return this._metrics[name];
|
|
}
|
|
|
|
setDefaultLabels(labels) {
|
|
this._defaultLabels = labels;
|
|
}
|
|
|
|
resetMetrics() {
|
|
for (const metric in this._metrics) {
|
|
this._metrics[metric].reset();
|
|
}
|
|
}
|
|
|
|
get contentType() {
|
|
return this._contentType;
|
|
}
|
|
|
|
setContentType(metricsContentType) {
|
|
if (
|
|
metricsContentType === Registry.OPENMETRICS_CONTENT_TYPE ||
|
|
metricsContentType === Registry.PROMETHEUS_CONTENT_TYPE
|
|
) {
|
|
this._contentType = metricsContentType;
|
|
} else {
|
|
throw new Error(`Content type ${metricsContentType} is unsupported`);
|
|
}
|
|
}
|
|
|
|
static merge(registers) {
|
|
const regType = registers[0].contentType;
|
|
for (const reg of registers) {
|
|
if (reg.contentType !== regType) {
|
|
throw new Error(
|
|
'Registers can only be merged if they have the same content type',
|
|
);
|
|
}
|
|
}
|
|
const mergedRegistry = new Registry(regType);
|
|
|
|
const metricsToMerge = registers.reduce(
|
|
(acc, reg) => acc.concat(reg.getMetricsAsArray()),
|
|
[],
|
|
);
|
|
|
|
metricsToMerge.forEach(mergedRegistry.registerMetric, mergedRegistry);
|
|
return mergedRegistry;
|
|
}
|
|
}
|
|
|
|
function formatLabels(labels, exclude) {
|
|
const { hasOwnProperty } = Object.prototype;
|
|
const formatted = [];
|
|
for (const [name, value] of Object.entries(labels)) {
|
|
if (!exclude || !hasOwnProperty.call(exclude, name)) {
|
|
formatted.push(`${name}="${escapeLabelValue(value)}"`);
|
|
}
|
|
}
|
|
return formatted;
|
|
}
|
|
|
|
const sharedLabelCache = new WeakMap();
|
|
function flattenSharedLabels(labels) {
|
|
const cached = sharedLabelCache.get(labels);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
const formattedLabels = formatLabels(labels);
|
|
const flattened = formattedLabels.join(',');
|
|
sharedLabelCache.set(labels, flattened);
|
|
return flattened;
|
|
}
|
|
function escapeLabelValue(str) {
|
|
if (typeof str !== 'string') {
|
|
return str;
|
|
}
|
|
return escapeString(str).replace(/"/g, '\\"');
|
|
}
|
|
function escapeString(str) {
|
|
return str.replace(/\\/g, '\\\\').replace(/\n/g, '\\n');
|
|
}
|
|
function standardizeCounterName(name) {
|
|
return name.replace(/_total$/, '');
|
|
}
|
|
|
|
module.exports = Registry;
|
|
module.exports.globalRegistry = new Registry();
|