diff --git a/src/lib/features/metrics/impact/metrics-translator.test.ts b/src/lib/features/metrics/impact/metrics-translator.test.ts new file mode 100644 index 0000000000..2294438ba8 --- /dev/null +++ b/src/lib/features/metrics/impact/metrics-translator.test.ts @@ -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', + ); + }); +}); diff --git a/src/lib/features/metrics/impact/metrics-translator.ts b/src/lib/features/metrics/impact/metrics-translator.ts new file mode 100644 index 0000000000..0f43128606 --- /dev/null +++ b/src/lib/features/metrics/impact/metrics-translator.ts @@ -0,0 +1,135 @@ +import { Counter, Gauge, type Registry } from 'prom-client'; + +interface MetricSample { + labels?: Record; + 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 | Gauge, + newLabelNames: string[], + ): boolean { + const existingLabelNames = (existingMetric as any).labelNames || []; + + return newLabelNames.some( + (label) => !existingLabelNames.includes(label), + ); + } + + translateMetric(metric: Metric): Counter | Gauge | null { + const existingMetric = this.registry.getSingleMetric(metric.name); + + const allLabelNames = new Set(); + 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; + + 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; + } + } 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; + + 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; + } + } 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 { + return this.registry.metrics(); + } + + translateAndSerializeMetrics(metrics: Metric[]): Promise { + this.translateMetrics(metrics); + return this.serializeMetrics(); + } +}