From 8ffcbda00f729db433a77047d808b9147d0c4302 Mon Sep 17 00:00:00 2001 From: Mael RB Date: Wed, 17 Sep 2025 16:51:52 +0200 Subject: [PATCH 1/5] Add Tracking api Signed-off-by: Mael RB --- Sources/OpenFeature/Client.swift | 2 +- .../ImmutableTrackingEventDetails.swift | 62 ++++++++++++++++++ Sources/OpenFeature/OpenFeatureClient.swift | 63 +++++++++++++++++++ .../Provider/FeatureProvider.swift | 13 ++++ Sources/OpenFeature/Tracking.swift | 24 +++++++ .../OpenFeature/TrackingEventDetails.swift | 8 +++ 6 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 Sources/OpenFeature/ImmutableTrackingEventDetails.swift create mode 100644 Sources/OpenFeature/Tracking.swift create mode 100644 Sources/OpenFeature/TrackingEventDetails.swift diff --git a/Sources/OpenFeature/Client.swift b/Sources/OpenFeature/Client.swift index 083f28e..22b0cc2 100644 --- a/Sources/OpenFeature/Client.swift +++ b/Sources/OpenFeature/Client.swift @@ -1,7 +1,7 @@ import Foundation /// Interface used to resolve flags of varying types. -public protocol Client: Features { +public protocol Client: Features, Tracking { var metadata: ClientMetadata { get } /// The hooks associated to this client. diff --git a/Sources/OpenFeature/ImmutableTrackingEventDetails.swift b/Sources/OpenFeature/ImmutableTrackingEventDetails.swift new file mode 100644 index 0000000..3fc74c3 --- /dev/null +++ b/Sources/OpenFeature/ImmutableTrackingEventDetails.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Represents data pertinent to a particular tracking event. +public struct ImmutableTrackingEventDetails: TrackingEventDetails { + private let value: Double? + private let structure: ImmutableStructure + + public init(value: Double? = nil, structure: ImmutableStructure = ImmutableStructure()) { + self.value = value + self.structure = structure + } + + public init(attributes: [String: Value]) { + self.init(structure: ImmutableStructure(attributes: attributes)) + } + + public func getValue() -> Double? { + value + } + + public func keySet() -> Set { + return structure.keySet() + } + + public func getValue(key: String) -> Value? { + return structure.getValue(key: key) + } + + public func asMap() -> [String: Value] { + return structure.asMap() + } + + public func asObjectMap() -> [String: AnyHashable?] { + return structure.asObjectMap() + } +} + +extension ImmutableTrackingEventDetails { + public func withValue(_ value: Double?) -> ImmutableTrackingEventDetails { + ImmutableTrackingEventDetails(value: value, structure: structure) + } + + public func withAttribute(key: String, value: Value) -> ImmutableTrackingEventDetails { + var newAttributes = structure.asMap() + newAttributes[key] = value + return ImmutableTrackingEventDetails(value: self.value, structure: ImmutableStructure(attributes: newAttributes)) + } + + public func withAttributes(_ attributes: [String: Value]) -> ImmutableTrackingEventDetails { + var newAttributes = structure.asMap() + for (key, value) in attributes { + newAttributes[key] = value + } + return ImmutableTrackingEventDetails(value: self.value, structure: ImmutableStructure(attributes: newAttributes)) + } + + public func withoutAttribute(key: String) -> ImmutableTrackingEventDetails { + var newAttributes = structure.asMap() + newAttributes.removeValue(forKey: key) + return ImmutableTrackingEventDetails(value: self.value, structure: ImmutableStructure(attributes: newAttributes)) + } +} diff --git a/Sources/OpenFeature/OpenFeatureClient.swift b/Sources/OpenFeature/OpenFeatureClient.swift index f90afd5..b4b76fd 100644 --- a/Sources/OpenFeature/OpenFeatureClient.swift +++ b/Sources/OpenFeature/OpenFeatureClient.swift @@ -220,3 +220,66 @@ extension OpenFeatureClient { throw OpenFeatureError.generalError(message: "Unable to match default value type with flag value type") } } + +// MARK: - Tracking + +extension OpenFeatureClient { + public func track(key: String) { + reportTrack(key: key, context: nil, details: nil) + } + + public func track(key: String, context: any EvaluationContext) { + reportTrack(key: key, context: context, details: nil) + } + + public func track(key: String, details: any TrackingEventDetails) { + reportTrack(key: key, context: nil, details: details) + } + + public func track(key: String, context: any EvaluationContext, details: any TrackingEventDetails) { + reportTrack(key: key, context: context, details: details) + } + + private func reportTrack(key: String, context: (any EvaluationContext)?, details: (any TrackingEventDetails)?) { + let openFeatureApiState = openFeatureApi.getState() + switch openFeatureApiState.providerStatus { + case .ready, .reconciling, .stale: + do { + let provider = openFeatureApiState.provider ?? NoOpProvider() + try provider.track(key: key, context: mergeEvaluationContext(context), details: details) + } catch { + logger.error("Unable to report track event with key \(key) due to exception \(error)") + } + default: + break + } + } +} + +extension OpenFeatureClient { + func mergeEvaluationContext(_ invocationContext: (any EvaluationContext)?) -> (any EvaluationContext)? { + let apiContext = OpenFeatureAPI.shared.getEvaluationContext() + return mergeContextMaps(apiContext, invocationContext) + } + + private func mergeContextMaps(_ contexts: (any EvaluationContext)?...) -> (any EvaluationContext)? { + guard !contexts.isEmpty else { return nil } + var merged: (any EvaluationContext)? = nil + for context in contexts { + guard let context else { continue } + if let _merged = merged { + let immutableContext = ImmutableContext( + targetingKey: _merged.getTargetingKey(), + structure: ImmutableStructure(attributes: _merged.asMap()) + ) + merged = immutableContext + .withTargetingKey(context.getTargetingKey()) + .withAttributes(context.asMap()) + } else { + merged = context + } + } + + return merged + } +} diff --git a/Sources/OpenFeature/Provider/FeatureProvider.swift b/Sources/OpenFeature/Provider/FeatureProvider.swift index 34cdd8c..63963fd 100644 --- a/Sources/OpenFeature/Provider/FeatureProvider.swift +++ b/Sources/OpenFeature/Provider/FeatureProvider.swift @@ -35,4 +35,17 @@ public protocol FeatureProvider: EventPublisher { -> ProviderEvaluation< Value > + + /// Performs tracking of a particular action or application state. + /// - Parameters: + /// - key: Event name to track + /// - context: Evaluation context used in flag evaluation + /// - details: Data pertinent to a particular tracking event + func track(key: String, context: (any EvaluationContext)?, details: (any TrackingEventDetails)?) throws +} + +public extension FeatureProvider { + func track(key: String, context: (any EvaluationContext)?, details: (any TrackingEventDetails)?) throws { + // Default to no-op + } } diff --git a/Sources/OpenFeature/Tracking.swift b/Sources/OpenFeature/Tracking.swift new file mode 100644 index 0000000..2b944d1 --- /dev/null +++ b/Sources/OpenFeature/Tracking.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Interface for Tracking events. +public protocol Tracking { + /// Performs tracking of a particular action or application state. + /// - Parameter key: Event name to track + func track(key: String) + /// Performs tracking of a particular action or application state. + /// - Parameters: + /// - key: Event name to track + /// - context: Evaluation context used in flag evaluation + func track(key: String, context: any EvaluationContext) + /// Performs tracking of a particular action or application state. + /// - Parameters: + /// - key: Event name to track + /// - details: Data pertinent to a particular tracking event + func track(key: String, details: any TrackingEventDetails) + /// Performs tracking of a particular action or application state. + /// - Parameters: + /// - key: Event name to track + /// - context: Evaluation context used in flag evaluation + /// - details: Data pertinent to a particular tracking event + func track(key: String, context: any EvaluationContext, details: any TrackingEventDetails) +} diff --git a/Sources/OpenFeature/TrackingEventDetails.swift b/Sources/OpenFeature/TrackingEventDetails.swift new file mode 100644 index 0000000..3811253 --- /dev/null +++ b/Sources/OpenFeature/TrackingEventDetails.swift @@ -0,0 +1,8 @@ +import Foundation + +/// Data pertinent to a particular tracking event. +public protocol TrackingEventDetails: Structure { + /// Get the value from this event. + /// - Returns: The optional numeric value tracking value. + func getValue() -> Double? +} From 8c087522042bf0904c4609e1fc2e844b26a56d56 Mon Sep 17 00:00:00 2001 From: Mael RB Date: Wed, 17 Sep 2025 16:53:32 +0200 Subject: [PATCH 2/5] Add unit tests Signed-off-by: Mael RB --- .../DeveloperExperienceTests.swift | 33 ++++ .../Helpers/MockProvider.swift | 13 +- .../ImmutableTrackingEventDetailsTests.swift | 174 ++++++++++++++++++ .../OpenFeatureClientTests.swift | 44 +++++ 4 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 Tests/OpenFeatureTests/ImmutableTrackingEventDetailsTests.swift diff --git a/Tests/OpenFeatureTests/DeveloperExperienceTests.swift b/Tests/OpenFeatureTests/DeveloperExperienceTests.swift index 15c8cf3..494670f 100644 --- a/Tests/OpenFeatureTests/DeveloperExperienceTests.swift +++ b/Tests/OpenFeatureTests/DeveloperExperienceTests.swift @@ -246,4 +246,37 @@ final class DeveloperExperienceTests: XCTestCase { observer.cancel() } + + func testTrack() async { + var trackCalled = false + let onTrack = { (key: String, _: EvaluationContext?, details: TrackingEventDetails?) -> Void in + trackCalled = true + XCTAssertEqual(key, "test") + XCTAssertEqual(details?.getValue(), 5) + } + await OpenFeatureAPI.shared.setProviderAndWait(provider: MockProvider(track: onTrack)) + let client = OpenFeatureAPI.shared.getClient() + + client.track(key: "test", details: ImmutableTrackingEventDetails(value: 5)) + + XCTAssertTrue(trackCalled) + } + + func testTrackMergeContext() async { + var context: (any EvaluationContext)? = nil + let onTrack = { (_: String, evaluationContext: EvaluationContext?, _: TrackingEventDetails?) -> Void in + context = evaluationContext + } + await OpenFeatureAPI.shared.setProviderAndWait(provider: MockProvider(track: onTrack)) + await OpenFeatureAPI.shared.setEvaluationContextAndWait( + evaluationContext: ImmutableContext(attributes: ["string": .string("user"), "num": .double(10)]) + ) + let client = OpenFeatureAPI.shared.getClient() + client.track(key: "test", context: ImmutableContext(attributes: ["num": .double(20), "bool": .boolean(true)]), details: ImmutableTrackingEventDetails(value: 5)) + + XCTAssertEqual(context?.keySet().count, 3) + XCTAssertEqual(context?.getValue(key: "string"), .string("user")) + XCTAssertEqual(context?.getValue(key: "num"), .double(20)) + XCTAssertEqual(context?.getValue(key: "bool"), .boolean(true)) + } } diff --git a/Tests/OpenFeatureTests/Helpers/MockProvider.swift b/Tests/OpenFeatureTests/Helpers/MockProvider.swift index 487edee..40a2dd5 100644 --- a/Tests/OpenFeatureTests/Helpers/MockProvider.swift +++ b/Tests/OpenFeatureTests/Helpers/MockProvider.swift @@ -19,6 +19,7 @@ class MockProvider: FeatureProvider { private let _getDoubleEvaluation: (String, Double, EvaluationContext?) throws -> ProviderEvaluation private let _getObjectEvaluation: (String, Value, EvaluationContext?) throws -> ProviderEvaluation private let _observe: () -> AnyPublisher + private let _track: (String, EvaluationContext?, TrackingEventDetails?) throws -> Void /// Initialize the provider with a set of callbacks that will be called when the provider is initialized, init( @@ -59,7 +60,12 @@ class MockProvider: FeatureProvider { ) throws -> ProviderEvaluation = { _, fallback, _ in return ProviderEvaluation(value: fallback, flagMetadata: [:]) }, - observe: @escaping () -> AnyPublisher = { Just(nil).eraseToAnyPublisher() } + observe: @escaping () -> AnyPublisher = { Just(nil).eraseToAnyPublisher() }, + track: @escaping ( + String, + EvaluationContext?, + TrackingEventDetails? + ) throws -> Void = { _, _, _ in } ) { self._onContextSet = onContextSet self._initialize = initialize @@ -69,6 +75,7 @@ class MockProvider: FeatureProvider { self._getDoubleEvaluation = getDoubleEvaluation self._getObjectEvaluation = getObjectEvaluation self._observe = observe + self._track = track } func onContextSet(oldContext: EvaluationContext?, newContext: EvaluationContext) async throws { @@ -112,6 +119,10 @@ class MockProvider: FeatureProvider { func observe() -> AnyPublisher { _observe() } + + func track(key: String, context: (any EvaluationContext)?, details: (any TrackingEventDetails)?) throws { + try _track(key, context, details) + } } extension MockProvider { diff --git a/Tests/OpenFeatureTests/ImmutableTrackingEventDetailsTests.swift b/Tests/OpenFeatureTests/ImmutableTrackingEventDetailsTests.swift new file mode 100644 index 0000000..a888a35 --- /dev/null +++ b/Tests/OpenFeatureTests/ImmutableTrackingEventDetailsTests.swift @@ -0,0 +1,174 @@ +import XCTest + +@testable import OpenFeature + +final class ImmutableTrackingEventDetailsTests: XCTestCase { + func testImmutableTrackingEventDetailsCreation() { + let context = ImmutableTrackingEventDetails(value: 5) + + XCTAssertEqual(context.getValue(), 5) + XCTAssertTrue(context.keySet().isEmpty) + } + + func testImmutableTrackingEventDetailsWithAttributes() { + let attributes: [String: Value] = [ + "string": .string("test-value"), + "integer": .integer(42), + "boolean": .boolean(true), + ] + + let context = ImmutableTrackingEventDetails(attributes: attributes) + + XCTAssertNil(context.getValue()) + XCTAssertEqual(context.keySet().count, 3) + XCTAssertEqual(context.getValue(key: "string")?.asString(), "test-value") + XCTAssertEqual(context.getValue(key: "integer")?.asInteger(), 42) + XCTAssertEqual(context.getValue(key: "boolean")?.asBoolean(), true) + } + + func testImmutableTrackingEventDetailsAsMap() { + let attributes: [String: Value] = [ + "string": .string("test"), + "integer": .integer(42), + "boolean": .boolean(true), + "list": .list([.string("item1"), .integer(100)]), + "structure": .structure([ + "nested": .string("nested-value") + ]), + ] + + let context = ImmutableTrackingEventDetails(attributes: attributes) + let map = context.asMap() + + XCTAssertEqual(map.count, 5) + XCTAssertEqual(map["string"]?.asString(), "test") + XCTAssertEqual(map["integer"]?.asInteger(), 42) + XCTAssertEqual(map["boolean"]?.asBoolean(), true) + XCTAssertEqual(map["list"]?.asList()?.count, 2) + XCTAssertEqual(map["structure"]?.asStructure()?["nested"]?.asString(), "nested-value") + } + + func testImmutableTrackingEventDetailsAsObjectMap() { + let attributes: [String: Value] = [ + "string": .string("test"), + "integer": .integer(42), + "boolean": .boolean(true), + "null": .null, + ] + + let context = ImmutableTrackingEventDetails(attributes: attributes) + let objectMap = context.asObjectMap() + + XCTAssertEqual(objectMap.count, 4) + XCTAssertEqual(objectMap["string"] as? String, "test") + XCTAssertEqual(objectMap["integer"] as? Int64, 42) + XCTAssertEqual(objectMap["boolean"] as? Bool, true) + + // For null values, we need to check the unwrapped value + let nullValue = objectMap["null"] + XCTAssertNil(nullValue as? AnyHashable) // But the unwrapped value is nil + } + + func testImmutableTrackingEventDetailsWithValue() { + let original = ImmutableTrackingEventDetails(value: 0) + let modified = original.withValue(2) + + XCTAssertEqual(original.getValue(), 0) + XCTAssertEqual(modified.getValue(), 2) + XCTAssertTrue(original.keySet().isEmpty) + XCTAssertTrue(modified.keySet().isEmpty) + } + + func testImmutableTrackingEventDetailsSetAttribute() { + let original = ImmutableTrackingEventDetails(value: 3) + let modified = original.withAttribute(key: "country", value: .string("US")) + + XCTAssertEqual(original.getValue(), 3) + XCTAssertEqual(modified.getValue(), 3) + XCTAssertTrue(original.keySet().isEmpty) + XCTAssertEqual(modified.keySet().count, 1) + XCTAssertEqual(modified.getValue(key: "country")?.asString(), "US") + XCTAssertNil(original.getValue(key: "country")) + } + + func testImmutableTrackingEventDetailsSetMultipleAttributes() { + let original = ImmutableTrackingEventDetails(value: 3) + let attributes: [String: Value] = [ + "country": .string("US"), + "age": .integer(25), + "premium": .boolean(true), + ] + let modified = original.withAttributes(attributes) + + XCTAssertEqual(original.getValue(), 3) + XCTAssertEqual(modified.getValue(), 3) + XCTAssertTrue(original.keySet().isEmpty) + XCTAssertEqual(modified.keySet().count, 3) + XCTAssertEqual(modified.getValue(key: "country")?.asString(), "US") + XCTAssertEqual(modified.getValue(key: "age")?.asInteger(), 25) + XCTAssertEqual(modified.getValue(key: "premium")?.asBoolean(), true) + } + + func testImmutableTrackingEventDetailsRemoveAttribute() { + let original = ImmutableTrackingEventDetails( + value: 1, + structure: ImmutableStructure(attributes: [ + "country": .string("US"), + "age": .integer(25), + "premium": .boolean(true), + ]) + ) + let modified = original.withoutAttribute(key: "age") + + XCTAssertEqual(original.getValue(), 1) + XCTAssertEqual(modified.getValue(), 1) + XCTAssertEqual(original.keySet().count, 3) + XCTAssertEqual(modified.keySet().count, 2) + XCTAssertEqual(original.getValue(key: "age")?.asInteger(), 25) + XCTAssertNil(modified.getValue(key: "age")) + XCTAssertEqual(modified.getValue(key: "country")?.asString(), "US") + XCTAssertEqual(modified.getValue(key: "premium")?.asBoolean(), true) + } + + func testImmutableTrackingEventDetailsChaining() { + let context = ImmutableTrackingEventDetails(value: 1) + .withAttribute(key: "country", value: .string("US")) + .withAttribute(key: "age", value: .integer(25)) + .withAttribute(key: "premium", value: .boolean(true)) + + XCTAssertEqual(context.getValue(), 1) + XCTAssertEqual(context.keySet().count, 3) + XCTAssertEqual(context.getValue(key: "country")?.asString(), "US") + XCTAssertEqual(context.getValue(key: "age")?.asInteger(), 25) + XCTAssertEqual(context.getValue(key: "premium")?.asBoolean(), true) + } + + func testImmutableTrackingEventDetailsThreadSafetyWithModifications() { + let original = ImmutableTrackingEventDetails(value: 1) + .withAttribute(key: "country", value: .string("US")) + + let expectation = XCTestExpectation(description: "Thread safety with modifications test") + expectation.expectedFulfillmentCount = 10 + + DispatchQueue.concurrentPerform(iterations: 10) { index in + let modified = + original + .withAttribute(key: "thread", value: .integer(Int64(index))) + .withAttribute(key: "timestamp", value: .double(Double(index))) + + XCTAssertEqual(modified.getValue(), 1) + XCTAssertEqual(modified.getValue(key: "country")?.asString(), "US") + XCTAssertEqual(modified.getValue(key: "thread")?.asInteger(), Int64(index)) + XCTAssertEqual(modified.getValue(key: "timestamp")?.asDouble(), Double(index)) + + XCTAssertEqual(original.keySet().count, 1) + XCTAssertEqual(original.getValue(key: "country")?.asString(), "US") + XCTAssertNil(original.getValue(key: "thread")) + XCTAssertNil(original.getValue(key: "timestamp")) + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/Tests/OpenFeatureTests/OpenFeatureClientTests.swift b/Tests/OpenFeatureTests/OpenFeatureClientTests.swift index e80ce66..6d238ba 100644 --- a/Tests/OpenFeatureTests/OpenFeatureClientTests.swift +++ b/Tests/OpenFeatureTests/OpenFeatureClientTests.swift @@ -32,4 +32,48 @@ final class OpenFeatureClientTests: XCTestCase { XCTAssertEqual(doubleDetails.value, 12_310) XCTAssertNotNil(eventState) } + + func testMergeEvaluationContext_ApiEmptyAndInvocationNil_ThenEmpty() async { + let client = OpenFeatureClient(openFeatureApi: OpenFeatureAPI.shared, name: nil, version: nil) + await OpenFeatureAPI.shared.setEvaluationContextAndWait(evaluationContext: ImmutableContext()) + let result = client.mergeEvaluationContext(nil) + XCTAssertTrue(result?.getTargetingKey().isEmpty == true) + XCTAssertTrue(result?.keySet().isEmpty == true) + } + + func testMergeEvaluationContext_ApiContextAndInvocationNil_ThenApiContext() async { + let client = OpenFeatureClient(openFeatureApi: OpenFeatureAPI.shared, name: nil, version: nil) + let context = ImmutableContext(targetingKey: "api", structure: ImmutableStructure(attributes: ["bool": .boolean(true)])) + await OpenFeatureAPI.shared.setEvaluationContextAndWait(evaluationContext: context) + let result = client.mergeEvaluationContext(nil) + XCTAssertEqual(result?.getTargetingKey(), context.getTargetingKey()) + XCTAssertEqual(result?.asMap(), context.asMap()) + } + + func testMergeEvaluationContext_ApiNilAndInvocationContext_ThenInvocationContext() { + let client = OpenFeatureClient(openFeatureApi: OpenFeatureAPI.shared, name: nil, version: nil) + let context = ImmutableContext(targetingKey: "invocation", structure: ImmutableStructure(attributes: ["bool": .boolean(true)])) + let result = client.mergeEvaluationContext(context) + XCTAssertEqual(result?.getTargetingKey(), context.getTargetingKey()) + XCTAssertEqual(result?.asMap(), context.asMap()) + } + + func testMergeEvaluationContext_ApiContextAndInvocationContext_ThenMergedContext() async { + let client = OpenFeatureClient(openFeatureApi: OpenFeatureAPI.shared, name: nil, version: nil) + let apiContext = ImmutableContext( + targetingKey: "api", + structure: ImmutableStructure(attributes: ["bool": .boolean(true), "num": .integer(1)]) + ) + let invocationContext = ImmutableContext( + targetingKey: "invocation", + structure: ImmutableStructure(attributes: ["bool": .boolean(false), "string": .string("test")]) + ) + await OpenFeatureAPI.shared.setEvaluationContextAndWait(evaluationContext: apiContext) + let result = client.mergeEvaluationContext(invocationContext) + XCTAssertEqual(result?.getTargetingKey(), invocationContext.getTargetingKey()) + XCTAssertEqual(result?.keySet().count, 3) + XCTAssertEqual(result?.getValue(key: "bool"), .boolean(false)) + XCTAssertEqual(result?.getValue(key: "num"), .integer(1)) + XCTAssertEqual(result?.getValue(key: "string"), .string("test")) + } } From 3e4b736e7c7173ef982963348501bc4b41e99b68 Mon Sep 17 00:00:00 2001 From: Mael RB Date: Wed, 17 Sep 2025 16:59:25 +0200 Subject: [PATCH 3/5] Update Readme Signed-off-by: Mael RB --- README.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1d161cb..63fa35c 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Task { | ✅ | [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. | | ✅ | [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). | | ✅ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. | -| ❌ | [Tracking](#tracking) | Associate user actions with feature flag evaluations. | +| ✅ | [Tracking](#tracking) | Associate user actions with feature flag evaluations. | | ❌ | [Logging](#logging) | Integrate with popular logging packages. | | ✅ | [MultiProvider](#multiprovider) | Utilize multiple providers in a single application. | | ✅ | [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 OpenFeatureAPI.shared.addHooks(hooks: ExampleHook()) // add a hook on this client, to run on all evaluations made by this client -val client = OpenFeatureAPI.shared.getClient() +let client = OpenFeatureAPI.shared.getClient() client.addHooks(ExampleHook()) // add a hook for this evaluation only @@ -174,7 +174,21 @@ _ = client.getValue( ``` ### Tracking -Tracking is not yet available in the iOS SDK. +The tracking API allows you to use OpenFeature abstractions and objects to associate user actions with feature flag evaluations. +This is essential for robust experimentation powered by feature flags. +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. + +```swift +let client = OpenFeatureAPI.shared.getClient() + +// Track an event +client.track(key: "test") + +// Track an event with a numeric value +client.track(key: "test-value", details: ImmutableTrackingEventDetails(value: 5)) +``` + +Note that some providers may not support tracking; check the documentation for your provider for more information. ### Logging From 87468291366c6490e77ad6b9ebe000c9b8e7a83b Mon Sep 17 00:00:00 2001 From: Mael RB Date: Wed, 17 Sep 2025 17:15:28 +0200 Subject: [PATCH 4/5] Format Signed-off-by: Mael RB --- .../ImmutableTrackingEventDetails.swift | 11 ++-- Sources/OpenFeature/OpenFeatureClient.swift | 15 ++--- .../Provider/FeatureProvider.swift | 6 +- .../MultiProvider/MultiProvider.swift | 10 ++-- .../DeveloperExperienceTests.swift | 12 ++-- .../Helpers/MockProvider.swift | 6 +- .../OpenFeatureClientTests.swift | 14 +++-- .../OpenFeatureTests/ProviderEventTests.swift | 58 +++++++++---------- 8 files changed, 71 insertions(+), 61 deletions(-) diff --git a/Sources/OpenFeature/ImmutableTrackingEventDetails.swift b/Sources/OpenFeature/ImmutableTrackingEventDetails.swift index 3fc74c3..124a5ce 100644 --- a/Sources/OpenFeature/ImmutableTrackingEventDetails.swift +++ b/Sources/OpenFeature/ImmutableTrackingEventDetails.swift @@ -39,11 +39,12 @@ extension ImmutableTrackingEventDetails { public func withValue(_ value: Double?) -> ImmutableTrackingEventDetails { ImmutableTrackingEventDetails(value: value, structure: structure) } - + public func withAttribute(key: String, value: Value) -> ImmutableTrackingEventDetails { var newAttributes = structure.asMap() newAttributes[key] = value - return ImmutableTrackingEventDetails(value: self.value, structure: ImmutableStructure(attributes: newAttributes)) + return ImmutableTrackingEventDetails( + value: self.value, structure: ImmutableStructure(attributes: newAttributes)) } public func withAttributes(_ attributes: [String: Value]) -> ImmutableTrackingEventDetails { @@ -51,12 +52,14 @@ extension ImmutableTrackingEventDetails { for (key, value) in attributes { newAttributes[key] = value } - return ImmutableTrackingEventDetails(value: self.value, structure: ImmutableStructure(attributes: newAttributes)) + return ImmutableTrackingEventDetails( + value: self.value, structure: ImmutableStructure(attributes: newAttributes)) } public func withoutAttribute(key: String) -> ImmutableTrackingEventDetails { var newAttributes = structure.asMap() newAttributes.removeValue(forKey: key) - return ImmutableTrackingEventDetails(value: self.value, structure: ImmutableStructure(attributes: newAttributes)) + return ImmutableTrackingEventDetails( + value: self.value, structure: ImmutableStructure(attributes: newAttributes)) } } diff --git a/Sources/OpenFeature/OpenFeatureClient.swift b/Sources/OpenFeature/OpenFeatureClient.swift index b4b76fd..35af0d5 100644 --- a/Sources/OpenFeature/OpenFeatureClient.swift +++ b/Sources/OpenFeature/OpenFeatureClient.swift @@ -227,19 +227,19 @@ extension OpenFeatureClient { public func track(key: String) { reportTrack(key: key, context: nil, details: nil) } - + public func track(key: String, context: any EvaluationContext) { reportTrack(key: key, context: context, details: nil) } - + public func track(key: String, details: any TrackingEventDetails) { reportTrack(key: key, context: nil, details: details) } - + public func track(key: String, context: any EvaluationContext, details: any TrackingEventDetails) { reportTrack(key: key, context: context, details: details) } - + private func reportTrack(key: String, context: (any EvaluationContext)?, details: (any TrackingEventDetails)?) { let openFeatureApiState = openFeatureApi.getState() switch openFeatureApiState.providerStatus { @@ -261,7 +261,7 @@ extension OpenFeatureClient { let apiContext = OpenFeatureAPI.shared.getEvaluationContext() return mergeContextMaps(apiContext, invocationContext) } - + private func mergeContextMaps(_ contexts: (any EvaluationContext)?...) -> (any EvaluationContext)? { guard !contexts.isEmpty else { return nil } var merged: (any EvaluationContext)? = nil @@ -272,14 +272,15 @@ extension OpenFeatureClient { targetingKey: _merged.getTargetingKey(), structure: ImmutableStructure(attributes: _merged.asMap()) ) - merged = immutableContext + merged = + immutableContext .withTargetingKey(context.getTargetingKey()) .withAttributes(context.asMap()) } else { merged = context } } - + return merged } } diff --git a/Sources/OpenFeature/Provider/FeatureProvider.swift b/Sources/OpenFeature/Provider/FeatureProvider.swift index 63963fd..fde860b 100644 --- a/Sources/OpenFeature/Provider/FeatureProvider.swift +++ b/Sources/OpenFeature/Provider/FeatureProvider.swift @@ -35,7 +35,7 @@ public protocol FeatureProvider: EventPublisher { -> ProviderEvaluation< Value > - + /// Performs tracking of a particular action or application state. /// - Parameters: /// - key: Event name to track @@ -44,8 +44,8 @@ public protocol FeatureProvider: EventPublisher { func track(key: String, context: (any EvaluationContext)?, details: (any TrackingEventDetails)?) throws } -public extension FeatureProvider { - func track(key: String, context: (any EvaluationContext)?, details: (any TrackingEventDetails)?) throws { +extension FeatureProvider { + public func track(key: String, context: (any EvaluationContext)?, details: (any TrackingEventDetails)?) throws { // Default to no-op } } diff --git a/Sources/OpenFeature/Provider/MultiProvider/MultiProvider.swift b/Sources/OpenFeature/Provider/MultiProvider/MultiProvider.swift index a80d3c2..4e1fa2e 100644 --- a/Sources/OpenFeature/Provider/MultiProvider/MultiProvider.swift +++ b/Sources/OpenFeature/Provider/MultiProvider/MultiProvider.swift @@ -121,10 +121,12 @@ public class MultiProvider: FeatureProvider { public var name: String? init(providers: [FeatureProvider]) { - name = "MultiProvider: " + providers.map { - $0.metadata.name ?? "Provider" - } - .joined(separator: ", ") + name = + "MultiProvider: " + + providers.map { + $0.metadata.name ?? "Provider" + } + .joined(separator: ", ") } } } diff --git a/Tests/OpenFeatureTests/DeveloperExperienceTests.swift b/Tests/OpenFeatureTests/DeveloperExperienceTests.swift index 494670f..0488ae1 100644 --- a/Tests/OpenFeatureTests/DeveloperExperienceTests.swift +++ b/Tests/OpenFeatureTests/DeveloperExperienceTests.swift @@ -246,7 +246,7 @@ final class DeveloperExperienceTests: XCTestCase { observer.cancel() } - + func testTrack() async { var trackCalled = false let onTrack = { (key: String, _: EvaluationContext?, details: TrackingEventDetails?) -> Void in @@ -258,10 +258,10 @@ final class DeveloperExperienceTests: XCTestCase { let client = OpenFeatureAPI.shared.getClient() client.track(key: "test", details: ImmutableTrackingEventDetails(value: 5)) - + XCTAssertTrue(trackCalled) } - + func testTrackMergeContext() async { var context: (any EvaluationContext)? = nil let onTrack = { (_: String, evaluationContext: EvaluationContext?, _: TrackingEventDetails?) -> Void in @@ -272,8 +272,10 @@ final class DeveloperExperienceTests: XCTestCase { evaluationContext: ImmutableContext(attributes: ["string": .string("user"), "num": .double(10)]) ) let client = OpenFeatureAPI.shared.getClient() - client.track(key: "test", context: ImmutableContext(attributes: ["num": .double(20), "bool": .boolean(true)]), details: ImmutableTrackingEventDetails(value: 5)) - + client.track( + key: "test", context: ImmutableContext(attributes: ["num": .double(20), "bool": .boolean(true)]), + details: ImmutableTrackingEventDetails(value: 5)) + XCTAssertEqual(context?.keySet().count, 3) XCTAssertEqual(context?.getValue(key: "string"), .string("user")) XCTAssertEqual(context?.getValue(key: "num"), .double(20)) diff --git a/Tests/OpenFeatureTests/Helpers/MockProvider.swift b/Tests/OpenFeatureTests/Helpers/MockProvider.swift index 40a2dd5..c6389df 100644 --- a/Tests/OpenFeatureTests/Helpers/MockProvider.swift +++ b/Tests/OpenFeatureTests/Helpers/MockProvider.swift @@ -43,7 +43,7 @@ class MockProvider: FeatureProvider { String, Int64, EvaluationContext? - ) throws -> ProviderEvaluation = { _, fallback, _ in + ) throws -> ProviderEvaluation = { _, fallback, _ in return ProviderEvaluation(value: fallback, flagMetadata: [:]) }, getDoubleEvaluation: @escaping ( @@ -57,7 +57,7 @@ class MockProvider: FeatureProvider { String, Value, EvaluationContext? - ) throws -> ProviderEvaluation = { _, fallback, _ in + ) throws -> ProviderEvaluation = { _, fallback, _ in return ProviderEvaluation(value: fallback, flagMetadata: [:]) }, observe: @escaping () -> AnyPublisher = { Just(nil).eraseToAnyPublisher() }, @@ -119,7 +119,7 @@ class MockProvider: FeatureProvider { func observe() -> AnyPublisher { _observe() } - + func track(key: String, context: (any EvaluationContext)?, details: (any TrackingEventDetails)?) throws { try _track(key, context, details) } diff --git a/Tests/OpenFeatureTests/OpenFeatureClientTests.swift b/Tests/OpenFeatureTests/OpenFeatureClientTests.swift index 6d238ba..0ad0468 100644 --- a/Tests/OpenFeatureTests/OpenFeatureClientTests.swift +++ b/Tests/OpenFeatureTests/OpenFeatureClientTests.swift @@ -32,7 +32,7 @@ final class OpenFeatureClientTests: XCTestCase { XCTAssertEqual(doubleDetails.value, 12_310) XCTAssertNotNil(eventState) } - + func testMergeEvaluationContext_ApiEmptyAndInvocationNil_ThenEmpty() async { let client = OpenFeatureClient(openFeatureApi: OpenFeatureAPI.shared, name: nil, version: nil) await OpenFeatureAPI.shared.setEvaluationContextAndWait(evaluationContext: ImmutableContext()) @@ -40,24 +40,26 @@ final class OpenFeatureClientTests: XCTestCase { XCTAssertTrue(result?.getTargetingKey().isEmpty == true) XCTAssertTrue(result?.keySet().isEmpty == true) } - + func testMergeEvaluationContext_ApiContextAndInvocationNil_ThenApiContext() async { let client = OpenFeatureClient(openFeatureApi: OpenFeatureAPI.shared, name: nil, version: nil) - let context = ImmutableContext(targetingKey: "api", structure: ImmutableStructure(attributes: ["bool": .boolean(true)])) + let context = ImmutableContext( + targetingKey: "api", structure: ImmutableStructure(attributes: ["bool": .boolean(true)])) await OpenFeatureAPI.shared.setEvaluationContextAndWait(evaluationContext: context) let result = client.mergeEvaluationContext(nil) XCTAssertEqual(result?.getTargetingKey(), context.getTargetingKey()) XCTAssertEqual(result?.asMap(), context.asMap()) } - + func testMergeEvaluationContext_ApiNilAndInvocationContext_ThenInvocationContext() { let client = OpenFeatureClient(openFeatureApi: OpenFeatureAPI.shared, name: nil, version: nil) - let context = ImmutableContext(targetingKey: "invocation", structure: ImmutableStructure(attributes: ["bool": .boolean(true)])) + let context = ImmutableContext( + targetingKey: "invocation", structure: ImmutableStructure(attributes: ["bool": .boolean(true)])) let result = client.mergeEvaluationContext(context) XCTAssertEqual(result?.getTargetingKey(), context.getTargetingKey()) XCTAssertEqual(result?.asMap(), context.asMap()) } - + func testMergeEvaluationContext_ApiContextAndInvocationContext_ThenMergedContext() async { let client = OpenFeatureClient(openFeatureApi: OpenFeatureAPI.shared, name: nil, version: nil) let apiContext = ImmutableContext( diff --git a/Tests/OpenFeatureTests/ProviderEventTests.swift b/Tests/OpenFeatureTests/ProviderEventTests.swift index 3be8f89..b4cdcd5 100644 --- a/Tests/OpenFeatureTests/ProviderEventTests.swift +++ b/Tests/OpenFeatureTests/ProviderEventTests.swift @@ -14,21 +14,21 @@ final class ProviderEventTests: XCTestCase { let api = OpenFeatureAPI() let expectation = XCTestExpectation(description: "Error") api - .observe() - .sink { event in - switch event { - case .error(let details): - if let details { - XCTAssertEqual(details.message, "Mock error") - } else { - XCTFail("Expected non-nil details") + .observe() + .sink { event in + switch event { + case .error(let details): + if let details { + XCTAssertEqual(details.message, "Mock error") + } else { + XCTFail("Expected non-nil details") + } + expectation.fulfill() + default: + break } - expectation.fulfill() - default: - break } - } - .store(in: &cancellables) + .store(in: &cancellables) api.setProvider(provider: provider) wait(for: [expectation], timeout: 5) cancellables.removeAll() @@ -44,16 +44,16 @@ final class ProviderEventTests: XCTestCase { let api = OpenFeatureAPI() let readyExpectation = XCTestExpectation(description: "Ready") api - .observe() - .sink { event in - switch event { - case .ready: - readyExpectation.fulfill() - default: - break + .observe() + .sink { event in + switch event { + case .ready: + readyExpectation.fulfill() + default: + break + } } - } - .store(in: &cancellables) + .store(in: &cancellables) api.setProvider(provider: provider) wait(for: [readyExpectation], timeout: 5) @@ -61,14 +61,14 @@ final class ProviderEventTests: XCTestCase { var receivedEvents: [ProviderEvent] = [] let mockEvents = [mockReady] api - .observe() - .sink { event in - if let event { - receivedEvents.append(event) + .observe() + .sink { event in + if let event { + receivedEvents.append(event) + } + receivedEvents.count == mockEvents.count ? eventsExpectation.fulfill() : nil } - receivedEvents.count == mockEvents.count ? eventsExpectation.fulfill() : nil - } - .store(in: &cancellables) + .store(in: &cancellables) mockEvents.forEach { event in mockEventHandler.send(event) } From cecbc300e9e9478aa34b09584a805069f43ec90b Mon Sep 17 00:00:00 2001 From: Mael RB Date: Thu, 18 Sep 2025 08:42:59 +0200 Subject: [PATCH 5/5] Fix mergeContextMaps method Signed-off-by: Mael RB --- .../ImmutableTrackingEventDetails.swift | 17 +++++++----- Sources/OpenFeature/OpenFeatureClient.swift | 26 ++++++------------- .../OpenFeatureClientTests.swift | 9 +++++++ 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/Sources/OpenFeature/ImmutableTrackingEventDetails.swift b/Sources/OpenFeature/ImmutableTrackingEventDetails.swift index 124a5ce..d766296 100644 --- a/Sources/OpenFeature/ImmutableTrackingEventDetails.swift +++ b/Sources/OpenFeature/ImmutableTrackingEventDetails.swift @@ -44,22 +44,25 @@ extension ImmutableTrackingEventDetails { var newAttributes = structure.asMap() newAttributes[key] = value return ImmutableTrackingEventDetails( - value: self.value, structure: ImmutableStructure(attributes: newAttributes)) + value: self.value, + structure: ImmutableStructure(attributes: newAttributes) + ) } public func withAttributes(_ attributes: [String: Value]) -> ImmutableTrackingEventDetails { - var newAttributes = structure.asMap() - for (key, value) in attributes { - newAttributes[key] = value - } + let newAttributes = structure.asMap().merging(attributes) { (_, new) in new } return ImmutableTrackingEventDetails( - value: self.value, structure: ImmutableStructure(attributes: newAttributes)) + value: self.value, + structure: ImmutableStructure(attributes: newAttributes) + ) } public func withoutAttribute(key: String) -> ImmutableTrackingEventDetails { var newAttributes = structure.asMap() newAttributes.removeValue(forKey: key) return ImmutableTrackingEventDetails( - value: self.value, structure: ImmutableStructure(attributes: newAttributes)) + value: self.value, + structure: ImmutableStructure(attributes: newAttributes) + ) } } diff --git a/Sources/OpenFeature/OpenFeatureClient.swift b/Sources/OpenFeature/OpenFeatureClient.swift index 35af0d5..b6507bf 100644 --- a/Sources/OpenFeature/OpenFeatureClient.swift +++ b/Sources/OpenFeature/OpenFeatureClient.swift @@ -263,24 +263,14 @@ extension OpenFeatureClient { } private func mergeContextMaps(_ contexts: (any EvaluationContext)?...) -> (any EvaluationContext)? { - guard !contexts.isEmpty else { return nil } - var merged: (any EvaluationContext)? = nil - for context in contexts { - guard let context else { continue } - if let _merged = merged { - let immutableContext = ImmutableContext( - targetingKey: _merged.getTargetingKey(), - structure: ImmutableStructure(attributes: _merged.asMap()) - ) - merged = - immutableContext - .withTargetingKey(context.getTargetingKey()) - .withAttributes(context.asMap()) - } else { - merged = context - } - } + let validContexts = contexts.compactMap { $0 } + guard !validContexts.isEmpty else { return nil } - return merged + return validContexts.reduce(ImmutableContext()) { merged, next in + let newTargetingKey = next.getTargetingKey() + let targetingKey = newTargetingKey.isEmpty ? merged.getTargetingKey() : newTargetingKey + let attributes = merged.asMap().merging(next.asMap()) { _, newKey in newKey } + return ImmutableContext(targetingKey: targetingKey, structure: ImmutableStructure(attributes: attributes)) + } } } diff --git a/Tests/OpenFeatureTests/OpenFeatureClientTests.swift b/Tests/OpenFeatureTests/OpenFeatureClientTests.swift index 0ad0468..59cb4fc 100644 --- a/Tests/OpenFeatureTests/OpenFeatureClientTests.swift +++ b/Tests/OpenFeatureTests/OpenFeatureClientTests.swift @@ -78,4 +78,13 @@ final class OpenFeatureClientTests: XCTestCase { XCTAssertEqual(result?.getValue(key: "num"), .integer(1)) XCTAssertEqual(result?.getValue(key: "string"), .string("test")) } + + func testMergeEvaluationContext_ApiContextAndInvocationContextWithEmptyKey_ThenTargetingKeyNotOverriden() async { + let client = OpenFeatureClient(openFeatureApi: OpenFeatureAPI.shared, name: nil, version: nil) + let apiContext = ImmutableContext(targetingKey: "api") + let invocationContext = ImmutableContext(targetingKey: "") + await OpenFeatureAPI.shared.setEvaluationContextAndWait(evaluationContext: apiContext) + let result = client.mergeEvaluationContext(invocationContext) + XCTAssertEqual(result?.getTargetingKey(), "api") + } }