Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 3 additions & 23 deletions Sources/Userpilot/Analytics/AnalyticsPublisher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Pending user updates disappear

If an identify with new properties is waiting for an ACK, a later identify with the old properties replaces temporaryUser. If the app closes before either ACK, startup restores only the old properties and loses the pending update. Keep the newer pending data when saving another identify.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Repeated identifies reload screens

When an app calls identify again with unchanged data, its ACK can queue a fake screen reload if a screen is active and the event queue is empty. Backend de-duplication cannot stop this extra client-side request. It sends another screen event and makes later events wait for its ACK. Avoid the reload when nothing about the user changed.


// Hold events while network monitor is still resolving initial state
if !networkMonitor.isReady {
Expand Down Expand Up @@ -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.
*
Expand Down
214 changes: 1 addition & 213 deletions Sources/Userpilot/Storage/Models/User.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}

/**
Expand Down
2 changes: 1 addition & 1 deletion Sources/Userpilot/Version.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
35 changes: 3 additions & 32 deletions Tests/UserpilotTests/Analytics/AnalyticsPublisherTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])
}

Expand Down
22 changes: 16 additions & 6 deletions Tests/UserpilotTests/Monitor/NetworkMonitorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading