Add non-boolean feature flags support to the StaticProvider (#115085)

* initial commit

* add support of integerts

* finialise the static provider

* minor refactoring

* the rest

* revert:  the rest

* add new thiongs

* more tests added

* add ff parsing tests to check if types are handled correctly

* update tests according to recent changes

* address golint issues

* Update pkg/setting/setting_feature_toggles.go

Co-authored-by: Dave Henderson <dave.henderson@grafana.com>

* fix rebase issues

* addressing review comments

* add test cases for enterprise

* handle enterprise cases

* minor refactoring to make api a bit easier to debug

* make test names a bit more precise

* fix linter

* add openfeature sdk to goleak ignore in testutil

* Remove only boolean check in ff gen tests

* add non-boolean types top the doc in default.ini and doc string in FeatureFlag type

* apply remarks, add docs to sample.ini

* reflect changes in feature flags in the public grafana configuration doc

* fix doc formatting

* apply suggestions to the doc file

---------

Co-authored-by: Dave Henderson <dave.henderson@grafana.com>
This commit is contained in:
Denis Vodopianov
2026-01-12 22:53:23 +01:00
committed by GitHub
co-authored by Dave Henderson
parent 8c8efd2494
commit ce9ab6a89a
15 changed files with 330 additions and 76 deletions
+75 -5
View File
@@ -1,13 +1,20 @@
package setting
import (
"encoding/json"
"math"
"strconv"
"gopkg.in/ini.v1"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/grafana/grafana/pkg/util"
)
// DefaultVariantName a placeholder name for config-based Feature Flags
const DefaultVariantName = "default"
// Deprecated: should use `featuremgmt.FeatureToggles`
func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error {
section := iniFile.Section("feature_toggles")
@@ -15,18 +22,27 @@ func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error {
if err != nil {
return err
}
// TODO IsFeatureToggleEnabled has been deprecated for 2 years now, we should remove this function completely
// nolint:staticcheck
cfg.IsFeatureToggleEnabled = func(key string) bool { return toggles[key] }
cfg.IsFeatureToggleEnabled = func(key string) bool {
toggle, ok := toggles[key]
if !ok {
return false
}
value, ok := toggle.Variants[toggle.DefaultVariant].(bool)
return value && ok
}
return nil
}
func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]bool, error) {
featureToggles := make(map[string]bool, 10)
func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]memprovider.InMemoryFlag, error) {
featureToggles := make(map[string]memprovider.InMemoryFlag, 10)
// parse the comma separated list in `enable`.
featuresTogglesStr := valueAsString(featureTogglesSection, "enable", "")
for _, feature := range util.SplitString(featuresTogglesStr) {
featureToggles[feature] = true
featureToggles[feature] = memprovider.InMemoryFlag{Key: feature, DefaultVariant: DefaultVariantName, Variants: map[string]any{DefaultVariantName: true}}
}
// read all other settings under [feature_toggles]. If a toggle is
@@ -36,7 +52,7 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str
continue
}
b, err := strconv.ParseBool(v.Value())
b, err := ParseFlag(v.Name(), v.Value())
if err != nil {
return featureToggles, err
}
@@ -45,3 +61,57 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str
}
return featureToggles, nil
}
func ParseFlag(name, value string) (memprovider.InMemoryFlag, error) {
var structure map[string]any
if integer, err := strconv.Atoi(value); err == nil {
return NewInMemoryFlag(name, integer), nil
}
if float, err := strconv.ParseFloat(value, 64); err == nil {
return NewInMemoryFlag(name, float), nil
}
if err := json.Unmarshal([]byte(value), &structure); err == nil {
return NewInMemoryFlag(name, structure), nil
}
if boolean, err := strconv.ParseBool(value); err == nil {
return NewInMemoryFlag(name, boolean), nil
}
return NewInMemoryFlag(name, value), nil
}
func NewInMemoryFlag(name string, value any) memprovider.InMemoryFlag {
return memprovider.InMemoryFlag{Key: name, DefaultVariant: DefaultVariantName, Variants: map[string]any{DefaultVariantName: value}}
}
func AsStringMap(m map[string]memprovider.InMemoryFlag) map[string]string {
var res = map[string]string{}
for k, v := range m {
res[k] = serializeFlagValue(v)
}
return res
}
func serializeFlagValue(flag memprovider.InMemoryFlag) string {
value := flag.Variants[flag.DefaultVariant]
switch castedValue := value.(type) {
case bool:
return strconv.FormatBool(castedValue)
case int64:
return strconv.FormatInt(castedValue, 10)
case float64:
// handle cases with a single or no zeros after the decimal point
if math.Trunc(castedValue) == castedValue {
return strconv.FormatFloat(castedValue, 'f', 1, 64)
}
return strconv.FormatFloat(castedValue, 'g', -1, 64)
case string:
return castedValue
default:
val, _ := json.Marshal(value)
return string(val)
}
}
+54 -23
View File
@@ -1,9 +1,11 @@
package setting
import (
"strconv"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/ini.v1"
)
@@ -12,17 +14,16 @@ func TestFeatureToggles(t *testing.T) {
testCases := []struct {
name string
conf map[string]string
err error
expectedToggles map[string]bool
expectedToggles map[string]memprovider.InMemoryFlag
}{
{
name: "can parse feature toggles passed in the `enable` array",
conf: map[string]string{
"enable": "feature1,feature2",
},
expectedToggles: map[string]bool{
"feature1": true,
"feature2": true,
expectedToggles: map[string]memprovider.InMemoryFlag{
"feature1": NewInMemoryFlag("feature1", true),
"feature2": NewInMemoryFlag("feature2", true),
},
},
{
@@ -31,10 +32,10 @@ func TestFeatureToggles(t *testing.T) {
"enable": "feature1,feature2",
"feature3": "true",
},
expectedToggles: map[string]bool{
"feature1": true,
"feature2": true,
"feature3": true,
expectedToggles: map[string]memprovider.InMemoryFlag{
"feature1": NewInMemoryFlag("feature1", true),
"feature2": NewInMemoryFlag("feature2", true),
"feature3": NewInMemoryFlag("feature3", true),
},
},
{
@@ -43,19 +44,26 @@ func TestFeatureToggles(t *testing.T) {
"enable": "feature1,feature2",
"feature2": "false",
},
expectedToggles: map[string]bool{
"feature1": true,
"feature2": false,
expectedToggles: map[string]memprovider.InMemoryFlag{
"feature1": NewInMemoryFlag("feature1", true),
"feature2": NewInMemoryFlag("feature2", false),
},
},
{
name: "invalid boolean value should return syntax error",
name: "feature flags of different types are handled correctly",
conf: map[string]string{
"enable": "feature1,feature2",
"feature2": "invalid",
"feature1": "1", "feature2": "1.0",
"feature3": `{"foo":"bar"}`, "feature4": "bar",
"feature5": "t", "feature6": "T",
},
expectedToggles: map[string]memprovider.InMemoryFlag{
"feature1": NewInMemoryFlag("feature1", 1),
"feature2": NewInMemoryFlag("feature2", 1.0),
"feature3": NewInMemoryFlag("feature3", map[string]any{"foo": "bar"}),
"feature4": NewInMemoryFlag("feature4", "bar"),
"feature5": NewInMemoryFlag("feature5", true),
"feature6": NewInMemoryFlag("feature6", true),
},
expectedToggles: map[string]bool{},
err: strconv.ErrSyntax,
},
}
@@ -69,12 +77,35 @@ func TestFeatureToggles(t *testing.T) {
}
featureToggles, err := ReadFeatureTogglesFromInitFile(toggles)
require.ErrorIs(t, err, tc.err)
require.NoError(t, err)
if err == nil {
for k, v := range featureToggles {
require.Equal(t, tc.expectedToggles[k], v, tc.name)
}
for k, v := range featureToggles {
toggle := tc.expectedToggles[k]
require.Equal(t, toggle, v, tc.name)
}
}
}
func TestFlagValueSerialization(t *testing.T) {
testCases := []memprovider.InMemoryFlag{
NewInMemoryFlag("int", 1),
NewInMemoryFlag("1.0f", 1.0),
NewInMemoryFlag("1.01f", 1.01),
NewInMemoryFlag("1.10f", 1.10),
NewInMemoryFlag("struct", map[string]any{"foo": "bar"}),
NewInMemoryFlag("string", "bar"),
NewInMemoryFlag("true", true),
NewInMemoryFlag("false", false),
}
for _, tt := range testCases {
asStringMap := AsStringMap(map[string]memprovider.InMemoryFlag{tt.Key: tt})
deserialized, err := ParseFlag(tt.Key, asStringMap[tt.Key])
assert.NoError(t, err)
if diff := cmp.Diff(tt, deserialized); diff != "" {
t.Errorf("(-want, +got) = %v", diff)
}
}
}