.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 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.9.0
<ItemGroup>
<PackageReference Include="ConfigDirector.ServerSdk" Version="1.9.0" />
</ItemGroup>
Configure and initialize the client
- Create an instance of the client providing your server SDK key. You can retrieve a server SDK key for each environment under
SDK keysin the dashboard's navigation panel. - Initialize the client to initiate its connection lifecycle.
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.
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:
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.
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:
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:
{
"Logging": {
"LogLevel": {
"ConfigDirector": "Debug"
}
}
}
Connection
The connection options have the following optional values:
Mode- The connection mode, which can be
ConnectionMode.Streaming,ConnectionMode.PollingorConnectionMode.Offline. It is recommended to use the default ofStreamingunless you have a specific need to usePollinginstead. InOfflinemode the client never connects to ConfigDirector and serves the values you give it; see Run without a connection. - Defaults to
ConnectionMode.Streaming
- The connection mode, which can be
File- The path of a JSON file holding the values to serve in
Offlinemode, resolved against the working directory of the process. Only one ofFileandValuescan be given, and only inOfflinemode. An emptyFilecounts as not given. See Run without a connection.
- The path of a JSON file holding the values to serve in
Values- The values to serve in
Offlinemode, as anIReadOnlyDictionary<string, object>keyed by config key. Only one ofFileandValuescan be given, and only inOfflinemode. See Run without a connection.
- The values to serve in
PollingInterval- Only used in
Pollingmode. The interval to poll ConfigDirector services for updates. A value below the minimum of1minute is raised to1minute and a warning is logged, including when it is bound from configuration. See Polling intervals. - Defaults to
5minutes
- Only used in
Timeout- The timeout to be used in initialization, and the limit on any single request to ConfigDirector services. This is how long
InitializeAsyncwill wait for data from ConfigDirector services. If the timeout is reached,InitializeAsyncwill 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. It has no effect inOfflinemode. - Defaults to
3seconds
- The timeout to be used in initialization, and the limit on any single request to ConfigDirector services. This is how long
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. It has no effect in
Offlinemode.
- The base URL used to connect to ConfigDirector services. Must be an absolute
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 an 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.
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 -=:
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, or has loaded its offline values, and is ready to evaluate configs. It is emitted once, and a handler registered after that point is never called — check
IsReadyfor that.
- Emitted when the client is initialized. When this event is emitted it means the client has received a payload from the ConfigDirector servers, or has loaded its offline values, and is ready to evaluate configs. It is emitted once, and a handler registered after that point is never called — check
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, and the keys of configs whose targeting rules use a segment the payload included, sorted.RemovedKeys: IReadOnlyList<string>- The keys of configs the payload removed, sorted. A config is removed when a full payload no longer includes it. Empty when nothing was removed.- Handlers run on the thread the update arrived on, or in offline mode on the thread that calls
InitializeAsync, 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- AConfigEvaluationrecord containing the details of the evaluation:Key: string- The config key that was evaluatedValue: 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 toWatch. 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 theValue. This can be useful for large values, like a JSON config, or to avoid disclosing the values themselves to third parties. It isnullwhen 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, or served an offline value, it will beFoundMatch. If the evaluation had to fall back to the default value, theReasonwill encode why the fallback was required:ClientNotReady- The evaluation happened before the client finished initializationConfigStateMissing- 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 neithertruenorfalse.InvalidJson- The config was requested as JSON but the value received from the server could not be read as the JSON shape asked for.TypeMismatch- The config was requested with a data type that did not match the type of the config and no reasonable type conversion was possible.ValueMissing- The config key was present in the payload received from the server, but it carried no value.
Context: Context?- The context that was provided to the evaluation function, ornullif no context was provided.
- Handlers run on the calling thread, so one that blocks delays the getter that triggered it.
- Emitted whenever a config is evaluated. This includes calls to the getters and evaluations delivered to watchers via
Run without a connection
Set the connection Mode to ConnectionMode.Offline to run your application without connecting to ConfigDirector, for example in a suite of UI tests where each run pins every flag to a known value. An offline client opens no connection and sends nothing, so the application is invisible to the ConfigDirector dashboard: no evaluations are counted and no lifecycle state changes. Every context receives the same value and targeting rules are not applied.
An offline client needs no server SDK key. Build it with new ConfigDirectorClient(options), as below, or pass a key that is null or empty, such as an environment variable that is not set.
Give the values in a JSON file, with one value per config key, or inline:
using ConfigDirector;
await using var client = new ConfigDirectorClient(new ConfigDirectorClientOptions
{
Connection =
{
Mode = ConnectionMode.Offline,
File = "flags/new-checkout-on.json",
},
});
await client.InitializeAsync();
{
"new-checkout": true,
"max-items": 20,
"greeting": "hello",
"theme": { "color": "blue" }
}
using ConfigDirector;
await using var client = new ConfigDirectorClient(new ConfigDirectorClientOptions
{
Connection =
{
Mode = ConnectionMode.Offline,
Values = new Dictionary<string, object>
{
["new-checkout"] = true,
["max-items"] = 20,
},
},
});
await client.InitializeAsync();
The file path is resolved against the working directory of the process. The config type of each value follows from the value itself: true or false (boolean), a whole number that fits in a long, such as 20 or 20.0 (integer), any other number (float), a string, or an object or array (json). Inline values follow the same rule with .NET types: a bool; an sbyte, byte, short, ushort, int, uint or long; a float, a double or a decimal; a string; or a dictionary with string keys, a list, or a JsonElement or JsonNode holding an object or an array. A value read with an in-code default value of another type returns the in-code default value, as it does against ConfigDirector. A watch registered before InitializeAsync is called with the offline value when InitializeAsync loads it. Offline values never change on their own, so the watch is called again only if InitializeAsync is called again, which loads the values, and reads the file, once more. PollingInterval, Timeout, Url and the Telemetry options have no effect, though one that is invalid, such as a relative Url, still throws when it is assigned.
With ConnectionMode.Offline and neither File nor Values, the client is ready at once and every config returns its in-code default value. An empty File counts as not given, so an environment variable left empty behaves as if it were not set.
InitializeAsync throws a ConfigDirectorInitializationException when the values cannot be loaded: the file is missing, cannot be read, is not a JSON object or holds a value such as null; an inline value is null or of a type not listed above; File and Values are both given; or either is given in Streaming or Polling mode. The client stays not ready, so a test run that starts from a misspelled path fails instead of passing on in-code default values. A later InitializeAsync that cannot load the values throws the same way and keeps serving the values loaded before.
An ASP.NET Core application sets the same options through configuration; see Run the application offline.
If you come from LaunchDarkly, a file of values is what LaunchDarkly calls "flags from files", and Offline with no values is its "offline mode".
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:
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
stringreturns 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
trueandfalseread as a boolean, in either casing. A config holding1yields the default. - A whole number the server wrote as
26.0or2.6e1still reads as26. A value afloatcannot hold, such as1e300, 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:
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.ConfigEvaluated.Evaluate config values with a context
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 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 context locally without additional network calls.
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:
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" },
});
To evaluate configs for an entity other than a user, such as a tenant, set the context kind with Kind:
var tenant = new Context
{
Id = "acme",
Kind = "tenant",
Name = "Acme Inc.",
Traits = { ["plan"] = "enterprise" },
};
client.GetValue("my-boolean-config-key", false, tenant);
Leaving Kind out means user. A context kind is 1 to 64 lowercase letters, digits, dashes, or underscores. A context with an invalid context kind is ignored: the SDK logs an error, and configs are evaluated as if no context had been given. Context kinds are available from version 1.8.0 of the SDK.
Context is a record and accepts the following:
Id- The context's identifier. With the context kind, it decides the context's bucket in a percentage rollout, so changing it can move the context into a different percentile. If it is not provided, a percentage rollout assigns an unstable bucket.
Kind- The context kind, such as
tenant. Contexts of different kinds are separate contexts even when they share an identifier, and a percentage rollout can place them in different shares.
- The context kind, such as
Name- The context'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.FromArrayandTraitValue.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.
- 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
Anonymous- Keeps the context out of the dashboard: it is evaluated but never persisted, and telemetry reports neither the context nor its id.
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 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. When a full payload no longer includes the watched config, the handler is called with the default value, so it always agrees with what the getter would return. Handlers run on the thread the update arrived on, so one that blocks delays later updates.
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:
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();
ConfigDirector.ServerSdk.AspNetCore package reduces all of the above to a single AddConfigDirector() call. See ASP.NET Core below.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.9.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.9.0
<ItemGroup>
<PackageReference Include="ConfigDirector.ServerSdk.AspNetCore" Version="1.9.0" />
</ItemGroup>
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
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddConfigDirector();
var app = builder.Build();
app.Run();
with the section it binds:
{
"ConfigDirector": {
"Connection": {
"Mode": "Streaming",
"Timeout": "00:00:03"
}
}
}
That one call does everything the manual registration above does:
- Registers a single
IConfigDirectorClientfor the whole application, which the container disposes on shutdown. - Binds
ConfigDirectorOptionsfrom theConfigDirectorconfiguration section. - Routes the SDK's logging through the host's
ILoggerFactory, so theLoggingconfiguration already shown controls it. - Awaits
InitializeAsyncduring 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, unless the connection mode is Offline, 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.
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:
builder.Services.AddConfigDirector(options => options.ServerSdkKey = secrets.ConfigDirectorKey);
To bind somewhere other than the ConfigDirector section, pass the section itself:
builder.Services.AddConfigDirector(builder.Configuration.GetSection("Features:ConfigDirector"));
Both forms also accept the delegate, so AddConfigDirector(section, options => ...) binds and then adjusts.
TryAdd, so an IConfigDirectorClient already present in the service collection is left alone. To test the application without the real client ever being built, register a test client with AddConfigDirectorTestClient; see Test your code below.ASP.NET Core configuration options
The bound section maps to ConfigDirectorOptions, which has the following values:
ServerSdkKey- Your server SDK key. Required, except in
Offlineconnection mode.
- Your server SDK key. Required, except in
AppName- The application name that targeting rules can match on. Defaults to the host's
IHostEnvironment.ApplicationName.
- The application name that targeting rules can match on. Defaults to the host's
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+9f4c1ais reported as1.2.3.
- 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
RequireReadyOnStartup- Whether a host that has not reached ConfigDirector by the end of startup fails to start.
- Defaults to
false.
Connection- The same
ConnectionOptionsdescribed above:Mode,File,Values,PollingInterval,Timeout, andUrl.
- The same
Telemetry- The same
TelemetryOptionsdescribed above:EventQueueLimitandFlushInterval.
- The same
Connection and Telemetry are the SDK's own option types rather than copies of them, so everything already described for those applies here unchanged:
{
"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
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:
{
"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:
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();
Run the application offline
With Connection:Mode set to Offline, the application starts without a server SDK key and serves the values in the file Connection:File names, without connecting to ConfigDirector; see Run without a connection. Both are configuration, so a test run can switch to a file of values with environment variables alone, with no change to Program.cs:
ConfigDirector__Connection__Mode=Offline
ConfigDirector__Connection__File=./flags/new-checkout-on.json
Give inline values in code rather than in appsettings.json. Configuration binding reads every value it binds as a string, so true there would be served as the string "true":
using ConfigDirector;
builder.Services.AddConfigDirector(options =>
{
options.Connection.Mode = ConnectionMode.Offline;
options.Connection.Values = new Dictionary<string, object> { ["new-checkout"] = true };
});
RequireReadyOnStartup is satisfied as soon as the values are loaded. If they cannot be loaded, or File or Values is given in another connection mode, the host fails to start with a ConfigDirectorInitializationException, whether or not RequireReadyOnStartup is set. An empty ConfigDirector__Connection__File counts as not given, so a deployment that defines the variable without a value starts as usual.
The context for a request
WithContext declares once how a request becomes an evaluation Context, rather than repeating it in every action:
using ConfigDirector;
builder.Services.AddConfigDirector()
.WithContext(http => new Context
{
Id = http.User.FindFirst("sub")?.Value,
Traits =
{
["region"] = http.Request.Headers["X-Region"].ToString(),
},
});
An omitted Kind means user. To evaluate for another kind of entity, set it:
builder.Services.AddConfigDirector()
.WithContext(http => new Context
{
Kind = "tenant",
Id = http.User.FindFirst("tenant_id")?.Value,
});
Actions then take an IConfigDirectorContextAccessor and read Context from it, instead of each one rebuilding the context itself:
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
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:
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.
Test your code
Use the SDK's testing package, ConfigDirector.ServerSdk.Testing, to test the code that reads your configs and flags. It creates a test client: the SDK's real client connected to an in-memory server that your test controls. Only the connection to ConfigDirector is replaced. Everything else is the same code that runs in production, so the code under test behaves exactly as it does against ConfigDirector, and no network connection is opened and no telemetry is sent.
The package is released together with the SDK and must be the same version as the ConfigDirector.ServerSdk your test project restores; it declares that exact dependency, and creating a test client checks it again. Add it to your test project only:
dotnet add package ConfigDirector.ServerSdk.Testing --version 1.9.0
using ConfigDirector.Testing;
await using var testClient = ConfigDirectorTesting.CreateTestClient(
new Dictionary<string, object> { ["new-checkout"] = true, ["max-items"] = 20 });
var service = new CheckoutService(testClient.Client);
await testClient.Client.InitializeAsync();
service.IsNewCheckoutEnabled("user-123").ShouldBeTrue();
testClient.SetValue("new-checkout", false);
service.IsNewCheckoutEnabled("user-123").ShouldBeFalse();
testClient.Client is an IConfigDirectorClient, so it goes anywhere your code accepts one. It starts uninitialized, like a production client, because the code under test usually owns the call to InitializeAsync; with no hold or failure armed, InitializeAsync completes at once with the stored values. TestClient is IAsyncDisposable and disposes its client.
In an ASP.NET Core application
ConfigDirector.ServerSdk.AspNetCore.Testing registers the test client with an application wired up with AddConfigDirector, so the application starts without an SDK key, opens no connection, and reads the values the test seeds:
dotnet add package ConfigDirector.ServerSdk.AspNetCore.Testing --version 1.9.0
using ConfigDirector.Testing;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
await using var testClient = ConfigDirectorTesting.CreateTestClient(
new Dictionary<string, object> { ["new-checkout"] = true });
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(host =>
host.ConfigureTestServices(services => services.AddConfigDirectorTestClient(testClient)));
using var http = factory.CreateClient();
(await http.GetStringAsync("/checkout")).ShouldBe("new");
testClient.SetValue("new-checkout", false);
(await http.GetStringAsync("/checkout")).ShouldBe("classic");
AddConfigDirectorTestClient removes every IConfigDirectorClient registration already in the collection and registers testClient.Client as an instance, so it works from ConfigureTestServices, which runs after the application's own AddConfigDirector. The container never disposes an instance registration; the test owns the test client and disposes it. A placeholder SDK key is supplied when none is configured, so the startup validation of the key passes, and the application's startup initialization stays in place: the host calls InitializeAsync as in production, which completes at once with the stored values. With FailInitialization and RequireReadyOnStartup, host startup throws ConfigDirectorConnectionException, as it would in production. With HoldInitialization, host startup waits until CompleteInitialization or the client's timeout; WebApplicationFactory.CreateClient() blocks on host startup, so call CompleteInitialization from another task.
Because the test client is built outside the container, it does not see the application's configuration: ConfigDirector:Connection:Timeout, the host's ILoggerFactory, and the AppName and AppVersion metadata. Pass the timeout and logger factory to CreateTestClient through TestClientOptions.
A WebApplicationFactory shared by a test class through IClassFixture shares one test client across its tests. Keep the test client with the factory, register it once, and call ReplaceValues in each test's setup: it resets the values and disarms any armed hold or failure.
public sealed class CheckoutTests : IClassFixture<CheckoutTests.Factory>
{
private readonly Factory _factory;
public CheckoutTests(Factory factory)
{
_factory = factory;
_factory.TestClient.ReplaceValues(new Dictionary<string, object> { ["new-checkout"] = true });
}
public sealed class Factory : WebApplicationFactory<Program>
{
public TestClient TestClient { get; } = ConfigDirectorTesting.CreateTestClient();
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.ConfigureTestServices(services => services.AddConfigDirectorTestClient(TestClient));
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
await TestClient.DisposeAsync();
}
}
}
Values
CreateTestClient, SetValue, and ReplaceValues take native values, and the config type follows from each value's runtime type: a bool; an integral type from sbyte to long (an integer config); a float, double, or decimal (a float config); a string; or a dictionary with string keys, a list, or a JsonElement or JsonNode holding an object or an array (a JSON config). A string is always a string config, so JSON text meant as a JSON config is given as JsonNode.Parse(text). Every value is served as an unconditional config, so every context receives the same value; a test that needs different values per context is written as one test per value. A null value throws ArgumentNullException; an empty key, a non-finite number, an unsupported type such as BigInteger or DateTime, a dictionary with non-string keys, or JSON contents that cannot be encoded throw ArgumentException; in every case nothing changes.
Reads behave as they do in production: SetValue("k", true) read as a string returns the in-code default value with the TypeMismatch reason, and SetValue("k", "") serves the in-code default value with the ValueMissing reason. JSON objects are encoded with their keys sorted.
Controls
| Control | Effect |
|---|---|
SetValue(key, value) | Stores the value and, once the client is connected, delivers an update carrying only key. Watches of key, ConfigsUpdated handlers, and reads see it before the call returns. |
RemoveValue(key) | Removes the value and delivers a full update without it. Reads return the in-code default value with the ConfigStateMissing reason, watches of key receive the default, and ConfigsUpdated lists key in RemovedKeys. |
ReplaceValues(values) | Replaces every stored value, disarms any armed hold or failure, and delivers a full update. Use it to reset a test client shared across tests. |
HoldInitialization() | The next InitializeAsync waits until CompleteInitialization() or FailInitialization(), or until the client's timeout elapses. |
CompleteInitialization() | Delivers the stored values to the held InitializeAsync, on the calling thread, so the client is ready when the call returns. Called while a hold is armed but not picked up, it disarms the hold. |
FailInitialization() | Fails the held InitializeAsync the way an invalid SDK key does: it completes promptly, the client is not ready, and the error is logged. Called with no InitializeAsync held, it arms the next one to fail. |
After a failed attempt, the next InitializeAsync succeeds and delivers the values stored in the meantime.
await using var testClient = ConfigDirectorTesting.CreateTestClient(
new Dictionary<string, object> { ["new-checkout"] = true },
new TestClientOptions { Timeout = TimeSpan.FromMilliseconds(500) });
testClient.FailInitialization();
await testClient.Client.InitializeAsync();
testClient.Client.IsReady.ShouldBeFalse();
service.IsNewCheckoutEnabled("user-123").ShouldBeFalse();
Options
CreateTestClient(values, new TestClientOptions { ... }) takes Timeout (the client's connection timeout, which bounds how long a held InitializeAsync waits; defaults to the SDK's production timeout) and LoggerFactory (defaults to the SDK's default, NullLoggerFactory, which discards everything; pass a factory to see the client's logs).
What to expect
- The first update is delivered while
InitializeAsyncconnects, so watches registered before it run before it completes, andConfigsUpdatedfires beforeClientReady. - Watches and handlers run on the thread that calls
SetValue,RemoveValue,ReplaceValues,CompleteInitialization, orInitializeAsync, not on a transport thread. - A held
InitializeAsyncthat times out, or that is interrupted by disposing the client, leaves the client not ready until the nextInitializeAsync, and logs the SDK's timeout warning; production streaming would keep retrying in the background. - Watches fire on every update carrying their key, whether or not the value changed, as in production.
RemoveValueandReplaceValuesdeliver a full update, so they also fire the watches of every remaining key. - An exception thrown by a watch or handler is caught and logged by the client, as in production, so a failed assertion inside a watch does not fail the test by itself.
- After the client is disposed,
IsReadyis false, reads return the in-code default value,GetAllConfigsreturns an empty dictionary,InitializeAsyncthrowsObjectDisposedException, every watch and event handler has been removed, and the test client's controls are silent no-ops.