How I approached creating feature flags that apply to a subset of users, consistently.
Introduction #
On my most recent project there was a new feature to be deployed. There was a desire to deploy this feature behind a feature flag, and to gradually open this up to users.
An additional requirement was for the users to consistently see the feature, or consistently not see the feature given re-entry to the application. This requirement provided some complexity, as there is no state for a user persisted between sessions, which means we can't save the bucket they are in to their session.
Feature Flag: a bit of code that allows us to switch a piece of functionality on or off. A feature flag can usually be toggled on or off without a code change and often without the need for a deployment.
To Bucket: the user is selected to see the flagged feature.
Randomness #
To bucket a user randomly we could use the crypto/rand
package from the Go Standard-Library. By generating a random number and then making an assertion on the result to decide whether to bucket the user.
In the following code we bucket 60% of the time, randomly:
https://go.dev/play/p/rpGXWfPuR1C
...
func main() {
included, err := bucketForFreeRubberDuck()
if err != nil {
panic(err)
}
if included {
fmt.Println("You get a free rubber duck with your order.")
return
}
fmt.Println("Sorry, no free stuff for you.")
}
func bucketForFreeRubberDuck() (bool, error) {
v, err := rand.Int(rand.Reader, big.NewInt(100))
if err != nil {
return false, err
}
return v.Uint64() < percentage(), nil
}
func percentage() uint64 {
// Pull this from environment variables or some config store.
return 60
}
This is not consistent though, it's random [0]. Someone coming back through the journey may get a different result, that's not acceptable in our case, we don't want to be giving out any free ducks to people who weren't initially bucketed.
- For the pedants out there, I know it's only semi-random, it's acceptably random though. [0]
Consistent Randomness #
In my case we have some details related to the user when we make our bucketing decisions. We will continue with the following as our user object:
type User struct {
Name string
Dob time.Time
LikesDucks bool
}
And the Go Standard-Library provides us with the hash
package, which allows use to calculate a hash-sum of some given bytes. I'll be using the 32-bit FNV-1a [1] alorithm.
If we take some values from the user object that won't change for a person, we can consistently put them in the same bucket.
So, we need a way to convert our User
struct values into a []byte
. I'll introduce an interface that represents something that contains hash-factors, and implement the interface for the User
type.
type Hashable interface {
Factors() []byte
}
func (u User) Factors() []byte {
return []byte(fmt.Sprint(u.Name, u.Dob, u.LikesDucks))
}
It's possible to do this more efficiently, by getting the bytes of the values rather than converting them all to strings. For my use case, converting to strings was more succinct, and also efficient enough.
Now, we need to take our []byte
and plug it into the hash/fnv
package:
func GenerateHash(h Hashable) uint32 {
ha := h.Factors() // Get our factors.
f := fnv.New32a() // Create a new 32-bit FNV-1a hasher.
f.Write(ha) // Write our []byte into the hasher.
return f.Sum32() // Return the hash-sum.
}
This will return a number between 0 and math.MaxUint32
(4294967295). Most importantly, it'll do this consistently!
We can compress this to be filtered as a percentage by applying modulo 100 (this could introduce some bias, avoidable by compressing to a factor of math.MaxUint32
, like 257).
Let's give it a spin:
https://go.dev/play/p/pzdalQHrc7C
func main() {
h := GenerateHash(User{
Name: "joe",
Dob: time.Date(1980, 01, 01, 0, 0, 0, 0, time.UTC),
LikesDucks: true,
})
fmt.Println(h % 100)
}
Outputs:
78
Program exited.
Finally, let's feed this back into our feature flag example:
https://go.dev/play/p/dteD_etjYaN
...
func main() {
joe := User{
Name: "joe",
Dob: time.Date(1980, 01, 01, 0, 0, 0, 0, time.UTC),
LikesDucks: true,
}
bob := User{
Name: "bob",
Dob: time.Date(1986, 01, 01, 0, 0, 0, 0, time.UTC),
LikesDucks: false,
}
if bucketForFreeRubberDuck(bob) {
fmt.Println("Bob gets a free rubber duck with his order.")
}
if bucketForFreeRubberDuck(joe) {
fmt.Println("Joe gets a free rubber duck with his order.") // Won't run
}
if bucketForFreeRubberDuck(bob) {
fmt.Println("Bob still gets a free rubber duck with his order.")
}
}
func bucketForFreeRubberDuck(u User) bool {
p := GenerateHash(u) % 100
return p < percentage()
}
func percentage() uint32 {
// Pull this from environment variables or some config store.
return 60
}
...
Outputs:
Bob gets a free rubber duck with his order.
Bob still gets a free rubber duck with his order.
Program exited.
We can now increase/decrease the amount of users seeing this feature by returning a different percentage, this would likely be driven by some configuration external to the code.
Further Enhancements #
This solution can be extended to handle multiple feature flags, we can include a value into the hash that changes depending on the flag being queried, so that hashes aren't common between features. And we could separate out the percentage config option, then it can be changed per-feature.
There will be a follow-up where I outline how to do this in an extensible way.
Conclusion #
To conclude, we don't need to purchase an expensive 3rd-party feature flag solution to do gradual roll-outs, we may also end up with more performant and readable code if we do so.
And if there are concerns about the distribution of values with this approach there are various methods of avoiding modulo bias that I won't get into in this post. Likewise, if there are concerns about efficiency, I'm sure there are various changes to my solution that could improve it, aside from the improvements I mentiond.