.NET SDK
Evaluate flags inside your .NET services, with the base class library only, sync and async getters, and an on-disk cache for starting while offline.
The .NET SDK keeps a copy of your environment’s flags in memory and evaluates them in your process, so a flag check never waits for the network and your users’ attributes never leave your service. It targets .NET 8 or newer and depends on nothing beyond the base class library (System.Text.Json, HttpClient).
Install
dotnet add package FeatFlags
dotnet add package FeatFlags.OpenFeature # the OpenFeature provider
The packages are FeatFlags and FeatFlags.OpenFeature on NuGet. The OpenTelemetry hook is in the core package.
Create a client
Create one client per environment key and share it: it’s safe to use from many threads, so register it as a singleton in dependency injection. On a server, use the environment’s server key (sk_…), which you keep secret.
using FeatFlags;
await using var flags = await FeatFlagsClient.CreateAsync(new FeatFlagsOptions
{
Key = "sk_…",
CdnUrl = "https://flags.featflags.io",
EventsUrl = "https://events.featflags.io",
});
CreateAsync fetches the first copy of your flags, bounded by Timeout; new FeatFlagsClient(options) does the same, blocking. If that fetch fails, the client starts anyway: it serves the cached copy (CachePath), the Bootstrap document, or your fallbacks with Reason.NotFound, until a poll succeeds. Version tells you which version of your flags is in use.
FeatFlagsOptions |
Default | What |
|---|---|---|
Key, CdnUrl |
required | The environment’s key and https://flags.featflags.io. |
EventsUrl |
off | https://events.featflags.io. Without it the SDK sends nothing: no evaluation counts, no experiment data. |
PollInterval |
5 s for sk_… keys, 60 s otherwise |
How often it checks for changes, ±10% jitter. TimeSpan.Zero turns polling off. |
FlushInterval |
60 s | How often it sends evaluation counts and experiment data. The first send waits up to one extra interval at random. |
OnExposure |
none | Called with an Exposure the first time each (context key, flag, variant) is evaluated, to forward to your own analytics. |
Hooks |
none | Run around every evaluation: see Hooks. |
Bootstrap |
none | A Document (Document.Parse(json)) to evaluate against until the first fetch answers. |
CachePath |
none | A file to keep the last copy of your flags in. When the first fetch fails, the client starts from it if it’s newer than Bootstrap. |
Timeout |
10 s | HTTP timeout. |
Clock |
system clock | Epoch milliseconds, for scheduled rollout steps; tests pin it. |
MembershipCache |
1024 | List shards kept in memory (about 2 MB) when lists are too big to download whole. |
MembershipTimeout |
500 ms | How long one evaluation waits for list shards it doesn’t have yet. |
ExperimentKey |
none | The environment’s experiment key, xk_…: see Experiments. |
Log |
standard error | Where warnings go, one line each: Log = m => logger.LogWarning("{Message}", m). |
Evaluate flags
var user = Context.Of(new Dictionary<string, object?> { ["key"] = "u-alice", ["plan"] = "pro" });
if (await flags.BoolVariationAsync("new-checkout", user, false))
{
// …
}
var d = flags.StringVariationDetail("pricing-page", user, "v1");
logger.LogInformation("pricing-page: {Value} ({Reason}, rule {RuleId})", d.Value, d.Reason.AsString(), d.RuleId);
BoolVariation, StringVariation, DoubleVariation and JsonVariation return the flag’s value, or your fallback when the flag doesn’t exist, the context is invalid, or the value has another type. The …Detail variants return a Detail<T> with Value, Variant and the reason: RuleId with Reason.Rule, Prerequisite with Reason.PrerequisiteFailed. AsString() gives the reason’s name as other SDKs spell it (prerequisite_failed). DoubleVariation serves any number; JsonVariation takes and returns a JsonElement.
Every getter has an …Async twin (BoolVariationAsync, BoolVariationDetailAsync, …) taking a CancellationToken. They only differ when an evaluation needs a list shard the client doesn’t have yet: the sync getter blocks its thread until it arrives, the async one awaits it. Otherwise both evaluate the copy in memory and complete at once.
Contexts
A context is who or what you’re evaluating for, in LaunchDarkly’s shape. Context.Of takes it as a dictionary, Context.FromJson(JsonElement) as JSON:
Context.Of(new Dictionary<string, object?> { ["key"] = "u-alice", ["email"] = "alice@acme.com" }); // a user, the default kind
Context.Of(new Dictionary<string, object?> { ["kind"] = "org", ["key"] = "acme", ["plan"] = "team" }); // another kind
Context.Of(new Dictionary<string, object?> // several at once
{
["kind"] = "multi",
["user"] = new Dictionary<string, object?> { ["key"] = "u-alice" },
["org"] = new Dictionary<string, object?> { ["key"] = "acme", ["plan"] = "team" },
});
Attribute values are strings, numbers (any numeric type; BigInteger keeps integer keys of any size exact for percentage rollouts), booleans, JsonElements, or sequences of those; null, dictionaries, nested sequences, NaN, infinities and other objects count as missing. Pass instants as RFC 3339 strings or epoch milliseconds. A Context is immutable, so build it once to evaluate many flags; IsValid and Key(kind) inspect it. An invalid context (no key, a bad kind) gets your fallback with Reason.Error. Concepts has more on kinds.
Lists and private segments
Targets and segment lists of 64 keys or fewer travel inside your flags. Longer ones, up to millions of keys, are split into small shards the SDK fetches separately:
- When all of an environment’s lists total under 50 MB, the client downloads every shard in the background as soon as your flags arrive.
- Otherwise it fetches a shard the first time an evaluation needs it and keeps the most recently used ones (
MembershipCache).
An evaluation that needs a shard the client doesn’t have yet waits until the shard arrives or MembershipTimeout passes (500 ms, shared by the whole evaluation): the sync getters block their thread, the async ones don’t. Past that, the list doesn’t match, the shard keeps loading for later evaluations, and the reason is Reason.MembershipPending: the value is what the flag serves without that list. A failed shard fetch reports the same and is retried by the next evaluation that needs it.
Private segments can’t be read in a browser without a token from your backend, so nobody can list who’s on them. Compute the tokens at sign-in and hand them to the browser:
var tokens = flags.MembershipToken(user); // {user: 8ecc…}
The TypeScript SDK takes them as identify(ctx, { membershipTokens: tokens }). MembershipToken is empty when the environment has no private lists, or for an invalid context.
Staying up to date
The client checks for changes every 5 seconds with a server key, so your servers have a change within about 5 seconds. Nothing is downloaded unless something changed. It never goes back to an older version of your flags, and when a check fails it keeps serving the last good copy, so a featflags outage leaves your flags as they were. With CachePath, a service that restarts while featflags can’t be reached starts from the last copy it saw instead of your fallbacks. RefreshAsync() checks now and throws on failure.
Regex conditions run on the SDK’s own matcher, never on System.Text.RegularExpressions, so a pattern can’t make an evaluation slow: time grows linearly with the attribute’s length.
Experiments
With EventsUrl set and the environment’s experiment key, the client reports what experiments need: which variant each unit saw, and the events you track. Keep the key with your other secrets; featflags never holds it.
await using var flags = await FeatFlagsClient.CreateAsync(new FeatFlagsOptions
{
Key = "sk_…",
CdnUrl = "https://flags.featflags.io",
EventsUrl = "https://events.featflags.io",
ExperimentKey = Environment.GetEnvironmentVariable("FEATFLAGS_EXPERIMENT_KEY"),
});
var on = flags.BoolVariation("new-checkout", user, false); // counts as an exposure if an experiment's audience served it
flags.Track(user, "checkout.completed", order.Total); // an event key and an optional number; nothing else
flags.Track(user, "page.error");
Track sends an event to every running experiment whose metrics count that event key, and nothing when none does, so tracking freely costs nothing. It doesn’t check that the user was exposed here: a conversion often happens on your server while the exposure happened in the browser. Event keys match [a-z0-9_.:-]{1,64}; a bad key or value (NaN, an infinity, 1e15 or more) is logged once and dropped, never thrown. Experiment data goes out with the evaluation counts, and only while the key matches the environment’s: after you rotate it, a deploy with the old key sends nothing and logs why.
Browsers and mobile apps get seeds instead of the key, which would be public in an app. Hand them over at sign-in, with the membership tokens:
var seeds = flags.ExperimentSeeds(user); // null without an experiment key
return Results.Json(new { membershipTokens = flags.MembershipToken(user), experimentSeeds = seeds });
ExperimentSeeds serializes with System.Text.Json to what the client SDKs take as experimentSeeds when they identify the user. Seeds carry the key’s fingerprint, so after a rotation an app sends nothing until it gets new ones. How units are computed.
Hooks
A hook runs around every evaluation, for logging, metrics or tracing: BeforeEvaluation in the order you registered hooks, AfterEvaluation in reverse, with the detail the caller gets. Subclass FeatFlags.Hook; both stages pass data through unless you override them. Stages run synchronously on the evaluating thread, for the async getters too. A stage that throws is logged and skipped.
sealed class Timer : Hook
{
public override string Name => "timer";
public override IDictionary<string, object?> BeforeEvaluation(EvaluationSeries series, IDictionary<string, object?> data)
{
data["start"] = Stopwatch.GetTimestamp();
return data;
}
public override IDictionary<string, object?> AfterEvaluation(EvaluationSeries series, IDictionary<string, object?> data, IDetail detail)
{
logger.LogInformation("{Method} {Flag}: {Reason} in {Elapsed}", series.Method, series.FlagKey, detail.Reason.AsString(), Stopwatch.GetElapsedTime((long)data["start"]!));
return data;
}
}
var flags = await FeatFlagsClient.CreateAsync(new FeatFlagsOptions { Key = "sk_…", CdnUrl = "https://flags.featflags.io", Hooks = [new Timer()] });
The hooks contract has the details every SDK shares.
OpenTelemetry
FeatFlags.OtelHook adds a feature_flag.evaluation event to Activity.Current, the span OpenTelemetry .NET records, with OpenTelemetry’s semantic convention attributes. System.Diagnostics.Activity is part of .NET, so the hook ships in the core package with no extra dependency:
var flags = await FeatFlagsClient.CreateAsync(new FeatFlagsOptions { Key = "sk_…", CdnUrl = "https://flags.featflags.io", Hooks = [new OtelHook()] });
OpenFeature
FeatFlags.OpenFeature.FeatFlagsProvider, in the FeatFlags.OpenFeature package, is an OpenFeature provider for the OpenFeature .NET SDK. The provider doesn’t own the client, so dispose it yourself.
await Api.Instance.SetProviderAsync(new FeatFlagsProvider(flags));
var client = Api.Instance.GetClient("checkout");
var on = await client.GetBooleanValueAsync("new-checkout", false, EvaluationContext.Builder().SetTargetingKey("u-alice").Set("plan", "pro").Build());
The targeting key is the context key. Add a kind attribute for another kind, or kind: "multi" with each member as a structure with its own targetingKey. Date-time attributes count as missing: pass instants as RFC 3339 strings or epoch milliseconds. OpenFeature and OpenTelemetry has how reasons map.
Shutting down
The client sends evaluation counts and experiment data once a minute. Dispose it (await using, DisposeAsync or Dispose) before a short-lived job or a service exits, so the last minute isn’t lost: it waits for a check or send in flight, then sends what’s waiting. FlushAsync() sends now without disposing, and throws on failure.
Evaluation counts carry the flag, variant, reason, rule and version, and nothing about the context.