Skip to content

Commit 019ab1d

Browse files
authored
feat: fractional evaluations (#136)
Signed-off-by: Skye Gill <[email protected]>
1 parent da66f55 commit 019ab1d

File tree

8 files changed

+593
-67
lines changed

8 files changed

+593
-67
lines changed

README.md

Lines changed: 2 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -229,74 +229,9 @@ returns
229229
{"value":true,"reason":"TARGETING_MATCH","variant":"off"}
230230
```
231231

232-
### Reusable targeting rules
232+
### [Reusable targeting rules](./docs/reusable_targeting_rules.md)
233233

234-
At the same level as the `flags` key one can define an `$evaluators` object. Each object defined under `$evaluators` is
235-
a reusable targeting rule. In any targeting rule one can reference a defined reusable targeting rule, foo, like so:
236-
`"$ref": "foo"`
237-
238-
<u>Example</u>
239-
240-
Flags/evaluators defined as such:
241-
242-
```json
243-
{
244-
"flags": {
245-
"fibAlgo": {
246-
"variants": {
247-
"recursive": "recursive",
248-
"memo": "memo",
249-
"loop": "loop",
250-
"binet": "binet"
251-
},
252-
"defaultVariant": "recursive",
253-
"state": "ENABLED",
254-
"targeting": {
255-
"if": [
256-
{
257-
"$ref": "emailWithFaas"
258-
}, "binet", null
259-
]
260-
}
261-
}
262-
},
263-
"$evaluators": {
264-
"emailWithFaas": {
265-
"in": ["@faas.com", {
266-
"var": ["email"]
267-
}]
268-
}
269-
}
270-
}
271-
```
272-
273-
becomes (once the `$evaluators` have been substituted):
274-
275-
```json
276-
{
277-
"flags": {
278-
"fibAlgo": {
279-
"variants": {
280-
"recursive": "recursive",
281-
"memo": "memo",
282-
"loop": "loop",
283-
"binet": "binet"
284-
},
285-
"defaultVariant": "recursive",
286-
"state": "ENABLED",
287-
"targeting": {
288-
"if": [
289-
{
290-
"in": ["@faas.com", {
291-
"var": ["email"]
292-
}]
293-
}, "binet", null
294-
]
295-
}
296-
}
297-
}
298-
}
299-
```
234+
### [Fractional Evaluation](./docs/fractional_evaluation.md)
300235

301236
### The people who make flagD great 💜
302237

config/samples/example_flags.json

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,44 @@
8484
}, "binet", null
8585
]
8686
}
87+
},
88+
"headerColor": {
89+
"variants": {
90+
"red": "#FF0000",
91+
"blue": "#0000FF",
92+
"green": "#00FF00",
93+
"yellow": "#FFFF00"
94+
},
95+
"defaultVariant": "red",
96+
"state": "ENABLED",
97+
"targeting": {
98+
"if": [
99+
{
100+
"$ref": "emailWithFaas"
101+
},
102+
{
103+
"fractionalEvaluation": [
104+
"email",
105+
[
106+
"red",
107+
25
108+
],
109+
[
110+
"blue",
111+
25
112+
],
113+
[
114+
"green",
115+
25
116+
],
117+
[
118+
"yellow",
119+
25
120+
]
121+
]
122+
}, null
123+
]
124+
}
87125
}
88126
},
89127
"$evaluators": {

docs/fractional_evaluation.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
### Fractional Evaluation
2+
3+
The `fractionalEvaluation` operation is a custom JsonLogic operation which deterministically selects a variant based on
4+
the defined distribution of each variant (as a percentage). This works by hashing ([murmur3](https://en.wikipedia.org/wiki/MurmurHash))
5+
the given data point, converting it into an int in the range [0, 99]. Whichever range this int falls in decides which variant
6+
is selected. As hashing is deterministic we can be sure to get the same result every time for the same data point.
7+
8+
<u>Example</u>
9+
10+
Flags defined as such:
11+
12+
```json
13+
{
14+
"flags": {
15+
"headerColor": {
16+
"variants": {
17+
"red": "#FF0000",
18+
"blue": "#0000FF",
19+
"green": "#00FF00"
20+
},
21+
"defaultVariant": "red",
22+
"state": "ENABLED",
23+
"targeting": {
24+
"fractionalEvaluation": [
25+
"email",
26+
[
27+
"red",
28+
50
29+
],
30+
[
31+
"blue",
32+
20
33+
],
34+
[
35+
"green",
36+
30
37+
]
38+
]
39+
}
40+
}
41+
}
42+
}
43+
```
44+
45+
will return variant `red` 50% of the time, `blue` 20% of the time & `green` 30% of the time.
46+
47+
```shell
48+
$ curl -X POST "localhost:8013/flags/headerColor/resolve/string" -d '{"email": "[email protected]"}'
49+
{"value":"#0000FF","reason":"TARGETING_MATCH","variant":"blue"}%
50+
51+
$ curl -X POST "localhost:8013/flags/headerColor/resolve/string" -d '{"email": "[email protected]"}'
52+
{"value":"#00FF00","reason":"TARGETING_MATCH","variant":"green"}%
53+
```

docs/reusable_targeting_rules.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
### Reusable targeting rules
2+
3+
At the same level as the `flags` key one can define an `$evaluators` object. Each object defined under `$evaluators` is
4+
a reusable targeting rule. In any targeting rule one can reference a defined reusable targeting rule, foo, like so:
5+
`"$ref": "foo"`
6+
7+
<u>Example</u>
8+
9+
Flags/evaluators defined as such:
10+
11+
```json
12+
{
13+
"flags": {
14+
"fibAlgo": {
15+
"variants": {
16+
"recursive": "recursive",
17+
"memo": "memo",
18+
"loop": "loop",
19+
"binet": "binet"
20+
},
21+
"defaultVariant": "recursive",
22+
"state": "ENABLED",
23+
"targeting": {
24+
"if": [
25+
{
26+
"$ref": "emailWithFaas"
27+
}, "binet", null
28+
]
29+
}
30+
}
31+
},
32+
"$evaluators": {
33+
"emailWithFaas": {
34+
"in": ["@faas.com", {
35+
"var": ["email"]
36+
}]
37+
}
38+
}
39+
}
40+
```
41+
42+
becomes (once the `$evaluators` have been substituted):
43+
44+
```json
45+
{
46+
"flags": {
47+
"fibAlgo": {
48+
"variants": {
49+
"recursive": "recursive",
50+
"memo": "memo",
51+
"loop": "loop",
52+
"binet": "binet"
53+
},
54+
"defaultVariant": "recursive",
55+
"state": "ENABLED",
56+
"targeting": {
57+
"if": [
58+
{
59+
"in": ["@faas.com", {
60+
"var": ["email"]
61+
}]
62+
}, "binet", null
63+
]
64+
}
65+
}
66+
}
67+
}
68+
```

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ require (
3737
github.com/pelletier/go-toml v1.9.4 // indirect
3838
github.com/pelletier/go-toml/v2 v2.0.0-beta.8 // indirect
3939
github.com/pmezard/go-difflib v1.0.0 // indirect
40+
github.com/spaolacci/murmur3 v1.1.0 // indirect
4041
github.com/spf13/afero v1.8.2 // indirect
4142
github.com/spf13/cast v1.4.1 // indirect
4243
github.com/spf13/jwalterweatherman v1.1.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,8 @@ github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic
298298
github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js=
299299
github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0=
300300
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
301+
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
302+
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
301303
github.com/spf13/afero v1.8.2 h1:xehSyVa0YnHWsJ49JFljMpg1HX19V6NDZ1fkm1Xznbo=
302304
github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
303305
github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA=

pkg/eval/fractional_evaluation.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package eval
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"math"
7+
8+
"github.com/diegoholiveira/jsonlogic/v3"
9+
log "github.com/sirupsen/logrus"
10+
"github.com/spaolacci/murmur3"
11+
)
12+
13+
func init() {
14+
jsonlogic.AddOperator("fractionalEvaluation", fractionalEvaluation)
15+
}
16+
17+
type fractionalEvaluationDistribution struct {
18+
variant string
19+
percentage int
20+
}
21+
22+
func fractionalEvaluation(values, data interface{}) interface{} {
23+
valueToDistribute, feDistributions, err := parseFractionalEvaluationData(values, data)
24+
if err != nil {
25+
log.Errorf("parseFractionalEvaluationData: %v", err)
26+
return nil
27+
}
28+
29+
return distributeValue(valueToDistribute, feDistributions)
30+
}
31+
32+
func parseFractionalEvaluationData(values, data interface{}) (string, []fractionalEvaluationDistribution, error) {
33+
valuesArray, ok := values.([]interface{})
34+
if !ok {
35+
return "", nil, errors.New("fractional evaluation data is not an array")
36+
}
37+
if len(valuesArray) < 2 {
38+
return "", nil, errors.New("fractional evaluation data has length under 2")
39+
}
40+
41+
bucketBy, ok := valuesArray[0].(string)
42+
if !ok {
43+
return "", nil, errors.New("first element of fractional evaluation data isn't of type string")
44+
}
45+
46+
dataMap, ok := data.(map[string]interface{})
47+
if !ok {
48+
return "", nil, errors.New("data isn't of type map[string]interface{}")
49+
}
50+
51+
v, ok := dataMap[bucketBy]
52+
if !ok {
53+
return "", nil, fmt.Errorf("%s isn't a found var in data", bucketBy)
54+
}
55+
56+
valueToDistribute, ok := v.(string)
57+
if !ok {
58+
return "", nil, fmt.Errorf("var %s isn't of type string", bucketBy)
59+
}
60+
61+
feDistributions, err := parseFractionalEvaluationDistributions(valuesArray)
62+
if err != nil {
63+
return "", nil, err
64+
}
65+
66+
return valueToDistribute, feDistributions, nil
67+
}
68+
69+
func parseFractionalEvaluationDistributions(values []interface{}) ([]fractionalEvaluationDistribution, error) {
70+
sumOfPercentages := 0
71+
var feDistributions []fractionalEvaluationDistribution
72+
for i := 1; i < len(values); i++ {
73+
distributionArray, ok := values[i].([]interface{})
74+
if !ok {
75+
return nil, errors.New("distribution elements aren't of type []interface{}")
76+
}
77+
78+
if len(distributionArray) != 2 {
79+
return nil, errors.New("distribution element isn't length 2")
80+
}
81+
82+
variant, ok := distributionArray[0].(string)
83+
if !ok {
84+
return nil, errors.New("first element of distribution element isn't string")
85+
}
86+
87+
percentage, ok := distributionArray[1].(float64)
88+
if !ok {
89+
return nil, errors.New("second element of distribution element isn't float")
90+
}
91+
92+
sumOfPercentages += int(percentage)
93+
94+
feDistributions = append(feDistributions, fractionalEvaluationDistribution{
95+
variant: variant,
96+
percentage: int(percentage),
97+
})
98+
}
99+
100+
if sumOfPercentages != 100 {
101+
return nil, fmt.Errorf("percentages must sum to 100, got %d", sumOfPercentages)
102+
}
103+
104+
return feDistributions, nil
105+
}
106+
107+
func distributeValue(value string, feDistribution []fractionalEvaluationDistribution) string {
108+
hashValue := murmur3.Sum64([]byte(value))
109+
110+
hashRatio := float64(hashValue) / math.Pow(2, 64) // divide the hash value by the largest possible value, integer 2^64
111+
112+
bucket := int(hashRatio * 100) // integer in range [0, 99]
113+
114+
rangeEnd := 0
115+
for _, dist := range feDistribution {
116+
rangeEnd += dist.percentage
117+
if bucket < rangeEnd {
118+
return dist.variant
119+
}
120+
}
121+
122+
return ""
123+
}

0 commit comments

Comments
 (0)