-
Notifications
You must be signed in to change notification settings - Fork 244
First time event targeting #685
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
There was a problem hiding this 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 |
| /// 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) | ||
| } |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
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.
|
|
||
| /// Extract the flag key from a pending event key | ||
| private func getFlagKeyFromPendingEventKey(_ eventKey: String) -> String? { | ||
| return eventKey.components(separatedBy: ":").first |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
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.
| return eventKey.components(separatedBy: ":").first | |
| return eventKey.split(separator: ":", maxSplits: 1).first.map { String($0) } |
| dependencies: [ | ||
| .package( | ||
| url: "https://github.com/advantagefse/json-logic-swift", | ||
| from: "1.2.0" | ||
| ) |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
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:
- Use dependency scanning/security tools
- Need to approve third-party dependencies
- 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).
| internal var activatedFirstTimeEvents: Set<String> = Set() // Stores "flagKey:cohortHash" keys | ||
|
|
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
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:
- A long-running session activates many different first-time events
- 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.
| 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() |
|
|
||
| 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") | ||
| } | ||
| } |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
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
}| guard let result: Bool = try? applyRule(rulesString, to: dataString), | ||
| result == true else { | ||
| continue | ||
| } |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
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
}| 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 | |
| } |
|
|
||
| // Check for first-time event matches | ||
| if let mixpanelInstance = mixpanelInstance { | ||
| mixpanelInstance.flags.checkFirstTimeEvents(eventName: ev, properties: p) |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
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:
- Early exit if
pendingFirstTimeEventsis empty - Index pending events by event name to avoid iterating all events when the name doesn't match
- Evaluate the performance impact of the lowercasing operations on every property
| mixpanelInstance.flags.checkFirstTimeEvents(eventName: ev, properties: p) | |
| if mixpanelInstance.flags.hasPendingFirstTimeEvents { | |
| mixpanelInstance.flags.checkFirstTimeEvents(eventName: ev, properties: p) | |
| } |
| pendingVariant: MixpanelFlagVariant | ||
| ) -> PendingFirstTimeEvent { | ||
| let json: [String: Any] = [ | ||
| "flag_key": flagKey, | ||
| "flag_id": "test-flag-id", | ||
| "project_id": 1, | ||
| "cohort_hash": "hash123", |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
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| 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, |
| 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) | ||
| } |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
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] {| 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 |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
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.
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:
getVariantstarts to return the activated valueThe following edge cases are also handled when fetching flags:
The backend work that the SDK interacts with can be found in this PR