Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[submodule "flagengine/engine-test-data"]
path = flagengine/engine-test-data
url = git@github.com:Flagsmith/engine-test-data.git
branch = v3.7.0
branch = v3.10.0
91 changes: 91 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,8 @@ func TestGetFlags(t *testing.T) {
assert.Equal(t, fixtures.Feature1Name, allFlags[0].FeatureName)
assert.Equal(t, fixtures.Feature1ID, allFlags[0].FeatureID)
assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value)
assert.Equal(t, fixtures.Feature1Reason, allFlags[0].Reason)
assert.Empty(t, allFlags[0].Variant)
}

func TestGetFlagsTransientIdentity(t *testing.T) {
Expand All @@ -261,6 +263,8 @@ func TestGetFlagsTransientIdentity(t *testing.T) {
assert.Equal(t, fixtures.Feature1Name, allFlags[0].FeatureName)
assert.Equal(t, fixtures.Feature1ID, allFlags[0].FeatureID)
assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value)
assert.Equal(t, fixtures.Feature1IdentityReason, allFlags[0].Reason)
assert.Equal(t, fixtures.Feature1IdentityVariant, allFlags[0].Variant)
}

func TestGetFlagsTransientTraits(t *testing.T) {
Expand Down Expand Up @@ -372,6 +376,7 @@ func TestGetEnvironmentFlagsUseslocalEnvironmentWhenAvailable(t *testing.T) {
assert.Equal(t, fixtures.Feature1Name, allFlags[0].FeatureName)
assert.Equal(t, fixtures.Feature1ID, allFlags[0].FeatureID)
assert.Equal(t, fixtures.Feature1Value, allFlags[0].Value)
assert.Equal(t, "DEFAULT", allFlags[0].Reason)
}

func TestGetEnvironmentFlagsCallsAPIWhenLocalEnvironmentNotAvailable(t *testing.T) {
Expand Down Expand Up @@ -447,6 +452,91 @@ func TestGetEnvironmentFlagsIgnoresSegmentOverrides(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, fixtures.Feature1Value, flag.Value)
assert.Equal(t, "some_value", flag.Value)
assert.Equal(t, "DEFAULT", flag.Reason)
}

func TestGetIdentityFlagsAppliesSegmentOverridesWithReason(t *testing.T) {
// Given
ctx := context.Background()
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK)
_, _ = io.WriteString(rw, fixtures.EnvironmentJsonWithSegmentOverride)
}))
defer server.Close()

// When
client := flagsmith.NewClient(fixtures.EnvironmentAPIKey,
flagsmith.WithLocalEvaluation(ctx),
flagsmith.WithBaseURL(server.URL+"/api/v1/"))
err := client.UpdateEnvironment(ctx)
assert.NoError(t, err)

flags, err := client.GetIdentityFlags(ctx, "test_identity", nil)

// Then
assert.NoError(t, err)
flag, err := flags.GetFlag(fixtures.Feature1Name)
assert.NoError(t, err)
assert.Equal(t, "segment_override", flag.Value)
assert.Equal(t, "TARGETING_MATCH; segment=Test Segment", flag.Reason)
}

func TestGetIdentityFlagsSetsVariantForMultivariateFeature(t *testing.T) {
// Given
ctx := context.Background()
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK)
_, _ = io.WriteString(rw, fixtures.EnvironmentJsonWithMultivariateFeature)
}))
defer server.Close()

// When
client := flagsmith.NewClient(fixtures.EnvironmentAPIKey,
flagsmith.WithLocalEvaluation(ctx),
flagsmith.WithBaseURL(server.URL+"/api/v1/"))
err := client.UpdateEnvironment(ctx)
assert.NoError(t, err)

flags, err := client.GetIdentityFlags(ctx, "test_identity", nil)

// Then
assert.NoError(t, err)
flag, err := flags.GetFlag(fixtures.MVFeatureName)
assert.NoError(t, err)
assert.Equal(t, fixtures.MVFeatureVariantValue, flag.Value)
assert.Equal(t, fixtures.MVFeatureVariantKey, flag.Variant)
assert.Equal(t, "SPLIT; weight=100", flag.Reason)
}

func TestGetEnvironmentFlagsHasNoVariantForMultivariateFeature(t *testing.T) {
// Given
ctx := context.Background()
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK)
_, _ = io.WriteString(rw, fixtures.EnvironmentJsonWithMultivariateFeature)
}))
defer server.Close()

