diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml index c6de095..4efffcb 100644 --- a/.github/workflows/sdk-spec.yml +++ b/.github/workflows/sdk-spec.yml @@ -11,7 +11,7 @@ permissions: jobs: sdk-spec: # Keep the ref in `uses:` and `spec-ref` in sync. - uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.4.0 + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.7.0 with: - spec-ref: v0.4.0 + spec-ref: v0.7.0 language: swift diff --git a/FlagsmithClient/Classes/Experiment.swift b/FlagsmithClient/Classes/Experiment.swift new file mode 100644 index 0000000..844e13b --- /dev/null +++ b/FlagsmithClient/Classes/Experiment.swift @@ -0,0 +1,22 @@ +// +// Experiment.swift +// FlagsmithClient +// + +import Foundation + +/** + An `Experiment` is the running experiment a `Flag` was evaluated under, for an identity. + */ +public struct Experiment: Codable, Sendable, Equatable { + enum CodingKeys: String, CodingKey { + case id + case name + case inExperiment = "in_experiment" + } + + public let id: Int + public let name: String + /// Whether the identity is enrolled in the experiment. + public let inExperiment: Bool +} diff --git a/FlagsmithClient/Classes/Flag.swift b/FlagsmithClient/Classes/Flag.swift index dc2f99b..6b91815 100644 --- a/FlagsmithClient/Classes/Flag.swift +++ b/FlagsmithClient/Classes/Flag.swift @@ -15,11 +15,21 @@ public struct Flag: Codable, Sendable, Equatable { case feature case value = "feature_state_value" case enabled + case variant + case metadata + } + + private struct Metadata: Codable { + let experiment: Experiment? } public let feature: Feature public let value: TypedValue public let enabled: Bool + /// The key of the multivariate option served to the identity, if any. + public let variant: String? + /// The running experiment the flag was evaluated under, for an identity. + public let experiment: Experiment? public init(featureName: String, boolValue: Bool, enabled: Bool, featureType: String? = nil, featureDescription: String? = nil) @@ -62,6 +72,17 @@ public struct Flag: Codable, Sendable, Equatable { feature = Feature(name: featureName, type: featureType, description: featureDescription) self.value = value self.enabled = enabled + variant = nil + experiment = nil + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + feature = try container.decode(Feature.self, forKey: .feature) + value = try container.decode(TypedValue.self, forKey: .value) + enabled = try container.decode(Bool.self, forKey: .enabled) + variant = try container.decodeIfPresent(String.self, forKey: .variant) + experiment = (try? container.decodeIfPresent(Metadata.self, forKey: .metadata))?.experiment } public func encode(to encoder: any Encoder) throws { @@ -69,6 +90,10 @@ public struct Flag: Codable, Sendable, Equatable { try container.encode(feature, forKey: .feature) try container.encode(value, forKey: .value) try container.encode(enabled, forKey: .enabled) + try container.encodeIfPresent(variant, forKey: .variant) + if let experiment = experiment { + try container.encode(Metadata(experiment: experiment), forKey: .metadata) + } } public static func == (lhs: Flag, rhs: Flag) -> Bool { diff --git a/FlagsmithClient/Classes/Flagsmith+Concurrency.swift b/FlagsmithClient/Classes/Flagsmith+Concurrency.swift index 53e71ee..81f5c31 100644 --- a/FlagsmithClient/Classes/Flagsmith+Concurrency.swift +++ b/FlagsmithClient/Classes/Flagsmith+Concurrency.swift @@ -100,6 +100,28 @@ public extension Flagsmith { } } + /// Get a feature flag for an identity, and record that the identity was exposed to it + /// + /// - Parameters: + /// - id: ID of the feature + /// - identity: ID of the user + /// - traits: Traits of the user (optional) + /// - returns: The Flag, if the feature exists + func getExperimentFlag(withID id: String, forIdentity identity: String, traits: [Trait]? = nil) async throws -> Flag? { + try await withCheckedThrowingContinuation { continuation in + getExperimentFlag(withID: id, forIdentity: identity, traits: traits) { result in + continuation.resume(with: result) + } + } + } + + /// Send the tracked events now, and wait until they have been sent, or dropped after failing to send + func flushEvents() async { + await withCheckedContinuation { continuation in + flushEvents { continuation.resume() } + } + } + /// Get all user traits for provided identity. Optionally filter results with a list of keys /// /// - Parameters: diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index 0b63dca..1b25608 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -20,6 +20,7 @@ public final class Flagsmith: @unchecked Sendable { private let apiManager: APIManager private let sseManager: SSEManager private let analytics: FlagsmithAnalytics + private let events: FlagsmithEvents // The last time we got flags via the API private var lastUpdatedAt: Double = 0.0 @@ -90,6 +91,28 @@ public final class Flagsmith: @unchecked Sendable { set { analytics.flushPeriod = newValue } } + /// Are experimentation events sent? Defaults to `false`. + /// + /// Events are tracked with `trackEvent`, `trackExposureEvent` and `getExperimentFlag`. + public var enableEvents: Bool { + get { events.enabled } + set { events.enabled = newValue } + } + + /// Base `URL` of Flagsmith's events API. + /// + /// The default implementation uses: `https://events.api.flagsmith.com/`. + public var eventsBaseURL: URL { + get { events.baseURL } + set { events.baseURL = newValue } + } + + /// How often to send the experimentation events, in seconds + public var eventsFlushPeriod: Int { + get { events.flushPeriod } + set { events.flushPeriod = newValue } + } + /// Default flags to fall back on if an API call fails private var _defaultFlags: [Flag] = [] public var defaultFlags: [Flag] { @@ -120,6 +143,7 @@ public final class Flagsmith: @unchecked Sendable { apiManager = APIManager() sseManager = SSEManager() analytics = FlagsmithAnalytics(apiManager: apiManager) + events = FlagsmithEvents(apiManager: apiManager) } /// Get all feature flags (flags and remote config) optionally for a specific identity @@ -430,6 +454,84 @@ extension Flagsmith { } } + /// Get a feature flag for an identity, and record that the identity was exposed to it + /// + /// When events are enabled, and the identity is enrolled in a running experiment on the feature, + /// this tracks a `$flag_exposure` event with the variant the identity was served. + /// + /// - Parameters: + /// - id: ID of the feature + /// - identity: ID of the user + /// - traits: Traits of the user (optional) + /// - completion: Closure with Result which contains the Flag, if the feature exists, in case of success or Error in case of failure + public func getExperimentFlag(withID id: String, + forIdentity identity: String, + traits: [Trait]? = nil, + completion: @Sendable @escaping (Result) -> Void) + { + analytics.trackEvent(flagName: id) + getFeatureFlags(forIdentity: identity, traits: traits) { result in + completion(result.map { flags in + let flag = flags.first(where: { $0.feature.name == id }) + if let flag = flag { + self.events.trackExposure(to: flag, identifier: identity, traits: traits.map(self.traitValues)) + } + return flag + }) + } + } + + /// Track an event, e.g. a conversion, when events are enabled + /// + /// - Parameters: + /// - event: Name of the event. Names starting with `$` are reserved for Flagsmith. + /// - identity: ID of the user (optional) + /// - value: Value of the event, e.g. a purchase amount (optional) + /// - traits: Traits of the user (optional) + /// - metadata: Additional data about the event (optional) + public func trackEvent(_ event: String, + forIdentity identity: String? = nil, + value: TypedValue? = nil, + traits: [Trait]? = nil, + metadata: [String: TypedValue]? = nil) throws + { + guard !event.hasPrefix("$") else { + throw FlagsmithError.invalidArgument("Event names starting with \"$\" are reserved: \(event)") + } + events.trackEvent(event, identifier: identity, value: value, traits: traits.map(traitValues), + metadata: metadata) + } + + /// Track that a user was exposed to a variant of a feature, as a `$flag_exposure` event, when events are enabled + /// + /// - Parameters: + /// - featureName: Name of the feature + /// - identity: ID of the user + /// - value: The variant the user was exposed to (optional) + /// - traits: Traits of the user (optional) + /// - metadata: Additional data about the exposure, e.g. `experiment_id` (optional) + public func trackExposureEvent(forFeature featureName: String, + forIdentity identity: String, + value: TypedValue? = nil, + traits: [Trait]? = nil, + metadata: [String: TypedValue]? = nil) + { + events.trackExposureEvent(featureName: featureName, identifier: identity, value: value, + traits: traits.map(traitValues), metadata: metadata) + } + + /// Send the tracked events now + /// + /// - Parameters: + /// - completion: Closure called once the events have been sent, or dropped after failing to send (optional) + public func flushEvents(completion: (@Sendable () -> Void)? = nil) { + events.flush(completion: completion) + } + + private func traitValues(_ traits: [Trait]) -> [String: TypedValue] { + Dictionary(traits.map { ($0.key, $0.typedValue) }) { _, last in last } + } + /// Get all user traits for provided identity. Optionally filter results with a list of keys /// /// - Parameters: diff --git a/FlagsmithClient/Classes/Internal/APIManager.swift b/FlagsmithClient/Classes/Internal/APIManager.swift index 38622fd..fc8702c 100644 --- a/FlagsmithClient/Classes/Internal/APIManager.swift +++ b/FlagsmithClient/Classes/Internal/APIManager.swift @@ -127,8 +127,11 @@ final class APIManager: NSObject, URLSessionDataDelegate, @unchecked Sendable { /// /// - parameters: /// - router: The path and parameters that should be requested. + /// - baseURL: The base URL to request the route from, instead of `self.baseURL`. /// - completion: Function block executed with the result of the request. - private func request(_ router: Router, completion: @Sendable @escaping (Result) -> Void) { + private func request(_ router: Router, baseURL: URL? = nil, + completion: @Sendable @escaping (Result) -> Void) + { guard let apiKey = apiKey, !apiKey.isEmpty else { completion(.failure(FlagsmithError.apiKey)) return @@ -136,7 +139,7 @@ final class APIManager: NSObject, URLSessionDataDelegate, @unchecked Sendable { var request: URLRequest do { - request = try router.request(baseUrl: baseURL, apiKey: apiKey) + request = try router.request(baseUrl: baseURL ?? self.baseURL, apiKey: apiKey) } catch { completion(.failure(error)) return @@ -170,9 +173,12 @@ final class APIManager: NSObject, URLSessionDataDelegate, @unchecked Sendable { /// /// - parameters: /// - router: The path and parameters that should be requested. + /// - baseURL: The base URL to request the route from, instead of `self.baseURL`. /// - completion: Function block executed with the result of the request. - func request(_ router: Router, completion: @Sendable @escaping (Result) -> Void) { - request(router) { (result: Result) in + func request(_ router: Router, baseURL: URL? = nil, + completion: @Sendable @escaping (Result) -> Void) + { + request(router, baseURL: baseURL) { (result: Result) in switch result { case let .failure(error): completion(.failure(FlagsmithError(error))) diff --git a/FlagsmithClient/Classes/Internal/FlagsmithEvents.swift b/FlagsmithClient/Classes/Internal/FlagsmithEvents.swift new file mode 100644 index 0000000..c6ee5af --- /dev/null +++ b/FlagsmithClient/Classes/Internal/FlagsmithEvents.swift @@ -0,0 +1,211 @@ +// +// FlagsmithEvents.swift +// FlagsmithClient +// + +import Foundation + +/// An experimentation event, as sent to Flagsmith's events API. +struct Event: Encodable, Equatable, Sendable { + enum CodingKeys: String, CodingKey { + case event + case featureName = "feature_name" + case identifier + case value + case traits + case metadata + case timestamp + } + + let event: String + let featureName: String? + let identifier: String? + let value: String? + let traits: [String: TypedValue]? + let metadata: [String: TypedValue] + /// Milliseconds since the Unix epoch. + let timestamp: Int + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(event, forKey: .event) + try container.encode(featureName, forKey: .featureName) + try container.encode(identifier, forKey: .identifier) + try container.encode(value, forKey: .value) + try container.encode(traits, forKey: .traits) + try container.encode(metadata, forKey: .metadata) + try container.encode(timestamp, forKey: .timestamp) + } +} + +/// Batches experimentation events to Flagsmith's events API. +/// +/// Sends every `flushPeriod` seconds, once `maxBuffer` events are buffered, and on `flush`. +/// Exposures are deduplicated within a batch. A failed batch is retried once, then dropped. +final class FlagsmithEvents: @unchecked Sendable { + static let flagExposureEvent = "$flag_exposure" + + /// Sends a batch of events to the events API at a base URL. + typealias Send = @Sendable (_ events: [Event], _ baseURL: URL, + _ completion: @Sendable @escaping (Result) -> Void) -> Void + + private let send: Send + private let queue = DispatchQueue(label: "flagsmithEventsQueue", qos: .utility) + private let uploads = DispatchGroup() + + private var _enabled = false + /// Indicates if events are sent. Events tracked while disabled are dropped. + var enabled: Bool { + get { queue.sync { _enabled } } + set { + queue.sync { _enabled = newValue } + setupTimer() + } + } + + private var _baseURL = URL(string: "https://events.api.flagsmith.com/")! + /// Base URL of the events API. + var baseURL: URL { + get { queue.sync { _baseURL } } + set { queue.sync { _baseURL = newValue } } + } + + private var _flushPeriod: Int = 10 + /// How often buffered events are sent (in seconds). + var flushPeriod: Int { + get { queue.sync { _flushPeriod } } + set { + queue.sync { _flushPeriod = newValue } + setupTimer() + } + } + + let maxBuffer: Int + let retryDelay: TimeInterval + + private var buffer: [Event] = [] + private var exposureKeys: Set<[String?]> = [] + private var timer: (any DispatchSourceTimer)? + + init(maxBuffer: Int = 1000, retryDelay: TimeInterval = 1, send: @escaping Send) { + self.maxBuffer = maxBuffer + self.retryDelay = retryDelay + self.send = send + } + + convenience init(apiManager: APIManager) { + self.init { events, baseURL, completion in + apiManager.request(.postEvents(events: events), baseURL: baseURL, completion: completion) + } + } + + deinit { + timer?.cancel() + } + + func trackEvent(_ event: String, identifier: String?, value: TypedValue?, + traits: [String: TypedValue]?, metadata: [String: TypedValue]?) + { + record(Event(event: event, featureName: nil, identifier: identifier, value: Self.eventValue(value), + traits: traits, metadata: Self.eventMetadata(metadata), timestamp: Self.now())) + } + + func trackExposureEvent(featureName: String, identifier: String, value: TypedValue?, + traits: [String: TypedValue]?, metadata: [String: TypedValue]?) + { + record(Event(event: Self.flagExposureEvent, featureName: featureName, identifier: identifier, + value: Self.eventValue(value), traits: traits, metadata: Self.eventMetadata(metadata), + timestamp: Self.now())) + } + + /// Tracks the exposure of an identity to a flag, if the identity is enrolled in a running experiment on it. + func trackExposure(to flag: Flag, identifier: String, traits: [String: TypedValue]?) { + guard flag.enabled, let experiment = flag.experiment, experiment.inExperiment, let variant = flag.variant else { + return + } + trackExposureEvent(featureName: flag.feature.name, identifier: identifier, value: .string(variant), + traits: traits, metadata: ["experiment_id": .int(experiment.id)]) + } + + /// Sends the buffered events, then calls `completion` once every send in progress has finished. + func flush(completion: (@Sendable () -> Void)? = nil) { + queue.sync { sendBuffer() } + if let completion = completion { + uploads.notify(queue: .global(), execute: completion) + } + } + + private func record(_ event: Event) { + queue.sync { + guard _enabled else { return } + if event.event == Self.flagExposureEvent { + // The experiment is part of the key, so that a new experiment on the same flag and variant is recorded. + let key = [event.featureName, event.identifier, event.value, event.metadata["experiment_id"]?.description] + guard exposureKeys.insert(key).inserted else { return } + } + buffer.append(event) + if buffer.count >= maxBuffer { + sendBuffer() + } + } + } + + private static func eventValue(_ value: TypedValue?) -> String? { + value.flatMap { $0 == .null ? nil : $0.description } + } + + private static func eventMetadata(_ metadata: [String: TypedValue]?) -> [String: TypedValue] { + (metadata ?? [:]).merging(["sdk_version": .string(Router.sdkVersion)]) { _, sdkVersion in sdkVersion } + } + + /// Milliseconds since the Unix epoch. + private static func now() -> Int { + Int(Date().timeIntervalSince1970 * 1000) + } + + /// Must be called on `queue`. + private func sendBuffer() { + guard !buffer.isEmpty else { return } + let events = buffer + buffer = [] + exposureKeys = [] + uploads.enter() + upload(events, baseURL: _baseURL, isRetry: false) + } + + private func upload(_ events: [Event], baseURL: URL, isRetry: Bool) { + send(events, baseURL) { @Sendable [weak self] result in + guard let self = self else { return } + switch result { + case .success: + self.uploads.leave() + case let .failure(error) where !isRetry: + print("Flagsmith - Sending events failed, retrying: \(error.localizedDescription)") + self.queue.asyncAfter(deadline: .now() + self.retryDelay) { + self.upload(events, baseURL: baseURL, isRetry: true) + } + case let .failure(error): + print("Flagsmith - Sending events failed, dropping \(events.count) events: \(error.localizedDescription)") + self.uploads.leave() + } + } + } + + /// Invalidates and re-schedules the timer for sending events, while enabled. + private func setupTimer() { + queue.sync { + timer?.cancel() + timer = nil + guard _enabled, _flushPeriod > 0 else { + sendBuffer() + return + } + let period = DispatchTimeInterval.seconds(_flushPeriod) + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + period, repeating: period) + timer.setEventHandler { [weak self] in self?.sendBuffer() } + timer.resume() + self.timer = timer + } + } +} diff --git a/FlagsmithClient/Classes/Internal/Router.swift b/FlagsmithClient/Classes/Internal/Router.swift index ac2407e..ea0fbe1 100644 --- a/FlagsmithClient/Classes/Internal/Router.swift +++ b/FlagsmithClient/Classes/Internal/Router.swift @@ -12,7 +12,7 @@ import Foundation enum Router: Sendable { // x-release-please-start-version - private static let sdkVersion = "3.10.1" + static let sdkVersion = "3.10.1" // x-release-please-end static let userAgent = "flagsmith-swift-ios-sdk/\(sdkVersion)" @@ -26,12 +26,13 @@ enum Router: Sendable { case postTrait(trait: Trait, identity: String) case postTraits(identity: String, traits: [Trait], transient: Bool = false) case postAnalytics(events: [String: Int]) + case postEvents(events: [Event]) private var method: HTTPMethod { switch self { case .getFlags, .getIdentity: return .get - case .postTrait, .postTraits, .postAnalytics: + case .postTrait, .postTraits, .postAnalytics, .postEvents: return .post } } @@ -46,6 +47,8 @@ enum Router: Sendable { return "traits/" case .postAnalytics: return "analytics/flags/" + case .postEvents: + return "v1/events" } } @@ -74,6 +77,8 @@ enum Router: Sendable { return try encoder.encode(traitsWithIdentity) case let .postAnalytics(events): return try encoder.encode(events) + case let .postEvents(events): + return try encoder.encode(["events": events]) } } diff --git a/FlagsmithClient/Tests/FlagTests.swift b/FlagsmithClient/Tests/FlagTests.swift index d60869b..d77dbc0 100644 --- a/FlagsmithClient/Tests/FlagTests.swift +++ b/FlagsmithClient/Tests/FlagTests.swift @@ -43,4 +43,43 @@ final class FlagTests: FlagsmithClientTestCase { XCTAssertEqual(disabledFlag.feature.name, "realtime_diagnostics_level") XCTAssertEqual(disabledFlag.value, .string("debug")) } + + func testDecodeFlagServedInExperiment() throws { + // Given + let json = """ + { + "feature": { "name": "banner" }, + "feature_state_value": "hello treatment", + "enabled": true, + "variant": "treatment", + "metadata": { "experiment": { "id": 1, "name": "banner experiment", "in_experiment": true } } + } + """ + + // When + let flag = try decoder.decode(Flag.self, from: Data(json.utf8)) + + // Then + XCTAssertEqual(flag.value, .string("hello treatment")) + XCTAssertEqual(flag.variant, "treatment") + XCTAssertEqual(flag.experiment, Experiment(id: 1, name: "banner experiment", inExperiment: true)) + XCTAssertEqual(try decoder.decode(Flag.self, from: encoder.encode(flag)).experiment, flag.experiment) + } + + func testDecodeFlagWithoutExperiment() throws { + // Given + let json = """ + [ + { "feature": { "name": "banner" }, "feature_state_value": "hello", "enabled": true }, + { "feature": { "name": "theme" }, "feature_state_value": "dark", "enabled": true, "metadata": { "experiment": "unknown" } } + ] + """ + + // When + let flags = try decoder.decode([Flag].self, from: Data(json.utf8)) + + // Then + XCTAssertEqual(flags.map(\.variant), [nil, nil]) + XCTAssertEqual(flags.map(\.experiment), [nil, nil]) + } } diff --git a/FlagsmithClient/Tests/FlagsmithEventsTests.swift b/FlagsmithClient/Tests/FlagsmithEventsTests.swift new file mode 100644 index 0000000..574f632 --- /dev/null +++ b/FlagsmithClient/Tests/FlagsmithEventsTests.swift @@ -0,0 +1,244 @@ +// +// FlagsmithEventsTests.swift +// FlagsmithClientTests +// + +@testable import FlagsmithClient +import XCTest + +/// Records the batches sent to the events API, and answers each with `result`. +final class EventsAPIMock: @unchecked Sendable { + private let lock = NSLock() + private var _batches: [(events: [Event], baseURL: URL)] = [] + var batches: [(events: [Event], baseURL: URL)] { + lock.lock() + defer { lock.unlock() } + return _batches + } + var result: Result = .success(()) + var onSend: (@Sendable () -> Void)? + + func send(_ events: [Event], _ baseURL: URL, _ completion: @Sendable @escaping (Result) -> Void) { + lock.lock() + _batches.append((events, baseURL)) + lock.unlock() + onSend?() + completion(result) + } +} + +final class FlagsmithEventsTests: FlagsmithClientTestCase { + let api = EventsAPIMock() + + func makeEvents(maxBuffer: Int = 1000) -> FlagsmithEvents { + let events = FlagsmithEvents(maxBuffer: maxBuffer, retryDelay: 0) { [api] in api.send($0, $1, $2) } + events.enabled = true + return events + } + + func flush(_ events: FlagsmithEvents) { + let flushed = expectation(description: "Events flushed") + events.flush { flushed.fulfill() } + wait(for: [flushed], timeout: 5) + } + + func testTrackedEventIsSentWithItsIdentityValueAndMetadata() throws { + // Given + let events = makeEvents() + + // When + events.trackEvent("checkout_completed", identifier: "user_123", value: .int(42), + traits: ["plan": .string("premium")], metadata: ["currency": .string("GBP")]) + flush(events) + + // Then + let batch = try XCTUnwrap(api.batches.first) + XCTAssertEqual(api.batches.count, 1) + XCTAssertEqual(batch.baseURL.absoluteString, "https://events.api.flagsmith.com/") + let event = try XCTUnwrap(batch.events.first) + XCTAssertEqual(batch.events.count, 1) + XCTAssertEqual(event.event, "checkout_completed") + XCTAssertNil(event.featureName) + XCTAssertEqual(event.identifier, "user_123") + XCTAssertEqual(event.value, "42") + XCTAssertEqual(event.traits, ["plan": .string("premium")]) + XCTAssertEqual(event.metadata, ["currency": .string("GBP"), "sdk_version": .string(Router.sdkVersion)]) + } + + func testEventsAreSentToTheConfiguredEventsAPI() throws { + // Given + let events = makeEvents() + events.baseURL = try XCTUnwrap(URL(string: "http://events:8080/")) + + // When + events.trackEvent("signed_up", identifier: nil, value: nil, traits: nil, metadata: nil) + flush(events) + + // Then + XCTAssertEqual(api.batches.map(\.baseURL.absoluteString), ["http://events:8080/"]) + XCTAssertEqual(api.batches.first?.events.first?.value, nil) + } + + func testEventsTrackedWhileDisabledAreNotSent() { + // Given + let events = makeEvents() + events.enabled = false + + // When + events.trackEvent("checkout_completed", identifier: "user_123", value: nil, traits: nil, metadata: nil) + events.trackExposureEvent(featureName: "banner", identifier: "user_123", value: nil, traits: nil, metadata: nil) + flush(events) + + // Then + XCTAssertTrue(api.batches.isEmpty) + } + + func testExposureIsSentAsAFlagExposureEvent() throws { + // Given + let events = makeEvents() + + // When + events.trackExposureEvent(featureName: "banner", identifier: "user_123", value: .string("control"), + traits: nil, metadata: ["experiment_id": .int(1)]) + flush(events) + + // Then + let event = try XCTUnwrap(api.batches.first?.events.first) + XCTAssertEqual(event.event, "$flag_exposure") + XCTAssertEqual(event.featureName, "banner") + XCTAssertEqual(event.identifier, "user_123") + XCTAssertEqual(event.value, "control") + XCTAssertEqual(event.metadata, ["experiment_id": .int(1), "sdk_version": .string(Router.sdkVersion)]) + } + + func testRepeatedExposureIsSentOncePerBatch() { + // Given + let events = makeEvents() + + // When + for experimentID in [1, 1, 2] { + events.trackExposureEvent(featureName: "banner", identifier: "user_123", value: .string("control"), + traits: nil, metadata: ["experiment_id": .int(experimentID)]) + } + flush(events) + events.trackExposureEvent(featureName: "banner", identifier: "user_123", value: .string("control"), + traits: nil, metadata: ["experiment_id": .int(1)]) + flush(events) + + // Then + XCTAssertEqual(api.batches.map { $0.events.map { $0.metadata["experiment_id"] } }, + [[.int(1), .int(2)], [.int(1)]]) + } + + func testExperimentFlagExposesTheIdentityToTheVariantItWasServed() throws { + // Given + let events = makeEvents() + let flag = try decoder.decode(Flag.self, from: Data(""" + { + "feature": { "name": "banner" }, + "feature_state_value": "hello treatment", + "enabled": true, + "variant": "treatment", + "metadata": { "experiment": { "id": 7, "name": "banner experiment", "in_experiment": true } } + } + """.utf8)) + + // When + events.trackExposure(to: flag, identifier: "user_123", traits: ["plan": .string("premium")]) + flush(events) + + // Then + let event = try XCTUnwrap(api.batches.first?.events.first) + XCTAssertEqual(event.event, "$flag_exposure") + XCTAssertEqual(event.featureName, "banner") + XCTAssertEqual(event.identifier, "user_123") + XCTAssertEqual(event.value, "treatment") + XCTAssertEqual(event.traits, ["plan": .string("premium")]) + XCTAssertEqual(event.metadata["experiment_id"], .int(7)) + } + + func testExperimentFlagDoesNotExposeAnIdentityOutsideARunningExperiment() throws { + // Given + let events = makeEvents() + let flags = try decoder.decode([Flag].self, from: Data(""" + [ + { "feature": { "name": "no_experiment" }, "feature_state_value": "hello", "enabled": true }, + { + "feature": { "name": "not_enrolled" }, "feature_state_value": "hello", "enabled": true, + "metadata": { "experiment": { "id": 1, "name": "experiment", "in_experiment": false } } + }, + { + "feature": { "name": "disabled" }, "feature_state_value": "hello", "enabled": false, "variant": "treatment", + "metadata": { "experiment": { "id": 2, "name": "experiment", "in_experiment": true } } + }, + { + "feature": { "name": "no_variant" }, "feature_state_value": "hello", "enabled": true, + "metadata": { "experiment": { "id": 3, "name": "experiment", "in_experiment": true } } + } + ] + """.utf8)) + + // When + for flag in flags { + events.trackExposure(to: flag, identifier: "user_123", traits: nil) + } + flush(events) + + // Then + XCTAssertTrue(api.batches.isEmpty) + } + + func testEventsAreSentOnceTheBufferIsFull() { + // Given + let events = makeEvents(maxBuffer: 2) + let sent = expectation(description: "Events sent") + api.onSend = { sent.fulfill() } + + // When + events.trackEvent("one", identifier: nil, value: nil, traits: nil, metadata: nil) + events.trackEvent("two", identifier: nil, value: nil, traits: nil, metadata: nil) + + // Then + wait(for: [sent], timeout: 5) + XCTAssertEqual(api.batches.map { $0.events.map(\.event) }, [["one", "two"]]) + } + + func testEventsAreSentEveryFlushPeriod() { + // Given + let events = makeEvents() + events.flushPeriod = 1 + let sent = expectation(description: "Events sent") + api.onSend = { sent.fulfill() } + + // When + events.trackEvent("checkout_completed", identifier: nil, value: nil, traits: nil, metadata: nil) + + // Then + wait(for: [sent], timeout: 5) + XCTAssertEqual(api.batches.map { $0.events.map(\.event) }, [["checkout_completed"]]) + } + + func testFailedEventsAreRetriedOnceThenDropped() { + // Given + let events = makeEvents() + api.result = .failure(FlagsmithError.statusCode(503)) + + // When + events.trackEvent("checkout_completed", identifier: nil, value: nil, traits: nil, metadata: nil) + flush(events) + flush(events) + + // Then + XCTAssertEqual(api.batches.map { $0.events.map(\.event) }, [["checkout_completed"], ["checkout_completed"]]) + } + + func testTrackingAnEventWithAReservedNameIsRejected() { + // Given / When + XCTAssertThrowsError(try Flagsmith.shared.trackEvent("$flag_exposure")) { error in + // Then + guard case FlagsmithError.invalidArgument = error else { + return XCTFail("Unexpected error: \(error)") + } + } + } +} diff --git a/FlagsmithClient/Tests/RouterTests.swift b/FlagsmithClient/Tests/RouterTests.swift index 1c34c5d..e104501 100644 --- a/FlagsmithClient/Tests/RouterTests.swift +++ b/FlagsmithClient/Tests/RouterTests.swift @@ -136,4 +136,35 @@ final class RouterTests: FlagsmithClientTestCase { """.json(using: .utf8) XCTAssertEqual(try request.httpBody.json(), json) } + + func testPostEventsRequest() throws { + // Given + let url = try XCTUnwrap(URL(string: "https://events.api.flagsmith.com/")) + let event = Event(event: "checkout_completed", featureName: nil, identifier: "user_123", value: "42", + traits: nil, metadata: ["currency": .string("GBP")], timestamp: 1_700_000_000_000) + + // When + let request = try Router.postEvents(events: [event]).request(baseUrl: url, apiKey: apiKey, using: encoder) + + // Then + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.absoluteString, "https://events.api.flagsmith.com/v1/events") + XCTAssertEqual(request.allHTTPHeaderFields?["X-Environment-Key"], apiKey) + let json = try """ + { + "events" : [ + { + "event" : "checkout_completed", + "feature_name" : null, + "identifier" : "user_123", + "value" : "42", + "traits" : null, + "metadata" : { "currency" : "GBP" }, + "timestamp" : 1700000000000 + } + ] + } + """.json(using: .utf8) + XCTAssertEqual(try request.httpBody.json(), json) + } } diff --git a/sdk-spec/steps.swift b/sdk-spec/steps.swift index 2d83709..90b6690 100644 --- a/sdk-spec/steps.swift +++ b/sdk-spec/steps.swift @@ -8,15 +8,42 @@ import Foundation public struct Client { let environmentKey: String let apiURL: URL + var eventsURL: URL? var flagsmith: Flagsmith { let flagsmith = Flagsmith.shared flagsmith.apiKey = environmentKey flagsmith.baseURL = apiURL + if let eventsURL { + flagsmith.eventsBaseURL = eventsURL + } + if flagsmith.enableEvents != (eventsURL != nil) { + flagsmith.enableEvents = eventsURL != nil + } return flagsmith } } +public struct StepArgumentError: Error { + let description: String +} + +/// Wraps a step function of five parameters for the harness, whose own `step` overloads take up to four: the +/// client, then each argument decoded to its parameter type. +public func step( + _ function: @escaping (StepClient, A, B, C, D) async throws -> R +) -> (Any?, [Argument]) async throws -> Any { + { client, args in + guard let client = client as? StepClient, args.count == 4 else { + throw StepArgumentError(description: "The step function takes a \(StepClient.self) and 4 arguments") + } + func decode(_ arg: Argument) throws -> T { + try JSONDecoder().decode(T.self, from: JSONEncoder().encode(arg)) + } + return try await function(client, decode(args[0]), decode(args[1]), decode(args[2]), decode(args[3])) + } +} + public struct SpecFlag: Encodable { let feature: String let enabled: Bool @@ -35,6 +62,14 @@ public func createClient(environmentKey: String, apiUrl: String) throws -> Clien return Client(environmentKey: environmentKey, apiURL: apiURL) } +// Create the SDK client with events enabled +public func createClientWithEvents(environmentKey: String, apiUrl: String, eventsApiUrl: String) throws -> Client { + var client = try createClient(environmentKey: environmentKey, apiUrl: apiUrl) + guard let eventsURL = URL(string: eventsApiUrl) else { throw FlagsmithError.apiURL(eventsApiUrl) } + client.eventsURL = eventsURL + return client +} + // When the application gets the environment flags public func getEnvironmentFlags(_ client: Client) async throws -> [SpecFlag] { try await client.flagsmith.getFeatureFlags().map(SpecFlag.init) @@ -48,3 +83,22 @@ public func getIdentityFlags(_ client: Client, identifier: String, traits: [Stri } return flags.map(SpecFlag.init) } + +// When the application gets the experiment flag "{feature}" for identity "{identifier}" with traits: +public func getExperimentFlag(_ client: Client, feature: String, identifier: String, + traits: [String: TypedValue]) async throws -> [SpecFlag] +{ + let traits = traits.map { Trait(key: $0.key, value: $0.value) } + let flag = try await client.flagsmith.getExperimentFlag(withID: feature, forIdentity: identifier, traits: traits) + return flag.map { [SpecFlag($0)] } ?? [] +} + +// When the application tracks event "{event}" for identity "{identifier}" with value {value} and metadata: +public func trackEvent(_ client: Client, event: String, identifier: String, value: TypedValue, metadata: [String: TypedValue]) throws { + try client.flagsmith.trackEvent(event, forIdentity: identifier, value: value, metadata: metadata) +} + +// When the application tracks an exposure of feature "{feature}" for identity "{identifier}" with value {value} and metadata: +public func trackExposureEvent(_ client: Client, feature: String, identifier: String, value: TypedValue, metadata: [String: TypedValue]) { + client.flagsmith.trackExposureEvent(forFeature: feature, forIdentity: identifier, value: value, metadata: metadata) +}