Skip to content

Commit

Permalink
feat: Add support for alternate key bucketing (#266)
Browse files Browse the repository at this point in the history
* feat: add alternate key bucketing

* fix: update determineUserBucketingValue to allow int and float64 conversion to string

* fix: create new v2 test config. refactor function to take targetBucketingKey as the first param

* feat: add support for booleans for alternate key bucketing
  • Loading branch information
kaushalkapasi authored Aug 16, 2024
1 parent 74eadef commit 7475535
Show file tree
Hide file tree
Showing 5 changed files with 1,062 additions and 10 deletions.
6 changes: 3 additions & 3 deletions api/model_public_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 39 additions & 5 deletions bucketing/bucketing.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package bucketing

import (
"errors"
"strconv"
"time"

"github.com/devcyclehq/go-server-sdk/v2/api"
Expand All @@ -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")
Expand All @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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

Expand Down
181 changes: 179 additions & 2 deletions bucketing/bucketing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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"])
}
1 change: 1 addition & 0 deletions bucketing/model_target.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading

0 comments on commit 7475535

Please sign in to comment.