Current section

Files

Jump to
unleash_fresha lib unleash.ex
Raw

lib/unleash.ex

defmodule Unleash do
@moduledoc """
Unofficial Elixir SDK for the [Unleash Feature Flag System](https://www.getunleash.io/).
Like all the official SDKs, this one keeps and serves feature from a local cache
(in our case, based on an `:ets` table), including evaluating activation strategies locally.
The features are initialised from a bootstrap file and updated periodically
from the Unleash API in the background, independently of how many feature checks are invoked.
This allows applications using this SDK to get the status of a feature instanteneously and locally,
without overloading the Unleash Server with API calls; but it also means that the features might be stale,
upto the polling interval or even longer in case of errors contacting the Unleash APIs.
The SDK is an Elixir Application that comes with its own Supervision Tree,
which starts two GenServer worker processes:
* `Unleash.Repo`, responsible for periodically refreshing the features from the Unleash API Server
and maintaining the local cache updated.
* `Unleash.Metrics`, responsible for periodically
[sending metrics](https://docs.getunleash.io/client-specification#metrics)
to the Unleash API Server.
The latter can be disabled with `disable_metrics: true`; while setting `disable_client: true`
will disable both of them and the SDK will start in "disconnected mode",
where no features are defined and feaure flags values are returned only from
local overrides (see `Unleash.Propagation`) and fallback values.
Note that:
* In "disconnected mode", at the moment the SDK won't even load features from the local bootstrap file.
That might change in the future, which would allow the SDK to work with a static set of
local features initialised from a local file.
* In the test environment, the SDK won't automatically start polling from the API, even if the client is enabled.
You'll have to start it explicitely with `Unleash.Repo.start_polling/0`.
"""
use Application
alias Unleash.Config
alias Unleash.Feature
alias Unleash.Metrics
alias Unleash.Propagation
alias Unleash.Repo
alias Unleash.Variant
require Logger
@typedoc """
Type that can be used to reference a feature.
Note that `:feature_a` and `"feature_a"` reference the same feature.
"""
@type feature_name() :: String.t() | atom()
@typedoc """
Option that can be passed to `enabled?/2` and `runtime_enabled?/2`.
* `:allow_overrides` - Whether propagated overrides should be considered for the feature flag check.
See `t:Unleash.overrides/0`. The default is `false`.
* `:context` - Context that might be used to evaluate activation strategies. The default is an empty map.
* `:fallback` - Fallback value that will be returned if the feature is not found in the local cache or overrides.
The default is `false`. You can use `nil` if you'd like to handle missing features explicitly.
"""
@type enabled_opt ::
{:allow_overrides, boolean()}
| {:context, context()}
| {:fallback, boolean() | nil}
# Internal equivalent of the opt post-normalization and defaults.
@typep enabled_params :: %{
required(:allow_overrides) => boolean(),
required(:context) => context(),
required(:fallback) => boolean() | nil
}
@enabled_default_params %{
allow_overrides: false,
context: %{},
fallback: false
}
@typedoc """
Option that can be passed to `get_variant/2` and `runtime_get_variant/2`.
* `:allow_overrides` - Whether propagated overrides should be considered for the feature flag check.
See `t:Unleash.overrides/0`. The default is `false`.
* `:context` - Context that might be used to evaluate activation strategies. The default is an empty map.
* `:fallback` - Fallback value that will be returned if the feature is not found in the local cache or overrides.
The default is the special "disabled" variant.
You can use `nil` if you'd like to handle missing features explicitly.
"""
@type get_variant_opt ::
{:allow_overrides, boolean()}
| {:context, context()}
| {:fallback, Variant.result() | nil}
# Internal equivalent of the opt post-normalization and defaults.
@typep get_variant_params :: %{
required(:allow_overrides) => boolean(),
required(:context) => context(),
required(:fallback) => Variant.result() | nil
}
@get_variant_default_params %{
allow_overrides: false,
context: %{},
fallback: Variant.disabled()
}
@supported_opts [:allow_overrides, :context, :fallback]
@typedoc """
The Unleash Context, mostly used to evaluate conditional activation strategies.
https://docs.getunleash.io/reference/unleash-context.
Custom context properties can be defined in the `:properties` field.
Note that, for hystorical / backwards-compatibility reasons,
both atom and string keys are allowed in the properties map.
You are encouraged to use `find_in_context/2` to lookup context values in a way
that handles fallback to properties and the different style of keys correctly.
"""
# Note that some context keys are never referenced in the SDK,
# as the bundled in strategies only use a subset of them.
# We still support the full context for propagation purposes and
# because users of the library might define custom strategies using any context key.
@type context :: %{
optional(:app_name) => String.t(),
optional(:environment) => String.t(),
optional(:user_id) => String.t(),
optional(:session_id) => String.t(),
optional(:remote_address) => String.t(),
# Having to allow both atom and string keys in the properties map
# is an unfortunate consequence of the evolution of the SDK:
# it started out supporting only atom keys, but when we introduced
# the propagation mechanism, properties are read from request HTTP headers.
# and building atom at runtime from uncontrolled user-data is a big no-no.
optional(:properties) => %{(atom() | String.t()) => String.t()},
optional(:current_time) => String.t()
}
@typedoc """
Represents local feature overrides that can be used to force a feature evaluation value,
bypassing its activation strategies.
These can be inherited from the clients,
see `Unleash.Propagation` for a high-level introduction to the topic.
"""
@type overrides :: %{
String.t() => :on | :off | String.t()
}
@typedoc """
Record of a feature flag activation check.
In other words, whenever a feature flag is queried for status via `enabled?/3`,
an impression represents the feature that was checked and the result of that check.
See https://docs.getunleash.io/reference/impression-data
"""
@type impression :: %{
feature_name: String.t(),
enabled: boolean()
}
@doc """
Checks if the given feature is enabled.
Takes a feature name and a set of extra parameters (in the form of a keyword list)
that customise the behaviour of the feature check,
see `t:enabled_opt/0` for the supported parameters and their defaults.
Returns a boolean indicating whether the feature flag is enabled.
Can return `nil` only if `:fallback` was set to `nil` and the feature was not found locally.
## Examples
iex> Unleash.enabled?(:flag_a)
iex> Unleash.enabled?(:flag_b, context: get_context(), fallback: true, allow_overrides: false)
### Compile-time option keys only
Whenever you call `Unleash.enabled?/2`, option keys must be provided **statically at compile-time**,
similar to how you would use function call syntax.
The compiler will raise an error when that's not the case.
This requirement does not extend to the feature name or option values,
which can be dynamic runtime terms.
However, for the sake of readability and clarity regarding which/how feature flags are being checked,
it is recommended to also provide those statically whenever possible, particularly for feature names.
For instance, rather than writing:
iex> opts = [allow_overrides: true, fallback: true]
...> {name, opts} = case some_condition do
...> true -> {:flag1, Keyword.put(opts, :context, %{user_id: "u-42"})}
...> false -> {:flag2, opts}
...> end
...> Unleash.enabled?(name, opts)
You should write:
iex> case some_condition do
...> true -> Unleash.enabled?(:flag1, allow_overrides: true, fallback: true, context: %{user_id: "u-42"})
...> false -> Unleash.enabled?(:flag2, allow_overrides: true, fallback: true)
...> end
In the second example, it is much clearer to see which feature flags are being checked and the parameters being used.
If you absolutely need to pass the options using arbitrary runtime expressions (which is extremely unlikely),
you can use `runtime_enabled?/2`, but its usage is discouraged.
"""
defmacro enabled?(feature_name_ast, opts_ast \\ []) do
check_opts(opts_ast, "enabled?")
quote bind_quoted: [feature_name: feature_name_ast, opts: opts_ast] do
Unleash.runtime_enabled?(feature_name, opts)
end
end
@doc """
Runtime-friendly version of `enabled?/2` with support for runtime option keys.
You are encouraged to use `enabled?/2` whenever possible. Check its doc for more info.
"""
@spec runtime_enabled?(feature_name(), [enabled_opt()]) :: boolean() | nil
def runtime_enabled?(feature_name, opts) do
params = Map.merge(@enabled_default_params, opts |> Keyword.take(@supported_opts) |> Map.new())
start_metadata = Unleash.Client.telemetry_metadata(%{feature: feature_name})
enabled? =
:telemetry.span(
[:unleash, :feature, :enabled?],
start_metadata,
fn -> do_enabled(feature_name, params, start_metadata) end
)
Propagation.record_impression(feature_name, enabled?)
enabled?
end
@spec do_enabled(feature_name(), enabled_params(), :telemetry.event_metadata()) ::
{boolean() | nil, :telemetry.event_metadata()}
defp do_enabled(name, params, start_metadata) do
{result, metadata} =
with {_, false} <- {:disable, Config.disable_client()},
{_, nil} <- {:override, get_propagated_override(name, params.allow_overrides)},
{_, feature} when not is_nil(feature) <- {:feature, Repo.get_feature(name)} do
context = enrich_context(params.context, feature.name)
{result, strategy_evaluations} = Feature.enabled?(feature, context)
Metrics.add_metric({feature, result})
metadata = %{
reason: :strategy_evaluations,
strategy_evaluations: strategy_evaluations,
feature_enabled: feature.enabled
}
{result, metadata}
else
{:disable, _} -> {params.fallback, %{reason: :disabled_client}}
{:override, val} when val == :on or is_binary(val) -> {true, %{reason: :override}}
{:override, :off} -> {false, %{reason: :propagated_override}}
{:feature, nil} -> {params.fallback, %{reason: :feature_not_found}}
end
final_metadata =
start_metadata
|> Map.put(:result, result)
|> Map.put(:evaluation_pid, self())
|> Map.merge(metadata)
{result, final_metadata}
end
@doc """
Returns the variant for the given feature flag.
See https://unleash.github.io/docs/beta_features#feature-toggle-variants
Takes a feature name and a set of options (in the form of a keyword list)
that customise the behaviour of the feature check,
see `t:get_variant_opt/0` for the supported options and their defaults.
The same constraints on options as `enabled/2` apply, see its doc.
A special variant `%{enabled: false, name: "disabled"}` will be returned in
any of these scenarios:
* the feature flag is disabled
* the feature flag does not define any variants
* the feature flag does not exist and no fallback has been supplied
## Examples
iex> Unleash.get_variant(:feature_a)
iex> Unleash.get_variant(:test, fallback: %{enabled: true, name: "variant_two"})
"""
defmacro get_variant(feature_name_ast, opts_ast \\ []) do
check_opts(opts_ast, "get_variant")
quote bind_quoted: [feature_name: feature_name_ast, opts: opts_ast] do
Unleash.runtime_get_variant(feature_name, opts)
end
end
@doc """
Runtime-friendly version of `get_variant/2` with support for runtime option keys.
You are encouraged to use `get_variant/2` whenever possible. Check its doc for more info.
"""
@spec runtime_get_variant(feature_name(), [get_variant_opt()]) :: Variant.result() | nil
def runtime_get_variant(feature_name, opts) do
params = Map.merge(@get_variant_default_params, opts |> Keyword.take(@supported_opts) |> Map.new())
start_metadata = Unleash.Client.telemetry_metadata(%{variant: feature_name})
result =
:telemetry.span(
[:unleash, :variant, :get],
start_metadata,
fn -> do_get_variant(feature_name, params, start_metadata) end
)
Propagation.record_impression(feature_name, result[:enabled])
result
end
@spec do_get_variant(feature_name(), get_variant_params(), :telemetry.event_metadata()) ::
{Variant.result(), :telemetry.event_metadata()}
defp do_get_variant(name, params, start_metadata) do
{result, metadata} =
with {_, false} <- {:disable, Config.disable_client()},
{_, override} when is_nil(override) or override == :on <-
{:override, get_propagated_override(name, params.allow_overrides)},
{_, feature} when not is_nil(feature) <- {:feature, Repo.get_feature(name)} do
context = enrich_context(params.context, name)
{result, metadata} =
case override do
# if no override, just use normal selection logic
nil -> Variant.select_variant(feature, context)
# If override is just :on (but with no variant name specified),
# use normal variant selection but forcing the feature to enabled
:on -> Variant.select_variant_assuming_feature_enabled(feature, context)
end
{result, Map.merge(start_metadata, metadata)}
else
{:disable, _} -> {params.fallback, %{reason: :disabled_client}}
# If override provides a variant name, use that variant name even if we don't have the feature locally
{:override, variant} -> {%{enabled: true, name: variant, payload: %{}}, %{reason: :propagated_override}}
# If override is :off, return disabled variant even if we don't have the feature locally
{:override, :off} -> {Variant.disabled(), %{reason: :propagated_override}}
{:feature, nil} -> {params.fallback, %{reason: :feature_not_found}}
end
{result, start_metadata |> Map.put(:result, result) |> Map.merge(metadata)}
end
@check_opts_error_message """
Incorrect usage of the Unleash.{{METHOD}}/2 macro.
Make sure that options are provided as a compile-time keyword list,
where the keys must be compile-time constants.
For examples, this is not allowed:
iex> opts = [allow_overrides: true, fallback: false]
...> Unleash.{{METHOD}}(:my_flag, opts)
Because when the macro is compiled, `opts` is a reference to a runtime local variable.
Instead, use:
iex> Unleash.{{METHOD}}(:my_flag, allow_overrides: true, fallback: false)
Note that the values are allowed to be runtime expressions.
See the documentation of `Unleash.enabled?/2` for more details.
"""
@spec check_opts(keyword(), String.t()) :: :ok
defp check_opts(opts_ast, method) do
unless Keyword.keyword?(opts_ast) do
raise ArgumentError, String.replace(@check_opts_error_message, "{{METHOD}}", method)
end
unknown_opts = Keyword.keys(opts_ast) -- @supported_opts
if unknown_opts != [] do
IO.warn("Unknown keyword options #{inspect(unknown_opts)} passed to Unleash.enabled?/2")
end
end
@spec enrich_context(context(), feature_name()) :: context()
defp enrich_context(local_context, feature_name) do
# We want the local context to override any field in the propagated context,
# except for `:properties`, which we want to "deep" merge.
{local_properties, local_context} = Map.pop(local_context, :properties, %{})
# Start with context passed from client (it might be empty). See `Unleash.Propagation`
propagated_context()
# Overwrite values that are defined in local context (minus `:properties`)
|> Map.merge(local_context)
# Special context value required by the flexible_rollout strategy
|> Map.put(:feature_toggle, feature_name)
# "Deep" merge client properties and local ones.
# Because properties is supposed to be a flat map, a normal merge should be enough.
|> Map.update(:properties, local_properties, &Map.merge(&1, local_properties))
end
defp get_propagated_override(_feature_name, false), do: nil
defp get_propagated_override(feature_name, true) do
case Unleash.Propagation.get_overrides() do
nil -> nil
overrides -> Map.get(overrides, feature_name, nil)
end
end
@spec propagated_context() :: context()
defp propagated_context do
case Unleash.Propagation.get_context() do
nil -> %{}
context when is_map(context) -> context
end
end
@doc """
Starts the Unleash SDK.
See `Unleash` for details.
"""
@impl Application
def start(_type, _args) do
children =
[
{Repo, Config.disable_client()},
{{Metrics, name: Metrics}, Config.disable_client() or Config.disable_metrics()}
]
|> Enum.filter(fn {_m, not_enabled} -> not not_enabled end)
|> Enum.map(fn {module, _e} -> module end)
unless children == [] do
Config.client().register_client()
end
Supervisor.start_link(children, strategy: :one_for_one)
end
@context_fields [:app_name, :environment, :user_id, :session_id, :remote_address, :properties, :current_time]
@string_context_fields ["appName", "environment", "userId", "sessionId", "remoteAddress", "properties", "currentTime"]
@doc """
Looks for a context value, taking care of some of the nasty details
like fallback to `:properties` (a.k.a. custom context values)
with support for both atom and string keys.
Specifically:
1. If the key is a standard context key (either in its atom or string form),
it looks for it in the standard context fields, without fallbacking to
properties.
2. Otherwise, it looks for it in properties, trying both the atomized and the stringified
version of the key, starting from the supplied one.
NOTE that this can create atom at runtimes, which is dangerous.
It is highly recommended you do not call this function with values
from untrusted / unbounded sources.
"""
@spec find_in_context(context(), atom() | String.t()) :: term()
def find_in_context(context, key)
# Do not fallback to properties for known context fields
def find_in_context(context, key) when key in @context_fields, do: Map.get(context, key)
def find_in_context(context, key) when key in @string_context_fields, do: Map.get(context, atomize_context_key(key))
def find_in_context(%{properties: %{} = props}, key) when is_binary(key) do
Map.get_lazy(props, key, fn -> Map.get(props, atomize_context_key(key)) end)
end
def find_in_context(%{properties: %{} = props}, key) when is_atom(key) do
Map.get_lazy(props, key, fn -> Map.get(props, stringify_context_key(key)) end)
end
def find_in_context(_context_with_no_props, _nonstandard_context_key), do: nil
@spec atomize_context_key(String.t()) :: atom()
def atomize_context_key(string_key) when is_binary(string_key) do
# NOTE: potentially dangerous. Can create new atoms.
string_key |> Recase.to_snake() |> String.to_atom()
end
@spec stringify_context_key(atom()) :: String.t()
def stringify_context_key(atom_key) when is_atom(atom_key) do
atom_key |> Recase.to_camel() |> to_string()
end
end