├── .github └── workflows │ └── main.yml ├── .gitignore ├── CONTRIBUTING ├── LICENSE ├── README.md ├── backend.go ├── backend_test.go ├── clamp └── clamp.go ├── doc └── rule_flags.md ├── examples_whitebox_test.go ├── fastflags.go ├── fasttags.go ├── flags └── rand.go ├── flags2 └── flags2.go ├── flags2_test.go ├── go.mod ├── go.sum ├── goforit.go ├── goforit_test.go ├── internal └── safepool │ └── rand.go ├── metrics.go └── testdata ├── flags2_acceptance.json ├── flags2_example.json ├── flags2_example_no_timestamp.json └── flags2_multiple_definitions.json /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | on: 3 | push: 4 | branches: [ master ] 5 | pull_request: 6 | branches: [ master ] 7 | workflow_dispatch: 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repo 13 | uses: actions/checkout@v2 14 | - name: Set up Go 15 | uses: actions/setup-go@v2 16 | with: 17 | go-version: 1.19 18 | - name: Set up paths 19 | run: | 20 | echo "GOPATH=$RUNNER_TEMP/go" >> $GITHUB_ENV 21 | echo "PATH=$PATH:$RUNNER_TEMP/go/bin" >> $GITHUB_ENV 22 | - name: Link source 23 | run: | 24 | mkdir -p $GOPATH/src/github.com/stripe 25 | ln -s $GITHUB_WORKSPACE $GOPATH/src/github.com/stripe/goforit 26 | - name: Run tests 27 | run: | 28 | cd $GOPATH/src/github.com/stripe/goforit 29 | go test -v -shuffle on -timeout 10s ./... 30 | go test -v -race -shuffle on -timeout 10s ./... 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # emacs 7 | *~ 8 | 9 | # Folders 10 | _obj 11 | _test 12 | 13 | # Architecture specific extensions/prefixes 14 | *.[568vq] 15 | [568vq].out 16 | 17 | *.cgo1.go 18 | *.cgo2.c 19 | _cgo_defun.c 20 | _cgo_gotypes.go 21 | _cgo_export.* 22 | 23 | _testmain.go 24 | 25 | *.exe 26 | *.test 27 | *.prof 28 | 29 | _work 30 | 31 | # intellij project files 32 | /.idea 33 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | This repo is deprecated and is no longer accepting contributions. 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Stripe, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > 2 | > 3 | > This project is deprecated and is no longer being actively maintained. 4 | 5 | [![Build Status](https://travis-ci.org/stripe/goforit.svg?branch=master)](https://travis-ci.org/stripe/goforit) 6 | [![GoDoc](https://godoc.org/github.com/stripe/goforit?status.svg)](http://godoc.org/github.com/stripe/goforit) 7 | 8 | goforit is an experimental, quick-and-dirty client library for feature flags in Go. 9 | 10 | # Backends 11 | 12 | Feature flags can be stored in any desired backend. goforit provides a several flatfile implementations out-of-the-box, so feature flags can be defined in a JSON or CSV file. See below for details. 13 | 14 | Alternatively, flags can be stored in a key-value store like Consul or Redis. 15 | 16 | 17 | # Usage 18 | 19 | Create a CSV file that defines the flag names and sampling rates: 20 | 21 | ```csv 22 | go.sun.money,0 23 | go.moon.mercury,1 24 | go.stars.money,.5 25 | ``` 26 | 27 | ```go 28 | func main() { 29 | ctx := context.Background() 30 | 31 | // flags.csv contains comma-separated flag names and sample rates. 32 | // See: testdata/flags_example.csv 33 | backend := goforit.BackendFromFile("flags.csv") 34 | goforit.Init(30*time.Second, backend) 35 | 36 | if goforit.Enabled(ctx, "go.sun.mercury", nil) { 37 | fmt.Println("The go.sun.mercury feature is enabled for 100% of requests") 38 | } 39 | 40 | if goforit.Enabled(ctx, "go.stars.money", nil) { 41 | fmt.Println("The go.stars.money feature is enabled for 50% of requests") 42 | } 43 | } 44 | ``` 45 | 46 | # Backends 47 | 48 | Included flatfile backends are: 49 | 50 | ## CSV 51 | 52 | This is a very simple backend, where every row defines a flag name and a rate at which it should be enabled, between zero and one. Initialize this backend with `BackendFromFile`. See [an example][CSV]. 53 | 54 | ## JSON v1 55 | 56 | This backend allows each flag to have multiple rules, like a series of if-statements. Each call to `.Enabled()` takes a map of properties, which rules can match against. Each rule's matching or non-matching can cause the overall flag to be on or off, or can fallthrough to the next rule. See [the proposal for this system][JSON1_proposal] or [an example JSON file][JSON1]. It's a bit confusing to understand. 57 | 58 | ## JSON v2 59 | 60 | In this format, each flag can have a number of rules, and each rule can contain a number of predicates for matching properties. When a flag is evaluated, it uses the first rule whose predicates match the given properties. See [an example JSON file, that also includes test cases][JSON2]. 61 | 62 | # Status 63 | 64 | goforit is in an experimental state and may introduce breaking changes without notice. 65 | 66 | [CSV]: https://github.com/stripe/goforit/blob/master/testdata/flags_example.csv 67 | [JSON1_proposal]: https://github.com/stripe/goforit/blob/master/doc/rule_flags.md 68 | [JSON1]: https://github.com/stripe/goforit/blob/master/testdata/flags_example.json 69 | [JSON2]: https://github.com/stripe/goforit/blob/master/testdata/flags2_acceptance.json 70 | 71 | # License 72 | 73 | goforit is available under the MIT license. 74 | -------------------------------------------------------------------------------- /backend.go: -------------------------------------------------------------------------------- 1 | package goforit 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "io" 7 | "os" 8 | "time" 9 | 10 | "github.com/stripe/goforit/flags2" 11 | ) 12 | 13 | type Backend interface { 14 | // Refresh returns a new set of flags. 15 | // It also returns the age of these flags, or an empty time if no age is known. 16 | Refresh() ([]*flags2.Flag2, time.Time, error) 17 | } 18 | 19 | type jsonFileBackend2 struct { 20 | filename string 21 | } 22 | 23 | func readFile(file string, parse func(io.Reader) ([]*flags2.Flag2, time.Time, error)) ([]*flags2.Flag2, time.Time, error) { 24 | f, err := os.Open(file) 25 | if err != nil { 26 | return nil, time.Time{}, err 27 | } 28 | defer func() { _ = f.Close() }() 29 | 30 | return parse(bufio.NewReaderSize(f, 128*1024)) 31 | } 32 | 33 | func (b jsonFileBackend2) Refresh() ([]*flags2.Flag2, time.Time, error) { 34 | flags, updated, err := readFile(b.filename, parseFlagsJSON2) 35 | if updated != time.Unix(0, 0) { 36 | return flags, updated, err 37 | } 38 | 39 | fileInfo, err := os.Stat(b.filename) 40 | if err != nil { 41 | return nil, time.Time{}, err 42 | } 43 | return flags, fileInfo.ModTime(), nil 44 | } 45 | 46 | func parseFlagsJSON2(r io.Reader) ([]*flags2.Flag2, time.Time, error) { 47 | dec := json.NewDecoder(r) 48 | var v flags2.JSONFormat2 49 | err := dec.Decode(&v) 50 | if err != nil { 51 | return nil, time.Time{}, err 52 | } 53 | 54 | flags := make([]*flags2.Flag2, len(v.Flags)) 55 | for i, f := range v.Flags { 56 | flags[i] = f 57 | } 58 | 59 | return flags, time.Unix(int64(v.Updated), 0), nil 60 | } 61 | 62 | // BackendFromJSONFile2 creates a v2 backend powered by a JSON file 63 | func BackendFromJSONFile2(filename string) Backend { 64 | return jsonFileBackend2{filename} 65 | } 66 | -------------------------------------------------------------------------------- /backend_test.go: -------------------------------------------------------------------------------- 1 | package goforit 2 | 3 | import ( 4 | "github.com/stripe/goforit/flags2" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestParseFlagsJSON(t *testing.T) { 13 | t.Parallel() 14 | 15 | filename := filepath.Join("testdata", "flags2_example.json") 16 | 17 | type testcase struct { 18 | Name string 19 | Filename string 20 | Expected []*flags2.Flag2 21 | } 22 | 23 | cases := []testcase{ 24 | { 25 | Name: "BasicExample", 26 | Filename: filepath.Join("testdata", "flags2_example.json"), 27 | Expected: []*flags2.Flag2{ 28 | { 29 | Name: "off_flag", 30 | Seed: "seed_1", 31 | Rules: []flags2.Rule2{}, 32 | }, 33 | { 34 | Name: "go.moon.mercury", 35 | Seed: "seed_1", 36 | Rules: []flags2.Rule2{ 37 | { 38 | HashBy: "_random", 39 | Percent: 1.0, 40 | Predicates: []flags2.Predicate2{}, 41 | }, 42 | }, 43 | }, 44 | { 45 | Name: "go.stars.money", 46 | Seed: "seed_1", 47 | Rules: []flags2.Rule2{ 48 | { 49 | HashBy: "_random", 50 | Percent: 0.5, 51 | Predicates: []flags2.Predicate2{}, 52 | }, 53 | }, 54 | }, 55 | { 56 | Name: "go.sun.money", 57 | Seed: "seed_1", 58 | Rules: []flags2.Rule2{ 59 | { 60 | HashBy: "_random", 61 | Percent: 0.0, 62 | Predicates: []flags2.Predicate2{}, 63 | }, 64 | }, 65 | }, 66 | { 67 | Name: "flag5", 68 | Seed: "seed_1", 69 | Rules: []flags2.Rule2{ 70 | { 71 | HashBy: "token", 72 | Percent: 1.0, 73 | Predicates: []flags2.Predicate2{ 74 | { 75 | Attribute: "token", 76 | Operation: flags2.OpIn, 77 | Values: map[string]bool{ 78 | "id_1": true, 79 | "id_2": true, 80 | }, 81 | }, 82 | { 83 | Attribute: "country", 84 | Operation: flags2.OpNotIn, 85 | Values: map[string]bool{ 86 | "KP": true, 87 | }, 88 | }, 89 | }, 90 | }, 91 | { 92 | HashBy: "token", 93 | Percent: 0.5, 94 | Predicates: []flags2.Predicate2{}, 95 | }, 96 | }, 97 | }, 98 | }, 99 | }, 100 | } 101 | 102 | for _, tc := range cases { 103 | t.Run(tc.Name, func(t *testing.T) { 104 | f, err := os.Open(filename) 105 | assert.NoError(t, err) 106 | defer f.Close() 107 | 108 | flags, _, err := parseFlagsJSON2(f) 109 | 110 | assert.Equal(t, tc.Expected, flags) 111 | }) 112 | } 113 | } 114 | 115 | func TestMultipleDefinitions(t *testing.T) { 116 | t.Parallel() 117 | 118 | const repeatedFlag = "go.sun.money" 119 | const lastValue = 0.7 120 | 121 | backend := BackendFromJSONFile2(filepath.Join("testdata", "flags2_multiple_definitions.json")) 122 | g, _ := testGoforit(0, backend, stalenessCheckInterval) 123 | defer func() { _ = g.Close() }() 124 | g.RefreshFlags(backend) 125 | 126 | flagHolder, ok := g.flags.Get(repeatedFlag) 127 | assert.True(t, ok) 128 | 129 | expected := &flags2.Flag2{ 130 | Name: repeatedFlag, 131 | Seed: "seed_1", 132 | Rules: []flags2.Rule2{ 133 | { 134 | HashBy: "_random", 135 | Percent: lastValue, 136 | Predicates: []flags2.Predicate2{}, 137 | }, 138 | }, 139 | } 140 | assert.Equal(t, expected, flagHolder.flag) 141 | } 142 | 143 | func TestTimestampFallback(t *testing.T) { 144 | backend := jsonFileBackend2{ 145 | filename: filepath.Join("testdata", "flags2_example.json"), 146 | } 147 | _, updated, err := backend.Refresh() 148 | assert.NoError(t, err) 149 | assert.Equal(t, int64(1584642857), updated.Unix()) 150 | 151 | backendNoTimestamp := jsonFileBackend2{ 152 | filename: filepath.Join("testdata", "flags2_example_no_timestamp.json"), 153 | } 154 | _, updated, err = backendNoTimestamp.Refresh() 155 | assert.NoError(t, err) 156 | 157 | info, err := os.Stat(filepath.Join("testdata", "flags2_example_no_timestamp.json")) 158 | assert.NoError(t, err) 159 | assert.Equal(t, info.ModTime(), updated) 160 | } 161 | -------------------------------------------------------------------------------- /clamp/clamp.go: -------------------------------------------------------------------------------- 1 | package clamp 2 | 3 | // Clamp denotes is a flag is constant or will vary 4 | type Clamp int 5 | 6 | const ( 7 | AlwaysOff Clamp = iota 8 | AlwaysOn 9 | MayVary 10 | ) 11 | -------------------------------------------------------------------------------- /doc/rule_flags.md: -------------------------------------------------------------------------------- 1 | # Rules-based flags 2 | 3 | This is a proposal for feature flags that exhibit rich behavior for determining if a flag is on or off. The core ideas are: 4 | 5 | * When a flag is checked, a set of properties or tags are specified 6 | * A flag's current settings are specified with a list of rules or conditions, which are executed in sequence to calculate whether or not the flag is enabled 7 | 8 | ## Using these flags 9 | 10 | When a client program wants to check if a flag is enabled, it specifies a set of properties. Properties are just a mapping of string keys to string values: 11 | 12 | ```go 13 | if goforit.Enabled(ctx, "myflag", map[string]string{"user": "bob"}) { 14 | ... 15 | } 16 | ``` 17 | 18 | Some properties should never change during a program's execution, such as "hostname", "cluster" or "service". These can be preset when the program starts: 19 | 20 | ```go 21 | goforit.AddDefaultTags(map[string]string{ 22 | "hostname": "myhost.com", 23 | "service": "myprogram", 24 | }) 25 | ``` 26 | 27 | When `.Enabled()` is called, the explicit properties are merged with the default properties—if any properties are in both, the explicit ones take precedence. 28 | 29 | 30 | ## Determining if a flag is enabled 31 | 32 | A flag's settings include the following: 33 | 34 | * A boolean to determine whether the flag is "active". This can be used to disable a flag while leaving its rules otherwise intact. 35 | * A list of zero or more rules, each of which has: 36 | * A type 37 | * Two actions to be performed if the rule matches, or if it doesn't match (aka "misses"). These can be either "on", "off", or "continue" 38 | * Some rule-type-specific properties 39 | 40 | To determine if a flag is enabled, first the following special cases are considered: 41 | 42 | * If the flag is not active, the flag is considered disabled 43 | * If the flag is active but has no rules, the flag is considered enabled 44 | 45 | Otherwise, the rules are executed in sequence. Each rule yields either a "match" or a "miss". (If any rule yields an error, that is considered a "miss", and the error is logged.) Depending on that result, the appropriate action is executed: 46 | 47 | * If the action is "on", the flag is considered enabled. Further rules are skipped 48 | * If the action is "off", the flag is considered disabled. Further rules are skipped 49 | * If the action is "continue", the next rule is executed. If this is the last rule for this flag, the flag is considered disabled 50 | 51 | 52 | 53 | ## Rule types 54 | 55 | The following rule types are currently defined: 56 | 57 | ### match_list 58 | 59 | This rule type matches a property against a list of values. It has the following attributes: 60 | 61 | * property: The name of the property to match 62 | * values: A list of values to be matched against 63 | 64 | Eg, given the following settings: 65 | 66 | ``` 67 | { 68 | "property": "user", 69 | "values: ["alice", "bob"] 70 | } 71 | ``` 72 | 73 | The following usage would match this rule: 74 | 75 | ```go 76 | goforit.Enabled(ctx, "myflag", map[string]string{"user": "bob"}) 77 | ``` 78 | 79 | This usage would **not** match this rule: 80 | 81 | ```go 82 | goforit.Enabled(ctx, "myflag", map[string]string{"user": "xavier"}) 83 | ``` 84 | 85 | If the "user" property was not provided at all, that would be an error. 86 | 87 | 88 | ### sample 89 | 90 | This rule type matches a given fraction of the time. It has the following attributes: 91 | 92 | * properties: The names of the properties for sampling 93 | * rate: The fraction of the time we should match, as a float from 0 to 1 94 | 95 | This rule type effectively has two modes: 96 | 97 | 1. When 'properties' is empty, it just randomly matches at the given rate. Eg, this will match 5% of the time `.Enabled()` is called: 98 | 99 | ``` 100 | { 101 | "properties": [], 102 | "rate": 0.05 103 | } 104 | ``` 105 | 106 | 2. When 'properties' is non-empty, it deterministically matches the given fraction of values for those properties. Eg: 107 | 108 | ``` 109 | { 110 | "properties": ["user", "currency"], 111 | "rate": 0.05 112 | } 113 | ``` 114 | 115 | This would match 5% of (user, currency) value pairs. Each such pair would either always match or always not-match. 116 | 117 | If the caller to `.Enabled()` does not provide any of the given properties, it is an error. 118 | 119 | 120 | ## JSON file format 121 | 122 | A JSON file is used to specify the current settings for each flag. The overall file format is: 123 | 124 | ``` 125 | { 126 | "updated": 1234567.89, 127 | "flags": [ 128 | // A list of flag objects 129 | ] 130 | } 131 | ``` 132 | 133 | Each flag object looks like the following: 134 | 135 | ``` 136 | { 137 | "name": "myflag", 138 | "active": true, 139 | "rules": [ 140 | // A list of rule objects 141 | ] 142 | } 143 | ``` 144 | 145 | Each rule has the basic format: 146 | 147 | ``` 148 | { 149 | "type": "sample", 150 | "on_match": "on", // or "off", or "continue" 151 | "on_miss": "off", // or "on", or "continue" 152 | 153 | // extra attributes particular to this type, eg: 154 | "rate": 0.5 155 | } 156 | ``` 157 | 158 | That's it! Here's a complete but small example: 159 | 160 | ``` 161 | { 162 | "flags": [ 163 | { 164 | "name": "go.sun.moon", 165 | "active": true, 166 | "rules": [ 167 | { 168 | "type": "match_list", 169 | "property": "host_name", 170 | "values": [ 171 | "srv_123", 172 | "srv_456" 173 | ], 174 | "on_match": "off", 175 | "on_miss": "continue" 176 | }, 177 | { 178 | "type": "match_list", 179 | "property": "host_name", 180 | "values": [ 181 | "srv_789" 182 | ], 183 | "on_match": "on", 184 | "on_miss": "continue" 185 | }, 186 | { 187 | "type": "sample", 188 | "rate": 0.01, 189 | "properties": ["cluster", "db"], 190 | "on_match": "on", 191 | "on_miss": "off" 192 | } 193 | ] 194 | }, 195 | ], 196 | "updated": 1519247256.0626957 197 | } 198 | ``` 199 | 200 | ## Examples 201 | 202 | Here are some common use cases, and how to implement them with rules: 203 | 204 | ### Always off 205 | 206 | ``` 207 | { 208 | "name": "test.off", 209 | "active": false, 210 | "rules": [] 211 | } 212 | ``` 213 | 214 | ### Always on 215 | 216 | ``` 217 | { 218 | "name": "test.on", 219 | "active": true, 220 | "rules": [ 221 | { 222 | "type": "sample", 223 | "properties": [], 224 | "rate": 1, 225 | "on_match": "on", 226 | "on_miss": "off" 227 | } 228 | ] 229 | } 230 | ``` 231 | 232 | ### Simple sampling 233 | 234 | On 1% of the time: 235 | 236 | ``` 237 | { 238 | "name": "test.random", 239 | "active": true, 240 | "rules": [ 241 | { 242 | "type": "sample", 243 | "properties": [], 244 | "rate": 0.01, 245 | "on_match": "on", 246 | "on_miss": "off" 247 | } 248 | ] 249 | } 250 | ``` 251 | 252 | ### Sampling by property 253 | 254 | On for 1% of users. Consistently on for the same set of users. 255 | 256 | ``` 257 | { 258 | "name": "test.random_by", 259 | "active": true, 260 | "rules": [ 261 | { 262 | "type": "sample", 263 | "properties": ["user"], 264 | "rate": 0.01, 265 | "on_match": "on", 266 | "on_miss": "off" 267 | } 268 | ] 269 | } 270 | ``` 271 | 272 | ### Allowlist 273 | 274 | On for only Alice and Bob: 275 | 276 | ``` 277 | { 278 | "name": "test.allowlist", 279 | "active": true, 280 | "rules": [ 281 | { 282 | "type": "match_list", 283 | "property": "user", 284 | "values": ["alice", "bob"], 285 | "on_match": "on", 286 | "on_miss": "off" 287 | } 288 | ] 289 | } 290 | ``` 291 | 292 | ### Denylist 293 | 294 | On for everyone except Xavier: 295 | 296 | ``` 297 | { 298 | "name": "test.denylist", 299 | "active": true, 300 | "rules": [ 301 | { 302 | "type": "match_list", 303 | "property": "user", 304 | "values": ["xavier"], 305 | "on_match": "off", 306 | "on_miss": "on" 307 | } 308 | ] 309 | } 310 | ``` 311 | 312 | ### Allowlist some, sample from the rest 313 | 314 | This is useful for a new feature, with some explicit test users, and a random selection of other users: 315 | 316 | 317 | ``` 318 | { 319 | "name": "test.random_by_with_allowlist", 320 | "active": true, 321 | "rules": [ 322 | { 323 | "type": "match_list", 324 | "property": "user", 325 | "values": ["test_user1", "test_user2"], 326 | "on_match": "on", 327 | "on_miss": "continue" 328 | }, 329 | { 330 | "type": "sample", 331 | "properties": ["user"], 332 | "rate": 0.01, 333 | "on_match": "on", 334 | "on_miss": "off" 335 | } 336 | ] 337 | } 338 | ``` 339 | 340 | ### Sample from only certain users 341 | 342 | Only Alice should have this feature, and she should only see it for 5% of requests: 343 | 344 | 345 | ``` 346 | { 347 | "name": "test.allowlist_then_sample", 348 | "active": true, 349 | "rules": [ 350 | { 351 | "type": "match_list", 352 | "property": "user", 353 | "values": ["alice"], 354 | "on_match": "continue", 355 | "on_miss": "off" 356 | }, 357 | { 358 | "type": "sample", 359 | "properties": ["request_id"], 360 | "rate": 0.01, 361 | "on_match": "on", 362 | "on_miss": "off" 363 | } 364 | ] 365 | } 366 | ``` 367 | 368 | ### Multiple allowlist 369 | 370 | Multiple properties must all be matched: 371 | 372 | ``` 373 | { 374 | "name": "test.multi_allowlist", 375 | "active": true, 376 | "rules": [ 377 | { 378 | "type": "match_list", 379 | "property": "user", 380 | "values": ["alice", "bob"], 381 | "on_match": "continue", 382 | "on_miss": "off" 383 | }, 384 | { 385 | "type": "match_list", 386 | "property": "currency", 387 | "values": ["usd", "cad"], 388 | "on_match": "on", 389 | "on_miss": "off" 390 | } 391 | ] 392 | } 393 | ` 394 | -------------------------------------------------------------------------------- /examples_whitebox_test.go: -------------------------------------------------------------------------------- 1 | package goforit_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/stripe/goforit" 9 | ) 10 | 11 | func Example() { 12 | ctx := context.Background() 13 | 14 | // flags.csv contains comma-separated flag names and sample rates. 15 | // See: testdata/flags2_example.json 16 | backend := goforit.BackendFromJSONFile2("testdata/flags2_example.json") 17 | flags := goforit.New(30*time.Second, backend, goforit.WithOwnedStats(true)) 18 | defer func() { _ = flags.Close() }() 19 | 20 | if flags.Enabled(ctx, "go.sun.mercury", nil) { 21 | fmt.Println("The go.sun.mercury feature is enabled for 100% of requests") 22 | } 23 | // Same thing. 24 | if flags.Enabled(nil, "go.sun.mercury", nil) { 25 | fmt.Println("The go.sun.mercury feature is enabled for 100% of requests") 26 | } 27 | 28 | if flags.Enabled(ctx, "go.stars.money", nil) { 29 | fmt.Println("The go.stars.money feature is enabled for 50% of requests") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /fastflags.go: -------------------------------------------------------------------------------- 1 | package goforit 2 | 3 | import ( 4 | "github.com/stripe/goforit/flags2" 5 | "sync" 6 | "sync/atomic" 7 | ) 8 | 9 | // fastFlags is a structure for fast access to read-mostly feature flags. 10 | // It supports lockless reads and synchronized updates. 11 | type fastFlags struct { 12 | flags atomic.Pointer[flagMap] 13 | 14 | writerLock sync.Mutex 15 | } 16 | 17 | type flagMap map[string]*flagHolder 18 | 19 | // newFastFlags returns a new, empty fastFlags instance. 20 | func newFastFlags() *fastFlags { 21 | return new(fastFlags) 22 | } 23 | 24 | func (ff *fastFlags) load() flagMap { 25 | if flags := ff.flags.Load(); flags != nil { 26 | return *flags 27 | } 28 | return nil 29 | } 30 | 31 | func (ff *fastFlags) Get(key string) (*flagHolder, bool) { 32 | if f, ok := ff.load()[key]; ok && f != nil { 33 | return f, ok 34 | } else { 35 | return nil, false 36 | } 37 | } 38 | 39 | func (ff *fastFlags) Update(refreshedFlags []*flags2.Flag2) { 40 | ff.writerLock.Lock() 41 | defer ff.writerLock.Unlock() 42 | 43 | changed := false 44 | 45 | oldFlags := ff.load() 46 | newFlags := make(flagMap) 47 | for _, flag := range refreshedFlags { 48 | name := flag.FlagName() 49 | var holder *flagHolder 50 | if oldFlagHolder, ok := oldFlags[name]; ok && oldFlagHolder.flag.Equal(flag) { 51 | holder = oldFlagHolder 52 | } else { 53 | changed = true 54 | holder = &flagHolder{ 55 | flag: flag, 56 | clamp: flag.Clamp(), 57 | } 58 | } 59 | newFlags[name] = holder 60 | } 61 | if len(oldFlags) != len(newFlags) { 62 | changed = true 63 | } 64 | 65 | // avoid storing the new map if it is the same as the old one. 66 | // this is largely for tests in gocode which compare if flags 67 | // are deeply equal in tests. 68 | if changed { 69 | ff.flags.Store(&newFlags) 70 | } 71 | 72 | return 73 | } 74 | 75 | func (ff *fastFlags) storeForTesting(key string, value *flagHolder) { 76 | ff.writerLock.Lock() 77 | defer ff.writerLock.Unlock() 78 | 79 | oldFlags := ff.load() 80 | newFlags := make(flagMap) 81 | for k, v := range oldFlags { 82 | newFlags[k] = v 83 | } 84 | 85 | newFlags[key] = value 86 | 87 | ff.flags.Store(&newFlags) 88 | } 89 | 90 | func (ff *fastFlags) deleteForTesting(keyToDelete string) { 91 | ff.writerLock.Lock() 92 | defer ff.writerLock.Unlock() 93 | 94 | oldFlags := ff.load() 95 | newFlags := make(flagMap) 96 | for k, v := range oldFlags { 97 | if k != keyToDelete { 98 | newFlags[k] = v 99 | } 100 | } 101 | 102 | ff.flags.Store(&newFlags) 103 | } 104 | 105 | func (ff *fastFlags) Close() { 106 | } 107 | -------------------------------------------------------------------------------- /fasttags.go: -------------------------------------------------------------------------------- 1 | package goforit 2 | 3 | import ( 4 | "sync" 5 | "sync/atomic" 6 | ) 7 | 8 | // fastTags is a structure for fast access to read-mostly default tags. 9 | // It supports lockless reads and synchronized updates. 10 | type fastTags struct { 11 | tags atomic.Pointer[map[string]string] 12 | 13 | writerLock sync.Mutex 14 | } 15 | 16 | func newFastTags() *fastTags { 17 | return new(fastTags) 18 | } 19 | 20 | // Load returns a map of default tags. This map MUST only be read, not written to. 21 | func (ft *fastTags) Load() map[string]string { 22 | if tags := ft.tags.Load(); tags != nil { 23 | return *tags 24 | } 25 | return nil 26 | } 27 | 28 | // Set replaces the default tags. 29 | func (ft *fastTags) Set(tags map[string]string) { 30 | ft.writerLock.Lock() 31 | defer ft.writerLock.Unlock() 32 | 33 | // copy argument into a new map to ensure caller can't mistakenly 34 | // hold on to a reference and cause a concurrent map modification panic 35 | newTags := make(map[string]string, len(tags)) 36 | for k, v := range tags { 37 | newTags[k] = v 38 | } 39 | 40 | ft.tags.Store(&newTags) 41 | } 42 | -------------------------------------------------------------------------------- /flags/rand.go: -------------------------------------------------------------------------------- 1 | package flags 2 | 3 | import ( 4 | "github.com/stripe/goforit/internal/safepool" 5 | ) 6 | 7 | // Rand is a source of pseudo-random floating point numbers between [0, 1.0). 8 | type Rand interface { 9 | // Float64 returns, as a float64, a pseudo-random number in the half-open interval [0.0,1.0). 10 | Float64() float64 11 | } 12 | 13 | type pooledRand struct { 14 | // Rand is not concurrency safe, so keep a pool of them for goroutine-independent use 15 | rndPool *safepool.RandPool 16 | } 17 | 18 | func (pr *pooledRand) Float64() float64 { 19 | rnd := pr.rndPool.Get() 20 | defer pr.rndPool.Put(rnd) 21 | return rnd.Float64() 22 | } 23 | 24 | func NewRand() Rand { 25 | return &pooledRand{ 26 | rndPool: safepool.NewRandPool(), 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /flags2/flags2.go: -------------------------------------------------------------------------------- 1 | package flags2 2 | 3 | import ( 4 | "crypto/sha1" 5 | "encoding/binary" 6 | "encoding/json" 7 | "fmt" 8 | "sort" 9 | 10 | "github.com/stripe/goforit/clamp" 11 | "github.com/stripe/goforit/flags" 12 | ) 13 | 14 | type Operation2 string 15 | 16 | const ( 17 | OpIn = "in" 18 | OpNotIn = "not_in" 19 | OpIsNil = "is_nil" 20 | OptNotNil = "is_not_nil" 21 | 22 | PercentOn = 1.0 23 | PercentOff = 0.0 24 | 25 | HashByRandom = "_random" 26 | ) 27 | 28 | // Predicate2 is a newer, more sophisticated type of flag! 29 | // 30 | // Each Flag2 contains a list of rules, and each rule contains a list of predicates. 31 | // When querying a flag, the first rule whose predicates match is applied. 32 | type Predicate2 struct { 33 | Attribute string `json:"attribute"` 34 | Operation Operation2 `json:"operation"` 35 | Values map[string]bool `json:"values"` 36 | } 37 | type Rule2 struct { 38 | HashBy string `json:"hash_by"` 39 | Percent float64 `json:"percent"` 40 | Predicates []Predicate2 `json:"predicates"` 41 | } 42 | type Flag2 struct { 43 | Name string `json:"name"` 44 | Seed string `json:"seed"` 45 | Rules []Rule2 `json:"rules"` 46 | Deleted bool `json:"deleted"` 47 | } 48 | 49 | type JSONFormat2 struct { 50 | Flags []*Flag2 `json:"flags"` 51 | Updated float64 `json:"updated"` 52 | } 53 | 54 | type predicate2Json struct { 55 | Attribute string `json:"attribute"` 56 | Operation Operation2 `json:"operation"` 57 | Values []string `json:"values"` 58 | } 59 | 60 | func (p *Predicate2) UnmarshalJSON(data []byte) error { 61 | var raw predicate2Json 62 | err := json.Unmarshal(data, &raw) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | *p = Predicate2{Attribute: raw.Attribute, Operation: raw.Operation, Values: map[string]bool{}} 68 | for _, v := range raw.Values { 69 | p.Values[v] = true 70 | } 71 | return nil 72 | } 73 | 74 | func (p *Predicate2) MarshalJSON() ([]byte, error) { 75 | raw := predicate2Json{ 76 | Attribute: p.Attribute, 77 | Operation: p.Operation, 78 | } 79 | 80 | if len(p.Values) > 0 { 81 | raw.Values = make([]string, 0, len(p.Values)) 82 | for k := range p.Values { 83 | raw.Values = append(raw.Values, k) 84 | } 85 | sort.Strings(raw.Values) 86 | } 87 | 88 | return json.Marshal(&raw) 89 | } 90 | 91 | func (f *Flag2) FlagName() string { 92 | return f.Name 93 | } 94 | 95 | func (f *Flag2) Enabled(rnd flags.Rand, properties, defaultTags map[string]string) (bool, error) { 96 | for i := range f.Rules { 97 | rule := &f.Rules[i] 98 | match, err := rule.matches(properties, defaultTags) 99 | if err != nil { 100 | return false, err 101 | } 102 | if !match { 103 | continue 104 | } 105 | 106 | return rule.evaluate(rnd, f.Seed, properties, defaultTags) 107 | } 108 | 109 | // If no rules match, the flag is off 110 | return false, nil 111 | } 112 | 113 | func (f *Flag2) Clamp() clamp.Clamp { 114 | if len(f.Rules) == 0 { 115 | return clamp.AlwaysOff 116 | } 117 | if len(f.Rules) == 1 && len(f.Rules[0].Predicates) == 0 { 118 | if f.Rules[0].Percent <= PercentOff { 119 | return clamp.AlwaysOff 120 | } else if f.Rules[0].Percent >= PercentOn { 121 | return clamp.AlwaysOn 122 | } 123 | } 124 | return clamp.MayVary 125 | } 126 | 127 | func (p *Predicate2) equal(o Predicate2) bool { 128 | if p.Attribute != o.Attribute || p.Operation != o.Operation || len(p.Values) != len(o.Values) { 129 | return false 130 | } 131 | 132 | for v := range p.Values { 133 | if !o.Values[v] { 134 | return false 135 | } 136 | } 137 | // Since cardinality is the same, the whole set must be the same 138 | return true 139 | } 140 | 141 | func (r *Rule2) equal(o Rule2) bool { 142 | if r.HashBy != o.HashBy || r.Percent != o.Percent || len(r.Predicates) != len(o.Predicates) { 143 | return false 144 | } 145 | for i := range r.Predicates { 146 | if !r.Predicates[i].equal(o.Predicates[i]) { 147 | return false 148 | } 149 | } 150 | return true 151 | } 152 | 153 | func (f *Flag2) Equal(o *Flag2) bool { 154 | if f.Name != o.Name || f.Seed != o.Seed || len(f.Rules) != len(o.Rules) { 155 | return false 156 | } 157 | for i := range f.Rules { 158 | if !f.Rules[i].equal(o.Rules[i]) { 159 | return false 160 | } 161 | } 162 | return true 163 | } 164 | 165 | func (f *Flag2) IsDeleted() bool { 166 | return f.Deleted 167 | } 168 | 169 | func (p *Predicate2) matches(properties, defaultTags map[string]string) (bool, error) { 170 | val, present := properties[p.Attribute] 171 | if !present { 172 | val, present = defaultTags[p.Attribute] 173 | } 174 | switch p.Operation { 175 | case OpIn: 176 | return p.Values[val], nil 177 | case OpNotIn: 178 | return !p.Values[val], nil 179 | case OpIsNil: 180 | return !present, nil 181 | case OptNotNil: 182 | return present, nil 183 | default: 184 | return false, fmt.Errorf("unknown predicate %q", p.Operation) 185 | } 186 | } 187 | 188 | func (r *Rule2) matches(properties, defaultTags map[string]string) (bool, error) { 189 | _, hashPresent := properties[r.HashBy] 190 | if !hashPresent { 191 | _, hashPresent = defaultTags[r.HashBy] 192 | } 193 | if !hashPresent && r.HashBy != HashByRandom && r.Percent > PercentOff && r.Percent < PercentOn { 194 | // We have no way to calculate a percentage, so the specced behavior is to skip this rule 195 | return false, nil 196 | } 197 | 198 | for i := range r.Predicates { 199 | pred := &r.Predicates[i] 200 | match, err := pred.matches(properties, defaultTags) 201 | if err != nil { 202 | return false, err 203 | } 204 | // ALL predicates must match 205 | if !match { 206 | return false, nil 207 | } 208 | } 209 | return true, nil 210 | } 211 | 212 | func (r *Rule2) hashValue(seed, val string) float64 { 213 | h := sha1.New() 214 | h.Write([]byte(seed)) 215 | h.Write([]byte{'.'}) 216 | h.Write([]byte(val)) 217 | buf := make([]byte, 0, sha1.Size) 218 | sum := h.Sum(buf) 219 | ival := binary.BigEndian.Uint16(sum) 220 | return float64(ival) / float64(1<<16) 221 | } 222 | 223 | func (r *Rule2) evaluate(rnd flags.Rand, seed string, properties, defaultTags map[string]string) (bool, error) { 224 | if r.Percent >= PercentOn { 225 | return true, nil 226 | } 227 | if r.Percent <= PercentOff { 228 | return false, nil 229 | } 230 | 231 | if r.HashBy == HashByRandom { 232 | return rnd.Float64() < r.Percent, nil 233 | } 234 | 235 | val, found := properties[r.HashBy] 236 | if !found { 237 | val = defaultTags[r.HashBy] 238 | } 239 | return r.hashValue(seed, val) < r.Percent, nil 240 | } 241 | -------------------------------------------------------------------------------- /flags2_test.go: -------------------------------------------------------------------------------- 1 | package goforit 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | "testing" 11 | "time" 12 | 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | 16 | "github.com/stripe/goforit/clamp" 17 | "github.com/stripe/goforit/flags2" 18 | ) 19 | 20 | type FlagTestCase2 struct { 21 | Flag string 22 | Expected bool 23 | Attrs map[string]*string 24 | Message string 25 | } 26 | type FlagAcceptance2 struct { 27 | flags2.JSONFormat2 28 | TestCases []FlagTestCase2 `json:"test_cases"` 29 | } 30 | 31 | func TestFlags2Backend(t *testing.T) { 32 | t.Parallel() 33 | 34 | expectedFlags := []*flags2.Flag2{ 35 | {Name: "off_flag", Seed: "seed_1", Rules: []flags2.Rule2{}}, 36 | { 37 | Name: "go.moon.mercury", 38 | Seed: "seed_1", 39 | Rules: []flags2.Rule2{{HashBy: "_random", Percent: 1.0, Predicates: []flags2.Predicate2{}}}, 40 | }, 41 | { 42 | Name: "go.stars.money", 43 | Seed: "seed_1", 44 | Rules: []flags2.Rule2{{HashBy: "_random", Percent: 0.5, Predicates: []flags2.Predicate2{}}}, 45 | }, 46 | { 47 | Name: "go.sun.money", 48 | Seed: "seed_1", 49 | Rules: []flags2.Rule2{{HashBy: "_random", Percent: 0.0, Predicates: []flags2.Predicate2{}}}, 50 | }, 51 | { 52 | Name: "flag5", 53 | Seed: "seed_1", 54 | Rules: []flags2.Rule2{ 55 | { 56 | HashBy: "token", 57 | Percent: 1.0, 58 | Predicates: []flags2.Predicate2{ 59 | {Attribute: "token", Operation: flags2.OpIn, Values: map[string]bool{"id_1": true, "id_2": true}}, 60 | {Attribute: "country", Operation: flags2.OpNotIn, Values: map[string]bool{"KP": true}}, 61 | }, 62 | }, 63 | {HashBy: "token", Percent: 0.5, Predicates: []flags2.Predicate2{}}, 64 | }, 65 | }, 66 | } 67 | 68 | backend := BackendFromJSONFile2(filepath.Join("testdata", "flags2_example.json")) 69 | flags, updated, err := backend.Refresh() 70 | 71 | assert.NoError(t, err) 72 | assert.Equal(t, expectedFlags, flags) 73 | assert.Equal(t, int64(1584642857), updated.Unix()) 74 | } 75 | 76 | func flags2AcceptanceTests(t *testing.T, f func(t *testing.T, flagname string, flag flags2.Flag2, properties map[string]string, expected bool, msg string)) { 77 | path := filepath.Join("testdata", "flags2_acceptance.json") 78 | buf, err := ioutil.ReadFile(path) 79 | require.NoError(t, err) 80 | 81 | var acceptanceData FlagAcceptance2 82 | err = json.Unmarshal(buf, &acceptanceData) 83 | require.NoError(t, err) 84 | 85 | flags := make(map[string]*flags2.Flag2) 86 | for _, f := range acceptanceData.Flags { 87 | flags[f.Name] = f 88 | } 89 | 90 | for _, tc := range acceptanceData.TestCases { 91 | name := fmt.Sprintf("%s:%s", tc.Flag, tc.Message) 92 | dup := tc 93 | t.Run(name, func(t *testing.T) { 94 | t.Parallel() 95 | 96 | // We don't distinguish between missing/nil values 97 | properties := map[string]string{} 98 | for k, v := range dup.Attrs { 99 | if v != nil { 100 | properties[k] = *v 101 | } 102 | } 103 | 104 | msg := fmt.Sprintf("%s %v", dup.Flag, dup.Attrs) 105 | f(t, dup.Flag, *flags[dup.Flag], properties, dup.Expected, msg) 106 | }) 107 | } 108 | } 109 | 110 | func TestFlags2Acceptance(t *testing.T) { 111 | t.Parallel() 112 | 113 | flags2AcceptanceTests(t, func(t *testing.T, flagname string, flag flags2.Flag2, properties map[string]string, expected bool, msg string) { 114 | enabled, err := flag.Enabled(nil, properties, nil) 115 | assert.NoError(t, err) 116 | assert.Equal(t, expected, enabled, msg) 117 | }) 118 | } 119 | 120 | func TestFlags2AcceptanceDefaultTags(t *testing.T) { 121 | t.Parallel() 122 | 123 | flags2AcceptanceTests(t, func(t *testing.T, flagname string, flag flags2.Flag2, properties map[string]string, expected bool, msg string) { 124 | enabled, err := flag.Enabled(nil, nil, properties) 125 | assert.NoError(t, err) 126 | assert.Equal(t, expected, enabled, msg) 127 | }) 128 | } 129 | 130 | func TestFlags2AcceptanceClamp(t *testing.T) { 131 | t.Parallel() 132 | 133 | flags2AcceptanceTests(t, func(t *testing.T, flagname string, flag flags2.Flag2, properties map[string]string, expected bool, msg string) { 134 | switch flag.Clamp() { 135 | case clamp.AlwaysOn: 136 | assert.True(t, expected, msg) 137 | case clamp.AlwaysOff: 138 | assert.False(t, expected, msg) 139 | } 140 | }) 141 | } 142 | 143 | func TestFlags2AcceptanceEndToEnd(t *testing.T) { 144 | t.Parallel() 145 | 146 | path := filepath.Join("testdata", "flags2_acceptance.json") 147 | backend := BackendFromJSONFile2(path) 148 | g, _ := testGoforit(10*time.Millisecond, backend, stalenessCheckInterval) 149 | defer g.Close() 150 | 151 | flags2AcceptanceTests(t, func(t *testing.T, flagname string, flag flags2.Flag2, properties map[string]string, expected bool, msg string) { 152 | enabled := g.Enabled(context.Background(), flagname, properties) 153 | assert.Equal(t, expected, enabled, msg) 154 | }) 155 | } 156 | 157 | func TestFlags2Reserialize(t *testing.T) { 158 | t.Parallel() 159 | 160 | path := filepath.Join("testdata", "flags2_acceptance.json") 161 | backend := BackendFromJSONFile2(path) 162 | 163 | flags, _, err := backend.Refresh() 164 | require.NoError(t, err) 165 | 166 | flagsOut := make([]*flags2.Flag2, len(flags)) 167 | for i := 0; i < len(flags); i++ { 168 | copied := *flags[i] 169 | flagsOut[i] = &copied 170 | } 171 | 172 | root := flags2.JSONFormat2{ 173 | Flags: flagsOut, 174 | } 175 | 176 | marshaled, err := json.MarshalIndent(&root, "", " ") 177 | require.NoError(t, err) 178 | 179 | file, err := os.CreateTemp("", "goforit-flags2-reserializetest") 180 | require.NoError(t, err) 181 | defer os.Remove(file.Name()) 182 | 183 | _, err = file.Write(marshaled) 184 | require.NoError(t, err) 185 | 186 | backend2 := BackendFromJSONFile2(file.Name()) 187 | flags2, _, err := backend2.Refresh() 188 | require.NoError(t, err) 189 | 190 | require.Equal(t, flags, flags2) 191 | } 192 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/stripe/goforit 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/DataDog/datadog-go v4.8.3+incompatible 7 | github.com/stretchr/testify v1.8.1 8 | go.uber.org/goleak v1.2.1 9 | ) 10 | 11 | require ( 12 | github.com/Microsoft/go-winio v0.5.2 // indirect 13 | github.com/davecgh/go-spew v1.1.1 // indirect 14 | github.com/kr/text v0.1.0 // indirect 15 | github.com/pmezard/go-difflib v1.0.0 // indirect 16 | golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 // indirect 17 | gopkg.in/yaml.v3 v3.0.1 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/DataDog/datadog-go v4.8.3+incompatible h1:fNGaYSuObuQb5nzeTQqowRAd9bpDIRRV4/gUtIBjh8Q= 2 | github.com/DataDog/datadog-go v4.8.3+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= 3 | github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA= 4 | github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 9 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 10 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 11 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 12 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 13 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 15 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 16 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 17 | github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= 18 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 19 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 20 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 21 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 22 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 23 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 24 | go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= 25 | go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= 26 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 27 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 28 | golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 h1:nhht2DYV/Sn3qOayu8lM+cU1ii9sTLUeBQwQQfUHtrs= 29 | golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 30 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 31 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 32 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 33 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 34 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 35 | -------------------------------------------------------------------------------- /goforit.go: -------------------------------------------------------------------------------- 1 | package goforit 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "log" 7 | "os" 8 | "sync" 9 | "sync/atomic" 10 | "time" 11 | 12 | "github.com/DataDog/datadog-go/statsd" 13 | 14 | "github.com/stripe/goforit/clamp" 15 | "github.com/stripe/goforit/flags2" 16 | "github.com/stripe/goforit/internal/safepool" 17 | ) 18 | 19 | // DefaultStatsdAddr is the address we will emit metrics to if not overridden. 20 | const DefaultStatsdAddr = "127.0.0.1:8200" 21 | 22 | const ( 23 | lastAssertInterval = 5 * time.Minute 24 | stalenessCheckInterval = 10 * time.Second 25 | ) 26 | 27 | const ( 28 | lastRefreshMetricName = "goforit.flags.last_refresh_s" 29 | reportCountsScannedMetricName = "goforit.report-counts.scanned" 30 | reportCountsReportedMetricName = "goforit.report-counts.reported" 31 | reportCountsDurationMetricName = "goforit.report-counts.duration" 32 | ) 33 | 34 | // MetricsClient is the set of methods required to emit metrics to statsd, for 35 | // customizing behavior or mocking. 36 | type MetricsClient interface { 37 | Histogram(string, float64, []string, float64) error 38 | TimeInMilliseconds(name string, milli float64, tags []string, rate float64) error 39 | Gauge(string, float64, []string, float64) error 40 | Count(string, int64, []string, float64) error 41 | io.Closer 42 | } 43 | 44 | // Goforit is the main interface for the library to check if flags enabled, refresh flags 45 | // customizing behavior or mocking. 46 | type Goforit interface { 47 | Enabled(ctx context.Context, name string, props map[string]string) (enabled bool) 48 | RefreshFlags(backend Backend) 49 | TryRefreshFlags(backend Backend) error 50 | SetStalenessThreshold(threshold time.Duration) 51 | AddDefaultTags(tags map[string]string) 52 | ReportCounts(callback func(name string, total, enabled uint64, isDeleted bool)) 53 | Close() error 54 | } 55 | 56 | type ( 57 | printFunc func(msg string, args ...interface{}) 58 | evaluationCallback func(flag string, active bool) 59 | ) 60 | 61 | type pooledRandFloater struct { 62 | // Rand is not concurrency safe, so keep a pool of them for goroutine-independent use 63 | rndPool atomic.Pointer[safepool.RandPool] 64 | } 65 | 66 | func (prf *pooledRandFloater) Float64() float64 { 67 | pool := prf.rndPool.Load() 68 | if pool == nil { 69 | pool = prf.init() 70 | } 71 | rnd := pool.Get() 72 | defer pool.Put(rnd) 73 | return rnd.Float64() 74 | } 75 | 76 | //go:noinline 77 | func (prf *pooledRandFloater) init() *safepool.RandPool { 78 | // CAS so that if we race here, we only write once 79 | prf.rndPool.CompareAndSwap(nil, safepool.NewRandPool()) 80 | return prf.rndPool.Load() 81 | } 82 | 83 | type goforit struct { 84 | flags *fastFlags 85 | defaultTags *fastTags 86 | evalCB evaluationCallback 87 | deletedCB evaluationCallback 88 | // math.Rand is not concurrency safe, so keep a pool of them for goroutine-independent use 89 | rnd *pooledRandFloater 90 | shouldCheckStaleness atomic.Bool 91 | ctxOverrideEnabled bool // immutable 92 | 93 | stalenessThreshold atomic.Pointer[time.Duration] 94 | 95 | // Unix time in nanos. 96 | lastFlagRefreshTime atomic.Int64 97 | 98 | stats atomic.Pointer[MetricsClient] 99 | shouldCloseStats bool // immutable 100 | 101 | isClosed atomic.Bool 102 | 103 | printf printFunc 104 | 105 | mu sync.Mutex 106 | 107 | done func() 108 | 109 | // lastAssert is the last time we alerted that flags may be out of date 110 | lastAssert time.Time 111 | // refreshTicker is used to tell the backend it should re-load state from disk 112 | refreshTicker *time.Ticker 113 | // stalenessTicker is used to tell Enabled it should check for staleness. 114 | stalenessTicker *time.Ticker 115 | } 116 | 117 | const DefaultInterval = 30 * time.Second 118 | 119 | func newWithoutInit(stalenessTickerInterval time.Duration) (*goforit, context.Context) { 120 | ctx, done := context.WithCancel(context.Background()) 121 | 122 | g := &goforit{ 123 | flags: newFastFlags(), 124 | defaultTags: newFastTags(), 125 | rnd: &pooledRandFloater{}, 126 | ctxOverrideEnabled: true, 127 | stalenessTicker: time.NewTicker(stalenessTickerInterval), 128 | printf: log.New(os.Stderr, "[goforit] ", log.LstdFlags).Printf, 129 | done: done, 130 | } 131 | 132 | // set an atomic value async rather than check channel inline (which takes a mutex) 133 | go func(stalenessTicker *time.Ticker) { 134 | doneCh := ctx.Done() 135 | for { 136 | select { 137 | case <-doneCh: 138 | return 139 | case <-stalenessTicker.C: 140 | g.shouldCheckStaleness.Store(true) 141 | } 142 | } 143 | }(g.stalenessTicker) 144 | 145 | return g, ctx 146 | } 147 | 148 | func (g *goforit) getStats() MetricsClient { 149 | if stats := g.stats.Load(); stats != nil { 150 | return *stats 151 | } 152 | return noopMetricsClient{} 153 | } 154 | 155 | func (g *goforit) setStats(c MetricsClient) { 156 | g.stats.Store(&c) 157 | } 158 | 159 | // New creates a new goforit 160 | func New(interval time.Duration, backend Backend, opts ...Option) Goforit { 161 | g, ctx := newWithoutInit(stalenessCheckInterval) 162 | g.init(interval, backend, ctx, opts...) 163 | // some users may depend on legacy behavior of creating a 164 | // non-dependency-injected statsd client. 165 | if g.stats.Load() == nil { 166 | client, _ := statsd.New(DefaultStatsdAddr) 167 | g.setStats(client) 168 | } 169 | return g 170 | } 171 | 172 | type Option interface { 173 | apply(g *goforit) 174 | } 175 | 176 | type optionFunc func(g *goforit) 177 | 178 | func (o optionFunc) apply(g *goforit) { 179 | o(g) 180 | } 181 | 182 | // WithOwnedStats instructs the returned Goforit instance to call 183 | // Close() on its stats client when Goforit is closed. 184 | func WithOwnedStats(isOwned bool) Option { 185 | return optionFunc(func(g *goforit) { 186 | g.shouldCloseStats = isOwned 187 | }) 188 | } 189 | 190 | func WithContextOverrideDisabled(disabled bool) Option { 191 | return optionFunc(func(g *goforit) { 192 | g.ctxOverrideEnabled = !disabled 193 | }) 194 | } 195 | 196 | // Logger uses the supplied function to log errors. By default, errors are 197 | // written to os.Stderr. 198 | func Logger(printf func(msg string, args ...interface{})) Option { 199 | return optionFunc(func(g *goforit) { 200 | g.printf = printf 201 | }) 202 | } 203 | 204 | // Statsd uses the supplied client to emit metrics to. By default, a client is 205 | // created and configured to emit metrics to DefaultStatsdAddr. 206 | func Statsd(stats MetricsClient) Option { 207 | return optionFunc(func(g *goforit) { 208 | g.stats.Store(&stats) 209 | }) 210 | } 211 | 212 | // EvaluationCallback registers a callback to execute for each evaluated flag 213 | func EvaluationCallback(cb evaluationCallback) Option { 214 | return optionFunc(func(g *goforit) { 215 | g.evalCB = cb 216 | }) 217 | } 218 | 219 | // DeletedCallback registers a callback to execute for each flag that is scheduled for deletion 220 | func DeletedCallback(cb evaluationCallback) Option { 221 | return optionFunc(func(g *goforit) { 222 | g.deletedCB = cb 223 | }) 224 | } 225 | 226 | type flagHolder struct { 227 | flag *flags2.Flag2 228 | disabledCount atomic.Uint64 229 | enabledCount atomic.Uint64 230 | clamp clamp.Clamp 231 | } 232 | 233 | func (g *goforit) getStalenessThreshold() time.Duration { 234 | if t := g.stalenessThreshold.Load(); t != nil { 235 | return *t 236 | } 237 | 238 | return 0 239 | } 240 | 241 | func (g *goforit) logStaleCheck() bool { 242 | g.mu.Lock() 243 | defer g.mu.Unlock() 244 | if time.Since(g.lastAssert) < lastAssertInterval { 245 | return false 246 | } 247 | g.lastAssert = time.Now() 248 | return true 249 | } 250 | 251 | // Check if a time is stale. 252 | func (g *goforit) staleCheck(t time.Time, metric string, metricRate float64, msg string, checkLastAssert bool) { 253 | if t.IsZero() { 254 | // Not really useful to treat this as a real time 255 | return 256 | } 257 | 258 | // Report the staleness 259 | staleness := time.Since(t) 260 | _ = g.getStats().Histogram(metric, staleness.Seconds(), nil, metricRate) 261 | 262 | // Log if we're old 263 | thresh := g.getStalenessThreshold() 264 | if thresh == 0 { 265 | return 266 | } 267 | if staleness <= thresh { 268 | return 269 | } 270 | // Don't log too often! 271 | if (!checkLastAssert || g.logStaleCheck()) && g.printf != nil { 272 | g.printf(msg, staleness, thresh) 273 | } 274 | } 275 | 276 | //go:noinline 277 | func (g *goforit) doStaleCheck() { 278 | last := g.lastFlagRefreshTime.Load() 279 | // time.Duration is conveniently measured in nanoseconds. 280 | lastRefreshTime := time.Unix(last/int64(time.Second), last%int64(time.Second)) 281 | g.staleCheck(lastRefreshTime, lastRefreshMetricName, 1, 282 | "Refresh cycle has not run in %s, past our threshold (%s)", true) 283 | } 284 | 285 | // Enabled returns true if the flag should be considered enabled. 286 | // It returns false if no flag with the specified name is found. 287 | func (g *goforit) Enabled(ctx context.Context, name string, properties map[string]string) (enabled bool) { 288 | enabled = false 289 | flag, flagExists := g.flags.Get(name) 290 | 291 | // nested loop is to avoid a Swap/write to the bool in the common case, 292 | // but still ensure only a single Enabled caller does the staleness check. 293 | if g.shouldCheckStaleness.Load() { 294 | if stillShouldCheck := g.shouldCheckStaleness.Swap(false); stillShouldCheck { 295 | g.doStaleCheck() 296 | } 297 | } 298 | 299 | if g.evalCB != nil { 300 | // Wrap in a func, so `enabled` is evaluated at return-time instead of when defer is called 301 | defer func() { g.evalCB(name, enabled) }() 302 | } 303 | if g.deletedCB != nil { 304 | if flag != nil && flag.flag.IsDeleted() { 305 | defer func() { g.deletedCB(name, enabled) }() 306 | } 307 | } 308 | 309 | // Check for an override. 310 | if g.ctxOverrideEnabled && ctx != nil { 311 | if ov, ok := ctx.Value(overrideContextKey).(overrides); ok { 312 | if enabled, ok = ov[name]; ok { 313 | return 314 | } 315 | } 316 | } 317 | 318 | if !flagExists { 319 | enabled = false 320 | return 321 | } 322 | 323 | switch flag.clamp { 324 | case clamp.AlwaysOff: 325 | enabled = false 326 | flag.disabledCount.Add(1) 327 | case clamp.AlwaysOn: 328 | enabled = true 329 | flag.enabledCount.Add(1) 330 | default: 331 | var err error 332 | enabled, err = flag.flag.Enabled(g.rnd, properties, g.defaultTags.Load()) 333 | if err != nil && g.printf != nil { 334 | g.printf(err.Error()) 335 | } 336 | // move setting these counts into the switch arms so that they can 337 | // be predicted better for the alwaysOn/alwaysOff cases. 338 | if enabled { 339 | flag.enabledCount.Add(1) 340 | } else { 341 | flag.disabledCount.Add(1) 342 | } 343 | } 344 | 345 | return 346 | } 347 | 348 | // RefreshFlags will use the provided thunk function to 349 | // fetch all feature flags and update the internal cache. 350 | // The thunk provided can use a variety of mechanisms for 351 | // querying the flag values, such as a local file or 352 | // Consul key/value storage. Backend refresh errors are 353 | // ignored. 354 | func (g *goforit) RefreshFlags(backend Backend) { 355 | _ = g.TryRefreshFlags(backend) 356 | } 357 | 358 | // TryRefreshFlags will use the provided thunk function to 359 | // fetch all feature flags and update the internal cache. 360 | // The thunk provided can use a variety of mechanisms for 361 | // querying the flag values, such as a local file or 362 | // Consul key/value storage. An error will be returned if 363 | // the backend refresh fails. 364 | func (g *goforit) TryRefreshFlags(backend Backend) error { 365 | // Ask the backend for the flags 366 | refreshedFlags, updated, err := backend.Refresh() 367 | if err != nil { 368 | _ = g.getStats().Count("goforit.refreshFlags.errors", 1, nil, 1) 369 | if g.printf != nil { 370 | g.printf("Error refreshing flags: %s", err) 371 | } 372 | return err 373 | } 374 | 375 | if !g.isClosed.Load() { 376 | g.lastFlagRefreshTime.Store(time.Now().UnixNano()) 377 | } 378 | 379 | g.flags.Update(refreshedFlags) 380 | 381 | g.staleCheck(updated, "goforit.flags.cache_file_age_s", 0.1, 382 | "Backend is stale (%s) past our threshold (%s)", false) 383 | 384 | return nil 385 | } 386 | 387 | func (g *goforit) SetStalenessThreshold(threshold time.Duration) { 388 | g.stalenessThreshold.Store(&threshold) 389 | } 390 | 391 | func (g *goforit) AddDefaultTags(tags map[string]string) { 392 | g.defaultTags.Set(tags) 393 | } 394 | 395 | // init initializes the flag backend, using the provided refresh function 396 | // to update the internal cache of flags periodically, at the specified interval. 397 | // Applies passed initialization options to the goforit instance. 398 | func (g *goforit) init(interval time.Duration, backend Backend, ctx context.Context, opts ...Option) { 399 | for _, opt := range opts { 400 | opt.apply(g) 401 | } 402 | 403 | g.RefreshFlags(backend) 404 | if interval != 0 { 405 | ticker := time.NewTicker(interval) 406 | g.refreshTicker = ticker 407 | 408 | go func() { 409 | for { 410 | select { 411 | case <-ctx.Done(): 412 | return 413 | case <-ticker.C: 414 | g.RefreshFlags(backend) 415 | } 416 | } 417 | }() 418 | } 419 | } 420 | 421 | // A unique context key for overrides 422 | type overrideContextKeyType struct{} 423 | 424 | var overrideContextKey = overrideContextKeyType{} 425 | 426 | type overrides map[string]bool 427 | 428 | // Override allows overriding the value of a goforit flag within a context. 429 | // This is mainly useful for tests. 430 | func Override(ctx context.Context, name string, value bool) context.Context { 431 | ov := overrides{} 432 | if old, ok := ctx.Value(overrideContextKey).(overrides); ok { 433 | for k, v := range old { 434 | ov[k] = v 435 | } 436 | } 437 | ov[name] = value 438 | return context.WithValue(ctx, overrideContextKey, ov) 439 | } 440 | 441 | // Close releases resources held 442 | // It's still safe to call Enabled() 443 | func (g *goforit) Close() error { 444 | if alreadyClosed := g.isClosed.Swap(true); alreadyClosed { 445 | return nil 446 | } 447 | 448 | if g.refreshTicker != nil { 449 | g.refreshTicker.Stop() 450 | g.refreshTicker = nil 451 | } 452 | 453 | if g.stalenessTicker != nil { 454 | g.stalenessTicker.Stop() 455 | g.stalenessTicker = nil 456 | } 457 | 458 | if g.done != nil { 459 | g.done() 460 | g.done = nil 461 | } 462 | 463 | if g.shouldCloseStats { 464 | _ = g.getStats().Close() 465 | } 466 | g.stats.Store(nil) 467 | 468 | // clear this so that tests work better 469 | g.lastFlagRefreshTime.Store(0) 470 | g.flags.Close() 471 | 472 | return nil 473 | } 474 | 475 | func (g *goforit) ReportCounts(callback func(name string, total, enabled uint64, isDeleted bool)) { 476 | g.mu.Lock() 477 | defer g.mu.Unlock() 478 | 479 | start := time.Now() 480 | scanned := int64(0) 481 | reported := int64(0) 482 | 483 | for name, fh := range g.flags.load() { 484 | scanned++ 485 | if fh.disabledCount.Load() == 0 && fh.enabledCount.Load() == 0 { 486 | continue 487 | } 488 | 489 | disabled := fh.disabledCount.Swap(0) 490 | enabled := fh.enabledCount.Swap(0) 491 | callback(name, disabled+enabled, enabled, fh.flag.IsDeleted()) 492 | reported++ 493 | } 494 | 495 | duration := time.Now().Sub(start) 496 | stats := g.getStats() 497 | _ = stats.Gauge(reportCountsScannedMetricName, float64(scanned), nil, 1.0) 498 | _ = stats.Gauge(reportCountsReportedMetricName, float64(reported), nil, 1.0) 499 | _ = stats.TimeInMilliseconds(reportCountsDurationMetricName, duration.Seconds()*1000, nil, 1.0) 500 | } 501 | 502 | // for the interface compatability static check 503 | var _ Goforit = &goforit{} 504 | -------------------------------------------------------------------------------- /goforit_test.go: -------------------------------------------------------------------------------- 1 | package goforit 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log" 10 | "math" 11 | "os" 12 | "path/filepath" 13 | "strings" 14 | "sync" 15 | "sync/atomic" 16 | "testing" 17 | "time" 18 | "unsafe" 19 | 20 | "github.com/stretchr/testify/assert" 21 | "go.uber.org/goleak" 22 | 23 | "github.com/stripe/goforit/clamp" 24 | "github.com/stripe/goforit/flags2" 25 | ) 26 | 27 | const ε = .02 28 | 29 | type mockStatsd struct { 30 | lock sync.Mutex 31 | histograms map[string][]float64 32 | durations map[string][]time.Duration 33 | gauges map[string]float64 34 | } 35 | 36 | func (m *mockStatsd) TimeInMilliseconds(name string, milli float64, tags []string, rate float64) error { 37 | m.lock.Lock() 38 | defer m.lock.Unlock() 39 | 40 | if m.durations == nil { 41 | m.durations = make(map[string][]time.Duration) 42 | } 43 | m.durations[name] = append(m.durations[name], time.Duration(milli*float64(time.Millisecond))) 44 | return nil 45 | } 46 | 47 | func (m *mockStatsd) Close() error { 48 | return nil 49 | } 50 | 51 | func (m *mockStatsd) Gauge(name string, val float64, _ []string, _ float64) error { 52 | m.lock.Lock() 53 | defer m.lock.Unlock() 54 | 55 | if m.gauges == nil { 56 | m.gauges = make(map[string]float64) 57 | } 58 | m.gauges[name] = val 59 | return nil 60 | } 61 | 62 | func (m *mockStatsd) Count(string, int64, []string, float64) error { 63 | return nil 64 | } 65 | 66 | func (m *mockStatsd) Histogram(name string, value float64, tags []string, rate float64) error { 67 | m.lock.Lock() 68 | defer m.lock.Unlock() 69 | if m.histograms == nil { 70 | m.histograms = make(map[string][]float64) 71 | } 72 | m.histograms[name] = append(m.histograms[name], value) 73 | return nil 74 | } 75 | 76 | func (m *mockStatsd) getHistogramValues(name string) []float64 { 77 | m.lock.Lock() 78 | defer m.lock.Unlock() 79 | s := make([]float64, len(m.histograms[name])) 80 | copy(s, m.histograms[name]) 81 | return s 82 | } 83 | 84 | func (m *mockStatsd) getDurationValues(name string) []time.Duration { 85 | m.lock.Lock() 86 | defer m.lock.Unlock() 87 | s := make([]time.Duration, len(m.durations[name])) 88 | copy(s, m.durations[name]) 89 | return s 90 | } 91 | 92 | func (m *mockStatsd) getGaugeValue(name string) float64 { 93 | m.lock.Lock() 94 | defer m.lock.Unlock() 95 | return m.gauges[name] 96 | } 97 | 98 | var _ MetricsClient = &mockStatsd{} 99 | 100 | // TestFlagHolderSize ensures that the struct we use to refer to flags in the 101 | // Enabled fast path doesn't grow by mistake. In particular, we do an atomic 102 | // write to the enabled or disabled Uint64 counter in the flag holder, and we 103 | // want to ensure that this struct is a divisor of cacheline size. This 104 | // ensures that instances don't "straddle" cachelines, resulting in (a) false 105 | // sharing and (b) needing to load multiple cache-lines in order to check a 106 | // flag. 107 | func TestFlagHolderSize(t *testing.T) { 108 | const cachelineSize = 64 109 | const expectedSize = cachelineSize / 2 110 | assert.Equal(t, expectedSize, int(unsafe.Sizeof(flagHolder{}))) 111 | } 112 | 113 | type logBuffer struct { 114 | buf bytes.Buffer 115 | mu sync.Mutex 116 | } 117 | 118 | func (l *logBuffer) Write(b []byte) (n int, err error) { 119 | l.mu.Lock() 120 | defer l.mu.Unlock() 121 | 122 | return l.buf.Write(b) 123 | } 124 | 125 | func (l *logBuffer) String() string { 126 | l.mu.Lock() 127 | defer l.mu.Unlock() 128 | 129 | return l.buf.String() 130 | } 131 | 132 | var _ io.Writer = &logBuffer{} 133 | 134 | // Build a goforit for testing 135 | // Also return the log output 136 | func testGoforit(interval time.Duration, backend Backend, enabledTickerInterval time.Duration, options ...Option) (*goforit, *logBuffer) { 137 | g, ctx := newWithoutInit(enabledTickerInterval) 138 | g.rnd = &pooledRandFloater{} 139 | buf := new(logBuffer) 140 | g.printf = log.New(buf, "", 9).Printf 141 | g.setStats(&mockStatsd{}) 142 | 143 | if backend != nil { 144 | g.init(interval, backend, ctx, options...) 145 | } 146 | 147 | return g, buf 148 | } 149 | 150 | func TestEnabled(t *testing.T) { 151 | t.Parallel() 152 | 153 | const iterations = 100000 154 | 155 | backend := BackendFromJSONFile2(filepath.Join("testdata", "flags2_example.json")) 156 | g, _ := testGoforit(DefaultInterval, backend, stalenessCheckInterval) 157 | 158 | assert.False(t, g.Enabled(context.Background(), "go.sun.money", nil)) 159 | assert.True(t, g.Enabled(context.Background(), "go.moon.mercury", nil)) 160 | 161 | // nil is equivalent to empty context 162 | assert.False(t, g.Enabled(nil, "go.sun.money", nil)) 163 | assert.True(t, g.Enabled(nil, "go.moon.mercury", nil)) 164 | 165 | count := 0 166 | for i := 0; i < iterations; i++ { 167 | if g.Enabled(context.Background(), "go.stars.money", nil) { 168 | count++ 169 | } 170 | } 171 | actualRate := float64(count) / float64(iterations) 172 | 173 | assert.InEpsilon(t, 0.5, actualRate, ε) 174 | 175 | // should be able to be called twice without error, hanging, or panic 176 | assert.NoError(t, g.Close()) 177 | assert.NoError(t, g.Close()) 178 | } 179 | 180 | // dummyBackend lets us test the RefreshFlags 181 | // by returning the flags only the second time the Refresh 182 | // method is called 183 | type dummyBackend struct { 184 | // tally how many times Refresh() has been called 185 | refreshedCount int32 // read atomically 186 | } 187 | 188 | func (b *dummyBackend) Refresh() ([]*flags2.Flag2, time.Time, error) { 189 | defer func() { 190 | atomic.AddInt32(&b.refreshedCount, 1) 191 | }() 192 | 193 | if atomic.LoadInt32(&b.refreshedCount) == 0 { 194 | return []*flags2.Flag2{}, time.Time{}, nil 195 | } 196 | 197 | f, err := os.Open(filepath.Join("testdata", "flags2_example.json")) 198 | if err != nil { 199 | return nil, time.Time{}, err 200 | } 201 | defer f.Close() 202 | return parseFlagsJSON2(f) 203 | } 204 | 205 | func TestRefresh(t *testing.T) { 206 | t.Parallel() 207 | 208 | backend := &dummyBackend{} 209 | g, _ := testGoforit(10*time.Millisecond, backend, stalenessCheckInterval) 210 | 211 | assert.False(t, g.Enabled(context.Background(), "go.sun.money", nil)) 212 | assert.False(t, g.Enabled(context.Background(), "go.moon.mercury", nil)) 213 | 214 | defer g.Close() 215 | 216 | // ensure refresh runs twice to avoid race conditions 217 | // in which the Refresh method returns but the assertions get called 218 | // before the flags are actually updated 219 | for atomic.LoadInt32(&backend.refreshedCount) < 2 { 220 | <-time.After(10 * time.Millisecond) 221 | } 222 | 223 | assert.False(t, g.Enabled(context.Background(), "go.sun.money", nil)) 224 | assert.True(t, g.Enabled(context.Background(), "go.moon.mercury", nil)) 225 | } 226 | 227 | func TestNonExistent(t *testing.T) { 228 | t.Parallel() 229 | 230 | backend := &dummyBackend{} 231 | g, _ := testGoforit(10*time.Millisecond, backend, stalenessCheckInterval) 232 | defer g.Close() 233 | 234 | g.deletedCB = func(name string, enabled bool) { 235 | assert.False(t, enabled) 236 | } 237 | 238 | // if non-existent flags aren't handled correctly, this could panic 239 | assert.False(t, g.Enabled(context.Background(), "non.existent.tag", nil)) 240 | } 241 | 242 | // errorBackend always returns an error for refreshes. 243 | type errorBackend struct{} 244 | 245 | func (e *errorBackend) Refresh() ([]*flags2.Flag2, time.Time, error) { 246 | return []*flags2.Flag2{}, time.Time{}, errors.New("read failed") 247 | } 248 | 249 | func TestTryRefresh(t *testing.T) { 250 | t.Parallel() 251 | 252 | backend := &errorBackend{} 253 | g, _ := testGoforit(10*time.Millisecond, backend, stalenessCheckInterval) 254 | defer g.Close() 255 | 256 | err := g.TryRefreshFlags(backend) 257 | assert.Error(t, err) 258 | } 259 | 260 | func TestRefreshTicker(t *testing.T) { 261 | t.Parallel() 262 | 263 | backend := BackendFromJSONFile2(filepath.Join("testdata", "flags2_example.json")) 264 | g, _ := testGoforit(10*time.Second, backend, stalenessCheckInterval) 265 | defer g.Close() 266 | 267 | g.flags.storeForTesting("go.earth.money", &flagHolder{ 268 | flag: &flags2.Flag2{"go.earth.money", "seed", nil, false}, 269 | clamp: clamp.MayVary, 270 | }) 271 | g.flags.deleteForTesting("go.stars.money") 272 | // Give tickers time to run. 273 | time.Sleep(time.Millisecond) 274 | 275 | g.RefreshFlags(backend) 276 | 277 | _, ok := g.flags.Get("go.sun.money") 278 | assert.True(t, ok) 279 | _, ok = g.flags.Get("go.moon.mercury") 280 | assert.True(t, ok) 281 | _, ok = g.flags.Get("go.stars.money") 282 | assert.True(t, ok) 283 | _, ok = g.flags.Get("go.earth.money") 284 | assert.False(t, ok) 285 | } 286 | 287 | func BenchmarkEnabled(b *testing.B) { 288 | backends := []struct { 289 | name string 290 | backend Backend 291 | }{ 292 | {"json2", BackendFromJSONFile2(filepath.Join("testdata", "flags2_example.json"))}, 293 | } 294 | flags := []struct { 295 | name string 296 | flag string 297 | }{ 298 | {"50pct", "go.stars.money"}, 299 | {"on", "go.moon.mercury"}, 300 | } 301 | 302 | for _, backend := range backends { 303 | for _, flag := range flags { 304 | name := fmt.Sprintf("%s/%s", backend.name, flag.name) 305 | b.Run(name, func(b *testing.B) { 306 | g, _ := testGoforit(10*time.Microsecond, backend.backend, stalenessCheckInterval) 307 | defer g.Close() 308 | b.ResetTimer() 309 | b.ReportAllocs() 310 | b.RunParallel(func(pb *testing.PB) { 311 | for pb.Next() { 312 | _ = g.Enabled(context.Background(), flag.flag, nil) 313 | } 314 | }) 315 | }) 316 | } 317 | } 318 | } 319 | 320 | func BenchmarkEnabledWithArgs(b *testing.B) { 321 | backends := []struct { 322 | name string 323 | backend Backend 324 | }{ 325 | {"json2", BackendFromJSONFile2(filepath.Join("testdata", "flags2_example.json"))}, 326 | } 327 | flags := []struct { 328 | name string 329 | flag string 330 | }{ 331 | {"flag5", "flag5"}, 332 | } 333 | defaultTags := []map[string]string{ 334 | nil, 335 | { 336 | "foo": "a", 337 | "bar": "b", 338 | }, 339 | } 340 | 341 | for _, backend := range backends { 342 | for _, flag := range flags { 343 | for _, tags := range defaultTags { 344 | name := fmt.Sprintf("%s/%s/%v", backend.name, flag.name, tags) 345 | b.Run(name, func(b *testing.B) { 346 | g, _ := testGoforit(10*time.Microsecond, backend.backend, stalenessCheckInterval) 347 | if tags != nil { 348 | g.AddDefaultTags(tags) 349 | } 350 | defer g.Close() 351 | b.ResetTimer() 352 | b.ReportAllocs() 353 | b.RunParallel(func(pb *testing.PB) { 354 | props := map[string]string{ 355 | "token": "id_123", 356 | } 357 | for pb.Next() { 358 | _ = g.Enabled(context.Background(), flag.flag, props) 359 | } 360 | }) 361 | }) 362 | } 363 | } 364 | } 365 | } 366 | 367 | type dummyDefaultFlagsBackend struct{} 368 | 369 | func (b *dummyDefaultFlagsBackend) Refresh() ([]*flags2.Flag2, time.Time, error) { 370 | testFlag := &flags2.Flag2{ 371 | "test", 372 | "seed", 373 | []flags2.Rule2{ 374 | { 375 | HashBy: flags2.HashByRandom, 376 | Percent: flags2.PercentOff, 377 | Predicates: []flags2.Predicate2{ 378 | { 379 | Attribute: "host_name", 380 | Operation: flags2.OpIn, 381 | Values: map[string]bool{ 382 | "apibox_789": true, 383 | }, 384 | }, 385 | }, 386 | }, 387 | { 388 | HashBy: flags2.HashByRandom, 389 | Percent: flags2.PercentOn, 390 | Predicates: []flags2.Predicate2{ 391 | { 392 | Attribute: "host_name", 393 | Operation: flags2.OpIn, 394 | Values: map[string]bool{ 395 | "apibox_123": true, 396 | "apibox_456": true, 397 | }, 398 | }, 399 | }, 400 | }, 401 | { 402 | HashBy: flags2.HashByRandom, 403 | Percent: flags2.PercentOn, 404 | Predicates: []flags2.Predicate2{ 405 | { 406 | Attribute: "cluster", 407 | Operation: flags2.OpIn, 408 | Values: map[string]bool{ 409 | "northwest-01": true, 410 | }, 411 | }, 412 | { 413 | Attribute: "db", 414 | Operation: flags2.OpIn, 415 | Values: map[string]bool{ 416 | "mongo-prod": true, 417 | }, 418 | }, 419 | }, 420 | }, 421 | }, 422 | false, 423 | } 424 | return []*flags2.Flag2{testFlag}, time.Time{}, nil 425 | } 426 | 427 | func TestDefaultTags(t *testing.T) { 428 | t.Parallel() 429 | 430 | g, _ := testGoforit(DefaultInterval, &dummyDefaultFlagsBackend{}, stalenessCheckInterval) 431 | defer func() { _ = g.Close() }() 432 | 433 | // if no properties passed, and no default tags added, then should return false 434 | assert.False(t, g.Enabled(context.Background(), "test", nil)) 435 | 436 | // test match list rule by adding hostname to default tag 437 | g.AddDefaultTags(map[string]string{"host_name": "apibox_123", "env": "prod"}) 438 | assert.True(t, g.Enabled(context.Background(), "test", nil)) 439 | 440 | // test overriding global default in local props map 441 | assert.False(t, g.Enabled(context.Background(), "test", map[string]string{"host_name": "apibox_789"})) 442 | 443 | // if missing cluster+db, then rate rule should return false 444 | assert.False(t, g.Enabled(context.Background(), "test", map[string]string{"host_name": "apibox_001"})) 445 | 446 | // if only one of cluster and db, then rate rule should return false 447 | assert.False(t, g.Enabled(context.Background(), "test", map[string]string{"host_name": "apibox_001", "db": "mongo-prod"})) 448 | 449 | // test combination of global tag and local props 450 | g.AddDefaultTags(map[string]string{"cluster": "northwest-01"}) 451 | assert.True(t, g.Enabled(context.Background(), "test", map[string]string{"host_name": "apibox_001", "db": "mongo-prod"})) 452 | assert.False(t, g.Enabled(context.Background(), "test", map[string]string{"host_name": "apibox_001", "db": "mongo-qa"})) 453 | } 454 | 455 | func TestOverride(t *testing.T) { 456 | t.Parallel() 457 | 458 | backend := BackendFromJSONFile2(filepath.Join("testdata", "flags2_example.json")) 459 | g, _ := testGoforit(10*time.Millisecond, backend, stalenessCheckInterval) 460 | defer g.Close() 461 | g.RefreshFlags(backend) 462 | 463 | // Empty context gets values from backend. 464 | assert.False(t, g.Enabled(context.Background(), "go.sun.money", nil)) 465 | assert.True(t, g.Enabled(context.Background(), "go.moon.mercury", nil)) 466 | assert.False(t, g.Enabled(context.Background(), "go.extra", nil)) 467 | 468 | // Nil is equivalent to empty context. 469 | assert.False(t, g.Enabled(nil, "go.sun.money", nil)) 470 | assert.True(t, g.Enabled(nil, "go.moon.mercury", nil)) 471 | assert.False(t, g.Enabled(nil, "go.extra", nil)) 472 | 473 | // Can override to true in context. 474 | ctx := context.Background() 475 | ctx = Override(ctx, "go.sun.money", true) 476 | assert.True(t, g.Enabled(ctx, "go.sun.money", nil)) 477 | assert.True(t, g.Enabled(ctx, "go.moon.mercury", nil)) 478 | assert.False(t, g.Enabled(ctx, "go.extra", nil)) 479 | 480 | // Can override to false. 481 | ctx = Override(ctx, "go.moon.mercury", false) 482 | assert.True(t, g.Enabled(ctx, "go.sun.money", nil)) 483 | assert.False(t, g.Enabled(ctx, "go.moon.mercury", nil)) 484 | assert.False(t, g.Enabled(ctx, "go.extra", nil)) 485 | 486 | // Can override brand new flag. 487 | ctx = Override(ctx, "go.extra", true) 488 | assert.True(t, g.Enabled(ctx, "go.sun.money", nil)) 489 | assert.False(t, g.Enabled(ctx, "go.moon.mercury", nil)) 490 | assert.True(t, g.Enabled(ctx, "go.extra", nil)) 491 | 492 | // Can override an override. 493 | ctx = Override(ctx, "go.extra", false) 494 | assert.True(t, g.Enabled(ctx, "go.sun.money", nil)) 495 | assert.False(t, g.Enabled(ctx, "go.moon.mercury", nil)) 496 | assert.False(t, g.Enabled(ctx, "go.extra", nil)) 497 | 498 | // Separate contexts don't interfere with each other. 499 | // This allows parallel tests that use feature flags. 500 | ctx2 := Override(context.Background(), "go.extra", true) 501 | assert.True(t, g.Enabled(ctx, "go.sun.money", nil)) 502 | assert.False(t, g.Enabled(ctx, "go.moon.mercury", nil)) 503 | assert.False(t, g.Enabled(ctx, "go.extra", nil)) 504 | assert.False(t, g.Enabled(ctx2, "go.sun.money", nil)) 505 | assert.True(t, g.Enabled(ctx2, "go.moon.mercury", nil)) 506 | assert.True(t, g.Enabled(ctx2, "go.extra", nil)) 507 | 508 | // Overrides apply to child contexts. 509 | child := context.WithValue(ctx, "foo", "bar") 510 | assert.True(t, g.Enabled(child, "go.sun.money", nil)) 511 | assert.False(t, g.Enabled(child, "go.moon.mercury", nil)) 512 | assert.False(t, g.Enabled(child, "go.extra", nil)) 513 | 514 | // Changes to child contexts don't affect parents. 515 | child = Override(child, "go.moon.mercury", true) 516 | assert.True(t, g.Enabled(child, "go.sun.money", nil)) 517 | assert.True(t, g.Enabled(child, "go.moon.mercury", nil)) 518 | assert.False(t, g.Enabled(child, "go.extra", nil)) 519 | assert.True(t, g.Enabled(ctx, "go.sun.money", nil)) 520 | assert.False(t, g.Enabled(ctx, "go.moon.mercury", nil)) 521 | assert.False(t, g.Enabled(ctx, "go.extra", nil)) 522 | } 523 | 524 | func TestOverrideWithoutInit(t *testing.T) { 525 | t.Parallel() 526 | 527 | g, _ := testGoforit(0, nil, stalenessCheckInterval) 528 | defer func() { _ = g.Close() }() 529 | 530 | // Everything is false by default. 531 | assert.False(t, g.Enabled(context.Background(), "go.sun.money", nil)) 532 | assert.False(t, g.Enabled(context.Background(), "go.moon.mercury", nil)) 533 | 534 | // Can override. 535 | ctx := Override(context.Background(), "go.sun.money", true) 536 | assert.True(t, g.Enabled(ctx, "go.sun.money", nil)) 537 | assert.False(t, g.Enabled(ctx, "go.moon.mercury", nil)) 538 | } 539 | 540 | type dummyAgeBackend struct { 541 | t time.Time 542 | mtx sync.RWMutex 543 | } 544 | 545 | func (b *dummyAgeBackend) Refresh() ([]*flags2.Flag2, time.Time, error) { 546 | testFlag := &flags2.Flag2{ 547 | Name: "go.sun.money", 548 | Seed: "seed", 549 | Rules: []flags2.Rule2{ 550 | { 551 | HashBy: flags2.HashByRandom, 552 | Percent: flags2.PercentOn, 553 | }, 554 | }, 555 | } 556 | b.mtx.RLock() 557 | defer b.mtx.RUnlock() 558 | return []*flags2.Flag2{testFlag}, b.t, nil 559 | } 560 | 561 | // Test to see proper monitoring of age of the flags dump 562 | func TestCacheFileMetric(t *testing.T) { 563 | t.Parallel() 564 | 565 | backend := &dummyAgeBackend{t: time.Now().Add(-10 * time.Minute)} 566 | g, _ := testGoforit(10*time.Millisecond, backend, stalenessCheckInterval) 567 | defer g.Close() 568 | 569 | time.Sleep(50 * time.Millisecond) 570 | func() { 571 | backend.mtx.Lock() 572 | defer backend.mtx.Unlock() 573 | backend.t = time.Now() 574 | }() 575 | time.Sleep(50 * time.Millisecond) 576 | 577 | // We expect something like: [600, 600.01, ..., 0.0, 0.01, ...] 578 | last := math.Inf(-1) 579 | old := 0 580 | recent := 0 581 | for _, v := range g.getStats().(*mockStatsd).getHistogramValues("goforit.flags.cache_file_age_s") { 582 | if v > 300 { 583 | // Should be older than last time 584 | assert.True(t, v > last) 585 | // Should be about 10 minutes 586 | assert.InDelta(t, 600, v, 3) 587 | old++ 588 | assert.Zero(t, recent, "Should never go from new -> old") 589 | } else { 590 | // Should be older (unless we just wrote the file) 591 | if recent > 0 { 592 | assert.True(t, v > last) 593 | } 594 | // Should be about zero 595 | assert.InDelta(t, 0, v, 3) 596 | recent++ 597 | } 598 | last = v 599 | } 600 | assert.True(t, old > 2) 601 | assert.True(t, recent > 2) 602 | } 603 | 604 | // Test to see proper monitoring of refreshing the flags dump file from disc 605 | func TestRefreshCycleMetric(t *testing.T) { 606 | t.Parallel() 607 | 608 | backend := &dummyAgeBackend{t: time.Now().Add(-10 * time.Minute)} 609 | g, _ := testGoforit(10*time.Millisecond, backend, 100*time.Microsecond) 610 | defer g.Close() 611 | 612 | flag, _ := g.flags.Get("go.sun.money") 613 | g.flags.storeForTesting("go.sun.money", flag) 614 | 615 | iters := 30 616 | for i := 0; i < iters; i++ { 617 | g.Enabled(nil, "go.sun.money", nil) 618 | time.Sleep(3 * time.Millisecond) 619 | } 620 | 621 | initialMetricCount := len(g.getStats().(*mockStatsd).getHistogramValues(lastRefreshMetricName)) 622 | 623 | const antiFlakeSlack = 10 624 | 625 | // subtract 2 for iters to avoid flakey tests 626 | assert.GreaterOrEqual(t, initialMetricCount, iters-antiFlakeSlack) 627 | 628 | // want to stop refreshTicker to simulate Refresh() hanging 629 | g.refreshTicker.Stop() 630 | time.Sleep(3 * time.Millisecond) 631 | 632 | for i := 0; i < iters; i++ { 633 | g.Enabled(nil, "go.sun.money", nil) 634 | // sleep to ensure the g.stalenessTicker pumps 635 | time.Sleep(3 * time.Millisecond) 636 | } 637 | 638 | values := g.getStats().(*mockStatsd).getHistogramValues(lastRefreshMetricName) 639 | assert.Greater(t, len(values), initialMetricCount) 640 | assert.GreaterOrEqual(t, len(values), iters-antiFlakeSlack) 641 | // We expect something like: [0, 0.01, 0, 0.01, ..., 0, 0.01, 0.02, 0.03] 642 | for i := 0; i < initialMetricCount; i++ { 643 | v := values[i] 644 | // Should be small. Really 10ms, but add a bit of wiggle room 645 | assert.True(t, v < 0.03) 646 | } 647 | 648 | last := math.Inf(-1) 649 | large := 0 650 | for i := initialMetricCount; i < len(values); i++ { 651 | v := values[i] 652 | assert.True(t, v > last, fmt.Sprintf("%d: %v: %v", i, v, values)) 653 | last = v 654 | if v > 0.03 { 655 | // At least some should be large now, since we're not refreshing 656 | large++ 657 | } 658 | } 659 | assert.True(t, large > 2) 660 | } 661 | 662 | func TestStaleFile(t *testing.T) { 663 | t.Parallel() 664 | 665 | backend := &dummyAgeBackend{t: time.Now().Add(-1000 * time.Hour)} 666 | g, buf := testGoforit(10*time.Millisecond, backend, stalenessCheckInterval) 667 | defer g.Close() 668 | g.SetStalenessThreshold(10*time.Minute + 42*time.Second) 669 | 670 | time.Sleep(50 * time.Millisecond) 671 | 672 | // Should see staleness warnings for backend 673 | lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") 674 | assert.True(t, len(lines) > 2) 675 | for _, line := range lines { 676 | assert.Contains(t, line, "10m42") 677 | assert.Contains(t, line, "Backend") 678 | } 679 | } 680 | 681 | func TestNoStaleFile(t *testing.T) { 682 | t.Parallel() 683 | 684 | backend := &dummyAgeBackend{t: time.Now().Add(-1000 * time.Hour)} 685 | g, buf := testGoforit(10*time.Millisecond, backend, stalenessCheckInterval) 686 | defer g.Close() 687 | 688 | time.Sleep(50 * time.Millisecond) 689 | 690 | // Never set staleness, so no warnings 691 | assert.Zero(t, buf.String()) 692 | } 693 | 694 | func TestStaleRefresh(t *testing.T) { 695 | t.Parallel() 696 | 697 | backend := &dummyBackend{} 698 | g, buf := testGoforit(10*time.Millisecond, backend, time.Nanosecond) 699 | defer func() { _ = g.Close() }() 700 | g.SetStalenessThreshold(50 * time.Millisecond) 701 | 702 | // Simulate stopping refresh 703 | g.refreshTicker.Stop() 704 | time.Sleep(100 * time.Millisecond) 705 | 706 | for i := 0; i < 10; i++ { 707 | g.Enabled(nil, "go.sun.money", nil) 708 | } 709 | 710 | // Should see just one staleness warning 711 | lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") 712 | assert.Equal(t, 1, len(lines)) 713 | assert.Contains(t, lines[0], "Refresh") 714 | assert.Contains(t, lines[0], "50ms") 715 | } 716 | 717 | type flagStatus struct { 718 | flag string 719 | active bool 720 | } 721 | 722 | func TestEvaluationCallback(t *testing.T) { 723 | t.Parallel() 724 | 725 | evaluated := map[flagStatus]int{} 726 | backend := BackendFromJSONFile2(filepath.Join("testdata", "flags2_example.json")) 727 | g := New(stalenessCheckInterval, 728 | backend, 729 | EvaluationCallback(func(flag string, active bool) { 730 | evaluated[flagStatus{flag, active}] += 1 731 | }), 732 | WithOwnedStats(true), 733 | ) 734 | defer g.Close() 735 | 736 | g.Enabled(nil, "go.sun.money", nil) 737 | g.Enabled(nil, "go.moon.mercury", nil) 738 | g.Enabled(nil, "go.moon.mercury", nil) 739 | 740 | assert.Equal(t, 2, len(evaluated)) 741 | assert.Equal(t, 1, evaluated[flagStatus{"go.sun.money", false}]) 742 | assert.Equal(t, 2, evaluated[flagStatus{"go.moon.mercury", true}]) 743 | } 744 | 745 | func TestDeletionCallback(t *testing.T) { 746 | t.Parallel() 747 | 748 | deleted := map[flagStatus]int{} 749 | backend := BackendFromJSONFile2(filepath.Join("testdata", "flags2_acceptance.json")) 750 | g := New(stalenessCheckInterval, 751 | backend, 752 | DeletedCallback(func(flag string, active bool) { 753 | deleted[flagStatus{flag, active}] += 1 754 | }), 755 | WithOwnedStats(true), 756 | ) 757 | defer g.Close() 758 | 759 | g.Enabled(nil, "on_flag", nil) 760 | g.Enabled(nil, "deleted_on_flag", nil) 761 | g.Enabled(nil, "deleted_on_flag", nil) 762 | g.Enabled(nil, "explicitly_not_deleted_flag", nil) 763 | 764 | assert.Equal(t, 1, len(deleted)) 765 | assert.Equal(t, 2, deleted[flagStatus{"deleted_on_flag", true}]) 766 | } 767 | 768 | func TestGoforit_ReportCounts(t *testing.T) { 769 | backend := BackendFromJSONFile2(filepath.Join("testdata", "flags2_example.json")) 770 | g, _ := testGoforit(10*time.Millisecond, backend, stalenessCheckInterval) 771 | defer func() { _ = g.Close() }() 772 | g.RefreshFlags(backend) 773 | 774 | ctx := context.Background() 775 | assert.True(t, g.Enabled(ctx, "go.moon.mercury", nil)) 776 | assert.True(t, g.Enabled(ctx, "go.moon.mercury", nil)) 777 | assert.True(t, g.Enabled(ctx, "go.moon.mercury", nil)) 778 | assert.False(t, g.Enabled(ctx, "off_flag", nil)) 779 | assert.False(t, g.Enabled(ctx, "off_flag", nil)) 780 | 781 | disabledCounts := make(map[string]uint64) 782 | enabledCounts := make(map[string]uint64) 783 | anyAreDeleted := false 784 | 785 | g.ReportCounts(func(name string, total, enabled uint64, isDeleted bool) { 786 | disabledCounts[name] = total - enabled 787 | enabledCounts[name] = enabled 788 | anyAreDeleted = anyAreDeleted || isDeleted 789 | }) 790 | 791 | expectedDisabledCounts := map[string]uint64{ 792 | "go.moon.mercury": 0, 793 | "off_flag": 2, 794 | } 795 | expectedEnabledCounts := map[string]uint64{ 796 | "go.moon.mercury": 3, 797 | "off_flag": 0, 798 | } 799 | 800 | assert.Equal(t, expectedDisabledCounts, disabledCounts) 801 | assert.Equal(t, expectedEnabledCounts, enabledCounts) 802 | assert.False(t, anyAreDeleted) 803 | 804 | stats := g.getStats().(*mockStatsd) 805 | // 5 FFs in the test file 806 | assert.Equal(t, float64(5), stats.getGaugeValue(reportCountsScannedMetricName)) 807 | // 2 FFs that were actually tested for in our code 808 | assert.Equal(t, float64(2), stats.getGaugeValue(reportCountsReportedMetricName)) 809 | assert.Equal(t, 1, len(stats.getDurationValues(reportCountsDurationMetricName))) 810 | duration := stats.getDurationValues(reportCountsDurationMetricName)[0] 811 | // duration should be positive 812 | assert.Greater(t, duration, time.Duration(0)) 813 | } 814 | 815 | func TestDefaultFastFlags(t *testing.T) { 816 | ff := &fastFlags{} 817 | 818 | // this shouldn't panic/crash on ff.flags being nil 819 | h, found := ff.Get("not_in_map") 820 | assert.False(t, found) 821 | assert.Nil(t, h) 822 | } 823 | 824 | func TestMain(m *testing.M) { 825 | goleak.VerifyTestMain(m) 826 | } 827 | -------------------------------------------------------------------------------- /internal/safepool/rand.go: -------------------------------------------------------------------------------- 1 | package safepool 2 | 3 | import ( 4 | crand "crypto/rand" 5 | "encoding/binary" 6 | "math/rand" 7 | "sync" 8 | ) 9 | 10 | // getSeed returns a cryptographically secure seed for calls to math/rand.NewSource. 11 | func getSeed() int64 { 12 | buf := make([]byte, 8) 13 | if n, err := crand.Reader.Read(buf); err != nil || n != 8 { 14 | panic("failed reading from crypto/rand.Reader") 15 | } 16 | 17 | return int64(binary.LittleEndian.Uint64(buf)) 18 | } 19 | 20 | // RandPool is a typed API over a sync.Pool of *math/rand.Rand instances. 21 | type RandPool struct { 22 | p sync.Pool 23 | } 24 | 25 | // NewRandPool returns a new RandPool instance. 26 | func NewRandPool() *RandPool { 27 | return &RandPool{ 28 | p: sync.Pool{ 29 | New: func() interface{} { 30 | return rand.New(rand.NewSource(getSeed())) 31 | }, 32 | }, 33 | } 34 | } 35 | 36 | // Get is safe for use from concurrent goroutines, but the returned *rand.Rand instance isn't. 37 | func (p *RandPool) Get() *rand.Rand { 38 | return p.p.Get().(*rand.Rand) 39 | } 40 | 41 | // Put is safe for use from concurrent goroutines and returns a *rand.Rand instance to the pool. 42 | func (p *RandPool) Put(item *rand.Rand) { 43 | p.p.Put(item) 44 | } 45 | -------------------------------------------------------------------------------- /metrics.go: -------------------------------------------------------------------------------- 1 | package goforit 2 | 3 | type noopMetricsClient struct{} 4 | 5 | func (n noopMetricsClient) Histogram(s string, f float64, strings []string, f2 float64) error { 6 | return nil 7 | } 8 | 9 | func (n noopMetricsClient) TimeInMilliseconds(name string, milli float64, tags []string, rate float64) error { 10 | return nil 11 | } 12 | 13 | func (n noopMetricsClient) Gauge(s string, f float64, strings []string, f2 float64) error { 14 | return nil 15 | } 16 | 17 | func (n noopMetricsClient) Count(s string, i int64, strings []string, f float64) error { 18 | return nil 19 | } 20 | 21 | func (n noopMetricsClient) Close() error { 22 | return nil 23 | } 24 | 25 | var _ MetricsClient = noopMetricsClient{} 26 | -------------------------------------------------------------------------------- /testdata/flags2_acceptance.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "flags": [ 4 | { 5 | "name": "off_flag", 6 | "_id": "ff_1", 7 | "seed": "seed_1", 8 | "rules": [], 9 | "updated": 1533106810.0, 10 | "version": "123abc", 11 | "should_be_ignored": "allow us to make additive changes to the schema" 12 | }, 13 | { 14 | "name": "on_flag", 15 | "_id": "ff_2", 16 | "seed": "seed_1", 17 | "rules": [ 18 | {"hash_by": "token", "percent": 1.0, "predicates": []} 19 | ], 20 | "updated": 1533106809.0, 21 | "version": "456def" 22 | }, 23 | { 24 | "name": "random_by_token_flag", 25 | "_id": "ff_3", 26 | "seed": "seed_1", 27 | "rules": [ 28 | {"hash_by": "token", "percent": 0.2, "predicates": []} 29 | ], 30 | "updated": 1533106808.0, 31 | "version": "789ghi" 32 | }, 33 | { 34 | "name": "random_by_token_flag_same_seed_increased_percent", 35 | "_id": "ff_4", 36 | "seed": "seed_1", 37 | "rules": [ 38 | {"hash_by": "token", "percent": 0.8, "predicates": []} 39 | ], 40 | "updated": 1533106807.0, 41 | "version": "123abc" 42 | }, 43 | { 44 | "name": "random_by_token_flag_different_seed", 45 | "_id": "ff_5", 46 | "seed": "seed_X", 47 | "rules": [ 48 | {"hash_by": "token", "percent": 0.2, "predicates": []} 49 | ], 50 | "updated": 1533106806.0, 51 | "version": "123abc" 52 | }, 53 | { 54 | "name": "blacklist_whitelist_by_token", 55 | "_id": "ff_6", 56 | "seed": "seed_1", 57 | "rules": [ 58 | {"hash_by": "token", "percent": 0.0, "predicates": [ 59 | {"attribute": "token", "operation": "in", "values": ["id_1", "id_2"]} 60 | ]}, 61 | {"hash_by": "token", "percent": 1.0, "predicates": [ 62 | {"attribute": "token", "operation": "in", "values": ["id_2", "id_3"]} 63 | ]} 64 | ], 65 | "updated": 1533106805.0, 66 | "version": "123abc" 67 | }, 68 | { 69 | "name": "country_ban", 70 | "_id": "ff_7", 71 | "seed": "seed_1", 72 | "rules": [ 73 | {"hash_by": "token", "percent": 1.0, "predicates": [ 74 | {"attribute": "token", "operation": "in", "values": ["id_1", "id_2"]}, 75 | {"attribute": "country", "operation": "not_in", "values": ["KP", "IR"]} 76 | ]} 77 | ], 78 | "updated": 1533106804.0, 79 | "version": "123abc" 80 | }, 81 | { 82 | "name": "off_flag_edge_override_on", 83 | "_id": "ff_8", 84 | "seed": "seed_1", 85 | "rules": [], 86 | "edge_override": true, 87 | "updated": 1533106803.0, 88 | "version": "123abc" 89 | }, 90 | { 91 | "name": "off_flag_edge_override_off", 92 | "_id": "ff_9", 93 | "seed": "seed_1", 94 | "rules": [], 95 | "edge_override": false, 96 | "updated": 1533106802.0, 97 | "version": "123abc" 98 | }, 99 | { 100 | "name": "bail_if_null_else_on", 101 | "_id": "ff_10", 102 | "seed": "seed_1", 103 | "rules": [ 104 | {"hash_by": "token", "percent": 0.0, "predicates": [ 105 | {"attribute": "token", "operation": "is_nil", "values": []} 106 | ]}, 107 | {"hash_by": "token", "percent": 1.0, "predicates": []} 108 | ], 109 | "updated": 1533106801.0, 110 | "version": "123abc" 111 | }, 112 | { 113 | "name": "country_with_multi_conditions", 114 | "_id": "ff_11", 115 | "seed": "seed_1", 116 | "rules": [ 117 | {"hash_by": "token", "percent": 1.0, "predicates": [ 118 | {"attribute": "token", "operation": "in", "values": ["id_1", "id_2"]}, 119 | {"attribute": "country", "operation": "in", "values": ["US", "CA"]} 120 | ]} 121 | ], 122 | "updated": 1533106799.0, 123 | "version": "123abc" 124 | }, 125 | { 126 | "name": "deleted_on_flag", 127 | "_id": "ff_12", 128 | "seed": "seed_1", 129 | "rules": [ 130 | {"hash_by": "token", "percent": 1.0, "predicates": []} 131 | ], 132 | "deleted": true, 133 | "updated": 1533106798.0, 134 | "version": "123abc" 135 | }, 136 | { 137 | "name": "explicitly_not_deleted_flag", 138 | "_id": "ff_13", 139 | "seed": "seed_1", 140 | "rules": [ 141 | {"hash_by": "token", "percent": 1.0, "predicates": []} 142 | ], 143 | "deleted": false, 144 | "updated": 1533106797.0, 145 | "version": "123abc" 146 | }, 147 | { 148 | "name": "on_flag_testmode_disabled", 149 | "_id": "ff_14", 150 | "seed": "seed_1", 151 | "rules": [ 152 | { 153 | "hash_by": "token", 154 | "percent": 1.0, 155 | "predicates": [] 156 | } 157 | ], 158 | "testmode_only": false, 159 | "updated": 1533106796.0, 160 | "version": "456def" 161 | }, 162 | { 163 | "name": "on_flag_with_experiment_rollout_type_rule", 164 | "_id": "ff_15", 165 | "seed": "seed_1", 166 | "rules": [ 167 | {"hash_by": "token", "percent": 1.0, "predicates": [], "rollout_type": "experiment"} 168 | ], 169 | "updated": 1533106809.0, 170 | "version": "456def" 171 | } 172 | ], 173 | "updated": 1533106800.0, 174 | 175 | "test_cases": [ 176 | {"flag": "off_flag", "expected": false, "attrs": {"token" : "x"}, "message": "always off"}, 177 | {"flag": "off_flag", "expected": false, "attrs": {"token" : "x", "foo" : "bar"}, "message": "always off, ignores attrs"}, 178 | {"flag": "off_flag", "expected": false, "attrs": {}, "message": "always off, ignores attrs, even when there are none"}, 179 | 180 | {"flag": "on_flag", "expected": true, "attrs": {"token": "x"}, "message": "always on"}, 181 | {"flag": "on_flag", "expected": true, "attrs": {"token": "x", "foo" : "bar"}, "message": "always on, ignores attrs"}, 182 | {"flag": "on_flag", "expected": true, "attrs": {}, "message": "always on, ignores attrs, even when there are none"}, 183 | 184 | {"flag": "random_by_token_flag", "expected": false, "attrs": {}}, 185 | {"flag": "random_by_token_flag", "expected": false, "attrs": {"token" : null}}, 186 | {"flag": "random_by_token_flag", "expected": false, "attrs": {"token" : "0"}}, 187 | {"flag": "random_by_token_flag", "expected": false, "attrs": {"token" : "1"}}, 188 | {"flag": "random_by_token_flag", "expected": true, "attrs": {"token" : "2"}}, 189 | {"flag": "random_by_token_flag", "expected": false, "attrs": {"token" : "3"}}, 190 | {"flag": "random_by_token_flag", "expected": true, "attrs": {"token" : "4"}}, 191 | {"flag": "random_by_token_flag", "expected": true, "attrs": {"token" : "5"}}, 192 | {"flag": "random_by_token_flag", "expected": false, "attrs": {"token" : "6"}}, 193 | {"flag": "random_by_token_flag", "expected": false, "attrs": {"token" : "7"}}, 194 | {"flag": "random_by_token_flag", "expected": false, "attrs": {"token" : "8"}}, 195 | {"flag": "random_by_token_flag", "expected": false, "attrs": {"token" : "9"}}, 196 | 197 | 198 | {"flag": "random_by_token_flag_same_seed_increased_percent", "expected": true, "attrs": {"token" : "0"}}, 199 | {"flag": "random_by_token_flag_same_seed_increased_percent", "expected": true, "attrs": {"token" : "1"}}, 200 | {"flag": "random_by_token_flag_same_seed_increased_percent", "expected": true, "attrs": {"token" : "2"}}, 201 | {"flag": "random_by_token_flag_same_seed_increased_percent", "expected": true, "attrs": {"token" : "3"}}, 202 | {"flag": "random_by_token_flag_same_seed_increased_percent", "expected": true, "attrs": {"token" : "4"}}, 203 | {"flag": "random_by_token_flag_same_seed_increased_percent", "expected": true, "attrs": {"token" : "5"}}, 204 | {"flag": "random_by_token_flag_same_seed_increased_percent", "expected": true, "attrs": {"token" : "6"}}, 205 | {"flag": "random_by_token_flag_same_seed_increased_percent", "expected": false, "attrs": {"token" : "7"}}, 206 | {"flag": "random_by_token_flag_same_seed_increased_percent", "expected": true, "attrs": {"token" : "8"}}, 207 | {"flag": "random_by_token_flag_same_seed_increased_percent", "expected": true, "attrs": {"token" : "9"}}, 208 | 209 | {"flag": "random_by_token_flag_different_seed", "expected": true, "attrs": {"token" : "0"}}, 210 | {"flag": "random_by_token_flag_different_seed", "expected": false, "attrs": {"token" : "1"}}, 211 | {"flag": "random_by_token_flag_different_seed", "expected": false, "attrs": {"token" : "2"}}, 212 | {"flag": "random_by_token_flag_different_seed", "expected": true, "attrs": {"token" : "3"}}, 213 | {"flag": "random_by_token_flag_different_seed", "expected": false, "attrs": {"token" : "4"}}, 214 | {"flag": "random_by_token_flag_different_seed", "expected": true, "attrs": {"token" : "5"}}, 215 | {"flag": "random_by_token_flag_different_seed", "expected": false, "attrs": {"token" : "6"}}, 216 | {"flag": "random_by_token_flag_different_seed", "expected": false, "attrs": {"token" : "7"}}, 217 | {"flag": "random_by_token_flag_different_seed", "expected": false, "attrs": {"token" : "8"}}, 218 | {"flag": "random_by_token_flag_different_seed", "expected": false, "attrs": {"token" : "9"}}, 219 | 220 | {"flag": "blacklist_whitelist_by_token", "expected": false, "attrs": {"token" : null}, "message": "null id"}, 221 | {"flag": "blacklist_whitelist_by_token", "expected": false, "attrs": {"token" : "id_1"}, "message": "blacklisted id"}, 222 | {"flag": "blacklist_whitelist_by_token", "expected": false, "attrs": {"token" : "id_2"}, "message": "blacklist evaluated first"}, 223 | {"flag": "blacklist_whitelist_by_token", "expected": true, "attrs": {"token" : "id_3"}, "message": "whitelist id"}, 224 | {"flag": "blacklist_whitelist_by_token", "expected": false, "attrs": {"token" : "id_X"}, "message": "false if neither"}, 225 | 226 | {"flag": "country_ban", "expected": false, "attrs": {"token" : "id_1", "country": "IR"}, "message": "banned country"}, 227 | {"flag": "country_ban", "expected": true, "attrs": {"token" : "id_1", "country": "US"}, "message": "allowed country, in whitelist"}, 228 | {"flag": "country_ban", "expected": false, "attrs": {"token" : "id_X", "country": "US"}, "message": "allowed country, not in whitelist"}, 229 | 230 | {"flag": "off_flag_edge_override_on", "expected": false, "attrs": {"token" : "x", "foo" : "bar"}, "message": "always off, edge_override present only for validation"}, 231 | {"flag": "off_flag_edge_override_off", "expected": false, "attrs": {"token" : "x", "foo" : "bar"}, "message": "always off, edge_override present only for validation"}, 232 | 233 | {"flag": "bail_if_null_else_on", "expected": false, "attrs": {"token" : null}}, 234 | {"flag": "bail_if_null_else_on", "expected": true, "attrs": {"token" : "foo"}}, 235 | 236 | {"flag": "country_with_multi_conditions", "expected": false, "attrs": {"token": "id_1", "country": "JP"}, "message": "country not in whitelist"}, 237 | {"flag": "country_with_multi_conditions", "expected": true, "attrs": {"token": "id_1", "country": "US"}, "message": "country in whitelist"}, 238 | 239 | {"flag": "deleted_on_flag", "expected": true, "attrs": {}}, 240 | {"flag": "explicitly_not_deleted_flag", "expected": true, "attrs": {}}, 241 | 242 | {"flag": "on_flag_testmode_disabled", "expected": true, "attrs": {}, "message": "always on, testmode_only present only for validation"}, 243 | 244 | {"flag": "on_flag_with_experiment_rollout_type_rule", "expected": true, "attrs": {"token": "x"}, "message": "always on"}, 245 | {"flag": "on_flag_with_experiment_rollout_type_rule", "expected": true, "attrs": {"token": "x", "foo" : "bar"}, "message": "always on, ignores attrs"}, 246 | {"flag": "on_flag_with_experiment_rollout_type_rule", "expected": true, "attrs": {}, "message": "always on, ignores attrs, even when there are none"} 247 | ] 248 | } 249 | 250 | -------------------------------------------------------------------------------- /testdata/flags2_example.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "flags": [ 4 | { 5 | "name": "off_flag", 6 | "_id": "ff_1", 7 | "seed": "seed_1", 8 | "rules": [] 9 | }, 10 | { 11 | "name": "go.moon.mercury", 12 | "_id:": "ff_2", 13 | "seed": "seed_1", 14 | "rules": [ 15 | {"hash_by": "_random", "percent": 1.0, "predicates": []} 16 | ] 17 | }, 18 | { 19 | "name": "go.stars.money", 20 | "_id:": "ff_3", 21 | "seed": "seed_1", 22 | "rules": [ 23 | {"hash_by": "_random", "percent": 0.5, "predicates": []} 24 | ] 25 | }, 26 | { 27 | "name": "go.sun.money", 28 | "_id:": "ff_6", 29 | "seed": "seed_1", 30 | "rules": [ 31 | {"hash_by": "_random", "percent": 0.0, "predicates": []} 32 | ] 33 | }, 34 | { 35 | "name": "flag5", 36 | "_id": "ff_5", 37 | "seed": "seed_1", 38 | "rules": [ 39 | {"hash_by": "token", "percent": 1.0, "predicates": [ 40 | {"attribute": "token", "operation": "in", "values": ["id_1", "id_2"]}, 41 | {"attribute": "country", "operation": "not_in", "values": ["KP"]} 42 | ]}, 43 | {"hash_by": "token", "percent": 0.5, "predicates": []} 44 | ] 45 | } 46 | ], 47 | "updated": 1584642857.7121534 48 | } 49 | -------------------------------------------------------------------------------- /testdata/flags2_example_no_timestamp.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "flags": [ 4 | { 5 | "name": "off_flag", 6 | "_id": "ff_1", 7 | "seed": "seed_1", 8 | "rules": [] 9 | }, 10 | { 11 | "name": "go.moon.mercury", 12 | "_id:": "ff_2", 13 | "seed": "seed_1", 14 | "rules": [ 15 | {"hash_by": "_random", "percent": 1.0, "predicates": []} 16 | ] 17 | }, 18 | { 19 | "name": "go.stars.money", 20 | "_id:": "ff_3", 21 | "seed": "seed_1", 22 | "rules": [ 23 | {"hash_by": "_random", "percent": 0.5, "predicates": []} 24 | ] 25 | }, 26 | { 27 | "name": "go.sun.money", 28 | "_id:": "ff_6", 29 | "seed": "seed_1", 30 | "rules": [ 31 | {"hash_by": "_random", "percent": 0.0, "predicates": []} 32 | ] 33 | }, 34 | { 35 | "name": "flag5", 36 | "_id": "ff_5", 37 | "seed": "seed_1", 38 | "rules": [ 39 | {"hash_by": "token", "percent": 1.0, "predicates": [ 40 | {"attribute": "token", "operation": "in", "values": ["id_1", "id_2"]}, 41 | {"attribute": "country", "operation": "not_in", "values": ["KP"]} 42 | ]}, 43 | {"hash_by": "token", "percent": 0.5, "predicates": []} 44 | ] 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /testdata/flags2_multiple_definitions.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "flags": [ 4 | { 5 | "name": "go.sun.money", 6 | "_id:": "ff_6", 7 | "seed": "seed_1", 8 | "rules": [ 9 | {"hash_by": "_random", "percent": 0.5, "predicates": []} 10 | ] 11 | }, 12 | { 13 | "name": "off_flag", 14 | "_id": "ff_1", 15 | "seed": "seed_1", 16 | "rules": [] 17 | }, 18 | { 19 | "name": "go.moon.mercury", 20 | "_id:": "ff_2", 21 | "seed": "seed_1", 22 | "rules": [ 23 | {"hash_by": "_random", "percent": 1.0, "predicates": []} 24 | ] 25 | }, 26 | { 27 | "name": "go.sun.money", 28 | "_id:": "ff_6", 29 | "seed": "seed_1", 30 | "rules": [ 31 | {"hash_by": "_random", "percent": 0.7, "predicates": []} 32 | ] 33 | }, 34 | { 35 | "name": "flag5", 36 | "_id": "ff_5", 37 | "seed": "seed_1", 38 | "rules": [ 39 | {"hash_by": "token", "percent": 1.0, "predicates": [ 40 | {"attribute": "token", "operation": "in", "values": ["id_1", "id_2"]}, 41 | {"attribute": "country", "operation": "not_in", "values": ["KP"]} 42 | ]}, 43 | {"hash_by": "token", "percent": 0.5, "predicates": []} 44 | ] 45 | } 46 | ], 47 | "updated": 1584642857.7121534 48 | } 49 | --------------------------------------------------------------------------------