From 4bfbbcc9625452aafa92c59a695ff6f8e775b251 Mon Sep 17 00:00:00 2001 From: Jared McFarland Date: Tue, 15 Apr 2025 18:01:51 -0700 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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()