Skip to content

Conversation

@msiebert
Copy link
Contributor

@msiebert msiebert commented Nov 27, 2025

Allows feature flags to detect when a users does an action for the first time and changes the variant for the remainder of the session. To accomplish this:

  1. The evaluation endpoint sends down "pending first time events" that contain the event and properties (in JSONLogic form to match the runtime property stuff used elsewhere in feature flags)
  2. The SDK checks each tracked event. If it matches, we update the cached flag variants with the variant in the pending event so that getVariant starts to return the activated value
  3. The SDK sends a call to a new endpoint that marks the user for the cohort so they no longer fall into the "pending" state

The following edge cases are also handled when fetching flags:

  1. If a flag does not have an assigned variant when first loading flags, we create the flag in the flags map when activating
  2. When we refresh flags, we keep the activated value
  3. When we refresh flags, if a flag that has been activated is not present in the response, we add it back in

The backend work that the SDK interacts with can be found in this PR

@msiebert msiebert self-assigned this Nov 27, 2025
@msiebert msiebert marked this pull request as ready for review December 1, 2025 23:18
Copilot finished reviewing on behalf of msiebert December 1, 2025 23:23
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements first-time event targeting for feature flags, allowing flags to dynamically change variants when a user performs a specific action for the first time during a session. The implementation integrates JSONLogic-based property filtering to enable sophisticated event matching beyond simple event names.

Key Changes

  • JSONLogic Dependency: Adds json-logic-swift (~> 1.2.0) as a new external dependency for evaluating property filter expressions
  • First-Time Event System: Implements end-to-end flow for pending events → matching → activation → backend recording with proper state management and session persistence
  • Flag Merge Strategy: Implements sophisticated merging logic to preserve activated variants across flag refreshes and handle orphaned flags

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
Package.swift Adds json-logic-swift dependency for property filtering
Mixpanel-swift.podspec Adds jsonlogic dependency declaration for CocoaPods integration
Mixpanel.xcodeproj/project.pbxproj Adds package references for all platform targets (iOS, tvOS, macOS, watchOS)
Sources/FeatureFlags.swift Implements core first-time event logic: PendingFirstTimeEvent model, checkFirstTimeEvents matching, mergeFlags strategy, recordFirstTimeEvent backend call, and case-insensitive property matching helpers
Sources/Track.swift Integrates first-time event checking into tracking flow by calling checkFirstTimeEvents on each tracked event
MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift Adds comprehensive test suite for first-time events including JSON parsing, matching logic, activation state, flag refresh edge cases, and test helper methods
MixpanelDemo/MixpanelDemoTests/MixpanelDemoTests.swift Adds empty checkFirstTimeEvents stub to mock MixpanelFlags implementation

Comment on lines +775 to +783
/// Lowercase all string keys and values in a nested structure
private func lowercaseKeysAndValues(_ val: Any) -> Any {
return transformStringsRecursively(val, transformDictKey: { $0.lowercased() })
}

/// Lowercase only leaf node string values in a nested structure (keys unchanged)
private func lowercaseOnlyLeafNodes(_ val: Any) -> Any {
return transformStringsRecursively(val)
}
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The case-insensitive property matching in lowercaseKeysAndValues and lowercaseOnlyLeafNodes converts all string values to lowercase. This could cause issues with property values that are case-sensitive identifiers (e.g., product IDs like "ABC-123" would become "abc-123").

Consider whether case-insensitive comparison is actually needed for property values, or if it should only apply to property keys. If case-insensitive values are intentional, this behavior should be clearly documented as it could lead to unexpected matching behavior.

Copilot uses AI. Check for mistakes.

