A weighted set of variants for an A/B test, and the assignment function.
A :variant flag resolves to a different value per caller, chosen by a
consistent hash of an identity you supply. The same identity always gets the
same variant, on every node and across restarts, so a user sees a stable
experience and the results stay analysable.
The weights live in the flag's stored value as a compact, human-editable string, so a rollout can be changed at runtime from the dashboard:
"control=50,new_flow=50"Assignment
The seed, flag key and identity are length-prefixed, concatenated, and run through SHA-256, with the leading 64 bits taken modulo 10000. The resulting point is looked up in a cumulative bucket table built once when the cache loads. (Length prefixes rather than a separator, so that a key or identity containing the separator cannot alias two different experiments together.)
SHA-256 rather than :erlang.phash2/2 is deliberate: phash2 is not
guaranteed stable across OTP major versions, so an OTP upgrade would silently
reshuffle every running experiment.
The flag key is part of the hash input, so two concurrent experiments do not
correlate — a user in control for one is not systematically in control for the
other. Pass an explicit :seed to deliberately correlate two flags, or to
re-randomise everyone when restarting an experiment.
Stickiness
Buckets are cumulative in declaration order, so growing a variant at the
expense of the next one moves only the boundary between them: going from
control=90,new=10 to control=80,new=20 moves the 80–90 band and leaves
everyone else where they were. That property is what makes a gradual rollout
safe.
It does not survive reordering the :variants declaration, changing
:seed, or a :ttl rollover. Any of those reshuffles the population.
TTL
With ttl: nil (the default) an assignment is permanent. A non-nil :ttl
in milliseconds folds a time window into the hash, so each identity is
re-rolled once per window. The window is offset per identity, so the
population does not all flip at the same instant.
Summary
Functions
Returns the variant name assigned to identity for the flag key.
Parses a stored weights string into a %PhoenixFlags.Variant{}.
Parses a weights string without checking that it totals 100.
The number of points the hash space is divided into.
Serialises {name, weight} pairs (or a %PhoenixFlags.Variant{}) back into
the stored string form.
The total the declared weights must sum to.
Types
@type name() :: String.t()
@type t() :: %PhoenixFlags.Variant{ buckets: [{name(), pos_integer()}], seed: String.t() | nil, ttl: pos_integer() | nil, weights: [{name(), weight()}] }
@type weight() :: non_neg_integer()
Functions
Returns the variant name assigned to identity for the flag key.
Deterministic: the same arguments always produce the same result. Returns
nil only when there are no buckets to choose from.
Raises when identity is nil or not a binary or integer — bucketing every
caller identically because an identity was quietly missing is a serious and
invisible bug, so it fails loudly instead.
Options
:now— current time in milliseconds, for testing TTL rollover without sleeping. Defaults to the system clock and is ignored whenttlisnil.
Parses a stored weights string into a %PhoenixFlags.Variant{}.
Returns {:ok, variant} or {:error, message}, where the message is
end-user readable — it is surfaced as a changeset error on the dashboard.
Options
:ttl— assignment lifetime in milliseconds;nil(default) is infinite:seed— hash seed;nil(default) keeps the split local to this flag:names— the declared variant names. When given, every name in the string must be one of them. Omit to skip that check (the caller may not have the declaration at hand).
Examples
iex> {:ok, variant} = PhoenixFlags.Variant.parse("control=60,new=40")
iex> variant.weights
[{"control", 60}, {"new", 40}]
iex> variant.buckets
[{"control", 6000}, {"new", 10000}]
iex> PhoenixFlags.Variant.parse("control=60,new=30")
{:error, "weights must total 100, got 90"}
Parses a weights string without checking that it totals 100.
The dashboard needs this to re-render inputs for a split the operator is
midway through editing, which is invalid by definition. Prefer parse/2
anywhere the result will actually be used for assignment.
Examples
iex> PhoenixFlags.Variant.parse_weights("a=30,b=30")
{:ok, [{"a", 30}, {"b", 30}]}
@spec resolution() :: pos_integer()
The number of points the hash space is divided into.
Serialises {name, weight} pairs (or a %PhoenixFlags.Variant{}) back into
the stored string form.
Examples
iex> PhoenixFlags.Variant.serialize([{"control", 60}, {"new", 40}])
"control=60,new=40"
@spec weight_total() :: pos_integer()
The total the declared weights must sum to.