Elixir SDK
Evaluate flags in your Elixir and Phoenix apps, in the calling process, from a client that sits in your supervision tree.
The Elixir SDK keeps a copy of an environment’s flags in memory and evaluates them in the process that asks, so an evaluation never waits on the client or the network and your users’ attributes never leave your servers. It needs Elixir 1.18 or later and OTP, with no other dependencies.
Install
# mix.exs
defp deps do
[
{:featflags, "~> 0.1"},
{:featflags_openfeature, "~> 0.1"}, # optional: the OpenFeature provider
{:featflags_otel, "~> 0.1"} # optional: the OpenTelemetry hook
]
end
The packages are on Hex: featflags, featflags_openfeature and featflags_otel.
Create a client
A client is a small supervision tree. Add it to your application’s, with a name you’ll call it by. On a server, use the environment’s server key (sk_…, under Environment → Keys), which reaches every flag; keep it secret.
# lib/my_app/application.ex
children = [
{Featflags,
name: MyApp.Flags,
key: System.fetch_env!("FEATFLAGS_KEY"), # sk_…
cdn_url: "https://flags.featflags.io",
events_url: "https://events.featflags.io"},
# …
]
Without :events_url the client sends nothing back: no evaluation counts for the flag’s page, and no experiment data.
| Option | Default | What |
|---|---|---|
:name |
required | The atom the client is called by. |
:key |
required | The environment key. |
:cdn_url |
required | https://flags.featflags.io. |
:events_url |
off | Where evaluation counts, experiment exposures and track/4 events go. |
:poll_interval |
5,000 ms for sk_… keys, 60,000 otherwise |
How often the client checks for new flags, ±10% jitter. Zero turns polling off. |
:flush_interval |
60,000 ms | How often counts, exposures and events are sent. They also go once 5,000 exposures and events are waiting. The first send waits up to one extra interval at random, so a fleet that starts together doesn’t send together. |
:on_exposure |
none | Called with a %Featflags.Exposure{} the first time each (context key, flag, variant) is evaluated, in the evaluating process, to forward to your own analytics. |
:bootstrap |
none | Flags to evaluate against until the first fetch answers: a %Featflags.Document{}, or its JSON as text or a map. |
:cache_path |
none | A file to keep the last fetched flags in, so the client can start from them when featflags can’t be reached. |
:timeout |
10,000 ms | HTTP timeout for fetching flags and sending counts. |
:membership_cache |
1024 | List shards kept in memory (about 2 MB) when a project’s lists are too big to download whole. |
:membership_timeout |
500 ms | How long one evaluation waits for list shards it doesn’t have yet. |
:hooks |
none | Modules implementing Featflags.Hook, run around every evaluation. |
:experiment_key |
none | The environment’s experiment key, xk_…, for experiments. |
Starting the client fetches the flags once, bounded by :timeout. If that fails, the client starts anyway: it serves the flags in :cache_path, :bootstrap, or your fallbacks with reason :not_found, until a later poll succeeds. Warnings go to Logger.
Evaluate flags
Evaluate anywhere by the client’s name. Each typed function takes the flag key, a context and the fallback your code gets when the flag is missing, the context is invalid or the value has another type:
user = %{key: "u-alice", plan: "pro", groups: ["qa"]}
if Featflags.bool_variation(MyApp.Flags, "new-checkout", user, false) do
# …
end
limit = Featflags.number_variation(MyApp.Flags, "upload-limit-mb", user, 50)
theme = Featflags.string_variation(MyApp.Flags, "theme", user, "light")
config = Featflags.json_variation(MyApp.Flags, "search-config", user, %{})
number_variation/4 returns an integer or a float as the JSON has it. json_variation/4 returns any value, objects as maps with string keys.
To know why a context got a value, use the …_detail functions (bool_variation_detail/4, string_variation_detail/4, …). They return a %Featflags.Detail{} with value, variant, reason as an atom, and rule_id or prerequisite when they apply:
d = Featflags.string_variation_detail(MyApp.Flags, "pricing-page", user, "v1")
Logger.info("pricing-page: #{d.value} (#{d.reason}, rule #{d.rule_id})")
Concepts lists the reasons.
In the calling process
Evaluations run in the process that calls them and never wait on the client’s processes: the client keeps the flags in an ETS table that evaluations read directly, and they add to counts in another, which the client sends. The one exception is a list shard the client doesn’t have yet (below). Calling a client that isn’t running, or is restarting, returns the fallback with reason :not_found.
Contexts
A context is a map with atom or string names. key is required; kind defaults to user.
%{key: "u-alice", email: "alice@acme.com"} # a user
%{kind: "org", key: "acme", plan: "team"} # another kind
%{kind: "multi", user: %{key: "u-alice"}, org: %{key: "acme", plan: "team"}}
Kinds and keys are strings (kind: :org is invalid). Attribute values are strings, numbers, booleans or lists of those; nil, maps, other atoms and nested lists count as missing. An invalid context evaluates to the fallback with reason :error.
To evaluate many flags for one context, parse it once with Featflags.Context.new(map) and pass that. valid?/1 says whether it’s valid, kinds/1 lists its kinds and key/2 returns a kind’s key. More on contexts in Concepts.
Lists and private segments
Targets and segment lists of up to 64 keys travel with the flags. Longer lists, up to millions of keys, are split into small shards the client fetches separately:
- When all of an environment’s lists total under 50 MB, the client downloads every shard in the background as soon as the flags arrive.
- Otherwise it fetches a shard the first time an evaluation needs it, and keeps the most recently used ones (
:membership_cache). Evaluations that need the same shard at once share one request.
An evaluation that needs a shard the client doesn’t have yet blocks its process until the shard arrives or :membership_timeout passes (500 ms, shared by every list lookup in the evaluation). Past that, the list doesn’t match, the shard keeps loading, and the reason is :membership_pending: the value is what the flag serves without that list, and later evaluations get the full answer.
Private segments can’t be read by a browser on its own. Your backend computes a token per context kind with membership_token/2 and hands it to the browser at sign-in:
tokens = Featflags.membership_token(MyApp.Flags, %{key: user_id}) # %{"user" => "8ecc…"}
The browser passes them to identify(context, { membershipTokens }) in the TypeScript SDK. membership_token/2 is empty when the environment has no private lists, or for an invalid context.
Staying up to date
The client checks for new flags every 5 seconds with a server key (60 seconds with a client key), and only downloads them when they’ve changed. It never goes back to older flags, and if featflags can’t be reached it keeps serving the last flags it had. With :cache_path, it also starts from them after a restart while featflags can’t be reached. Featflags.version/1 says which version of the flags it’s using.
Featflags.refresh/1 fetches the flags now and Featflags.flush/1 sends counts now; both return :ok or {:error, exception}.
Experiments
To run experiments, give the client :events_url and the environment’s experiment key, xk_…. It’s shown once when you generate it; keep it with your other secrets, since featflags never holds it.
{Featflags,
name: MyApp.Flags,
key: System.fetch_env!("FEATFLAGS_KEY"),
cdn_url: "https://flags.featflags.io",
events_url: "https://events.featflags.io",
experiment_key: System.fetch_env!("FEATFLAGS_EXPERIMENT_KEY")}
on = Featflags.bool_variation(MyApp.Flags, "new-checkout", user, false) # counts as an exposure if an experiment served it
Featflags.track(MyApp.Flags, user, "checkout.completed", order.total) # an event key and an optional number
Featflags.track(MyApp.Flags, user, "page.error")
- An evaluation an experiment served is an exposure, sent once per user, variant and period.
track/4sends the event to every running experiment whose metrics count it, and nothing when none does, so tracking freely costs nothing. It doesn’t need the user to have been exposed in this process: the exposure often happened in the browser. Like evaluations, it records in the calling process and never waits on the client.- Event keys match
[a-z0-9_.:-]{1,64}, and the value is an optional number below 10¹⁵. A bad key or value is logged once and dropped, never raised;track/4always returns:ok. - Users are sent as an ID made from their key with your experiment key, different in every experiment. Nothing else about them is sent.
- After you rotate the experiment key, a deploy still holding the old one sends nothing, and logs it once.
Browsers and mobile apps shouldn’t hold the experiment key, since anything in an app is public. Give each signed-in user their own seeds instead, alongside their membership tokens:
seeds = Featflags.experiment_seeds(MyApp.Flags, user) # %{"fingerprint" => "e178…", "seeds" => %{"user" => "d457…"}}; nil without a key
%{membershipTokens: Featflags.membership_token(MyApp.Flags, user), experimentSeeds: seeds}
The client SDKs take them in identify. How units and seeds are computed is in the SDK specification.
Hooks
A hook runs around every evaluation, for logging, metrics or tracing, in the evaluating process. A hook is a module: use Featflags.Hook and override the stages you need. before_evaluation/2 runs in the order hooks were added, after_evaluation/3 in reverse, with the detail the caller gets. A stage that raises, throws or exits is logged and skipped.
defmodule MyApp.FlagTimer do
use Featflags.Hook
require Logger
@impl true
def name, do: "timer"
@impl true
def before_evaluation(_series, data), do: Map.put(data, :start, System.monotonic_time())
@impl true
def after_evaluation(series, data, detail) do
elapsed = System.convert_time_unit(System.monotonic_time() - data.start, :native, :microsecond)
Logger.info("#{series.method} #{series.flag_key}: #{detail.reason} in #{elapsed} µs")
data
end
end
{Featflags, name: MyApp.Flags, key: key, cdn_url: "https://flags.featflags.io", hooks: [MyApp.FlagTimer]}
track/4 runs no hooks. The full contract is in the SDK specification.
OpenTelemetry
The featflags_otel package adds a feature_flag.evaluation event, with OpenTelemetry’s semantic convention attributes, to the evaluating process’s current span:
{Featflags, name: MyApp.Flags, key: key, cdn_url: "https://flags.featflags.io", hooks: [Featflags.Otel.Hook]}
OpenFeature
The featflags_openfeature package is a provider for open_feature, the official OpenFeature Elixir SDK:
# The provider doesn't own the client: it stays in your supervision tree.
{:ok, _} = OpenFeature.set_provider(Featflags.OpenFeature.Provider.new(MyApp.Flags))
client = OpenFeature.get_client()
OpenFeature.Client.get_boolean_value(client, "new-checkout", false, context: %{targeting_key: "u-alice", plan: "pro"})
The targeting key is the context key. Add a kind field for another kind, or kind: "multi" with each member as a map under its kind, each with its own targeting_key. Flag metadata carries the featflags "reason", and "rule_id" and "prerequisite" when set. How reasons map to OpenFeature’s is on the OpenFeature page.
Shutting down
Stopping the client sends what’s left to send. Inside your supervision tree that happens on its own when your application shuts down. A client started outside one, in a script, stops with Featflags.stop/1.