From 0aef65dac7958a2b7f97b4a02f26069b6f4b0714 Mon Sep 17 00:00:00 2001 From: andrea liu Date: Wed, 3 Jun 2026 15:29:59 -0700 Subject: [PATCH 1/6] add optional descriptor key config for override rate limit keys Signed-off-by: andrea liu --- src/config/config.go | 2 +- src/config/config_impl.go | 24 ++++-- src/config/descriptor_key_config.go | 84 +++++++++++++++++++ src/config_check_cmd/main.go | 12 ++- .../descriptor_key_config_provider.go | 18 ++++ src/provider/file_provider.go | 4 +- src/provider/xds_grpc_sotw_provider.go | 4 +- src/settings/settings.go | 11 +-- test/config/config_test.go | 80 +++++++++--------- test/mocks/config/config.go | 8 +- 10 files changed, 184 insertions(+), 63 deletions(-) create mode 100644 src/config/descriptor_key_config.go create mode 100644 src/provider/descriptor_key_config_provider.go diff --git a/src/config/config.go b/src/config/config.go index fd8619269..5e760d2a2 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -63,5 +63,5 @@ type RateLimitConfigLoader interface { // @param mergeDomainConfigs defines whether multiple configurations referencing the same domain will be merged or rejected throwing an error. // @return a new configuration. // @throws RateLimitConfigError if the configuration could not be created. - Load(configs []RateLimitConfigToLoad, statsManager stats.Manager, mergeDomainConfigs bool) RateLimitConfig + Load(configs []RateLimitConfigToLoad, statsManager stats.Manager, mergeDomainConfigs bool, descriptorKeyConfig *DescriptorKeyConfig) RateLimitConfig } diff --git a/src/config/config_impl.go b/src/config/config_impl.go index c2b48e3f9..7d7dc8ffc 100644 --- a/src/config/config_impl.go +++ b/src/config/config_impl.go @@ -66,9 +66,10 @@ type rateLimitDomain struct { } type rateLimitConfigImpl struct { - domains map[string]*rateLimitDomain - statsManager stats.Manager - mergeDomainConfigs bool + domains map[string]*rateLimitDomain + statsManager stats.Manager + mergeDomainConfigs bool + descriptorKeyConfig *DescriptorKeyConfig } var validKeys = map[string]bool{ @@ -457,7 +458,7 @@ func (this *rateLimitConfigImpl) GetLimit( } if descriptor.GetLimit() != nil { - rateLimitKey := descriptorKey(domain, descriptor) + rateLimitKey := descriptorKey(domain, descriptor, this.descriptorKeyConfig) rateLimitOverrideUnit := pb.RateLimitResponse_RateLimit_Unit(descriptor.GetLimit().GetUnit()) // When limit override is provided by envoy config, we don't want to enable shadow_mode rateLimit = NewRateLimit( @@ -676,14 +677,14 @@ func (this *rateLimitConfigImpl) IsEmptyDomains() bool { return len(this.domains) == 0 } -func descriptorKey(domain string, descriptor *pb_struct.RateLimitDescriptor) string { +func descriptorKey(domain string, descriptor *pb_struct.RateLimitDescriptor, cfg *DescriptorKeyConfig) string { rateLimitKey := "" for _, entry := range descriptor.Entries { if rateLimitKey != "" { rateLimitKey += "." } rateLimitKey += entry.Key - if entry.Value != "" { + if entry.Value != "" && (cfg == nil || cfg.IncludeEntryValueForKey(domain, entry.Key)) { rateLimitKey += "_" + entry.Value } } @@ -722,8 +723,14 @@ func ConfigFileContentToYaml(fileName, content string) *YamlRoot { // @return a new config. func NewRateLimitConfigImpl( configs []RateLimitConfigToLoad, statsManager stats.Manager, mergeDomainConfigs bool, + descriptorKeyConfig *DescriptorKeyConfig, ) RateLimitConfig { - ret := &rateLimitConfigImpl{map[string]*rateLimitDomain{}, statsManager, mergeDomainConfigs} + ret := &rateLimitConfigImpl{ + domains: map[string]*rateLimitDomain{}, + statsManager: statsManager, + mergeDomainConfigs: mergeDomainConfigs, + descriptorKeyConfig: descriptorKeyConfig, + } for _, config := range configs { ret.loadConfig(config) } @@ -735,8 +742,9 @@ type rateLimitConfigLoaderImpl struct{} func (this *rateLimitConfigLoaderImpl) Load( configs []RateLimitConfigToLoad, statsManager stats.Manager, mergeDomainConfigs bool, + descriptorKeyConfig *DescriptorKeyConfig, ) RateLimitConfig { - return NewRateLimitConfigImpl(configs, statsManager, mergeDomainConfigs) + return NewRateLimitConfigImpl(configs, statsManager, mergeDomainConfigs, descriptorKeyConfig) } // @return a new default config loader implementation. diff --git a/src/config/descriptor_key_config.go b/src/config/descriptor_key_config.go new file mode 100644 index 000000000..ee81fa19a --- /dev/null +++ b/src/config/descriptor_key_config.go @@ -0,0 +1,84 @@ +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v2" +) + +// DescriptorKeyConfig controls which descriptor entries include their runtime +// value in rate limit keys built by descriptorKey for Envoy limit overrides. +type DescriptorKeyConfig struct { + defaultKeys map[string]struct{} + domainKeys map[string]map[string]struct{} +} + +type yamlDescriptorKeyRoot struct { + Default *yamlDescriptorKeyRule `yaml:"default"` + Domains map[string]yamlDescriptorKeyRule `yaml:"domains"` +} + +type yamlDescriptorKeyRule struct { + IncludeEntryValueForKeys []string `yaml:"include_entry_value_for_keys"` +} + +// LoadDescriptorKeyConfig reads descriptor key rules from a YAML file. +// Returns nil if path is empty (legacy behavior: include all non-empty values). +func LoadDescriptorKeyConfig(path string) (*DescriptorKeyConfig, error) { + if path == "" { + return nil, nil + } + + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("descriptor key config: %w", err) + } + return ParseDescriptorKeyConfig(content) +} + +func ParseDescriptorKeyConfig(content []byte) (*DescriptorKeyConfig, error) { + var root yamlDescriptorKeyRoot + if err := yaml.Unmarshal(content, &root); err != nil { + return nil, fmt.Errorf("descriptor key config: %w", err) + } + + ret := &DescriptorKeyConfig{ + defaultKeys: EntriesToSet(nil), + domainKeys: map[string]map[string]struct{}{}, + } + + if root.Default != nil { + ret.defaultKeys = EntriesToSet(root.Default.IncludeEntryValueForKeys) + } + + for domain, rule := range root.Domains { + ret.domainKeys[domain] = EntriesToSet(rule.IncludeEntryValueForKeys) + } + + return ret, nil +} + +func EntriesToSet(entries []string) map[string]struct{} { + entrySet := make(map[string]struct{}, len(entries)) + for _, entry := range entries { + entrySet[entry] = struct{}{} + } + return entrySet +} + +// IncludeEntryValueForKey reports whether entry.Value should be appended for this domain/key. +// When cfg is nil, all non-empty values are included (backward compatible). +func (cfg *DescriptorKeyConfig) IncludeEntryValueForKey(domain, entry string) bool { + if cfg == nil { + return true + } + + if domainKeys, ok := cfg.domainKeys[domain]; ok { + _, include := domainKeys[entry] + return include + } + + _, include := cfg.defaultKeys[entry] + return include +} diff --git a/src/config_check_cmd/main.go b/src/config_check_cmd/main.go index 8f83a7248..d0229003d 100644 --- a/src/config_check_cmd/main.go +++ b/src/config_check_cmd/main.go @@ -14,7 +14,7 @@ import ( "github.com/envoyproxy/ratelimit/src/config" ) -func loadConfigs(allConfigs []config.RateLimitConfigToLoad, mergeDomainConfigs bool) { +func loadConfigs(allConfigs []config.RateLimitConfigToLoad, mergeDomainConfigs bool, descriptorKeyConfigPath string) { defer func() { err := recover() if err != nil { @@ -23,7 +23,11 @@ func loadConfigs(allConfigs []config.RateLimitConfigToLoad, mergeDomainConfigs b } }() statsManager := stats.NewStatManager(gostats.NewStore(gostats.NewNullSink(), false), settings.NewSettings()) - config.NewRateLimitConfigImpl(allConfigs, statsManager, mergeDomainConfigs) + descriptorKeyConfig, err := config.LoadDescriptorKeyConfig(descriptorKeyConfigPath) + if err != nil { + panic(err) + } + config.NewRateLimitConfigImpl(allConfigs, statsManager, mergeDomainConfigs, descriptorKeyConfig) } func main() { @@ -31,6 +35,8 @@ func main() { "config_dir", "", "path to directory containing rate limit configs") mergeDomainConfigs := flag.Bool( "merge_domain_configs", false, "whether to merge configurations, referencing the same domain") + descriptorKeyConfigPath := flag.String( + "descriptor_key_config", "", "path to descriptor_key.yaml (optional)") flag.Parse() fmt.Printf("checking rate limit configs...\n") fmt.Printf("loading config directory: %s\n", *configDirectory) @@ -54,6 +60,6 @@ func main() { allConfigs = append(allConfigs, config.RateLimitConfigToLoad{Name: finalPath, ConfigYaml: configYaml}) } - loadConfigs(allConfigs, *mergeDomainConfigs) + loadConfigs(allConfigs, *mergeDomainConfigs, *descriptorKeyConfigPath) fmt.Printf("all rate limit configs ok\n") } diff --git a/src/provider/descriptor_key_config_provider.go b/src/provider/descriptor_key_config_provider.go new file mode 100644 index 000000000..8a247e993 --- /dev/null +++ b/src/provider/descriptor_key_config_provider.go @@ -0,0 +1,18 @@ +package provider + +import ( + logger "github.com/sirupsen/logrus" + + "github.com/envoyproxy/ratelimit/src/config" +) + +func loadDescriptorKeyConfigOrPanic(path string) *config.DescriptorKeyConfig { + cfg, err := config.LoadDescriptorKeyConfig(path) + if err != nil { + logger.Fatalf("failed to load descriptor key config from %q: %v", path, err) + } + if cfg != nil { + logger.Infof("loaded descriptor key config from %q", path) + } + return cfg +} diff --git a/src/provider/file_provider.go b/src/provider/file_provider.go index a1d2c8c72..6049424e2 100644 --- a/src/provider/file_provider.go +++ b/src/provider/file_provider.go @@ -22,6 +22,7 @@ type FileProvider struct { runtimeWatchRoot bool rootStore gostats.Store statsManager stats.Manager + descriptorKeyConfig *config.DescriptorKeyConfig } func (p *FileProvider) ConfigUpdateEvent() <-chan ConfigUpdateEvent { @@ -64,7 +65,7 @@ func (p *FileProvider) sendEvent() { } rlSettings := settings.NewSettings() - newConfig := p.loader.Load(files, p.statsManager, rlSettings.MergeDomainConfigurations) + newConfig := p.loader.Load(files, p.statsManager, rlSettings.MergeDomainConfigurations, p.descriptorKeyConfig) p.configUpdateEventChan <- &ConfigUpdateEventImpl{config: newConfig} } @@ -111,6 +112,7 @@ func NewFileProvider(settings settings.Settings, statsManager stats.Manager, roo runtimeWatchRoot: settings.RuntimeWatchRoot, rootStore: rootStore, statsManager: statsManager, + descriptorKeyConfig: loadDescriptorKeyConfigOrPanic(settings.DescriptorKeyConfigPath), } p.setupRuntime() go p.watch() diff --git a/src/provider/xds_grpc_sotw_provider.go b/src/provider/xds_grpc_sotw_provider.go index 4a5d0621e..de6aca176 100644 --- a/src/provider/xds_grpc_sotw_provider.go +++ b/src/provider/xds_grpc_sotw_provider.go @@ -38,6 +38,7 @@ type XdsGrpcSotwProvider struct { adsClient sotw.ADSClient // connectionRetryChannel is the channel which trigger true for connection issues connectionRetryChannel chan bool + descriptorKeyConfig *config.DescriptorKeyConfig } // NewXdsGrpcSotwProvider initializes xDS listener and returns the xDS provider. @@ -51,6 +52,7 @@ func NewXdsGrpcSotwProvider(settings settings.Settings, statsManager stats.Manag connectionRetryChannel: make(chan bool), loader: config.NewRateLimitConfigLoaderImpl(), adsClient: sotw.NewADSClient(ctx, getClientNode(settings), resource.RateLimitConfigType), + descriptorKeyConfig: loadDescriptorKeyConfigOrPanic(settings.DescriptorKeyConfigPath), } go p.initXdsClient() return p @@ -180,7 +182,7 @@ func (p *XdsGrpcSotwProvider) sendConfigs(resources []*anypb.Any) { conf = append(conf, config.RateLimitConfigToLoad{Name: confPb.Name, ConfigYaml: configYaml}) } rlSettings := settings.NewSettings() - rlsConf := p.loader.Load(conf, p.statsManager, rlSettings.MergeDomainConfigurations) + rlsConf := p.loader.Load(conf, p.statsManager, rlSettings.MergeDomainConfigurations, p.descriptorKeyConfig) p.configUpdateEventChan <- &ConfigUpdateEventImpl{config: rlsConf} p.adsClient.Ack() } diff --git a/src/settings/settings.go b/src/settings/settings.go index 83d657764..dacc08a51 100644 --- a/src/settings/settings.go +++ b/src/settings/settings.go @@ -97,11 +97,12 @@ type Settings struct { PrometheusResponseTimeAsMilliseconds bool `envconfig:"PROMETHEUS_RESPONSE_TIME_AS_MILLISECONDS" default:"false"` // Settings for rate limit configuration - RuntimePath string `envconfig:"RUNTIME_ROOT" default:"/srv/runtime_data/current"` - RuntimeSubdirectory string `envconfig:"RUNTIME_SUBDIRECTORY"` - RuntimeAppDirectory string `envconfig:"RUNTIME_APPDIRECTORY" default:"config"` - RuntimeIgnoreDotFiles bool `envconfig:"RUNTIME_IGNOREDOTFILES" default:"false"` - RuntimeWatchRoot bool `envconfig:"RUNTIME_WATCH_ROOT" default:"true"` + DescriptorKeyConfigPath string `envconfig:"DESCRIPTOR_KEY_CONFIG" default:""` + RuntimePath string `envconfig:"RUNTIME_ROOT" default:"/srv/runtime_data/current"` + RuntimeSubdirectory string `envconfig:"RUNTIME_SUBDIRECTORY"` + RuntimeAppDirectory string `envconfig:"RUNTIME_APPDIRECTORY" default:"config"` + RuntimeIgnoreDotFiles bool `envconfig:"RUNTIME_IGNOREDOTFILES" default:"false"` + RuntimeWatchRoot bool `envconfig:"RUNTIME_WATCH_ROOT" default:"true"` // Settings for all cache types ExpirationJitterMaxSeconds int64 `envconfig:"EXPIRATION_JITTER_MAX_SECONDS" default:"300"` diff --git a/test/config/config_test.go b/test/config/config_test.go index 2bd009a99..c752bd373 100644 --- a/test/config/config_test.go +++ b/test/config/config_test.go @@ -30,7 +30,7 @@ func loadFile(path string) []config.RateLimitConfigToLoad { func TestBasicConfig(t *testing.T) { assert := assert.New(t) stats := stats.NewStore(stats.NewNullSink(), false) - rlConfig := config.NewRateLimitConfigImpl(loadFile("basic_config.yaml"), mockstats.NewMockStatManager(stats), false) + rlConfig := config.NewRateLimitConfigImpl(loadFile("basic_config.yaml"), mockstats.NewMockStatManager(stats), false, nil) rlConfig.Dump() assert.Equal(rlConfig.IsEmptyDomains(), false) assert.EqualValues(0, stats.NewCounter("foo_domain.domain_not_found").Value()) @@ -231,7 +231,7 @@ func TestDomainMerge(t *testing.T) { files := loadFile("merge_domain_key1.yaml") files = append(files, loadFile("merge_domain_key2.yaml")...) - rlConfig := config.NewRateLimitConfigImpl(files, mockstats.NewMockStatManager(stats), true) + rlConfig := config.NewRateLimitConfigImpl(files, mockstats.NewMockStatManager(stats), true, nil) rlConfig.Dump() assert.Nil(rlConfig.GetLimit(context.TODO(), "foo_domain", &pb_struct.RateLimitDescriptor{})) assert.Nil(rlConfig.GetLimit(context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{})) @@ -256,7 +256,7 @@ func TestDomainMerge(t *testing.T) { func TestConfigLimitOverride(t *testing.T) { assert := assert.New(t) stats := stats.NewStore(stats.NewNullSink(), false) - rlConfig := config.NewRateLimitConfigImpl(loadFile("basic_config.yaml"), mockstats.NewMockStatManager(stats), false) + rlConfig := config.NewRateLimitConfigImpl(loadFile("basic_config.yaml"), mockstats.NewMockStatManager(stats), false, nil) rlConfig.Dump() // No matching domain assert.Nil(rlConfig.GetLimit(context.TODO(), "foo_domain", &pb_struct.RateLimitDescriptor{ @@ -349,7 +349,7 @@ func TestEmptyDomain(t *testing.T) { t, func() { config.NewRateLimitConfigImpl( - loadFile("empty_domain.yaml"), mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + loadFile("empty_domain.yaml"), mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, nil) }, "empty_domain.yaml: config file cannot have empty domain") } @@ -360,7 +360,7 @@ func TestDuplicateDomain(t *testing.T) { func() { files := loadFile("basic_config.yaml") files = append(files, loadFile("duplicate_domain.yaml")...) - config.NewRateLimitConfigImpl(files, mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + config.NewRateLimitConfigImpl(files, mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, nil) }, "duplicate_domain.yaml: duplicate domain 'test-domain' in config file") } @@ -371,7 +371,7 @@ func TestEmptyKey(t *testing.T) { func() { config.NewRateLimitConfigImpl( loadFile("empty_key.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, nil) }, "empty_key.yaml: descriptor has empty key") } @@ -382,7 +382,7 @@ func TestDuplicateKey(t *testing.T) { func() { config.NewRateLimitConfigImpl( loadFile("duplicate_key.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, nil) }, "duplicate_key.yaml: duplicate descriptor composite key 'test-domain.key1_value1'") } @@ -395,7 +395,7 @@ func TestDuplicateKeyDomainMerge(t *testing.T) { files = append(files, loadFile("merge_domain_key1.yaml")...) config.NewRateLimitConfigImpl( files, - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), true) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), true, nil) }, "merge_domain_key1.yaml: duplicate descriptor composite key 'test-domain.key1_value1'") } @@ -406,7 +406,7 @@ func TestBadLimitUnit(t *testing.T) { func() { config.NewRateLimitConfigImpl( loadFile("bad_limit_unit.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, nil) }, "bad_limit_unit.yaml: invalid rate limit unit 'foo'") } @@ -417,7 +417,7 @@ func TestReplacesSelf(t *testing.T) { func() { config.NewRateLimitConfigImpl( loadFile("replaces_self.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, nil) }, "replaces_self.yaml: replaces should not contain name of same descriptor") } @@ -428,7 +428,7 @@ func TestReplacesEmpty(t *testing.T) { func() { config.NewRateLimitConfigImpl( loadFile("replaces_empty.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, nil) }, "replaces_empty.yaml: should not have an empty replaces entry") } @@ -439,7 +439,7 @@ func TestBadYaml(t *testing.T) { func() { config.NewRateLimitConfigImpl( loadFile("bad_yaml.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, nil) }, "bad_yaml.yaml: error loading config file: yaml: line 2: found unexpected end of stream") } @@ -450,7 +450,7 @@ func TestMisspelledKey(t *testing.T) { func() { config.NewRateLimitConfigImpl( loadFile("misspelled_key.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, nil) }, "misspelled_key.yaml: config error, unknown key 'ratelimit'") @@ -459,7 +459,7 @@ func TestMisspelledKey(t *testing.T) { func() { config.NewRateLimitConfigImpl( loadFile("misspelled_key2.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, nil) }, "misspelled_key2.yaml: config error, unknown key 'requestsperunit'") } @@ -470,7 +470,7 @@ func TestNonStringKey(t *testing.T) { func() { config.NewRateLimitConfigImpl( loadFile("non_string_key.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, nil) }, "non_string_key.yaml: config error, key is not of type string: 0.25") } @@ -481,7 +481,7 @@ func TestNonMapList(t *testing.T) { func() { config.NewRateLimitConfigImpl( loadFile("non_map_list.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, nil) }, "non_map_list.yaml: config error, yaml file contains list of type other than map: a") } @@ -492,7 +492,7 @@ func TestUnlimitedWithRateLimitUnit(t *testing.T) { func() { config.NewRateLimitConfigImpl( loadFile("unlimited_with_unit.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, nil) }, "unlimited_with_unit.yaml: should not specify rate limit unit when unlimited") } @@ -501,7 +501,7 @@ func TestShadowModeConfig(t *testing.T) { assert := assert.New(t) stats := stats.NewStore(stats.NewNullSink(), false) - rlConfig := config.NewRateLimitConfigImpl(loadFile("shadowmode_config.yaml"), mockstats.NewMockStatManager(stats), false) + rlConfig := config.NewRateLimitConfigImpl(loadFile("shadowmode_config.yaml"), mockstats.NewMockStatManager(stats), false, nil) rlConfig.Dump() rl := rlConfig.GetLimit( @@ -576,7 +576,7 @@ func TestShadowModeConfig(t *testing.T) { func TestWildcardConfig(t *testing.T) { assert := assert.New(t) stats := stats.NewStore(stats.NewNullSink(), false) - rlConfig := config.NewRateLimitConfigImpl(loadFile("wildcard.yaml"), mockstats.NewMockStatManager(stats), false) + rlConfig := config.NewRateLimitConfigImpl(loadFile("wildcard.yaml"), mockstats.NewMockStatManager(stats), false, nil) rlConfig.Dump() // Baseline to show wildcard works like no value @@ -1011,7 +1011,7 @@ func TestDetailedMetric(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - rlConfig := config.NewRateLimitConfigImpl(tt.config, mockstats.NewMockStatManager(stats), true) + rlConfig := config.NewRateLimitConfigImpl(tt.config, mockstats.NewMockStatManager(stats), true, nil) rlConfig.Dump() rl := rlConfig.GetLimit( @@ -1058,7 +1058,7 @@ func TestValueToMetric_UsesRuntimeValuesInStats(t *testing.T) { }, } - rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false, nil) rl := rlConfig.GetLimit( context.TODO(), "domain", @@ -1112,7 +1112,7 @@ func TestValueToMetric_DefaultKeyIncludesValueAtThatLevel(t *testing.T) { }, } - rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false, nil) rl := rlConfig.GetLimit( context.TODO(), "d", &pb_struct.RateLimitDescriptor{Entries: []*pb_struct.RateLimitDescriptor_Entry{ @@ -1154,7 +1154,7 @@ func TestValueToMetric_MidLevelOnly(t *testing.T) { }, } - rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false, nil) rl := rlConfig.GetLimit( context.TODO(), "d", &pb_struct.RateLimitDescriptor{Entries: []*pb_struct.RateLimitDescriptor_Entry{ @@ -1192,7 +1192,7 @@ func TestValueToMetric_NoFlag_Unchanged(t *testing.T) { }, } - rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false, nil) rl := rlConfig.GetLimit( context.TODO(), "d", &pb_struct.RateLimitDescriptor{Entries: []*pb_struct.RateLimitDescriptor_Entry{ @@ -1240,7 +1240,7 @@ func TestValueToMetric_DoesNotOverrideDetailedMetric(t *testing.T) { }, } - rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false, nil) rl := rlConfig.GetLimit( context.TODO(), "domain", @@ -1311,7 +1311,7 @@ func TestValueToMetric_WithConfiguredValues(t *testing.T) { }, } - rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false, nil) // Test GET path - should include runtime value for route, but use configured value for http_method rl := rlConfig.GetLimit( @@ -1386,7 +1386,7 @@ func TestValueToMetric_WithWildcard(t *testing.T) { }, } - rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false, nil) // Test wildcard matching with value_to_metric - should include full runtime value rl := rlConfig.GetLimit( @@ -1459,7 +1459,7 @@ func TestValueToMetric_WithEmptyValue(t *testing.T) { }, } - rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false, nil) // Test with empty value for route - should not include underscore and empty value rl := rlConfig.GetLimit( @@ -1557,7 +1557,7 @@ func TestValueToMetric_FullKeyMatchesStatsKey(t *testing.T) { }, } - rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false, nil) // Test case 1: value_to_metric enabled - FullKey should match Stats.Key rl := rlConfig.GetLimit( @@ -1605,7 +1605,7 @@ func TestValueToMetric_FullKeyMatchesStatsKey(t *testing.T) { }, } - rlConfig2 := config.NewRateLimitConfigImpl(cfgNoValueToMetric, mockstats.NewMockStatManager(store), false) + rlConfig2 := config.NewRateLimitConfigImpl(cfgNoValueToMetric, mockstats.NewMockStatManager(store), false, nil) rl2 := rlConfig2.GetLimit( context.TODO(), "test-domain-2", &pb_struct.RateLimitDescriptor{ @@ -1649,7 +1649,7 @@ func TestValueToMetric_FullKeyMatchesStatsKey(t *testing.T) { }, } - rlConfig3 := config.NewRateLimitConfigImpl(cfgDetailedMetric, mockstats.NewMockStatManager(store), false) + rlConfig3 := config.NewRateLimitConfigImpl(cfgDetailedMetric, mockstats.NewMockStatManager(store), false, nil) rl3 := rlConfig3.GetLimit( context.TODO(), "test-domain-3", &pb_struct.RateLimitDescriptor{ @@ -1669,7 +1669,7 @@ func TestValueToMetric_FullKeyMatchesStatsKey(t *testing.T) { func TestShareThreshold(t *testing.T) { asrt := assert.New(t) stats := stats.NewStore(stats.NewNullSink(), false) - rlConfig := config.NewRateLimitConfigImpl(loadFile("share_threshold.yaml"), mockstats.NewMockStatManager(stats), false) + rlConfig := config.NewRateLimitConfigImpl(loadFile("share_threshold.yaml"), mockstats.NewMockStatManager(stats), false, nil) // Test Case 1: Basic share_threshold functionality t.Run("Basic share_threshold", func(t *testing.T) { @@ -2008,7 +2008,7 @@ func TestWildcardStatsBehavior(t *testing.T) { }, } - rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false, nil) rl := rlConfig.GetLimit(context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "wild", Value: "foo1"}}, @@ -2039,7 +2039,7 @@ func TestWildcardStatsBehavior(t *testing.T) { }, } - rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false, nil) rl := rlConfig.GetLimit(context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "wild", Value: "foo1"}}, @@ -2069,7 +2069,7 @@ func TestWildcardStatsBehavior(t *testing.T) { }, } - rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false, nil) rl := rlConfig.GetLimit(context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "wild", Value: "foo1"}}, @@ -2101,7 +2101,7 @@ func TestWildcardStatsBehavior(t *testing.T) { }, } - rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false, nil) rl := rlConfig.GetLimit(context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "wild", Value: "foo1"}}, @@ -2139,7 +2139,7 @@ func TestWildcardStatsBehavior(t *testing.T) { }, } - rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false, nil) rl := rlConfig.GetLimit( context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ @@ -2170,7 +2170,7 @@ func TestWildcardStatsBehavior(t *testing.T) { }, }, }} - rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false, nil) rl1 := rlConfig.GetLimit(context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "wild", Value: "foo123bar456"}}, @@ -2196,7 +2196,7 @@ func TestWildcardStatsBehavior(t *testing.T) { }, }, }} - rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false, nil) rl := rlConfig.GetLimit(context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "wild", Value: "foo123bar456"}}, @@ -2209,7 +2209,7 @@ func TestWildcardStatsBehavior(t *testing.T) { func TestMetadata(t *testing.T) { assert := assert.New(t) stats := stats.NewStore(stats.NewNullSink(), false) - rlConfig := config.NewRateLimitConfigImpl(loadFile("metadata.yaml"), mockstats.NewMockStatManager(stats), false) + rlConfig := config.NewRateLimitConfigImpl(loadFile("metadata.yaml"), mockstats.NewMockStatManager(stats), false, nil) rlConfig.Dump() assert.Equal(rlConfig.IsEmptyDomains(), false) assert.Nil(rlConfig.GetLimit(context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{})) diff --git a/test/mocks/config/config.go b/test/mocks/config/config.go index 7875387cf..21a3b2d4b 100644 --- a/test/mocks/config/config.go +++ b/test/mocks/config/config.go @@ -104,15 +104,15 @@ func (m *MockRateLimitConfigLoader) EXPECT() *MockRateLimitConfigLoaderMockRecor } // Load mocks base method -func (m *MockRateLimitConfigLoader) Load(arg0 []config.RateLimitConfigToLoad, arg1 stats.Manager, arg2 bool) config.RateLimitConfig { +func (m *MockRateLimitConfigLoader) Load(arg0 []config.RateLimitConfigToLoad, arg1 stats.Manager, arg2 bool, arg3 *config.DescriptorKeyConfig) config.RateLimitConfig { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Load", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "Load", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(config.RateLimitConfig) return ret0 } // Load indicates an expected call of Load -func (mr *MockRateLimitConfigLoaderMockRecorder) Load(arg0, arg1, arg2 interface{}) *gomock.Call { +func (mr *MockRateLimitConfigLoaderMockRecorder) Load(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockRateLimitConfigLoader)(nil).Load), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockRateLimitConfigLoader)(nil).Load), arg0, arg1, arg2, arg3) } From 007c3749f8f826b13b4a4c2c8c86ef5c2c669e43 Mon Sep 17 00:00:00 2001 From: andrea liu Date: Wed, 3 Jun 2026 15:32:32 -0700 Subject: [PATCH 2/6] add tests Signed-off-by: andrea liu --- test/config/descriptor_key_config_test.go | 215 ++++++++++++++++++++++ test/config/multiple_1.yaml | 2 + test/config/multiple_2.yaml | 2 + test/config/multiple_3.yaml | 2 + test/config/multiple_default.yaml | 2 + 5 files changed, 223 insertions(+) create mode 100644 test/config/descriptor_key_config_test.go create mode 100644 test/config/multiple_1.yaml create mode 100644 test/config/multiple_2.yaml create mode 100644 test/config/multiple_3.yaml create mode 100644 test/config/multiple_default.yaml diff --git a/test/config/descriptor_key_config_test.go b/test/config/descriptor_key_config_test.go new file mode 100644 index 000000000..3b2fb480e --- /dev/null +++ b/test/config/descriptor_key_config_test.go @@ -0,0 +1,215 @@ +package config_test + +import ( + "context" + "testing" + + pb_struct "github.com/envoyproxy/go-control-plane/envoy/extensions/common/ratelimit/v3" + pb_type "github.com/envoyproxy/go-control-plane/envoy/type/v3" + gostats "github.com/lyft/gostats" + "github.com/stretchr/testify/assert" + + "github.com/envoyproxy/ratelimit/src/config" + mockstats "github.com/envoyproxy/ratelimit/test/mocks/stats" +) + +func TestDescriptorKeyConfigSelectiveValues(t *testing.T) { + assert := assert.New(t) + yaml := []byte(` +default: + include_entry_value_for_keys: [] +domains: + test-domain: + include_entry_value_for_keys: + - subkey1 +`) + descriptorKeyConfig, err := config.ParseDescriptorKeyConfig(yaml) + assert.NoError(err) + + statsStore := gostats.NewStore(gostats.NewNullSink(), false) + rlConfig := config.NewRateLimitConfigImpl( + loadFile("basic_config.yaml"), + mockstats.NewMockStatManager(statsStore), + false, + descriptorKeyConfig, + ) + + rl := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "key1", Value: "value1"}, + {Key: "subkey1", Value: "something"}, + }, + Limit: &pb_struct.RateLimitDescriptor_RateLimitOverride{ + RequestsPerUnit: 10, Unit: pb_type.RateLimitUnit_DAY, + }, + }) + assert.NotNil(rl) + assert.Equal("test-domain.key1.subkey1_something", rl.FullKey) +} + +func TestDescriptorKeyConfigDefault(t *testing.T) { + cfg, err := config.ParseDescriptorKeyConfig([]byte(` +default: + include_entry_value_for_keys: + - foo +`)) + assert.NoError(t, err) + assert.True(t, cfg.IncludeEntryValueForKey("unknown-domain", "foo")) + assert.False(t, cfg.IncludeEntryValueForKey("unknown-domain", "bar")) +} + +func TestDescriptorKeyConfigMultipleDomains(t *testing.T) { + assert := assert.New(t) + yaml := []byte(` +default: + include_entry_value_for_keys: [default_key] +domains: + test-domain-1: + include_entry_value_for_keys: + - subkey1 + test-domain-2: + include_entry_value_for_keys: + - subkey2 + test-domain-3: + include_entry_value_for_keys: + - subkey3 +`) + descriptorKeyConfig, err := config.ParseDescriptorKeyConfig(yaml) + assert.NoError(err) + + files := loadFile("multiple_default.yaml") + files = append(files, loadFile("multiple_1.yaml")...) + files = append(files, loadFile("multiple_2.yaml")...) + files = append(files, loadFile("multiple_3.yaml")...) + + statsStore := gostats.NewStore(gostats.NewNullSink(), false) + rlConfig := config.NewRateLimitConfigImpl( + files, + mockstats.NewMockStatManager(statsStore), + false, + descriptorKeyConfig, + ) + + default_key := "default_key" + subkey1 := "subkey1" + subkey2 := "subkey2" + subkey3 := "subkey3" + + default_value := "default_value" + value1 := "value1" + value2 := "value2" + value3 := "value3" + + rl := rlConfig.GetLimit( + context.TODO(), "default", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: default_key, Value: default_value}, + {Key: subkey1, Value: value1}, + {Key: subkey2, Value: value2}, + {Key: subkey3, Value: value3}, + }, + Limit: &pb_struct.RateLimitDescriptor_RateLimitOverride{ + RequestsPerUnit: 10, Unit: pb_type.RateLimitUnit_DAY, + }, + }) + assert.NotNil(rl) + assert.Equal("default."+default_key+"_"+default_value+"."+subkey1+"."+subkey2+"."+subkey3, rl.FullKey) + + rl = rlConfig.GetLimit( + context.TODO(), "test-domain-1", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: default_key, Value: default_value}, + {Key: subkey1, Value: value1}, + {Key: subkey2, Value: value2}, + {Key: subkey3, Value: value3}, + }, + Limit: &pb_struct.RateLimitDescriptor_RateLimitOverride{ + RequestsPerUnit: 10, Unit: pb_type.RateLimitUnit_DAY, + }, + }) + assert.NotNil(rl) + assert.Equal("test-domain-1."+default_key+"."+subkey1+"_"+value1+"."+subkey2+"."+subkey3, rl.FullKey) + + rl = rlConfig.GetLimit( + context.TODO(), "test-domain-2", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: default_key, Value: default_value}, + {Key: subkey1, Value: value1}, + {Key: subkey2, Value: value2}, + {Key: subkey3, Value: value3}, + }, + Limit: &pb_struct.RateLimitDescriptor_RateLimitOverride{ + RequestsPerUnit: 10, Unit: pb_type.RateLimitUnit_DAY, + }, + }) + assert.NotNil(rl) + assert.Equal("test-domain-2."+default_key+"."+subkey1+"."+subkey2+"_"+value2+"."+subkey3, rl.FullKey) + + rl = rlConfig.GetLimit( + context.TODO(), "test-domain-3", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: default_key, Value: default_value}, + {Key: subkey1, Value: value1}, + {Key: subkey2, Value: value2}, + {Key: subkey3, Value: value3}, + }, + Limit: &pb_struct.RateLimitDescriptor_RateLimitOverride{ + RequestsPerUnit: 10, Unit: pb_type.RateLimitUnit_DAY, + }, + }) + assert.NotNil(rl) + assert.Equal("test-domain-3."+default_key+"."+subkey1+"."+subkey2+"."+subkey3+"_"+value3, rl.FullKey) +} + +func TestDescriptorKeyConfigMultipleEntries(t *testing.T) { + assert := assert.New(t) + yaml := []byte(` +default: + include_entry_value_for_keys: [] +domains: + test-domain: + include_entry_value_for_keys: + - subkey1 + - subkey2 + - subkey3 +`) + descriptorKeyConfig, err := config.ParseDescriptorKeyConfig(yaml) + assert.NoError(err) + + statsStore := gostats.NewStore(gostats.NewNullSink(), false) + rlConfig := config.NewRateLimitConfigImpl( + loadFile("basic_config.yaml"), + mockstats.NewMockStatManager(statsStore), + false, + descriptorKeyConfig, + ) + + subkey1 := "subkey1" + subkey2 := "subkey2" + subkey4 := "subkey4" + + value1 := "value1" + value2 := "value2" + value4 := "value4" + + rl := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: subkey1, Value: value1}, + {Key: subkey4, Value: value4}, + {Key: subkey2, Value: value2}, + }, + Limit: &pb_struct.RateLimitDescriptor_RateLimitOverride{ + RequestsPerUnit: 10, Unit: pb_type.RateLimitUnit_DAY, + }, + }) + assert.NotNil(rl) + assert.Equal("test-domain."+subkey1+"_"+value1+"."+subkey4+"."+subkey2+"_"+value2, rl.FullKey) +} diff --git a/test/config/multiple_1.yaml b/test/config/multiple_1.yaml new file mode 100644 index 000000000..63f0f3aa8 --- /dev/null +++ b/test/config/multiple_1.yaml @@ -0,0 +1,2 @@ +domain: test-domain-1 +descriptors: diff --git a/test/config/multiple_2.yaml b/test/config/multiple_2.yaml new file mode 100644 index 000000000..9e58832b4 --- /dev/null +++ b/test/config/multiple_2.yaml @@ -0,0 +1,2 @@ +domain: test-domain-2 +descriptors: diff --git a/test/config/multiple_3.yaml b/test/config/multiple_3.yaml new file mode 100644 index 000000000..40b5b9ac4 --- /dev/null +++ b/test/config/multiple_3.yaml @@ -0,0 +1,2 @@ +domain: test-domain-3 +descriptors: diff --git a/test/config/multiple_default.yaml b/test/config/multiple_default.yaml new file mode 100644 index 000000000..a30c0c557 --- /dev/null +++ b/test/config/multiple_default.yaml @@ -0,0 +1,2 @@ +domain: default +descriptors: From 1117d0e85a4df804e950a6276967d2a3fd1b6c4a Mon Sep 17 00:00:00 2001 From: andrea liu Date: Wed, 3 Jun 2026 17:02:16 -0700 Subject: [PATCH 3/6] add to README and example config file Signed-off-by: andrea liu --- README.md | 42 ++++++++++++++++++++++++++ examples/ratelimit/descriptor_key.yaml | 17 +++++++++++ 2 files changed, 59 insertions(+) create mode 100644 examples/ratelimit/descriptor_key.yaml diff --git a/README.md b/README.md index 6cf2e51c2..5452e9e3a 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ - [ShadowMode](#shadowmode) - [Including detailed metrics for unspecified values](#including-detailed-metrics-for-unspecified-values) - [Including descriptor values in metrics](#including-descriptor-values-in-metrics) + - [Limit override descriptor key config](#limit-override-descriptor-key-config) - [Sharing thresholds for wildcard matches](#sharing-thresholds-for-wildcard-matches) - [Examples](#examples) - [Example 1](#example-1) @@ -353,6 +354,46 @@ Setting `value_to_metric: true` (default: `false`) for a descriptor will include When combined with wildcard matching, the full runtime value is included in the metric key, not just the wildcard prefix. This feature works independently of `detailed_metric` - when `detailed_metric` is set, it takes precedence and `value_to_metric` is ignored. +### Limit override descriptor key config + +When Envoy sends a [rate limit override](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/rate_limit_filter#config-http-filters-rate-limit-override) on a descriptor (`limit` set in the request), the service builds the stats key from the descriptor entries in the request. By default, every non-empty entry value is appended to the key (legacy behavior), which can produce high metric cardinality when many descriptor entries are present. + +`value_to_metric` and `detailed_metric` apply to limits matched from the rate limit **configuration**. They do not control stats keys for Envoy **overrides**. + +To selectively include entry values in override stats keys, set `DESCRIPTOR_KEY_CONFIG` to the path of a YAML file (see [`examples/ratelimit/descriptor_key.yaml`](examples/ratelimit/descriptor_key.yaml)). The file is loaded once when the configuration provider starts (file or xDS). Changing the file requires a process restart. + +```yaml +default: + include_entry_value_for_keys: [] + +domains: + example: + include_entry_value_for_keys: + - key1 + - key2 +``` + +- **`default`**: used for any domain not listed under `domains`. +- **`domains`**: per-domain allowlists of entry **keys** whose runtime **values** are appended as `_` in the stats key. +- Unlisted entry keys contribute only the key name (no value suffix). + +If `DESCRIPTOR_KEY_CONFIG` is unset, all non-empty entry values are included (backward compatible). + +**Example:** for domain `example`, descriptor entries `key1=value1`, `key2=value2`, `key3=value3`, and the YAML above: + +- Default (no config): `example.key1_value1.key2_value2.key3_value3` +- With config: `example.key1_value1.key2_value2.key3` + +This affects **stats keys only** for override limits. Redis cache keys are unchanged. Limits matched from the rate limit configuration file continue to use the existing stats key rules (`detailed_metric`, `value_to_metric`, etc.). + +The configuration checker accepts the same file: + +```bash +ratelimit_config_check \ + -config_dir=examples/ratelimit/config \ + -descriptor_key_config=examples/ratelimit/descriptor_key.yaml +``` + ### Sharing thresholds for wildcard matches Setting `share_threshold: true` (default: `false`) for a descriptor with a wildcard value (ending with `*`) allows all values matching that wildcard to share the same rate limit threshold, instead of using isolated thresholds for each matching value. @@ -1045,6 +1086,7 @@ ratelimit.service.rate_limit.messaging.auth-service.over_limit.shadow_mode: 1 ## Statistics options 1. `EXTRA_TAGS`: set to `","` to tag all emitted stats with the provided tags. You might want to tag build commit or release version, for example. +2. `DESCRIPTOR_KEY_CONFIG`: path to a YAML file that controls which descriptor entry values are included in stats keys for Envoy limit overrides. See [Limit override descriptor key config](#limit-override-descriptor-key-config). Loaded at startup; restart required to pick up changes. ## DogStatsD diff --git a/examples/ratelimit/descriptor_key.yaml b/examples/ratelimit/descriptor_key.yaml new file mode 100644 index 000000000..0e09f58b8 --- /dev/null +++ b/examples/ratelimit/descriptor_key.yaml @@ -0,0 +1,17 @@ +# Controls which descriptor entry keys include their runtime value in rate limit key +# for Envoy limit overrides (descriptorKey). +# +# When DESCRIPTOR_KEY_CONFIG points at this file: +# - Listed entries append "_" to the rate limit key segment. +# - Unlisted entries contribute only the entry name, reducing cardinality. +# +# Omit DESCRIPTOR_KEY_CONFIG to keep legacy behavior (all non-empty values included). + +default: + include_entry_value_for_keys: [] + +domains: + example: + include_entry_value_for_keys: + - key1 + - key2 From a297c9f82b739e5bc8a5c502ecada4716a5dfb90 Mon Sep 17 00:00:00 2001 From: andrea liu Date: Thu, 4 Jun 2026 11:42:29 -0700 Subject: [PATCH 4/6] clarify in comments that only stats key is affected Signed-off-by: andrea liu --- examples/ratelimit/descriptor_key.yaml | 5 +++-- src/config/descriptor_key_config.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/ratelimit/descriptor_key.yaml b/examples/ratelimit/descriptor_key.yaml index 0e09f58b8..f6cb3f146 100644 --- a/examples/ratelimit/descriptor_key.yaml +++ b/examples/ratelimit/descriptor_key.yaml @@ -1,5 +1,6 @@ -# Controls which descriptor entry keys include their runtime value in rate limit key -# for Envoy limit overrides (descriptorKey). +# Controls which descriptor entry keys include their runtime value in rate limit stats key +# for Envoy limit overrides (descriptorKey). This config file only affects metrics key, the +# cache key generation is unaffected. # # When DESCRIPTOR_KEY_CONFIG points at this file: # - Listed entries append "_" to the rate limit key segment. diff --git a/src/config/descriptor_key_config.go b/src/config/descriptor_key_config.go index ee81fa19a..cdc00fc9c 100644 --- a/src/config/descriptor_key_config.go +++ b/src/config/descriptor_key_config.go @@ -8,7 +8,7 @@ import ( ) // DescriptorKeyConfig controls which descriptor entries include their runtime -// value in rate limit keys built by descriptorKey for Envoy limit overrides. +// value in rate limit stats keys built by descriptorKey for Envoy limit overrides. type DescriptorKeyConfig struct { defaultKeys map[string]struct{} domainKeys map[string]map[string]struct{} From 656c0bfd86dab7a593810c45f31f527affc42fc2 Mon Sep 17 00:00:00 2001 From: andrea liu Date: Thu, 4 Jun 2026 11:45:38 -0700 Subject: [PATCH 5/6] update comment Signed-off-by: andrea liu --- examples/ratelimit/descriptor_key.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/ratelimit/descriptor_key.yaml b/examples/ratelimit/descriptor_key.yaml index f6cb3f146..4f0eb16b5 100644 --- a/examples/ratelimit/descriptor_key.yaml +++ b/examples/ratelimit/descriptor_key.yaml @@ -1,5 +1,5 @@ # Controls which descriptor entry keys include their runtime value in rate limit stats key -# for Envoy limit overrides (descriptorKey). This config file only affects metrics key, the +# for Envoy limit overrides (descriptorKey). This config file only affects the stats key, the # cache key generation is unaffected. # # When DESCRIPTOR_KEY_CONFIG points at this file: From e0933a0a4993dee9c20eef3b602494b6e3bce422 Mon Sep 17 00:00:00 2001 From: andrea liu Date: Thu, 4 Jun 2026 13:47:47 -0700 Subject: [PATCH 6/6] remove redundant nil check and add nil behavior test Signed-off-by: andrea liu --- src/config/config_impl.go | 2 +- test/config/descriptor_key_config_test.go | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/config/config_impl.go b/src/config/config_impl.go index 7d7dc8ffc..ef902841a 100644 --- a/src/config/config_impl.go +++ b/src/config/config_impl.go @@ -684,7 +684,7 @@ func descriptorKey(domain string, descriptor *pb_struct.RateLimitDescriptor, cfg rateLimitKey += "." } rateLimitKey += entry.Key - if entry.Value != "" && (cfg == nil || cfg.IncludeEntryValueForKey(domain, entry.Key)) { + if entry.Value != "" && cfg.IncludeEntryValueForKey(domain, entry.Key) { rateLimitKey += "_" + entry.Value } } diff --git a/test/config/descriptor_key_config_test.go b/test/config/descriptor_key_config_test.go index 3b2fb480e..1d2f9c545 100644 --- a/test/config/descriptor_key_config_test.go +++ b/test/config/descriptor_key_config_test.go @@ -60,6 +60,12 @@ default: assert.False(t, cfg.IncludeEntryValueForKey("unknown-domain", "bar")) } +func TestDescriptorKeyConfigNilIncludesAllValues(t *testing.T) { + var cfg *config.DescriptorKeyConfig + cfg = nil + assert.True(t, cfg.IncludeEntryValueForKey("any-domain", "any-key")) +} + func TestDescriptorKeyConfigMultipleDomains(t *testing.T) { assert := assert.New(t) yaml := []byte(`