Skip to content

About

Rust SDK for Featurevisor

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Featurevisor Rust SDK

Use the Featurevisor Rust SDK to evaluate flags, variations and variables in Rust applications.

The library is synchronous, thread safe, and compatible with Rust 1.74 or newer.

Table of contents

Installation

Upgrading an existing application? Follow the Rust migration guide.

Add the SDK to Cargo.toml:

[dependencies]
featurevisor = "2.0"

On Rust 1.74, run cargo update -p psm --precise 0.1.26 if dependency resolution selects a psm release that requires a newer compiler.

The optional command line runner is available with the cli feature:

featurevisor = { version = "2.0", features = ["cli"] }

Initialization

Read a built datafile from featurevisor-all.json in your working directory:

use featurevisor::{context, create_featurevisor, DatafileInput, FeaturevisorOptions};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let datafile = std::fs::read_to_string("featurevisor-all.json")?;
    let f = create_featurevisor(FeaturevisorOptions {
        datafile: Some(DatafileInput::Json(datafile)),
        ..Default::default()
    });
    let context = context! { "userId" => "123" };
    println!("{}", f.is_enabled("checkout", Some(&context)));
    f.close();
    Ok(())
}

File read errors propagate from main. This example closes the instance after evaluation. In a long running application, keep it alive for subsequent evaluations and close it during shutdown.

Initialization also accepts DatafileInput::Content(datafile_content). Invalid datafiles are rejected without replacing the previous datafile and report a diagnostic with the message Could not parse datafile.

Public API

The main API is create_featurevisor. Most applications need that function, the Featurevisor instance, FeaturevisorOptions, and the datafile types.

use featurevisor::{create_featurevisor, FeaturevisorOptions};

let f = create_featurevisor(FeaturevisorOptions::default());

Featurevisor is cheaply cloneable and can be shared between threads. Accepted state is published atomically and callbacks run outside mutation locks. A definition already selected can finish using it, while later dependency reads can observe datafile updates made by callbacks. An evaluation is not a transaction pinned to one datafile version.

Evaluation types

Featurevisor evaluates four kinds of values:

  • Flag (bool): whether the feature is enabled or not
  • Variation (String): the variation of the feature (if any)
  • Feature variable: a configuration value owned by a feature
  • Global variable: a configuration value evaluated independently of a feature

Every evaluation uses the active datafile and the effective context.

Context

Contexts are maps of attributes used by conditions and bucketing. AttributeValue supports strings, integers, doubles, booleans, dates, arrays, objects, and null.

Conditions compare these attributes using the operators, including empty and notEmpty.

Date comparisons accept portable timestamp strings and AttributeValue::Date instants. Offsets must stay within 14 hours and both local and UTC years within 0001 to 9999. Fractions are truncated to milliseconds. Native leap seconds are not accepted.

use featurevisor::{context, AttributeValue, Context};

let context: Context = context! {
    "userId" => "123",
    "country" => "nl",
    "isEmployee" => false,
};

Setting initial context

let f = featurevisor::create_featurevisor(featurevisor::FeaturevisorOptions {
    context: Some(context.clone()),
    ..Default::default()
});

Setting after initialization

f.set_context(context.clone(), false);

The default is to merge the supplied attributes with the stored context.

Replacing existing context

f.set_context(context.clone(), true);

Manually passing context

let is_enabled = f.is_enabled("checkout", Some(&context));
let variation = f.get_variation("checkout", Some(&context), None);
let value = f.get_variable("checkout", "bgColor", Some(&context), None);

Context passed for one evaluation wins over stored context for matching keys.

Check if enabled

let is_enabled = f.is_enabled("checkout", None);
if is_enabled {
    // show the enabled experience
}

Getting variation

if let Some(variation) = f.get_variation("checkout", None, None) {
    println!("variation: {variation}");
}

To provide a fallback when no variation is selected:

use featurevisor::OverrideOptions;

let variation = f.get_variation(
    "checkout",
    None,
    Some(&OverrideOptions {
        default_variation_value: Some("control".to_string()),
        ..Default::default()
    }),
);

Getting variables

Getting feature variables

let value = f.get_variable("checkout", "bgColor", None, None);

Getting global variables

Global variables use separate method names because Rust does not overload functions:

let email = f.get_global_variable("supportEmail", None, None);
let evaluation = f.evaluate_global_variable("supportEmail", None, None);

Type specific methods

The SDK also provides typed getters:

let enabled = f.get_variable_boolean("checkout", "enabled", None, None);
let title = f.get_variable_string("checkout", "title", None, None);
let count = f.get_variable_integer("checkout", "count", None, None);
let ratio = f.get_variable_double("checkout", "ratio", None, None);
let items = f.get_variable_array("checkout", "items", None, None);
let object = f.get_variable_object("checkout", "object", None, None);
let json = f.get_variable_json("checkout", "json", None, None);

Typed getters do not coerce strings, booleans, or unrelated collections. Integer getters accept finite whole numbers written with a decimal point, such as 1.0.

Conditions and overrides

Conditions and overrides are applied for you. See Conditions and Variables for authoring guidance. Ordinary getters keep the same arguments.

All selectors share one engine: segments, feature and global variable root gates, force entries, traffic rules and variable overrides. Leaves compare attributes, reference a segment by its literal key, check a feature getter, or compare a variable getter. Variable leaves can name a feature owner and a compiled path array. A feature check defaults to enabled, with an optional variation.

{
  "and": [
    "customers",
    { "feature": "checkout", "enabled": true },
    { "variable": "deliveryPolicy", "path": ["limit"], "operator": "greaterThan", "value": 0 }
  ]
}

Arrays imply AND. not negates the AND of its direct children. Evaluation preserves order and stops when the result is known. New empty expressions are false. Feature evaluation checks sticky state, force, the root gate, then traffic. Global variables check sticky state and the root gate before sources or overrides. An unmet root reports conditions_unmet and its normalized expression in Evaluation.conditions. Existing variation and feature variable disabled value policies still apply.

Modules should return the same result for the same input during one evaluation. Pass values that change through context.

To see which overrides changed a value, inspect variable_override_paths in the detailed evaluation:

let evaluation = f.evaluate_global_variable("runtimePolicy", None, None);
let paths = evaluation.variable_override_paths;

Composed variables

Compositions use the normal global variable getter:

let screen = f.get_global_variable("screen", None, None);
let policy = f.get_global_variable("policyAlias", None, None);

A compiled map composition declares a nonempty list of getter references. Each key becomes a literal property in a fresh outer object:

{
  "type": "object",
  "compose": [
    { "key": "enabled", "feature": "checkout" },
    { "key": "layout", "feature": "checkout", "variation": true },
    { "key": "label", "feature": "checkout", "variable": "label" },
    { "key": "policy", "variable": "deliveryPolicy" }
  ]
}

The alias form uses a literal global variable key, such as "compose": "deliveryPolicy", and returns that source value directly. Sources use ordinary getter behaviour, effective context, sticky values and modules. False, zero, empty strings, arrays and null remain values. JSON sources are decoded exactly once, even when the decoded value resembles JSON. An explicit default on the outer getter is not passed to sources.

Compositions have no defaultValue or useDefaultWhenDisabled. Optional root conditions run before all sources. A missed gate skips sources and overrides and returns disabledValue, or null when absent. Whole variable sticky state bypasses sources and overrides. All members are read before the existing override program runs. Mutations do not change sources, sibling properties or sticky objects. Rust returns owned values. Composition memo entries share their internal source values instead of copying large results merely for caching.

Detailed results report variable_composed, or variable_override_rule when an override matches. Cycles, more than 256 internal dependency handoffs and invalid compositions fail the whole evaluation with reason error, without returning a partial composition. Ordinary getters return their usual false or absent value on failure. An ordinary completed false or null source result is not a dependency error. Request state is released on every exit.

SDK dependency failures are propagated as values, not panics. They do not invoke the panic hook or write to standard error, including with panic = "abort". Configured diagnostic handlers and error event listeners still receive evaluation errors. Panics from application callbacks follow the application's panic policy: unwinding panics are caught around callbacks, but aborting panics cannot be recovered.

