Packages
unleash_fresha
1.14.1-git-e754
5.1.4
5.1.3
5.1.2
5.1.1
5.1.0
5.0.0
4.0.0
4.0.0-git-bda3
3.0.1
3.0.0
2.1.0
2.0.1
2.0.0-git-75d8
2.0.0-git-67b2
2.0.0-git-4b61
2.0.0-git-49f4
1.16.0
1.15.0
1.14.1-git-e754
1.14.0
1.14.0-git-71e9
1.13.0
1.13.0-git-4ba6
1.12.0
1.11.0
1.11.0-git-1b62
1.10.2
1.10.2-git-b835
1.10.1
1.10.0
1.9.3-alpha0
1.9.2
1.9.1
1.9.0
An Unleash Feature Flag client for Elixir, forked from [unleash](https://gitlab.com/afontaine/unleash_ex)
Current section
Files
Jump to
Current section
Files
lib/unleash.ex
defmodule Unleash do
@moduledoc """
If you have no plans on extending the client, then `Unleash` will be the main
usage point of the library. Upon starting your app, the client is registered
with the unleash server, and two `GenServer`s are started, one to fetch and
poll for feature flags from the server, and one to send metrics.
Configuring `:disable_client` to `true` disables both servers as well as
registration, while configuring `:disable_metrics` to `true` disables only
the metrics `GenServer`.
"""
use Application
alias Unleash.Config
alias Unleash.Feature
alias Unleash.Metrics
alias Unleash.Repo
alias Unleash.Variant
@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.Plugs`
"""
@type overrides :: %{
String.t() => :on | :off | String.t()
}
@doc """
Aliased to `enabled?/2`
"""
@spec is_enabled?(atom() | String.t(), boolean) :: boolean
# credo:disable-for-next-line
def is_enabled?(feature, default) when is_boolean(default), do: enabled?(feature, default)
@doc """
Aliased to `enabled?/3`
"""
@spec is_enabled?(atom() | String.t(), map(), boolean) :: boolean
# credo:disable-for-next-line
def is_enabled?(feature, context \\ %{}, default \\ false), do: enabled?(feature, context, default)
@doc """
Checks if the given feature is enabled. Checks as though an empty context was
passed in.
## Examples
iex> Unleash.enabled?(:my_feature, false)
false
iex> Unleash.enabled?(:my_feature, true)
true
"""
@spec enabled?(atom() | String.t(), boolean) :: boolean
def enabled?(feature, default) when is_boolean(default), do: enabled?(feature, %{}, default)
@doc """
Checks if the given feature is enabled.
If `:disable_client` is `true`, simply returns the given `default`.
If `:disable_metrics` is `true`, nothing is logged about the given toggle.
## Examples
iex> Unleash.enabled?(:my_feature)
false
iex> Unleash.enabled?(:my_feature, context)
false
iex> Unleash.enabled?(:my_feature, context, true)
false
"""
@spec enabled?(atom() | String.t(), map(), boolean) :: boolean
def enabled?(feature, context \\ %{}, default \\ false) do
start_metadata = Unleash.Client.telemetry_metadata(%{feature: feature})
:telemetry.span(
[:unleash, :feature, :enabled?],
start_metadata,
fn -> do_enabled(feature, start_metadata, context, default) end
)
end
@doc """
Returns a variant for the given name.
If `:disable_client` is `true`, returns the fallback.
A [variant](https://unleash.github.io/docs/beta_features#feature-toggle-variants)
allows for more complicated toggling than a simple `true`/`false`, instead
returning one of the configured variants depending on whether or not there
are any overrides for a given context value as well as factoring in the
weights for the various weight options.
## Examples
iex> Unleash.get_variant(:test)
%{enabled: true, name: "test", payload: %{...}}
iex> Unleash.get_variant(:test)
%{enabled: false, name: "disabled"}
"""
@spec get_variant(atom() | String.t(), map(), Variant.result()) :: Variant.result()
def get_variant(name, context \\ %{}, fallback \\ Variant.disabled()) do
start_metadata = Unleash.Client.telemetry_metadata(%{variant: name})
:telemetry.span(
[:unleash, :variant, :get],
start_metadata,
fn -> do_get_variant(name, start_metadata, context, fallback) end
)
end
@doc false
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
defp do_get_variant(name, start_metadata, context, fallback) do
{result, metadata} =
with {_, false} <- {:disable, Config.disable_client()},
{_, override} when is_nil(override) or override == :on <- {:override, get_propagated_override(name)},
{_, feature} when not is_nil(feature) <- {:feature, Repo.get_feature(name)} do
context = enrich_context(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, _} -> {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} -> {fallback, %{reason: :feature_not_found}}
end
{result, start_metadata |> Map.put(:result, result) |> Map.merge(metadata)}
end
defp do_enabled(name, start_metadata, context, fallback) do
{result, metadata} =
with {_, false} <- {:disable, Config.disable_client()},
{_, nil} <- {:override, get_propagated_override(name)},
{_, feature} when not is_nil(feature) <- {:feature, Repo.get_feature(name)} do
context = enrich_context(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, _} -> {fallback, %{reason: :disabled_client}}
{:override, val} when val == :on or is_binary(val) -> {true, %{reason: :override}}
{:override, :off} -> {false, %{reason: :propagated_override}}
{:feature, nil} -> {fallback, %{reason: :feature_not_found}}
end
{result, start_metadata |> Map.put(:result, result) |> Map.merge(metadata)}
end
@spec enrich_context(context(), String.t()) :: 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) 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
@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