Files
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

154 lines
5.4 KiB
Go

package localentitylimiter
import (
"sync"
"github.com/grafana/tempo/modules/generator/registry"
tempo_log "github.com/grafana/tempo/pkg/util/log"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/prometheus/model/labels"
)
type localEntityLimiterMetrics struct {
totalEntitiesLimited *prometheus.CounterVec
activeEntities *prometheus.GaugeVec
maxActiveEntities *prometheus.GaugeVec
totalEntitiesAdded *prometheus.CounterVec
totalEntitiesRemoved *prometheus.CounterVec
}
var _ registry.Limiter = (*LocalEntityLimiter)(nil)
func newMetrics(reg prometheus.Registerer) localEntityLimiterMetrics {
return localEntityLimiterMetrics{
totalEntitiesLimited: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{
Namespace: "tempo",
Name: "metrics_generator_registry_entities_limited_total",
Help: "The total amount of entities not created because of limits per tenant",
}, []string{"tenant"}),
activeEntities: promauto.With(reg).NewGaugeVec(prometheus.GaugeOpts{
Namespace: "tempo",
Name: "metrics_generator_registry_active_entities",
Help: "The number of active entities in the metrics generator registry",
}, []string{"tenant"}),
maxActiveEntities: promauto.With(reg).NewGaugeVec(prometheus.GaugeOpts{
Namespace: "tempo",
Name: "metrics_generator_registry_max_active_entities",
Help: "The maximum number of entities allowed to be active in the metrics generator registry",
}, []string{"tenant"}),
totalEntitiesAdded: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{
Namespace: "tempo",
Name: "metrics_generator_registry_entities_added_total",
Help: "The total amount of entities created per tenant",
}, []string{"tenant"}),
totalEntitiesRemoved: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{
Namespace: "tempo",
Name: "metrics_generator_registry_entities_removed_total",
Help: "The total amount of entities removed after they have become stale per tenant",
}, []string{"tenant"}),
}
}
var metrics = newMetrics(prometheus.DefaultRegisterer)
type maxEntityFunc func(tenant string) uint32
type LocalEntityLimiter struct {
tenant string
entityActiveSeries map[uint64]uint32
mtx sync.Mutex
maxEntityFunc maxEntityFunc
limitLogger *tempo_log.RateLimitedLogger
metricTotalEntitiesLimited prometheus.Counter
metricActiveEntities prometheus.Gauge
metricMaxActiveEntities prometheus.Gauge
metricTotalEntitiesAdded prometheus.Counter
metricTotalEntitiesRemoved prometheus.Counter
overflowEntity labels.Labels
overflowEntityHash uint64
}
func New(maxEntityF maxEntityFunc, tenant string, limitLogger *tempo_log.RateLimitedLogger) *LocalEntityLimiter {
l := &LocalEntityLimiter{
tenant: tenant,
entityActiveSeries: make(map[uint64]uint32),
maxEntityFunc: maxEntityF,
limitLogger: limitLogger,
metricTotalEntitiesLimited: metrics.totalEntitiesLimited.WithLabelValues(tenant),
metricActiveEntities: metrics.activeEntities.WithLabelValues(tenant),
metricMaxActiveEntities: metrics.maxActiveEntities.WithLabelValues(tenant),
metricTotalEntitiesAdded: metrics.totalEntitiesAdded.WithLabelValues(tenant),
metricTotalEntitiesRemoved: metrics.totalEntitiesRemoved.WithLabelValues(tenant),
overflowEntity: registry.OverflowEntity,
overflowEntityHash: registry.OverflowEntity.Hash(),
}
return l
}
func (l *LocalEntityLimiter) OnAdd(labelHash uint64, seriesCount uint32, lbls labels.Labels) (labels.Labels, uint64) {
l.mtx.Lock()
defer l.mtx.Unlock()
if labelHash == l.overflowEntityHash {
// The overflow entity is always accepted.
return l.overflowEntity, l.overflowEntityHash
}
activeSeries, ok := l.entityActiveSeries[labelHash]
if ok {
// If we've already accepted this entity, return the original labelset.
activeSeries += seriesCount
l.entityActiveSeries[labelHash] = activeSeries
return lbls, labelHash
}
maxEntities := l.maxEntityFunc(l.tenant)
if maxEntities != 0 && uint32(len(l.entityActiveSeries))+1 > maxEntities {
// We're at the limit, return overflow labels
l.limitLogger.Log("msg", "reached max active entities", "active_entities", len(l.entityActiveSeries), "max_active_entities", maxEntities)
l.metricTotalEntitiesLimited.Add(float64(1))
return l.overflowEntity, l.overflowEntityHash
}
// We're under the limit, add the new entity
l.entityActiveSeries[labelHash] = seriesCount
l.metricActiveEntities.Set(float64(len(l.entityActiveSeries)))
l.metricMaxActiveEntities.Set(float64(maxEntities))
l.metricTotalEntitiesAdded.Add(float64(1))
return lbls, labelHash
}
func (l *LocalEntityLimiter) OnUpdate(uint64, uint32) {
// No-op, we rely on OnDelete to clean up
}
func (l *LocalEntityLimiter) OnDelete(labelHash uint64, seriesCount uint32) {
l.mtx.Lock()
defer l.mtx.Unlock()
activeSeries, ok := l.entityActiveSeries[labelHash]
if !ok {
return
}
// Guard against accidental overflow. This is a programming error, but we
// should be defensive.
if seriesCount > activeSeries {
seriesCount = activeSeries
}
activeSeries -= seriesCount
if activeSeries == 0 {
delete(l.entityActiveSeries, labelHash)
} else {
l.entityActiveSeries[labelHash] = activeSeries
}
l.metricActiveEntities.Set(float64(len(l.entityActiveSeries)))
l.metricTotalEntitiesRemoved.Add(1)
}