defmodule GrowthBook do
@external_resource "README.md"
@moduledoc "README.md"
|> File.read!()
|> String.split("<!-- MDOC !-->")
|> Enum.fetch!(1)
alias GrowthBook.{
Condition,
Context,
Feature,
Experiment,
ExperimentResult,
FeatureResult,
FeatureRule,
Helpers,
Hash,
Filter,
ParentCondition
}
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()}
@type init_result :: {:ok, :initialized} | {:error, String.t()}
@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
# TODO fix dialyzer error @spec get_experiment_result(
# Context.t(),
# Experiment.t(),
# String.t(),
# integer(),
# boolean(),
# number()
# ) :: Experiment.t()
def get_experiment_result(
%Context{} = context,
%Experiment{} = experiment,
feature_id \\ nil,
variation_id \\ -1,
hash_used \\ false,
bucket \\ nil
) do
{in_experiment, variation_id} =
if variation_id < 0 or variation_id >= length(experiment.variations),
do: {false, 0},
else: {true, variation_id}
hash_attribute = experiment.hash_attribute || "id"
hash_value = context.attributes[hash_attribute] || ""
meta =
if is_list(experiment.meta) do
Enum.at(experiment.meta, variation_id)
end
%ExperimentResult{
key:
if meta && meta.key do
meta.key
else
to_string(variation_id)
end,
feature_id: feature_id,
in_experiment?: in_experiment,
hash_used?: hash_used,
variation_id: variation_id,
value: Enum.at(experiment.variations, variation_id),
hash_attribute: hash_attribute,
hash_value: hash_value,
name:
if meta && meta.name do
meta.name
end,
passthrough?: meta && meta.passthrough?,
bucket: bucket
}
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(), [feature_key()]) :: FeatureResult.t()
def feature(%Context{} = context, feature_id, path \\ []) do
features = Context.get_features(context)
case Map.get(features, feature_id) do
nil ->
Logger.debug(
"No feature with id: #{feature_id}, known features are: #{inspect(Map.keys(features))}"
)
get_feature_result(nil, :unknown_feature)
%Feature{rules: rules} = feature ->
eval_rules(context, feature_id, feature, rules, path)
end
end
@doc false
defp eval_rules(%Context{} = _context, _feature_id, %Feature{} = feature, [], _path) do
get_feature_result(feature.default_value, :default_value)
end
defp eval_rules(
%Context{} = context,
feature_id,
%Feature{} = feature,
[%FeatureRule{} = rule | rest],
path
) do
with true <-
ParentCondition.eval(context, rule.parent_conditions, [feature_id | path]) || :skip,
true <- not filtered_out?(context, rule.filters) || :skip,
true <- eval_rule_condition(context.attributes, rule.condition) || :skip,
true <- eval_forced_rule(context.attributes, feature_id, rule),
exp = %Experiment{} = Experiment.from_rule(feature_id, rule),
result = %ExperimentResult{} = run(context, exp, feature_id),
true <- (result.in_experiment? && !result.passthrough?) || :skip do
get_feature_result(result.value, :experiment, exp, result)
else
:skip ->
eval_rules(context, feature_id, feature, rest, path)
%FeatureResult{} = result ->
result
{:error, %ParentCondition.CyclingError{}} ->
get_feature_result(nil, :cyclic_prerequisite)
{:error, %ParentCondition.PrerequisiteError{}} ->
get_feature_result(nil, :prerequisite)
end
end
defp eval_forced_rule(_, _, %FeatureRule{force: nil}), do: true
defp eval_forced_rule(attributes, feature_id, %FeatureRule{} = rule) do
if Helpers.included_in_rollout?(
attributes,
Helpers.coalesce(rule.seed, feature_id),
rule.hash_attribute,
rule.range,
rule.coverage,
rule.hash_version
) do
# TODO add rule.tracks callbacks calls
get_feature_result(rule.force, :force)
else
:skip
end
end
defp eval_rule_condition(_, nil), do: true
defp eval_rule_condition(attributes, condition),
do: Condition.eval_condition(attributes, condition)
defp filtered_out?(_context, nil), do: false
defp filtered_out?(context, filters) when is_list(filters) do
Enum.any?(filters, &filtered_out?(context, &1))
end
defp filtered_out?(%Context{} = context, %Filter{} = filter) do
hash_attribute = filter.attribute
hash_value = context.attributes[hash_attribute] || ""
case hash_value do
"" ->
true
_ ->
n = Hash.hash(filter.seed, hash_value, filter.hash_version)
not Enum.any?(filter.ranges, &Helpers.in_range?(n, &1))
end
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(), String.t() | nil) :: ExperimentResult.t()
def run(%Context{} = context, %Experiment{} = exp, feature_id \\ nil, path \\ []) do
with variations_count <- length(exp.variations),
true <- variations_count >= 2 || {:error, "has less than 2 variations"},
true <- context.enabled? || {:error, "disabled"},
:ok <- check_query_string_override(context, exp, feature_id),
:ok <- check_forced_variation(context, exp, feature_id),
true <- exp.active? || {:error, "is not active"},
{:ok, _hash_attribute, hash_value} <- get_experiment_hash_value(context, exp),
true <- not filtered_out?(context, exp.filters) || {:error, "filtered out"},
true <-
(exp.filters || []) != [] || Helpers.in_namespace?(hash_value, exp.namespace) ||
{:error, "not in namespace"},
true <-
eval_rule_condition(context.attributes, exp.condition) ||
{:error, "condition is false"},
true <-
ParentCondition.eval(context, exp.parent_conditions, path) ||
{:error, "parent conditions are false"} do
bucket_ranges =
exp.ranges ||
Helpers.get_bucket_ranges(variations_count, exp.coverage || 1.0, exp.weights || [])
hash = Hash.hash(exp.seed || exp.key, hash_value, exp.hash_version || 1)
variation_id = Helpers.choose_variation(hash, bucket_ranges)
cond do
variation_id < 0 ->
Logger.debug("Experiment #{exp.key} skipped: no assigned variation")
get_experiment_result(context, exp, feature_id)
not is_nil(exp.force) ->
Logger.debug("Experiment #{exp.key} forced: #{exp.force}")
get_experiment_result(context, exp, feature_id, exp.force)
context.qa_mode? ->
Logger.debug("Experiment #{exp.key} skipped: QA mode enabled")
get_experiment_result(context, exp, feature_id)
true ->
get_experiment_result(context, exp, feature_id, variation_id, true, hash)
end
else
{:error, %ParentCondition.CyclingError{message: message}} ->
Logger.debug("Experiment #{exp.key} skipped: #{message}")
get_experiment_result(context, exp)
{:error, %ParentCondition.PrerequisiteError{message: message}} ->
Logger.debug("Experiment #{exp.key} skipped: #{message}")
get_experiment_result(context, exp)
{:error, error} ->
Logger.debug("Experiment #{exp.key} skipped: #{error}")
get_experiment_result(context, exp)
%ExperimentResult{} = result ->
result
end
end
defp check_query_string_override(%Context{url: nil}, %Experiment{}, _), do: :ok
defp check_query_string_override(%Context{url: url} = context, %Experiment{} = exp, feature_id) do
qs_override = Helpers.get_query_string_override(exp.key, url, length(exp.variations))
if not is_nil(qs_override) do
get_experiment_result(context, exp, feature_id, qs_override)
else
:ok
end
end
defp check_forced_variation(%Context{} = context, %Experiment{} = exp, feature_id) do
case Map.get(context.forced_variations, exp.key) do
nil -> :ok
var -> get_experiment_result(context, exp, feature_id, var)
end
end
defp get_experiment_hash_value(%Context{} = context, %Experiment{} = exp) do
hash_attribute = exp.hash_attribute || "id"
hash_value = context.attributes[hash_attribute] || ""
case hash_value do
"" -> get_experiment_fallback_value(context, exp)
_ -> {:ok, hash_attribute, hash_value}
end
end
defp get_experiment_fallback_value(%Context{} = context, %Experiment{} = exp) do
case {exp.fallback_attribute, context.attributes[exp.fallback_attribute]} do
{nil, _} -> {:error, "empty fallback attribute"}
{_, nil} -> {:error, "empty fallback attribute value"}
{attr, val} -> {:ok, attr, val}
end
end
@doc """
Initialize GrowthBook with the feature repository configuration.
Returns {:ok, :initialized} if initialization succeeds with features loaded, {:error, reason} otherwise.
## Options
* `:client_key` - Required. The API client key
* `:api_host` - Required. The GrowthBook API host
* `:decryption_key` - Optional. Key for decrypting feature payloads
* `:swr_ttl_seconds` - Optional. Cache TTL in seconds (default: 60)
* `:refresh_strategy` - Optional. Either :periodic or :manual (default: :periodic)
* `:on_refresh` - Optional. Function to call when features are refreshed
* `:initialization_timeout` - Optional. Timeout in ms for initial feature fetch (default: 5000)
"""
@spec init(Keyword.t()) :: init_result()
def init(opts) do
# Validate required options
unless opts[:client_key] && opts[:api_host] do
raise ArgumentError, "client_key and api_host are required"
end
# Validate callback if provided
if opts[:on_refresh] && !is_function(opts[:on_refresh], 1) do
raise ArgumentError, "on_refresh must be a function that accepts one argument"
end
case GrowthBook.FeatureRepository.start_link(opts) do
{:ok, pid} ->
# Wait for initial feature fetch
timeout = opts[:initialization_timeout] || 5000
case GrowthBook.FeatureRepository.await_initialization(pid, timeout) do
:ok ->
{:ok, :initialized}
{:error, :timeout} ->
GenServer.stop(pid)
{:error, "initialization timed out after #{timeout}ms"}
{:error, reason} ->
GenServer.stop(pid)
{:error, "initialization failed: #{reason}"}
end
{:error, reason} ->
{:error, "failed to start feature repository: #{inspect(reason)}"}
end
end
@doc """
Build a context with the given attributes and features.
If features are not provided, it will use the FeatureRepository to always get the latest features.
"""
@spec build_context(map(), map() | nil) :: Context.t()
def build_context(attributes, features \\ nil) do
features_provider =
case features do
nil -> &GrowthBook.FeatureRepository.get_latest_features/0
features -> fn -> features end
end
%Context{
attributes: attributes,
features_provider: features_provider,
# Set to nil since we're using features_provider
features: nil,
enabled?: true,
url: nil,
qa_mode?: false,
forced_variations: %{}
}
end
@doc """
Get a feature's value with a default fallback
This is a convenience function that calls `GrowthBook.feature/2` and returns the value,
or the provided default if the feature isn't found or its value is nil.
## Examples
iex> GrowthBook.feature_value(context, "my-feature", false)
true
iex> GrowthBook.feature_value(context, "unknown-feature", "default")
"default"
"""
@spec feature_value(Context.t(), feature_key(), term()) :: term()
def feature_value(%Context{} = context, feature_id, default) do
result = feature(context, feature_id)
if is_nil(result.value), do: default, else: result.value
end
end