Skip to content

Commit b54d9b0

Browse files
committed
Added ability to subscribe to user state changes.
1 parent 34d55c8 commit b54d9b0

File tree

4 files changed

+309
-3
lines changed

4 files changed

+309
-3
lines changed

Sources/Segment/Analytics.swift

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,66 @@ extension Analytics {
292292
}
293293
}
294294
}
295-
295+
296+
/// Subscribes to UserInfo state changes.
297+
///
298+
/// The handler is called immediately with the current UserInfo, then again whenever
299+
/// the user's identity, traits, or referrer changes. The subscription remains active
300+
/// for the lifetime of the Analytics instance unless explicitly unsubscribed.
301+
///
302+
/// - Parameter handler: A closure called on the main queue with updated UserInfo.
303+
///
304+
/// - Returns: A subscription ID that can be passed to `unsubscribe(_:)` to stop
305+
/// receiving updates. If you don't need to unsubscribe, you can ignore the return value.
306+
///
307+
/// - Note: Multiple calls create multiple independent subscriptions.
308+
///
309+
/// ## Example
310+
/// ```swift
311+
/// // Subscribe for the lifetime of Analytics
312+
/// analytics.subscribeToUserInfo { userInfo in
313+
/// print("User: \(userInfo.userId ?? userInfo.anonymousId)")
314+
/// if let referrer = userInfo.referrer {
315+
/// print("Referred from: \(referrer)")
316+
/// }
317+
/// }
318+
///
319+
/// // Subscribe with manual cleanup
320+
/// let subscriptionId = analytics.subscribeToUserInfo { userInfo in
321+
/// // ... handle update
322+
/// }
323+
/// // Later, when you're done...
324+
/// analytics.unsubscribe(subscriptionId)
325+
/// ```
326+
@discardableResult
327+
public func subscribeToUserInfo(handler: @escaping (UserInfo) -> ()) -> Int {
328+
return store.subscribe(self, initialState: true, queue: .main) { (state: UserInfo) in
329+
handler(state)
330+
}
331+
}
332+
333+
/// Unsubscribes from state updates.
334+
///
335+
/// Stops receiving updates for the subscription associated with the given ID.
336+
/// After calling this, the handler will no longer be invoked for state changes.
337+
///
338+
/// - Parameter id: The subscription ID returned from a previous subscribe call.
339+
///
340+
/// - Note: Unsubscribing an already-unsubscribed or invalid ID is a no-op.
341+
///
342+
/// ## Example
343+
/// ```swift
344+
/// let id = analytics.subscribeToUserInfo { userInfo in
345+
/// print("User changed: \(userInfo.userId ?? "anonymous")")
346+
/// }
347+
///
348+
/// // Later, stop listening
349+
/// analytics.unsubscribe(id)
350+
/// ```
351+
public func unsubscribe(_ id: Int) {
352+
store.unsubscribe(identifier: id)
353+
}
354+
296355
/// Retrieve the version of this library in use.
297356
/// - Returns: A string representing the version in "BREAKING.FEATURE.FIX" format.
298357
public func version() -> String {

Sources/Segment/Settings.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public struct Settings: Codable {
1212
public var plan: JSON? = nil
1313
public var edgeFunction: JSON? = nil
1414
public var middlewareSettings: JSON? = nil
15+
public var autoInstrumentation: JSON? = nil
1516
public var metrics: JSON? = nil
1617
public var consentSettings: JSON? = nil
1718

@@ -39,6 +40,7 @@ public struct Settings: Codable {
3940
self.plan = try? values.decode(JSON.self, forKey: CodingKeys.plan)
4041
self.edgeFunction = try? values.decode(JSON.self, forKey: CodingKeys.edgeFunction)
4142
self.middlewareSettings = try? values.decode(JSON.self, forKey: CodingKeys.middlewareSettings)
43+
self.autoInstrumentation = try? values.decode(JSON.self, forKey: CodingKeys.autoInstrumentation)
4244
self.metrics = try? values.decode(JSON.self, forKey: CodingKeys.metrics)
4345
self.consentSettings = try? values.decode(JSON.self, forKey: CodingKeys.consentSettings)
4446
}
@@ -60,6 +62,7 @@ public struct Settings: Codable {
6062
case plan
6163
case edgeFunction
6264
case middlewareSettings
65+
case autoInstrumentation
6366
case metrics
6467
case consentSettings
6568
}

Sources/Segment/State.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,14 +166,16 @@ struct System: State {
166166

167167
// MARK: - User information
168168

169-
struct UserInfo: Codable, State {
169+
public struct UserInfo: Codable, State {
170170
let anonymousId: String
171171
let userId: String?
172172
let traits: JSON
173173
let referrer: URL?
174174

175175
@Noncodable var anonIdGenerator: AnonymousIdGenerator?
176-
176+
}
177+
178+
extension UserInfo {
177179
struct ResetAction: Action {
178180
func reduce(state: UserInfo) -> UserInfo {
179181
var anonId: String

Tests/Segment-Tests/Analytics_Tests.swift

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,4 +1052,246 @@ final class Analytics_Tests: XCTestCase {
10521052
let trackEvent2: TrackEvent? = outputReader.lastEvent as? TrackEvent
10531053
XCTAssertEqual(trackEvent2?.context?.value(forKeyPath: "__eventOrigin.type"), "mobile")
10541054
}
1055+
1056+
func testUserInfoSubscription() {
1057+
Storage.hardSettingsReset(writeKey: "test")
1058+
let analytics = Analytics(configuration: Configuration(writeKey: "test"))
1059+
1060+
waitUntilStarted(analytics: analytics)
1061+
1062+
var callCount = 0
1063+
var capturedUserInfo: UserInfo?
1064+
1065+
let initialExpectation = XCTestExpectation(description: "Initial state received")
1066+
let identifyExpectation = XCTestExpectation(description: "Identify update received")
1067+
1068+
// Subscribe and verify we get initial state immediately
1069+
let subscriptionId = analytics.subscribeToUserInfo { userInfo in
1070+
callCount += 1
1071+
capturedUserInfo = userInfo
1072+
1073+
if callCount == 1 {
1074+
initialExpectation.fulfill()
1075+
} else if callCount == 2 {
1076+
identifyExpectation.fulfill()
1077+
}
1078+
}
1079+
1080+
// Wait for initial callback
1081+
wait(for: [initialExpectation], timeout: 2.0)
1082+
1083+
XCTAssertEqual(1, callCount)
1084+
XCTAssertNotNil(capturedUserInfo)
1085+
XCTAssertNotNil(capturedUserInfo?.anonymousId)
1086+
XCTAssertNil(capturedUserInfo?.userId)
1087+
1088+
let initialAnonId = analytics.anonymousId
1089+
XCTAssertEqual(initialAnonId, capturedUserInfo?.anonymousId)
1090+
1091+
// Update user info and verify handler is called again
1092+
analytics.identify(userId: "brandon", traits: MyTraits(email: "[email protected]"))
1093+
1094+
wait(for: [identifyExpectation], timeout: 2.0)
1095+
1096+
XCTAssertEqual(2, callCount)
1097+
XCTAssertEqual("brandon", capturedUserInfo?.userId)
1098+
XCTAssertEqual("brandon", analytics.userId)
1099+
1100+
let traits: MyTraits? = analytics.traits()
1101+
XCTAssertEqual("[email protected]", traits?.email)
1102+
1103+
// Unsubscribe and verify handler stops firing
1104+
analytics.unsubscribe(subscriptionId)
1105+
1106+
let oldCallCount = callCount
1107+
analytics.identify(userId: "different_user")
1108+
1109+
// Give it a moment to potentially fire (it shouldn't)
1110+
let noCallExpectation = XCTestExpectation(description: "Should not be called")
1111+
noCallExpectation.isInverted = true
1112+
1113+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
1114+
if callCount > oldCallCount {
1115+
noCallExpectation.fulfill()
1116+
}
1117+
}
1118+
1119+
wait(for: [noCallExpectation], timeout: 1.0)
1120+
XCTAssertEqual(oldCallCount, callCount)
1121+
XCTAssertEqual("brandon", capturedUserInfo?.userId) // Still has old value
1122+
}
1123+
1124+
func testUserInfoSubscriptionWithReset() {
1125+
Storage.hardSettingsReset(writeKey: "test")
1126+
let analytics = Analytics(configuration: Configuration(writeKey: "test"))
1127+
1128+
waitUntilStarted(analytics: analytics)
1129+
1130+
var callCount = 0
1131+
var capturedUserInfo: UserInfo?
1132+
1133+
let initialExpectation = XCTestExpectation(description: "Initial")
1134+
let identifyExpectation = XCTestExpectation(description: "Identify")
1135+
let resetExpectation = XCTestExpectation(description: "Reset")
1136+
1137+
analytics.subscribeToUserInfo { userInfo in
1138+
callCount += 1
1139+
capturedUserInfo = userInfo
1140+
1141+
if callCount == 1 {
1142+
initialExpectation.fulfill()
1143+
} else if callCount == 2 {
1144+
identifyExpectation.fulfill()
1145+
} else if callCount == 3 {
1146+
resetExpectation.fulfill()
1147+
}
1148+
}
1149+
1150+
wait(for: [initialExpectation], timeout: 2.0)
1151+
1152+
let originalAnonId = capturedUserInfo?.anonymousId
1153+
XCTAssertEqual(1, callCount)
1154+
1155+
// Set some user data
1156+
analytics.identify(userId: "brandon", traits: MyTraits(email: "[email protected]"))
1157+
wait(for: [identifyExpectation], timeout: 2.0)
1158+
1159+
XCTAssertEqual(2, callCount)
1160+
XCTAssertEqual("brandon", capturedUserInfo?.userId)
1161+
1162+
// Reset and verify handler is called with cleared data
1163+
analytics.reset()
1164+
wait(for: [resetExpectation], timeout: 2.0)
1165+
1166+
XCTAssertEqual(3, callCount)
1167+
XCTAssertNil(capturedUserInfo?.userId)
1168+
XCTAssertNil(capturedUserInfo?.referrer)
1169+
XCTAssertNotEqual(originalAnonId, capturedUserInfo?.anonymousId)
1170+
1171+
// Check analytics state AFTER waiting for callback
1172+
let traitsDict: [String: Any]? = analytics.traits()
1173+
XCTAssertEqual(traitsDict?.count, 0)
1174+
}
1175+
1176+
func testUserInfoSubscriptionWithReferrer() {
1177+
Storage.hardSettingsReset(writeKey: "test")
1178+
let analytics = Analytics(configuration: Configuration(writeKey: "test"))
1179+
1180+
waitUntilStarted(analytics: analytics)
1181+
1182+
var callCount = 0
1183+
var capturedUserInfo: UserInfo?
1184+
1185+
let initialExpectation = XCTestExpectation(description: "Initial")
1186+
let referrerExpectation = XCTestExpectation(description: "Referrer")
1187+
1188+
analytics.subscribeToUserInfo { userInfo in
1189+
callCount += 1
1190+
capturedUserInfo = userInfo
1191+
1192+
if callCount == 1 {
1193+
initialExpectation.fulfill()
1194+
} else if callCount == 2 {
1195+
referrerExpectation.fulfill()
1196+
}
1197+
}
1198+
1199+
wait(for: [initialExpectation], timeout: 2.0)
1200+
1201+
XCTAssertEqual(1, callCount)
1202+
XCTAssertNil(capturedUserInfo?.referrer)
1203+
1204+
// Set a referrer
1205+
analytics.openURL(URL(string: "https://google.com")!)
1206+
wait(for: [referrerExpectation], timeout: 2.0)
1207+
1208+
XCTAssertEqual(2, callCount)
1209+
XCTAssertEqual("https://google.com", capturedUserInfo?.referrer?.absoluteString)
1210+
}
1211+
1212+
func testMultipleUserInfoSubscriptions() {
1213+
Storage.hardSettingsReset(writeKey: "test")
1214+
let analytics = Analytics(configuration: Configuration(writeKey: "test"))
1215+
1216+
waitUntilStarted(analytics: analytics)
1217+
1218+
var firstCallCount = 0
1219+
var secondCallCount = 0
1220+
1221+
let initialExpectation = XCTestExpectation(description: "Initial callbacks")
1222+
initialExpectation.expectedFulfillmentCount = 2 // Both subscriptions
1223+
1224+
let identifyExpectation = XCTestExpectation(description: "Identify callbacks")
1225+
identifyExpectation.expectedFulfillmentCount = 2 // Both subscriptions
1226+
1227+
// Create two subscriptions
1228+
analytics.subscribeToUserInfo { _ in
1229+
firstCallCount += 1
1230+
if firstCallCount == 1 {
1231+
initialExpectation.fulfill()
1232+
} else if firstCallCount == 2 {
1233+
identifyExpectation.fulfill()
1234+
}
1235+
}
1236+
1237+
analytics.subscribeToUserInfo { _ in
1238+
secondCallCount += 1
1239+
if secondCallCount == 1 {
1240+
initialExpectation.fulfill()
1241+
} else if secondCallCount == 2 {
1242+
identifyExpectation.fulfill()
1243+
}
1244+
}
1245+
1246+
// Both should be called for initial state
1247+
wait(for: [initialExpectation], timeout: 2.0)
1248+
XCTAssertEqual(1, firstCallCount)
1249+
XCTAssertEqual(1, secondCallCount)
1250+
1251+
// Both should fire when state changes
1252+
analytics.identify(userId: "brandon")
1253+
wait(for: [identifyExpectation], timeout: 2.0)
1254+
1255+
XCTAssertEqual(2, firstCallCount)
1256+
XCTAssertEqual(2, secondCallCount)
1257+
}
1258+
1259+
func testUserInfoSubscriptionCalledOnMainQueue() {
1260+
Storage.hardSettingsReset(writeKey: "test")
1261+
let analytics = Analytics(configuration: Configuration(writeKey: "test"))
1262+
1263+
waitUntilStarted(analytics: analytics)
1264+
1265+
let expectation = XCTestExpectation(description: "Handler called on main queue")
1266+
expectation.expectedFulfillmentCount = 2 // Initial + identify
1267+
1268+
analytics.subscribeToUserInfo { userInfo in
1269+
XCTAssertTrue(Thread.isMainThread, "Handler should be called on main thread")
1270+
expectation.fulfill()
1271+
}
1272+
1273+
analytics.identify(userId: "brandon")
1274+
1275+
wait(for: [expectation], timeout: 2.0)
1276+
}
1277+
1278+
func testUnsubscribeWithInvalidId() {
1279+
Storage.hardSettingsReset(writeKey: "test")
1280+
let analytics = Analytics(configuration: Configuration(writeKey: "test"))
1281+
1282+
waitUntilStarted(analytics: analytics)
1283+
1284+
// Should not crash with invalid ID
1285+
analytics.unsubscribe(999999)
1286+
analytics.unsubscribe(-1)
1287+
1288+
// Should work fine after bogus unsubscribe calls
1289+
let expectation = XCTestExpectation(description: "Subscription works after invalid unsubscribe")
1290+
1291+
analytics.subscribeToUserInfo { _ in
1292+
expectation.fulfill()
1293+
}
1294+
1295+
wait(for: [expectation], timeout: 2.0)
1296+
}
10551297
}

0 commit comments

Comments
 (0)