Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/actions/publish/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ runs:
run: |
dotnet restore
dotnet pack --no-build --output nupkgs --configuration Release src/LaunchDarkly.OpenFeature.ServerProvider/LaunchDarkly.OpenFeature.ServerProvider.csproj
dotnet pack --no-build --output nupkgs --configuration Release src/LaunchDarkly.OpenFeature.ServerProvider.Hosting/LaunchDarkly.OpenFeature.ServerProvider.Hosting.csproj

- name: Publish Package
if: ${{ inputs.dry_run == 'false' }}
Expand Down
4 changes: 3 additions & 1 deletion .github/actions/release-build/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ runs:
- name: Build
shell: bash
# For a release we do not want to build the test project. The internals are not visible in release and it will fail.
run: dotnet build /p:Configuration=Release ./src/LaunchDarkly.OpenFeature.ServerProvider/LaunchDarkly.OpenFeature.ServerProvider.csproj
run: |
dotnet build /p:Configuration=Release ./src/LaunchDarkly.OpenFeature.ServerProvider/LaunchDarkly.OpenFeature.ServerProvider.csproj
dotnet build /p:Configuration=Release ./src/LaunchDarkly.OpenFeature.ServerProvider.Hosting/LaunchDarkly.OpenFeature.ServerProvider.Hosting.csproj
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,18 @@ Or, to build for only one target framework (in this example, .NET Standard 2.0):
dotnet build src/LaunchDarkly.OpenFeature.ServerProvider -f netstandard2.0
```

The dependency injection and hosting support is a separate package, `src/LaunchDarkly.OpenFeature.ServerProvider.Hosting`, which is versioned and released together with the provider. Dependencies used only by that integration belong there, so that the provider package remains usable without them.

### Testing

To run all unit tests:

```bash
dotnet test
```

Or, to run the tests for a single project:

```bash
dotnet test test/LaunchDarkly.OpenFeature.ServerProvider.Tests/LaunchDarkly.OpenFeature.ServerProvider.Tests.csproj
```
20 changes: 20 additions & 0 deletions LaunchDarkly.OpenFeature.ServerProvider.sln
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.OpenFeature.Se
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.OpenFeature.ServerProvider.Tests", "test\LaunchDarkly.OpenFeature.ServerProvider.Tests\LaunchDarkly.OpenFeature.ServerProvider.Tests.csproj", "{A1B1DB34-6B22-4AA4-9974-6EE67145BDB9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{80D014BF-F653-436F-9F7D-9A81FB2AE17F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.OpenFeature.ServerProvider.Hosting", "src\LaunchDarkly.OpenFeature.ServerProvider.Hosting\LaunchDarkly.OpenFeature.ServerProvider.Hosting.csproj", "{1B406D33-B27C-42AA-BBA4-431FA53105DF}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{6DAE4E12-F3FD-4E83-9BFB-AE8C923881A9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.OpenFeature.ServerProvider.Hosting.Tests", "test\LaunchDarkly.OpenFeature.ServerProvider.Hosting.Tests\LaunchDarkly.OpenFeature.ServerProvider.Hosting.Tests.csproj", "{93ADE451-57AE-4622-8048-C255217E4AD5}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -28,5 +36,17 @@ Global
{A1B1DB34-6B22-4AA4-9974-6EE67145BDB9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B1DB34-6B22-4AA4-9974-6EE67145BDB9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B1DB34-6B22-4AA4-9974-6EE67145BDB9}.Release|Any CPU.Build.0 = Release|Any CPU
{1B406D33-B27C-42AA-BBA4-431FA53105DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1B406D33-B27C-42AA-BBA4-431FA53105DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1B406D33-B27C-42AA-BBA4-431FA53105DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1B406D33-B27C-42AA-BBA4-431FA53105DF}.Release|Any CPU.Build.0 = Release|Any CPU
{93ADE451-57AE-4622-8048-C255217E4AD5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{93ADE451-57AE-4622-8048-C255217E4AD5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{93ADE451-57AE-4622-8048-C255217E4AD5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{93ADE451-57AE-4622-8048-C255217E4AD5}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{1B406D33-B27C-42AA-BBA4-431FA53105DF} = {80D014BF-F653-436F-9F7D-9A81FB2AE17F}
{93ADE451-57AE-4622-8048-C255217E4AD5} = {6DAE4E12-F3FD-4E83-9BFB-AE8C923881A9}
EndGlobalSection
EndGlobal
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,54 @@ Refer to the [SDK reference guide](https://docs.launchdarkly.com/sdk/server-side

For information on using the OpenFeature client please refer to the [OpenFeature Documentation](https://docs.openfeature.dev/docs/reference/concepts/evaluation-api/).

### Dependency injection

Support for the OpenFeature dependency injection and hosting integration is in a separate package, so that applications which do not use dependency injection do not take a dependency on `Microsoft.Extensions.*`.

```bash
dotnet add package LaunchDarkly.OpenFeature.ServerProvider.Hosting
```

```csharp
using LaunchDarkly.OpenFeature.ServerProvider.Hosting;