Getting evaluations

let features = f.get_feature_evaluations(None, &[], None);
let variables = f.get_global_variable_evaluations(None, &[], None);

Pass a list of keys to limit either result. An empty list evaluates every entity of that kind in the datafile.

Sticky

Sticky values keep selected evaluations stable for the lifetime of an instance or child instance.

Treat sticky values supplied at creation or through setters as read only inputs. Use a setter to change sticky state. Returned evaluation values can be used independently.

Initialize with sticky

use std::collections::HashMap;
use featurevisor::{EvaluatedFeature, FeaturevisorOptions};

let mut sticky = HashMap::new();
sticky.insert("checkout".to_string(), EvaluatedFeature {
    enabled: true,
    variation: Some("treatment".to_string()),
    variables: None,
});

let f = featurevisor::create_featurevisor(FeaturevisorOptions {
    sticky_features: Some(sticky),
    sticky_variables: Some(HashMap::from([
        ("supportEmail".to_string(), "sticky@example.com".into()),
    ])),
    ..Default::default()
});

Set sticky afterwards

f.set_sticky_features(HashMap::new(), false);
f.set_sticky_variables(HashMap::new(), false);
f.set_sticky_features(HashMap::new(), true); // replace all sticky feature values

Setting datafile

set_datafile returns true when the datafile is accepted, including when its revision and contents are unchanged. It returns false when the input is rejected or the instance is closed. Accepted input still emits datafile_set, even when unchanged. Diagnostic and event callback failures do not change the return value.

Invalid input retains the last valid datafile and reports the existing error diagnostic without emitting datafile_set. The return value is a bool, not a Result. Rejection details remain available through diagnostics and the error event. Closed instances do not emit diagnostics or events for attempted updates.

Merging by default

set_datafile merges incoming features, global variables, and segments into the stored datafile. Incoming entries replace entries with the same key.

New generated datafiles use schema version "4". Schema "2" selectors normalize eagerly before merging, retaining their informational version metadata. Schema 4 entities normalize lazily on first read. A partial update invalidates only incoming entity caches. Replacement clears them all. Caller data is never modified. Compatible legacy selectors are converted to conditions rather than evaluated by a separate runtime.

let accepted = f.set_datafile(featurevisor::DatafileInput::Json(datafile_json), false);

Replacing

Pass true to replace the complete stored datafile:

let accepted = f.set_datafile(featurevisor::DatafileInput::Json(datafile_json), true); // replace

Loading datafiles on demand

Fetch a datafile using the HTTP client used by your application, then pass its body as DatafileInput::Json.

Updating datafile

Call set_datafile whenever your application receives a newer datafile. A datafile_set event contains changed feature and global variable keys, including transitive dependants affected by conditions, segments or composition sources, plus revision information. Both the previous and next dependency graphs participate, including added and removed references.

Interval based update

The SDK does not start a background timer. Use your application's scheduler to fetch and set datafiles at the interval that suits your service.

Diagnostics

Diagnostics are the SDK observability API.

Levels

The levels are fatal, error, warn, info, and debug. Set the threshold with set_log_level or FeaturevisorOptions::log_level.

Handler

use std::sync::Arc;
use featurevisor::{create_featurevisor, Diagnostic, FeaturevisorOptions, LogLevel};

let f = create_featurevisor(FeaturevisorOptions {
    log_level: Some(LogLevel::Warn),
    on_diagnostic: Some(Arc::new(|diagnostic: &Diagnostic| {
        eprintln!("{}: {}", diagnostic.code, diagnostic.message);
    })),
    ..Default::default()
});

Regular expression conditions use Rust's regex crate and therefore support Featurevisor's portable regular expression subset. Lookaround and backreferences cannot be evaluated by this engine. They produce a condition_match_error diagnostic instead of being evaluated, so lint the project before shipping its datafiles.

Module diagnostics can be subscribed to through ModuleApi. Diagnostic details are always an object, and error diagnostics also emit the error event.

