PhoenixFlags.Variant (PhoenixFlags v0.9.0)

Copy Markdown View Source

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

name()

@type name() :: String.t()

t()

@type t() :: %PhoenixFlags.Variant{
  buckets: [{name(), pos_integer()}],
  seed: String.t() | nil,
  ttl: pos_integer() | nil,
  weights: [{name(), weight()}]
}

weight()

@type weight() :: non_neg_integer()

Functions

assign(variant, key, identity, opts \\ [])

@spec assign(t(), String.t(), String.t() | integer(), keyword()) :: name() | nil

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 when ttl is nil.

parse(value, opts \\ [])

@spec parse(
  String.t(),
  keyword()
) :: {:ok, t()} | {:error, String.t()}

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"}

parse_weights(value)

@spec parse_weights(String.t()) :: {:ok, [{name(), weight()}]} | {:error, String.t()}

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}]}

resolution()

@spec resolution() :: pos_integer()

The number of points the hash space is divided into.

serialize(weights)

@spec serialize(t() | [{name(), weight()}]) :: String.t()

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"

weight_total()

@spec weight_total() :: pos_integer()

The total the declared weights must sum to.