Skip to content

Commit 456abba

Browse files
Merge branch 'go-alpha' into eventProcessor
2 parents c00d9ae + 20e7011 commit 456abba

File tree

12 files changed

+733
-5
lines changed

12 files changed

+733
-5
lines changed

.travis.yml

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,25 @@
11
language: go
2-
32
go:
43
- 1.12.x
54
- master
6-
75
git:
86
depth: 1
9-
107
script:
118
- go test -v -race ./...
9+
stages:
10+
- 'Integration tests'
11+
- 'Test'
12+
jobs:
13+
include:
14+
- stage: 'Integration Tests'
15+
merge_mode: replace
16+
env: SDK=go
17+
cache: false
18+
language: minimal
19+
install: skip
20+
before_script:
21+
- mkdir $HOME/travisci-tools && pushd $HOME/travisci-tools && git init && git pull https://[email protected]/optimizely/travisci-tools.git && popd
22+
script:
23+
- "$HOME/travisci-tools/fsc-trigger/trigger_fullstack-sdk-compat.sh"
24+
after_success: travis_terminate 0
25+

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/decision/evaluator/condition.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ import (
2424
)
2525

2626
const (
27-
// EXACT match type performs an equality comparison
2827
exactMatchType = "exact"
28+
existsMatchType = "exists"
29+
ltMatchType = "lt"
30+
gtMatchType = "gt"
2931
)
3032

3133
// ConditionEvaluator evaluates a condition against the given user's attributes
@@ -50,6 +52,18 @@ func (c CustomAttributeConditionEvaluator) Evaluate(condition entities.Condition
5052
matcher = matchers.ExactMatcher{
5153
Condition: condition,
5254
}
55+
case existsMatchType:
56+
matcher = matchers.ExistsMatcher{
57+
Condition: condition,
58+
}
59+
case ltMatchType:
60+
matcher = matchers.LtMatcher{
61+
Condition: condition,
62+
}
63+
case gtMatchType:
64+
matcher = matchers.GtMatcher{
65+
Condition: condition,
66+
}
5367
}
5468

5569
result, err := matcher.Match(user)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/****************************************************************************
2+
* Copyright 2019, Optimizely, Inc. and contributors *
3+
* *
4+
* Licensed under the Apache License, Version 2.0 (the "License"); *
5+
* you may not use this file except in compliance with the License. *
6+
* You may obtain a copy of the License at *
7+
* *
8+
* http://www.apache.org/licenses/LICENSE-2.0 *
9+
* *
10+
* Unless required by applicable law or agreed to in writing, software *
11+
* distributed under the License is distributed on an "AS IS" BASIS, *
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
13+
* See the License for the specific language governing permissions and *
14+
* limitations under the License. *
15+
***************************************************************************/
16+
17+
package matchers
18+
19+
import (
20+
"github.com/optimizely/go-sdk/optimizely/entities"
21+
)
22+
23+
// ExistsMatcher matches against the "exists" match type
24+
type ExistsMatcher struct {
25+
Condition entities.Condition
26+
}
27+
28+
// Match returns true if the user's attribute is in the condition
29+
func (m ExistsMatcher) Match(user entities.UserContext) (bool, error) {
30+
31+
_, err := user.Attributes.GetString(m.Condition.Name)
32+
if err != nil {
33+
return false, nil
34+
}
35+
36+
return true, nil
37+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/****************************************************************************
2+
* Copyright 2019, Optimizely, Inc. and contributors *
3+
* *
4+
* Licensed under the Apache License, Version 2.0 (the "License"); *
5+
* you may not use this file except in compliance with the License. *
6+
* You may obtain a copy of the License at *
7+
* *
8+
* http://www.apache.org/licenses/LICENSE-2.0 *
9+
* *
10+
* Unless required by applicable law or agreed to in writing, software *
11+
* distributed under the License is distributed on an "AS IS" BASIS, *
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
13+
* See the License for the specific language governing permissions and *
14+
* limitations under the License. *
15+
***************************************************************************/
16+
17+
package matchers
18+
19+
import (
20+
"testing"
21+
22+
"github.com/stretchr/testify/assert"
23+
24+
"github.com/optimizely/go-sdk/optimizely/entities"
25+
)
26+
27+
func TestExistsMatcher(t *testing.T) {
28+
matcher := ExistsMatcher{
29+
Condition: entities.Condition{
30+
Match: "exists",
31+
Name: "string_foo",
32+
},
33+
}
34+
35+
// Test match
36+
user := entities.UserContext{
37+
Attributes: entities.UserAttributes{
38+
Attributes: map[string]interface{}{
39+
"string_foo": "any_value",
40+
},
41+
},
42+
}
43+
result, err := matcher.Match(user)
44+
assert.NoError(t, err)
45+
assert.True(t, result)
46+
47+
// Test no match
48+
user = entities.UserContext{
49+
Attributes: entities.UserAttributes{
50+
Attributes: map[string]interface{}{
51+
"string_foo1": "not_foo",
52+
},
53+
},
54+
}
55+
56+
result, err = matcher.Match(user)
57+
assert.NoError(t, err)
58+
assert.False(t, result)
59+
60+
// Test null case
61+
user = entities.UserContext{
62+
Attributes: entities.UserAttributes{
63+
Attributes: map[string]interface{}{},
64+
},
65+
}
66+
result, err = matcher.Match(user)
67+
assert.NoError(t, err)
68+
assert.False(t, result)
69+
}
70+

0 commit comments

Comments
 (0)