Malformed stringified selector JSON fails that selector, including through negated segment references, without rejecting the datafile or disabling unrelated entities. It reports conditions_parse_error at error level once per selector per incoming entity load. Details contain exactly conditions, entityType and entityKey. Schema 2 loading reports after the accepted revision is stored. Schema 4 reports on first read, after caching the normalized entity. Callbacks run without internal locks and can safely reenter the instance.

Events

Register an event callback with f.on(event_name, callback). The returned unsubscribe function is safe to call more than once.

datafile_set

Emitted after a valid datafile is stored. Details include revision, previousRevision, revisionChanged, features, variables, and replaced.

context_set

Emitted after context is merged or replaced. Details include context and replaced.

sticky_features_set and sticky_variables_set

sticky_features_set is emitted after sticky features are merged or replaced. Details include features and replaced.

sticky_variables_set is emitted after sticky global variables are merged or replaced. Its details include variables and replaced.

error

Emitted for error diagnostics. The details include the complete diagnostic.

Evaluation details

Use the detailed methods when you need the reason and rule information:

let evaluation = f.evaluate_flag("checkout", None);
println!("{evaluation:?}");

let variation_evaluation = f.evaluate_variation("checkout", None, None);
let variable_evaluation = f.evaluate_variable("checkout", "bgColor", None, None);
let global_evaluation = f.evaluate_global_variable("supportEmail", None, None);

An evaluation includes the type, feature key, reason, and when applicable the bucket key, bucket value, rule, traffic, variation, variable, or diagnostic error.

Root misses include conditions and reason conditions_unmet. Global composition reports variable_composed. Override provenance uses variable_override_index, variable_override_key, variable_override_path and variable_override_paths. Their JSON names use camel case.

Modules

The before evaluation and after evaluation phases each use the module membership at the start of that phase. Where an evaluation uses bucketing, the bucket key and bucket value phases each use their own starting membership. Adding or removing a module during a phase does not change the iteration already in progress. The next phase uses the membership then installed. A module added before the after phase can receive after callbacks for evaluations already in progress, even if it did not receive their before callbacks. A module removed before the after phase receives no after callback.

Modules extend evaluation and lifecycle behaviour without changing the public evaluation methods.

Defining a module

use featurevisor::{ConfigureBucketValueOptions, FeaturevisorModule, ModuleApi};

struct AuditModule;

impl FeaturevisorModule for AuditModule {
    fn name(&self) -> Option<&str> { Some("audit") }

    fn setup(&self, api: &ModuleApi) {
        println!("datafile revision: {}", api.get_revision());
    }

    fn bucket_value(&self, options: ConfigureBucketValueOptions) -> u32 {
        options.bucket_value
    }

    fn close(&self) {
        println!("module closed");
    }
}

Registering modules

use std::sync::Arc;

let f = featurevisor::create_featurevisor(featurevisor::FeaturevisorOptions {
    modules: vec![Arc::new(AuditModule)],
    ..Default::default()
});

let unsubscribe = f.add_module(Arc::new(AuditModule));
f.remove_module("audit");
drop(unsubscribe);

All before_evaluation callbacks run in registration order before evaluation. Caller defaults are applied next, followed by all after_evaluation callbacks in registration order. Condition and composition getters use the same module pipeline. Bucket key and bucket value callbacks run during feature bucketing. Duplicate module names are reported and ignored.

EvaluateOptions.dependency_of and dependency_purpose are None for an application call. An internal read identifies the immediate requesting evaluation through evaluation_type, optional feature_key and optional variable_key. Its purpose is DependencyPurpose::Condition or DependencyPurpose::Composition. Tracking modules can distinguish eligibility checks from consumed source values instead of dropping every dependency read. Memoization separates purposes, requesters, context and state, and lasts only for the current public call. Callbacks never receive the private session.

Evaluation remains synchronous on the calling thread at every dependency depth. Module callbacks retain thread identity and thread local context. Deep getter and segment chains use stacker to grow the stack on that same thread when necessary, without spawning worker threads or changing the 256 handoff bound. Stack growth depends on the native platform support provided by stacker. WebAssembly is unsupported because stack growth is unavailable there and deep dependency chains can trap. Modules implement Send + Sync because applications can share instances between threads. Callbacks run outside internal mutation locks.

