Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/impact-metrics/metric-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
158 changes: 151 additions & 7 deletions src/impact-metrics/metric-types.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 | '+Inf'; 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;
Expand All @@ -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,
}));
Expand Down Expand Up @@ -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,
}));
Expand All @@ -107,6 +122,86 @@ class GaugeImpl implements Gauge {
}
}

interface HistogramData {
count: number;
sum: number;
buckets: Map<number, number>;
}

class HistogramImpl implements Histogram {
private values = new Map<LabelValuesKey, HistogramData>();

private buckets: number[];

constructor(private opts: BucketMetricOptions) {
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];
}

restore(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 === '+Inf' ? Infinity : (b.le as number), 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) {
const buckets = new Map<number, number>();
for (const bucket of this.buckets) {
buckets.set(bucket, 0);
}

data = {
count: 0,
sum: 0,
buckets,
};
this.values.set(key, data);
}

data.count++;
data.sum += value;

for (const bucket of this.buckets) {
if (value <= bucket) {
const currentCount = data.buckets.get(bucket)!;
data.buckets.set(bucket, currentCount + 1);
}
}
}

collect(): CollectedMetric {
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: le === Infinity ? '+Inf' : le,
count,
})),
}));

this.values.clear();

return {
name: this.opts.name,
help: this.opts.help,
type: 'histogram',
samples,
};
}
}

export type MetricLabels = Record<string, string>;

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

export interface Histogram {
observe(value: number, labels?: MetricLabels): void;
restore(sample: BucketMetricSample): void;
}

export interface ImpactMetricsDataSource {
collect(): CollectedMetric[];
restore(metrics: CollectedMetric[]): void;
Expand All @@ -127,15 +227,19 @@ 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 {
private counters = new Map<string, Counter & CollectibleMetric>();

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

private histograms = new Map<string, Histogram & CollectibleMetric>();

getCounter(counterName: string): Counter | undefined {
return this.counters.get(counterName);
}
Expand All @@ -144,6 +248,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)) {
Expand All @@ -160,10 +268,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 : [];
Expand All @@ -175,15 +292,38 @@ 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;
}

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': {
const firstSample = metric.samples.find(isBucketMetricSample);
if (firstSample) {
const buckets = firstSample.buckets.map((b) =>
b.le === '+Inf' ? Infinity : (b.le as number),
);
const histogram = this.histogram({
name: metric.name,
help: metric.help,
buckets,
});

metric.samples.filter(isBucketMetricSample).forEach((sample) => {
histogram.restore(sample);
});
}
break;
}
Expand All @@ -198,6 +338,10 @@ export interface MetricOptions {
labelNames?: string[];
}

export interface BucketMetricOptions extends MetricOptions {
buckets: number[];
}

export interface MetricFlagContext {
flagNames: string[];
context: Context;
Expand Down
79 changes: 79 additions & 0 deletions src/test/impact-metrics/metric-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Loading