1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

feat: translate impact metrics to prom format (#10147)

This commit is contained in:
Mateusz Kwasniewski 2025-06-17 11:52:41 +02:00 committed by GitHub
parent c165e87d74
commit d2b9751e01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 276 additions and 0 deletions

View File

@ -0,0 +1,141 @@
import { MetricsTranslator } from './metrics-translator.js';
import { Registry } from 'prom-client';
describe('MetricsTranslator', () => {
it('should handle metrics with labels', async () => {
const metrics = [
{
name: 'labeled_counter',
help: 'with labels',
type: 'counter' as const,
samples: [
{
labels: { foo: 'bar' },
value: 5,
},
],
},
{
name: 'test_gauge',
help: 'gauge test',
type: 'gauge' as const,
samples: [
{
labels: { env: 'prod' },
value: 10,
},
],
},
];
const registry = new Registry();
const translator = new MetricsTranslator(registry);
const result = await translator.translateAndSerializeMetrics(metrics);
expect(typeof result).toBe('string');
expect(result).toContain('# HELP labeled_counter with labels');
expect(result).toContain('# TYPE labeled_counter counter');
expect(result).toContain('labeled_counter{foo="bar"} 5');
expect(result).toContain('test_gauge{env="prod"} 10');
});
it('should ignore unsupported metric types', async () => {
const metrics = [
{
name: 'test_counter',
help: 'test counter',
type: 'counter' as const,
samples: [{ value: 1 }],
},
{
name: 'unsupported',
help: 'unsupported type',
type: 'histogram' as any,
samples: [],
},
{
name: 'test_gauge',
help: 'gauge test',
type: 'gauge' as const,
samples: [{ value: 2 }],
},
];
const registry = new Registry();
const translator = new MetricsTranslator(registry);
const result = await translator.translateAndSerializeMetrics(metrics);
expect(typeof result).toBe('string');
expect(result).toContain('# HELP test_counter test counter');
expect(result).toContain('# TYPE test_counter counter');
expect(result).toContain('# HELP test_gauge gauge test');
expect(result).toContain('# TYPE test_gauge gauge');
expect(result).not.toContain('unsupported');
});
it('should handle re-labeling of metrics', async () => {
const registry = new Registry();
const translator = new MetricsTranslator(registry);
const metrics1 = [
{
name: 'counter_with_labels',
help: 'counter with labels',
type: 'counter' as const,
samples: [
{
labels: { foo: 'bar' },
value: 5,
},
],
},
{
name: 'gauge_with_labels',
help: 'gauge with labels',
type: 'gauge' as const,
samples: [
{
labels: { env: 'prod' },
value: 10,
},
],
},
];
const result1 = await translator.translateAndSerializeMetrics(metrics1);
expect(result1).toContain('counter_with_labels{foo="bar"} 5');
expect(result1).toContain('gauge_with_labels{env="prod"} 10');
const metrics2 = [
{
name: 'counter_with_labels',
help: 'counter with labels',
type: 'counter' as const,
samples: [
{
labels: { foo: 'bar', baz: 'qux' }, // Added a new label
value: 15,
},
],
},
{
name: 'gauge_with_labels',
help: 'gauge with labels',
type: 'gauge' as const,
samples: [
{
labels: { env: 'prod', region: 'us-east' }, // Added a new label
value: 20,
},
],
},
];
const result2 = await translator.translateAndSerializeMetrics(metrics2);
expect(result2).toContain(
'counter_with_labels{foo="bar",baz="qux"} 15',
);
expect(result2).toContain(
'gauge_with_labels{env="prod",region="us-east"} 20',
);
});
});

View File

@ -0,0 +1,135 @@
import { Counter, Gauge, type Registry } from 'prom-client';
interface MetricSample {
labels?: Record<string, string | number>;
value: number;
}
interface Metric {
name: string;
help: string;
type: 'counter' | 'gauge';
samples: MetricSample[];
}
export class MetricsTranslator {
private registry: Registry;
constructor(registry: Registry) {
this.registry = registry;
}
private hasNewLabels(
existingMetric: Counter<string> | Gauge<string>,
newLabelNames: string[],
): boolean {
const existingLabelNames = (existingMetric as any).labelNames || [];
return newLabelNames.some(
(label) => !existingLabelNames.includes(label),
);
}
translateMetric(metric: Metric): Counter<string> | Gauge<string> | null {
const existingMetric = this.registry.getSingleMetric(metric.name);
const allLabelNames = new Set<string>();
for (const sample of metric.samples) {
if (sample.labels) {
Object.keys(sample.labels).forEach((label) =>
allLabelNames.add(label),
);
}
}
const labelNames = Array.from(allLabelNames);
if (metric.type === 'counter') {
let counter: Counter<string>;
if (existingMetric && existingMetric instanceof Counter) {
if (this.hasNewLabels(existingMetric, labelNames)) {
this.registry.removeSingleMetric(metric.name);
counter = new Counter({
name: metric.name,
help: metric.help,
registers: [this.registry],
labelNames,
});
} else {
counter = existingMetric as Counter<string>;
}
} else {
counter = new Counter({
name: metric.name,
help: metric.help,
registers: [this.registry],
labelNames,
});
}
for (const sample of metric.samples) {
if (sample.labels) {
counter.inc(sample.labels, sample.value);
} else {
counter.inc(sample.value);
}
}
return counter;
} else if (metric.type === 'gauge') {
let gauge: Gauge<string>;
if (existingMetric && existingMetric instanceof Gauge) {
if (this.hasNewLabels(existingMetric, labelNames)) {
this.registry.removeSingleMetric(metric.name);
gauge = new Gauge({
name: metric.name,
help: metric.help,
registers: [this.registry],
labelNames,
});
} else {
gauge = existingMetric as Gauge<string>;
}
} else {
gauge = new Gauge({
name: metric.name,
help: metric.help,
registers: [this.registry],
labelNames,
});
}
for (const sample of metric.samples) {
if (sample.labels) {
gauge.set(sample.labels, sample.value);
} else {
gauge.set(sample.value);
}
}
return gauge;
}
return null;
}
translateMetrics(metrics: Metric[]): Registry {
for (const metric of metrics) {
this.translateMetric(metric);
}
return this.registry;
}
serializeMetrics(): Promise<string> {
return this.registry.metrics();
}
translateAndSerializeMetrics(metrics: Metric[]): Promise<string> {
this.translateMetrics(metrics);
return this.serializeMetrics();
}
}