Packages

Elixir SDK for running A/B tests with GrowthBook

Current section

Files

Jump to
growthbook lib growth_book.ex
Raw

lib/growth_book.ex

defmodule GrowthBook do
@external_resource "README.md"
@moduledoc "README.md"
|> File.read!()
|> String.split("<!-- MDOC !-->")
|> Enum.fetch!(1)
alias GrowthBook.Condition
alias GrowthBook.Context
alias GrowthBook.Feature
alias GrowthBook.Experiment
alias GrowthBook.ExperimentResult
alias GrowthBook.FeatureResult
alias GrowthBook.FeatureRule
alias GrowthBook.Helpers
require Logger
@typedoc """
Bucket range
A tuple that describes a range of the numberline between `0` and `1`.
The tuple has 2 parts, both floats - the start of the range and the end. For example:
```
{0.3, 0.7}
```
"""
@type bucket_range() :: {float(), float()}
@typedoc """
Feature key
A key for a feature. This is a string that references a feature.
"""
@type feature_key() :: String.t()
@typedoc """
Namespace
A tuple that specifies what part of a namespace an experiment includes. If two experiments are
in the same namespace and their ranges don't overlap, they wil be mutually exclusive.
The tuple has 3 parts:
1. The namespace id (`String.t()`)
2. The beginning of the range (`float()`, between `0` and `1`)
3. The end of the range (`float()`, between `0` and `1`)
For example:
```
{"namespace1", 0, 0.5}
```
"""
@type namespace() :: {String.t(), float(), float()}
@doc false
@spec get_feature_result(
term(),
FeatureResult.source(),
Experiment.t() | nil,
ExperimentResult.t() | nil
) :: FeatureResult.t()
def get_feature_result(value, source, experiment \\ nil, experiment_result \\ nil) do
%FeatureResult{
value: value,
on: Helpers.cast_boolish(value),
on?: Helpers.cast_boolish(value),
off: not Helpers.cast_boolish(value),
off?: not Helpers.cast_boolish(value),
source: source,
experiment: experiment,
experiment_result: experiment_result
}
end
@doc false
# NOTE: This is called "getResult" in the JS SDK, but the guide says "getExperimentResult"
@spec get_experiment_result(Context.t(), Experiment.t() | nil, integer(), boolean()) ::
ExperimentResult.t()
def get_experiment_result(
%Context{} = context,
%Experiment{} = experiment,
variation_id \\ 0,
in_experiment? \\ false
) do
hash_attribute = experiment.hash_attribute || "id"
variation_id =
if variation_id < 0 or variation_id > length(experiment.variations),
do: 0,
else: variation_id
%ExperimentResult{
value: Enum.at(experiment.variations, variation_id),
variation_id: variation_id,
in_experiment?: in_experiment?,
hash_attribute: hash_attribute,
hash_value: Map.get(context.attributes, hash_attribute) || ""
}
end
@doc """
Determine feature state for a given context
This function takes a context and a feature key, and returns a `GrowthBook.FeatureResult` struct.
"""
@spec feature(Context.t(), feature_key()) :: FeatureResult.t()
def feature(%Context{features: features} = context, feature_id)
when is_map_key(features, feature_id) do
%{^feature_id => %Feature{} = feature} = features
cond do
# No rules, using default value
feature.rules == [] -> get_feature_result(feature.default_value, :default_value)
true -> find_matching_feature_rule(context, feature, feature_id)
end
end
def feature(%Context{} = context, missing_feature_id) do
Logger.debug(
"No feature with id: #{missing_feature_id}, known features are: #{inspect(Map.keys(context.features))}"
)
get_feature_result(nil, :unknown_feature)
end
@doc false
@spec find_matching_feature_rule(Context.t(), Feature.t(), feature_key()) :: FeatureResult.t()
def find_matching_feature_rule(%Context{} = context, %Feature{} = feature, feature_id) do
Enum.find_value(feature.rules, fn %FeatureRule{} = rule ->
cond do
# Skip this rule if the condition doesn't evaluate to true
rule.condition && not Condition.eval_condition(context.attributes, rule.condition) ->
Logger.debug(
"#{feature_id}: Skipping rule #{rule.key} because condition evaluated to false"
)
false
# Feature being forced with coverage
not is_nil(rule.force) and not is_nil(rule.coverage) ->
hash_value = Map.get(context.attributes, rule.hash_attribute || "id")
# If the hash value is empty, or if the rule is excluded because of coverage, skip
cond do
hash_value in [nil, ""] ->
Logger.debug("#{feature_id}: Skipping rule #{rule.key} because hash value is empty")
false
Helpers.hash(hash_value <> feature_id) > rule.coverage ->
Logger.debug(
"#{feature_id}: Skipping rule #{rule.key} because it's outside coverage"
)
false
true ->
Logger.debug("#{feature_id}: Force value from rule #{rule.key}")
get_feature_result(rule.force, :force)
end
# Feature being forced without coverage
not is_nil(rule.force) ->
Logger.debug("#{feature_id}: Force value from rule #{rule.key}")
get_feature_result(rule.force, :force)
# Skip invalid rule
rule.variations in [[], nil] ->
Logger.debug("#{feature_id}: Skipping rule #{rule.key} because it has no variations")
false
# Run the experiment
true ->
experiment = %Experiment{
key: rule.key || feature_id,
variations: rule.variations,
coverage: rule.coverage,
weights: rule.weights,
hash_attribute: rule.hash_attribute,
namespace: rule.namespace
}
%ExperimentResult{} = experiment_result = run(context, experiment)
if experiment_result.in_experiment? do
get_feature_result(
experiment_result.value,
:experiment,
experiment,
experiment_result
)
else
Logger.debug(
"#{feature_id}: Skipping rule #{rule.key} because it is not in the experiment"
)
false
end
end
end) || get_feature_result(feature.default_value, :default_value)
end
@doc """
Run an experiment for the given context
This function takes a context and an experiment, and returns an `GrowthBook.ExperimentResult` struct.
"""
@spec run(Context.t(), Experiment.t()) :: ExperimentResult.t()
def run(context, experiment)
# 2. When the context is disabled
def run(%Context{enabled?: false} = context, %Experiment{} = experiment) do
Logger.debug("Experiment is disabled")
get_experiment_result(context, experiment)
end
# 1. If experiment has less than 2 variations
def run(%Context{} = context, %Experiment{variations: variations} = experiment)
when length(variations) < 2 do
Logger.debug("Experiment is invalid: has less than 2 variations")
get_experiment_result(context, experiment)
end
def run(%Context{} = context, %Experiment{key: key, variations: variations} = experiment) do
variations_count = length(variations)
# 2.5. Merge in experiment overrides from context
experiment = Experiment.merge_with_overrides(experiment, context.overrides)
query_string_override =
not is_nil(context.url) &&
Helpers.get_query_string_override(key, context.url, variations_count)
hash_value = Map.get(context.attributes, experiment.hash_attribute || "id")
# 9. Get bucket ranges and choose variation
bucket_ranges =
Helpers.get_bucket_ranges(variations_count, experiment.coverage || 1.0, experiment.weights)
hash = if hash_value, do: Helpers.hash(hash_value <> key)
assigned_variation = Helpers.choose_variation(hash, bucket_ranges)
cond do
# 3. If a variation is forced from a query string, return forced variation
query_string_override ->
Logger.debug("#{key}: Forced variation from query string: #{query_string_override}")
get_experiment_result(context, experiment, query_string_override)
# 4. If a variation is forced in the context, return forced variation
is_map_key(context.forced_variations, key) ->
Logger.debug("#{key}: Forced variation from context: #{context.forced_variations[key]}")
get_experiment_result(context, experiment, context.forced_variations[key])
# 5. Exclude if experiment is inactive
experiment.active? == false ->
Logger.debug("#{key}: Experiment is inactive")
get_experiment_result(context, experiment)
# 6. Skip if hash value is empty
hash_value in [nil, ""] ->
Logger.debug("#{key}: Skipping experiment because hash value is empty")
get_experiment_result(context, experiment)
# 7. Exclude if user not in experiment's namespace
experiment.namespace && not Helpers.in_namespace?(hash_value, experiment.namespace) ->
Logger.debug("#{key}: Skipping experiment because user is not in namespace")
get_experiment_result(context, experiment)
# 8. Exclude if condition is set and it doesn't evaluate to true
experiment.condition &&
not Condition.eval_condition(context.attributes, experiment.condition) ->
Logger.debug("#{key}: Skipping experiment because condition evaluated to false")
get_experiment_result(context, experiment)
# NOTE: Legacy URL and Group targetting is omitted in favor of conditions
# 10. Exclude if not in experiment
assigned_variation < 0 ->
Logger.debug("#{key}: Skipping experiment because user is not assigned to variation")
get_experiment_result(context, experiment)
# 11. If experiment has forced variation
experiment.force ->
Logger.debug("#{key}: Forced variation from experiment: #{experiment.force}")
get_experiment_result(context, experiment, experiment.force)
# 12. Exclude if in QA mode
context.qa_mode? ->
Logger.debug("#{key}: Skipping experiment because QA mode is enabled")
get_experiment_result(context, experiment)
# 13. Experiment is active
true ->
Logger.debug("#{key}: Experiment is active")
get_experiment_result(context, experiment, assigned_variation, true)
end
end
end