From 985e0cf3f803000345cfe3e7eb2a85c8ce1e9b7d Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Fri, 2 Oct 2026 14:02:39 +0300 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=94=A5=20refactor(Analytics):=20Remov?= =?UTF-8?q?e=20client-side=20identify=20de-duplication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend now de-duplicates identify calls, so the SDK no longer compares an identify with the cached user and drops it when nothing changed. Every identify is sent, and the pending identify is still cached until the backend acknowledges it. Removes the now-unused User comparison helpers and their tests. Co-Authored-By: Claude Opus 5.5 --- .../Analytics/AnalyticsPublisher.swift | 26 +-- Sources/Userpilot/Storage/Models/User.swift | 214 +----------------- .../Analytics/AnalyticsPublisherTests.swift | 35 +-- Tests/UserpilotTests/Storage/UserTests.swift | 64 ------ 4 files changed, 7 insertions(+), 332 deletions(-) diff --git a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift index f2e03221..e7c35583 100644 --- a/Sources/Userpilot/Analytics/AnalyticsPublisher.swift +++ b/Sources/Userpilot/Analytics/AnalyticsPublisher.swift @@ -348,7 +348,7 @@ extension AnalyticsPublisher: AnalyticsPublishing { * * Routing order: * 1. Drop while the app is not active. - * 2. Ignore unchanged identifies and cache pending user updates. + * 2. Cache identify events as the pending user. * 3. Hold in `initialQueue` until network readiness is known (NWPathMonitor is async). * 4. Persist to local storage when the network is known unavailable. * 5. Drop during socket shutdown. @@ -373,8 +373,8 @@ extension AnalyticsPublisher: AnalyticsPublishing { // Handle app state - drop events when app is not in active state guard sessionMonitorer?.isAppActive ?? false else { return } - // Drop identify events that carry nothing new. Non-identify events skip this. - if event.isIdentifyEvent, didHandleIdentifyEvent(event) { return } + // Cache the pending user until the backend acknowledges the identify + if event.isIdentifyEvent { storage.temporaryUser = event.toUser().toJson() } // Hold events while network monitor is still resolving initial state if !networkMonitor.isReady { @@ -482,26 +482,6 @@ extension AnalyticsPublisher: AnalyticsPublishing { ) } - /** - * Drops identify events that carry nothing new, and caches the pending user for the rest. - * - * - Parameter event: The identify event to handle - * - Returns: true if the event should be ignored, false if processing should continue - */ - private func didHandleIdentifyEvent(_ event: Event) -> Bool { - // A pending identify has not reached the backend yet, so a closed socket means - // `onSocketClosed` is replaying it, not a fresh host-app call. A replay must go through or - // the reconnect never re-identifies the user. - let isPendingReplay = storage.temporaryUser != nil && !socketManager.isSocketOpened - let carriesNoNewData = storage.user.isNotEmpty - && User.fromJson(storage.user).isSameIdentifyEvent(event: event) - - let shouldIgnore = carriesNoNewData && !isPendingReplay - // Update temporary cached user in storage - if !shouldIgnore { storage.temporaryUser = event.toUser().toJson() } - return shouldIgnore - } - /** * Handles events when socket is closed by attempting to open connection. * diff --git a/Sources/Userpilot/Storage/Models/User.swift b/Sources/Userpilot/Storage/Models/User.swift index 5da88568..28d061ac 100644 --- a/Sources/Userpilot/Storage/Models/User.swift +++ b/Sources/Userpilot/Storage/Models/User.swift @@ -8,7 +8,7 @@ // [Brief Description] // The `User` struct is a holder for user details in the Userpilot SDK. // It tracks information about user properties and company attributes and supports -// custom serialization, updating with events, and comparison between user objects. +// custom serialization and updating with events. // import Foundation @@ -115,218 +115,6 @@ extension User { userId = eventUserId return self } - - /// Compares this User with an Event to determine if they represent the same identify event - /// - Parameter event: The Event to compare against - /// - Returns: true if the user and event represent the same identify event - func isSameIdentifyEvent(event: Event) -> Bool { - // Fix: Compare userId with event.userId, not event.type.userId - guard userId == event.userId else { - return false - } - - // For partial updates, check if the event is a subset of existing user data - let eventProperties = event.properties ?? [:] - let eventCompany = event.company ?? [:] - - // If event has no properties/company data, consider it the same user (just userId match) - if eventProperties.isEmpty && eventCompany.isEmpty { - return true - } - - // Check if event properties are a subset of user properties with same values - let propertiesMatch = properties.containsAll(from: eventProperties) - let companyDataMatch = company.containsAll(from: eventCompany) - - return propertiesMatch && companyDataMatch - } - - /// Alternative method with different comparison strategies - func isSameIdentifyEvent(event: Event, strategy: ComparisonStrategy = .partialUpdate) -> Bool { - guard userId == event.userId else { - return false - } - - let eventProperties = event.properties ?? [:] - let eventCompany = event.company ?? [:] - - switch strategy { - case .exactMatch: - // Original behavior - exact match required - return properties.isEqual(to: eventProperties) && - company.isEqual(to: eventCompany) - - case .partialUpdate: - // New behavior - event can be subset of stored user - return properties.containsAll(from: eventProperties) && - company.containsAll(from: eventCompany) - - case .ignoreEmpty: - // Ignore empty properties in comparison - let filteredProperties = properties.filteringOutEmptyValues() - let filteredEventProperties = eventProperties.filteringOutEmptyValues() - let filteredCompany = company.filteringOutEmptyValues() - let filteredEventCompany = eventCompany.filteringOutEmptyValues() - - return filteredProperties.containsAll(from: filteredEventProperties) && - filteredCompany.containsAll(from: filteredEventCompany) - } - } -} - -internal enum ComparisonStrategy { - case exactMatch // Both objects must be identical - case partialUpdate // Event can be a subset of stored user - case ignoreEmpty // Ignore empty values in comparison -} - -/** - * Extension for `Dictionary` where the key is a `String` and the value is `Any`. - * Adds the ability to deeply compare dictionaries to determine if they are equal. - */ -internal extension Dictionary where Key == String, Value == Any { - - /// Compares the current dictionary to another dictionary with deep comparison - /// - Parameter other: The other dictionary to compare against - /// - Returns: true if both dictionaries are identical in terms of keys and values - func isEqual(to other: [String: Any]) -> Bool { - // Check if both dictionaries have the same size - guard self.count == other.count else { - return false - } - - // Check if all keys and values match - for (key, value) in self { - guard let otherValue = other[key] else { - return false - } - - if !compareValues(value, otherValue) { - return false - } - } - - return true - } - - /// Filters out empty values (empty strings, empty arrays, empty dictionaries) - /// - Returns: A new dictionary with empty values removed - func filteringOutEmptyValues() -> [String: Any] { - return self.compactMapValues { value in - switch value { - case let string as String: - return string.isEmpty ? nil : value - case let array as [Any]: - return array.isEmpty ? nil : value - case let dict as [String: Any]: - return dict.isEmpty ? nil : value - default: - return value - } - } - } - - /// Checks if this dictionary contains all key-value pairs from another dictionary - /// This enables partial update detection - the other dictionary can be a subset - /// - Parameter other: The dictionary to check against (can be partial) - /// - Returns: true if all key-value pairs in 'other' exist in this dictionary with same values - func containsAll(from other: [String: Any]) -> Bool { - // Empty dictionary is considered contained in any dictionary - guard !other.isEmpty else { - return true - } - - // Check if all key-value pairs in 'other' exist in self with same values - for (key, otherValue) in other { - guard let selfValue = self[key] else { - return false // Key doesn't exist in self - } - - if !compareValues(selfValue, otherValue) { - return false // Values don't match - } - } - - return true - } - - // MARK: - Private Helper Methods - - // Deeply compares Any type values with improved type handling - // swiftlint:disable:next cyclomatic_complexity - private func compareValues(_ lhs: Any, _ rhs: Any) -> Bool { - // Handle nil values - if isNil(lhs) && isNil(rhs) { - return true - } - if isNil(lhs) || isNil(rhs) { - return false - } - - switch (lhs, rhs) { - case (let left as Int, let right as Int): - return left == right - case (let left as String, let right as String): - return left == right - case (let left as Double, let right as Double): - return left.isEqual(to: right, tolerance: 0.0001) // Handle floating point precision - case (let left as Float, let right as Float): - return abs(left - right) < 0.0001 - case (let left as Bool, let right as Bool): - return left == right - case (let left as [String: Any], let right as [String: Any]): - return left.isEqual(to: right) - case (let left as [Any], let right as [Any]): - return compareArrays(left, right) - // Handle number type conversions - case (let left as Int, let right as Double): - return Double(left) == right - case (let left as Double, let right as Int): - return left == Double(right) - case (let left as NSNumber, let right as NSNumber): - return left.isEqual(to: right) - default: - // Fallback to NSObject comparison for other types - if let leftObj = lhs as? NSObject, let rightObj = rhs as? NSObject { - return leftObj.isEqual(rightObj) - } - return false - } - } - - /// Compares arrays of Any with deep comparison - private func compareArrays(_ lhs: [Any], _ rhs: [Any]) -> Bool { - guard lhs.count == rhs.count else { - return false - } - - for (leftValue, rightValue) in zip(lhs, rhs) { - // swiftlint:disable:next for_where - if !compareValues(leftValue, rightValue) { - return false - } - } - - return true - } - - /// Helper to check if a value is nil (including NSNull) - private func isNil(_ value: Any) -> Bool { - if value is NSNull { - return true - } - // Use reflection to check for nil optionals - let mirror = Mirror(reflecting: value) - return mirror.displayStyle == .optional && mirror.children.count == 0 - } -} - -// MARK: - Double Extension for Floating Point Comparison - -private extension Double { - func isEqual(to other: Double, tolerance: Double) -> Bool { - return abs(self - other) <= tolerance - } } /** diff --git a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift index 59a43ae2..89900095 100644 --- a/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift +++ b/Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift @@ -1261,27 +1261,6 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertEqual(publishCount, 1) } - func testPublish_withSameIdentifyEvent_shouldNotReprocess() { - // Arrange - let userId = "test-user" - let properties = ["name": "John"] - let identifyEvent = Event(type: .identify(userId), properties: properties) - - // Set up existing user - let user = User(userId: userId, properties: properties, company: [:]) - userpilot.storage.user = user.toJson() ?? "" - - var publishIdentifyEventCalled = false - userpilot.socketManager.onPublish = { _, _ in publishIdentifyEventCalled = true } - - // Act - analyticsPublisher.publish(identifyEvent) - - // Assert - // Should not reprocess same identify event - XCTAssertFalse(publishIdentifyEventCalled) - } - // MARK: - User Switch With A Screen Tracked Right After Identify /// A user switch blanks the user id via `clean()` while the new user's identify waits in the @@ -1514,23 +1493,15 @@ class AnalyticsPublisherTests: XCTestCase { XCTAssertEqual(published(), []) } - func testDuplicateIdentifyIsDropped() { + /// Identify de-duplication is the backend's job, so an identify matching the cached user is + /// still sent. + func testIdentifyCarryingNothingNewIsForwarded() { arrangeReloadableScreen() let published = recordPublishedEvents() analyticsPublisher.publish( Event(type: .identify("reload-user"), properties: ["plan": "pro"])) - XCTAssertEqual(published(), []) - } - - func testIdentifyCarryingNewDataIsForwarded() { - arrangeReloadableScreen() - let published = recordPublishedEvents() - - analyticsPublisher.publish( - Event(type: .identify("reload-user"), properties: ["plan": "enterprise"])) - XCTAssertEqual(published(), [Constants.Event.identifyEvent]) } diff --git a/Tests/UserpilotTests/Storage/UserTests.swift b/Tests/UserpilotTests/Storage/UserTests.swift index 26d4754c..4ba83707 100644 --- a/Tests/UserpilotTests/Storage/UserTests.swift +++ b/Tests/UserpilotTests/Storage/UserTests.swift @@ -75,68 +75,4 @@ final class UserTests: XCTestCase { XCTAssertNil(updated.company["oldCompany"]) XCTAssertEqual(updated.company["company"] as? String, "new") } - - func testIsSameIdentifyEventSupportsPartialUpdates() { - let user = User( - userId: "user-1", - properties: [ - "name": "Jane", - "profile": ["role": "admin", "tier": 2], - "scores": [1, "two", true] - ], - company: ["id": "company-1", "size": 10] - ) - let event = Event( - type: .identify("user-1"), - properties: [ - "profile": ["role": "admin", "tier": 2], - "scores": [1, "two", true] - ], - company: ["id": "company-1"] - ) - - XCTAssertTrue(user.isSameIdentifyEvent(event: event)) - XCTAssertTrue(user.isSameIdentifyEvent(event: event, strategy: .partialUpdate)) - } - - func testIsSameIdentifyEventExactMatchRequiresSameKeys() { - let user = User(userId: "user-1", properties: ["name": "Jane", "age": 30], company: [:]) - let partialEvent = Event(type: .identify("user-1"), properties: ["name": "Jane"], company: [:]) - let exactEvent = Event(type: .identify("user-1"), properties: ["name": "Jane", "age": 30], company: [:]) - - XCTAssertFalse(user.isSameIdentifyEvent(event: partialEvent, strategy: .exactMatch)) - XCTAssertTrue(user.isSameIdentifyEvent(event: exactEvent, strategy: .exactMatch)) - } - - func testIsSameIdentifyEventIgnoreEmptyValues() { - let user = User( - userId: "user-1", - properties: ["name": "Jane", "empty": "", "emptyArray": [Any]()], - company: ["id": "company-1", "emptyCompany": [String: Any]()] - ) - let event = Event( - type: .identify("user-1"), - properties: ["name": "Jane", "empty": ""], - company: ["id": "company-1", "emptyCompany": [String: Any]()] - ) - - XCTAssertTrue(user.isSameIdentifyEvent(event: event, strategy: .ignoreEmpty)) - } - - func testDictionaryComparisonSupportsNumbersToleranceAndNilValues() { - let dict: [String: Any] = [ - "intAsDouble": 1, - "double": 1.00001, - "float": Float(2.00001), - "null": NSNull() - ] - - XCTAssertTrue(dict.containsAll(from: [ - "intAsDouble": 1.0, - "double": 1.00002, - "float": Float(2.00002), - "null": NSNull() - ])) - XCTAssertFalse(dict.containsAll(from: ["double": 1.2])) - } } From e4de00d5cf068350d6cca8401045090caad21b23 Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Fri, 2 Oct 2026 14:02:41 +0300 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=94=96=20chore(release):=20Bump=20ver?= =?UTF-8?q?sion=20to=201.4.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Sources/Userpilot/Version.swift | 2 +- Userpilot.podspec | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Userpilot/Version.swift b/Sources/Userpilot/Version.swift index 1cb2a6d6..ad25c84a 100644 --- a/Sources/Userpilot/Version.swift +++ b/Sources/Userpilot/Version.swift @@ -14,5 +14,5 @@ import Foundation /// The current version of the Userpilot SDK. private let versionMajor = "1" private let versionMinor = "4" -private let versionPatch = "0" +private let versionPatch = "1" let userpilotVersion = "\(versionMajor).\(versionMinor).\(versionPatch)" diff --git a/Userpilot.podspec b/Userpilot.podspec index 0ae5e1d7..09233300 100644 --- a/Userpilot.podspec +++ b/Userpilot.podspec @@ -2,7 +2,7 @@ Pod::Spec.new do |spec| spec.name = "Userpilot" spec.module_name = "Userpilot" - spec.version = "1.4.0" + spec.version = "1.4.1" spec.summary = "Userpilot iOS SDK allows you to integrate Userpilot experiences into your native iOS apps" spec.description = <<-DESC From a8b417f8a7dca0a3aa8cac34f2274251f1ddafff Mon Sep 17 00:00:00 2001 From: motasem-userpilot Date: Sun, 4 Oct 2026 10:51:46 +0300 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=85=20test(Monitor):=20Wait=20for=20n?= =?UTF-8?q?etwork=20state=20before=20assertions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace fixed setup delays with expectations for monitor readiness and interface loss. Keep a slower debounce in the interface-down test to cover the timing condition that failed the PR quality gate. SDK behavior is unchanged. --- .../Monitor/NetworkMonitorTests.swift | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/Tests/UserpilotTests/Monitor/NetworkMonitorTests.swift b/Tests/UserpilotTests/Monitor/NetworkMonitorTests.swift index a60e368e..7dfb0e4f 100644 --- a/Tests/UserpilotTests/Monitor/NetworkMonitorTests.swift +++ b/Tests/UserpilotTests/Monitor/NetworkMonitorTests.swift @@ -94,9 +94,13 @@ final class NetworkMonitorTests: XCTestCase { private func arrangeOfflineOnALiveInterface(counter: ProbeCounter) { monitor.updateInterfaceState(hasInterface: true, connectionType: .wifi) // The interface debounce plus the probe are both async. - let settled = expectation(description: "first probe settled") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { settled.fulfill() } - wait(for: [settled], timeout: 2.0) + let settled = XCTNSPredicateExpectation( + predicate: NSPredicate { [weak monitor] _, _ in + monitor?.isReady == true + }, + object: nil + ) + wait(for: [settled], timeout: 5.0) XCTAssertGreaterThanOrEqual(counter.value, 1, "precondition: the first probe ran") XCTAssertFalse(monitor.isNetworkAvailable, "precondition: the monitor is offline") @@ -174,15 +178,21 @@ final class NetworkMonitorTests: XCTestCase { /// Without an interface there is nothing to reach through; the interface transition itself /// re-probes when one comes back. func testRecheckIfOffline_doesNothing_whileTheInterfaceIsDown() { + // Exercise a debounce longer than the former fixed setup wait. + monitor.debounceDelay = 0.5 let counter = ProbeCounter() installProbe(failuresBeforeSuccess: .max, counter: counter) monitor.reachabilityRecheckInterval = 0 arrangeOfflineOnALiveInterface(counter: counter) monitor.updateInterfaceState(hasInterface: false, connectionType: .unknown) - let dropped = expectation(description: "interface drop settled") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { dropped.fulfill() } - wait(for: [dropped], timeout: 2.0) + let dropped = XCTNSPredicateExpectation( + predicate: NSPredicate { [weak monitor] _, _ in + monitor?.connectionType == .unknown + }, + object: nil + ) + wait(for: [dropped], timeout: 5.0) let afterDrop = counter.value for _ in 0..<5 {