diff --git a/README.md b/README.md index 44f1b63..365cbd8 100644 --- a/README.md +++ b/README.md @@ -579,6 +579,7 @@ And optionally these properties depending on whether you are evaluating a featur - `VariableKey`: the variable key - `VariableValue`: the variable value - `VariableSchema`: the variable schema +- `VariableOverrideIndex`: index of matched variable override when applicable ## Hooks diff --git a/cmd/commands/test.go b/cmd/commands/test.go index 2fb3104..48df232 100644 --- a/cmd/commands/test.go +++ b/cmd/commands/test.go @@ -460,6 +460,11 @@ func getEvaluationValue(evaluation featurevisor.Evaluation, key string) interfac return evaluation.VariableValue case "variableSchema": return evaluation.VariableSchema + case "variableOverrideIndex": + if evaluation.VariableOverrideIndex != nil { + return *evaluation.VariableOverrideIndex + } + return nil default: return nil } diff --git a/cmd/commands/test_evaluation_value_test.go b/cmd/commands/test_evaluation_value_test.go new file mode 100644 index 0000000..5839772 --- /dev/null +++ b/cmd/commands/test_evaluation_value_test.go @@ -0,0 +1,22 @@ +package commands + +import ( + "testing" + + "github.com/featurevisor/featurevisor-go" +) + +func TestGetEvaluationValueVariableOverrideIndex(t *testing.T) { + index := 2 + evaluation := featurevisor.Evaluation{ + Type: featurevisor.EvaluationTypeVariable, + FeatureKey: "test", + Reason: featurevisor.EvaluationReasonVariableOverrideRule, + VariableOverrideIndex: &index, + } + + value := getEvaluationValue(evaluation, "variableOverrideIndex") + if value != 2 { + t.Fatalf("expected variableOverrideIndex to be 2, got %#v", value) + } +} diff --git a/evaluate.go b/evaluate.go index 2fb744a..61daa59 100644 --- a/evaluate.go +++ b/evaluate.go @@ -778,26 +778,67 @@ func Evaluate(options EvaluateOptions) Evaluation { } // override from rule - if matchedTraffic != nil && matchedTraffic.Variables != nil { - if variableValue, exists := matchedTraffic.Variables[string(*options.VariableKey)]; exists { - evaluation = Evaluation{ - Type: options.Type, - FeatureKey: options.FeatureKey, - Reason: EvaluationReasonRule, - BucketKey: &bucketKey, - BucketValue: &bucketValue, - RuleKey: &matchedTraffic.Key, - Traffic: matchedTraffic, - VariableKey: options.VariableKey, - VariableSchema: variableSchema, - VariableValue: variableValue, + if matchedTraffic != nil { + if matchedTraffic.VariableOverrides != nil { + if overrides, exists := matchedTraffic.VariableOverrides[*options.VariableKey]; exists { + for index, override := range overrides { + matched := false + + if override.Conditions != nil { + parsedConditions := options.DatafileReader.parseConditionsIfStringified(override.Conditions) + matched = options.DatafileReader.AllConditionsAreMatched(parsedConditions, options.Context) + } else if override.Segments != nil { + parsedSegments := options.DatafileReader.parseSegmentsIfStringified(override.Segments) + matched = options.DatafileReader.AllSegmentsAreMatched(parsedSegments, options.Context) + } + + if matched { + overrideIndex := index + evaluation = Evaluation{ + Type: options.Type, + FeatureKey: options.FeatureKey, + Reason: EvaluationReasonVariableOverrideRule, + BucketKey: &bucketKey, + BucketValue: &bucketValue, + RuleKey: &matchedTraffic.Key, + Traffic: matchedTraffic, + VariableKey: options.VariableKey, + VariableSchema: variableSchema, + VariableValue: override.Value, + VariableOverrideIndex: &overrideIndex, + } + + options.Logger.Debug("variable override from rule", LogDetails{ + "evaluation": evaluation, + }) + + return evaluation + } + } } + } - options.Logger.Debug("override from rule", LogDetails{ - "evaluation": evaluation, - }) + if matchedTraffic.Variables != nil { + if variableValue, exists := matchedTraffic.Variables[string(*options.VariableKey)]; exists { + evaluation = Evaluation{ + Type: options.Type, + FeatureKey: options.FeatureKey, + Reason: EvaluationReasonRule, + BucketKey: &bucketKey, + BucketValue: &bucketValue, + RuleKey: &matchedTraffic.Key, + Traffic: matchedTraffic, + VariableKey: options.VariableKey, + VariableSchema: variableSchema, + VariableValue: variableValue, + } - return evaluation + options.Logger.Debug("override from rule", LogDetails{ + "evaluation": evaluation, + }) + + return evaluation + } } } @@ -817,11 +858,12 @@ func Evaluate(options EvaluateOptions) Evaluation { if variation.Value == *variationValue { if variation.VariableOverrides != nil { if overrides, exists := variation.VariableOverrides[*options.VariableKey]; exists { - for _, override := range overrides { + for index, override := range overrides { matched := false if override.Conditions != nil { - matched = options.DatafileReader.AllConditionsAreMatched(override.Conditions, options.Context) + parsedConditions := options.DatafileReader.parseConditionsIfStringified(override.Conditions) + matched = options.DatafileReader.AllConditionsAreMatched(parsedConditions, options.Context) } else if override.Segments != nil { // Parse segments if they come from JSON unmarshaling parsedSegments := options.DatafileReader.parseSegmentsIfStringified(override.Segments) @@ -829,10 +871,11 @@ func Evaluate(options EvaluateOptions) Evaluation { } if matched { + overrideIndex := index evaluation = Evaluation{ Type: options.Type, FeatureKey: options.FeatureKey, - Reason: EvaluationReasonVariableOverride, + Reason: EvaluationReasonVariableOverrideVariation, BucketKey: &bucketKey, BucketValue: &bucketValue, RuleKey: func() *RuleKey { @@ -841,13 +884,14 @@ func Evaluate(options EvaluateOptions) Evaluation { } return nil }(), - Traffic: matchedTraffic, - VariableKey: options.VariableKey, - VariableSchema: variableSchema, - VariableValue: override.Value, + Traffic: matchedTraffic, + VariableKey: options.VariableKey, + VariableSchema: variableSchema, + VariableValue: override.Value, + VariableOverrideIndex: &overrideIndex, } - options.Logger.Debug("variable override", LogDetails{ + options.Logger.Debug("variable override from variation", LogDetails{ "evaluation": evaluation, }) diff --git a/evaluation.go b/evaluation.go index 68be060..2502a23 100644 --- a/evaluation.go +++ b/evaluation.go @@ -15,10 +15,11 @@ const ( EvaluationReasonVariationDisabled EvaluationReason = "variation_disabled" // feature is disabled, and variation's disabledVariationValue is used // Variable specific - EvaluationReasonVariableNotFound EvaluationReason = "variable_not_found" // variable's schema is not defined in the feature - EvaluationReasonVariableDefault EvaluationReason = "variable_default" // default variable value used - EvaluationReasonVariableDisabled EvaluationReason = "variable_disabled" // feature is disabled, and variable's disabledValue is used - EvaluationReasonVariableOverride EvaluationReason = "variable_override" // variable overridden from inside a variation + EvaluationReasonVariableNotFound EvaluationReason = "variable_not_found" // variable's schema is not defined in the feature + EvaluationReasonVariableDefault EvaluationReason = "variable_default" // default variable value used + EvaluationReasonVariableDisabled EvaluationReason = "variable_disabled" // feature is disabled, and variable's disabledValue is used + EvaluationReasonVariableOverrideVariation EvaluationReason = "variable_override_variation" // variable overridden from inside a variation + EvaluationReasonVariableOverrideRule EvaluationReason = "variable_override_rule" // variable overridden from inside a rule // Common EvaluationReasonNoMatch EvaluationReason = "no_match" // no rules matched @@ -63,7 +64,8 @@ type Evaluation struct { VariationValue *VariationValue `json:"variationValue,omitempty"` // Variable - VariableKey *VariableKey `json:"variableKey,omitempty"` - VariableValue VariableValue `json:"variableValue,omitempty"` - VariableSchema *VariableSchema `json:"variableSchema,omitempty"` + VariableKey *VariableKey `json:"variableKey,omitempty"` + VariableValue VariableValue `json:"variableValue,omitempty"` + VariableSchema *VariableSchema `json:"variableSchema,omitempty"` + VariableOverrideIndex *int `json:"variableOverrideIndex,omitempty"` } diff --git a/instance_rule_variable_overrides_test.go b/instance_rule_variable_overrides_test.go new file mode 100644 index 0000000..e35a2af --- /dev/null +++ b/instance_rule_variable_overrides_test.go @@ -0,0 +1,225 @@ +package featurevisor + +import "testing" + +func TestRuleVariableOverridesParity(t *testing.T) { + jsonDatafile := `{ + "schemaVersion": "2", + "revision": "1.0", + "segments": { + "germany": { + "key": "germany", + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"de\"}]" + }, + "mobile": { + "key": "mobile", + "conditions": "[{\"attribute\":\"device\",\"operator\":\"equals\",\"value\":\"mobile\"}]" + } + }, + "features": { + "test": { + "key": "test", + "bucketBy": "userId", + "variablesSchema": { + "config": { + "key": "config", + "type": "object", + "defaultValue": {"source":"default","nested":{"value":0}} + }, + "banner": { + "key": "banner", + "type": "string", + "defaultValue": "default-banner" + } + }, + "traffic": [ + { + "key": "germany", + "segments": "germany", + "percentage": 100000, + "variables": { + "config": {"source":"rule","nested":{"value":10},"flag":true}, + "banner": "rule-banner" + }, + "variableOverrides": { + "config": [ + { + "segments": "mobile", + "value": {"source":"rule","nested":{"value":20},"flag":true} + }, + { + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"de\"}]", + "value": {"source":"rule","nested":{"value":30},"flag":true} + } + ], + "banner": [ + { + "conditions": [ + {"attribute":"country","operator":"equals","value":"de"} + ], + "value": "rule-banner-structured" + } + ] + }, + "allocation": [] + }, + { + "key": "everyone", + "segments": "*", + "percentage": 100000, + "variables": { + "config": {"source":"everyone","nested":{"value":1}} + }, + "allocation": [] + } + ] + } + } + }` + + var datafile DatafileContent + if err := datafile.FromJSON(jsonDatafile); err != nil { + t.Fatalf("failed to parse datafile: %v", err) + } + + sdk := CreateInstance(Options{Datafile: datafile}) + + // first matching rule override by segments should win (index 0) + evaluation := sdk.EvaluateVariable("test", "config", Context{ + "userId": "user-1", + "country": "de", + "device": "mobile", + }, OverrideOptions{}) + if evaluation.Reason != EvaluationReasonVariableOverrideRule { + t.Fatalf("expected reason %q, got %q", EvaluationReasonVariableOverrideRule, evaluation.Reason) + } + if evaluation.VariableOverrideIndex == nil || *evaluation.VariableOverrideIndex != 0 { + t.Fatalf("expected variableOverrideIndex 0, got %#v", evaluation.VariableOverrideIndex) + } + + config, ok := evaluation.VariableValue.(map[string]interface{}) + if !ok || config["source"] != "rule" { + t.Fatalf("expected rule override config value, got %#v", evaluation.VariableValue) + } + nested, ok := config["nested"].(map[string]interface{}) + if !ok || nested["value"] != float64(20) { + t.Fatalf("expected nested.value 20 from first override, got %#v", config) + } + + // stringified conditions override should match as second entry (index 1) + evaluation = sdk.EvaluateVariable("test", "config", Context{ + "userId": "user-1", + "country": "de", + }, OverrideOptions{}) + if evaluation.Reason != EvaluationReasonVariableOverrideRule { + t.Fatalf("expected reason %q, got %q", EvaluationReasonVariableOverrideRule, evaluation.Reason) + } + if evaluation.VariableOverrideIndex == nil || *evaluation.VariableOverrideIndex != 1 { + t.Fatalf("expected variableOverrideIndex 1, got %#v", evaluation.VariableOverrideIndex) + } + + config, ok = evaluation.VariableValue.(map[string]interface{}) + if !ok { + t.Fatalf("expected map config, got %#v", evaluation.VariableValue) + } + nested, ok = config["nested"].(map[string]interface{}) + if !ok || nested["value"] != float64(30) { + t.Fatalf("expected nested.value 30 from second override, got %#v", config) + } + + // structured conditions override should match for banner + evaluation = sdk.EvaluateVariable("test", "banner", Context{ + "userId": "user-1", + "country": "de", + }, OverrideOptions{}) + if evaluation.Reason != EvaluationReasonVariableOverrideRule { + t.Fatalf("expected reason %q, got %q", EvaluationReasonVariableOverrideRule, evaluation.Reason) + } + if evaluation.VariableOverrideIndex == nil || *evaluation.VariableOverrideIndex != 0 { + t.Fatalf("expected variableOverrideIndex 0 for banner, got %#v", evaluation.VariableOverrideIndex) + } + if evaluation.VariableValue != "rule-banner-structured" { + t.Fatalf("expected banner override value, got %#v", evaluation.VariableValue) + } + + // no rule override match should fall back to matched rule variables + evaluation = sdk.EvaluateVariable("test", "config", Context{ + "userId": "user-1", + "country": "nl", + }, OverrideOptions{}) + if evaluation.Reason != EvaluationReasonRule { + t.Fatalf("expected rule reason fallback, got %q", evaluation.Reason) + } + config, ok = evaluation.VariableValue.(map[string]interface{}) + if !ok || config["source"] != "everyone" { + t.Fatalf("expected fallback to everyone rule variable, got %#v", evaluation.VariableValue) + } +} + +func TestVariationVariableOverrideReasonSplit(t *testing.T) { + jsonDatafile := `{ + "schemaVersion": "2", + "revision": "1.0", + "segments": { + "germany": { + "key": "germany", + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"de\"}]" + } + }, + "features": { + "test": { + "key": "test", + "bucketBy": "userId", + "variablesSchema": { + "color": { + "key": "color", + "type": "string", + "defaultValue": "default-color" + } + }, + "variations": [ + {"value":"control"}, + { + "value":"treatment", + "variables": {"color":"blue"}, + "variableOverrides": { + "color": [ + {"segments":"germany","value":"yellow"} + ] + } + } + ], + "traffic": [ + { + "key":"everyone", + "segments":"*", + "percentage":100000, + "allocation":[ + {"variation":"treatment","range":[0,100000]} + ] + } + ] + } + } + }` + + var datafile DatafileContent + if err := datafile.FromJSON(jsonDatafile); err != nil { + t.Fatalf("failed to parse datafile: %v", err) + } + + sdk := CreateInstance(Options{Datafile: datafile}) + evaluation := sdk.EvaluateVariable("test", "color", Context{ + "userId": "user-1", + "country": "de", + }, OverrideOptions{}) + if evaluation.Reason != EvaluationReasonVariableOverrideVariation { + t.Fatalf("expected reason %q, got %q", EvaluationReasonVariableOverrideVariation, evaluation.Reason) + } + if evaluation.VariableOverrideIndex == nil || *evaluation.VariableOverrideIndex != 0 { + t.Fatalf("expected variation variableOverrideIndex 0, got %#v", evaluation.VariableOverrideIndex) + } + if evaluation.VariableValue != "yellow" { + t.Fatalf("expected yellow from variation override, got %#v", evaluation.VariableValue) + } +} diff --git a/sdk_types.go b/sdk_types.go index 654449a..d37156e 100644 --- a/sdk_types.go +++ b/sdk_types.go @@ -256,14 +256,15 @@ type RuleKey = string // Rule represents a rule type Rule struct { - Key RuleKey `json:"key"` - Description *string `json:"description,omitempty"` - Segments interface{} `json:"segments"` // GroupSegment | GroupSegment[] - Percentage Weight `json:"percentage"` - Enabled *bool `json:"enabled,omitempty"` - Variation *VariationValue `json:"variation,omitempty"` - Variables map[string]VariableValue `json:"variables,omitempty"` - VariationWeights map[string]Weight `json:"variationWeights,omitempty"` + Key RuleKey `json:"key"` + Description *string `json:"description,omitempty"` + Segments interface{} `json:"segments"` // GroupSegment | GroupSegment[] + Percentage Weight `json:"percentage"` + Enabled *bool `json:"enabled,omitempty"` + Variation *VariationValue `json:"variation,omitempty"` + Variables map[string]VariableValue `json:"variables,omitempty"` + VariationWeights map[string]Weight `json:"variationWeights,omitempty"` + VariableOverrides map[VariableKey][]VariableOverride `json:"variableOverrides,omitempty"` } // RulesByEnvironment represents rules by environment @@ -339,14 +340,15 @@ type Allocation struct { // Traffic represents traffic configuration type Traffic struct { - Key RuleKey `json:"key"` - Segments interface{} `json:"segments"` // GroupSegment | GroupSegment[] | "*" - Percentage Percentage `json:"percentage"` - Enabled *bool `json:"enabled,omitempty"` - Variation *VariationValue `json:"variation,omitempty"` - Variables map[string]VariableValue `json:"variables,omitempty"` - VariationWeights map[string]Weight `json:"variationWeights,omitempty"` - Allocation []Allocation `json:"allocation,omitempty"` + Key RuleKey `json:"key"` + Segments interface{} `json:"segments"` // GroupSegment | GroupSegment[] | "*" + Percentage Percentage `json:"percentage"` + Enabled *bool `json:"enabled,omitempty"` + Variation *VariationValue `json:"variation,omitempty"` + Variables map[string]VariableValue `json:"variables,omitempty"` + VariationWeights map[string]Weight `json:"variationWeights,omitempty"` + VariableOverrides map[VariableKey][]VariableOverride `json:"variableOverrides,omitempty"` + Allocation []Allocation `json:"allocation,omitempty"` } /** @@ -380,21 +382,21 @@ type SchemaKey = string // Schema represents JSON schema-like validations used by variable schema. type Schema struct { Type *VariableType `json:"type,omitempty"` - Properties SchemaMap `json:"properties,omitempty"` - AdditionalProperties interface{} `json:"additionalProperties,omitempty"` // bool | Schema - Required []string `json:"required,omitempty"` - Items *Schema `json:"items,omitempty"` - OneOf []Schema `json:"oneOf,omitempty"` - Enum []Value `json:"enum,omitempty"` - Const VariableValue `json:"const,omitempty"` - Minimum *float64 `json:"minimum,omitempty"` - Maximum *float64 `json:"maximum,omitempty"` - MinLength *int `json:"minLength,omitempty"` - MaxLength *int `json:"maxLength,omitempty"` - Pattern *string `json:"pattern,omitempty"` - MinItems *int `json:"minItems,omitempty"` - MaxItems *int `json:"maxItems,omitempty"` - UniqueItems *bool `json:"uniqueItems,omitempty"` + Properties SchemaMap `json:"properties,omitempty"` + AdditionalProperties interface{} `json:"additionalProperties,omitempty"` // bool | Schema + Required []string `json:"required,omitempty"` + Items *Schema `json:"items,omitempty"` + OneOf []Schema `json:"oneOf,omitempty"` + Enum []Value `json:"enum,omitempty"` + Const VariableValue `json:"const,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + MinLength *int `json:"minLength,omitempty"` + MaxLength *int `json:"maxLength,omitempty"` + Pattern *string `json:"pattern,omitempty"` + MinItems *int `json:"minItems,omitempty"` + MaxItems *int `json:"maxItems,omitempty"` + UniqueItems *bool `json:"uniqueItems,omitempty"` } // SchemaMap represents schema object properties map. diff --git a/sdk_types_test.go b/sdk_types_test.go index 01caf4f..f1504f5 100644 --- a/sdk_types_test.go +++ b/sdk_types_test.go @@ -674,3 +674,73 @@ func TestStagingTagCheckoutDatafile(t *testing.T) { datafile.SchemaVersion, datafile2.SchemaVersion) } } + +func TestTrafficVariableOverridesUnmarshal(t *testing.T) { + jsonData := `{ + "schemaVersion": "2", + "revision": "1", + "segments": { + "germany": { + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"de\"}]" + } + }, + "features": { + "test": { + "bucketBy": "userId", + "variablesSchema": { + "config": { + "type": "object", + "defaultValue": {"source":"default"} + } + }, + "traffic": [ + { + "key": "germany", + "segments": "germany", + "percentage": 100000, + "variables": { + "config": {"source":"rule","nested":{"value":10}} + }, + "variableOverrides": { + "config": [ + { + "conditions": "[{\"attribute\":\"country\",\"operator\":\"equals\",\"value\":\"de\"}]", + "value": {"source":"rule","nested":{"value":20}} + } + ] + } + } + ] + } + } + }` + + var datafile DatafileContent + if err := datafile.FromJSON(jsonData); err != nil { + t.Fatalf("failed to parse datafile: %v", err) + } + + feature := datafile.Features["test"] + if len(feature.Traffic) != 1 { + t.Fatalf("expected 1 traffic rule, got %d", len(feature.Traffic)) + } + + traffic := feature.Traffic[0] + if traffic.VariableOverrides == nil { + t.Fatal("expected traffic variableOverrides to be present") + } + + overrides, exists := traffic.VariableOverrides["config"] + if !exists || len(overrides) != 1 { + t.Fatalf("expected one config override, got %#v", traffic.VariableOverrides) + } + + valueMap, ok := overrides[0].Value.(map[string]interface{}) + if !ok { + t.Fatalf("expected override value map, got %#v", overrides[0].Value) + } + nested, ok := valueMap["nested"].(map[string]interface{}) + if !ok || nested["value"] != float64(20) { + t.Fatalf("expected nested override value to be preserved, got %#v", valueMap) + } +}