From fe8db9883b2f8ad84dc71c9f8b24e3c61abc9c6d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 23 Feb 2024 08:50:03 -0800 Subject: [PATCH] feat: Add support for initialization, shutdown, and provider status events. (#26) I will add configuration change event support in another PR. --- README.md | 52 +++++- .../EvalContextConverter.cs | 4 +- ...chDarkly.OpenFeature.ServerProvider.csproj | 4 +- .../LaunchDarklyProviderInitException.cs | 20 +++ .../Provider.StatusProvider.cs | 95 +++++++++++ .../Provider.cs | 139 +++++++++++++--- .../ProviderConfiguration.cs | 77 --------- .../ProviderConfigurationBuilder.cs | 112 ------------- .../EvalContextConverterTests.cs | 7 +- .../ProviderConfigurationTests.cs | 32 ---- .../ProviderTests.cs | 157 +++++++++++++++--- 11 files changed, 417 insertions(+), 282 deletions(-) create mode 100644 src/LaunchDarkly.OpenFeature.ServerProvider/LaunchDarklyProviderInitException.cs create mode 100644 src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs delete mode 100644 src/LaunchDarkly.OpenFeature.ServerProvider/ProviderConfiguration.cs delete mode 100644 src/LaunchDarkly.OpenFeature.ServerProvider/ProviderConfigurationBuilder.cs delete mode 100644 test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderConfigurationTests.cs diff --git a/README.md b/README.md index 87b6f7d..e73dd8f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,11 @@ This provider allows for using LaunchDarkly with the OpenFeature SDK for .NET. This provider is designed primarily for use in multi-user systems such as web servers and applications. It follows the server-side LaunchDarkly model for multi-user contexts. It is not intended for use in desktop and embedded systems applications. -This provider is a beta version and should not be considered ready for production use while this message is visible. +> [!WARNING] +> This is a beta version. The API is not stabilized and may introduce breaking changes. + +> [!NOTE] +> This OpenFeature provider uses production versions of the LaunchDarkly SDK, which adhere to our standard [versioning policy](https://docs.launchdarkly.com/home/relay-proxy/versioning). # LaunchDarkly overview @@ -25,14 +29,14 @@ This version of the SDK is built for the following targets: ### Installation -``` +```bash dotnet add package LaunchDarkly.ServerSdk dotnet add package LaunchDarkly.OpenFeature.ServerProvider dotnet add package OpenFeature ``` ### Usage -``` +```csharp using LaunchDarkly.OpenFeature.ServerProvider; using LaunchDarkly.Sdk.Server; @@ -40,10 +44,13 @@ var config = Configuration.Builder("my-sdk-key") .StartWaitTime(TimeSpan.FromSeconds(10)) .Build(); -var ldClient = new LdClient(config); -var provider = new Provider(ldClient); +var provider = new Provider(config); + +// If you need access to the LdClient, then you can use GetClient(). +// This can be used for use-cases that are not supported by OpenFeature such as migration flags and track events. +var ldClient = provider.GetClient() -OpenFeature.Api.Instance.SetProvider(provider); +await OpenFeature.Api.Instance.SetProviderAsync(provider); ``` Refer to the [SDK reference guide](https://docs.launchdarkly.com/sdk/server-side/dotnet) for instructions on getting started with using the SDK. @@ -144,6 +151,39 @@ var evaluationContext = EvaluationContext.Builder() .Build(); ``` +### Advanced Usage + +#### Asynchronous Initialization + +The LaunchDarkly SDK by default blocks on construction for up to 5 seconds for initialization. If you require construction to be non-blocking, then you can adjust the `startWaitTime` to `TimeSpan.Zero`. Initialization will be completed asynchronously and OpenFeature will emit a ready event when the provider has initialized. The `SetProviderAsync` method can be awaited to wait for the SDK to finish initialization. + +```csharp +var config = Configuration.Builder("my-sdk-key") + .StartWaitTime(TimeSpan.Zero) + .Build(); +``` + +#### Provider Shutdown + +This provider cannot be re-initialized after being shutdown. This will not impact typical usage, as the LaunchDarkly provider will be set once and used throughout the execution of the application. If you remove the LaunchDarkly Provider, by replacing the default provider or any named providers aliased to the LaunchDarkly provider, then you must create a new provider instance. + +```csharp +var ldProvider = new Provider(config); + +await OpenFeature.Api.Instance.SetProviderAsync(provider); +await OpenFeatute.Api.Instance.SetProviderAsync(new SomeOtherProvider()); +/// The LaunchDarkly provider will be shutdown and SomeOtherProvider will start handling requests. + +// This provider will never finish initializing. +await OpenFeature.Api.Instance.SetProviderAsync(ldProvider); + +// Instead you should create a new provider. +var ldProvider2 = new Provider(config); +await OpenFeature.Api.Instance.SetProviderAsync(ldProvider2); + +``` + + ## Learn more Read our [documentation](http://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly. You can also head straight to the [complete reference guide for this SDK](https://docs.launchdarkly.com/sdk/server-side/dotnet). diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/EvalContextConverter.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/EvalContextConverter.cs index 2bdce34..70ff523 100644 --- a/src/LaunchDarkly.OpenFeature.ServerProvider/EvalContextConverter.cs +++ b/src/LaunchDarkly.OpenFeature.ServerProvider/EvalContextConverter.cs @@ -195,13 +195,13 @@ private Context BuildSingleLdContext(IImmutableDictionary attribu if (keyAttr != null && targetingKey != null) { _log.Warn("The EvaluationContext contained both a 'targetingKey' and a 'key' attribute. The 'key'" + - "attribute will be discarded."); + " attribute will be discarded."); } if (finalKey == null) { _log.Error("The EvaluationContext must contain either a 'targetingKey' or a 'key' and the type" + - "must be a string."); + " must be a string."); } var contextBuilder = Context.Builder(ContextKind.Of(kindString), finalKey); diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/LaunchDarkly.OpenFeature.ServerProvider.csproj b/src/LaunchDarkly.OpenFeature.ServerProvider/LaunchDarkly.OpenFeature.ServerProvider.csproj index 63f56d5..b2c2ec2 100644 --- a/src/LaunchDarkly.OpenFeature.ServerProvider/LaunchDarkly.OpenFeature.ServerProvider.csproj +++ b/src/LaunchDarkly.OpenFeature.ServerProvider/LaunchDarkly.OpenFeature.ServerProvider.csproj @@ -40,8 +40,8 @@ - - + + diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/LaunchDarklyProviderInitException.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/LaunchDarklyProviderInitException.cs new file mode 100644 index 0000000..6a9ad6b --- /dev/null +++ b/src/LaunchDarkly.OpenFeature.ServerProvider/LaunchDarklyProviderInitException.cs @@ -0,0 +1,20 @@ +using System; + +namespace LaunchDarkly.OpenFeature.ServerProvider +{ + /// + /// This exception is used to indicate that the provider has encountered a permanent exception, or has been + /// shutdown, during initialization. + /// + public class LaunchDarklyProviderInitException: Exception + { + /// + /// Construct an exception with the given message. + /// + /// The exception message + public LaunchDarklyProviderInitException(string message) + : base(message) + { + } + } +} diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs new file mode 100644 index 0000000..5d8ed6b --- /dev/null +++ b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.StatusProvider.cs @@ -0,0 +1,95 @@ +using System.Threading.Channels; +using System.Threading.Tasks; +using LaunchDarkly.Logging; +using OpenFeature.Constant; +using OpenFeature.Model; + +namespace LaunchDarkly.OpenFeature.ServerProvider +{ + public sealed partial class Provider + { + private class StatusProvider + { + private ProviderStatus _providerStatus = ProviderStatus.NotReady; + private object _statusLock = new object(); + private Channel _eventChannel; + private string _providerName; + private Logger _logger; + + public StatusProvider(Channel eventChannel, string providerName, Logger logger) + { + _eventChannel = eventChannel; + _providerName = providerName; + _logger = logger; + } + + private void EmitProviderEvent(ProviderEventTypes type, string message) + { + var payload = new ProviderEventPayload + { + Type = type, + ProviderName = _providerName + }; + if (message != null) + { + payload.Message = message; + } + + // Trigger the task do run, but don't wait for it. We wrap the exceptions inside SafeWrite, + // so we aren't going to have unexpected exceptions here. + Task.Run(() => SafeWrite(payload)).ConfigureAwait(false); + } + + private async Task SafeWrite(ProviderEventPayload payload) + { + try + { + await _eventChannel.Writer.WriteAsync(payload).ConfigureAwait(false); + } + catch + { + _logger.Warn("Failed to send provider status event"); + } + } + + public void SetStatus(ProviderStatus status, string message = null) + { + lock (_statusLock) + { + if (status == _providerStatus) + { + return; + } + + _providerStatus = status; + switch (status) + { + case ProviderStatus.NotReady: + break; + case ProviderStatus.Ready: + EmitProviderEvent(ProviderEventTypes.ProviderReady, message); + break; + case ProviderStatus.Stale: + EmitProviderEvent(ProviderEventTypes.ProviderStale, message); + break; + case ProviderStatus.Error: + default: + EmitProviderEvent(ProviderEventTypes.ProviderError, message); + break; + } + } + } + + public ProviderStatus Status + { + get + { + lock (_statusLock) + { + return _providerStatus; + } + } + } + } + } +} diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs index 21d7336..e40e2c5 100644 --- a/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs +++ b/src/LaunchDarkly.OpenFeature.ServerProvider/Provider.cs @@ -1,10 +1,11 @@ -using System.Threading.Tasks; -using LaunchDarkly.Logging; +using System; +using System.Threading.Tasks; using LaunchDarkly.Sdk; +using LaunchDarkly.Sdk.Internal.Concurrent; using LaunchDarkly.Sdk.Server; using LaunchDarkly.Sdk.Server.Interfaces; -using LaunchDarkly.Sdk.Server.Subsystems; using OpenFeature; +using OpenFeature.Constant; using OpenFeature.Model; namespace LaunchDarkly.OpenFeature.ServerProvider @@ -14,39 +15,67 @@ namespace LaunchDarkly.OpenFeature.ServerProvider /// with OpenFeature. /// /// - /// var config = Configuration.Builder("my-sdk-key") - /// .Build(); - /// - /// var ldClient = new LdClient(config); - /// var provider = new Provider(ldClient); + /// var provider = new Provider(Configuration.Builder("my-sdk-key").Build()); /// /// OpenFeature.Api.Instance.SetProvider(provider); /// /// var client = OpenFeature.Api.Instance.GetClient(); /// - public sealed class Provider : FeatureProvider + 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 AtomicBoolean _initializeCalled = new AtomicBoolean(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 const string ProviderShutdownMessage = + "the provider has encountered a permanent error or been shutdown"; + + internal Provider(ILdClient client) + { + _client = client; + var logger = _client.GetLogger().SubLogger(NameSpace); + _statusProvider = new StatusProvider(EventChannel, _metadata.Name, logger); + _contextConverter = new EvalContextConverter(logger); + } /// - /// Construct a new instance of the provider. + /// Construct a new instance of the provider with the given configuration. /// - /// The instance - /// An optional - public Provider(ILdClient client, ProviderConfiguration config = null) + /// A client configuration object + public Provider(Configuration config) : this(new LdClient(config)) + { + } + + /// + /// Construct a new instance of the provider with the given SDK key. + /// + /// The SDK key + public Provider(string sdkKey) : this(new LdClient(sdkKey)) { - _client = client; - var logConfig = (config?.LoggingConfigurationFactory ?? Components.Logging()) - .Build(null); - - // If there is a base name for the logger, then use the namespace as the name. - var log = logConfig.LogAdapter.Logger(logConfig.BaseLoggerName != null - ? $"{logConfig.BaseLoggerName}.{NameSpace}" - : _metadata.Name); - _contextConverter = new EvalContextConverter(log); + } + + /// + /// + /// 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; } #region FeatureProvider Implementation @@ -84,6 +113,72 @@ public override Task> ResolveStructureValue(string flag .JsonVariationDetail(flagKey, _contextConverter.ToLdContext(context), LdValue.Null) .ToValueDetail(defaultValue).ToResolutionDetails(flagKey)); + /// + public override ProviderStatus GetStatus() + { + return _statusProvider.Status; + } + + /// + public override Task Initialize(EvaluationContext context) + { + if (_initializeCalled.GetAndSet(true)) + { + return _initCompletion.Task; + } + + _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 Shutdown() + { + _client.DataSourceStatusProvider.StatusChanged -= StatusChangeHandler; + (_client as IDisposable)?.Dispose(); + _statusProvider.SetStatus(ProviderStatus.NotReady); + return Task.CompletedTask; + } + #endregion + + 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: + _statusProvider.SetStatus(ProviderStatus.Error, + status.LastError?.Message ?? "encountered an unknown error"); + break; + case DataSourceState.Off: + default: + _statusProvider.SetStatus(ProviderStatus.Error, ProviderShutdownMessage); + _initCompletion.TrySetException(new LaunchDarklyProviderInitException(ProviderShutdownMessage)); + break; + } + } } } diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/ProviderConfiguration.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/ProviderConfiguration.cs deleted file mode 100644 index 54ce120..0000000 --- a/src/LaunchDarkly.OpenFeature.ServerProvider/ProviderConfiguration.cs +++ /dev/null @@ -1,77 +0,0 @@ -using LaunchDarkly.Sdk.Server.Interfaces; -using LaunchDarkly.Sdk.Server.Subsystems; - -namespace LaunchDarkly.OpenFeature.ServerProvider -{ - /// - /// Configuration options for . This class should normally be constructed with - /// . - /// - /// - /// Instances of are immutable once created. They can be created using a builder - /// pattern with . - /// - public sealed class ProviderConfiguration - { - /// - /// Creates a for constructing a configuration object using a fluent - /// syntax. - /// - /// - /// The has methods for setting any number of - /// properties, after which you call to get the resulting - /// ProviderConfiguration instance. - /// - /// - /// - /// var config = ProviderConfigurationBuilder.Builder() - /// .Logging(Components.NoLogging) - /// .Build(); - /// - /// - /// a builder object - public static ProviderConfigurationBuilder Builder() - { - return new ProviderConfigurationBuilder(); - } - - /// - /// Creates an based on an existing configuration. - /// - /// - /// Modifying properties of the builder will not affect the original configuration object. - /// - /// - /// - /// var configWithCustomEventProperties = Configuration.Builder(originalConfig) - /// .Logging(Components.NoLogging) - /// .Build(); - /// - /// - /// the existing configuration - /// a builder object - public static ProviderConfigurationBuilder Builder(ProviderConfiguration fromConfiguration) - { - return new ProviderConfigurationBuilder(fromConfiguration); - } - - /// - /// A factory object that creates a , defining the SDK's - /// logging configuration. - /// - /// - /// SDK components should not use this property directly; instead, the SDK client will use it to create a - /// logger instance which will be in . - /// - public IComponentConfigurer LoggingConfigurationFactory { get; } - - #region Internal constructor - - internal ProviderConfiguration(ProviderConfigurationBuilder builder) - { - LoggingConfigurationFactory = builder._loggingConfigurationFactory; - } - - #endregion - } -} diff --git a/src/LaunchDarkly.OpenFeature.ServerProvider/ProviderConfigurationBuilder.cs b/src/LaunchDarkly.OpenFeature.ServerProvider/ProviderConfigurationBuilder.cs deleted file mode 100644 index df8fbf7..0000000 --- a/src/LaunchDarkly.OpenFeature.ServerProvider/ProviderConfigurationBuilder.cs +++ /dev/null @@ -1,112 +0,0 @@ -using LaunchDarkly.Logging; -using LaunchDarkly.Sdk.Server; -using LaunchDarkly.Sdk.Server.Subsystems; - -namespace LaunchDarkly.OpenFeature.ServerProvider -{ - /// - /// A mutable object that uses the Builder pattern to specify properties for a object. - /// - /// - /// Obtain an instance of this class by calling . - /// - /// All of the builder methods for setting a configuration property return a reference to the same builder, so they can be - /// chained together. - /// - /// - /// - /// var config = ProviderConfigurationBuilder.Builder() - /// .Logging(Components.NoLogging) - /// .Build(); - /// - /// - public sealed class ProviderConfigurationBuilder - { - #region Internal properties - - // Rider/ReSharper would prefer these not use the private naming convention. - // ReSharper disable once InconsistentNaming - internal IComponentConfigurer _loggingConfigurationFactory; - - #endregion - - #region Internal constructor - - internal ProviderConfigurationBuilder() {} - - internal ProviderConfigurationBuilder(ProviderConfiguration fromConfiguration) - { - _loggingConfigurationFactory = fromConfiguration.LoggingConfigurationFactory; - } - - #endregion - /// - /// Sets the provider's logging configuration, using a factory object. - /// - /// - /// - /// This object is normally a configuration builder obtained from - /// which has methods for setting individual logging-related properties. As a shortcut for disabling - /// logging, you may use instead. If all you want to do is to set - /// the basic logging destination, and you do not need to set other logging properties, you can use - /// instead. - /// - /// - /// The provider uses the same logging mechanism as the LaunchDarkly Server-Side SDK for .NET. - /// - /// - /// For more about how logging works in the SDK, see the SDK - /// SDK reference guide. - /// - /// - /// - /// var config = ProviderConfigurationBuilder.Builder() - /// .Logging(Components.Logging().Level(LogLevel.Warn))) - /// .Build(); - /// - /// the factory object - /// the same builder - /// - /// - /// - /// - public ProviderConfigurationBuilder Logging(IComponentConfigurer loggingConfigurationFactory) - { - _loggingConfigurationFactory = loggingConfigurationFactory; - return this; - } - - /// - /// Sets the provider's logging destination. - /// - /// - /// - /// This is a shortcut for Logging(Components.Logging(logAdapter)). You can use it when you - /// only want to specify the basic logging destination, and do not need to set other log properties. - /// - /// - /// For more about how logging works in the SDK, see the SDK - /// SDK reference guide. - /// - /// - /// - /// var config = ProviderConfigurationBuilder.Builder() - /// .Logging(Logs.ToWriter(Console.Out)) - /// .Build(); - /// - /// an ILogAdapter for the desired logging implementation - /// the same builder - public ProviderConfigurationBuilder Logging(ILogAdapter logAdapter) => - Logging(Components.Logging(logAdapter)); - - /// - /// Creates a based on the properties that have been set on the builder. - /// Modifying the builder after this point does not affect the returned . - /// - /// the configured ProviderConfiguration object - public ProviderConfiguration Build() - { - return new ProviderConfiguration(this); - } - } -} diff --git a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/EvalContextConverterTests.cs b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/EvalContextConverterTests.cs index 5e59077..2c0c433 100644 --- a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/EvalContextConverterTests.cs +++ b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/EvalContextConverterTests.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using LaunchDarkly.Logging; using LaunchDarkly.Sdk; -using LaunchDarkly.Sdk.Server; using OpenFeature.Model; using Xunit; @@ -9,7 +8,7 @@ namespace LaunchDarkly.OpenFeature.ServerProvider.Tests { public class EvalContextConverterTests { - private LogCapture _logCapture; + private readonly LogCapture _logCapture; private readonly EvalContextConverter _converter; public EvalContextConverterTests() @@ -83,7 +82,7 @@ public void ItLogsAndErrorWhenThereIsNoTargetingKey() _converter.ToLdContext(EvaluationContext.Empty); Assert.True(_logCapture.HasMessageWithText(LogLevel.Error, "The EvaluationContext must contain either a 'targetingKey' or a 'key' and the type" + - "must be a string.")); + " must be a string.")); } [Fact] @@ -92,7 +91,7 @@ public void ItLogsAWarningWhenBothTargetingKeyAndKeyAreDefined() _converter.ToLdContext(EvaluationContext.Builder().Set("targetingKey", "key").Set("key", "key").Build()); Assert.True(_logCapture.HasMessageWithText(LogLevel.Warn, "The EvaluationContext contained both a 'targetingKey' and a 'key' attribute. The 'key'" + - "attribute will be discarded.")); + " attribute will be discarded.")); } [Theory] diff --git a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderConfigurationTests.cs b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderConfigurationTests.cs deleted file mode 100644 index f39c5e0..0000000 --- a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderConfigurationTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using LaunchDarkly.Logging; -using LaunchDarkly.Sdk.Server; -using LaunchDarkly.TestHelpers; -using Xunit; - -namespace LaunchDarkly.OpenFeature.ServerProvider.Tests -{ - public class ProviderConfigurationTests - { - private readonly BuilderBehavior.BuildTester _tester = - BuilderBehavior.For(ProviderConfiguration.Builder, b => b.Build()) - .WithCopyConstructor(ProviderConfiguration.Builder); - - [Fact] - public void Logging() - { - var prop = _tester.Property(c => c.LoggingConfigurationFactory, (b, v) => b.Logging(v)); - prop.AssertDefault(null); - prop.AssertCanSet(Components.Logging(Logs.ToWriter(Console.Out))); - } - - [Fact] - public void LoggingAdapterShortcut() - { - var adapter = Logs.ToWriter(Console.Out); - var config = ProviderConfiguration.Builder().Logging(adapter).Build(); - var logConfig = config.LoggingConfigurationFactory.Build(null); - Assert.Same(adapter, logConfig.LogAdapter); - } - } -} diff --git a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs index b85827f..b9daca1 100644 --- a/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs +++ b/test/LaunchDarkly.OpenFeature.ServerProvider.Tests/ProviderTests.cs @@ -1,9 +1,11 @@ +using System.Threading.Tasks; +using System.Timers; using LaunchDarkly.Logging; using LaunchDarkly.Sdk; using LaunchDarkly.Sdk.Server; -using LaunchDarkly.Sdk.Server.Integrations; using LaunchDarkly.Sdk.Server.Interfaces; using Moq; +using OpenFeature.Constant; using OpenFeature.Model; using Xunit; @@ -17,43 +19,133 @@ public class ProviderTests [Fact] public void ItCanProvideMetaData() { - var mock = new Mock(); - var provider = new Provider(mock.Object); + var provider = new Provider(Configuration.Builder("").Offline(true).Build()); Assert.Equal("LaunchDarkly.OpenFeature.ServerProvider", provider.GetMetadata().Name); } [Fact] - public void ItCanBeConstructedWithLoggingConfiguration() + public async Task ItHandlesValidInitializationWhenClientIsImmediatelyReady() { - var logCapture = new LogCapture(); - var mock = new Mock(); - var provider = new Provider(mock.Object, ProviderConfiguration.Builder().Logging( - Components.Logging().Adapter(logCapture) - ).Build()); + var provider = new Provider(Configuration.Builder("").Offline(true).Build()); - // This context is malformed and will cause a log. - var evaluationContext = EvaluationContext.Builder() - .Set("targetingKey", "the-key") - .Set("key", "the-key") - .Build(); + await provider.Initialize(EvaluationContext.Builder().Set("key", "test").Build()); + Assert.Equal(ProviderStatus.Ready, provider.GetStatus()); - provider.ResolveBooleanValue("the-flag", false, evaluationContext); - Assert.True(logCapture.HasMessageWithText(LogLevel.Warn, - "The EvaluationContext contained both a 'targetingKey' and a 'key' attribute. The 'key'" + - "attribute will be discarded.")); + var eventContent = await provider.GetEventChannel().Reader.ReadAsync(); + var payload = eventContent as ProviderEventPayload; + Assert.Equal(ProviderEventTypes.ProviderReady, payload?.Type); + } + + [Fact] + public async Task ItHandlesMultipleCallsToInitialize() + { + var provider = new Provider(Configuration.Builder("").Offline(true).Build()); + + await provider.Initialize(EvaluationContext.Builder().Set("key", "test").Build()); + await provider.Initialize(EvaluationContext.Builder().Set("key", "test").Build()); + Assert.Equal(ProviderStatus.Ready, provider.GetStatus()); - Assert.Equal("LaunchDarkly.OpenFeature.ServerProvider", logCapture.GetMessages()[0].LoggerName); + var eventContent = await provider.GetEventChannel().Reader.ReadAsync(); + var payload = eventContent as ProviderEventPayload; + Assert.Equal(ProviderEventTypes.ProviderReady, payload?.Type); } [Fact] - public void ItUsesTheBaseLoggerNameSpecifiedInTheLoggingConfiguration() + public async Task ItHandlesValidInitializationWhenClientIsReadyAfterADelay() + { + var mockClient = new Mock(); + mockClient.Setup(l => l.GetLogger()) + .Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null)); + + var mockDataSourceStatus = new Mock(); + mockDataSourceStatus.Setup(l => l.Status).Returns(new DataSourceStatus + { + State = DataSourceState.Initializing + }); + mockClient.Setup(l => l.DataSourceStatusProvider).Returns(mockDataSourceStatus.Object); + + var provider = new Provider(mockClient.Object); + + // Setup a timer to indicate that the client has initialized after some amount of time. + var completionTimer = new Timer(100); + completionTimer.AutoReset = false; + completionTimer.Elapsed += (sender, args) => + { + mockDataSourceStatus.Raise(e => e.StatusChanged += null, + mockDataSourceStatus.Object, + new DataSourceStatus {State = DataSourceState.Valid}); + }; + completionTimer.Start(); + + await provider.Initialize(EvaluationContext.Empty); + var eventContent = await provider.GetEventChannel().Reader.ReadAsync(); + var payload = eventContent as ProviderEventPayload; + Assert.Equal(ProviderEventTypes.ProviderReady, payload?.Type); + + Assert.Equal(ProviderStatus.Ready, provider.GetStatus()); + } + + [Fact] + public async Task ItCanBeShutdown() + { + var provider = new Provider(Configuration.Builder("").Offline(true).Build()); + + await provider.Initialize(EvaluationContext.Builder().Set("key", "test").Build()); + Assert.Equal(ProviderStatus.Ready, provider.GetStatus()); + + var eventContent = await provider.GetEventChannel().Reader.ReadAsync(); + var payload = eventContent as ProviderEventPayload; + Assert.Equal(ProviderEventTypes.ProviderReady, payload?.Type); + + await provider.Shutdown(); + Assert.Equal(ProviderStatus.NotReady, provider.GetStatus()); + } + + [Fact] + public async Task ItHandlesFailedInitialization() + { + var mockClient = new Mock(); + mockClient.Setup(l => l.GetLogger()) + .Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null)); + + var mockDataSourceStatus = new Mock(); + mockDataSourceStatus.Setup(l => l.Status).Returns(new DataSourceStatus + { + State = DataSourceState.Initializing + }); + mockClient.Setup(l => l.DataSourceStatusProvider).Returns(mockDataSourceStatus.Object); + + var provider = new Provider(mockClient.Object); + + // Setup a timer to indicate that the client has initialized after some amount of time. + var completionTimer = new Timer(100); + completionTimer.AutoReset = false; + completionTimer.Elapsed += (sender, args) => + { + mockDataSourceStatus.Raise(e => e.StatusChanged += null, + mockDataSourceStatus.Object, + new DataSourceStatus {State = DataSourceState.Off}); + }; + completionTimer.Start(); + + var exception = + await Record.ExceptionAsync(async () => await provider.Initialize(EvaluationContext.Empty)); + Assert.NotNull(exception); + Assert.Equal("the provider has encountered a permanent error or been shutdown", exception.Message); + var eventContent = await provider.GetEventChannel().Reader.ReadAsync(); + var payload = eventContent as ProviderEventPayload; + Assert.Equal(ProviderEventTypes.ProviderError, payload?.Type); + + Assert.Equal(ProviderStatus.Error, provider.GetStatus()); + } + + [Fact] + public void ItCanBeConstructedWithLoggingConfiguration() { var logCapture = new LogCapture(); - var mock = new Mock(); - var provider = new Provider(mock.Object, ProviderConfiguration.Builder().Logging( - Components.Logging().Adapter(logCapture).BaseLoggerName("MyStuff.LDStuff") - ).Build()); + var provider = new Provider(Configuration.Builder("").Offline(true) + .Logging(Components.Logging().Adapter(logCapture)).Build()); // This context is malformed and will cause a log. var evaluationContext = EvaluationContext.Builder() @@ -62,8 +154,13 @@ public void ItUsesTheBaseLoggerNameSpecifiedInTheLoggingConfiguration() .Build(); provider.ResolveBooleanValue("the-flag", false, evaluationContext); + Assert.True(logCapture.HasMessageWithText(LogLevel.Warn, + "The EvaluationContext contained both a 'targetingKey' and a 'key' attribute. The 'key'" + + " attribute will be discarded.")); - Assert.Equal("MyStuff.LDStuff.OpenFeature.ServerProvider", logCapture.GetMessages()[0].LoggerName); + var exception = Record.Exception(() => logCapture.GetMessages() + .Find(message => message.LoggerName == "LaunchDarkly.Sdk.OpenFeature.ServerProvider")); + Assert.Null(exception); } [Fact] @@ -73,6 +170,8 @@ public void ItCanDoABooleanEvaluation() .Set("targetingKey", "the-key") .Build(); var mock = new Mock(); + mock.Setup(l => l.GetLogger()) + .Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null)); mock.Setup(l => l.BoolVariationDetail("flag-key", _converter.ToLdContext(evaluationContext), false)) .Returns(new EvaluationDetail(true, 10, EvaluationReason.FallthroughReason)); @@ -89,6 +188,8 @@ public void ItCanDoAStringEvaluation() .Set("targetingKey", "the-key") .Build(); var mock = new Mock(); + mock.Setup(l => l.GetLogger()) + .Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null)); mock.Setup(l => l.StringVariationDetail("flag-key", _converter.ToLdContext(evaluationContext), "default")) .Returns(new EvaluationDetail("notDefault", 10, EvaluationReason.FallthroughReason)); @@ -105,6 +206,8 @@ public void ItCanDoAnIntegerEvaluation() .Set("targetingKey", "the-key") .Build(); var mock = new Mock(); + mock.Setup(l => l.GetLogger()) + .Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null)); mock.Setup(l => l.IntVariationDetail("flag-key", _converter.ToLdContext(evaluationContext), 0)) .Returns(new EvaluationDetail(1, 10, EvaluationReason.FallthroughReason)); @@ -121,6 +224,8 @@ public void ItCanDoADoubleEvaluation() .Set("targetingKey", "the-key") .Build(); var mock = new Mock(); + mock.Setup(l => l.GetLogger()) + .Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null)); mock.Setup(l => l.DoubleVariationDetail("flag-key", _converter.ToLdContext(evaluationContext), 0)) .Returns(new EvaluationDetail(1.7, 10, EvaluationReason.FallthroughReason)); @@ -137,6 +242,8 @@ public void ItCanDoAValueEvaluation() .Set("targetingKey", "the-key") .Build(); var mock = new Mock(); + mock.Setup(l => l.GetLogger()) + .Returns(Components.NoLogging.Build(null).LogAdapter.Logger(null)); mock.Setup(l => l.JsonVariationDetail("flag-key", It.IsAny(), It.IsAny())) .Returns(new EvaluationDetail(LdValue.Of("true"), 10, EvaluationReason.FallthroughReason));