Child instance

A child keeps its own context, sticky values, and listeners while evaluating through the parent's datafile and modules:

let child = f.spawn(context! { "userId" => "child-1" }, Default::default());
let enabled = child.is_enabled("checkout", None);
child.set_context(context! { "country" => "nl" }, false);
child.close();

The child snapshots parent context keys available at creation. Parent keys added later are inherited, while child keys win. Close the child when it is no longer needed.

Close

f.close();

Close is idempotent. It closes modules, clears diagnostic subscriptions and event listeners, releases parsed condition and regex caches, and makes later state changes no ops. The instance diagnostic handler remains available for module shutdown errors and is then released.

Call close() when you finish using an instance, and child.close() when you finish using a child. This is especially important when your callbacks capture their own instance or child: Rust reference counting does not collect ownership cycles. Dropping a cleanup function does not unsubscribe it. Call the function to unsubscribe. Cleanup functions use weak references to their SDK owner, so retaining one does not by itself keep that owner alive.

Closing does not invalidate evaluations already in progress. Their datafile snapshots are released when those evaluations finish. Datafile and context values remain owned by any instance handles you retain. Closing is not a promise to return every allocation to the operating system immediately.

OpenFeature

The OpenFeature provider is published as a separate crate. Applications that only use the Featurevisor SDK do not compile or link OpenFeature, Tokio, or the provider code.

The provider supports the official OpenFeature Rust SDK 0.3.0 and requires Rust 1.80.1 or newer. The base Featurevisor crate continues to support Rust 1.74 or newer.

[dependencies]
featurevisor = "2.0"
featurevisor-openfeature = "2.0"
open-feature = "0.3"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

Create a provider that owns its Featurevisor instance:

use featurevisor::{DatafileInput, FeaturevisorOptions};
use featurevisor_openfeature::{FeaturevisorProvider, FeaturevisorProviderOptions};
use open_feature::{EvaluationContext, OpenFeature};

async fn configure(datafile: String) -> Result<(), Box<dyn std::error::Error>> {
    let provider = FeaturevisorProvider::new(FeaturevisorProviderOptions {
        featurevisor_options: FeaturevisorOptions {
            datafile: Some(DatafileInput::Json(datafile)),
            ..Default::default()
        },
        ..Default::default()
    })?;

    let mut api = OpenFeature::singleton_mut().await;
    api.set_provider(provider).await;
    let client = api.create_client();
    drop(api);

    let enabled = client
        .get_bool_value(
            "checkout",
            Some(&EvaluationContext::default().with_targeting_key("user-123")),
            None,
        )
        .await?;

    println!("Checkout enabled: {enabled}");
    Ok(())
}

Pass an existing Featurevisor instance for the provider to borrow. The provider does not close it:

use featurevisor_openfeature::FeaturevisorProvider;

let provider = FeaturevisorProvider::from_featurevisor(f.clone())?;

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 provider implements boolean, integer, float, string, and structure resolution. The OpenFeature Rust SDK represents top level object values with StructValue. Arrays can be nested in those objects, but its provider contract does not expose a separate top level array resolver.

The upstream structure value type cannot represent null. Resolving a null structure reports a type mismatch so the OpenFeature client can use its supplied default. Null object properties and array members are omitted during structure conversion, including in composed values.

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, invalid contexts, and invalid datafiles use standard OpenFeature errors. Replacing an invalid datafile with a valid one recovers the provider.

An unmet root condition keeps the Featurevisor reason conditions_unmet and maps to OpenFeature DISABLED. Composed values map to TARGETING_MATCH, including matched composed overrides. The provider does not decode composed JSON values again.

The OpenFeature Rust SDK does not currently expose provider tracking or provider event callbacks. Featurevisor modules and diagnostics continue to work inside the Featurevisor instance.

The provider status accessor reports Error for an invalid initial datafile, independently of the diagnostic log level, and Ready after a valid update. The upstream Rust initialization method has no error return value. Evaluations report ParseError while the datafile is unhealthy. Borrowed instances remain owned by the caller.

See the OpenFeature provider guide for the shared key convention and providers for other languages.

