From cca1634762c99ed3b314bbb6cfcad1fafabe5f21 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Wed, 30 Sep 2026 14:46:38 +0000
Subject: [PATCH 1/2] fix: Include the last data source error in provider error
and stale events
Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
---
.../Provider.cs | 501 +++++++++---------
.../ProviderTests.cs | 73 +++
2 files changed, 330 insertions(+), 244 deletions(-)
diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs
index 971d3c7..8160f2e 100644
--- a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs
+++ b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs
@@ -1,244 +1,257 @@
-using System;
-using System.Collections.Generic;
-using System.Threading;
-using System.Threading.Tasks;
-using LaunchDarkly.Logging;
-using LaunchDarkly.Sdk;
-using LaunchDarkly.Sdk.Server;
-using LaunchDarkly.Sdk.Server.Interfaces;
-using OpenFeature;
-using OpenFeature.Constant;
-using OpenFeature.Model;
-
-namespace LaunchDarkly.OpenFeature.ServerProvider
-{
- ///
- /// An OpenFeature which enables the use of the LaunchDarkly Server-Side SDK for .NET
- /// with OpenFeature.
- ///
- ///
- /// var provider = new Provider(Configuration.Builder("my-sdk-key").Build());
- ///
- /// OpenFeature.Api.Instance.SetProvider(provider);
- ///
- /// var client = OpenFeature.Api.Instance.GetClient();
- ///
- public sealed partial class Provider : FeatureProvider
- {
- private const string NameSpace = "OpenFeature.ServerProvider";
- private readonly Metadata _metadata = new Metadata($"LaunchDarkly.{NameSpace}");
- private readonly ILdClient _client;
- private readonly EvalContextConverter _contextConverter;
- private readonly StatusProvider _statusProvider;
-
- private readonly object _initLock = new object();
- private bool _initializeCalled = false;
-
- // There is no support for void task completion, so we use bool as a dummy result type.
- private readonly TaskCompletionSource _initCompletion = new TaskCompletionSource();
- private readonly Logger _logger;
-
- private const string ProviderShutdownMessage =
- "the provider has encountered a permanent error or been shutdown";
-
- internal Provider(ILdClient client)
- {
- _client = client;
- _logger = _client.GetLogger().SubLogger(NameSpace);
- _statusProvider = new StatusProvider(EventChannel, _metadata.Name, _logger);
- _contextConverter = new EvalContextConverter(_logger);
- }
-
- ///
- /// Construct a new instance of the provider with the given configuration.
- ///
- /// A client configuration object
- public Provider(Configuration config) : this(new LdClient(WrapConfig(config)))
- {
- }
-
- ///
- /// Construct a new instance of the provider with the given SDK key.
- ///
- /// The SDK key
- public Provider(string sdkKey) : this(new LdClient(WrapConfig(Configuration.Builder(sdkKey).Build())))
- {
- }
-
- ///
- ///
- /// Get the underlying client instance.
- ///
- ///
- /// If the initialization/shutdown features of OpenFeature are being used, then the returned client instance
- /// may be shutdown if the provider has been removed from the OpenFeature API instance.
- ///
- ///
- /// This client instance can be used to take advantage of features which are not part of OpenFeature.
- /// For instance using migration flags.
- ///
- ///
- /// The LaunchDarkly client instance
- public ILdClient GetClient()
- {
- return _client;
- }
-
- private static Configuration WrapConfig(Configuration config)
- {
- return Configuration.Builder(config)
- .WrapperInfo(Components.WrapperInfo().Name("open-feature-dotnet-server")
- .Version(typeof(Provider).Assembly.GetName().Version.ToString()))
- .Build();
- }
-
- #region FeatureProvider Implementation
-
- ///
- public override Metadata GetMetadata() => _metadata;
-
- ///
- public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue,
- EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client
- .BoolVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
- .ToResolutionDetails(flagKey));
-
- ///
- public override Task> ResolveStringValueAsync(string flagKey, string defaultValue,
- EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client
- .StringVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
- .ToResolutionDetails(flagKey));
-
- ///
- public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue,
- EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client
- .IntVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
- .ToResolutionDetails(flagKey));
-
- ///
- public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue,
- EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client
- .DoubleVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
- .ToResolutionDetails(flagKey));
-
- ///
- public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue,
- EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client
- .JsonVariationDetail(flagKey, _contextConverter.ToLdContext(context), LdValue.Null)
- .ToValueDetail(defaultValue).ToResolutionDetails(flagKey));
-
- ///
- public override Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default)
- {
- lock (_initLock)
- {
- if (_initializeCalled)
- {
- return _initCompletion.Task;
- }
- _initializeCalled = true;
- }
-
- _client.FlagTracker.FlagChanged += FlagChangeHandler;
-
- _client.DataSourceStatusProvider.StatusChanged += StatusChangeHandler;
-
- // We start listening for status changes, and then we check the current status change. If we do not check
- // then we could have missed a status change. If we check before registering a listener, then we could
- // miss a change between checking and listening. Doing it this way we can get duplicates, but we filter
- // when the status does not actually change, so we won't emit duplicate events.
- if (_client.Initialized)
- {
- _statusProvider.SetStatus(ProviderStatus.Ready);
- _initCompletion.TrySetResult(true);
- }
-
- if (_client.DataSourceStatusProvider.Status.State == DataSourceState.Off)
- {
- _statusProvider.SetStatus(ProviderStatus.Error, ProviderShutdownMessage);
- _initCompletion.TrySetException(new LaunchDarklyProviderInitException(ProviderShutdownMessage));
- }
-
- return _initCompletion.Task;
- }
-
- ///
- public override Task ShutdownAsync(CancellationToken cancellationToken = default)
- {
- _client.DataSourceStatusProvider.StatusChanged -= StatusChangeHandler;
- _client.FlagTracker.FlagChanged -= FlagChangeHandler;
- (_client as IDisposable)?.Dispose();
- _statusProvider.SetStatus(ProviderStatus.NotReady);
- return Task.CompletedTask;
- }
-
- #endregion
-
- private void FlagChangeHandler(object sender, FlagChangeEvent changeEvent)
- {
- Task.Run(() => SafeWriteChangeEvent(changeEvent)).ConfigureAwait(false);
- }
-
- private async Task SafeWriteChangeEvent(FlagChangeEvent changeEvent)
- {
- try
- {
- await EventChannel.Writer.WriteAsync(new ProviderEventPayload
- {
- ProviderName = _metadata.Name,
- Type = ProviderEventTypes.ProviderConfigurationChanged,
- FlagsChanged = new List { changeEvent.Key },
- }).ConfigureAwait(false);
- }
- catch (Exception e)
- {
- _logger.Warn($"Encountered an error sending configuration changed events: {e.Message}");
- }
- }
-
- private void StatusChangeHandler(object sender, DataSourceStatus status)
- {
- switch (status.State)
- {
- case DataSourceState.Initializing:
- break;
- case DataSourceState.Valid:
- _statusProvider.SetStatus(ProviderStatus.Ready);
- _initCompletion.TrySetResult(true);
- break;
- case DataSourceState.Interrupted:
- // The "ProviderStatus.Error" state says it is unable to evaluate flags. We can always evaluate
- // flags.
- _statusProvider.SetStatus(ProviderStatus.Stale,
- status.LastError?.Message ?? "encountered an unknown error");
- break;
- case DataSourceState.Off:
- default:
- // The status is an error, and not fatal, because the LaunchDarkly client can continue to
- // evaluate flags using the data it already has.
- _statusProvider.SetStatus(ProviderStatus.Error, ProviderShutdownMessage);
- _initCompletion.TrySetException(new LaunchDarklyProviderInitException(ProviderShutdownMessage));
- break;
- }
- }
-
- ///
- public override void Track(string trackingEventName, EvaluationContext evaluationContext = null, TrackingEventDetails trackingEventDetails = default)
- {
- var (value, details) = trackingEventDetails.ToLdValue();
-
- if (value.HasValue)
- {
- _client.Track(trackingEventName, _contextConverter.ToLdContext(evaluationContext), details, value.Value);
- }
- else if (details.Type != LdValueType.Null)
- {
- _client.Track(trackingEventName, _contextConverter.ToLdContext(evaluationContext), details);
- }
- else
- {
- _client.Track(trackingEventName, _contextConverter.ToLdContext(evaluationContext));
- }
- }
- }
-}
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using LaunchDarkly.Logging;
+using LaunchDarkly.Sdk;
+using LaunchDarkly.Sdk.Server;
+using LaunchDarkly.Sdk.Server.Interfaces;
+using OpenFeature;
+using OpenFeature.Constant;
+using OpenFeature.Model;
+
+namespace LaunchDarkly.OpenFeature.ServerProvider
+{
+ ///
+ /// An OpenFeature which enables the use of the LaunchDarkly Server-Side SDK for .NET
+ /// with OpenFeature.
+ ///
+ ///
+ /// var provider = new Provider(Configuration.Builder("my-sdk-key").Build());
+ ///
+ /// OpenFeature.Api.Instance.SetProvider(provider);
+ ///
+ /// var client = OpenFeature.Api.Instance.GetClient();
+ ///
+ public sealed partial class Provider : FeatureProvider
+ {
+ private const string NameSpace = "OpenFeature.ServerProvider";
+ private readonly Metadata _metadata = new Metadata($"LaunchDarkly.{NameSpace}");
+ private readonly ILdClient _client;
+ private readonly EvalContextConverter _contextConverter;
+ private readonly StatusProvider _statusProvider;
+
+ private readonly object _initLock = new object();
+ private bool _initializeCalled = false;
+
+ // There is no support for void task completion, so we use bool as a dummy result type.
+ private readonly TaskCompletionSource _initCompletion = new TaskCompletionSource();
+ private readonly Logger _logger;
+
+ private const string ProviderShutdownMessage =
+ "the provider has encountered a permanent error or been shutdown";
+
+ internal Provider(ILdClient client)
+ {
+ _client = client;
+ _logger = _client.GetLogger().SubLogger(NameSpace);
+ _statusProvider = new StatusProvider(EventChannel, _metadata.Name, _logger);
+ _contextConverter = new EvalContextConverter(_logger);
+ }
+
+ ///
+ /// Construct a new instance of the provider with the given configuration.
+ ///
+ /// A client configuration object
+ public Provider(Configuration config) : this(new LdClient(WrapConfig(config)))
+ {
+ }
+
+ ///
+ /// Construct a new instance of the provider with the given SDK key.
+ ///
+ /// The SDK key
+ public Provider(string sdkKey) : this(new LdClient(WrapConfig(Configuration.Builder(sdkKey).Build())))
+ {
+ }
+
+ ///
+ ///
+ /// Get the underlying client instance.
+ ///
+ ///
+ /// If the initialization/shutdown features of OpenFeature are being used, then the returned client instance
+ /// may be shutdown if the provider has been removed from the OpenFeature API instance.
+ ///
+ ///
+ /// This client instance can be used to take advantage of features which are not part of OpenFeature.
+ /// For instance using migration flags.
+ ///
+ ///
+ /// The LaunchDarkly client instance
+ public ILdClient GetClient()
+ {
+ return _client;
+ }
+
+ private static Configuration WrapConfig(Configuration config)
+ {
+ return Configuration.Builder(config)
+ .WrapperInfo(Components.WrapperInfo().Name("open-feature-dotnet-server")
+ .Version(typeof(Provider).Assembly.GetName().Version.ToString()))
+ .Build();
+ }
+
+ #region FeatureProvider Implementation
+
+ ///
+ public override Metadata GetMetadata() => _metadata;
+
+ ///
+ public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue,
+ EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client
+ .BoolVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
+ .ToResolutionDetails(flagKey));
+
+ ///
+ public override Task> ResolveStringValueAsync(string flagKey, string defaultValue,
+ EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client
+ .StringVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
+ .ToResolutionDetails(flagKey));
+
+ ///
+ public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue,
+ EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client
+ .IntVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
+ .ToResolutionDetails(flagKey));
+
+ ///
+ public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue,
+ EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client
+ .DoubleVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
+ .ToResolutionDetails(flagKey));
+
+ ///
+ public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue,
+ EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client
+ .JsonVariationDetail(flagKey, _contextConverter.ToLdContext(context), LdValue.Null)
+ .ToValueDetail(defaultValue).ToResolutionDetails(flagKey));
+
+ ///
+ public override Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default)
+ {
+ lock (_initLock)
+ {
+ if (_initializeCalled)
+ {
+ return _initCompletion.Task;
+ }
+ _initializeCalled = true;
+ }
+
+ _client.FlagTracker.FlagChanged += FlagChangeHandler;
+
+ _client.DataSourceStatusProvider.StatusChanged += StatusChangeHandler;
+
+ // We start listening for status changes, and then we check the current status change. If we do not check
+ // then we could have missed a status change. If we check before registering a listener, then we could
+ // miss a change between checking and listening. Doing it this way we can get duplicates, but we filter
+ // when the status does not actually change, so we won't emit duplicate events.
+ if (_client.Initialized)
+ {
+ _statusProvider.SetStatus(ProviderStatus.Ready);
+ _initCompletion.TrySetResult(true);
+ }
+
+ var dataSourceStatus = _client.DataSourceStatusProvider.Status;
+ if (dataSourceStatus.State == DataSourceState.Off)
+ {
+ var message = DescribeLastError(dataSourceStatus, ProviderShutdownMessage);
+ _statusProvider.SetStatus(ProviderStatus.Error, message);
+ _initCompletion.TrySetException(new LaunchDarklyProviderInitException(message));
+ }
+
+ return _initCompletion.Task;
+ }
+
+ ///
+ public override Task ShutdownAsync(CancellationToken cancellationToken = default)
+ {
+ _client.DataSourceStatusProvider.StatusChanged -= StatusChangeHandler;
+ _client.FlagTracker.FlagChanged -= FlagChangeHandler;
+ (_client as IDisposable)?.Dispose();
+ _statusProvider.SetStatus(ProviderStatus.NotReady);
+ return Task.CompletedTask;
+ }
+
+ #endregion
+
+ private void FlagChangeHandler(object sender, FlagChangeEvent changeEvent)
+ {
+ Task.Run(() => SafeWriteChangeEvent(changeEvent)).ConfigureAwait(false);
+ }
+
+ private async Task SafeWriteChangeEvent(FlagChangeEvent changeEvent)
+ {
+ try
+ {
+ await EventChannel.Writer.WriteAsync(new ProviderEventPayload
+ {
+ ProviderName = _metadata.Name,
+ Type = ProviderEventTypes.ProviderConfigurationChanged,
+ FlagsChanged = new List { changeEvent.Key },
+ }).ConfigureAwait(false);
+ }
+ catch (Exception e)
+ {
+ _logger.Warn($"Encountered an error sending configuration changed events: {e.Message}");
+ }
+ }
+
+ private void StatusChangeHandler(object sender, DataSourceStatus status)
+ {
+ switch (status.State)
+ {
+ case DataSourceState.Initializing:
+ break;
+ case DataSourceState.Valid:
+ _statusProvider.SetStatus(ProviderStatus.Ready);
+ _initCompletion.TrySetResult(true);
+ break;
+ case DataSourceState.Interrupted:
+ // The "ProviderStatus.Error" state says it is unable to evaluate flags. We can always evaluate
+ // flags.
+ _statusProvider.SetStatus(ProviderStatus.Stale,
+ DescribeLastError(status, "encountered an unknown error"));
+ break;
+ case DataSourceState.Off:
+ default:
+ // The status is an error, and not fatal, because the LaunchDarkly client can continue to
+ // evaluate flags using the data it already has.
+ var message = DescribeLastError(status, ProviderShutdownMessage);
+ _statusProvider.SetStatus(ProviderStatus.Error, message);
+ _initCompletion.TrySetException(new LaunchDarklyProviderInitException(message));
+ break;
+ }
+ }
+
+ private static string DescribeLastError(DataSourceStatus status, string fallback)
+ {
+ if (status.LastError is DataSourceStatus.ErrorInfo lastError)
+ {
+ return lastError.Message ?? lastError.ToString();
+ }
+
+ return fallback;
+ }
+
+ ///
+ public override void Track(string trackingEventName, EvaluationContext evaluationContext = null, TrackingEventDetails trackingEventDetails = default)
+ {
+ var (value, details) = trackingEventDetails.ToLdValue();
+
+ if (value.HasValue)
+ {
+ _client.Track(trackingEventName, _contextConverter.ToLdContext(evaluationContext), details, value.Value);
+ }
+ else if (details.Type != LdValueType.Null)
+ {
+ _client.Track(trackingEventName, _contextConverter.ToLdContext(evaluationContext), details);
+ }
+ else
+ {
+ _client.Track(trackingEventName, _contextConverter.ToLdContext(evaluationContext));
+ }
+ }
+ }
+}
diff --git a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs
index ec642dd..8df398c 100644
--- a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs
+++ b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs
@@ -6,6 +6,7 @@
using LaunchDarkly.Sdk.Server;
using LaunchDarkly.Sdk.Server.Interfaces;
using Moq;
+using OpenFeature.Constant;
using OpenFeature.Model;
using Xunit;
using LaunchDarkly.Sdk.Server.Integrations;
@@ -273,6 +274,78 @@ public async Task ItEmitsConfigurationChangedEvents()
Assert.NotEqual(eventPayloadA?.FlagsChanged[0], eventPayloadB?.FlagsChanged[0]);
}
+ private static async Task EmitStatusAndReadEvent(DataSourceStatus status)
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(l => l.GetLogger())
+ .Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null));
+ mockClient.Setup(l => l.Initialized).Returns(true);
+
+ var mockDataSourceStatus = new Mock();
+ mockDataSourceStatus.Setup(l => l.Status).Returns(new DataSourceStatus
+ {
+ State = DataSourceState.Valid
+ });
+ mockClient.Setup(l => l.DataSourceStatusProvider).Returns(mockDataSourceStatus.Object);
+
+ var mockFlagTracker = new Mock();
+ mockClient.Setup(l => l.FlagTracker).Returns(mockFlagTracker.Object);
+
+ var provider = new Provider(mockClient.Object);
+ await provider.InitializeAsync(EvaluationContext.Empty);
+
+ mockDataSourceStatus.Raise(e => e.StatusChanged += null, mockDataSourceStatus.Object, status);
+
+ return await provider.GetEventChannel().Reader.ReadAsync() as ProviderEventPayload;
+ }
+
+ [Fact(Timeout = 5000)]
+ public async Task ItIncludesTheLastErrorMessageInErrorEvents()
+ {
+ var errorEvent = await EmitStatusAndReadEvent(new DataSourceStatus
+ {
+ State = DataSourceState.Off,
+ LastError = DataSourceStatus.ErrorInfo.FromException(new System.Exception("the key was invalid"))
+ });
+ Assert.Equal(ProviderEventTypes.ProviderError, errorEvent?.Type);
+ Assert.Equal("the key was invalid", errorEvent?.Message);
+ }
+
+ [Fact(Timeout = 5000)]
+ public async Task ItDescribesHttpErrorsWithoutAMessageInErrorEvents()
+ {
+ var lastError = DataSourceStatus.ErrorInfo.FromHttpError(401);
+ var errorEvent = await EmitStatusAndReadEvent(new DataSourceStatus
+ {
+ State = DataSourceState.Off,
+ LastError = lastError
+ });
+ Assert.Equal(ProviderEventTypes.ProviderError, errorEvent?.Type);
+ Assert.Equal(lastError.ToString(), errorEvent?.Message);
+ Assert.Contains("401", errorEvent?.Message);
+ }
+
+ [Fact(Timeout = 5000)]
+ public async Task ItUsesADefaultMessageInErrorEventsWithoutALastError()
+ {
+ var errorEvent = await EmitStatusAndReadEvent(new DataSourceStatus { State = DataSourceState.Off });
+ Assert.Equal(ProviderEventTypes.ProviderError, errorEvent?.Type);
+ Assert.Equal("the provider has encountered a permanent error or been shutdown", errorEvent?.Message);
+ }
+
+ [Fact(Timeout = 5000)]
+ public async Task ItDescribesHttpErrorsWithoutAMessageInStaleEvents()
+ {
+ var lastError = DataSourceStatus.ErrorInfo.FromHttpError(503);
+ var staleEvent = await EmitStatusAndReadEvent(new DataSourceStatus
+ {
+ State = DataSourceState.Interrupted,
+ LastError = lastError
+ });
+ Assert.Equal(ProviderEventTypes.ProviderStale, staleEvent?.Type);
+ Assert.Equal(lastError.ToString(), staleEvent?.Message);
+ }
+
[Fact(Timeout = 5000)]
public void ItTracksCustomEvents()
{
From 432411406830ac4386d34b4523cefe5f6c801898 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Wed, 30 Sep 2026 14:48:28 +0000
Subject: [PATCH 2/2] chore: Restore CRLF line endings
Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
---
.../Provider.cs | 514 +++++++++---------
1 file changed, 257 insertions(+), 257 deletions(-)
diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs
index 8160f2e..5257232 100644
--- a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs
+++ b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs
@@ -1,257 +1,257 @@
-using System;
-using System.Collections.Generic;
-using System.Threading;
-using System.Threading.Tasks;
-using LaunchDarkly.Logging;
-using LaunchDarkly.Sdk;
-using LaunchDarkly.Sdk.Server;
-using LaunchDarkly.Sdk.Server.Interfaces;
-using OpenFeature;
-using OpenFeature.Constant;
-using OpenFeature.Model;
-
-namespace LaunchDarkly.OpenFeature.ServerProvider
-{
- ///
- /// An OpenFeature which enables the use of the LaunchDarkly Server-Side SDK for .NET
- /// with OpenFeature.
- ///
- ///
- /// var provider = new Provider(Configuration.Builder("my-sdk-key").Build());
- ///
- /// OpenFeature.Api.Instance.SetProvider(provider);
- ///
- /// var client = OpenFeature.Api.Instance.GetClient();
- ///
- public sealed partial class Provider : FeatureProvider
- {
- private const string NameSpace = "OpenFeature.ServerProvider";
- private readonly Metadata _metadata = new Metadata($"LaunchDarkly.{NameSpace}");
- private readonly ILdClient _client;
- private readonly EvalContextConverter _contextConverter;
- private readonly StatusProvider _statusProvider;
-
- private readonly object _initLock = new object();
- private bool _initializeCalled = false;
-
- // There is no support for void task completion, so we use bool as a dummy result type.
- private readonly TaskCompletionSource _initCompletion = new TaskCompletionSource();
- private readonly Logger _logger;
-
- private const string ProviderShutdownMessage =
- "the provider has encountered a permanent error or been shutdown";
-
- internal Provider(ILdClient client)
- {
- _client = client;
- _logger = _client.GetLogger().SubLogger(NameSpace);
- _statusProvider = new StatusProvider(EventChannel, _metadata.Name, _logger);
- _contextConverter = new EvalContextConverter(_logger);
- }
-
- ///
- /// Construct a new instance of the provider with the given configuration.
- ///
- /// A client configuration object
- public Provider(Configuration config) : this(new LdClient(WrapConfig(config)))
- {
- }
-
- ///
- /// Construct a new instance of the provider with the given SDK key.
- ///
- /// The SDK key
- public Provider(string sdkKey) : this(new LdClient(WrapConfig(Configuration.Builder(sdkKey).Build())))
- {
- }
-
- ///
- ///
- /// Get the underlying client instance.
- ///
- ///
- /// If the initialization/shutdown features of OpenFeature are being used, then the returned client instance
- /// may be shutdown if the provider has been removed from the OpenFeature API instance.
- ///
- ///
- /// This client instance can be used to take advantage of features which are not part of OpenFeature.
- /// For instance using migration flags.
- ///
- ///
- /// The LaunchDarkly client instance
- public ILdClient GetClient()
- {
- return _client;
- }
-
- private static Configuration WrapConfig(Configuration config)
- {
- return Configuration.Builder(config)
- .WrapperInfo(Components.WrapperInfo().Name("open-feature-dotnet-server")
- .Version(typeof(Provider).Assembly.GetName().Version.ToString()))
- .Build();
- }
-
- #region FeatureProvider Implementation
-
- ///
- public override Metadata GetMetadata() => _metadata;
-
- ///
- public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue,
- EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client
- .BoolVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
- .ToResolutionDetails(flagKey));
-
- ///
- public override Task> ResolveStringValueAsync(string flagKey, string defaultValue,
- EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client
- .StringVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
- .ToResolutionDetails(flagKey));
-
- ///
- public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue,
- EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client
- .IntVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
- .ToResolutionDetails(flagKey));
-
- ///
- public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue,
- EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client
- .DoubleVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
- .ToResolutionDetails(flagKey));
-
- ///
- public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue,
- EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client
- .JsonVariationDetail(flagKey, _contextConverter.ToLdContext(context), LdValue.Null)
- .ToValueDetail(defaultValue).ToResolutionDetails(flagKey));
-
- ///
- public override Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default)
- {
- lock (_initLock)
- {
- if (_initializeCalled)
- {
- return _initCompletion.Task;
- }
- _initializeCalled = true;
- }
-
- _client.FlagTracker.FlagChanged += FlagChangeHandler;
-
- _client.DataSourceStatusProvider.StatusChanged += StatusChangeHandler;
-
- // We start listening for status changes, and then we check the current status change. If we do not check
- // then we could have missed a status change. If we check before registering a listener, then we could
- // miss a change between checking and listening. Doing it this way we can get duplicates, but we filter
- // when the status does not actually change, so we won't emit duplicate events.
- if (_client.Initialized)
- {
- _statusProvider.SetStatus(ProviderStatus.Ready);
- _initCompletion.TrySetResult(true);
- }
-
- var dataSourceStatus = _client.DataSourceStatusProvider.Status;
- if (dataSourceStatus.State == DataSourceState.Off)
- {
- var message = DescribeLastError(dataSourceStatus, ProviderShutdownMessage);
- _statusProvider.SetStatus(ProviderStatus.Error, message);
- _initCompletion.TrySetException(new LaunchDarklyProviderInitException(message));
- }
-
- return _initCompletion.Task;
- }
-
- ///
- public override Task ShutdownAsync(CancellationToken cancellationToken = default)
- {
- _client.DataSourceStatusProvider.StatusChanged -= StatusChangeHandler;
- _client.FlagTracker.FlagChanged -= FlagChangeHandler;
- (_client as IDisposable)?.Dispose();
- _statusProvider.SetStatus(ProviderStatus.NotReady);
- return Task.CompletedTask;
- }
-
- #endregion
-
- private void FlagChangeHandler(object sender, FlagChangeEvent changeEvent)
- {
- Task.Run(() => SafeWriteChangeEvent(changeEvent)).ConfigureAwait(false);
- }
-
- private async Task SafeWriteChangeEvent(FlagChangeEvent changeEvent)
- {
- try
- {
- await EventChannel.Writer.WriteAsync(new ProviderEventPayload
- {
- ProviderName = _metadata.Name,
- Type = ProviderEventTypes.ProviderConfigurationChanged,
- FlagsChanged = new List { changeEvent.Key },
- }).ConfigureAwait(false);
- }
- catch (Exception e)
- {
- _logger.Warn($"Encountered an error sending configuration changed events: {e.Message}");
- }
- }
-
- private void StatusChangeHandler(object sender, DataSourceStatus status)
- {
- switch (status.State)
- {
- case DataSourceState.Initializing:
- break;
- case DataSourceState.Valid:
- _statusProvider.SetStatus(ProviderStatus.Ready);
- _initCompletion.TrySetResult(true);
- break;
- case DataSourceState.Interrupted:
- // The "ProviderStatus.Error" state says it is unable to evaluate flags. We can always evaluate
- // flags.
- _statusProvider.SetStatus(ProviderStatus.Stale,
- DescribeLastError(status, "encountered an unknown error"));
- break;
- case DataSourceState.Off:
- default:
- // The status is an error, and not fatal, because the LaunchDarkly client can continue to
- // evaluate flags using the data it already has.
- var message = DescribeLastError(status, ProviderShutdownMessage);
- _statusProvider.SetStatus(ProviderStatus.Error, message);
- _initCompletion.TrySetException(new LaunchDarklyProviderInitException(message));
- break;
- }
- }
-
- private static string DescribeLastError(DataSourceStatus status, string fallback)
- {
- if (status.LastError is DataSourceStatus.ErrorInfo lastError)
- {
- return lastError.Message ?? lastError.ToString();
- }
-
- return fallback;
- }
-
- ///
- public override void Track(string trackingEventName, EvaluationContext evaluationContext = null, TrackingEventDetails trackingEventDetails = default)
- {
- var (value, details) = trackingEventDetails.ToLdValue();
-
- if (value.HasValue)
- {
- _client.Track(trackingEventName, _contextConverter.ToLdContext(evaluationContext), details, value.Value);
- }
- else if (details.Type != LdValueType.Null)
- {
- _client.Track(trackingEventName, _contextConverter.ToLdContext(evaluationContext), details);
- }
- else
- {
- _client.Track(trackingEventName, _contextConverter.ToLdContext(evaluationContext));
- }
- }
- }
-}
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using LaunchDarkly.Logging;
+using LaunchDarkly.Sdk;
+using LaunchDarkly.Sdk.Server;
+using LaunchDarkly.Sdk.Server.Interfaces;
+using OpenFeature;
+using OpenFeature.Constant;
+using OpenFeature.Model;
+
+namespace LaunchDarkly.OpenFeature.ServerProvider
+{
+ ///
+ /// An OpenFeature which enables the use of the LaunchDarkly Server-Side SDK for .NET
+ /// with OpenFeature.
+ ///
+ ///
+ /// var provider = new Provider(Configuration.Builder("my-sdk-key").Build());
+ ///
+ /// OpenFeature.Api.Instance.SetProvider(provider);
+ ///
+ /// var client = OpenFeature.Api.Instance.GetClient();
+ ///
+ public sealed partial class Provider : FeatureProvider
+ {
+ private const string NameSpace = "OpenFeature.ServerProvider";
+ private readonly Metadata _metadata = new Metadata($"LaunchDarkly.{NameSpace}");
+ private readonly ILdClient _client;
+ private readonly EvalContextConverter _contextConverter;
+ private readonly StatusProvider _statusProvider;
+
+ private readonly object _initLock = new object();
+ private bool _initializeCalled = false;
+
+ // There is no support for void task completion, so we use bool as a dummy result type.
+ private readonly TaskCompletionSource _initCompletion = new TaskCompletionSource();
+ private readonly Logger _logger;
+
+ private const string ProviderShutdownMessage =
+ "the provider has encountered a permanent error or been shutdown";
+
+ internal Provider(ILdClient client)
+ {
+ _client = client;
+ _logger = _client.GetLogger().SubLogger(NameSpace);
+ _statusProvider = new StatusProvider(EventChannel, _metadata.Name, _logger);
+ _contextConverter = new EvalContextConverter(_logger);
+ }
+
+ ///
+ /// Construct a new instance of the provider with the given configuration.
+ ///
+ /// A client configuration object
+ public Provider(Configuration config) : this(new LdClient(WrapConfig(config)))
+ {
+ }
+
+ ///
+ /// Construct a new instance of the provider with the given SDK key.
+ ///
+ /// The SDK key
+ public Provider(string sdkKey) : this(new LdClient(WrapConfig(Configuration.Builder(sdkKey).Build())))
+ {
+ }
+
+ ///
+ ///
+ /// Get the underlying client instance.
+ ///
+ ///
+ /// If the initialization/shutdown features of OpenFeature are being used, then the returned client instance
+ /// may be shutdown if the provider has been removed from the OpenFeature API instance.
+ ///
+ ///
+ /// This client instance can be used to take advantage of features which are not part of OpenFeature.
+ /// For instance using migration flags.
+ ///
+ ///
+ /// The LaunchDarkly client instance
+ public ILdClient GetClient()
+ {
+ return _client;
+ }
+
+ private static Configuration WrapConfig(Configuration config)
+ {
+ return Configuration.Builder(config)
+ .WrapperInfo(Components.WrapperInfo().Name("open-feature-dotnet-server")
+ .Version(typeof(Provider).Assembly.GetName().Version.ToString()))
+ .Build();
+ }
+
+ #region FeatureProvider Implementation
+
+ ///
+ public override Metadata GetMetadata() => _metadata;
+
+ ///
+ public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue,
+ EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client
+ .BoolVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
+ .ToResolutionDetails(flagKey));
+
+ ///
+ public override Task> ResolveStringValueAsync(string flagKey, string defaultValue,
+ EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client
+ .StringVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
+ .ToResolutionDetails(flagKey));
+
+ ///
+ public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue,
+ EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client
+ .IntVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
+ .ToResolutionDetails(flagKey));
+
+ ///
+ public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue,
+ EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client
+ .DoubleVariationDetail(flagKey, _contextConverter.ToLdContext(context), defaultValue)
+ .ToResolutionDetails(flagKey));
+
+ ///
+ public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue,
+ EvaluationContext context = null, CancellationToken cancellationToken = default) => Task.FromResult(_client
+ .JsonVariationDetail(flagKey, _contextConverter.ToLdContext(context), LdValue.Null)
+ .ToValueDetail(defaultValue).ToResolutionDetails(flagKey));
+
+ ///
+ public override Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default)
+ {
+ lock (_initLock)
+ {
+ if (_initializeCalled)
+ {
+ return _initCompletion.Task;
+ }
+ _initializeCalled = true;
+ }
+
+ _client.FlagTracker.FlagChanged += FlagChangeHandler;
+
+ _client.DataSourceStatusProvider.StatusChanged += StatusChangeHandler;
+
+ // We start listening for status changes, and then we check the current status change. If we do not check
+ // then we could have missed a status change. If we check before registering a listener, then we could
+ // miss a change between checking and listening. Doing it this way we can get duplicates, but we filter
+ // when the status does not actually change, so we won't emit duplicate events.
+ if (_client.Initialized)
+ {
+ _statusProvider.SetStatus(ProviderStatus.Ready);
+ _initCompletion.TrySetResult(true);
+ }
+
+ var dataSourceStatus = _client.DataSourceStatusProvider.Status;
+ if (dataSourceStatus.State == DataSourceState.Off)
+ {
+ var message = DescribeLastError(dataSourceStatus, ProviderShutdownMessage);
+ _statusProvider.SetStatus(ProviderStatus.Error, message);
+ _initCompletion.TrySetException(new LaunchDarklyProviderInitException(message));
+ }
+
+ return _initCompletion.Task;
+ }
+
+ ///
+ public override Task ShutdownAsync(CancellationToken cancellationToken = default)
+ {
+ _client.DataSourceStatusProvider.StatusChanged -= StatusChangeHandler;
+ _client.FlagTracker.FlagChanged -= FlagChangeHandler;
+ (_client as IDisposable)?.Dispose();
+ _statusProvider.SetStatus(ProviderStatus.NotReady);
+ return Task.CompletedTask;
+ }
+
+ #endregion
+
+ private void FlagChangeHandler(object sender, FlagChangeEvent changeEvent)
+ {
+ Task.Run(() => SafeWriteChangeEvent(changeEvent)).ConfigureAwait(false);
+ }
+
+ private async Task SafeWriteChangeEvent(FlagChangeEvent changeEvent)
+ {
+ try
+ {
+ await EventChannel.Writer.WriteAsync(new ProviderEventPayload
+ {
+ ProviderName = _metadata.Name,
+ Type = ProviderEventTypes.ProviderConfigurationChanged,
+ FlagsChanged = new List { changeEvent.Key },
+ }).ConfigureAwait(false);
+ }
+ catch (Exception e)
+ {
+ _logger.Warn($"Encountered an error sending configuration changed events: {e.Message}");
+ }
+ }
+
+ private void StatusChangeHandler(object sender, DataSourceStatus status)
+ {
+ switch (status.State)
+ {
+ case DataSourceState.Initializing:
+ break;
+ case DataSourceState.Valid:
+ _statusProvider.SetStatus(ProviderStatus.Ready);
+ _initCompletion.TrySetResult(true);
+ break;
+ case DataSourceState.Interrupted:
+ // The "ProviderStatus.Error" state says it is unable to evaluate flags. We can always evaluate
+ // flags.
+ _statusProvider.SetStatus(ProviderStatus.Stale,
+ DescribeLastError(status, "encountered an unknown error"));
+ break;
+ case DataSourceState.Off:
+ default:
+ // The status is an error, and not fatal, because the LaunchDarkly client can continue to
+ // evaluate flags using the data it already has.
+ var message = DescribeLastError(status, ProviderShutdownMessage);
+ _statusProvider.SetStatus(ProviderStatus.Error, message);
+ _initCompletion.TrySetException(new LaunchDarklyProviderInitException(message));
+ break;
+ }
+ }
+
+ private static string DescribeLastError(DataSourceStatus status, string fallback)
+ {
+ if (status.LastError is DataSourceStatus.ErrorInfo lastError)
+ {
+ return lastError.Message ?? lastError.ToString();
+ }
+
+ return fallback;
+ }
+
+ ///
+ public override void Track(string trackingEventName, EvaluationContext evaluationContext = null, TrackingEventDetails trackingEventDetails = default)
+ {
+ var (value, details) = trackingEventDetails.ToLdValue();
+
+ if (value.HasValue)
+ {
+ _client.Track(trackingEventName, _contextConverter.ToLdContext(evaluationContext), details, value.Value);
+ }
+ else if (details.Type != LdValueType.Null)
+ {
+ _client.Track(trackingEventName, _contextConverter.ToLdContext(evaluationContext), details);
+ }
+ else
+ {
+ _client.Track(trackingEventName, _contextConverter.ToLdContext(evaluationContext));
+ }
+ }
+ }
+}