Skip to content

Commit 0461f6a

Browse files
feat: Identify hooks (#462)
**Requirements** Support identifyAfter and identifyBefore hooks. Need notify customer about user authorization <img width="1229" height="690" alt="image" src="https://github.com/user-attachments/assets/8f8d5234-c1bb-4480-b0ed-77f0d1e8cf1f" /> **Related issues** Provide links to any issues in this repository or elsewhere relating to this pull request. **Describe the solution you've provided** Provide a clear and concise description of what you expect to happen. **Describe alternatives you've considered** Provide a clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context about the pull request here. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds before/after identify hooks and executes them around `identify` (with timeout support), plus supporting context types and tests. > > - **Hooks API**: > - Add identify hooks: `Hook.beforeIdentify(...)` and `Hook.afterIdentify(...)`, with default no-op implementations. > - Introduce `IdentifySeriesContext` and `IdentifySeriesData` to carry identify-series metadata and data. > - **Identify flow**: > - Wrap `identify` operations with hook execution via new `LDClient._identifyHooked(...)`, preserving optional `timeout` behavior. > - Update `LDClient.identify(...)` overloads to call `_identifyHooked`; expose `_identify(...)` for internal reuse. > - New helper module `LDClientIdentifyHook.swift` orchestrates before/after hook calls and timeout completion. > - **Evaluations**: > - Minor guard cleanup in `evaluateWithHooks` (no functional change). > - **Tests**: > - Add `LDClientIdentifyHookSpec` covering hook registration, order, data passing, result capture, and timeout path. > - Rename evaluation hook test class to `LDClientEvaluationHookSpec`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 196579b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 452b2c4 commit 0461f6a

File tree

8 files changed

+277
-22
lines changed

8 files changed

+277
-22
lines changed

LaunchDarkly/LaunchDarkly/LDClient.swift

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ public class LDClient {
290290
*/
291291
@available(*, deprecated, message: "Use LDClient.identify(context: completion:) with non-optional completion parameter")
292292
public func identify(context: LDContext, completion: (() -> Void)? = nil) {
293-
_identify(context: context, sheddable: false, useCache: .yes) { _ in
293+
_identifyHooked(context: context, sheddable: false, useCache: .yes, timeout: 0) { _ in
294294
if let completion = completion {
295295
completion()
296296
}
@@ -313,7 +313,7 @@ public class LDClient {
313313
- parameter completion: Closure called when the embedded `setOnlineIdentify` call completes, subject to throttling delays.
314314
*/
315315
public func identify(context: LDContext, completion: @escaping (_ result: IdentifyResult) -> Void) {
316-
_identify(context: context, sheddable: true, useCache: .yes, completion: completion)
316+
_identifyHooked(context: context, sheddable: true, useCache: .yes, timeout: 0, completion: completion)
317317
}
318318

319319
/**
@@ -327,12 +327,12 @@ public class LDClient {
327327
- parameter completion: Closure called when the embedded `setOnlineIdentify` call completes, subject to throttling delays.
328328
*/
329329
public func identify(context: LDContext, useCache: IdentifyCacheUsage, completion: @escaping (_ result: IdentifyResult) -> Void) {
330-
_identify(context: context, sheddable: true, useCache: useCache, completion: completion)
330+
_identifyHooked(context: context, sheddable: true, useCache: useCache, timeout: 0, completion: completion)
331331
}
332332

333333
// Temporary helper method to allow code sharing between the sheddable and unsheddable identify methods. In the next major release, we will remove the deprecated identify method and inline
334334
// this implementation in the other one.
335-
private func _identify(context: LDContext, sheddable: Bool, useCache: IdentifyCacheUsage, completion: @escaping (_ result: IdentifyResult) -> Void) {
335+
func _identify(context: LDContext, sheddable: Bool, useCache: IdentifyCacheUsage, completion: @escaping (_ result: IdentifyResult) -> Void) {
336336
let work: TaskHandler = { taskCompletion in
337337
let dispatch = DispatchGroup()
338338

@@ -352,7 +352,7 @@ public class LDClient {
352352
}
353353
identifyQueue.enqueue(request: identifyTask)
354354
}
355-
355+
356356
/**
357357
Sets the LDContext into the LDClient inline with the behavior detailed on `LDClient.identify(context: completion:)`. Additionally,
358358
this method will ensure the `completion` parameter will be called within the specified time interval.
@@ -385,17 +385,7 @@ public class LDClient {
385385
os_log("%s LDClient.identify was called with a timeout greater than %f seconds. We recommend a timeout of less than %f seconds.", log: config.logger, type: .info, self.typeName(and: #function), LDClient.longTimeoutInterval, LDClient.longTimeoutInterval)
386386
}
387387

388-
TimeoutExecutor.run(
389-
timeout: timeout,
390-
queue: .global(),
391-
operation: { done in
392-
self.identify(context: context, useCache: useCache) { result in
393-
done(result)
394-
}
395-
},
396-
timeoutValue: .timeout,
397-
completion: completion
398-
)
388+
self._identifyHooked(context: context, sheddable: true, useCache: useCache, timeout: timeout, completion: completion)
399389
}
400390

401391
func internalIdentify(newContext: LDContext, useCache: IdentifyCacheUsage, completion: (() -> Void)? = nil) {
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import Foundation
2+
3+
extension LDClient {
4+
private struct IdentifyHookState {
5+
let seriesContext: IdentifySeriesContext
6+
let seriesData: [IdentifySeriesData]
7+
let hooksSnapshot: [Hook]
8+
}
9+
10+
private func executeWithIdentifyHooks(context: LDContext, work: @escaping ((@escaping (IdentifyResult) -> Void)) -> Void) {
11+
let state = executeBeforeIdentifyHooks(context: context)
12+
work() { [weak self] result in
13+
guard let state else {
14+
return
15+
}
16+
self?.executeAfterIdentifyHooks(state: state, result: result)
17+
}
18+
}
19+
20+
private func executeBeforeIdentifyHooks(context: LDContext) -> IdentifyHookState? {
21+
guard !hooks.isEmpty else {
22+
return nil
23+
}
24+
25+
let hooksSnapshot = self.hooks
26+
let seriesContext = IdentifySeriesContext(context: context, methodName: "identify")
27+
let seriesData = hooksSnapshot.map { hook in
28+
hook.beforeIdentify(seriesContext: seriesContext, seriesData: EvaluationSeriesData())
29+
}
30+
return IdentifyHookState(seriesContext: seriesContext, seriesData: seriesData, hooksSnapshot: hooksSnapshot)
31+
}
32+
33+
private func executeAfterIdentifyHooks(state: IdentifyHookState, result: IdentifyResult) {
34+
guard !state.hooksSnapshot.isEmpty else {
35+
return
36+
}
37+
38+
// Invoke hooks in reverse order and give them back the series data they gave us.
39+
zip(state.hooksSnapshot, state.seriesData).reversed().forEach { (hook, data) in
40+
_ = hook.afterIdentify(seriesContext: state.seriesContext, seriesData: data, result: result)
41+
}
42+
}
43+
44+
func _identifyHooked(context: LDContext, sheddable: Bool, useCache: IdentifyCacheUsage, timeout: TimeInterval, completion: @escaping (_ result: IdentifyResult) -> Void) {
45+
if timeout > 0 {
46+
executeWithIdentifyHooks(context: context) { hooksCompletion in
47+
TimeoutExecutor.run(
48+
timeout: timeout,
49+
queue: .global(),
50+
operation: { [weak self] done in
51+
self?._identify(context: context, sheddable: sheddable, useCache: useCache) { result in
52+
done(result)
53+
}
54+
},
55+
timeoutValue: .timeout,
56+
completion: { result in
57+
completion(result)
58+
hooksCompletion(result)
59+
}
60+
)
61+
}
62+
} else {
63+
executeWithIdentifyHooks(context: context) { [weak self] hooksCompletion in
64+
self?._identify(context: context, sheddable: sheddable, useCache: useCache) { result in
65+
completion(result)
66+
hooksCompletion(result)
67+
}
68+
}
69+
}
70+
}
71+
}

LaunchDarkly/LaunchDarkly/LDClientVariation.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ extension LDClient {
144144
}
145145

146146
private func evaluateWithHooks<D>(flagKey: LDFlagKey, defaultValue: D, methodName: String, evaluation: () -> LDEvaluationDetail<D>) -> LDEvaluationDetail<D> where D: LDValueConvertible, D: Decodable {
147-
if self.hooks.isEmpty {
147+
guard !self.hooks.isEmpty else {
148148
return evaluation()
149149
}
150150

LaunchDarkly/LaunchDarkly/Models/Hooks/EvaluationSeriesContext.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Foundation
22

3+
34
/// Contextual information that will be provided to handlers during evaluation series.
45
public class EvaluationSeriesContext {
56
/// The key of the flag being evaluated.

LaunchDarkly/LaunchDarkly/Models/Hooks/Hook.swift

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,62 @@ import Foundation
44
///
55
/// Hook implementations can use this to store data needed between stages.
66
public typealias EvaluationSeriesData = [String: Any]
7+
public typealias IdentifySeriesData = [String: Any]
78

89
/// Protocol for extending SDK functionality via hooks.
910
public protocol Hook {
1011
/// Get metadata about the hook implementation.
1112
func metadata() -> Metadata
12-
/// The before method is called during the execution of a variation method before the flag value has been
13-
/// determined. The method is executed synchronously.
13+
14+
/// Executed by the SDK at the start of the evaluation of a feature flag.
15+
///
16+
/// This is not executed as part of a call to `LDClient.allFlags()`.
17+
///
18+
/// To provide custom data to the series which will be given back to your Hook at the next stage of the
19+
/// series, return a dictionary containing the custom data. You should initialize this dictionary from the
20+
/// `seriesData`.
21+
///
22+
/// - Parameters:
23+
/// - seriesContext: Container of parameters associated with this evaluation.
24+
/// - seriesData: Immutable data from the previous stage in the evaluation series.
25+
/// `beforeEvaluation` is the first stage in this series, so this will be an empty dictionary.
26+
/// - Returns: A dictionary containing custom data that will be carried through to the next stage of the series.
1427
func beforeEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData) -> EvaluationSeriesData
15-
/// The after method is called during the execution of the variation method after the flag value has been
16-
/// determined. The method is executed synchronously.
28+
29+
/// Executed by the SDK after the evaluation of a feature flag completes.
30+
///
31+
/// This is not executed as part of a call to `LDClient.allFlags()`.
32+
///
33+
/// This is currently the last stage of the evaluation series in the Hook, but that may not be the case in the
34+
/// future. To ensure forward compatibility, return the `seriesData` unmodified.
35+
///
36+
/// - Parameters:
37+
/// - seriesContext: Container of parameters associated with this evaluation.
38+
/// - seriesData: Immutable data from the previous stage in the evaluation series.
39+
/// - evaluationDetail: The result of the evaluation that took place before this hook was invoked.
40+
/// - Returns: A dictionary containing custom data that will be carried through to the next stage of the series (if added in the future).
1741
func afterEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData, evaluationDetail: LDEvaluationDetail<LDValue>) -> EvaluationSeriesData
42+
43+
/// To provide custom data to the series which will be given back to your Hook at the next stage of the series,
44+
/// return a dictionary containing the custom data. You should initialize this dictionary from the `seriesData`.
45+
///
46+
/// - Parameters:
47+
/// - seriesContext: Contains information about the identify operation being performed. This is not mutable.
48+
/// - seriesData: A record associated with each stage of hook invocations. Each stage is called with the data of the previous stage for a series. The input record should not be modified.
49+
/// - Returns: A dictionary containing custom data that will be carried through to the next stage of the series.
50+
func beforeIdentify(seriesContext: IdentifySeriesContext, seriesData: IdentifySeriesData) -> IdentifySeriesData
51+
52+
/// Called during the execution of the identify process, after the operation completes.
53+
///
54+
/// This is currently the last stage of the identify series in the Hook, but that may not be the case in the future.
55+
/// To ensure forward compatibility, return the `seriesData` unmodified.
56+
///
57+
/// - Parameters:
58+
/// - seriesContext: Contains information about the identify operation being performed. This is not mutable.
59+
/// - seriesData: A record associated with each stage of hook invocations. Each stage is called with the data of the previous stage for a series. The input record should not be modified.
60+
/// - result: The result of the identify operation.
61+
/// - Returns: A dictionary containing custom data that will be carried through to the next stage of the series (if added in the future).
62+
func afterIdentify(seriesContext: IdentifySeriesContext, seriesData: IdentifySeriesData, result: IdentifyResult) -> IdentifySeriesData
1863
}
1964

2065
public extension Hook {
@@ -34,4 +79,19 @@ public extension Hook {
3479
func afterEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData, evaluationDetail: LDEvaluationDetail<LDValue>) -> EvaluationSeriesData {
3580
return seriesData
3681
}
82+
83+
/// Called during the execution of the identify process before the operation completes,
84+
/// but after any context modifications are performed.
85+
///
86+
/// Default implementation is a no-op that returns `seriesData` unchanged.
87+
func beforeIdentify(seriesContext: IdentifySeriesContext, seriesData: IdentifySeriesData) -> IdentifySeriesData {
88+
return seriesData
89+
}
90+
91+
/// Called during the execution of the identify process, after the operation completes.
92+
///
93+
/// Default implementation is a no-op that returns `seriesData` unchanged.
94+
func afterIdentify(seriesContext: IdentifySeriesContext, seriesData: IdentifySeriesData, result: IdentifyResult) -> IdentifySeriesData {
95+
return seriesData
96+
}
3797
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import Foundation
2+
3+
/// Contextual information that will be provided to handlers during identify series.
4+
public class IdentifySeriesContext {
5+
/// The context involved in the identify operation.
6+
public let context: LDContext
7+
/// A string identifying the name of the method called.
8+
public let methodName: String
9+
10+
init(context: LDContext, methodName: String) {
11+
self.context = context
12+
self.methodName = methodName
13+
}
14+
}

LaunchDarkly/LaunchDarklyTests/LDClientHookSpec.swift renamed to LaunchDarkly/LaunchDarklyTests/LDClientEvaluationHookSpec.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import LDSwiftEventSource
66
import XCTest
77
@testable import LaunchDarkly
88

9-
final class LDClientHookSpec: XCTestCase {
9+
final class LDClientEvaluationHookSpec: XCTestCase {
1010
func testRegistration() {
1111
var count = 0
1212
let hook = MockHook(before: { _, data in count += 1; return data }, after: { _, data, _ in count += 2; return data })
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import Foundation
2+
import OSLog
3+
import Quick
4+
import Nimble
5+
import LDSwiftEventSource
6+
import XCTest
7+
@testable import LaunchDarkly
8+
9+
final class LDClientIdentifyHookSpec: XCTestCase {
10+
func testRegistration() {
11+
var count = 0
12+
let hook = MockHook(before: { _, data in count += 1; return data }, after: { _, data, _ in count += 2; return data })
13+
var config = LDConfig(mobileKey: "mobile-key", autoEnvAttributes: .disabled)
14+
config.hooks = [hook]
15+
var testContext: TestContext!
16+
waitUntil { done in
17+
testContext = TestContext(newConfig: config)
18+
testContext.start(completion: done)
19+
}
20+
testContext.subject.identify(context: LDContext.stub()) { _ in }
21+
expect(count).toEventually(equal(3))
22+
}
23+
24+
func testRegistrationWithTimeout() {
25+
var count = 0
26+
let hook = MockHook(before: { _, data in count += 1; return data }, after: { _, data, _ in count += 2; return data })
27+
var config = LDConfig(mobileKey: "mobile-key", autoEnvAttributes: .disabled)
28+
config.hooks = [hook]
29+
var testContext: TestContext!
30+
waitUntil { done in
31+
testContext = TestContext(newConfig: config)
32+
testContext.start(completion: done)
33+
}
34+
testContext.subject.identify(context: LDContext.stub(), timeout: 30.0) { _ in }
35+
expect(count).toEventually(equal(3))
36+
}
37+
38+
func testIdentifyOrder() {
39+
var callRecord: [String] = []
40+
let firstHook = MockHook(before: { _, data in callRecord.append("first before"); return data }, after: { _, data, _ in callRecord.append("first after"); return data })
41+
let secondHook = MockHook(before: { _, data in callRecord.append("second before"); return data }, after: { _, data, _ in callRecord.append("second after"); return data })
42+
var config = LDConfig(mobileKey: "mobile-key", autoEnvAttributes: .disabled)
43+
config.hooks = [firstHook, secondHook]
44+
45+
var testContext: TestContext!
46+
waitUntil { done in
47+
testContext = TestContext(newConfig: config)
48+
testContext.start(completion: done)
49+
}
50+
51+
testContext.subject.identify(context: LDContext.stub()) { _ in }
52+
expect(callRecord).toEventually(equal(["first before", "second before", "second after", "first after"]))
53+
}
54+
55+
func testIdentifyResultIsCaptured() {
56+
var captured: IdentifyResult? = nil
57+
let hook = MockHook(before: { _, data in return data }, after: { _, data, result in captured = result; return data })
58+
var config = LDConfig(mobileKey: "mobile-key", autoEnvAttributes: .disabled)
59+
config.hooks = [hook]
60+
61+
var testContext: TestContext!
62+
waitUntil { done in
63+
testContext = TestContext(newConfig: config)
64+
testContext.start(completion: done)
65+
}
66+
67+
testContext.subject.identify(context: LDContext.stub()) { _ in }
68+
69+
expect(captured).toEventually(equal(.complete))
70+
}
71+
72+
func testBeforeHookPassesDataToAfterHook() {
73+
var seriesData: IdentifySeriesData? = nil
74+
let beforeHook: BeforeHook = { _, seriesData in
75+
var modified = seriesData
76+
modified["before"] = "was called"
77+
78+
return modified
79+
}
80+
let hook = MockHook(before: beforeHook, after: { _, sd, _ in seriesData = sd; return sd })
81+
var config = LDConfig(mobileKey: "mobile-key", autoEnvAttributes: .disabled)
82+
config.hooks = [hook]
83+
84+
var testContext: TestContext!
85+
waitUntil { done in
86+
testContext = TestContext(newConfig: config)
87+
testContext.start(completion: done)
88+
}
89+
90+
testContext.subject.identify(context: LDContext.stub()) { _ in }
91+
92+
expect(seriesData?["before"] as? String).toEventually(equal("was called"))
93+
}
94+
95+
typealias BeforeHook = (_: IdentifySeriesContext, _: IdentifySeriesData) -> IdentifySeriesData
96+
typealias AfterHook = (_: IdentifySeriesContext, _: IdentifySeriesData, _: IdentifyResult) -> IdentifySeriesData
97+
98+
class MockHook: Hook {
99+
let before: BeforeHook
100+
let after: AfterHook
101+
102+
init(before: @escaping BeforeHook, after: @escaping AfterHook) {
103+
self.before = before
104+
self.after = after
105+
}
106+
107+
func metadata() -> LaunchDarkly.Metadata {
108+
return Metadata(name: "counting-hook")
109+
}
110+
111+
func beforeIdentify(seriesContext: LaunchDarkly.IdentifySeriesContext, seriesData: LaunchDarkly.IdentifySeriesData) -> LaunchDarkly.IdentifySeriesData {
112+
return self.before(seriesContext, seriesData)
113+
}
114+
115+
func afterIdentify(seriesContext: LaunchDarkly.IdentifySeriesContext, seriesData: LaunchDarkly.IdentifySeriesData, result: LaunchDarkly.IdentifyResult) -> LaunchDarkly.IdentifySeriesData {
116+
return self.after(seriesContext, seriesData, result)
117+
}
118+
}
119+
}

0 commit comments

Comments
 (0)