diff --git a/core/pkg/eval/fractional_evaluation.go b/core/pkg/eval/fractional_evaluation.go index 7876abd1a..f18bbee3a 100644 --- a/core/pkg/eval/fractional_evaluation.go +++ b/core/pkg/eval/fractional_evaluation.go @@ -9,6 +9,8 @@ import ( "github.com/twmb/murmur3" ) +const FractionEvaluationName = "fractional" + type FractionalEvaluator struct { Logger *logger.Logger } diff --git a/core/pkg/eval/fractional_evaluation_test.go b/core/pkg/eval/fractional_evaluation_test.go index cc5d5dfbe..06aaaf8bb 100644 --- a/core/pkg/eval/fractional_evaluation_test.go +++ b/core/pkg/eval/fractional_evaluation_test.go @@ -28,7 +28,7 @@ func TestFractionalEvaluation(t *testing.T) { }] }, { - "fractionalEvaluation": [ + "fractional": [ {"var": "email"}, [ "red", @@ -123,7 +123,7 @@ func TestFractionalEvaluation(t *testing.T) { }] }, { - "fractionalEvaluation": [ + "fractional": [ {"var": "email"}, [ "red", @@ -176,7 +176,7 @@ func TestFractionalEvaluation(t *testing.T) { }] }, { - "fractionalEvaluation": [ + "fractional": [ "email", [ "red", @@ -218,7 +218,7 @@ func TestFractionalEvaluation(t *testing.T) { "yellow": "#FFFF00", }, Targeting: []byte(`{ - "fractionalEvaluation": [ + "fractional": [ {"var": "email"}, [ "red", @@ -260,7 +260,7 @@ func TestFractionalEvaluation(t *testing.T) { "yellow": "#FFFF00", }, Targeting: []byte(`{ - "fractionalEvaluation": [ + "fractional": [ {"var": "email"}, [ "black", @@ -292,7 +292,7 @@ func TestFractionalEvaluation(t *testing.T) { "yellow": "#FFFF00", }, Targeting: []byte(`{ - "fractionalEvaluation": [ + "fractional": [ {"var": "email"}, [ "red", @@ -328,7 +328,7 @@ func TestFractionalEvaluation(t *testing.T) { "yellow": "#FFFF00", }, Targeting: []byte(`{ - "fractionalEvaluation": [ + "fractional": [ [ "blue", 50 @@ -359,7 +359,7 @@ func TestFractionalEvaluation(t *testing.T) { log, store.NewFlags(), WithEvaluator( - "fractionalEvaluation", + FractionEvaluationName, NewFractionalEvaluator(log).FractionalEvaluation, ), ) @@ -406,7 +406,7 @@ func BenchmarkFractionalEvaluation(b *testing.B) { }] }, { - "fractionalEvaluation": [ + "fractional": [ "email", [ "red", @@ -490,7 +490,7 @@ func BenchmarkFractionalEvaluation(b *testing.B) { log, &store.Flags{Flags: tt.flags.Flags}, WithEvaluator( - "fractionalEvaluation", + FractionEvaluationName, NewFractionalEvaluator(log).FractionalEvaluation, ), ) diff --git a/core/pkg/eval/json_evaluator.go b/core/pkg/eval/json_evaluator.go index a0578fdc3..edf338bfc 100644 --- a/core/pkg/eval/json_evaluator.go +++ b/core/pkg/eval/json_evaluator.go @@ -331,7 +331,7 @@ func (je *JSONEvaluator) evaluateVariant(reqID string, flagKey string, context m } var result bytes.Buffer - // evaluate json-logic rules to determine the variant + // evaluate JsonLogic rules to determine the variant err = jsonlogic.Apply(bytes.NewReader(targetingBytes), bytes.NewReader(b), &result) if err != nil { je.Logger.ErrorWithID(reqID, fmt.Sprintf("error applying rules: %s", err)) diff --git a/core/pkg/eval/legacy_fractional_evaluation.go b/core/pkg/eval/legacy_fractional_evaluation.go new file mode 100644 index 000000000..836d287e0 --- /dev/null +++ b/core/pkg/eval/legacy_fractional_evaluation.go @@ -0,0 +1,145 @@ +// This evaluation type is deprecated and will be removed before v1. +// Do not enhance it or use it for reference. + +package eval + +import ( + "errors" + "fmt" + "math" + + "github.com/open-feature/flagd/core/pkg/logger" + "github.com/zeebo/xxh3" +) + +const ( + LegacyFractionEvaluationName = "fractionalEvaluation" + LegacyFractionEvaluationLink = "https://flagd.dev/concepts/#migrating-from-legacy-fractionalevaluation" +) + +// Deprecated: LegacyFractionalEvaluator is deprecated. This will be removed prior to v1 release. +type LegacyFractionalEvaluator struct { + Logger *logger.Logger +} + +type legacyFractionalEvaluationDistribution struct { + variant string + percentage int +} + +func NewLegacyFractionalEvaluator(logger *logger.Logger) *LegacyFractionalEvaluator { + return &LegacyFractionalEvaluator{Logger: logger} +} + +func (fe *LegacyFractionalEvaluator) LegacyFractionalEvaluation(values, data interface{}) interface{} { + fe.Logger.Warn( + fmt.Sprintf("%s is deprecated, please use %s, see: %s", + LegacyFractionEvaluationName, + FractionEvaluationName, + LegacyFractionEvaluationLink)) + + valueToDistribute, feDistributions, err := parseLegacyFractionalEvaluationData(values, data) + if err != nil { + fe.Logger.Error(fmt.Sprintf("parse fractional evaluation data: %v", err)) + return nil + } + + return distributeLegacyValue(valueToDistribute, feDistributions) +} + +func parseLegacyFractionalEvaluationData(values, data interface{}) (string, + []legacyFractionalEvaluationDistribution, error, +) { + valuesArray, ok := values.([]interface{}) + if !ok { + return "", nil, errors.New("fractional evaluation data is not an array") + } + if len(valuesArray) < 2 { + return "", nil, errors.New("fractional evaluation data has length under 2") + } + + bucketBy, ok := valuesArray[0].(string) + if !ok { + return "", nil, errors.New("first element of fractional evaluation data isn't of type string") + } + + dataMap, ok := data.(map[string]interface{}) + if !ok { + return "", nil, errors.New("data isn't of type map[string]interface{}") + } + + v, ok := dataMap[bucketBy] + if !ok { + return "", nil, nil + } + + valueToDistribute, ok := v.(string) + if !ok { + return "", nil, fmt.Errorf("var: %s isn't of type string", bucketBy) + } + + feDistributions, err := parseLegacyFractionalEvaluationDistributions(valuesArray) + if err != nil { + return "", nil, err + } + + return valueToDistribute, feDistributions, nil +} + +func parseLegacyFractionalEvaluationDistributions(values []interface{}) ( + []legacyFractionalEvaluationDistribution, error, +) { + sumOfPercentages := 0 + var feDistributions []legacyFractionalEvaluationDistribution + for i := 1; i < len(values); i++ { + distributionArray, ok := values[i].([]interface{}) + if !ok { + return nil, errors.New("distribution elements aren't of type []interface{}") + } + + if len(distributionArray) != 2 { + return nil, errors.New("distribution element isn't length 2") + } + + variant, ok := distributionArray[0].(string) + if !ok { + return nil, errors.New("first element of distribution element isn't string") + } + + percentage, ok := distributionArray[1].(float64) + if !ok { + return nil, errors.New("second element of distribution element isn't float") + } + + sumOfPercentages += int(percentage) + + feDistributions = append(feDistributions, legacyFractionalEvaluationDistribution{ + variant: variant, + percentage: int(percentage), + }) + } + + if sumOfPercentages != 100 { + return nil, fmt.Errorf("percentages must sum to 100, got: %d", sumOfPercentages) + } + + return feDistributions, nil +} + +func distributeLegacyValue(value string, feDistribution []legacyFractionalEvaluationDistribution) string { + hashValue := xxh3.HashString(value) + + hashRatio := float64(hashValue) / math.Pow(2, 64) // divide the hash value by the largest possible value, integer 2^64 + + bucket := int(hashRatio * 100) // integer in range [0, 99] + + rangeEnd := 0 + for _, dist := range feDistribution { + rangeEnd += dist.percentage + if bucket < rangeEnd { + return dist.variant + } + } + + return "" +} diff --git a/core/pkg/eval/legacy_fractional_evaluation_test.go b/core/pkg/eval/legacy_fractional_evaluation_test.go new file mode 100644 index 000000000..008aecd77 --- /dev/null +++ b/core/pkg/eval/legacy_fractional_evaluation_test.go @@ -0,0 +1,299 @@ +package eval + +import ( + "testing" + + "github.com/open-feature/flagd/core/pkg/logger" + "github.com/open-feature/flagd/core/pkg/model" + "github.com/open-feature/flagd/core/pkg/store" +) + +func TestLegacyFractionalEvaluation(t *testing.T) { + flags := Flags{ + Flags: map[string]model.Flag{ + "headerColor": { + State: "ENABLED", + DefaultVariant: "red", + Variants: map[string]any{ + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00", + "yellow": "#FFFF00", + }, + Targeting: []byte(`{ + "if": [ + { + "in": ["@faas.com", { + "var": ["email"] + }] + }, + { + "fractionalEvaluation": [ + "email", + [ + "red", + 25 + ], + [ + "blue", + 25 + ], + [ + "green", + 25 + ], + [ + "yellow", + 25 + ] + ] + }, null + ] + }`), + }, + }, + } + + tests := map[string]struct { + flags Flags + flagKey string + context map[string]any + expectedValue string + expectedVariant string + expectedReason string + expectedError error + }{ + "test@faas.com": { + flags: flags, + flagKey: "headerColor", + context: map[string]any{ + "email": "test@faas.com", + }, + expectedVariant: "red", + expectedValue: "#FF0000", + expectedReason: model.TargetingMatchReason, + }, + "test2@faas.com": { + flags: flags, + flagKey: "headerColor", + context: map[string]any{ + "email": "test2@faas.com", + }, + expectedVariant: "yellow", + expectedValue: "#FFFF00", + expectedReason: model.TargetingMatchReason, + }, + "test3@faas.com": { + flags: flags, + flagKey: "headerColor", + context: map[string]any{ + "email": "test3@faas.com", + }, + expectedVariant: "red", + expectedValue: "#FF0000", + expectedReason: model.TargetingMatchReason, + }, + "test4@faas.com": { + flags: flags, + flagKey: "headerColor", + context: map[string]any{ + "email": "test4@faas.com", + }, + expectedVariant: "blue", + expectedValue: "#0000FF", + expectedReason: model.TargetingMatchReason, + }, + "non even split": { + flags: Flags{ + Flags: map[string]model.Flag{ + "headerColor": { + State: "ENABLED", + DefaultVariant: "red", + Variants: map[string]any{ + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00", + "yellow": "#FFFF00", + }, + Targeting: []byte(`{ + "if": [ + { + "in": ["@faas.com", { + "var": ["email"] + }] + }, + { + "fractionalEvaluation": [ + "email", + [ + "red", + 50 + ], + [ + "blue", + 25 + ], + [ + "green", + 25 + ] + ] + }, null + ] + }`), + }, + }, + }, + flagKey: "headerColor", + context: map[string]any{ + "email": "test4@faas.com", + }, + expectedVariant: "red", + expectedValue: "#FF0000", + expectedReason: model.TargetingMatchReason, + }, + "fallback to default variant if no email provided": { + flags: Flags{ + Flags: map[string]model.Flag{ + "headerColor": { + State: "ENABLED", + DefaultVariant: "red", + Variants: map[string]any{ + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00", + "yellow": "#FFFF00", + }, + Targeting: []byte(`{ + "fractionalEvaluation": [ + "email", + [ + "red", + 25 + ], + [ + "blue", + 25 + ], + [ + "green", + 25 + ], + [ + "yellow", + 25 + ] + ] + }`), + }, + }, + }, + flagKey: "headerColor", + context: map[string]any{}, + expectedVariant: "red", + expectedValue: "#FF0000", + expectedReason: model.DefaultReason, + }, + "fallback to default variant if invalid variant as result of fractional evaluation": { + flags: Flags{ + Flags: map[string]model.Flag{ + "headerColor": { + State: "ENABLED", + DefaultVariant: "red", + Variants: map[string]any{ + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00", + "yellow": "#FFFF00", + }, + Targeting: []byte(`{ + "fractionalEvaluation": [ + "email", + [ + "black", + 100 + ] + ] + }`), + }, + }, + }, + flagKey: "headerColor", + context: map[string]any{ + "email": "foo@foo.com", + }, + expectedVariant: "red", + expectedValue: "#FF0000", + expectedReason: model.DefaultReason, + }, + "fallback to default variant if percentages don't sum to 100": { + flags: Flags{ + Flags: map[string]model.Flag{ + "headerColor": { + State: "ENABLED", + DefaultVariant: "red", + Variants: map[string]any{ + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00", + "yellow": "#FFFF00", + }, + Targeting: []byte(`{ + "fractionalEvaluation": [ + "email", + [ + "red", + 25 + ], + [ + "blue", + 25 + ] + ] + }`), + }, + }, + }, + flagKey: "headerColor", + context: map[string]any{ + "email": "foo@foo.com", + }, + expectedVariant: "red", + expectedValue: "#FF0000", + expectedReason: model.DefaultReason, + }, + } + const reqID = "default" + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + log := logger.NewLogger(nil, false) + je := NewJSONEvaluator( + log, + store.NewFlags(), + WithEvaluator( + "fractionalEvaluation", + NewLegacyFractionalEvaluator(log).LegacyFractionalEvaluation, + ), + ) + je.store.Flags = tt.flags.Flags + + value, variant, reason, _, err := resolve[string](reqID, tt.flagKey, tt.context, je.evaluateVariant) + + if value != tt.expectedValue { + t.Errorf("expected value '%s', got '%s'", tt.expectedValue, value) + } + + if variant != tt.expectedVariant { + t.Errorf("expected variant '%s', got '%s'", tt.expectedVariant, variant) + } + + if reason != tt.expectedReason { + t.Errorf("expected reason '%s', got '%s'", tt.expectedReason, reason) + } + + if err != tt.expectedError { + t.Errorf("expected err '%v', got '%v'", tt.expectedError, err) + } + }) + } +} diff --git a/core/pkg/eval/semver_evaluation.go b/core/pkg/eval/semver_evaluation.go index 0a5735e33..09a97240c 100644 --- a/core/pkg/eval/semver_evaluation.go +++ b/core/pkg/eval/semver_evaluation.go @@ -9,6 +9,8 @@ import ( "golang.org/x/mod/semver" ) +const SemVerEvaluationName = "sem_ver" + type SemVerOperator string const ( diff --git a/core/pkg/eval/semver_evaluation_test.go b/core/pkg/eval/semver_evaluation_test.go index c2d70d5f9..9920d77bc 100644 --- a/core/pkg/eval/semver_evaluation_test.go +++ b/core/pkg/eval/semver_evaluation_test.go @@ -701,7 +701,7 @@ func TestJSONEvaluator_semVerEvaluation(t *testing.T) { log, store.NewFlags(), WithEvaluator( - "sem_ver", + SemVerEvaluationName, NewSemVerComparisonEvaluator(log).SemVerEvaluation, ), ) diff --git a/core/pkg/eval/string_comparison_evaluation.go b/core/pkg/eval/string_comparison_evaluation.go index 7b99ce229..20e4861dd 100644 --- a/core/pkg/eval/string_comparison_evaluation.go +++ b/core/pkg/eval/string_comparison_evaluation.go @@ -8,6 +8,11 @@ import ( "github.com/open-feature/flagd/core/pkg/logger" ) +const ( + StartsWithEvaluationName = "starts_with" + EndsWithEvaluationName = "ends_with" +) + type StringComparisonEvaluator struct { Logger *logger.Logger } diff --git a/core/pkg/eval/string_comparison_evaluation_test.go b/core/pkg/eval/string_comparison_evaluation_test.go index 689bee416..048795133 100644 --- a/core/pkg/eval/string_comparison_evaluation_test.go +++ b/core/pkg/eval/string_comparison_evaluation_test.go @@ -185,7 +185,7 @@ func TestJSONEvaluator_startsWithEvaluation(t *testing.T) { log, store.NewFlags(), WithEvaluator( - "starts_with", + StartsWithEvaluationName, NewStringComparisonEvaluator(log).StartsWithEvaluation, ), ) @@ -387,7 +387,7 @@ func TestJSONEvaluator_endsWithEvaluation(t *testing.T) { log, store.NewFlags(), WithEvaluator( - "ends_with", + EndsWithEvaluationName, NewStringComparisonEvaluator(log).EndsWithEvaluation, ), ) diff --git a/core/pkg/runtime/from_config.go b/core/pkg/runtime/from_config.go index 9882773cb..0dafcf9b6 100644 --- a/core/pkg/runtime/from_config.go +++ b/core/pkg/runtime/from_config.go @@ -151,21 +151,26 @@ func setupJSONEvaluator(logger *logger.Logger, s *store.Flags) *eval.JSONEvaluat logger, s, eval.WithEvaluator( - "fractionalEvaluation", + eval.FractionEvaluationName, eval.NewFractionalEvaluator(logger).FractionalEvaluation, ), eval.WithEvaluator( - "starts_with", + eval.StartsWithEvaluationName, eval.NewStringComparisonEvaluator(logger).StartsWithEvaluation, ), eval.WithEvaluator( - "ends_with", + eval.EndsWithEvaluationName, eval.NewStringComparisonEvaluator(logger).EndsWithEvaluation, ), eval.WithEvaluator( - "sem_ver", + eval.SemVerEvaluationName, eval.NewSemVerComparisonEvaluator(logger).SemVerEvaluation, ), + // deprecated: will be removed before v1! + eval.WithEvaluator( + eval.LegacyFractionEvaluationName, + eval.NewLegacyFractionalEvaluator(logger).LegacyFractionalEvaluation, + ), ) return evaluator } diff --git a/docs/configuration/fractional_evaluation.md b/docs/configuration/fractional_evaluation.md index 83b8e22b6..2080df582 100644 --- a/docs/configuration/fractional_evaluation.md +++ b/docs/configuration/fractional_evaluation.md @@ -4,15 +4,18 @@ OpenFeature allows clients to pass contextual information which can then be used In some scenarios, it is desirable to use that contextual information to segment the user population further and thus return dynamic values. -Look at the [headerColor](https://github.com/open-feature/flagd/blob/main/samples/example_flags.flagd.json#L88-#L133) flag. The `defaultVariant` is `red`, but it contains a [targeting rule](reusable_targeting_rules.md), meaning a fractional evaluation occurs for flag evaluation with a `context` object containing `email` and where that `email` value contains `@faas.com`. +See the [headerColor](https://github.com/open-feature/flagd/blob/main/samples/example_flags.flagd.json#L88-#L133) flag. +The `defaultVariant` is `red`, but it contains a [targeting rule](reusable_targeting_rules.md), meaning a fractional evaluation occurs for flag evaluation with a `context` object containing `email` and where that `email` value contains `@faas.com`. -In this case, `25%` of the email addresses will receive `red`, `25%` will receive `blue`, and so on. +In this case, `25%` of the evaluations will receive `red`, `25%` will receive `blue`, and so on. -Importantly, the evaluations are "sticky" meaning that the same `email` address will always belong to the same "bucket" and thus always receive the same color. +Assignment is deterministic (sticky) based on the expression supplied as the first parameter (`{ "var": "email" }`, in this case). +The value retrieved by this expression is referred to as the "bucketing value". +The bucketing value expression can be omitted, in which case a concatenation of the `targetingKey` and the `flagKey` will be used. ## Fractional Evaluation: Technical Description -The `fractionalEvaluation` operation is a custom JsonLogic operation which deterministically selects a variant based on +The `fractional` operation is a custom JsonLogic operation which deterministically selects a variant based on the defined distribution of each variant (as a percentage). This works by hashing ([murmur3](https://github.com/aappleby/smhasher/blob/master/src/MurmurHash3.cpp)) the given data point, converting it into an int in the range [0, 99]. @@ -22,7 +25,7 @@ As hashing is deterministic we can be sure to get the same result every time for ## Fractional evaluation configuration -The `fractionalEvaluation` can be added as part of a targeting definition. +The `fractional` operation can be added as part of a targeting definition. The value is an array and the first element is the name of the property to use from the evaluation context. This value should typically be something that remains consistent for the duration of a users session (e.g. email or session ID). The other elements in the array are nested arrays with the first element representing a variant and the second being the percentage that this option is selected. @@ -30,9 +33,9 @@ There is no limit to the number of elements but the configured percentages must ```js // Factional evaluation property name used in a targeting rule -"fractionalEvaluation": [ +"fractional": [ // Evaluation context property used to determine the split - "email", + { "var": "email" }, // Split definitions contain an array with a variant and percentage // Percentages must add up to 100 [ @@ -66,8 +69,8 @@ Flags defined as such: "defaultVariant": "red", "state": "ENABLED", "targeting": { - "fractionalEvaluation": [ - "email", + "fractional": [ + { "var": "email" }, [ "red", 50 @@ -114,4 +117,28 @@ Result: ``` Notice that rerunning either curl command will always return the same variant and value. -The only way to get a different value is to change the email or update the `fractionalEvaluation` configuration. +The only way to get a different value is to change the email or update the `fractional` configuration. + +### Migrating from legacy fractionalEvaluation + +If you are using a legacy fractional evaluation (`fractionalEvaluation`), it's recommended you migrate to `fractional`. +The new `fractional` evaluator supports nested properties and JsonLogic expressions. +To migrate, simply use a JsonLogic variable declaration for the bucketing property, instead of a string: + +old: + +```json +"fractionalEvaluation": [ + "email", + [ "red", 25 ], [ "blue", 25 ], [ "green", 25 ], [ "yellow", 25 ] +] +``` + +new: + +```json +"fractional": [ + { "var": "email" }, + [ "red", 25 ], [ "blue", 25 ], [ "green", 25 ], [ "yellow", 25 ] +] +``` diff --git a/web-docs/concepts/index.md b/web-docs/concepts/index.md index ebbe07c3b..573ad51db 100644 --- a/web-docs/concepts/index.md +++ b/web-docs/concepts/index.md @@ -160,7 +160,8 @@ In this case, `25%` of the email addresses will receive `red`, `25%` will receiv } }, { - "fractionalEvaluation": [ "email", + "fractional": [ + { "var": "email" }, [ "red", 25 ], [ "blue", 25 ], [ "green", 25 ], [ "yellow", 25 ] ] }, null @@ -173,12 +174,39 @@ In this case, `25%` of the email addresses will receive `red`, `25%` will receiv ### Fractional evaluations are sticky -Fractional evaluations are "sticky" and deterministic meaning that the same email address will always belong to the same "bucket" and thus always receive the same color. +Fractional evaluations are "sticky" (deterministic) meaning that the same email address will always belong to the same "bucket" and thus always receive the same color. +This is true even if you run multiple flagd instances completely independently. -This is true even if you run multiple flagd APIs completely independently. +Note that the first argument to the `fractional` operator is an expression specifying the *bucketing value*. +This value is used as input to the bucketing algorithm to ensure a deterministic result. +This argument can be omitted, in which case a concatenation of the `targetingKey` and the `flagKey` will be used as the bucketing value. See this page for more information on [flagd fractional evaluation logic](https://github.com/open-feature/flagd/blob/main/docs/configuration/fractional_evaluation.md). +### Migrating from legacy fractionalEvaluation + +If you are using a legacy fractional evaluation (`fractionalEvaluation`), it's recommended you migrate to `fractional`. +The new `fractional` evaluator supports nested properties and JsonLogic expressions. +To migrate, simply use a JsonLogic variable declaration for the bucketing property, instead of a string: + +old: + +```json +"fractionalEvaluation": [ + "email", + [ "red", 25 ], [ "blue", 25 ], [ "green", 25 ], [ "yellow", 25 ] +] +``` + +new: + +```json +"fractional": [ + { "var": "email" }, + [ "red", 25 ], [ "blue", 25 ], [ "green", 25 ], [ "yellow", 25 ] +] +``` + ## Other target specifiers The example above shows the `in` keyword being used, but flagd is also compatible with: