Server SDKs

.NET Server SDK

Introduction

The .NET Server SDK is a server-side SDK that evaluates configs and their targeting rules. Upon initialization, it retrieves configs and targeting rules from ConfigDirector services. From that point on, it evaluates configs locally for any user context, and receives updates via server sent events (SSE) when configs are updated on the dashboard or via the admin API.

The SDK targets net8.0 and netstandard2.0, so it runs on .NET 8 and later as well as on .NET Framework 4.6.2 and later.

The client is thread safe. Create one instance when your application starts, share it for the lifetime of the process, and dispose it on shutdown. Evaluations read config state the client already holds in memory, so they make no network calls on the request path.

In an ASP.NET Core application, the companion ConfigDirector.ServerSdk.AspNetCore package does that wiring for you: registration, configuration binding, startup initialization, and disposal. Everything on this page applies either way, see ASP.NET Core further below.

Installation

The SDK can be installed from NuGet: https://www.nuget.org/packages/ConfigDirector.ServerSdk

dotnet add package ConfigDirector.ServerSdk --version 1.2.0

Configure and initialize the client

  1. Create an instance of the client providing your server SDK key. You can retrieve a server SDK key under your project settings in the Environments & SDK Keys tab.
  2. Initialize the client to initiate its connection lifecycle.
ConfigDirectorSetup.cs
using ConfigDirector;

// IMPORTANT: Do not commit the server SDK key to your source code, it is a secret value.
// Building the client makes no network calls.
await using var client = new ConfigDirectorClient("YOUR-SERVER-SDK-KEY");

// Waits until the client is initialized or it times out.
// If initialization times out, the client will continue attempting to
// initialize in the background.
await client.InitializeAsync();

The client is thread safe. Create one at startup, share it for the lifetime of the process, and dispose it on shutdown.

Server SDK keys are secret values. Do not commit them to your source code repository. Provide them at runtime via environment variables instead.

InitializeAsync completes when the initial config state arrives or the configured timeout elapses, and it never throws on a connection failure. Check IsReady to find out whether config state actually arrived. It also accepts a CancellationToken to abandon the wait:

ConfigDirectorSetup.cs
await client.InitializeAsync(cancellationToken);

Additional configuration options

Options are supplied through a ConfigDirectorClientOptions passed to the constructor. Connection and Telemetry are get-only, so they are populated with a nested object initializer. Options are read once when the client is built; changing them afterwards has no effect.

Each setting is validated as it is assigned, so an unusable value throws an ArgumentOutOfRangeException (or an ArgumentException for a relative Url) at the line that wrote it rather than as a client that quietly never updates.

Metadata

The Metadata option allows you to provide your application's name and version. These values can be used in targeting rules conditionals. For example, if a certain feature should only be enabled starting with a certain version of your application.

ConfigDirectorSetup.cs
using ConfigDirector;

var client = new ConfigDirectorClient("YOUR-SERVER-SDK-KEY", new ConfigDirectorClientOptions
{
    Metadata = new Metadata { AppName = "YOUR-APP-NAME", AppVersion = "1.0.2" },
});

await client.InitializeAsync();

LoggerFactory

The SDK logs through Microsoft.Extensions.Logging. It defaults to NullLoggerFactory, which discards everything, so pass your application's own ILoggerFactory to route the SDK's output through your logging pipeline:

ConfigDirectorSetup.cs
var client = new ConfigDirectorClient("YOUR-SERVER-SDK-KEY", new ConfigDirectorClientOptions
{
    LoggerFactory = loggerFactory,
});

Categories are namespaced under ConfigDirector, so levels, formatting, and destinations are controlled through your usual logging configuration:

appsettings.json
{
  "Logging": {
    "LogLevel": {
      "ConfigDirector": "Debug"
    }
  }
}

Connection

The connection options have the following optional values:

  • Mode
    • The connection mode, which can be ConnectionMode.Streaming or ConnectionMode.Polling. It is recommended to use the default of Streaming unless you have a specific need to use Polling instead.
    • Defaults to ConnectionMode.Streaming
  • PollingInterval
    • Only used in Polling mode. The interval to poll ConfigDirector services for updates. Setting an interval shorter than 1 minute throws an ArgumentOutOfRangeException, including when it is bound from configuration.
    • Defaults to 5 minutes
  • Timeout
    • The timeout to be used in initialization, and the limit on any single request to ConfigDirector services. This is how long InitializeAsync will wait for data from ConfigDirector services. If the timeout is reached, InitializeAsync will return but the client will still be in an unready status and returning default values. While streaming, the client will continue to attempt to connect and retrieve config values in the background.
    • Defaults to 3 seconds
  • Url
    • The base URL used to connect to ConfigDirector services. Must be an absolute Uri.
    • This should only be provided if your environment requires you to configure a proxy server in order to connect to ConfigDirector services
ConfigDirectorSetup.cs
using ConfigDirector;

var client = new ConfigDirectorClient("YOUR-SERVER-SDK-KEY", new ConfigDirectorClientOptions
{
    Connection =
    {
        Mode = ConnectionMode.Polling,
        PollingInterval = TimeSpan.FromMinutes(10),
        Timeout = TimeSpan.FromSeconds(5),
    },
});

Telemetry

Telemetry tuning. It is unlikely these settings need to be adjusted. However, in cases where your application has a large number of evaluations per second, you can adjust these settings to tune the memory footprint and frequency of telemetry requests.

Keep in mind that ConfigDirector relies on these telemetry events to provide insights and features related to the configs being used.

The telemetry options have the following optional values:

  • EventQueueLimit
    • The size limit of telemetry event queues. If the size limit is reached before the events are flushed to the network, older events will be dropped.
    • ConfigDirector keeps a count of dropped events. If the number of dropped events is higher than 50% of the total events, ConfigDirector will issue a notification alert in the dashboard.
    • A number between 100 and 100,000. Defaults to 5,000.
  • FlushInterval
    • How often events are flushed and sent over the network.
    • Decrease this number if your application consistently captures a large number of events in short periods of time in order to reduce memory footprint from a large event queue.
    • Defaults to 30 seconds.
ConfigDirectorSetup.cs
var client = new ConfigDirectorClient("YOUR-SERVER-SDK-KEY", new ConfigDirectorClientOptions
{
    Telemetry =
    {
        EventQueueLimit = 10_000,
        FlushInterval = TimeSpan.FromSeconds(15),
    },
});

Hooks

Hooks are .NET events you can subscribe to in order to be notified of some key actions from the client. Subscribe with += and unsubscribe with -=:

ConfigDirectorSetup.cs
void OnConfigsUpdated(object? sender, ConfigsUpdatedEventArgs e) =>
    Console.WriteLine(string.Join(", ", e.Keys));

client.ConfigsUpdated += OnConfigsUpdated;

client.ConfigsUpdated -= OnConfigsUpdated; // Cancels the registration

The following hooks are available:

  • ClientReady: ClientReadyEventArgs
    • Emitted when the client is initialized. When this event is emitted it means the client has received a payload from the ConfigDirector servers and is ready to evaluate configs. It is emitted once, and a handler registered after that point is never called — check IsReady for that.
  • ConfigsUpdated: ConfigsUpdatedEventArgs
    • Emitted when a payload is received from the ConfigDirector servers with config data. It is emitted during initialization when an entire config payload is received. After initialization, it is emitted when updates are pushed from the server (or discovered via polling if that connection mode is used).
    • Keys: IReadOnlyList<string> - The config keys that were included in the payload from the server, sorted.
    • Handlers run on the thread the update arrived on, so one that blocks delays later updates.
  • ConfigEvaluated: ConfigEvaluatedEventArgs
    • Emitted whenever a config is evaluated. This includes calls to the getters and evaluations delivered to watchers via Watch.
    • Evaluation: ConfigEvaluation - A ConfigEvaluation record containing the details of the evaluation:
      • Key: string - The config key that was evaluated
      • Value: object - The value the config evaluated to, in the type the caller's default asked for. It can be the default value provided to the getter or to Watch. For example, if the config was evaluated before the client was initialized.
      • ValueId: string? - The value ID, which is a stable hash of the value that can be used for analytics or other third parties rather than sending the Value. This can be useful for large values, like a JSON config, or to avoid disclosing the values themselves to third parties. It is null when the evaluation fell back to the default value.
      • IsDefault: bool - Whether or not the evaluation fell back to the default value provided in code.
      • Reason: EvaluationReason - The reason for the evaluation resolution. In the case the config successfully evaluated based on server-provided targeting rules, it will be FoundMatch. If the evaluation had to fall back to the default value, the Reason will encode why the fallback was required:
        • ClientNotReady - The evaluation happened before the client finished initialization
        • ConfigStateMissing - The requested config key was not present in the payload received from the server. This could be due to an incorrect config key, or a config that was not enabled to be available to client SDKs.
        • InvalidNumber - The config was requested as a number but the value received from the server could not be read as that number type.
        • InvalidBoolean - The config was requested as a boolean but the value received from the server was neither true nor false.
        • InvalidJson - The config was requested as JSON but the value received from the server could not be read as the JSON shape asked for.
        • ValueMissing - The config key was present in the payload received from the server, but it carried no value.
      • Context: Context? - The user context that was provided to the evaluation function, or null if no context was provided.
    • Handlers run on the calling thread, so one that blocks delays the getter that triggered it.

Retrieve config values

Retrieve config values via the typed GetValue overloads, and subscribe to updates via Watch. The first argument is the config key and the second is the default value, which is returned if the config is not available or cannot be read as the given type. The type of the default value decides the type the config is read as:

Program.cs
using System.Text.Json;

// Retrieve config values
var retries = client.GetValue("max-retries", 3);
var theme = client.GetValue("theme", "light");
var newCheckout = client.GetValue("new-checkout", false);
var limits = client.GetValue("rate-limits", default(JsonElement)); // JSON config

// Watch for config updates
var subscription = client.Watch(
    "new-checkout",
    false,
    value => Console.WriteLine($"new-checkout is now {value}"));

// Dispose the subscription when it is no longer needed
subscription.Dispose();

The type a config is read as is decided by the overload you call, not by how the config was declared in the dashboard. There is one overload per type the SDK can read exactly, so a type it cannot fill is a compile error rather than a surprise at runtime. Each one takes the value to return when the config is missing, the service is unreachable, or the value will not read as the requested type — so the default should always be the safe choice:

  • GetValue(string configKey, bool defaultValue, Context? context = null)
  • GetValue(string configKey, string defaultValue, Context? context = null)
  • GetValue(string configKey, int defaultValue, Context? context = null)
  • GetValue(string configKey, long defaultValue, Context? context = null)
  • GetValue(string configKey, double defaultValue, Context? context = null)
  • GetValue(string configKey, float defaultValue, Context? context = null)
  • GetValue(string configKey, decimal defaultValue, Context? context = null)
  • GetValue(string configKey, JsonElement defaultValue, Context? context = null)

A few of those are worth calling out:

  • Reading as string returns the value as the server spelled it, with no parsing, so any config reads as text: a boolean config gives "true", and a JSON config gives its JSON.
  • Only true and false read as a boolean, in either casing. A config holding 1 yields the default.
  • A whole number the server wrote as 26.0 or 2.6e1 still reads as 26. A value a float cannot hold, such as 1e300, yields the default rather than an infinity.

JSON configs

GetValue with a JsonElement default returns the config's JSON whole, whatever shape it is. GetJsonValue<T> binds it to a type of your own instead:

Program.cs
using System.Text.Json;

// The shape lives in the dashboard, so read it as it stands
var raw = client.GetValue("rate-limits", default(JsonElement));

// The shape belongs to this application, so bind it
var limits = client.GetJsonValue("rate-limits", new RateLimits());

public sealed record RateLimits
{
    public int PerMinute { get; init; } = 60;
}
GetJsonValue<T> binds by System.Text.Json's rules, so any property T does not declare is dropped. A config whose shape has moved on binds to T's own defaults and cannot be told apart from the default value you passed. Read the config as a JsonElement when that matters.
Getters never throw when a config is missing or a value will not read as the requested type — they return your default instead. Which of those happened is reported through ConfigEvaluated.

Evaluate config values with a user context

Unlike client SDKs, the server SDKs are able to evaluate targeting rules for the given user context locally without additional network calls.

