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)); + } + } + } +}