defmodule Bandera do
@moduledoc """
Runtime-configured feature flags, API-compatible with fun_with_flags.
The active store is resolved at runtime (`Bandera.Store.active/0`), so nothing
about persistence or caching is fixed at compile time.
"""
alias Bandera.Flag
alias Bandera.Gate
alias Bandera.Store
require Logger
@doc "Re-read application env into the runtime config snapshot."
@spec reload_config() :: :ok
defdelegate reload_config, to: Bandera.Config, as: :reload
# ---- enabled? ----
@doc """
Returns whether `flag_name` is enabled.
Pass `for: actor` to evaluate actor, group, and percentage-of-actors gates against
a specific subject (the actor is identified via the `Bandera.Actor`/`Bandera.Group`
protocols). The flag is read through the active store (cache included). A missing
flag, or a store lookup error, resolves to `false` (the error is logged).
Pass `default: true` to fail open (return true) when the store is unreachable; the
default is false.
## Examples
iex> Bandera.enabled?(:unknown_flag)
false
iex> Bandera.enable(:checkout)
iex> Bandera.enabled?(:checkout)
true
iex> Bandera.enable(:beta, for_actor: "user-1")
iex> Bandera.enabled?(:beta, for: "user-1")
true
iex> Bandera.enabled?(:beta, for: "user-2")
false
"""
@spec enabled?(atom, keyword) :: boolean
def enabled?(flag_name, options \\ [])
def enabled?(flag_name, options) when is_atom(flag_name) do
{default, rest} = Keyword.pop(options, :default, false)
eval_opts = rest |> Keyword.take([:for, :context]) |> drop_nil_for()
result =
case Store.active().lookup(flag_name) do
{:ok, flag} ->
if prerequisites_met?(flag, eval_opts, [flag_name]) do
Flag.enabled?(expand_segments(flag), eval_opts)
else
false
end
error ->
lookup_failed(flag_name, error, default)
end
track_enabled?(flag_name, eval_opts, result)
end
defp drop_nil_for(opts) do
case Keyword.fetch(opts, :for) do
{:ok, nil} -> Keyword.delete(opts, :for)
_ -> opts
end
end
@segment_prefix "bandera_segment:"
# Expand each :segment gate into the referenced segment's :rule gate so the pure
# Flag evaluator can resolve it. Unresolvable segments are dropped (ignored).
defp expand_segments(%Flag{gates: gates} = flag) do
expanded =
Enum.flat_map(gates, fn
%Gate{type: :segment, for: name, enabled: enabled} ->
case Store.active().lookup(String.to_atom(@segment_prefix <> name)) do
{:ok, %Flag{gates: seg_gates}} ->
case Enum.find(seg_gates, &Gate.rule?/1) do
%Gate{value: constraints} -> [Gate.new(:rule, constraints, enabled)]
_ -> []
end
_ ->
[]
end
gate ->
[gate]
end)
%{flag | gates: expanded}
end
defp track_enabled?(flag_name, options, result) do
Bandera.Telemetry.event([:enabled?], %{flag_name: flag_name, options: options, result: result})
result
end
# ---- enable ----
@doc """
Enables `flag_name`, optionally scoped by an option, and returns `{:ok, enabled?}`.
With no options the boolean gate is turned on. Supported scopes:
* `for_actor: actor` — enable for one actor
* `for_group: group` — enable for a named group
* `for_percentage_of: {:time, ratio}` — enable for a ratio of calls
* `for_percentage_of: {:actors, ratio}` — enable for a ratio of actors
* `when: constraints` — enable when the evaluation context matches a rule
* `for_segment: name` — enable for a reusable named segment
* `requires: parent` (or `{parent, required_state}`) — add a prerequisite
* `schedule: {from, until}` — enable inside an ISO-8601 time window
`ratio` is a float in `0.0 < r < 1.0`. The write goes to the persistent store and
busts/refreshes the cache; returns `{:error, reason}` if the store write fails.
The returned `enabled?` is the immediate state for unconditional/percentage gates.
For the **conditional** scopes (`when:`, `for_segment:`, `requires:`, `schedule:`)
it is `true` to signal a successful write — those gates are evaluated per call by
`enabled?/2` against the relevant context, actor, time, or parent flag.
Pass `by: identity` to record who made the change; it is carried in the write
telemetry metadata (see `Bandera.Audit`) and does not affect the gate written.
## Examples
iex> Bandera.enable(:checkout)
{:ok, true}
iex> Bandera.enable(:beta, for_actor: "user-1")
{:ok, true}
iex> Bandera.enable(:gradual, for_percentage_of: {:actors, 0.25})
{:ok, true}
"""
@spec enable(atom, keyword) :: {:ok, boolean} | {:error, term}
def enable(flag_name, options \\ [])
def enable(flag_name, options) when is_atom(flag_name) do
{_by, rest} = Keyword.pop(options, :by)
Bandera.Telemetry.span([:enable], %{flag_name: flag_name, options: options}, fn ->
result = do_enable(flag_name, rest)
{result, %{result: result}}
end)
end
defp do_enable(flag_name, []) when is_atom(flag_name),
do: put_and_verify(flag_name, Gate.new(:boolean, true), [])
defp do_enable(flag_name, for_actor: nil), do: do_enable(flag_name, [])
defp do_enable(flag_name, for_actor: actor) when is_atom(flag_name),
do: put_and_verify(flag_name, Gate.new(:actor, actor, true), for: actor)
defp do_enable(flag_name, for_group: nil), do: do_enable(flag_name, [])
defp do_enable(flag_name, for_group: group_name) when is_atom(flag_name),
do: put_constant(flag_name, Gate.new(:group, group_name, true), true)
defp do_enable(flag_name, for_percentage_of: {:time, ratio}) when is_atom(flag_name),
do: put_constant(flag_name, Gate.new(:percentage_of_time, ratio), true)
defp do_enable(flag_name, for_percentage_of: {:actors, ratio}) when is_atom(flag_name),
do: put_constant(flag_name, Gate.new(:percentage_of_actors, ratio), true)
defp do_enable(_flag_name, when: []),
do: raise(ArgumentError, "enable/2 :when requires at least one constraint")
defp do_enable(flag_name, when: constraints) when is_atom(flag_name) and is_list(constraints) do
gate = Gate.new(:rule, Enum.map(constraints, &to_constraint/1), true)
put_constant(flag_name, gate, true)
end
defp do_enable(flag_name, for_segment: name) when is_atom(flag_name),
do: put_constant(flag_name, Gate.new(:segment, name, true), true)
defp do_enable(flag_name, schedule: {from, until}) when is_atom(flag_name),
do: put_constant(flag_name, Gate.new(:schedule, {from, until}), true)
defp do_enable(flag_name, requires: parent) when is_atom(flag_name) and is_atom(parent),
do: put_constant(flag_name, Gate.new(:prerequisite, parent, true), true)
defp do_enable(flag_name, requires: {parent, required})
when is_atom(flag_name) and is_atom(parent) and is_boolean(required),
do: put_constant(flag_name, Gate.new(:prerequisite, parent, required), true)
defp to_constraint(%Bandera.Constraint{} = c), do: c
defp to_constraint({attribute, operator, value}),
do: Bandera.Constraint.new(attribute, operator, value)
# ---- disable ----
@doc """
Disables `flag_name`, optionally scoped by an option, and returns `{:ok, enabled?}`.
Accepts the negatable scopes `for_actor:`, `for_group:`, and `for_percentage_of:`
(for a percentage scope, disabling for `ratio` is equivalent to enabling for
`1.0 - ratio`). To remove a grant-only gate (`variant`, `rule`, `segment`,
`prerequisite`, `schedule`), use `clear/2`; passing one of those scopes here
returns `{:error, :unsupported_scope}`. Returns `{:error, reason}` on a store
write failure.
Accepts `by: identity` to record who made the change (see `Bandera.Audit`).
## Examples
iex> Bandera.disable(:checkout)
{:ok, false}
iex> Bandera.enable(:beta)
iex> Bandera.disable(:beta)
{:ok, false}
"""
@spec disable(atom, keyword) :: {:ok, boolean} | {:error, term}
def disable(flag_name, options \\ [])
def disable(flag_name, options) when is_atom(flag_name) do
{_by, rest} = Keyword.pop(options, :by)
Bandera.Telemetry.span([:disable], %{flag_name: flag_name, options: options}, fn ->
result = do_disable(flag_name, rest)
{result, %{result: result}}
end)
end
defp do_disable(flag_name, []) when is_atom(flag_name),
do: put_and_verify(flag_name, Gate.new(:boolean, false), [])
defp do_disable(flag_name, for_actor: nil), do: do_disable(flag_name, [])
defp do_disable(flag_name, for_actor: actor) when is_atom(flag_name),
do: put_and_verify(flag_name, Gate.new(:actor, actor, false), for: actor)
defp do_disable(flag_name, for_group: nil), do: do_disable(flag_name, [])
defp do_disable(flag_name, for_group: group_name) when is_atom(flag_name),
do: put_constant(flag_name, Gate.new(:group, group_name, false), false)
defp do_disable(flag_name, for_percentage_of: {type, ratio})
when is_atom(flag_name) and is_float(ratio) do
case do_enable(flag_name, for_percentage_of: {type, 1.0 - ratio}) do
{:ok, true} -> {:ok, false}
error -> error
end
end
defp do_disable(_flag_name, _options), do: {:error, :unsupported_scope}
# ---- clear ----
@doc """
Removes gates from `flag_name`, returning `:ok`.
With no options the whole flag (all its gates) is deleted. A scope removes just
that gate, letting evaluation fall through to whatever remains:
* `boolean: true` — clear the boolean gate
* `for_actor: actor` — clear one actor gate
* `for_group: group` — clear one group gate
* `for_percentage: true` — clear the percentage gate
* `variant: true` — clear the variant gate
* `rule: true` — clear the rule gate
* `for_segment: name` — clear one segment gate
* `requires: parent` — clear one prerequisite gate
* `schedule: true` — clear the schedule gate
Accepts `by: identity` to record who made the change (see `Bandera.Audit`).
Returns `{:error, reason}` if the store delete fails.
## Examples
iex> Bandera.enable(:checkout)
iex> Bandera.clear(:checkout)
:ok
iex> Bandera.enabled?(:checkout)
false
"""
@spec clear(atom, keyword) :: :ok | {:error, term}
def clear(flag_name, options \\ [])
def clear(flag_name, options) when is_atom(flag_name) do
{_by, rest} = Keyword.pop(options, :by)
Bandera.Telemetry.span([:clear], %{flag_name: flag_name, options: options}, fn ->
result = do_clear(flag_name, rest)
{result, %{result: result}}
end)
end
defp do_clear(flag_name, []) when is_atom(flag_name) do
case Store.active().delete(flag_name) do
{:ok, _flag} -> :ok
error -> error
end
end
defp do_clear(flag_name, boolean: true), do: clear_gate(flag_name, Gate.new(:boolean, false))
defp do_clear(flag_name, for_actor: nil), do: do_clear(flag_name, [])
defp do_clear(flag_name, for_actor: actor) when is_atom(flag_name),
do: clear_gate(flag_name, Gate.new(:actor, actor, false))
defp do_clear(flag_name, for_group: nil), do: do_clear(flag_name, [])
defp do_clear(flag_name, for_group: group_name) when is_atom(flag_name),
do: clear_gate(flag_name, Gate.new(:group, group_name, false))
defp do_clear(flag_name, for_percentage: true),
do: clear_gate(flag_name, Gate.new(:percentage_of_time, 0.5))
# Gate.new/2 for :variant requires a positive-weight map, so use a bare struct;
# Gate.id/1 derives the slot id from the type alone, making the value irrelevant.
defp do_clear(flag_name, variant: true),
do: clear_gate(flag_name, %Gate{type: :variant})
defp do_clear(flag_name, rule: true),
do: clear_gate(flag_name, Gate.new(:rule, [], false))
defp do_clear(flag_name, for_segment: name) when is_atom(flag_name),
do: clear_gate(flag_name, Gate.new(:segment, name, false))
defp do_clear(flag_name, schedule: true),
do: clear_gate(flag_name, Gate.new(:schedule, {nil, nil}))
defp do_clear(flag_name, requires: parent) when is_atom(flag_name) and is_atom(parent),
do: clear_gate(flag_name, Gate.new(:prerequisite, parent, false))
# The required state is not part of the prerequisite gate's slot id, so the tuple
# form clears the same gate as the bare-atom form — accepted for symmetry with enable/2.
defp do_clear(flag_name, requires: {parent, _required}) when is_atom(flag_name),
do: do_clear(flag_name, requires: parent)
defp do_clear(_flag_name, _options), do: {:error, :unsupported_scope}
# ---- variant ----
@doc """
Returns the variant chosen for the flag named `flag_name` (bucketed by the actor
passed via `for:`), or `options[:default]` (nil if not given) when the flag is
missing, has no variant gate, or `for:` is absent or `nil`.
Looks up the flag from the active store and delegates to `Flag.variant/2`. A missing
flag or store lookup error returns `options[:default]` (the error is logged).
## Examples
iex> Bandera.put_variants(:ab_test, %{"a" => 1, "b" => 1})
iex> Bandera.variant(:ab_test, for: %{id: 1}) in ["a", "b"]
true
"""
@spec variant(atom, keyword) :: term
def variant(flag_name, options \\ []) when is_atom(flag_name) do
default = Keyword.get(options, :default)
result =
case Store.active().lookup(flag_name) do
{:ok, flag} -> Flag.variant(flag, options)
error -> variant_lookup_failed(flag_name, error, default)
end
Bandera.Telemetry.event([:variant], %{flag_name: flag_name, options: options, result: result})
result
end
@doc """
Stores a `:variant` gate for `flag_name` with the given `weights` map.
`weights` is a `%{variant_name => weight}` map; actors are bucketed proportionally
by weight using a stable SHA-256 hash per actor+flag. Returns `{:ok, flag}` on
success, `{:error, reason}` on a store write failure.
The optional third argument is accepted for API uniformity but is ignored.
`put_variants` does not support `by:` and is not audited by `Bandera.Audit`.
## Examples
iex> {:ok, flag} = Bandera.put_variants(:hero, %{"blue" => 1, "green" => 1})
iex> flag.name
:hero
"""
@spec put_variants(atom, %{optional(String.t()) => number}, keyword) ::
{:ok, Flag.t()} | {:error, term}
def put_variants(flag_name, weights, _options \\ [])
when is_atom(flag_name) and is_map(weights) do
Bandera.Telemetry.span([:put_variants], %{flag_name: flag_name, weights: weights}, fn ->
result = Store.active().put(flag_name, Gate.new(:variant, weights))
{result, %{result: result}}
end)
end
defp variant_lookup_failed(flag_name, error, default) do
Logger.warning("[Bandera] variant lookup for #{inspect(flag_name)} failed: #{inspect(error)}")
default
end
# ---- segments ----
@doc """
Stores a reusable named constraint set (a segment) under the reserved key
`:"bandera_segment:<name>"`.
Segments are referenced from flags via `enable(flag, for_segment: name)` and are
expanded at evaluation time so that `Flag` stays pure. `name` must be a
developer-defined atom — never untrusted user input.
## Examples
iex> {:ok, _} = Bandera.put_segment(:premium, [{"plan", :eq, "premium"}])
iex> {:ok, _flag} = Bandera.get_flag(:"bandera_segment:premium")
"""
@spec put_segment(atom, [tuple | Bandera.Constraint.t()]) :: {:ok, Flag.t()} | {:error, term}
def put_segment(_name, []),
do: raise(ArgumentError, "put_segment/2 requires at least one constraint")
def put_segment(name, constraints) when is_atom(name) and is_list(constraints) do
gate = Gate.new(:rule, Enum.map(constraints, &to_constraint/1), true)
Store.active().put(segment_key(name), gate)
end
defp segment_key(name), do: String.to_atom(@segment_prefix <> to_string(name))
# ---- introspection ----
@doc """
Returns `{:ok, names}` with every known flag name, or `{:error, reason}`.
## Examples
iex> Bandera.enable(:checkout)
iex> Bandera.all_flag_names()
{:ok, [:checkout]}
"""
@spec all_flag_names() :: {:ok, [atom]} | {:error, term}
def all_flag_names, do: Store.active().all_flag_names()
@doc """
Returns `{:ok, flags}` with every stored `Bandera.Flag`, or `{:error, reason}`.
## Examples
iex> Bandera.enable(:checkout)
iex> {:ok, flags} = Bandera.all_flags()
iex> Enum.map(flags, & &1.name)
[:checkout]
"""
@spec all_flags() :: {:ok, [Flag.t()]} | {:error, term}
def all_flags, do: Store.active().all_flags()
@doc """
Looks up a single flag, returning `{:ok, %Bandera.Flag{}}` or `{:error, reason}`.
An unknown flag still returns `{:ok, flag}` with an empty gate list (a disabled
flag), not an error.
## Examples
iex> Bandera.enable(:checkout)
iex> {:ok, flag} = Bandera.get_flag(:checkout)
iex> flag.gates
[%Bandera.Gate{type: :boolean, for: nil, enabled: true}]
iex> {:ok, flag} = Bandera.get_flag(:unknown_flag)
iex> flag.gates
[]
"""
@spec get_flag(atom) :: {:ok, Flag.t()} | {:error, term}
def get_flag(flag_name) when is_atom(flag_name), do: Store.active().lookup(flag_name)
# ---- helpers ----
defp put_and_verify(flag_name, gate, verify_opts) do
case Store.active().put(flag_name, gate) do
{:ok, flag} -> {:ok, Flag.enabled?(flag, verify_opts)}
error -> error
end
end
defp put_constant(flag_name, gate, result) do
case Store.active().put(flag_name, gate) do
{:ok, _flag} -> {:ok, result}
error -> error
end
end
defp clear_gate(flag_name, gate) do
case Store.active().delete(flag_name, gate) do
{:ok, _flag} -> :ok
error -> error
end
end
@doc """
List flags whose last evaluation is older than `older_than` days (or never
evaluated). Requires `Bandera.Usage` to be running and attached.
"""
@spec stale_flags(keyword) :: [atom]
def stale_flags(opts \\ []) do
# Clamp to >= 0 so a negative window can't push the cutoff into the future (which
# would report every flag, even freshly-evaluated ones, as stale).
days = opts |> Keyword.get(:older_than, 30) |> max(0)
cutoff = DateTime.add(DateTime.utc_now(), -days * 86_400, :second)
case all_flag_names() do
{:ok, names} ->
names
|> Enum.reject(&segment_flag?/1)
|> Enum.filter(fn name ->
case safe_last_evaluated(name) do
nil -> true
at -> DateTime.compare(at, cutoff) == :lt
end
end)
_ ->
[]
end
end
# Internal segment definitions are stored as reserved flags and are never
# evaluated via enabled?/2, so they would always look stale — exclude them.
defp segment_flag?(name), do: String.starts_with?(to_string(name), @segment_prefix)
# Calls Usage.last_evaluated but returns nil when the Usage table isn't running.
defp safe_last_evaluated(flag_name) do
Bandera.Usage.last_evaluated(flag_name)
rescue
ArgumentError -> nil
end
defp prerequisites_met?(flag, eval_opts, visited) do
{status, _memo} = prereqs_status(flag, eval_opts, visited, %{})
status == :ok
end
# Status of a flag's prerequisite gates: :ok (all met), :not_met (a parent is in the
# wrong state), or :cycle (resolving a parent re-entered a flag already on the stack).
# A cycle propagates as :cycle so it fails closed uniformly — including required:false
# edges, which a plain false would otherwise satisfy.
defp prereqs_status(%Flag{gates: gates}, eval_opts, visited, memo) do
gates
|> Enum.filter(&Gate.prerequisite?/1)
|> Enum.reduce_while({:ok, memo}, fn %Gate{for: parent, enabled: required}, {_status, m} ->
cond do
# An unresolved parent (e.g. an unknown atom from corrupt store data) fails closed.
not is_atom(parent) ->
{:halt, {:not_met, m}}
true ->
case resolve(parent, eval_opts, visited, m) do
{{:ok, enabled}, m} when enabled == required -> {:cont, {:ok, m}}
{{:ok, _enabled}, m} -> {:halt, {:not_met, m}}
{:cycle, m} -> {:halt, {:cycle, m}}
end
end
end)
end
# Resolve a flag's effective enabled state, carrying a per-evaluation memo (so a
# shared/diamond prerequisite is evaluated once, not re-walked exponentially) and a
# visited set for cycle detection. Returns `{{:ok, boolean} | :cycle, memo}`. Cycle
# results are never memoized so they stay path-correct.
defp resolve(flag_name, eval_opts, visited, memo) do
cond do
flag_name in visited ->
{:cycle, memo}
Map.has_key?(memo, flag_name) ->
{{:ok, Map.fetch!(memo, flag_name)}, memo}
true ->
case Store.active().lookup(flag_name) do
{:ok, flag} ->
case prereqs_status(flag, eval_opts, [flag_name | visited], memo) do
{:cycle, m} ->
{:cycle, m}
{:not_met, m} ->
{{:ok, false}, Map.put(m, flag_name, false)}
{:ok, m} ->
enabled = Flag.enabled?(expand_segments(flag), eval_opts)
{{:ok, enabled}, Map.put(m, flag_name, enabled)}
end
_ ->
{{:ok, false}, Map.put(memo, flag_name, false)}
end
end
end
defp lookup_failed(flag_name, error, default) do
Logger.warning("[Bandera] store lookup for #{inspect(flag_name)} failed: #{inspect(error)}")
default
end
end