Skip to content

Commit 55fff0a

Browse files
author
Michael Ng
authored
feat(decision): Add experiment bucketer implementation. (#32)
1 parent 33bbba5 commit 55fff0a

File tree

4 files changed

+186
-1
lines changed

4 files changed

+186
-1
lines changed

optimizely/client/client.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ type OptimizelyClient struct {
3535
// IsFeatureEnabled returns true if the feature is enabled for the given user
3636
func (optly *OptimizelyClient) IsFeatureEnabled(featureKey string, userID string, attributes map[string]interface{}) bool {
3737
if !optly.isValid {
38-
logger.Error("Optimizely instance is not valid. Failing IsFeatureEnabled.", nil)
38+
errorMessage := "Optimizely instance is not valid. Failing IsFeatureEnabled."
39+
logger.Error(errorMessage, nil)
3940
return false
4041
}
4142

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package bucketer
2+
3+
import (
4+
"math"
5+
6+
"github.com/optimizely/go-sdk/optimizely/entities"
7+
"github.com/twmb/murmur3"
8+
)
9+
10+
var maxHashValue = float32(math.Pow(2, 32))
11+
12+
// DefaultHashSeed is the hash seed to use for murmurhash
13+
const DefaultHashSeed = 1
14+
const maxTrafficValue = 10000
15+
16+
// ExperimentBucketer buckets the user
17+
type ExperimentBucketer struct {
18+
hashSeed uint32
19+
}
20+
21+
// NewExperimentBucketer returns a new instance of the experiment bucketer
22+
func NewExperimentBucketer(hashSeed uint32) *ExperimentBucketer {
23+
return &ExperimentBucketer{
24+
hashSeed: hashSeed,
25+
}
26+
}
27+
28+
// Bucket buckets the user into the given experiment
29+
func (b *ExperimentBucketer) Bucket(bucketingID string, experiment entities.Experiment, group entities.Group) entities.Variation {
30+
if experiment.GroupID != "" && group.Policy == "random" {
31+
bucketKey := bucketingID + group.ID
32+
bucketedExperimentID := b.bucketToEntity(bucketKey, group.TrafficAllocation)
33+
if bucketedExperimentID == "" || bucketedExperimentID != experiment.ID {
34+
// User is not bucketed into an experiment in the exclusion group, return an empty variation
35+
return entities.Variation{}
36+
}
37+
}
38+
39+
bucketKey := bucketingID + experiment.ID
40+
bucketedVariationID := b.bucketToEntity(bucketKey, experiment.TrafficAllocation)
41+
if bucketedVariationID == "" {
42+
// User is not bucketed into a variation in the experiment, return an empty variation
43+
return entities.Variation{}
44+
}
45+
46+
if variation, ok := experiment.Variations[bucketedVariationID]; ok {
47+
return variation
48+
}
49+
50+
return entities.Variation{}
51+
}
52+
53+
func (b *ExperimentBucketer) bucketToEntity(bucketKey string, trafficAllocations []entities.Range) (entityID string) {
54+
// TODO(mng): return log message re: bucket value
55+
bucketValue := b.generateBucketValue(bucketKey)
56+
var currentEndOfRange int
57+
for _, trafficAllocationRange := range trafficAllocations {
58+
currentEndOfRange = trafficAllocationRange.EndOfRange
59+
if bucketValue < currentEndOfRange {
60+
return trafficAllocationRange.EntityID
61+
}
62+
}
63+
64+
return ""
65+
}
66+
67+
func (b *ExperimentBucketer) generateBucketValue(bucketingKey string) int {
68+
hasher := murmur3.SeedNew32(b.hashSeed)
69+
hasher.Write([]byte(bucketingKey))
70+
hashCode := hasher.Sum32()
71+
ratio := float32(hashCode) / maxHashValue
72+
return int(ratio * maxTrafficValue)
73+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package bucketer
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/optimizely/go-sdk/optimizely/entities"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestGenerateBucketValue(t *testing.T) {
12+
bucketer := NewExperimentBucketer(DefaultHashSeed)
13+
14+
// copied from unit tests in the other SDKs
15+
experimentID := "1886780721"
16+
experimentID2 := "1886780722"
17+
bucketingKey1 := fmt.Sprintf("%s%s", "ppid1", experimentID)
18+
bucketingKey2 := fmt.Sprintf("%s%s", "ppid2", experimentID)
19+
bucketingKey3 := fmt.Sprintf("%s%s", "ppid2", experimentID2)
20+
bucketingKey4 := fmt.Sprintf("%s%s", "ppid3", experimentID)
21+
22+
assert.Equal(t, 5254, bucketer.generateBucketValue(bucketingKey1))
23+
assert.Equal(t, 4299, bucketer.generateBucketValue(bucketingKey2))
24+
assert.Equal(t, 2434, bucketer.generateBucketValue(bucketingKey3))
25+
assert.Equal(t, 5439, bucketer.generateBucketValue(bucketingKey4))
26+
}
27+
28+
func TestBucketToEntity(t *testing.T) {
29+
bucketer := NewExperimentBucketer(DefaultHashSeed)
30+
31+
experimentID := "1886780721"
32+
experimentID2 := "1886780722"
33+
34+
// bucket value 5254
35+
bucketingKey1 := fmt.Sprintf("%s%s", "ppid1", experimentID)
36+
// bucket value 4299
37+
bucketingKey2 := fmt.Sprintf("%s%s", "ppid2", experimentID)
38+
// bucket value 2434
39+
bucketingKey3 := fmt.Sprintf("%s%s", "ppid2", experimentID2)
40+
// bucket value 5439
41+
bucketingKey4 := fmt.Sprintf("%s%s", "ppid3", experimentID)
42+
43+
variation1 := "1234567123"
44+
variation2 := "5949300123"
45+
trafficAlloc := []entities.Range{
46+
entities.Range{
47+
EntityID: "",
48+
EndOfRange: 2500,
49+
},
50+
entities.Range{
51+
EntityID: variation1,
52+
EndOfRange: 4999,
53+
},
54+
entities.Range{
55+
EntityID: variation2,
56+
EndOfRange: 5399,
57+
},
58+
}
59+
60+
assert.Equal(t, variation2, bucketer.bucketToEntity(bucketingKey1, trafficAlloc))
61+
assert.Equal(t, variation1, bucketer.bucketToEntity(bucketingKey2, trafficAlloc))
62+
63+
// bucket to empty variation range
64+
assert.Equal(t, "", bucketer.bucketToEntity(bucketingKey3, trafficAlloc))
65+
66+
// bucket outside of range (not in experiment)
67+
assert.Equal(t, "", bucketer.bucketToEntity(bucketingKey4, trafficAlloc))
68+
}
69+
70+
func TestBucketExclusionGroups(t *testing.T) {
71+
experiment1 := entities.Experiment{
72+
ID: "1886780721",
73+
Variations: map[string]entities.Variation{
74+
"22222": entities.Variation{ID: "22222", Key: "exp_1_var_1"},
75+
"22223": entities.Variation{ID: "22223", Key: "exp_1_var_2"},
76+
},
77+
TrafficAllocation: []entities.Range{
78+
entities.Range{EntityID: "22222", EndOfRange: 4999},
79+
entities.Range{EntityID: "22223", EndOfRange: 10000},
80+
},
81+
GroupID: "1886780722",
82+
}
83+
experiment2 := entities.Experiment{
84+
ID: "1886780723",
85+
Variations: map[string]entities.Variation{
86+
"22224": entities.Variation{ID: "22224", Key: "exp_2_var_1"},
87+
"22225": entities.Variation{ID: "22225", Key: "exp_2_var_2"},
88+
},
89+
TrafficAllocation: []entities.Range{
90+
entities.Range{EntityID: "22224", EndOfRange: 4999},
91+
entities.Range{EntityID: "22225", EndOfRange: 10000},
92+
},
93+
GroupID: "1886780722",
94+
}
95+
96+
exclusionGroup := entities.Group{
97+
ID: "1886780722",
98+
Policy: "random",
99+
TrafficAllocation: []entities.Range{
100+
entities.Range{EntityID: "1886780721", EndOfRange: 2500},
101+
entities.Range{EntityID: "1886780723", EndOfRange: 5000},
102+
},
103+
}
104+
105+
bucketer := NewExperimentBucketer(DefaultHashSeed)
106+
// ppid2 + 1886780722 (groupId) will generate bucket value of 2434 which maps to experiment 1
107+
assert.Equal(t, experiment1.Variations["22222"], bucketer.Bucket("ppid2", experiment1, exclusionGroup))
108+
// since the bucket value maps to experiment 1, the user will not be bucketed for experiment 2
109+
assert.Equal(t, entities.Variation{}, bucketer.Bucket("ppid2", experiment2, exclusionGroup))
110+
}

optimizely/entities/group.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@ package entities
2020
type Group struct {
2121
ID string
2222
TrafficAllocation []Range
23+
Policy string
2324
}

0 commit comments

Comments
 (0)