CLI usage

Install the optional CLI with Cargo:

cargo install featurevisor --version "^2.0" --features cli

The native test runner requires @featurevisor/cli 4.0.0 or later and Node.js 24 or later. Install the Node.js CLI in your Featurevisor project:

npm install --save-dev @featurevisor/cli@^4.0.0

Project parsing, test definitions, execution protocol, matrix expansion and datafile generation use npx featurevisor. Evaluations run through this Rust SDK. Use the Node.js CLI directly for authoring commands. This native CLI provides only test, benchmark and distribution commands. The SDK runtime has no Node.js dependency.

Options use kebab case, such as --project-directory-path. The camelCase forms from earlier releases still work, and --root-directory-path is an alias for --project-directory-path. Pass values as --name=value or --name value. Repeat --target to select several targets in order. Run <command> --help to list every option.

Duplicate targets are evaluated once, at their first position.

Test

featurevisor test \
  --project-directory-path=/path/to/featurevisor-project \
  --only-failures

See Testing for authoring specs and Checks for check syntax and operators.

Useful test options:

  • --key-pattern=<pattern> and --assertion-pattern=<pattern> filter tests.
  • --entity-type=scenario selects scenarios. --set=<set> selects one project set. Omitting --set runs all sets.
  • --case=<id> reruns one expanded case. Copy the opaque ID or rerun command from a failure report.
  • --json --pretty produces an indented JSON report.
  • --coverage reports observed rule and override selections. --uncovered lists features and global variables without associated specs.
  • --only-failures, --quiet, --verbose and --show-datafile control diagnostic output.

Benchmark

The examples below use a project without environments. Add --environment=<name> if your project configures environments. In a sets project, omit --set to run every set or pass --set=<name>. Repeat --target=<key> to evaluate specific target datafiles. The Node.js CLI validates these options and builds the datafiles. Rust evaluates them. Replace checkout with a feature defined in your project.

featurevisor benchmark \
  --project-directory-path=/path/to/featurevisor-project \
  --feature=checkout \
  --context='{"country":"nl"}' \
  --n=1000

Use --variation for a variation or --variable=<key> for a feature variable. Omit --feature to benchmark a global variable. Do not combine --variation with --variable. See Benchmarking.

Assess distribution

featurevisor assess-distribution \
  --project-directory-path=/path/to/featurevisor-project \
  --feature=checkout \
  --populate-uuid=userId \
  --n=1000

Repeat --populate-uuid=<key> or --populate-uuid <key> to generate UUID values for multiple context attributes. Add --verbose to print generated contexts. Both measurement commands accept --n=1000, --n 1000, -n=1000 and -n 1000. Counts must be positive integers. Quote JSON context and paths containing spaces as one shell argument. See Assess distribution.

The legacy --with-scopes, --with-tags, --schemaVersion and --schema-version options are accepted and ignored.

GitHub repositories

See Overrides and requirements.

The aggregate evaluation reference has moved to Getting evaluations.

Development of this package

The base SDK supports Rust 1.74 or newer. The complete workspace requires Rust 1.80.1 or newer because it includes the OpenFeature provider. With a current stable toolchain, run:

make check
make test-example-1

The package uses cargo fmt, cargo clippy, and cargo test. Cargo.lock is committed so library, CLI, and provider dependency resolution stays reproducible.

The native tests cover runtime conformance and project integration.

Run cargo test --offline -p featurevisor --test readme_initialization to compile and execute the actual initialization snippet in an isolated consumer using cached dependencies. The check also runs in the ordinary SDK suite and CI.

The repository publishes two crates with the same version:

  • featurevisor
  • featurevisor-openfeature

To release, update both versions, run the checks, merge the change, and tag the matching version. The release workflow publishes the base crate first and the provider second so the provider's exact Featurevisor dependency is available on crates.io.

Cargo can only package the provider after that exact base crate version is visible on crates.io. Pull request checks therefore build, test, lint, and document the provider. The tagged release performs the final provider package inspection after publishing the base crate.

License

MIT © Fahad Heylaal

About

Rust SDK for Featurevisor

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages