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
This commit is contained in:
Claude
2026-01-20 12:01:10 +00:00
parent e605a02528
commit 43ece09fe9
4 changed files with 213 additions and 102 deletions
+38
View File
@@ -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",
+1
View File
@@ -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"
},
+132 -102
View File
@@ -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<string, MetricValue> = new Map()
private promCounter: promClient.Counter<string>
private localValues: Map<string, MetricValue> = 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<string, MetricValue> = new Map()
private promGauge: promClient.Gauge<string>
private localValues: Map<string, MetricValue> = 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<string, HistogramValue> = new Map()
private promHistogram: promClient.Histogram<string>
private localValues: Map<string, HistogramValue> = 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<string> {
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<void>((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)
+42
View File
@@ -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"