Rust SDK

Evaluate flags in Rust services and jobs, with an on-disk cache so your service starts even when featflags can't be reached.

The featflags crate keeps a copy of your environment’s flags in your process and evaluates them there: an evaluation is a function call, and your users’ attributes never leave the service. It runs on Tokio and fetches with reqwest over rustls.

Install

cargo add featflags
cargo add featflags --features otel   # with the OpenTelemetry hook

It’s published on crates.io as featflags, for the Rust 2024 edition (Rust 1.85 or later).

Create a client

Build one client at startup, inside a Tokio runtime, and share it: every method takes &self, so put it behind an Arc.

use featflags::{Client, context};

let flags = Client::builder("https://flags.featflags.io", "sk_…")
    .events_url("https://events.featflags.io")
    .build()
    .await;

let user = context(serde_json::json!({ "key": "user-1", "plan": "pro" }));
if flags.bool("new-checkout", &user, false) {
    // …
}

Use a server key (sk_…): it reaches every flag, so keep it secret. build() waits for the first fetch, at most initial_timeout, then keeps the flags up to date in the background.

Builder method Default What it does
events_url none Where usage counts, experiment exposures and track events go: https://events.featflags.io. Without it, nothing is sent and flag pages show no evaluations.
experiment_key none The environment’s experiment key (xk_…).
poll_interval 5 s for server keys, 60 s for client keys How often it checks for changes, ±10%. Zero turns polling off.
flush_interval 60 s How often it sends counts, plus a random offset of up to one interval the first time.
initial_timeout 2 s How long build waits for the first fetch.
cache_path none A file to keep the last flags in, so the service starts while featflags is unreachable.
bootstrap none Flags (a featflags::Document) to evaluate until, or if never, the first fetch answers, such as a file featflags doc pull wrote at build time.
membership_cache 1,024 shards (about 2 MB) List shards kept in memory when a list index is too big to download whole.
membership_timeout 500 ms How long an evaluation waits for a list shard it doesn’t have.
hook none Adds a hook; call it once per hook.
on_exposure none Called the first time each user sees each variant of a flag, for your own analytics.
clock, http_client the system’s A clock for rollout steps and date conditions, and your own reqwest::Client.

Evaluate flags

flags.bool("new-checkout", &user, false);
flags.string("checkout-copy", &user, "Buy now");
flags.number("max-items", &user, 10.0);
flags.json("pricing", &user, serde_json::json!({ "tiers": [] }));

You get the fallback when the flag doesn’t exist (or is archived), the context is invalid, or the value has another type. bool_detail and detail also return why:

let d = flags.bool_detail("new-checkout", &user, false);
// d.value, d.variant (Option<String>), d.reason (featflags::Reason), d.rule_id, d.prerequisite

Reason is one of Disabled, PrerequisiteFailed, Target, Rule, Default, Fallback, NotFound, TypeMismatch, Error or MembershipPending; evaluation reasons explains each.

Contexts

context builds a context from JSON. A single context has a kind (default user), a key and any attributes your rules use; a multi-context evaluates for several kinds at once:

let user = context(json!({ "key": "user-1", "email": "ana@acme.com", "plan": "pro" }));
let org = context(json!({ "kind": "org", "key": "o-1", "plan": "team" }));
let both = context(json!({ "kind": "multi", "user": { "key": "user-1" }, "org": { "key": "o-1" } }));

Pass the context to every evaluation; one client serves every request. An invalid context gets the fallback with reason Error. See contexts.

Lists and private segments

When the lists an environment uses come to less than 50 MB in all, the client downloads them in the background as the flags arrive. Bigger ones are fetched a shard at a time when an evaluation first needs one, and the 1,024 most recently used are kept. An evaluation that needs a shard the client doesn’t have waits for it, blocking its thread up to membership_timeout (500 ms); past that, the list doesn’t match, the reason is MembershipPending, and the shard keeps loading for later evaluations.

Private segments need a token in browsers and apps. Mint it here at sign-in and hand it to the client SDK with the context:

let tokens = flags.membership_token(&user); // kind → 64 hex digits

It’s empty when the environment has no private segments.

Staying up to date

The client checks for changes every 5 seconds with a server key, and only downloads anything when something changed. When featflags can’t be reached, it keeps serving the flags it has, and it never goes back to older flags than the ones it holds.

With cache_path, it writes every fetched version to that file (atomically, so a crash never leaves half a file), and when the first fetch at startup doesn’t answer within initial_timeout, it starts from the file if it’s newer than bootstrap. That’s how a service starts during an outage. flags.version() is the version in use, and flags.refresh().await checks for changes now.

Experiments

With the environment’s experiment key, evaluations of a flag in a running experiment report which variant each user saw, and track reports what they did, under IDs only someone holding the key can link back to a person:

let flags = Client::builder("https://flags.featflags.io", "sk_…")
    .events_url("https://events.featflags.io")
    .experiment_key(std::env::var("FEATFLAGS_EXPERIMENT_KEY")?)
    .build()
    .await;

flags.track(&user, "checkout.completed", Some(order_total));
let seeds = flags.experiment_seeds(&user); // into the sign-in response, for the app's experimentSeeds

track(context, event_key, value) takes an event key matching [a-z0-9_.:-]{1,64} and an optional number below 10¹⁵. It sends an event to every running experiment whose metrics count that key and whose kind the context has, and nothing when none does, so tracking freely costs nothing. A bad key or value is logged once and dropped.

experiment_seeds gives a signed-in user’s seeds, for your backend to hand to a browser or app at sign-in, so the key never leaves your servers. It’s None without a key. A key that isn’t xk_ and 64 hex digits is logged at build, and experiments then send nothing; so does a key whose fingerprint isn’t the environment’s, after you rotate it.

Hooks

Hooks run around every evaluation, for logging, metrics or tracing. Implement featflags::Hook, whose stages default to doing nothing:

use featflags::{Detail, EvaluationSeries, Hook, SeriesData};

struct Log;

impl Hook for Log {
    fn name(&self) -> &str {
        "log"
    }

    fn after_evaluation(&self, series: &EvaluationSeries, data: SeriesData, detail: &Detail<serde_json::Value>) -> SeriesData {
        tracing::info!(flag = series.flag_key, reason = ?detail.reason);
        data
    }
}

let flags = Client::builder(cdn, key).hook(Log).build().await;

before_evaluation runs in the order hooks were added and after_evaluation in reverse, each hook’s after stage getting what its own before stage returned. A hook that panics is logged and skipped, unless your binary is built with panic = "abort". track runs no hooks. The SDK specification has the full contract.

OpenTelemetry

With the otel feature, featflags::otel::OtelHook adds a feature_flag.evaluation event to the current span, following OpenTelemetry’s semantic conventions for feature flags:

let flags = Client::builder(cdn, key).hook(featflags::otel::OtelHook).build().await;

Shutting down

flags.close().await stops the background work and sends pending counts, exposures and events. Call it before a short-lived job exits, or its last minute of counts is lost. flags.flush().await sends what’s pending without stopping; what fails to send is dropped, never retried.