Botica.Flags (Botica v2.1.0)

Copy Markdown View Source

Feature flags for Elixir with deterministic per-entity rollouts.

Why this exists

Botica already deals with runtime diagnostics and configuration. Feature flags are a natural extension: they let you toggle behaviour at runtime without redeploying, and they let you roll features out to a percentage of users safely.

The implementation is intentionally tiny:

  • Storage: ETS (:set, :public, read_concurrency: true). No external service, no Redis, no Postgres.
  • Writes: Serialised through Botica.Flags.Store (GenServer) to avoid race conditions.
  • Reads: O(1) direct ETS lookup via the Store — no GenServer round-trip on the hot path.
  • Rollout: :erlang.phash2/2 for deterministic bucketing — enabled?(name, for: user_id) is stable across restarts.

Quick start

# 1. Define flags (typically at boot, e.g. in your Application.start)
Botica.Flags.define(:new_dashboard, default: false)
Botica.Flags.define(:beta_search, default: true)
Botica.Flags.define(:rate_limiting, default: false, rollout: 25)

# 2. Query at runtime
Botica.Flags.enabled?(:new_dashboard)                    # => false
Botica.Flags.enabled?(:beta_search)                      # => true
Botica.Flags.enabled?(:rate_limiting, for: user_id)      # => true / false deterministically

# 3. Mutate at runtime (serialised via GenServer)
Botica.Flags.enable(:new_dashboard)
Botica.Flags.disable(:new_dashboard)
Botica.Flags.set(:rate_limiting, rollout: 50)

# 4. Introspection
Botica.Flags.all()        # => [%Flag{}, ...]
Botica.Flags.get(:foo)     # => {:ok, %Flag{}} | :error
Botica.Flags.count()       # => 3

Rollout semantics

When rollout: N is set (0 ≤ N ≤ 100) AND enabled: true, the flag is on for the first N percent of entities, where "entity" is any term you pass as for: (typically a user_id or session_id). The bucketing is deterministic:

iex> Botica.Flags.define(:r, default: false, rollout: 50)
iex> Botica.Flags.enable(:r)
iex> Botica.Flags.enabled?(:r, for: "user_42")
true
iex> Botica.Flags.enabled?(:r, for: "user_42")  # always the same
true

When rollout: nil (the default), the flag is binary on/off and the for: option is ignored.

Summary

Functions

Returns all registered flags (most recently updated first).

Total number of registered flags.

Defines a new flag or updates an existing one.

Removes a flag from the registry.

Forces a flag to disabled. Existing rollout percentages are preserved.

Forces a flag to enabled regardless of its rollout percentage.

Returns whether a flag is enabled. Two-arity version takes a deterministic for: argument used for rollout bucketing.

Returns the flag struct or :error.

Updates one or more attributes of an existing flag.

Functions

all()

@spec all() :: [Botica.Flags.Flag.t()]

Returns all registered flags (most recently updated first).

count()

@spec count() :: non_neg_integer()

Total number of registered flags.

define(name, opts \\ [])

@spec define(
  atom(),
  keyword()
) :: :ok

Defines a new flag or updates an existing one.

Options

  • :default — value when flag is not enabled (default false)
  • :description — optional human-readable note
  • :rollout0..100 percentage when enabled (default nil)

Returns :ok always (write is sync via GenServer.call).

Examples

Botica.Flags.define(:new_dashboard, default: false)
Botica.Flags.define(:beta_search, default: true, description: "WIP")
Botica.Flags.define(:rate_limiting, default: false, rollout: 25)

delete(name)

@spec delete(atom()) :: :ok

Removes a flag from the registry.

disable(name)

@spec disable(atom()) :: :ok

Forces a flag to disabled. Existing rollout percentages are preserved.

enable(name)

@spec enable(atom()) :: :ok

Forces a flag to enabled regardless of its rollout percentage.

The flag is also created with default = true if it didn't exist.

enabled?(name)

@spec enabled?(atom()) :: boolean()

Returns whether a flag is enabled. Two-arity version takes a deterministic for: argument used for rollout bucketing.

Behaviour:

  • If the flag is not defined → returns its default value (or false).
  • If the flag is defined and enabled: false → returns false.
  • If the flag is defined, enabled: true, and rollout: nil → returns true.
  • If the flag is defined, enabled: true, and rollout: P → returns true for the first P% of entities (deterministic hash).

Examples

iex> Botica.Flags.define(:a, default: false)
iex> Botica.Flags.enabled?(:a)
false

iex> Botica.Flags.define(:b, default: true)
iex> Botica.Flags.enabled?(:b)
true

enabled?(name, opts)

@spec enabled?(
  atom(),
  keyword()
) :: boolean()

get(name)

@spec get(atom()) :: {:ok, Botica.Flags.Flag.t()} | :error

Returns the flag struct or :error.

set(name, opts)

@spec set(
  atom(),
  keyword()
) :: :ok | {:error, :not_found}

Updates one or more attributes of an existing flag.

Accepted keys: :enabled, :rollout, :description, :default.

Examples

Botica.Flags.set(:rate_limiting, rollout: 50)
Botica.Flags.set(:new_dashboard, enabled: true, description: "Rolled out to all")