Repository: koordinator-sh/koordinator

Issue:
[proposal] utils: support GCHistogramVec
<!-- Please only use this template for submitting proposal -->

**What is your proposal**:

Support a histogram metric vector that can garbage collect the expired metrics automatically.

**Why is this needed**:

Koordinator has enabled garbage-collecting metrics for the Counter and Gauge type. See: https://github.com/koordinator-sh/koordinator/blob/main/pkg/util/metrics/expire_metric.go#L95

However, there are components that use the Histogram metrics to collect and aggregate quantile values. To ease the recording of histogram metrics, we should support garbage collection for the Histogram type.

**Is there a suggested solution, if so, please add it**:

Follow the existing interface and structs like `MetricVecGC` and `GCGaugeVec`, and add a structure `GCHistogramVec`. Feel free to extend the interface if it limits the implementation.


Discussion:
@googs1025: /assign @googs1025: In addition to this issue, do we need to migrate other metrics that were not originally used in GC metrics? 🤔 @saintube: > In addition to this issue, do we need to migrate other metrics that were not originally used in GC metrics? 🤔

It is a good point. We can scan and migrate the metrics that miss a GC logic.@googs1025: I will make an issue to track later ...

Solution patch:
diff --git a/pkg/util/metrics/expire_metric.go b/pkg/util/metrics/expire_metric.go
index 17cb8ea6..b1df484e 100644
--- a/pkg/util/metrics/expire_metric.go
+++ b/pkg/util/metrics/expire_metric.go
@@ -92,6 +92,38 @@ func (g *GCCounterVec) Delete(labels prometheus.Labels) {
 	g.expireStatus.RemoveStatus(g.name, labels)
 }
 
+// GCHistogramVec wraps a prometheus.HistogramVec and integrates with MetricGC for expiration handling.
+type GCHistogramVec struct {
+	name         string
+	vec          *prometheus.HistogramVec
+	expireStatus MetricGC
+}
+
+func NewGCHistogramVec(name string, vec *prometheus.HistogramVec) *GCHistogramVec {
+	return newGCHistogramVec(name, vec, defaultMetricGC)
+}
+
+func newGCHistogramVec(name string, vec *prometheus.HistogramVec, metricGC MetricGC) *GCHistogramVec {
+	metricGC.AddMetric(name, vec.MetricVec)
+	return &GCHistogramVec{name: name, vec: vec, expireStatus: metricGC}
+}
+
+func (g *GCHistogramVec) GetHistogramVec() *prometheus.HistogramVec {
+	return g.vec
+}
+
+// WithObserve records a value in the histogram and updates the expiration status.
+func (g *GCHistogramVec) WithObserve(labels prometheus.Labels, value float64) {
+	g.vec.With(labels).Observe(value)
+	g.expireStatus.UpdateStatus(g.name, labels)
+}
+
+// Delete removes the metric with the given labels and updates the expiration status.
+func (g *GCHistogramVec) Delete(labels prometheus.Labels) {
+	g.vec.Delete(labels)
+	g.expireStatus.RemoveStatus(g.name, labels)
+}
+
 type MetricVecGC interface {
 	// Len returns the length of the alive metric statuses.
 	Len() int



Test patch:
diff --git a/pkg/util/metrics/expire_metric_test.go b/pkg/util/metrics/expire_metric_test.go
index f4c8d537..9e458f9e 100644
--- a/pkg/util/metrics/expire_metric_test.go
+++ b/pkg/util/metrics/expire_metric_test.go
@@ -118,6 +118,50 @@ func Test_GCCounterVec(t *testing.T) {
 	assert.Equal(t, 1, testCounterVec.expireStatus.CountStatus(metricName), "checkStatusNum")
 }
 
+func Test_GCHistogramVec(t *testing.T) {
+	metricName := "test_histogram"
+	vec := prometheus.NewHistogramVec(prometheus.HistogramOpts{
+		Subsystem: testSubsystem,
+		Name:      metricName,
+		Buckets:   []float64{0.1, 0.5, 1, 2},
+	}, []string{"node", "pod_name", "pod_namespace"})
+
+	testDefaultHistogramVec := NewGCHistogramVec(metricName, vec)
+	assert.Equal(t, vec, testDefaultHistogramVec.GetHistogramVec())
+
+	// Custom MetricGC
+	testMetricGC := NewMetricGC(DefaultExpireTime, DefaultGCInterval).(*metricGC)
+	defer testMetricGC.Stop()
+
+	testHistogramVec := newGCHistogramVec(metricName, vec, testMetricGC)
+
+	// Add metric1
+	pod1Labels := prometheus.Labels{"node": "node1", "pod_name": "pod1", "pod_namespace": "ns1"}
+	testHistogramVec.WithObserve(pod1Labels, 0.3)
+	ms := collectMetrics(vec)
+	assert.Equal(t, 1, len(ms), "checkMetricsNum")
+	assert.Equal(t, 1, testHistogramVec.expireStatus.CountStatus(metricName), "checkStatusNum")
+
+	// Add metric2
+	pod2Labels := prometheus.Labels{"node": "node2", "pod_name": "pod2", "pod_namespace": "ns2"}
+	testHistogramVec.WithObserve(pod2Labels, 1.5)
+	ms = collectMetrics(vec)
+	assert.Equal(t, 2, len(ms), "checkMetricsNum")
+	assert.Equal(t, 2, testHistogramVec.expireStatus.CountStatus(metricName), "checkStatusNum")
+
+	// Update metric1
+	testHistogramVec.WithObserve(pod1Labels, 0.7)
+	ms = collectMetrics(vec)
+	assert.Equal(t, 2, len(ms), "checkMetricsNum")
+	assert.Equal(t, 2, testHistogramVec.expireStatus.CountStatus(metricName), "checkStatusNum")
+
+	// Delete metric1
+	testHistogramVec.Delete(pod1Labels)
+	ms = collectMetrics(vec)
+	assert.Equal(t, 1, len(ms), "checkMetricsNum")
+	assert.Equal(t, 1, testHistogramVec.expireStatus.CountStatus(metricName), "checkStatusNum")
+}
+
 func Test_MetricGC_GC(t *testing.T) {
 	testMetricGC := NewMetricGC(DefaultExpireTime, 1*time.Microsecond).(*metricGC)
 	defer testMetricGC.Stop()