diff --git a/pkg/services/featuremgmt/openfeature.go b/pkg/services/featuremgmt/openfeature.go index 234739ee59f..9b9556f3ad6 100644 --- a/pkg/services/featuremgmt/openfeature.go +++ b/pkg/services/featuremgmt/openfeature.go @@ -25,8 +25,8 @@ type OpenFeatureConfig struct { URL *url.URL // HTTPClient is a pre-configured HTTP client (optional, used for GOFF provider) HTTPClient *http.Client - // StaticFlags are the feature flags to use with static provider - StaticFlags map[string]bool + // TypedFlags are the feature flags to use with static provider + StaticFlags map[string]setting.TypedFeatureFlag // TargetingKey is used for evaluation context TargetingKey string // ContextAttrs are additional attributes for evaluation context @@ -60,7 +60,8 @@ func InitOpenFeature(config OpenFeatureConfig) error { // InitOpenFeatureWithCfg initializes OpenFeature from setting.Cfg func InitOpenFeatureWithCfg(cfg *setting.Cfg) error { - confFlags, err := setting.ReadFeatureTogglesFromInitFile(cfg.Raw.Section("feature_toggles")) + // Read typed flags from config + confFlags, err := setting.ReadTypedFeatureTogglesFromInitFile(cfg.Raw.Section("feature_toggles")) if err != nil { return fmt.Errorf("failed to read feature flags from config: %w", err) } @@ -96,7 +97,7 @@ func InitOpenFeatureWithCfg(cfg *setting.Cfg) error { func createProvider( providerType string, u *url.URL, - staticFlags map[string]bool, + staticFlags map[string]setting.TypedFeatureFlag, httpClient *http.Client, ) (openfeature.FeatureProvider, error) { if providerType != setting.GOFFProviderType { diff --git a/pkg/services/featuremgmt/static_evaluator.go b/pkg/services/featuremgmt/static_evaluator.go index c3d46837d28..73845b9ae3b 100644 --- a/pkg/services/featuremgmt/static_evaluator.go +++ b/pkg/services/featuremgmt/static_evaluator.go @@ -24,12 +24,12 @@ func CreateStaticEvaluator(cfg *setting.Cfg) (StaticFlagEvaluator, error) { return nil, fmt.Errorf("provider is not a static provider, type %s", setting.StaticProviderType) } - staticFlags, err := setting.ReadFeatureTogglesFromInitFile(cfg.Raw.Section("feature_toggles")) + typedFlags, err := setting.ReadTypedFeatureTogglesFromInitFile(cfg.Raw.Section("feature_toggles")) if err != nil { return nil, fmt.Errorf("failed to read feature flags from config: %w", err) } - staticProvider, err := newStaticProvider(staticFlags) + staticProvider, err := newStaticProvider(typedFlags) if err != nil { return nil, fmt.Errorf("failed to create static provider: %w", err) } @@ -56,19 +56,7 @@ type staticEvaluator struct { } func (s *staticEvaluator) EvalFlag(ctx context.Context, flagKey string) (goffmodel.OFREPEvaluateSuccessResponse, error) { - result, err := s.client.BooleanValueDetails(ctx, flagKey, false, openfeature.TransactionContext(ctx)) - if err != nil { - return goffmodel.OFREPEvaluateSuccessResponse{}, fmt.Errorf("failed to evaluate flag %s: %w", flagKey, err) - } - resp := goffmodel.OFREPEvaluateSuccessResponse{ - Key: flagKey, - Value: result.Value, - Reason: "static provider evaluation result", - Variant: result.Variant, - Metadata: result.FlagMetadata, - } - - return resp, nil + return s.evaluateFlagWithTypeDetection(ctx, flagKey) } func (s *staticEvaluator) EvalAllFlags(ctx context.Context) (goffmodel.OFREPBulkEvaluateSuccessResponse, error) { @@ -79,24 +67,70 @@ func (s *staticEvaluator) EvalAllFlags(ctx context.Context) (goffmodel.OFREPBulk allFlags := make([]goffmodel.OFREPFlagBulkEvaluateSuccessResponse, 0, len(flags)) for _, flagKey := range flags { - result, err := s.client.BooleanValueDetails(ctx, flagKey, false, openfeature.TransactionContext(ctx)) + result, err := s.evaluateFlagWithTypeDetection(ctx, flagKey) if err != nil { s.log.Error("failed to evaluate flag during bulk evaluation", "flagKey", flagKey, "error", err) continue } allFlags = append(allFlags, goffmodel.OFREPFlagBulkEvaluateSuccessResponse{ - OFREPEvaluateSuccessResponse: goffmodel.OFREPEvaluateSuccessResponse{ - Key: flagKey, - Value: result.Value, - Reason: "static provider evaluation result", - Variant: result.Variant, - Metadata: result.FlagMetadata, - }, - ErrorCode: string(result.ErrorCode), - ErrorDetails: result.ErrorMessage, + OFREPEvaluateSuccessResponse: result, }) } return goffmodel.OFREPBulkEvaluateSuccessResponse{Flags: allFlags}, nil } + +// evaluateFlagWithTypeDetection tries different flag types and returns the first successful evaluation result +func (s *staticEvaluator) evaluateFlagWithTypeDetection(ctx context.Context, flagKey string) (goffmodel.OFREPEvaluateSuccessResponse, error) { + // Try boolean evaluation first for backward compatibility + result, err := s.client.BooleanValueDetails(ctx, flagKey, false, openfeature.TransactionContext(ctx)) + if err == nil { + return goffmodel.OFREPEvaluateSuccessResponse{ + Key: flagKey, + Value: result.Value, + Reason: "static provider evaluation result", + Variant: result.Variant, + Metadata: result.FlagMetadata, + }, nil + } + + // If boolean evaluation fails, try other types + s.log.Debug("boolean evaluation failed, trying other types", "flagKey", flagKey, "error", err) + + // Try string evaluation + if stringResult, stringErr := s.client.StringValueDetails(ctx, flagKey, "", openfeature.TransactionContext(ctx)); stringErr == nil { + return goffmodel.OFREPEvaluateSuccessResponse{ + Key: flagKey, + Value: stringResult.Value, + Reason: "static provider evaluation result", + Variant: stringResult.Variant, + Metadata: stringResult.FlagMetadata, + }, nil + } + + // Try number evaluation + if numberResult, numberErr := s.client.FloatValueDetails(ctx, flagKey, 0.0, openfeature.TransactionContext(ctx)); numberErr == nil { + return goffmodel.OFREPEvaluateSuccessResponse{ + Key: flagKey, + Value: numberResult.Value, + Reason: "static provider evaluation result", + Variant: numberResult.Variant, + Metadata: numberResult.FlagMetadata, + }, nil + } + + // Try object evaluation + if objectResult, objectErr := s.client.ObjectValueDetails(ctx, flagKey, map[string]interface{}{}, openfeature.TransactionContext(ctx)); objectErr == nil { + return goffmodel.OFREPEvaluateSuccessResponse{ + Key: flagKey, + Value: objectResult.Value, + Reason: "static provider evaluation result", + Variant: objectResult.Variant, + Metadata: objectResult.FlagMetadata, + }, nil + } + + // If all evaluations fail, return the original boolean error + return goffmodel.OFREPEvaluateSuccessResponse{}, fmt.Errorf("failed to evaluate flag %s: %w", flagKey, err) +} diff --git a/pkg/services/featuremgmt/static_provider.go b/pkg/services/featuremgmt/static_provider.go index f384bd00de1..6d614e1453c 100644 --- a/pkg/services/featuremgmt/static_provider.go +++ b/pkg/services/featuremgmt/static_provider.go @@ -1,8 +1,13 @@ package featuremgmt import ( + "encoding/json" + "strconv" + "github.com/open-feature/go-sdk/openfeature" "github.com/open-feature/go-sdk/openfeature/memprovider" + + "github.com/grafana/grafana/pkg/setting" ) // inMemoryBulkProvider is a wrapper around memprovider.InMemoryProvider that @@ -28,28 +33,59 @@ func (p *inMemoryBulkProvider) ListFlags() ([]string, error) { return keys, nil } -func newStaticProvider(confFlags map[string]bool) (openfeature.FeatureProvider, error) { - flags := make(map[string]memprovider.InMemoryFlag, len(standardFeatureFlags)) +// newStaticProvider creates a provider with support for different flag types +func newStaticProvider(typedFlags map[string]setting.TypedFeatureFlag) (openfeature.FeatureProvider, error) { + flags := make(map[string]memprovider.InMemoryFlag, len(standardFeatureFlags)+len(typedFlags)) - // Add flags from config.ini file - for name, value := range confFlags { - flags[name] = createInMemoryFlag(name, value) + // Add standard flags first (these are always boolean) + for _, flag := range standardFeatureFlags { + enabled := flag.Expression == "true" + flags[flag.Name] = createBooleanFlag(flag.Name, enabled) } - // Add standard flags - for _, flag := range standardFeatureFlags { - if _, exists := flags[flag.Name]; !exists { - enabled := flag.Expression == "true" - flags[flag.Name] = createInMemoryFlag(flag.Name, enabled) - } + // Add typed flags from config (these can override standard flags) + for n, f := range typedFlags { + flags[n] = createTypedFlag(n, f.Type, f.Value) } return newInMemoryBulkProvider(flags), nil } -func createInMemoryFlag(name string, enabled bool) memprovider.InMemoryFlag { +type FlagType string + +const ( + FlagTypeBoolean FlagType = "boolean" + FlagTypeString FlagType = "string" + FlagTypeNumber FlagType = "number" // TODO: check in OFREP spec + FlagTypeObject FlagType = "object" +) + +// TypedFlag represents a flag with its type and value +type TypedFlag struct { + Name string + Type FlagType + Value interface{} +} + +func createTypedFlag(name, flagType string, value interface{}) memprovider.InMemoryFlag { + switch flagType { + case "boolean": + return createBooleanFlag(name, value.(bool)) + case "string": + return createStringFlag(name, value.(string)) + case "number": + return createNumberFlag(name, value.(float64)) + case "object": + return createObjectFlag(name, value.(map[string]interface{})) + default: + // Default to boolean for backward compatibility + return createBooleanFlag(name, false) + } +} + +func createBooleanFlag(name string, value bool) memprovider.InMemoryFlag { variant := "disabled" - if enabled { + if value { variant = "enabled" } @@ -62,3 +98,55 @@ func createInMemoryFlag(name string, enabled bool) memprovider.InMemoryFlag { }, } } + +func createStringFlag(name string, value string) memprovider.InMemoryFlag { + return memprovider.InMemoryFlag{ + Key: name, + DefaultVariant: "default", + Variants: map[string]interface{}{ + "default": value, + }, + } +} + +func createNumberFlag(name string, value float64) memprovider.InMemoryFlag { + return memprovider.InMemoryFlag{ + Key: name, + DefaultVariant: "default", + Variants: map[string]interface{}{ + "default": value, + }, + } +} + +func createObjectFlag(name string, value map[string]interface{}) memprovider.InMemoryFlag { + return memprovider.InMemoryFlag{ + Key: name, + DefaultVariant: "default", + Variants: map[string]interface{}{ + "default": value, + }, + } +} + +// parseTypedFlagValue attempts to parse a string value into the appropriate type +func parseTypedFlagValue(value string) (interface{}, FlagType, error) { + // Try to parse as boolean + if boolVal, err := strconv.ParseBool(value); err == nil { + return boolVal, FlagTypeBoolean, nil + } + + // Try to parse as number + if numVal, err := strconv.ParseFloat(value, 64); err == nil { + return numVal, FlagTypeNumber, nil + } + + // Try to parse as JSON object + var objVal map[string]interface{} + if err := json.Unmarshal([]byte(value), &objVal); err == nil { + return objVal, FlagTypeObject, nil + } + + // Default to string + return value, FlagTypeString, nil +} diff --git a/pkg/services/featuremgmt/static_provider_test.go b/pkg/services/featuremgmt/static_provider_test.go index 29610baa8c4..2337d9db151 100644 --- a/pkg/services/featuremgmt/static_provider_test.go +++ b/pkg/services/featuremgmt/static_provider_test.go @@ -22,7 +22,7 @@ func Test_StaticProvider(t *testing.T) { t.Run("empty config loads standard flags", func(t *testing.T) { setup(t, []byte(``)) // Check for one of the standard flags - feat, err := openfeature.NewDefaultClient().BooleanValueDetails(ctx, stFeatName, !stFeatValue, evalCtx) + feat, err := openfeature.NewClient("").BooleanValueDetails(ctx, stFeatName, !stFeatValue, evalCtx) assert.NoError(t, err) assert.True(t, stFeatValue == feat.Value) }) diff --git a/pkg/setting/setting_feature_toggles.go b/pkg/setting/setting_feature_toggles.go index e09b45e9edb..33e5ef8082b 100644 --- a/pkg/setting/setting_feature_toggles.go +++ b/pkg/setting/setting_feature_toggles.go @@ -1,6 +1,7 @@ package setting import ( + "encoding/json" "strconv" "gopkg.in/ini.v1" @@ -45,3 +46,78 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str } return featureToggles, nil } + +// TypedFeatureFlag represents a flag with its type and value +type TypedFeatureFlag struct { + Type string `json:"type"` + Value interface{} `json:"value"` +} + +// ReadTypedFeatureTogglesFromInitFile reads feature flags with support for different types +func ReadTypedFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]TypedFeatureFlag, error) { + typedFlags := make(map[string]TypedFeatureFlag, 10) + + // parse the comma separated list of values in `enable` key + featuresTogglesStr := valueAsString(featureTogglesSection, "enable", "") + for _, feature := range util.SplitString(featuresTogglesStr) { + typedFlags[feature] = TypedFeatureFlag{ + Type: "boolean", + Value: true, + } + } + + // read all the other keys under [feature_toggles] section + for _, v := range featureTogglesSection.Keys() { + if v.Name() == "enable" { + continue + } + + value := v.Value() + + // try to determine the type of flag value + flagType, parsedValue, err := parseTypedFlagValue(value) + if err != nil { + // upon failure, default to boolean for backward compatibility + if boolVal, boolErr := strconv.ParseBool(value); boolErr == nil { + typedFlags[v.Name()] = TypedFeatureFlag{ + Type: "boolean", + Value: boolVal, + } + } else { + // treat as string if even parsing as boolean fails + typedFlags[v.Name()] = TypedFeatureFlag{ + Type: "string", + Value: value, + } + } + } else { + typedFlags[v.Name()] = TypedFeatureFlag{ + Type: flagType, + Value: parsedValue, + } + } + } + return typedFlags, nil +} + +// parseTypedFlagValue attempts to parse a string value +// into the appropriate type - bool, float, object +// defaults to string +func parseTypedFlagValue(value string) (string, interface{}, error) { + if boolVal, err := strconv.ParseBool(value); err == nil { + return "boolean", boolVal, nil + } + + // TODO: probably int is needed as well + + if numVal, err := strconv.ParseFloat(value, 64); err == nil { + return "number", numVal, nil + } + + var objVal map[string]interface{} + if err := json.Unmarshal([]byte(value), &objVal); err == nil { + return "object", objVal, nil + } + + return "string", value, nil +}