Skip to content

Commit

Permalink
Add ability to control anonymousId values. (#327)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
bsneed authored Apr 17, 2024
1 parent 72415f4 commit d43c7a8
Show file tree
Hide file tree
Showing 7 changed files with 187 additions and 29 deletions.
2 changes: 1 addition & 1 deletion Sources/Segment/Analytics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public class Analytics {

// provide our default state
store.provide(state: System.defaultState(configuration: configuration, from: storage))
store.provide(state: UserInfo.defaultState(from: storage))
store.provide(state: UserInfo.defaultState(from: storage, anonIdGenerator: configuration.values.anonymousIdGenerator))

storage.analytics = self

Expand Down
19 changes: 19 additions & 0 deletions Sources/Segment/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ import JSONSafeEncoder
import FoundationNetworking
#endif

// MARK: - Custom AnonymousId generator
/// Conform to this protocol to generate your own AnonymousID
public protocol AnonymousIdGenerator: AnyObject, Codable {
/// Returns a new anonymousId. Segment still manages storage and retrieval of the
/// current anonymousId and will call this method when new id's are needed.
///
/// - Returns: A new anonymousId.
func newAnonymousId() -> String
}

// MARK: - Operating Mode
/// Specifies the operating mode/context
public enum OperatingMode {
Expand Down Expand Up @@ -56,6 +66,7 @@ public class Configuration {
var userAgent: String? = nil
var jsonNonConformingNumberStrategy: JSONSafeEncoder.NonConformingFloatEncodingStrategy = .zero
var storageMode: StorageMode = .disk
var anonymousIdGenerator: AnonymousIdGenerator = SegmentAnonymousId()
}

internal var values: Values
Expand Down Expand Up @@ -248,11 +259,19 @@ public extension Configuration {
return self
}

/// Specify the storage mode to use. The default is `.disk`.
@discardableResult
func storageMode(_ mode: StorageMode) -> Configuration {
values.storageMode = mode
return self
}

/// Specify a custom anonymousId generator. The default is and instance of `SegmentAnonymousId`.
@discardableResult
func anonymousIdGenerator(_ generator: AnonymousIdGenerator) -> Configuration {
values.anonymousIdGenerator = generator
return self
}
}

extension Analytics {
Expand Down
6 changes: 6 additions & 0 deletions Sources/Segment/Plugins/SegmentDestination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ import Sovran
import FoundationNetworking
#endif

public class SegmentAnonymousId: AnonymousIdGenerator {
public func newAnonymousId() -> String {
return UUID().uuidString
}
}

public class SegmentDestination: DestinationPlugin, Subscriber, FlushCompletion {
internal enum Constants: String {
case integrationName = "Segment.io"
Expand Down
34 changes: 18 additions & 16 deletions Sources/Segment/State.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,25 +111,33 @@ struct UserInfo: Codable, State {
let traits: JSON?
let referrer: URL?

@Noncodable var anonIdGenerator: AnonymousIdGenerator?

struct ResetAction: Action {
func reduce(state: UserInfo) -> UserInfo {
return UserInfo(anonymousId: UUID().uuidString, userId: nil, traits: nil, referrer: nil)
var anonId: String
if let id = state.anonIdGenerator?.newAnonymousId() {
anonId = id
} else {
anonId = UUID().uuidString
}
return UserInfo(anonymousId: anonId, userId: nil, traits: nil, referrer: nil, anonIdGenerator: state.anonIdGenerator)
}
}

struct SetUserIdAction: Action {
let userId: String

func reduce(state: UserInfo) -> UserInfo {
return UserInfo(anonymousId: state.anonymousId, userId: userId, traits: state.traits, referrer: state.referrer)
return UserInfo(anonymousId: state.anonymousId, userId: userId, traits: state.traits, referrer: state.referrer, anonIdGenerator: state.anonIdGenerator)
}
}

struct SetTraitsAction: Action {
let traits: JSON?

func reduce(state: UserInfo) -> UserInfo {
return UserInfo(anonymousId: state.anonymousId, userId: state.userId, traits: traits, referrer: state.referrer)
return UserInfo(anonymousId: state.anonymousId, userId: state.userId, traits: traits, referrer: state.referrer, anonIdGenerator: state.anonIdGenerator)
}
}

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

func reduce(state: UserInfo) -> UserInfo {
return UserInfo(anonymousId: state.anonymousId, userId: userId, traits: traits, referrer: state.referrer)
}
}

struct SetAnonymousIdAction: Action {
let anonymousId: String

func reduce(state: UserInfo) -> UserInfo {
return UserInfo(anonymousId: anonymousId, userId: state.userId, traits: state.traits, referrer: state.referrer)
return UserInfo(anonymousId: state.anonymousId, userId: userId, traits: traits, referrer: state.referrer, anonIdGenerator: state.anonIdGenerator)
}
}

struct SetReferrerAction: Action {
let url: URL

func reduce(state: UserInfo) -> UserInfo {
return UserInfo(anonymousId: state.anonymousId, userId: state.userId, traits: state.traits, referrer: url)
return UserInfo(anonymousId: state.anonymousId, userId: state.userId, traits: state.traits, referrer: url, anonIdGenerator: state.anonIdGenerator)
}
}
}
Expand All @@ -176,13 +176,15 @@ extension System {
}

extension UserInfo {
static func defaultState(from storage: Storage) -> UserInfo {
static func defaultState(from storage: Storage, anonIdGenerator: AnonymousIdGenerator) -> UserInfo {
let userId: String? = storage.read(.userId)
let traits: JSON? = storage.read(.traits)
var anonymousId: String = UUID().uuidString
var anonymousId: String
if let existingId: String = storage.read(.anonymousId) {
anonymousId = existingId
} else {
anonymousId = anonIdGenerator.newAnonymousId()
}
return UserInfo(anonymousId: anonymousId, userId: userId, traits: traits, referrer: nil)
return UserInfo(anonymousId: anonymousId, userId: userId, traits: traits, referrer: nil, anonIdGenerator: anonIdGenerator)
}
}
34 changes: 34 additions & 0 deletions Sources/Segment/Utilities/Noncodable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// Noncodable.swift
//
//
// Created by Brandon Sneed on 4/17/24.
//

import Foundation

@propertyWrapper
internal struct Noncodable<T>: Codable {
public var wrappedValue: T?
public init(wrappedValue: T?) {
self.wrappedValue = wrappedValue
}
public init(from decoder: Decoder) throws {
self.wrappedValue = nil
}
public func encode(to encoder: Encoder) throws {
// Do nothing
}
}

extension KeyedDecodingContainer {
internal func decode<T>(_ type: Noncodable<T>.Type, forKey key: Self.Key) throws -> Noncodable<T> {
return Noncodable(wrappedValue: nil)
}
}

extension KeyedEncodingContainer {
internal mutating func encode<T>(_ value: Noncodable<T>, forKey key: KeyedEncodingContainer<K>.Key) throws {
// Do nothing
}
}
2 changes: 2 additions & 0 deletions Sources/Segment/Utilities/Utils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,5 @@ internal func eventStorageDirectory(writeKey: String) -> URL {
try? FileManager.default.createDirectory(at: segmentURL, withIntermediateDirectories: true, attributes: nil)
return segmentURL
}


119 changes: 107 additions & 12 deletions Tests/Segment-Tests/Analytics_Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,11 @@ final class Analytics_Tests: XCTestCase {
let expectation = XCTestExpectation(description: "MyDestination Expectation")
let myDestination = MyDestination(disabled: true) {
expectation.fulfill()
print("called")
return true
}

let configuration = Configuration(writeKey: "test")
let configuration = Configuration(writeKey: "testDestNotEnabled")
let analytics = Analytics(configuration: configuration)

analytics.add(plugin: myDestination)
Expand Down Expand Up @@ -754,25 +755,36 @@ final class Analytics_Tests: XCTestCase {
.flushAt(9999)
.operatingMode(.asynchronous))

// set the httpclient to use our blocker session
let segment = analytics.find(pluginType: SegmentDestination.self)
let configuration = URLSessionConfiguration.ephemeral
configuration.allowsCellularAccess = true
configuration.timeoutIntervalForResource = 30
configuration.timeoutIntervalForRequest = 60
configuration.httpMaximumConnectionsPerHost = 2
configuration.protocolClasses = [BlockNetworkCalls.self]
configuration.httpAdditionalHeaders = ["Content-Type": "application/json; charset=utf-8",
"Authorization": "Basic test",
"User-Agent": "analytics-ios/\(Analytics.version())"]
let blockSession = URLSession(configuration: configuration, delegate: nil, delegateQueue: nil)
segment?.httpClient?.session = blockSession

waitUntilStarted(analytics: analytics)

analytics.storage.hardReset(doYouKnowHowToUseThis: true)

@Atomic var completionCalled = false
let expectation = XCTestExpectation()

// put an event in the pipe ...
analytics.track(name: "completion test1")
// flush it, that'll get us an upload going
analytics.flush {
// verify completion is called.
completionCalled = true
expectation.fulfill()
}

while !completionCalled {
RunLoop.main.run(until: Date.distantPast)
}
wait(for: [expectation], timeout: 5)

XCTAssertTrue(completionCalled)
XCTAssertNil(analytics.pendingUploads)
}

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

// set the httpclient to use our blocker session
let segment = analytics.find(pluginType: SegmentDestination.self)
let configuration = URLSessionConfiguration.ephemeral
configuration.allowsCellularAccess = true
configuration.timeoutIntervalForResource = 30
configuration.timeoutIntervalForRequest = 60
configuration.httpMaximumConnectionsPerHost = 2
configuration.protocolClasses = [BlockNetworkCalls.self]
configuration.httpAdditionalHeaders = ["Content-Type": "application/json; charset=utf-8",
"Authorization": "Basic test",
"User-Agent": "analytics-ios/\(Analytics.version())"]
let blockSession = URLSession(configuration: configuration, delegate: nil, delegateQueue: nil)
segment?.httpClient?.session = blockSession

waitUntilStarted(analytics: analytics)

analytics.storage.hardReset(doYouKnowHowToUseThis: true)

@Atomic var completionCalled = false

let expectation = XCTestExpectation()
// put an event in the pipe ...
analytics.track(name: "completion test1")
// flush it, that'll get us an upload going
analytics.flush {
// verify completion is called.
completionCalled = true
expectation.fulfill()
}

// completion shouldn't be called before flush returned.
XCTAssertTrue(completionCalled)
wait(for: [expectation], timeout: 1)

XCTAssertNil(analytics.pendingUploads)

// put another event in the pipe.
Expand Down Expand Up @@ -921,4 +946,74 @@ final class Analytics_Tests: XCTestCase {
XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path))
}
#endif

func testAnonIDGenerator() throws {
class MyAnonIdGenerator: AnonymousIdGenerator {
var currentId: String = "blah-"
func newAnonymousId() -> String {
currentId = currentId + "1"
return currentId
}
}

// need to clear settings for this one.
UserDefaults.standard.removePersistentDomain(forName: "com.segment.storage.anonIdGenerator")

let anonIdGenerator = MyAnonIdGenerator()
var analytics: Analytics? = Analytics(configuration: Configuration(writeKey: "anonIdGenerator").anonymousIdGenerator(anonIdGenerator))
let outputReader = OutputReaderPlugin()
analytics?.add(plugin: outputReader)

waitUntilStarted(analytics: analytics)
XCTAssertEqual(analytics?.anonymousId, "blah-1")

analytics?.track(name: "Test1")
XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-1")
XCTAssertEqual(anonIdGenerator.currentId, "blah-1")
XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId)

analytics?.track(name: "Test2")
XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-1")
XCTAssertEqual(anonIdGenerator.currentId, "blah-1")
XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId)

analytics?.reset()

analytics?.track(name: "Test3")
XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-11")
XCTAssertEqual(anonIdGenerator.currentId, "blah-11")
XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId)

analytics?.identify(userId: "Roger")
XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-11")
XCTAssertEqual(anonIdGenerator.currentId, "blah-11")
XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId)

analytics?.reset()

analytics?.screen(title: "Screen")
XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-111")
XCTAssertEqual(anonIdGenerator.currentId, "blah-111")
XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId)

// get rid of this instance, leave it time to go away ...
// ... also let any state updates happen as handlers get called async
RunLoop.main.run(until: .distantPast)
analytics = nil
// ... give it some time to release all it's stuff.
RunLoop.main.run(until: .distantPast)

// make sure it makes it to the next instance
analytics = Analytics(configuration: Configuration(writeKey: "anonIdGenerator").anonymousIdGenerator(anonIdGenerator))
analytics?.add(plugin: outputReader)

waitUntilStarted(analytics: analytics)

// same anonId as last time, yes?
analytics?.screen(title: "Screen")
XCTAssertEqual(outputReader.lastEvent?.anonymousId, "blah-111")
XCTAssertEqual(anonIdGenerator.currentId, "blah-111")
XCTAssertEqual(outputReader.lastEvent?.anonymousId, anonIdGenerator.currentId)

}
}

0 comments on commit d43c7a8

Please sign in to comment.