Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
61 changes: 60 additions & 1 deletion Sources/Segment/Analytics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,66 @@ extension Analytics {
}
}
}


/// Subscribes to UserInfo state changes.
///
/// The handler is called immediately with the current UserInfo, then again whenever
/// the user's identity, traits, or referrer changes. The subscription remains active
/// for the lifetime of the Analytics instance unless explicitly unsubscribed.
///
/// - Parameter handler: A closure called on the main queue with updated UserInfo.
///
/// - Returns: A subscription ID that can be passed to `unsubscribe(_:)` to stop
/// receiving updates. If you don't need to unsubscribe, you can ignore the return value.
///
/// - Note: Multiple calls create multiple independent subscriptions.
///
/// ## Example
/// ```swift
/// // Subscribe for the lifetime of Analytics
/// analytics.subscribeToUserInfo { userInfo in
/// print("User: \(userInfo.userId ?? userInfo.anonymousId)")
/// if let referrer = userInfo.referrer {
/// print("Referred from: \(referrer)")
/// }
/// }
///
/// // Subscribe with manual cleanup
/// let subscriptionId = analytics.subscribeToUserInfo { userInfo in
/// // ... handle update
/// }
/// // Later, when you're done...
/// analytics.unsubscribe(subscriptionId)
/// ```
@discardableResult
public func subscribeToUserInfo(handler: @escaping (UserInfo) -> ()) -> Int {
return store.subscribe(self, initialState: true, queue: .main) { (state: UserInfo) in
handler(state)
}
}

/// Unsubscribes from state updates.
///
/// Stops receiving updates for the subscription associated with the given ID.
/// After calling this, the handler will no longer be invoked for state changes.
///
/// - Parameter id: The subscription ID returned from a previous subscribe call.
///
/// - Note: Unsubscribing an already-unsubscribed or invalid ID is a no-op.
///
/// ## Example
/// ```swift
/// let id = analytics.subscribeToUserInfo { userInfo in
/// print("User changed: \(userInfo.userId ?? "anonymous")")
/// }
///
/// // Later, stop listening
/// analytics.unsubscribe(id)
/// ```
public func unsubscribe(_ id: Int) {
store.unsubscribe(identifier: id)
}