Every getter takes an optional Context as its last argument, which is what targeting rules are evaluated against. The same key can therefore resolve differently per user:

The user context can be provided as the last argument to any of the getters. Unlike client SDKs, the server SDKs are able to evaluate targeting rules for the given user context locally without additional network calls.

Program.cs
using ConfigDirector;

var context = new Context
{
    Id = "12345",
    Name = "Example User",
    Traits =
    {
        ["region"] = "North America", // Any arbitrary traits which can be referenced in targeting rules
    },
};

client.GetValue("my-boolean-config-key", false, context);

It can also be provided as the fourth argument to Watch:

Program.cs
using ConfigDirector;

var subscription = client.Watch(
    "new-checkout",
    false,
    value => Console.WriteLine($"new-checkout is now {value}"),
    new Context
    {
        Id = "12345",
        Name = "Example User",
        Traits = { ["region"] = "North America" },
    });

Context is a record and accepts the following:

  • Id
    • The user's identifier. It decides their bucket in a percentage rollout, so changing it can move a user into a different percentile. If it is not provided, a percentage rollout assigns an unstable bucket.
  • Name
    • The user's display name.
  • Traits
    • Arbitrary traits which can be referenced in targeting rules, keyed by the name the rule references. Names are matched exactly, as JSON member names are. Values are JSON-shaped: strings, numbers, and booleans convert implicitly, and arrays of those convert too. Nested shapes are built with TraitValue.FromArray and TraitValue.FromObject. A value with no text form — null, a list, a nested object — will not match a targeting rule that compares text.
    • It is never null: a context with no traits carries an empty collection, and assigning a collection copies it.
  • Anonymous
    • Keeps the context out of the dashboard: it is evaluated but never persisted, and telemetry reports neither the context nor its id.
Program.cs
using ConfigDirector;

var context = new Context
{
    Id = "user-id",
    Name = "Example User",
    Traits =
    {
        ["region"] = "North America",
        ["age"] = 26,
        ["tags"] = new[] { "beta", "internal" },
    },
};

Watch for updates

Watch accepts four arguments, the first is the config key, the second argument is the default value, the third argument is a handler that will be executed when the config value is updated, and the fourth and optional argument is a user context. It has the same set of typed overloads as GetValue, plus WatchJson<T> as the counterpart to GetJsonValue<T>.

Registering a watch before InitializeAsync means it is called for the first config state as well; one registered afterwards only sees later updates. Handlers run on the thread the update arrived on, so one that blocks delays later updates.

Program.cs
using ConfigDirector;

var subscription = client.Watch(
    "new-checkout",
    false,
    value => Console.WriteLine($"new-checkout is now {value}"),
    new Context
    {
        Id = "user-id",
        Name = "Example User",
        Traits = { ["region"] = "North America" },
    });

Watch returns an IDisposable that cancels the watch when disposed. Disposing it twice is harmless.

Other useful client features

The client provides additional members.

IsReady

Returns a boolean indicating if the client has received config state and is ready to evaluate configs. It is initially false and becomes true once the first config state arrives. Until it does, every getter returns its default.

IsClosed

Returns a boolean indicating if the client has been disposed. A disposed client cannot be reopened.

GetAllConfigs

Returns every config the client currently holds as an IReadOnlyDictionary<string, ConfigState>, evaluated but before type parsing. It optionally accepts a Context and a collection of config keys to restrict the result to.

This is intended for handing state to a client SDK to hydrate with. It records no telemetry, since the SDK that receives the state reports its own evaluations.

Unwatch

Cancels every watch on one config key.

UnwatchAll

Cancels every watch on every config key.

Disposing the client

Disposing closes all connections to ConfigDirector services, reports whatever telemetry is pending, and cancels every watch and event handler. Disposing twice is harmless.

Only dispose when your application shuts down and it will no longer make use of the client instance. ConfigDirectorClient implements both IDisposable and IAsyncDisposable; prefer the asynchronous one, which lets the final telemetry flush complete without blocking a thread.

In an ASP.NET Core application, register the client as a singleton and let the host own the lifecycle — it disposes singletons on shutdown:

Program.cs
using ConfigDirector;