builder.Services.AddOpenFeature(openFeature => openFeature
.AddLaunchDarklyProvider(options =>
{
options.SdkKey = "my-sdk-key";
options.ConfigureSdk = config => config.StartWaitTime(TimeSpan.FromSeconds(10));
}));
```

Injected `IFeatureClient` instances then evaluate flags using LaunchDarkly, and the provider is initialized and shut down with the host.

The `ILdClient` used by the provider is also registered, for use-cases that OpenFeature does not support such as migration flags and track events:

```csharp
public class MyService
{
public MyService(IFeatureClient featureClient, ILdClient ldClient) { }
}
```

Providers may also be registered for a specific OpenFeature domain, in which case the `ILdClient` for that provider is registered as a keyed service using the domain as the key:

```csharp
builder.Services.AddOpenFeature(openFeature => openFeature
.AddLaunchDarklyProvider("beta", options => options.SdkKey = "my-sdk-key"));
```

```csharp
public class MyService
{
public MyService([FromKeyedServices("beta")] ILdClient ldClient) { }
}
```

Each registration uses a single provider, and therefore a single `ILdClient`, no matter how many times it is resolved.

The OpenFeature dependency injection integration does not use `OpenFeature.Api.Instance`, so a provider registered this way is not visible to code which evaluates flags using that global instance.

## OpenFeature Specific Considerations

LaunchDarkly evaluates contexts, and it can either evaluate a single-context, or a multi-context. When using OpenFeature both single and multi-contexts must be encoded into a single `EvaluationContext`. This is accomplished by looking for an attribute named `kind` in the `EvaluationContext`.
Expand Down
3 changes: 2 additions & 1 deletion release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"include-v-in-tag": false,
"include-component-in-tag": false,
"extra-files": [
"src/LaunchDarkly.OpenFeature.ServerProvider/LaunchDarkly.OpenFeature.ServerProvider.csproj"
"src/LaunchDarkly.OpenFeature.ServerProvider/LaunchDarkly.OpenFeature.ServerProvider.csproj",
"src/LaunchDarkly.OpenFeature.ServerProvider.Hosting/LaunchDarkly.OpenFeature.ServerProvider.Hosting.csproj"
]
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using System;
using LaunchDarkly.Sdk.Server.Interfaces;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using OpenFeature;
using OpenFeature.Hosting;

namespace LaunchDarkly.OpenFeature.ServerProvider.Hosting
{
/// <summary>
/// Extension methods which register the LaunchDarkly provider with the OpenFeature dependency
/// injection integration.
/// </summary>
/// <remarks>
/// <para>
/// In addition to the provider these methods register the <see cref="ILdClient"/> used by the provider,
/// which can be used for LaunchDarkly functionality that OpenFeature does not expose, such as migration
/// flags and tracking. For a provider registered with a domain the client is registered as a keyed service
/// using that domain as the key.
/// </para>
/// <para>
/// The provider, and its client, are shut down when the host stops.
/// </para>
/// </remarks>
public static class FeatureBuilderExtensions
{
/// <summary>
/// Registers the LaunchDarkly provider as the default OpenFeature provider using options configured
/// elsewhere, for instance by binding <see cref="LaunchDarklyProviderOptions"/> to configuration.
/// </summary>
/// <param name="builder">The OpenFeature builder</param>
/// <returns>The OpenFeature builder</returns>
public static OpenFeatureBuilder AddLaunchDarklyProvider(this OpenFeatureBuilder builder)
{
if (builder == null) throw new ArgumentNullException(nameof(builder));

return AddDefaultProvider(builder);
}

/// <summary>
/// Registers the LaunchDarkly provider as the default OpenFeature provider.
/// </summary>
/// <param name="builder">The OpenFeature builder</param>
/// <param name="configureOptions">Configures the LaunchDarkly provider options</param>
/// <returns>The OpenFeature builder</returns>
public static OpenFeatureBuilder AddLaunchDarklyProvider(this OpenFeatureBuilder builder,
Action<LaunchDarklyProviderOptions> configureOptions)
{
if (builder == null) throw new ArgumentNullException(nameof(builder));
if (configureOptions == null) throw new ArgumentNullException(nameof(configureOptions));

builder.Services.Configure(ProviderRegistry.DefaultDomain, configureOptions);
return AddDefaultProvider(builder);
}

/// <summary>
/// Registers the LaunchDarkly provider for the given domain using options configured elsewhere,
/// for instance by binding named <see cref="LaunchDarklyProviderOptions"/> to configuration.
/// </summary>
/// <param name="builder">The OpenFeature builder</param>
/// <param name="domain">The OpenFeature domain the provider is bound to, which is also the name of
/// the options used to configure it</param>
/// <returns>The OpenFeature builder</returns>
public static OpenFeatureBuilder AddLaunchDarklyProvider(this OpenFeatureBuilder builder, string domain)
{
if (builder == null) throw new ArgumentNullException(nameof(builder));
if (string.IsNullOrWhiteSpace(domain)) throw new ArgumentNullException(nameof(domain));

return AddDomainProvider(builder, domain);
}

/// <summary>
/// Registers the LaunchDarkly provider for the given domain.
/// </summary>
/// <param name="builder">The OpenFeature builder</param>
/// <param name="domain">The OpenFeature domain the provider is bound to</param>
/// <param name="configureOptions">Configures the LaunchDarkly provider options</param>
/// <returns>The OpenFeature builder</returns>
public static OpenFeatureBuilder AddLaunchDarklyProvider(this OpenFeatureBuilder builder, string domain,
Action<LaunchDarklyProviderOptions> configureOptions)
{
if (builder == null) throw new ArgumentNullException(nameof(builder));
if (string.IsNullOrWhiteSpace(domain)) throw new ArgumentNullException(nameof(domain));
if (configureOptions == null) throw new ArgumentNullException(nameof(configureOptions));

builder.Services.Configure(domain, configureOptions);
return AddDomainProvider(builder, domain);
}

private static OpenFeatureBuilder AddDefaultProvider(OpenFeatureBuilder builder)
{
AddRegistry(builder);
builder.Services.TryAddSingleton(sp =>
sp.GetRequiredService<ProviderRegistry>().Get(ProviderRegistry.DefaultDomain).GetClient());

return builder.AddProvider(sp =>
sp.GetRequiredService<ProviderRegistry>().Get(ProviderRegistry.DefaultDomain));
}

private static OpenFeatureBuilder AddDomainProvider(OpenFeatureBuilder builder, string domain)
{
AddRegistry(builder);
builder.Services.TryAddKeyedSingleton<ILdClient>(domain, (sp, key) =>
sp.GetRequiredService<ProviderRegistry>().Get(key.ToString()).GetClient());

return builder.AddProvider(domain,
(sp, providerDomain) => sp.GetRequiredService<ProviderRegistry>().Get(providerDomain));
}

private static void AddRegistry(OpenFeatureBuilder builder)
{
builder.Services.AddOptions();
builder.Services.TryAddSingleton<ProviderRegistry>();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<!--x-release-please-start-version-->
<Version>2.3.0</Version>
<!--x-release-please-end-->
<!-- The BUILDFRAMEWORKS variable allows us to override the target frameworks with a
single framework that we are testing; this allows us to test with older SDK
versions that would error out if they saw any newer target frameworks listed
here, even if we weren't running those. -->
<BuildFrameworks Condition="'$(BUILDFRAMEWORKS)' == ''">netstandard2.0;net471;net6.0;net8.0</BuildFrameworks>
<TargetFrameworks>$(BUILDFRAMEWORKS)</TargetFrameworks>

<DebugType>portable</DebugType>
<AssemblyName>LaunchDarkly.OpenFeature.ServerProvider.Hosting</AssemblyName>
<OutputType>Library</OutputType>
<PackageId>LaunchDarkly.OpenFeature.ServerProvider.Hosting</PackageId>
<RootNamespace>LaunchDarkly.OpenFeature.ServerProvider.Hosting</RootNamespace>
<LangVersion>7.3</LangVersion>
<Description>Dependency injection and hosting support for the LaunchDarkly OpenFeature Provider for the Server-Side SDK for .NET</Description>
<Authors>LaunchDarkly</Authors>
<Owners>LaunchDarkly</Owners>
<Company>LaunchDarkly</Company>
<Copyright>Copyright 2026 LaunchDarkly</Copyright>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/launchdarkly/openfeature-dotnet-server</PackageProjectUrl>
<RepositoryUrl>https://github.com/launchdarkly/openfeature-dotnet-server</RepositoryUrl>
<RepositoryBranch>main</RepositoryBranch>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>

<!-- fail if XML comments are missing or invalid -->
<WarningsAsErrors>1570,1571,1572,1573,1574,1580,1581,1584,1591,1710,1711,1712</WarningsAsErrors>
</PropertyGroup>

<ItemGroup Condition="'$(Configuration)'!='Release'">
<InternalsVisibleTo Include="LaunchDarkly.OpenFeature.ServerProvider.Hosting.Tests" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="OpenFeature.Hosting" Version="[2.14.0, 3.0.0)" />
<ProjectReference Include="..\LaunchDarkly.OpenFeature.ServerProvider\LaunchDarkly.OpenFeature.ServerProvider.csproj" />
</ItemGroup>

<PropertyGroup>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\LaunchDarkly.OpenFeature.ServerProvider.Hosting.xml</DocumentationFile>
</PropertyGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using System;
using LaunchDarkly.Sdk.Server;

namespace LaunchDarkly.OpenFeature.ServerProvider.Hosting
{
/// <summary>
/// Options used to configure a LaunchDarkly <see cref="Provider"/> registered through dependency injection.
/// </summary>
public sealed class LaunchDarklyProviderOptions
{
/// <summary>
/// The LaunchDarkly SDK key.
/// </summary>
public string SdkKey { get; set; }

/// <summary>
/// An optional delegate which can further configure the LaunchDarkly SDK.
/// </summary>
public Action<ConfigurationBuilder> ConfigureSdk { get; set; }

internal Configuration BuildConfiguration()
{
if (string.IsNullOrWhiteSpace(SdkKey))
{
throw new InvalidOperationException(
$"{nameof(LaunchDarklyProviderOptions)}.{nameof(SdkKey)} must be configured.");
}

var configurationBuilder = Configuration.Builder(SdkKey);
ConfigureSdk?.Invoke(configurationBuilder);
return configurationBuilder.Build();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System.Collections.Generic;
using Microsoft.Extensions.Options;

namespace LaunchDarkly.OpenFeature.ServerProvider.Hosting
{
/// <summary>
/// Caches a single provider instance per domain so that resolving the provider more than once does not
/// create additional LaunchDarkly clients.
/// </summary>
internal sealed class ProviderRegistry
{
internal const string DefaultDomain = "";

private readonly IOptionsMonitor<LaunchDarklyProviderOptions> _options;
private readonly Dictionary<string, Provider> _providers = new Dictionary<string, Provider>();
private readonly object _lock = new object();

public ProviderRegistry(IOptionsMonitor<LaunchDarklyProviderOptions> options)
{
_options = options;
}

public Provider Get(string domain)
{
var key = domain ?? DefaultDomain;
lock (_lock)
{
if (_providers.TryGetValue(key, out var existing))
{
return existing;
}

var created = new Provider(_options.Get(key).BuildConfiguration());
_providers[key] = created;
return created;
}
}
}
}
Loading
Loading