Skip to content

Commit d43c7a8

Browse files
authored
Add ability to control anonymousId values. (#327)
* Add ability to control anonymousId values. * Removed singleton aspect of the initial implementation. * Adjusted test * CI checking * Another CI check * Another CI run * Revised failing test * Adjusted timing based tests.
1 parent 72415f4 commit d43c7a8

File tree

7 files changed

+187
-29
lines changed

7 files changed

+187
-29
lines changed

Sources/Segment/Analytics.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ public class Analytics {
7878

7979
// provide our default state
8080
store.provide(state: System.defaultState(configuration: configuration, from: storage))
81-
store.provide(state: UserInfo.defaultState(from: storage))
81+
store.provide(state: UserInfo.defaultState(from: storage, anonIdGenerator: configuration.values.anonymousIdGenerator))
8282

8383
storage.analytics = self
8484

Sources/Segment/Configuration.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ import JSONSafeEncoder
1111
import FoundationNetworking
1212
#endif
1313

14+
// MARK: - Custom AnonymousId generator
15+
/// Conform to this protocol to generate your own AnonymousID
16+
public protocol AnonymousIdGenerator: AnyObject, Codable {
17+
/// Returns a new anonymousId. Segment still manages storage and retrieval of the
18+
/// current anonymousId and will call this method when new id's are needed.
19+
///
20+
/// - Returns: A new anonymousId.
21+
func newAnonymousId() -> String
22+
}
23+
1424
// MARK: - Operating Mode
1525
/// Specifies the operating mode/context
1626
public enum OperatingMode {
@@ -56,6 +66,7 @@ public class Configuration {
5666
var userAgent: String? = nil
5767
var jsonNonConformingNumberStrategy: JSONSafeEncoder.NonConformingFloatEncodingStrategy = .zero
5868
var storageMode: StorageMode = .disk
69+
var anonymousIdGenerator: AnonymousIdGenerator = SegmentAnonymousId()
5970
}
6071

6172
internal var values: Values
@@ -248,11 +259,19 @@ public extension Configuration {
248259
return self
249260
}
250261

262+
/// Specify the storage mode to use. The default is `.disk`.
251263
@discardableResult
252264
func storageMode(_ mode: StorageMode) -> Configuration {
253265
values.storageMode = mode
254266
return self
255267
}
268+
269+
/// Specify a custom anonymousId generator. The default is and instance of `SegmentAnonymousId`.
270+
@discardableResult
271+
func anonymousIdGenerator(_ generator: AnonymousIdGenerator) -> Configuration {
272+
values.anonymousIdGenerator = generator
273+
return self
274+
}
256275
}
257276

258277
extension Analytics {

Sources/Segment/Plugins/SegmentDestination.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ import Sovran
1616
import FoundationNetworking
1717
#endif
1818

19+
public class SegmentAnonymousId: AnonymousIdGenerator {
20+
public func newAnonymousId() -> String {
21+
return UUID().uuidString
22+
}
23+
}
24+
1925
public class SegmentDestination: DestinationPlugin, Subscriber, FlushCompletion {
2026
internal enum Constants: String {
2127
case integrationName = "Segment.io"

Sources/Segment/State.swift

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -111,25 +111,33 @@ struct UserInfo: Codable, State {
111111
let traits: JSON?
112112
let referrer: URL?
113113

114+
@Noncodable var anonIdGenerator: AnonymousIdGenerator?
115+
114116
struct ResetAction: Action {
115117
func reduce(state: UserInfo) -> UserInfo {
116-
return UserInfo(anonymousId: UUID().uuidString, userId: nil, traits: nil, referrer: nil)
118+
var anonId: String
119+
if let id = state.anonIdGenerator?.newAnonymousId() {
120+
anonId = id
121+
} else {
122+
anonId = UUID().uuidString
123+
}
124+
return UserInfo(anonymousId: anonId, userId: nil, traits: nil, referrer: nil, anonIdGenerator: state.anonIdGenerator)
117125
}
118126
}
119127

120128
struct SetUserIdAction: Action {
121129
let userId: String
122130

123131
func reduce(state: UserInfo) -> UserInfo {
124-
return UserInfo(anonymousId: state.anonymousId, userId: userId, traits: state.traits, referrer: state.referrer)
132+
return UserInfo(anonymousId: state.anonymousId, userId: userId, traits: state.traits, referrer: state.referrer, anonIdGenerator: state.anonIdGenerator)
125133
}
126134
}
127135

128136
struct SetTraitsAction: Action {
129137
let traits: JSON?
130138

131139
func reduce(state: UserInfo) -> UserInfo {
132-
return UserInfo(anonymousId: state.anonymousId, userId: state.userId, traits: traits, referrer: state.referrer)
140+
return UserInfo(anonymousId: state.anonymousId, userId: state.userId, traits: traits, referrer: state.referrer, anonIdGenerator: state.anonIdGenerator)
133141
}
134142
}
135143

@@ -138,23 +146,15 @@ struct UserInfo: Codable, State {
138146
let traits: JSON?
139147

140148
func reduce(state: UserInfo) -> UserInfo {
141-
return UserInfo(anonymousId: state.anonymousId, userId: userId, traits: traits, referrer: state.referrer)
142-
}
143-
}
144-
145-
struct SetAnonymousIdAction: Action {
146-
let anonymousId: String
147-
148-
func reduce(state: UserInfo) -> UserInfo {
149-
return UserInfo(anonymousId: anonymousId, userId: state.userId, traits: state.traits, referrer: state.referrer)
149+
return UserInfo(anonymousId: state.anonymousId, userId: userId, traits: traits, referrer: state.referrer, anonIdGenerator: state.anonIdGenerator)
150150
}
151151
}
152152

153153
struct SetReferrerAction: Action {
154154
let url: URL
155155

156156
func reduce(state: UserInfo) -> UserInfo {
157-
return UserInfo(anonymousId: state.anonymousId, userId: state.userId, traits: state.traits, referrer: url)
157+
return UserInfo(anonymousId: state.anonymousId, userId: state.userId, traits: state.traits, referrer: url, anonIdGenerator: state.anonIdGenerator)
158158
}
159159
}
160160
}
@@ -176,13 +176,15 @@ extension System {
176176
}
177177

178178
extension UserInfo {
179-
static func defaultState(from storage: Storage) -> UserInfo {
179+
static func defaultState(from storage: Storage, anonIdGenerator: AnonymousIdGenerator) -> UserInfo {
180180
let userId: String? = storage.read(.userId)
181181
let traits: JSON? = storage.read(.traits)
182-
var anonymousId: String = UUID().uuidString
182+
var anonymousId: String
183183
if let existingId: String = storage.read(.anonymousId) {
184184
anonymousId = existingId
185+
} else {
186+
anonymousId = anonIdGenerator.newAnonymousId()
185187
}
186-
return UserInfo(anonymousId: anonymousId, userId: userId, traits: traits, referrer: nil)
188+
return UserInfo(anonymousId: anonymousId, userId: userId, traits: traits, referrer: nil, anonIdGenerator: anonIdGenerator)
187189
}
188190
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//
2+
// Noncodable.swift
3+
//
4+
//
5+
// Created by Brandon Sneed on 4/17/24.
6+
//
7+
8+
import Foundation
9+
10+
@propertyWrapper
11+
internal struct Noncodable<T>: Codable {
12+
public var wrappedValue: T?
13+
public init(wrappedValue: T?) {
14+
self.wrappedValue = wrappedValue
15+
}
16+
public init(from decoder: Decoder) throws {
17+
self.wrappedValue = nil
18+
}
19+
public func encode(to encoder: Encoder) throws {
20+
// Do nothing
21+
}
22+
}
23+
24+
extension KeyedDecodingContainer {
25+
internal func decode<T>(_ type: Noncodable<T>.Type, forKey key: Self.Key) throws -> Noncodable<T> {
26+
return Noncodable(wrappedValue: nil)
27+
}
28+
}
29+
30+
extension KeyedEncodingContainer {
31+
internal mutating func encode<T>(_ value: Noncodable<T>, forKey key: KeyedEncodingContainer<K>.Key) throws {
32+
// Do nothing
33+
}
34+
}

Sources/Segment/Utilities/Utils.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,5 @@ internal func eventStorageDirectory(writeKey: String) -> URL {
8484
try? FileManager.default.createDirectory(at: segmentURL, withIntermediateDirectories: true, attributes: nil)
8585
return segmentURL
8686
}
87+
88+

Tests/Segment-Tests/Analytics_Tests.swift

Lines changed: 107 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,11 @@ final class Analytics_Tests: XCTestCase {
142142
let expectation = XCTestExpectation(description: "MyDestination Expectation")
143143
let myDestination = MyDestination(disabled: true) {
144144
expectation.fulfill()
145+
print("called")
145146
return true
146147
}
147148

148-
let configuration = Configuration(writeKey: "test")
149+
let configuration = Configuration(writeKey: "testDestNotEnabled")
149150
let analytics = Analytics(configuration: configuration)
150151

151152
analytics.add(plugin: myDestination)
@@ -754,25 +755,36 @@ final class Analytics_Tests: XCTestCase {
754755
.flushAt(9999)
755756
.operatingMode(.asynchronous))
756757

758+
// set the httpclient to use our blocker session
759+
let segment = analytics.find(pluginType: SegmentDestination.self)
760+
let configuration = URLSessionConfiguration.ephemeral
761+
configuration.allowsCellularAccess = true
762+
configuration.timeoutIntervalForResource = 30
763+
configuration.timeoutIntervalForRequest = 60
764+
configuration.httpMaximumConnectionsPerHost = 2
765+
configuration.protocolClasses = [BlockNetworkCalls.self]
766+
configuration.httpAdditionalHeaders = ["Content-Type": "application/json; charset=utf-8",
767+
"Authorization": "Basic test",
768+
"User-Agent": "analytics-ios/\(Analytics.version())"]
769+
let blockSession = URLSession(configuration: configuration, delegate: nil, delegateQueue: nil)
770+
segment?.httpClient?.session = blockSession
771+
757772
waitUntilStarted(analytics: analytics)
758773

759774
analytics.storage.hardReset(doYouKnowHowToUseThis: true)
760775

761-
@Atomic var completionCalled = false
776+
let expectation = XCTestExpectation()
762777

763778
// put an event in the pipe ...
764779
analytics.track(name: "completion test1")
765780
// flush it, that'll get us an upload going
766781
analytics.flush {
767782
// verify completion is called.
768-
completionCalled = true
783+
expectation.fulfill()
769784
}
770785

771-
while !completionCalled {
772-
RunLoop.main.run(until: Date.distantPast)
773-
}
786+
wait(for: [expectation], timeout: 5)
774787

775-
XCTAssertTrue(completionCalled)
776788
XCTAssertNil(analytics.pendingUploads)
777789
}
778790

@@ -783,22 +795,35 @@ final class Analytics_Tests: XCTestCase {
783795
.flushAt(9999)
784796
.operatingMode(.synchronous))
785797

798+
// set the httpclient to use our blocker session
799+
let segment = analytics.find(pluginType: SegmentDestination.self)
800+
let configuration = URLSessionConfiguration.ephemeral
801+
configuration.allowsCellularAccess = true
802+
configuration.timeoutIntervalForResource = 30
803+
configuration.timeoutIntervalForRequest = 60
804+
configuration.httpMaximumConnectionsPerHost = 2
805+
configuration.protocolClasses = [BlockNetworkCalls.self]
806+
configuration.httpAdditionalHeaders = ["Content-Type": "application/json; charset=utf-8",
807+
"Authorization": "Basic test",
808+
"User-Agent": "analytics-ios/\(Analytics.version())"]
809+
let blockSession = URLSession(configuration: configuration, delegate: nil, delegateQueue: nil)
810+
segment?.httpClient?.session = blockSession
811+
786812
waitUntilStarted(analytics: analytics)
787813

788814
analytics.storage.hardReset(doYouKnowHowToUseThis: true)
789815

790-
@Atomic var completionCalled = false
791-
816+
let expectation = XCTestExpectation()
792817
// put an event in the pipe ...
793818
analytics.track(name: "completion test1")
794819
// flush it, that'll get us an upload going
795820
analytics.flush {
796821
// verify completion is called.
797-
completionCalled = true
822+
expectation.fulfill()
798823
}
799824

800-
// completion shouldn't be called before flush returned.
801-
XCTAssertTrue(completionCalled)
825+
wait(for: [expectation], timeout: 1)
826+
802827
XCTAssertNil(analytics.pendingUploads)
803828

804829
// put another event in the pipe.
@@ -921,4 +946,74 @@ final class Analytics_Tests: XCTestCase {
921946
XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path))
922947
}
923948
#endif
949+
950+
func testAnonIDGenerator() throws {
951+
class MyAnonIdGenerator: AnonymousIdGenerator {
952+
var currentId: String = "blah-"
953+
func newAnonymousId() -> String {
954+
currentId = currentId + "1"
955+
return currentId
956+
}
957+
}
958+
959+
// need to clear settings for this one.
960+
UserDefaults.standard.removePersistentDomain(forName: "com.segment.storage.anonIdGenerator")
961+
962+
let anonIdGenerator = MyAnonIdGenerator()
963+
var analytics: Analytics? = Analytics(configuration: Configuration(writeKey: "anonIdGenerator").anonymousIdGenerator(anonIdGenerator))
964+
let outputReader = OutputReaderPlugin()
965+
analytics?.add(plugin: outputReader)
966+
967+
waitUntilStarted(analytics: analytics)
968+
XCTAssertEqual(analytics?.anonymousId, "blah-1")
969+
970+
analytics?.track(name: "Test1")
971+
XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-1")
972+
XCTAssertEqual(anonIdGenerator.currentId, "blah-1")
973+
XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId)
974+
975+
analytics?.track(name: "Test2")
976+
XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-1")
977+
XCTAssertEqual(anonIdGenerator.currentId, "blah-1")
978+
XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId)
979+
980+
analytics?.reset()
981+
982+
analytics?.track(name: "Test3")
983+
XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-11")
984+
XCTAssertEqual(anonIdGenerator.currentId, "blah-11")
985+
XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId)
986+
987+
analytics?.identify(userId: "Roger")
988+
XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-11")
989+
XCTAssertEqual(anonIdGenerator.currentId, "blah-11")
990+
XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId)
991+
992+
analytics?.reset()
993+
994+
analytics?.screen(title: "Screen")
995+
XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-111")
996+
XCTAssertEqual(anonIdGenerator.currentId, "blah-111")
997+
XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId)
998+
999+
// get rid of this instance, leave it time to go away ...
1000+
// ... also let any state updates happen as handlers get called async
1001+
RunLoop.main.run(until: .distantPast)
1002+
analytics = nil
1003+
// ... give it some time to release all it's stuff.
1004+
RunLoop.main.run(until: .distantPast)
1005+
1006+
// make sure it makes it to the next instance
1007+
analytics = Analytics(configuration: Configuration(writeKey: "anonIdGenerator").anonymousIdGenerator(anonIdGenerator))
1008+
analytics?.add(plugin: outputReader)
1009+
1010+
waitUntilStarted(analytics: analytics)
1011+
1012+
// same anonId as last time, yes?
1013+
analytics?.screen(title: "Screen")
1014+
XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-111")
1015+
XCTAssertEqual(anonIdGenerator.currentId, "blah-111")
1016+
XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId)
1017+
1018+
}
9241019
}

0 commit comments

Comments
 (0)