First time event targeting (#685)

* support for first time event detection
This commit is contained in:
Mark Siebert 2026-03-13 14:32:42 -07:00 committed by GitHub
parent 7da924f18e
commit d1fd4318d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1480 additions and 340 deletions

View file

@ -10,6 +10,7 @@ Pod::Spec.new do |s|
s.source = { :git => 'https://github.com/mixpanel/mixpanel-swift.git',
:tag => "v#{s.version}" }
s.resource_bundles = {'Mixpanel' => ['Sources/Mixpanel/PrivacyInfo.xcprivacy']}
s.dependency 'jsonlogic', '~> 1.2.0'
s.ios.deployment_target = '12.0'
s.ios.frameworks = 'UIKit', 'Foundation', 'CoreTelephony'
s.ios.pod_target_xcconfig = {

View file

@ -343,6 +343,9 @@
dependencies = (
);
name = Mixpanel_watchOS;
packageProductDependencies = (
17JSONLOGIC00000005 /* jsonlogic */,
);
productName = Mixpanel_watchOS;
productReference = 86F86E81224404BD00B69832 /* Mixpanel.framework */;
productType = "com.apple.product-type.framework";
@ -361,6 +364,9 @@
dependencies = (
);
name = Mixpanel;
packageProductDependencies = (
17JSONLOGIC00000002 /* jsonlogic */,
);
productName = Mixpanel;
productReference = E115947D1CFF1491007F8B4F /* Mixpanel.framework */;
productType = "com.apple.product-type.framework";
@ -379,6 +385,9 @@
dependencies = (
);
name = Mixpanel_tvOS;
packageProductDependencies = (
17JSONLOGIC00000003 /* jsonlogic */,
);
productName = Mixpanel_tvOS;
productReference = E12782B31D4AB4B30025FB05 /* Mixpanel.framework */;
productType = "com.apple.product-type.framework";
@ -397,6 +406,9 @@
dependencies = (
);
name = Mixpanel_macOS;
packageProductDependencies = (
17JSONLOGIC00000004 /* jsonlogic */,
);
productName = Mixpanel_OSX;
productReference = E1F15FC91E64A10700391AE3 /* Mixpanel.framework */;
productType = "com.apple.product-type.framework";
@ -443,6 +455,9 @@
);
mainGroup = E11594731CFF1491007F8B4F;
productRefGroup = E115947E1CFF1491007F8B4F /* Products */;
packageReferences = (
17JSONLOGIC00000001 /* XCRemoteSwiftPackageReference "json-logic-swift" */,
);
projectDirPath = "";
projectRoot = "";
targets = (
@ -1063,6 +1078,40 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
17JSONLOGIC00000001 /* XCRemoteSwiftPackageReference "json-logic-swift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/advantagefse/json-logic-swift";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.2.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
17JSONLOGIC00000002 /* jsonlogic */ = {
isa = XCSwiftPackageProductDependency;
package = 17JSONLOGIC00000001 /* XCRemoteSwiftPackageReference "json-logic-swift" */;
productName = jsonlogic;
};
17JSONLOGIC00000003 /* jsonlogic */ = {
isa = XCSwiftPackageProductDependency;
package = 17JSONLOGIC00000001 /* XCRemoteSwiftPackageReference "json-logic-swift" */;
productName = jsonlogic;
};
17JSONLOGIC00000004 /* jsonlogic */ = {
isa = XCSwiftPackageProductDependency;
package = 17JSONLOGIC00000001 /* XCRemoteSwiftPackageReference "json-logic-swift" */;
productName = jsonlogic;
};
17JSONLOGIC00000005 /* jsonlogic */ = {
isa = XCSwiftPackageProductDependency;
package = 17JSONLOGIC00000001 /* XCRemoteSwiftPackageReference "json-logic-swift" */;
productName = jsonlogic;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = E11594741CFF1491007F8B4F /* Project object */;
}

File diff suppressed because it is too large Load diff

View file

@ -13,9 +13,18 @@ let package = Package(
products: [
.library(name: "Mixpanel", targets: ["Mixpanel"])
],
dependencies: [
.package(
url: "https://github.com/advantagefse/json-logic-swift",
from: "1.2.0"
)
],
targets: [
.target(
name: "Mixpanel",
dependencies: [
.product(name: "jsonlogic", package: "json-logic-swift")
],
path: "Sources",
resources: [
.copy("Mixpanel/PrivacyInfo.xcprivacy")

View file

@ -65,6 +65,14 @@ github "mixpanel/mixpanel-swift"
```
Check out the [Carthage docs](https://github.com/Carthage/Carthage#if-youre-building-for-ios-tvos-or-watchos) for more info.
### Dependencies
The Mixpanel Swift SDK includes the following third-party dependencies:
- **json-logic-swift** (v1.2.0+) - Used for evaluating property filters in first-time event targeting for Feature Flags.
This dependency is automatically managed by your package manager (Swift Package Manager, CocoaPods, or Carthage) and does not require manual installation.
## 2. Initialize Mixpanel
Import Mixpanel into AppDelegate.swift, and initialize Mixpanel within application:didFinishLaunchingWithOptions:
```swift

View file

@ -1,4 +1,7 @@
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)
@ -76,9 +79,63 @@ public struct MixpanelFlagVariant: Decodable {
}
}
// 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 ---
@ -217,20 +274,28 @@ class FeatureFlagManager: Network, MixpanelFlags {
weak var delegate: MixpanelFlagDelegate?
// *** Use a SERIAL queue for automatic state serialization ***
private static let accessQueueKey = DispatchSpecificKey<Int>()
let accessQueue: DispatchQueue = {
let queue = DispatchQueue(label: "com.mixpanel.featureflagmanager.serialqueue")
queue.setSpecific(key: FeatureFlagManager.accessQueueKey, value: 1)
return queue
}()
// Thread safety using ReadWriteLock (consistent with Track, People, MixpanelInstance)
internal let flagsLock = ReadWriteLock(label: "com.mixpanel.featureflagmanager")
// Internal State - Protected by accessQueue
// 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()
// Timing tracking properties
private var fetchStartTime: Date?
var timeLastFetched: Date?
@ -254,15 +319,17 @@ class FeatureFlagManager: Network, MixpanelFlags {
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
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?._fetchFlagsIfNeeded(completion: nil)
}
}
func areFlagsReady() -> Bool {
// Simple sync read - serial queue ensures this is safe
accessQueue.sync { flags != nil }
var result: Bool = false
flagsLock.read {
result = (flags != nil)
}
return result
}
// --- Sync Flag Retrieval ---
@ -273,25 +340,24 @@ class FeatureFlagManager: Network, MixpanelFlags {
var capturedTimeLastFetched: Date?
var capturedFetchLatencyMs: Int?
// === Serial Queue: Single Sync Block for Read AND Track Update ===
accessQueue.sync {
// 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 *within the same sync block*
// Perform atomic check-and-set for tracking
if !self.trackedFeatures.contains(flagName) {
self.trackedFeatures.insert(flagName)
tracked = true
// Capture timing data while on queue
// Capture timing data while in lock
capturedTimeLastFetched = self.timeLastFetched
capturedFetchLatencyMs = self.fetchLatencyMs
}
}
// If flag wasn't found, flagVariant remains nil
}
// === End Sync Block ===
// Now, process the results outside the lock
@ -307,7 +373,7 @@ class FeatureFlagManager: Network, MixpanelFlags {
}
return foundVariant
} else {
print("Info: Flag '\(flagName)' not found or flags not ready. Returning fallback.")
MixpanelLogger.info(message: "Flag '\(flagName)' not found or flags not ready. Returning fallback.")
return fallback
}
}
@ -318,30 +384,28 @@ class FeatureFlagManager: Network, MixpanelFlags {
_ flagName: String, fallback: MixpanelFlagVariant,
completion: @escaping (MixpanelFlagVariant) -> Void
) {
accessQueue.async { [weak self] in // Block A runs serially on accessQueue
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self = self else { return }
var flagVariant: MixpanelFlagVariant?
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 variant = currentFlags[flagName] {
flagVariant = variant
// Also safe to access trackedFeatures directly here
needsTrackingCheck = !self.trackedFeatures.contains(flagName)
// 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)
}
}
}
// === State access finished ===
if flagsAreCurrentlyReady {
let result = flagVariant ?? fallback
if flagVariant != nil, needsTrackingCheck {
// Perform atomic check-and-track. _trackFeatureIfNeeded uses its
// own sync block, which is safe to call from here (it's not nested).
// Perform atomic check-and-track
self._trackFlagIfNeeded(flagName: flagName, variant: result)
}
DispatchQueue.main.async { completion(result) }
@ -349,7 +413,7 @@ class FeatureFlagManager: Network, MixpanelFlags {
} else {
// --- Flags were NOT ready ---
// Trigger fetch; fetch completion will handle calling the original completion handler
print("Flags not ready, attempting fetch for getFeature call...")
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
@ -357,17 +421,14 @@ class FeatureFlagManager: Network, MixpanelFlags {
// Fetch succeeded, get the flag SYNCHRONOUSLY
result = self.getVariantSync(flagName, fallback: fallback)
} else {
print("Warning: Failed to fetch flags, returning fallback for \(flagName).")
MixpanelLogger.warn(message: "Failed to fetch flags, returning fallback for \(flagName).")
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)
}
}
func getVariantValueSync(_ flagName: String, fallbackValue: Any?) -> Any? {
@ -405,29 +466,33 @@ class FeatureFlagManager: Network, MixpanelFlags {
// --- Bulk Flag Retrieval ---
func getAllVariantsSync() -> [String: MixpanelFlagVariant] {
return accessQueue.sync {
return self.flags ?? [:]
var result: [String: MixpanelFlagVariant] = [:]
flagsLock.read {
result = self.flags ?? [:]
}
return result
}
func getAllVariants(completion: @escaping ([String: MixpanelFlagVariant]) -> Void) {
accessQueue.async { [weak self] in
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self = self else {
DispatchQueue.main.async { completion([:]) }
return
}
if let currentFlags = self.flags {
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
print("Flags not ready, attempting fetch for getAllVariants call...")
MixpanelLogger.debug(message: "Flags not ready, attempting fetch for getAllVariants call...")
self._fetchFlagsIfNeeded { success in
let result: [String: MixpanelFlagVariant]
if success {
result = self.getAllVariantsSync()
} else {
print("Warning: Failed to fetch flags, returning empty dictionary.")
MixpanelLogger.warn(message: "Failed to fetch flags, returning empty dictionary.")
result = [:]
}
DispatchQueue.main.async { completion(result) }
@ -440,12 +505,10 @@ class FeatureFlagManager: Network, MixpanelFlags {
// Internal function to handle fetch logic and state checks
private func _fetchFlagsIfNeeded(completion: ((Bool) -> Void)?) {
var shouldStartFetch = false
let optionsSnapshot = self.currentOptions // Read options directly (safe on accessQueue)
let optionsSnapshot = self.currentOptions
guard let options = optionsSnapshot, options.featureFlagOptions.enabled else {
print("Feature flags are disabled, not fetching.")
MixpanelLogger.debug(message: "Feature flags are disabled, not fetching.")
// Dispatch completion to main queue to avoid potential deadlock
DispatchQueue.main.async {
completion?(false)
@ -453,51 +516,48 @@ class FeatureFlagManager: Network, MixpanelFlags {
return // Exit method
}
// 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)
var shouldStartFetch = false
// Access/Modify isFetching and fetchCompletionHandlers with write lock
flagsLock.write {
if !self.isFetching {
self.isFetching = true
shouldStartFetch = true
if let completion = completion {
self.fetchCompletionHandlers.append(completion)
}
} else {
MixpanelLogger.debug(message: "Fetch already in progress, queueing completion handler.")
if let completion = completion {
self.fetchCompletionHandlers.append(completion)
}
}
}
// 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()
}
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() {
// This method runs OUTSIDE the accessQueue
// Record fetch start time
let startTime = Date()
accessQueue.async { [weak self] in
self?.fetchStartTime = startTime
flagsLock.write {
self.fetchStartTime = startTime
}
guard let delegate = self.delegate, let options = self.currentOptions else {
print("Error: Delegate or options missing for fetch.")
MixpanelLogger.error(message: "Delegate or options missing for fetch.")
self._completeFetch(success: false)
return
}
let distinctId = delegate.getDistinctId()
let anonymousId = delegate.getAnonymousId()
print("Fetching flags for distinct ID: \(distinctId)")
MixpanelLogger.debug(message: "Fetching flags for distinct ID: \(distinctId)")
var context = options.featureFlagOptions.context
context["distinct_id"] = distinctId
@ -510,18 +570,16 @@ class FeatureFlagManager: Network, MixpanelFlags {
withJSONObject: context, options: []),
let contextString = String(data: contextData, encoding: .utf8)
else {
print("Error: Failed to serialize context for flags.")
MixpanelLogger.error(message: "Failed to serialize context for flags.")
self._completeFetch(success: false)
return
}
guard let authData = "\(options.token):".data(using: .utf8) else {
print("Error: Failed to create auth data.")
guard let headers = createAuthHeaders(token: options.token) else {
MixpanelLogger.error(message: "Failed to create auth headers.")
self._completeFetch(success: false)
return
}
let base64Auth = authData.base64EncodedString()
let headers = ["Authorization": "Basic \(base64Auth)"]
let queryItems = [
URLQueryItem(name: "context", value: contextString),
@ -532,7 +590,7 @@ class FeatureFlagManager: Network, MixpanelFlags {
let responseParser: (Data) -> FlagsResponse? = { data in
do { return try JSONDecoder().decode(FlagsResponse.self, from: data) } catch {
print("Error parsing flags JSON: \(error)")
MixpanelLogger.error(message: "Error parsing flags JSON: \(error)")
return nil
}
}
@ -544,22 +602,25 @@ class FeatureFlagManager: Network, MixpanelFlags {
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)
}
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 // Completion handlers run on URLSession's queue
print("Successfully fetched flags.")
success: { [weak self] (flagsResponse, response) in
MixpanelLogger.info(message: "Successfully fetched flags.")
guard let self = self else { return }
let fetchEndTime = Date()
// 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 ?? [:]
// 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 {
@ -568,24 +629,94 @@ class FeatureFlagManager: Network, MixpanelFlags {
}
self.timeLastFetched = fetchEndTime
print("Flags updated: \(self.flags ?? [:])")
self._completeFetch(success: true) // still on accessQueue
MixpanelLogger.debug(message: "Flags updated: \(self.flags ?? [:]), Pending events: \(self.pendingFirstTimeEvents.count)")
}
self._completeFetch(success: true)
}
)
}
// Centralized fetch completion logic - MUST be called from within accessQueue
// Centralized fetch completion logic
func _completeFetch(success: Bool) {
self.isFetching = false
let handlers = self.fetchCompletionHandlers
self.fetchCompletionHandlers.removeAll()
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
@ -594,17 +725,18 @@ class FeatureFlagManager: Network, MixpanelFlags {
var capturedTimeLastFetched: Date?
var capturedFetchLatencyMs: Int?
// We are already executing on the serial accessQueue, so this is safe.
if !self.trackedFeatures.contains(flagName) {
self.trackedFeatures.insert(flagName)
shouldCallDelegate = true
// Capture timing data while on queue
capturedTimeLastFetched = self.timeLastFetched
capturedFetchLatencyMs = self.fetchLatencyMs
// 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* this conceptual block if tracking occurred
// This prevents holding any potential implicit lock during delegate execution
// Call delegate outside the lock if tracking occurred
if shouldCallDelegate {
self._performTrackingDelegateCall(
flagName: flagName,
@ -651,7 +783,7 @@ class FeatureFlagManager: Network, MixpanelFlags {
// Dispatch delegate call asynchronously to main thread for safety
DispatchQueue.main.async {
delegate.track(event: "$experiment_started", properties: properties)
print("Tracked $experiment_started for \(flagName) (dispatched to main)")
MixpanelLogger.debug(message: "Tracked $experiment_started for \(flagName) (dispatched to main)")
}
}
@ -663,8 +795,266 @@ class FeatureFlagManager: Network, MixpanelFlags {
if let boolVal = val as? Bool {
return boolVal
} else {
print("Error: Flag '\(flagName)' is not Bool")
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 is **asynchronous** with respect to the caller. It dispatches its work onto
/// the queue and returns immediately, without waiting for first-time event processing to
/// complete. As a result, there is a short window during which a subsequent `getVariant` call
/// may not yet observe the newly activated variant. Callers should not rely on immediate
/// visibility of first-time event activations in the same synchronous call chain.
internal func checkFirstTimeEvents(eventName: String, properties: [String: Any]) {
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self = self else { return }
// O(1) check: skip iteration if no pending event matches this event name
var hasPendingEvent = false
self.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] = [:]
self.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.
self.flagsLock.write {
if !self.activatedFirstTimeEvents.contains(eventKey) {
// We won the race - activate this event
self.activatedFirstTimeEvents.insert(eventKey)
if self.flags == nil {
self.flags = [:]
}
self.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)
// Record to backend (fire-and-forget)
self.recordFirstTimeEvent(
flagId: pendingEvent.flagId,
projectId: pendingEvent.projectId,
firstTimeEventHash: pendingEvent.firstTimeEventHash
)
}
}
}
}
/// Records a first-time event activation to the backend
internal func recordFirstTimeEvent(flagId: String, projectId: Int, firstTimeEventHash: String) {
guard let delegate = self.delegate else {
MixpanelLogger.error(message: "Delegate missing for recording first-time event")
return
}
let distinctId = delegate.getDistinctId()
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: { [weak self] reason, _, _ in
guard self != nil else { return }
// Silent failure - cohort sync will catch up
MixpanelLogger.warn(message: "Failed to record first-time event for flag \(flagId): \(reason)")
},
success: { [weak self] _, _ in
guard self != nil else { return }
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

View file

@ -81,6 +81,12 @@ class Track {
p += properties
}
// Check for first-time event matches
if let mixpanelInstance = mixpanelInstance,
let flagManager = mixpanelInstance.flags as? FeatureFlagManager {
flagManager.checkFirstTimeEvents(eventName: ev, properties: p)
}
var trackEvent: InternalProperties = ["event": ev, "properties": p]
metadata.toDict().forEach { (k, v) in trackEvent[k] = v }