mirror of
https://github.com/mixpanel/mixpanel-swift
synced 2026-04-21 13:37:18 +00:00
* added fix for the race FF condition * removed unused initilizers * passed the distinctId to the recordFirstTimeEvent * Update Sources/FeatureFlags.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Sources/FeatureFlags.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: avoid trackingQueue.sync from main thread in async flag paths and fix doc comments Agent-Logs-Url: https://github.com/mixpanel/mixpanel-swift/sessions/035f0c40-e3d9-4629-a0f5-e52a16a531a6 Co-authored-by: ketanmixpanel <188901560+ketanmixpanel@users.noreply.github.com> * fix: align getAllVariantsSync doc comment to use 'may block' for consistency Agent-Logs-Url: https://github.com/mixpanel/mixpanel-swift/sessions/035f0c40-e3d9-4629-a0f5-e52a16a531a6 Co-authored-by: ketanmixpanel <188901560+ketanmixpanel@users.noreply.github.com> * fixed review feedback * removed queue scheduling --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ketanmixpanel <188901560+ketanmixpanel@users.noreply.github.com>
1145 lines
44 KiB
Swift
1145 lines
44 KiB
Swift
import Foundation
|
||
import jsonlogic
|
||
|
||
// MARK: - AnyCodable
|
||
|
||
// 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) {
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Represents the variant associated with a feature flag
|
||
public struct MixpanelFlagVariant: Decodable {
|
||
public let key: String // Corresponds to 'variant_key' from API
|
||
public let value: Any? // Corresponds to 'variant_value' from API
|
||
public let experimentID: String? // Corresponds to 'experiment_id' from API
|
||
public let isExperimentActive: Bool? // Corresponds to 'is_experiment_active' from API
|
||
public let isQATester: Bool? // Corresponds to 'is_qa_tester' from API
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case key = "variant_key"
|
||
case value = "variant_value"
|
||
case experimentID = "experiment_id"
|
||
case isExperimentActive = "is_experiment_active"
|
||
case isQATester = "is_qa_tester"
|
||
}
|
||
|
||
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
|
||
|
||
// Decode optional fields for tracking
|
||
experimentID = try container.decodeIfPresent(String.self, forKey: .experimentID)
|
||
isExperimentActive = try container.decodeIfPresent(Bool.self, forKey: .isExperimentActive)
|
||
isQATester = try container.decodeIfPresent(Bool.self, forKey: .isQATester)
|
||
}
|
||
|
||
// Helper initializer with fallbacks, value defaults to key if nil
|
||
public init(key: String = "", value: Any? = nil, isExperimentActive: Bool? = nil, isQATester: Bool? = nil, experimentID: String? = nil) {
|
||
self.key = key
|
||
if let value = value {
|
||
self.value = value
|
||
} else {
|
||
self.value = key
|
||
}
|
||
self.experimentID = experimentID
|
||
self.isExperimentActive = isExperimentActive
|
||
self.isQATester = isQATester
|
||
}
|
||
}
|
||
|
||
// MARK: - PendingFirstTimeEvent
|
||
|
||
/// Represents a pending first-time event definition from the flags endpoint
|
||
struct PendingFirstTimeEvent: Decodable {
|
||
let flagKey: String
|
||
let flagId: String
|
||
let projectId: Int
|
||
let firstTimeEventHash: String
|
||
let eventName: String
|
||
let propertyFilters: [String: Any]?
|
||
let propertyFiltersJSON: String?
|
||
let pendingVariant: MixpanelFlagVariant
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case flagKey = "flag_key"
|
||
case flagId = "flag_id"
|
||
case projectId = "project_id"
|
||
case firstTimeEventHash = "first_time_event_hash"
|
||
case eventName = "event_name"
|
||
case propertyFilters = "property_filters"
|
||
case pendingVariant = "pending_variant"
|
||
}
|
||
|
||
init(from decoder: Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
flagKey = try container.decode(String.self, forKey: .flagKey)
|
||
flagId = try container.decode(String.self, forKey: .flagId)
|
||
projectId = try container.decode(Int.self, forKey: .projectId)
|
||
firstTimeEventHash = try container.decode(String.self, forKey: .firstTimeEventHash)
|
||
eventName = try container.decode(String.self, forKey: .eventName)
|
||
pendingVariant = try container.decode(MixpanelFlagVariant.self, forKey: .pendingVariant)
|
||
|
||
// Decode propertyFilters using AnyCodable
|
||
if let filtersContainer = try? container.decode([String: AnyCodable].self, forKey: .propertyFilters) {
|
||
let filters = filtersContainer.mapValues { $0.value as Any }
|
||
propertyFilters = filters
|
||
if let jsonData = try? JSONSerialization.data(withJSONObject: filters) {
|
||
propertyFiltersJSON = String(data: jsonData, encoding: .utf8)
|
||
} else {
|
||
propertyFiltersJSON = nil
|
||
}
|
||
} else {
|
||
propertyFilters = nil
|
||
propertyFiltersJSON = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
// Response structure for the /flags endpoint
|
||
struct FlagsResponse: Decodable {
|
||
let flags: [String: MixpanelFlagVariant]? // Dictionary where key is flag name
|
||
let pendingFirstTimeEvents: [PendingFirstTimeEvent]? // Array of pending first-time event definitions
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case flags
|
||
case pendingFirstTimeEvents = "pending_first_time_events"
|
||
}
|
||
}
|
||
|
||
// --- FeatureFlagDelegate Protocol ---
|
||
public protocol MixpanelFlagDelegate: AnyObject {
|
||
func getOptions() -> MixpanelOptions
|
||
func getDistinctId() -> String
|
||
func getAnonymousId() -> String?
|
||
func track(event: String?, properties: Properties?)
|
||
}
|
||
|
||
/// A protocol defining the public interface for a feature flagging system.
|
||
public protocol MixpanelFlags {
|
||
|
||
/// The delegate responsible for handling feature flag lifecycle events,
|
||
/// such as tracking. It is declared `weak` to prevent retain cycles.
|
||
var delegate: MixpanelFlagDelegate? { get set }
|
||
|
||
// --- Public Methods ---
|
||
|
||
/// Initiates the loading or refreshing of flags
|
||
func loadFlags()
|
||
|
||
/// Initiates the loading or refreshing of flags with a completion callback.
|
||
/// The completion handler is called with `true` on success and `false` on failure.
|
||
func loadFlags(completion: ((Bool) -> Void)?)
|
||
|
||
/// Synchronously checks if the flags have been successfully loaded
|
||
/// and are available for querying.
|
||
///
|
||
/// - Returns: `true` if the flags are loaded and ready for use, `false` otherwise.
|
||
func areFlagsReady() -> Bool
|
||
|
||
// --- Sync Flag Retrieval ---
|
||
|
||
/// Synchronously retrieves the complete `MixpanelFlagVariant` for a given flag name.
|
||
/// If the feature flag is found and flags are ready, its variant is returned.
|
||
/// Otherwise, the provided `fallback` `MixpanelFlagVariant` is returned.
|
||
/// This method will also trigger any necessary tracking logic for the accessed flag.
|
||
///
|
||
/// - Important: This method may block the calling thread until the value can be retrieved.
|
||
/// It is NOT recommended to call this from the main UI thread.
|
||
/// If flags are not ready (`areFlagsReady()` is false), this method returns the `fallback`
|
||
/// value, but it may still block while waiting for queued tracking or activation work to complete.
|
||
/// If called immediately after track(), variants may not be activated yet due to a
|
||
/// race condition as track is executed asynchronously. Use `getVariant` instead.
|
||
///
|
||
/// - Parameters:
|
||
/// - flagName: The unique identifier for the feature flag.
|
||
/// - fallback: The `MixpanelFlagVariant` to return if the specified flag is not found
|
||
/// or if the flags are not yet loaded.
|
||
/// - Returns: The `MixpanelFlagVariant` associated with `flagName`, or the `fallback` variant.
|
||
func getVariantSync(_ flagName: String, fallback: MixpanelFlagVariant) -> MixpanelFlagVariant
|
||
|
||
/// Asynchronously retrieves the complete `MixpanelFlagVariant` for a given flag name.
|
||
/// If flags are not ready, an attempt will be made to load them.
|
||
/// The `completion` handler is called with the `MixpanelFlagVariant` for the flag,
|
||
/// or the `fallback` variant if the flag is not found or loading fails.
|
||
/// This method will also trigger any necessary tracking logic for the accessed flag.
|
||
/// The completion handler is typically invoked on the main thread.
|
||
///
|
||
/// - Parameters:
|
||
/// - flagName: The unique identifier for the feature flag.
|
||
/// - fallback: The `MixpanelFlagVariant` to use as a default if the specified flag
|
||
/// is not found or an error occurs during fetching.
|
||
/// - completion: A closure that is called with the resulting `MixpanelFlagVariant`.
|
||
/// This closure will be executed on the main dispatch queue.
|
||
func getVariant(
|
||
_ flagName: String, fallback: MixpanelFlagVariant,
|
||
completion: @escaping (MixpanelFlagVariant) -> Void)
|
||
|
||
/// Synchronously retrieves the underlying value of a feature flag.
|
||
/// This is a convenience method that extracts the `value` property from the `MixpanelFlagVariant`
|
||
/// obtained via `getVariantSync`.
|
||
///
|
||
/// - Important: This method may block the calling thread until the value can be retrieved.
|
||
/// It is NOT recommended to call this from the main UI thread.
|
||
/// If flags are not ready (`areFlagsReady()` is false), this method returns the `fallbackValue`,
|
||
/// but it may still block while waiting for queued tracking or activation work to complete.
|
||
/// If called immediately after track(), variants may not be activated yet due to a
|
||
/// race condition as track is executed asynchronously. Use `getVariantValue` instead.
|
||
///
|
||
/// - Parameters:
|
||
/// - flagName: The unique identifier for the feature flag.
|
||
/// - fallbackValue: The default value to return if the flag is not found,
|
||
/// its variant doesn't contain a value, or flags are not ready.
|
||
/// - Returns: The value of the feature flag, or `fallbackValue`. The type is `Any?`.
|
||
func getVariantValueSync(_ flagName: String, fallbackValue: Any?) -> Any?
|
||
|
||
/// Asynchronously retrieves the underlying value of a feature flag.
|
||
/// This is a convenience method that extracts the `value` property from the `MixpanelFlagVariant`
|
||
/// obtained via `getVariant`. If flags are not ready, an attempt will be made to load them.
|
||
/// The `completion` handler is called with the flag's value or the `fallbackValue`.
|
||
/// The completion handler is typically invoked on the main thread.
|
||
///
|
||
/// - Parameters:
|
||
/// - flagName: The unique identifier for the feature flag.
|
||
/// - fallbackValue: The default value to use if the flag is not found,
|
||
/// fetching fails, or its variant doesn't contain a value.
|
||
/// - completion: A closure that is called with the resulting value (`Any?`).
|
||
/// This closure will be executed on the main dispatch queue.
|
||
func getVariantValue(
|
||
_ flagName: String, fallbackValue: Any?, completion: @escaping (Any?) -> Void)
|
||
|
||
/// Synchronously checks if a specific feature flag is considered "enabled".
|
||
/// This typically involves retrieving the flag's value and evaluating it as a boolean.
|
||
/// The exact logic for what constitutes "enabled" (e.g., `true`, non-nil, a specific string)
|
||
/// should be defined by the implementing class.
|
||
///
|
||
/// - Important: This method may block the calling thread until the value can be retrieved.
|
||
/// It is NOT recommended to call this from the main UI thread.
|
||
/// If flags are not ready (`areFlagsReady()` is false), this method returns the `fallbackValue`,
|
||
/// but it may still block while waiting for queued tracking or activation work to complete.
|
||
///
|
||
/// - Parameters:
|
||
/// - flagName: The unique identifier for the feature flag.
|
||
/// - fallbackValue: The boolean value to return if the flag is not found,
|
||
/// cannot be evaluated as a boolean, or flags are not ready. Defaults to `false`.
|
||
/// - Returns: `true` if the flag is considered enabled, `false` otherwise (including if `fallbackValue` is used).
|
||
func isEnabledSync(_ flagName: String, fallbackValue: Bool) -> Bool
|
||
|
||
/// Asynchronously checks if a specific feature flag is considered "enabled".
|
||
/// This typically involves retrieving the flag's value and evaluating it as a boolean.
|
||
/// If flags are not ready, an attempt will be made to load them.
|
||
/// The `completion` handler is called with the boolean result.
|
||
/// The completion handler is typically invoked on the main thread.
|
||
///
|
||
/// - Parameters:
|
||
/// - flagName: The unique identifier for the feature flag.
|
||
/// - fallbackValue: The boolean value to use if the flag is not found, fetching fails,
|
||
/// or it cannot be evaluated as a boolean. Defaults to `false`.
|
||
/// - completion: A closure that is called with the boolean result.
|
||
/// This closure will be executed on the main dispatch queue.
|
||
func isEnabled(_ flagName: String, fallbackValue: Bool, completion: @escaping (Bool) -> Void)
|
||
|
||
// --- Bulk Flag Retrieval ---
|
||
|
||
/// Synchronously retrieves all currently fetched feature flag variants.
|
||
/// Returns an empty dictionary if flags have not been loaded yet.
|
||
/// This method does not trigger tracking for any flags.
|
||
///
|
||
/// - Important: This method may block the calling thread until the value can be retrieved.
|
||
/// It is NOT recommended to call this from the main UI thread.
|
||
/// If flags are not ready (`areFlagsReady()` is false), it returns an empty dictionary
|
||
/// immediately without fetching, but it may still block while waiting for queued tracking
|
||
/// or activation work to complete.
|
||
/// If called immediately after track(), variants may not be activated yet due to a
|
||
/// race condition as track is executed asynchronously. Use `getAllVariants` instead.
|
||
///
|
||
/// - Returns: A dictionary mapping flag names to their `MixpanelFlagVariant` values,
|
||
/// or an empty dictionary if flags are not ready.
|
||
func getAllVariantsSync() -> [String: MixpanelFlagVariant]
|
||
|
||
/// Asynchronously retrieves all feature flag variants.
|
||
/// If flags are not ready, an attempt will be made to load them.
|
||
/// This method does not trigger tracking for any flags.
|
||
/// The completion handler is typically invoked on the main thread.
|
||
///
|
||
/// - Parameter completion: A closure that is called with a dictionary mapping flag names
|
||
/// to their `MixpanelFlagVariant` values. Returns an empty dictionary
|
||
/// if fetching fails.
|
||
func getAllVariants(completion: @escaping ([String: MixpanelFlagVariant]) -> Void)
|
||
|
||
/// Replaces the current custom flag evaluation context entirely and triggers a flag re-fetch.
|
||
///
|
||
/// - Parameters:
|
||
/// - context: The new context dictionary to use for flag evaluation.
|
||
/// - completion: A closure called when the fetch completes (success or failure).
|
||
func setContext(_ context: [String: Any], completion: @escaping () -> Void)
|
||
}
|
||
|
||
// --- FeatureFlagManager Class ---
|
||
|
||
class FeatureFlagManager: MixpanelFlags {
|
||
|
||
weak var delegate: MixpanelFlagDelegate? {
|
||
didSet {
|
||
if let context = delegate?.getOptions().featureFlagOptions.context, !context.isEmpty {
|
||
flagsLock.write {
|
||
if self.flagContext.isEmpty {
|
||
self.flagContext = context
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
var serverURL: String!
|
||
// Thread safety using ReadWriteLock (consistent with Track, People, MixpanelInstance)
|
||
internal let flagsLock = ReadWriteLock(label: "com.mixpanel.featureflagmanager")
|
||
|
||
// Internal State - Protected by flagsLock
|
||
var flags: [String: MixpanelFlagVariant]? = nil
|
||
var isFetching: Bool = false
|
||
private var trackedFeatures: Set<String> = Set()
|
||
private var fetchCompletionHandlers: [(Bool) -> Void] = []
|
||
|
||
// First-time event targeting state
|
||
internal var pendingFirstTimeEvents: [String: PendingFirstTimeEvent] = [:] // Keyed by "flagKey:firstTimeEventHash"
|
||
|
||
/// O(1) lookup set of event names that have pending first-time events.
|
||
/// Maintained in parallel with `pendingFirstTimeEvents` to avoid iterating
|
||
/// the full dictionary on every tracked event.
|
||
internal var pendingFirstTimeEventNames: Set<String> = Set()
|
||
|
||
/// Stores "flagKey:firstTimeEventHash" keys for activated first-time events.
|
||
/// This set grows throughout the session as events are activated.
|
||
/// It is session-scoped and cleared on app restart.
|
||
internal var activatedFirstTimeEvents: Set<String> = Set()
|
||
|
||
// Flag evaluation context (protected by flagsLock)
|
||
private var flagContext: [String: Any]
|
||
|
||
// Timing tracking properties
|
||
private var fetchStartTime: Date?
|
||
var timeLastFetched: Date?
|
||
var fetchLatencyMs: Int?
|
||
|
||
// Configuration
|
||
private var currentOptions: MixpanelOptions? { delegate?.getOptions() }
|
||
private var flagsRoute = "/flags/"
|
||
|
||
// Queue for synchronizing flag operations with tracking
|
||
private var trackingQueue: DispatchQueue
|
||
|
||
// Initializers
|
||
internal init(serverURL: String, trackingQueue: DispatchQueue, delegate: MixpanelFlagDelegate? = nil) {
|
||
self.serverURL = serverURL
|
||
self.trackingQueue = trackingQueue
|
||
self.delegate = delegate
|
||
self.flagContext = delegate?.getOptions().featureFlagOptions.context ?? [:]
|
||
}
|
||
|
||
// --- Public Methods ---
|
||
|
||
func loadFlags() {
|
||
loadFlags(completion: nil)
|
||
}
|
||
|
||
func loadFlags(completion: ((Bool) -> Void)?) {
|
||
// Dispatch fetch trigger to allow caller to continue
|
||
trackingQueue.async { [weak self] in
|
||
self?._fetchFlagsIfNeeded(completion: completion)
|
||
}
|
||
}
|
||
|
||
func setContext(_ context: [String: Any], completion: @escaping () -> Void) {
|
||
flagsLock.write {
|
||
self.flagContext = context
|
||
}
|
||
trackingQueue.async { [weak self] in
|
||
self?._fetchFlagsIfNeeded { _ in
|
||
completion()
|
||
}
|
||
}
|
||
}
|
||
|
||
func areFlagsReady() -> Bool {
|
||
var result: Bool = false
|
||
flagsLock.read {
|
||
result = (flags != nil)
|
||
}
|
||
return result
|
||
}
|
||
|
||
// --- Sync Flag Retrieval ---
|
||
|
||
func getVariantSync(_ flagName: String, fallback: MixpanelFlagVariant) -> MixpanelFlagVariant {
|
||
if Thread.isMainThread {
|
||
MixpanelLogger.warn(
|
||
message: "It is NOT recommended to call this method from the main thread as it might block the calling thread until the value can be retrieved. Consider using async getVariant() instead."
|
||
)
|
||
}
|
||
return _getVariantSyncImpl(flagName, fallback: fallback)
|
||
}
|
||
|
||
private func _getVariantSyncImpl(_ flagName: String, fallback: MixpanelFlagVariant) -> MixpanelFlagVariant {
|
||
var flagVariant: MixpanelFlagVariant?
|
||
var tracked = false
|
||
var capturedTimeLastFetched: Date?
|
||
var capturedFetchLatencyMs: Int?
|
||
|
||
// Use write lock to perform atomic check-and-set for tracking
|
||
flagsLock.write {
|
||
guard let currentFlags = self.flags else { return }
|
||
|
||
if let variant = currentFlags[flagName] {
|
||
flagVariant = variant
|
||
|
||
// Perform atomic check-and-set for tracking
|
||
if !self.trackedFeatures.contains(flagName) {
|
||
self.trackedFeatures.insert(flagName)
|
||
tracked = true
|
||
// Capture timing data while in lock
|
||
capturedTimeLastFetched = self.timeLastFetched
|
||
capturedFetchLatencyMs = self.fetchLatencyMs
|
||
}
|
||
}
|
||
// If flag wasn't found, flagVariant remains nil
|
||
}
|
||
|
||
// Now, process the results outside the lock
|
||
|
||
if let foundVariant = flagVariant {
|
||
// If tracking was done *in this call*, call the delegate with timing data
|
||
if tracked {
|
||
self._performTrackingDelegateCall(
|
||
flagName: flagName,
|
||
variant: foundVariant,
|
||
timeLastFetched: capturedTimeLastFetched,
|
||
fetchLatencyMs: capturedFetchLatencyMs
|
||
)
|
||
}
|
||
return foundVariant
|
||
} else {
|
||
MixpanelLogger.info(message: "Flag '\(flagName)' not found or flags not ready. Returning fallback.")
|
||
return fallback
|
||
}
|
||
}
|
||
|
||
// --- Async Flag Retrieval ---
|
||
|
||
func getVariant(
|
||
_ flagName: String, fallback: MixpanelFlagVariant,
|
||
completion: @escaping (MixpanelFlagVariant) -> Void
|
||
) {
|
||
trackingQueue.async { [weak self] in
|
||
guard let self = self else { return }
|
||
|
||
var flagVariant: MixpanelFlagVariant?
|
||
var needsTrackingCheck = false
|
||
var flagsAreCurrentlyReady = false
|
||
|
||
// Read state with lock
|
||
self.flagsLock.read {
|
||
flagsAreCurrentlyReady = (self.flags != nil)
|
||
if flagsAreCurrentlyReady, let currentFlags = self.flags {
|
||
if let variant = currentFlags[flagName] {
|
||
flagVariant = variant
|
||
needsTrackingCheck = !self.trackedFeatures.contains(flagName)
|
||
}
|
||
}
|
||
}
|
||
|
||
if flagsAreCurrentlyReady {
|
||
let result = flagVariant ?? fallback
|
||
if flagVariant != nil, needsTrackingCheck {
|
||
// Perform atomic check-and-track
|
||
self._trackFlagIfNeeded(flagName: flagName, variant: result)
|
||
}
|
||
DispatchQueue.main.async { completion(result) }
|
||
|
||
} else {
|
||
// --- Flags were NOT ready ---
|
||
// Trigger fetch; fetch completion will handle calling the original completion handler
|
||
MixpanelLogger.debug(message: "Flags not ready, attempting fetch for getFeature call...")
|
||
self._fetchFlagsIfNeeded { success in
|
||
// This completion runs *after* fetch completes (or fails)
|
||
let result: MixpanelFlagVariant
|
||
if success {
|
||
// Fetch succeeded – call the private impl directly to avoid false positive DEBUG warning
|
||
result = self._getVariantSyncImpl(flagName, fallback: fallback)
|
||
} else {
|
||
MixpanelLogger.warn(message: "Failed to fetch flags, returning fallback for \(flagName).")
|
||
result = fallback
|
||
}
|
||
// Call original completion (on main thread)
|
||
DispatchQueue.main.async { completion(result) }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func getVariantValueSync(_ flagName: String, fallbackValue: Any?) -> Any? {
|
||
return getVariantSync(flagName, fallback: MixpanelFlagVariant(value: fallbackValue)).value
|
||
}
|
||
|
||
func getVariantValue(
|
||
_ flagName: String, fallbackValue: Any?, completion: @escaping (Any?) -> Void
|
||
) {
|
||
getVariant(flagName, fallback: MixpanelFlagVariant(value: fallbackValue)) { flagVariant in
|
||
completion(flagVariant.value)
|
||
}
|
||
}
|
||
|
||
func isEnabledSync(_ flagName: String, fallbackValue: Bool = false) -> Bool {
|
||
let variantValue = getVariantValueSync(flagName, fallbackValue: fallbackValue)
|
||
return self._evaluateBooleanFlag(
|
||
flagName: flagName, variantValue: variantValue, fallbackValue: fallbackValue)
|
||
}
|
||
|
||
func isEnabled(
|
||
_ flagName: String, fallbackValue: Bool = false, completion: @escaping (Bool) -> Void
|
||
) {
|
||
getVariantValue(flagName, fallbackValue: fallbackValue) { [weak self] variantValue in
|
||
guard let self = self else {
|
||
completion(fallbackValue)
|
||
return
|
||
}
|
||
let result = self._evaluateBooleanFlag(
|
||
flagName: flagName, variantValue: variantValue, fallbackValue: fallbackValue)
|
||
completion(result)
|
||
}
|
||
}
|
||
|
||
// --- Bulk Flag Retrieval ---
|
||
|
||
func getAllVariantsSync() -> [String: MixpanelFlagVariant] {
|
||
if Thread.isMainThread {
|
||
MixpanelLogger.warn(
|
||
message: "It is NOT recommended to call this method from the main thread as it might block the calling thread until the value can be retrieved. Consider using async getAllVariants() instead."
|
||
)
|
||
}
|
||
return _getAllVariantsSyncImpl()
|
||
}
|
||
|
||
private func _getAllVariantsSyncImpl() -> [String: MixpanelFlagVariant] {
|
||
var result: [String: MixpanelFlagVariant] = [:]
|
||
flagsLock.read {
|
||
result = self.flags ?? [:]
|
||
}
|
||
return result
|
||
}
|
||
|
||
func getAllVariants(completion: @escaping ([String: MixpanelFlagVariant]) -> Void) {
|
||
trackingQueue.async { [weak self] in
|
||
guard let self = self else {
|
||
DispatchQueue.main.async { completion([:]) }
|
||
return
|
||
}
|
||
|
||
var currentFlags: [String: MixpanelFlagVariant]?
|
||
self.flagsLock.read { currentFlags = self.flags }
|
||
if let currentFlags = currentFlags {
|
||
DispatchQueue.main.async { completion(currentFlags) }
|
||
} else {
|
||
// Flags not ready, trigger fetch
|
||
MixpanelLogger.debug(message: "Flags not ready, attempting fetch for getAllVariants call...")
|
||
self._fetchFlagsIfNeeded { success in
|
||
let result: [String: MixpanelFlagVariant]
|
||
if success {
|
||
// Fetch succeeded – call the private impl directly to avoid false positive DEBUG warning
|
||
result = self._getAllVariantsSyncImpl()
|
||
} else {
|
||
MixpanelLogger.warn(message: "Failed to fetch flags, returning empty dictionary.")
|
||
result = [:]
|
||
}
|
||
DispatchQueue.main.async { completion(result) }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Fetching Logic (Simplified by Serial Queue) ---
|
||
|
||
// Internal function to handle fetch logic and state checks
|
||
private func _fetchFlagsIfNeeded(completion: ((Bool) -> Void)?) {
|
||
let optionsSnapshot = self.currentOptions
|
||
|
||
guard let options = optionsSnapshot, options.featureFlagOptions.enabled else {
|
||
MixpanelLogger.debug(message: "Feature flags are disabled, not fetching.")
|
||
// Dispatch completion to main queue to avoid potential deadlock
|
||
DispatchQueue.main.async {
|
||
completion?(false)
|
||
}
|
||
return // Exit method
|
||
}
|
||
|
||
var shouldStartFetch = false
|
||
|
||
// Access/Modify isFetching and fetchCompletionHandlers with write lock
|
||
flagsLock.write {
|
||
if let completion = completion {
|
||
self.fetchCompletionHandlers.append(completion)
|
||
}
|
||
if !self.isFetching {
|
||
self.isFetching = true
|
||
shouldStartFetch = true
|
||
} else {
|
||
MixpanelLogger.debug(message: "Fetch already in progress, queueing completion handler.")
|
||
}
|
||
}
|
||
|
||
if shouldStartFetch {
|
||
MixpanelLogger.info(message: "Starting flag fetch...")
|
||
// Already on a background queue (callers dispatch before calling this method)
|
||
self._performFetchRequest()
|
||
}
|
||
}
|
||
|
||
// Performs the actual network request construction and call
|
||
internal func _performFetchRequest() {
|
||
// Record fetch start time
|
||
let startTime = Date()
|
||
flagsLock.write {
|
||
self.fetchStartTime = startTime
|
||
}
|
||
|
||
guard let delegate = self.delegate, let options = self.currentOptions else {
|
||
MixpanelLogger.error(message: "Delegate or options missing for fetch.")
|
||
self._completeFetch(success: false)
|
||
return
|
||
}
|
||
|
||
let distinctId = delegate.getDistinctId()
|
||
let anonymousId = delegate.getAnonymousId()
|
||
MixpanelLogger.debug(message: "Fetching flags for distinct ID: \(distinctId)")
|
||
|
||
var context: [String: Any] = [:]
|
||
flagsLock.read {
|
||
context = self.flagContext
|
||
}
|
||
context["distinct_id"] = distinctId
|
||
if let anonymousId = anonymousId {
|
||
context["device_id"] = anonymousId
|
||
}
|
||
|
||
guard
|
||
let contextData = try? JSONSerialization.data(
|
||
withJSONObject: context, options: []),
|
||
let contextString = String(data: contextData, encoding: .utf8)
|
||
else {
|
||
MixpanelLogger.error(message: "Failed to serialize context for flags.")
|
||
self._completeFetch(success: false)
|
||
return
|
||
}
|
||
|
||
guard let headers = createAuthHeaders(token: options.token) else {
|
||
MixpanelLogger.error(message: "Failed to create auth headers.")
|
||
self._completeFetch(success: false)
|
||
return
|
||
}
|
||
|
||
let queryItems = [
|
||
URLQueryItem(name: "context", value: contextString),
|
||
URLQueryItem(name: "token", value: options.token),
|
||
URLQueryItem(name: "mp_lib", value: "swift"),
|
||
URLQueryItem(name: "$lib_version", value: AutomaticProperties.libVersion())
|
||
]
|
||
|
||
let responseParser: (Data) -> FlagsResponse? = { data in
|
||
do { return try JSONDecoder().decode(FlagsResponse.self, from: data) } catch {
|
||
MixpanelLogger.error(message: "Error parsing flags JSON: \(error)")
|
||
return nil
|
||
}
|
||
}
|
||
let resource = Network.buildResource(
|
||
path: flagsRoute, method: .get, queryItems: queryItems, headers: headers,
|
||
parse: responseParser)
|
||
|
||
// Make the API request
|
||
Network.apiRequest(
|
||
base: serverURL,
|
||
resource: resource,
|
||
failure: { [weak self] reason, data, response in
|
||
MixpanelLogger.error(message: "Failed to fetch flags. Reason: \(reason)")
|
||
self?._completeFetch(success: false)
|
||
},
|
||
success: { [weak self] (flagsResponse, response) in
|
||
MixpanelLogger.info(message: "Successfully fetched flags. \(flagsResponse)")
|
||
guard let self = self else { return }
|
||
let fetchEndTime = Date()
|
||
|
||
// Merge flags and update state with write lock
|
||
let (mergedFlags, mergedPendingEvents, mergedPendingEventNames) = self.mergeFlags(
|
||
responseFlags: flagsResponse.flags,
|
||
responsePendingEvents: flagsResponse.pendingFirstTimeEvents
|
||
)
|
||
|
||
self.flagsLock.write {
|
||
self.flags = mergedFlags
|
||
self.pendingFirstTimeEvents = mergedPendingEvents
|
||
self.pendingFirstTimeEventNames = mergedPendingEventNames
|
||
|
||
// Calculate timing metrics
|
||
if let startTime = self.fetchStartTime {
|
||
let latencyMs = Int(fetchEndTime.timeIntervalSince(startTime) * 1000)
|
||
self.fetchLatencyMs = latencyMs
|
||
}
|
||
self.timeLastFetched = fetchEndTime
|
||
|
||
MixpanelLogger.debug(message: "Flags updated: \(self.flags ?? [:]), Pending events: \(self.pendingFirstTimeEvents.count)")
|
||
}
|
||
|
||
self._completeFetch(success: true)
|
||
}
|
||
)
|
||
}
|
||
|
||
// Centralized fetch completion logic
|
||
func _completeFetch(success: Bool) {
|
||
var handlers: [(Bool) -> Void] = []
|
||
|
||
flagsLock.write {
|
||
self.isFetching = false
|
||
handlers = self.fetchCompletionHandlers
|
||
self.fetchCompletionHandlers.removeAll()
|
||
}
|
||
|
||
DispatchQueue.main.async {
|
||
handlers.forEach { $0(success) }
|
||
}
|
||
}
|
||
|
||
// --- Flag Merging Helper ---
|
||
func mergeFlags(
|
||
responseFlags: [String: MixpanelFlagVariant]?,
|
||
responsePendingEvents: [PendingFirstTimeEvent]?
|
||
) -> (flags: [String: MixpanelFlagVariant], pendingEvents: [String: PendingFirstTimeEvent], pendingEventNames: Set<String>) {
|
||
var newFlags: [String: MixpanelFlagVariant] = [:]
|
||
var newPendingEvents: [String: PendingFirstTimeEvent] = [:]
|
||
var newPendingEventNames: Set<String> = Set()
|
||
|
||
var currentFlags: [String: MixpanelFlagVariant]?
|
||
var activatedEvents: Set<String> = []
|
||
|
||
// Read current state with lock
|
||
flagsLock.read {
|
||
currentFlags = self.flags
|
||
activatedEvents = self.activatedFirstTimeEvents
|
||
}
|
||
|
||
// Process flags from response
|
||
if let responseFlags = responseFlags {
|
||
for (flagKey, variant) in responseFlags {
|
||
// Check if any event for this flag was activated
|
||
let hasActivatedEvent = activatedEvents.contains { eventKey in
|
||
eventKey.hasPrefix("\(flagKey):")
|
||
}
|
||
|
||
if hasActivatedEvent, let currentFlag = currentFlags?[flagKey] {
|
||
// Preserve activated variant
|
||
newFlags[flagKey] = currentFlag
|
||
} else {
|
||
// Use server's current variant
|
||
newFlags[flagKey] = variant
|
||
}
|
||
}
|
||
}
|
||
|
||
// Process pending first-time events from response
|
||
if let responsePendingEvents = responsePendingEvents {
|
||
for pendingEvent in responsePendingEvents {
|
||
let eventKey = self.getPendingEventKey(pendingEvent.flagKey, pendingEvent.firstTimeEventHash)
|
||
|
||
// Skip if already activated
|
||
if activatedEvents.contains(eventKey) {
|
||
continue
|
||
}
|
||
|
||
newPendingEvents[eventKey] = pendingEvent
|
||
newPendingEventNames.insert(pendingEvent.eventName)
|
||
}
|
||
}
|
||
|
||
// Preserve orphaned activated flags
|
||
for eventKey in activatedEvents {
|
||
guard let flagKey = self.getFlagKeyFromPendingEventKey(eventKey) else {
|
||
MixpanelLogger.warn(message: "Failed to parse flag key from event key: \(eventKey)")
|
||
continue
|
||
}
|
||
if newFlags[flagKey] == nil, let orphanedFlag = currentFlags?[flagKey] {
|
||
newFlags[flagKey] = orphanedFlag
|
||
}
|
||
}
|
||
|
||
return (flags: newFlags, pendingEvents: newPendingEvents, pendingEventNames: newPendingEventNames)
|
||
}
|
||
|
||
// --- Tracking Logic ---
|
||
|
||
// Performs the atomic check and triggers delegate call if needed
|
||
private func _trackFlagIfNeeded(flagName: String, variant: MixpanelFlagVariant) {
|
||
var shouldCallDelegate = false
|
||
var capturedTimeLastFetched: Date?
|
||
var capturedFetchLatencyMs: Int?
|
||
|
||
// Use write lock for atomic check-and-set
|
||
flagsLock.write {
|
||
if !self.trackedFeatures.contains(flagName) {
|
||
self.trackedFeatures.insert(flagName)
|
||
shouldCallDelegate = true
|
||
// Capture timing data while in lock
|
||
capturedTimeLastFetched = self.timeLastFetched
|
||
capturedFetchLatencyMs = self.fetchLatencyMs
|
||
}
|
||
}
|
||
|
||
// Call delegate outside the lock if tracking occurred
|
||
if shouldCallDelegate {
|
||
self._performTrackingDelegateCall(
|
||
flagName: flagName,
|
||
variant: variant,
|
||
timeLastFetched: capturedTimeLastFetched,
|
||
fetchLatencyMs: capturedFetchLatencyMs
|
||
)
|
||
}
|
||
}
|
||
|
||
// Helper to call the delegate with timing data passed as parameters
|
||
private func _performTrackingDelegateCall(
|
||
flagName: String,
|
||
variant: MixpanelFlagVariant,
|
||
timeLastFetched: Date? = nil,
|
||
fetchLatencyMs: Int? = nil
|
||
) {
|
||
guard let delegate = self.delegate else { return }
|
||
|
||
var properties: Properties = [
|
||
"Experiment name": flagName,
|
||
"Variant name": variant.key,
|
||
"$experiment_type": "feature_flag",
|
||
]
|
||
|
||
// Add timing properties if provided
|
||
if let timeLastFetched = timeLastFetched {
|
||
properties["timeLastFetched"] = Int(timeLastFetched.timeIntervalSince1970)
|
||
}
|
||
if let fetchLatencyMs = fetchLatencyMs {
|
||
properties["fetchLatencyMs"] = fetchLatencyMs
|
||
}
|
||
|
||
if let experimentID = variant.experimentID {
|
||
properties["$experiment_id"] = experimentID
|
||
}
|
||
if let isExperimentActive = variant.isExperimentActive {
|
||
properties["$is_experiment_active"] = isExperimentActive
|
||
}
|
||
if let isQATester = variant.isQATester {
|
||
properties["$is_qa_tester"] = isQATester
|
||
}
|
||
|
||
// Dispatch delegate call asynchronously to main thread for safety
|
||
DispatchQueue.main.async {
|
||
delegate.track(event: "$experiment_started", properties: properties)
|
||
MixpanelLogger.debug(message: "Tracked $experiment_started for \(flagName) (dispatched to main)")
|
||
}
|
||
}
|
||
|
||
// --- Boolean Evaluation Helper ---
|
||
private func _evaluateBooleanFlag(flagName: String, variantValue: Any?, fallbackValue: Bool)
|
||
-> Bool
|
||
{
|
||
guard let val = variantValue else { return fallbackValue }
|
||
if let boolVal = val as? Bool {
|
||
return boolVal
|
||
} else {
|
||
MixpanelLogger.error(message: "Flag '\(flagName)' is not Bool")
|
||
return fallbackValue
|
||
}
|
||
}
|
||
|
||
// --- Auth Header Helper ---
|
||
private func createAuthHeaders(token: String, includeContentType: Bool = false) -> [String: String]? {
|
||
guard let authData = "\(token):".data(using: .utf8) else {
|
||
return nil
|
||
}
|
||
|
||
var headers = ["Authorization": "Basic \(authData.base64EncodedString())"]
|
||
|
||
if includeContentType {
|
||
headers["Content-Type"] = "application/json"
|
||
}
|
||
|
||
return headers
|
||
}
|
||
|
||
// MARK: - First-Time Event Helpers
|
||
|
||
/// Generate a unique key for a pending first-time event
|
||
private func getPendingEventKey(_ flagKey: String, _ firstTimeEventHash: String) -> String {
|
||
return "\(flagKey):\(firstTimeEventHash)"
|
||
}
|
||
|
||
/// Extract the flag key from a pending event key
|
||
private func getFlagKeyFromPendingEventKey(_ eventKey: String) -> String? {
|
||
return eventKey.split(separator: ":", maxSplits: 1).first.map { String($0) }
|
||
}
|
||
|
||
// MARK: - First-Time Event Checking
|
||
|
||
/// Checks if a tracked event matches any pending first-time events and activates the corresponding variant.
|
||
///
|
||
///- Note:
|
||
/// This method **must** be called from the `trackingQueue`.
|
||
/// Executing this sequentially on the background serial queue ensures that
|
||
/// any subsequent `getVariant` calls (which also wait for or read from this state)
|
||
/// will receive the newly activated variant, effectively eliminating the race
|
||
/// condition between tracking and flag evaluation.
|
||
internal func checkFirstTimeEvents(eventName: String, properties: [String: Any]) {
|
||
// O(1) check: skip iteration if no pending event matches this event name
|
||
var hasPendingEvent = false
|
||
flagsLock.read {
|
||
hasPendingEvent = self.pendingFirstTimeEventNames.contains(eventName)
|
||
}
|
||
guard hasPendingEvent else { return }
|
||
|
||
// Snapshot pending events with lock
|
||
// Note: We don't snapshot activatedFirstTimeEvents because we'll check it
|
||
// atomically later under write lock to avoid TOCTOU race
|
||
var pendingEventsCopy: [String: PendingFirstTimeEvent] = [:]
|
||
|
||
flagsLock.read {
|
||
pendingEventsCopy = self.pendingFirstTimeEvents
|
||
}
|
||
|
||
// Iterate through all pending first-time events
|
||
for (eventKey, pendingEvent) in pendingEventsCopy {
|
||
// Check exact event name match (case-sensitive)
|
||
if eventName != pendingEvent.eventName {
|
||
continue
|
||
}
|
||
|
||
// Evaluate property filters using json-logic-swift library
|
||
if let filters = pendingEvent.propertyFilters, !filters.isEmpty {
|
||
// Convert to JSON strings for json-logic-swift library
|
||
guard let rulesString = pendingEvent.propertyFiltersJSON,
|
||
let dataJSON = try? JSONSerialization.data(withJSONObject: properties),
|
||
let dataString = String(data: dataJSON, encoding: .utf8) else {
|
||
MixpanelLogger.warn(message: "Failed to serialize JsonLogic filters for event '\(eventKey)' matching '\(eventName)'")
|
||
continue
|
||
}
|
||
|
||
// Evaluate the filter
|
||
do {
|
||
let result: Bool = try applyRule(rulesString, to: dataString)
|
||
if !result {
|
||
MixpanelLogger.debug(message: "JsonLogic filter evaluated to false for event '\(eventKey)'")
|
||
continue
|
||
}
|
||
} catch {
|
||
MixpanelLogger.error(message: "JsonLogic evaluation error for event '\(eventKey)': \(error)")
|
||
continue
|
||
}
|
||
}
|
||
|
||
// Event matched! Try to activate the variant atomically
|
||
let flagKey = pendingEvent.flagKey
|
||
var shouldActivate = false
|
||
|
||
// Atomic check-and-set: Ensure only one thread activates this event.
|
||
// This prevents duplicate recordFirstTimeEvent calls and flag variant changes
|
||
// when multiple threads concurrently process the same event.
|
||
flagsLock.write {
|
||
if !activatedFirstTimeEvents.contains(eventKey) {
|
||
// We won the race - activate this event
|
||
activatedFirstTimeEvents.insert(eventKey)
|
||
|
||
if flags == nil {
|
||
flags = [:]
|
||
}
|
||
flags![flagKey] = pendingEvent.pendingVariant
|
||
shouldActivate = true
|
||
}
|
||
}
|
||
|
||
// Only proceed with external calls if we successfully activated
|
||
if shouldActivate {
|
||
MixpanelLogger.info(message: "First-time event matched for flag '\(flagKey)': \(eventName)")
|
||
|
||
// Track the feature flag check event with the new variant
|
||
self._trackFlagIfNeeded(flagName: flagKey, variant: pendingEvent.pendingVariant)
|
||
|
||
guard let delegate = self.delegate else {
|
||
MixpanelLogger.error(message: "Delegate missing for recording first-time event")
|
||
return
|
||
}
|
||
|
||
let distinctId = delegate.getDistinctId()
|
||
|
||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||
// Record to backend (fire-and-forget)
|
||
self?.recordFirstTimeEvent(
|
||
flagId: pendingEvent.flagId,
|
||
projectId: pendingEvent.projectId,
|
||
firstTimeEventHash: pendingEvent.firstTimeEventHash,
|
||
distinctId: distinctId
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Records a first-time event activation to the backend
|
||
internal func recordFirstTimeEvent(flagId: String, projectId: Int, firstTimeEventHash: String, distinctId: String) {
|
||
let url = "/flags/\(flagId)/first-time-events"
|
||
|
||
let queryItems = [
|
||
URLQueryItem(name: "mp_lib", value: "swift"),
|
||
URLQueryItem(name: "$lib_version", value: AutomaticProperties.libVersion())
|
||
]
|
||
|
||
let payload: [String: Any] = [
|
||
"distinct_id": distinctId,
|
||
"project_id": projectId,
|
||
"first_time_event_hash": firstTimeEventHash
|
||
]
|
||
|
||
guard let jsonData = try? JSONSerialization.data(withJSONObject: payload),
|
||
let options = currentOptions else {
|
||
MixpanelLogger.error(message: "Failed to prepare first-time event recording request")
|
||
return
|
||
}
|
||
|
||
guard let headers = createAuthHeaders(token: options.token, includeContentType: true) else {
|
||
MixpanelLogger.error(message: "Failed to create auth headers for first-time event recording")
|
||
return
|
||
}
|
||
|
||
let responseParser: (Data) -> Bool? = { _ in true }
|
||
let resource = Network.buildResource(
|
||
path: url,
|
||
method: .post,
|
||
requestBody: jsonData,
|
||
queryItems: queryItems,
|
||
headers: headers,
|
||
parse: responseParser
|
||
)
|
||
|
||
MixpanelLogger.debug(message: "Recording first-time event for flag: \(flagId)")
|
||
|
||
// Fire-and-forget POST request
|
||
Network.apiRequest(
|
||
base: serverURL,
|
||
resource: resource,
|
||
failure: { reason, _, _ in
|
||
// Silent failure - cohort sync will catch up
|
||
MixpanelLogger.warn(message: "Failed to record first-time event for flag \(flagId): \(reason)")
|
||
},
|
||
success: { _, _ in
|
||
MixpanelLogger.debug(message: "Successfully recorded first-time event for flag \(flagId)")
|
||
}
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - DEBUG Extensions for MixpanelDemo
|
||
|
||
#if DEBUG
|
||
extension PendingFirstTimeEvent {
|
||
init(flagKey: String, flagId: String, projectId: Int,
|
||
firstTimeEventHash: String, eventName: String,
|
||
propertyFilters: [String: Any]?, pendingVariant: MixpanelFlagVariant) {
|
||
self.flagKey = flagKey
|
||
self.flagId = flagId
|
||
self.projectId = projectId
|
||
self.firstTimeEventHash = firstTimeEventHash
|
||
self.eventName = eventName
|
||
self.propertyFilters = propertyFilters
|
||
if let filters = propertyFilters, let jsonData = try? JSONSerialization.data(withJSONObject: filters) {
|
||
self.propertyFiltersJSON = String(data: jsonData, encoding: .utf8)
|
||
} else {
|
||
self.propertyFiltersJSON = nil
|
||
}
|
||
self.pendingVariant = pendingVariant
|
||
}
|
||
}
|
||
|
||
extension FeatureFlagManager {
|
||
internal func injectMockFirstTimeEvents(_ mockEvents: [PendingFirstTimeEvent],
|
||
_ mockFlags: [String: MixpanelFlagVariant]) {
|
||
flagsLock.write {
|
||
self.activatedFirstTimeEvents.removeAll()
|
||
self.flags = mockFlags
|
||
self.pendingFirstTimeEvents.removeAll()
|
||
self.pendingFirstTimeEventNames.removeAll()
|
||
for event in mockEvents {
|
||
let key = getPendingEventKey(event.flagKey, event.firstTimeEventHash)
|
||
self.pendingFirstTimeEvents[key] = event
|
||
self.pendingFirstTimeEventNames.insert(event.eventName)
|
||
}
|
||
}
|
||
}
|
||
|
||
internal func resetFirstTimeEventsForDemo() {
|
||
flagsLock.write {
|
||
self.activatedFirstTimeEvents.removeAll()
|
||
for (_, event) in self.pendingFirstTimeEvents {
|
||
self.flags?[event.flagKey] = event.pendingVariant
|
||
}
|
||
}
|
||
}
|
||
|
||
internal func getPendingEventsForDebug() -> [(eventKey: String, event: PendingFirstTimeEvent)] {
|
||
var result: [(eventKey: String, event: PendingFirstTimeEvent)] = []
|
||
flagsLock.read {
|
||
result = self.pendingFirstTimeEvents.map { (eventKey: $0.key, event: $0.value) }
|
||
}
|
||
return result.sorted { $0.eventKey < $1.eventKey }
|
||
}
|
||
|
||
internal func getActivatedEventsForDebug() -> [String] {
|
||
var result: [String] = []
|
||
flagsLock.read {
|
||
result = Array(self.activatedFirstTimeEvents)
|
||
}
|
||
return result.sorted()
|
||
}
|
||
|
||
internal func getFlagsForDebug() -> [String: MixpanelFlagVariant] {
|
||
var result: [String: MixpanelFlagVariant] = [:]
|
||
flagsLock.read {
|
||
result = self.flags ?? [:]
|
||
}
|
||
return result
|
||
}
|
||
}
|
||
#endif
|