Files
traces/modules/generator/registry/per_label_limiter_test.go
Suraj Nathandzeekay 5e4cd1913d metrics-generator: Add per-label limiter to control cardinality (#6414)
* metrics-generator: Add per-label cardinality sanitizer

Adds a per-label cardinality cap that replaces only the high-cardinality
label value with "__overflow__" while preserving all other dimensions.

Implementation:
- CardinalitySanitizer: per-label HLL sketch tracking with cached
  overLimit flag updated every 15s. Always inserts original value hash
  to prevent estimate oscillation.
- ChainSanitizer: composes sanitizers in order (DRAIN first, then
  cardinality sanitizer as hard backstop).
- New config field: max_cardinality_per_label (0 = disabled).

* metrics-generator: Add docs for per-label cardinality sanitizer

Rename overflow value to __cardinality_overflow__ for clarity and add
troubleshooting docs explaining the per-label cardinality limit, its
PromQL query, configuration, and how it interacts with the series and
entity limiters.

* metrics-generator: Move max_cardinality_per_label to per-tenant override

Move max_cardinality_per_label from static registry config to a
per-tenant runtime override, consistent with max_active_series and
max_active_entities. The CardinalitySanitizer now receives the
Overrides interface and refreshes the cached value every maintenance
tick (15s), allowing operators to tune it per tenant without restart.

* move from piggybacking on sanitizer and have a standalone per label limiter

sanitizer is there to sanitize the labels, so we shouldn't piggyback and rely
on the chained sanitizer hack to ensure per label limiter runs after all sanitizer.

we create a new LabelLimiter interface and run it after Sanitizer and decouple from Sanitizer

* extract maxSeriesFunc and maxEntityFunc to type

* test the overflow -> recovery -> overflow liftcycle

* make the test more strict

* rework docs

* track label_names and add a demand estimate metric as well

* Add tests for MetricsGeneratorMaxCardinalityPerLabel override

* optimize hot path

* Update TestPerLabelLimiter_ConcurrentAccess test

* Add BenchmarkPerLabelLimiter to check perf and allocs

* Add CHANGELOG.md

* Update config index page

* drop noopLabelLimiter from tests

* always run s.doPeriodicMaintenance first to refresh the config value
2026-02-22 00:55:23 -08:00

451 lines
16 KiB
Go

package registry
import (
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
io_prometheus_client "github.com/prometheus/client_model/go"
"github.com/prometheus/prometheus/model/labels"
"github.com/stretchr/testify/require"
)
// testMaxCardinality returns a maxCardinalityFunc that always returns the given value.
func testMaxCardinality(value uint64) maxCardinalityFunc {
return func(string) uint64 { return value }
}
func TestPerLabelLimiter_Disabled(t *testing.T) {
s := NewPerLabelLimiter("test", testMaxCardinality(0), 15*time.Minute)
lbls := labels.FromStrings("__name__", "foo", "method", "GET", "url", "/api/users/123")
result := s.Limit(lbls)
require.Equal(t, lbls, result)
}
// TestPerLabelLimiter_RuntimeEnableDisable verifies that toggling max_cardinality_per_label at runtime
// via overrides takes effect without restarting the generator.
func TestPerLabelLimiter_RuntimeEnableDisable(t *testing.T) {
tenant := "test-runtime-toggle"
var maxCardinality atomic.Uint64
maxCardinality.Store(0) // start disabled
s := NewPerLabelLimiter(tenant, func(string) uint64 {
return maxCardinality.Load()
}, 15*time.Minute)
// Phase 1: Disabled - all labels pass through
for i := 0; i < 10; i++ {
lbls := labels.FromStrings("__name__", "m", "url", fmt.Sprintf("/path/%d", i))
result := s.Limit(lbls)
require.Equal(t, fmt.Sprintf("/path/%d", i), result.Get("url"), "should pass through when disabled")
}
// when disabled, Limit() returns before inserting into sketches, so labelsState
// is empty, which means no demand gauge was published as well.
triggerDemandUpdate(s)
s.mtx.Lock()
labelsStateLen := len(s.labelsState)
s.mtx.Unlock()
require.Equal(t, 0, labelsStateLen, "no label state should exist when disabled")
// Phase 2: Enable at runtime by changing the override
maxCardinality.Store(5)
triggerDemandUpdate(s)
// Push more distinct values - should overflow now
for i := 0; i < 10; i++ {
lbls := labels.FromStrings("__name__", "m", "url", fmt.Sprintf("/path/%d", i))
s.Limit(lbls)
}
triggerDemandUpdate(s)
// demand gauge should be published when enabled
var g io_prometheus_client.Metric
require.NoError(t, metricLabelCardinalityDemand.WithLabelValues(tenant, "url").Write(&g))
require.Greater(t, g.GetGauge().GetValue(), float64(5), "demand gauge should be published when enabled")
lbls := labels.FromStrings("__name__", "m", "url", "/path/999")
result := s.Limit(lbls)
require.Equal(t, overflowValue, result.Get("url"), "should overflow after runtime enable")
// capture the demand gauge value before disabling
var before io_prometheus_client.Metric
require.NoError(t, metricLabelCardinalityDemand.WithLabelValues(tenant, "url").Write(&before))
demandBefore := before.GetGauge().GetValue()
// Phase 3: Disable again at runtime
maxCardinality.Store(0)
triggerDemandUpdate(s)
lbls = labels.FromStrings("__name__", "m", "url", "/path/new/10000")
result = s.Limit(lbls)
require.Equal(t, "/path/new/10000", result.Get("url"), "should pass through after runtime disable")
// demand gauge should be stale (not updated) after disabling
var after io_prometheus_client.Metric
require.NoError(t, metricLabelCardinalityDemand.WithLabelValues(tenant, "url").Write(&after))
require.Equal(t, demandBefore, after.GetGauge().GetValue(), "demand gauge should not be updated when disabled")
}
func TestPerLabelLimiter_UnderLimit(t *testing.T) {
s := NewPerLabelLimiter("test", testMaxCardinality(100), 15*time.Minute)
// Insert a few distinct values - well under the limit
for i := 0; i < 5; i++ {
lbls := labels.FromStrings("__name__", "foo", "method", fmt.Sprintf("m%d", i))
result := s.Limit(lbls)
// Before the first maintenance tick, overLimit is false (default), so everything passes through
require.Equal(t, fmt.Sprintf("m%d", i), result.Get("method"))
}
// Trigger maintenance to update overLimit flags
triggerDemandUpdate(s)
// Still under limit, should pass through
lbls := labels.FromStrings("__name__", "foo", "method", "GET")
result := s.Limit(lbls)
require.Equal(t, "GET", result.Get("method"))
}
func TestPerLabelLimiter_HighCardinalityOverflows(t *testing.T) {
s := NewPerLabelLimiter("test", testMaxCardinality(5), 15*time.Minute)
// Push distinct url values but few method values
for i := 0; i < 10; i++ {
lbls := labels.FromStrings("__name__", "http_requests", "method", "GET", "url", fmt.Sprintf("/users/%d", i))
s.Limit(lbls)
}
// Trigger maintenance to update overLimit flags
triggerDemandUpdate(s)
// Now the url should overflow but the method should be preserved
lbls := labels.FromStrings("__name__", "http_requests", "method", "GET", "url", "/users/999")
result := s.Limit(lbls)
require.Equal(t, "GET", result.Get("method"), "low-cardinality label should be preserved")
require.Equal(t, overflowValue, result.Get("url"), "high-cardinality label should have overflow value")
require.Equal(t, "http_requests", result.Get("__name__"), "__name__ should be preserved")
}
func TestPerLabelLimiter_MultipleHighCardinalityOverflows(t *testing.T) {
s := NewPerLabelLimiter("test", testMaxCardinality(5), 15*time.Minute)
// Push many distinct values for BOTH url and user_id
for i := 0; i < 10; i++ {
lbls := labels.FromStrings("__name__", "m", "method", "GET", "url", fmt.Sprintf("/p/%d", i), "user_id", fmt.Sprintf("u%d", i))
s.Limit(lbls)
}
triggerDemandUpdate(s)
lbls := labels.FromStrings("__name__", "m", "method", "GET", "url", "/p/999", "user_id", "u999")
result := s.Limit(lbls)
require.Equal(t, "GET", result.Get("method"), "low-cardinality label preserved")
require.Equal(t, overflowValue, result.Get("url"), "high-cardinality url overflows")
require.Equal(t, overflowValue, result.Get("user_id"), "high-cardinality user_id overflows")
}
func TestPerLabelLimiter_MetadataLabelsNeverOverflows(t *testing.T) {
s := NewPerLabelLimiter("test", testMaxCardinality(5), 15*time.Minute)
// Push many distinct values for all metadata labels to exceed the limit
for i := 0; i < 10; i++ {
lbls := labels.FromStrings(
"__name__", fmt.Sprintf("metric_%d", i),
"__type__", fmt.Sprintf("type_%d", i),
"__unit__", fmt.Sprintf("unit_%d", i),
"url", fmt.Sprintf("/path/%d", i),
)
s.Limit(lbls)
}
triggerDemandUpdate(s)
lbls := labels.FromStrings("__name__", "metric_999", "__type__", "type_999", "__unit__", "unit_999", "url", "/path/999")
result := s.Limit(lbls)
require.Equal(t, "metric_999", result.Get("__name__"), "__name__ should never overflow")
require.Equal(t, "type_999", result.Get("__type__"), "__type__ should never overflow")
require.Equal(t, "unit_999", result.Get("__unit__"), "__unit__ should never overflow")
require.Equal(t, overflowValue, result.Get("url"), "non-metadata label should overflow")
}
// TestPerLabelLimiter_RecoveryAfterOverflow verifies that a label
// recovers from overflow once the user reduces cardinality and the old
// high-cardinality sketches rotate out of the sliding window.
func TestPerLabelLimiter_RecoveryAfterOverflow(t *testing.T) {
staleDuration := 15 * time.Minute
s := NewPerLabelLimiter("test", testMaxCardinality(5), staleDuration)
// Phase 1: Push high-cardinality data to trigger overflow
for i := 0; i < 10; i++ {
lbls := labels.FromStrings("__name__", "http_requests", "url", fmt.Sprintf("/users/%d", i))
s.Limit(lbls)
}
triggerDemandUpdate(s)
// Verify that overflow happens
lbls := labels.FromStrings("__name__", "http_requests", "url", "/users/999")
result := s.Limit(lbls)
require.Equal(t, overflowValue, result.Get("url"), "should overflow while cardinality is high")
// Phase 2: Simulate time passing - Advance the sketch enough times to fully rotate
// out all old high-cardinality data from the sketch ring and prune it.
// With staleDuration=15m and sketchDuration=5m, sketchesLength=4, so prune it 4 times
for i := 0; i < 4; i++ {
triggerPrune(s)
}
// Phase 3: Push only low-cardinality data (within limit)
for i := 0; i < 3; i++ {
lbls := labels.FromStrings("__name__", "http_requests", "url", fmt.Sprintf("/api/v1/resource_%d", i))
s.Limit(lbls)
}
// Trigger maintenance to re-evaluate overLimit from the now-low estimate
triggerDemandUpdate(s)
// Verify recovery - label values should pass through again
lbls = labels.FromStrings("__name__", "http_requests", "url", "/api/v1/healthy")
result = s.Limit(lbls)
require.Equal(t, "/api/v1/healthy", result.Get("url"), "should recover after cardinality drops below limit")
// Phase 4: Cardinality explodes again - verify overflow kicks back in
for i := 0; i < 10; i++ {
lbls := labels.FromStrings("__name__", "http_requests", "url", fmt.Sprintf("/new_endpoint/%d", i))
s.Limit(lbls)
}
triggerDemandUpdate(s)
lbls = labels.FromStrings("__name__", "http_requests", "url", "/new_endpoint/999")
result = s.Limit(lbls)
require.Equal(t, overflowValue, result.Get("url"), "should overflow again after cardinality increases")
}
func TestPerLabelLimiter_OverflowMetrics(t *testing.T) {
tenant := "test-overflow-metrics"
s := NewPerLabelLimiter(tenant, testMaxCardinality(5), 15*time.Minute)
// Push enough distinct values to exceed the limit
for i := 0; i < 10; i++ {
lbls := labels.FromStrings("__name__", "m", "url", fmt.Sprintf("/path/%d", i))
s.Limit(lbls)
}
triggerDemandUpdate(s)
// Now limit - should increment the counter
lbls := labels.FromStrings("__name__", "m", "url", "/path/new")
result := s.Limit(lbls)
require.Equal(t, overflowValue, result.Get("url"))
var m io_prometheus_client.Metric
require.NoError(t, metricLabelValuesLimited.WithLabelValues(tenant, "url").Write(&m))
require.Equal(t, float64(1), m.GetCounter().GetValue())
// Verify demand estimate gauge was updated during triggerDemandUpdate
var g io_prometheus_client.Metric
require.NoError(t, metricLabelCardinalityDemand.WithLabelValues(tenant, "url").Write(&g))
require.GreaterOrEqual(t, g.GetGauge().GetValue(), float64(10), "demand estimate should reflect the distinct values pushed")
}
// TestPerLabelLimiter_ConcurrentAccess verifies Limit() is safe to call
// from multiple goroutines while doPeriodicMaintenance fires concurrently.
// Run with -race to detect unsynchronized access.
func TestPerLabelLimiter_ConcurrentAccess(t *testing.T) {
s := NewPerLabelLimiter("test", testMaxCardinality(10), 15*time.Minute)
// Replace tickers with channels we control, so doPeriodicMaintenance
// actually runs its demand-update and prune paths during the test.
demandCh := make(chan time.Time, 10)
pruneCh := make(chan time.Time, 10)
s.demandUpdateChan = demandCh
s.pruneChan = pruneCh
var wg sync.WaitGroup
wg.Add(12) // 10 Limit() goroutines + 1 demand update ticker + 1 prune ticker
for g := 0; g < 10; g++ {
go func(id int) {
defer wg.Done()
for i := 0; i < 100; i++ {
lbls := labels.FromStrings("__name__", "m", "label", fmt.Sprintf("g%d-v%d", id, i))
result := s.Limit(lbls)
require.Equal(t, "m", result.Get("__name__"), "metric name must be preserved")
val := result.Get("label")
require.True(t, val == fmt.Sprintf("g%d-v%d", id, i) || val == overflowValue,
"label value must be original or overflow, got: %s", val)
}
}(g)
}
// Feed demand update ticks concurrently - picked up by doPeriodicMaintenance
// inside Limit() calls, exercising the full code path.
go func() {
defer wg.Done()
for i := 0; i < 10; i++ {
demandCh <- time.Now()
time.Sleep(time.Millisecond)
}
}()
// Feed prune ticks concurrently
go func() {
defer wg.Done()
for i := 0; i < 5; i++ {
pruneCh <- time.Now()
time.Sleep(time.Millisecond)
}
}()
wg.Wait()
// After all goroutines finish, trigger maintenance and verify the state is consistent
triggerDemandUpdate(s)
s.mtx.Lock()
state, ok := s.labelsState["label"]
s.mtx.Unlock()
require.True(t, ok, "label state should exist after concurrent inserts")
// Estimate may be less than 1000 because prune ticks rotate out sketch data during the test
require.Greater(t, state.sketch.Estimate(), uint64(0), "sketch should have recorded values")
}
func BenchmarkPerLabelLimiter_Limit(b *testing.B) {
b.Run("disabled", func(b *testing.B) {
s := NewPerLabelLimiter("bench", testMaxCardinality(0), 15*time.Minute)
lbls := labels.FromStrings("__name__", "http_requests", "method", "GET", "url", "/api/v1/users")
b.ReportAllocs()
// Reset timer so setup (limiter creation, label generation, warmup) isn't measured
b.ResetTimer()
for i := 0; i < b.N; i++ {
s.Limit(lbls)
}
})
b.Run("under_limit", func(b *testing.B) {
s := NewPerLabelLimiter("bench", testMaxCardinality(1000), 15*time.Minute)
// Pre-generate distinct labels to simulate real traffic with unique values
n := 500
allLbls := make([]labels.Labels, n)
for i := 0; i < n; i++ {
allLbls[i] = labels.FromStrings("__name__", "http_requests", "method", "GET", "url", fmt.Sprintf("/api/v1/users/%d", i))
}
s.Limit(allLbls[0])
triggerDemandUpdate(s)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
s.Limit(allLbls[i%n])
}
})
b.Run("over_limit", func(b *testing.B) {
s := NewPerLabelLimiter("bench", testMaxCardinality(5), 15*time.Minute)
n := 500
allLbls := make([]labels.Labels, n)
for i := 0; i < n; i++ {
allLbls[i] = labels.FromStrings("__name__", "http_requests", "method", "GET", "url", fmt.Sprintf("/api/v1/users/%d", i))
}
for i := 0; i < 20; i++ {
s.Limit(allLbls[i])
}
triggerDemandUpdate(s)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
s.Limit(allLbls[i%n])
}
})
b.Run("many_labels_under_limit", func(b *testing.B) {
s := NewPerLabelLimiter("bench", testMaxCardinality(1000), 15*time.Minute)
n := 500
allLbls := make([]labels.Labels, n)
for i := 0; i < n; i++ {
allLbls[i] = labels.FromStrings(
"__name__", "http_requests",
"method", "GET",
"url", fmt.Sprintf("/api/v1/users/%d", i),
"status_code", "200",
"service", "frontend",
"region", "us-east-1",
"instance", fmt.Sprintf("pod-%d", i),
)
}
s.Limit(allLbls[0])
triggerDemandUpdate(s)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
s.Limit(allLbls[i%n])
}
})
b.Run("many_labels_over_limit", func(b *testing.B) {
s := NewPerLabelLimiter("bench", testMaxCardinality(5), 15*time.Minute)
n := 500
allLbls := make([]labels.Labels, n)
for i := 0; i < n; i++ {
allLbls[i] = labels.FromStrings(
"__name__", "http_requests",
"method", fmt.Sprintf("m%d", i),
"url", fmt.Sprintf("/path/%d", i),
"status_code", fmt.Sprintf("%d", i),
"service", fmt.Sprintf("svc%d", i),
"region", fmt.Sprintf("r%d", i),
"instance", fmt.Sprintf("pod%d", i),
)
}
// Warm up to trigger overflow on all labels
for i := 0; i < 20; i++ {
s.Limit(allLbls[i])
}
triggerDemandUpdate(s)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
s.Limit(allLbls[i%n])
}
})
b.Run("parallel", func(b *testing.B) {
s := NewPerLabelLimiter("bench", testMaxCardinality(1000), 15*time.Minute)
n := 500
allLbls := make([]labels.Labels, n)
for i := 0; i < n; i++ {
allLbls[i] = labels.FromStrings("__name__", "http_requests", "method", "GET", "url", fmt.Sprintf("/api/v1/users/%d", i))
}
s.Limit(allLbls[0])
triggerDemandUpdate(s)
b.ReportAllocs()
b.ResetTimer()
var counter atomic.Int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
i := int(counter.Add(1))
s.Limit(allLbls[i%n])
}
})
})
}
// triggerDemandUpdate force runs the demand-update path of doPeriodicMaintenance,
// re-evaluating overLimit from current sketch estimates.
func triggerDemandUpdate(s *PerLabelLimiter) {
ch := make(chan time.Time, 1)
s.demandUpdateChan = ch
ch <- time.Now()
s.doPeriodicMaintenance()
}
// triggerPrune force runs the prune path of doPeriodicMaintenance, advancing the sketch ring by one step.
func triggerPrune(s *PerLabelLimiter) {
ch := make(chan time.Time, 1)
s.pruneChan = ch
ch <- time.Now()
s.doPeriodicMaintenance()
}