We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
Feature flags are simple ways to control how features are rolled out to users and run A/B tests. Instead of using third-party services or libraries we can easily implement a useful feature flag system in our Elixir app.
Calculating feature flags
First we set up the feature flags module which calculates the feature flags for users:
# lib/my_app/feature_flags.ex
defmodule MyApp.FeatureFlags do
@moduledoc """
Context for managing feature flags.
An md5 hash combining user ID and the flag is used to determine the rollout
of feature flags to users, as md5 is readily available in both Postgres and
Elixir.
"""
import Ecto.Query
alias MyApp.Users.User
@flag_ratios %{
"feature_a" => 1.0,
"feature_b" => 0.5,
"feature_c" => 0.0
}
@doc """
Returns the flag ratios.
"""
def flag_ratios, do: @flag_ratios
@doc """
Returns the feature flags enabled for a given user based on rollout ratios.
## Examples
iex> MyApp.FeatureFlags.list_feature_flags(
...> %MyApp.Users.User{id: "11111111-1111-1111-1111-111111111113"},
...> %{"feature_a" => 0.3, "feature_b" => 0.6}
...> )
["feature_b"]
"""
def list_feature_flags(user, flag_ratios \\ flag_ratios()) do
flag_ratios
|> Enum.filter(fn {flag, ratio} ->
<<score::size(16), _rest::binary>> = :crypto.hash(:md5, "#{user.id}:#{flag}")
score / 65_536 < ratio
end)
|> Enum.map(fn {flag, _ratio} -> flag end)
end
@doc """
Returns an Ecto query filtering users who have the given feature flag enabled.
Raises `KeyError` if the flag is not present in the flag ratio map.
## Examples
iex> %Ecto.Query{} = MyApp.FeatureFlags.users_with_feature_flag_query(
...> "feature_b",
...> %{"feature_a" => 0.3, "feature_b" => 0.6}
...> )
"""
def users_with_feature_flag_query(flag, flag_ratios \\ flag_ratios()) do
ratio = Map.fetch!(flag_ratios, flag)
where(
User,
[u],
fragment(
"""
(
(
('x' || substr(md5(?::text || ':' || ?), 1, 4))::bit(16)::int
) / 65536.0
) < ?
""",
u.id,
^flag,
^ratio
)
)
end
@doc """
Returns an Ecto query filtering users who don't have the given feature flag enabled.
Raises `KeyError` if the flag is not present in the flag ratio map.
## Examples
iex> %Ecto.Query{} = MyApp.FeatureFlags.users_without_feature_flag_query(
...> "feature_b",
...> %{"feature_a" => 0.3, "feature_b" => 0.6}
...> )
"""
def users_without_feature_flag_query(flag, flag_ratios \\ flag_ratios()) do
ratio = Map.fetch!(flag_ratios, flag)
where(
User,
[u],
fragment(
"""
(
('x' || substr(md5(?::text || ':' || ?), 1, 4))::bit(16)::int
) / 65536.0 >= ?
""",
u.id,
^flag,
^ratio
)
)
end
end
Tests for MyApp.FeatureFlags.
# lib/test/my_app/feature_flags_test.exs
defmodule MyApp.FeatureFlagsTest do
use MyApp.DataCase
doctest MyApp.FeatureFlags
alias MyApp.{FeatureFlags, Repo}
test "list_feature_flags/2" do
user_1 = user_fixture("11111111-1111-1111-1111-111111111131")
user_2 = user_fixture("11111111-1111-1111-1111-111111111111")
user_3 = user_fixture("11111111-1111-1111-1111-111111111114")
user_4 = user_fixture("11111111-1111-1111-1111-111111111116")
flag_ratios = %{"a" => 0.0, "b" => 0.0}
assert FeatureFlags.list_feature_flags(user_1, flag_ratios) == []
assert FeatureFlags.list_feature_flags(user_2, flag_ratios) == []
assert FeatureFlags.list_feature_flags(user_3, flag_ratios) == []
assert FeatureFlags.list_feature_flags(user_4, flag_ratios) == []
flag_ratios = %{"a" => 0.25, "b" => 0.5}
assert FeatureFlags.list_feature_flags(user_1, flag_ratios) == ["a", "b"]
assert FeatureFlags.list_feature_flags(user_2, flag_ratios) == ["b"]
assert FeatureFlags.list_feature_flags(user_3, flag_ratios) == []
assert FeatureFlags.list_feature_flags(user_4, flag_ratios) == []
flag_ratios = %{"a" => 0.5, "b" => 0.5}
assert FeatureFlags.list_feature_flags(user_1, flag_ratios) == ["a", "b"]
assert FeatureFlags.list_feature_flags(user_2, flag_ratios) == ["b"]
assert FeatureFlags.list_feature_flags(user_3, flag_ratios) == ["a"]
assert FeatureFlags.list_feature_flags(user_4, flag_ratios) == []
flag_ratios = %{"a" => 1.0, "b" => 1.0}
assert FeatureFlags.list_feature_flags(user_1, flag_ratios) == ["a", "b"]
assert FeatureFlags.list_feature_flags(user_2, flag_ratios) == ["a", "b"]
assert FeatureFlags.list_feature_flags(user_3, flag_ratios) == ["a", "b"]
assert FeatureFlags.list_feature_flags(user_4, flag_ratios) == ["a", "b"]
end
test "users_with_feature_flag_query/2" do
user_1 = user_fixture("11111111-1111-1111-1111-111111111111")
user_2 = user_fixture("11111111-1111-1111-1111-111111111117")
flag_ratios = %{"a" => 0.25, "b" => 0.5}
# Ensure feature flags are correctly assigned to users
assert FeatureFlags.list_feature_flags(user_1, flag_ratios) == ["b"]
assert FeatureFlags.list_feature_flags(user_2, flag_ratios) == ["a"]
assert [loaded_user] = Repo.all(FeatureFlags.users_with_feature_flag_query("a", flag_ratios))
assert loaded_user.id == user_2.id
assert [loaded_user] = Repo.all(FeatureFlags.users_with_feature_flag_query("b", flag_ratios))
assert loaded_user.id == user_1.id
end
test "users_without_feature_flag_query/2" do
user_1 = user_fixture("11111111-1111-1111-1111-111111111111")
user_2 = user_fixture("11111111-1111-1111-1111-111111111117")
flag_ratios = %{"a" => 0.25, "b" => 0.5}
# Ensure feature flags are correctly assigned to users
assert FeatureFlags.list_feature_flags(user_1, flag_ratios) == ["b"]
assert FeatureFlags.list_feature_flags(user_2, flag_ratios) == ["a"]
assert [loaded_user] = Repo.all(FeatureFlags.users_without_feature_flag_query("a", flag_ratios))
assert loaded_user.id == user_1.id
assert [loaded_user] = Repo.all(FeatureFlags.users_without_feature_flag_query("b", flag_ratios))
assert loaded_user.id == user_2.id
end
defp user_fixture(id), do: Repo.insert!(%MyApp.Users.User{id: id})
end
We use the user's UUID because it is stable and unique. Other stable and unique identifiers can be used, but be careful. Using an identifier that can change like email, can move users from cohort to cohort invalidating the metrics. Using an identifier that's not unique, such as inserted_at with limited precision, can cause users to receive the same hash and end up in the same cohort instead of distributing equally. Whatever identifier we use, hashing it gives us a deterministic value that we can use to assign users to feature cohorts.
The first 16 bits of the hash are converted into an integer between 0 and 65,535, we then divide by the total number of possible values, 65,536, to get a value from 0.0 to less than 1.0. We can use that to compare with our rollout ratio, e.g. 0.25 means that 25% of the users will be in the feature cohort.
Gating features
To gate the features all we need to do is:
case "flag_b" in FeatureFlags.list_feature_flags(user) do
true -> # Do flag A stuff
false -> # Don't do flag A stuff
end
A practical example could be only sending a notification to the feature cohort:
case "activity_notification" in FeatureFlags.list_feature_flags(user) do
true -> Notifier.send(user, activity)
false -> :ok
end
Feature flags in an API
We can return a list of features in an API if we use a third party client like a mobile app:
# lib/my_app_web/controllers/v1/feature_flags_controller.ex
defmodule MyAppWeb.V1.FeatureFlagsController do
use MyAppWeb, :controller
alias MyApp.FeatureFlags
def show(conn, _params) do
flags = FeatureFlags.list_feature_flags(conn.assigns.current_user)
render(conn, "show.json", flags: flags)
end
end
# lib/my_app_web/controllers/v1/feature_flags_json.ex
defmodule MyAppWeb.V1.FeatureFlagsJSON do
def show(%{flags: flags}) do
%{
object: "feature_flags",
flags: flags
}
end
end
Tests for MyAppWeb.V1.FeatureFlagsController.
# lib/test/my_app_web/controllers/v1/feature_flags_controller_test.exs
defmodule MyAppWeb.V1.FeatureFlagsControllerTest do
use MyAppAPIWeb.ConnCase
setup :setup_authenticated_user_conn
@tag user: %{id: "11111111-1111-1111-1111-111111111111"}
test "GET /v1/user/feature-flags", %{conn: conn} do
conn = get(conn, ~p"/v1/user/feature-flags")
assert json = json_response(conn, 200)
assert json["object"] == "feature_flags"
assert json["flags"] == ["feature_a", "feature_b"]
end
end
In the above we have a helper setup_authenticated_user_conn that inserts the user fixture with the :user tag attributes to ensure we test that the flags are returned.
A/B testing
To track performance of a feature, we can build queries that show whether it had the impact we looked for. In the following let's say we have tasks users complete and we want to see how a new feature impacts the metrics:
defmodule MyApp.Analytics do
@moduledoc """
Context for analytics.
"""
import Ecto.Query
alias MyApp.{FeatureFlags, Repo, Tasks.Task}
@doc """
Calculates the task performance metrics based on the flag.
"""
def get_task_performance(flag, since, opts \\ []) do
flag_ratios = Keyword.get(opts, :flag_ratios, FeatureFlags.flag_ratios())
%{
enabled:
get_task_completion_metrics(
FeatureFlags.users_with_feature_flag_query(flag, flag_ratios),
since
),
disabled:
get_task_completion_metrics(
FeatureFlags.users_without_feature_flag_query(flag, flag_ratios),
since
)
}
end
# This query excludes users who didn't create a task in the experimentation
# period. This is fine as we only want to measure completion rate for users
# who create tasks.
defp get_task_completion_metrics(users_query, since) do
users_query = select(users_query, [u], u.id)
Task
|> where([t], t.inserted_at >= ^since)
|> where([t], t.user_id in subquery(users_query))
|> group_by([t], t.user_id)
|> select([t], %{
user_id: t.user_id,
total: count(t.id),
completed: filter(count(t.id), not is_nil(t.completed_at)),
completion_rate:
fragment(
"""
COUNT(?) FILTER (WHERE ? IS NOT NULL)::float
/ NULLIF(COUNT(?), 0)
""",
t.id,
t.completed_at,
t.id
),
average_completion_time:
filter(
avg(
fragment(
"EXTRACT(EPOCH FROM (? - ?))",
t.completed_at,
t.inserted_at
)
),
not is_nil(t.completed_at)
)
})
|> subquery()
|> from()
|> select([u], %{
users: count(u.user_id),
total_tasks: type(sum(u.total), :integer),
completed_tasks: type(sum(u.completed), :integer),
average_user_completion_rate: avg(u.completion_rate),
average_completion_time: avg(u.average_completion_time)
})
|> Repo.one()
end
end
Tests for MyApp.Analytics.
# lib/test/my_app/analytics_test.exs
defmodule MyApp.AnalyticsTest do
use MyApp.DataCase
alias MyApp.{Analytics, FeatureFlags, Repo}
test "get_task_performance/2" do
user_a = user_fixture("11111111-1111-1111-1111-111111111112")
user_b = user_fixture("11111111-1111-1111-1111-111111111113")
flag_ratios = %{"experiment" => 0.5}
since = DateTime.utc_now()
# Ensure feature flags are correctly assigned to users
assert FeatureFlags.list_feature_flags(user_a, flag_ratios) == ["experiment"]
assert FeatureFlags.list_feature_flags(user_b, flag_ratios) == []
# User A completes 1 of 2 tasks (50%) with 1 older task.
task_fixture(user_a, %{inserted_at: DateTime.add(since, -1, :second), completed_at: DateTime.utc_now()})
task_fixture(user_a, %{completed_at: DateTime.utc_now()})
task_fixture(user_a, %{completed_at: nil})
# User B completes 3 of 3 tasks (100%).
task_fixture(user_b, %{completed_at: DateTime.utc_now()})
task_fixture(user_b, %{completed_at: DateTime.utc_now()})
task_fixture(user_b, %{completed_at: DateTime.utc_now()})
result =
Analytics.get_task_performance("experiment", since,
flag_ratios: flag_ratios
)
assert result.enabled.users == 1
assert result.enabled.total_tasks == 2
assert result.enabled.completed_tasks == 1
assert result.enabled.average_user_completion_rate == 0.5
assert result.disabled.users == 1
assert result.disabled.total_tasks == 3
assert result.disabled.completed_tasks == 3
assert result.disabled.average_user_completion_rate == 1.0
end
defp user_fixture(id), do: Repo.insert!(%MyApp.Users.User{id: id})
defp task_fixture(user, attrs) do
user
|> Ecto.build_assoc(:tasks)
|> Map.merge(attrs)
|> Repo.insert!()
end
end
Note that changing the rollout ratio of the feature flag starts a new experiment period, since a change in ratio means that users will switch cohorts. That's why we pass in a datetime with the since argument. This also means that if you want to look at historical experiments you must use the exact same ratios and limit the datetime range.
One thing I want to warn about with A/B tests is that it rarely provides significant insights. Either the product doesn't have enough traffic or the metrics captured don't matter. I have only ever seen it working for marginal improvements. If your product doesn't have traction then A/B testing isn't going to save it. Personally, I believe building what you think is good is better than building what metrics suggests is good.
You need enough users and meaningful events for reliable signal.
A/B/C testing
While I want you to understand that testing with more than two groups can make the signal in the metrics far weaker, it's very simple with our feature flags design:
@flag_ratios %{
"feature_a" => 0.67,
"feature_b" => 0.5
}
This gives us three equal groups with the following query:
# lib/my_app/analytics.ex
def feature_metric_group_query(group, flag_ratios \\ FeatureFlags.flag_ratios()) do
group_a =
"feature_a"
|> FeatureFlags.users_with_feature_flag_query(flag_ratios)
|> select([u], u.id)
group_b =
"feature_b"
|> FeatureFlags.users_with_feature_flag_query(flag_ratios)
|> select([u], u.id)
case group do
# Control has no feature_a
:control -> where(User, [u], u.id not in subquery(group_a))
# Variant A has feature_a but not feature_b
:variant_a -> where(User, [u], u.id in subquery(group_a) and u.id not in subquery(group_b))
# Variant B has feature_a and feature_b
:variant_b -> where(User, [u], u.id in subquery(group_a) and u.id in subquery(group_b))
group -> raise ArgumentError, "expected valid group, got: #{inspect(group)}"
end
end
Added test for MyApp.Analytics.
# lib/test/my_app/analytics_test.exs
test "feature_metric_group_query/2" do
user_control = user_fixture("11111111-1111-1111-1111-111111111113")
user_a = user_fixture("11111111-1111-1111-1111-111111111112")
user_b = user_fixture("11111111-1111-1111-1111-111111111111")
flag_ratios = %{"feature_a" => 0.67, "feature_b" => 0.5}
# Ensure feature flags are correctly assigned to users
assert FeatureFlags.list_feature_flags(user_control, flag_ratios) == []
assert FeatureFlags.list_feature_flags(user_a, flag_ratios) == ["feature_a"]
assert FeatureFlags.list_feature_flags(user_b, flag_ratios) == ["feature_a", "feature_b"]
assert [loaded_user] = Repo.all(Analytics.feature_metric_group_query(:control, flag_ratios))
assert loaded_user.id == user_control.id
assert [loaded_user] = Repo.all(Analytics.feature_metric_group_query(:variant_a, flag_ratios))
assert loaded_user.id == user_a.id
assert [loaded_user] = Repo.all(Analytics.feature_metric_group_query(:variant_b, flag_ratios))
assert loaded_user.id == user_b.id
end
If we want it to be more precise we could use 2.0 / 3.0 instead of 0.67. To gate the features we do this:
flags = FeatureFlags.list_feature_flags(user)
cond do
"flag_a" not in flags -> # Control, don't do anything different
"flag_b" not in flags -> # Do variant A stuff
true -> # Do variant B stuff
end
You can easily expand this to A/B/C/D testing:
@flag_ratios %{
# Control has no feature_a
"feature_a" => 0.75,
# Variant A has feature_a but not feature_b or feature_c
"feature_b" => 0.67,
# Variant B has feature_a and feature_b, but not feature_c
# Variant C has feature_a, feature_b, and feature_c
"feature_c" => 0.5
}
We progressively divide the remaining users into approximately equal groups. First, 25% become the control group. Of the remaining 75%, approximately 33% become variant A. We then divide the remaining users equally between variants B and C. This gives us approximately 25% of users in each group.
You must be very careful though. Testing more than two groups can make it significantly harder to tell what the signal really is
Word of caution
I did mention this earlier, but I want to really drive home the point. In 1975, Charles Goodhart wrote what later got popularized as this paraphrased quote:
When a measure becomes a target, it ceases to be a good measure.
If you have a relatively small project you shouldn't consider A/B testing at all, and if you have a large project you should use it sparingly. There is no replacement for human ingenuity and determination. Rather than discovering the future, we must build it, and we should attempt to build what we believe is the best version rather than what metrics tell us.
Metrics are useful and important, but we cannot make it our north star. The north star must be our own beliefs. Often A/B tests are misused to optimize engagement metrics that aren't necessarily meaningful. Optimizing for more engagement doesn't mean we are building a better product. Instead I think we should focus more on how to improve the baseline user experience.
For example, we can use A/B testing to experiment if:
- Users complete tasks with fewer errors, reducing user frustration.
- Users successfully find and use a feature, improving discoverability.
- Users can complete tasks in less time or with fewer steps without reducing quality.
- Users can recover from errors by themselves, improving success rate.
All metrics need context. Completing a task faster doesn't necessarily mean it's an improvement if the output is worse. The goal is to find a genuine signal that things are working better, not simply that a number goes up.
Ultimately, I think one of the best ways to evaluate what we build is to dogfood our own product. We can use metrics to understand consequences of decisions, but we shouldn't let them make decisions for us.
Hi, I'm Dan Schultzer, I write in this blog, work a lot in Elixir, maintain several open source projects, and help companies streamline their development process