UNPKG

@exa-labs/prom-client

Version:
279 lines (226 loc) 6.89 kB
'use strict'; 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();