Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion Sources/OpenFeature/Client.swift
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
68 changes: 68 additions & 0 deletions Sources/OpenFeature/ImmutableTrackingEventDetails.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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<String> {
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 {
let newAttributes = structure.asMap().merging(attributes) { (_, new) in new }

Check warning on line 53 in Sources/OpenFeature/ImmutableTrackingEventDetails.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Unneeded Parentheses in Closure Argument Violation: Parentheses are not needed when declaring closure arguments (unneeded_parentheses_in_closure_argument)
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)
)
}
}
54 changes: 54 additions & 0 deletions Sources/OpenFeature/OpenFeatureClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,57 @@ 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)? {
let validContexts = contexts.compactMap { $0 }
guard !validContexts.isEmpty else { return nil }

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))
}
}
}
13 changes: 13 additions & 0 deletions Sources/OpenFeature/Provider/FeatureProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

extension FeatureProvider {
public func track(key: String, context: (any EvaluationContext)?, details: (any TrackingEventDetails)?) throws {
// Default to no-op
}
}
10 changes: 6 additions & 4 deletions Sources/OpenFeature/Provider/MultiProvider/MultiProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: ", ")
}
}
}
24 changes: 24 additions & 0 deletions Sources/OpenFeature/Tracking.swift
Original file line number Diff line number Diff line change
@@ -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)
}
8 changes: 8 additions & 0 deletions Sources/OpenFeature/TrackingEventDetails.swift
Original file line number Diff line number Diff line change
@@ -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?
}
35 changes: 35 additions & 0 deletions Tests/OpenFeatureTests/DeveloperExperienceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -246,4 +246,39 @@

observer.cancel()
}

func testTrack() async {
var trackCalled = false
let onTrack = { (key: String, _: EvaluationContext?, details: TrackingEventDetails?) -> Void in

Check warning on line 252 in Tests/OpenFeatureTests/DeveloperExperienceTests.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Redundant Void Return Violation: Returning Void in a function declaration is redundant (redundant_void_return)
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

Check warning on line 266 in Tests/OpenFeatureTests/DeveloperExperienceTests.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Implicit Optional Initialization Violation: Optional should be implicitly initialized without nil (implicit_optional_initialization)
let onTrack = { (_: String, evaluationContext: EvaluationContext?, _: TrackingEventDetails?) -> Void in

Check warning on line 267 in Tests/OpenFeatureTests/DeveloperExperienceTests.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Redundant Void Return Violation: Returning Void in a function declaration is redundant (redundant_void_return)
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)]),

Check warning on line 276 in Tests/OpenFeatureTests/DeveloperExperienceTests.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Multiline Arguments Violation: Arguments should be either on the same line, or one per line (multiline_arguments)
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))
}
}
17 changes: 14 additions & 3 deletions Tests/OpenFeatureTests/Helpers/MockProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class MockProvider: FeatureProvider {
private let _getDoubleEvaluation: (String, Double, EvaluationContext?) throws -> ProviderEvaluation<Double>
private let _getObjectEvaluation: (String, Value, EvaluationContext?) throws -> ProviderEvaluation<Value>
private let _observe: () -> AnyPublisher<ProviderEvent?, Never>
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(
Expand All @@ -42,7 +43,7 @@ class MockProvider: FeatureProvider {
String,
Int64,
EvaluationContext?
) throws -> ProviderEvaluation<Int64> = { _, fallback, _ in
) throws -> ProviderEvaluation<Int64> = { _, fallback, _ in
return ProviderEvaluation(value: fallback, flagMetadata: [:])
},
getDoubleEvaluation: @escaping (
Expand All @@ -56,10 +57,15 @@ class MockProvider: FeatureProvider {
String,
Value,
EvaluationContext?
) throws -> ProviderEvaluation<Value> = { _, fallback, _ in
) throws -> ProviderEvaluation<Value> = { _, fallback, _ in
return ProviderEvaluation(value: fallback, flagMetadata: [:])
},
observe: @escaping () -> AnyPublisher<ProviderEvent?, Never> = { Just(nil).eraseToAnyPublisher() }
observe: @escaping () -> AnyPublisher<ProviderEvent?, Never> = { Just(nil).eraseToAnyPublisher() },
track: @escaping (
String,
EvaluationContext?,
TrackingEventDetails?
) throws -> Void = { _, _, _ in }
) {
self._onContextSet = onContextSet
self._initialize = initialize
Expand All @@ -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 {
Expand Down Expand Up @@ -112,6 +119,10 @@ class MockProvider: FeatureProvider {
func observe() -> AnyPublisher<ProviderEvent?, Never> {
_observe()
}

func track(key: String, context: (any EvaluationContext)?, details: (any TrackingEventDetails)?) throws {
try _track(key, context, details)
}
}

extension MockProvider {
Expand Down
Loading