Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to control anonymousId values. #327

Merged
merged 8 commits into from
Apr 17, 2024
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
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)

}
}
Loading