diff --git a/api/model_public_project.go b/api/model_public_project.go index 72d4a81a..1f9d4243 100644 --- a/api/model_public_project.go +++ b/api/model_public_project.go @@ -8,9 +8,9 @@ type Project struct { } type ProjectSettings struct { - EdgeDB EdgeDBSettings `json:"edgeDB"` - OptIn OptInSettings `json:"optIn"` - DisablePassthroughRollouts bool `json:"disablePassthroughRollouts"` + EdgeDB EdgeDBSettings `json:"edgeDB"` + OptIn OptInSettings `json:"optIn"` + DisablePassthroughRollouts bool `json:"disablePassthroughRollouts"` } type EdgeDBSettings struct { diff --git a/bucketing/bucketing.go b/bucketing/bucketing.go index 95111c72..70979a73 100644 --- a/bucketing/bucketing.go +++ b/bucketing/bucketing.go @@ -2,6 +2,7 @@ package bucketing import ( "errors" + "strconv" "time" "github.com/devcyclehq/go-server-sdk/v2/api" @@ -11,6 +12,7 @@ import ( // Max value of an unsigned 32-bit integer, which is what murmurhash returns const maxHashValue uint32 = 4294967295 const baseSeed = 1 +const defaultBucketingValue = "null" var ErrMissingVariableForVariation = errors.New("Config missing variable for variation") var ErrMissingFeature = errors.New("Config missing feature for variable") @@ -27,11 +29,11 @@ type boundedHash struct { BucketingHash float64 `json:"bucketingHash"` } -func generateBoundedHashes(userId, targetId string) boundedHash { +func generateBoundedHashes(bucketingKeyValue, targetId string) boundedHash { var targetHash = murmurhashV3(targetId, baseSeed) var bhash = boundedHash{ - RolloutHash: generateBoundedHash(userId+"_rollout", targetHash), - BucketingHash: generateBoundedHash(userId, targetHash), + RolloutHash: generateBoundedHash(bucketingKeyValue+"_rollout", targetHash), + BucketingHash: generateBoundedHash(bucketingKeyValue, targetHash), } return bhash } @@ -41,6 +43,32 @@ func generateBoundedHash(input string, hashSeed uint32) float64 { return float64(mh) / float64(maxHashValue) } +func determineUserBucketingValueForTarget(targetBucketingKey, userId string, mergedCustomData map[string]interface{}) string { + if targetBucketingKey == "" || targetBucketingKey == "user_id" { + return userId + } + + if customDataValue, keyExists := mergedCustomData[targetBucketingKey]; keyExists { + if customDataValue == nil { + return defaultBucketingValue + } + + switch v := customDataValue.(type) { + case int: + return strconv.Itoa(v) + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + case string: + return v + case bool: + return strconv.FormatBool(v) + default: + return defaultBucketingValue + } + } + return defaultBucketingValue +} + func getCurrentRolloutPercentage(rollout Rollout, currentDate time.Time) float64 { var start = rollout.StartPercentage var startDateTime = rollout.StartDate @@ -105,11 +133,14 @@ func doesUserPassRollout(rollout Rollout, boundedHash float64) bool { } func evaluateSegmentationForFeature(config *configBody, feature *ConfigFeature, user api.PopulatedUser, clientCustomData map[string]interface{}) *Target { + var mergedCustomData = user.CombinedCustomData() for _, target := range feature.Configuration.Targets { passthroughEnabled := !config.Project.Settings.DisablePassthroughRollouts doesUserPassthrough := true if target.Rollout != nil && passthroughEnabled { - boundedHash := generateBoundedHashes(user.UserId, target.Id) + var bucketingValue = determineUserBucketingValueForTarget(target.BucketingKey, user.UserId, mergedCustomData) + + boundedHash := generateBoundedHashes(bucketingValue, target.Id) rolloutHash := boundedHash.RolloutHash doesUserPassthrough = doesUserPassRollout(*target.Rollout, rolloutHash) } @@ -132,7 +163,10 @@ func doesUserQualifyForFeature(config *configBody, feature *ConfigFeature, user return targetAndHashes{}, ErrUserDoesNotQualifyForTargets } - boundedHashes := generateBoundedHashes(user.UserId, target.Id) + var mergedCustomData = user.CombinedCustomData() + var bucketingValue = determineUserBucketingValueForTarget(target.BucketingKey, user.UserId, mergedCustomData) + + boundedHashes := generateBoundedHashes(bucketingValue, target.Id) rolloutHash := boundedHashes.RolloutHash passthroughEnabled := !config.Project.Settings.DisablePassthroughRollouts diff --git a/bucketing/bucketing_test.go b/bucketing/bucketing_test.go index 17b8b483..cca2ecf9 100644 --- a/bucketing/bucketing_test.go +++ b/bucketing/bucketing_test.go @@ -16,6 +16,9 @@ var ( //go:embed testdata/fixture_test_config.json test_config []byte + //go:embed testdata/fixture_test_v2_config.json + test_v2_config []byte + //go:embed testdata/fixture_test_broken_config.json test_broken_config []byte @@ -29,8 +32,8 @@ type testconfig struct { } var test_configs = []testconfig{ - {configBody: test_config, description: "Passthrough disabled"}, - {configBody: test_config_disable_passthrough, description: "Passthrough Enabled"}, + {configBody: test_config, description: "Passthrough Enabled"}, + {configBody: test_config_disable_passthrough, description: "Passthrough Disabled"}, } // Bucketing puts the user in the target for the first audience they match @@ -714,3 +717,177 @@ func TestGenerateBucketedConfig_MissingVariables(t *testing.T) { _, err = GenerateBucketedConfig("broken_config", user, nil) require.ErrorIs(t, err, ErrMissingVariable) } + +func TestBucketing_Deterministic_StringAlternateKeyRandomDistribution(t *testing.T) { + user := api.User{ + UserId: "client-test", + CustomData: map[string]interface{}{ + "favouriteFood": "cake", + "favouriteNull": nil, + }, + }.GetPopulatedUser(&api.PlatformData{ + PlatformVersion: "1.1.2", + }) + user2 := api.User{ + UserId: "client_test_2", + CustomData: map[string]interface{}{ + "favouriteFood": "cake", + "favouriteNull": nil, + }, + }.GetPopulatedUser(&api.PlatformData{ + PlatformVersion: "1.1.2", + }) + user3 := api.User{ + UserId: "client_test_3", + CustomData: map[string]interface{}{ + "favouriteFood": nil, + "favouriteNull": nil, + }, + }.GetPopulatedUser(&api.PlatformData{ + PlatformVersion: "1.1.2", + }) + user4 := api.User{ + UserId: "client_test_3", + CustomData: map[string]interface{}{ + "favouriteNull": nil, + }, + }.GetPopulatedUser(&api.PlatformData{ + PlatformVersion: "1.1.2", + }) + + err := SetConfig(test_v2_config, "test", "", "", "") + require.NoError(t, err) + + // Check if users with the same alternate bucketing key get the same variation + bucketedUserConfig, err := GenerateBucketedConfig("test", user, nil) + require.NoError(t, err) + bucketedUserConfig2, err := GenerateBucketedConfig("test", user2, nil) + require.NoError(t, err) + require.Equal(t, bucketedUserConfig.FeatureVariationMap["614ef8aa475928459060721d"], bucketedUserConfig2.FeatureVariationMap["614ef8aa475928459060721d"]) + + // Check if users with explicitly null or missing alternate bucketing key get the same variation + bucketedUserConfig3, err := GenerateBucketedConfig("test", user3, nil) + require.NoError(t, err) + bucketedUserConfig4, err := GenerateBucketedConfig("test", user4, nil) + require.NoError(t, err) + + require.Equal(t, bucketedUserConfig3.FeatureVariationMap["614ef8aa475928459060721d"], bucketedUserConfig4.FeatureVariationMap["614ef8aa475928459060721d"]) +} + +func TestBucketing_Deterministic_NumberAlternateKeyRollout(t *testing.T) { + user := api.User{ + UserId: "client-test", + CustomData: map[string]interface{}{ + "favouriteFood": "cake", + "favouriteNull": nil, + "numericId": 123, + }, + }.GetPopulatedUser(&api.PlatformData{ + PlatformVersion: "1.1.2", + }) + user2 := api.User{ + UserId: "client_test_2", + CustomData: map[string]interface{}{ + "favouriteFood": "cake", + "favouriteNull": nil, + "numericId": 123, + }, + }.GetPopulatedUser(&api.PlatformData{ + PlatformVersion: "1.1.2", + }) + user3 := api.User{ + UserId: "client_test_3", + CustomData: map[string]interface{}{ + "favouriteFood": nil, + "favouriteNull": nil, + "numericId": nil, + }, + }.GetPopulatedUser(&api.PlatformData{ + PlatformVersion: "1.1.2", + }) + user4 := api.User{ + UserId: "client_test_3", + CustomData: map[string]interface{}{ + "favouriteNull": nil, + }, + }.GetPopulatedUser(&api.PlatformData{ + PlatformVersion: "1.1.2", + }) + + err := SetConfig(test_v2_config, "test", "", "", "") + require.NoError(t, err) + + // Check if users with the same alternate bucketing key get the same variation + bucketedUserConfig, err := GenerateBucketedConfig("test", user, nil) + require.NoError(t, err) + bucketedUserConfig2, err := GenerateBucketedConfig("test", user2, nil) + require.NoError(t, err) + require.Equal(t, bucketedUserConfig.FeatureVariationMap["614ef8aa475928459060721e"], bucketedUserConfig2.FeatureVariationMap["614ef8aa475928459060721e"]) + + // Check if users with explicitly null or missing alternate bucketing key get the same variation + bucketedUserConfig3, err := GenerateBucketedConfig("test", user3, nil) + require.NoError(t, err) + bucketedUserConfig4, err := GenerateBucketedConfig("test", user4, nil) + require.NoError(t, err) + + require.Equal(t, bucketedUserConfig3.FeatureVariationMap["614ef8aa475928459060721e"], bucketedUserConfig4.FeatureVariationMap["614ef8aa475928459060721e"]) +} + +func TestBucketing_Deterministic_BooleanAlternateKeyRandomDistribution(t *testing.T) { + user := api.User{ + UserId: "client-test", + CustomData: map[string]interface{}{ + "favouriteFood": "cake", + "favouriteNull": nil, + "isSubscriber": true, + }, + }.GetPopulatedUser(&api.PlatformData{ + PlatformVersion: "1.1.2", + }) + user2 := api.User{ + UserId: "client_test_2", + CustomData: map[string]interface{}{ + "favouriteFood": "cake", + "favouriteNull": nil, + "isSubscriber": true, + }, + }.GetPopulatedUser(&api.PlatformData{ + PlatformVersion: "1.1.2", + }) + user3 := api.User{ + UserId: "client_test_3", + CustomData: map[string]interface{}{ + "favouriteFood": nil, + "favouriteNull": nil, + "isSubscriber": nil, + }, + }.GetPopulatedUser(&api.PlatformData{ + PlatformVersion: "1.1.2", + }) + user4 := api.User{ + UserId: "client_test_3", + CustomData: map[string]interface{}{ + "favouriteNull": nil, + }, + }.GetPopulatedUser(&api.PlatformData{ + PlatformVersion: "1.1.2", + }) + + err := SetConfig(test_v2_config, "test", "", "", "") + require.NoError(t, err) + + // Check if users with the same alternate bucketing key get the same variation + bucketedUserConfig, err := GenerateBucketedConfig("test", user, nil) + require.NoError(t, err) + bucketedUserConfig2, err := GenerateBucketedConfig("test", user2, nil) + require.NoError(t, err) + require.Equal(t, bucketedUserConfig.FeatureVariationMap["614ef8aa475928459060721f"], bucketedUserConfig2.FeatureVariationMap["614ef8aa475928459060721f"]) + + // Check if users with explicitly null or missing alternate bucketing key get the same variation + bucketedUserConfig3, err := GenerateBucketedConfig("test", user3, nil) + require.NoError(t, err) + bucketedUserConfig4, err := GenerateBucketedConfig("test", user4, nil) + require.NoError(t, err) + + require.Equal(t, bucketedUserConfig3.FeatureVariationMap["614ef8aa475928459060721f"], bucketedUserConfig4.FeatureVariationMap["614ef8aa475928459060721f"]) +} diff --git a/bucketing/model_target.go b/bucketing/model_target.go index 9a84d4d9..eab4a421 100644 --- a/bucketing/model_target.go +++ b/bucketing/model_target.go @@ -9,6 +9,7 @@ type Target struct { Audience *Audience `json:"_audience"` Rollout *Rollout `json:"rollout"` Distribution []TargetDistribution `json:"distribution"` + BucketingKey string `json:"bucketingKey"` } func (t *Target) DecideTargetVariation(boundedHash float64) (string, error) { diff --git a/bucketing/testdata/fixture_test_v2_config.json b/bucketing/testdata/fixture_test_v2_config.json new file mode 100644 index 00000000..f4f3047b --- /dev/null +++ b/bucketing/testdata/fixture_test_v2_config.json @@ -0,0 +1,840 @@ +{ + "project": { + "_id": "61535533396f00bab586cb17", + "key": "test-project", + "a0_organization": "org_12345612345", + "settings": { + "edgeDB": { + "enabled": false + } + } + }, + "environment": { + "_id": "6153553b8cf4e45e0464268d", + "key": "test-environment" + }, + "audiences": { + "614ef6ea475929459060721a": { + "filters": { + "filters": [ + { + "type": "user", + "subType": "email", + "comparator": "=", + "values": ["test@email.com", "test2@email.com"] + } + ], + "operator": "and" + } + }, + "6153557f1ed7bac7268ea0d9": { + "filters": { + "filters": [ + { + "filters": [ + { + "type": "user", + "subType": "user_id", + "comparator": "=", + "values": ["asuh"] + }, + { + "type": "user", + "subType": "country", + "comparator": "!=", + "values": ["U S AND A"] + } + ], + "operator": "and" + }, + { + "type": "user", + "subType": "user_id", + "comparator": "=", + "values": ["asuh"] + }, + { + "type": "user", + "subType": "country", + "comparator": "!=", + "values": ["U S AND A"] + } + ], + "operator": "and" + } + }, + "6153557f1ed7bac7268ea0d5": { + "filters": { + "filters": [ + { + "type": "user", + "subType": "platformVersion", + "comparator": ">", + "values": ["1.1.1"] + }, + { + "type": "user", + "subType": "customData", + "dataKey": "favouriteFood", + "dataKeyType": "String", + "comparator": "=", + "values": ["pizza"] + }, + { + "type": "user", + "subType": "customData", + "dataKey": "favouriteDrink", + "dataKeyType": "String", + "comparator": "=", + "values": ["coffee"] + } + ], + "operator": "and" + } + }, + "6153557f1ed7bac7268ea0d6": { + "filters": { + "filters": [ + { + "type": "user", + "subType": "customData", + "dataKey": "favouriteNumber", + "dataKeyType": "Number", + "comparator": "=", + "values": [610] + }, + { + "type": "user", + "subType": "customData", + "dataKey": "favouriteBoolean", + "dataKeyType": "Boolean", + "comparator": "=", + "values": [true, false] + } + ], + "operator": "and" + } + }, + "6153557f1ed7bac7268ea074": { + "filters": { + "filters": [ + { + "type": "audienceMatch", + "comparator": "=", + "_audiences": ["614ef6ea475929459060721a"] + } + ], + "operator": "and" + } + }, + "6153557f1ed7bac7268ea0d7": { + "filters": { + "filters": [ + { + "type": "user", + "subType": "customData", + "dataKey": "favouriteNull", + "dataKeyType": "String", + "comparator": "exist", + "values": [] + } + ], + "operator": "and" + } + }, + "6153557f1ed7bac7268ea0d8": { + "filters": { + "filters": [ + { + "type": "user", + "subType": "customData", + "dataKey": "favouriteNull", + "dataKeyType": "String", + "comparator": "!exist", + "values": [] + } + ], + "operator": "and" + } + } + }, + "features": [ + { + "_id": "614ef6aa473928459060721a", + "type": "release", + "key": "feature1", + "configuration": { + "_id": "614ef6ea475328459060721a", + "targets": [ + { + "_id": "61536f3bc838a705c105eb62", + "_audience": { + "_id": "614ef6ea475929459060721a", + "filters": { + "filters": [ + { + "type": "user", + "subType": "email", + "comparator": "=", + "values": ["test@email.com", "test2@email.com"] + } + ], + "operator": "and" + } + }, + "distribution": [ + { + "_variation": "6153553b8cf4e45e0464268d", + "percentage": 0.5 + }, + { + "_variation": "615357cf7e9ebdca58446ed0", + "percentage": 0.5 + } + ] + }, + { + "_id": "61536f468fd67f0091982533", + "_audience": { + "_id": "6153557f1ed7bac7268ea0d9", + "filters": { + "filters": [ + { + "filters": [ + { + "type": "user", + "subType": "user_id", + "comparator": "=", + "values": ["asuh"] + }, + { + "type": "user", + "subType": "country", + "comparator": "!=", + "values": ["U S AND A"] + } + ], + "operator": "and" + }, + { + "type": "user", + "subType": "user_id", + "comparator": "=", + "values": ["asuh"] + }, + { + "type": "user", + "subType": "country", + "comparator": "!=", + "values": ["U S AND A"] + } + ], + "operator": "and" + } + }, + "distribution": [ + { + "_variation": "615357cf7e9ebdca58446ed0", + "percentage": 1 + } + ] + }, + { + "_id": "61536f468fd67f0091982534", + "_audience": { + "_id": "6153557f1ed7bac7268ea0d5", + "filters": { + "filters": [ + { + "type": "user", + "subType": "platformVersion", + "comparator": ">", + "values": ["1.1.1"] + }, + { + "type": "user", + "subType": "customData", + "dataKey": "favouriteFood", + "dataKeyType": "String", + "comparator": "=", + "values": ["pizza"] + }, + { + "type": "user", + "subType": "customData", + "dataKey": "favouriteDrink", + "dataKeyType": "String", + "comparator": "=", + "values": ["coffee"] + } + ], + "operator": "and" + } + }, + "rollout": { + "type": "gradual", + "startPercentage": 0, + "startDate": "2023-04-05T17:58:32.318Z", + "stages": [ + { + "type": "linear", + "percentage": 1, + "date": "2023-04-07T17:58:32.319Z" + } + ] + }, + "distribution": [ + { + "_variation": "615357cf7e9ebdca58446ed0", + "percentage": 1 + } + ] + } + ] + }, + "variations": [ + { + "_id": "6153553b8cf4e45e0464268d", + "name": "variation 1", + "key": "variation-1-key", + "variables": [ + { + "_var": "614ef6ea475129459160721a", + "value": "scat" + }, + { + "_var": "615356f120ed334a6054564c", + "value": "man" + }, + { + "_var": "61538237b0a70b58ae6af71y", + "value": false + }, + { + "_var": "61538237b0a70b58ae6af71s", + "value": 610.61 + }, + { + "_var": "61538237b0a70b58ae6af71q", + "value": "{\"hello\":\"world\",\"num\":610,\"bool\":true}" + } + ] + }, + { + "_id": "615357cf7e9ebdca58446ed0", + "name": "variation 2", + "key": "variation-2-key", + "variables": [ + { + "_var": "615356f120ed334a6054564c", + "value": "YEEEEOWZA" + }, + { + "_var": "61538237b0a70b58ae6af71y", + "value": false + }, + { + "_var": "61538237b0a70b58ae6af71s", + "value": 610.61 + }, + { + "_var": "61538237b0a70b58ae6af71q", + "value": "{\"hello\":\"world\",\"num\":610,\"bool\":true}" + } + ] + } + ] + }, + { + "_id": "614ef6aa475928459060721a", + "type": "release", + "key": "feature2", + "configuration": { + "_id": "61536f62502d80fff97ed649", + "targets": [ + { + "_id": "61536f468fd67f0091982533", + "_audience": { + "_id": "614ef6ea475929459060721a", + "filters": { + "filters": [ + { + "type": "user", + "subType": "email", + "comparator": "=", + "values": ["test@email.com", "test2@email.com"] + } + ], + "operator": "and" + } + }, + "distribution": [ + { + "_variation": "615382338424cb11646d7668", + "percentage": 1 + }, + { + "_variation": "615382338424cb11646d7669", + "percentage": 0 + } + ] + }, + { + "_id": "61536f669c69b86cccc5f15e", + "_audience": { + "_id": "6153557f1ed7bac7268ea0d5", + "filters": { + "filters": [ + { + "type": "user", + "subType": "platformVersion", + "comparator": ">", + "values": ["1.1.1"] + }, + { + "type": "user", + "subType": "customData", + "dataKey": "favouriteFood", + "dataKeyType": "String", + "comparator": "=", + "values": ["pizza"] + }, + { + "type": "user", + "subType": "customData", + "dataKey": "favouriteDrink", + "dataKeyType": "String", + "comparator": "=", + "values": ["coffee"] + } + ], + "operator": "and" + } + }, + "distribution": [ + { + "_variation": "615382338424cb11646d7667", + "percentage": 1 + } + ] + } + ] + }, + "variations": [ + { + "_id": "615382338424cb11646d7667", + "name": "variation 1 aud 2", + "key": "variation-1-aud-2-key", + "variables": [ + { + "_var": "61538237b0a70b58ae6af71g", + "value": "Var 1 aud 2" + }, + { + "_var": "61538237b0a70b58ae6af71h", + "value": "Var 1 aud 2" + }, + { + "_var": "61538237b0a70b58ae6af71f", + "value": "Var 1 aud 2" + } + ] + }, + { + "_id": "615382338424cb11646d7668", + "name": "feature 2 variation", + "key": "variation-feature-2-key", + "variables": [ + { + "_var": "61538237b0a70b58ae6af71g", + "value": "multivar first" + }, + { + "_var": "61538237b0a70b58ae6af71h", + "value": "multivar last" + }, + { + "_var": "61538237b0a70b58ae6af71f", + "value": "Var 1 multivar" + } + ] + }, + { + "_id": "615382338424cb11646d7669", + "name": "feature 2 never used variation", + "key": "variation-never-used-key", + "variables": [ + { + "_var": "61538237b0a70b58ae6af71g", + "value": "multivar first unused" + }, + { + "_var": "61538237b0a70b58ae6af71h", + "value": "multivar last unused" + }, + { + "_var": "61538237b0a70b58ae6af71f", + "value": "Var 1 multivar" + } + ] + } + ] + }, + { + "_id": "614ef6aa475928459060721c", + "type": "release", + "key": "feature3", + "configuration": { + "_id": "61536f62502d80fff97ed640", + "targets": [ + { + "_id": "61536f468fd67f0091982531", + "_audience": { + "_id": "6153557f1ed7bac7268ea074", + "filters": { + "filters": [ + { + "type": "audienceMatch", + "comparator": "=", + "_audiences": ["614ef6ea475929459060721a"] + } + ], + "operator": "and" + } + }, + "distribution": [ + { + "_variation": "615382338424cb11646d7662", + "percentage": 1 + } + ] + } + ] + }, + "variations": [ + { + "_id": "615382338424cb11646d7662", + "name": "audience match variation", + "key": "audience-match-variation", + "variables": [ + { + "_var": "61538237b0a70b58ae6af71z", + "value": "audience_match" + } + ] + } + ] + }, + { + "_id": "614ef8aa475928459060721c", + "type": "release", + "key": "feature4", + "configuration": { + "_id": "61536f62502d80fff97ed640", + "targets": [ + { + "_id": "61536f468fd67f0091982531", + "_audience": { + "_id": "614ef6ea475929459060721a", + "filters": { + "filters": [ + { + "type": "user", + "subType": "email", + "comparator": "=", + "values": ["test@email.com", "test2@email.com"] + } + ], + "operator": "and" + } + }, + "distribution": [ + { + "_variation": "615382338424cb11646d9668", + "percentage": 1 + } + ] + } + ] + }, + "variations": [ + { + "_id": "615382338424cb11646d9668", + "name": "feature 4 variation", + "key": "variation-feature-2-key", + "variables": [ + { + "_var": "61538937b0a70b58ae6af71f", + "value": "feature 4 value" + } + ] + } + ] + }, + { + "_id": "614ef8aa475928459060721d", + "type": "experiment", + "key": "header-copy", + "configuration": { + "_id": "61536f62502d80fff97ed641", + "targets": [ + { + "_id": "61536f468fd67f0091982532", + "_audience": { + "filters": { + "filters": [ + { + "type": "all", + "subType": "", + "comparator": "", + "values": [] + } + ], + "operator": "and" + } + }, + "distribution": [ + { + "_variation": "615382338424cb11646d9669", + "percentage": 0.5 + }, + { + "_variation": "615382338424cb11646d9670", + "percentage": 0.5 + } + ], + "bucketingKey": "favouriteFood" + } + ] + }, + "variations": [ + { + "_id": "615382338424cb11646d9669", + "name": "New Copy", + "key": "new-copy", + "variables": [ + { + "_var": "61538937b0a70b58ae6af71x", + "value": "New!" + } + ] + }, + { + "_id": "615382338424cb11646d9670", + "name": "Old Copy", + "key": "old-copy", + "variables": [ + { + "_var": "61538937b0a70b58ae6af71x", + "value": "default header" + } + ] + } + ] + }, + { + "_id": "614ef8aa475928459060721e", + "type": "permission", + "key": "feature_access", + "configuration": { + "_id": "61536f62502d80fff97ed642", + "targets": [ + { + "_id": "61536f468fd67f0091982533", + "_audience": { + "filters": { + "filters": [ + { + "type": "all", + "subType": "", + "comparator": "", + "values": [] + } + ], + "operator": "and" + } + }, + "distribution": [ + { + "_variation": "615382338424cb11646d9671", + "percentage": 1 + } + ], + "rollout": { + "type": "gradual", + "startPercentage": 0, + "startDate": "2024-04-05T17:58:32.318Z", + "stages": [ + { + "type": "linear", + "percentage": 1, + "date": "2034-04-07T17:58:32.319Z" + } + ] + }, + "bucketingKey": "numericId" + } + ] + }, + "variations": [ + { + "_id": "615382338424cb11646d9671", + "name": "Has Access", + "key": "has-access", + "variables": [ + { + "_var": "61538937b0a70b58ae6af71g", + "value": true + } + ] + }, + { + "_id": "615382338424cb11646d9672", + "name": "No Access", + "key": "no-access", + "variables": [ + { + "_var": "61538937b0a70b58ae6af71g", + "value": false + } + ] + } + ] + }, + { + "_id": "614ef8aa475928459060721f", + "type": "ops", + "key": "operational_guard", + "configuration": { + "_id": "61536f62502d80fff97ed643", + "targets": [ + { + "_id": "61536f468fd67f0091982535", + "_audience": { + "filters": { + "filters": [ + { + "type": "all", + "subType": "", + "comparator": "", + "values": [] + } + ], + "operator": "and" + } + }, + "distribution": [ + { + "_variation": "615382338424cb11646d9673", + "percentage": 0.5 + }, + { + "_variation": "615382338424cb11646d9674", + "percentage": 0.5 + } + ], + "bucketingKey": "isSubscriber" + } + ] + }, + "variations": [ + { + "_id": "615382338424cb11646d9673", + "name": "Has Access", + "key": "has-access", + "variables": [ + { + "_var": "61538937b0a70b58ae6af71h", + "value": true + } + ] + }, + { + "_id": "615382338424cb11646d9674", + "name": "No Access", + "key": "no-access", + "variables": [ + { + "_var": "61538937b0a70b58ae6af71h", + "value": false + } + ] + } + ] + } + ], + "variables": [ + { + "_id": "614ef6ea475129459160721a", + "type": "String", + "key": "test" + }, + { + "_id": "615356f120ed334a6054564c", + "type": "String", + "key": "swagTest" + }, + { + "_id": "61538237b0a70b58ae6af71f", + "type": "String", + "key": "feature2Var" + }, + { + "_id": "61538237b0a70b58ae6af71g", + "type": "String", + "key": "feature2.cool" + }, + { + "_id": "61538237b0a70b58ae6af71h", + "type": "String", + "key": "feature2.hello" + }, + { + "_id": "61538237b0a70b58ae6af71z", + "type": "String", + "key": "audience-match" + }, + { + "_id": "61538237b0a70b58ae6af71y", + "type": "Boolean", + "key": "bool-var" + }, + { + "_id": "61538237b0a70b58ae6af71s", + "type": "Number", + "key": "num-var" + }, + { + "_id": "61538237b0a70b58ae6af71q", + "type": "JSON", + "key": "json-var" + }, + { + "_id": "61538937b0a70b58ae6af71f", + "type": "String", + "key": "feature4Var" + }, + { + "_id": "61538937b0a70b58ae6af71x", + "type": "String", + "key": "experiment_var" + }, + { + "_id": "61538937b0a70b58ae6af71g", + "type": "Boolean", + "key": "new_feature" + }, + { + "_id": "61538937b0a70b58ae6af71h", + "type": "Boolean", + "key": "gated_access" + } + ], + "variableHashes": { + "test": 3126796075, + "swagTest": 2547774734, + "feature2Var": 1879689550, + "feature2.cool": 2621975932, + "feature2.hello": 4138596111 + } +} \ No newline at end of file