builder.Services.AddSingleton<IConfigDirectorClient>(services =>
    new ConfigDirectorClient(
        builder.Configuration["ConfigDirector:ServerSdkKey"]!,
        new ConfigDirectorClientOptions
        {
            LoggerFactory = services.GetRequiredService<ILoggerFactory>(),
        }));

var app = builder.Build();

// Awaited before the server starts listening, so requests are not served config
// defaults while the first config state is still in flight.
await app.Services.GetRequiredService<IConfigDirectorClient>().InitializeAsync();

app.Run();
The ConfigDirector.ServerSdk.AspNetCore package reduces all of the above to a single AddConfigDirector() call. See ASP.NET Core below.
Never build a client per request. Each one holds its own connection, initialization does network I/O, and a fresh client serves defaults until its first config state arrives.

ASP.NET Core

The ConfigDirector.ServerSdk.AspNetCore package replaces the wiring above with one call. It registers the client, binds its settings from configuration, connects before the server starts listening, and lets the host dispose it on shutdown.

It sits on top of this SDK and brings it along, so installing it gives you both. It replaces the wiring only: reading a config is the same IConfigDirectorClient API either way, and an application that would rather register the client by hand can keep doing so.

The package targets net8.0 and requires ConfigDirector.ServerSdk 1.2.0 or later.

Installing the ASP.NET Core package

The package can be installed from NuGet: https://www.nuget.org/packages/ConfigDirector.ServerSdk.AspNetCore

dotnet add package ConfigDirector.ServerSdk.AspNetCore --version 1.2.0

There is no need to reference ConfigDirector.ServerSdk as well. It arrives as a dependency, which is how a consuming application gets both.

Register the client

Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddConfigDirector();

var app = builder.Build();

app.Run();

with the section it binds:

appsettings.json
{
  "ConfigDirector": {
    "Connection": {
      "Mode": "Streaming",
      "Timeout": "00:00:03"
    }
  }
}

That one call does everything the manual registration above does:

  • Registers a single IConfigDirectorClient for the whole application, which the container disposes on shutdown.
  • Binds ConfigDirectorOptions from the ConfigDirector configuration section.
  • Routes the SDK's logging through the host's ILoggerFactory, so the Logging configuration already shown controls it.
  • Awaits InitializeAsync during startup, before the server accepts its first request.
AddConfigDirector lives in the Microsoft.Extensions.DependencyInjection namespace, so Program.cs needs no using directive for it. Injecting IConfigDirectorClient into a controller or an endpoint does need using ConfigDirector;.

The server SDK key is required, and it is validated when the host starts. A missing key therefore fails at startup with an OptionsValidationException naming the setting, rather than as a client that quietly evaluates every config to its default.

Server SDK keys are secret values. Do not commit them to appsettings.json. Provide them at runtime via environment variables, user secrets, or from a secret store in code.

A key held in a secret store is the usual reason to adjust the bound settings in code. A delegate passed to AddConfigDirector runs after binding, so it wins over configuration:

Program.cs
builder.Services.AddConfigDirector(options => options.ServerSdkKey = secrets.ConfigDirectorKey);

To bind somewhere other than the ConfigDirector section, pass the section itself:

Program.cs
builder.Services.AddConfigDirector(builder.Configuration.GetSection("Features:ConfigDirector"));

Both forms also accept the delegate, so AddConfigDirector(section, options => ...) binds and then adjusts.

The client is registered with TryAdd, so an IConfigDirectorClient already present in the service collection is left alone. That is how a test substitutes a fake without the real client ever being built.

ASP.NET Core configuration options

The bound section maps to ConfigDirectorOptions, which has the following values:

  • ServerSdkKey
    • Your server SDK key. Required.
  • AppName
    • The application name that targeting rules can match on. Defaults to the host's IHostEnvironment.ApplicationName.
  • AppVersion
    • The application version that targeting rules match by semver rules. Defaults to the entry assembly's informational version, with any build metadata suffix removed, so 1.2.3+9f4c1a is reported as 1.2.3.
  • RequireReadyOnStartup
    • Whether a host that has not reached ConfigDirector by the end of startup fails to start.
    • Defaults to false.
  • Connection
    • The same ConnectionOptions described above: Mode, PollingInterval, Timeout, and Url.
  • Telemetry
    • The same TelemetryOptions described above: EventQueueLimit and FlushInterval.

