From 5509b4e23a1ad6c401c3c87e02173bfc59f485d7 Mon Sep 17 00:00:00 2001 From: kwasniew Date: Fri, 12 Sep 2025 14:57:11 +0200 Subject: [PATCH 1/7] feat: histogram metric --- src/impact-metrics/metric-types.ts | 163 ++++++++++++++++++- src/test/impact-metrics/metric-types.test.ts | 119 ++++++++++++++ 2 files changed, 275 insertions(+), 7 deletions(-) diff --git a/src/impact-metrics/metric-types.ts b/src/impact-metrics/metric-types.ts index a6a72126..551ad8ad 100644 --- a/src/impact-metrics/metric-types.ts +++ b/src/impact-metrics/metric-types.ts @@ -1,6 +1,6 @@ import { Context } from '../context'; -type MetricType = 'counter' | 'gauge'; +type MetricType = 'counter' | 'gauge' | 'histogram'; type LabelValuesKey = string; function getLabelKey(labels?: MetricLabels): LabelValuesKey { @@ -21,11 +21,26 @@ function parseLabelKey(key: string): MetricLabels { return labels; } -export interface MetricSample { +export interface NumericMetricSample { labels: MetricLabels; value: number; } +export interface BucketMetricSample { + labels: MetricLabels; + count: number; + sum: number; + buckets: Array<{ le: number; count: number }>; +} + +export type MetricSample = NumericMetricSample | BucketMetricSample; + +const isNumericMetricSample = (sample: MetricSample): sample is NumericMetricSample => + 'value' in sample; + +const isBucketMetricSample = (sample: MetricSample): sample is BucketMetricSample => + 'buckets' in sample; + export interface CollectedMetric { name: string; help: string; @@ -50,7 +65,7 @@ class CounterImpl implements Counter { } collect(): CollectedMetric { - const samples = [...this.values.entries()].map(([key, value]) => ({ + const samples: NumericMetricSample[] = [...this.values.entries()].map(([key, value]) => ({ labels: parseLabelKey(key), value, })); @@ -91,7 +106,7 @@ class GaugeImpl implements Gauge { } collect(): CollectedMetric { - const samples = [...this.values.entries()].map(([key, value]) => ({ + const samples: NumericMetricSample[] = [...this.values.entries()].map(([key, value]) => ({ labels: parseLabelKey(key), value, })); @@ -107,6 +122,94 @@ class GaugeImpl implements Gauge { } } +interface HistogramData { + count: number; + sum: number; + buckets: Map; +} + +class HistogramImpl implements Histogram { + private values = new Map(); + + private buckets: number[]; + + constructor(private opts: BucketMetricOptions) { + this.buckets = opts.buckets || [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]; + this.buckets = [...new Set(this.buckets.filter((b) => b !== Infinity))].sort((a, b) => a - b); + } + + restoreData(sample: BucketMetricSample): void { + const key = getLabelKey(sample.labels); + const data: HistogramData = { + count: sample.count, + sum: sample.sum, + buckets: new Map(sample.buckets.map((b) => [b.le, b.count])), + }; + this.values.set(key, data); + } + + observe(value: number, labels?: MetricLabels): void { + const key = getLabelKey(labels); + let data = this.values.get(key); + + if (!data) { + data = { + count: 0, + sum: 0, + buckets: new Map(), + }; + + for (const bucket of this.buckets) { + data.buckets.set(bucket, 0); + } + // Always track Infinity bucket internally + data.buckets.set(Infinity, 0); + this.values.set(key, data); + } + + data.count++; + data.sum += value; + + for (const bucket of this.buckets) { + if (value <= bucket) { + const current = data.buckets.get(bucket) || 0; + data.buckets.set(bucket, current + 1); + } + } + const infCount = data.buckets.get(Infinity) || 0; + data.buckets.set(Infinity, infCount + 1); + } + + collect(): CollectedMetric { + const samples: BucketMetricSample[] = []; + + for (const [key, data] of this.values.entries()) { + const labels = parseLabelKey(key); + + const bucketArray: Array<{ le: number; count: number }> = []; + for (const [bucket, count] of data.buckets.entries()) { + bucketArray.push({ le: bucket, count }); + } + + samples.push({ + labels, + count: data.count, + sum: data.sum, + buckets: bucketArray, + }); + } + + this.values.clear(); + + return { + name: this.opts.name, + help: this.opts.help, + type: 'histogram', + samples, + }; + } +} + export type MetricLabels = Record; export interface Counter { @@ -119,6 +222,11 @@ export interface Gauge { set(value: number, labels?: MetricLabels): void; } +export interface Histogram { + observe(value: number, labels?: MetricLabels): void; + restoreData(sample: BucketMetricSample): void; +} + export interface ImpactMetricsDataSource { collect(): CollectedMetric[]; restore(metrics: CollectedMetric[]): void; @@ -127,8 +235,10 @@ export interface ImpactMetricsDataSource { export interface ImpactMetricRegistry { getCounter(counterName: string): Counter | undefined; getGauge(gaugeName: string): Gauge | undefined; + getHistogram(histogramName: string): Histogram | undefined; counter(opts: MetricOptions): Counter; gauge(opts: MetricOptions): Gauge; + histogram(opts: BucketMetricOptions): Histogram; } export class InMemoryMetricRegistry implements ImpactMetricsDataSource, ImpactMetricRegistry { @@ -136,6 +246,8 @@ export class InMemoryMetricRegistry implements ImpactMetricsDataSource, ImpactMe private gauges = new Map(); + private histograms = new Map(); + getCounter(counterName: string): Counter | undefined { return this.counters.get(counterName); } @@ -144,6 +256,10 @@ export class InMemoryMetricRegistry implements ImpactMetricsDataSource, ImpactMe return this.gauges.get(gaugeName); } + getHistogram(histogramName: string): Histogram | undefined { + return this.histograms.get(histogramName); + } + counter(opts: MetricOptions): Counter { const key = opts.name; if (!this.counters.has(key)) { @@ -160,10 +276,19 @@ export class InMemoryMetricRegistry implements ImpactMetricsDataSource, ImpactMe return this.gauges.get(key)!; } + histogram(opts: BucketMetricOptions): Histogram { + const key = opts.name; + if (!this.histograms.has(key)) { + this.histograms.set(key, new HistogramImpl(opts)); + } + return this.histograms.get(key)!; + } + collect(): CollectedMetric[] { const allCounters = [...this.counters.values()].map((c) => c.collect()); const allGauges = [...this.gauges.values()].map((g) => g.collect()); - const allMetrics = [...allCounters, ...allGauges]; + const allHistograms = [...this.histograms.values()].map((h) => h.collect()); + const allMetrics = [...allCounters, ...allGauges, ...allHistograms]; const nonEmpty = allMetrics.filter((metric) => metric.samples.length > 0); return nonEmpty.length > 0 ? nonEmpty : []; @@ -175,7 +300,9 @@ export class InMemoryMetricRegistry implements ImpactMetricsDataSource, ImpactMe case 'counter': { const counter = this.counter({ name: metric.name, help: metric.help }); for (const sample of metric.samples) { - counter.inc(sample.value, sample.labels); + if (isNumericMetricSample(sample)) { + counter.inc(sample.value, sample.labels); + } } break; } @@ -183,7 +310,25 @@ export class InMemoryMetricRegistry implements ImpactMetricsDataSource, ImpactMe case 'gauge': { const gauge = this.gauge({ name: metric.name, help: metric.help }); for (const sample of metric.samples) { - gauge.set(sample.value, sample.labels); + if (isNumericMetricSample(sample)) { + gauge.set(sample.value, sample.labels); + } + } + break; + } + + case 'histogram': { + for (const sample of metric.samples) { + if (isBucketMetricSample(sample)) { + const buckets = sample.buckets.map((b) => b.le); + const histogram = this.histogram({ + name: metric.name, + help: metric.help, + buckets, + }); + + histogram.restoreData(sample); + } } break; } @@ -198,6 +343,10 @@ export interface MetricOptions { labelNames?: string[]; } +export interface BucketMetricOptions extends MetricOptions { + buckets?: number[]; +} + export interface MetricFlagContext { flagNames: string[]; context: Context; diff --git a/src/test/impact-metrics/metric-types.test.ts b/src/test/impact-metrics/metric-types.test.ts index b5b272bd..7b167546 100644 --- a/src/test/impact-metrics/metric-types.test.ts +++ b/src/test/impact-metrics/metric-types.test.ts @@ -166,3 +166,122 @@ test('restore reinserts collected metrics into the registry', (t) => { }, ]); }); + +test('Histogram observes values', (t) => { + const registry = new InMemoryMetricRegistry(); + const histogram = registry.histogram({ + name: 'test_histogram', + help: 'testing histogram', + buckets: [0.1, 0.5, 1, 2.5, 5], + }); + + histogram.observe(0.05, { env: 'prod' }); + histogram.observe(0.75, { env: 'prod' }); + histogram.observe(3, { env: 'prod' }); + + const result = registry.collect(); + + t.deepEqual(result, [ + { + name: 'test_histogram', + help: 'testing histogram', + type: 'histogram', + samples: [ + { + labels: { env: 'prod' }, + count: 3, + sum: 3.8, + buckets: [ + { le: 0.1, count: 1 }, + { le: 0.5, count: 1 }, + { le: 1, count: 2 }, + { le: 2.5, count: 2 }, + { le: 5, count: 3 }, + { le: Infinity, count: 3 }, + ], + }, + ], + }, + ]); +}); + +test('Histogram tracks different label combinations separately', (t) => { + const registry = new InMemoryMetricRegistry(); + const histogram = registry.histogram({ + name: 'multi_label_histogram', + help: 'histogram with multiple labels', + buckets: [1, 10], + }); + + histogram.observe(0.5, { method: 'GET' }); + histogram.observe(5, { method: 'POST' }); + histogram.observe(15); + + const result = registry.collect(); + + t.deepEqual(result, [ + { + name: 'multi_label_histogram', + help: 'histogram with multiple labels', + type: 'histogram', + samples: [ + { + labels: { method: 'GET' }, + count: 1, + sum: 0.5, + buckets: [ + { le: 1, count: 1 }, + { le: 10, count: 1 }, + { le: Infinity, count: 1 }, + ], + }, + { + labels: { method: 'POST' }, + count: 1, + sum: 5, + buckets: [ + { le: 1, count: 0 }, + { le: 10, count: 1 }, + { le: Infinity, count: 1 }, + ], + }, + { + labels: {}, + count: 1, + sum: 15, + buckets: [ + { le: 1, count: 0 }, + { le: 10, count: 0 }, + { le: Infinity, count: 1 }, + ], + }, + ], + }, + ]); +}); + +test('Histogram restoration preserves exact data', (t) => { + const registry = new InMemoryMetricRegistry(); + const histogram = registry.histogram({ + name: 'restore_histogram', + help: 'testing histogram restore', + buckets: [0.1, 1, 10], + }); + + histogram.observe(0.05, { method: 'GET' }); + histogram.observe(0.5, { method: 'GET' }); + histogram.observe(5, { method: 'POST' }); + histogram.observe(15, { method: 'POST' }); + + const firstCollect = registry.collect(); + t.is(firstCollect.length, 1); + + const emptyCollect = registry.collect(); + t.deepEqual(emptyCollect, []); + + registry.restore(firstCollect); + + const restoredCollect = registry.collect(); + + t.deepEqual(restoredCollect, firstCollect); +}); From 4a83b291c3dd53093649743559b2f5d24732e778 Mon Sep 17 00:00:00 2001 From: kwasniew Date: Fri, 12 Sep 2025 15:09:45 +0200 Subject: [PATCH 2/7] refactor: simplify logic --- src/impact-metrics/metric-types.ts | 59 ++++++++++-------------------- 1 file changed, 20 insertions(+), 39 deletions(-) diff --git a/src/impact-metrics/metric-types.ts b/src/impact-metrics/metric-types.ts index 551ad8ad..03a7f511 100644 --- a/src/impact-metrics/metric-types.ts +++ b/src/impact-metrics/metric-types.ts @@ -156,48 +156,28 @@ class HistogramImpl implements Histogram { data = { count: 0, sum: 0, - buckets: new Map(), + buckets: new Map([...this.buckets, Infinity].map((bucket) => [bucket, 0])), }; - - for (const bucket of this.buckets) { - data.buckets.set(bucket, 0); - } - // Always track Infinity bucket internally - data.buckets.set(Infinity, 0); this.values.set(key, data); } data.count++; data.sum += value; - for (const bucket of this.buckets) { + for (const [bucket] of data.buckets) { if (value <= bucket) { - const current = data.buckets.get(bucket) || 0; - data.buckets.set(bucket, current + 1); + data.buckets.set(bucket, data.buckets.get(bucket)! + 1); } } - const infCount = data.buckets.get(Infinity) || 0; - data.buckets.set(Infinity, infCount + 1); } collect(): CollectedMetric { - const samples: BucketMetricSample[] = []; - - for (const [key, data] of this.values.entries()) { - const labels = parseLabelKey(key); - - const bucketArray: Array<{ le: number; count: number }> = []; - for (const [bucket, count] of data.buckets.entries()) { - bucketArray.push({ le: bucket, count }); - } - - samples.push({ - labels, - count: data.count, - sum: data.sum, - buckets: bucketArray, - }); - } + const samples: BucketMetricSample[] = Array.from(this.values.entries()).map(([key, data]) => ({ + labels: parseLabelKey(key), + count: data.count, + sum: data.sum, + buckets: Array.from(data.buckets.entries()).map(([le, count]) => ({ le, count })), + })); this.values.clear(); @@ -318,17 +298,18 @@ export class InMemoryMetricRegistry implements ImpactMetricsDataSource, ImpactMe } case 'histogram': { - for (const sample of metric.samples) { - if (isBucketMetricSample(sample)) { - const buckets = sample.buckets.map((b) => b.le); - const histogram = this.histogram({ - name: metric.name, - help: metric.help, - buckets, - }); - + const firstSample = metric.samples.find(isBucketMetricSample); + if (firstSample) { + const buckets = firstSample.buckets.map((b) => b.le); + const histogram = this.histogram({ + name: metric.name, + help: metric.help, + buckets, + }); + + metric.samples.filter(isBucketMetricSample).forEach((sample) => { histogram.restoreData(sample); - } + }); } break; } From 1e0cc459ad824155ff31723a0a9f20ecc3370ab1 Mon Sep 17 00:00:00 2001 From: kwasniew Date: Fri, 12 Sep 2025 15:25:36 +0200 Subject: [PATCH 3/7] refactor: more performant observe --- src/impact-metrics/metric-types.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/impact-metrics/metric-types.ts b/src/impact-metrics/metric-types.ts index 03a7f511..d3a81156 100644 --- a/src/impact-metrics/metric-types.ts +++ b/src/impact-metrics/metric-types.ts @@ -134,8 +134,9 @@ class HistogramImpl implements Histogram { private buckets: number[]; constructor(private opts: BucketMetricOptions) { - this.buckets = opts.buckets || [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]; - this.buckets = [...new Set(this.buckets.filter((b) => b !== Infinity))].sort((a, b) => a - b); + const buckets = opts.buckets || [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]; + const sortedBuckets = [...new Set(buckets.filter((b) => b !== Infinity))].sort((a, b) => a - b); + this.buckets = [...sortedBuckets, Infinity]; } restoreData(sample: BucketMetricSample): void { @@ -153,10 +154,15 @@ class HistogramImpl implements Histogram { let data = this.values.get(key); if (!data) { + const buckets = new Map(); + for (const bucket of this.buckets) { + buckets.set(bucket, 0); + } + data = { count: 0, sum: 0, - buckets: new Map([...this.buckets, Infinity].map((bucket) => [bucket, 0])), + buckets, }; this.values.set(key, data); } @@ -164,9 +170,10 @@ class HistogramImpl implements Histogram { data.count++; data.sum += value; - for (const [bucket] of data.buckets) { + for (const bucket of this.buckets) { if (value <= bucket) { - data.buckets.set(bucket, data.buckets.get(bucket)! + 1); + const currentCount = data.buckets.get(bucket)!; + data.buckets.set(bucket, currentCount + 1); } } } From f0638b44670871013f998cc85da35c1aad10cec0 Mon Sep 17 00:00:00 2001 From: kwasniew Date: Fri, 12 Sep 2025 15:27:07 +0200 Subject: [PATCH 4/7] refactor: rename method --- src/impact-metrics/metric-types.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/impact-metrics/metric-types.ts b/src/impact-metrics/metric-types.ts index d3a81156..5454e095 100644 --- a/src/impact-metrics/metric-types.ts +++ b/src/impact-metrics/metric-types.ts @@ -139,7 +139,7 @@ class HistogramImpl implements Histogram { this.buckets = [...sortedBuckets, Infinity]; } - restoreData(sample: BucketMetricSample): void { + restore(sample: BucketMetricSample): void { const key = getLabelKey(sample.labels); const data: HistogramData = { count: sample.count, @@ -211,7 +211,7 @@ export interface Gauge { export interface Histogram { observe(value: number, labels?: MetricLabels): void; - restoreData(sample: BucketMetricSample): void; + restore(sample: BucketMetricSample): void; } export interface ImpactMetricsDataSource { @@ -315,7 +315,7 @@ export class InMemoryMetricRegistry implements ImpactMetricsDataSource, ImpactMe }); metric.samples.filter(isBucketMetricSample).forEach((sample) => { - histogram.restoreData(sample); + histogram.restore(sample); }); } break; From d68d7c8667ffb52ae1285692a33b8dea864bd68c Mon Sep 17 00:00:00 2001 From: kwasniew Date: Fri, 12 Sep 2025 15:37:01 +0200 Subject: [PATCH 5/7] refactor: make buckets required --- src/impact-metrics/metric-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/impact-metrics/metric-types.ts b/src/impact-metrics/metric-types.ts index 5454e095..ee95dffc 100644 --- a/src/impact-metrics/metric-types.ts +++ b/src/impact-metrics/metric-types.ts @@ -332,7 +332,7 @@ export interface MetricOptions { } export interface BucketMetricOptions extends MetricOptions { - buckets?: number[]; + buckets: number[]; } export interface MetricFlagContext { From 01d668e2d201d9a5863b6d15df06cd65b4eada81 Mon Sep 17 00:00:00 2001 From: kwasniew Date: Mon, 22 Sep 2025 11:08:16 +0200 Subject: [PATCH 6/7] feat: infinity representation serializable --- src/impact-metrics/metric-types.ts | 15 +++++++++++---- src/test/impact-metrics/metric-types.test.ts | 8 ++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/impact-metrics/metric-types.ts b/src/impact-metrics/metric-types.ts index ee95dffc..fa4ab3d2 100644 --- a/src/impact-metrics/metric-types.ts +++ b/src/impact-metrics/metric-types.ts @@ -30,7 +30,7 @@ export interface BucketMetricSample { labels: MetricLabels; count: number; sum: number; - buckets: Array<{ le: number; count: number }>; + buckets: Array<{ le: number | '+Inf'; count: number }>; } export type MetricSample = NumericMetricSample | BucketMetricSample; @@ -144,7 +144,9 @@ class HistogramImpl implements Histogram { const data: HistogramData = { count: sample.count, sum: sample.sum, - buckets: new Map(sample.buckets.map((b) => [b.le, b.count])), + buckets: new Map( + sample.buckets.map((b) => [b.le === '+Inf' ? Infinity : (b.le as number), b.count]), + ), }; this.values.set(key, data); } @@ -183,7 +185,10 @@ class HistogramImpl implements Histogram { labels: parseLabelKey(key), count: data.count, sum: data.sum, - buckets: Array.from(data.buckets.entries()).map(([le, count]) => ({ le, count })), + buckets: Array.from(data.buckets.entries()).map(([le, count]) => ({ + le: le === Infinity ? '+Inf' : le, + count, + })), })); this.values.clear(); @@ -307,7 +312,9 @@ export class InMemoryMetricRegistry implements ImpactMetricsDataSource, ImpactMe case 'histogram': { const firstSample = metric.samples.find(isBucketMetricSample); if (firstSample) { - const buckets = firstSample.buckets.map((b) => b.le); + const buckets = firstSample.buckets.map((b) => + b.le === '+Inf' ? Infinity : (b.le as number), + ); const histogram = this.histogram({ name: metric.name, help: metric.help, diff --git a/src/test/impact-metrics/metric-types.test.ts b/src/test/impact-metrics/metric-types.test.ts index 7b167546..8370b8bb 100644 --- a/src/test/impact-metrics/metric-types.test.ts +++ b/src/test/impact-metrics/metric-types.test.ts @@ -197,7 +197,7 @@ test('Histogram observes values', (t) => { { le: 1, count: 2 }, { le: 2.5, count: 2 }, { le: 5, count: 3 }, - { le: Infinity, count: 3 }, + { le: '+Inf', count: 3 }, ], }, ], @@ -232,7 +232,7 @@ test('Histogram tracks different label combinations separately', (t) => { buckets: [ { le: 1, count: 1 }, { le: 10, count: 1 }, - { le: Infinity, count: 1 }, + { le: '+Inf', count: 1 }, ], }, { @@ -242,7 +242,7 @@ test('Histogram tracks different label combinations separately', (t) => { buckets: [ { le: 1, count: 0 }, { le: 10, count: 1 }, - { le: Infinity, count: 1 }, + { le: '+Inf', count: 1 }, ], }, { @@ -252,7 +252,7 @@ test('Histogram tracks different label combinations separately', (t) => { buckets: [ { le: 1, count: 0 }, { le: 10, count: 0 }, - { le: Infinity, count: 1 }, + { le: '+Inf', count: 1 }, ], }, ], From d613d7ad9e034a2254a54626d379b5169a9bea7d Mon Sep 17 00:00:00 2001 From: kwasniew Date: Mon, 22 Sep 2025 14:56:56 +0200 Subject: [PATCH 7/7] feat: add histogram to metric-client --- src/impact-metrics/metric-client.ts | 29 +++++++ src/test/impact-metrics/metric-client.test.ts | 79 +++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/src/impact-metrics/metric-client.ts b/src/impact-metrics/metric-client.ts index 771607a2..73cfa1fc 100644 --- a/src/impact-metrics/metric-client.ts +++ b/src/impact-metrics/metric-client.ts @@ -31,6 +31,15 @@ export class MetricsAPI extends EventEmitter { this.metricRegistry.gauge({ name, help, labelNames }); } + defineHistogram(name: string, help: string, buckets?: number[]) { + if (!name || !help) { + this.emit(UnleashEvents.Warn, `Histogram name or help cannot be empty: ${name}, ${help}.`); + return; + } + const labelNames = ['featureName', 'appName', 'environment']; + this.metricRegistry.histogram({ name, help, labelNames, buckets: buckets || [] }); + } + private getFlagLabels(flagContext?: MetricFlagContext): MetricLabels { let flagLabels: MetricLabels = {}; if (flagContext) { @@ -85,6 +94,26 @@ export class MetricsAPI extends EventEmitter { gauge.set(value, labels); } + + observeHistogram(name: string, value: number, flagContext?: MetricFlagContext): void { + const histogram = this.metricRegistry.getHistogram(name); + if (!histogram) { + this.emit( + UnleashEvents.Warn, + `Histogram ${name} not defined, this histogram will not be updated.`, + ); + return; + } + + const flagLabels = this.getFlagLabels(flagContext); + + const labels = { + ...flagLabels, + ...this.staticContext, + }; + + histogram.observe(value, labels); + } } export class UnleashMetricClient extends Unleash { diff --git a/src/test/impact-metrics/metric-client.test.ts b/src/test/impact-metrics/metric-client.test.ts index 943de3a6..9566ab43 100644 --- a/src/test/impact-metrics/metric-client.test.ts +++ b/src/test/impact-metrics/metric-client.test.ts @@ -173,3 +173,82 @@ test('defining a gauge automatically sets label names', (t) => { api.defineGauge('test_gauge', 'Test help text'); t.true(gaugeRegistered, 'Gauge should be registered'); }); + +test('should not register a histogram with empty name or help', (t) => { + let histogramRegistered = false; + + const fakeRegistry = { + histogram: () => { + histogramRegistered = true; + }, + }; + + const staticContext = { appName: 'my-app', environment: 'dev' }; + const api = new MetricsAPI(fakeRegistry as any, fakeVariantResolver(), staticContext); + + api.defineHistogram('some_name', ''); + t.false(histogramRegistered, 'Histogram should not be registered with empty help'); + + api.defineHistogram('', 'some_help'); + t.false(histogramRegistered, 'Histogram should not be registered with empty name'); +}); + +test('should register a histogram with valid name and help', (t) => { + let histogramRegistered = false; + + const fakeRegistry = { + histogram: () => { + histogramRegistered = true; + }, + }; + + const staticContext = { appName: 'my-app', environment: 'dev' }; + const api = new MetricsAPI(fakeRegistry as any, fakeVariantResolver(), staticContext); + + api.defineHistogram('valid_name', 'Valid help text'); + t.true(histogramRegistered, 'Histogram should be registered with valid name and help'); +}); + +test('should observe histogram with valid parameters', (t) => { + let histogramObserved = false; + let recordedLabels: MetricLabels = {}; + + const fakeHistogram = { + observe: (_value: number, labels: MetricLabels) => { + histogramObserved = true; + recordedLabels = labels; + }, + }; + + const fakeRegistry = { + getHistogram: () => fakeHistogram, + }; + + const staticContext = { appName: 'my-app', environment: 'dev' }; + const api = new MetricsAPI(fakeRegistry as any, fakeVariantResolver(), staticContext); + + api.observeHistogram('valid_histogram', 1.5, { flagNames: ['featureX'], context: staticContext }); + t.true(histogramObserved, 'Histogram should be observed with valid parameters'); + t.deepEqual(recordedLabels, { appName: 'my-app', environment: 'dev', featureX: 'enabled' }); +}); + +test('defining a histogram automatically sets label names', (t) => { + let histogramRegistered = false; + + const fakeRegistry = { + histogram: (config: any) => { + histogramRegistered = true; + t.deepEqual( + config.labelNames, + ['featureName', 'appName', 'environment'], + 'Label names should be set correctly', + ); + }, + }; + + const staticContext = { appName: 'my-app', environment: 'dev' }; + const api = new MetricsAPI(fakeRegistry as any, fakeVariantResolver(), staticContext); + + api.defineHistogram('test_histogram', 'Test help text'); + t.true(histogramRegistered, 'Histogram should be registered'); +});