From 4bfbbcc9625452aafa92c59a695ff6f8e775b251 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Tue, 15 Apr 2025 18:01:51 -0700 Subject: [PATCH 01/20] initial feature flag support --- Mixpanel.xcodeproj/project.pbxproj | 20 ++ Sources/FeatureFlags.swift | 445 +++++++++++++++++++++++++++++ Sources/Mixpanel.swift | 27 ++ Sources/MixpanelConfig.swift | 50 ++++ Sources/MixpanelInstance.swift | 38 ++- Sources/MixpanelPersistence.swift | 31 ++ 6 files changed, 610 insertions(+), 1 deletion(-) create mode 100644 Sources/FeatureFlags.swift create mode 100644 Sources/MixpanelConfig.swift diff --git a/Mixpanel.xcodeproj/project.pbxproj b/Mixpanel.xcodeproj/project.pbxproj index bda18251..33fcc1b3 100644 --- a/Mixpanel.xcodeproj/project.pbxproj +++ b/Mixpanel.xcodeproj/project.pbxproj @@ -7,6 +7,14 @@ objects = { /* Begin PBXBuildFile section */ + 171E4C122DAF108400B7CB11 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C112DAF108400B7CB11 /* FeatureFlags.swift */; }; + 171E4C132DAF108400B7CB11 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C112DAF108400B7CB11 /* FeatureFlags.swift */; }; + 171E4C142DAF108400B7CB11 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C112DAF108400B7CB11 /* FeatureFlags.swift */; }; + 171E4C152DAF108400B7CB11 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C112DAF108400B7CB11 /* FeatureFlags.swift */; }; + 171E4C172DAF2B3100B7CB11 /* MixpanelConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C162DAF2B3100B7CB11 /* MixpanelConfig.swift */; }; + 171E4C182DAF2B3100B7CB11 /* MixpanelConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C162DAF2B3100B7CB11 /* MixpanelConfig.swift */; }; + 171E4C192DAF2B3100B7CB11 /* MixpanelConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C162DAF2B3100B7CB11 /* MixpanelConfig.swift */; }; + 171E4C1A2DAF2B3100B7CB11 /* MixpanelConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C162DAF2B3100B7CB11 /* MixpanelConfig.swift */; }; 17C6547A2BB1F15C00C8A126 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1728208D2BA8BDE4002CD973 /* PrivacyInfo.xcprivacy */; }; 17C6547B2BB1F16000C8A126 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1728208D2BA8BDE4002CD973 /* PrivacyInfo.xcprivacy */; }; 17C6547C2BB1F16400C8A126 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1728208D2BA8BDE4002CD973 /* PrivacyInfo.xcprivacy */; }; @@ -103,6 +111,8 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 171E4C112DAF108400B7CB11 /* FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; + 171E4C162DAF2B3100B7CB11 /* MixpanelConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MixpanelConfig.swift; sourceTree = ""; }; 1728208D2BA8BDE4002CD973 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Sources/Mixpanel/PrivacyInfo.xcprivacy; sourceTree = SOURCE_ROOT; }; 51DD56791D306B740045D3DB /* MixpanelLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MixpanelLogger.swift; sourceTree = ""; }; 51DD56801D306B7B0045D3DB /* PrintLogging.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PrintLogging.swift; sourceTree = ""; }; @@ -226,12 +236,14 @@ E11594881CFF14D3007F8B4F /* Source */ = { isa = PBXGroup; children = ( + 171E4C162DAF2B3100B7CB11 /* MixpanelConfig.swift */, 17C654792BB1EF6700C8A126 /* Mixpanel */, E189D8FB1D5A6943007F3F29 /* Networking */, 51DD56771D306B620045D3DB /* Log */, E189D8FA1D5A692A007F3F29 /* Utilities */, E115948A1CFF1538007F8B4F /* Mixpanel.swift */, E115948D1D000709007F8B4F /* MixpanelInstance.swift */, + 171E4C112DAF108400B7CB11 /* FeatureFlags.swift */, E115949E1D01BE14007F8B4F /* Flush.swift */, E11594A01D01C597007F8B4F /* Track.swift */, E15FF7C71D0435670076CDE3 /* People.swift */, @@ -488,6 +500,7 @@ 86F86EC722443A3C00B69832 /* FileLogging.swift in Sources */, 86F86EC622443A3100B69832 /* Error.swift in Sources */, 86F86EC522443A2C00B69832 /* People.swift in Sources */, + 171E4C172DAF2B3100B7CB11 /* MixpanelConfig.swift in Sources */, 86F86EC422443A2300B69832 /* ReadWriteLock.swift in Sources */, 8625BEBE26D045CE0009BAA9 /* MPDB.swift in Sources */, 95ECF06B2C9B851C006364D2 /* Data+Compression.swift in Sources */, @@ -495,6 +508,7 @@ 86F86EC122443A0E00B69832 /* JSONHandler.swift in Sources */, 86F86EC022443A0800B69832 /* MixpanelType.swift in Sources */, 86F86EBE224439FA00B69832 /* Network.swift in Sources */, + 171E4C142DAF108400B7CB11 /* FeatureFlags.swift in Sources */, 86F86EBD224439F500B69832 /* Flush.swift in Sources */, 86F86EBC224439F100B69832 /* PrintLogging.swift in Sources */, 868550AF2699096F001FCDDC /* MixpanelPersistence.swift in Sources */, @@ -517,6 +531,7 @@ E1D335CE1D30578E00E68E12 /* Constants.swift in Sources */, E115949F1D01BE14007F8B4F /* Flush.swift in Sources */, E11594971D006022007F8B4F /* Network.swift in Sources */, + 171E4C182DAF2B3100B7CB11 /* MixpanelConfig.swift in Sources */, E15FF7C81D0435670076CDE3 /* People.swift in Sources */, 673ABE3A21360CBE00B1784B /* Group.swift in Sources */, 95ECF0682C9B851A006364D2 /* Data+Compression.swift in Sources */, @@ -524,6 +539,7 @@ E11594991D01689F007F8B4F /* JSONHandler.swift in Sources */, E1D335D01D3059A800E68E12 /* AutomaticProperties.swift in Sources */, 51DD567C1D306B740045D3DB /* MixpanelLogger.swift in Sources */, + 171E4C122DAF108400B7CB11 /* FeatureFlags.swift in Sources */, E165228F1D6781DF000D5949 /* MixpanelType.swift in Sources */, BB9614171F3BB87700C3EF3E /* ReadWriteLock.swift in Sources */, E190522D1F9FC1BC00900E5D /* SessionMetadata.swift in Sources */, @@ -546,6 +562,7 @@ E12782BD1D4AB5CB0025FB05 /* MixpanelLogger.swift in Sources */, E12782BE1D4AB5CB0025FB05 /* Mixpanel.swift in Sources */, E12782BF1D4AB5CB0025FB05 /* MixpanelInstance.swift in Sources */, + 171E4C192DAF2B3100B7CB11 /* MixpanelConfig.swift in Sources */, E12782C11D4AB5CB0025FB05 /* Network.swift in Sources */, 8625BEBC26D045CE0009BAA9 /* MPDB.swift in Sources */, 95ECF0692C9B851B006364D2 /* Data+Compression.swift in Sources */, @@ -553,6 +570,7 @@ E12782C31D4AB5CB0025FB05 /* Flush.swift in Sources */, E12782C41D4AB5CB0025FB05 /* FlushRequest.swift in Sources */, E12782C51D4AB5CB0025FB05 /* Track.swift in Sources */, + 171E4C132DAF108400B7CB11 /* FeatureFlags.swift in Sources */, E12782C61D4AB5CB0025FB05 /* People.swift in Sources */, E19052001F9548F000900E5D /* ReadWriteLock.swift in Sources */, 868550AD2699096F001FCDDC /* MixpanelPersistence.swift in Sources */, @@ -575,6 +593,7 @@ E1F15FDC1E64B60A00391AE3 /* AutomaticProperties.swift in Sources */, E1F15FD91E64B60600391AE3 /* MixpanelLogger.swift in Sources */, E1F15FD61E64B5FC00391AE3 /* FlushRequest.swift in Sources */, + 171E4C1A2DAF2B3100B7CB11 /* MixpanelConfig.swift in Sources */, E1F15FD71E64B60200391AE3 /* PrintLogging.swift in Sources */, 8625BEBD26D045CE0009BAA9 /* MPDB.swift in Sources */, 95ECF06A2C9B851B006364D2 /* Data+Compression.swift in Sources */, @@ -582,6 +601,7 @@ E1F15FD51E64B5F800391AE3 /* Network.swift in Sources */, E1F15FDE1E64B60A00391AE3 /* MixpanelType.swift in Sources */, E1F15FDA1E64B60A00391AE3 /* JSONHandler.swift in Sources */, + 171E4C152DAF108400B7CB11 /* FeatureFlags.swift in Sources */, E1F15FE31E64B60D00391AE3 /* Track.swift in Sources */, E19052011F9548F000900E5D /* ReadWriteLock.swift in Sources */, 868550AE2699096F001FCDDC /* MixpanelPersistence.swift in Sources */, diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift new file mode 100644 index 00000000..1c901418 --- /dev/null +++ b/Sources/FeatureFlags.swift @@ -0,0 +1,445 @@ +import Foundation + +// --- Helper Structures --- + +// Represents the data associated with a feature flag +struct FeatureFlagData: Decodable { + let key: String // Corresponds to 'variant_key' in JS + let value: Any? // Corresponds to 'variant_value' in JS - Use Any? for flexibility + + // Manual decoding to handle Any? for the value + enum CodingKeys: String, CodingKey { + case key = "variant_key" + case value = "variant_value" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + key = try container.decode(String.self, forKey: .key) + + // Attempt to decode value flexibly (Bool, String, Int, Double, Array, Dictionary) + if let boolValue = try? container.decode(Bool.self, forKey: .value) { + value = boolValue + } else if let stringValue = try? container.decode(String.self, forKey: .value) { + value = stringValue + } else if let intValue = try? container.decode(Int.self, forKey: .value) { + value = intValue + } else if let doubleValue = try? container.decode(Double.self, forKey: .value) { + value = doubleValue + } else if let arrayValue = try? container.decode([AnyCodable].self, forKey: .value) { + value = arrayValue.map { $0.value } // Extract underlying values + } else if let dictValue = try? container.decode([String: AnyCodable].self, forKey: .value) { + value = dictValue.mapValues { $0.value } // Extract underlying values + } else if container.contains(.value) && (try? container.decodeNil(forKey: .value)) == true { + value = nil // Explicitly handle null + } + else { + // Log or handle the case where the type is unexpected or null + let context = DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unsupported type for variant_value or value is null.") + throw DecodingError.dataCorrupted(context) + // Or set value = nil if you prefer to silently ignore unknown types + // value = nil + } + } + + // Helper initializer for fallbacks + init(key: String = "", value: Any?) { + self.key = key + self.value = value + } +} + +// Wrapper to help decode 'Any' types within Codable structures +struct AnyCodable: Decodable { + let value: Any? + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let intValue = try? container.decode(Int.self) { + value = intValue + } else if let doubleValue = try? container.decode(Double.self) { + value = doubleValue + } else if let stringValue = try? container.decode(String.self) { + value = stringValue + } else if let boolValue = try? container.decode(Bool.self) { + value = boolValue + } else if let arrayValue = try? container.decode([AnyCodable].self) { + value = arrayValue.map { $0.value } + } else if let dictValue = try? container.decode([String: AnyCodable].self) { + value = dictValue.mapValues { $0.value } + } else if container.decodeNil() { + value = nil + } + else { + let context = DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unsupported type in AnyCodable.") + throw DecodingError.dataCorrupted(context) + } + } +} + + +// Response structure for the /flags endpoint +struct FlagsResponse: Decodable { + let flags: [String: FeatureFlagData]? // Dictionary where key is feature name +} + + +// --- FeatureFlagManager Class --- + +class FeatureFlagManager: Network { + + private var instanceName: String? + + // Internal State + private var flags: [String: FeatureFlagData]? = nil // Holds the fetched flags + private var trackedFeatures: Set = Set() + private var isFetching: Bool = false + private var fetchCompletionHandlers: [(Bool) -> Void] = [] // To notify callers when fetch completes + private let accessQueue = DispatchQueue(label: "com.mixpanel.featureflagmanager.queue", attributes: .concurrent) // For thread safety + + // Configuration Keys + private let flagsConfigKey = "flags" + private let configContextKey = "context" + private let flagsRoute = "/flags/" + + init(serverURL: String, instanceName: String) { + super.init(serverURL: serverURL) + self.instanceName = instanceName + // Initial fetch is triggered by an explicit call or first access usually + print("FeatureFlagManager initialized.") // Replaces logger.log + } + + required init(serverURL: String) { + super.init(serverURL: serverURL) + } + + // Public function to start loading flags + func loadFlags() { + fetchFlags(completion: nil) + } + + // --- Configuration Access --- + + private func getInstance() -> MixpanelInstance? { + if let instanceName, let instance = Mixpanel.getInstance(name: instanceName) { + return instance + } else if let instance = Mixpanel.safeMainInstance() { + return instance + } + return nil + } + + private func getFullConfig() -> MixpanelConfig? { + getInstance()?.getConfig() + } + + private func getContext() -> InternalProperties { + return getFullConfig()?.flagsContext ?? [:] + } + + private func isEnabled() -> Bool { + return getFullConfig()?.flagsEnabled ?? false + } + + // --- Flag State --- + + func areFeaturesReady() -> Bool { + var ready = false + accessQueue.sync { // Read needs sync access + ready = self.flags != nil + } + if !ready && isEnabled() { + print("Warning: Feature flags checked before being loaded.") // Replaces logger.log [cite: 21] + } else if !isEnabled() { + print("Error: Feature Flags not enabled.") // Replaces logger.error [cite: 11] + } + return ready + } + + // --- Fetching Logic --- + + private func fetchFlags(completion: ((Bool) -> Void)?) { + guard isEnabled() else { // [cite: 12] + print("Feature flags are disabled, not fetching.") + completion?(false) + return + } + + let shouldFetch = accessQueue.sync(flags: .barrier) { // Write access needs barrier + if self.isFetching { + // Queue completion if already fetching + if let completion = completion { + self.fetchCompletionHandlers.append(completion) + } + return false // Don't start another fetch + } + // Mark as fetching and add the first completion handler + self.isFetching = true + if let completion = completion { + self.fetchCompletionHandlers.append(completion) + } + return true // Start fetch + } + + guard shouldFetch else { return } + + if let instance = getInstance() { + let distinctId = instance.distinctId + print("Fetching flags for distinct ID: \(distinctId)") // Replaces logger.log [cite: 13] + + // Prepare request context [cite: 14] + var context = getContext() + context["distinct_id"] = distinctId + + let requestBodyDict = ["context": context] + + guard let requestBodyData = try? JSONSerialization.data(withJSONObject: requestBodyDict, options: []) else { + print("Error: Failed to serialize request body for flags.") + completeFetch(success: false) + return + } + + // Basic Auth Header + guard let authData = "\(instance.apiToken):".data(using: .utf8) else { + print("Error: Failed to create auth data.") + completeFetch(success: false) + return + } + let base64Auth = authData.base64EncodedString() + let headers = [ + "Authorization": "Basic \(base64Auth)", + "Content-Type": "application/json" // Assuming JSON, though JS used octet-stream [cite: 15] adjust if needed + ] + + // Define the response parser + let responseParser: (Data) -> FlagsResponse? = { data in + do { + let decoder = JSONDecoder() + let response = try decoder.decode(FlagsResponse.self, from: data) + return response + } catch { + print("Error: Failed to parse flags response JSON: \(error)") // Replaces logger.error [cite: 18] + return nil + } + } + + // Build the resource [cite: 51] + let resource = Network.buildResource(path: flagsRoute, // e.g., "/flags" + method: .post, + requestBody: requestBodyData, + headers: headers, + parse: responseParser) // [cite: 52] + + // Make the API request [cite: 42] + Network.apiRequest(base: serverURL, // e.g., "https://api.mixpanel.com" [cite: 36] + resource: resource, + failure: { reason, data, response in + print("Error: Failed to fetch flags. Reason: \(reason)") // Replaces logger.error [cite: 18] + if let data = data, let responseString = String(data: data, encoding: .utf8) { + print("Error response body: \(responseString)") + } + self.completeFetch(success: false) + }, + success: { [weak self] (flagsResponse, response) in // [cite: 16] + print("Successfully fetched flags.") + self?.accessQueue.sync(flags: .barrier) { // Write needs barrier + self?.flags = flagsResponse.flags ?? [:] // Store fetched flags [cite: 17] + } + self?.completeFetch(success: true) + }) + } + } + + private func completeFetch(success: Bool) { + accessQueue.sync(flags: .barrier) { // Write needs barrier + let handlers = self.fetchCompletionHandlers + self.fetchCompletionHandlers.removeAll() + self.isFetching = false + // Notify all queued handlers + DispatchQueue.main.async { // Call handlers on main thread + handlers.forEach { $0(success) } + } + } + } + + + // --- Getting Feature Flags (Async) --- + + // Use completion handler pattern similar to Network class + func getFeature(_ featureName: String, fallback: FeatureFlagData = FeatureFlagData(value: nil), completion: @escaping (FeatureFlagData) -> Void) { + accessQueue.async { // Read can be concurrent + if self.flags != nil { + // Flags already loaded, return sync result immediately on main thread + let result = self._getFeatureSync(featureName, fallback: fallback) + DispatchQueue.main.async { completion(result) } + } else { + // Flags not loaded, trigger fetch and call completion when done + DispatchQueue.main.async { // Ensure fetchFlags is called from a consistent thread if needed, or manage internally + self.fetchFlags { [weak self] success in + guard let self = self else { + completion(fallback) + return + } + if success { + let result = self._getFeatureSync(featureName, fallback: fallback) // Called within fetch completion, safe to access flags + completion(result) + } else { + print("Warning: Failed to fetch flags, returning fallback for \(featureName).") + completion(fallback) + } + } + } + } + } + } + + + func getFeatureData(_ featureName: String, fallbackValue: Any? = nil, completion: @escaping (Any?) -> Void) { + getFeature(featureName, fallback: FeatureFlagData(value: fallbackValue)) { featureData in + completion(featureData.value) + } + } + + func isFeatureEnabled(_ featureName: String, fallbackValue: Bool = false, completion: @escaping (Bool) -> Void) { + // Fetch the data first, then evaluate if it's true/false + getFeatureData(featureName, fallbackValue: fallbackValue) { [weak self] dataValue in + guard let self = self else { + completion(fallbackValue) + return + } + // Use the sync logic for evaluation after data is retrieved + completion(self._isFeatureEnabledSync(featureName: featureName, dataValue: dataValue, fallbackValue: fallbackValue)) + } + } + + + // --- Getting Feature Flags (Sync) --- + + // Private helper to avoid queue logic repetition, assumes flags are loaded or called from within completion + private func _getFeatureSync(_ featureName: String, fallback: FeatureFlagData) -> FeatureFlagData { + // Assumes called within accessQueue.sync or after flags are confirmed non-nil + guard let currentFlags = self.flags else { + // This path should ideally not be hit if areFeaturesReady is checked, but good for safety + print("Warning: getFeatureSync called before flags loaded for \(featureName).") // [cite: 21] + return fallback + } + + guard let feature = currentFlags[featureName] else { + print("Info: No flag found for '\(featureName)', returning fallback.") // [cite: 23] + return fallback + } + + // Track experiment exposure [cite: 24] + trackFeatureCheck(featureName: featureName, feature: feature) + return feature + } + + // Public sync methods require careful usage - check areFeaturesReady() first! + func getFeatureSync(_ featureName: String, fallback: FeatureFlagData = FeatureFlagData(value: nil)) -> FeatureFlagData { + guard areFeaturesReady() else { + print("Warning: Flags not ready for getFeatureSync call for \(featureName). Returning fallback.") // [cite: 21] + return fallback + } + // Access flags safely using the queue + var result: FeatureFlagData! + accessQueue.sync { // Read needs sync access + // We know flags is not nil here due to areFeaturesReady check + result = self._getFeatureSync(featureName, fallback: fallback) + } + return result + } + + + func getFeatureDataSync(_ featureName: String, fallbackValue: Any? = nil) -> Any? { + return getFeatureSync(featureName, fallback: FeatureFlagData(value: fallbackValue)).value + } + + + // Private helper for boolean evaluation + private func _isFeatureEnabledSync(featureName: String, dataValue: Any?, fallbackValue: Bool) -> Bool { + guard let val = dataValue else { + print("Info: Feature flag '\(featureName)' value is nil; returning fallback: \(fallbackValue)") + return fallbackValue + } + + if let boolVal = val as? Bool { + return boolVal // [cite: 28] + } else { + // Log error if value is not a boolean [cite: 28] + print("Error: Feature flag '\(featureName)' value: \(val) is not a boolean; returning fallback: \(fallbackValue)") + return fallbackValue // [cite: 29] + } + } + + func isFeatureEnabledSync(_ featureName: String, fallbackValue: Bool = false) -> Bool { // [cite: 27] + let dataValue = getFeatureDataSync(featureName, fallbackValue: fallbackValue) + return _isFeatureEnabledSync(featureName: featureName, dataValue: dataValue, fallbackValue: fallbackValue) + } + + + // --- Tracking --- + + private func trackFeatureCheck(featureName: String, feature: FeatureFlagData) { + accessQueue.sync(flags: .barrier) { // Write needs barrier + guard !self.trackedFeatures.contains(featureName) else { // [cite: 30] + return + } + self.trackedFeatures.insert(featureName) // [cite: 31] + } + + // Call the tracking function provided during initialization + let properties: Properties = [ + "Experiment name": featureName, + "Variant name": feature.key, + "$experiment_type": "feature_flag" + ] + if let instance = getInstance() { + instance.track(event: "$experiment_started", properties: properties) + print("Tracked $experiment_started for \(featureName)") + } + } +} + +// --- Example Usage Placeholder (Requires Mixpanel instance setup) --- +/* + // Assuming you have a Mixpanel instance and Network setup: + let mixpanelInstance = Mixpanel.initialize(token: "YOUR_TOKEN", launchOptions: nil, flushInterval: 60) + let network = Network(serverURL: mixpanelInstance.serverURL) // Or however Network gets initialized + + let featureFlagManager = FeatureFlagManager( + getConfigFunc: { key in mixpanelInstance.configuration.get(key) }, // Adapt based on actual config access + getDistinctIdFunc: { mixpanelInstance.distinctId }, + trackFunc: { eventName, properties in mixpanelInstance.track(event: eventName, properties: properties) }, + network: network + ) + + // Load flags initially (e.g., during app startup) + featureFlagManager.loadFlags() + + // Later, check a flag (async) + featureFlagManager.isFeatureEnabled("new_checkout_flow", fallbackValue: false) { isEnabled in + if isEnabled { + print("New checkout flow is enabled!") + // Show new UI + } else { + print("New checkout flow is disabled.") + // Show old UI + } + } + + // Or check synchronously *after* confirming flags are loaded + if featureFlagManager.areFeaturesReady() { + let buttonColorData = featureFlagManager.getFeatureDataSync("button_color", fallbackValue: "blue") + if let buttonColor = buttonColorData as? String { + print("Button color variant: \(buttonColor)") + // Apply button color + } + + let shouldUseNewAPI = featureFlagManager.isFeatureEnabledSync("use_new_api", fallbackValue: false) + print("Should use new API (sync): \(shouldUseNewAPI)") + + } else { + print("Flags not ready yet for sync access.") + // Use default behavior or wait + } + */ diff --git a/Sources/Mixpanel.swift b/Sources/Mixpanel.swift index 0666bb8a..f88804bb 100644 --- a/Sources/Mixpanel.swift +++ b/Sources/Mixpanel.swift @@ -14,6 +14,17 @@ import UIKit /// The primary class for integrating Mixpanel with your app. open class Mixpanel { + @discardableResult + open class func initialize(config: MixpanelConfig) -> MixpanelInstance { + let instanceName = config.instanceName ?? config.token + + if let proxyServerConfig = config.proxyServerConfig { + return MixpanelManager.sharedInstance.initialize(config: config) + } else { + return MixpanelManager.sharedInstance.initialize(config: config) + } + } + #if !os(OSX) && !os(watchOS) /** Initializes an instance of the API with the given project token. @@ -259,6 +270,15 @@ open class Mixpanel { open class func removeInstance(name: String) { MixpanelManager.sharedInstance.removeInstance(name: name) } + + open class func getConfig(name: String? = nil) -> MixpanelConfig? { + if let name, let instance = MixpanelManager.sharedInstance.getInstance(name: name) { + return instance.getConfig() + } else if let instance = MixpanelManager.sharedInstance.getMainInstance() { + return instance.getConfig() + } + return nil + } } final class MixpanelManager { @@ -276,6 +296,12 @@ final class MixpanelManager { instanceQueue = DispatchQueue(label: "com.mixpanel.instance.manager.instance", qos: .utility, autoreleaseFrequency: .workItem) } + func initialize(config: MixpanelConfig) -> MixpanelInstance { + return dequeueInstance(instanceName: config.instanceName ?? config.token) { + return MixpanelInstance(config: config) + } + } + func initialize(token apiToken: String, flushInterval: Double, instanceName: String, @@ -383,5 +409,6 @@ final class MixpanelManager { } } + } diff --git a/Sources/MixpanelConfig.swift b/Sources/MixpanelConfig.swift new file mode 100644 index 00000000..c4d441aa --- /dev/null +++ b/Sources/MixpanelConfig.swift @@ -0,0 +1,50 @@ +// +// public.swift +// Mixpanel +// +// Created by Jared McFarland on 4/15/25. +// Copyright © 2025 Mixpanel. All rights reserved. +// + + +// New MixpanelConfig class +public class MixpanelConfig { + public let token: String + public let flushInterval: Double + public let instanceName: String? + public let trackAutomaticEvents: Bool + public let optOutTrackingByDefault: Bool + public let useUniqueDistinctId: Bool + public let superProperties: Properties? + public let serverURL: String? + public let proxyServerConfig: ProxyServerConfig? + public let useGzipCompression: Bool + public let flagsEnabled: Bool + public let flagsContext: Dictionary? + + public init(token: String, + flushInterval: Double = 60, + instanceName: String? = nil, + trackAutomaticEvents: Bool = false, + optOutTrackingByDefault: Bool = false, + useUniqueDistinctId: Bool = false, + superProperties: Properties? = nil, + serverURL: String? = nil, + proxyServerConfig: ProxyServerConfig? = nil, + useGzipCompression: Bool = true, // NOTE: This is a new default value! + flagsEnabled: Bool = false, + flagsContext: Dictionary? = nil) { + self.token = token + self.flushInterval = flushInterval + self.instanceName = instanceName + self.trackAutomaticEvents = trackAutomaticEvents + self.optOutTrackingByDefault = optOutTrackingByDefault + self.useUniqueDistinctId = useUniqueDistinctId + self.superProperties = superProperties + self.serverURL = serverURL + self.proxyServerConfig = proxyServerConfig + self.useGzipCompression = useGzipCompression + self.flagsEnabled = flagsEnabled + self.flagsContext = flagsContext + } +} diff --git a/Sources/MixpanelInstance.swift b/Sources/MixpanelInstance.swift index ea5f5568..48d2d76f 100644 --- a/Sources/MixpanelInstance.swift +++ b/Sources/MixpanelInstance.swift @@ -77,6 +77,8 @@ public struct ProxyServerConfig { /// The class that represents the Mixpanel Instance open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDelegate { + private let config: MixpanelConfig + /// apiToken string that identifies the project to track data to open var apiToken = "" @@ -262,12 +264,27 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele let sessionMetadata: SessionMetadata let flushInstance: Flush let trackInstance: Track + let featureFlagManager: FeatureFlagManager #if os(iOS) || os(tvOS) || os(visionOS) let automaticEvents = AutomaticEvents() #endif private let registerSuperPropertiesNotificationName = Notification.Name("com.mixpanel.properties.register") private let unregisterSuperPropertiesNotificationName = Notification.Name("com.mixpanel.properties.unregister") + convenience init(config: MixpanelConfig) { + self.init(apiToken: config.token, + flushInterval: config.flushInterval, + name: config.instanceName ?? config.token, + trackAutomaticEvents: config.trackAutomaticEvents, + optOutTrackingByDefault: config.optOutTrackingByDefault, + useUniqueDistinctId: config.useUniqueDistinctId, + superProperties: config.superProperties, + serverURL: config.serverURL, + proxyServerDelegate: config.proxyServerConfig?.delegate, + useGzipCompression: config.useGzipCompression, + config: config) + } + convenience init( apiToken: String?, flushInterval: Double, @@ -325,8 +342,22 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele superProperties: Properties? = nil, serverURL: String? = nil, proxyServerDelegate: MixpanelProxyServerDelegate? = nil, - useGzipCompression: Bool = false + useGzipCompression: Bool = false, + config: MixpanelConfig? = nil ) { + // Store the config if provided, otherwise create one with the current values + self.config = config ?? MixpanelConfig( + token: apiToken ?? "", + flushInterval: flushInterval, + instanceName: name, + trackAutomaticEvents: trackAutomaticEvents, + optOutTrackingByDefault: optOutTrackingByDefault, + useUniqueDistinctId: useUniqueDistinctId, + superProperties: superProperties, + serverURL: serverURL, + useGzipCompression: useGzipCompression + ) + if let apiToken = apiToken, !apiToken.isEmpty { self.apiToken = apiToken } @@ -352,6 +383,7 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele instanceName: self.name, lock: self.readWriteLock, metadata: sessionMetadata, mixpanelPersistence: mixpanelPersistence) + featureFlagManager = FeatureFlagManager(serverURL: self.serverURL, instanceName: self.name) trackInstance.mixpanelInstance = self #if os(iOS) && !targetEnvironment(macCatalyst) if let reachability = MixpanelInstance.reachability { @@ -406,6 +438,10 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele #endif } + public func getConfig() -> MixpanelConfig { + return config + } + #if !os(OSX) && !os(watchOS) private func setupListeners() { let notificationCenter = NotificationCenter.default diff --git a/Sources/MixpanelPersistence.swift b/Sources/MixpanelPersistence.swift index 24f72dc2..0353d9c0 100644 --- a/Sources/MixpanelPersistence.swift +++ b/Sources/MixpanelPersistence.swift @@ -47,6 +47,7 @@ struct MixpanelUserDefaultsKeys { static let userID = "MPUserId" static let alias = "MPAlias" static let hadPersistedDistinctId = "MPHadPersistedDistinctId" + static let flags = "MPFlags" } class MixpanelPersistence { @@ -189,6 +190,36 @@ class MixpanelPersistence { } } + static func saveFlags(flags: InternalProperties, instanceName: String) { + guard let defaults = UserDefaults(suiteName: MixpanelUserDefaultsKeys.suiteName) else { + return + } + let prefix = "\(MixpanelUserDefaultsKeys.prefix)-\(instanceName)-" + do { + let flagsData = try NSKeyedArchiver.archivedData(withRootObject: flags, requiringSecureCoding: false) + defaults.set(flagsData, forKey: "\(prefix)\(MixpanelUserDefaultsKeys.flags)") + defaults.synchronize() + } catch { + MixpanelLogger.warn(message: "Failed to archive flags") + } + } + + static func loadFlags(instanceName: String) -> InternalProperties { + guard let defaults = UserDefaults(suiteName: MixpanelUserDefaultsKeys.suiteName) else { + return InternalProperties() + } + let prefix = "\(MixpanelUserDefaultsKeys.prefix)-\(instanceName)-" + guard let flags = defaults.data(forKey: "\(prefix)\(MixpanelUserDefaultsKeys.flags)") else { + return InternalProperties() + } + do { + return try NSKeyedUnarchiver.unarchivedObject(ofClasses: archivedClasses, from: flags) as? InternalProperties ?? InternalProperties() + } catch { + MixpanelLogger.warn(message: "Failed to unarchive flags") + return InternalProperties() + } + } + static func saveIdentity(_ mixpanelIdentity: MixpanelIdentity, instanceName: String) { guard let defaults = UserDefaults(suiteName: MixpanelUserDefaultsKeys.suiteName) else { return From 957ffed779dd88186b864b1a16e2b86414bdeb99 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Tue, 22 Apr 2025 11:39:51 -0700 Subject: [PATCH 02/20] tests and tweaks --- .../MixpanelDemo.xcodeproj/project.pbxproj | 4 + MixpanelDemo/MixpanelDemo/AppDelegate.swift | 4 +- .../MixpanelFeatureFlagTests.swift | 516 ++++++++++++++++ Sources/FeatureFlags.swift | 552 +++++++++--------- Sources/MixpanelConfig.swift | 9 +- Sources/MixpanelInstance.swift | 12 +- 6 files changed, 809 insertions(+), 288 deletions(-) create mode 100644 MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift diff --git a/MixpanelDemo/MixpanelDemo.xcodeproj/project.pbxproj b/MixpanelDemo/MixpanelDemo.xcodeproj/project.pbxproj index 19faca82..48a337e4 100644 --- a/MixpanelDemo/MixpanelDemo.xcodeproj/project.pbxproj +++ b/MixpanelDemo/MixpanelDemo.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 171E4C1C2DB055BC00B7CB11 /* MixpanelFeatureFlagTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C1B2DB055A900B7CB11 /* MixpanelFeatureFlagTests.swift */; }; 51DD568A1D3077390045D3DB /* LoggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51DD56891D3077390045D3DB /* LoggerTests.swift */; }; 60CB587123D77F9200F1632B /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60CB587023D77F9200F1632B /* LoginViewController.swift */; }; 671EECAF21432E5F006DD9FA /* GroupsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 671EECAE21432E5F006DD9FA /* GroupsViewController.swift */; }; @@ -249,6 +250,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 171E4C1B2DB055A900B7CB11 /* MixpanelFeatureFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MixpanelFeatureFlagTests.swift; sourceTree = ""; }; 51DD56891D3077390045D3DB /* LoggerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoggerTests.swift; sourceTree = ""; }; 60CB587023D77F9200F1632B /* LoginViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewController.swift; sourceTree = ""; }; 671EECAE21432E5F006DD9FA /* GroupsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupsViewController.swift; sourceTree = ""; }; @@ -590,6 +592,7 @@ E15FF7EA1D0461130076CDE3 /* MixpanelDemoTests */ = { isa = PBXGroup; children = ( + 171E4C1B2DB055A900B7CB11 /* MixpanelFeatureFlagTests.swift */, E124061F1D249B2500383635 /* MixpanelBaseTests.swift */, E15FF7EB1D0461130076CDE3 /* MixpanelDemoTests.swift */, E1C61EB91D22F6470056C56C /* MixpanelPeopleTests.swift */, @@ -1114,6 +1117,7 @@ E12406201D249B2500383635 /* MixpanelBaseTests.swift in Sources */, E17AA05E1EC6234E0066EFE8 /* MixpanelAutomaticEventsTests.swift in Sources */, E15FF7EC1D0461130076CDE3 /* MixpanelDemoTests.swift in Sources */, + 171E4C1C2DB055BC00B7CB11 /* MixpanelFeatureFlagTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/MixpanelDemo/MixpanelDemo/AppDelegate.swift b/MixpanelDemo/MixpanelDemo/AppDelegate.swift index 73607c9c..9dfc2d59 100644 --- a/MixpanelDemo/MixpanelDemo/AppDelegate.swift +++ b/MixpanelDemo/MixpanelDemo/AppDelegate.swift @@ -16,8 +16,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { - var ADD_YOUR_MIXPANEL_TOKEN_BELOW_🛠🛠🛠🛠🛠🛠: String - Mixpanel.initialize(token: "MIXPANEL_TOKEN", trackAutomaticEvents: true) + let mixpanelConfig = MixpanelConfig(token: "metrics-1", trackAutomaticEvents: false, flagsConfig: FlagsConfig(enabled: true, context: ["key": "value"])) + Mixpanel.initialize(config: mixpanelConfig) Mixpanel.mainInstance().loggingEnabled = true return true diff --git a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift new file mode 100644 index 00000000..e81bd6c4 --- /dev/null +++ b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift @@ -0,0 +1,516 @@ +// +// MixpanelFeatureFlagTests.swift +// MixpanelDemo +// +// Created by Jared McFarland on 4/16/25. +// Copyright © 2025 Mixpanel. All rights reserved. +// + +import XCTest +@testable import Mixpanel + +// MARK: - Mocks and Helpers (Largely Unchanged) + +class MockFeatureFlagDelegate: FeatureFlagDelegate { + + var config: MixpanelConfig + var distinctId: String + var trackedEvents: [(event: String?, properties: Properties?)] = [] + var trackExpectation: XCTestExpectation? + var getConfigCallCount = 0 + var getDistinctIdCallCount = 0 + + init(config: MixpanelConfig = MixpanelConfig(token: "test", flagsConfig: FlagsConfig(enabled: true)), distinctId: String = "test_distinct_id") { + self.config = config + self.distinctId = distinctId + } + + func getConfig() -> MixpanelConfig { + getConfigCallCount += 1 + return config + } + + func getDistinctId() -> String { + getDistinctIdCallCount += 1 + return distinctId + } + + func track(event: String?, properties: Properties?) { + print("MOCK Delegate: Track called - Event: \(event ?? "nil"), Props: \(properties ?? [:])") + trackedEvents.append((event: event, properties: properties)) + trackExpectation?.fulfill() + } +} + +// AssertEqual helper (Unchanged from previous working version) +func AssertEqual(_ value1: Any?, _ value2: Any?, file: StaticString = #file, line: UInt = #line) { + // ... (Use the version that fixed the Any?? issues) ... + switch (value1, value2) { + case (nil, nil): + break // Equal + case (let v1 as Bool, let v2 as Bool): + XCTAssertEqual(v1, v2, file: file, line: line) + case (let v1 as String, let v2 as String): + XCTAssertEqual(v1, v2, file: file, line: line) + case (let v1 as Int, let v2 as Int): + XCTAssertEqual(v1, v2, file: file, line: line) + case (let v1 as Double, let v2 as Double): + // Handle potential precision issues if necessary + XCTAssertEqual(v1, v2, accuracy: 0.00001, file: file, line: line) + case (let v1 as [Any?], let v2 as [Any?]): + XCTAssertEqual(v1.count, v2.count, "Array counts differ", file: file, line: line) + for (index, item1) in v1.enumerated() { + guard index < v2.count else { + XCTFail("Index \(index) out of bounds for second array", file: file, line: line) + return + } + AssertEqual(item1, v2[index], file: file, line: line) + } + case (let v1 as [String: Any?], let v2 as [String: Any?]): + XCTAssertEqual(v1.count, v2.count, "Dictionary counts differ (\(v1.keys.sorted()) vs \(v2.keys.sorted()))", file: file, line: line) + for (key, item1) in v1 { + guard v2.keys.contains(key) else { + XCTFail("Key '\(key)' missing in second dictionary", file: file, line: line) + continue + } + let item2DoubleOptional = v2[key] + AssertEqual(item1, item2DoubleOptional ?? nil, file: file, line: line) + } + default: + if let n1 = value1 as? NSNumber, let n2 = value2 as? NSNumber { + XCTAssertEqual(n1, n2, "NSNumber values differ: \(n1) vs \(n2)", file: file, line: line) + } else { + XCTFail("Values are not equal or of comparable types: \(String(describing: value1)) vs \(String(describing: value2))", file: file, line: line) + } + } +} + + +// MARK: - Refactored FeatureFlagManager Tests + +class FeatureFlagManagerTests: XCTestCase { + + var mockDelegate: MockFeatureFlagDelegate! + var manager: FeatureFlagManager! + // Sample flag data for simulating fetch results + let sampleFlags: [String: FeatureFlagData] = [ + "feature_bool_true": FeatureFlagData(key: "v_true", value: true), + "feature_bool_false": FeatureFlagData(key: "v_false", value: false), + "feature_string": FeatureFlagData(key: "v_str", value: "test_string"), + "feature_int": FeatureFlagData(key: "v_int", value: 101), + "feature_double": FeatureFlagData(key: "v_double", value: 99.9), + "feature_null": FeatureFlagData(key: "v_null", value: nil) + ] + let defaultFallback = FeatureFlagData(value: nil) // Default fallback for convenience + + override func setUpWithError() throws { + try super.setUpWithError() + mockDelegate = MockFeatureFlagDelegate() + // Ensure manager is initialized with the delegate + manager = FeatureFlagManager(serverURL: "https://test.com", delegate: mockDelegate) + } + + override func tearDownWithError() throws { + mockDelegate = nil + manager = nil + try super.tearDownWithError() + } + + // --- Simulation Helpers --- + // These now directly modify state and call the *internal* _completeFetch + // Requires _completeFetch to be accessible (e.g., internal or @testable import) + + private func simulateFetchSuccess(flags: [String: FeatureFlagData]? = nil) { + let flagsToSet = flags ?? sampleFlags + // Set flags directly *before* calling completeFetch + manager.accessQueue.sync { + manager.flags = flagsToSet + // Important: Set isFetching = true *before* calling _completeFetch, + // as _completeFetch assumes a fetch was in progress. + manager.isFetching = true + } + // Call internal completion logic + manager._completeFetch(success: true) + } + + private func simulateFetchFailure() { + // Set isFetching = true before calling _completeFetch + manager.accessQueue.sync { + manager.isFetching = true + // Ensure flags are nil or unchanged on failure simulation if desired + manager.flags = nil // Or keep existing flags based on desired failure behavior + } + // Call internal completion logic + manager._completeFetch(success: false) + } + + // --- State and Configuration Tests --- + + func testAreFeaturesReady_InitialState() { + XCTAssertFalse(manager.areFeaturesReady(), "Features should not be ready initially") + } + + func testAreFeaturesReady_AfterSuccessfulFetchSimulation() { + simulateFetchSuccess() + // Need to wait briefly for the main queue dispatch in _completeFetch to potentially run + let expectation = XCTestExpectation(description: "Wait for potential completion dispatch") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { expectation.fulfill() } + wait(for: [expectation], timeout: 0.5) + XCTAssertTrue(manager.areFeaturesReady(), "Features should be ready after successful fetch simulation") + } + + func testAreFeaturesReady_AfterFailedFetchSimulation() { + simulateFetchFailure() + // Need to wait briefly for the main queue dispatch in _completeFetch to potentially run + let expectation = XCTestExpectation(description: "Wait for potential completion dispatch") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { expectation.fulfill() } + wait(for: [expectation], timeout: 0.5) + XCTAssertFalse(manager.areFeaturesReady(), "Features should not be ready after failed fetch simulation") + } + + // --- Load Flags Tests --- + + func testLoadFlags_WhenDisabledInConfig() { + mockDelegate.config = MixpanelConfig(token:"test", flagsConfig: FlagsConfig(enabled: false)) // Explicitly disable + manager.loadFlags() // Call public API + + // Wait to ensure no async fetch operations started changing state + let expectation = XCTestExpectation(description: "Wait briefly") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { expectation.fulfill() } + wait(for: [expectation], timeout: 0.5) + + XCTAssertFalse(manager.areFeaturesReady(), "Flags should not become ready if disabled") + // We can't easily check if _fetchFlagsIfNeeded was *not* called without more testability hooks + } + + // Note: Testing that loadFlags *starts* a fetch is harder now without exposing internal state. + // We test the outcome via the async getFeature tests below. + + // --- Sync Flag Retrieval Tests --- + + func testGetFeatureSync_FlagsReady_ExistingFlag() { + simulateFetchSuccess() // Flags loaded + let featureData = manager.getFeatureSync("feature_string") + AssertEqual(featureData.key, "v_str") + AssertEqual(featureData.value, "test_string") + // Tracking check happens later + } + + func testGetFeatureSync_FlagsReady_MissingFlag_UsesFallback() { + simulateFetchSuccess() + let fallback = FeatureFlagData(key: "fb_key", value: "fb_value") + let featureData = manager.getFeatureSync("missing_feature", fallback: fallback) + AssertEqual(featureData.key, fallback.key) + AssertEqual(featureData.value, fallback.value) + XCTAssertEqual(mockDelegate.trackedEvents.count, 0, "Should not track for fallback") + } + + func testGetFeatureSync_FlagsNotReady_UsesFallback() { + XCTAssertFalse(manager.areFeaturesReady()) // Precondition + let fallback = FeatureFlagData(key: "fb_key", value: 999) + let featureData = manager.getFeatureSync("feature_bool_true", fallback: fallback) + AssertEqual(featureData.key, fallback.key) + AssertEqual(featureData.value, fallback.value) + XCTAssertEqual(mockDelegate.trackedEvents.count, 0, "Should not track if flags not ready") + } + + func testGetFeatureDataSync_FlagsReady() { + simulateFetchSuccess() + let value = manager.getFeatureDataSync("feature_int", fallbackValue: -1) + AssertEqual(value, 101) + } + + func testGetFeatureDataSync_FlagsReady_MissingFlag() { + simulateFetchSuccess() + let value = manager.getFeatureDataSync("missing_feature", fallbackValue: "default") + AssertEqual(value, "default") + } + + func testGetFeatureDataSync_FlagsNotReady() { + XCTAssertFalse(manager.areFeaturesReady()) + let value = manager.getFeatureDataSync("feature_int", fallbackValue: -1) + AssertEqual(value, -1) + } + + func testIsFeatureEnabledSync_FlagsReady_True() { + simulateFetchSuccess() + XCTAssertTrue(manager.isFeatureEnabledSync("feature_bool_true")) + } + + func testIsFeatureEnabledSync_FlagsReady_False() { + simulateFetchSuccess() + XCTAssertFalse(manager.isFeatureEnabledSync("feature_bool_false")) + } + + func testIsFeatureEnabledSync_FlagsReady_MissingFlag_UsesFallback() { + simulateFetchSuccess() + XCTAssertTrue(manager.isFeatureEnabledSync("missing", fallbackValue: true)) + XCTAssertFalse(manager.isFeatureEnabledSync("missing", fallbackValue: false)) + } + + func testIsFeatureEnabledSync_FlagsReady_NonBoolValue_UsesFallback() { + simulateFetchSuccess() + XCTAssertTrue(manager.isFeatureEnabledSync("feature_string", fallbackValue: true)) // String value + XCTAssertFalse(manager.isFeatureEnabledSync("feature_int", fallbackValue: false)) // Int value + XCTAssertTrue(manager.isFeatureEnabledSync("feature_null", fallbackValue: true)) // Null value + } + + func testIsFeatureEnabledSync_FlagsNotReady_UsesFallback() { + XCTAssertFalse(manager.areFeaturesReady()) + XCTAssertTrue(manager.isFeatureEnabledSync("feature_bool_true", fallbackValue: true)) + XCTAssertFalse(manager.isFeatureEnabledSync("feature_bool_true", fallbackValue: false)) + } + + // --- Async Flag Retrieval Tests --- + + func testGetFeature_Async_FlagsReady_ExistingFlag_XCTWaiter() { + // Arrange + simulateFetchSuccess() // Ensure flags are ready + let expectation = XCTestExpectation(description: "Async getFeature ready - XCTWaiter Wait") + var receivedData: FeatureFlagData? + var assertionError: String? + + // Act + manager.getFeature("feature_double") { data in + // This completion should run on the main thread + if !Thread.isMainThread { assertionError = "Completion not on main thread (\(Thread.current))" } + receivedData = data + // Perform crucial checks inside completion + if receivedData == nil { assertionError = (assertionError ?? "") + "; Received data was nil" } + if receivedData?.key != "v_double" { assertionError = (assertionError ?? "") + "; Received key mismatch" } + // Add other essential checks if needed + expectation.fulfill() + } + + // Assert - Wait using an explicit XCTWaiter instance + let waiter = XCTWaiter() + let result = waiter.wait(for: [expectation], timeout: 2.0) // Increased timeout + + // Check waiter result and any errors captured in completion + if result != .completed { + XCTFail("XCTWaiter timed out waiting for expectation. Error captured: \(assertionError ?? "None")") + } else if let error = assertionError { + XCTFail("Assertions failed within completion block: \(error)") + } + + // Final check on data after wait + // These might be redundant if checked thoroughly in completion, but good final check + XCTAssertNotNil(receivedData, "Received data should be non-nil after successful wait") + AssertEqual(receivedData?.key, "v_double") + AssertEqual(receivedData?.value, 99.9) + } + + func testGetFeature_Async_FlagsReady_MissingFlag_UsesFallback() { + simulateFetchSuccess() // Flags loaded + let expectation = XCTestExpectation(description: "Async getFeature (Flags Ready, Missing) completes") + let fallback = FeatureFlagData(key: "fb_async", value: -1) + var receivedData: FeatureFlagData? + + manager.getFeature("missing_feature", fallback: fallback) { data in + XCTAssertTrue(Thread.isMainThread, "Completion should be on main thread") + receivedData = data + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + + XCTAssertNotNil(receivedData) + AssertEqual(receivedData?.key, fallback.key) + AssertEqual(receivedData?.value, fallback.value) + // Check delegate tracking after wait (should not have tracked) + XCTAssertEqual(mockDelegate.trackedEvents.count, 0, "Should not track fallback") + } + + // Test fetch triggering and completion via getFeature when not ready + func testGetFeature_Async_FlagsNotReady_FetchSuccess() { + XCTAssertFalse(manager.areFeaturesReady()) + let expectation = XCTestExpectation(description: "Async getFeature (Flags Not Ready) triggers fetch and succeeds") + var receivedData: FeatureFlagData? + + // Setup tracking expectation *before* calling getFeature + mockDelegate.trackExpectation = XCTestExpectation(description: "Tracking call for fetch success") + + // Call getFeature - this should trigger the fetch logic internally + manager.getFeature("feature_int") { data in + XCTAssertTrue(Thread.isMainThread, "Completion should be on main thread") + receivedData = data + expectation.fulfill() // Fulfill main expectation + } + + // Crucially, simulate the fetch success *after* getFeature was called. + // Add a slight delay to mimic network latency and allow fetch logic to start. + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + print("Simulating fetch success...") + self.simulateFetchSuccess() // This sets flags and calls _completeFetch + } + + // Wait for BOTH the getFeature completion AND the tracking expectation + wait(for: [expectation, mockDelegate.trackExpectation!], timeout: 3.0) // Increased timeout + + XCTAssertNotNil(receivedData) + AssertEqual(receivedData?.key, "v_int") // Check correct flag data received + AssertEqual(receivedData?.value, 101) + XCTAssertTrue(manager.areFeaturesReady(), "Flags should be ready after successful fetch") + XCTAssertEqual(mockDelegate.trackedEvents.count, 1, "Tracking event should have been recorded") + } + + func testGetFeature_Async_FlagsNotReady_FetchFailure() { + XCTAssertFalse(manager.areFeaturesReady()) + let expectation = XCTestExpectation(description: "Async getFeature (Flags Not Ready) triggers fetch and fails") + let fallback = FeatureFlagData(key:"fb_fail", value: "failed_fetch") + var receivedData: FeatureFlagData? + + // Call getFeature + manager.getFeature("feature_string", fallback: fallback) { data in + XCTAssertTrue(Thread.isMainThread, "Completion should be on main thread") + receivedData = data + expectation.fulfill() + } + + // Simulate fetch failure after a delay + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + print("Simulating fetch failure...") + self.simulateFetchFailure() // This calls _completeFetch(success: false) + } + + wait(for: [expectation], timeout: 3.0) + + XCTAssertNotNil(receivedData) + AssertEqual(receivedData?.key, fallback.key) // Should receive fallback + AssertEqual(receivedData?.value, fallback.value) + XCTAssertFalse(manager.areFeaturesReady(), "Flags should still not be ready after failed fetch") + XCTAssertEqual(mockDelegate.trackedEvents.count, 0, "Should not track on fetch failure/fallback") + } + + + // --- Tracking Tests --- + + func testTracking_CalledOncePerFeature() { + simulateFetchSuccess() // Flags ready + + mockDelegate.trackExpectation = XCTestExpectation(description: "Track called once for feature_bool_true") + mockDelegate.trackExpectation?.expectedFulfillmentCount = 1 // Expect exactly one call + + // Call sync methods multiple times + _ = manager.getFeatureSync("feature_bool_true") + _ = manager.getFeatureDataSync("feature_bool_true") + _ = manager.isFeatureEnabledSync("feature_bool_true") + + // Call async method + let asyncExpectation = XCTestExpectation(description: "Async getFeature completes for tracking test") + manager.getFeature("feature_bool_true") { _ in asyncExpectation.fulfill() } + + // Wait for async call AND the track expectation + wait(for: [asyncExpectation, mockDelegate.trackExpectation!], timeout: 2.0) + + // Verify track delegate method was called exactly once + let trueEvents = mockDelegate.trackedEvents.filter { $0.properties?["Experiment name"] as? String == "feature_bool_true" } + XCTAssertEqual(trueEvents.count, 1, "Track should only be called once for the same feature") + + // --- Call for a *different* feature --- + mockDelegate.trackExpectation = XCTestExpectation(description: "Track called for feature_string") + _ = manager.getFeatureSync("feature_string") + wait(for: [mockDelegate.trackExpectation!], timeout: 1.0) + + let stringEvents = mockDelegate.trackedEvents.filter { $0.properties?["Experiment name"] as? String == "feature_string" } + XCTAssertEqual(stringEvents.count, 1, "Track should be called again for a different feature") + + // Verify total calls + XCTAssertEqual(mockDelegate.trackedEvents.count, 2, "Total track calls should be 2") + } + + func testTracking_SendsCorrectProperties() { + simulateFetchSuccess() + mockDelegate.trackExpectation = XCTestExpectation(description: "Track called for properties check") + + _ = manager.getFeatureSync("feature_int") // Trigger tracking + + wait(for: [mockDelegate.trackExpectation!], timeout: 1.0) + + XCTAssertEqual(mockDelegate.trackedEvents.count, 1) + let tracked = mockDelegate.trackedEvents[0] + XCTAssertEqual(tracked.event, "$experiment_started") + XCTAssertNotNil(tracked.properties) + + let props = tracked.properties! + AssertEqual(props["Experiment name"] ?? nil, "feature_int") + AssertEqual(props["Variant name"] ?? nil, "v_int") + AssertEqual(props["$experiment_type"] ?? nil, "feature_flag") + } + + func testTracking_DoesNotTrackForFallback_Sync() { + simulateFetchSuccess() // Flags ready + _ = manager.getFeatureSync("missing_feature", fallback: FeatureFlagData(key:"fb", value:"v")) // Request missing flag + // Wait briefly to ensure no unexpected tracking call + let expectation = XCTestExpectation(description: "Wait briefly for no track") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { expectation.fulfill() } + wait(for: [expectation], timeout: 0.5) + XCTAssertEqual(mockDelegate.trackedEvents.count, 0, "Track should not be called when a fallback is used (sync)") + } + + func testTracking_DoesNotTrackForFallback_Async() { + simulateFetchSuccess() // Flags ready + let expectation = XCTestExpectation(description: "Async getFeature (Fallback) completes") + + manager.getFeature("missing_feature", fallback: FeatureFlagData(key:"fb", value:"v")) { _ in + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + // Check delegate tracking after wait + XCTAssertEqual(mockDelegate.trackedEvents.count, 0, "Track should not be called when a fallback is used (async)") + } + + // --- Concurrency Tests --- + + // Test concurrent fetch attempts (via getFeature when not ready) + func testConcurrentGetFeature_WhenNotReady_OnlyOneFetch() { + XCTAssertFalse(manager.areFeaturesReady()) + + let numConcurrentCalls = 5 + var expectations: [XCTestExpectation] = [] + var completionResults: [FeatureFlagData?] = Array(repeating: nil, count: numConcurrentCalls) + + // Expect tracking only ONCE for the actual feature if fetch succeeds + mockDelegate.trackExpectation = XCTestExpectation(description: "Track call (should be once)") + mockDelegate.trackExpectation?.expectedFulfillmentCount = 1 + + print("Starting \(numConcurrentCalls) concurrent getFeature calls...") + for i in 0.. MixpanelConfig + func getDistinctId() -> String + func track(event: String?, properties: Properties?) +} + // --- FeatureFlagManager Class --- class FeatureFlagManager: Network { - private var instanceName: String? + weak var delegate: FeatureFlagDelegate? - // Internal State - private var flags: [String: FeatureFlagData]? = nil // Holds the fetched flags - private var trackedFeatures: Set = Set() - private var isFetching: Bool = false - private var fetchCompletionHandlers: [(Bool) -> Void] = [] // To notify callers when fetch completes - private let accessQueue = DispatchQueue(label: "com.mixpanel.featureflagmanager.queue", attributes: .concurrent) // For thread safety + // *** Use a SERIAL queue for automatic state serialization *** + let accessQueue = DispatchQueue(label: "com.mixpanel.featureflagmanager.serialqueue") - // Configuration Keys - private let flagsConfigKey = "flags" - private let configContextKey = "context" - private let flagsRoute = "/flags/" + // Internal State - Protected by accessQueue + var flags: [String: FeatureFlagData]? = nil + var isFetching: Bool = false + private var trackedFeatures: Set = Set() + private var fetchCompletionHandlers: [(Bool) -> Void] = [] - init(serverURL: String, instanceName: String) { - super.init(serverURL: serverURL) - self.instanceName = instanceName - // Initial fetch is triggered by an explicit call or first access usually - print("FeatureFlagManager initialized.") // Replaces logger.log - } + // Configuration + private var currentConfig: MixpanelConfig? { delegate?.getConfig() } + private var flagsRoute = "/flags/" + // Initializers required init(serverURL: String) { super.init(serverURL: serverURL) } - // Public function to start loading flags - func loadFlags() { - fetchFlags(completion: nil) + public init(serverURL: String, delegate: FeatureFlagDelegate?) { + self.delegate = delegate + super.init(serverURL: serverURL) } - // --- Configuration Access --- + // --- Public Methods --- - private func getInstance() -> MixpanelInstance? { - if let instanceName, let instance = Mixpanel.getInstance(name: instanceName) { - return instance - } else if let instance = Mixpanel.safeMainInstance() { - return instance + func loadFlags() { + // Dispatch fetch trigger to allow caller to continue + // Using the serial queue itself for this background task is fine + accessQueue.async { [weak self] in + self?._fetchFlagsIfNeeded(completion: nil) } - return nil - } - - private func getFullConfig() -> MixpanelConfig? { - getInstance()?.getConfig() - } - - private func getContext() -> InternalProperties { - return getFullConfig()?.flagsContext ?? [:] - } - - private func isEnabled() -> Bool { - return getFullConfig()?.flagsEnabled ?? false } - // --- Flag State --- - func areFeaturesReady() -> Bool { - var ready = false - accessQueue.sync { // Read needs sync access - ready = self.flags != nil - } - if !ready && isEnabled() { - print("Warning: Feature flags checked before being loaded.") // Replaces logger.log [cite: 21] - } else if !isEnabled() { - print("Error: Feature Flags not enabled.") // Replaces logger.error [cite: 11] - } - return ready + // Simple sync read - serial queue ensures this is safe + accessQueue.sync { flags != nil } } - // --- Fetching Logic --- + // --- Sync Flag Retrieval --- - private func fetchFlags(completion: ((Bool) -> Void)?) { - guard isEnabled() else { // [cite: 12] - print("Feature flags are disabled, not fetching.") - completion?(false) - return - } - - let shouldFetch = accessQueue.sync(flags: .barrier) { // Write access needs barrier - if self.isFetching { - // Queue completion if already fetching - if let completion = completion { - self.fetchCompletionHandlers.append(completion) + func getFeatureSync(_ featureName: String, fallback: FeatureFlagData = FeatureFlagData(value: nil)) -> FeatureFlagData { + var featureData: FeatureFlagData? + var tracked = false + // === Serial Queue: Single Sync Block for Read AND Track Update === + accessQueue.sync { + guard let currentFlags = self.flags else { return } + + if let feature = currentFlags[featureName] { + featureData = feature + + // Perform atomic check-and-set for tracking *within the same sync block* + if !self.trackedFeatures.contains(featureName) { + self.trackedFeatures.insert(featureName) + tracked = true } - return false // Don't start another fetch - } - // Mark as fetching and add the first completion handler - self.isFetching = true - if let completion = completion { - self.fetchCompletionHandlers.append(completion) } - return true // Start fetch + // If feature wasn't found, featureData remains nil } + // === End Sync Block === - guard shouldFetch else { return } + // Now, process the results outside the lock - if let instance = getInstance() { - let distinctId = instance.distinctId - print("Fetching flags for distinct ID: \(distinctId)") // Replaces logger.log [cite: 13] - - // Prepare request context [cite: 14] - var context = getContext() - context["distinct_id"] = distinctId - - let requestBodyDict = ["context": context] - - guard let requestBodyData = try? JSONSerialization.data(withJSONObject: requestBodyDict, options: []) else { - print("Error: Failed to serialize request body for flags.") - completeFetch(success: false) - return - } - - // Basic Auth Header - guard let authData = "\(instance.apiToken):".data(using: .utf8) else { - print("Error: Failed to create auth data.") - completeFetch(success: false) - return - } - let base64Auth = authData.base64EncodedString() - let headers = [ - "Authorization": "Basic \(base64Auth)", - "Content-Type": "application/json" // Assuming JSON, though JS used octet-stream [cite: 15] adjust if needed - ] - - // Define the response parser - let responseParser: (Data) -> FlagsResponse? = { data in - do { - let decoder = JSONDecoder() - let response = try decoder.decode(FlagsResponse.self, from: data) - return response - } catch { - print("Error: Failed to parse flags response JSON: \(error)") // Replaces logger.error [cite: 18] - return nil - } + if let foundFeature = featureData { + // If tracking was done *in this call*, call the delegate + if tracked { + self._performTrackingDelegateCall(featureName: featureName, feature: foundFeature) } - - // Build the resource [cite: 51] - let resource = Network.buildResource(path: flagsRoute, // e.g., "/flags" - method: .post, - requestBody: requestBodyData, - headers: headers, - parse: responseParser) // [cite: 52] - - // Make the API request [cite: 42] - Network.apiRequest(base: serverURL, // e.g., "https://api.mixpanel.com" [cite: 36] - resource: resource, - failure: { reason, data, response in - print("Error: Failed to fetch flags. Reason: \(reason)") // Replaces logger.error [cite: 18] - if let data = data, let responseString = String(data: data, encoding: .utf8) { - print("Error response body: \(responseString)") - } - self.completeFetch(success: false) - }, - success: { [weak self] (flagsResponse, response) in // [cite: 16] - print("Successfully fetched flags.") - self?.accessQueue.sync(flags: .barrier) { // Write needs barrier - self?.flags = flagsResponse.flags ?? [:] // Store fetched flags [cite: 17] - } - self?.completeFetch(success: true) - }) + return foundFeature + } else { + print("Info: Flag '\(featureName)' not found or flags not ready. Returning fallback.") + return fallback } } - private func completeFetch(success: Bool) { - accessQueue.sync(flags: .barrier) { // Write needs barrier - let handlers = self.fetchCompletionHandlers - self.fetchCompletionHandlers.removeAll() - self.isFetching = false - // Notify all queued handlers - DispatchQueue.main.async { // Call handlers on main thread - handlers.forEach { $0(success) } - } - } + func getFeatureDataSync(_ featureName: String, fallbackValue: Any? = nil) -> Any? { + return getFeatureSync(featureName, fallback: FeatureFlagData(value: fallbackValue)).value + } + + func isFeatureEnabledSync(_ featureName: String, fallbackValue: Bool = false) -> Bool { + let dataValue = getFeatureDataSync(featureName, fallbackValue: fallbackValue) + return self._evaluateBooleanFlag(featureName: featureName, dataValue: dataValue, fallbackValue: fallbackValue) } - // --- Getting Feature Flags (Async) --- + // --- Async Flag Retrieval --- - // Use completion handler pattern similar to Network class func getFeature(_ featureName: String, fallback: FeatureFlagData = FeatureFlagData(value: nil), completion: @escaping (FeatureFlagData) -> Void) { - accessQueue.async { // Read can be concurrent - if self.flags != nil { - // Flags already loaded, return sync result immediately on main thread - let result = self._getFeatureSync(featureName, fallback: fallback) + accessQueue.async { [weak self] in // Block A runs serially on accessQueue + guard let self = self else { return } + + var featureData: FeatureFlagData? + var needsTrackingCheck = false + var flagsAreCurrentlyReady = false + + // === Access state DIRECTLY within the async block === + // No inner sync needed - we are already synchronized by the serial queue + flagsAreCurrentlyReady = (self.flags != nil) + if flagsAreCurrentlyReady, let currentFlags = self.flags { + if let feature = currentFlags[featureName] { + featureData = feature + // Also safe to access trackedFeatures directly here + needsTrackingCheck = !self.trackedFeatures.contains(featureName) + } + } + // === State access finished === + + if flagsAreCurrentlyReady { + let result = featureData ?? fallback + if featureData != nil, needsTrackingCheck { + // Perform atomic check-and-track. _trackFeatureIfNeeded uses its + // own sync block, which is safe to call from here (it's not nested). + self._trackFeatureIfNeeded(featureName: featureName, feature: result) + } DispatchQueue.main.async { completion(result) } + } else { - // Flags not loaded, trigger fetch and call completion when done - DispatchQueue.main.async { // Ensure fetchFlags is called from a consistent thread if needed, or manage internally - self.fetchFlags { [weak self] success in - guard let self = self else { - completion(fallback) - return - } - if success { - let result = self._getFeatureSync(featureName, fallback: fallback) // Called within fetch completion, safe to access flags - completion(result) - } else { - print("Warning: Failed to fetch flags, returning fallback for \(featureName).") - completion(fallback) - } + // --- Flags were NOT ready --- + // Trigger fetch; fetch completion will handle calling the original completion handler + print("Flags not ready, attempting fetch for getFeature call...") + self._fetchFlagsIfNeeded { success in + // This completion runs *after* fetch completes (or fails) + let result: FeatureFlagData + if success { + // Fetch succeeded, get the feature SYNCHRONOUSLY + result = self.getFeatureSync(featureName, fallback: fallback) + } else { + print("Warning: Failed to fetch flags, returning fallback for \(featureName).") + result = fallback } + // Call original completion (on main thread) + DispatchQueue.main.async { completion(result) } } + + return // Exit Block A early, fetch completion handles the callback. + } - } + } // End accessQueue.async (Block A) } @@ -301,145 +280,164 @@ class FeatureFlagManager: Network { } func isFeatureEnabled(_ featureName: String, fallbackValue: Bool = false, completion: @escaping (Bool) -> Void) { - // Fetch the data first, then evaluate if it's true/false getFeatureData(featureName, fallbackValue: fallbackValue) { [weak self] dataValue in guard let self = self else { completion(fallbackValue) return } - // Use the sync logic for evaluation after data is retrieved - completion(self._isFeatureEnabledSync(featureName: featureName, dataValue: dataValue, fallbackValue: fallbackValue)) + let result = self._evaluateBooleanFlag(featureName: featureName, dataValue: dataValue, fallbackValue: fallbackValue) + completion(result) } } + // --- Fetching Logic (Simplified by Serial Queue) --- - // --- Getting Feature Flags (Sync) --- - - // Private helper to avoid queue logic repetition, assumes flags are loaded or called from within completion - private func _getFeatureSync(_ featureName: String, fallback: FeatureFlagData) -> FeatureFlagData { - // Assumes called within accessQueue.sync or after flags are confirmed non-nil - guard let currentFlags = self.flags else { - // This path should ideally not be hit if areFeaturesReady is checked, but good for safety - print("Warning: getFeatureSync called before flags loaded for \(featureName).") // [cite: 21] - return fallback - } + // Internal function to handle fetch logic and state checks + private func _fetchFlagsIfNeeded(completion: ((Bool) -> Void)?) { - guard let feature = currentFlags[featureName] else { - print("Info: No flag found for '\(featureName)', returning fallback.") // [cite: 23] - return fallback + var shouldStartFetch = false + let configSnapshot = self.currentConfig // Read config directly (safe on accessQueue) + + + guard let config = configSnapshot, config.flagsConfig.enabled else { + print("Feature flags are disabled, not fetching.") + // Call completion immediately since we know the result and are on the queue. + completion?(false) + return // Exit method } - // Track experiment exposure [cite: 24] - trackFeatureCheck(featureName: featureName, feature: feature) - return feature - } - - // Public sync methods require careful usage - check areFeaturesReady() first! - func getFeatureSync(_ featureName: String, fallback: FeatureFlagData = FeatureFlagData(value: nil)) -> FeatureFlagData { - guard areFeaturesReady() else { - print("Warning: Flags not ready for getFeatureSync call for \(featureName). Returning fallback.") // [cite: 21] - return fallback + // Access/Modify isFetching and fetchCompletionHandlers directly (safe on accessQueue) + if !self.isFetching { + self.isFetching = true + shouldStartFetch = true + if let completion = completion { + self.fetchCompletionHandlers.append(completion) + } + } else { + print("Fetch already in progress, queueing completion handler.") + if let completion = completion { + self.fetchCompletionHandlers.append(completion) + } } - // Access flags safely using the queue - var result: FeatureFlagData! - accessQueue.sync { // Read needs sync access - // We know flags is not nil here due to areFeaturesReady check - result = self._getFeatureSync(featureName, fallback: fallback) + // State modifications related to starting the fetch are complete + + if shouldStartFetch { + print("Starting flag fetch (dispatching network request)...") + // Perform network request OUTSIDE the serial accessQueue context + // to avoid blocking the queue during network latency. + // Dispatch the network request initiation to a global queue. + DispatchQueue.global(qos: .utility).async { [weak self] in + self?._performFetchRequest() + } } - return result - } - - - func getFeatureDataSync(_ featureName: String, fallbackValue: Any? = nil) -> Any? { - return getFeatureSync(featureName, fallback: FeatureFlagData(value: fallbackValue)).value } - // Private helper for boolean evaluation - private func _isFeatureEnabledSync(featureName: String, dataValue: Any?, fallbackValue: Bool) -> Bool { - guard let val = dataValue else { - print("Info: Feature flag '\(featureName)' value is nil; returning fallback: \(fallbackValue)") - return fallbackValue + // Performs the actual network request construction and call + private func _performFetchRequest() { + // This method runs OUTSIDE the accessQueue + + guard let delegate = self.delegate, let config = self.currentConfig else { + print("Error: Delegate or config missing for fetch.") + self._completeFetch(success: false) + return } - if let boolVal = val as? Bool { - return boolVal // [cite: 28] - } else { - // Log error if value is not a boolean [cite: 28] - print("Error: Feature flag '\(featureName)' value: \(val) is not a boolean; returning fallback: \(fallbackValue)") - return fallbackValue // [cite: 29] + let distinctId = delegate.getDistinctId() + print("Fetching flags for distinct ID: \(distinctId)") + + var context = config.flagsConfig.context + context["distinct_id"] = distinctId + let requestBodyDict = ["context": context] + + guard let requestBodyData = try? JSONSerialization.data(withJSONObject: requestBodyDict, options: []) else { + print("Error: Failed to serialize request body for flags.") + self._completeFetch(success: false); return } + guard let authData = "\(config.token):".data(using: .utf8) else { + print("Error: Failed to create auth data."); self._completeFetch(success: false); return + } + let base64Auth = authData.base64EncodedString() + let headers = ["Authorization": "Basic \(base64Auth)", "Content-Type": "application/json"] + let responseParser: (Data) -> FlagsResponse? = { data in /* ... */ + do { return try JSONDecoder().decode(FlagsResponse.self, from: data) } + catch { print("Error parsing flags JSON: \(error)"); return nil } + } + let resource = Network.buildResource(path: flagsRoute, method: .post, requestBody: requestBodyData, headers: headers, parse: responseParser) + + // Make the API request + Network.apiRequest( + base: serverURL, + resource: resource, + failure: { [weak self] reason, data, response in // Completion handlers run on URLSession's queue + print("Error: Failed to fetch flags. Reason: \(reason)") + // Update state and call completions via _completeFetch on the serial queue + self?.accessQueue.async { // Dispatch completion handling to serial queue + self?._completeFetch(success: false) + } + }, + success: { [weak self] (flagsResponse, response) in // Completion handlers run on URLSession's queue + print("Successfully fetched flags.") + guard let self = self else { return } + // Update state and call completions via _completeFetch on the serial queue + self.accessQueue.async { [weak self] in + guard let self = self else { return } + // already on accessQueue – write directly + self.flags = flagsResponse.flags ?? [:] + self._completeFetch(success: true) // still on accessQueue + } + } + ) } - func isFeatureEnabledSync(_ featureName: String, fallbackValue: Bool = false) -> Bool { // [cite: 27] - let dataValue = getFeatureDataSync(featureName, fallbackValue: fallbackValue) - return _isFeatureEnabledSync(featureName: featureName, dataValue: dataValue, fallbackValue: fallbackValue) + // Centralized fetch completion logic - MUST be called from within accessQueue + func _completeFetch(success: Bool) { + self.isFetching = false + let handlers = self.fetchCompletionHandlers + self.fetchCompletionHandlers.removeAll() + + DispatchQueue.main.async { + handlers.forEach { $0(success) } + } } - // --- Tracking --- + // --- Tracking Logic --- - private func trackFeatureCheck(featureName: String, feature: FeatureFlagData) { - accessQueue.sync(flags: .barrier) { // Write needs barrier - guard !self.trackedFeatures.contains(featureName) else { // [cite: 30] - return - } - self.trackedFeatures.insert(featureName) // [cite: 31] + // Performs the atomic check and triggers delegate call if needed + private func _trackFeatureIfNeeded(featureName: String, feature: FeatureFlagData) { + var shouldCallDelegate = false + + // We are already executing on the serial accessQueue, so this is safe. + if !self.trackedFeatures.contains(featureName) { + self.trackedFeatures.insert(featureName) + shouldCallDelegate = true } - // Call the tracking function provided during initialization + // Call delegate *outside* this conceptual block if tracking occurred + // This prevents holding any potential implicit lock during delegate execution + if shouldCallDelegate { + self._performTrackingDelegateCall(featureName: featureName, feature: feature) + } + } + + // Helper to just call the delegate (no locking) + private func _performTrackingDelegateCall(featureName: String, feature: FeatureFlagData) { + guard let delegate = self.delegate else { return } let properties: Properties = [ - "Experiment name": featureName, - "Variant name": feature.key, - "$experiment_type": "feature_flag" + "Experiment name": featureName, "Variant name": feature.key, "$experiment_type": "feature_flag" ] - if let instance = getInstance() { - instance.track(event: "$experiment_started", properties: properties) - print("Tracked $experiment_started for \(featureName)") + // Dispatch delegate call asynchronously to main thread for safety + DispatchQueue.main.async { + delegate.track(event: "$experiment_started", properties: properties) + print("Tracked $experiment_started for \(featureName) (dispatched to main)") } } + + // --- Boolean Evaluation Helper --- + private func _evaluateBooleanFlag(featureName: String, dataValue: Any?, fallbackValue: Bool) -> Bool { + guard let val = dataValue else { return fallbackValue } + if let boolVal = val as? Bool { return boolVal } + else { print("Error: Flag '\(featureName)' is not Bool"); return fallbackValue } + } } - -// --- Example Usage Placeholder (Requires Mixpanel instance setup) --- -/* - // Assuming you have a Mixpanel instance and Network setup: - let mixpanelInstance = Mixpanel.initialize(token: "YOUR_TOKEN", launchOptions: nil, flushInterval: 60) - let network = Network(serverURL: mixpanelInstance.serverURL) // Or however Network gets initialized - - let featureFlagManager = FeatureFlagManager( - getConfigFunc: { key in mixpanelInstance.configuration.get(key) }, // Adapt based on actual config access - getDistinctIdFunc: { mixpanelInstance.distinctId }, - trackFunc: { eventName, properties in mixpanelInstance.track(event: eventName, properties: properties) }, - network: network - ) - - // Load flags initially (e.g., during app startup) - featureFlagManager.loadFlags() - - // Later, check a flag (async) - featureFlagManager.isFeatureEnabled("new_checkout_flow", fallbackValue: false) { isEnabled in - if isEnabled { - print("New checkout flow is enabled!") - // Show new UI - } else { - print("New checkout flow is disabled.") - // Show old UI - } - } - - // Or check synchronously *after* confirming flags are loaded - if featureFlagManager.areFeaturesReady() { - let buttonColorData = featureFlagManager.getFeatureDataSync("button_color", fallbackValue: "blue") - if let buttonColor = buttonColorData as? String { - print("Button color variant: \(buttonColor)") - // Apply button color - } - - let shouldUseNewAPI = featureFlagManager.isFeatureEnabledSync("use_new_api", fallbackValue: false) - print("Should use new API (sync): \(shouldUseNewAPI)") - - } else { - print("Flags not ready yet for sync access.") - // Use default behavior or wait - } - */ diff --git a/Sources/MixpanelConfig.swift b/Sources/MixpanelConfig.swift index c4d441aa..bfbf1767 100644 --- a/Sources/MixpanelConfig.swift +++ b/Sources/MixpanelConfig.swift @@ -19,8 +19,7 @@ public class MixpanelConfig { public let serverURL: String? public let proxyServerConfig: ProxyServerConfig? public let useGzipCompression: Bool - public let flagsEnabled: Bool - public let flagsContext: Dictionary? + public let flagsConfig: FlagsConfig public init(token: String, flushInterval: Double = 60, @@ -32,8 +31,7 @@ public class MixpanelConfig { serverURL: String? = nil, proxyServerConfig: ProxyServerConfig? = nil, useGzipCompression: Bool = true, // NOTE: This is a new default value! - flagsEnabled: Bool = false, - flagsContext: Dictionary? = nil) { + flagsConfig: FlagsConfig = FlagsConfig()) { self.token = token self.flushInterval = flushInterval self.instanceName = instanceName @@ -44,7 +42,6 @@ public class MixpanelConfig { self.serverURL = serverURL self.proxyServerConfig = proxyServerConfig self.useGzipCompression = useGzipCompression - self.flagsEnabled = flagsEnabled - self.flagsContext = flagsContext + self.flagsConfig = flagsConfig } } diff --git a/Sources/MixpanelInstance.swift b/Sources/MixpanelInstance.swift index 48d2d76f..83c1deb3 100644 --- a/Sources/MixpanelInstance.swift +++ b/Sources/MixpanelInstance.swift @@ -75,8 +75,8 @@ public struct ProxyServerConfig { } /// The class that represents the Mixpanel Instance -open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDelegate { - +open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDelegate, FeatureFlagDelegate { + private let config: MixpanelConfig /// apiToken string that identifies the project to track data to @@ -383,8 +383,9 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele instanceName: self.name, lock: self.readWriteLock, metadata: sessionMetadata, mixpanelPersistence: mixpanelPersistence) - featureFlagManager = FeatureFlagManager(serverURL: self.serverURL, instanceName: self.name) + featureFlagManager = FeatureFlagManager(serverURL: self.serverURL) trackInstance.mixpanelInstance = self + featureFlagManager.delegate = self #if os(iOS) && !targetEnvironment(macCatalyst) if let reachability = MixpanelInstance.reachability { var context = SCNetworkReachabilityContext(version: 0, info: nil, retain: nil, release: nil, copyDescription: nil) @@ -436,12 +437,17 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele automaticEvents.initializeEvents(instanceName: self.name) } #endif + featureFlagManager.loadFlags() } public func getConfig() -> MixpanelConfig { return config } + func getDistinctId() -> String { + return distinctId + } + #if !os(OSX) && !os(watchOS) private func setupListeners() { let notificationCenter = NotificationCenter.default From 27311c4657de12539bd296334d62d33ab611479c Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Tue, 22 Apr 2025 11:53:01 -0700 Subject: [PATCH 03/20] add new sources to podspec --- Mixpanel-swift.podspec | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Mixpanel-swift.podspec b/Mixpanel-swift.podspec index d8845952..6cc49d9c 100644 --- a/Mixpanel-swift.podspec +++ b/Mixpanel-swift.podspec @@ -19,9 +19,9 @@ Pod::Spec.new do |s| base_source_files = ['Sources/Network.swift', 'Sources/FlushRequest.swift', 'Sources/PrintLogging.swift', 'Sources/FileLogging.swift', 'Sources/MixpanelLogger.swift', 'Sources/JSONHandler.swift', 'Sources/Error.swift', 'Sources/AutomaticProperties.swift', 'Sources/Constants.swift', 'Sources/MixpanelType.swift', 'Sources/Mixpanel.swift', 'Sources/MixpanelInstance.swift', - 'Sources/Flush.swift','Sources/Track.swift', 'Sources/People.swift', 'Sources/AutomaticEvents.swift', - 'Sources/Group.swift', - 'Sources/ReadWriteLock.swift', 'Sources/SessionMetadata.swift', 'Sources/MPDB.swift', 'Sources/MixpanelPersistence.swift', 'Sources/Data+Compression.swift'] + 'Sources/Flush.swift', 'Sources/Track.swift', 'Sources/People.swift', 'Sources/AutomaticEvents.swift', + 'Sources/Group.swift', 'Sources/ReadWriteLock.swift', 'Sources/SessionMetadata.swift', 'Sources/MPDB.swift', 'Sources/MixpanelPersistence.swift', + 'Sources/Data+Compression.swift', 'Sources/MixpanelConfig.swift', 'Sources/FeatureFlags.swift'] s.tvos.deployment_target = '11.0' s.tvos.frameworks = 'UIKit', 'Foundation' s.tvos.pod_target_xcconfig = { From 0d7a3c1219fd7de4daa9f2ef3e5a012c0aff20b6 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Wed, 23 Apr 2025 10:12:43 -0700 Subject: [PATCH 04/20] fix config init --- Sources/Mixpanel.swift | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/Sources/Mixpanel.swift b/Sources/Mixpanel.swift index f88804bb..2716fa98 100644 --- a/Sources/Mixpanel.swift +++ b/Sources/Mixpanel.swift @@ -16,13 +16,7 @@ open class Mixpanel { @discardableResult open class func initialize(config: MixpanelConfig) -> MixpanelInstance { - let instanceName = config.instanceName ?? config.token - - if let proxyServerConfig = config.proxyServerConfig { - return MixpanelManager.sharedInstance.initialize(config: config) - } else { - return MixpanelManager.sharedInstance.initialize(config: config) - } + return MixpanelManager.sharedInstance.initialize(config: config) } #if !os(OSX) && !os(watchOS) @@ -297,7 +291,8 @@ final class MixpanelManager { } func initialize(config: MixpanelConfig) -> MixpanelInstance { - return dequeueInstance(instanceName: config.instanceName ?? config.token) { + let instanceName = config.instanceName ?? config.token + return dequeueInstance(instanceName: instanceName) { return MixpanelInstance(config: config) } } From 765aa1b9acdaa8af24a6713026aa7987ee66c118 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Thu, 24 Apr 2025 15:29:26 -0700 Subject: [PATCH 05/20] tests and cleanup --- .../MixpanelDemo/TrackingViewController.swift | 49 ++- .../MixpanelFeatureFlagTests.swift | 302 ++++++++++++++++++ Sources/FeatureFlags.swift | 115 +++---- Sources/MixpanelInstance.swift | 67 ++++ 4 files changed, 466 insertions(+), 67 deletions(-) diff --git a/MixpanelDemo/MixpanelDemo/TrackingViewController.swift b/MixpanelDemo/MixpanelDemo/TrackingViewController.swift index d40e9581..b511eabc 100644 --- a/MixpanelDemo/MixpanelDemo/TrackingViewController.swift +++ b/MixpanelDemo/MixpanelDemo/TrackingViewController.swift @@ -21,7 +21,15 @@ class TrackingViewController: UIViewController, UITableViewDelegate, UITableView "Register SuperProperties", "Register SuperProperties Once", "Register SP Once w Default Value", - "Unregister SuperProperty"] + "Unregister SuperProperty", + "Load Flags", + "Are Features Ready", + "Get Feature", + "Get Feature Sync", + "Get Feature Data", + "Get Feature Data Sync", + "Is Feature Enabled", + "Is Feature Enabled Sync"] override func viewDidLoad() { super.viewDidLoad() @@ -92,6 +100,45 @@ class TrackingViewController: UIViewController, UITableViewDelegate, UITableView let p = "Super Property 2" Mixpanel.mainInstance().unregisterSuperProperty(p) descStr = "Properties: \(p)" + case 10: + Mixpanel.mainInstance().loadFlags() + descStr = "Flags Loaded" + case 11: + let ready = Mixpanel.mainInstance().areFeaturesReady() + descStr = "Features Ready: \(ready)" + case 12: + var flagData = FeatureFlagData(key: "super-neat") + Mixpanel.mainInstance().getFeature("marks_nifty_feature_flag", fallback: flagData) { data in + flagData = data + print("Feature: \(flagData.key), Value: \(String(describing: flagData.value))") + } + descStr = "Feature: \(flagData.key), Value: \(String(describing: flagData.value))" + case 13: + var flagData = FeatureFlagData(key: "enabled") + flagData = Mixpanel.mainInstance().getFeatureSync("jb_qa_flag", fallback: flagData) + descStr = "Feature: \(flagData.key), Value: \(String(describing: flagData.value))" + case 14: + var flagValue = "NOT_donnaqacontrol" + Mixpanel.mainInstance().getFeatureData("new_feature_flag_1744737773860", fallbackValue: flagValue) { value in + flagValue = value as! String + print("Feature Value: \(flagValue)") + } + descStr = "Feature Value: \(flagValue)" + case 15: + var flagValue = "NOT_donnaqacontrol" + flagValue = Mixpanel.mainInstance().getFeatureDataSync("new_feature_flag_1744737773860", fallbackValue: flagValue) as! String + descStr = "Feature Value: \(flagValue)" + case 16: + var enabled = false + Mixpanel.mainInstance().isFeatureEnabled("jared_boolean_flag", fallbackValue: enabled) { isEnabled in + enabled = isEnabled + print("Feature Enabled: \(enabled)") + } + descStr = "Feature Enabled: \(enabled)" + case 17: + var enabled = false + enabled = Mixpanel.mainInstance().isFeatureEnabledSync("jared_boolean_flag", fallbackValue: enabled) + descStr = "Feature Enabled: \(enabled)" default: break } diff --git a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift index e81bd6c4..84c535db 100644 --- a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift +++ b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift @@ -513,4 +513,306 @@ class FeatureFlagManagerTests: XCTestCase { XCTAssertEqual(trackEvents.count, 1, "Tracking should have occurred exactly once despite concurrent calls") } + // --- Response Parser Tests --- + +// func testResponseParserFunction() { +// // Get access to the responseParser function indirectly through _performFetchRequest +// // by making a property wrapper to capture the API request +// var capturedResource: Network.Resource? +// +// // Create a test wrapper that swizzles apiRequest just for the test +// let originalApiRequest = Network.apiRequest +// defer { Network.apiRequest = originalApiRequest } // Restore when done +// +// // Create a mock request function that captures the resource but doesn't execute +// Network.apiRequest = { base, resource, failure, success in +// // Capture the resource to inspect its parser function +// capturedResource = resource as? Network.Resource +// // Don't actually call any callbacks since we're just testing parser +// return +// } +// +// // Trigger _performFetchRequest by calling fetchFlagsIfNeeded +// manager.accessQueue.sync { +// manager.fetchF() +// } +// +// // Verify resource was captured +// XCTAssertNotNil(capturedResource, "Request resource should be captured") +// +// // Create various test data scenarios +// let validJSON = """ +// { +// "flags": { +// "test_flag": { +// "variant_key": "test_variant", +// "variant_value": "test_value" +// } +// } +// } +// """.data(using: .utf8)! +// +// let emptyFlagsJSON = """ +// { +// "flags": {} +// } +// """.data(using: .utf8)! +// +// let nullFlagsJSON = """ +// { +// "flags": null +// } +// """.data(using: .utf8)! +// +// let malformedJSON = "not json".data(using: .utf8)! +// +// // Test the parser with valid data +// if let resource = capturedResource { +// let parser = resource.parse +// +// // Test valid JSON with flags +// let validResult = parser(validJSON) +// XCTAssertNotNil(validResult, "Parser should handle valid JSON") +// XCTAssertNotNil(validResult?.flags, "Flags should be non-nil") +// XCTAssertEqual(validResult?.flags?.count, 1, "Should have one flag") +// XCTAssertEqual(validResult?.flags?["test_flag"]?.key, "test_variant") +// XCTAssertEqual(validResult?.flags?["test_flag"]?.value as? String, "test_value") +// +// // Test empty flags object +// let emptyResult = parser(emptyFlagsJSON) +// XCTAssertNotNil(emptyResult, "Parser should handle empty flags object") +// XCTAssertNotNil(emptyResult?.flags, "Flags should be non-nil") +// XCTAssertEqual(emptyResult?.flags?.count, 0, "Flags should be empty") +// +// // Test null flags field +// let nullResult = parser(nullFlagsJSON) +// XCTAssertNotNil(nullResult, "Parser should handle null flags") +// XCTAssertNil(nullResult?.flags, "Flags should be nil when null in JSON") +// +// // Test malformed JSON +// let malformedResult = parser(malformedJSON) +// XCTAssertNil(malformedResult, "Parser should return nil for malformed JSON") +// } +// } + + // --- Delegate Error Handling Tests --- + + func testDelegateNilHandling() { + // Set up with flags ready, but then remove delegate + simulateFetchSuccess() + manager.delegate = nil + + // Test all operations with nil delegate + + // Synchronous operations + let syncData = manager.getFeatureSync("feature_bool_true") + XCTAssertEqual(syncData.key, "v_true") + XCTAssertEqual(syncData.value as? Bool, true) + + // Async operations + let expectation = XCTestExpectation(description: "Async with nil delegate") + manager.getFeature("feature_int") { data in + XCTAssertEqual(data.key, "v_int") + XCTAssertEqual(data.value as? Int, 101) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + + // No tracking calls should succeed, but operations should still work + // This is "success" as the code doesn't crash when delegate is nil + } + + func testFetchWithNoDelegate() { + // Create manager with no delegate + let noDelegate = FeatureFlagManager(serverURL: "https://test.com", delegate: nil) + + // Try to load flags + noDelegate.loadFlags() + + // Verify no crash; attempt a flag fetch after a short delay + let expectation = XCTestExpectation(description: "Check after attempted fetch") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + XCTAssertFalse(noDelegate.areFeaturesReady(), "Flags should not be ready without delegate") + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func testDelegateConfigDisabledHandling() { + // Set delegate config to disabled + mockDelegate.config = MixpanelConfig(token: "test", flagsConfig: FlagsConfig(enabled: false)) + + // Try to load flags + manager.loadFlags() + + // Verify no fetch is triggered + let expectation = XCTestExpectation(description: "Check disabled config behavior") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + XCTAssertFalse(self.manager.areFeaturesReady(), "Flags should not be ready when config disabled") + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + // --- AnyCodable Edge Cases --- + + func testAnyCodableWithComplexTypes() { + // Use reflection to test AnyCodable directly + + // Test with nested array + let nestedArrayJSON = """ + { + "variant_key": "complex_array", + "variant_value": [1, "string", true, [2, 3], {"key": "value"}] + } + """.data(using: .utf8)! + + do { + let decoder = JSONDecoder() + let flagData = try decoder.decode(FeatureFlagData.self, from: nestedArrayJSON) + + XCTAssertEqual(flagData.key, "complex_array") + XCTAssertNotNil(flagData.value, "Value should not be nil") + + // Verify array structure + guard let array = flagData.value as? [Any?] else { + XCTFail("Value should be an array") + return + } + + XCTAssertEqual(array.count, 5, "Array should have 5 elements") + XCTAssertEqual(array[0] as? Int, 1) + XCTAssertEqual(array[1] as? String, "string") + XCTAssertEqual(array[2] as? Bool, true) + + // Nested array check + guard let nestedArray = array[3] as? [Any?] else { + XCTFail("Element 3 should be an array") + return + } + XCTAssertEqual(nestedArray.count, 2) + XCTAssertEqual(nestedArray[0] as? Int, 2) + XCTAssertEqual(nestedArray[1] as? Int, 3) + + // Nested dictionary check + guard let nestedDict = array[4] as? [String: Any?] else { + XCTFail("Element 4 should be a dictionary") + return + } + XCTAssertEqual(nestedDict.count, 1) + XCTAssertEqual(nestedDict["key"] as? String, "value") + + } catch { + XCTFail("Failed to decode nested array JSON: \(error)") + } + + // Test with deeply nested object + let nestedObjectJSON = """ + { + "variant_key": "complex_object", + "variant_value": { + "str": "value", + "num": 42, + "bool": true, + "null": null, + "array": [1, 2], + "nested": { + "deeper": { + "deepest": "bottom" + } + } + } + } + """.data(using: .utf8)! + + do { + let decoder = JSONDecoder() + let flagData = try decoder.decode(FeatureFlagData.self, from: nestedObjectJSON) + + XCTAssertEqual(flagData.key, "complex_object") + XCTAssertNotNil(flagData.value, "Value should not be nil") + + // Verify dictionary structure + guard let dict = flagData.value as? [String: Any?] else { + XCTFail("Value should be a dictionary") + return + } + + XCTAssertEqual(dict.count, 6, "Dictionary should have 6 keys") + XCTAssertEqual(dict["str"] as? String, "value") + XCTAssertEqual(dict["num"] as? Int, 42) + XCTAssertEqual(dict["bool"] as? Bool, true) + XCTAssertTrue(dict.keys.contains("null"), "Key 'null' should exist") + if let nullEntry = dict["null"] { + // Key exists with a value of nil (as wanted) + XCTAssertNil(nullEntry, "Value for null key should be nil") + } else { + // Key doesn't exist (which would be wrong) + XCTFail("'null' key should exist in dictionary") + } + + // Check nested array + guard let array = dict["array"] as? [Any?] else { + XCTFail("Array key should contain an array") + return + } + XCTAssertEqual(array.count, 2) + + // Check deeply nested structure + guard let nested = dict["nested"] as? [String: Any?] else { + XCTFail("Nested key should contain dictionary") + return + } + + guard let deeper = nested["deeper"] as? [String: Any?] else { + XCTFail("Deeper key should contain dictionary") + return + } + + XCTAssertEqual(deeper["deepest"] as? String, "bottom") + + } catch { + XCTFail("Failed to decode nested object JSON: \(error)") + } + } + + func testAnyCodableWithInvalidTypes() { + // Test case where variant_value has an unsupported type + // Note: This is harder to test directly since JSON doesn't have many "invalid" types + // We can test error handling by constructing invalid JSON manually + + let unsupportedTypeJSON = """ + { + "variant_key": "invalid_type", + "variant_value": "infinity" + } + """.data(using: .utf8)! + + // This is a valid test since the string will decode properly + do { + let decoder = JSONDecoder() + let flagData = try decoder.decode(FeatureFlagData.self, from: unsupportedTypeJSON) + XCTAssertEqual(flagData.key, "invalid_type") + XCTAssertEqual(flagData.value as? String, "infinity") + } catch { + XCTFail("Should not fail with simple string value: \(error)") + } + + // Test handling of missing variant_value + let missingValueJSON = """ + { + "variant_key": "missing_value" + } + """.data(using: .utf8)! + + do { + let decoder = JSONDecoder() + let _ = try decoder.decode(FeatureFlagData.self, from: missingValueJSON) + XCTFail("Decoding should fail with missing variant_value") + } catch { + // This is expected to fail, so the test passes + XCTAssertTrue(error is DecodingError, "Error should be a DecodingError") + } + } + } // End Test Class diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift index dcdc03af..b2817aee 100644 --- a/Sources/FeatureFlags.swift +++ b/Sources/FeatureFlags.swift @@ -1,58 +1,10 @@ import Foundation -// --- Helper Structures --- - -// Represents the data associated with a feature flag -struct FeatureFlagData: Decodable { - let key: String // Corresponds to 'variant_key' from API - let value: Any? // Corresponds to 'variant_value' from API - Use Any? for flexibility - - // Manual decoding to handle Any? for the value - enum CodingKeys: String, CodingKey { - case key = "variant_key" - case value = "variant_value" - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - key = try container.decode(String.self, forKey: .key) - - // Attempt to decode value flexibly (Bool, String, Int, Double, Array, Dictionary) - if let boolValue = try? container.decode(Bool.self, forKey: .value) { - value = boolValue - } else if let stringValue = try? container.decode(String.self, forKey: .value) { - value = stringValue - } else if let intValue = try? container.decode(Int.self, forKey: .value) { - value = intValue - } else if let doubleValue = try? container.decode(Double.self, forKey: .value) { - value = doubleValue - } else if let arrayValue = try? container.decode([AnyCodable].self, forKey: .value) { - value = arrayValue.map { $0.value } // Extract underlying values - } else if let dictValue = try? container.decode([String: AnyCodable].self, forKey: .value) { - value = dictValue.mapValues { $0.value } // Extract underlying values - } else if container.contains(.value) && (try? container.decodeNil(forKey: .value)) == true { - value = nil // Explicitly handle null - } - else { - // Log or handle the case where the type is unexpected or null - let context = DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unsupported type for variant_value or value is null.") - throw DecodingError.dataCorrupted(context) - // Or set value = nil if you prefer to silently ignore unknown types - // value = nil - } - } - - // Helper initializer for fallbacks - init(key: String = "", value: Any?) { - self.key = key - self.value = value - } -} - // Wrapper to help decode 'Any' types within Codable structures +// (Keep AnyCodable as defined previously, it holds the necessary decoding logic) struct AnyCodable: Decodable { let value: Any? - + init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let intValue = try? container.decode(Int.self) { @@ -69,8 +21,7 @@ struct AnyCodable: Decodable { value = dictValue.mapValues { $0.value } } else if container.decodeNil() { value = nil - } - else { + } else { let context = DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unsupported type in AnyCodable.") throw DecodingError.dataCorrupted(context) } @@ -78,6 +29,39 @@ struct AnyCodable: Decodable { } +// Represents the data associated with a feature flag +public struct FeatureFlagData: Decodable { + public let key: String // Corresponds to 'variant_key' from API + public let value: Any? // Corresponds to 'variant_value' from API + + enum CodingKeys: String, CodingKey { + case key = "variant_key" + case value = "variant_value" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + key = try container.decode(String.self, forKey: .key) + + // Directly decode the 'variant_value' using AnyCodable. + // If the key is missing, it throws. + // If the value is null, AnyCodable handles it. + // If the value is an unsupported type, AnyCodable throws. + let anyCodableValue = try container.decode(AnyCodable.self, forKey: .value) + value = anyCodableValue.value // Extract the underlying Any? value + } + + // Helper initializer with fallbacks, value defaults to key if nil + public init(key: String = "", value: Any? = nil) { + self.key = key + if let value = value { + self.value = value + } else { + self.value = key + } + } +} + // Response structure for the /flags endpoint struct FlagsResponse: Decodable { let flags: [String: FeatureFlagData]? // Dictionary where key is feature name @@ -173,7 +157,7 @@ class FeatureFlagManager: Network { // --- Sync Flag Retrieval --- - func getFeatureSync(_ featureName: String, fallback: FeatureFlagData = FeatureFlagData(value: nil)) -> FeatureFlagData { + func getFeatureSync(_ featureName: String, fallback: FeatureFlagData) -> FeatureFlagData { var featureData: FeatureFlagData? var tracked = false // === Serial Queue: Single Sync Block for Read AND Track Update === @@ -207,19 +191,9 @@ class FeatureFlagManager: Network { } } - func getFeatureDataSync(_ featureName: String, fallbackValue: Any? = nil) -> Any? { - return getFeatureSync(featureName, fallback: FeatureFlagData(value: fallbackValue)).value - } - - func isFeatureEnabledSync(_ featureName: String, fallbackValue: Bool = false) -> Bool { - let dataValue = getFeatureDataSync(featureName, fallbackValue: fallbackValue) - return self._evaluateBooleanFlag(featureName: featureName, dataValue: dataValue, fallbackValue: fallbackValue) - } - - // --- Async Flag Retrieval --- - func getFeature(_ featureName: String, fallback: FeatureFlagData = FeatureFlagData(value: nil), completion: @escaping (FeatureFlagData) -> Void) { + func getFeature(_ featureName: String, fallback: FeatureFlagData, completion: @escaping (FeatureFlagData) -> Void) { accessQueue.async { [weak self] in // Block A runs serially on accessQueue guard let self = self else { return } @@ -272,13 +246,21 @@ class FeatureFlagManager: Network { } // End accessQueue.async (Block A) } + func getFeatureDataSync(_ featureName: String, fallbackValue: Any?) -> Any? { + return getFeatureSync(featureName, fallback: FeatureFlagData(value: fallbackValue)).value + } - func getFeatureData(_ featureName: String, fallbackValue: Any? = nil, completion: @escaping (Any?) -> Void) { + func getFeatureData(_ featureName: String, fallbackValue: Any?, completion: @escaping (Any?) -> Void) { getFeature(featureName, fallback: FeatureFlagData(value: fallbackValue)) { featureData in completion(featureData.value) } } + func isFeatureEnabledSync(_ featureName: String, fallbackValue: Bool = false) -> Bool { + let dataValue = getFeatureDataSync(featureName, fallbackValue: fallbackValue) + return self._evaluateBooleanFlag(featureName: featureName, dataValue: dataValue, fallbackValue: fallbackValue) + } + func isFeatureEnabled(_ featureName: String, fallbackValue: Bool = false, completion: @escaping (Bool) -> Void) { getFeatureData(featureName, fallbackValue: fallbackValue) { [weak self] dataValue in guard let self = self else { @@ -359,7 +341,7 @@ class FeatureFlagManager: Network { } let base64Auth = authData.base64EncodedString() let headers = ["Authorization": "Basic \(base64Auth)", "Content-Type": "application/json"] - let responseParser: (Data) -> FlagsResponse? = { data in /* ... */ + let responseParser: (Data) -> FlagsResponse? = { data in do { return try JSONDecoder().decode(FlagsResponse.self, from: data) } catch { print("Error parsing flags JSON: \(error)"); return nil } } @@ -384,6 +366,7 @@ class FeatureFlagManager: Network { guard let self = self else { return } // already on accessQueue – write directly self.flags = flagsResponse.flags ?? [:] + print("Flags updated: \(self.flags ?? [:])") self._completeFetch(success: true) // still on accessQueue } } diff --git a/Sources/MixpanelInstance.swift b/Sources/MixpanelInstance.swift index 83c1deb3..798eaf58 100644 --- a/Sources/MixpanelInstance.swift +++ b/Sources/MixpanelInstance.swift @@ -448,6 +448,73 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele return distinctId } + // MARK: - Feature Flag Methods + + /// Triggers a fetch of feature flags from the server + public func loadFlags() { + featureFlagManager.loadFlags() + } + + /// Returns whether feature flags have been successfully loaded + /// - Returns: True if flags are loaded and ready to use, false otherwise + public func areFeaturesReady() -> Bool { + return featureFlagManager.areFeaturesReady() + } + + /// Returns a feature flag synchronously with the specified fallback if not available + /// - Parameters: + /// - featureName: The name of the feature flag to retrieve + /// - fallback: FeatureFlagData to return if the feature flag doesn't exist or flags aren't loaded + /// - Returns: The FeatureFlagData of the feature flag, or the fallback if not available + public func getFeatureSync(_ featureName: String, fallback: FeatureFlagData) -> FeatureFlagData { + return featureFlagManager.getFeatureSync(featureName, fallback: fallback) + } + + /// Gets a feature flag asynchronously + /// - Parameters: + /// - featureName: The name of the feature flag to retrieve + /// - fallback: FeatureFlagData to return if the feature flag doesn't exist or flags aren't loaded + /// - completion: Callback function that receives the FeatureFlagData + public func getFeature(_ featureName: String, fallback: FeatureFlagData, completion: @escaping (FeatureFlagData) -> Void) { + featureFlagManager.getFeature(featureName, fallback: fallback, completion: completion) + } + + /// Gets feature data synchronously + /// - Parameters: + /// - featureName: The name of the feature flag to retrieve + /// - fallbackValue: Value to return if the feature flag doesn't exist or flags aren't loaded + /// - Returns: The value of the feature flag, or the fallback if not available + public func getFeatureDataSync(_ featureName: String, fallbackValue: Any?) -> Any? { + return featureFlagManager.getFeatureDataSync(featureName, fallbackValue: fallbackValue) + } + + /// Gets feature data asynchronously + /// - Parameters: + /// - featureName: The name of the feature flag to retrieve + /// - fallbackValue: Value to return if the feature flag doesn't exist or flags aren't loaded + /// - completion: Callback function that receives the feature value + public func getFeatureData(_ featureName: String, fallbackValue: Any?, completion: @escaping (Any?) -> Void) { + featureFlagManager.getFeatureData(featureName, fallbackValue: fallbackValue, completion: completion) + } + + /// Check if a boolean feature flag is enabled + /// - Parameters: + /// - featureName: The name of the feature flag to check + /// - fallbackValue: Value to return if the feature flag doesn't exist or flags aren't loaded + /// - Returns: True if the feature is enabled, false otherwisxtee + public func isFeatureEnabledSync(_ featureName: String, fallbackValue: Bool = false) -> Bool { + return featureFlagManager.isFeatureEnabledSync(featureName, fallbackValue: fallbackValue) + } + + /// Check if a boolean feature flag is enabled asynchronously + /// - Parameters: + /// - featureName: The name of the feature flag to check + /// - fallbackValue: Value to return if the feature flag doesn't exist or flags aren't loaded + /// - completion: Callback function that receives the boolean result + public func isFeatureEnabled(_ featureName: String, fallbackValue: Bool = false, completion: @escaping (Bool) -> Void) { + featureFlagManager.isFeatureEnabled(featureName, fallbackValue: fallbackValue, completion: completion) + } + #if !os(OSX) && !os(watchOS) private func setupListeners() { let notificationCenter = NotificationCenter.default From e3515d0f0263b431023ea890ed21a09c72815249 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Wed, 30 Apr 2025 14:07:45 -0700 Subject: [PATCH 06/20] pass to AnalyticsMessages and add to mixpaneldemo --- MixpanelDemo/MixpanelDemo/AppDelegate.swift | 1 + .../MixpanelFeatureFlagTests.swift | 18 +++++++++--------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/MixpanelDemo/MixpanelDemo/AppDelegate.swift b/MixpanelDemo/MixpanelDemo/AppDelegate.swift index 9dfc2d59..ae656024 100644 --- a/MixpanelDemo/MixpanelDemo/AppDelegate.swift +++ b/MixpanelDemo/MixpanelDemo/AppDelegate.swift @@ -18,6 +18,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { let mixpanelConfig = MixpanelConfig(token: "metrics-1", trackAutomaticEvents: false, flagsConfig: FlagsConfig(enabled: true, context: ["key": "value"])) Mixpanel.initialize(config: mixpanelConfig) + print("apiToken \(Mixpanel.mainInstance().apiToken)") Mixpanel.mainInstance().loggingEnabled = true return true diff --git a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift index 84c535db..ed559852 100644 --- a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift +++ b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift @@ -190,7 +190,7 @@ class FeatureFlagManagerTests: XCTestCase { func testGetFeatureSync_FlagsReady_ExistingFlag() { simulateFetchSuccess() // Flags loaded - let featureData = manager.getFeatureSync("feature_string") + let featureData = manager.getFeatureSync("feature_string", fallback: defaultFallback) AssertEqual(featureData.key, "v_str") AssertEqual(featureData.value, "test_string") // Tracking check happens later @@ -271,7 +271,7 @@ class FeatureFlagManagerTests: XCTestCase { var assertionError: String? // Act - manager.getFeature("feature_double") { data in + manager.getFeature("feature_double", fallback: defaultFallback) { data in // This completion should run on the main thread if !Thread.isMainThread { assertionError = "Completion not on main thread (\(Thread.current))" } receivedData = data @@ -331,7 +331,7 @@ class FeatureFlagManagerTests: XCTestCase { mockDelegate.trackExpectation = XCTestExpectation(description: "Tracking call for fetch success") // Call getFeature - this should trigger the fetch logic internally - manager.getFeature("feature_int") { data in + manager.getFeature("feature_int", fallback: defaultFallback) { data in XCTAssertTrue(Thread.isMainThread, "Completion should be on main thread") receivedData = data expectation.fulfill() // Fulfill main expectation @@ -392,13 +392,13 @@ class FeatureFlagManagerTests: XCTestCase { mockDelegate.trackExpectation?.expectedFulfillmentCount = 1 // Expect exactly one call // Call sync methods multiple times - _ = manager.getFeatureSync("feature_bool_true") - _ = manager.getFeatureDataSync("feature_bool_true") + _ = manager.getFeatureSync("feature_bool_true", fallback: defaultFallback) + _ = manager.getFeatureDataSync("feature_bool_true", fallbackValue: nil) _ = manager.isFeatureEnabledSync("feature_bool_true") // Call async method let asyncExpectation = XCTestExpectation(description: "Async getFeature completes for tracking test") - manager.getFeature("feature_bool_true") { _ in asyncExpectation.fulfill() } + manager.getFeature("feature_bool_true", fallback: defaultFallback) { _ in asyncExpectation.fulfill() } // Wait for async call AND the track expectation wait(for: [asyncExpectation, mockDelegate.trackExpectation!], timeout: 2.0) @@ -409,7 +409,7 @@ class FeatureFlagManagerTests: XCTestCase { // --- Call for a *different* feature --- mockDelegate.trackExpectation = XCTestExpectation(description: "Track called for feature_string") - _ = manager.getFeatureSync("feature_string") + _ = manager.getFeatureSync("feature_string", fallback: defaultFallback) wait(for: [mockDelegate.trackExpectation!], timeout: 1.0) let stringEvents = mockDelegate.trackedEvents.filter { $0.properties?["Experiment name"] as? String == "feature_string" } @@ -423,7 +423,7 @@ class FeatureFlagManagerTests: XCTestCase { simulateFetchSuccess() mockDelegate.trackExpectation = XCTestExpectation(description: "Track called for properties check") - _ = manager.getFeatureSync("feature_int") // Trigger tracking + _ = manager.getFeatureSync("feature_int", fallback: defaultFallback) // Trigger tracking wait(for: [mockDelegate.trackExpectation!], timeout: 1.0) @@ -480,7 +480,7 @@ class FeatureFlagManagerTests: XCTestCase { let exp = XCTestExpectation(description: "Async getFeature \(i) completes") expectations.append(exp) DispatchQueue.global().async { // Simulate calls from different threads - self.manager.getFeature("feature_bool_true") { data in + self.manager.getFeature("feature_bool_true", fallback: self.defaultFallback) { data in print("Completion handler \(i) called.") completionResults[i] = data exp.fulfill() From ba5281b45793fa2c7dafab7608431530e0d71f1e Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Thu, 1 May 2025 09:28:31 -0700 Subject: [PATCH 07/20] fix test --- MixpanelDemo/MixpanelDemo/AppDelegate.swift | 1 - MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/MixpanelDemo/MixpanelDemo/AppDelegate.swift b/MixpanelDemo/MixpanelDemo/AppDelegate.swift index ae656024..9dfc2d59 100644 --- a/MixpanelDemo/MixpanelDemo/AppDelegate.swift +++ b/MixpanelDemo/MixpanelDemo/AppDelegate.swift @@ -18,7 +18,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { let mixpanelConfig = MixpanelConfig(token: "metrics-1", trackAutomaticEvents: false, flagsConfig: FlagsConfig(enabled: true, context: ["key": "value"])) Mixpanel.initialize(config: mixpanelConfig) - print("apiToken \(Mixpanel.mainInstance().apiToken)") Mixpanel.mainInstance().loggingEnabled = true return true diff --git a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift index ed559852..20959075 100644 --- a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift +++ b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift @@ -605,13 +605,13 @@ class FeatureFlagManagerTests: XCTestCase { // Test all operations with nil delegate // Synchronous operations - let syncData = manager.getFeatureSync("feature_bool_true") + let syncData = manager.getFeatureSync("feature_bool_true", fallback: defaultFallback) XCTAssertEqual(syncData.key, "v_true") XCTAssertEqual(syncData.value as? Bool, true) // Async operations let expectation = XCTestExpectation(description: "Async with nil delegate") - manager.getFeature("feature_int") { data in + manager.getFeature("feature_int", fallback: defaultFallback) { data in XCTAssertEqual(data.key, "v_int") XCTAssertEqual(data.value as? Int, 101) expectation.fulfill() From f609f8e6c2eb20172cd2d17bc321e34633981aa6 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Thu, 15 May 2025 15:31:26 -0700 Subject: [PATCH 08/20] rename APIs --- .../MixpanelDemo/TrackingViewController.swift | 16 +-- .../MixpanelFeatureFlagTests.swift | 120 ++++++++-------- Sources/Constants.swift | 6 + Sources/FeatureFlags.swift | 130 ++++++++++++++++-- Sources/MixpanelInstance.swift | 79 +---------- 5 files changed, 197 insertions(+), 154 deletions(-) diff --git a/MixpanelDemo/MixpanelDemo/TrackingViewController.swift b/MixpanelDemo/MixpanelDemo/TrackingViewController.swift index b511eabc..af7e6427 100644 --- a/MixpanelDemo/MixpanelDemo/TrackingViewController.swift +++ b/MixpanelDemo/MixpanelDemo/TrackingViewController.swift @@ -101,43 +101,43 @@ class TrackingViewController: UIViewController, UITableViewDelegate, UITableView Mixpanel.mainInstance().unregisterSuperProperty(p) descStr = "Properties: \(p)" case 10: - Mixpanel.mainInstance().loadFlags() + Mixpanel.mainInstance().flags.loadFlags() descStr = "Flags Loaded" case 11: - let ready = Mixpanel.mainInstance().areFeaturesReady() + let ready = Mixpanel.mainInstance().flags.areFlagsReady() descStr = "Features Ready: \(ready)" case 12: var flagData = FeatureFlagData(key: "super-neat") - Mixpanel.mainInstance().getFeature("marks_nifty_feature_flag", fallback: flagData) { data in + Mixpanel.mainInstance().flags.getVariant("marks_nifty_feature_flag", fallback: flagData) { data in flagData = data print("Feature: \(flagData.key), Value: \(String(describing: flagData.value))") } descStr = "Feature: \(flagData.key), Value: \(String(describing: flagData.value))" case 13: var flagData = FeatureFlagData(key: "enabled") - flagData = Mixpanel.mainInstance().getFeatureSync("jb_qa_flag", fallback: flagData) + flagData = Mixpanel.mainInstance().flags.getVariantSync("jb_qa_flag", fallback: flagData) descStr = "Feature: \(flagData.key), Value: \(String(describing: flagData.value))" case 14: var flagValue = "NOT_donnaqacontrol" - Mixpanel.mainInstance().getFeatureData("new_feature_flag_1744737773860", fallbackValue: flagValue) { value in + Mixpanel.mainInstance().flags.getVariantValue("new_feature_flag_1744737773860", fallbackValue: flagValue) { value in flagValue = value as! String print("Feature Value: \(flagValue)") } descStr = "Feature Value: \(flagValue)" case 15: var flagValue = "NOT_donnaqacontrol" - flagValue = Mixpanel.mainInstance().getFeatureDataSync("new_feature_flag_1744737773860", fallbackValue: flagValue) as! String + flagValue = Mixpanel.mainInstance().flags.getVariantValueSync("new_feature_flag_1744737773860", fallbackValue: flagValue) as! String descStr = "Feature Value: \(flagValue)" case 16: var enabled = false - Mixpanel.mainInstance().isFeatureEnabled("jared_boolean_flag", fallbackValue: enabled) { isEnabled in + Mixpanel.mainInstance().flags.isFlagEnabled("jared_boolean_flag", fallbackValue: enabled) { isEnabled in enabled = isEnabled print("Feature Enabled: \(enabled)") } descStr = "Feature Enabled: \(enabled)" case 17: var enabled = false - enabled = Mixpanel.mainInstance().isFeatureEnabledSync("jared_boolean_flag", fallbackValue: enabled) + enabled = Mixpanel.mainInstance().flags.isFlagEnabledSync("jared_boolean_flag", fallbackValue: enabled) descStr = "Feature Enabled: \(enabled)" default: break diff --git a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift index 20959075..dde6b6d9 100644 --- a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift +++ b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift @@ -147,7 +147,7 @@ class FeatureFlagManagerTests: XCTestCase { // --- State and Configuration Tests --- func testAreFeaturesReady_InitialState() { - XCTAssertFalse(manager.areFeaturesReady(), "Features should not be ready initially") + XCTAssertFalse(manager.areFlagsReady(), "Features should not be ready initially") } func testAreFeaturesReady_AfterSuccessfulFetchSimulation() { @@ -156,7 +156,7 @@ class FeatureFlagManagerTests: XCTestCase { let expectation = XCTestExpectation(description: "Wait for potential completion dispatch") DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { expectation.fulfill() } wait(for: [expectation], timeout: 0.5) - XCTAssertTrue(manager.areFeaturesReady(), "Features should be ready after successful fetch simulation") + XCTAssertTrue(manager.areFlagsReady(), "Features should be ready after successful fetch simulation") } func testAreFeaturesReady_AfterFailedFetchSimulation() { @@ -165,7 +165,7 @@ class FeatureFlagManagerTests: XCTestCase { let expectation = XCTestExpectation(description: "Wait for potential completion dispatch") DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { expectation.fulfill() } wait(for: [expectation], timeout: 0.5) - XCTAssertFalse(manager.areFeaturesReady(), "Features should not be ready after failed fetch simulation") + XCTAssertFalse(manager.areFlagsReady(), "Features should not be ready after failed fetch simulation") } // --- Load Flags Tests --- @@ -179,7 +179,7 @@ class FeatureFlagManagerTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { expectation.fulfill() } wait(for: [expectation], timeout: 0.5) - XCTAssertFalse(manager.areFeaturesReady(), "Flags should not become ready if disabled") + XCTAssertFalse(manager.areFlagsReady(), "Flags should not become ready if disabled") // We can't easily check if _fetchFlagsIfNeeded was *not* called without more testability hooks } @@ -188,82 +188,82 @@ class FeatureFlagManagerTests: XCTestCase { // --- Sync Flag Retrieval Tests --- - func testGetFeatureSync_FlagsReady_ExistingFlag() { + func testGetVariantSync_FlagsReady_ExistingFlag() { simulateFetchSuccess() // Flags loaded - let featureData = manager.getFeatureSync("feature_string", fallback: defaultFallback) + let featureData = manager.getVariantSync("feature_string", fallback: defaultFallback) AssertEqual(featureData.key, "v_str") AssertEqual(featureData.value, "test_string") // Tracking check happens later } - func testGetFeatureSync_FlagsReady_MissingFlag_UsesFallback() { + func testGetVariantSync_FlagsReady_MissingFlag_UsesFallback() { simulateFetchSuccess() let fallback = FeatureFlagData(key: "fb_key", value: "fb_value") - let featureData = manager.getFeatureSync("missing_feature", fallback: fallback) + let featureData = manager.getVariantSync("missing_feature", fallback: fallback) AssertEqual(featureData.key, fallback.key) AssertEqual(featureData.value, fallback.value) XCTAssertEqual(mockDelegate.trackedEvents.count, 0, "Should not track for fallback") } - func testGetFeatureSync_FlagsNotReady_UsesFallback() { - XCTAssertFalse(manager.areFeaturesReady()) // Precondition + func testGetVariantSync_FlagsNotReady_UsesFallback() { + XCTAssertFalse(manager.areFlagsReady()) // Precondition let fallback = FeatureFlagData(key: "fb_key", value: 999) - let featureData = manager.getFeatureSync("feature_bool_true", fallback: fallback) + let featureData = manager.getVariantSync("feature_bool_true", fallback: fallback) AssertEqual(featureData.key, fallback.key) AssertEqual(featureData.value, fallback.value) XCTAssertEqual(mockDelegate.trackedEvents.count, 0, "Should not track if flags not ready") } - func testGetFeatureDataSync_FlagsReady() { + func testGetVariantValueSync_FlagsReady() { simulateFetchSuccess() - let value = manager.getFeatureDataSync("feature_int", fallbackValue: -1) + let value = manager.getVariantValueSync("feature_int", fallbackValue: -1) AssertEqual(value, 101) } - func testGetFeatureDataSync_FlagsReady_MissingFlag() { + func testGetVariantValueSync_FlagsReady_MissingFlag() { simulateFetchSuccess() - let value = manager.getFeatureDataSync("missing_feature", fallbackValue: "default") + let value = manager.getVariantValueSync("missing_feature", fallbackValue: "default") AssertEqual(value, "default") } - func testGetFeatureDataSync_FlagsNotReady() { - XCTAssertFalse(manager.areFeaturesReady()) - let value = manager.getFeatureDataSync("feature_int", fallbackValue: -1) + func testGetVariantValueSync_FlagsNotReady() { + XCTAssertFalse(manager.areFlagsReady()) + let value = manager.getVariantValueSync("feature_int", fallbackValue: -1) AssertEqual(value, -1) } - func testIsFeatureEnabledSync_FlagsReady_True() { + func testIsFlagEnabledSync_FlagsReady_True() { simulateFetchSuccess() - XCTAssertTrue(manager.isFeatureEnabledSync("feature_bool_true")) + XCTAssertTrue(manager.isFlagEnabledSync("feature_bool_true")) } - func testIsFeatureEnabledSync_FlagsReady_False() { + func testIsFlagEnabledSync_FlagsReady_False() { simulateFetchSuccess() - XCTAssertFalse(manager.isFeatureEnabledSync("feature_bool_false")) + XCTAssertFalse(manager.isFlagEnabledSync("feature_bool_false")) } - func testIsFeatureEnabledSync_FlagsReady_MissingFlag_UsesFallback() { + func testIsFlagEnabledSync_FlagsReady_MissingFlag_UsesFallback() { simulateFetchSuccess() - XCTAssertTrue(manager.isFeatureEnabledSync("missing", fallbackValue: true)) - XCTAssertFalse(manager.isFeatureEnabledSync("missing", fallbackValue: false)) + XCTAssertTrue(manager.isFlagEnabledSync("missing", fallbackValue: true)) + XCTAssertFalse(manager.isFlagEnabledSync("missing", fallbackValue: false)) } - func testIsFeatureEnabledSync_FlagsReady_NonBoolValue_UsesFallback() { + func testIsFlagEnabledSync_FlagsReady_NonBoolValue_UsesFallback() { simulateFetchSuccess() - XCTAssertTrue(manager.isFeatureEnabledSync("feature_string", fallbackValue: true)) // String value - XCTAssertFalse(manager.isFeatureEnabledSync("feature_int", fallbackValue: false)) // Int value - XCTAssertTrue(manager.isFeatureEnabledSync("feature_null", fallbackValue: true)) // Null value + XCTAssertTrue(manager.isFlagEnabledSync("feature_string", fallbackValue: true)) // String value + XCTAssertFalse(manager.isFlagEnabledSync("feature_int", fallbackValue: false)) // Int value + XCTAssertTrue(manager.isFlagEnabledSync("feature_null", fallbackValue: true)) // Null value } - func testIsFeatureEnabledSync_FlagsNotReady_UsesFallback() { - XCTAssertFalse(manager.areFeaturesReady()) - XCTAssertTrue(manager.isFeatureEnabledSync("feature_bool_true", fallbackValue: true)) - XCTAssertFalse(manager.isFeatureEnabledSync("feature_bool_true", fallbackValue: false)) + func testIsFlagEnabledSync_FlagsNotReady_UsesFallback() { + XCTAssertFalse(manager.areFlagsReady()) + XCTAssertTrue(manager.isFlagEnabledSync("feature_bool_true", fallbackValue: true)) + XCTAssertFalse(manager.isFlagEnabledSync("feature_bool_true", fallbackValue: false)) } // --- Async Flag Retrieval Tests --- - func testGetFeature_Async_FlagsReady_ExistingFlag_XCTWaiter() { + func testGetVariant_Async_FlagsReady_ExistingFlag_XCTWaiter() { // Arrange simulateFetchSuccess() // Ensure flags are ready let expectation = XCTestExpectation(description: "Async getFeature ready - XCTWaiter Wait") @@ -271,7 +271,7 @@ class FeatureFlagManagerTests: XCTestCase { var assertionError: String? // Act - manager.getFeature("feature_double", fallback: defaultFallback) { data in + manager.getVariant("feature_double", fallback: defaultFallback) { data in // This completion should run on the main thread if !Thread.isMainThread { assertionError = "Completion not on main thread (\(Thread.current))" } receivedData = data @@ -300,13 +300,13 @@ class FeatureFlagManagerTests: XCTestCase { AssertEqual(receivedData?.value, 99.9) } - func testGetFeature_Async_FlagsReady_MissingFlag_UsesFallback() { + func testGetVariant_Async_FlagsReady_MissingFlag_UsesFallback() { simulateFetchSuccess() // Flags loaded let expectation = XCTestExpectation(description: "Async getFeature (Flags Ready, Missing) completes") let fallback = FeatureFlagData(key: "fb_async", value: -1) var receivedData: FeatureFlagData? - manager.getFeature("missing_feature", fallback: fallback) { data in + manager.getVariant("missing_feature", fallback: fallback) { data in XCTAssertTrue(Thread.isMainThread, "Completion should be on main thread") receivedData = data expectation.fulfill() @@ -322,8 +322,8 @@ class FeatureFlagManagerTests: XCTestCase { } // Test fetch triggering and completion via getFeature when not ready - func testGetFeature_Async_FlagsNotReady_FetchSuccess() { - XCTAssertFalse(manager.areFeaturesReady()) + func testGetVariant_Async_FlagsNotReady_FetchSuccess() { + XCTAssertFalse(manager.areFlagsReady()) let expectation = XCTestExpectation(description: "Async getFeature (Flags Not Ready) triggers fetch and succeeds") var receivedData: FeatureFlagData? @@ -331,7 +331,7 @@ class FeatureFlagManagerTests: XCTestCase { mockDelegate.trackExpectation = XCTestExpectation(description: "Tracking call for fetch success") // Call getFeature - this should trigger the fetch logic internally - manager.getFeature("feature_int", fallback: defaultFallback) { data in + manager.getVariant("feature_int", fallback: defaultFallback) { data in XCTAssertTrue(Thread.isMainThread, "Completion should be on main thread") receivedData = data expectation.fulfill() // Fulfill main expectation @@ -350,18 +350,18 @@ class FeatureFlagManagerTests: XCTestCase { XCTAssertNotNil(receivedData) AssertEqual(receivedData?.key, "v_int") // Check correct flag data received AssertEqual(receivedData?.value, 101) - XCTAssertTrue(manager.areFeaturesReady(), "Flags should be ready after successful fetch") + XCTAssertTrue(manager.areFlagsReady(), "Flags should be ready after successful fetch") XCTAssertEqual(mockDelegate.trackedEvents.count, 1, "Tracking event should have been recorded") } - func testGetFeature_Async_FlagsNotReady_FetchFailure() { - XCTAssertFalse(manager.areFeaturesReady()) + func testGetVariant_Async_FlagsNotReady_FetchFailure() { + XCTAssertFalse(manager.areFlagsReady()) let expectation = XCTestExpectation(description: "Async getFeature (Flags Not Ready) triggers fetch and fails") let fallback = FeatureFlagData(key:"fb_fail", value: "failed_fetch") var receivedData: FeatureFlagData? // Call getFeature - manager.getFeature("feature_string", fallback: fallback) { data in + manager.getVariant("feature_string", fallback: fallback) { data in XCTAssertTrue(Thread.isMainThread, "Completion should be on main thread") receivedData = data expectation.fulfill() @@ -378,7 +378,7 @@ class FeatureFlagManagerTests: XCTestCase { XCTAssertNotNil(receivedData) AssertEqual(receivedData?.key, fallback.key) // Should receive fallback AssertEqual(receivedData?.value, fallback.value) - XCTAssertFalse(manager.areFeaturesReady(), "Flags should still not be ready after failed fetch") + XCTAssertFalse(manager.areFlagsReady(), "Flags should still not be ready after failed fetch") XCTAssertEqual(mockDelegate.trackedEvents.count, 0, "Should not track on fetch failure/fallback") } @@ -392,13 +392,13 @@ class FeatureFlagManagerTests: XCTestCase { mockDelegate.trackExpectation?.expectedFulfillmentCount = 1 // Expect exactly one call // Call sync methods multiple times - _ = manager.getFeatureSync("feature_bool_true", fallback: defaultFallback) - _ = manager.getFeatureDataSync("feature_bool_true", fallbackValue: nil) - _ = manager.isFeatureEnabledSync("feature_bool_true") + _ = manager.getVariantSync("feature_bool_true", fallback: defaultFallback) + _ = manager.getVariantValueSync("feature_bool_true", fallbackValue: nil) + _ = manager.isFlagEnabledSync("feature_bool_true") // Call async method let asyncExpectation = XCTestExpectation(description: "Async getFeature completes for tracking test") - manager.getFeature("feature_bool_true", fallback: defaultFallback) { _ in asyncExpectation.fulfill() } + manager.getVariant("feature_bool_true", fallback: defaultFallback) { _ in asyncExpectation.fulfill() } // Wait for async call AND the track expectation wait(for: [asyncExpectation, mockDelegate.trackExpectation!], timeout: 2.0) @@ -409,7 +409,7 @@ class FeatureFlagManagerTests: XCTestCase { // --- Call for a *different* feature --- mockDelegate.trackExpectation = XCTestExpectation(description: "Track called for feature_string") - _ = manager.getFeatureSync("feature_string", fallback: defaultFallback) + _ = manager.getVariantSync("feature_string", fallback: defaultFallback) wait(for: [mockDelegate.trackExpectation!], timeout: 1.0) let stringEvents = mockDelegate.trackedEvents.filter { $0.properties?["Experiment name"] as? String == "feature_string" } @@ -423,7 +423,7 @@ class FeatureFlagManagerTests: XCTestCase { simulateFetchSuccess() mockDelegate.trackExpectation = XCTestExpectation(description: "Track called for properties check") - _ = manager.getFeatureSync("feature_int", fallback: defaultFallback) // Trigger tracking + _ = manager.getVariantSync("feature_int", fallback: defaultFallback) // Trigger tracking wait(for: [mockDelegate.trackExpectation!], timeout: 1.0) @@ -440,7 +440,7 @@ class FeatureFlagManagerTests: XCTestCase { func testTracking_DoesNotTrackForFallback_Sync() { simulateFetchSuccess() // Flags ready - _ = manager.getFeatureSync("missing_feature", fallback: FeatureFlagData(key:"fb", value:"v")) // Request missing flag + _ = manager.getVariantSync("missing_feature", fallback: FeatureFlagData(key:"fb", value:"v")) // Request missing flag // Wait briefly to ensure no unexpected tracking call let expectation = XCTestExpectation(description: "Wait briefly for no track") DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { expectation.fulfill() } @@ -452,7 +452,7 @@ class FeatureFlagManagerTests: XCTestCase { simulateFetchSuccess() // Flags ready let expectation = XCTestExpectation(description: "Async getFeature (Fallback) completes") - manager.getFeature("missing_feature", fallback: FeatureFlagData(key:"fb", value:"v")) { _ in + manager.getVariant("missing_feature", fallback: FeatureFlagData(key:"fb", value:"v")) { _ in expectation.fulfill() } @@ -465,7 +465,7 @@ class FeatureFlagManagerTests: XCTestCase { // Test concurrent fetch attempts (via getFeature when not ready) func testConcurrentGetFeature_WhenNotReady_OnlyOneFetch() { - XCTAssertFalse(manager.areFeaturesReady()) + XCTAssertFalse(manager.areFlagsReady()) let numConcurrentCalls = 5 var expectations: [XCTestExpectation] = [] @@ -480,7 +480,7 @@ class FeatureFlagManagerTests: XCTestCase { let exp = XCTestExpectation(description: "Async getFeature \(i) completes") expectations.append(exp) DispatchQueue.global().async { // Simulate calls from different threads - self.manager.getFeature("feature_bool_true", fallback: self.defaultFallback) { data in + self.manager.getVariant("feature_bool_true", fallback: self.defaultFallback) { data in print("Completion handler \(i) called.") completionResults[i] = data exp.fulfill() @@ -508,7 +508,7 @@ class FeatureFlagManagerTests: XCTestCase { } // Verify flags are ready and tracking occurred only once - XCTAssertTrue(manager.areFeaturesReady()) + XCTAssertTrue(manager.areFlagsReady()) let trackEvents = mockDelegate.trackedEvents.filter { $0.properties?["Experiment name"] as? String == "feature_bool_true" } XCTAssertEqual(trackEvents.count, 1, "Tracking should have occurred exactly once despite concurrent calls") } @@ -605,13 +605,13 @@ class FeatureFlagManagerTests: XCTestCase { // Test all operations with nil delegate // Synchronous operations - let syncData = manager.getFeatureSync("feature_bool_true", fallback: defaultFallback) + let syncData = manager.getVariantSync("feature_bool_true", fallback: defaultFallback) XCTAssertEqual(syncData.key, "v_true") XCTAssertEqual(syncData.value as? Bool, true) // Async operations let expectation = XCTestExpectation(description: "Async with nil delegate") - manager.getFeature("feature_int", fallback: defaultFallback) { data in + manager.getVariant("feature_int", fallback: defaultFallback) { data in XCTAssertEqual(data.key, "v_int") XCTAssertEqual(data.value as? Int, 101) expectation.fulfill() @@ -632,7 +632,7 @@ class FeatureFlagManagerTests: XCTestCase { // Verify no crash; attempt a flag fetch after a short delay let expectation = XCTestExpectation(description: "Check after attempted fetch") DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - XCTAssertFalse(noDelegate.areFeaturesReady(), "Flags should not be ready without delegate") + XCTAssertFalse(noDelegate.areFlagsReady(), "Flags should not be ready without delegate") expectation.fulfill() } wait(for: [expectation], timeout: 1.0) @@ -648,7 +648,7 @@ class FeatureFlagManagerTests: XCTestCase { // Verify no fetch is triggered let expectation = XCTestExpectation(description: "Check disabled config behavior") DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - XCTAssertFalse(self.manager.areFeaturesReady(), "Flags should not be ready when config disabled") + XCTAssertFalse(self.manager.areFlagsReady(), "Flags should not be ready when config disabled") expectation.fulfill() } wait(for: [expectation], timeout: 1.0) diff --git a/Sources/Constants.swift b/Sources/Constants.swift index 88796d92..5a9f64b5 100644 --- a/Sources/Constants.swift +++ b/Sources/Constants.swift @@ -31,6 +31,12 @@ struct GzipSettings { static let gzipHeaderOffset = Int32(16) } +struct FeatureFlags { + static let flagsKey = "flags" + static let variantKey = "variant_key" + static let variantValue = "variant_value" +} + #if !os(OSX) && !os(watchOS) && !os(visionOS) extension UIDevice { var iPhoneX: Bool { diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift index b2817aee..af0550bb 100644 --- a/Sources/FeatureFlags.swift +++ b/Sources/FeatureFlags.swift @@ -104,16 +104,118 @@ public struct FlagsConfig: Decodable { // --- FeatureFlagDelegate Protocol --- -protocol FeatureFlagDelegate: AnyObject { +public protocol FeatureFlagDelegate: AnyObject { func getConfig() -> MixpanelConfig func getDistinctId() -> String func track(event: String?, properties: Properties?) } +/// A protocol defining the public interface for a feature flagging system. +public protocol MixpanelFlags { + + /// The delegate responsible for handling feature flag lifecycle events, + /// such as tracking. It is declared `weak` to prevent retain cycles. + var delegate: FeatureFlagDelegate? { get set } + + // --- Public Methods --- + + /// Initiates the loading or refreshing of flag configurations from a remote source or cache. + /// This operation should be performed asynchronously to avoid blocking the calling thread. + /// Implementations should ensure that subsequent calls to retrieve flags + /// will use the latest data once loaded. + func loadFlags() + + /// Synchronously checks if the flag configurations have been successfully loaded + /// and are available for querying. + /// + /// - Returns: `true` if the flags are loaded and ready for use, `false` otherwise. + func areFlagsReady() -> Bool + + // --- Sync Flag Retrieval --- + + /// Synchronously retrieves the complete `FeatureFlagData` for a given feature name. + /// If the feature flag is found and flags are ready, its data is returned. + /// Otherwise, the provided `fallback` `FeatureFlagData` is returned. + /// This method will also trigger any necessary tracking logic for the accessed flag. + /// + /// - Parameters: + /// - featureName: The unique identifier for the feature flag. + /// - fallback: The `FeatureFlagData` to return if the specified flag is not found + /// or if the flags are not yet loaded. + /// - Returns: The `FeatureFlagData` associated with `featureName`, or the `fallback` data. + func getVariantSync(_ featureName: String, fallback: FeatureFlagData) -> FeatureFlagData + + /// Asynchronously retrieves the complete `FeatureFlagData` for a given feature name. + /// If flags are not ready, an attempt will be made to load them. + /// The `completion` handler is called with the `FeatureFlagData` for the feature, + /// or the `fallback` data if the flag is not found or loading fails. + /// This method will also trigger any necessary tracking logic for the accessed flag. + /// The completion handler is typically invoked on the main thread. + /// + /// - Parameters: + /// - featureName: The unique identifier for the feature flag. + /// - fallback: The `FeatureFlagData` to use as a default if the specified flag + /// is not found or an error occurs during fetching. + /// - completion: A closure that is called with the resulting `FeatureFlagData`. + /// This closure will be executed on the main dispatch queue. + func getVariant(_ featureName: String, fallback: FeatureFlagData, completion: @escaping (FeatureFlagData) -> Void) + + /// Synchronously retrieves the underlying value of a feature flag. + /// This is a convenience method that extracts the `value` property from the `FeatureFlagData` + /// obtained via `getVariantSync`. + /// + /// - Parameters: + /// - featureName: The unique identifier for the feature flag. + /// - fallbackValue: The default value to return if the flag is not found, + /// its data doesn't contain a value, or flags are not ready. + /// - Returns: The value of the feature flag, or `fallbackValue`. The type is `Any?`. + func getVariantValueSync(_ featureName: String, fallbackValue: Any?) -> Any? + + /// Asynchronously retrieves the underlying value of a feature flag. + /// This is a convenience method that extracts the `value` property from the `FeatureFlagData` + /// obtained via `getVariant`. If flags are not ready, an attempt will be made to load them. + /// The `completion` handler is called with the flag's value or the `fallbackValue`. + /// The completion handler is typically invoked on the main thread. + /// + /// - Parameters: + /// - featureName: The unique identifier for the feature flag. + /// - fallbackValue: The default value to use if the flag is not found, + /// fetching fails, or its data doesn't contain a value. + /// - completion: A closure that is called with the resulting value (`Any?`). + /// This closure will be executed on the main dispatch queue. + func getVariantValue(_ featureName: String, fallbackValue: Any?, completion: @escaping (Any?) -> Void) + + /// Synchronously checks if a specific feature flag is considered "enabled". + /// This typically involves retrieving the flag's value and evaluating it as a boolean. + /// The exact logic for what constitutes "enabled" (e.g., `true`, non-nil, a specific string) + /// should be defined by the implementing class. + /// + /// - Parameters: + /// - featureName: The unique identifier for the feature flag. + /// - fallbackValue: The boolean value to return if the flag is not found, + /// cannot be evaluated as a boolean, or flags are not ready. Defaults to `false`. + /// - Returns: `true` if the flag is considered enabled, `false` otherwise (including if `fallbackValue` is used). + func isFlagEnabledSync(_ featureName: String, fallbackValue: Bool) -> Bool + + /// Asynchronously checks if a specific feature flag is considered "enabled". + /// This typically involves retrieving the flag's value and evaluating it as a boolean. + /// If flags are not ready, an attempt will be made to load them. + /// The `completion` handler is called with the boolean result. + /// The completion handler is typically invoked on the main thread. + /// + /// - Parameters: + /// - featureName: The unique identifier for the feature flag. + /// - fallbackValue: The boolean value to use if the flag is not found, fetching fails, + /// or it cannot be evaluated as a boolean. Defaults to `false`. + /// - completion: A closure that is called with the boolean result. + /// This closure will be executed on the main dispatch queue. + func isFlagEnabled(_ featureName: String, fallbackValue: Bool, completion: @escaping (Bool) -> Void) +} + // --- FeatureFlagManager Class --- -class FeatureFlagManager: Network { +class FeatureFlagManager: Network, MixpanelFlags { weak var delegate: FeatureFlagDelegate? @@ -150,14 +252,14 @@ class FeatureFlagManager: Network { } } - func areFeaturesReady() -> Bool { + func areFlagsReady() -> Bool { // Simple sync read - serial queue ensures this is safe accessQueue.sync { flags != nil } } // --- Sync Flag Retrieval --- - func getFeatureSync(_ featureName: String, fallback: FeatureFlagData) -> FeatureFlagData { + func getVariantSync(_ featureName: String, fallback: FeatureFlagData) -> FeatureFlagData { var featureData: FeatureFlagData? var tracked = false // === Serial Queue: Single Sync Block for Read AND Track Update === @@ -193,7 +295,7 @@ class FeatureFlagManager: Network { // --- Async Flag Retrieval --- - func getFeature(_ featureName: String, fallback: FeatureFlagData, completion: @escaping (FeatureFlagData) -> Void) { + func getVariant(_ featureName: String, fallback: FeatureFlagData, completion: @escaping (FeatureFlagData) -> Void) { accessQueue.async { [weak self] in // Block A runs serially on accessQueue guard let self = self else { return } @@ -231,7 +333,7 @@ class FeatureFlagManager: Network { let result: FeatureFlagData if success { // Fetch succeeded, get the feature SYNCHRONOUSLY - result = self.getFeatureSync(featureName, fallback: fallback) + result = self.getVariantSync(featureName, fallback: fallback) } else { print("Warning: Failed to fetch flags, returning fallback for \(featureName).") result = fallback @@ -246,23 +348,23 @@ class FeatureFlagManager: Network { } // End accessQueue.async (Block A) } - func getFeatureDataSync(_ featureName: String, fallbackValue: Any?) -> Any? { - return getFeatureSync(featureName, fallback: FeatureFlagData(value: fallbackValue)).value + func getVariantValueSync(_ featureName: String, fallbackValue: Any?) -> Any? { + return getVariantSync(featureName, fallback: FeatureFlagData(value: fallbackValue)).value } - func getFeatureData(_ featureName: String, fallbackValue: Any?, completion: @escaping (Any?) -> Void) { - getFeature(featureName, fallback: FeatureFlagData(value: fallbackValue)) { featureData in + func getVariantValue(_ featureName: String, fallbackValue: Any?, completion: @escaping (Any?) -> Void) { + getVariant(featureName, fallback: FeatureFlagData(value: fallbackValue)) { featureData in completion(featureData.value) } } - func isFeatureEnabledSync(_ featureName: String, fallbackValue: Bool = false) -> Bool { - let dataValue = getFeatureDataSync(featureName, fallbackValue: fallbackValue) + func isFlagEnabledSync(_ featureName: String, fallbackValue: Bool = false) -> Bool { + let dataValue = getVariantValueSync(featureName, fallbackValue: fallbackValue) return self._evaluateBooleanFlag(featureName: featureName, dataValue: dataValue, fallbackValue: fallbackValue) } - func isFeatureEnabled(_ featureName: String, fallbackValue: Bool = false, completion: @escaping (Bool) -> Void) { - getFeatureData(featureName, fallbackValue: fallbackValue) { [weak self] dataValue in + func isFlagEnabled(_ featureName: String, fallbackValue: Bool = false, completion: @escaping (Bool) -> Void) { + getVariantValue(featureName, fallbackValue: fallbackValue) { [weak self] dataValue in guard let self = self else { completion(fallbackValue) return diff --git a/Sources/MixpanelInstance.swift b/Sources/MixpanelInstance.swift index 798eaf58..24985c02 100644 --- a/Sources/MixpanelInstance.swift +++ b/Sources/MixpanelInstance.swift @@ -104,6 +104,9 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele /// Accessor to the Mixpanel People API object. open var people: People! + /// Accessor the Mixpanel Feature Flags API object. + open var flags: MixpanelFlags! + let mixpanelPersistence: MixpanelPersistence /// Accessor to the Mixpanel People API object. @@ -264,7 +267,6 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele let sessionMetadata: SessionMetadata let flushInstance: Flush let trackInstance: Track - let featureFlagManager: FeatureFlagManager #if os(iOS) || os(tvOS) || os(visionOS) let automaticEvents = AutomaticEvents() #endif @@ -383,9 +385,9 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele instanceName: self.name, lock: self.readWriteLock, metadata: sessionMetadata, mixpanelPersistence: mixpanelPersistence) - featureFlagManager = FeatureFlagManager(serverURL: self.serverURL) + flags = FeatureFlagManager(serverURL: self.serverURL) trackInstance.mixpanelInstance = self - featureFlagManager.delegate = self + flags.delegate = self #if os(iOS) && !targetEnvironment(macCatalyst) if let reachability = MixpanelInstance.reachability { var context = SCNetworkReachabilityContext(version: 0, info: nil, retain: nil, release: nil, copyDescription: nil) @@ -437,84 +439,17 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele automaticEvents.initializeEvents(instanceName: self.name) } #endif - featureFlagManager.loadFlags() + flags.loadFlags() } public func getConfig() -> MixpanelConfig { return config } - func getDistinctId() -> String { + public func getDistinctId() -> String { return distinctId } - // MARK: - Feature Flag Methods - - /// Triggers a fetch of feature flags from the server - public func loadFlags() { - featureFlagManager.loadFlags() - } - - /// Returns whether feature flags have been successfully loaded - /// - Returns: True if flags are loaded and ready to use, false otherwise - public func areFeaturesReady() -> Bool { - return featureFlagManager.areFeaturesReady() - } - - /// Returns a feature flag synchronously with the specified fallback if not available - /// - Parameters: - /// - featureName: The name of the feature flag to retrieve - /// - fallback: FeatureFlagData to return if the feature flag doesn't exist or flags aren't loaded - /// - Returns: The FeatureFlagData of the feature flag, or the fallback if not available - public func getFeatureSync(_ featureName: String, fallback: FeatureFlagData) -> FeatureFlagData { - return featureFlagManager.getFeatureSync(featureName, fallback: fallback) - } - - /// Gets a feature flag asynchronously - /// - Parameters: - /// - featureName: The name of the feature flag to retrieve - /// - fallback: FeatureFlagData to return if the feature flag doesn't exist or flags aren't loaded - /// - completion: Callback function that receives the FeatureFlagData - public func getFeature(_ featureName: String, fallback: FeatureFlagData, completion: @escaping (FeatureFlagData) -> Void) { - featureFlagManager.getFeature(featureName, fallback: fallback, completion: completion) - } - - /// Gets feature data synchronously - /// - Parameters: - /// - featureName: The name of the feature flag to retrieve - /// - fallbackValue: Value to return if the feature flag doesn't exist or flags aren't loaded - /// - Returns: The value of the feature flag, or the fallback if not available - public func getFeatureDataSync(_ featureName: String, fallbackValue: Any?) -> Any? { - return featureFlagManager.getFeatureDataSync(featureName, fallbackValue: fallbackValue) - } - - /// Gets feature data asynchronously - /// - Parameters: - /// - featureName: The name of the feature flag to retrieve - /// - fallbackValue: Value to return if the feature flag doesn't exist or flags aren't loaded - /// - completion: Callback function that receives the feature value - public func getFeatureData(_ featureName: String, fallbackValue: Any?, completion: @escaping (Any?) -> Void) { - featureFlagManager.getFeatureData(featureName, fallbackValue: fallbackValue, completion: completion) - } - - /// Check if a boolean feature flag is enabled - /// - Parameters: - /// - featureName: The name of the feature flag to check - /// - fallbackValue: Value to return if the feature flag doesn't exist or flags aren't loaded - /// - Returns: True if the feature is enabled, false otherwisxtee - public func isFeatureEnabledSync(_ featureName: String, fallbackValue: Bool = false) -> Bool { - return featureFlagManager.isFeatureEnabledSync(featureName, fallbackValue: fallbackValue) - } - - /// Check if a boolean feature flag is enabled asynchronously - /// - Parameters: - /// - featureName: The name of the feature flag to check - /// - fallbackValue: Value to return if the feature flag doesn't exist or flags aren't loaded - /// - completion: Callback function that receives the boolean result - public func isFeatureEnabled(_ featureName: String, fallbackValue: Bool = false, completion: @escaping (Bool) -> Void) { - featureFlagManager.isFeatureEnabled(featureName, fallbackValue: fallbackValue, completion: completion) - } - #if !os(OSX) && !os(watchOS) private func setupListeners() { let notificationCenter = NotificationCenter.default From 4c0a53c5dadad5fc64cc26d757c87831e4e4591b Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Thu, 15 May 2025 17:08:44 -0700 Subject: [PATCH 09/20] more renaming --- Mixpanel.xcodeproj/project.pbxproj | 20 +- MixpanelDemo/MixpanelDemo/AppDelegate.swift | 4 +- .../MixpanelDemo/TrackingViewController.swift | 4 +- .../MixpanelFeatureFlagTests.swift | 78 +++---- Sources/Constants.swift | 6 - Sources/FeatureFlags.swift | 215 +++++++----------- Sources/Mixpanel.swift | 15 +- Sources/MixpanelInstance.swift | 12 +- ...anelConfig.swift => MixpanelOptions.swift} | 14 +- Sources/MixpanelPersistence.swift | 3 + 10 files changed, 161 insertions(+), 210 deletions(-) rename Sources/{MixpanelConfig.swift => MixpanelOptions.swift} (80%) diff --git a/Mixpanel.xcodeproj/project.pbxproj b/Mixpanel.xcodeproj/project.pbxproj index 33fcc1b3..4221c6e5 100644 --- a/Mixpanel.xcodeproj/project.pbxproj +++ b/Mixpanel.xcodeproj/project.pbxproj @@ -11,10 +11,10 @@ 171E4C132DAF108400B7CB11 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C112DAF108400B7CB11 /* FeatureFlags.swift */; }; 171E4C142DAF108400B7CB11 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C112DAF108400B7CB11 /* FeatureFlags.swift */; }; 171E4C152DAF108400B7CB11 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C112DAF108400B7CB11 /* FeatureFlags.swift */; }; - 171E4C172DAF2B3100B7CB11 /* MixpanelConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C162DAF2B3100B7CB11 /* MixpanelConfig.swift */; }; - 171E4C182DAF2B3100B7CB11 /* MixpanelConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C162DAF2B3100B7CB11 /* MixpanelConfig.swift */; }; - 171E4C192DAF2B3100B7CB11 /* MixpanelConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C162DAF2B3100B7CB11 /* MixpanelConfig.swift */; }; - 171E4C1A2DAF2B3100B7CB11 /* MixpanelConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C162DAF2B3100B7CB11 /* MixpanelConfig.swift */; }; + 171E4C172DAF2B3100B7CB11 /* MixpanelOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C162DAF2B3100B7CB11 /* MixpanelOptions.swift */; }; + 171E4C182DAF2B3100B7CB11 /* MixpanelOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C162DAF2B3100B7CB11 /* MixpanelOptions.swift */; }; + 171E4C192DAF2B3100B7CB11 /* MixpanelOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C162DAF2B3100B7CB11 /* MixpanelOptions.swift */; }; + 171E4C1A2DAF2B3100B7CB11 /* MixpanelOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E4C162DAF2B3100B7CB11 /* MixpanelOptions.swift */; }; 17C6547A2BB1F15C00C8A126 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1728208D2BA8BDE4002CD973 /* PrivacyInfo.xcprivacy */; }; 17C6547B2BB1F16000C8A126 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1728208D2BA8BDE4002CD973 /* PrivacyInfo.xcprivacy */; }; 17C6547C2BB1F16400C8A126 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1728208D2BA8BDE4002CD973 /* PrivacyInfo.xcprivacy */; }; @@ -112,7 +112,7 @@ /* Begin PBXFileReference section */ 171E4C112DAF108400B7CB11 /* FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; - 171E4C162DAF2B3100B7CB11 /* MixpanelConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MixpanelConfig.swift; sourceTree = ""; }; + 171E4C162DAF2B3100B7CB11 /* MixpanelOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MixpanelOptions.swift; sourceTree = ""; }; 1728208D2BA8BDE4002CD973 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Sources/Mixpanel/PrivacyInfo.xcprivacy; sourceTree = SOURCE_ROOT; }; 51DD56791D306B740045D3DB /* MixpanelLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MixpanelLogger.swift; sourceTree = ""; }; 51DD56801D306B7B0045D3DB /* PrintLogging.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PrintLogging.swift; sourceTree = ""; }; @@ -236,7 +236,7 @@ E11594881CFF14D3007F8B4F /* Source */ = { isa = PBXGroup; children = ( - 171E4C162DAF2B3100B7CB11 /* MixpanelConfig.swift */, + 171E4C162DAF2B3100B7CB11 /* MixpanelOptions.swift */, 17C654792BB1EF6700C8A126 /* Mixpanel */, E189D8FB1D5A6943007F3F29 /* Networking */, 51DD56771D306B620045D3DB /* Log */, @@ -500,7 +500,7 @@ 86F86EC722443A3C00B69832 /* FileLogging.swift in Sources */, 86F86EC622443A3100B69832 /* Error.swift in Sources */, 86F86EC522443A2C00B69832 /* People.swift in Sources */, - 171E4C172DAF2B3100B7CB11 /* MixpanelConfig.swift in Sources */, + 171E4C172DAF2B3100B7CB11 /* MixpanelOptions.swift in Sources */, 86F86EC422443A2300B69832 /* ReadWriteLock.swift in Sources */, 8625BEBE26D045CE0009BAA9 /* MPDB.swift in Sources */, 95ECF06B2C9B851C006364D2 /* Data+Compression.swift in Sources */, @@ -531,7 +531,7 @@ E1D335CE1D30578E00E68E12 /* Constants.swift in Sources */, E115949F1D01BE14007F8B4F /* Flush.swift in Sources */, E11594971D006022007F8B4F /* Network.swift in Sources */, - 171E4C182DAF2B3100B7CB11 /* MixpanelConfig.swift in Sources */, + 171E4C182DAF2B3100B7CB11 /* MixpanelOptions.swift in Sources */, E15FF7C81D0435670076CDE3 /* People.swift in Sources */, 673ABE3A21360CBE00B1784B /* Group.swift in Sources */, 95ECF0682C9B851A006364D2 /* Data+Compression.swift in Sources */, @@ -562,7 +562,7 @@ E12782BD1D4AB5CB0025FB05 /* MixpanelLogger.swift in Sources */, E12782BE1D4AB5CB0025FB05 /* Mixpanel.swift in Sources */, E12782BF1D4AB5CB0025FB05 /* MixpanelInstance.swift in Sources */, - 171E4C192DAF2B3100B7CB11 /* MixpanelConfig.swift in Sources */, + 171E4C192DAF2B3100B7CB11 /* MixpanelOptions.swift in Sources */, E12782C11D4AB5CB0025FB05 /* Network.swift in Sources */, 8625BEBC26D045CE0009BAA9 /* MPDB.swift in Sources */, 95ECF0692C9B851B006364D2 /* Data+Compression.swift in Sources */, @@ -593,7 +593,7 @@ E1F15FDC1E64B60A00391AE3 /* AutomaticProperties.swift in Sources */, E1F15FD91E64B60600391AE3 /* MixpanelLogger.swift in Sources */, E1F15FD61E64B5FC00391AE3 /* FlushRequest.swift in Sources */, - 171E4C1A2DAF2B3100B7CB11 /* MixpanelConfig.swift in Sources */, + 171E4C1A2DAF2B3100B7CB11 /* MixpanelOptions.swift in Sources */, E1F15FD71E64B60200391AE3 /* PrintLogging.swift in Sources */, 8625BEBD26D045CE0009BAA9 /* MPDB.swift in Sources */, 95ECF06A2C9B851B006364D2 /* Data+Compression.swift in Sources */, diff --git a/MixpanelDemo/MixpanelDemo/AppDelegate.swift b/MixpanelDemo/MixpanelDemo/AppDelegate.swift index 9dfc2d59..1fc31635 100644 --- a/MixpanelDemo/MixpanelDemo/AppDelegate.swift +++ b/MixpanelDemo/MixpanelDemo/AppDelegate.swift @@ -16,8 +16,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { - let mixpanelConfig = MixpanelConfig(token: "metrics-1", trackAutomaticEvents: false, flagsConfig: FlagsConfig(enabled: true, context: ["key": "value"])) - Mixpanel.initialize(config: mixpanelConfig) + let mixpanelOptions = MixpanelOptions(token: "metrics-1", trackAutomaticEvents: false, flagsConfig: FlagsConfig(enabled: true, context: ["key": "value"])) + Mixpanel.initialize(options: mixpanelOptions) Mixpanel.mainInstance().loggingEnabled = true return true diff --git a/MixpanelDemo/MixpanelDemo/TrackingViewController.swift b/MixpanelDemo/MixpanelDemo/TrackingViewController.swift index af7e6427..97a2bacb 100644 --- a/MixpanelDemo/MixpanelDemo/TrackingViewController.swift +++ b/MixpanelDemo/MixpanelDemo/TrackingViewController.swift @@ -107,14 +107,14 @@ class TrackingViewController: UIViewController, UITableViewDelegate, UITableView let ready = Mixpanel.mainInstance().flags.areFlagsReady() descStr = "Features Ready: \(ready)" case 12: - var flagData = FeatureFlagData(key: "super-neat") + var flagData = MixpanelFlagVariant(key: "super-neat") Mixpanel.mainInstance().flags.getVariant("marks_nifty_feature_flag", fallback: flagData) { data in flagData = data print("Feature: \(flagData.key), Value: \(String(describing: flagData.value))") } descStr = "Feature: \(flagData.key), Value: \(String(describing: flagData.value))" case 13: - var flagData = FeatureFlagData(key: "enabled") + var flagData = MixpanelFlagVariant(key: "enabled") flagData = Mixpanel.mainInstance().flags.getVariantSync("jb_qa_flag", fallback: flagData) descStr = "Feature: \(flagData.key), Value: \(String(describing: flagData.value))" case 14: diff --git a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift index dde6b6d9..252bd378 100644 --- a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift +++ b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift @@ -11,21 +11,21 @@ import XCTest // MARK: - Mocks and Helpers (Largely Unchanged) -class MockFeatureFlagDelegate: FeatureFlagDelegate { +class MockFeatureFlagDelegate: MixpanelFlagDelegate { - var config: MixpanelConfig + var config: MixpanelOptions var distinctId: String var trackedEvents: [(event: String?, properties: Properties?)] = [] var trackExpectation: XCTestExpectation? var getConfigCallCount = 0 var getDistinctIdCallCount = 0 - init(config: MixpanelConfig = MixpanelConfig(token: "test", flagsConfig: FlagsConfig(enabled: true)), distinctId: String = "test_distinct_id") { + init(config: MixpanelOptions = MixpanelOptions(token: "test", flagsConfig: FlagsConfig(enabled: true)), distinctId: String = "test_distinct_id") { self.config = config self.distinctId = distinctId } - func getConfig() -> MixpanelConfig { + func getOptions() -> MixpanelOptions { getConfigCallCount += 1 return config } @@ -93,15 +93,15 @@ class FeatureFlagManagerTests: XCTestCase { var mockDelegate: MockFeatureFlagDelegate! var manager: FeatureFlagManager! // Sample flag data for simulating fetch results - let sampleFlags: [String: FeatureFlagData] = [ - "feature_bool_true": FeatureFlagData(key: "v_true", value: true), - "feature_bool_false": FeatureFlagData(key: "v_false", value: false), - "feature_string": FeatureFlagData(key: "v_str", value: "test_string"), - "feature_int": FeatureFlagData(key: "v_int", value: 101), - "feature_double": FeatureFlagData(key: "v_double", value: 99.9), - "feature_null": FeatureFlagData(key: "v_null", value: nil) + let sampleFlags: [String: MixpanelFlagVariant] = [ + "feature_bool_true": MixpanelFlagVariant(key: "v_true", value: true), + "feature_bool_false": MixpanelFlagVariant(key: "v_false", value: false), + "feature_string": MixpanelFlagVariant(key: "v_str", value: "test_string"), + "feature_int": MixpanelFlagVariant(key: "v_int", value: 101), + "feature_double": MixpanelFlagVariant(key: "v_double", value: 99.9), + "feature_null": MixpanelFlagVariant(key: "v_null", value: nil) ] - let defaultFallback = FeatureFlagData(value: nil) // Default fallback for convenience + let defaultFallback = MixpanelFlagVariant(value: nil) // Default fallback for convenience override func setUpWithError() throws { try super.setUpWithError() @@ -120,7 +120,7 @@ class FeatureFlagManagerTests: XCTestCase { // These now directly modify state and call the *internal* _completeFetch // Requires _completeFetch to be accessible (e.g., internal or @testable import) - private func simulateFetchSuccess(flags: [String: FeatureFlagData]? = nil) { + private func simulateFetchSuccess(flags: [String: MixpanelFlagVariant]? = nil) { let flagsToSet = flags ?? sampleFlags // Set flags directly *before* calling completeFetch manager.accessQueue.sync { @@ -171,7 +171,7 @@ class FeatureFlagManagerTests: XCTestCase { // --- Load Flags Tests --- func testLoadFlags_WhenDisabledInConfig() { - mockDelegate.config = MixpanelConfig(token:"test", flagsConfig: FlagsConfig(enabled: false)) // Explicitly disable + mockDelegate.config = MixpanelOptions(token:"test", flagsConfig: FlagsConfig(enabled: false)) // Explicitly disable manager.loadFlags() // Call public API // Wait to ensure no async fetch operations started changing state @@ -190,27 +190,27 @@ class FeatureFlagManagerTests: XCTestCase { func testGetVariantSync_FlagsReady_ExistingFlag() { simulateFetchSuccess() // Flags loaded - let featureData = manager.getVariantSync("feature_string", fallback: defaultFallback) - AssertEqual(featureData.key, "v_str") - AssertEqual(featureData.value, "test_string") + let flagVariant = manager.getVariantSync("feature_string", fallback: defaultFallback) + AssertEqual(flagVariant.key, "v_str") + AssertEqual(flagVariant.value, "test_string") // Tracking check happens later } func testGetVariantSync_FlagsReady_MissingFlag_UsesFallback() { simulateFetchSuccess() - let fallback = FeatureFlagData(key: "fb_key", value: "fb_value") - let featureData = manager.getVariantSync("missing_feature", fallback: fallback) - AssertEqual(featureData.key, fallback.key) - AssertEqual(featureData.value, fallback.value) + let fallback = MixpanelFlagVariant(key: "fb_key", value: "fb_value") + let flagVariant = manager.getVariantSync("missing_feature", fallback: fallback) + AssertEqual(flagVariant.key, fallback.key) + AssertEqual(flagVariant.value, fallback.value) XCTAssertEqual(mockDelegate.trackedEvents.count, 0, "Should not track for fallback") } func testGetVariantSync_FlagsNotReady_UsesFallback() { XCTAssertFalse(manager.areFlagsReady()) // Precondition - let fallback = FeatureFlagData(key: "fb_key", value: 999) - let featureData = manager.getVariantSync("feature_bool_true", fallback: fallback) - AssertEqual(featureData.key, fallback.key) - AssertEqual(featureData.value, fallback.value) + let fallback = MixpanelFlagVariant(key: "fb_key", value: 999) + let flagVariant = manager.getVariantSync("feature_bool_true", fallback: fallback) + AssertEqual(flagVariant.key, fallback.key) + AssertEqual(flagVariant.value, fallback.value) XCTAssertEqual(mockDelegate.trackedEvents.count, 0, "Should not track if flags not ready") } @@ -267,7 +267,7 @@ class FeatureFlagManagerTests: XCTestCase { // Arrange simulateFetchSuccess() // Ensure flags are ready let expectation = XCTestExpectation(description: "Async getFeature ready - XCTWaiter Wait") - var receivedData: FeatureFlagData? + var receivedData: MixpanelFlagVariant? var assertionError: String? // Act @@ -303,8 +303,8 @@ class FeatureFlagManagerTests: XCTestCase { func testGetVariant_Async_FlagsReady_MissingFlag_UsesFallback() { simulateFetchSuccess() // Flags loaded let expectation = XCTestExpectation(description: "Async getFeature (Flags Ready, Missing) completes") - let fallback = FeatureFlagData(key: "fb_async", value: -1) - var receivedData: FeatureFlagData? + let fallback = MixpanelFlagVariant(key: "fb_async", value: -1) + var receivedData: MixpanelFlagVariant? manager.getVariant("missing_feature", fallback: fallback) { data in XCTAssertTrue(Thread.isMainThread, "Completion should be on main thread") @@ -325,7 +325,7 @@ class FeatureFlagManagerTests: XCTestCase { func testGetVariant_Async_FlagsNotReady_FetchSuccess() { XCTAssertFalse(manager.areFlagsReady()) let expectation = XCTestExpectation(description: "Async getFeature (Flags Not Ready) triggers fetch and succeeds") - var receivedData: FeatureFlagData? + var receivedData: MixpanelFlagVariant? // Setup tracking expectation *before* calling getFeature mockDelegate.trackExpectation = XCTestExpectation(description: "Tracking call for fetch success") @@ -357,8 +357,8 @@ class FeatureFlagManagerTests: XCTestCase { func testGetVariant_Async_FlagsNotReady_FetchFailure() { XCTAssertFalse(manager.areFlagsReady()) let expectation = XCTestExpectation(description: "Async getFeature (Flags Not Ready) triggers fetch and fails") - let fallback = FeatureFlagData(key:"fb_fail", value: "failed_fetch") - var receivedData: FeatureFlagData? + let fallback = MixpanelFlagVariant(key:"fb_fail", value: "failed_fetch") + var receivedData: MixpanelFlagVariant? // Call getFeature manager.getVariant("feature_string", fallback: fallback) { data in @@ -440,7 +440,7 @@ class FeatureFlagManagerTests: XCTestCase { func testTracking_DoesNotTrackForFallback_Sync() { simulateFetchSuccess() // Flags ready - _ = manager.getVariantSync("missing_feature", fallback: FeatureFlagData(key:"fb", value:"v")) // Request missing flag + _ = manager.getVariantSync("missing_feature", fallback: MixpanelFlagVariant(key:"fb", value:"v")) // Request missing flag // Wait briefly to ensure no unexpected tracking call let expectation = XCTestExpectation(description: "Wait briefly for no track") DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { expectation.fulfill() } @@ -452,7 +452,7 @@ class FeatureFlagManagerTests: XCTestCase { simulateFetchSuccess() // Flags ready let expectation = XCTestExpectation(description: "Async getFeature (Fallback) completes") - manager.getVariant("missing_feature", fallback: FeatureFlagData(key:"fb", value:"v")) { _ in + manager.getVariant("missing_feature", fallback: MixpanelFlagVariant(key:"fb", value:"v")) { _ in expectation.fulfill() } @@ -469,7 +469,7 @@ class FeatureFlagManagerTests: XCTestCase { let numConcurrentCalls = 5 var expectations: [XCTestExpectation] = [] - var completionResults: [FeatureFlagData?] = Array(repeating: nil, count: numConcurrentCalls) + var completionResults: [MixpanelFlagVariant?] = Array(repeating: nil, count: numConcurrentCalls) // Expect tracking only ONCE for the actual feature if fetch succeeds mockDelegate.trackExpectation = XCTestExpectation(description: "Track call (should be once)") @@ -640,7 +640,7 @@ class FeatureFlagManagerTests: XCTestCase { func testDelegateConfigDisabledHandling() { // Set delegate config to disabled - mockDelegate.config = MixpanelConfig(token: "test", flagsConfig: FlagsConfig(enabled: false)) + mockDelegate.config = MixpanelOptions(token: "test", flagsConfig: FlagsConfig(enabled: false)) // Try to load flags manager.loadFlags() @@ -669,7 +669,7 @@ class FeatureFlagManagerTests: XCTestCase { do { let decoder = JSONDecoder() - let flagData = try decoder.decode(FeatureFlagData.self, from: nestedArrayJSON) + let flagData = try decoder.decode(MixpanelFlagVariant.self, from: nestedArrayJSON) XCTAssertEqual(flagData.key, "complex_array") XCTAssertNotNil(flagData.value, "Value should not be nil") @@ -727,7 +727,7 @@ class FeatureFlagManagerTests: XCTestCase { do { let decoder = JSONDecoder() - let flagData = try decoder.decode(FeatureFlagData.self, from: nestedObjectJSON) + let flagData = try decoder.decode(MixpanelFlagVariant.self, from: nestedObjectJSON) XCTAssertEqual(flagData.key, "complex_object") XCTAssertNotNil(flagData.value, "Value should not be nil") @@ -791,7 +791,7 @@ class FeatureFlagManagerTests: XCTestCase { // This is a valid test since the string will decode properly do { let decoder = JSONDecoder() - let flagData = try decoder.decode(FeatureFlagData.self, from: unsupportedTypeJSON) + let flagData = try decoder.decode(MixpanelFlagVariant.self, from: unsupportedTypeJSON) XCTAssertEqual(flagData.key, "invalid_type") XCTAssertEqual(flagData.value as? String, "infinity") } catch { @@ -807,7 +807,7 @@ class FeatureFlagManagerTests: XCTestCase { do { let decoder = JSONDecoder() - let _ = try decoder.decode(FeatureFlagData.self, from: missingValueJSON) + let _ = try decoder.decode(MixpanelFlagVariant.self, from: missingValueJSON) XCTFail("Decoding should fail with missing variant_value") } catch { // This is expected to fail, so the test passes diff --git a/Sources/Constants.swift b/Sources/Constants.swift index 5a9f64b5..88796d92 100644 --- a/Sources/Constants.swift +++ b/Sources/Constants.swift @@ -31,12 +31,6 @@ struct GzipSettings { static let gzipHeaderOffset = Int32(16) } -struct FeatureFlags { - static let flagsKey = "flags" - static let variantKey = "variant_key" - static let variantValue = "variant_value" -} - #if !os(OSX) && !os(watchOS) && !os(visionOS) extension UIDevice { var iPhoneX: Bool { diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift index af0550bb..49aafb57 100644 --- a/Sources/FeatureFlags.swift +++ b/Sources/FeatureFlags.swift @@ -29,8 +29,8 @@ struct AnyCodable: Decodable { } -// Represents the data associated with a feature flag -public struct FeatureFlagData: Decodable { +// Represents the variant associated with a feature flag +public struct MixpanelFlagVariant: Decodable { public let key: String // Corresponds to 'variant_key' from API public let value: Any? // Corresponds to 'variant_value' from API @@ -64,48 +64,12 @@ public struct FeatureFlagData: Decodable { // Response structure for the /flags endpoint struct FlagsResponse: Decodable { - let flags: [String: FeatureFlagData]? // Dictionary where key is feature name + let flags: [String: MixpanelFlagVariant]? // Dictionary where key is flag name } -// Feature Flag Config Struct conforming to Decodable -public struct FlagsConfig: Decodable { - let enabled: Bool - let context: [String: Any?] // Context for the request (using Any? for flexibility with nil) - - // Define the keys corresponding to the JSON structure - enum CodingKeys: String, CodingKey { - case enabled - case context - } - - // Custom initializer required by Decodable - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - // Decode the 'enabled' boolean directly - enabled = try container.decode(Bool.self, forKey: .enabled) - - // Decode the 'context' dictionary using AnyCodable for values - // Use decodeIfPresent if the 'context' key might be optional in the JSON - // If 'context' is guaranteed to exist, use decode(). - let anyCodableContext = try container.decodeIfPresent([String: AnyCodable].self, forKey: .context) ?? [:] - - // Map the [String: AnyCodable] dictionary to [String: Any?] - // by extracting the 'value' from each AnyCodable wrapper. - context = anyCodableContext.mapValues { $0.value } - } - - // memberwise initializer for non-decoding instantiation - public init(enabled: Bool = false, context: [String: Any?] = [:]) { - self.enabled = enabled - self.context = context - } -} - - // --- FeatureFlagDelegate Protocol --- -public protocol FeatureFlagDelegate: AnyObject { - func getConfig() -> MixpanelConfig +public protocol MixpanelFlagDelegate: AnyObject { + func getOptions() -> MixpanelOptions func getDistinctId() -> String func track(event: String?, properties: Properties?) } @@ -115,17 +79,14 @@ public protocol MixpanelFlags { /// The delegate responsible for handling feature flag lifecycle events, /// such as tracking. It is declared `weak` to prevent retain cycles. - var delegate: FeatureFlagDelegate? { get set } + var delegate: MixpanelFlagDelegate? { get set } // --- Public Methods --- - /// Initiates the loading or refreshing of flag configurations from a remote source or cache. - /// This operation should be performed asynchronously to avoid blocking the calling thread. - /// Implementations should ensure that subsequent calls to retrieve flags - /// will use the latest data once loaded. + /// Initiates the loading or refreshing of flags func loadFlags() - /// Synchronously checks if the flag configurations have been successfully loaded + /// Synchronously checks if the flags have been successfully loaded /// and are available for querying. /// /// - Returns: `true` if the flags are loaded and ready for use, `false` otherwise. @@ -133,57 +94,57 @@ public protocol MixpanelFlags { // --- Sync Flag Retrieval --- - /// Synchronously retrieves the complete `FeatureFlagData` for a given feature name. - /// If the feature flag is found and flags are ready, its data is returned. - /// Otherwise, the provided `fallback` `FeatureFlagData` is returned. + /// Synchronously retrieves the complete `MixpanelFlagVariant` for a given flag name. + /// If the feature flag is found and flags are ready, its variant is returned. + /// Otherwise, the provided `fallback` `MixpanelFlagVariant` is returned. /// This method will also trigger any necessary tracking logic for the accessed flag. /// /// - Parameters: - /// - featureName: The unique identifier for the feature flag. - /// - fallback: The `FeatureFlagData` to return if the specified flag is not found + /// - flagName: The unique identifier for the feature flag. + /// - fallback: The `MixpanelFlagVariant` to return if the specified flag is not found /// or if the flags are not yet loaded. - /// - Returns: The `FeatureFlagData` associated with `featureName`, or the `fallback` data. - func getVariantSync(_ featureName: String, fallback: FeatureFlagData) -> FeatureFlagData + /// - Returns: The `MixpanelFlagVariant` associated with `flagName`, or the `fallback` variant. + func getVariantSync(_ flagName: String, fallback: MixpanelFlagVariant) -> MixpanelFlagVariant - /// Asynchronously retrieves the complete `FeatureFlagData` for a given feature name. + /// Asynchronously retrieves the complete `MixpanelFlagVariant` for a given flag name. /// If flags are not ready, an attempt will be made to load them. - /// The `completion` handler is called with the `FeatureFlagData` for the feature, - /// or the `fallback` data if the flag is not found or loading fails. + /// The `completion` handler is called with the `MixpanelFlagVariant` for the flag, + /// or the `fallback` variant if the flag is not found or loading fails. /// This method will also trigger any necessary tracking logic for the accessed flag. /// The completion handler is typically invoked on the main thread. /// /// - Parameters: - /// - featureName: The unique identifier for the feature flag. - /// - fallback: The `FeatureFlagData` to use as a default if the specified flag + /// - flagName: The unique identifier for the feature flag. + /// - fallback: The `MixpanelFlagVariant` to use as a default if the specified flag /// is not found or an error occurs during fetching. - /// - completion: A closure that is called with the resulting `FeatureFlagData`. + /// - completion: A closure that is called with the resulting `MixpanelFlagVariant`. /// This closure will be executed on the main dispatch queue. - func getVariant(_ featureName: String, fallback: FeatureFlagData, completion: @escaping (FeatureFlagData) -> Void) + func getVariant(_ flagName: String, fallback: MixpanelFlagVariant, completion: @escaping (MixpanelFlagVariant) -> Void) /// Synchronously retrieves the underlying value of a feature flag. - /// This is a convenience method that extracts the `value` property from the `FeatureFlagData` + /// This is a convenience method that extracts the `value` property from the `MixpanelFlagVariant` /// obtained via `getVariantSync`. /// /// - Parameters: - /// - featureName: The unique identifier for the feature flag. + /// - flagName: The unique identifier for the feature flag. /// - fallbackValue: The default value to return if the flag is not found, - /// its data doesn't contain a value, or flags are not ready. + /// its variant doesn't contain a value, or flags are not ready. /// - Returns: The value of the feature flag, or `fallbackValue`. The type is `Any?`. - func getVariantValueSync(_ featureName: String, fallbackValue: Any?) -> Any? + func getVariantValueSync(_ flagName: String, fallbackValue: Any?) -> Any? /// Asynchronously retrieves the underlying value of a feature flag. - /// This is a convenience method that extracts the `value` property from the `FeatureFlagData` + /// This is a convenience method that extracts the `value` property from the `MixpanelFlagVariant` /// obtained via `getVariant`. If flags are not ready, an attempt will be made to load them. /// The `completion` handler is called with the flag's value or the `fallbackValue`. /// The completion handler is typically invoked on the main thread. /// /// - Parameters: - /// - featureName: The unique identifier for the feature flag. + /// - flagName: The unique identifier for the feature flag. /// - fallbackValue: The default value to use if the flag is not found, - /// fetching fails, or its data doesn't contain a value. + /// fetching fails, or its variant doesn't contain a value. /// - completion: A closure that is called with the resulting value (`Any?`). /// This closure will be executed on the main dispatch queue. - func getVariantValue(_ featureName: String, fallbackValue: Any?, completion: @escaping (Any?) -> Void) + func getVariantValue(_ flagName: String, fallbackValue: Any?, completion: @escaping (Any?) -> Void) /// Synchronously checks if a specific feature flag is considered "enabled". /// This typically involves retrieving the flag's value and evaluating it as a boolean. @@ -191,11 +152,11 @@ public protocol MixpanelFlags { /// should be defined by the implementing class. /// /// - Parameters: - /// - featureName: The unique identifier for the feature flag. + /// - flagName: The unique identifier for the feature flag. /// - fallbackValue: The boolean value to return if the flag is not found, /// cannot be evaluated as a boolean, or flags are not ready. Defaults to `false`. /// - Returns: `true` if the flag is considered enabled, `false` otherwise (including if `fallbackValue` is used). - func isFlagEnabledSync(_ featureName: String, fallbackValue: Bool) -> Bool + func isFlagEnabledSync(_ flagName: String, fallbackValue: Bool) -> Bool /// Asynchronously checks if a specific feature flag is considered "enabled". /// This typically involves retrieving the flag's value and evaluating it as a boolean. @@ -204,12 +165,12 @@ public protocol MixpanelFlags { /// The completion handler is typically invoked on the main thread. /// /// - Parameters: - /// - featureName: The unique identifier for the feature flag. + /// - flagName: The unique identifier for the feature flag. /// - fallbackValue: The boolean value to use if the flag is not found, fetching fails, /// or it cannot be evaluated as a boolean. Defaults to `false`. /// - completion: A closure that is called with the boolean result. /// This closure will be executed on the main dispatch queue. - func isFlagEnabled(_ featureName: String, fallbackValue: Bool, completion: @escaping (Bool) -> Void) + func isFlagEnabled(_ flagName: String, fallbackValue: Bool, completion: @escaping (Bool) -> Void) } @@ -217,19 +178,19 @@ public protocol MixpanelFlags { class FeatureFlagManager: Network, MixpanelFlags { - weak var delegate: FeatureFlagDelegate? + weak var delegate: MixpanelFlagDelegate? // *** Use a SERIAL queue for automatic state serialization *** let accessQueue = DispatchQueue(label: "com.mixpanel.featureflagmanager.serialqueue") // Internal State - Protected by accessQueue - var flags: [String: FeatureFlagData]? = nil + var flags: [String: MixpanelFlagVariant]? = nil var isFetching: Bool = false private var trackedFeatures: Set = Set() private var fetchCompletionHandlers: [(Bool) -> Void] = [] // Configuration - private var currentConfig: MixpanelConfig? { delegate?.getConfig() } + private var currentOptions: MixpanelOptions? { delegate?.getOptions() } private var flagsRoute = "/flags/" // Initializers @@ -237,7 +198,7 @@ class FeatureFlagManager: Network, MixpanelFlags { super.init(serverURL: serverURL) } - public init(serverURL: String, delegate: FeatureFlagDelegate?) { + public init(serverURL: String, delegate: MixpanelFlagDelegate?) { self.delegate = delegate super.init(serverURL: serverURL) } @@ -259,47 +220,47 @@ class FeatureFlagManager: Network, MixpanelFlags { // --- Sync Flag Retrieval --- - func getVariantSync(_ featureName: String, fallback: FeatureFlagData) -> FeatureFlagData { - var featureData: FeatureFlagData? + func getVariantSync(_ flagName: String, fallback: MixpanelFlagVariant) -> MixpanelFlagVariant { + var flagVariant: MixpanelFlagVariant? var tracked = false // === Serial Queue: Single Sync Block for Read AND Track Update === accessQueue.sync { guard let currentFlags = self.flags else { return } - if let feature = currentFlags[featureName] { - featureData = feature + if let variant = currentFlags[flagName] { + flagVariant = variant // Perform atomic check-and-set for tracking *within the same sync block* - if !self.trackedFeatures.contains(featureName) { - self.trackedFeatures.insert(featureName) + if !self.trackedFeatures.contains(flagName) { + self.trackedFeatures.insert(flagName) tracked = true } } - // If feature wasn't found, featureData remains nil + // If flag wasn't found, flagVariant remains nil } // === End Sync Block === // Now, process the results outside the lock - if let foundFeature = featureData { + if let foundVariant = flagVariant { // If tracking was done *in this call*, call the delegate if tracked { - self._performTrackingDelegateCall(featureName: featureName, feature: foundFeature) + self._performTrackingDelegateCall(flagName: flagName, variant: foundVariant) } - return foundFeature + return foundVariant } else { - print("Info: Flag '\(featureName)' not found or flags not ready. Returning fallback.") + print("Info: Flag '\(flagName)' not found or flags not ready. Returning fallback.") return fallback } } // --- Async Flag Retrieval --- - func getVariant(_ featureName: String, fallback: FeatureFlagData, completion: @escaping (FeatureFlagData) -> Void) { + func getVariant(_ flagName: String, fallback: MixpanelFlagVariant, completion: @escaping (MixpanelFlagVariant) -> Void) { accessQueue.async { [weak self] in // Block A runs serially on accessQueue guard let self = self else { return } - var featureData: FeatureFlagData? + var flagVariant: MixpanelFlagVariant? var needsTrackingCheck = false var flagsAreCurrentlyReady = false @@ -307,20 +268,20 @@ class FeatureFlagManager: Network, MixpanelFlags { // No inner sync needed - we are already synchronized by the serial queue flagsAreCurrentlyReady = (self.flags != nil) if flagsAreCurrentlyReady, let currentFlags = self.flags { - if let feature = currentFlags[featureName] { - featureData = feature + if let variant = currentFlags[flagName] { + flagVariant = variant // Also safe to access trackedFeatures directly here - needsTrackingCheck = !self.trackedFeatures.contains(featureName) + needsTrackingCheck = !self.trackedFeatures.contains(flagName) } } // === State access finished === if flagsAreCurrentlyReady { - let result = featureData ?? fallback - if featureData != nil, needsTrackingCheck { + let result = flagVariant ?? fallback + if flagVariant != nil, needsTrackingCheck { // Perform atomic check-and-track. _trackFeatureIfNeeded uses its // own sync block, which is safe to call from here (it's not nested). - self._trackFeatureIfNeeded(featureName: featureName, feature: result) + self._trackFlagIfNeeded(flagName: flagName, variant: result) } DispatchQueue.main.async { completion(result) } @@ -330,12 +291,12 @@ class FeatureFlagManager: Network, MixpanelFlags { print("Flags not ready, attempting fetch for getFeature call...") self._fetchFlagsIfNeeded { success in // This completion runs *after* fetch completes (or fails) - let result: FeatureFlagData + let result: MixpanelFlagVariant if success { - // Fetch succeeded, get the feature SYNCHRONOUSLY - result = self.getVariantSync(featureName, fallback: fallback) + // Fetch succeeded, get the flag SYNCHRONOUSLY + result = self.getVariantSync(flagName, fallback: fallback) } else { - print("Warning: Failed to fetch flags, returning fallback for \(featureName).") + print("Warning: Failed to fetch flags, returning fallback for \(flagName).") result = fallback } // Call original completion (on main thread) @@ -348,28 +309,28 @@ class FeatureFlagManager: Network, MixpanelFlags { } // End accessQueue.async (Block A) } - func getVariantValueSync(_ featureName: String, fallbackValue: Any?) -> Any? { - return getVariantSync(featureName, fallback: FeatureFlagData(value: fallbackValue)).value + func getVariantValueSync(_ flagName: String, fallbackValue: Any?) -> Any? { + return getVariantSync(flagName, fallback: MixpanelFlagVariant(value: fallbackValue)).value } - func getVariantValue(_ featureName: String, fallbackValue: Any?, completion: @escaping (Any?) -> Void) { - getVariant(featureName, fallback: FeatureFlagData(value: fallbackValue)) { featureData in - completion(featureData.value) + func getVariantValue(_ flagName: String, fallbackValue: Any?, completion: @escaping (Any?) -> Void) { + getVariant(flagName, fallback: MixpanelFlagVariant(value: fallbackValue)) { flagVariant in + completion(flagVariant.value) } } - func isFlagEnabledSync(_ featureName: String, fallbackValue: Bool = false) -> Bool { - let dataValue = getVariantValueSync(featureName, fallbackValue: fallbackValue) - return self._evaluateBooleanFlag(featureName: featureName, dataValue: dataValue, fallbackValue: fallbackValue) + func isFlagEnabledSync(_ flagName: String, fallbackValue: Bool = false) -> Bool { + let variantValue = getVariantValueSync(flagName, fallbackValue: fallbackValue) + return self._evaluateBooleanFlag(flagName: flagName, variantValue: variantValue, fallbackValue: fallbackValue) } - func isFlagEnabled(_ featureName: String, fallbackValue: Bool = false, completion: @escaping (Bool) -> Void) { - getVariantValue(featureName, fallbackValue: fallbackValue) { [weak self] dataValue in + func isFlagEnabled(_ flagName: String, fallbackValue: Bool = false, completion: @escaping (Bool) -> Void) { + getVariantValue(flagName, fallbackValue: fallbackValue) { [weak self] variantValue in guard let self = self else { completion(fallbackValue) return } - let result = self._evaluateBooleanFlag(featureName: featureName, dataValue: dataValue, fallbackValue: fallbackValue) + let result = self._evaluateBooleanFlag(flagName: flagName, variantValue: variantValue, fallbackValue: fallbackValue) completion(result) } } @@ -380,10 +341,10 @@ class FeatureFlagManager: Network, MixpanelFlags { private func _fetchFlagsIfNeeded(completion: ((Bool) -> Void)?) { var shouldStartFetch = false - let configSnapshot = self.currentConfig // Read config directly (safe on accessQueue) + let optionsSnapshot = self.currentOptions // Read options directly (safe on accessQueue) - guard let config = configSnapshot, config.flagsConfig.enabled else { + guard let options = optionsSnapshot, options.featureFlagsEnabled else { print("Feature flags are disabled, not fetching.") // Call completion immediately since we know the result and are on the queue. completion?(false) @@ -421,8 +382,8 @@ class FeatureFlagManager: Network, MixpanelFlags { private func _performFetchRequest() { // This method runs OUTSIDE the accessQueue - guard let delegate = self.delegate, let config = self.currentConfig else { - print("Error: Delegate or config missing for fetch.") + guard let delegate = self.delegate, let options = self.currentOptions else { + print("Error: Delegate or options missing for fetch.") self._completeFetch(success: false) return } @@ -430,7 +391,7 @@ class FeatureFlagManager: Network, MixpanelFlags { let distinctId = delegate.getDistinctId() print("Fetching flags for distinct ID: \(distinctId)") - var context = config.flagsConfig.context + var context = options.featureFlagsContext context["distinct_id"] = distinctId let requestBodyDict = ["context": context] @@ -438,7 +399,7 @@ class FeatureFlagManager: Network, MixpanelFlags { print("Error: Failed to serialize request body for flags.") self._completeFetch(success: false); return } - guard let authData = "\(config.token):".data(using: .utf8) else { + guard let authData = "\(options.token):".data(using: .utf8) else { print("Error: Failed to create auth data."); self._completeFetch(success: false); return } let base64Auth = authData.base64EncodedString() @@ -490,39 +451,39 @@ class FeatureFlagManager: Network, MixpanelFlags { // --- Tracking Logic --- // Performs the atomic check and triggers delegate call if needed - private func _trackFeatureIfNeeded(featureName: String, feature: FeatureFlagData) { + private func _trackFlagIfNeeded(flagName: String, variant: MixpanelFlagVariant) { var shouldCallDelegate = false // We are already executing on the serial accessQueue, so this is safe. - if !self.trackedFeatures.contains(featureName) { - self.trackedFeatures.insert(featureName) + if !self.trackedFeatures.contains(flagName) { + self.trackedFeatures.insert(flagName) shouldCallDelegate = true } // Call delegate *outside* this conceptual block if tracking occurred // This prevents holding any potential implicit lock during delegate execution if shouldCallDelegate { - self._performTrackingDelegateCall(featureName: featureName, feature: feature) + self._performTrackingDelegateCall(flagName: flagName, variant: variant) } } // Helper to just call the delegate (no locking) - private func _performTrackingDelegateCall(featureName: String, feature: FeatureFlagData) { + private func _performTrackingDelegateCall(flagName: String, variant: MixpanelFlagVariant) { guard let delegate = self.delegate else { return } let properties: Properties = [ - "Experiment name": featureName, "Variant name": feature.key, "$experiment_type": "feature_flag" + "Experiment name": flagName, "Variant name": variant.key, "$experiment_type": "feature_flag" ] // Dispatch delegate call asynchronously to main thread for safety DispatchQueue.main.async { delegate.track(event: "$experiment_started", properties: properties) - print("Tracked $experiment_started for \(featureName) (dispatched to main)") + print("Tracked $experiment_started for \(flagName) (dispatched to main)") } } // --- Boolean Evaluation Helper --- - private func _evaluateBooleanFlag(featureName: String, dataValue: Any?, fallbackValue: Bool) -> Bool { - guard let val = dataValue else { return fallbackValue } + private func _evaluateBooleanFlag(flagName: String, variantValue: Any?, fallbackValue: Bool) -> Bool { + guard let val = variantValue else { return fallbackValue } if let boolVal = val as? Bool { return boolVal } - else { print("Error: Flag '\(featureName)' is not Bool"); return fallbackValue } + else { print("Error: Flag '\(flagName)' is not Bool"); return fallbackValue } } } diff --git a/Sources/Mixpanel.swift b/Sources/Mixpanel.swift index 2716fa98..8c627f27 100644 --- a/Sources/Mixpanel.swift +++ b/Sources/Mixpanel.swift @@ -15,8 +15,8 @@ import UIKit open class Mixpanel { @discardableResult - open class func initialize(config: MixpanelConfig) -> MixpanelInstance { - return MixpanelManager.sharedInstance.initialize(config: config) + open class func initialize(options: MixpanelOptions) -> MixpanelInstance { + return MixpanelManager.sharedInstance.initialize(config: options) } #if !os(OSX) && !os(watchOS) @@ -264,15 +264,6 @@ open class Mixpanel { open class func removeInstance(name: String) { MixpanelManager.sharedInstance.removeInstance(name: name) } - - open class func getConfig(name: String? = nil) -> MixpanelConfig? { - if let name, let instance = MixpanelManager.sharedInstance.getInstance(name: name) { - return instance.getConfig() - } else if let instance = MixpanelManager.sharedInstance.getMainInstance() { - return instance.getConfig() - } - return nil - } } final class MixpanelManager { @@ -290,7 +281,7 @@ final class MixpanelManager { instanceQueue = DispatchQueue(label: "com.mixpanel.instance.manager.instance", qos: .utility, autoreleaseFrequency: .workItem) } - func initialize(config: MixpanelConfig) -> MixpanelInstance { + func initialize(config: MixpanelOptions) -> MixpanelInstance { let instanceName = config.instanceName ?? config.token return dequeueInstance(instanceName: instanceName) { return MixpanelInstance(config: config) diff --git a/Sources/MixpanelInstance.swift b/Sources/MixpanelInstance.swift index 24985c02..caee4957 100644 --- a/Sources/MixpanelInstance.swift +++ b/Sources/MixpanelInstance.swift @@ -75,9 +75,9 @@ public struct ProxyServerConfig { } /// The class that represents the Mixpanel Instance -open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDelegate, FeatureFlagDelegate { +open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDelegate, MixpanelFlagDelegate { - private let config: MixpanelConfig + private let config: MixpanelOptions /// apiToken string that identifies the project to track data to open var apiToken = "" @@ -273,7 +273,7 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele private let registerSuperPropertiesNotificationName = Notification.Name("com.mixpanel.properties.register") private let unregisterSuperPropertiesNotificationName = Notification.Name("com.mixpanel.properties.unregister") - convenience init(config: MixpanelConfig) { + convenience init(config: MixpanelOptions) { self.init(apiToken: config.token, flushInterval: config.flushInterval, name: config.instanceName ?? config.token, @@ -345,10 +345,10 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele serverURL: String? = nil, proxyServerDelegate: MixpanelProxyServerDelegate? = nil, useGzipCompression: Bool = false, - config: MixpanelConfig? = nil + config: MixpanelOptions? = nil ) { // Store the config if provided, otherwise create one with the current values - self.config = config ?? MixpanelConfig( + self.config = config ?? MixpanelOptions( token: apiToken ?? "", flushInterval: flushInterval, instanceName: name, @@ -442,7 +442,7 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele flags.loadFlags() } - public func getConfig() -> MixpanelConfig { + public func getOptions() -> MixpanelOptions { return config } diff --git a/Sources/MixpanelConfig.swift b/Sources/MixpanelOptions.swift similarity index 80% rename from Sources/MixpanelConfig.swift rename to Sources/MixpanelOptions.swift index bfbf1767..cbd30c06 100644 --- a/Sources/MixpanelConfig.swift +++ b/Sources/MixpanelOptions.swift @@ -1,5 +1,5 @@ // -// public.swift +// MixpanelOptions.swift // Mixpanel // // Created by Jared McFarland on 4/15/25. @@ -7,8 +7,7 @@ // -// New MixpanelConfig class -public class MixpanelConfig { +public class MixpanelOptions { public let token: String public let flushInterval: Double public let instanceName: String? @@ -19,7 +18,8 @@ public class MixpanelConfig { public let serverURL: String? public let proxyServerConfig: ProxyServerConfig? public let useGzipCompression: Bool - public let flagsConfig: FlagsConfig + public let featureFlagsEnabled: Bool + public let featureFlagsContext: [String: Any] public init(token: String, flushInterval: Double = 60, @@ -31,7 +31,8 @@ public class MixpanelConfig { serverURL: String? = nil, proxyServerConfig: ProxyServerConfig? = nil, useGzipCompression: Bool = true, // NOTE: This is a new default value! - flagsConfig: FlagsConfig = FlagsConfig()) { + featureFlagsEnabled: Bool = false, + featureFlagsContext: [String: Any] = [:]) { self.token = token self.flushInterval = flushInterval self.instanceName = instanceName @@ -42,6 +43,7 @@ public class MixpanelConfig { self.serverURL = serverURL self.proxyServerConfig = proxyServerConfig self.useGzipCompression = useGzipCompression - self.flagsConfig = flagsConfig + self.featureFlagsEnabled = featureFlagsEnabled + self.featureFlagsContext = featureFlagsContext } } diff --git a/Sources/MixpanelPersistence.swift b/Sources/MixpanelPersistence.swift index 0353d9c0..50ea622e 100644 --- a/Sources/MixpanelPersistence.swift +++ b/Sources/MixpanelPersistence.swift @@ -190,6 +190,9 @@ class MixpanelPersistence { } } + /// -- Feauture Flags -- + /// NOT currently used + static func saveFlags(flags: InternalProperties, instanceName: String) { guard let defaults = UserDefaults(suiteName: MixpanelUserDefaultsKeys.suiteName) else { return From ebffa40d8ce0e71174ea8ac71aa74bac07450a55 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Thu, 15 May 2025 17:12:34 -0700 Subject: [PATCH 10/20] fix demo app delegate --- MixpanelDemo/MixpanelDemo/AppDelegate.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MixpanelDemo/MixpanelDemo/AppDelegate.swift b/MixpanelDemo/MixpanelDemo/AppDelegate.swift index 1fc31635..a89f1416 100644 --- a/MixpanelDemo/MixpanelDemo/AppDelegate.swift +++ b/MixpanelDemo/MixpanelDemo/AppDelegate.swift @@ -16,7 +16,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { - let mixpanelOptions = MixpanelOptions(token: "metrics-1", trackAutomaticEvents: false, flagsConfig: FlagsConfig(enabled: true, context: ["key": "value"])) + let mixpanelOptions = MixpanelOptions(token: "metrics-1", trackAutomaticEvents: false, featureFlagsEnabled: true, featureFlagsContext: ["key": "value"]) Mixpanel.initialize(options: mixpanelOptions) Mixpanel.mainInstance().loggingEnabled = true From 5bdf20a55bd2a33ad9ed451a8f9492a1939af89e Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Thu, 15 May 2025 17:19:58 -0700 Subject: [PATCH 11/20] fix tests --- .../MixpanelFeatureFlagTests.swift | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift index 252bd378..f99611eb 100644 --- a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift +++ b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift @@ -13,21 +13,21 @@ import XCTest class MockFeatureFlagDelegate: MixpanelFlagDelegate { - var config: MixpanelOptions + var options: MixpanelOptions var distinctId: String var trackedEvents: [(event: String?, properties: Properties?)] = [] var trackExpectation: XCTestExpectation? - var getConfigCallCount = 0 + var getOptionsCallCount = 0 var getDistinctIdCallCount = 0 - init(config: MixpanelOptions = MixpanelOptions(token: "test", flagsConfig: FlagsConfig(enabled: true)), distinctId: String = "test_distinct_id") { - self.config = config + init(options: MixpanelOptions = MixpanelOptions(token: "test", featureFlagsEnabled: true), distinctId: String = "test_distinct_id") { + self.options = options self.distinctId = distinctId } func getOptions() -> MixpanelOptions { - getConfigCallCount += 1 - return config + getOptionsCallCount += 1 + return options } func getDistinctId() -> String { @@ -171,7 +171,7 @@ class FeatureFlagManagerTests: XCTestCase { // --- Load Flags Tests --- func testLoadFlags_WhenDisabledInConfig() { - mockDelegate.config = MixpanelOptions(token:"test", flagsConfig: FlagsConfig(enabled: false)) // Explicitly disable + mockDelegate.options = MixpanelOptions(token:"test", featureFlagsEnabled: false) // Explicitly disable manager.loadFlags() // Call public API // Wait to ensure no async fetch operations started changing state @@ -639,16 +639,16 @@ class FeatureFlagManagerTests: XCTestCase { } func testDelegateConfigDisabledHandling() { - // Set delegate config to disabled - mockDelegate.config = MixpanelOptions(token: "test", flagsConfig: FlagsConfig(enabled: false)) + // Set delegate options to disabled + mockDelegate.options = MixpanelOptions(token: "test", featureFlagsEnabled: false) // Try to load flags manager.loadFlags() // Verify no fetch is triggered - let expectation = XCTestExpectation(description: "Check disabled config behavior") + let expectation = XCTestExpectation(description: "Check disabled options behavior") DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - XCTAssertFalse(self.manager.areFlagsReady(), "Flags should not be ready when config disabled") + XCTAssertFalse(self.manager.areFlagsReady(), "Flags should not be ready when options disabled") expectation.fulfill() } wait(for: [expectation], timeout: 1.0) From 117ddd44106953cd447801eae8cda3e78f1b2ec0 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Thu, 15 May 2025 17:26:54 -0700 Subject: [PATCH 12/20] update podspec --- Mixpanel-swift.podspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mixpanel-swift.podspec b/Mixpanel-swift.podspec index 6cc49d9c..499ea524 100644 --- a/Mixpanel-swift.podspec +++ b/Mixpanel-swift.podspec @@ -21,7 +21,7 @@ Pod::Spec.new do |s| 'Sources/Constants.swift', 'Sources/MixpanelType.swift', 'Sources/Mixpanel.swift', 'Sources/MixpanelInstance.swift', 'Sources/Flush.swift', 'Sources/Track.swift', 'Sources/People.swift', 'Sources/AutomaticEvents.swift', 'Sources/Group.swift', 'Sources/ReadWriteLock.swift', 'Sources/SessionMetadata.swift', 'Sources/MPDB.swift', 'Sources/MixpanelPersistence.swift', - 'Sources/Data+Compression.swift', 'Sources/MixpanelConfig.swift', 'Sources/FeatureFlags.swift'] + 'Sources/Data+Compression.swift', 'Sources/MixpanelOptions.swift', 'Sources/FeatureFlags.swift'] s.tvos.deployment_target = '11.0' s.tvos.frameworks = 'UIKit', 'Foundation' s.tvos.pod_target_xcconfig = { From 5ce6ff75d5bbb1ee86664bd2c7a075002358e228 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Thu, 15 May 2025 17:49:39 -0700 Subject: [PATCH 13/20] revert demo app changes --- MixpanelDemo/MixpanelDemo/AppDelegate.swift | 3 +- .../MixpanelDemo/TrackingViewController.swift | 49 +------------------ 2 files changed, 3 insertions(+), 49 deletions(-) diff --git a/MixpanelDemo/MixpanelDemo/AppDelegate.swift b/MixpanelDemo/MixpanelDemo/AppDelegate.swift index a89f1416..a2906a53 100644 --- a/MixpanelDemo/MixpanelDemo/AppDelegate.swift +++ b/MixpanelDemo/MixpanelDemo/AppDelegate.swift @@ -16,7 +16,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { - let mixpanelOptions = MixpanelOptions(token: "metrics-1", trackAutomaticEvents: false, featureFlagsEnabled: true, featureFlagsContext: ["key": "value"]) + var ADD_YOUR_MIXPANEL_TOKEN_BELOW_🛠🛠🛠🛠🛠🛠: String + let mixpanelOptions = MixpanelOptions(token: "MIXPANEL_TOKEN", trackAutomaticEvents: true) Mixpanel.initialize(options: mixpanelOptions) Mixpanel.mainInstance().loggingEnabled = true diff --git a/MixpanelDemo/MixpanelDemo/TrackingViewController.swift b/MixpanelDemo/MixpanelDemo/TrackingViewController.swift index 97a2bacb..d40e9581 100644 --- a/MixpanelDemo/MixpanelDemo/TrackingViewController.swift +++ b/MixpanelDemo/MixpanelDemo/TrackingViewController.swift @@ -21,15 +21,7 @@ class TrackingViewController: UIViewController, UITableViewDelegate, UITableView "Register SuperProperties", "Register SuperProperties Once", "Register SP Once w Default Value", - "Unregister SuperProperty", - "Load Flags", - "Are Features Ready", - "Get Feature", - "Get Feature Sync", - "Get Feature Data", - "Get Feature Data Sync", - "Is Feature Enabled", - "Is Feature Enabled Sync"] + "Unregister SuperProperty"] override func viewDidLoad() { super.viewDidLoad() @@ -100,45 +92,6 @@ class TrackingViewController: UIViewController, UITableViewDelegate, UITableView let p = "Super Property 2" Mixpanel.mainInstance().unregisterSuperProperty(p) descStr = "Properties: \(p)" - case 10: - Mixpanel.mainInstance().flags.loadFlags() - descStr = "Flags Loaded" - case 11: - let ready = Mixpanel.mainInstance().flags.areFlagsReady() - descStr = "Features Ready: \(ready)" - case 12: - var flagData = MixpanelFlagVariant(key: "super-neat") - Mixpanel.mainInstance().flags.getVariant("marks_nifty_feature_flag", fallback: flagData) { data in - flagData = data - print("Feature: \(flagData.key), Value: \(String(describing: flagData.value))") - } - descStr = "Feature: \(flagData.key), Value: \(String(describing: flagData.value))" - case 13: - var flagData = MixpanelFlagVariant(key: "enabled") - flagData = Mixpanel.mainInstance().flags.getVariantSync("jb_qa_flag", fallback: flagData) - descStr = "Feature: \(flagData.key), Value: \(String(describing: flagData.value))" - case 14: - var flagValue = "NOT_donnaqacontrol" - Mixpanel.mainInstance().flags.getVariantValue("new_feature_flag_1744737773860", fallbackValue: flagValue) { value in - flagValue = value as! String - print("Feature Value: \(flagValue)") - } - descStr = "Feature Value: \(flagValue)" - case 15: - var flagValue = "NOT_donnaqacontrol" - flagValue = Mixpanel.mainInstance().flags.getVariantValueSync("new_feature_flag_1744737773860", fallbackValue: flagValue) as! String - descStr = "Feature Value: \(flagValue)" - case 16: - var enabled = false - Mixpanel.mainInstance().flags.isFlagEnabled("jared_boolean_flag", fallbackValue: enabled) { isEnabled in - enabled = isEnabled - print("Feature Enabled: \(enabled)") - } - descStr = "Feature Enabled: \(enabled)" - case 17: - var enabled = false - enabled = Mixpanel.mainInstance().flags.isFlagEnabledSync("jared_boolean_flag", fallbackValue: enabled) - descStr = "Feature Enabled: \(enabled)" default: break } From ee05cea66b41fb7cb187dfe8e65546f79453acb4 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Thu, 15 May 2025 17:51:56 -0700 Subject: [PATCH 14/20] one more config -> options rename --- Sources/Mixpanel.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Mixpanel.swift b/Sources/Mixpanel.swift index 8c627f27..a7f16959 100644 --- a/Sources/Mixpanel.swift +++ b/Sources/Mixpanel.swift @@ -16,7 +16,7 @@ open class Mixpanel { @discardableResult open class func initialize(options: MixpanelOptions) -> MixpanelInstance { - return MixpanelManager.sharedInstance.initialize(config: options) + return MixpanelManager.sharedInstance.initialize(options: options) } #if !os(OSX) && !os(watchOS) @@ -281,10 +281,10 @@ final class MixpanelManager { instanceQueue = DispatchQueue(label: "com.mixpanel.instance.manager.instance", qos: .utility, autoreleaseFrequency: .workItem) } - func initialize(config: MixpanelOptions) -> MixpanelInstance { - let instanceName = config.instanceName ?? config.token + func initialize(options: MixpanelOptions) -> MixpanelInstance { + let instanceName = options.instanceName ?? options.token return dequeueInstance(instanceName: instanceName) { - return MixpanelInstance(config: config) + return MixpanelInstance(config: options) } } From 8db0339be55d6c1dd6b53b5e0d9f56715711eb52 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Thu, 15 May 2025 17:55:27 -0700 Subject: [PATCH 15/20] nope... more config->options renaming --- Sources/Mixpanel.swift | 2 +- Sources/MixpanelInstance.swift | 32 ++++++++++++++++---------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Sources/Mixpanel.swift b/Sources/Mixpanel.swift index a7f16959..ba22af39 100644 --- a/Sources/Mixpanel.swift +++ b/Sources/Mixpanel.swift @@ -284,7 +284,7 @@ final class MixpanelManager { func initialize(options: MixpanelOptions) -> MixpanelInstance { let instanceName = options.instanceName ?? options.token return dequeueInstance(instanceName: instanceName) { - return MixpanelInstance(config: options) + return MixpanelInstance(options: options) } } diff --git a/Sources/MixpanelInstance.swift b/Sources/MixpanelInstance.swift index caee4957..155fd0cd 100644 --- a/Sources/MixpanelInstance.swift +++ b/Sources/MixpanelInstance.swift @@ -77,7 +77,7 @@ public struct ProxyServerConfig { /// The class that represents the Mixpanel Instance open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDelegate, MixpanelFlagDelegate { - private let config: MixpanelOptions + private let options: MixpanelOptions /// apiToken string that identifies the project to track data to open var apiToken = "" @@ -273,18 +273,18 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele private let registerSuperPropertiesNotificationName = Notification.Name("com.mixpanel.properties.register") private let unregisterSuperPropertiesNotificationName = Notification.Name("com.mixpanel.properties.unregister") - convenience init(config: MixpanelOptions) { - self.init(apiToken: config.token, - flushInterval: config.flushInterval, - name: config.instanceName ?? config.token, - trackAutomaticEvents: config.trackAutomaticEvents, - optOutTrackingByDefault: config.optOutTrackingByDefault, - useUniqueDistinctId: config.useUniqueDistinctId, - superProperties: config.superProperties, - serverURL: config.serverURL, - proxyServerDelegate: config.proxyServerConfig?.delegate, - useGzipCompression: config.useGzipCompression, - config: config) + convenience init(options: MixpanelOptions) { + self.init(apiToken: options.token, + flushInterval: options.flushInterval, + name: options.instanceName ?? options.token, + trackAutomaticEvents: options.trackAutomaticEvents, + optOutTrackingByDefault: options.optOutTrackingByDefault, + useUniqueDistinctId: options.useUniqueDistinctId, + superProperties: options.superProperties, + serverURL: options.serverURL, + proxyServerDelegate: options.proxyServerConfig?.delegate, + useGzipCompression: options.useGzipCompression, + options: options) } convenience init( @@ -345,10 +345,10 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele serverURL: String? = nil, proxyServerDelegate: MixpanelProxyServerDelegate? = nil, useGzipCompression: Bool = false, - config: MixpanelOptions? = nil + options: MixpanelOptions? = nil ) { // Store the config if provided, otherwise create one with the current values - self.config = config ?? MixpanelOptions( + self.options = options ?? MixpanelOptions( token: apiToken ?? "", flushInterval: flushInterval, instanceName: name, @@ -443,7 +443,7 @@ open class MixpanelInstance: CustomDebugStringConvertible, FlushDelegate, AEDele } public func getOptions() -> MixpanelOptions { - return config + return options } public func getDistinctId() -> String { From 8c2a9669ae963fc4d69c4d10cae0c1c1ef66ab7b Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Thu, 22 May 2025 13:02:46 -0700 Subject: [PATCH 16/20] modify enabled method names and load flags on identify --- .gitignore | 3 + .../MixpanelDemoTests/MixpanelDemoTests.swift | 59 +++++++++++++++++++ .../MixpanelFeatureFlagTests.swift | 20 +++---- Sources/FeatureFlags.swift | 8 +-- Sources/MixpanelInstance.swift | 1 + 5 files changed, 77 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 1d6f9765..ff2a1abb 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ profile Carthage/Build/ MixpanelDemo/build/ + +# Claude.ai instructions +CLAUDE.md diff --git a/MixpanelDemo/MixpanelDemoTests/MixpanelDemoTests.swift b/MixpanelDemo/MixpanelDemoTests/MixpanelDemoTests.swift index 36052d58..5b3d7efc 100644 --- a/MixpanelDemo/MixpanelDemoTests/MixpanelDemoTests.swift +++ b/MixpanelDemo/MixpanelDemoTests/MixpanelDemoTests.swift @@ -179,8 +179,51 @@ class MixpanelDemoTests: MixpanelBaseTests { removeDBfile(testMixpanel.apiToken) } + // Mock implementation of MixpanelFlags to track loadFlags calls + class MockMixpanelFlags: MixpanelFlags { + var delegate: MixpanelFlagDelegate? + var loadFlagsCallCount = 0 + + func loadFlags() { + loadFlagsCallCount += 1 + } + + func areFlagsReady() -> Bool { + return true + } + + func getVariantSync(_ flagName: String, fallback: MixpanelFlagVariant) -> MixpanelFlagVariant { + return fallback + } + + func getVariant(_ flagName: String, fallback: MixpanelFlagVariant, completion: @escaping (MixpanelFlagVariant) -> Void) { + completion(fallback) + } + + func getVariantValueSync(_ flagName: String, fallbackValue: Any?) -> Any? { + return fallbackValue + } + + func getVariantValue(_ flagName: String, fallbackValue: Any?, completion: @escaping (Any?) -> Void) { + completion(fallbackValue) + } + + func isEnabledSync(_ flagName: String, fallbackValue: Bool) -> Bool { + return fallbackValue + } + + func isEnabled(_ flagName: String, fallbackValue: Bool, completion: @escaping (Bool) -> Void) { + completion(fallbackValue) + } + } + func testIdentify() { let testMixpanel = Mixpanel.initialize(token: randomId(), trackAutomaticEvents: true, flushInterval: 60) + + // Inject our mock flags object + let mockFlags = MockMixpanelFlags() + testMixpanel.flags = mockFlags + for _ in 0..<2 { // run this twice to test reset works correctly wrt to distinct ids let distinctId: String = "d1" @@ -221,8 +264,16 @@ class MixpanelDemoTests: MixpanelBaseTests { XCTAssertEqual(unidentifiedQueue.last?["$token"] as? String, testMixpanel.apiToken, "incorrect project token in people record") + // Record the loadFlags call count before identify + let loadFlagsCallCountBefore = mockFlags.loadFlagsCallCount + testMixpanel.identify(distinctId: distinctId) waitForTrackingQueue(testMixpanel) + + // Assert that loadFlags was called when distinctId changed + XCTAssertEqual(mockFlags.loadFlagsCallCount, loadFlagsCallCountBefore + 1, + "loadFlags should be called when distinctId changes during identify") + let anonymousId = testMixpanel.anonymousId peopleQueue_value = peopleQueue(token: testMixpanel.apiToken) unidentifiedQueue = unIdentifiedPeopleQueue(token: testMixpanel.apiToken) @@ -263,6 +314,14 @@ class MixpanelDemoTests: MixpanelBaseTests { let newDistinctId = (eventQueue(token: testMixpanel.apiToken).last?["properties"] as? InternalProperties)?["distinct_id"] as? String XCTAssertEqual(newDistinctId, distinctId, "events should use new distinct id after identify:") + + // Test that calling identify with the same distinctId does NOT trigger loadFlags + let loadFlagsCountBeforeSameId = mockFlags.loadFlagsCallCount + testMixpanel.identify(distinctId: distinctId) // Same distinctId + waitForTrackingQueue(testMixpanel) + XCTAssertEqual(mockFlags.loadFlagsCallCount, loadFlagsCountBeforeSameId, + "loadFlags should NOT be called when distinctId doesn't change") + testMixpanel.reset() waitForTrackingQueue(testMixpanel) } diff --git a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift index f99611eb..1cfc4443 100644 --- a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift +++ b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift @@ -234,31 +234,31 @@ class FeatureFlagManagerTests: XCTestCase { func testIsFlagEnabledSync_FlagsReady_True() { simulateFetchSuccess() - XCTAssertTrue(manager.isFlagEnabledSync("feature_bool_true")) + XCTAssertTrue(manager.isEnabledSync("feature_bool_true")) } func testIsFlagEnabledSync_FlagsReady_False() { simulateFetchSuccess() - XCTAssertFalse(manager.isFlagEnabledSync("feature_bool_false")) + XCTAssertFalse(manager.isEnabledSync("feature_bool_false")) } func testIsFlagEnabledSync_FlagsReady_MissingFlag_UsesFallback() { simulateFetchSuccess() - XCTAssertTrue(manager.isFlagEnabledSync("missing", fallbackValue: true)) - XCTAssertFalse(manager.isFlagEnabledSync("missing", fallbackValue: false)) + XCTAssertTrue(manager.isEnabledSync("missing", fallbackValue: true)) + XCTAssertFalse(manager.isEnabledSync("missing", fallbackValue: false)) } func testIsFlagEnabledSync_FlagsReady_NonBoolValue_UsesFallback() { simulateFetchSuccess() - XCTAssertTrue(manager.isFlagEnabledSync("feature_string", fallbackValue: true)) // String value - XCTAssertFalse(manager.isFlagEnabledSync("feature_int", fallbackValue: false)) // Int value - XCTAssertTrue(manager.isFlagEnabledSync("feature_null", fallbackValue: true)) // Null value + XCTAssertTrue(manager.isEnabledSync("feature_string", fallbackValue: true)) // String value + XCTAssertFalse(manager.isEnabledSync("feature_int", fallbackValue: false)) // Int value + XCTAssertTrue(manager.isEnabledSync("feature_null", fallbackValue: true)) // Null value } func testIsFlagEnabledSync_FlagsNotReady_UsesFallback() { XCTAssertFalse(manager.areFlagsReady()) - XCTAssertTrue(manager.isFlagEnabledSync("feature_bool_true", fallbackValue: true)) - XCTAssertFalse(manager.isFlagEnabledSync("feature_bool_true", fallbackValue: false)) + XCTAssertTrue(manager.isEnabledSync("feature_bool_true", fallbackValue: true)) + XCTAssertFalse(manager.isEnabledSync("feature_bool_true", fallbackValue: false)) } // --- Async Flag Retrieval Tests --- @@ -394,7 +394,7 @@ class FeatureFlagManagerTests: XCTestCase { // Call sync methods multiple times _ = manager.getVariantSync("feature_bool_true", fallback: defaultFallback) _ = manager.getVariantValueSync("feature_bool_true", fallbackValue: nil) - _ = manager.isFlagEnabledSync("feature_bool_true") + _ = manager.isEnabledSync("feature_bool_true") // Call async method let asyncExpectation = XCTestExpectation(description: "Async getFeature completes for tracking test") diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift index 49aafb57..68be3605 100644 --- a/Sources/FeatureFlags.swift +++ b/Sources/FeatureFlags.swift @@ -156,7 +156,7 @@ public protocol MixpanelFlags { /// - fallbackValue: The boolean value to return if the flag is not found, /// cannot be evaluated as a boolean, or flags are not ready. Defaults to `false`. /// - Returns: `true` if the flag is considered enabled, `false` otherwise (including if `fallbackValue` is used). - func isFlagEnabledSync(_ flagName: String, fallbackValue: Bool) -> Bool + func isEnabledSync(_ flagName: String, fallbackValue: Bool) -> Bool /// Asynchronously checks if a specific feature flag is considered "enabled". /// This typically involves retrieving the flag's value and evaluating it as a boolean. @@ -170,7 +170,7 @@ public protocol MixpanelFlags { /// or it cannot be evaluated as a boolean. Defaults to `false`. /// - completion: A closure that is called with the boolean result. /// This closure will be executed on the main dispatch queue. - func isFlagEnabled(_ flagName: String, fallbackValue: Bool, completion: @escaping (Bool) -> Void) + func isEnabled(_ flagName: String, fallbackValue: Bool, completion: @escaping (Bool) -> Void) } @@ -319,12 +319,12 @@ class FeatureFlagManager: Network, MixpanelFlags { } } - func isFlagEnabledSync(_ flagName: String, fallbackValue: Bool = false) -> Bool { + func isEnabledSync(_ flagName: String, fallbackValue: Bool = false) -> Bool { let variantValue = getVariantValueSync(flagName, fallbackValue: fallbackValue) return self._evaluateBooleanFlag(flagName: flagName, variantValue: variantValue, fallbackValue: fallbackValue) } - func isFlagEnabled(_ flagName: String, fallbackValue: Bool = false, completion: @escaping (Bool) -> Void) { + func isEnabled(_ flagName: String, fallbackValue: Bool = false, completion: @escaping (Bool) -> Void) { getVariantValue(flagName, fallbackValue: fallbackValue) { [weak self] variantValue in guard let self = self else { completion(fallbackValue) diff --git a/Sources/MixpanelInstance.swift b/Sources/MixpanelInstance.swift index 155fd0cd..b8854ccb 100644 --- a/Sources/MixpanelInstance.swift +++ b/Sources/MixpanelInstance.swift @@ -789,6 +789,7 @@ extension MixpanelInstance { self.distinctId = distinctId self.userId = distinctId } + self.flags.loadFlags() self.track(event: "$identify", properties: ["$anon_distinct_id": oldDistinctId]) } From 29979b382d569386a68bb8448a098ed03c3b066a Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Thu, 22 May 2025 13:04:01 -0700 Subject: [PATCH 17/20] Update Sources/MixpanelPersistence.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Sources/MixpanelPersistence.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/MixpanelPersistence.swift b/Sources/MixpanelPersistence.swift index 50ea622e..ba49ce51 100644 --- a/Sources/MixpanelPersistence.swift +++ b/Sources/MixpanelPersistence.swift @@ -190,7 +190,7 @@ class MixpanelPersistence { } } - /// -- Feauture Flags -- + /// -- Feature Flags -- /// NOT currently used static func saveFlags(flags: InternalProperties, instanceName: String) { From 1a88efdd1d0d67f40ec2702860949cb06453e779 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Thu, 22 May 2025 14:36:06 -0700 Subject: [PATCH 18/20] fix response parser function test --- .../MixpanelFeatureFlagTests.swift | 180 ++++++++++-------- 1 file changed, 101 insertions(+), 79 deletions(-) diff --git a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift index 1cfc4443..8b68a43f 100644 --- a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift +++ b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift @@ -515,85 +515,107 @@ class FeatureFlagManagerTests: XCTestCase { // --- Response Parser Tests --- -// func testResponseParserFunction() { -// // Get access to the responseParser function indirectly through _performFetchRequest -// // by making a property wrapper to capture the API request -// var capturedResource: Network.Resource? -// -// // Create a test wrapper that swizzles apiRequest just for the test -// let originalApiRequest = Network.apiRequest -// defer { Network.apiRequest = originalApiRequest } // Restore when done -// -// // Create a mock request function that captures the resource but doesn't execute -// Network.apiRequest = { base, resource, failure, success in -// // Capture the resource to inspect its parser function -// capturedResource = resource as? Network.Resource -// // Don't actually call any callbacks since we're just testing parser -// return -// } -// -// // Trigger _performFetchRequest by calling fetchFlagsIfNeeded -// manager.accessQueue.sync { -// manager.fetchF() -// } -// -// // Verify resource was captured -// XCTAssertNotNil(capturedResource, "Request resource should be captured") -// -// // Create various test data scenarios -// let validJSON = """ -// { -// "flags": { -// "test_flag": { -// "variant_key": "test_variant", -// "variant_value": "test_value" -// } -// } -// } -// """.data(using: .utf8)! -// -// let emptyFlagsJSON = """ -// { -// "flags": {} -// } -// """.data(using: .utf8)! -// -// let nullFlagsJSON = """ -// { -// "flags": null -// } -// """.data(using: .utf8)! -// -// let malformedJSON = "not json".data(using: .utf8)! -// -// // Test the parser with valid data -// if let resource = capturedResource { -// let parser = resource.parse -// -// // Test valid JSON with flags -// let validResult = parser(validJSON) -// XCTAssertNotNil(validResult, "Parser should handle valid JSON") -// XCTAssertNotNil(validResult?.flags, "Flags should be non-nil") -// XCTAssertEqual(validResult?.flags?.count, 1, "Should have one flag") -// XCTAssertEqual(validResult?.flags?["test_flag"]?.key, "test_variant") -// XCTAssertEqual(validResult?.flags?["test_flag"]?.value as? String, "test_value") -// -// // Test empty flags object -// let emptyResult = parser(emptyFlagsJSON) -// XCTAssertNotNil(emptyResult, "Parser should handle empty flags object") -// XCTAssertNotNil(emptyResult?.flags, "Flags should be non-nil") -// XCTAssertEqual(emptyResult?.flags?.count, 0, "Flags should be empty") -// -// // Test null flags field -// let nullResult = parser(nullFlagsJSON) -// XCTAssertNotNil(nullResult, "Parser should handle null flags") -// XCTAssertNil(nullResult?.flags, "Flags should be nil when null in JSON") -// -// // Test malformed JSON -// let malformedResult = parser(malformedJSON) -// XCTAssertNil(malformedResult, "Parser should return nil for malformed JSON") -// } -// } + func testResponseParserFunction() { + // Test the response parser functionality by simulating various JSON responses + // We'll create test data and parse it directly using JSONDecoder + + // Helper function to parse JSON data like the actual implementation does + let parseResponse: (Data) -> FlagsResponse? = { data in + do { + return try JSONDecoder().decode(FlagsResponse.self, from: data) + } catch { + print("Error parsing flags JSON: \(error)") + return nil + } + } + + // Create various test data scenarios + let validJSON = """ + { + "flags": { + "test_flag": { + "variant_key": "test_variant", + "variant_value": "test_value" + } + } + } + """.data(using: .utf8)! + + let emptyFlagsJSON = """ + { + "flags": {} + } + """.data(using: .utf8)! + + let nullFlagsJSON = """ + { + "flags": null + } + """.data(using: .utf8)! + + let malformedJSON = "not json".data(using: .utf8)! + + // Test valid JSON with flags + let validResult = parseResponse(validJSON) + XCTAssertNotNil(validResult, "Parser should handle valid JSON") + XCTAssertNotNil(validResult?.flags, "Flags should be non-nil") + XCTAssertEqual(validResult?.flags?.count, 1, "Should have one flag") + XCTAssertEqual(validResult?.flags?["test_flag"]?.key, "test_variant") + XCTAssertEqual(validResult?.flags?["test_flag"]?.value as? String, "test_value") + + // Test empty flags object + let emptyResult = parseResponse(emptyFlagsJSON) + XCTAssertNotNil(emptyResult, "Parser should handle empty flags object") + XCTAssertNotNil(emptyResult?.flags, "Flags should be non-nil") + XCTAssertEqual(emptyResult?.flags?.count, 0, "Flags should be empty") + + // Test null flags field + let nullResult = parseResponse(nullFlagsJSON) + XCTAssertNotNil(nullResult, "Parser should handle null flags") + XCTAssertNil(nullResult?.flags, "Flags should be nil when null in JSON") + + // Test malformed JSON + let malformedResult = parseResponse(malformedJSON) + XCTAssertNil(malformedResult, "Parser should return nil for malformed JSON") + + // Test with multiple flags + let multipleFlagsJSON = """ + { + "flags": { + "feature_a": { + "variant_key": "variant_a", + "variant_value": true + }, + "feature_b": { + "variant_key": "variant_b", + "variant_value": 42 + }, + "feature_c": { + "variant_key": "variant_c", + "variant_value": null + } + } + } + """.data(using: .utf8)! + + let multiResult = parseResponse(multipleFlagsJSON) + XCTAssertNotNil(multiResult, "Parser should handle multiple flags") + XCTAssertEqual(multiResult?.flags?.count, 3, "Should have three flags") + XCTAssertEqual(multiResult?.flags?["feature_a"]?.value as? Bool, true) + XCTAssertEqual(multiResult?.flags?["feature_b"]?.value as? Int, 42) + XCTAssertNil(multiResult?.flags?["feature_c"]?.value, "Null value should be preserved") + + // Test with missing required fields + let missingFieldJSON = """ + { + "not_flags": {} + } + """.data(using: .utf8)! + + let missingFieldResult = parseResponse(missingFieldJSON) + XCTAssertNotNil(missingFieldResult, "Parser should handle missing flags field") + XCTAssertNil(missingFieldResult?.flags, "Flags should be nil when field is missing") + } // --- Delegate Error Handling Tests --- From 4f7625be7e51733277f0449b8296ed31247f4e49 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Thu, 22 May 2025 16:07:17 -0700 Subject: [PATCH 19/20] update README with DeepWiki badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b1909f78..d3343ee7 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ No worries, here are some links that you will find useful: * **[Advanced iOS - Swift Guide](https://developer.mixpanel.com/docs/swift)** * **[Sample app](https://github.com/mixpanel/mixpanel-swift/tree/master/MixpanelDemo)** * **[Full API Reference](https://mixpanel.github.io/mixpanel-swift)** +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/mixpanel/mixpanel-swift) Have any questions? Reach out to Mixpanel [Support](https://help.mixpanel.com/hc/en-us/requests/new) to speak to someone smart, quickly. From 835df6c522e387f8b868b0e015829eeeb978753d Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Thu, 22 May 2025 16:54:03 -0700 Subject: [PATCH 20/20] tweak README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index d3343ee7..75639350 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ No worries, here are some links that you will find useful: * **[Advanced iOS - Swift Guide](https://developer.mixpanel.com/docs/swift)** * **[Sample app](https://github.com/mixpanel/mixpanel-swift/tree/master/MixpanelDemo)** * **[Full API Reference](https://mixpanel.github.io/mixpanel-swift)** + [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/mixpanel/mixpanel-swift) Have any questions? Reach out to Mixpanel [Support](https://help.mixpanel.com/hc/en-us/requests/new) to speak to someone smart, quickly.