From 5798e94bdc79b307a95cc2645cd424caba614b4f Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 21 Aug 2026 13:31:39 -0700 Subject: [PATCH 1/3] feat: add registerPlugin so a plugin can be added after the client starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins could only be supplied through LDConfig, so an integration that learns about a plugin later — or that wants to instrument a client it did not configure — had no way in. Hooks were held in a constant array, which registration after start cannot extend, so they now live behind a lock and are replaced rather than mutated in place. Each series reads a snapshot once, so the hooks a series ends with are the hooks it began with: read again mid-series, a hook registered in between would be handed an "after" stage for a series whose "before" stage it was never in. Hooks go live only once register returns, matching the Android and .NET ordering, so a plugin's own hooks do not observe its register call. Retaining EnvironmentMetadata on the instance lets a plugin registered later be handed the same environment description as one configured up front, and removes the duplicate construction in start and collectHooks. Co-authored-by: Cursor --- LaunchDarkly/LaunchDarkly/LDClient.swift | 79 +++++++--- .../LaunchDarkly/LDClientIdentifyHook.swift | 4 +- .../LaunchDarkly/LDClientVariation.swift | 1 + .../LDClientPluginsSpec.swift | 143 ++++++++++++++++++ 4 files changed, 205 insertions(+), 22 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index 8a9e15c7..386de39d 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -281,10 +281,34 @@ public class LDClient { /// a path an application may take on every redraw of a view. let mobileKeyHash: String let service: DarklyServiceProvider - /// The hooks registered with this client: the configuration's. - /// Constant, so that a series reading it more than once, as an evaluation series does for its before and after - /// stages, runs the same hooks in both, whichever thread the evaluation was made from. - let hooks: [Hook] + /// The hooks registered with this client: the configuration's, then those contributed by plugins. + /// Replaced rather than modified in place, so that a caller of `registerPlugin` on one thread cannot be seen half + /// way by a series running on another. + private var storedHooks: [Hook] + private let hooksLock = NSLock() + + /// A snapshot of the hooks to run. Every series reads this once into a local and works from that, so the hooks a + /// series ends with are the hooks it began with: were it read again mid-series, a hook registered in between would + /// be given an "after" stage for a series whose "before" stage it was never in. + var hooks: [Hook] { + hooksLock.lock() + defer { hooksLock.unlock() } + return storedHooks + } + + /// Describes this client's environment to a plugin. Retained so that a plugin registered later, via + /// `registerPlugin`, is handed the same description as one configured up front. + let environmentMetadata: EnvironmentMetadata + + /// Adds hooks, which the next series to begin will run. A series already under way runs the hooks it began with. + private func addHooks(_ newHooks: [Hook]) { + guard !newHooks.isEmpty else { return } + + hooksLock.lock() + defer { hooksLock.unlock() } + storedHooks.append(contentsOf: newHooks) + } + private(set) var context: LDContext /** @@ -733,6 +757,7 @@ public class LDClient { } private func executeAfterTrackHooks(key: String, data: LDValue?, metricValue: Double?) { + let hooks = self.hooks guard !hooks.isEmpty else { return } @@ -745,6 +770,26 @@ public class LDClient { } } + /** + Registers a single plugin with this client after the client has been configured and started. To register plugins + before the client starts, set `LDConfig.plugins` instead. + + The plugin's hooks are collected first, then `Plugin.register` is called, and only then do those hooks begin + running. This ordering differs from registration at configuration time, where a plugin's hooks are active before + `register` is called: here a plugin's own hooks will not observe flag evaluations or identify calls that its + `register` makes. Once this method returns, later evaluations, identify calls, and track calls do run them. + + This registers the plugin with this client only. In a multi-environment configuration each environment has its own + client, so registering with every environment means calling this on each of them. + + - parameter plugin: The plugin to register. + */ + public func registerPlugin(_ plugin: Plugin) { + let pluginHooks = plugin.getHooks(metadata: environmentMetadata) + plugin.register(client: self, metadata: environmentMetadata) + addHooks(pluginHooks) + } + /** Tells the SDK to immediately send any currently queued events to LaunchDarkly. @@ -851,17 +896,10 @@ public class LDClient { LDClient.instances?[name] = instance } - let sdkMetadata = SdkMetadata(name: SystemCapabilities.systemName, version: ReportingConsts.sdkVersion) - let environmentMetadata = EnvironmentMetadata( - applicationInfo: instance.environmentReporter.applicationInfo, - sdkMetadata: sdkMetadata, - credential: mobileKey - ) - // now register the client with all the plugins for plugin in config.plugins { do { - plugin.register(client: instance, metadata: environmentMetadata) + plugin.register(client: instance, metadata: instance.environmentMetadata) } catch { os_log("Exception thrown registering plugin %@.", log: config.logger, type: .error, plugin.getMetadata().getName()) } @@ -958,13 +996,8 @@ public class LDClient { /// The hooks the configuration registers, followed by the hooks the plugins contribute. Collected before the init /// identify series opens, so that plugin hooks take part in it. - private static func collectHooks(configuration: LDConfig, environmentReporter: EnvironmentReporting) -> [Hook] { + private static func collectHooks(configuration: LDConfig, metadata: EnvironmentMetadata) -> [Hook] { var hooks = Array(configuration.hooks) - let metadata = EnvironmentMetadata( - applicationInfo: environmentReporter.applicationInfo, - sdkMetadata: SdkMetadata(name: SystemCapabilities.systemName, version: ReportingConsts.sdkVersion), - credential: configuration.mobileKey - ) for plugin in configuration.plugins { do { hooks.append(contentsOf: try plugin.getHooks(metadata: metadata)) @@ -981,8 +1014,14 @@ public class LDClient { self.mobileKeyHash = Util.sha256base64(configuration.mobileKey) self.serviceFactory = serviceFactory environmentReporter = self.serviceFactory.makeEnvironmentReporter(config: configuration) - let hooks = LDClient.collectHooks(configuration: configuration, environmentReporter: environmentReporter) - self.hooks = hooks + let environmentMetadata = EnvironmentMetadata( + applicationInfo: environmentReporter.applicationInfo, + sdkMetadata: SdkMetadata(name: SystemCapabilities.systemName, version: ReportingConsts.sdkVersion), + credential: configuration.mobileKey + ) + self.environmentMetadata = environmentMetadata + let hooks = LDClient.collectHooks(configuration: configuration, metadata: environmentMetadata) + self.storedHooks = hooks flagCache = self.serviceFactory.makeFeatureFlagCache(mobileKey: configuration.mobileKey, maxCachedContexts: configuration.maxCachedContexts) flagStore = self.serviceFactory.makeFlagStore() diff --git a/LaunchDarkly/LaunchDarkly/LDClientIdentifyHook.swift b/LaunchDarkly/LaunchDarkly/LDClientIdentifyHook.swift index 3ccd5acc..1db2f9cc 100644 --- a/LaunchDarkly/LaunchDarkly/LDClientIdentifyHook.swift +++ b/LaunchDarkly/LaunchDarkly/LDClientIdentifyHook.swift @@ -18,11 +18,11 @@ extension LDClient { } internal func executeBeforeIdentifyHooks(context: LDContext) -> IdentifyHookState? { - guard !hooks.isEmpty else { + let hooksSnapshot = self.hooks + guard !hooksSnapshot.isEmpty else { return nil } - let hooksSnapshot = self.hooks let seriesContext = IdentifySeriesContext(context: context, methodName: "identify") let seriesData = hooksSnapshot.map { hook in hook.beforeIdentify(seriesContext: seriesContext, seriesData: EvaluationSeriesData()) diff --git a/LaunchDarkly/LaunchDarkly/LDClientVariation.swift b/LaunchDarkly/LaunchDarkly/LDClientVariation.swift index a91af227..77bd60f8 100644 --- a/LaunchDarkly/LaunchDarkly/LDClientVariation.swift +++ b/LaunchDarkly/LaunchDarkly/LDClientVariation.swift @@ -144,6 +144,7 @@ extension LDClient { } private func evaluateWithHooks(flagKey: LDFlagKey, defaultValue: D, methodName: String, featureFlag: FeatureFlag?, evaluation: () -> LDEvaluationDetail) -> LDEvaluationDetail where D: LDValueConvertible, D: Decodable { + let hooks = self.hooks guard !hooks.isEmpty else { return evaluation() } diff --git a/LaunchDarkly/LaunchDarklyTests/LDClientPluginsSpec.swift b/LaunchDarkly/LaunchDarklyTests/LDClientPluginsSpec.swift index 08bfca00..d17cb1cc 100644 --- a/LaunchDarkly/LaunchDarklyTests/LDClientPluginsSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/LDClientPluginsSpec.swift @@ -83,6 +83,149 @@ final class LDClientPluginsSpec: XCTestCase { XCTAssertEqual(mockPlugin.getCallRecord()[3], "first after") } + func testRegisterPluginPassesClientAndEnvironmentMetadata() { + var registerCallCount = 0 + var receivedClient: LDClient? + var receivedMetadata: EnvironmentMetadata? + + let mockPlugin = MockPlugin { client, metadata in + registerCallCount += 1 + receivedClient = client + receivedMetadata = metadata + } + + let config = LDConfig(mobileKey: "mobile-key", autoEnvAttributes: .disabled) + var testContext: TestContext! + waitUntil { done in + testContext = TestContext(newConfig: config) + testContext.start(completion: done) + } + + // Nothing happens until the plugin is registered, since it was not in the configuration. + XCTAssertEqual(registerCallCount, 0) + + testContext.subject.registerPlugin(mockPlugin) + + XCTAssertEqual(registerCallCount, 1) + XCTAssertTrue(receivedClient === testContext.subject) + // The same environment description a plugin configured up front would have been given. + XCTAssertEqual(receivedMetadata?.credential, "mobile-key") + XCTAssertEqual(receivedMetadata?.sdkMetadata.name, SystemCapabilities.systemName) + } + + func testRegisterPluginActivatesBundledHooks() { + let mockPlugin = MockPlugin { _, _ in } + + let config = LDConfig(mobileKey: "mobile-key", autoEnvAttributes: .disabled) + var testContext: TestContext! + waitUntil { done in + testContext = TestContext(newConfig: config) + testContext.start(completion: done) + } + + testContext.subject.registerPlugin(mockPlugin) + testContext.subject.boolVariation(forKey: "test-flag", defaultValue: false) + + XCTAssertEqual(mockPlugin.getCallRecord(), ["first before", "second before", "second after", "first after"]) + } + + func testRegisterPluginDoesNotRunTheRegisteringPluginsOwnHooks() { + // Evaluates a flag from inside register, so the test can tell whether this plugin's own hooks were live then. + let mockPlugin = MockPlugin { client, _ in + client.boolVariation(forKey: "test-flag", defaultValue: false) + } + + let config = LDConfig(mobileKey: "mobile-key", autoEnvAttributes: .disabled) + var testContext: TestContext! + waitUntil { done in + testContext = TestContext(newConfig: config) + testContext.start(completion: done) + } + + testContext.subject.registerPlugin(mockPlugin) + XCTAssertEqual(mockPlugin.getCallRecord(), []) + + // They do run for evaluations made once registration has completed. + testContext.subject.boolVariation(forKey: "test-flag", defaultValue: false) + XCTAssertEqual(mockPlugin.getCallRecord(), ["first before", "second before", "second after", "first after"]) + } + + func testRegisterPluginHooksRunAfterConfiguredHooks() { + var callRecord: [String] = [] + let record: (String) -> Void = { callRecord.append($0) } + + var config = LDConfig(mobileKey: "mobile-key", autoEnvAttributes: .disabled) + config.hooks = [LDClientPluginsSpec.recordingHook("config", into: record)] + + var testContext: TestContext! + waitUntil { done in + testContext = TestContext(newConfig: config) + testContext.start(completion: done) + } + + let plugin = StubPlugin(hooks: [LDClientPluginsSpec.recordingHook("plugin", into: record)]) + testContext.subject.registerPlugin(plugin) + + testContext.subject.boolVariation(forKey: "test-flag", defaultValue: false) + + // The configured hook was registered first, so it opens the series and, the after stage running in reverse, + // closes it last. + XCTAssertEqual(callRecord, ["config before", "plugin before", "plugin after", "config after"]) + } + + func testRegisterPluginAppliesOnlyToTheClientItIsCalledOn() { + var callRecord: [String] = [] + let record: (String) -> Void = { callRecord.append($0) } + + var config = LDConfig(mobileKey: "primary-mobile-key", autoEnvAttributes: .disabled) + try! config.setSecondaryMobileKeys(["test": "secondary-key-1"]) + + var testContext: TestContext! + waitUntil { done in + testContext = TestContext(newConfig: config) + testContext.start(completion: done) + } + + let plugin = StubPlugin(hooks: [LDClientPluginsSpec.recordingHook("plugin", into: record)]) + testContext.subject.registerPlugin(plugin) + + testContext.subject.boolVariation(forKey: "test-flag", defaultValue: false) + XCTAssertEqual(callRecord, ["plugin before", "plugin after"]) + + // The other environment has its own client, which this plugin was not registered with. + LDClient.get(environment: "test")?.boolVariation(forKey: "test-flag", defaultValue: false) + XCTAssertEqual(callRecord, ["plugin before", "plugin after"]) + } + + private static func recordingHook(_ name: String, into record: @escaping (String) -> Void) -> MockHook { + MockHook( + before: { _, data in record("\(name) before"); return data }, + after: { _, data, _ in record("\(name) after"); return data }) + } + + /// Contributes a fixed set of hooks, and optionally runs a closure when registered. + class StubPlugin: Plugin { + private let hooksToReturn: [Hook] + private let onRegister: (LDClient, EnvironmentMetadata) -> Void + + init(hooks: [Hook], onRegister: @escaping (LDClient, EnvironmentMetadata) -> Void = { _, _ in }) { + self.hooksToReturn = hooks + self.onRegister = onRegister + } + + func getMetadata() -> PluginMetadata { + return PluginMetadata(name: "StubPlugin") + } + + func register(client: LDClient, metadata: EnvironmentMetadata) { + onRegister(client, metadata) + } + + func getHooks(metadata: EnvironmentMetadata) -> [Hook] { + return hooksToReturn + } + } + class MockPlugin: Plugin { private let registerCallback: (LDClient, EnvironmentMetadata) -> Void private var callRecord: [String] = [] From 624f1bab1d67e839d6fa899359d6c18f238c519f Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 24 Aug 2026 15:39:24 -0700 Subject: [PATCH 2/3] ios hook before --- LaunchDarkly/LaunchDarkly/LDClient.swift | 10 ++++------ .../LaunchDarklyTests/LDClientPluginsSpec.swift | 13 +++++++++---- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index 386de39d..add8fe5c 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -774,10 +774,9 @@ public class LDClient { Registers a single plugin with this client after the client has been configured and started. To register plugins before the client starts, set `LDConfig.plugins` instead. - The plugin's hooks are collected first, then `Plugin.register` is called, and only then do those hooks begin - running. This ordering differs from registration at configuration time, where a plugin's hooks are active before - `register` is called: here a plugin's own hooks will not observe flag evaluations or identify calls that its - `register` makes. Once this method returns, later evaluations, identify calls, and track calls do run them. + The plugin's hooks begin running before `Plugin.register` is called, as they do for a plugin registered at + configuration time, so a plugin's own hooks observe the flag evaluations and identify calls that its `register` + makes. A plugin whose `register` fails keeps contributing its hooks, again matching configuration time. This registers the plugin with this client only. In a multi-environment configuration each environment has its own client, so registering with every environment means calling this on each of them. @@ -785,9 +784,8 @@ public class LDClient { - parameter plugin: The plugin to register. */ public func registerPlugin(_ plugin: Plugin) { - let pluginHooks = plugin.getHooks(metadata: environmentMetadata) + addHooks(plugin.getHooks(metadata: environmentMetadata)) plugin.register(client: self, metadata: environmentMetadata) - addHooks(pluginHooks) } /** diff --git a/LaunchDarkly/LaunchDarklyTests/LDClientPluginsSpec.swift b/LaunchDarkly/LaunchDarklyTests/LDClientPluginsSpec.swift index d17cb1cc..c56910dc 100644 --- a/LaunchDarkly/LaunchDarklyTests/LDClientPluginsSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/LDClientPluginsSpec.swift @@ -129,7 +129,7 @@ final class LDClientPluginsSpec: XCTestCase { XCTAssertEqual(mockPlugin.getCallRecord(), ["first before", "second before", "second after", "first after"]) } - func testRegisterPluginDoesNotRunTheRegisteringPluginsOwnHooks() { + func testRegisterPluginRunsTheRegisteringPluginsOwnHooks() { // Evaluates a flag from inside register, so the test can tell whether this plugin's own hooks were live then. let mockPlugin = MockPlugin { client, _ in client.boolVariation(forKey: "test-flag", defaultValue: false) @@ -142,12 +142,17 @@ final class LDClientPluginsSpec: XCTestCase { testContext.start(completion: done) } + // The hooks are live by the time register runs, as they are for a plugin configured up front, so the + // evaluation register makes passes through them. testContext.subject.registerPlugin(mockPlugin) - XCTAssertEqual(mockPlugin.getCallRecord(), []) + XCTAssertEqual(mockPlugin.getCallRecord(), ["first before", "second before", "second after", "first after"]) - // They do run for evaluations made once registration has completed. + // And they keep running for evaluations made after registration. testContext.subject.boolVariation(forKey: "test-flag", defaultValue: false) - XCTAssertEqual(mockPlugin.getCallRecord(), ["first before", "second before", "second after", "first after"]) + XCTAssertEqual(mockPlugin.getCallRecord(), [ + "first before", "second before", "second after", "first after", + "first before", "second before", "second after", "first after" + ]) } func testRegisterPluginHooksRunAfterConfiguredHooks() { From 4db66030b420793b8d50f11dbbd24e4383cf00c8 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Tue, 25 Aug 2026 14:55:38 -0700 Subject: [PATCH 3/3] refactored plugin register --- LaunchDarkly/LaunchDarkly/LDClient.swift | 87 ++++++++++--------- .../LaunchDarkly/LDClientIdentifyHook.swift | 4 +- .../LDClientPluginsSpec.swift | 38 ++++++-- 3 files changed, 77 insertions(+), 52 deletions(-) diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index add8fe5c..bc8a108f 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -774,9 +774,9 @@ public class LDClient { Registers a single plugin with this client after the client has been configured and started. To register plugins before the client starts, set `LDConfig.plugins` instead. - The plugin's hooks begin running before `Plugin.register` is called, as they do for a plugin registered at - configuration time, so a plugin's own hooks observe the flag evaluations and identify calls that its `register` - makes. A plugin whose `register` fails keeps contributing its hooks, again matching configuration time. + The plugin's hooks begin running only once `Plugin.register` has returned, so a plugin's own hooks do not observe + the flag evaluations or identify calls that its `register` makes. This is also how the plugins in + `LDConfig.plugins` are registered, by this same method, so a plugin behaves the same however it was registered. This registers the plugin with this client only. In a multi-environment configuration each environment has its own client, so registering with every environment means calling this on each of them. @@ -784,8 +784,24 @@ public class LDClient { - parameter plugin: The plugin to register. */ public func registerPlugin(_ plugin: Plugin) { - addHooks(plugin.getHooks(metadata: environmentMetadata)) - plugin.register(client: self, metadata: environmentMetadata) + registerPlugins([plugin]) + } + + /// Registers each of `plugins` with this client, then activates the hooks they contribute. + /// + /// The hooks go live as the last step, once every plugin has registered, so that no plugin's hooks observe + /// any plugin's `register` call. Shared by the plugins in `LDConfig.plugins` and by `registerPlugin`, so that + /// a plugin behaves the same however it was registered. + private func registerPlugins(_ plugins: [Plugin]) { + var hooksToActivate: [Hook] = [] + + for plugin in plugins { + let pluginHooks = plugin.getHooks(metadata: environmentMetadata) + plugin.register(client: self, metadata: environmentMetadata) + hooksToActivate.append(contentsOf: pluginHooks) + } + + addHooks(hooksToActivate) } /** @@ -889,19 +905,18 @@ public class LDClient { for (name, mobileKey) in mobileKeys { var internalConfig = config internalConfig.mobileKey = mobileKey - let instance: LDClient = LDClient(serviceFactory: serviceFactory, configuration: internalConfig, startContext: context, completion: completionCheck) + let instance: LDClient = LDClient(serviceFactory: serviceFactory, configuration: internalConfig, startContext: context) + // Published before the plugins register, so that a plugin calling `LDClient.get()` from its `register` + // finds this instance. instancesQueue.sync(flags: .barrier) { LDClient.instances?[name] = instance } - // now register the client with all the plugins - for plugin in config.plugins { - do { - plugin.register(client: instance, metadata: instance.environmentMetadata) - } catch { - os_log("Exception thrown registering plugin %@.", log: config.logger, type: .error, plugin.getMetadata().getName()) - } - } + // Registered before the first identify series opens, so that the hooks the plugins contribute take + // part in it just as the configuration's own hooks do. + instance.registerPlugins(config.plugins) + + instance.startIdentifyAndGoOnline(completion: completionCheck) } completionCheck() @@ -992,21 +1007,7 @@ public class LDClient { private var initializedQueue = DispatchQueue(label: "com.launchdarkly.LDClient.initializedQueue") private var identifyQueue = SheddingQueue() - /// The hooks the configuration registers, followed by the hooks the plugins contribute. Collected before the init - /// identify series opens, so that plugin hooks take part in it. - private static func collectHooks(configuration: LDConfig, metadata: EnvironmentMetadata) -> [Hook] { - var hooks = Array(configuration.hooks) - for plugin in configuration.plugins { - do { - hooks.append(contentsOf: try plugin.getHooks(metadata: metadata)) - } catch { - os_log("Exception thrown getting hooks for plugin %@. Unable to get hooks, plugin will not be registered.", log: configuration.logger, type: .error, plugin.getMetadata().getName()) - } - } - return hooks - } - - private init(serviceFactory: ClientServiceCreating, configuration: LDConfig, startContext: LDContext?, completion: (() -> Void)? = nil) { + private init(serviceFactory: ClientServiceCreating, configuration: LDConfig, startContext: LDContext?) { // Set before the hooks below run, so that the environment a hook is told about is this client's rather than the // primary one's. self.mobileKeyHash = Util.sha256base64(configuration.mobileKey) @@ -1018,8 +1019,9 @@ public class LDClient { credential: configuration.mobileKey ) self.environmentMetadata = environmentMetadata - let hooks = LDClient.collectHooks(configuration: configuration, metadata: environmentMetadata) - self.storedHooks = hooks + // Only the configuration's own hooks. A plugin's hooks are added by `registerPlugin` once that plugin has + // registered, which `start` does before opening the first identify series. + self.storedHooks = Array(configuration.hooks) flagCache = self.serviceFactory.makeFeatureFlagCache(mobileKey: configuration.mobileKey, maxCachedContexts: configuration.maxCachedContexts) flagStore = self.serviceFactory.makeFlagStore() @@ -1033,13 +1035,6 @@ public class LDClient { context = AutoEnvContextModifier(environmentReporter: environmentReporter, logger: config.logger).modifyContext(context) } - var hookState: IdentifyHookState? = nil - if !hooks.isEmpty { - let seriesContext = IdentifySeriesContext(context: context, methodName: "init") - let seriesData = hooks.map { hook in hook.beforeIdentify(seriesContext: seriesContext, seriesData: EvaluationSeriesData()) } - hookState = IdentifyHookState(seriesContext: seriesContext, seriesData: seriesData, hooksSnapshot: hooks) - } - service = self.serviceFactory.makeDarklyServiceProvider(config: config, context: context, envReporter: environmentReporter) diagnosticReporter = self.serviceFactory.makeDiagnosticReporter(config: config, service: service, environmentReporter: environmentReporter) eventReporter = self.serviceFactory.makeEventReporter(config: config, service: service) @@ -1074,11 +1069,21 @@ public class LDClient { flagStore.replaceStore(newStoredItems: cachedFlags) } - eventReporter.record(IdentifyEvent(context: context)) self.connectionInformation = ConnectionInformation.uncacheConnectionInformation(config: config, ldClient: self, clientServiceFactory: self.serviceFactory) + } + + /// Opens the first identify series and brings the client online, finishing the startup `init` began. + /// + /// Separate from `init` so that `start` can publish the instance and register its configured plugins in + /// between: a plugin's hooks go live only once it has registered, and they must be in place before this + /// series opens for them to take part in it, as the configuration's own hooks do. + private func startIdentifyAndGoOnline(completion: (() -> Void)? = nil) { + let hookState = executeBeforeIdentifyHooks(context: context, methodName: "init") + + eventReporter.record(IdentifyEvent(context: context)) - internalSetOnline(configuration.startOnline) { - os_log("%s LDClient started", log: configuration.logger, type: .debug, self.typeName(and: #function)) + internalSetOnline(config.startOnline) { + os_log("%s LDClient started", log: self.config.logger, type: .debug, self.typeName(and: #function)) completion?() if let state = hookState { self.executeAfterIdentifyHooks(state: state, result: .complete) diff --git a/LaunchDarkly/LaunchDarkly/LDClientIdentifyHook.swift b/LaunchDarkly/LaunchDarkly/LDClientIdentifyHook.swift index 1db2f9cc..d3bea9d9 100644 --- a/LaunchDarkly/LaunchDarkly/LDClientIdentifyHook.swift +++ b/LaunchDarkly/LaunchDarkly/LDClientIdentifyHook.swift @@ -17,13 +17,13 @@ extension LDClient { } } - internal func executeBeforeIdentifyHooks(context: LDContext) -> IdentifyHookState? { + internal func executeBeforeIdentifyHooks(context: LDContext, methodName: String = "identify") -> IdentifyHookState? { let hooksSnapshot = self.hooks guard !hooksSnapshot.isEmpty else { return nil } - let seriesContext = IdentifySeriesContext(context: context, methodName: "identify") + let seriesContext = IdentifySeriesContext(context: context, methodName: methodName) let seriesData = hooksSnapshot.map { hook in hook.beforeIdentify(seriesContext: seriesContext, seriesData: EvaluationSeriesData()) } diff --git a/LaunchDarkly/LaunchDarklyTests/LDClientPluginsSpec.swift b/LaunchDarkly/LaunchDarklyTests/LDClientPluginsSpec.swift index c56910dc..22f5a966 100644 --- a/LaunchDarkly/LaunchDarklyTests/LDClientPluginsSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/LDClientPluginsSpec.swift @@ -129,7 +129,7 @@ final class LDClientPluginsSpec: XCTestCase { XCTAssertEqual(mockPlugin.getCallRecord(), ["first before", "second before", "second after", "first after"]) } - func testRegisterPluginRunsTheRegisteringPluginsOwnHooks() { + func testRegisterPluginDoesNotRunTheRegisteringPluginsOwnHooks() { // Evaluates a flag from inside register, so the test can tell whether this plugin's own hooks were live then. let mockPlugin = MockPlugin { client, _ in client.boolVariation(forKey: "test-flag", defaultValue: false) @@ -142,17 +142,13 @@ final class LDClientPluginsSpec: XCTestCase { testContext.start(completion: done) } - // The hooks are live by the time register runs, as they are for a plugin configured up front, so the - // evaluation register makes passes through them. + // The hooks only go live once register has returned, so the evaluation register makes does not reach them. testContext.subject.registerPlugin(mockPlugin) - XCTAssertEqual(mockPlugin.getCallRecord(), ["first before", "second before", "second after", "first after"]) + XCTAssertEqual(mockPlugin.getCallRecord(), []) - // And they keep running for evaluations made after registration. + // They do run for evaluations made once registration has completed. testContext.subject.boolVariation(forKey: "test-flag", defaultValue: false) - XCTAssertEqual(mockPlugin.getCallRecord(), [ - "first before", "second before", "second after", "first after", - "first before", "second before", "second after", "first after" - ]) + XCTAssertEqual(mockPlugin.getCallRecord(), ["first before", "second before", "second after", "first after"]) } func testRegisterPluginHooksRunAfterConfiguredHooks() { @@ -202,6 +198,30 @@ final class LDClientPluginsSpec: XCTestCase { XCTAssertEqual(callRecord, ["plugin before", "plugin after"]) } + func testConfiguredPluginHooksDoNotObserveAnotherPluginsRegister() { + let firstPlugin = MockPlugin { _, _ in } + // Registers after the first plugin, and evaluates a flag while doing so. + let secondPlugin = MockPlugin { client, _ in + _ = client.boolVariation(forKey: "test-flag", defaultValue: false) + } + + var config = LDConfig(mobileKey: "mobile-key", autoEnvAttributes: .disabled) + config.plugins = [firstPlugin, secondPlugin] + + var testContext: TestContext! + waitUntil { done in + testContext = TestContext(newConfig: config) + testContext.start(completion: done) + } + + // Hooks are activated only once every plugin has registered, so the first plugin's hooks did not + // observe the evaluation the second plugin made while registering. + XCTAssertEqual(firstPlugin.getCallRecord(), []) + + _ = testContext.subject.boolVariation(forKey: "test-flag", defaultValue: false) + XCTAssertEqual(firstPlugin.getCallRecord(), ["first before", "second before", "second after", "first after"]) + } + private static func recordingHook(_ name: String, into record: @escaping (String) -> Void) -> MockHook { MockHook( before: { _, data in record("\(name) before"); return data },