From 43ece09fe90145054065f077981006ab634f6275 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 12:01:10 +0000 Subject: [PATCH 1/4] 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" From 2d84da8bd746dedc48197e4d80bf8329ba93b9e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 12:31:34 +0000 Subject: [PATCH 2/4] fix(retry): resolve circular dependency with Defaults module Define RETRY_BACKOFF_DELAYS and RETRY_JITTER_FACTOR locally in retry-utils.ts to avoid circular dependency that caused "Cannot access 'RETRY_BACKOFF_DELAYS' before initialization" error. --- src/Utils/retry-utils.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Utils/retry-utils.ts b/src/Utils/retry-utils.ts index 72677105..1936e767 100644 --- a/src/Utils/retry-utils.ts +++ b/src/Utils/retry-utils.ts @@ -16,7 +16,19 @@ import { EventEmitter } from 'events' import { metrics } from './prometheus-metrics.js' import type { CircuitBreaker } from './circuit-breaker.js' -import { RETRY_BACKOFF_DELAYS, RETRY_JITTER_FACTOR } from '../Defaults/index.js' + +/** + * Retry configuration with exponential backoff + * Delays in milliseconds: 1s, 2s, 5s, 10s, 20s + * Defined locally to avoid circular dependency with Defaults + */ +const RETRY_BACKOFF_DELAYS = [1000, 2000, 5000, 10000, 20000] + +/** + * Jitter factor for retry delays (0.15 = ±15% randomization) + * Helps prevent thundering herd problem + */ +const RETRY_JITTER_FACTOR = 0.15 /** * Backoff strategies From b719349c57a3b6d7768763362bd7a2f5dda78788 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 13:05:13 +0000 Subject: [PATCH 3/4] fix(retry): address PR #12 code review feedback - Add 'stepped' backoff strategy that uses RETRY_BACKOFF_DELAYS directly - Update rsocket config to use 'stepped' instead of 'exponential' - Clarify maxWebSocketListeners calculation (8 core + 10 dynamic + 2 buffer) - Document maxSocketClientListeners calculation (20 core + 20 dynamic + 10 buffer) - Change 'exponential backoff' to 'custom progressive backoff' in docs - Add comprehensive tests for getRetryDelayWithJitter and getAllRetryDelaysWithJitter - Fix existing test type errors (onRetry mock, always/never predicates) --- src/Defaults/index.ts | 12 +- src/Types/Socket.ts | 3 +- src/Utils/retry-utils.ts | 17 ++- src/__tests__/Utils/retry-utils.test.ts | 157 +++++++++++++++++++++++- 4 files changed, 178 insertions(+), 11 deletions(-) diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index 820737ff..3c7a3934 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -82,8 +82,13 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { // Circuit breaker configuration enableCircuitBreaker: true, // Listener limits (memory leak prevention) - // 8 base WS events + 10 dynamic listeners + 2 buffer slots + // WebSocket: 8 core events (open, close, error, message, ping, pong, upgrade, unexpected-response) + // + 10 dynamic listeners (reconnect handlers, custom events) + // + 2 buffer slots for temporary listeners = 20 total maxWebSocketListeners: 20, + // SocketClient: 20 core events (connection, messaging, presence, groups, calls, etc.) + // + 20 dynamic listeners (user handlers, plugins) + // + 10 buffer slots for high-load scenarios = 50 total maxSocketClientListeners: 50 } @@ -165,8 +170,9 @@ export const DEFAULT_CACHE_MAX_KEYS = { } /** - * Retry configuration with exponential backoff - * Delays in milliseconds: 1s, 2s, 5s, 10s, 20s + * Retry configuration with custom progressive backoff + * Fixed delay steps in milliseconds: 1s → 2s → 5s → 10s → 20s + * Use with 'stepped' backoff strategy in retry-utils.ts */ export const RETRY_BACKOFF_DELAYS = [1000, 2000, 5000, 10000, 20000] diff --git a/src/Types/Socket.ts b/src/Types/Socket.ts index 5d98c075..a8299896 100644 --- a/src/Types/Socket.ts +++ b/src/Types/Socket.ts @@ -170,13 +170,14 @@ export type SocketConfig = { /** * Max WebSocket event listeners (default: 20) - * Calculated as: 8 base WS events + 10 dynamic listeners + 2 buffer slots + * Calculation: 8 core WS events + 10 dynamic listeners + 2 buffer slots * WARNING: Setting to 0 disables limit and allows potential memory leaks! */ maxWebSocketListeners?: number /** * Max SocketClient event listeners (default: 50) + * Calculation: 20 core events + 20 dynamic listeners + 10 buffer slots * WARNING: Setting to 0 disables limit and allows potential memory leaks! */ maxSocketClientListeners?: number diff --git a/src/Utils/retry-utils.ts b/src/Utils/retry-utils.ts index 1936e767..a9baa19b 100644 --- a/src/Utils/retry-utils.ts +++ b/src/Utils/retry-utils.ts @@ -33,7 +33,7 @@ const RETRY_JITTER_FACTOR = 0.15 /** * Backoff strategies */ -export type BackoffStrategy = 'exponential' | 'linear' | 'constant' | 'fibonacci' +export type BackoffStrategy = 'exponential' | 'linear' | 'constant' | 'fibonacci' | 'stepped' /** * Retry configuration options @@ -152,6 +152,14 @@ export function calculateDelay( break } + case 'stepped': { + // Uses pre-defined delay array directly (ignores baseDelay/multiplier) + // Falls back to last delay if attempt exceeds array length + const index = Math.min(attempt - 1, RETRY_BACKOFF_DELAYS.length - 1) + delay = RETRY_BACKOFF_DELAYS[index] ?? RETRY_BACKOFF_DELAYS[0] ?? baseDelay + break + } + default: delay = baseDelay } @@ -646,14 +654,15 @@ export const retryConfigs = { }, /** - * RSocket-style retry (uses RETRY_BACKOFF_DELAYS from Defaults) - * Delays: 1s, 2s, 5s, 10s, 20s with jitter + * RSocket-style retry with stepped delays + * Uses fixed delay array: 1s, 2s, 5s, 10s, 20s (with ±15% jitter) + * Unlike exponential, this uses exact delays from RETRY_BACKOFF_DELAYS */ rsocket: { maxAttempts: RETRY_BACKOFF_DELAYS.length, baseDelay: RETRY_BACKOFF_DELAYS[0], maxDelay: RETRY_BACKOFF_DELAYS[RETRY_BACKOFF_DELAYS.length - 1], - backoffStrategy: 'exponential' as const, + backoffStrategy: 'stepped' as const, jitter: RETRY_JITTER_FACTOR, }, } diff --git a/src/__tests__/Utils/retry-utils.test.ts b/src/__tests__/Utils/retry-utils.test.ts index 9e9df2bf..6ded3a5b 100644 --- a/src/__tests__/Utils/retry-utils.test.ts +++ b/src/__tests__/Utils/retry-utils.test.ts @@ -14,6 +14,8 @@ import { calculateDelay, retryPredicates, retryConfigs, + getRetryDelayWithJitter, + getAllRetryDelaysWithJitter, type RetryContext, } from '../../Utils/retry-utils.js' @@ -114,7 +116,7 @@ describe('retry', () => { describe('callbacks', () => { it('should call onRetry callback', async () => { - const onRetry = jest.fn() + const onRetry = jest.fn<(error: Error, attempt: number, delay: number) => void>() let attempts = 0 await retry( @@ -388,13 +390,13 @@ describe('RetryManager', () => { describe('retryPredicates', () => { describe('always', () => { it('should always return true', () => { - expect(retryPredicates.always(new Error(), 1)).toBe(true) + expect(retryPredicates.always()).toBe(true) }) }) describe('never', () => { it('should always return false', () => { - expect(retryPredicates.never(new Error(), 1)).toBe(false) + expect(retryPredicates.never()).toBe(false) }) }) @@ -463,4 +465,153 @@ describe('retryConfigs', () => { it('should have network config with predicate', () => { expect(retryConfigs.network.shouldRetry).toBe(retryPredicates.onNetworkError) }) + + it('should have rsocket config with stepped strategy', () => { + expect(retryConfigs.rsocket.backoffStrategy).toBe('stepped') + expect(retryConfigs.rsocket.maxAttempts).toBe(5) + expect(retryConfigs.rsocket.baseDelay).toBe(1000) + expect(retryConfigs.rsocket.maxDelay).toBe(20000) + }) +}) + +describe('calculateDelay - stepped strategy', () => { + it('should use exact delays from RETRY_BACKOFF_DELAYS array', () => { + // Expected delays: [1000, 2000, 5000, 10000, 20000] + const delay1 = calculateDelay(1, 100, 50000, 'stepped', 2, 0) + const delay2 = calculateDelay(2, 100, 50000, 'stepped', 2, 0) + const delay3 = calculateDelay(3, 100, 50000, 'stepped', 2, 0) + const delay4 = calculateDelay(4, 100, 50000, 'stepped', 2, 0) + const delay5 = calculateDelay(5, 100, 50000, 'stepped', 2, 0) + + expect(delay1).toBe(1000) + expect(delay2).toBe(2000) + expect(delay3).toBe(5000) + expect(delay4).toBe(10000) + expect(delay5).toBe(20000) + }) + + it('should use last delay for attempts beyond array length', () => { + const delay6 = calculateDelay(6, 100, 50000, 'stepped', 2, 0) + const delay10 = calculateDelay(10, 100, 50000, 'stepped', 2, 0) + + expect(delay6).toBe(20000) + expect(delay10).toBe(20000) + }) + + it('should ignore baseDelay and multiplier parameters', () => { + // baseDelay=9999 and multiplier=99 should be ignored + const delay = calculateDelay(1, 9999, 50000, 'stepped', 99, 0) + expect(delay).toBe(1000) // First step from RETRY_BACKOFF_DELAYS + }) + + it('should still apply jitter to stepped delays', () => { + const delays = new Set() + + for (let i = 0; i < 20; i++) { + delays.add(calculateDelay(1, 100, 50000, 'stepped', 2, 0.15)) + } + + // With 15% jitter on 1000ms, delays should vary between ~850ms and ~1150ms + expect(delays.size).toBeGreaterThan(1) + }) +}) + +describe('getRetryDelayWithJitter', () => { + it('should return delay for each attempt with jitter applied', () => { + // Test multiple times to verify jitter creates variation + const delays: number[] = [] + for (let i = 0; i < 10; i++) { + delays.push(getRetryDelayWithJitter(1)) + } + + // All delays should be within ±15% of 1000ms (850-1150ms) + delays.forEach(delay => { + expect(delay).toBeGreaterThanOrEqual(850) + expect(delay).toBeLessThanOrEqual(1150) + }) + + // With jitter, we should have some variation + const uniqueDelays = new Set(delays) + expect(uniqueDelays.size).toBeGreaterThan(1) + }) + + it('should return correct base delays for each attempt', () => { + // Test without considering jitter - just verify rough ranges + // Attempt 1: 1000ms ±15% = 850-1150ms + // Attempt 2: 2000ms ±15% = 1700-2300ms + // Attempt 3: 5000ms ±15% = 4250-5750ms + // Attempt 4: 10000ms ±15% = 8500-11500ms + // Attempt 5: 20000ms ±15% = 17000-23000ms + + const d1 = getRetryDelayWithJitter(1) + const d2 = getRetryDelayWithJitter(2) + const d3 = getRetryDelayWithJitter(3) + const d4 = getRetryDelayWithJitter(4) + const d5 = getRetryDelayWithJitter(5) + + expect(d1).toBeGreaterThanOrEqual(850) + expect(d1).toBeLessThanOrEqual(1150) + + expect(d2).toBeGreaterThanOrEqual(1700) + expect(d2).toBeLessThanOrEqual(2300) + + expect(d3).toBeGreaterThanOrEqual(4250) + expect(d3).toBeLessThanOrEqual(5750) + + expect(d4).toBeGreaterThanOrEqual(8500) + expect(d4).toBeLessThanOrEqual(11500) + + expect(d5).toBeGreaterThanOrEqual(17000) + expect(d5).toBeLessThanOrEqual(23000) + }) + + it('should handle attempt 0 by using first delay', () => { + const delay = getRetryDelayWithJitter(0) + expect(delay).toBeGreaterThanOrEqual(850) + expect(delay).toBeLessThanOrEqual(1150) + }) + + it('should handle negative attempts by using first delay', () => { + const delay = getRetryDelayWithJitter(-5) + expect(delay).toBeGreaterThanOrEqual(850) + expect(delay).toBeLessThanOrEqual(1150) + }) + + it('should use last delay for attempts beyond array length', () => { + const delay6 = getRetryDelayWithJitter(6) + const delay100 = getRetryDelayWithJitter(100) + + // Both should use 20000ms ±15% = 17000-23000ms + expect(delay6).toBeGreaterThanOrEqual(17000) + expect(delay6).toBeLessThanOrEqual(23000) + + expect(delay100).toBeGreaterThanOrEqual(17000) + expect(delay100).toBeLessThanOrEqual(23000) + }) +}) + +describe('getAllRetryDelaysWithJitter', () => { + it('should return array with 5 delays', () => { + const delays = getAllRetryDelaysWithJitter() + expect(delays).toHaveLength(5) + }) + + it('should return delays in increasing order (approximately)', () => { + const delays = getAllRetryDelaysWithJitter() + + // Each delay should be roughly larger than the previous + // (with jitter, there's small chance of overlap at boundaries) + expect(delays[0]!).toBeLessThan(delays[2]!) // 1000 < 5000 + expect(delays[1]!).toBeLessThan(delays[3]!) // 2000 < 10000 + expect(delays[2]!).toBeLessThan(delays[4]!) // 5000 < 20000 + }) + + it('should return different values on each call due to jitter', () => { + const delays1 = getAllRetryDelaysWithJitter() + const delays2 = getAllRetryDelaysWithJitter() + + // At least one delay should differ between calls + const allSame = delays1.every((d, i) => d === delays2[i]) + expect(allSame).toBe(false) + }) }) From 4924912f85dc5c33039b8d813ce2b2a5a6a7d7a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 13:16:43 +0000 Subject: [PATCH 4/4] fix(metrics): address PR #11 code review feedback Major improvements to prometheus-metrics.ts: - Use custom registry instead of global promClient.register (isolation) - Remove duplicate localValues - prom-client is now single source of truth - Fix reset(labels) to respect labels param using remove() instead of reset() - Migrate Summary class to use prom-client internally - getMetricsOutput() now uses customRegistry for proper isolation Fix misleading comments in retry-utils.ts: - Change "exponential backoff" to "custom progressive backoff" - Fix comment claiming constants are "from Defaults" (now local) Breaking changes to getValues()/get() - now async as they query prom-client --- src/Utils/prometheus-metrics.ts | 353 +++++++++++++++++--------------- src/Utils/retry-utils.ts | 6 +- 2 files changed, 192 insertions(+), 167 deletions(-) diff --git a/src/Utils/prometheus-metrics.ts b/src/Utils/prometheus-metrics.ts index 142e8cf2..262b19a5 100644 --- a/src/Utils/prometheus-metrics.ts +++ b/src/Utils/prometheus-metrics.ts @@ -35,6 +35,9 @@ import { createServer, IncomingMessage, ServerResponse, Server } from 'http' import * as os from 'os' import * as promClient from 'prom-client' +// Create a custom registry to avoid conflicts with global registry +const customRegistry = new promClient.Registry() + // ============================================ // Configuration // ============================================ @@ -177,7 +180,6 @@ export interface SummaryValue { export class Counter implements BaseMetric { readonly type = 'counter' as const private promCounter: promClient.Counter - private localValues: Map = new Map() // For getValues() compatibility constructor( public name: string, @@ -188,6 +190,7 @@ export class Counter implements BaseMetric { name, help, labelNames, + registers: [customRegistry], // Use custom registry, not global }) } @@ -209,54 +212,48 @@ export class Counter implements BaseMetric { throw new Error('Counter cannot be decremented') } - // Use prom-client + // Use prom-client as single source of truth 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.localValues.set(key, { - labels, - value: incValue, - timestamp: Date.now(), - }) - } } /** - * Get current value (from local cache) + * Get current value (async, from prom-client) */ - get(labels: Labels = {}): number { + async get(labels: Labels = {}): Promise { + const metric = await this.promCounter.get() const key = this.labelsToKey(labels) - return this.localValues.get(key)?.value ?? 0 + const value = metric.values.find(v => this.labelsToKey(v.labels as Labels) === key) + return value?.value ?? 0 } /** * Reset the counter + * If labels provided, removes only that label combination + * Otherwise resets all values */ reset(labels?: Labels): void { - this.promCounter.reset() - if (labels) { - const key = this.labelsToKey(labels) - this.localValues.delete(key) + if (labels && Object.keys(labels).length > 0) { + // prom-client doesn't support removing specific label combinations + // Use remove() to remove the specific label set + this.promCounter.remove(labels) } else { - this.localValues.clear() + this.promCounter.reset() } } /** - * Get all values (from local cache for compatibility) + * Get all values (async, from prom-client) */ - getValues(): MetricValue[] { - return Array.from(this.localValues.values()) + async getValues(): Promise { + const metric = await this.promCounter.get() + return metric.values.map(v => ({ + labels: v.labels as Labels, + value: v.value, + })) } private labelsToKey(labels: Labels): string { @@ -287,7 +284,6 @@ export class Counter implements BaseMetric { export class Gauge implements BaseMetric { readonly type = 'gauge' as const private promGauge: promClient.Gauge - private localValues: Map = new Map() // For getValues() compatibility constructor( public name: string, @@ -298,6 +294,7 @@ export class Gauge implements BaseMetric { name, help, labelNames, + registers: [customRegistry], // Use custom registry, not global }) } @@ -315,20 +312,12 @@ export class Gauge implements BaseMetric { setValue = value ?? 0 } - // Use prom-client + // Use prom-client as single source of truth 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.localValues.set(key, { - labels, - value: setValue, - timestamp: Date.now(), - }) } /** @@ -345,22 +334,12 @@ export class Gauge implements BaseMetric { incValue = value ?? 1 } - // Use prom-client + // Use prom-client as single source of truth if (Object.keys(labels).length > 0) { this.promGauge.labels(labels).inc(incValue) } else { this.promGauge.inc(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(), - }) } /** @@ -377,22 +356,12 @@ export class Gauge implements BaseMetric { decValue = value ?? 1 } - // Use prom-client + // Use prom-client as single source of truth if (Object.keys(labels).length > 0) { this.promGauge.labels(labels).dec(decValue) } else { this.promGauge.dec(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(), - }) } /** @@ -403,31 +372,37 @@ export class Gauge implements BaseMetric { } /** - * Get current value (from local cache) + * Get current value (async, from prom-client) */ - get(labels: Labels = {}): number { + async get(labels: Labels = {}): Promise { + const metric = await this.promGauge.get() const key = this.labelsToKey(labels) - return this.localValues.get(key)?.value ?? 0 + const value = metric.values.find(v => this.labelsToKey(v.labels as Labels) === key) + return value?.value ?? 0 } /** * Reset the gauge + * If labels provided, removes only that label combination + * Otherwise resets all values */ reset(labels?: Labels): void { - this.promGauge.reset() - if (labels) { - const key = this.labelsToKey(labels) - this.localValues.delete(key) + if (labels && Object.keys(labels).length > 0) { + this.promGauge.remove(labels) } else { - this.localValues.clear() + this.promGauge.reset() } } /** - * Get all values (from local cache for compatibility) + * Get all values (async, from prom-client) */ - getValues(): MetricValue[] { - return Array.from(this.localValues.values()) + async getValues(): Promise { + const metric = await this.promGauge.get() + return metric.values.map(v => ({ + labels: v.labels as Labels, + value: v.value, + })) } private labelsToKey(labels: Labels): string { @@ -476,7 +451,6 @@ export class Gauge implements BaseMetric { export class Histogram implements BaseMetric { readonly type = 'histogram' as const private promHistogram: promClient.Histogram - private localValues: Map = new Map() // For getValues() compatibility private buckets: number[] constructor( @@ -491,6 +465,7 @@ export class Histogram implements BaseMetric { help, labelNames, buckets: this.buckets, + registers: [customRegistry], // Use custom registry, not global }) } @@ -508,36 +483,12 @@ export class Histogram implements BaseMetric { observeValue = value ?? 0 } - // Use prom-client + // Use prom-client as single source of truth 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.localValues.get(key) - - if (!histValue) { - histValue = { - labels, - buckets: new Map(this.buckets.map((b) => [b, 0])), - sum: 0, - count: 0, - } - this.localValues.set(key, histValue) - } - - // Increment appropriate buckets (cumulative) - for (const bucket of this.buckets) { - if (observeValue <= bucket) { - histValue.buckets.set(bucket, (histValue.buckets.get(bucket) ?? 0) + 1) - } - } - - histValue.sum += observeValue - histValue.count++ } /** @@ -565,31 +516,85 @@ export class Histogram implements BaseMetric { } /** - * Get histogram values (from local cache) + * Get histogram values (async, from prom-client) */ - get(labels: Labels = {}): HistogramValue | undefined { + async get(labels: Labels = {}): Promise { + const metric = await this.promHistogram.get() const key = this.labelsToKey(labels) - return this.localValues.get(key) + + // Find values matching labels + const matchingValues = metric.values.filter(v => { + const vLabels = { ...v.labels } as Labels + delete (vLabels as Record)['le'] // Remove bucket label + return this.labelsToKey(vLabels) === key + }) + + if (matchingValues.length === 0) return undefined + + const buckets = new Map() + let sum = 0 + let count = 0 + + for (const v of matchingValues) { + if (v.metricName?.endsWith('_bucket')) { + const le = parseFloat((v.labels as Record)['le'] ?? '0') + buckets.set(le, v.value) + } else if (v.metricName?.endsWith('_sum')) { + sum = v.value + } else if (v.metricName?.endsWith('_count')) { + count = v.value + } + } + + return { labels, buckets, sum, count } } /** * Reset the histogram + * If labels provided, removes only that label combination + * Otherwise resets all values */ reset(labels?: Labels): void { - this.promHistogram.reset() - if (labels) { - const key = this.labelsToKey(labels) - this.localValues.delete(key) + if (labels && Object.keys(labels).length > 0) { + this.promHistogram.remove(labels) } else { - this.localValues.clear() + this.promHistogram.reset() } } /** - * Get all values (from local cache for compatibility) + * Get all values (async, from prom-client) */ - getValues(): HistogramValue[] { - return Array.from(this.localValues.values()) + async getValues(): Promise { + const metric = await this.promHistogram.get() + const grouped = new Map() + + for (const v of metric.values) { + const vLabels = { ...v.labels } as Labels + delete (vLabels as Record)['le'] + const key = this.labelsToKey(vLabels) + + if (!grouped.has(key)) { + grouped.set(key, { + labels: vLabels, + buckets: new Map(), + sum: 0, + count: 0, + }) + } + + const histValue = grouped.get(key)! + if (v.metricName?.endsWith('_bucket')) { + const le = parseFloat((v.labels as Record)['le'] ?? '0') + histValue.buckets.set(le, v.value) + } else if (v.metricName?.endsWith('_sum')) { + histValue.sum = v.value + } else if (v.metricName?.endsWith('_count')) { + histValue.count = v.value + } + } + + return Array.from(grouped.values()) } /** @@ -623,26 +628,32 @@ export class Histogram implements BaseMetric { /** * Summary class - value percentiles (quantiles) + * Now backed by prom-client for full Prometheus compatibility * * Summaries calculate quantiles over a sliding time window. * Useful for tracking latency distributions. */ export class Summary implements BaseMetric { readonly type = 'summary' as const - private values: Map = new Map() + private promSummary: promClient.Summary private percentiles: number[] - private maxAge: number // ms - private maxSize: number constructor( public name: string, public help: string, public labelNames: string[] = [], - options: { percentiles?: number[]; maxAge?: number; maxSize?: number } = {} + options: { percentiles?: number[]; maxAgeSeconds?: number; ageBuckets?: number } = {} ) { this.percentiles = options.percentiles ?? DEFAULT_PERCENTILES - this.maxAge = options.maxAge ?? 600000 // 10 min - this.maxSize = options.maxSize ?? 1000 + this.promSummary = new promClient.Summary({ + name, + help, + labelNames, + percentiles: this.percentiles, + maxAgeSeconds: options.maxAgeSeconds ?? 600, // 10 min default + ageBuckets: options.ageBuckets ?? 5, + registers: [customRegistry], // Use custom registry, not global + }) } /** @@ -659,30 +670,11 @@ export class Summary implements BaseMetric { observeValue = value ?? 0 } - const key = this.labelsToKey(labels) - let summaryValue = this.values.get(key) - - if (!summaryValue) { - summaryValue = { - labels, - values: [], - sum: 0, - count: 0, - } - this.values.set(key, summaryValue) - } - - summaryValue.values.push(observeValue) - summaryValue.sum += observeValue - summaryValue.count++ - - // Limit size to prevent memory leaks - if (summaryValue.values.length > this.maxSize) { - const removed = summaryValue.values.shift() - if (removed !== undefined) { - summaryValue.sum -= removed - summaryValue.count-- - } + // Use prom-client as single source of truth + if (Object.keys(labels).length > 0) { + this.promSummary.labels(labels).observe(observeValue) + } else { + this.promSummary.observe(observeValue) } } @@ -699,46 +691,78 @@ export class Summary implements BaseMetric { } /** - * Calculate percentile + * Get summary values (async, from prom-client) */ - getPercentile(labels: Labels, percentile: number): number | undefined { + async get(labels: Labels = {}): Promise { + const metric = await this.promSummary.get() const key = this.labelsToKey(labels) - const summaryValue = this.values.get(key) - if (!summaryValue || summaryValue.values.length === 0) { - return undefined + // Find values matching labels + const matchingValues = metric.values.filter(v => { + const vLabels = { ...v.labels } as Labels + delete (vLabels as Record)['quantile'] + return this.labelsToKey(vLabels) === key + }) + + if (matchingValues.length === 0) return undefined + + let sum = 0 + let count = 0 + + for (const v of matchingValues) { + if (v.metricName?.endsWith('_sum')) { + sum = v.value + } else if (v.metricName?.endsWith('_count')) { + count = v.value + } } - const sorted = [...summaryValue.values].sort((a, b) => a - b) - const index = Math.ceil(percentile * sorted.length) - 1 - return sorted[Math.max(0, index)] - } - - /** - * Get summary values - */ - get(labels: Labels = {}): SummaryValue | undefined { - const key = this.labelsToKey(labels) - return this.values.get(key) + return { labels, values: [], sum, count } } /** * Reset the summary + * If labels provided, removes only that label combination + * Otherwise resets all values */ reset(labels?: Labels): void { - if (labels) { - const key = this.labelsToKey(labels) - this.values.delete(key) + if (labels && Object.keys(labels).length > 0) { + this.promSummary.remove(labels) } else { - this.values.clear() + this.promSummary.reset() } } /** - * Get all values + * Get all values (async, from prom-client) */ - getValues(): SummaryValue[] { - return Array.from(this.values.values()) + async getValues(): Promise { + const metric = await this.promSummary.get() + const grouped = new Map() + + for (const v of metric.values) { + const vLabels = { ...v.labels } as Labels + delete (vLabels as Record)['quantile'] + const key = this.labelsToKey(vLabels) + + if (!grouped.has(key)) { + grouped.set(key, { + labels: vLabels, + values: [], + sum: 0, + count: 0, + }) + } + + const summaryValue = grouped.get(key)! + if (v.metricName?.endsWith('_sum')) { + summaryValue.sum = v.value + } else if (v.metricName?.endsWith('_count')) { + summaryValue.count = v.value + } + } + + return Array.from(grouped.values()) } /** @@ -844,18 +868,19 @@ export class MetricsRegistry { /** * Return metrics in Prometheus exposition format - * Uses prom-client's native metrics() for proper OpenMetrics format + * Uses custom registry with configured prefix and defaultLabels */ async getMetricsOutput(): Promise { - // Use prom-client's register for native Prometheus format - return await promClient.register.metrics() + // Use customRegistry (not global) for proper isolation + // Prefix and defaultLabels are applied when metrics are created + return await customRegistry.metrics() } /** * Return content type for Prometheus (using prom-client) */ contentType(): string { - return promClient.register.contentType + return customRegistry.contentType } private formatLabels(labels: Labels): string { diff --git a/src/Utils/retry-utils.ts b/src/Utils/retry-utils.ts index a9baa19b..66594804 100644 --- a/src/Utils/retry-utils.ts +++ b/src/Utils/retry-utils.ts @@ -18,8 +18,8 @@ import { metrics } from './prometheus-metrics.js' import type { CircuitBreaker } from './circuit-breaker.js' /** - * Retry configuration with exponential backoff - * Delays in milliseconds: 1s, 2s, 5s, 10s, 20s + * Retry configuration with custom progressive backoff + * Fixed delay steps in milliseconds: 1s → 2s → 5s → 10s → 20s * Defined locally to avoid circular dependency with Defaults */ const RETRY_BACKOFF_DELAYS = [1000, 2000, 5000, 10000, 20000] @@ -669,7 +669,7 @@ export const retryConfigs = { /** * Get retry delay with jitter applied - * Uses RETRY_BACKOFF_DELAYS and RETRY_JITTER_FACTOR from Defaults + * Uses RETRY_BACKOFF_DELAYS and RETRY_JITTER_FACTOR defined locally * * @param attempt - Current attempt number (1-based) * @returns Delay in ms with jitter applied