Skip to content

Commit 55b711a

Browse files
authored
feat: histogram metric (#767)
1 parent 2c01030 commit 55b711a

File tree

4 files changed

+378
-7
lines changed

4 files changed

+378
-7
lines changed

src/impact-metrics/metric-client.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ export class MetricsAPI extends EventEmitter {
3131
this.metricRegistry.gauge({ name, help, labelNames });
3232
}
3333

34+
defineHistogram(name: string, help: string, buckets?: number[]) {
35+
if (!name || !help) {
36+
this.emit(UnleashEvents.Warn, `Histogram name or help cannot be empty: ${name}, ${help}.`);
37+
return;
38+
}
39+
const labelNames = ['featureName', 'appName', 'environment'];
40+
this.metricRegistry.histogram({ name, help, labelNames, buckets: buckets || [] });
41+
}
42+
3443
private getFlagLabels(flagContext?: MetricFlagContext): MetricLabels {
3544
let flagLabels: MetricLabels = {};
3645
if (flagContext) {
@@ -85,6 +94,26 @@ export class MetricsAPI extends EventEmitter {
8594

8695
gauge.set(value, labels);
8796
}
97+
98+
observeHistogram(name: string, value: number, flagContext?: MetricFlagContext): void {
99+
const histogram = this.metricRegistry.getHistogram(name);
100+
if (!histogram) {
101+
this.emit(
102+
UnleashEvents.Warn,
103+
`Histogram ${name} not defined, this histogram will not be updated.`,
104+
);
105+
return;
106+
}
107+
108+
const flagLabels = this.getFlagLabels(flagContext);
109+
110+
const labels = {
111+
...flagLabels,
112+
...this.staticContext,
113+
};
114+
115+
histogram.observe(value, labels);
116+
}
88117
}
89118

90119
export class UnleashMetricClient extends Unleash {

src/impact-metrics/metric-types.ts

Lines changed: 151 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Context } from '../context';
22

3-
type MetricType = 'counter' | 'gauge';
3+
type MetricType = 'counter' | 'gauge' | 'histogram';
44
type LabelValuesKey = string;
55

66
function getLabelKey(labels?: MetricLabels): LabelValuesKey {
@@ -21,11 +21,26 @@ function parseLabelKey(key: string): MetricLabels {
2121
return labels;
2222
}
2323

24-
export interface MetricSample {
24+
export interface NumericMetricSample {
2525
labels: MetricLabels;
2626
value: number;
2727
}
2828

29+
export interface BucketMetricSample {
30+
labels: MetricLabels;
31+
count: number;
32+
sum: number;
33+
buckets: Array<{ le: number | '+Inf'; count: number }>;
34+
}
35+
36+
export type MetricSample = NumericMetricSample | BucketMetricSample;
37+
38+
const isNumericMetricSample = (sample: MetricSample): sample is NumericMetricSample =>
39+
'value' in sample;
40+
41+
const isBucketMetricSample = (sample: MetricSample): sample is BucketMetricSample =>
42+
'buckets' in sample;
43+
2944
export interface CollectedMetric {
3045
name: string;
3146
help: string;
@@ -50,7 +65,7 @@ class CounterImpl implements Counter {
5065
}
5166

5267
collect(): CollectedMetric {
53-
const samples = [...this.values.entries()].map(([key, value]) => ({
68+
const samples: NumericMetricSample[] = [...this.values.entries()].map(([key, value]) => ({
5469
labels: parseLabelKey(key),
5570
value,
5671
}));
@@ -91,7 +106,7 @@ class GaugeImpl implements Gauge {
91106
}
92107

93108
collect(): CollectedMetric {
94-
const samples = [...this.values.entries()].map(([key, value]) => ({
109+
const samples: NumericMetricSample[] = [...this.values.entries()].map(([key, value]) => ({
95110
labels: parseLabelKey(key),
96111
value,
97112
}));
@@ -107,6 +122,86 @@ class GaugeImpl implements Gauge {
107122
}
108123
}
109124

125+
interface HistogramData {
126+
count: number;
127+
sum: number;
128+
buckets: Map<number, number>;
129+
}
130+
131+
class HistogramImpl implements Histogram {
132+
private values = new Map<LabelValuesKey, HistogramData>();
133+
134+
private buckets: number[];
135+
136+
constructor(private opts: BucketMetricOptions) {
137+
const buckets = opts.buckets || [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
138+
const sortedBuckets = [...new Set(buckets.filter((b) => b !== Infinity))].sort((a, b) => a - b);
139+
this.buckets = [...sortedBuckets, Infinity];
140+
}
141+
142+
restore(sample: BucketMetricSample): void {
143+
const key = getLabelKey(sample.labels);
144+
const data: HistogramData = {
145+
count: sample.count,
146+
sum: sample.sum,
147+
buckets: new Map(
148+
sample.buckets.map((b) => [b.le === '+Inf' ? Infinity : (b.le as number), b.count]),
149+
),
150+
};
151+
this.values.set(key, data);
152+
}
153+
154+
observe(value: number, labels?: MetricLabels): void {
155+
const key = getLabelKey(labels);
156+
let data = this.values.get(key);
157+
158+
if (!data) {
159+
const buckets = new Map<number, number>();
160+
for (const bucket of this.buckets) {
161+
buckets.set(bucket, 0);
162+
}
163+
164+
data = {
165+
count: 0,
166+
sum: 0,
167+
buckets,
168+
};
169+
this.values.set(key, data);
170+
}
171+
172+
data.count++;
173+
data.sum += value;
174+
175+
for (const bucket of this.buckets) {
176+
if (value <= bucket) {
177+
const currentCount = data.buckets.get(bucket)!;
178+
data.buckets.set(bucket, currentCount + 1);
179+
}
180+
}
181+
}
182+
183+
collect(): CollectedMetric {
184+
const samples: BucketMetricSample[] = Array.from(this.values.entries()).map(([key, data]) => ({
185+
labels: parseLabelKey(key),
186+
count: data.count,
187+
sum: data.sum,
188+
buckets: Array.from(data.buckets.entries()).map(([le, count]) => ({
189+
le: le === Infinity ? '+Inf' : le,
190+
count,
191+
})),
192+
}));
193+
194+
this.values.clear();
195+
196+
return {
197+
name: this.opts.name,
198+
help: this.opts.help,
199+
type: 'histogram',
200+
samples,
201+
};
202+
}
203+
}
204+
110205
export type MetricLabels = Record<string, string>;
111206

112207
export interface Counter {
@@ -119,6 +214,11 @@ export interface Gauge {
119214
set(value: number, labels?: MetricLabels): void;
120215
}
121216

217+
export interface Histogram {
218+
observe(value: number, labels?: MetricLabels): void;
219+
restore(sample: BucketMetricSample): void;
220+
}
221+
122222
export interface ImpactMetricsDataSource {
123223
collect(): CollectedMetric[];
124224
restore(metrics: CollectedMetric[]): void;
@@ -127,15 +227,19 @@ export interface ImpactMetricsDataSource {
127227
export interface ImpactMetricRegistry {
128228
getCounter(counterName: string): Counter | undefined;
129229
getGauge(gaugeName: string): Gauge | undefined;
230+
getHistogram(histogramName: string): Histogram | undefined;
130231
counter(opts: MetricOptions): Counter;
131232
gauge(opts: MetricOptions): Gauge;
233+
histogram(opts: BucketMetricOptions): Histogram;
132234
}
133235

134236
export class InMemoryMetricRegistry implements ImpactMetricsDataSource, ImpactMetricRegistry {
135237
private counters = new Map<string, Counter & CollectibleMetric>();
136238

137239
private gauges = new Map<string, Gauge & CollectibleMetric>();
138240

241+
private histograms = new Map<string, Histogram & CollectibleMetric>();
242+
139243
getCounter(counterName: string): Counter | undefined {
140244
return this.counters.get(counterName);
141245
}
@@ -144,6 +248,10 @@ export class InMemoryMetricRegistry implements ImpactMetricsDataSource, ImpactMe
144248
return this.gauges.get(gaugeName);
145249
}
146250

251+
getHistogram(histogramName: string): Histogram | undefined {
252+
return this.histograms.get(histogramName);
253+
}
254+
147255
counter(opts: MetricOptions): Counter {
148256
const key = opts.name;
149257
if (!this.counters.has(key)) {
@@ -160,10 +268,19 @@ export class InMemoryMetricRegistry implements ImpactMetricsDataSource, ImpactMe
160268
return this.gauges.get(key)!;
161269
}
162270

271+
histogram(opts: BucketMetricOptions): Histogram {
272+
const key = opts.name;
273+
if (!this.histograms.has(key)) {
274+
this.histograms.set(key, new HistogramImpl(opts));
275+
}
276+
return this.histograms.get(key)!;
277+
}
278+
163279
collect(): CollectedMetric[] {
164280
const allCounters = [...this.counters.values()].map((c) => c.collect());
165281
const allGauges = [...this.gauges.values()].map((g) => g.collect());
166-
const allMetrics = [...allCounters, ...allGauges];
282+
const allHistograms = [...this.histograms.values()].map((h) => h.collect());
283+
const allMetrics = [...allCounters, ...allGauges, ...allHistograms];
167284

168285
const nonEmpty = allMetrics.filter((metric) => metric.samples.length > 0);
169286
return nonEmpty.length > 0 ? nonEmpty : [];
@@ -175,15 +292,38 @@ export class InMemoryMetricRegistry implements ImpactMetricsDataSource, ImpactMe
175292
case 'counter': {
176293
const counter = this.counter({ name: metric.name, help: metric.help });
177294
for (const sample of metric.samples) {
178-
counter.inc(sample.value, sample.labels);
295+
if (isNumericMetricSample(sample)) {
296+
counter.inc(sample.value, sample.labels);
297+
}
179298
}
180299
break;
181300
}
182301

183302
case 'gauge': {
184303
const gauge = this.gauge({ name: metric.name, help: metric.help });
185304
for (const sample of metric.samples) {
186-
gauge.set(sample.value, sample.labels);
305+
if (isNumericMetricSample(sample)) {
306+
gauge.set(sample.value, sample.labels);
307+
}
308+
}
309+
break;
310+
}
311+
312+
case 'histogram': {
313+
const firstSample = metric.samples.find(isBucketMetricSample);
314+
if (firstSample) {
315+
const buckets = firstSample.buckets.map((b) =>
316+
b.le === '+Inf' ? Infinity : (b.le as number),
317+
);
318+
const histogram = this.histogram({
319+
name: metric.name,
320+
help: metric.help,
321+
buckets,
322+
});
323+
324+
metric.samples.filter(isBucketMetricSample).forEach((sample) => {
325+
histogram.restore(sample);
326+
});
187327
}
188328
break;
189329
}
@@ -198,6 +338,10 @@ export interface MetricOptions {
198338
labelNames?: string[];
199339
}
200340

341+
export interface BucketMetricOptions extends MetricOptions {
342+
buckets: number[];
343+
}
344+
201345
export interface MetricFlagContext {
202346
flagNames: string[];
203347
context: Context;

src/test/impact-metrics/metric-client.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,82 @@ test('defining a gauge automatically sets label names', (t) => {
173173
api.defineGauge('test_gauge', 'Test help text');
174174
t.true(gaugeRegistered, 'Gauge should be registered');
175175
});
176+
177+
test('should not register a histogram with empty name or help', (t) => {
178+
let histogramRegistered = false;
179+
180+
const fakeRegistry = {
181+
histogram: () => {
182+
histogramRegistered = true;
183+
},
184+
};
185+
186+
const staticContext = { appName: 'my-app', environment: 'dev' };
187+
const api = new MetricsAPI(fakeRegistry as any, fakeVariantResolver(), staticContext);
188+
189+
api.defineHistogram('some_name', '');
190+
t.false(histogramRegistered, 'Histogram should not be registered with empty help');
191+
192+
api.defineHistogram('', 'some_help');
193+
t.false(histogramRegistered, 'Histogram should not be registered with empty name');
194+
});
195+
196+
test('should register a histogram with valid name and help', (t) => {
197+
let histogramRegistered = false;
198+
199+
const fakeRegistry = {
200+
histogram: () => {
201+
histogramRegistered = true;
202+
},
203+
};
204+
205+
const staticContext = { appName: 'my-app', environment: 'dev' };
206+
const api = new MetricsAPI(fakeRegistry as any, fakeVariantResolver(), staticContext);
207+
208+
api.defineHistogram('valid_name', 'Valid help text');
209+
t.true(histogramRegistered, 'Histogram should be registered with valid name and help');
210+
});
211+
212+
test('should observe histogram with valid parameters', (t) => {
213+
let histogramObserved = false;
214+
let recordedLabels: MetricLabels = {};
215+
216+
const fakeHistogram = {
217+
observe: (_value: number, labels: MetricLabels) => {
218+
histogramObserved = true;
219+
recordedLabels = labels;
220+
},
221+
};
222+
223+
const fakeRegistry = {
224+
getHistogram: () => fakeHistogram,
225+
};
226+
227+
const staticContext = { appName: 'my-app', environment: 'dev' };
228+
const api = new MetricsAPI(fakeRegistry as any, fakeVariantResolver(), staticContext);
229+
230+
api.observeHistogram('valid_histogram', 1.5, { flagNames: ['featureX'], context: staticContext });
231+
t.true(histogramObserved, 'Histogram should be observed with valid parameters');
232+
t.deepEqual(recordedLabels, { appName: 'my-app', environment: 'dev', featureX: 'enabled' });
233+
});
234+
235+
test('defining a histogram automatically sets label names', (t) => {
236+
let histogramRegistered = false;
237+
238+
const fakeRegistry = {
239+
histogram: (config: any) => {
240+
histogramRegistered = true;
241+
t.deepEqual(
242+
config.labelNames,
243+
['featureName', 'appName', 'environment'],
244+
'Label names should be set correctly',
245+
);
246+
},
247+
};
248+
249+
const staticContext = { appName: 'my-app', environment: 'dev' };
250+
const api = new MetricsAPI(fakeRegistry as any, fakeVariantResolver(), staticContext);
251+
252+
api.defineHistogram('test_histogram', 'Test help text');
253+
t.true(histogramRegistered, 'Histogram should be registered');
254+
});

0 commit comments

Comments
 (0)