// When
client := flagsmith.NewClient(fixtures.EnvironmentAPIKey,
flagsmith.WithLocalEvaluation(ctx),
flagsmith.WithBaseURL(server.URL+"/api/v1/"))
err := client.UpdateEnvironment(ctx)
assert.NoError(t, err)

flags, err := client.GetEnvironmentFlags(ctx)

// Then: without an identity there is nothing to bucket, so the control value is
// served without a variant
assert.NoError(t, err)
flag, err := flags.GetFlag(fixtures.MVFeatureName)
assert.NoError(t, err)
assert.Equal(t, "control_value", flag.Value)
assert.Empty(t, flag.Variant)
assert.Equal(t, "DEFAULT", flag.Reason)
}

func TestGetIdentityFlagsUseslocalEnvironmentWhenAvailable(t *testing.T) {
Expand Down Expand Up @@ -499,6 +589,7 @@ func TestGetIdentityFlagsUseslocalOverridesWhenAvailable(t *testing.T) {
assert.Equal(t, fixtures.Feature1Name, allFlags[0].FeatureName)
assert.Equal(t, fixtures.Feature1ID, allFlags[0].FeatureID)
assert.Equal(t, fixtures.Feature1OverriddenValue, allFlags[0].Value)
assert.Equal(t, "TARGETING_MATCH; segment=identity_overrides", allFlags[0].Reason)
}

func TestGetIdentityFlagsCallsAPIWhenLocalEnvironmentNotAvailableWithTraits(t *testing.T) {
Expand Down
58 changes: 58 additions & 0 deletions fixtures/fixture.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ const EnvironmentAPIKey = "ser.test_key"
const Feature1Value = "some_value"
const Feature1Name = "feature_1"
const Feature1ID = 1
const Feature1Reason = "DEFAULT"
const Feature1IdentityReason = "SPLIT; weight=50.0"
const Feature1IdentityVariant = "treatment"

const MVFeatureName = "mv_feature"
const MVFeatureVariantKey = "treatment"
const MVFeatureVariantValue = "variant_value"

const Feature1OverriddenValue = "some-overridden-value"
const ClientAPIKey = "B62qaMZNwfiqT76p38ggrQ"
Expand Down Expand Up @@ -163,6 +170,54 @@ const EnvironmentJsonWithSegmentOverride = `
}
`

// EnvironmentJsonWithMultivariateFeature contains a single multivariate feature whose
// only variant is keyed and allocated 100%, so every identity is bucketed into it.
const EnvironmentJsonWithMultivariateFeature = `
{
"api_key": "B62qaMZNwfiqT76p38ggrQ",
"name": "Test Environment",
"updated_at": "2023-12-06T10:21:54.079725Z",
"project": {
"name": "Test project",
"organisation": {
"feature_analytics": false,
"name": "Test Org",
"id": 1,
"persist_trait_data": true,
"stop_serving_flags": false
},
"id": 1,
"hide_disabled_flags": false,
"segments": []
},
"segment_overrides": [],
"id": 1,
"feature_states": [{
"multivariate_feature_state_values": [{
"id": 1,
"multivariate_feature_option": {
"id": 1,
"value": "variant_value",
"key": "treatment"
},
"percentage_allocation": 100,
"mv_fs_value_uuid": "1e1e1e1e-1e1e-1e1e-1e1e-1e1e1e1e1e1e"
}],
"feature_state_value": "control_value",
"id": 2,
"featurestate_uuid": "f0c8f0c8-f0c8-f0c8-f0c8-f0c8f0c8f0c8",
"feature": {
"name": "mv_feature",
"type": "MULTIVARIATE",
"id": 2
},
"segment_id": null,
"enabled": true
}],
"identity_overrides": []
}
`

const FlagsJson = `
[{
"id": 1,
Expand All @@ -177,6 +232,7 @@ const FlagsJson = `
"project": 1
},
"feature_state_value": "some_value",
"reason": "DEFAULT",
"enabled": true,
"environment": 1,
"identity": null,
Expand All @@ -198,6 +254,8 @@ const IdentityResponseJson = `
"project": 1
},
"feature_state_value": "some_value",
"reason": "SPLIT; weight=50.0",
"variant": "treatment",
"enabled": true,
"environment": 1,
"identity": null,
Expand Down
2 changes: 1 addition & 1 deletion flagengine/engine-test-data
Submodule engine-test-data updated 157 files
8 changes: 8 additions & 0 deletions flagengine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils"
)

const controlVariantKey = "control"

type featureContextWithSegmentName struct {
featureContext *engine_eval.FeatureContext
segmentName string
Expand Down Expand Up @@ -130,9 +132,13 @@ func GetEvaluationResult(ec *engine_eval.EngineEvaluationContext) engine_eval.Ev
// getFlagResultFromFeatureContext creates a FlagResult from a FeatureContext.
func getFlagResultFromFeatureContext(featureName string, featureContext *engine_eval.FeatureContext, identityKey *string, reason string) engine_eval.FlagResult {
value := featureContext.Value
variantKey := ""

// Handle multivariate features
if len(featureContext.Variants) > 0 && identityKey != nil && featureContext.Key != "" {
// Default to the control bucket; a selected variant overrides this
variantKey = controlVariantKey

// Sort variants by priority (lower priority value = higher priority)
sortedVariants := getSortedVariantsByPriority(featureContext.Variants)

Expand All @@ -146,6 +152,7 @@ func getFlagResultFromFeatureContext(featureName string, featureContext *engine_
cumulativeWeight += variant.Weight
if hashPercentage <= cumulativeWeight {
value = variant.Value
variantKey = variant.Key
reason = fmt.Sprintf("SPLIT; weight=%g", variant.Weight)
break
}
Expand All @@ -156,6 +163,7 @@ func getFlagResultFromFeatureContext(featureName string, featureContext *engine_
Enabled: featureContext.Enabled,
Name: featureName,
Value: value,
Variant: variantKey,
Reason: reason,
Metadata: featureContext.Metadata,
}
Expand Down
3 changes: 3 additions & 0 deletions flagengine/engine_eval/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ type FeatureContext struct {

// Represents a multivariate value for a feature flag.
type FeatureValue struct {
// A stable identifier for the variant, reported as the flag result's variant
// when this value is selected. Empty if the variant is not keyed.
Key string `json:"key,omitempty"`
// The value of the feature.
Value any `json:"value"`
// The weight of the feature value variant, as a percentage number (i.e. 100.0).
Expand Down
1 change: 1 addition & 0 deletions flagengine/engine_eval/mappers.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ func mapMultivariateFeatureStateValuesToVariants(multivariateValues []*features.
Value: mv.MultivariateFeatureOption.Value,
Weight: mv.PercentageAllocation,
Priority: mv.Priority(),
Key: mv.MultivariateFeatureOption.Key,
})
}
return variants
Expand Down
4 changes: 4 additions & 0 deletions flagengine/engine_eval/result.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ type FlagResult struct {
Reason string `json:"reason,omitempty"`
// Feature flag value.
Value any `json:"value,omitempty"`
// Key of the multivariate variant the value was selected from: "control" when
// the identity falls in the control bucket, or the selected variant's key.
// Empty for standard features, unkeyed variants, and evaluation without an identity.
Variant string `json:"variant,omitempty"`
// Metadata about the feature.
Metadata FeatureMetadata `json:"metadata,omitempty"`
}
Expand Down
1 change: 1 addition & 0 deletions flagengine/features/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func (fs *FeatureStateModel) UnmarshalJSON(bytes []byte) error {
type MultivariateFeatureOptionModel struct {
ID int `json:"id"`
Value interface{} `json:"value"`
Key string `json:"key"`
}

type MultivariateFeatureStateValueModel struct {
Expand Down
8 changes: 8 additions & 0 deletions models.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ type Flag struct {
IsDefault bool
FeatureID int
FeatureName string
Reason string
Variant string
}

type Trait = trait.Trait
Expand All @@ -36,6 +38,8 @@ func makeFlagFromEngineEvaluationFlagResult(flagResult *engine_eval.FlagResult)
IsDefault: false,
FeatureID: featureID,
FeatureName: flagResult.Name,
Reason: flagResult.Reason,
Variant: flagResult.Variant,
}
}

Expand Down Expand Up @@ -67,6 +71,8 @@ type jsonFlag struct {
Enabled bool `json:"enabled"`
Value interface{} `json:"feature_state_value"`
Feature jsonFeature `json:"feature"`
Reason string `json:"reason"`
Variant string `json:"variant"`
}

func (jf *jsonFlag) toFlag() Flag {
Expand All @@ -76,6 +82,8 @@ func (jf *jsonFlag) toFlag() Flag {
IsDefault: false,
FeatureID: jf.Feature.ID,
FeatureName: jf.Feature.Name,
Reason: jf.Reason,
Variant: jf.Variant,
}
}
func makeFlagsFromAPIFlags(flagsJson []byte, analyticsProcessor *AnalyticsProcessor, defaultFlagHandler func(string) (Flag, error)) (Flags, error) {
Expand Down
Loading
Loading