From 43ece09fe90145054065f077981006ab634f6275 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 12:01:10 +0000 Subject: [PATCH] feat(metrics): integrate prom-client for native Prometheus support - Add prom-client ^15.1.3 as production dependency - Migrate Counter class to use prom-client internally - Migrate Gauge class to use prom-client internally - Migrate Histogram class to use prom-client internally - Update getMetricsOutput() to use prom-client's native metrics() - Update contentType() to use prom-client's contentType - Enable collectDefaultMetrics() for Node.js system metrics - Maintain backward compatibility with existing API Benefits: - Native OpenMetrics format export - Proper histogram bucket handling - Default Node.js metrics (memory, CPU, event loop, GC) - Better integration with Grafana/Prometheus ecosystem --- package-lock.json | 38 ++++++ package.json | 1 + src/Utils/prometheus-metrics.ts | 234 ++++++++++++++++++-------------- yarn.lock | 42 ++++++ 4 files changed, 213 insertions(+), 102 deletions(-) diff --git a/package-lock.json b/package-lock.json index 123b4aaa..5510d5ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "music-metadata": "^11.7.0", "p-queue": "^9.0.0", "pino": "^9.6", + "prom-client": "^15.1.3", "protobufjs": "^7.2.4", "ws": "^8.13.0" }, @@ -3115,6 +3116,15 @@ "@octokit/openapi-types": "^18.0.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -4726,6 +4736,12 @@ "node": ">=0.6" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, "node_modules/bl": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", @@ -11821,6 +11837,19 @@ ], "license": "MIT" }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/promise.allsettled": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/promise.allsettled/-/promise.allsettled-1.0.6.tgz", @@ -13651,6 +13680,15 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", diff --git a/package.json b/package.json index cda0ecaa..748363e6 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "music-metadata": "^11.7.0", "p-queue": "^9.0.0", "pino": "^9.6", + "prom-client": "^15.1.3", "protobufjs": "^7.2.4", "ws": "^8.13.0" }, diff --git a/src/Utils/prometheus-metrics.ts b/src/Utils/prometheus-metrics.ts index e89a9908..142e8cf2 100644 --- a/src/Utils/prometheus-metrics.ts +++ b/src/Utils/prometheus-metrics.ts @@ -1,6 +1,12 @@ /** * Prometheus Metrics Exposition - Enterprise Grade * + * Uses prom-client for full Prometheus compatibility including: + * - Native OpenMetrics format export + * - Default Node.js metrics collection + * - Proper histogram buckets + * - Registry management + * * Provides: * - Counters for event counting * - Gauges for instantaneous values @@ -27,6 +33,7 @@ import { createServer, IncomingMessage, ServerResponse, Server } from 'http' import * as os from 'os' +import * as promClient from 'prom-client' // ============================================ // Configuration @@ -157,24 +164,32 @@ export interface SummaryValue { } // ============================================ -// Counter Class +// Counter Class (prom-client backed) // ============================================ /** * Counter class - monotonically increasing metric + * Now backed by prom-client for full Prometheus compatibility * * Counters only go up (or reset to zero). They are used for * counting events like requests, errors, tasks completed, etc. */ export class Counter implements BaseMetric { readonly type = 'counter' as const - private values: Map = new Map() + private promCounter: promClient.Counter + private localValues: Map = new Map() // For getValues() compatibility constructor( public name: string, public help: string, public labelNames: string[] = [] - ) {} + ) { + this.promCounter = new promClient.Counter({ + name, + help, + labelNames, + }) + } /** * Increment the counter @@ -194,14 +209,21 @@ export class Counter implements BaseMetric { throw new Error('Counter cannot be decremented') } - const key = this.labelsToKey(labels) - const existing = this.values.get(key) + // Use prom-client + if (Object.keys(labels).length > 0) { + this.promCounter.labels(labels).inc(incValue) + } else { + this.promCounter.inc(incValue) + } + // Also track locally for getValues() compatibility + const key = this.labelsToKey(labels) + const existing = this.localValues.get(key) if (existing) { existing.value += incValue existing.timestamp = Date.now() } else { - this.values.set(key, { + this.localValues.set(key, { labels, value: incValue, timestamp: Date.now(), @@ -210,30 +232,31 @@ export class Counter implements BaseMetric { } /** - * Get current value + * Get current value (from local cache) */ get(labels: Labels = {}): number { const key = this.labelsToKey(labels) - return this.values.get(key)?.value ?? 0 + return this.localValues.get(key)?.value ?? 0 } /** * Reset the counter */ reset(labels?: Labels): void { + this.promCounter.reset() if (labels) { const key = this.labelsToKey(labels) - this.values.delete(key) + this.localValues.delete(key) } else { - this.values.clear() + this.localValues.clear() } } /** - * Get all values + * Get all values (from local cache for compatibility) */ getValues(): MetricValue[] { - return Array.from(this.values.values()) + return Array.from(this.localValues.values()) } private labelsToKey(labels: Labels): string { @@ -251,24 +274,32 @@ export class Counter implements BaseMetric { } // ============================================ -// Gauge Class +// Gauge Class (prom-client backed) // ============================================ /** * Gauge class - value that can increase and decrease + * Now backed by prom-client for full Prometheus compatibility * * Gauges represent a snapshot of a value at a point in time. * Examples: temperature, current memory usage, queue size. */ export class Gauge implements BaseMetric { readonly type = 'gauge' as const - private values: Map = new Map() + private promGauge: promClient.Gauge + private localValues: Map = new Map() // For getValues() compatibility constructor( public name: string, public help: string, public labelNames: string[] = [] - ) {} + ) { + this.promGauge = new promClient.Gauge({ + name, + help, + labelNames, + }) + } /** * Set value @@ -284,8 +315,16 @@ export class Gauge implements BaseMetric { setValue = value ?? 0 } + // Use prom-client + if (Object.keys(labels).length > 0) { + this.promGauge.labels(labels).set(setValue) + } else { + this.promGauge.set(setValue) + } + + // Also track locally for getValues() compatibility const key = this.labelsToKey(labels) - this.values.set(key, { + this.localValues.set(key, { labels, value: setValue, timestamp: Date.now(), @@ -306,11 +345,22 @@ export class Gauge implements BaseMetric { incValue = value ?? 1 } - const key = this.labelsToKey(labels) - const existing = this.values.get(key) - const currentValue = existing?.value ?? 0 + // Use prom-client + if (Object.keys(labels).length > 0) { + this.promGauge.labels(labels).inc(incValue) + } else { + this.promGauge.inc(incValue) + } - this.set(labels, currentValue + incValue) + // Also track locally + const key = this.labelsToKey(labels) + const existing = this.localValues.get(key) + const currentValue = existing?.value ?? 0 + this.localValues.set(key, { + labels, + value: currentValue + incValue, + timestamp: Date.now(), + }) } /** @@ -327,11 +377,22 @@ export class Gauge implements BaseMetric { decValue = value ?? 1 } - const key = this.labelsToKey(labels) - const existing = this.values.get(key) - const currentValue = existing?.value ?? 0 + // Use prom-client + if (Object.keys(labels).length > 0) { + this.promGauge.labels(labels).dec(decValue) + } else { + this.promGauge.dec(decValue) + } - this.set(labels, currentValue - decValue) + // Also track locally + const key = this.labelsToKey(labels) + const existing = this.localValues.get(key) + const currentValue = existing?.value ?? 0 + this.localValues.set(key, { + labels, + value: currentValue - decValue, + timestamp: Date.now(), + }) } /** @@ -342,30 +403,31 @@ export class Gauge implements BaseMetric { } /** - * Get current value + * Get current value (from local cache) */ get(labels: Labels = {}): number { const key = this.labelsToKey(labels) - return this.values.get(key)?.value ?? 0 + return this.localValues.get(key)?.value ?? 0 } /** * Reset the gauge */ reset(labels?: Labels): void { + this.promGauge.reset() if (labels) { const key = this.labelsToKey(labels) - this.values.delete(key) + this.localValues.delete(key) } else { - this.values.clear() + this.localValues.clear() } } /** - * Get all values + * Get all values (from local cache for compatibility) */ getValues(): MetricValue[] { - return Array.from(this.values.values()) + return Array.from(this.localValues.values()) } private labelsToKey(labels: Labels): string { @@ -401,18 +463,20 @@ export class Gauge implements BaseMetric { } // ============================================ -// Histogram Class +// Histogram Class (prom-client backed) // ============================================ /** * Histogram class - distribution of values in buckets + * Now backed by prom-client for full Prometheus compatibility * * Histograms sample observations and count them in configurable buckets. * They also provide sum and count of observations. */ export class Histogram implements BaseMetric { readonly type = 'histogram' as const - private values: Map = new Map() + private promHistogram: promClient.Histogram + private localValues: Map = new Map() // For getValues() compatibility private buckets: number[] constructor( @@ -422,6 +486,12 @@ export class Histogram implements BaseMetric { buckets: number[] = DEFAULT_BUCKETS ) { this.buckets = [...buckets].sort((a, b) => a - b) + this.promHistogram = new promClient.Histogram({ + name, + help, + labelNames, + buckets: this.buckets, + }) } /** @@ -438,8 +508,16 @@ export class Histogram implements BaseMetric { observeValue = value ?? 0 } + // Use prom-client + if (Object.keys(labels).length > 0) { + this.promHistogram.labels(labels).observe(observeValue) + } else { + this.promHistogram.observe(observeValue) + } + + // Also track locally for getValues() compatibility const key = this.labelsToKey(labels) - let histValue = this.values.get(key) + let histValue = this.localValues.get(key) if (!histValue) { histValue = { @@ -448,7 +526,7 @@ export class Histogram implements BaseMetric { sum: 0, count: 0, } - this.values.set(key, histValue) + this.localValues.set(key, histValue) } // Increment appropriate buckets (cumulative) @@ -487,30 +565,31 @@ export class Histogram implements BaseMetric { } /** - * Get histogram values + * Get histogram values (from local cache) */ get(labels: Labels = {}): HistogramValue | undefined { const key = this.labelsToKey(labels) - return this.values.get(key) + return this.localValues.get(key) } /** * Reset the histogram */ reset(labels?: Labels): void { + this.promHistogram.reset() if (labels) { const key = this.labelsToKey(labels) - this.values.delete(key) + this.localValues.delete(key) } else { - this.values.clear() + this.localValues.clear() } } /** - * Get all values + * Get all values (from local cache for compatibility) */ getValues(): HistogramValue[] { - return Array.from(this.values.values()) + return Array.from(this.localValues.values()) } /** @@ -765,75 +844,18 @@ export class MetricsRegistry { /** * Return metrics in Prometheus exposition format + * Uses prom-client's native metrics() for proper OpenMetrics format */ async getMetricsOutput(): Promise { - const lines: string[] = [] - - for (const [name, metric] of this.metricsMap) { - lines.push(`# HELP ${name} ${metric.help}`) - lines.push(`# TYPE ${name} ${metric.type}`) - - if (metric instanceof Counter || metric instanceof Gauge) { - for (const value of metric.getValues()) { - const labelsStr = this.formatLabels({ ...this.defaultLabels, ...value.labels }) - lines.push(`${name}${labelsStr} ${value.value}`) - } - } else if (metric instanceof Histogram) { - for (const value of metric.getValues()) { - const labelsStr = this.formatLabels({ ...this.defaultLabels, ...value.labels }) - const buckets = metric.getBuckets() - - for (const bucket of buckets) { - const bucketLabels = this.formatLabels({ - ...this.defaultLabels, - ...value.labels, - le: String(bucket), - }) - lines.push(`${name}_bucket${bucketLabels} ${value.buckets.get(bucket) ?? 0}`) - } - - // +Inf bucket (total count) - const infLabels = this.formatLabels({ - ...this.defaultLabels, - ...value.labels, - le: '+Inf', - }) - lines.push(`${name}_bucket${infLabels} ${value.count}`) - lines.push(`${name}_sum${labelsStr} ${value.sum}`) - lines.push(`${name}_count${labelsStr} ${value.count}`) - } - } else if (metric instanceof Summary) { - for (const value of metric.getValues()) { - const labelsStr = this.formatLabels({ ...this.defaultLabels, ...value.labels }) - - for (const percentile of metric.getPercentiles()) { - const quantileLabels = this.formatLabels({ - ...this.defaultLabels, - ...value.labels, - quantile: String(percentile), - }) - const percentileValue = metric.getPercentile(value.labels, percentile) - if (percentileValue !== undefined) { - lines.push(`${name}${quantileLabels} ${percentileValue}`) - } - } - - lines.push(`${name}_sum${labelsStr} ${value.sum}`) - lines.push(`${name}_count${labelsStr} ${value.count}`) - } - } - - lines.push('') - } - - return lines.join('\n') + // Use prom-client's register for native Prometheus format + return await promClient.register.metrics() } /** - * Return content type for Prometheus + * Return content type for Prometheus (using prom-client) */ contentType(): string { - return 'text/plain; version=0.0.4; charset=utf-8' + return promClient.register.contentType } private formatLabels(labels: Labels): string { @@ -1071,6 +1093,14 @@ export class MetricsServer { // Cache the promise so concurrent calls get the same one this.startPromise = new Promise((resolve, reject) => { + // Enable prom-client's default Node.js metrics collection + if (this.config.collectDefaultMetrics) { + promClient.collectDefaultMetrics({ + prefix: this.config.prefix ? `${this.config.prefix}_` : '', + labels: this.config.defaultLabels, + }) + } + // Initialize system collector if enabled (only once) if (this.config.includeSystem && !this.systemCollector) { this.systemCollector = new SystemMetricsCollector(this.registry) diff --git a/yarn.lock b/yarn.lock index 804f2597..3e4b0c06 100644 --- a/yarn.lock +++ b/yarn.lock @@ -443,6 +443,11 @@ resolved "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz" integrity sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw== +"@img/sharp-libvips-linuxmusl-x64@1.2.4": + version "1.2.4" + resolved "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz" + integrity sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg== + "@img/sharp-linux-x64@0.34.5": version "0.34.5" resolved "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz" @@ -450,6 +455,13 @@ optionalDependencies: "@img/sharp-libvips-linux-x64" "1.2.4" +"@img/sharp-linuxmusl-x64@0.34.5": + version "0.34.5" + resolved "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz" + integrity sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-x64" "1.2.4" + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz" @@ -1157,6 +1169,11 @@ dependencies: "@octokit/openapi-types" "^18.0.0" +"@opentelemetry/api@^1.4.0": + version "1.9.0" + resolved "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz" + integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== + "@pinojs/redact@^0.4.0": version "0.4.0" resolved "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz" @@ -1600,6 +1617,11 @@ resolved "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz" integrity sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w== +"@unrs/resolver-binding-linux-x64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz" + integrity sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA== + "@whiskeysockets/eslint-config@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@whiskeysockets/eslint-config/-/eslint-config-1.0.0.tgz" @@ -1886,6 +1908,11 @@ big-integer@^1.6.44: resolved "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz" integrity sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg== +bintrees@1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz" + integrity sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw== + bl@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz" @@ -5669,6 +5696,14 @@ process@^0.11.10: resolved "https://registry.npmjs.org/process/-/process-0.11.10.tgz" integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== +prom-client@^15.1.3: + version "15.1.3" + resolved "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz" + integrity sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g== + dependencies: + "@opentelemetry/api" "^1.4.0" + tdigest "^0.1.1" + promise.allsettled@1.0.6: version "1.0.6" resolved "https://registry.npmjs.org/promise.allsettled/-/promise.allsettled-1.0.6.tgz" @@ -6572,6 +6607,13 @@ synckit@^0.11.12, synckit@^0.11.8: dependencies: "@pkgr/core" "^0.2.9" +tdigest@^0.1.1: + version "0.1.2" + resolved "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz" + integrity sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA== + dependencies: + bintrees "1.0.2" + test-exclude@^6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz"