@exa-labs/prom-client
Version:
Client for prometheus
279 lines (226 loc) • 6.89 kB
JavaScript
;
const { getValueAsString } = require('./util');
const ESCAPE_STRING_REPLACE_MAP = {
'\\': '\\\\',
'\n': '\\n',
};
const ESCAPE_LABEL_VALUE_REPLACE_MAP = {
...ESCAPE_STRING_REPLACE_MAP,
'"': '\\\\"',
};
const ESCAPE_REPLACE_REGEXP = /\\|\n|"/g;
function REPLACE_FUNC(dict) {
return char => dict[char] || '';
}
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 = new Map();
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;
}
/**
* Return all metrics
*
* @returns {object[]}
*/
getMetricsAsArray() {
return Array.from(this._metrics.values());
}
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];
let defaultLabelNames = Object.keys(this._defaultLabels);
if (defaultLabelNames.length === 0) {
defaultLabelNames = undefined;
}
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 (defaultLabelNames !== undefined) {
// Make a copy before mutating
labels = { ...labels };
for (const labelName of defaultLabelNames) {
labels[labelName] ??= this._defaultLabels[labelName];
}
}
// 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) {
const existing = this._metrics.get(metric.name);
if (existing !== undefined && existing !== metric) {
throw new Error(
`A metric with the name ${metric.name} has already been registered.`,
);
}
this._metrics.set(metric.name, metric);
}
clear() {
this._metrics = new Map();
this._defaultLabels = {};
}
async getMetricsAsJSON() {
const metrics = [];
let defaultLabelNames = Object.keys(this._defaultLabels);
if (defaultLabelNames.length === 0) {
defaultLabelNames = undefined;
}
const promises = [];
for (const metric of this.getMetricsAsArray()) {
promises.push(metric.get());
}
const resolves = await Promise.all(promises);
for (const item of resolves) {
if (defaultLabelNames !== undefined && item.values !== undefined) {
for (const val of item.values) {
// Make a copy before mutating
val.labels = { ...val.labels };
for (const labelName of defaultLabelNames) {
val.labels[labelName] ??= this._defaultLabels[labelName];
}
}
}
metrics.push(item);
}
return metrics;
}
removeSingleMetric(name) {
this._metrics.delete(name);
}
getSingleMetricAsString(name) {
return this.getMetricsAsString(this._metrics.get(name));
}
getSingleMetric(name) {
return this._metrics.get(name);
}
setDefaultLabels(labels) {
this._defaultLabels = labels;
}
resetMetrics() {
for (const metric of this._metrics.values()) {
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 formatted = [];
for (const [name, value] of Object.entries(labels)) {
if (!exclude || !Object.hasOwn(exclude, name)) {
formatted.push(`${name}="${escapeLabelValue(value)}"`);
}
}
return formatted;
}
const sharedLabelCache = new WeakMap();
function flattenSharedLabels(labels) {
const cached = sharedLabelCache.get(labels);
if (cached !== undefined) {
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, ESCAPE_LABEL_VALUE_REPLACE_MAP);
}
function escapeString(str, extraReplaceDict) {
const fullDict = extraReplaceDict
? extraReplaceDict
: ESCAPE_STRING_REPLACE_MAP;
return str.replace(ESCAPE_REPLACE_REGEXP, REPLACE_FUNC(fullDict));
}
function standardizeCounterName(name) {
return name.replace(/_total$/, '');
}
module.exports = Registry;
module.exports.globalRegistry = new Registry();