From da37078c52e9808f247f9cac0367d0146bd018a7 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 25 Feb 2026 10:44:26 -0800 Subject: [PATCH 01/21] Plugin support for CliendSDK --- pkgs/sdk/client/src/Components.cs | 26 +++++++- pkgs/sdk/client/src/Configuration.cs | 9 ++- pkgs/sdk/client/src/ConfigurationBuilder.cs | 16 ++++- pkgs/sdk/client/src/Hooks/Hook.cs | 63 +++++++++++++++++++ .../PluginConfigurationBuilder.cs | 44 +++++++++++++ pkgs/sdk/client/src/LdClient.cs | 29 +++++++++ pkgs/sdk/client/src/Plugins/Plugin.cs | 23 +++++++ .../src/Subsystems/PluginConfiguration.cs | 25 ++++++++ 8 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 pkgs/sdk/client/src/Hooks/Hook.cs create mode 100644 pkgs/sdk/client/src/Integrations/PluginConfigurationBuilder.cs create mode 100644 pkgs/sdk/client/src/Plugins/Plugin.cs create mode 100644 pkgs/sdk/client/src/Subsystems/PluginConfiguration.cs diff --git a/pkgs/sdk/client/src/Components.cs b/pkgs/sdk/client/src/Components.cs index 3a6c02ab..602bdfc3 100644 --- a/pkgs/sdk/client/src/Components.cs +++ b/pkgs/sdk/client/src/Components.cs @@ -1,7 +1,10 @@ -using LaunchDarkly.Logging; +using System.Collections.Generic; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Hooks; using LaunchDarkly.Sdk.Client.Integrations; using LaunchDarkly.Sdk.Client.Internal; using LaunchDarkly.Sdk.Client.Internal.DataStores; +using LaunchDarkly.Sdk.Client.Plugins; using LaunchDarkly.Sdk.Client.Subsystems; namespace LaunchDarkly.Sdk.Client @@ -336,5 +339,26 @@ public static PollingDataSourceBuilder PollingDataSource() => /// public static StreamingDataSourceBuilder StreamingDataSource() => new StreamingDataSourceBuilder(); + + /// + /// Returns a configuration builder for the SDK's plugin configuration. + /// + /// + /// + /// var config = Configuration.Builder(mobileKey, AutoEnvAttributes.Enabled) + /// .Plugins(Components.Plugins() + /// .Add(new MyPlugin(...)) + /// ).Build(); + /// + /// + /// a configuration builder + public static PluginConfigurationBuilder Plugins() => new PluginConfigurationBuilder(); + + /// + /// Returns a configuration builder for the SDK's plugin configuration, with an initial set of plugins. + /// + /// a collection of plugins + /// a configuration builder + public static PluginConfigurationBuilder Plugins(IEnumerable plugins) => new PluginConfigurationBuilder(plugins); } } diff --git a/pkgs/sdk/client/src/Configuration.cs b/pkgs/sdk/client/src/Configuration.cs index 9b457674..71ba9100 100644 --- a/pkgs/sdk/client/src/Configuration.cs +++ b/pkgs/sdk/client/src/Configuration.cs @@ -1,9 +1,10 @@ -using System; +using System; using System.Collections.Immutable; using LaunchDarkly.Sdk.Client.Integrations; using LaunchDarkly.Sdk.Client.Interfaces; using LaunchDarkly.Sdk.Client.Internal.Interfaces; using LaunchDarkly.Sdk.Client.PlatformSpecific; +using LaunchDarkly.Sdk.Client.Plugins; using LaunchDarkly.Sdk.Client.Subsystems; namespace LaunchDarkly.Sdk.Client @@ -125,6 +126,11 @@ public sealed class Configuration /// public PersistenceConfigurationBuilder PersistenceConfigurationBuilder { get; } + /// + /// Contains methods for configuring the SDK's 'plugins' to extend or customize SDK behavior. + /// + public PluginConfigurationBuilder Plugins { get; } + /// /// Defines the base service URIs used by SDK components. /// @@ -210,6 +216,7 @@ internal Configuration(ConfigurationBuilder builder) MobileKey = builder._mobileKey; Offline = builder._offline; PersistenceConfigurationBuilder = builder._persistenceConfigurationBuilder; + Plugins = builder._plugins; ServiceEndpoints = (builder._serviceEndpointsBuilder ?? Components.ServiceEndpoints()).Build(); BackgroundModeManager = builder._backgroundModeManager; ConnectivityStateManager = builder._connectivityStateManager; diff --git a/pkgs/sdk/client/src/ConfigurationBuilder.cs b/pkgs/sdk/client/src/ConfigurationBuilder.cs index 76e3fd7f..e5d0245b 100644 --- a/pkgs/sdk/client/src/ConfigurationBuilder.cs +++ b/pkgs/sdk/client/src/ConfigurationBuilder.cs @@ -1,7 +1,8 @@ -using System.Net.Http; +using System.Net.Http; using LaunchDarkly.Logging; using LaunchDarkly.Sdk.Client.Integrations; using LaunchDarkly.Sdk.Client.Internal.Interfaces; +using LaunchDarkly.Sdk.Client.Plugins; using LaunchDarkly.Sdk.Client.Subsystems; using LaunchDarkly.Sdk.Helpers; @@ -70,6 +71,7 @@ public enum AutoEnvAttributes internal string _mobileKey; internal bool _offline = false; internal PersistenceConfigurationBuilder _persistenceConfigurationBuilder = null; + internal PluginConfigurationBuilder _plugins = null; internal ServiceEndpointsBuilder _serviceEndpointsBuilder = null; // Internal properties only settable for testing @@ -108,6 +110,7 @@ internal ConfigurationBuilder(Configuration copyFrom) SetMobileKeyIfValid(copyFrom.MobileKey); _offline = copyFrom.Offline; _persistenceConfigurationBuilder = copyFrom.PersistenceConfigurationBuilder; + _plugins = copyFrom.Plugins; _serviceEndpointsBuilder = new ServiceEndpointsBuilder(copyFrom.ServiceEndpoints); } @@ -435,6 +438,17 @@ public ConfigurationBuilder Persistence(PersistenceConfigurationBuilder persiste return this; } + /// + /// Sets the SDK's plugin configuration. + /// + /// the plugin configuration + /// the same builder + public ConfigurationBuilder Plugins(PluginConfigurationBuilder pluginsConfig) + { + _plugins = pluginsConfig; + return this; + } + /// /// Sets the SDK's service URIs, using a configuration builder obtained from /// . diff --git a/pkgs/sdk/client/src/Hooks/Hook.cs b/pkgs/sdk/client/src/Hooks/Hook.cs new file mode 100644 index 00000000..7fbe7575 --- /dev/null +++ b/pkgs/sdk/client/src/Hooks/Hook.cs @@ -0,0 +1,63 @@ +using System; + +namespace LaunchDarkly.Sdk.Client.Hooks +{ + /// + /// A Hook is a set of user-defined callbacks that are executed by the SDK at various points + /// of interest. To create your own hook with customized logic, derive from Hook and override its methods. + /// + public class Hook : IDisposable + { + /// + /// Access this hook's . + /// + public HookMetadata Metadata { get; private set; } + + /// + /// Constructs a new Hook with the given name. The name may be used in log messages. + /// + /// the name of the hook + public Hook(string name) + { + Metadata = new HookMetadata(name); + } + + /// + /// Disposes the hook. This method will be called when the SDK is disposed. + /// + /// true if the caller is Dispose, false if the caller is a finalizer + protected virtual void Dispose(bool disposing) + { + } + + /// + /// Disposes the hook. This method will be called when the SDK is disposed. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + } + + /// + /// HookMetadata contains information related to a Hook which can be inspected by the SDK, or within + /// a hook stage. + /// + public sealed class HookMetadata + { + /// + /// Constructs a new HookMetadata with the given hook name. + /// + /// name of the hook. May be used in logs by the SDK + public HookMetadata(string name) + { + Name = name; + } + + /// + /// Returns the name of the hook. + /// + public string Name { get; } + } +} diff --git a/pkgs/sdk/client/src/Integrations/PluginConfigurationBuilder.cs b/pkgs/sdk/client/src/Integrations/PluginConfigurationBuilder.cs new file mode 100644 index 00000000..6a2f7ff1 --- /dev/null +++ b/pkgs/sdk/client/src/Integrations/PluginConfigurationBuilder.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Linq; +using LaunchDarkly.Sdk.Client.Plugins; +using LaunchDarkly.Sdk.Client.Subsystems; + +namespace LaunchDarkly.Sdk.Client.Integrations +{ + /// + /// PluginConfigurationBuilder is a builder for the SDK's plugin configuration. + /// + public sealed class PluginConfigurationBuilder + { + private readonly List _plugins; + + /// + /// Constructs a configuration from an existing collection of plugins. + /// + /// optional initial collection of plugins + public PluginConfigurationBuilder(IEnumerable plugins = null) + { + _plugins = plugins is null ? new List() : plugins.ToList(); + } + + /// + /// Adds a plugin to the configuration. + /// + /// the plugin to add + /// the builder + public PluginConfigurationBuilder Add(Plugin plugin) + { + _plugins.Add(plugin); + return this; + } + + /// + /// Builds the configuration. + /// + /// the built configuration + public PluginConfiguration Build() + { + return new PluginConfiguration(_plugins.ToList()); + } + } +} diff --git a/pkgs/sdk/client/src/LdClient.cs b/pkgs/sdk/client/src/LdClient.cs index 195f24dd..d31706f6 100644 --- a/pkgs/sdk/client/src/LdClient.cs +++ b/pkgs/sdk/client/src/LdClient.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Hooks; using LaunchDarkly.Sdk.Client.Interfaces; using LaunchDarkly.Sdk.Client.Internal; using LaunchDarkly.Sdk.Client.Internal.DataSources; @@ -13,6 +14,7 @@ using LaunchDarkly.Sdk.Client.Internal.Interfaces; using LaunchDarkly.Sdk.Client.PlatformSpecific; using LaunchDarkly.Sdk.Client.Subsystems; +using LaunchDarkly.Sdk.Integrations.Plugins; using LaunchDarkly.Sdk.Internal; using LaunchDarkly.Sdk.Internal.Concurrent; @@ -68,6 +70,7 @@ public sealed class LdClient : ILdClient readonly TaskExecutor _taskExecutor; readonly AnonymousKeyContextDecorator _anonymousKeyContextDecorator; private readonly AutoEnvContextDecorator _autoEnvContextDecorator; + private readonly List _pluginHooks; private readonly Logger _log; @@ -228,6 +231,11 @@ public sealed class LdClient : ILdClient }); } + var pluginConfig = (_config.Plugins ?? Components.Plugins()).Build(); + var environmentMetadata = CreateEnvironmentMetadata(); + _pluginHooks = this.GetPluginHooks(pluginConfig.Plugins, environmentMetadata, _log); + this.RegisterPlugins(pluginConfig.Plugins, environmentMetadata, _log); + _backgroundModeManager = _config.BackgroundModeManager ?? new DefaultBackgroundModeManager(); _backgroundModeManager.BackgroundModeChanged += OnBackgroundModeChanged; } @@ -923,6 +931,23 @@ public async Task IdentifyAsync(Context context) return await _connectionManager.SetContext(newContext); } + private EnvironmentMetadata CreateEnvironmentMetadata() + { + var applicationInfo = _config.ApplicationInfo?.Build() ?? new ApplicationInfo(); + + var sdkMetadata = new SdkMetadata( + SdkPackage.Name, + SdkPackage.Version + ); + + var applicationMetadata = new ApplicationMetadata( + applicationInfo.ApplicationId, + applicationInfo.ApplicationVersion + ); + + return new EnvironmentMetadata(sdkMetadata, _config.MobileKey, CredentialType.MobileKey, applicationMetadata); + } + /// /// Permanently shuts down the SDK client. /// @@ -954,6 +979,10 @@ void Dispose(bool disposing) _connectionManager.Dispose(); _dataStore.Dispose(); _eventProcessor.Dispose(); + foreach (var hook in _pluginHooks) + { + hook?.Dispose(); + } // Reset the static Instance to null *if* it was referring to this instance DetachInstance(); diff --git a/pkgs/sdk/client/src/Plugins/Plugin.cs b/pkgs/sdk/client/src/Plugins/Plugin.cs new file mode 100644 index 00000000..251a393b --- /dev/null +++ b/pkgs/sdk/client/src/Plugins/Plugin.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using LaunchDarkly.Sdk.Client.Hooks; +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.Sdk.Integrations.Plugins; + +namespace LaunchDarkly.Sdk.Client.Plugins +{ + /// + /// Abstract base class for extending SDK functionality via plugins in the client-side SDK. + /// All provided client-side plugin implementations MUST inherit from this class. + /// + public abstract class Plugin : PluginBase + { + /// + /// Initializes a new instance of the class with the specified name. + /// + /// The name of the plugin. + protected Plugin(string name) + : base(name) + { + } + } +} diff --git a/pkgs/sdk/client/src/Subsystems/PluginConfiguration.cs b/pkgs/sdk/client/src/Subsystems/PluginConfiguration.cs new file mode 100644 index 00000000..693289c1 --- /dev/null +++ b/pkgs/sdk/client/src/Subsystems/PluginConfiguration.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using LaunchDarkly.Sdk.Client.Plugins; + +namespace LaunchDarkly.Sdk.Client.Subsystems +{ + /// + /// Configuration containing plugins for the SDK. + /// + public sealed class PluginConfiguration + { + /// + /// The collection of plugins. + /// + public IEnumerable Plugins { get; } + + /// + /// Initializes a new instance of the class with the specified plugins. + /// + /// The plugins to include in this configuration. + public PluginConfiguration(IEnumerable plugins) + { + Plugins = plugins; + } + } +} From 7aee7be83f66ed5d66155f889bbf8607a15ef246 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 25 Feb 2026 11:38:04 -0800 Subject: [PATCH 02/21] Plugin tests --- .../PluginConfigurationBuilderTest.cs | 132 ++++++++++++++ .../LdClientPluginTests.cs | 165 ++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Integrations/PluginConfigurationBuilderTest.cs create mode 100644 pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/LdClientPluginTests.cs diff --git a/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Integrations/PluginConfigurationBuilderTest.cs b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Integrations/PluginConfigurationBuilderTest.cs new file mode 100644 index 00000000..7dabe75c --- /dev/null +++ b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Integrations/PluginConfigurationBuilderTest.cs @@ -0,0 +1,132 @@ +using System.Collections.Generic; +using System.Linq; +using LaunchDarkly.Sdk.Integrations.Plugins; +using LaunchDarkly.Sdk.Client.Hooks; +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.Sdk.Client.Plugins; +using LaunchDarkly.Sdk.Client.Subsystems; +using Xunit; + +namespace LaunchDarkly.Sdk.Client.Integrations +{ + public class TestPlugin : Plugin + { + public TestPlugin(string name = "test-plugin") + :base(name) { } + + public override void Register(ILdClient client, EnvironmentMetadata metadata) + { + } + } + + public class PluginConfigurationBuilderTest + { + [Fact] + public void Constructor_WithNoParameters_CreatesEmptyConfiguration() + { + var builder = new PluginConfigurationBuilder(); + var config = builder.Build(); + + Assert.NotNull(config.Plugins); + Assert.Empty(config.Plugins); + } + + [Fact] + public void Constructor_WithPluginCollection_CreatesConfigurationWithPlugins() + { + var plugin1 = new TestPlugin("plugin1"); + var plugin2 = new TestPlugin("plugin2"); + var plugins = new List { plugin1, plugin2 }; + + var builder = new PluginConfigurationBuilder(plugins); + var config = builder.Build(); + + Assert.Equal(2, config.Plugins.Count()); + Assert.Contains(plugin1, config.Plugins); + Assert.Contains(plugin2, config.Plugins); + } + + [Fact] + public void Add_MultiplePlugins_AddsAllPluginsToConfiguration() + { + var builder = new PluginConfigurationBuilder(); + var plugin1 = new TestPlugin("plugin1"); + var plugin2 = new TestPlugin("plugin2"); + var plugin3 = new TestPlugin("plugin3"); + + builder.Add(plugin1).Add(plugin2).Add(plugin3); + var config = builder.Build(); + + Assert.Equal(3, config.Plugins.Count()); + Assert.Contains(plugin1, config.Plugins); + Assert.Contains(plugin2, config.Plugins); + Assert.Contains(plugin3, config.Plugins); + } + + [Fact] + public void Add_ReturnsBuilderInstance_AllowsFluentInterface() + { + var builder = new PluginConfigurationBuilder(); + var plugin = new TestPlugin("test-plugin"); + + var returnedBuilder = builder.Add(plugin); + + Assert.Same(builder, returnedBuilder); + } + + [Fact] + public void Build_CanBeCalledMultipleTimes() + { + var builder = new PluginConfigurationBuilder(); + var plugin = new TestPlugin("test-plugin"); + builder.Add(plugin); + + var config1 = builder.Build(); + var config2 = builder.Build(); + + Assert.Single(config1.Plugins); + Assert.Single(config2.Plugins); + Assert.Contains(plugin, config1.Plugins); + Assert.Contains(plugin, config2.Plugins); + } + + [Fact] + public void Build_ModifyingBuilderAfterBuild_DoesNotAffectPreviousConfiguration() + { + var builder = new PluginConfigurationBuilder(); + var plugin1 = new TestPlugin("plugin1"); + var plugin2 = new TestPlugin("plugin2"); + + builder.Add(plugin1); + var config1 = builder.Build(); + + builder.Add(plugin2); + var config2 = builder.Build(); + + Assert.Single(config1.Plugins); + Assert.Equal(2, config2.Plugins.Count()); + Assert.Contains(plugin1, config1.Plugins); + Assert.DoesNotContain(plugin2, config1.Plugins); + } + + [Fact] + public void Constructor_WithEmptyPluginCollection_CreatesEmptyConfiguration() + { + var builder = new PluginConfigurationBuilder(new List()); + var config = builder.Build(); + + Assert.NotNull(config.Plugins); + Assert.Empty(config.Plugins); + } + + [Fact] + public void Constructor_WithNullPluginCollection_CreatesEmptyConfiguration() + { + var builder = new PluginConfigurationBuilder(null); + var config = builder.Build(); + + Assert.NotNull(config.Plugins); + Assert.Empty(config.Plugins); + } + } +} diff --git a/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/LdClientPluginTests.cs b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/LdClientPluginTests.cs new file mode 100644 index 00000000..b6524926 --- /dev/null +++ b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/LdClientPluginTests.cs @@ -0,0 +1,165 @@ +using System.Collections.Generic; +using LaunchDarkly.Sdk.Client.Hooks; +using LaunchDarkly.Sdk.Client.Integrations; +using LaunchDarkly.Sdk.Client.Interfaces; +using LaunchDarkly.Sdk.Client.Plugins; +using LaunchDarkly.Sdk.Integrations.Plugins; +using Xunit; +using Xunit.Abstractions; + +namespace LaunchDarkly.Sdk.Client +{ + public class LdClientPluginTests : BaseTest + { + public LdClientPluginTests(ITestOutputHelper testOutput) : base(testOutput) { } + + [Fact] + public void RegisterIsCalledForSinglePlugin() + { + var plugin = new SpyPlugin("spy"); + var config = BasicConfig() + .Plugins(new PluginConfigurationBuilder().Add(plugin)) + .Build(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + Assert.True(plugin.Registered); + Assert.NotNull(plugin.ReceivedClient); + Assert.NotNull(plugin.ReceivedMetadata); + } + } + + [Fact] + public void RegisterIsCalledForMultiplePlugins() + { + var plugin1 = new SpyPlugin("first"); + var plugin2 = new SpyPlugin("second"); + var config = BasicConfig() + .Plugins(new PluginConfigurationBuilder().Add(plugin1).Add(plugin2)) + .Build(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + Assert.True(plugin1.Registered); + Assert.True(plugin2.Registered); + } + } + + [Fact] + public void RegisterReceivesClientInstance() + { + var plugin = new SpyPlugin("spy"); + var config = BasicConfig() + .Plugins(new PluginConfigurationBuilder().Add(plugin)) + .Build(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + Assert.Same(client, plugin.ReceivedClient); + } + } + + [Fact] + public void RegisterReceivesEnvironmentMetadata() + { + var plugin = new SpyPlugin("spy"); + var config = BasicConfig() + .Plugins(new PluginConfigurationBuilder().Add(plugin)) + .Build(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + Assert.NotNull(plugin.ReceivedMetadata); + Assert.Equal(BasicMobileKey, plugin.ReceivedMetadata.Credential); + Assert.Equal(CredentialType.MobileKey, plugin.ReceivedMetadata.CredentialType); + } + } + + [Fact] + public void NoPluginsConfiguredDoesNotCauseError() + { + var config = BasicConfig() + .Plugins(new PluginConfigurationBuilder()) + .Build(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + Assert.NotNull(client); + } + } + + [Fact] + public void PluginHooksAreCollected() + { + var hook = new StubHook("plugin-hook"); + var plugin = new SpyPlugin("spy", new List { hook }); + var config = BasicConfig() + .Plugins(new PluginConfigurationBuilder().Add(plugin)) + .Build(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + Assert.True(plugin.Registered); + Assert.True(plugin.GetHooksCalled); + } + } + + [Fact] + public void FailingPluginRegisterDoesNotPreventOtherPlugins() + { + var badPlugin = new FailingPlugin("bad"); + var goodPlugin = new SpyPlugin("good"); + var config = BasicConfig() + .Plugins(new PluginConfigurationBuilder().Add(badPlugin).Add(goodPlugin)) + .Build(); + + using (var client = TestUtil.CreateClient(config, BasicUser)) + { + Assert.True(goodPlugin.Registered); + } + } + + private class SpyPlugin : Plugin + { + public bool Registered { get; private set; } + public bool GetHooksCalled { get; private set; } + public ILdClient ReceivedClient { get; private set; } + public EnvironmentMetadata ReceivedMetadata { get; private set; } + + private readonly IList _hooks; + + public SpyPlugin(string name, IList hooks = null) : base(name) + { + _hooks = hooks ?? new List(); + } + + public override void Register(ILdClient client, EnvironmentMetadata metadata) + { + Registered = true; + ReceivedClient = client; + ReceivedMetadata = metadata; + } + + public override IList GetHooks(EnvironmentMetadata metadata) + { + GetHooksCalled = true; + return _hooks; + } + } + + private class StubHook : Hook + { + public StubHook(string name) : base(name) { } + } + + private class FailingPlugin : Plugin + { + public FailingPlugin(string name) : base(name) { } + + public override void Register(ILdClient client, EnvironmentMetadata metadata) + { + throw new System.Exception("intentional failure"); + } + } + } +} From f4a903b00d55e9210a8ebeeba2fca1eeeb8f7fc7 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 25 Feb 2026 12:56:27 -0800 Subject: [PATCH 03/21] defensive check --- pkgs/sdk/client/src/LdClient.cs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/pkgs/sdk/client/src/LdClient.cs b/pkgs/sdk/client/src/LdClient.cs index d31706f6..951fcbf1 100644 --- a/pkgs/sdk/client/src/LdClient.cs +++ b/pkgs/sdk/client/src/LdClient.cs @@ -70,7 +70,7 @@ public sealed class LdClient : ILdClient readonly TaskExecutor _taskExecutor; readonly AnonymousKeyContextDecorator _anonymousKeyContextDecorator; private readonly AutoEnvContextDecorator _autoEnvContextDecorator; - private readonly List _pluginHooks; + private readonly List _pluginHooks = new List(); private readonly Logger _log; @@ -231,10 +231,16 @@ public sealed class LdClient : ILdClient }); } - var pluginConfig = (_config.Plugins ?? Components.Plugins()).Build(); - var environmentMetadata = CreateEnvironmentMetadata(); - _pluginHooks = this.GetPluginHooks(pluginConfig.Plugins, environmentMetadata, _log); - this.RegisterPlugins(pluginConfig.Plugins, environmentMetadata, _log); + if (_config.Plugins != null) + { + var pluginConfig = _config.Plugins.Build(); + if (pluginConfig.Plugins.Any()) + { + var environmentMetadata = CreateEnvironmentMetadata(); + _pluginHooks = this.GetPluginHooks(pluginConfig.Plugins, environmentMetadata, _log); + this.RegisterPlugins(pluginConfig.Plugins, environmentMetadata, _log); + } + } _backgroundModeManager = _config.BackgroundModeManager ?? new DefaultBackgroundModeManager(); _backgroundModeManager.BackgroundModeChanged += OnBackgroundModeChanged; From 6849630a20d586bc94260e7c3d849a067a7a401b Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 25 Feb 2026 13:18:48 -0800 Subject: [PATCH 04/21] Refactor LdClient cleanup logic to ensure proper disposal order of resources --- pkgs/sdk/client/src/LdClient.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkgs/sdk/client/src/LdClient.cs b/pkgs/sdk/client/src/LdClient.cs index 951fcbf1..eb7d42db 100644 --- a/pkgs/sdk/client/src/LdClient.cs +++ b/pkgs/sdk/client/src/LdClient.cs @@ -982,13 +982,13 @@ void Dispose(bool disposing) _dataSourceUpdateSink.UpdateStatus(DataSourceState.Shutdown, null); _backgroundModeManager.BackgroundModeChanged -= OnBackgroundModeChanged; - _connectionManager.Dispose(); - _dataStore.Dispose(); - _eventProcessor.Dispose(); foreach (var hook in _pluginHooks) { hook?.Dispose(); } + _connectionManager.Dispose(); + _dataStore.Dispose(); + _eventProcessor.Dispose(); // Reset the static Instance to null *if* it was referring to this instance DetachInstance(); From ebd9c5c20c83e272c32f4cb6c24739ca7d6fe327 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 26 Feb 2026 12:58:33 -0800 Subject: [PATCH 05/21] Flag Evaluations hook --- .../src/Hooks/EvaluationSeriesContext.cs | 50 +++++ pkgs/sdk/client/src/Hooks/Hook.cs | 90 ++++++++ .../client/src/Hooks/IdentifySeriesContext.cs | 30 +++ .../client/src/Hooks/IdentifySeriesResult.cs | 39 ++++ pkgs/sdk/client/src/Hooks/Method.cs | 23 +++ .../sdk/client/src/Hooks/SeriesDataBuilder.cs | 59 ++++++ .../src/Internal/Hooks/Executor/Executor.cs | 47 +++++ .../Internal/Hooks/Executor/NoopExecutor.cs | 20 ++ .../Hooks/Interfaces/IHookExecutor.cs | 25 +++ .../Hooks/Interfaces/IStageExecutor.cs | 35 ++++ .../Internal/Hooks/Series/EvaluationSeries.cs | 95 +++++++++ pkgs/sdk/client/src/Internal/LogNames.cs | 4 +- pkgs/sdk/client/src/LdClient.cs | 50 +++-- .../Hooks/EvaluationSeriesTest.cs | 195 ++++++++++++++++++ 14 files changed, 744 insertions(+), 18 deletions(-) create mode 100644 pkgs/sdk/client/src/Hooks/EvaluationSeriesContext.cs create mode 100644 pkgs/sdk/client/src/Hooks/IdentifySeriesContext.cs create mode 100644 pkgs/sdk/client/src/Hooks/IdentifySeriesResult.cs create mode 100644 pkgs/sdk/client/src/Hooks/Method.cs create mode 100644 pkgs/sdk/client/src/Hooks/SeriesDataBuilder.cs create mode 100644 pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs create mode 100644 pkgs/sdk/client/src/Internal/Hooks/Executor/NoopExecutor.cs create mode 100644 pkgs/sdk/client/src/Internal/Hooks/Interfaces/IHookExecutor.cs create mode 100644 pkgs/sdk/client/src/Internal/Hooks/Interfaces/IStageExecutor.cs create mode 100644 pkgs/sdk/client/src/Internal/Hooks/Series/EvaluationSeries.cs create mode 100644 pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/EvaluationSeriesTest.cs diff --git a/pkgs/sdk/client/src/Hooks/EvaluationSeriesContext.cs b/pkgs/sdk/client/src/Hooks/EvaluationSeriesContext.cs new file mode 100644 index 00000000..3c1135ef --- /dev/null +++ b/pkgs/sdk/client/src/Hooks/EvaluationSeriesContext.cs @@ -0,0 +1,50 @@ +namespace LaunchDarkly.Sdk.Client.Hooks +{ + /// + /// EvaluationSeriesContext represents parameters associated with a feature flag evaluation. It is + /// made available in stage callbacks. + /// + public sealed class EvaluationSeriesContext { + /// + /// The key of the feature flag. + /// + public string FlagKey { get; } + + /// + /// The Context used for evaluation. + /// + public Context Context { get; } + + /// + /// The user-provided default value for the evaluation. + /// + public LdValue DefaultValue { get; } + + /// + /// The variation method that triggered the evaluation. + /// + public string Method { get; } + + /// + /// The environment ID for the evaluation, or null if not available. + /// + public string EnvironmentId { get; } + + /// + /// Constructs a new EvaluationSeriesContext. + /// + /// the flag key + /// the context + /// the default value + /// the variation method + /// the environment ID + public EvaluationSeriesContext(string flagKey, Context context, LdValue defaultValue, string method, + string environmentId = null) { + FlagKey = flagKey; + Context = context; + DefaultValue = defaultValue; + Method = method; + EnvironmentId = environmentId; + } + } +} diff --git a/pkgs/sdk/client/src/Hooks/Hook.cs b/pkgs/sdk/client/src/Hooks/Hook.cs index 7fbe7575..344703c8 100644 --- a/pkgs/sdk/client/src/Hooks/Hook.cs +++ b/pkgs/sdk/client/src/Hooks/Hook.cs @@ -1,10 +1,26 @@ using System; +using System.Collections.Immutable; namespace LaunchDarkly.Sdk.Client.Hooks { + + using SeriesData = ImmutableDictionary; + /// /// A Hook is a set of user-defined callbacks that are executed by the SDK at various points /// of interest. To create your own hook with customized logic, derive from Hook and override its methods. + /// + /// Hook currently defines an "evaluation" series, which is composed of two stages: + /// "beforeEvaluation" and "afterEvaluation". + /// + /// These are executed by the SDK before and after the evaluation of a + /// feature flag. + /// + /// Multiple hooks may be configured in the SDK. By default, the SDK will execute each hook's beforeEvaluation + /// stage in the order they were configured, and afterEvaluation in reverse order. + /// + /// This means the last hook defined will tightly wrap the evaluation process, while hooks defined earlier in the + /// sequence are nested outside of it. /// public class Hook : IDisposable { @@ -13,6 +29,80 @@ public class Hook : IDisposable /// public HookMetadata Metadata { get; private set; } + + /// + /// BeforeEvaluation is executed by the SDK before the evaluation of a feature flag. It does not apply to + /// evaluations performed during a call to AllFlagsState. + /// + /// To pass user-configured data to , return a modification of the given + /// . You may use existing ImmutableDictionary methods, for example: + /// + /// + /// var builder = data.ToBuilder(); + /// builder["foo"] = "bar"; + /// return builder.ToImmutable(); + /// + /// + /// Or, you may use the for a fluent API: + /// + /// return new SeriesDataBuilder(data).Set("foo", "bar").Build(); + /// + /// + /// The modified data is not shared with any other hook. It will be passed to subsequent stages in the evaluation + /// series, including . + /// + /// + /// parameters associated with this evaluation + /// user-configurable data, currently empty + /// user-configurable data, which will be forwarded to + public virtual SeriesData BeforeEvaluation(EvaluationSeriesContext context, SeriesData data) => + data; + + + /// + /// AfterEvaluation is executed by the SDK after the evaluation of a feature flag. It does not apply to + /// evaluations performed during a call to AllFlagsState. + /// + /// The function should return the given unmodified, for forward compatibility with subsequent + /// stages that may be added. + /// + /// + /// parameters associated with this evaluation + /// user-configurable data from the stage + /// flag evaluation result + /// user-configurable data, which is currently unused but may be forwarded to subsequent stages in future versions of the SDK + public virtual SeriesData AfterEvaluation(EvaluationSeriesContext context, SeriesData data, + EvaluationDetail detail) => data; + + + /// + /// BeforeIdentify is executed by the SDK before an identify operation. + /// + /// The modified data is not shared with any other hook. It will be passed to subsequent stages in the identify + /// series, including . + /// + /// + /// parameters associated with this identify operation + /// user-configurable data, currently empty + /// user-configurable data, which will be forwarded to + public virtual SeriesData BeforeIdentify(IdentifySeriesContext context, SeriesData data) => + data; + + + /// + /// AfterIdentify is executed by the SDK after an identify operation. + /// + /// The function should return the given unmodified, for forward compatibility with subsequent + /// stages that may be added. + /// + /// + /// parameters associated with this identify operation + /// user-configurable data from the stage + /// the result of the identify operation + /// user-configurable data, which is currently unused but may be forwarded to subsequent stages in future versions of the SDK + public virtual SeriesData AfterIdentify(IdentifySeriesContext context, SeriesData data, + IdentifySeriesResult result) => data; + /// /// Constructs a new Hook with the given name. The name may be used in log messages. /// diff --git a/pkgs/sdk/client/src/Hooks/IdentifySeriesContext.cs b/pkgs/sdk/client/src/Hooks/IdentifySeriesContext.cs new file mode 100644 index 00000000..c4f2ed4b --- /dev/null +++ b/pkgs/sdk/client/src/Hooks/IdentifySeriesContext.cs @@ -0,0 +1,30 @@ +namespace LaunchDarkly.Sdk.Client.Hooks +{ + /// + /// IdentifySeriesContext represents parameters associated with an identify operation. It is + /// made available in stage callbacks. + /// + public sealed class IdentifySeriesContext + { + /// + /// The Context being identified. + /// + public Context Context { get; } + + /// + /// The timeout in seconds for the identify operation. + /// + public int Timeout { get; } + + /// + /// Constructs a new IdentifySeriesContext. + /// + /// the context being identified + /// the timeout in seconds + public IdentifySeriesContext(Context context, int timeout) + { + Context = context; + Timeout = timeout; + } + } +} diff --git a/pkgs/sdk/client/src/Hooks/IdentifySeriesResult.cs b/pkgs/sdk/client/src/Hooks/IdentifySeriesResult.cs new file mode 100644 index 00000000..3e576732 --- /dev/null +++ b/pkgs/sdk/client/src/Hooks/IdentifySeriesResult.cs @@ -0,0 +1,39 @@ +namespace LaunchDarkly.Sdk.Client.Hooks +{ + /// + /// IdentifySeriesResult contains the outcome of an identify operation, made available + /// in . + /// + public sealed class IdentifySeriesResult + { + /// + /// Represents the possible statuses of an identify operation. + /// + public enum IdentifySeriesStatus + { + /// + /// The identify operation completed successfully. + /// + Completed, + + /// + /// The identify operation encountered an error. + /// + Error + } + + /// + /// The status of the identify operation. + /// + public IdentifySeriesStatus Status { get; } + + /// + /// Constructs a new IdentifySeriesResult. + /// + /// the status of the identify operation + public IdentifySeriesResult(IdentifySeriesStatus status) + { + Status = status; + } + } +} diff --git a/pkgs/sdk/client/src/Hooks/Method.cs b/pkgs/sdk/client/src/Hooks/Method.cs new file mode 100644 index 00000000..419540fb --- /dev/null +++ b/pkgs/sdk/client/src/Hooks/Method.cs @@ -0,0 +1,23 @@ +namespace LaunchDarkly.Sdk.Client.Hooks +{ + /// + /// Method represents the SDK client method that triggered a hook invocation. + /// + #pragma warning disable 1591 + public static class Method + { + public const string BoolVariation = "LdClient.BoolVariation"; + public const string BoolVariationDetail = "LdClient.BoolVariationDetail"; + public const string IntVariation = "LdClient.IntVariation"; + public const string IntVariationDetail = "LdClient.IntVariationDetail"; + public const string FloatVariation = "LdClient.FloatVariation"; + public const string FloatVariationDetail = "LdClient.FloatVariationDetail"; + public const string DoubleVariation = "LdClient.DoubleVariation"; + public const string DoubleVariationDetail = "LdClient.DoubleVariationDetail"; + public const string StringVariation = "LdClient.StringVariation"; + public const string StringVariationDetail = "LdClient.StringVariationDetail"; + public const string JsonVariation = "LdClient.JsonVariation"; + public const string JsonVariationDetail = "LdClient.JsonVariationDetail"; + } + #pragma warning restore 1591 +} diff --git a/pkgs/sdk/client/src/Hooks/SeriesDataBuilder.cs b/pkgs/sdk/client/src/Hooks/SeriesDataBuilder.cs new file mode 100644 index 00000000..9c5b139b --- /dev/null +++ b/pkgs/sdk/client/src/Hooks/SeriesDataBuilder.cs @@ -0,0 +1,59 @@ +using System.Collections.Immutable; + +namespace LaunchDarkly.Sdk.Client.Hooks +{ + /// + /// Builder for constructing series data, which is passed to between methods. + /// + /// Use of this builder is optional; it is provided for convenience. + /// + /// + /// + /// // ImmutableDictionary passed into Hook method: + /// var data = ... + /// // Add a new key and return an updated dictionary: + /// return new SeriesDataBuilder(data).Set("key", "value").Build(); + /// + /// + /// + public sealed class SeriesDataBuilder + { + private readonly ImmutableDictionary.Builder _builder; + + /// + /// Constructs a new builder from pre-existing series data. + /// + /// pre-existing series data + public SeriesDataBuilder(ImmutableDictionary dictionary) + { + _builder = dictionary.ToBuilder(); + } + + /// + /// Constructs a new builder with empty series data. + /// + public SeriesDataBuilder(): this(ImmutableDictionary.Empty) {} + + + /// + /// Sets a key-value pair. + /// + /// key of value + /// the value to set + /// this builder + public SeriesDataBuilder Set(string key, object value) + { + _builder[key] = value; + return this; + } + + /// + /// Returns a SeriesData based on the current state of the builder. + /// + /// new series data + public ImmutableDictionary Build() + { + return _builder.Count == 0 ? ImmutableDictionary.Empty : _builder.ToImmutable(); + } + } +} diff --git a/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs b/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs new file mode 100644 index 00000000..3f626007 --- /dev/null +++ b/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Hooks; +using LaunchDarkly.Sdk.Client.Internal.Hooks.Interfaces; +using LaunchDarkly.Sdk.Client.Internal.Hooks.Series; + +namespace LaunchDarkly.Sdk.Client.Internal.Hooks.Executor +{ + internal sealed class Executor : IHookExecutor + { + private readonly List _hooks; + + private readonly IStageExecutor _beforeEvaluation; + private readonly IStageExecutor> _afterEvaluation; + + public Executor(Logger logger, IEnumerable hooks) + { + _hooks = hooks.ToList(); + _beforeEvaluation = new BeforeEvaluation(logger, _hooks, EvaluationStage.Order.Forward); + _afterEvaluation = new AfterEvaluation(logger, _hooks, EvaluationStage.Order.Reverse); + } + + public EvaluationDetail EvaluationSeries(EvaluationSeriesContext context, + LdValue.Converter converter, Func> evaluate) + { + var seriesData = _beforeEvaluation.Execute(context, default); + + var detail = evaluate(); + + _afterEvaluation.Execute(context, + new EvaluationDetail(converter.FromType(detail.Value), detail.VariationIndex, detail.Reason), + seriesData); + + return detail; + } + + public void Dispose() + { + foreach (var hook in _hooks) + { + hook?.Dispose(); + } + } + } +} diff --git a/pkgs/sdk/client/src/Internal/Hooks/Executor/NoopExecutor.cs b/pkgs/sdk/client/src/Internal/Hooks/Executor/NoopExecutor.cs new file mode 100644 index 00000000..cdc4a39a --- /dev/null +++ b/pkgs/sdk/client/src/Internal/Hooks/Executor/NoopExecutor.cs @@ -0,0 +1,20 @@ +using System; +using LaunchDarkly.Sdk.Client.Hooks; +using LaunchDarkly.Sdk.Client.Internal.Hooks.Interfaces; + +namespace LaunchDarkly.Sdk.Client.Internal.Hooks.Executor +{ + /// + /// NoopExecutor does not execute any hook logic. It may be used to avoid any overhead associated with + /// hook execution if the SDK is configured without hooks. + /// + internal sealed class NoopExecutor : IHookExecutor + { + public EvaluationDetail EvaluationSeries(EvaluationSeriesContext context, + LdValue.Converter converter, Func> evaluate) => evaluate(); + + public void Dispose() + { + } + } +} diff --git a/pkgs/sdk/client/src/Internal/Hooks/Interfaces/IHookExecutor.cs b/pkgs/sdk/client/src/Internal/Hooks/Interfaces/IHookExecutor.cs new file mode 100644 index 00000000..6899bac1 --- /dev/null +++ b/pkgs/sdk/client/src/Internal/Hooks/Interfaces/IHookExecutor.cs @@ -0,0 +1,25 @@ +using System; +using LaunchDarkly.Sdk.Client.Hooks; + +namespace LaunchDarkly.Sdk.Client.Internal.Hooks.Interfaces +{ + /// + /// An IHookExecutor is responsible for executing the logic contained in a series of hook stages. + /// + /// The purpose of this interface is to allow the SDK to swap out the executor based on having any hooks + /// configured or not. If there are no hooks, the interface methods can be no-ops. + /// + internal interface IHookExecutor : IDisposable + { + /// + /// EvaluationSeries should run the evaluation series for each configured hook. + /// + /// context for the evaluation series + /// used to convert the primitive evaluation value into a wrapped suitable for use in hooks + /// function to evaluate the flag value + /// primitive type of the flag value + /// the EvaluationDetail returned from the evaluator + EvaluationDetail EvaluationSeries(EvaluationSeriesContext context, + LdValue.Converter converter, Func> evaluate); + } +} diff --git a/pkgs/sdk/client/src/Internal/Hooks/Interfaces/IStageExecutor.cs b/pkgs/sdk/client/src/Internal/Hooks/Interfaces/IStageExecutor.cs new file mode 100644 index 00000000..012ece43 --- /dev/null +++ b/pkgs/sdk/client/src/Internal/Hooks/Interfaces/IStageExecutor.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace LaunchDarkly.Sdk.Client.Internal.Hooks.Interfaces +{ + using SeriesData = ImmutableDictionary; + + /// + /// Allows the Executor to arbitrarily wrap stage execution logic. For example, a benchmarking utility + /// can take an arbitrary IStageExecutor and wrap the execution logic in a timer. + /// + /// the context type + internal interface IStageExecutor + { + /// + /// Implementation should execute the same stage for all hooks with the given context and series data. + /// + /// the context + /// the pre-existing series data; if null, the implementation should create empty data as necessary + /// updated series data + IEnumerable Execute(TContext context, IEnumerable data); + } + + internal interface IStageExecutor + { + /// + /// Implementation should execute the same stage for all hooks with the given context, series data, and extra data. + /// + /// the context + /// the extra data + /// the pre-existing series data + /// updated series data + IEnumerable Execute(TContext context, TExtra extra, IEnumerable data); + } +} diff --git a/pkgs/sdk/client/src/Internal/Hooks/Series/EvaluationSeries.cs b/pkgs/sdk/client/src/Internal/Hooks/Series/EvaluationSeries.cs new file mode 100644 index 00000000..23ccfe9b --- /dev/null +++ b/pkgs/sdk/client/src/Internal/Hooks/Series/EvaluationSeries.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Hooks; +using LaunchDarkly.Sdk.Client.Internal.Hooks.Interfaces; + +namespace LaunchDarkly.Sdk.Client.Internal.Hooks.Series +{ + using SeriesData = ImmutableDictionary; + + internal class EvaluationStage + { + public enum Order + { + Forward, + Reverse + } + + protected enum Stage + { + BeforeEvaluation, + AfterEvaluation + } + + protected readonly Order _order; + private readonly Logger _logger; + + protected EvaluationStage(Logger logger, Order order) + { + _logger = logger; + _order = order; + } + + protected void LogFailure(EvaluationSeriesContext context, Hook h, Stage stage, Exception e) + { + _logger.Error("During evaluation of flag \"{0}\", stage \"{1}\" of hook \"{2}\" reported error: {3}", + context.FlagKey, stage.ToString(), h.Metadata.Name, e.Message); + } + } + + internal sealed class BeforeEvaluation : EvaluationStage, IStageExecutor + { + private readonly IEnumerable _hooks; + + public BeforeEvaluation(Logger logger, IEnumerable hooks, Order order) : base(logger, order) + { + _hooks = (order == Order.Forward) ? hooks : hooks.Reverse(); + } + + public IEnumerable Execute(EvaluationSeriesContext context, IEnumerable _) + { + return _hooks.Select(hook => + { + try + { + return hook.BeforeEvaluation(context, SeriesData.Empty); + } + catch (Exception e) + { + LogFailure(context, hook, Stage.BeforeEvaluation, e); + return SeriesData.Empty; + } + }).ToList(); + } + } + + internal sealed class AfterEvaluation : EvaluationStage, IStageExecutor> + { + private readonly IEnumerable _hooks; + + public AfterEvaluation(Logger logger, IEnumerable hooks, Order order) : base(logger, order) + { + _hooks = (order == Order.Forward) ? hooks : hooks.Reverse(); + } + + public IEnumerable Execute(EvaluationSeriesContext context, EvaluationDetail detail, + IEnumerable seriesData) + { + return _hooks.Zip((_order == Order.Reverse ? seriesData.Reverse() : seriesData), (hook, data) => + { + try + { + return hook.AfterEvaluation(context, data, detail); + } + catch (Exception e) + { + LogFailure(context, hook, Stage.AfterEvaluation, e); + return SeriesData.Empty; + } + }).ToList(); + } + } +} diff --git a/pkgs/sdk/client/src/Internal/LogNames.cs b/pkgs/sdk/client/src/Internal/LogNames.cs index fabdf07d..d615d369 100644 --- a/pkgs/sdk/client/src/Internal/LogNames.cs +++ b/pkgs/sdk/client/src/Internal/LogNames.cs @@ -1,4 +1,4 @@ - + namespace LaunchDarkly.Sdk.Client.Internal { internal static class LogNames @@ -10,5 +10,7 @@ internal static class LogNames internal const string DataStoreSubLog = "DataStore"; internal const string EventsSubLog = "Events"; + + internal const string HooksSubLog = "Hooks"; } } diff --git a/pkgs/sdk/client/src/LdClient.cs b/pkgs/sdk/client/src/LdClient.cs index eb7d42db..df783870 100644 --- a/pkgs/sdk/client/src/LdClient.cs +++ b/pkgs/sdk/client/src/LdClient.cs @@ -9,6 +9,8 @@ using LaunchDarkly.Sdk.Client.Interfaces; using LaunchDarkly.Sdk.Client.Internal; using LaunchDarkly.Sdk.Client.Internal.DataSources; +using LaunchDarkly.Sdk.Client.Internal.Hooks.Executor; +using LaunchDarkly.Sdk.Client.Internal.Hooks.Interfaces; using LaunchDarkly.Sdk.Client.Internal.DataStores; using LaunchDarkly.Sdk.Client.Internal.Events; using LaunchDarkly.Sdk.Client.Internal.Interfaces; @@ -70,7 +72,8 @@ public sealed class LdClient : ILdClient readonly TaskExecutor _taskExecutor; readonly AnonymousKeyContextDecorator _anonymousKeyContextDecorator; private readonly AutoEnvContextDecorator _autoEnvContextDecorator; - private readonly List _pluginHooks = new List(); + private readonly IHookExecutor _hookExecutor; + private List _pluginHooks = new List(); private readonly Logger _log; @@ -242,6 +245,10 @@ public sealed class LdClient : ILdClient } } + _hookExecutor = _pluginHooks.Any() + ? (IHookExecutor)new Executor(_log.SubLogger(LogNames.HooksSubLog), _pluginHooks) + : new NoopExecutor(); + _backgroundModeManager = _config.BackgroundModeManager ?? new DefaultBackgroundModeManager(); _backgroundModeManager.BackgroundModeChanged += OnBackgroundModeChanged; } @@ -741,6 +748,15 @@ public EvaluationDetail JsonVariationDetail(string key, LdValue default EvaluationDetail VariationInternal(string featureKey, LdValue defaultJson, LdValue.Converter converter, bool checkType, EventFactory eventFactory) + { + var evalSeriesContext = new EvaluationSeriesContext(featureKey, Context, defaultJson, + GetMethodName(checkType, eventFactory)); + return _hookExecutor.EvaluationSeries(evalSeriesContext, converter, + () => EvaluateInternal(featureKey, defaultJson, converter, checkType, eventFactory)); + } + + EvaluationDetail EvaluateInternal(string featureKey, LdValue defaultJson, LdValue.Converter converter, + bool checkType, EventFactory eventFactory) { T defaultValue = converter.ToType(defaultJson); @@ -775,22 +791,13 @@ EvaluationDetail errorResult(EvaluationErrorKind kind) => } } - // The flag.Prerequisites array represents the evaluated prerequisites of this flag. We need to generate - // events for both this flag and its prerequisites (recursively), which is necessary to ensure LaunchDarkly - // analytics functions properly. - // - // We're using JsonVariationDetail because the type of the prerequisite is both unknown and irrelevant - // to emitting the events. - // - // We're passing LdValue.Null to match a server-side SDK's behavior when evaluating prerequisites. - // - // NOTE: if "hooks" functionality is implemented into this SDK, take care that evaluating prerequisites - // does not trigger hooks. This may require refactoring the code below to not use JsonVariationDetail. + // Prerequisites are evaluated directly via EvaluateInternal to avoid triggering hooks. if (flag.Prerequisites != null) { foreach (var prerequisiteKey in flag.Prerequisites) { - JsonVariationDetail(prerequisiteKey, LdValue.Null); + EvaluateInternal(prerequisiteKey, LdValue.Null, LdValue.Convert.Json, false, + _eventFactoryWithReasons); } } @@ -825,6 +832,18 @@ EvaluationDetail errorResult(EvaluationErrorKind kind) => return result; } + private string GetMethodName(bool checkType, EventFactory eventFactory) + { + bool isDetail = eventFactory == _eventFactoryWithReasons; + var type = typeof(T); + if (type == typeof(bool)) return isDetail ? Method.BoolVariationDetail : Method.BoolVariation; + if (type == typeof(int)) return isDetail ? Method.IntVariationDetail : Method.IntVariation; + if (type == typeof(float)) return isDetail ? Method.FloatVariationDetail : Method.FloatVariation; + if (type == typeof(double)) return isDetail ? Method.DoubleVariationDetail : Method.DoubleVariation; + if (type == typeof(string)) return isDetail ? Method.StringVariationDetail : Method.StringVariation; + return isDetail ? Method.JsonVariationDetail : Method.JsonVariation; + } + private void SendEvaluationEventIfOnline(EventProcessorTypes.EvaluationEvent e) { EventProcessorIfEnabled().RecordEvaluationEvent(e); @@ -982,10 +1001,7 @@ void Dispose(bool disposing) _dataSourceUpdateSink.UpdateStatus(DataSourceState.Shutdown, null); _backgroundModeManager.BackgroundModeChanged -= OnBackgroundModeChanged; - foreach (var hook in _pluginHooks) - { - hook?.Dispose(); - } + _hookExecutor.Dispose(); _connectionManager.Dispose(); _dataStore.Dispose(); _eventProcessor.Dispose(); diff --git a/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/EvaluationSeriesTest.cs b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/EvaluationSeriesTest.cs new file mode 100644 index 00000000..178b568d --- /dev/null +++ b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/EvaluationSeriesTest.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using LaunchDarkly.Sdk.Client.Hooks; +using LaunchDarkly.Sdk.Client.Internal.Hooks.Series; +using LaunchDarkly.Sdk.Client.Internal.Hooks.Executor; +using Xunit; +using LogLevel = LaunchDarkly.Logging.LogLevel; + +namespace LaunchDarkly.Sdk.Client.Hooks +{ + using SeriesData = ImmutableDictionary; + + public class EvaluationSeriesTest : BaseTest + { + private class SpyHook : Hook + { + private readonly List _recorder; + public SpyHook(string name, List recorder) : base(name) + { + _recorder = recorder; + } + + public override SeriesData BeforeEvaluation(EvaluationSeriesContext context, SeriesData data) + { + _recorder.Add(Metadata.Name + "_before"); + return data; + } + + public override SeriesData AfterEvaluation(EvaluationSeriesContext context, SeriesData data, + EvaluationDetail detail) + { + _recorder.Add(Metadata.Name + "_after"); + return data; + } + } + + private class ThrowingHook : SpyHook + { + private readonly string _beforeError; + private readonly string _afterError; + + public ThrowingHook(string name, List recorder, string beforeError, string afterError) + : base(name, recorder) + { + _beforeError = beforeError; + _afterError = afterError; + } + + public override SeriesData BeforeEvaluation(EvaluationSeriesContext context, SeriesData data) + { + if (_beforeError != null) + { + throw new Exception(_beforeError); + } + return base.BeforeEvaluation(context, data); + } + + public override SeriesData AfterEvaluation(EvaluationSeriesContext context, SeriesData data, + EvaluationDetail detail) + { + if (_afterError != null) + { + throw new Exception(_afterError); + } + return base.AfterEvaluation(context, data, detail); + } + } + + private class DisposingHook : Hook + { + private bool _disposedValue; + + public bool Disposed => _disposedValue; + public DisposingHook(string name) : base(name) + { + _disposedValue = false; + } + + protected override void Dispose(bool disposing) + { + if (!_disposedValue) + { + _disposedValue = true; + } + base.Dispose(disposing); + } + } + + [Theory] + [InlineData(new string[] { }, new string[] { })] + [InlineData(new[] { "a" }, new[] { "a_before", "a_after" })] + [InlineData(new[] { "a", "b", "c" }, + new[] { "a_before", "b_before", "c_before", "c_after", "b_after", "a_after" })] + public void HooksAreExecutedInLifoOrder(string[] hookNames, string[] executions) + { + var got = new List(); + + var context = new EvaluationSeriesContext("flag", Context.New("test"), LdValue.Null, + Method.BoolVariation); + + var executor = new Executor(testLogger, hookNames.Select(name => new SpyHook(name, got))); + + executor.EvaluationSeries(context, LdValue.Convert.Bool, () => new EvaluationDetail()); + + Assert.Equal(executions, got); + } + + [Fact] + public void MultipleExceptionsThrownFromDifferentStagesShouldNotPreventOtherStagesFromRunning() + { + var got = new List(); + + var context = new EvaluationSeriesContext("flag", Context.New("test"), LdValue.Null, + Method.BoolVariation); + + var hooks = new List + { + new ThrowingHook("a", got, "error in before!", "error in after!"), + new SpyHook("b", got), + new ThrowingHook("c", got, null, "error in after!"), + new SpyHook("d", got), + new ThrowingHook("e", got, "error in before!", null), + new SpyHook("f", got) + }; + + var before = new BeforeEvaluation(testLogger, hooks, EvaluationStage.Order.Forward); + var after = new AfterEvaluation(testLogger, hooks, EvaluationStage.Order.Reverse); + + var beforeData = before.Execute(context, null).ToList(); + + Assert.True(beforeData.Count == hooks.Count); + Assert.True(beforeData.All(d => d.Equals(SeriesData.Empty))); + + var afterData = after.Execute(context, new EvaluationDetail(), beforeData).ToList(); + + Assert.True(afterData.Count == hooks.Count); + Assert.True(afterData.All(d => d.Equals(SeriesData.Empty))); + + var expected = new List + { + "b_before", "c_before", "d_before", "f_before", + "f_after", "e_after", "d_after", "b_after" + }; + Assert.Equal(expected, got); + } + + [Theory] + [InlineData("flag-1", "LaunchDarkly Test hook", "before failed", "after failed")] + [InlineData("flag-2", "test-hook", "before exception!", "after exception!")] + public void StageFailureLogsExpectedMessages(string flagName, string hookName, string beforeError, + string afterError) + { + var hooks = new List + { + new ThrowingHook(hookName, new List(), beforeError, afterError) + }; + + var context = new EvaluationSeriesContext(flagName, Context.New("test"), LdValue.Null, + Method.BoolVariation); + + var executor = new Executor(testLogger, hooks); + + executor.EvaluationSeries(context, LdValue.Convert.Bool, () => new EvaluationDetail()); + + Assert.True(logCapture.GetMessages().Count == 2); + + logCapture.HasMessageWithText(LogLevel.Error, + $"During evaluation of flag \"{flagName}\", stage \"BeforeEvaluation\" of hook \"{hookName}\" reported error: {beforeError}"); + logCapture.HasMessageWithText(LogLevel.Error, + $"During evaluation of flag \"{flagName}\", stage \"AfterEvaluation\" of hook \"{hookName}\" reported error: {afterError}"); + } + + [Fact] + public void DisposeCallsDisposeOnAllHooks() + { + var hooks = new List + { + new DisposingHook("a"), + new DisposingHook("b"), + new DisposingHook("c") + }; + + var executor = new Executor(testLogger, hooks); + var context = new EvaluationSeriesContext("", Context.New("foo"), LdValue.Null, "bar"); + + executor.EvaluationSeries(context, LdValue.Convert.Bool, () => new EvaluationDetail()); + + Assert.True(hooks.All(h => !h.Disposed)); + executor.Dispose(); + Assert.True(hooks.All(h => h.Disposed)); + } + } +} From 5c8ddd4216b393fd6f9702e8c6b2a1f151774141 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 26 Feb 2026 13:25:45 -0800 Subject: [PATCH 06/21] Refactor plugin registration logic in LdClient to ensure hooks are available before registration --- pkgs/sdk/client/src/LdClient.cs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pkgs/sdk/client/src/LdClient.cs b/pkgs/sdk/client/src/LdClient.cs index df783870..aae520ec 100644 --- a/pkgs/sdk/client/src/LdClient.cs +++ b/pkgs/sdk/client/src/LdClient.cs @@ -234,14 +234,15 @@ public sealed class LdClient : ILdClient }); } + PluginConfiguration pluginConfig = null; + EnvironmentMetadata environmentMetadata = null; if (_config.Plugins != null) { - var pluginConfig = _config.Plugins.Build(); + pluginConfig = _config.Plugins.Build(); if (pluginConfig.Plugins.Any()) { - var environmentMetadata = CreateEnvironmentMetadata(); + environmentMetadata = CreateEnvironmentMetadata(); _pluginHooks = this.GetPluginHooks(pluginConfig.Plugins, environmentMetadata, _log); - this.RegisterPlugins(pluginConfig.Plugins, environmentMetadata, _log); } } @@ -249,6 +250,12 @@ public sealed class LdClient : ILdClient ? (IHookExecutor)new Executor(_log.SubLogger(LogNames.HooksSubLog), _pluginHooks) : new NoopExecutor(); + // Register plugins after creating the hook executor to ensure hooks are available + if (pluginConfig != null && pluginConfig.Plugins.Any()) + { + this.RegisterPlugins(pluginConfig.Plugins, environmentMetadata, _log); + } + _backgroundModeManager = _config.BackgroundModeManager ?? new DefaultBackgroundModeManager(); _backgroundModeManager.BackgroundModeChanged += OnBackgroundModeChanged; } From e5d9dc0b2333f3ca5f96530942aa0f4c420f5ae5 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 26 Feb 2026 13:28:24 -0800 Subject: [PATCH 07/21] remove unnecessary parameter --- pkgs/sdk/client/src/LdClient.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/sdk/client/src/LdClient.cs b/pkgs/sdk/client/src/LdClient.cs index aae520ec..7ec87765 100644 --- a/pkgs/sdk/client/src/LdClient.cs +++ b/pkgs/sdk/client/src/LdClient.cs @@ -757,7 +757,7 @@ EvaluationDetail VariationInternal(string featureKey, LdValue defaultJson, bool checkType, EventFactory eventFactory) { var evalSeriesContext = new EvaluationSeriesContext(featureKey, Context, defaultJson, - GetMethodName(checkType, eventFactory)); + GetMethodName(eventFactory)); return _hookExecutor.EvaluationSeries(evalSeriesContext, converter, () => EvaluateInternal(featureKey, defaultJson, converter, checkType, eventFactory)); } @@ -839,7 +839,7 @@ EvaluationDetail errorResult(EvaluationErrorKind kind) => return result; } - private string GetMethodName(bool checkType, EventFactory eventFactory) + private string GetMethodName(EventFactory eventFactory) { bool isDetail = eventFactory == _eventFactoryWithReasons; var type = typeof(T); From 55a09502aac4793ce83d04bb9630f91a2c1df137 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 2 Mar 2026 19:08:11 -0800 Subject: [PATCH 08/21] remove identify --- pkgs/sdk/client/src/Hooks/Hook.cs | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/pkgs/sdk/client/src/Hooks/Hook.cs b/pkgs/sdk/client/src/Hooks/Hook.cs index 344703c8..c0d9de03 100644 --- a/pkgs/sdk/client/src/Hooks/Hook.cs +++ b/pkgs/sdk/client/src/Hooks/Hook.cs @@ -74,35 +74,6 @@ public virtual SeriesData BeforeEvaluation(EvaluationSeriesContext context, Seri public virtual SeriesData AfterEvaluation(EvaluationSeriesContext context, SeriesData data, EvaluationDetail detail) => data; - - /// - /// BeforeIdentify is executed by the SDK before an identify operation. - /// - /// The modified data is not shared with any other hook. It will be passed to subsequent stages in the identify - /// series, including . - /// - /// - /// parameters associated with this identify operation - /// user-configurable data, currently empty - /// user-configurable data, which will be forwarded to - public virtual SeriesData BeforeIdentify(IdentifySeriesContext context, SeriesData data) => - data; - - - /// - /// AfterIdentify is executed by the SDK after an identify operation. - /// - /// The function should return the given unmodified, for forward compatibility with subsequent - /// stages that may be added. - /// - /// - /// parameters associated with this identify operation - /// user-configurable data from the stage - /// the result of the identify operation - /// user-configurable data, which is currently unused but may be forwarded to subsequent stages in future versions of the SDK - public virtual SeriesData AfterIdentify(IdentifySeriesContext context, SeriesData data, - IdentifySeriesResult result) => data; - /// /// Constructs a new Hook with the given name. The name may be used in log messages. /// From 17e0c568b0ab998709f41178150bda068c93907a Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 2 Mar 2026 19:08:49 -0800 Subject: [PATCH 09/21] fix comment --- pkgs/sdk/client/src/Hooks/IdentifySeriesResult.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkgs/sdk/client/src/Hooks/IdentifySeriesResult.cs b/pkgs/sdk/client/src/Hooks/IdentifySeriesResult.cs index 3e576732..b803e51f 100644 --- a/pkgs/sdk/client/src/Hooks/IdentifySeriesResult.cs +++ b/pkgs/sdk/client/src/Hooks/IdentifySeriesResult.cs @@ -1,8 +1,7 @@ namespace LaunchDarkly.Sdk.Client.Hooks { /// - /// IdentifySeriesResult contains the outcome of an identify operation, made available - /// in . + /// IdentifySeriesResult contains the outcome of an identify operation. /// public sealed class IdentifySeriesResult { From 88078c393864a7c6f5f61a093f201ecdae53c90d Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 2 Mar 2026 21:26:21 -0800 Subject: [PATCH 10/21] remove files --- .../client/src/Hooks/IdentifySeriesContext.cs | 30 -------------- .../client/src/Hooks/IdentifySeriesResult.cs | 39 ------------------- pkgs/sdk/client/src/LdClient.cs | 4 +- 3 files changed, 3 insertions(+), 70 deletions(-) delete mode 100644 pkgs/sdk/client/src/Hooks/IdentifySeriesContext.cs delete mode 100644 pkgs/sdk/client/src/Hooks/IdentifySeriesResult.cs diff --git a/pkgs/sdk/client/src/Hooks/IdentifySeriesContext.cs b/pkgs/sdk/client/src/Hooks/IdentifySeriesContext.cs deleted file mode 100644 index c4f2ed4b..00000000 --- a/pkgs/sdk/client/src/Hooks/IdentifySeriesContext.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace LaunchDarkly.Sdk.Client.Hooks -{ - /// - /// IdentifySeriesContext represents parameters associated with an identify operation. It is - /// made available in stage callbacks. - /// - public sealed class IdentifySeriesContext - { - /// - /// The Context being identified. - /// - public Context Context { get; } - - /// - /// The timeout in seconds for the identify operation. - /// - public int Timeout { get; } - - /// - /// Constructs a new IdentifySeriesContext. - /// - /// the context being identified - /// the timeout in seconds - public IdentifySeriesContext(Context context, int timeout) - { - Context = context; - Timeout = timeout; - } - } -} diff --git a/pkgs/sdk/client/src/Hooks/IdentifySeriesResult.cs b/pkgs/sdk/client/src/Hooks/IdentifySeriesResult.cs deleted file mode 100644 index 3e576732..00000000 --- a/pkgs/sdk/client/src/Hooks/IdentifySeriesResult.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace LaunchDarkly.Sdk.Client.Hooks -{ - /// - /// IdentifySeriesResult contains the outcome of an identify operation, made available - /// in . - /// - public sealed class IdentifySeriesResult - { - /// - /// Represents the possible statuses of an identify operation. - /// - public enum IdentifySeriesStatus - { - /// - /// The identify operation completed successfully. - /// - Completed, - - /// - /// The identify operation encountered an error. - /// - Error - } - - /// - /// The status of the identify operation. - /// - public IdentifySeriesStatus Status { get; } - - /// - /// Constructs a new IdentifySeriesResult. - /// - /// the status of the identify operation - public IdentifySeriesResult(IdentifySeriesStatus status) - { - Status = status; - } - } -} diff --git a/pkgs/sdk/client/src/LdClient.cs b/pkgs/sdk/client/src/LdClient.cs index 7ec87765..5b9960dc 100644 --- a/pkgs/sdk/client/src/LdClient.cs +++ b/pkgs/sdk/client/src/LdClient.cs @@ -974,7 +974,9 @@ private EnvironmentMetadata CreateEnvironmentMetadata() var applicationMetadata = new ApplicationMetadata( applicationInfo.ApplicationId, - applicationInfo.ApplicationVersion + applicationInfo.ApplicationVersion, + applicationInfo.ApplicationName, + applicationInfo.ApplicationVersionName ); return new EnvironmentMetadata(sdkMetadata, _config.MobileKey, CredentialType.MobileKey, applicationMetadata); From b08463ad7e79ecdaccc4307608c6003f6c0be0b8 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 2 Mar 2026 22:49:12 -0800 Subject: [PATCH 11/21] address feadback --- .../src/Hooks/EvaluationSeriesContext.cs | 10 +------ pkgs/sdk/client/src/LdClient.cs | 27 ++++++------------- 2 files changed, 9 insertions(+), 28 deletions(-) diff --git a/pkgs/sdk/client/src/Hooks/EvaluationSeriesContext.cs b/pkgs/sdk/client/src/Hooks/EvaluationSeriesContext.cs index 3c1135ef..132d6d8c 100644 --- a/pkgs/sdk/client/src/Hooks/EvaluationSeriesContext.cs +++ b/pkgs/sdk/client/src/Hooks/EvaluationSeriesContext.cs @@ -25,11 +25,6 @@ public sealed class EvaluationSeriesContext { /// public string Method { get; } - /// - /// The environment ID for the evaluation, or null if not available. - /// - public string EnvironmentId { get; } - /// /// Constructs a new EvaluationSeriesContext. /// @@ -37,14 +32,11 @@ public sealed class EvaluationSeriesContext { /// the context /// the default value /// the variation method - /// the environment ID - public EvaluationSeriesContext(string flagKey, Context context, LdValue defaultValue, string method, - string environmentId = null) { + public EvaluationSeriesContext(string flagKey, Context context, LdValue defaultValue, string method) { FlagKey = flagKey; Context = context; DefaultValue = defaultValue; Method = method; - EnvironmentId = environmentId; } } } diff --git a/pkgs/sdk/client/src/LdClient.cs b/pkgs/sdk/client/src/LdClient.cs index 5b9960dc..3e8f7321 100644 --- a/pkgs/sdk/client/src/LdClient.cs +++ b/pkgs/sdk/client/src/LdClient.cs @@ -73,7 +73,6 @@ public sealed class LdClient : ILdClient readonly AnonymousKeyContextDecorator _anonymousKeyContextDecorator; private readonly AutoEnvContextDecorator _autoEnvContextDecorator; private readonly IHookExecutor _hookExecutor; - private List _pluginHooks = new List(); private readonly Logger _log; @@ -234,27 +233,17 @@ public sealed class LdClient : ILdClient }); } - PluginConfiguration pluginConfig = null; - EnvironmentMetadata environmentMetadata = null; - if (_config.Plugins != null) - { - pluginConfig = _config.Plugins.Build(); - if (pluginConfig.Plugins.Any()) - { - environmentMetadata = CreateEnvironmentMetadata(); - _pluginHooks = this.GetPluginHooks(pluginConfig.Plugins, environmentMetadata, _log); - } - } + var pluginConfig = (_config.Plugins ?? Components.Plugins()).Build(); + var environmentMetadata = pluginConfig.Plugins.Any() ? CreateEnvironmentMetadata() : null; + var hooks = pluginConfig.Plugins.Any() + ? this.GetPluginHooks(pluginConfig.Plugins, environmentMetadata, _log) + : new List(); - _hookExecutor = _pluginHooks.Any() - ? (IHookExecutor)new Executor(_log.SubLogger(LogNames.HooksSubLog), _pluginHooks) + _hookExecutor = hooks.Any() + ? (IHookExecutor)new Executor(_log.SubLogger(LogNames.HooksSubLog), hooks) : new NoopExecutor(); - // Register plugins after creating the hook executor to ensure hooks are available - if (pluginConfig != null && pluginConfig.Plugins.Any()) - { - this.RegisterPlugins(pluginConfig.Plugins, environmentMetadata, _log); - } + this.RegisterPlugins(pluginConfig.Plugins, environmentMetadata, _log); _backgroundModeManager = _config.BackgroundModeManager ?? new DefaultBackgroundModeManager(); _backgroundModeManager.BackgroundModeChanged += OnBackgroundModeChanged; From 451e890abfd5af828733ed299483fe0638c5a880 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 10 Mar 2026 15:56:32 -0700 Subject: [PATCH 12/21] Update IdentifyAsync method to include maxWaitTime parameter - Modified the ILdClient and ILdClientExtensions interfaces to add a maxWaitTime parameter to IdentifyAsync. - Updated related documentation and references across multiple files to reflect the new method signature. - Enhanced the Identify method implementation to utilize the updated IdentifyAsync method with the maxWaitTime parameter. - Introduced IdentifySeries method in the hook executor to manage identify operations with hooks, including error handling and execution order. --- pkgs/sdk/client/src/ILdClientExtensions.cs | 4 +- .../sdk/client/src/Interfaces/IFlagTracker.cs | 4 +- pkgs/sdk/client/src/Interfaces/ILdClient.cs | 13 +- .../src/Internal/Hooks/Executor/Executor.cs | 31 +++ .../Internal/Hooks/Executor/NoopExecutor.cs | 3 + .../Hooks/Interfaces/IHookExecutor.cs | 10 + .../Internal/Hooks/Series/IdentifySeries.cs | 89 +++++++ pkgs/sdk/client/src/LdClient.cs | 23 +- .../Hooks/IdentifySeriesTest.cs | 238 ++++++++++++++++++ .../ILdClientExtensionsTest.cs | 2 +- 10 files changed, 396 insertions(+), 21 deletions(-) create mode 100644 pkgs/sdk/client/src/Internal/Hooks/Series/IdentifySeries.cs create mode 100644 pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs diff --git a/pkgs/sdk/client/src/ILdClientExtensions.cs b/pkgs/sdk/client/src/ILdClientExtensions.cs index 549adaf7..f4640e18 100644 --- a/pkgs/sdk/client/src/ILdClientExtensions.cs +++ b/pkgs/sdk/client/src/ILdClientExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading.Tasks; using LaunchDarkly.Sdk.Client.Interfaces; @@ -116,7 +116,7 @@ public static bool Identify(this ILdClient client, User user, TimeSpan maxWaitTi /// and generates an analytics event to tell LaunchDarkly about the user. /// /// - /// This is equivalent to , but using the + /// This is equivalent to , but using the /// type instead of . /// /// the client instance diff --git a/pkgs/sdk/client/src/Interfaces/IFlagTracker.cs b/pkgs/sdk/client/src/Interfaces/IFlagTracker.cs index 722e971c..d95f468a 100644 --- a/pkgs/sdk/client/src/Interfaces/IFlagTracker.cs +++ b/pkgs/sdk/client/src/Interfaces/IFlagTracker.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace LaunchDarkly.Sdk.Client.Interfaces { @@ -24,7 +24,7 @@ public interface IFlagTracker /// /// /// Currently this event will not fire in a scenario where 1. the client is offline, 2. - /// or + /// or /// has been called to change the current user, and 3. the SDK had previously stored flag data /// for that user (see ) and has /// now loaded those flags. The event will only fire if the SDK has received new flag data diff --git a/pkgs/sdk/client/src/Interfaces/ILdClient.cs b/pkgs/sdk/client/src/Interfaces/ILdClient.cs index 5629eb8e..e0d8c9a6 100644 --- a/pkgs/sdk/client/src/Interfaces/ILdClient.cs +++ b/pkgs/sdk/client/src/Interfaces/ILdClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading.Tasks; using LaunchDarkly.Sdk.Client.Integrations; @@ -48,7 +48,7 @@ public interface ILdClient : IDisposable /// . This serves the purpose of letting the app know that there was a problem of some kind. /// /// - /// If you call or , + /// If you call or , /// will become until the SDK receives the new context's flags. /// /// @@ -309,7 +309,7 @@ public interface ILdClient : IDisposable /// /// /// - /// This is equivalent to , but as a synchronous method. + /// This is equivalent to , but as a synchronous method. /// /// /// If the SDK is online, waits to receive feature flag values for the new context from @@ -319,14 +319,14 @@ public interface ILdClient : IDisposable /// in offline mode, it returns . /// /// - /// If you do not want to wait, you can either set maxWaitTime to zero or call . + /// If you do not want to wait, you can either set maxWaitTime to zero or call . /// /// /// the new evaluation context; see for more /// about setting the context and optionally requesting a unique key for it /// the maximum time to wait for the new flag values /// true if new flag values were obtained - /// + /// bool Identify(Context context, TimeSpan maxWaitTime); /// @@ -346,9 +346,10 @@ public interface ILdClient : IDisposable /// /// the new evaluation context; see for more /// about setting the context and optionally requesting a unique key for it + /// the maximum time to wait for the new flag values; defaults to zero (no timeout) /// a task that yields true if new flag values were obtained /// - Task IdentifyAsync(Context context); + Task IdentifyAsync(Context context, TimeSpan maxWaitTime = default); /// /// Tells the client that all pending analytics events (if any) should be delivered as soon diff --git a/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs b/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs index 890e19c5..c811a14d 100644 --- a/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs +++ b/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using LaunchDarkly.Logging; using LaunchDarkly.Sdk.Client.Hooks; using LaunchDarkly.Sdk.Client.Internal.Hooks.Interfaces; @@ -16,12 +17,17 @@ internal sealed class Executor : IHookExecutor private readonly IStageExecutor _beforeEvaluation; private readonly IStageExecutor> _afterEvaluation; + private readonly IStageExecutor _beforeIdentify; + private readonly IStageExecutor _afterIdentify; + public Executor(Logger logger, IEnumerable hooks) { _logger = logger; _hooks = hooks.ToList(); _beforeEvaluation = new BeforeEvaluation(logger, _hooks, EvaluationStage.Order.Forward); _afterEvaluation = new AfterEvaluation(logger, _hooks, EvaluationStage.Order.Reverse); + _beforeIdentify = new BeforeIdentify(logger, _hooks, EvaluationStage.Order.Forward); + _afterIdentify = new AfterIdentify(logger, _hooks, EvaluationStage.Order.Reverse); } public EvaluationDetail EvaluationSeries(EvaluationSeriesContext context, @@ -38,6 +44,31 @@ public EvaluationDetail EvaluationSeries(EvaluationSeriesContext context, return detail; } + public async Task IdentifySeries(Context context, TimeSpan maxWaitTime, Func> identify) + { + var identifyContext = new IdentifySeriesContext(context, (int)maxWaitTime.TotalSeconds); + var seriesData = _beforeIdentify.Execute(identifyContext, default); + + try + { + var result = await identify(); + + _afterIdentify.Execute(identifyContext, + new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.Completed), + seriesData); + + return result; + } + catch (Exception) + { + _afterIdentify.Execute(identifyContext, + new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.Error), + seriesData); + + throw; + } + } + public void Dispose() { foreach (var hook in _hooks) diff --git a/pkgs/sdk/client/src/Internal/Hooks/Executor/NoopExecutor.cs b/pkgs/sdk/client/src/Internal/Hooks/Executor/NoopExecutor.cs index cdc4a39a..9fa95fb2 100644 --- a/pkgs/sdk/client/src/Internal/Hooks/Executor/NoopExecutor.cs +++ b/pkgs/sdk/client/src/Internal/Hooks/Executor/NoopExecutor.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using LaunchDarkly.Sdk.Client.Hooks; using LaunchDarkly.Sdk.Client.Internal.Hooks.Interfaces; @@ -13,6 +14,8 @@ internal sealed class NoopExecutor : IHookExecutor public EvaluationDetail EvaluationSeries(EvaluationSeriesContext context, LdValue.Converter converter, Func> evaluate) => evaluate(); + public Task IdentifySeries(Context context, TimeSpan maxWaitTime, Func> identify) => identify(); + public void Dispose() { } diff --git a/pkgs/sdk/client/src/Internal/Hooks/Interfaces/IHookExecutor.cs b/pkgs/sdk/client/src/Internal/Hooks/Interfaces/IHookExecutor.cs index 6899bac1..61486abc 100644 --- a/pkgs/sdk/client/src/Internal/Hooks/Interfaces/IHookExecutor.cs +++ b/pkgs/sdk/client/src/Internal/Hooks/Interfaces/IHookExecutor.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using LaunchDarkly.Sdk.Client.Hooks; namespace LaunchDarkly.Sdk.Client.Internal.Hooks.Interfaces @@ -21,5 +22,14 @@ internal interface IHookExecutor : IDisposable /// the EvaluationDetail returned from the evaluator EvaluationDetail EvaluationSeries(EvaluationSeriesContext context, LdValue.Converter converter, Func> evaluate); + + /// + /// IdentifySeries should run the identify series for each configured hook. + /// + /// the evaluation context being identified + /// the timeout for the identify operation + /// async function that performs the identify operation + /// the result of the identify operation + Task IdentifySeries(Context context, TimeSpan maxWaitTime, Func> identify); } } diff --git a/pkgs/sdk/client/src/Internal/Hooks/Series/IdentifySeries.cs b/pkgs/sdk/client/src/Internal/Hooks/Series/IdentifySeries.cs new file mode 100644 index 00000000..a8e2d2db --- /dev/null +++ b/pkgs/sdk/client/src/Internal/Hooks/Series/IdentifySeries.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.Client.Hooks; +using LaunchDarkly.Sdk.Client.Internal.Hooks.Interfaces; + +namespace LaunchDarkly.Sdk.Client.Internal.Hooks.Series +{ + using SeriesData = ImmutableDictionary; + + internal class IdentifyStage + { + protected enum Stage + { + BeforeIdentify, + AfterIdentify + } + + protected readonly EvaluationStage.Order _order; + private readonly Logger _logger; + + protected IdentifyStage(Logger logger, EvaluationStage.Order order) + { + _logger = logger; + _order = order; + } + + protected void LogFailure(IdentifySeriesContext context, Hook h, Stage stage, Exception e) + { + _logger.Error("During identify of context \"{0}\", stage \"{1}\" of hook \"{2}\" reported error: {3}", + context.Context.Key, stage.ToString(), h.Metadata.Name, e.Message); + } + } + + internal sealed class BeforeIdentify : IdentifyStage, IStageExecutor + { + private readonly IEnumerable _hooks; + + public BeforeIdentify(Logger logger, IEnumerable hooks, EvaluationStage.Order order) : base(logger, order) + { + _hooks = (order == EvaluationStage.Order.Forward) ? hooks : hooks.Reverse(); + } + + public IEnumerable Execute(IdentifySeriesContext context, IEnumerable _) + { + return _hooks.Select(hook => + { + try + { + return hook.BeforeIdentify(context, SeriesData.Empty); + } + catch (Exception e) + { + LogFailure(context, hook, Stage.BeforeIdentify, e); + return SeriesData.Empty; + } + }).ToList(); + } + } + + internal sealed class AfterIdentify : IdentifyStage, IStageExecutor + { + private readonly IEnumerable _hooks; + + public AfterIdentify(Logger logger, IEnumerable hooks, EvaluationStage.Order order) : base(logger, order) + { + _hooks = (order == EvaluationStage.Order.Forward) ? hooks : hooks.Reverse(); + } + + public IEnumerable Execute(IdentifySeriesContext context, IdentifySeriesResult result, + IEnumerable seriesData) + { + return _hooks.Zip((_order == EvaluationStage.Order.Reverse ? seriesData.Reverse() : seriesData), (hook, data) => + { + try + { + return hook.AfterIdentify(context, data, result); + } + catch (Exception e) + { + LogFailure(context, hook, Stage.AfterIdentify, e); + return SeriesData.Empty; + } + }).ToList(); + } + } +} diff --git a/pkgs/sdk/client/src/LdClient.cs b/pkgs/sdk/client/src/LdClient.cs index 3e8f7321..82515428 100644 --- a/pkgs/sdk/client/src/LdClient.cs +++ b/pkgs/sdk/client/src/LdClient.cs @@ -30,7 +30,7 @@ namespace LaunchDarkly.Sdk.Client /// /// Like all client-side LaunchDarkly SDKs, the LdClient always has a single current . /// You specify this context at initialization time, and you can change it later with - /// or . All subsequent calls to evaluation methods like + /// or . All subsequent calls to evaluation methods like /// refer to the flag values for the current context. /// /// @@ -106,7 +106,7 @@ public sealed class LdClient : ILdClient /// /// This is initially the context specified for or /// , but can be changed later with - /// or . + /// or . /// public Context Context => LockUtils.WithReadLock(_stateLock, () => _context); @@ -902,11 +902,11 @@ public Task FlushAndWaitAsync(TimeSpan timeout) => /// public bool Identify(Context context, TimeSpan maxWaitTime) { - return AsyncUtils.WaitSafely(() => IdentifyAsync(context), maxWaitTime); + return AsyncUtils.WaitSafely(() => IdentifyAsync(context, maxWaitTime), maxWaitTime); } /// - public async Task IdentifyAsync(Context context) + public async Task IdentifyAsync(Context context, TimeSpan maxWaitTime = default) { Context newContext = _anonymousKeyContextDecorator.DecorateContext(context); if (_config.AutoEnvAttributes) @@ -943,13 +943,16 @@ public async Task IdentifyAsync(Context context) false // false means "don't rewrite the flags to persistent storage" ); - EventProcessorIfEnabled().RecordIdentifyEvent(new EventProcessorTypes.IdentifyEvent + return await _hookExecutor.IdentifySeries(newContext, maxWaitTime, async () => { - Timestamp = UnixMillisecondTime.Now, - Context = newContext - }); + EventProcessorIfEnabled().RecordIdentifyEvent(new EventProcessorTypes.IdentifyEvent + { + Timestamp = UnixMillisecondTime.Now, + Context = newContext + }); - return await _connectionManager.SetContext(newContext); + return await _connectionManager.SetContext(newContext); + }); } private EnvironmentMetadata CreateEnvironmentMetadata() @@ -970,7 +973,7 @@ private EnvironmentMetadata CreateEnvironmentMetadata() return new EnvironmentMetadata(sdkMetadata, _config.MobileKey, CredentialType.MobileKey, applicationMetadata); } - + /// /// Permanently shuts down the SDK client. /// diff --git a/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs new file mode 100644 index 00000000..78111455 --- /dev/null +++ b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using LaunchDarkly.Sdk.Client.Hooks; +using LaunchDarkly.Sdk.Client.Internal.Hooks.Series; +using LaunchDarkly.Sdk.Client.Internal.Hooks.Executor; +using Xunit; +using LogLevel = LaunchDarkly.Logging.LogLevel; + +namespace LaunchDarkly.Sdk.Client.Hooks +{ + using SeriesData = ImmutableDictionary; + + public class IdentifySeriesTest : BaseTest + { + private class SpyHook : Hook + { + private readonly List _recorder; + + public SpyHook(string name, List recorder) : base(name) + { + _recorder = recorder; + } + + public override SeriesData BeforeIdentify(IdentifySeriesContext context, SeriesData data) + { + _recorder.Add(Metadata.Name + "_before"); + return data; + } + + public override SeriesData AfterIdentify(IdentifySeriesContext context, SeriesData data, + IdentifySeriesResult result) + { + _recorder.Add(Metadata.Name + "_after"); + return data; + } + } + + private class ThrowingHook : SpyHook + { + private readonly string _beforeError; + private readonly string _afterError; + + public ThrowingHook(string name, List recorder, string beforeError, string afterError) + : base(name, recorder) + { + _beforeError = beforeError; + _afterError = afterError; + } + + public override SeriesData BeforeIdentify(IdentifySeriesContext context, SeriesData data) + { + if (_beforeError != null) + { + throw new Exception(_beforeError); + } + return base.BeforeIdentify(context, data); + } + + public override SeriesData AfterIdentify(IdentifySeriesContext context, SeriesData data, + IdentifySeriesResult result) + { + if (_afterError != null) + { + throw new Exception(_afterError); + } + return base.AfterIdentify(context, data, result); + } + } + + private class DataCapturingHook : Hook + { + public IdentifySeriesResult CapturedResult { get; private set; } + public SeriesData CapturedAfterData { get; private set; } + + public DataCapturingHook(string name) : base(name) { } + + public override SeriesData BeforeIdentify(IdentifySeriesContext context, SeriesData data) + { + var builder = data.ToBuilder(); + builder["before"] = "was called"; + return builder.ToImmutable(); + } + + public override SeriesData AfterIdentify(IdentifySeriesContext context, SeriesData data, + IdentifySeriesResult result) + { + CapturedResult = result; + CapturedAfterData = data; + return data; + } + } + + private static Context MakeContext(string key = "test") => Context.New(key); + + [Theory] + [InlineData(new string[] { }, new string[] { })] + [InlineData(new[] { "a" }, new[] { "a_before", "a_after" })] + [InlineData(new[] { "a", "b", "c" }, + new[] { "a_before", "b_before", "c_before", "c_after", "b_after", "a_after" })] + public async Task HooksAreExecutedInLifoOrder(string[] hookNames, string[] executions) + { + var got = new List(); + + var context = MakeContext(); + + var executor = new Executor(testLogger, hookNames.Select(name => new SpyHook(name, got))); + + await executor.IdentifySeries(context, TimeSpan.Zero, () => Task.FromResult(true)); + + Assert.Equal(executions, got); + } + + [Fact] + public async Task MultipleExceptionsThrownFromDifferentStagesShouldNotPreventOtherStagesFromRunning() + { + var got = new List(); + + var seriesContext = new IdentifySeriesContext(MakeContext(), 0); + + var hooks = new List + { + new ThrowingHook("a", got, "error in before!", "error in after!"), + new SpyHook("b", got), + new ThrowingHook("c", got, null, "error in after!"), + new SpyHook("d", got), + new ThrowingHook("e", got, "error in before!", null), + new SpyHook("f", got) + }; + + var before = new BeforeIdentify(testLogger, hooks, EvaluationStage.Order.Forward); + var after = new AfterIdentify(testLogger, hooks, EvaluationStage.Order.Reverse); + + var beforeData = before.Execute(seriesContext, null).ToList(); + + Assert.True(beforeData.Count == hooks.Count); + Assert.True(beforeData.All(d => d.Equals(SeriesData.Empty))); + + var afterData = after.Execute(seriesContext, + new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.Completed), beforeData).ToList(); + + Assert.True(afterData.Count == hooks.Count); + Assert.True(afterData.All(d => d.Equals(SeriesData.Empty))); + + var expected = new List + { + "b_before", "c_before", "d_before", "f_before", + "f_after", "e_after", "d_after", "b_after" + }; + Assert.Equal(expected, got); + } + + [Theory] + [InlineData("test-context-1", "LaunchDarkly Test hook", "before failed", "after failed")] + [InlineData("test-context-2", "test-hook", "before exception!", "after exception!")] + public async Task StageFailureLogsExpectedMessages(string contextKey, string hookName, string beforeError, + string afterError) + { + var hooks = new List + { + new ThrowingHook(hookName, new List(), beforeError, afterError) + }; + + var context = MakeContext(contextKey); + + var executor = new Executor(testLogger, hooks); + + await executor.IdentifySeries(context, TimeSpan.Zero, () => Task.FromResult(true)); + + Assert.True(logCapture.GetMessages().Count == 2); + + logCapture.HasMessageWithText(LogLevel.Error, + $"During identify of context \"{contextKey}\", stage \"BeforeIdentify\" of hook \"{hookName}\" reported error: {beforeError}"); + logCapture.HasMessageWithText(LogLevel.Error, + $"During identify of context \"{contextKey}\", stage \"AfterIdentify\" of hook \"{hookName}\" reported error: {afterError}"); + } + + [Fact] + public async Task IdentifyResultIsCapturedAsCompleted() + { + var hook = new DataCapturingHook("capturing-hook"); + var context = MakeContext(); + + var executor = new Executor(testLogger, new List { hook }); + + await executor.IdentifySeries(context, TimeSpan.Zero, () => Task.FromResult(true)); + + Assert.NotNull(hook.CapturedResult); + Assert.Equal(IdentifySeriesResult.IdentifySeriesStatus.Completed, hook.CapturedResult.Status); + } + + [Fact] + public async Task IdentifyResultIsCapturedAsErrorOnException() + { + var hook = new DataCapturingHook("capturing-hook"); + var context = MakeContext(); + + var executor = new Executor(testLogger, new List { hook }); + + await Assert.ThrowsAsync(async () => + await executor.IdentifySeries(context, TimeSpan.Zero, + () => Task.FromException(new InvalidOperationException("identify failed")))); + + Assert.NotNull(hook.CapturedResult); + Assert.Equal(IdentifySeriesResult.IdentifySeriesStatus.Error, hook.CapturedResult.Status); + } + + [Fact] + public async Task BeforeHookPassesDataToAfterHook() + { + var hook = new DataCapturingHook("capturing-hook"); + var context = MakeContext(); + + var executor = new Executor(testLogger, new List { hook }); + + await executor.IdentifySeries(context, TimeSpan.Zero, () => Task.FromResult(true)); + + Assert.NotNull(hook.CapturedAfterData); + Assert.Equal("was called", hook.CapturedAfterData["before"]); + } + + [Fact] + public async Task IdentifyOperationResultIsReturned() + { + var got = new List(); + var hooks = new List { new SpyHook("a", got) }; + + var context = MakeContext(); + var executor = new Executor(testLogger, hooks); + + var result = await executor.IdentifySeries(context, TimeSpan.Zero, () => Task.FromResult(true)); + + Assert.True(result); + } + } +} diff --git a/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/ILdClientExtensionsTest.cs b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/ILdClientExtensionsTest.cs index dafeb5a7..d51da67c 100644 --- a/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/ILdClientExtensionsTest.cs +++ b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/ILdClientExtensionsTest.cs @@ -153,7 +153,7 @@ public void Flush() { } public bool Identify(Context context, System.TimeSpan maxWaitTime) => throw new System.NotImplementedException(); - public Task IdentifyAsync(Context context) => + public Task IdentifyAsync(Context context, TimeSpan maxWaitTime = default) => throw new System.NotImplementedException(); public int IntVariation(string key, int defaultValue = 0) => From 493f59814dcea6d8d9ede3bbae0320044b3b33bf Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 10 Mar 2026 16:05:43 -0700 Subject: [PATCH 13/21] Identify in Hook interface --- pkgs/sdk/client/src/Hooks/Hook.cs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/pkgs/sdk/client/src/Hooks/Hook.cs b/pkgs/sdk/client/src/Hooks/Hook.cs index c0d9de03..344703c8 100644 --- a/pkgs/sdk/client/src/Hooks/Hook.cs +++ b/pkgs/sdk/client/src/Hooks/Hook.cs @@ -74,6 +74,35 @@ public virtual SeriesData BeforeEvaluation(EvaluationSeriesContext context, Seri public virtual SeriesData AfterEvaluation(EvaluationSeriesContext context, SeriesData data, EvaluationDetail detail) => data; + + /// + /// BeforeIdentify is executed by the SDK before an identify operation. + /// + /// The modified data is not shared with any other hook. It will be passed to subsequent stages in the identify + /// series, including . + /// + /// + /// parameters associated with this identify operation + /// user-configurable data, currently empty + /// user-configurable data, which will be forwarded to + public virtual SeriesData BeforeIdentify(IdentifySeriesContext context, SeriesData data) => + data; + + + /// + /// AfterIdentify is executed by the SDK after an identify operation. + /// + /// The function should return the given unmodified, for forward compatibility with subsequent + /// stages that may be added. + /// + /// + /// parameters associated with this identify operation + /// user-configurable data from the stage + /// the result of the identify operation + /// user-configurable data, which is currently unused but may be forwarded to subsequent stages in future versions of the SDK + public virtual SeriesData AfterIdentify(IdentifySeriesContext context, SeriesData data, + IdentifySeriesResult result) => data; + /// /// Constructs a new Hook with the given name. The name may be used in log messages. /// From 61629a68a92db37f43c92e6026aeac7251dce3f2 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 10 Mar 2026 16:16:41 -0700 Subject: [PATCH 14/21] Fix IdentifyAsync method signature in LdClient documentation and implementation - Updated the IdentifyAsync method to remove the maxWaitTime parameter from the public interface. - Adjusted related documentation to reflect the new method signature. - Ensured internal implementation retains the maxWaitTime parameter for flexibility in asynchronous identification operations. --- pkgs/sdk/client/src/LdClient.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pkgs/sdk/client/src/LdClient.cs b/pkgs/sdk/client/src/LdClient.cs index 82515428..8f7e750c 100644 --- a/pkgs/sdk/client/src/LdClient.cs +++ b/pkgs/sdk/client/src/LdClient.cs @@ -30,7 +30,7 @@ namespace LaunchDarkly.Sdk.Client /// /// Like all client-side LaunchDarkly SDKs, the LdClient always has a single current . /// You specify this context at initialization time, and you can change it later with - /// or . All subsequent calls to evaluation methods like + /// or . All subsequent calls to evaluation methods like /// refer to the flag values for the current context. /// /// @@ -106,7 +106,7 @@ public sealed class LdClient : ILdClient /// /// This is initially the context specified for or /// , but can be changed later with - /// or . + /// or . /// public Context Context => LockUtils.WithReadLock(_stateLock, () => _context); @@ -906,7 +906,9 @@ public bool Identify(Context context, TimeSpan maxWaitTime) } /// - public async Task IdentifyAsync(Context context, TimeSpan maxWaitTime = default) + public Task IdentifyAsync(Context context) => IdentifyAsync(context, TimeSpan.Zero); + + internal async Task IdentifyAsync(Context context, TimeSpan maxWaitTime) { Context newContext = _anonymousKeyContextDecorator.DecorateContext(context); if (_config.AutoEnvAttributes) From c4bcf6f07a379d7997defea454b3bc324bd5861b Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 10 Mar 2026 16:18:14 -0700 Subject: [PATCH 15/21] make unneeded parameter internal --- pkgs/sdk/client/src/ILdClientExtensions.cs | 2 +- pkgs/sdk/client/src/Interfaces/IFlagTracker.cs | 2 +- pkgs/sdk/client/src/Interfaces/ILdClient.cs | 11 +++++------ .../ILdClientExtensionsTest.cs | 2 +- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/pkgs/sdk/client/src/ILdClientExtensions.cs b/pkgs/sdk/client/src/ILdClientExtensions.cs index f4640e18..de08c847 100644 --- a/pkgs/sdk/client/src/ILdClientExtensions.cs +++ b/pkgs/sdk/client/src/ILdClientExtensions.cs @@ -116,7 +116,7 @@ public static bool Identify(this ILdClient client, User user, TimeSpan maxWaitTi /// and generates an analytics event to tell LaunchDarkly about the user. /// /// - /// This is equivalent to , but using the + /// This is equivalent to , but using the /// type instead of . /// /// the client instance diff --git a/pkgs/sdk/client/src/Interfaces/IFlagTracker.cs b/pkgs/sdk/client/src/Interfaces/IFlagTracker.cs index d95f468a..d4e4c3ca 100644 --- a/pkgs/sdk/client/src/Interfaces/IFlagTracker.cs +++ b/pkgs/sdk/client/src/Interfaces/IFlagTracker.cs @@ -24,7 +24,7 @@ public interface IFlagTracker /// /// /// Currently this event will not fire in a scenario where 1. the client is offline, 2. - /// or + /// or /// has been called to change the current user, and 3. the SDK had previously stored flag data /// for that user (see ) and has /// now loaded those flags. The event will only fire if the SDK has received new flag data diff --git a/pkgs/sdk/client/src/Interfaces/ILdClient.cs b/pkgs/sdk/client/src/Interfaces/ILdClient.cs index e0d8c9a6..bf1d3f8b 100644 --- a/pkgs/sdk/client/src/Interfaces/ILdClient.cs +++ b/pkgs/sdk/client/src/Interfaces/ILdClient.cs @@ -48,7 +48,7 @@ public interface ILdClient : IDisposable /// . This serves the purpose of letting the app know that there was a problem of some kind. /// /// - /// If you call or , + /// If you call or , /// will become until the SDK receives the new context's flags. /// /// @@ -309,7 +309,7 @@ public interface ILdClient : IDisposable /// /// /// - /// This is equivalent to , but as a synchronous method. + /// This is equivalent to , but as a synchronous method. /// /// /// If the SDK is online, waits to receive feature flag values for the new context from @@ -319,14 +319,14 @@ public interface ILdClient : IDisposable /// in offline mode, it returns . /// /// - /// If you do not want to wait, you can either set maxWaitTime to zero or call . + /// If you do not want to wait, you can either set maxWaitTime to zero or call . /// /// /// the new evaluation context; see for more /// about setting the context and optionally requesting a unique key for it /// the maximum time to wait for the new flag values /// true if new flag values were obtained - /// + /// bool Identify(Context context, TimeSpan maxWaitTime); /// @@ -346,10 +346,9 @@ public interface ILdClient : IDisposable /// /// the new evaluation context; see for more /// about setting the context and optionally requesting a unique key for it - /// the maximum time to wait for the new flag values; defaults to zero (no timeout) /// a task that yields true if new flag values were obtained /// - Task IdentifyAsync(Context context, TimeSpan maxWaitTime = default); + Task IdentifyAsync(Context context); /// /// Tells the client that all pending analytics events (if any) should be delivered as soon diff --git a/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/ILdClientExtensionsTest.cs b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/ILdClientExtensionsTest.cs index d51da67c..dafeb5a7 100644 --- a/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/ILdClientExtensionsTest.cs +++ b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/ILdClientExtensionsTest.cs @@ -153,7 +153,7 @@ public void Flush() { } public bool Identify(Context context, System.TimeSpan maxWaitTime) => throw new System.NotImplementedException(); - public Task IdentifyAsync(Context context, TimeSpan maxWaitTime = default) => + public Task IdentifyAsync(Context context) => throw new System.NotImplementedException(); public int IntVariation(string key, int defaultValue = 0) => From 283e9d2752580dfdb1c7feb4b30ed178ba752de2 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 10 Mar 2026 16:38:45 -0700 Subject: [PATCH 16/21] Refactor IdentifySeriesContext to use TimeSpan for timeout - Updated IdentifySeriesContext to accept TimeSpan instead of int for timeout, improving clarity and flexibility. - Adjusted IdentifySeries method in Executor to align with the new IdentifySeriesContext signature. - Modified IdentifySeriesTest to utilize TimeSpan.Zero for timeout, ensuring consistency in test cases. --- pkgs/sdk/client/src/Hooks/IdentifySeriesContext.cs | 11 +++++++---- .../client/src/Internal/Hooks/Executor/Executor.cs | 2 +- .../Hooks/IdentifySeriesTest.cs | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/pkgs/sdk/client/src/Hooks/IdentifySeriesContext.cs b/pkgs/sdk/client/src/Hooks/IdentifySeriesContext.cs index c4f2ed4b..5614117c 100644 --- a/pkgs/sdk/client/src/Hooks/IdentifySeriesContext.cs +++ b/pkgs/sdk/client/src/Hooks/IdentifySeriesContext.cs @@ -1,3 +1,5 @@ +using System; + namespace LaunchDarkly.Sdk.Client.Hooks { /// @@ -12,16 +14,17 @@ public sealed class IdentifySeriesContext public Context Context { get; } /// - /// The timeout in seconds for the identify operation. + /// The timeout for the identify operation. A value of indicates + /// that no timeout was specified. /// - public int Timeout { get; } + public TimeSpan Timeout { get; } /// /// Constructs a new IdentifySeriesContext. /// /// the context being identified - /// the timeout in seconds - public IdentifySeriesContext(Context context, int timeout) + /// the timeout for the identify operation + public IdentifySeriesContext(Context context, TimeSpan timeout) { Context = context; Timeout = timeout; diff --git a/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs b/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs index c811a14d..7c2bf108 100644 --- a/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs +++ b/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs @@ -46,7 +46,7 @@ public EvaluationDetail EvaluationSeries(EvaluationSeriesContext context, public async Task IdentifySeries(Context context, TimeSpan maxWaitTime, Func> identify) { - var identifyContext = new IdentifySeriesContext(context, (int)maxWaitTime.TotalSeconds); + var identifyContext = new IdentifySeriesContext(context, maxWaitTime); var seriesData = _beforeIdentify.Execute(identifyContext, default); try diff --git a/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs index 78111455..27725036 100644 --- a/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs +++ b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs @@ -118,7 +118,7 @@ public async Task MultipleExceptionsThrownFromDifferentStagesShouldNotPreventOth { var got = new List(); - var seriesContext = new IdentifySeriesContext(MakeContext(), 0); + var seriesContext = new IdentifySeriesContext(MakeContext(), TimeSpan.Zero); var hooks = new List { From fe9d524431484f41184dd86a275f80db49ed6e71 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 12 Mar 2026 16:21:36 -0700 Subject: [PATCH 17/21] IdentifyWithHook --- pkgs/sdk/client/src/LdClient.cs | 33 +++++----- .../Hooks/IdentifySeriesTest.cs | 66 +++++++++++++++++++ 2 files changed, 83 insertions(+), 16 deletions(-) diff --git a/pkgs/sdk/client/src/LdClient.cs b/pkgs/sdk/client/src/LdClient.cs index 8f7e750c..55f1f168 100644 --- a/pkgs/sdk/client/src/LdClient.cs +++ b/pkgs/sdk/client/src/LdClient.cs @@ -222,17 +222,7 @@ public sealed class LdClient : ILdClient _ = _connectionManager.SetNetworkEnabled(networkAvailable); // do not await the result }; - // Send an initial identify event, but only if we weren't explicitly set to be offline - - if (!_config.Offline) - { - _eventProcessor.RecordIdentifyEvent(new EventProcessorTypes.IdentifyEvent - { - Timestamp = UnixMillisecondTime.Now, - Context = _context - }); - } - + // Build the plugin config and environment metadata var pluginConfig = (_config.Plugins ?? Components.Plugins()).Build(); var environmentMetadata = pluginConfig.Plugins.Any() ? CreateEnvironmentMetadata() : null; var hooks = pluginConfig.Plugins.Any() @@ -245,8 +235,10 @@ public sealed class LdClient : ILdClient this.RegisterPlugins(pluginConfig.Plugins, environmentMetadata, _log); + // Start the background mode manager _backgroundModeManager = _config.BackgroundModeManager ?? new DefaultBackgroundModeManager(); _backgroundModeManager.BackgroundModeChanged += OnBackgroundModeChanged; + } void Start(TimeSpan maxWaitTime) @@ -261,6 +253,7 @@ void Start(TimeSpan maxWaitTime) { _log.Warn(DidNotInitializeTimelyWarning, maxWaitTime.TotalMilliseconds); } + _ = IdentifyWithHook(_context, maxWaitTime); } /// @@ -283,6 +276,7 @@ async Task StartAsync(TimeSpan maxWaitTime) { _log.Warn(DidNotInitializeTimelyWarning, maxWaitTime.TotalMilliseconds); } + await IdentifyWithHook(_context, maxWaitTime); } /// @@ -291,6 +285,7 @@ async Task StartAsync(TimeSpan maxWaitTime) async Task StartAsync() { await _connectionManager.Start(); + await IdentifyWithHook(_context, TimeSpan.Zero); } /// @@ -902,13 +897,13 @@ public Task FlushAndWaitAsync(TimeSpan timeout) => /// public bool Identify(Context context, TimeSpan maxWaitTime) { - return AsyncUtils.WaitSafely(() => IdentifyAsync(context, maxWaitTime), maxWaitTime); + return AsyncUtils.WaitSafely(() => InternalIdentifyAsync(context, maxWaitTime), maxWaitTime); } /// - public Task IdentifyAsync(Context context) => IdentifyAsync(context, TimeSpan.Zero); + public Task IdentifyAsync(Context context) => InternalIdentifyAsync(context, TimeSpan.Zero); - internal async Task IdentifyAsync(Context context, TimeSpan maxWaitTime) + private async Task InternalIdentifyAsync(Context context, TimeSpan maxWaitTime) { Context newContext = _anonymousKeyContextDecorator.DecorateContext(context); if (_config.AutoEnvAttributes) @@ -945,7 +940,13 @@ internal async Task IdentifyAsync(Context context, TimeSpan maxWaitTime) false // false means "don't rewrite the flags to persistent storage" ); - return await _hookExecutor.IdentifySeries(newContext, maxWaitTime, async () => + await IdentifyWithHook(newContext, maxWaitTime); + return await _connectionManager.SetContext(newContext); + } + + private async Task IdentifyWithHook(Context newContext, TimeSpan maxWaitTime) + { + await _hookExecutor.IdentifySeries(newContext, maxWaitTime, () => { EventProcessorIfEnabled().RecordIdentifyEvent(new EventProcessorTypes.IdentifyEvent { @@ -953,7 +954,7 @@ internal async Task IdentifyAsync(Context context, TimeSpan maxWaitTime) Context = newContext }); - return await _connectionManager.SetContext(newContext); + return Task.FromResult(true); }); } diff --git a/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs index 27725036..df3e2212 100644 --- a/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs +++ b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs @@ -4,8 +4,12 @@ using System.Linq; using System.Threading.Tasks; using LaunchDarkly.Sdk.Client.Hooks; +using LaunchDarkly.Sdk.Client.Integrations; +using LaunchDarkly.Sdk.Client.Interfaces; using LaunchDarkly.Sdk.Client.Internal.Hooks.Series; using LaunchDarkly.Sdk.Client.Internal.Hooks.Executor; +using LaunchDarkly.Sdk.Client.Plugins; +using LaunchDarkly.Sdk.Integrations.Plugins; using Xunit; using LogLevel = LaunchDarkly.Logging.LogLevel; @@ -234,5 +238,67 @@ public async Task IdentifyOperationResultIsReturned() Assert.True(result); } + + [Fact] + public async Task IdentifyHookIsActivatedAfterInitAsync() + { + var hook = new IdentifyTrackingHook("init-hook"); + var plugin = new HookProvidingPlugin("test-plugin", hook); + var config = Configuration.Builder("mobile-key", ConfigurationBuilder.AutoEnvAttributes.Disabled) + .BackgroundModeManager(new MockBackgroundModeManager()) + .ConnectivityStateManager(new MockConnectivityStateManager(true)) + .DataSource(new MockDataSource().AsSingletonFactory()) + .Events(Components.NoEvents) + .Logging(testLogging) + .Persistence( + Components.Persistence().Storage( + new MockPersistentDataStore().AsSingletonFactory())) + .Plugins(new PluginConfigurationBuilder().Add(plugin)) + .Build(); + + using (var client = await TestUtil.CreateClientAsync(config, MakeContext(), TimeSpan.FromSeconds(5))) + { + Assert.True(hook.BeforeIdentifyCalled, "BeforeIdentify should have been called after InitAsync"); + Assert.True(hook.AfterIdentifyCalled, "AfterIdentify should have been called after InitAsync"); + } + } + + private class IdentifyTrackingHook : Hook + { + public bool BeforeIdentifyCalled { get; private set; } + public bool AfterIdentifyCalled { get; private set; } + + public IdentifyTrackingHook(string name) : base(name) { } + + public override SeriesData BeforeIdentify(IdentifySeriesContext context, SeriesData data) + { + BeforeIdentifyCalled = true; + return data; + } + + public override SeriesData AfterIdentify(IdentifySeriesContext context, SeriesData data, + IdentifySeriesResult result) + { + AfterIdentifyCalled = true; + return data; + } + } + + private class HookProvidingPlugin : Plugin + { + private readonly Hook _hook; + + public HookProvidingPlugin(string name, Hook hook) : base(name) + { + _hook = hook; + } + + public override void Register(ILdClient client, EnvironmentMetadata metadata) { } + + public override IList GetHooks(EnvironmentMetadata metadata) + { + return new List { _hook }; + } + } } } From 551c275f3842a70e03ed249d88e122e94f8a56f7 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 12 Mar 2026 16:28:11 -0700 Subject: [PATCH 18/21] return false --- pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs | 2 +- .../Hooks/IdentifySeriesTest.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs b/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs index 7c2bf108..b05a6139 100644 --- a/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs +++ b/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs @@ -65,7 +65,7 @@ public async Task IdentifySeries(Context context, TimeSpan maxWaitTime, Fu new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.Error), seriesData); - throw; + return false; } } diff --git a/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs index df3e2212..9b8c4ace 100644 --- a/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs +++ b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs @@ -203,10 +203,10 @@ public async Task IdentifyResultIsCapturedAsErrorOnException() var executor = new Executor(testLogger, new List { hook }); - await Assert.ThrowsAsync(async () => - await executor.IdentifySeries(context, TimeSpan.Zero, - () => Task.FromException(new InvalidOperationException("identify failed")))); + var result = await executor.IdentifySeries(context, TimeSpan.Zero, + () => Task.FromException(new InvalidOperationException("identify failed"))); + Assert.False(result); Assert.NotNull(hook.CapturedResult); Assert.Equal(IdentifySeriesResult.IdentifySeriesStatus.Error, hook.CapturedResult.Status); } From 0430db3a8796453c8a8d018d35bb6ef3b1d6464f Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 12 Mar 2026 17:03:02 -0700 Subject: [PATCH 19/21] Use large wrapper --- pkgs/sdk/client/src/LdClient.cs | 77 ++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 35 deletions(-) diff --git a/pkgs/sdk/client/src/LdClient.cs b/pkgs/sdk/client/src/LdClient.cs index 55f1f168..c4f9178e 100644 --- a/pkgs/sdk/client/src/LdClient.cs +++ b/pkgs/sdk/client/src/LdClient.cs @@ -253,7 +253,7 @@ void Start(TimeSpan maxWaitTime) { _log.Warn(DidNotInitializeTimelyWarning, maxWaitTime.TotalMilliseconds); } - _ = IdentifyWithHook(_context, maxWaitTime); + _ = RecordIdentify(_context, maxWaitTime); } /// @@ -276,7 +276,7 @@ async Task StartAsync(TimeSpan maxWaitTime) { _log.Warn(DidNotInitializeTimelyWarning, maxWaitTime.TotalMilliseconds); } - await IdentifyWithHook(_context, maxWaitTime); + await RecordIdentify(_context, maxWaitTime); } /// @@ -285,7 +285,7 @@ async Task StartAsync(TimeSpan maxWaitTime) async Task StartAsync() { await _connectionManager.Start(); - await IdentifyWithHook(_context, TimeSpan.Zero); + await RecordIdentify(_context, TimeSpan.Zero); } /// @@ -897,13 +897,13 @@ public Task FlushAndWaitAsync(TimeSpan timeout) => /// public bool Identify(Context context, TimeSpan maxWaitTime) { - return AsyncUtils.WaitSafely(() => InternalIdentifyAsync(context, maxWaitTime), maxWaitTime); + return AsyncUtils.WaitSafely(() => RecordIdentifyWithContextUpdate(context, maxWaitTime), maxWaitTime); } /// - public Task IdentifyAsync(Context context) => InternalIdentifyAsync(context, TimeSpan.Zero); + public Task IdentifyAsync(Context context) => RecordIdentifyWithContextUpdate(context, TimeSpan.Zero); - private async Task InternalIdentifyAsync(Context context, TimeSpan maxWaitTime) + private async Task RecordIdentifyWithContextUpdate(Context context, TimeSpan maxWaitTime) { Context newContext = _anonymousKeyContextDecorator.DecorateContext(context); if (_config.AutoEnvAttributes) @@ -911,40 +911,48 @@ private async Task InternalIdentifyAsync(Context context, TimeSpan maxWait newContext = _autoEnvContextDecorator.DecorateContext(newContext); } - Context - oldContext = - newContext; // this initialization is overwritten below, it's only here to satisfy the compiler - - LockUtils.WithWriteLock(_stateLock, () => + return await _hookExecutor.IdentifySeries(newContext, maxWaitTime, async () => { - oldContext = _context; - _context = newContext; - }); + Context + oldContext = + newContext; // this initialization is overwritten below, it's only here to satisfy the compiler - // If we had cached data for the new context, set the current in-memory flag data state to use - // that data, so that any Variation calls made before Identify has completed will use the - // last known values. If we did not have cached data, then we update the current in-memory - // state to reflect that there is no flag data, so that Variation calls done before completion - // will receive default values rather than the previous context's values. This does not modify - // any flags in persistent storage, and (currently) it does *not* trigger any FlagValueChanged - // events from FlagTracker. - var cachedData = _dataStore.GetCachedData(newContext); - if (cachedData != null) - { - _log.Debug("Identify found cached flag data for the new context"); - } + LockUtils.WithWriteLock(_stateLock, () => + { + oldContext = _context; + _context = newContext; + }); - _dataStore.Init( - newContext, - cachedData ?? new DataStoreTypes.FullDataSet(null), - false // false means "don't rewrite the flags to persistent storage" - ); + // If we had cached data for the new context, set the current in-memory flag data state to use + // that data, so that any Variation calls made before Identify has completed will use the + // last known values. If we did not have cached data, then we update the current in-memory + // state to reflect that there is no flag data, so that Variation calls done before completion + // will receive default values rather than the previous context's values. This does not modify + // any flags in persistent storage, and (currently) it does *not* trigger any FlagValueChanged + // events from FlagTracker. + var cachedData = _dataStore.GetCachedData(newContext); + if (cachedData != null) + { + _log.Debug("Identify found cached flag data for the new context"); + } + + _dataStore.Init( + newContext, + cachedData ?? new DataStoreTypes.FullDataSet(null), + false // false means "don't rewrite the flags to persistent storage" + ); + + EventProcessorIfEnabled().RecordIdentifyEvent(new EventProcessorTypes.IdentifyEvent + { + Timestamp = UnixMillisecondTime.Now, + Context = newContext + }); - await IdentifyWithHook(newContext, maxWaitTime); - return await _connectionManager.SetContext(newContext); + return await _connectionManager.SetContext(newContext); + }); } - private async Task IdentifyWithHook(Context newContext, TimeSpan maxWaitTime) + private async Task RecordIdentify(Context newContext, TimeSpan maxWaitTime) { await _hookExecutor.IdentifySeries(newContext, maxWaitTime, () => { @@ -953,7 +961,6 @@ await _hookExecutor.IdentifySeries(newContext, maxWaitTime, () => Timestamp = UnixMillisecondTime.Now, Context = newContext }); - return Task.FromResult(true); }); } From 167d6c87c6c4725ff544906cb132a45f6234a02e Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 12 Mar 2026 17:24:46 -0700 Subject: [PATCH 20/21] noopexecetor --- .../src/Internal/Hooks/Executor/NoopExecutor.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/pkgs/sdk/client/src/Internal/Hooks/Executor/NoopExecutor.cs b/pkgs/sdk/client/src/Internal/Hooks/Executor/NoopExecutor.cs index 9fa95fb2..9430d92e 100644 --- a/pkgs/sdk/client/src/Internal/Hooks/Executor/NoopExecutor.cs +++ b/pkgs/sdk/client/src/Internal/Hooks/Executor/NoopExecutor.cs @@ -14,7 +14,17 @@ internal sealed class NoopExecutor : IHookExecutor public EvaluationDetail EvaluationSeries(EvaluationSeriesContext context, LdValue.Converter converter, Func> evaluate) => evaluate(); - public Task IdentifySeries(Context context, TimeSpan maxWaitTime, Func> identify) => identify(); + public async Task IdentifySeries(Context context, TimeSpan maxWaitTime, Func> identify) + { + try + { + return await identify(); + } + catch (Exception) + { + return false; + } + } public void Dispose() { From 11b9692484b6b6cffca3610d96bfd38dc9b4e24e Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 12 Mar 2026 17:46:55 -0700 Subject: [PATCH 21/21] let's throw --- .../client/src/Internal/Hooks/Executor/Executor.cs | 2 +- .../src/Internal/Hooks/Executor/NoopExecutor.cs | 13 ++----------- .../src/Internal/Hooks/Interfaces/IHookExecutor.cs | 7 +++++-- .../Hooks/IdentifySeriesTest.cs | 6 +++--- 4 files changed, 11 insertions(+), 17 deletions(-) diff --git a/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs b/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs index b05a6139..7c2bf108 100644 --- a/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs +++ b/pkgs/sdk/client/src/Internal/Hooks/Executor/Executor.cs @@ -65,7 +65,7 @@ public async Task IdentifySeries(Context context, TimeSpan maxWaitTime, Fu new IdentifySeriesResult(IdentifySeriesResult.IdentifySeriesStatus.Error), seriesData); - return false; + throw; } } diff --git a/pkgs/sdk/client/src/Internal/Hooks/Executor/NoopExecutor.cs b/pkgs/sdk/client/src/Internal/Hooks/Executor/NoopExecutor.cs index 9430d92e..fbc5f006 100644 --- a/pkgs/sdk/client/src/Internal/Hooks/Executor/NoopExecutor.cs +++ b/pkgs/sdk/client/src/Internal/Hooks/Executor/NoopExecutor.cs @@ -14,17 +14,8 @@ internal sealed class NoopExecutor : IHookExecutor public EvaluationDetail EvaluationSeries(EvaluationSeriesContext context, LdValue.Converter converter, Func> evaluate) => evaluate(); - public async Task IdentifySeries(Context context, TimeSpan maxWaitTime, Func> identify) - { - try - { - return await identify(); - } - catch (Exception) - { - return false; - } - } + public Task IdentifySeries(Context context, TimeSpan maxWaitTime, Func> identify) => + identify(); public void Dispose() { diff --git a/pkgs/sdk/client/src/Internal/Hooks/Interfaces/IHookExecutor.cs b/pkgs/sdk/client/src/Internal/Hooks/Interfaces/IHookExecutor.cs index 61486abc..16111529 100644 --- a/pkgs/sdk/client/src/Internal/Hooks/Interfaces/IHookExecutor.cs +++ b/pkgs/sdk/client/src/Internal/Hooks/Interfaces/IHookExecutor.cs @@ -13,7 +13,8 @@ namespace LaunchDarkly.Sdk.Client.Internal.Hooks.Interfaces internal interface IHookExecutor : IDisposable { /// - /// EvaluationSeries should run the evaluation series for each configured hook. + /// Runs the evaluation series for each configured hook, invoking the + /// delegate to obtain the flag value. Exceptions thrown by the delegate are propagated to the caller. /// /// context for the evaluation series /// used to convert the primitive evaluation value into a wrapped suitable for use in hooks @@ -24,7 +25,9 @@ EvaluationDetail EvaluationSeries(EvaluationSeriesContext context, LdValue.Converter converter, Func> evaluate); /// - /// IdentifySeries should run the identify series for each configured hook. + /// Runs the identify series for each configured hook, invoking the + /// delegate to perform the identify operation. Exceptions thrown by the delegate are propagated + /// to the caller. /// /// the evaluation context being identified /// the timeout for the identify operation diff --git a/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs index 9b8c4ace..c2405418 100644 --- a/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs +++ b/pkgs/sdk/client/test/LaunchDarkly.ClientSdk.Tests/Hooks/IdentifySeriesTest.cs @@ -203,10 +203,10 @@ public async Task IdentifyResultIsCapturedAsErrorOnException() var executor = new Executor(testLogger, new List { hook }); - var result = await executor.IdentifySeries(context, TimeSpan.Zero, - () => Task.FromException(new InvalidOperationException("identify failed"))); + await Assert.ThrowsAsync(() => + executor.IdentifySeries(context, TimeSpan.Zero, + () => Task.FromException(new InvalidOperationException("identify failed")))); - Assert.False(result); Assert.NotNull(hook.CapturedResult); Assert.Equal(IdentifySeriesResult.IdentifySeriesStatus.Error, hook.CapturedResult.Status); }