Connection and Telemetry are the SDK's own option types rather than copies of them, so everything already described for those applies here unchanged:

appsettings.json
{
  "ConfigDirector": {
    "AppName": "checkout",
    "AppVersion": "1.0.2",
    "RequireReadyOnStartup": false,
    "Connection": {
      "Mode": "Polling",
      "PollingInterval": "00:10:00",
      "Timeout": "00:00:05"
    },
    "Telemetry": {
      "EventQueueLimit": 10000,
      "FlushInterval": "00:00:15"
    }
  }
}

Nested settings follow the usual environment variable convention, with a double underscore between levels:

ConfigDirector__ServerSdkKey=YOUR-SERVER-SDK-KEY
ConfigDirector__Connection__Mode=Polling
Settings are read once, when the client is first resolved. Reloading configuration afterwards does not reach a client that has already been built.

Startup initialization

The package connects during startup rather than on the first request, and it does so before the server begins listening, so no request is served config defaults while the first config state is still in flight.

By default, a host that cannot reach ConfigDirector still starts. It logs a warning, and every config resolves to the default its caller supplied until config state arrives, which is the SDK's own posture. Set RequireReadyOnStartup to fail startup instead, with a ConfigDirectorConnectionException:

appsettings.json
{
  "ConfigDirector": {
    "RequireReadyOnStartup": true
  }
}

Registering a watch is the one thing that still happens in code, between building the application and running it. Resolving the client there builds it without making any network calls; the connection is opened later, as the host starts. A watch registered in that gap is called for the first config state as well as for the updates after it:

Program.cs
var app = builder.Build();

var client = app.Services.GetRequiredService<IConfigDirectorClient>();

client.Watch("new-checkout", false, enabled =>
    app.Logger.LogInformation("new-checkout is now {Enabled}", enabled));

app.Run();

The user context for a request

WithContext declares once how a request becomes an evaluation Context, rather than repeating it in every action:

Program.cs
using ConfigDirector;

builder.Services.AddConfigDirector()
    .WithContext(http => new Context
    {
        Id = http.User.FindFirst("sub")?.Value,
        Traits =
        {
            ["region"] = http.Request.Headers["X-Region"].ToString(),
        },
    });

Actions then take an IConfigDirectorContextAccessor and read Context from it, instead of each one rebuilding the context itself:

ConfigsController.cs
using ConfigDirector;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("configs")]
public sealed class ConfigsController : ControllerBase
{
    private readonly IConfigDirectorClient _client;
    private readonly IConfigDirectorContextAccessor _context;

    public ConfigsController(IConfigDirectorClient client, IConfigDirectorContextAccessor context)
    {
        _client = client;
        _context = context;
    }

    [HttpGet]
    public bool Get() => _client.GetValue("new-checkout", false, _context.Context);
}

The delegate runs at most once per request, the first time the context is asked for, so several evaluations in one action cost one call to it. It may return null to evaluate without a context. Context is also null when no request is in flight, which is what a background service reading the accessor sees.

IConfigDirectorContextAccessor is registered only when WithContext has been called. Injecting it without that fails at resolution, rather than quietly evaluating every config with no context, which would disable targeting without saying so.

Health checks

Program.cs
builder.Services.AddHealthChecks().AddConfigDirector();

var app = builder.Build();

app.MapHealthChecks("/health");

The check reports Healthy once config state has arrived. Until it does, it reports Degraded rather than Unhealthy: the application still answers every request, with each config resolving to the default its caller supplied, so taking the instance out of rotation is the wrong response to ConfigDirector being unreachable.

Pass a failure status to say otherwise, along with the name the check is registered under and any tags to filter it by:

Program.cs
using Microsoft.Extensions.Diagnostics.HealthChecks;

builder.Services.AddHealthChecks()
    .AddConfigDirector(
        name: "configdirector",
        failureStatus: HealthStatus.Unhealthy,
        tags: ["ready"]);

name defaults to configdirector and failureStatus to HealthStatus.Degraded. A client that has been disposed always reports Unhealthy, whatever failure status is configured, since it cannot be reopened.

Copyright © 2026