/// Retrieve the version of this library in use.
/// - Returns: A string representing the version in "BREAKING.FEATURE.FIX" format.
public func version() -> String {
Expand Down
3 changes: 3 additions & 0 deletions Sources/Segment/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public struct Settings: Codable {
public var plan: JSON? = nil
public var edgeFunction: JSON? = nil
public var middlewareSettings: JSON? = nil
public var autoInstrumentation: JSON? = nil
public var metrics: JSON? = nil
public var consentSettings: JSON? = nil

Expand Down Expand Up @@ -39,6 +40,7 @@ public struct Settings: Codable {
self.plan = try? values.decode(JSON.self, forKey: CodingKeys.plan)
self.edgeFunction = try? values.decode(JSON.self, forKey: CodingKeys.edgeFunction)
self.middlewareSettings = try? values.decode(JSON.self, forKey: CodingKeys.middlewareSettings)
self.autoInstrumentation = try? values.decode(JSON.self, forKey: CodingKeys.autoInstrumentation)
self.metrics = try? values.decode(JSON.self, forKey: CodingKeys.metrics)
self.consentSettings = try? values.decode(JSON.self, forKey: CodingKeys.consentSettings)
}
Expand All @@ -60,6 +62,7 @@ public struct Settings: Codable {
case plan
case edgeFunction
case middlewareSettings
case autoInstrumentation
case metrics
case consentSettings
}
Expand Down
6 changes: 4 additions & 2 deletions Sources/Segment/State.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,14 +166,16 @@ struct System: State {

// MARK: - User information

struct UserInfo: Codable, State {
public struct UserInfo: Codable, State {
let anonymousId: String
let userId: String?
let traits: JSON
let referrer: URL?

@Noncodable var anonIdGenerator: AnonymousIdGenerator?

}

extension UserInfo {
struct ResetAction: Action {
func reduce(state: UserInfo) -> UserInfo {
var anonId: String
Expand Down
8 changes: 4 additions & 4 deletions Sources/Segment/Utilities/Logging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@
import Foundation

extension Analytics {
internal enum LogKind: CustomStringConvertible, CustomDebugStringConvertible {
public enum LogKind: CustomStringConvertible, CustomDebugStringConvertible {
case error
case warning
case debug
case none

var description: String { return string }
var debugDescription: String { return string }
public var description: String { return string }
public var debugDescription: String { return string }

var string: String {
switch self {
Expand All @@ -35,7 +35,7 @@ extension Analytics {
Self.segmentLog(message: message, kind: .none)
}

static internal func segmentLog(message: String, kind: LogKind) {
static public func segmentLog(message: String, kind: LogKind) {
#if DEBUG
if Self.debugLogsEnabled {
print("\(kind)\(message)")
Expand Down
242 changes: 242 additions & 0 deletions Tests/Segment-Tests/Analytics_Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1052,4 +1052,246 @@ final class Analytics_Tests: XCTestCase {
let trackEvent2: TrackEvent? = outputReader.lastEvent as? TrackEvent
XCTAssertEqual(trackEvent2?.context?.value(forKeyPath: "__eventOrigin.type"), "mobile")
}

func testUserInfoSubscription() {
Storage.hardSettingsReset(writeKey: "test")
let analytics = Analytics(configuration: Configuration(writeKey: "test"))

waitUntilStarted(analytics: analytics)

var callCount = 0
var capturedUserInfo: UserInfo?

let initialExpectation = XCTestExpectation(description: "Initial state received")
let identifyExpectation = XCTestExpectation(description: "Identify update received")

// Subscribe and verify we get initial state immediately
let subscriptionId = analytics.subscribeToUserInfo { userInfo in
callCount += 1
capturedUserInfo = userInfo

if callCount == 1 {
initialExpectation.fulfill()
} else if callCount == 2 {
identifyExpectation.fulfill()
}
}

// Wait for initial callback
wait(for: [initialExpectation], timeout: 2.0)

XCTAssertEqual(1, callCount)
XCTAssertNotNil(capturedUserInfo)
XCTAssertNotNil(capturedUserInfo?.anonymousId)
XCTAssertNil(capturedUserInfo?.userId)

let initialAnonId = analytics.anonymousId
XCTAssertEqual(initialAnonId, capturedUserInfo?.anonymousId)

// Update user info and verify handler is called again
analytics.identify(userId: "brandon", traits: MyTraits(email: "[email protected]"))

wait(for: [identifyExpectation], timeout: 2.0)

XCTAssertEqual(2, callCount)
XCTAssertEqual("brandon", capturedUserInfo?.userId)
XCTAssertEqual("brandon", analytics.userId)

let traits: MyTraits? = analytics.traits()
XCTAssertEqual("[email protected]", traits?.email)

// Unsubscribe and verify handler stops firing
analytics.unsubscribe(subscriptionId)

let oldCallCount = callCount
analytics.identify(userId: "different_user")

// Give it a moment to potentially fire (it shouldn't)
let noCallExpectation = XCTestExpectation(description: "Should not be called")
noCallExpectation.isInverted = true

DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if callCount > oldCallCount {
noCallExpectation.fulfill()
}
}

wait(for: [noCallExpectation], timeout: 1.0)
XCTAssertEqual(oldCallCount, callCount)
XCTAssertEqual("brandon", capturedUserInfo?.userId) // Still has old value
}

func testUserInfoSubscriptionWithReset() {
Storage.hardSettingsReset(writeKey: "test")
let analytics = Analytics(configuration: Configuration(writeKey: "test"))

waitUntilStarted(analytics: analytics)

var callCount = 0
var capturedUserInfo: UserInfo?

let initialExpectation = XCTestExpectation(description: "Initial")
let identifyExpectation = XCTestExpectation(description: "Identify")
let resetExpectation = XCTestExpectation(description: "Reset")

analytics.subscribeToUserInfo { userInfo in
callCount += 1
capturedUserInfo = userInfo

if callCount == 1 {
initialExpectation.fulfill()
} else if callCount == 2 {
identifyExpectation.fulfill()
} else if callCount == 3 {
resetExpectation.fulfill()
}
}

wait(for: [initialExpectation], timeout: 2.0)

let originalAnonId = capturedUserInfo?.anonymousId
XCTAssertEqual(1, callCount)

// Set some user data
analytics.identify(userId: "brandon", traits: MyTraits(email: "[email protected]"))
wait(for: [identifyExpectation], timeout: 2.0)

XCTAssertEqual(2, callCount)
XCTAssertEqual("brandon", capturedUserInfo?.userId)

// Reset and verify handler is called with cleared data
analytics.reset()
wait(for: [resetExpectation], timeout: 2.0)

XCTAssertEqual(3, callCount)
XCTAssertNil(capturedUserInfo?.userId)
XCTAssertNil(capturedUserInfo?.referrer)
XCTAssertNotEqual(originalAnonId, capturedUserInfo?.anonymousId)

// Check analytics state AFTER waiting for callback
let traitsDict: [String: Any]? = analytics.traits()
XCTAssertEqual(traitsDict?.count, 0)
}

func testUserInfoSubscriptionWithReferrer() {
Storage.hardSettingsReset(writeKey: "test")
let analytics = Analytics(configuration: Configuration(writeKey: "test"))

waitUntilStarted(analytics: analytics)

var callCount = 0
var capturedUserInfo: UserInfo?

let initialExpectation = XCTestExpectation(description: "Initial")
let referrerExpectation = XCTestExpectation(description: "Referrer")

analytics.subscribeToUserInfo { userInfo in
callCount += 1
capturedUserInfo = userInfo

if callCount == 1 {
initialExpectation.fulfill()
} else if callCount == 2 {
referrerExpectation.fulfill()
}
}

wait(for: [initialExpectation], timeout: 2.0)

XCTAssertEqual(1, callCount)
XCTAssertNil(capturedUserInfo?.referrer)

// Set a referrer
analytics.openURL(URL(string: "https://google.com")!)
wait(for: [referrerExpectation], timeout: 2.0)

XCTAssertEqual(2, callCount)
XCTAssertEqual("https://google.com", capturedUserInfo?.referrer?.absoluteString)
}

func testMultipleUserInfoSubscriptions() {
Storage.hardSettingsReset(writeKey: "test")
let analytics = Analytics(configuration: Configuration(writeKey: "test"))

waitUntilStarted(analytics: analytics)

var firstCallCount = 0
var secondCallCount = 0

let initialExpectation = XCTestExpectation(description: "Initial callbacks")
initialExpectation.expectedFulfillmentCount = 2 // Both subscriptions

let identifyExpectation = XCTestExpectation(description: "Identify callbacks")
identifyExpectation.expectedFulfillmentCount = 2 // Both subscriptions

// Create two subscriptions
analytics.subscribeToUserInfo { _ in
firstCallCount += 1
if firstCallCount == 1 {
initialExpectation.fulfill()
} else if firstCallCount == 2 {
identifyExpectation.fulfill()
}
}

analytics.subscribeToUserInfo { _ in
secondCallCount += 1
if secondCallCount == 1 {
initialExpectation.fulfill()
} else if secondCallCount == 2 {
identifyExpectation.fulfill()
}
}

// Both should be called for initial state
wait(for: [initialExpectation], timeout: 2.0)
XCTAssertEqual(1, firstCallCount)
XCTAssertEqual(1, secondCallCount)

// Both should fire when state changes
analytics.identify(userId: "brandon")
wait(for: [identifyExpectation], timeout: 2.0)

XCTAssertEqual(2, firstCallCount)
XCTAssertEqual(2, secondCallCount)
}

func testUserInfoSubscriptionCalledOnMainQueue() {
Storage.hardSettingsReset(writeKey: "test")
let analytics = Analytics(configuration: Configuration(writeKey: "test"))

waitUntilStarted(analytics: analytics)

let expectation = XCTestExpectation(description: "Handler called on main queue")
expectation.expectedFulfillmentCount = 2 // Initial + identify

analytics.subscribeToUserInfo { userInfo in
XCTAssertTrue(Thread.isMainThread, "Handler should be called on main thread")
expectation.fulfill()
}

analytics.identify(userId: "brandon")

wait(for: [expectation], timeout: 2.0)
}

func testUnsubscribeWithInvalidId() {
Storage.hardSettingsReset(writeKey: "test")
let analytics = Analytics(configuration: Configuration(writeKey: "test"))

waitUntilStarted(analytics: analytics)

// Should not crash with invalid ID
analytics.unsubscribe(999999)
analytics.unsubscribe(-1)

// Should work fine after bogus unsubscribe calls
let expectation = XCTestExpectation(description: "Subscription works after invalid unsubscribe")

analytics.subscribeToUserInfo { _ in
expectation.fulfill()
}

wait(for: [expectation], timeout: 2.0)
}
}
Loading