Skip to content

Commit c14b0cd

Browse files
authored
feat: add Tracking API (#81)
<!-- Please use this template for your pull request. --> <!-- Please use the sections that you need and delete other sections --> ## This PR <!-- add the description of the PR here --> - Add Tracking API following OpenFeature specifications - Every requirement from [tracking](https://openfeature.dev/specification/sections/tracking) has been developed - No breaking change ### Related Issues <!-- add here the GitHub issue that this PR resolves if applicable --> Fixes [51](#51) --------- Signed-off-by: Mael RB <mael.rb@outlook.com>
1 parent 0b07a8f commit c14b0cd

13 files changed

+498
-40
lines changed

README.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ Task {
113113
|| [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. |
114114
|| [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). |
115115
|| [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
116-
| | [Tracking](#tracking) | Associate user actions with feature flag evaluations. |
116+
| | [Tracking](#tracking) | Associate user actions with feature flag evaluations. |
117117
|| [Logging](#logging) | Integrate with popular logging packages. |
118118
|| [MultiProvider](#multiprovider) | Utilize multiple providers in a single application. |
119119
|| [Eventing](#eventing) | React to state changes in the provider or flag management system. |
@@ -163,7 +163,7 @@ Once you've added a hook as a dependency, it can be registered at the global, cl
163163
OpenFeatureAPI.shared.addHooks(hooks: ExampleHook())
164164

165165
// add a hook on this client, to run on all evaluations made by this client
166-
val client = OpenFeatureAPI.shared.getClient()
166+
let client = OpenFeatureAPI.shared.getClient()
167167
client.addHooks(ExampleHook())
168168

169169
// add a hook for this evaluation only
@@ -174,7 +174,21 @@ _ = client.getValue(
174174
```
175175
### Tracking
176176

177-
Tracking is not yet available in the iOS SDK.
177+
The tracking API allows you to use OpenFeature abstractions and objects to associate user actions with feature flag evaluations.
178+
This is essential for robust experimentation powered by feature flags.
179+
For example, a flag enhancing the appearance of a UI component might drive user engagement to a new feature; to test this hypothesis, telemetry collected by a [hook](#hooks) or [provider](#providers) can be associated with telemetry reported in the client's `track` function.
180+
181+
```swift
182+
let client = OpenFeatureAPI.shared.getClient()
183+
184+
// Track an event
185+
client.track(key: "test")
186+
187+
// Track an event with a numeric value
188+
client.track(key: "test-value", details: ImmutableTrackingEventDetails(value: 5))
189+
```
190+
191+
Note that some providers may not support tracking; check the documentation for your provider for more information.
178192

179193
### Logging
180194

Sources/OpenFeature/Client.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import Foundation
22

33
/// Interface used to resolve flags of varying types.
4-
public protocol Client: Features {
4+
public protocol Client: Features, Tracking {
55
var metadata: ClientMetadata { get }
66

77
/// The hooks associated to this client.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import Foundation
2+
3+
/// Represents data pertinent to a particular tracking event.
4+
public struct ImmutableTrackingEventDetails: TrackingEventDetails {
5+
private let value: Double?
6+
private let structure: ImmutableStructure
7+
8+
public init(value: Double? = nil, structure: ImmutableStructure = ImmutableStructure()) {
9+
self.value = value
10+
self.structure = structure
11+
}
12+
13+
public init(attributes: [String: Value]) {
14+
self.init(structure: ImmutableStructure(attributes: attributes))
15+
}
16+
17+
public func getValue() -> Double? {
18+
value
19+
}
20+
21+
public func keySet() -> Set<String> {
22+
return structure.keySet()
23+
}
24+
25+
public func getValue(key: String) -> Value? {
26+
return structure.getValue(key: key)
27+
}
28+
29+
public func asMap() -> [String: Value] {
30+
return structure.asMap()
31+
}
32+
33+
public func asObjectMap() -> [String: AnyHashable?] {
34+
return structure.asObjectMap()
35+
}
36+
}
37+
38+
extension ImmutableTrackingEventDetails {
39+
public func withValue(_ value: Double?) -> ImmutableTrackingEventDetails {
40+
ImmutableTrackingEventDetails(value: value, structure: structure)
41+
}
42+
43+
public func withAttribute(key: String, value: Value) -> ImmutableTrackingEventDetails {
44+
var newAttributes = structure.asMap()
45+
newAttributes[key] = value
46+
return ImmutableTrackingEventDetails(
47+
value: self.value,
48+
structure: ImmutableStructure(attributes: newAttributes)
49+
)
50+
}
51+
52+
public func withAttributes(_ attributes: [String: Value]) -> ImmutableTrackingEventDetails {
53+
let newAttributes = structure.asMap().merging(attributes) { (_, new) in new }
54+
return ImmutableTrackingEventDetails(
55+
value: self.value,
56+
structure: ImmutableStructure(attributes: newAttributes)
57+
)
58+
}
59+
60+
public func withoutAttribute(key: String) -> ImmutableTrackingEventDetails {
61+
var newAttributes = structure.asMap()
62+
newAttributes.removeValue(forKey: key)
63+
return ImmutableTrackingEventDetails(
64+
value: self.value,
65+
structure: ImmutableStructure(attributes: newAttributes)
66+
)
67+
}
68+
}

Sources/OpenFeature/OpenFeatureClient.swift

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,57 @@ extension OpenFeatureClient {
220220
throw OpenFeatureError.generalError(message: "Unable to match default value type with flag value type")
221221
}
222222
}
223+
224+
// MARK: - Tracking
225+
226+
extension OpenFeatureClient {
227+
public func track(key: String) {
228+
reportTrack(key: key, context: nil, details: nil)
229+
}
230+
231+
public func track(key: String, context: any EvaluationContext) {
232+
reportTrack(key: key, context: context, details: nil)
233+
}
234+
235+
public func track(key: String, details: any TrackingEventDetails) {
236+
reportTrack(key: key, context: nil, details: details)
237+
}
238+
239+
public func track(key: String, context: any EvaluationContext, details: any TrackingEventDetails) {
240+
reportTrack(key: key, context: context, details: details)
241+
}
242+
243+
private func reportTrack(key: String, context: (any EvaluationContext)?, details: (any TrackingEventDetails)?) {
244+
let openFeatureApiState = openFeatureApi.getState()
245+
switch openFeatureApiState.providerStatus {
246+
case .ready, .reconciling, .stale:
247+
do {
248+
let provider = openFeatureApiState.provider ?? NoOpProvider()
249+
try provider.track(key: key, context: mergeEvaluationContext(context), details: details)
250+
} catch {
251+
logger.error("Unable to report track event with key \(key) due to exception \(error)")
252+
}
253+
default:
254+
break
255+
}
256+
}
257+
}
258+
259+
extension OpenFeatureClient {
260+
func mergeEvaluationContext(_ invocationContext: (any EvaluationContext)?) -> (any EvaluationContext)? {
261+
let apiContext = OpenFeatureAPI.shared.getEvaluationContext()
262+
return mergeContextMaps(apiContext, invocationContext)
263+
}
264+
265+
private func mergeContextMaps(_ contexts: (any EvaluationContext)?...) -> (any EvaluationContext)? {
266+
let validContexts = contexts.compactMap { $0 }
267+
guard !validContexts.isEmpty else { return nil }
268+
269+
return validContexts.reduce(ImmutableContext()) { merged, next in
270+
let newTargetingKey = next.getTargetingKey()
271+
let targetingKey = newTargetingKey.isEmpty ? merged.getTargetingKey() : newTargetingKey
272+
let attributes = merged.asMap().merging(next.asMap()) { _, newKey in newKey }
273+
return ImmutableContext(targetingKey: targetingKey, structure: ImmutableStructure(attributes: attributes))
274+
}
275+
}
276+
}

Sources/OpenFeature/Provider/FeatureProvider.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,17 @@ public protocol FeatureProvider: EventPublisher {
3535
-> ProviderEvaluation<
3636
Value
3737
>
38+
39+
/// Performs tracking of a particular action or application state.
40+
/// - Parameters:
41+
/// - key: Event name to track
42+
/// - context: Evaluation context used in flag evaluation
43+
/// - details: Data pertinent to a particular tracking event
44+
func track(key: String, context: (any EvaluationContext)?, details: (any TrackingEventDetails)?) throws
45+
}
46+
47+
extension FeatureProvider {
48+
public func track(key: String, context: (any EvaluationContext)?, details: (any TrackingEventDetails)?) throws {
49+
// Default to no-op
50+
}
3851
}

Sources/OpenFeature/Provider/MultiProvider/MultiProvider.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,12 @@ public class MultiProvider: FeatureProvider {
121121
public var name: String?
122122

123123
init(providers: [FeatureProvider]) {
124-
name = "MultiProvider: " + providers.map {
125-
$0.metadata.name ?? "Provider"
126-
}
127-
.joined(separator: ", ")
124+
name =
125+
"MultiProvider: "
126+
+ providers.map {
127+
$0.metadata.name ?? "Provider"
128+
}
129+
.joined(separator: ", ")
128130
}
129131
}
130132
}

Sources/OpenFeature/Tracking.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import Foundation
2+
3+
/// Interface for Tracking events.
4+
public protocol Tracking {
5+
/// Performs tracking of a particular action or application state.
6+
/// - Parameter key: Event name to track
7+
func track(key: String)
8+
/// Performs tracking of a particular action or application state.
9+
/// - Parameters:
10+
/// - key: Event name to track
11+
/// - context: Evaluation context used in flag evaluation
12+
func track(key: String, context: any EvaluationContext)
13+
/// Performs tracking of a particular action or application state.
14+
/// - Parameters:
15+
/// - key: Event name to track
16+
/// - details: Data pertinent to a particular tracking event
17+
func track(key: String, details: any TrackingEventDetails)
18+
/// Performs tracking of a particular action or application state.
19+
/// - Parameters:
20+
/// - key: Event name to track
21+
/// - context: Evaluation context used in flag evaluation
22+
/// - details: Data pertinent to a particular tracking event
23+
func track(key: String, context: any EvaluationContext, details: any TrackingEventDetails)
24+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import Foundation
2+
3+
/// Data pertinent to a particular tracking event.
4+
public protocol TrackingEventDetails: Structure {
5+
/// Get the value from this event.
6+
/// - Returns: The optional numeric value tracking value.
7+
func getValue() -> Double?
8+
}

Tests/OpenFeatureTests/DeveloperExperienceTests.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,4 +246,39 @@ final class DeveloperExperienceTests: XCTestCase {
246246

247247
observer.cancel()
248248
}
249+
250+
func testTrack() async {
251+
var trackCalled = false
252+
let onTrack = { (key: String, _: EvaluationContext?, details: TrackingEventDetails?) -> Void in
253+
trackCalled = true
254+
XCTAssertEqual(key, "test")
255+
XCTAssertEqual(details?.getValue(), 5)
256+
}
257+
await OpenFeatureAPI.shared.setProviderAndWait(provider: MockProvider(track: onTrack))
258+
let client = OpenFeatureAPI.shared.getClient()
259+
260+
client.track(key: "test", details: ImmutableTrackingEventDetails(value: 5))
261+
262+
XCTAssertTrue(trackCalled)
263+
}
264+
265+
func testTrackMergeContext() async {
266+
var context: (any EvaluationContext)? = nil
267+
let onTrack = { (_: String, evaluationContext: EvaluationContext?, _: TrackingEventDetails?) -> Void in
268+
context = evaluationContext
269+
}
270+
await OpenFeatureAPI.shared.setProviderAndWait(provider: MockProvider(track: onTrack))
271+
await OpenFeatureAPI.shared.setEvaluationContextAndWait(
272+
evaluationContext: ImmutableContext(attributes: ["string": .string("user"), "num": .double(10)])
273+
)
274+
let client = OpenFeatureAPI.shared.getClient()
275+
client.track(
276+
key: "test", context: ImmutableContext(attributes: ["num": .double(20), "bool": .boolean(true)]),
277+
details: ImmutableTrackingEventDetails(value: 5))
278+
279+
XCTAssertEqual(context?.keySet().count, 3)
280+
XCTAssertEqual(context?.getValue(key: "string"), .string("user"))
281+
XCTAssertEqual(context?.getValue(key: "num"), .double(20))
282+
XCTAssertEqual(context?.getValue(key: "bool"), .boolean(true))
283+
}
249284
}

Tests/OpenFeatureTests/Helpers/MockProvider.swift

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class MockProvider: FeatureProvider {
1919
private let _getDoubleEvaluation: (String, Double, EvaluationContext?) throws -> ProviderEvaluation<Double>
2020
private let _getObjectEvaluation: (String, Value, EvaluationContext?) throws -> ProviderEvaluation<Value>
2121
private let _observe: () -> AnyPublisher<ProviderEvent?, Never>
22+
private let _track: (String, EvaluationContext?, TrackingEventDetails?) throws -> Void
2223

2324
/// Initialize the provider with a set of callbacks that will be called when the provider is initialized,
2425
init(
@@ -42,7 +43,7 @@ class MockProvider: FeatureProvider {
4243
String,
4344
Int64,
4445
EvaluationContext?
45-
) throws -> ProviderEvaluation<Int64> = { _, fallback, _ in
46+
) throws -> ProviderEvaluation<Int64> = { _, fallback, _ in
4647
return ProviderEvaluation(value: fallback, flagMetadata: [:])
4748
},
4849
getDoubleEvaluation: @escaping (
@@ -56,10 +57,15 @@ class MockProvider: FeatureProvider {
5657
String,
5758
Value,
5859
EvaluationContext?
59-
) throws -> ProviderEvaluation<Value> = { _, fallback, _ in
60+
) throws -> ProviderEvaluation<Value> = { _, fallback, _ in
6061
return ProviderEvaluation(value: fallback, flagMetadata: [:])
6162
},
62-
observe: @escaping () -> AnyPublisher<ProviderEvent?, Never> = { Just(nil).eraseToAnyPublisher() }
63+
observe: @escaping () -> AnyPublisher<ProviderEvent?, Never> = { Just(nil).eraseToAnyPublisher() },
64+
track: @escaping (
65+
String,
66+
EvaluationContext?,
67+
TrackingEventDetails?
68+
) throws -> Void = { _, _, _ in }
6369
) {
6470
self._onContextSet = onContextSet
6571
self._initialize = initialize
@@ -69,6 +75,7 @@ class MockProvider: FeatureProvider {
6975
self._getDoubleEvaluation = getDoubleEvaluation
7076
self._getObjectEvaluation = getObjectEvaluation
7177
self._observe = observe
78+
self._track = track
7279
}
7380

7481
func onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) async throws {
@@ -112,6 +119,10 @@ class MockProvider: FeatureProvider {
112119
func observe() -> AnyPublisher<ProviderEvent?, Never> {
113120
_observe()
114121
}
122+
123+
func track(key: String, context: (any EvaluationContext)?, details: (any TrackingEventDetails)?) throws {
124+
try _track(key, context, details)
125+
}
115126
}
116127

117128
extension MockProvider {

0 commit comments

Comments
 (0)