mirror of
https://github.com/Unleash/unleash.git
synced 2025-09-24 17:51:14 +02:00
feat: histogram impact metric ingestion
This commit is contained in:
parent
3296add50f
commit
d082125a69
@ -259,7 +259,7 @@ export default class ClientMetricsServiceV2 {
|
|||||||
this.impactMetricsTranslator.translateMetrics(value);
|
this.impactMetricsTranslator.translateMetrics(value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// impact metrics should not affect other metrics on failure
|
// impact metrics should not affect other metrics on failure
|
||||||
this.logger.warn(e);
|
this.logger.warn('Impact metrics registration failed:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,12 +6,15 @@ import dbInit, {
|
|||||||
type ITestDb,
|
type ITestDb,
|
||||||
} from '../../../../test/e2e/helpers/database-init.js';
|
} from '../../../../test/e2e/helpers/database-init.js';
|
||||||
import getLogger from '../../../../test/fixtures/no-logger.js';
|
import getLogger from '../../../../test/fixtures/no-logger.js';
|
||||||
import type { Metric } from './metrics-translator.js';
|
import type { NumericMetric, BucketMetric } from './metrics-translator.js';
|
||||||
|
|
||||||
let app: IUnleashTest;
|
let app: IUnleashTest;
|
||||||
let db: ITestDb;
|
let db: ITestDb;
|
||||||
|
|
||||||
const sendImpactMetrics = async (impactMetrics: Metric[], status = 202) =>
|
const sendImpactMetrics = async (
|
||||||
|
impactMetrics: (NumericMetric | BucketMetric)[],
|
||||||
|
status = 202,
|
||||||
|
) =>
|
||||||
app.request
|
app.request
|
||||||
.post('/api/client/metrics')
|
.post('/api/client/metrics')
|
||||||
.send({
|
.send({
|
||||||
@ -27,7 +30,7 @@ const sendImpactMetrics = async (impactMetrics: Metric[], status = 202) =>
|
|||||||
.expect(status);
|
.expect(status);
|
||||||
|
|
||||||
const sendBulkMetricsWithImpact = async (
|
const sendBulkMetricsWithImpact = async (
|
||||||
impactMetrics: Metric[],
|
impactMetrics: (NumericMetric | BucketMetric)[],
|
||||||
status = 202,
|
status = 202,
|
||||||
) => {
|
) => {
|
||||||
return app.request
|
return app.request
|
||||||
@ -170,3 +173,71 @@ test('should store impact metrics sent via bulk metrics endpoint', async () => {
|
|||||||
/unleash_counter_bulk_counter{unleash_source="bulk",unleash_origin="sdk"} 15/,
|
/unleash_counter_bulk_counter{unleash_source="bulk",unleash_origin="sdk"} 15/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should store histogram metrics with batch data', async () => {
|
||||||
|
await sendImpactMetrics([
|
||||||
|
{
|
||||||
|
name: 'response_time',
|
||||||
|
help: 'Response time histogram',
|
||||||
|
type: 'histogram',
|
||||||
|
buckets: [1],
|
||||||
|
samples: [
|
||||||
|
{
|
||||||
|
labels: { foo: 'bar' },
|
||||||
|
count: 10,
|
||||||
|
sum: 8.5,
|
||||||
|
buckets: [
|
||||||
|
{ le: 1, count: 7 },
|
||||||
|
{ le: '+Inf', count: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await sendImpactMetrics([
|
||||||
|
{
|
||||||
|
name: 'response_time',
|
||||||
|
help: 'Response time histogram',
|
||||||
|
type: 'histogram',
|
||||||
|
buckets: [1],
|
||||||
|
samples: [
|
||||||
|
{
|
||||||
|
labels: { foo: 'bar' },
|
||||||
|
count: 5,
|
||||||
|
sum: 3.2,
|
||||||
|
buckets: [
|
||||||
|
{ le: 1, count: 3 },
|
||||||
|
{ le: '+Inf', count: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = await app.request
|
||||||
|
.get('/internal-backstage/impact/metrics')
|
||||||
|
.expect('Content-Type', /text/)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const metricsText = response.text;
|
||||||
|
|
||||||
|
expect(metricsText).toContain(
|
||||||
|
'# HELP unleash_histogram_response_time Response time histogram',
|
||||||
|
);
|
||||||
|
expect(metricsText).toContain(
|
||||||
|
'# TYPE unleash_histogram_response_time histogram',
|
||||||
|
);
|
||||||
|
expect(metricsText).toContain(
|
||||||
|
'unleash_histogram_response_time_bucket{unleash_foo="bar",unleash_origin="sdk",le="1"} 10',
|
||||||
|
);
|
||||||
|
expect(metricsText).toContain(
|
||||||
|
'unleash_histogram_response_time_bucket{unleash_foo="bar",unleash_origin="sdk",le="+Inf"} 15',
|
||||||
|
);
|
||||||
|
expect(metricsText).toContain(
|
||||||
|
'unleash_histogram_response_time_sum{unleash_foo="bar",unleash_origin="sdk"} 11.7',
|
||||||
|
);
|
||||||
|
expect(metricsText).toContain(
|
||||||
|
'unleash_histogram_response_time_count{unleash_foo="bar",unleash_origin="sdk"} 15',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
@ -1,19 +1,38 @@
|
|||||||
import { Counter, Gauge, type Registry } from 'prom-client';
|
import { Counter, Gauge, type Registry } from 'prom-client';
|
||||||
|
import { BatchHistogram } from './batch-histogram.js';
|
||||||
|
|
||||||
export interface MetricSample {
|
export interface NumericMetricSample {
|
||||||
labels?: Record<string, string | number>;
|
labels?: Record<string, string | number>;
|
||||||
value: number;
|
value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Metric {
|
export interface BucketMetricSample {
|
||||||
|
labels?: Record<string, string | number>;
|
||||||
|
count: number;
|
||||||
|
sum: number;
|
||||||
|
buckets: Array<{ le: number | '+Inf'; count: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumericMetric {
|
||||||
name: string;
|
name: string;
|
||||||
help: string;
|
help: string;
|
||||||
type: 'counter' | 'gauge';
|
type: 'counter' | 'gauge';
|
||||||
samples: MetricSample[];
|
samples: NumericMetricSample[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BucketMetric {
|
||||||
|
name: string;
|
||||||
|
help: string;
|
||||||
|
type: 'histogram';
|
||||||
|
buckets: number[];
|
||||||
|
samples: BucketMetricSample[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Metric = NumericMetric | BucketMetric;
|
||||||
|
|
||||||
export class MetricsTranslator {
|
export class MetricsTranslator {
|
||||||
private registry: Registry;
|
private registry: Registry;
|
||||||
|
private histograms: Map<string, BatchHistogram> = new Map();
|
||||||
|
|
||||||
constructor(registry: Registry) {
|
constructor(registry: Registry) {
|
||||||
this.registry = registry;
|
this.registry = registry;
|
||||||
@ -22,13 +41,11 @@ export class MetricsTranslator {
|
|||||||
sanitizeName(name: string): string {
|
sanitizeName(name: string): string {
|
||||||
const regex = /[^a-zA-Z0-9_]/g;
|
const regex = /[^a-zA-Z0-9_]/g;
|
||||||
|
|
||||||
const sanitized = name.replace(regex, '_');
|
return name.replace(regex, '_');
|
||||||
|
|
||||||
return sanitized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private hasNewLabels(
|
private hasNewLabels(
|
||||||
existingMetric: Counter<string> | Gauge<string>,
|
existingMetric: Counter<string> | Gauge<string> | BatchHistogram,
|
||||||
newLabelNames: string[],
|
newLabelNames: string[],
|
||||||
): boolean {
|
): boolean {
|
||||||
const existingLabelNames = (existingMetric as any).labelNames || [];
|
const existingLabelNames = (existingMetric as any).labelNames || [];
|
||||||
@ -50,7 +67,7 @@ export class MetricsTranslator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private addOriginLabel(
|
private addOriginLabel(
|
||||||
sample: MetricSample,
|
sample: NumericMetricSample,
|
||||||
): Record<string, string | number> {
|
): Record<string, string | number> {
|
||||||
return {
|
return {
|
||||||
...(sample.labels || {}),
|
...(sample.labels || {}),
|
||||||
@ -58,7 +75,9 @@ export class MetricsTranslator {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
translateMetric(metric: Metric): Counter<string> | Gauge<string> | null {
|
translateMetric(
|
||||||
|
metric: Metric,
|
||||||
|
): Counter<string> | Gauge<string> | BatchHistogram | null {
|
||||||
const sanitizedName = this.sanitizeName(metric.name);
|
const sanitizedName = this.sanitizeName(metric.name);
|
||||||
const prefixedName = `unleash_${metric.type}_${sanitizedName}`;
|
const prefixedName = `unleash_${metric.type}_${sanitizedName}`;
|
||||||
const existingMetric = this.registry.getSingleMetric(prefixedName);
|
const existingMetric = this.registry.getSingleMetric(prefixedName);
|
||||||
@ -142,6 +161,35 @@ export class MetricsTranslator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return gauge;
|
return gauge;
|
||||||
|
} else if (metric.type === 'histogram') {
|
||||||
|
if (!metric.samples || metric.samples.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let histogram = this.histograms.get(prefixedName);
|
||||||
|
if (!histogram) {
|
||||||
|
histogram = new BatchHistogram({
|
||||||
|
name: prefixedName,
|
||||||
|
help: metric.help,
|
||||||
|
registry: this.registry,
|
||||||
|
});
|
||||||
|
this.histograms.set(prefixedName, histogram);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sample of metric.samples) {
|
||||||
|
const transformedLabels = this.transformLabels({
|
||||||
|
...sample.labels,
|
||||||
|
origin: sample.labels?.origin || 'sdk',
|
||||||
|
});
|
||||||
|
|
||||||
|
histogram.recordBatch(transformedLabels, {
|
||||||
|
count: sample.count,
|
||||||
|
sum: sample.sum,
|
||||||
|
buckets: sample.buckets,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return histogram;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
@ -99,6 +99,33 @@ export const metricSampleSchema = joi
|
|||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const histogramSampleSchema = joi
|
||||||
|
.object()
|
||||||
|
.options({ stripUnknown: true })
|
||||||
|
.keys({
|
||||||
|
count: joi.number().required(),
|
||||||
|
sum: joi.number().required(),
|
||||||
|
buckets: joi
|
||||||
|
.array()
|
||||||
|
.items(
|
||||||
|
joi.object({
|
||||||
|
le: joi
|
||||||
|
.alternatives()
|
||||||
|
.try(joi.number(), joi.string().valid('+Inf'))
|
||||||
|
.required(),
|
||||||
|
count: joi.number().required(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.required(),
|
||||||
|
labels: joi
|
||||||
|
.object()
|
||||||
|
.pattern(
|
||||||
|
joi.string(),
|
||||||
|
joi.alternatives().try(joi.string(), joi.number()),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const impactMetricSchema = joi
|
export const impactMetricSchema = joi
|
||||||
.object()
|
.object()
|
||||||
.options({ stripUnknown: true })
|
.options({ stripUnknown: true })
|
||||||
@ -106,7 +133,12 @@ export const impactMetricSchema = joi
|
|||||||
name: joi.string().required(),
|
name: joi.string().required(),
|
||||||
help: joi.string().required(),
|
help: joi.string().required(),
|
||||||
type: joi.string().required(),
|
type: joi.string().required(),
|
||||||
samples: joi.array().items(metricSampleSchema).required(),
|
buckets: joi.array().items(joi.number()).optional(),
|
||||||
|
samples: joi.when('type', {
|
||||||
|
is: 'histogram',
|
||||||
|
then: joi.array().items(histogramSampleSchema).required(),
|
||||||
|
otherwise: joi.array().items(metricSampleSchema).required(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const impactMetricsSchema = joi
|
export const impactMetricsSchema = joi
|
||||||
|
Loading…
Reference in New Issue
Block a user