/// Extract the flag key from a pending event key
private func getFlagKeyFromPendingEventKey(_ eventKey: String) -> String? {
return eventKey.components(separatedBy: ":").first
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getFlagKeyFromPendingEventKey method uses components(separatedBy: ":") and takes the first component. However, if a flag key itself contains a colon (which might be valid), this parsing will fail and extract the wrong flag key.

Consider using a more robust key format or document that flag keys cannot contain colons. Alternatively, you could use split(separator: ":", maxSplits: 1) or store the flag key separately in activatedFirstTimeEvents instead of using a composite key.

Suggested change
return eventKey.components(separatedBy: ":").first
return eventKey.split(separator: ":", maxSplits: 1).first.map { String($0) }

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +20
dependencies: [
.package(
url: "https://github.com/advantagefse/json-logic-swift",
from: "1.2.0"
)
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A new external dependency json-logic-swift (version ~> 1.2.0) from https://github.com/advantagefse/json-logic-swift has been added. This dependency should be documented in the README.md or CHANGELOG.md to inform users about the new dependency, especially for those who:

  1. Use dependency scanning/security tools
  2. Need to approve third-party dependencies
  3. Experience issues with the dependency

Consider adding a section to README.md mentioning this dependency and why it's needed (for first-time event property filtering).

Copilot uses AI. Check for mistakes.
Comment on lines +273 to +274
internal var activatedFirstTimeEvents: Set<String> = Set() // Stores "flagKey:cohortHash" keys

Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The activatedFirstTimeEvents Set (line 273) grows unbounded throughout the session as events are activated. While this is likely acceptable for a single session, consider potential memory implications if:

  1. A long-running session activates many different first-time events
  2. The same user has many different cohort configurations

Consider adding documentation about the memory behavior or implementing a maximum size limit if needed. Alternatively, document that this is session-scoped and will be cleared on app restart.

Suggested change
internal var activatedFirstTimeEvents: Set<String> = Set() // Stores "flagKey:cohortHash" keys
/// Stores "flagKey:cohortHash" keys for first-time event activation.
/// Grows unbounded throughout the session as events are activated.
/// This is session-scoped and will be cleared on app restart.
/// If a long-running session activates many different first-time events,
/// or the same user has many different cohort configurations, memory usage may increase.
/// Consider limiting session duration or monitoring usage if this is a concern.
internal var activatedFirstTimeEvents: Set<String> = Set()

Copilot uses AI. Check for mistakes.
Comment on lines +1990 to +2067

func testFirstTimeEventMatching_ExactNameMatch() {
let pendingVariant = createExperimentVariant(key: "activated", value: true, experimentID: "exp-123")
let initialVariant = createControlVariant(value: false)

setupAndTriggerFirstTimeEvent(
flagKey: "welcome-modal",
eventName: "Dashboard Viewed",
pendingVariant: pendingVariant,
initialVariant: initialVariant
) { mockMgr in
let flag = mockMgr.flags?["welcome-modal"]
XCTAssertEqual(flag?.key, "activated")
XCTAssertEqual(flag?.value as? Bool, true)
XCTAssertTrue(mockMgr.activatedFirstTimeEvents.contains("welcome-modal:hash123"))
}
}

func testFirstTimeEventMatching_WithPropertyFilters() {
let pendingVariant = createExperimentVariant(key: "premium", value: ["discount": 20], experimentID: "exp-456")
let initialVariant = createControlVariant(value: nil)
let filters: [String: Any] = [">": [["var": "properties.amount"], 100]]

setupAndTriggerFirstTimeEvent(
flagKey: "premium-welcome",
eventName: "Purchase Complete",
eventProperties: ["amount": 150],
filters: filters,
pendingVariant: pendingVariant,
initialVariant: initialVariant,
cohortHash: "hash456"
) { mockMgr in
let flag = mockMgr.flags?["premium-welcome"]
XCTAssertEqual(flag?.key, "premium")
XCTAssertTrue(mockMgr.activatedFirstTimeEvents.contains("premium-welcome:hash456"))
}
}

func testFirstTimeEventMatching_PropertyFilterNoMatch() {
let pendingVariant = MixpanelFlagVariant(key: "premium", value: true)
let initialVariant = createControlVariant(value: false)
let filters: [String: Any] = [">": [["var": "properties.amount"], 100]]

// Trigger event with amount < 100 (should NOT match)
setupAndTriggerFirstTimeEvent(
flagKey: "premium-welcome",
eventName: "Purchase Complete",
eventProperties: ["amount": 50],
filters: filters,
pendingVariant: pendingVariant,
initialVariant: initialVariant,
cohortHash: "hash456"
) { mockMgr in
let flag = mockMgr.flags?["premium-welcome"]
XCTAssertEqual(flag?.key, "control")
XCTAssertFalse(mockMgr.activatedFirstTimeEvents.contains("premium-welcome:hash456"))
}
}

func testFirstTimeEventMatching_CaseInsensitiveProperties() {
let pendingVariant = MixpanelFlagVariant(key: "matched", value: true)
let initialVariant = createControlVariant(value: false)
let filters: [String: Any] = ["==": [["var": "properties.plan"], "PREMIUM"]]

// Trigger event with lowercase plan (should match due to case-insensitive comparison)
setupAndTriggerFirstTimeEvent(
flagKey: "case-test",
eventName: "Test Event",
eventProperties: ["plan": "premium"],
filters: filters,
pendingVariant: pendingVariant,
initialVariant: initialVariant,
cohortHash: "hash789"
) { mockMgr in
let flag = mockMgr.flags?["case-test"]
XCTAssertEqual(flag?.key, "matched")
}
}
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test suite for first-time event targeting is missing a test case for event name mismatch. While there are tests for:

  • Exact name match (testFirstTimeEventMatching_ExactNameMatch)
  • Property filter match (testFirstTimeEventMatching_WithPropertyFilters)
  • Property filter no match (testFirstTimeEventMatching_PropertyFilterNoMatch)

There's no test verifying that when an event name doesn't match the pending event name, the variant is NOT activated. This is an important edge case since event name matching is case-sensitive (line 810 in FeatureFlags.swift).

Consider adding a test case like:

func testFirstTimeEventMatching_EventNameMismatch() {
  // Set up pending event for "Purchase Complete"
  // Track event "purchase complete" (different case) or "Purchase Started" (different event)
  // Assert flag remains at control variant
}

Copilot uses AI. Check for mistakes.
Comment on lines +835 to +838
guard let result: Bool = try? applyRule(rulesString, to: dataString),
result == true else {
continue
}
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSONLogic evaluation silently continues on failure (line 836) with no logging. If the JsonLogic rules are malformed or evaluation fails, the event won't match but there's no way to debug why. The only logging is for serialization failures (line 830).

Consider adding a log message when JsonLogic evaluation fails or returns false due to an error, to help with debugging filter configurations. For example:

guard let result: Bool = try? applyRule(rulesString, to: dataString) else {
    print("Warning: JsonLogic evaluation failed for event '\(eventKey)' matching '\(eventName)'")
    continue
}
guard result == true else {
    continue
}
Suggested change
guard let result: Bool = try? applyRule(rulesString, to: dataString),
result == true else {
continue
}
guard let result: Bool = try? applyRule(rulesString, to: dataString) else {
print("Warning: JsonLogic evaluation failed for event '\(eventKey)' matching '\(eventName)'")
continue
}
guard result == true else {
continue
}

Copilot uses AI. Check for mistakes.

// Check for first-time event matches
if let mixpanelInstance = mixpanelInstance {
mixpanelInstance.flags.checkFirstTimeEvents(eventName: ev, properties: p)
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The checkFirstTimeEvents method is called synchronously on every tracked event (line 86) and iterates through all pending events, performing string lowercasing and potentially JSONLogic evaluation. This could impact tracking performance if there are many pending first-time events.

Consider:

  1. Early exit if pendingFirstTimeEvents is empty
  2. Index pending events by event name to avoid iterating all events when the name doesn't match
  3. Evaluate the performance impact of the lowercasing operations on every property
Suggested change
mixpanelInstance.flags.checkFirstTimeEvents(eventName: ev, properties: p)
if mixpanelInstance.flags.hasPendingFirstTimeEvents {
mixpanelInstance.flags.checkFirstTimeEvents(eventName: ev, properties: p)
}

Copilot uses AI. Check for mistakes.
Comment on lines +2178 to +2184
pendingVariant: MixpanelFlagVariant
) -> PendingFirstTimeEvent {
let json: [String: Any] = [
"flag_key": flagKey,
"flag_id": "test-flag-id",
"project_id": 1,
"cohort_hash": "hash123",
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test helper createPendingEvent hardcodes cohort_hash to "hash123" (line 2184) regardless of the cohortHash parameter passed to setupAndTriggerFirstTimeEvent. This creates a mismatch where the test sets up a pending event with key "\(flagKey):\(cohortHash)" (line 656) but the event object itself has cohortHash: "hash123".

The test happens to work because the matching uses the dictionary key, not the event's internal cohortHash. However, when recordFirstTimeEvent is called, it will send the wrong cohort_hash to the backend. This could cause issues in integration testing or when verifying the recorded parameters.

Fix by using the passed cohortHash parameter:

"cohort_hash": cohortHash,  // Use parameter instead of hardcoded value
Suggested change
pendingVariant: MixpanelFlagVariant
) -> PendingFirstTimeEvent {
let json: [String: Any] = [
"flag_key": flagKey,
"flag_id": "test-flag-id",
"project_id": 1,
"cohort_hash": "hash123",
pendingVariant: MixpanelFlagVariant,
cohortHash: String
) -> PendingFirstTimeEvent {
let json: [String: Any] = [
"flag_key": flagKey,
"flag_id": "test-flag-id",
"project_id": 1,
"cohort_hash": cohortHash,

Copilot uses AI. Check for mistakes.
Comment on lines +602 to +653
func mergeFlags(
responseFlags: [String: MixpanelFlagVariant]?,
responsePendingEvents: [PendingFirstTimeEvent]?
) -> (flags: [String: MixpanelFlagVariant], pendingEvents: [String: PendingFirstTimeEvent]) {
var newFlags: [String: MixpanelFlagVariant] = [:]
var newPendingEvents: [String: PendingFirstTimeEvent] = [:]

// Process flags from response
if let responseFlags = responseFlags {
for (flagKey, variant) in responseFlags {
// Check if any event for this flag was activated
let hasActivatedEvent = self.activatedFirstTimeEvents.contains { eventKey in
eventKey.hasPrefix("\(flagKey):")
}

if hasActivatedEvent, let currentFlag = self.flags?[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.cohortHash)

// Skip if already activated
if self.activatedFirstTimeEvents.contains(eventKey) {
continue
}

newPendingEvents[eventKey] = pendingEvent
}
}

// Preserve orphaned activated flags
for eventKey in self.activatedFirstTimeEvents {
guard let flagKey = self.getFlagKeyFromPendingEventKey(eventKey) else {
print("Warning: Failed to parse flag key from event key: \(eventKey)")
continue
}
if newFlags[flagKey] == nil, let orphanedFlag = self.flags?[flagKey] {
newFlags[flagKey] = orphanedFlag
}
}

return (flags: newFlags, pendingEvents: newPendingEvents)
}
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mergeFlags method only processes responseFlags if it's non-nil (line 610), but then accesses self.flags which could also be nil (line 617, 647). When self.flags is nil, the optional chaining returns nil and the merge logic won't preserve activated variants correctly.

While this might work in practice because flags is typically initialized, consider explicitly handling the nil case or initializing flags to an empty dictionary to make the logic more robust:

if hasActivatedEvent, let currentFlag = (self.flags ?? [:])[flagKey] {

Copilot uses AI. Check for mistakes.
Comment on lines +868 to +886
internal func recordFirstTimeEvent(flagId: String, projectId: Int, cohortHash: String) {
guard let delegate = self.delegate else {
print("Error: Delegate missing for recording first-time event")
return
}

let distinctId = delegate.getDistinctId()
let url = "/flags/\(flagId)/first-time-events"

let payload: [String: Any] = [
"distinct_id": distinctId,
"project_id": projectId,
"cohort_hash": cohortHash
]

guard let jsonData = try? JSONSerialization.data(withJSONObject: payload),
let options = currentOptions else {
print("Error: Failed to prepare first-time event recording request")
return
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recordFirstTimeEvent method is called from within accessQueue.async context (line 858), but it then accesses currentOptions property without queue protection. Since currentOptions is part of the mutable state, this could potentially lead to a race condition if options are being updated concurrently.

Consider capturing currentOptions before the recordFirstTimeEvent call while still on the accessQueue, or ensure currentOptions access is thread-safe.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants