This is the official Featurevisor SDK for Elixir. It evaluates feature flags, variations, and variables in Elixir applications using Featurevisor schema version 2 datafiles.
The SDK supports concurrent evaluations, structured diagnostics, lifecycle modules, child instances, and the Featurevisor project test runner.
- Installation
- Public API
- Initialization
- Supervision
- Evaluation types
- Context
- Check if enabled
- Getting variation
- Getting variables
- Getting all evaluations
- Sticky
- Setting datafile
- Diagnostics
- Events
- Evaluation details
- Modules
- Child instance
- Close
- OpenFeature
- CLI usage
- Development
- Publishing
- License
Add featurevisor to your dependencies in mix.exs:
def deps do
[
{:featurevisor, "~> 1.2"}
]
endThen install dependencies:
mix deps.getCreate instances with Featurevisor.create_featurevisor/1. The returned Featurevisor handle is the main SDK instance.
Most applications use:
Featurevisor.create_featurevisor/1Featurevisor.start_link/1andFeaturevisor.instance/1for supervisionFeaturevisor.enabled?/2Featurevisor.get_variation/2Featurevisor.get_variable/3Featurevisor.close/1Featurevisor.Modulefor extensionsFeaturevisor.Diagnosticfor observabilityFeaturevisor.Evaluationfor detailed results
The datafile and context use ordinary Elixir maps with string keys. This preserves the JSON wire format and avoids a second public reader API.
Initialize with a decoded datafile:
datafile =
"datafile.json"
|> File.read!()
|> Jason.decode!()
f = Featurevisor.create_featurevisor(%{datafile: datafile})Create one long lived instance for your application and share it between processes. Evaluation reads immutable ETS snapshots and does not queue through the instance process. The convenience constructor is not linked to the calling process, so its owner must call Featurevisor.close/1 during application shutdown.
You may pass the JSON string directly:
f = Featurevisor.create_featurevisor(%{datafile: File.read!("datafile.json")})Invalid datafiles do not replace the active datafile. They report an invalid_datafile diagnostic with the stable message Could not parse datafile.
Use a supervised instance in long running OTP applications:
children = [
{Featurevisor,
name: MyApp.Featurevisor,
datafile: datafile,
log_level: :warn}
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)Resolve its Featurevisor handle wherever it is needed:
f = Featurevisor.instance(MyApp.Featurevisor)
Featurevisor.enabled?(f, "my_feature", %{"userId" => "123"})The supervisor owns shutdown and restart. Module close callbacks run during supervised shutdown. Resolve the handle again after a restart because the new owner process creates new ETS tables.
Featurevisor evaluates four kinds of values:
- a flag answers whether a feature is enabled
- a variation returns a variation value
- a feature variable returns remote configuration owned by a feature
- a global variable returns remote configuration independently of a feature
Every evaluation uses the active datafile and the effective context.
Contexts are maps of attributes used by conditions and bucketing. Use string keys so nested paths and datafile attribute names match exactly.
Date conditions accept ISO 8601 strings with an explicit time zone and native DateTime values.
f = Featurevisor.create_featurevisor(%{
datafile: datafile,
context: %{
"userId" => "123",
"country" => "nl"
}
})Context is merged by default:
Featurevisor.set_context(f, %{"device" => "mobile"})Pass true to replace all stored context:
Featurevisor.set_context(f, %{"userId" => "456"}, true)context = %{"country" => "de"}
Featurevisor.enabled?(f, "my_feature", context)
Featurevisor.get_variation(f, "my_feature", context)
Featurevisor.get_variable(f, "my_feature", "title", context)Evaluation context wins over stored context for matching keys.
if Featurevisor.enabled?(f, "my_feature", %{"userId" => "123"}) do
# show the enabled experience
endThe idiomatic Elixir enabled?/4 function corresponds to isEnabled in the JavaScript SDK and similarly named methods in other Featurevisor SDKs.
case Featurevisor.get_variation(f, "checkout_experiment", context) do
"control" -> show_control()
"treatment" -> show_treatment()
nil -> show_fallback()
endProvide an explicit fallback when no variation is selected:
Featurevisor.get_variation(
f,
"checkout_experiment",
context,
%{default_variation_value: "control"}
)title = Featurevisor.get_variable(f, "checkout", "title", context)JSON variables are decoded before they are returned.
Global variables use explicit function names so feature ownership remains clear:
email = Featurevisor.get_global_variable(f, "supportEmail", context)
evaluation = Featurevisor.evaluate_global_variable(f, "supportEmail", context)Use a typed getter when your application wants runtime type validation:
enabled = Featurevisor.get_variable_boolean(f, "checkout", "enabled", context)
title = Featurevisor.get_variable_string(f, "checkout", "title", context)
count = Featurevisor.get_variable_integer(f, "checkout", "count", context)
ratio = Featurevisor.get_variable_double(f, "checkout", "ratio", context)
items = Featurevisor.get_variable_array(f, "checkout", "items", context)
config = Featurevisor.get_variable_object(f, "checkout", "config", context)
json = Featurevisor.get_variable_json(f, "checkout", "json", context)Typed getters return nil for a mismatched value and do not coerce strings, booleans, or collections.
features = Featurevisor.get_feature_evaluations(f, context)
variables = Featurevisor.get_global_variable_evaluations(f, context)Pass feature keys to evaluate a selected set:
features = Featurevisor.get_feature_evaluations(f, context, ["checkout", "pricing"])
variables = Featurevisor.get_global_variable_evaluations(f, context, ["supportEmail"])Sticky values keep selected evaluations stable for the lifetime of an instance or child instance.
f = Featurevisor.create_featurevisor(%{
datafile: datafile,
sticky_features: %{
"checkout" => %{
"enabled" => true,
"variation" => "treatment",
"variables" => %{"title" => "Welcome back"}
}
},
sticky_variables: %{"supportEmail" => "sticky@example.com"}
})Featurevisor.set_sticky_features(f, sticky_features)
Featurevisor.set_sticky_variables(f, sticky_variables)
Featurevisor.set_sticky_features(f, replacement, true)Sticky values are instance state. They are not accepted as public per evaluation options.
set_datafile/3 accepts a decoded map or JSON string.
Incoming features, global variables, and segments are merged into the stored datafile. Incoming entries replace entries with the same key.
Featurevisor.set_datafile(f, next_datafile)Pass true to replace the complete datafile:
Featurevisor.set_datafile(f, next_datafile, true)Use the HTTP client and scheduling tools already present in your application. Pass each successful response body to set_datafile/3. The SDK does not start a background fetch process or choose an HTTP client for you.
Diagnostics are the only SDK observability API. There is no separate logger handler.
The levels are :fatal, :error, :warn, :info, and :debug.
Featurevisor.set_log_level(f, :warn)f = Featurevisor.create_featurevisor(%{
datafile: datafile,
log_level: :warn,
on_diagnostic: fn diagnostic ->
Logger.warning("#{diagnostic.code}: #{diagnostic.message}")
end
})Every Featurevisor.Diagnostic contains level, code, message, and an always present details map. Error diagnostics also emit the :error event.
Featurevisor project linting enforces the portable regular expression subset shared by all SDKs. The Elixir runtime treats the g flag as a compatibility no op. Invalid patterns or flags produce a condition_match_error diagnostic and do not match.
Register event callbacks with Featurevisor.on/3. The returned unsubscribe function is idempotent.
unsubscribe = Featurevisor.on(f, :datafile_set, fn details ->
IO.inspect(details, label: "datafile changed")
end)
unsubscribe.()Supported events are:
:datafile_set:context_set:sticky_features_set:sticky_variables_set:error
The :datafile_set details include changed features and variables, including dependants affected by changed required features or segments.
Use detailed methods when you need reasons, rule keys, bucket values, or matched definitions:
flag = Featurevisor.evaluate_flag(f, "checkout", context)
variation = Featurevisor.evaluate_variation(f, "checkout", context)
variable = Featurevisor.evaluate_variable(f, "checkout", "title", context)
global_variable = Featurevisor.evaluate_global_variable(f, "supportEmail", context)Each method returns a Featurevisor.Evaluation struct.
Modules extend evaluation and lifecycle behaviour without changing evaluation methods.
module = %Featurevisor.Module{
name: "audit",
setup: fn api ->
IO.puts("Revision: #{api.get_revision.()}")
end,
before_evaluation: fn options ->
%{options | context: Map.put_new(options.context, "service", "checkout")}
end,
bucket_value: fn options ->
options.bucket_value
end,
after_evaluation: fn evaluation, _options ->
evaluation
end,
close: fn ->
:ok
end
}
remove = Featurevisor.add_module(f, module)
remove.()Module callbacks are setup, before, before_evaluation, bucket_key, bucket_value, after_evaluation, after, and close. For feature evaluations, all before callbacks run in registration order, followed by all before_evaluation callbacks. After evaluation and caller defaults, all after_evaluation callbacks run, followed by all after callbacks. Global variable evaluations use only before_evaluation and after_evaluation. Required feature checks run through the complete module pipeline, and transformed defaults are preserved. Callback option maps use idiomatic snake case keys such as bucket_key and bucket_value.
before and after remain available as deprecated feature-only compatibility callbacks. Use before_evaluation and after_evaluation for new modules so the same callbacks can handle feature and global variable evaluations.
Named duplicates are rejected with a duplicate_module diagnostic. A failed setup is removed, its diagnostic subscriptions are cleared, and its close callback is invoked.
A child has isolated context, sticky state, and local listeners while sharing its parent's datafile, modules, and diagnostics.
child = Featurevisor.spawn(
f,
%{"accountId" => "account-123"},
%{sticky_features: sticky_features, sticky_variables: sticky_variables}
)
Featurevisor.Child.enabled?(child, "checkout", %{"userId" => "user-456"})
Featurevisor.Child.get_variation(child, "checkout")
Featurevisor.Child.get_variable(child, "checkout", "title")
Featurevisor.Child.get_variable_string(child, "checkout", "title")
Featurevisor.Child.get_global_variable(child, "supportEmail")
Featurevisor.Child.get_feature_evaluations(child)
Featurevisor.Child.get_global_variable_evaluations(child)
Featurevisor.Child.close(child)Existing parent context keys are snapshotted when the child is created. Parent keys added later are inherited. Child context wins over parent context, and per evaluation context wins over child context.
Close an instance when its owner stops:
Featurevisor.close(f)Close invokes module close callbacks and removes listeners, diagnostic subscriptions, and caches. Calling close more than once is safe.
Do not call close/1 directly for a supervised instance. Stop its supervisor or remove its child instead. A closed handle evaluates against an empty datafile, so flags return false, variations and variables return nil, and the revision returns "unknown".
The OpenFeature provider is published as a separate Hex package. Applications that only use the Featurevisor SDK do not install or compile OpenFeature or the provider code.
def deps do
[
{:featurevisor, "~> 1.2"},
{:featurevisor_openfeature, "~> 1.2"},
{:open_feature, "~> 0.1.3"}
]
endCreate a provider that owns its Featurevisor instance:
provider =
FeaturevisorOpenFeature.Provider.new(
featurevisor_options: %{datafile: datafile}
)
{:ok, provider} = OpenFeature.set_provider(provider)
client = OpenFeature.get_client()
enabled =
OpenFeature.Client.get_boolean_value(client, "checkout", false,
context: %{"targetingKey" => "user-123"}
)You can also pass an existing Featurevisor instance. The provider borrows it and does not close it:
provider = FeaturevisorOpenFeature.Provider.new(featurevisor: f)OpenFeature uses one flag key while Featurevisor supports flags, variations, feature variables, and global variables:
| OpenFeature key | Featurevisor evaluation |
|---|---|
checkout |
Flag for feature checkout |
checkout:variation |
Variation for feature checkout |
checkout:title |
Variable title inside feature checkout |
variable:supportEmail |
Global variable supportEmail |
targeting_key_field, key_separator, variation_key, and
global_variable_prefix customize this mapping. The targeting key maps to
userId by default. The global variable prefix defaults to variable and
cannot contain the separator.
The official OpenFeature Elixir SDK represents evaluation context as a map. The
provider accepts "targetingKey", "targeting_key", or :targeting_key and
copies its string value to the configured Featurevisor targeting field.
The provider implements boolean, string, number, and map resolution. Featurevisor evaluation reasons, variation values, revision, schema version, rule keys, bucket information, and override information are mapped to OpenFeature resolution details and flag metadata. Missing definitions, type mismatches, and invalid datafiles use standard OpenFeature errors. Replacing an invalid datafile with a valid one recovers the provider.
The OpenFeature Elixir SDK does not currently expose provider tracking. Featurevisor modules and diagnostics continue to run inside the Featurevisor instance.
See the OpenFeature provider guide for the shared key convention and providers for other languages.
Build the escript from this repository:
mix escript.buildThe Elixir runner delegates project parsing, Target discovery, matrix expansion, and datafile generation to npx featurevisor. Evaluations and assertions run through this Elixir SDK.
./featurevisor test \
--projectDirectoryPath=../featurevisor/examples/example-1 \
--onlyFailuresUse one or more Targets when required:
./featurevisor test \
--projectDirectoryPath=../featurevisor/examples/example-1 \
--target=all \
--target=checkout./featurevisor benchmark \
--projectDirectoryPath=../featurevisor/examples/example-1 \
--environment=production \
--feature=allowSignup \
--variation \
--context='{"country":"nl"}' \
--n=1000000Benchmark output reports total duration and the minimum, average, and maximum duration of individual evaluations.
Pass --variable=supportEmail without --feature to benchmark a global variable.
./featurevisor assess-distribution \
--projectDirectoryPath=../featurevisor/examples/example-1 \
--environment=production \
--feature=allowSignup \
--context='{"country":"nl"}' \
--populateUuid=userId \
--n=10000Repeat --target and --populateUuid where needed.
Install dependencies and run the complete local gate:
mix deps.get
make checkRun the SDK against the sibling Featurevisor example project:
make test-example-1The integration target executes all expanded example-1 assertions, including Target datafiles, through the Elixir evaluator.
make check verifies both the base SDK and OpenFeature provider. The separate make test-example-1 target requires the sibling Featurevisor monorepo, so checks and publishing workflows run that integration explicitly after the package gate.
Before tagging a release:
make check
mix hex.publish --dry-runThe repository publishes featurevisor and featurevisor_openfeature with the same version. The release workflow verifies the tag, package contents, documentation, and example-1 integration, then publishes the base package before the provider package.
Hex can only resolve the provider's exact base package dependency after the
matching featurevisor release is visible. The workflow waits for that release
before it performs the final provider dry run and publication.
The published Hex package intentionally includes runtime source, mix.exs, the README, and the licence. Tests and the conformance fixture remain repository verification assets and are not shipped to consumers.
MIT © Fahad Heylaal