diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index 61e4a248..df17d936 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -281,6 +281,7 @@ public class LDClient { func internalIdentify(newUser: LDUser, completion: (() -> Void)? = nil) { internalIdentifyQueue.sync { + let previousUser = self.user self.user = newUser Log.debug(self.typeName(and: #function) + "new user set with key: " + self.user.key ) let wasOnline = self.isOnline @@ -297,13 +298,18 @@ public class LDClient { } } self.service = self.serviceFactory.makeDarklyServiceProvider(config: self.config, user: self.user) - self.service.clearFlagResponseCache() if self.hasStarted { self.eventReporter.record(Event.identifyEvent(user: self.user)) } self.internalSetOnline(wasOnline, completion: completion) + + if !config.autoAliasingOptOut && previousUser.isAnonymous && !newUser.isAnonymous { + self.internalAlias(context: newUser, previousContext: previousUser) + } + + self.service.clearFlagResponseCache() } } @@ -757,6 +763,33 @@ public class LDClient { eventReporter.record(event) } + /** + Tells the SDK to generate an alias event. + + Associates two users for analytics purposes. + + This can be helpful in the situation where a person is represented by multiple + LaunchDarkly users. This may happen, for example, when a person initially logs into + an application-- the person might be represented by an anonymous user prior to logging + in and a different user after logging in, as denoted by a different user key. + + - parameter context: the user that will be aliased to + - parameter previousContext: the user that will be bound to the new context + */ + public func alias(context new: LDUser, previousContext old: LDUser) { + guard hasStarted + else { + Log.debug(typeName(and: #function) + "aborted. LDClient not started") + return + } + + internalAlias(context: new, previousContext: old) + } + + private func internalAlias(context new: LDUser, previousContext old: LDUser) { + self.eventReporter.record(Event.aliasEvent(newUser: new, oldUser: old)) + } + /** Tells the SDK to immediately send any currently queued events to LaunchDarkly. @@ -903,7 +936,7 @@ public class LDClient { private var _hasStarted = true private var hasStartedQueue = DispatchQueue(label: "com.launchdarkly.LDClient.hasStartedQueue") - private init(serviceFactory: ClientServiceCreating? = nil, configuration: LDConfig, startUser: LDUser?, newCache: FeatureFlagCaching, flagNotifier: FlagChangeNotifying, testing: Bool = false, completion: (() -> Void)? = nil) { + private init(serviceFactory: ClientServiceCreating? = nil, configuration: LDConfig, startUser: LDUser?, newCache: FeatureFlagCaching, flagNotifier: FlagChangeNotifying, completion: (() -> Void)? = nil) { if let serviceFactory = serviceFactory { self.serviceFactory = serviceFactory } diff --git a/LaunchDarkly/LaunchDarkly/Models/DiagnosticEvent.swift b/LaunchDarkly/LaunchDarkly/Models/DiagnosticEvent.swift index 4f2e0b35..5023c7d0 100644 --- a/LaunchDarkly/LaunchDarkly/Models/DiagnosticEvent.swift +++ b/LaunchDarkly/LaunchDarkly/Models/DiagnosticEvent.swift @@ -98,6 +98,7 @@ struct DiagnosticSdk: Encodable { } struct DiagnosticConfig: Codable { + let autoAliasingOptOut: Bool let customBaseURI: Bool let customEventsURI: Bool let customStreamURI: Bool @@ -118,6 +119,7 @@ struct DiagnosticConfig: Codable { let customHeaders: Bool init(config: LDConfig) { + autoAliasingOptOut = config.autoAliasingOptOut customBaseURI = config.baseUrl != LDConfig.Defaults.baseUrl customEventsURI = config.eventsUrl != LDConfig.Defaults.eventsUrl customStreamURI = config.streamUrl != LDConfig.Defaults.streamUrl diff --git a/LaunchDarkly/LaunchDarkly/Models/Event.swift b/LaunchDarkly/LaunchDarkly/Models/Event.swift index 8de4722d..5c0d705c 100644 --- a/LaunchDarkly/LaunchDarkly/Models/Event.swift +++ b/LaunchDarkly/LaunchDarkly/Models/Event.swift @@ -7,33 +7,45 @@ import Foundation +func userType(_ user: LDUser) -> String { + return user.isAnonymous ? "anonymousUser" : "user" +} + struct Event { enum CodingKeys: String, CodingKey { - case key, kind, creationDate, user, userKey, value, defaultValue = "default", variation, version, data, endDate, reason, metricValue + case key, previousKey, kind, creationDate, user, userKey, + value, defaultValue = "default", variation, version, + data, endDate, reason, metricValue, + // for aliasing + contextKind, previousContextKind } enum Kind: String { - case feature, debug, identify, custom, summary + case feature, debug, identify, custom, summary, alias static var allKinds: [Kind] { - [feature, debug, identify, custom, summary] - } - static var alwaysInlineUserKinds: [Kind] { - [identify, debug] + [feature, debug, identify, custom, summary, alias] } + var isAlwaysInlineUserKind: Bool { - Kind.alwaysInlineUserKinds.contains(self) - } - static var alwaysIncludeValueKinds: [Kind] { - [feature, debug] + [.identify, .debug].contains(self) } + var isAlwaysIncludeValueKinds: Bool { - Kind.alwaysIncludeValueKinds.contains(self) + [.feature, .debug].contains(self) } + + var needsContextKind: Bool { + [.feature, .custom].contains(self) + } + + // true if the contextKind and previousContextKind fields are required + var needsContext: Bool { self == .alias } } let kind: Kind let key: String? + let previousKey: String? let creationDate: Date? let user: LDUser? let value: Any? @@ -44,9 +56,14 @@ struct Event { let endDate: Date? let includeReason: Bool let metricValue: Double? + let contextKind: String? + let previousContextKind: String? init(kind: Kind = .custom, key: String? = nil, + previousKey: String? = nil, + contextKind: String? = nil, + previousContextKind: String? = nil, user: LDUser? = nil, value: Any? = nil, defaultValue: Any? = nil, @@ -58,6 +75,7 @@ struct Event { metricValue: Double? = nil) { self.kind = kind self.key = key + self.previousKey = previousKey self.creationDate = kind == .summary ? nil : Date() self.user = user self.value = value @@ -68,6 +86,13 @@ struct Event { self.endDate = endDate self.includeReason = includeReason self.metricValue = metricValue + + if contextKind == nil { + self.contextKind = userType(user ?? LDUser(isAnonymous: true)) + } else { + self.contextKind = contextKind + } + self.previousContextKind = previousContextKind } // swiftlint:disable:next function_parameter_count @@ -107,10 +132,16 @@ struct Event { return Event(kind: .summary, flagRequestTracker: flagRequestTracker, endDate: endDate) } + static func aliasEvent(newUser new: LDUser, oldUser old: LDUser) -> Event { + Log.debug("\(typeName(and: #function)) key: \(new.key), previousKey: \(old.key)") + return Event(kind: .alias, key: new.key, previousKey: old.key, contextKind: userType(new), previousContextKind: userType(old)) + } + func dictionaryValue(config: LDConfig) -> [String: Any] { var eventDictionary = [String: Any]() eventDictionary[CodingKeys.kind.rawValue] = kind.rawValue eventDictionary[CodingKeys.key.rawValue] = key + eventDictionary[CodingKeys.previousKey.rawValue] = previousKey eventDictionary[CodingKeys.creationDate.rawValue] = creationDate?.millisSince1970 if kind.isAlwaysInlineUserKind || config.inlineUserInEvents { eventDictionary[CodingKeys.user.rawValue] = user?.dictionaryValue(includePrivateAttributes: false, config: config) @@ -134,6 +165,16 @@ struct Event { eventDictionary[CodingKeys.reason.rawValue] = includeReason || featureFlag?.trackReason ?? false ? featureFlag?.reason : nil eventDictionary[CodingKeys.metricValue.rawValue] = metricValue + if kind.needsContextKind { + eventDictionary[CodingKeys.contextKind.rawValue] = self.contextKind + } + + // alias events override the contextKind field set earlier + if kind.needsContext { + eventDictionary[CodingKeys.contextKind.rawValue] = self.contextKind + eventDictionary[CodingKeys.previousContextKind.rawValue] = self.previousContextKind + } + return eventDictionary } } diff --git a/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift b/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift index 3b748082..d86bf94c 100644 --- a/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift +++ b/LaunchDarkly/LaunchDarkly/Models/LDConfig.swift @@ -105,6 +105,9 @@ public struct LDConfig { /// a closure to allow dynamic changes of headers on connect & reconnect static let headerDelegate: RequestHeaderTransform? = nil + + /// should anonymous users automatically be aliased when identifying + static let autoAliasingOptOut: Bool = false } /// Constants relevant to setting up an `LDConfig` @@ -254,6 +257,7 @@ public struct LDConfig { /// Additional headers that should be added to all HTTP requests from SDK components to LaunchDarkly services public var additionalHeaders: [String: String] = [:] + /* TODO: find a way to make delegates equatable */ /// a closure to allow dynamic changes of headers on connect & reconnect public var headerDelegate: RequestHeaderTransform? @@ -265,6 +269,9 @@ public struct LDConfig { let environmentReporter: EnvironmentReporting + /// should anonymous users automatically be aliased when identifying + public var autoAliasingOptOut: Bool = Defaults.autoAliasingOptOut + /// A Dictionary of identifying names to unique mobile keys for all environments private var mobileKeys: [String: String] { var internalMobileKeys = getSecondaryMobileKeys() @@ -367,6 +374,7 @@ extension LDConfig: Equatable { && lhs.wrapperName == rhs.wrapperName && lhs.wrapperVersion == rhs.wrapperVersion && lhs.additionalHeaders == rhs.additionalHeaders + && lhs.autoAliasingOptOut == rhs.autoAliasingOptOut } } diff --git a/LaunchDarkly/LaunchDarkly/Models/User/LDUser.swift b/LaunchDarkly/LaunchDarkly/Models/User/LDUser.swift index b4a1a236..b525cc13 100644 --- a/LaunchDarkly/LaunchDarkly/Models/User/LDUser.swift +++ b/LaunchDarkly/LaunchDarkly/Models/User/LDUser.swift @@ -62,6 +62,7 @@ public struct LDUser { public var device: String? ///Client app defined operatingSystem for the user. The SDK will determine the operatingSystem automatically, however the client app can override the value. The SDK will insert the operatingSystem into the `custom` dictionary. The operatingSystem cannot be made private. (Default: the system identified operating system) public var operatingSystem: String? + /** Client app defined privateAttributes for the user. The SDK will not include private attribute values in analytics events, but private attribute names will be sent. diff --git a/LaunchDarkly/LaunchDarkly/ObjectiveC/ObjcLDClient.swift b/LaunchDarkly/LaunchDarkly/ObjectiveC/ObjcLDClient.swift index 69acc251..fa00476e 100644 --- a/LaunchDarkly/LaunchDarkly/ObjectiveC/ObjcLDClient.swift +++ b/LaunchDarkly/LaunchDarkly/ObjectiveC/ObjcLDClient.swift @@ -794,6 +794,23 @@ public final class ObjcLDClient: NSObject { ldClient.flush() } + /** + Tells the SDK to generate an alias event. + + Associates two users for analytics purposes. + + This can be helpful in the situation where a person is represented by multiple + LaunchDarkly users. This may happen, for example, when a person initially logs into + an application-- the person might be represented by an anonymous user prior to logging + in and a different user after logging in, as denoted by a different user key. + + - parameter context: the user that will be aliased to + - parameter previousContext: the user that will be bound to the new context + */ + @objc public func alias(context: ObjcLDUser, previousContext: ObjcLDUser) { + ldClient.alias(context: context.user, previousContext: previousContext.user) + } + /** Starts the LDClient using the passed in `config` & `user`. Call this before requesting feature flag values. The LDClient will not go online until you call this method. Starting the LDClient means setting the `config` & `user`, setting the client online if `config.startOnline` is true (the default setting), and starting event recording. The client app must start the LDClient before it will report feature flag values. If a client does not call `start`, no methods will work. diff --git a/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift index f194d81d..f621ad4a 100644 --- a/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift @@ -109,6 +109,7 @@ final class LDClientSpec: QuickSpec { enableBackgroundUpdates: Bool = true, runMode: LDClientRunMode = .foreground, operatingSystem: OperatingSystem? = nil, + autoAliasingOptOut: Bool = true, completion: (() -> Void)? = nil) { let clientServiceFactory = ClientServiceMockFactory() @@ -121,6 +122,7 @@ final class LDClientSpec: QuickSpec { config.streamingMode = streamingMode config.enableBackgroundUpdates = enableBackgroundUpdates config.eventFlushInterval = 300.0 //5 min...don't want this to trigger + config.autoAliasingOptOut = autoAliasingOptOut user = newUser ?? LDUser.stub() let stubFlags = FlagMaintainingMock(flags: FlagMaintainingMock.stubFlags(includeNullValue: true, includeVersions: true)) clientServiceFactory.makeFlagStoreReturnValue = stubFlags @@ -144,6 +146,7 @@ final class LDClientSpec: QuickSpec { operatingSystem: OperatingSystem? = nil, timeOut: TimeInterval, forceTimeout: Bool = false, + autoAliasingOptOut: Bool = true, timeOutCompletion: ((_ timedOut: Bool) -> Void)? = nil) { let clientServiceFactory = ClientServiceMockFactory() @@ -156,6 +159,7 @@ final class LDClientSpec: QuickSpec { config.streamingMode = streamingMode config.enableBackgroundUpdates = enableBackgroundUpdates config.eventFlushInterval = 300.0 //5 min...don't want this to trigger + config.autoAliasingOptOut = autoAliasingOptOut user = newUser ?? LDUser.stub() let stubFlags = FlagMaintainingMock(flags: FlagMaintainingMock.stubFlags(includeNullValue: true, includeVersions: true)) clientServiceFactory.makeFlagStoreReturnValue = stubFlags @@ -214,6 +218,67 @@ final class LDClientSpec: QuickSpec { allFlagValuesSpec() connectionInformationSpec() variationDetailSpec() + aliasingSpec() + } + + private func aliasingSpec() { + describe("aliasing") { + var ctx: TestContext! + + context("automatic aliasing from anonymous to user") { + beforeEach { + waitUntil { done in + ctx = TestContext(newUser: LDUser(isAnonymous: true), autoAliasingOptOut: false, completion: done) + } + let notAnonymous = LDUser(key: "something", isAnonymous: false) + waitUntil { done in + ctx.subject.internalIdentify(newUser: notAnonymous, completion: done) + } + } + + it("records an alias and identify event") { + // init, identify, and alias event + expect(ctx.eventReporterMock.recordCallCount) == 3 + expect(ctx.recordedEvent?.kind) == .alias + } + } + + context("automatic aliasing from user to user") { + beforeEach { + waitUntil { done in + ctx = TestContext(newUser: LDUser(isAnonymous: false), completion: done) + } + let notAnonymous = LDUser(key: "something", isAnonymous: false) + waitUntil { done in + ctx.subject.internalIdentify(newUser: notAnonymous, completion: done) + } + } + + it("doesnt record an alias event") { + // init and identify event + expect(ctx.eventReporterMock.recordCallCount) == 2 + expect(ctx.recordedEvent?.kind) == .identify + } + } + + context("automatic aliasing from anonymous to anonymous") { + beforeEach { + waitUntil { done in + ctx = TestContext(newUser: LDUser(isAnonymous: false), completion: done) + } + let notAnonymous = LDUser(key: "something", isAnonymous: false) + waitUntil { done in + ctx.subject.internalIdentify(newUser: notAnonymous, completion: done) + } + } + + it("doesnt record an alias event") { + // init and identify event + expect(ctx.eventReporterMock.recordCallCount) == 2 + expect(ctx.recordedEvent?.kind) == .identify + } + } + } } private func startSpec() { diff --git a/LaunchDarkly/LaunchDarklyTests/Models/DiagnosticEventSpec.swift b/LaunchDarkly/LaunchDarklyTests/Models/DiagnosticEventSpec.swift index 0697e148..2023bab8 100644 --- a/LaunchDarkly/LaunchDarklyTests/Models/DiagnosticEventSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Models/DiagnosticEventSpec.swift @@ -275,7 +275,8 @@ final class DiagnosticEventSpec: QuickSpec { context("using \(desc) encoding") { it("encodes correct values to keys") { let decoded = self.loadAndRestoreRaw(scheme, diagnosticConfig) - expect(decoded.count) == 18 + expect(decoded.count) == 19 + expect((decoded["autoAliasingOptOut"] as! Bool)) == diagnosticConfig.autoAliasingOptOut expect((decoded["customBaseURI"] as! Bool)) == diagnosticConfig.customBaseURI expect((decoded["customEventsURI"] as! Bool)) == diagnosticConfig.customEventsURI expect((decoded["customStreamURI"] as! Bool)) == diagnosticConfig.customStreamURI diff --git a/LaunchDarkly/LaunchDarklyTests/Models/EventSpec.swift b/LaunchDarkly/LaunchDarklyTests/Models/EventSpec.swift index 00e83cf2..9ae69de1 100644 --- a/LaunchDarkly/LaunchDarklyTests/Models/EventSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Models/EventSpec.swift @@ -35,7 +35,7 @@ final class EventSpec: QuickSpec { override func spec() { initSpec() - kindSpec() + aliasSpec() featureEventSpec() debugEventSpec() customEventSpec() @@ -94,11 +94,32 @@ final class EventSpec: QuickSpec { } } - private func kindSpec() { - describe("isAlwaysInlineUserKind") { - it("returns true when event kind should inline user") { - for kind in Event.Kind.allKinds { - expect(kind.isAlwaysInlineUserKind) == Event.Kind.alwaysInlineUserKinds.contains(kind) + private func aliasSpec() { + describe("alias events") { + var event: Event! + context("aliasing users") { + it("has correct fields") { + event = Event.aliasEvent(newUser: LDUser(), oldUser: LDUser()) + + expect(event.kind) == Event.Kind.alias + } + + it("from user to user") { + event = Event.aliasEvent(newUser: LDUser(key: "new"), oldUser: LDUser(key: "old")) + + expect(event.key) == "new" + expect(event.previousKey) == "old" + expect(event.contextKind) == "user" + expect(event.previousContextKind) == "user" + } + + it("from anon to anon") { + event = Event.aliasEvent(newUser: LDUser(key: "new", isAnonymous: true), oldUser: LDUser(key: "old", isAnonymous: true)) + + expect(event.key) == "new" + expect(event.previousKey) == "old" + expect(event.contextKind) == "anonymousUser" + expect(event.previousContextKind) == "anonymousUser" } } } @@ -1067,6 +1088,7 @@ extension Event { case .identify: return Event.identifyEvent(user: user) case .custom: return (try? Event.customEvent(key: UUID().uuidString, user: user, data: ["custom": UUID().uuidString]))! case .summary: return Event.summaryEvent(flagRequestTracker: FlagRequestTracker.stub())! + case .alias: return Event.aliasEvent(newUser: LDUser(), oldUser: LDUser()) } } diff --git a/LaunchDarkly/LaunchDarklyTests/Models/LDConfigSpec.swift b/LaunchDarkly/LaunchDarklyTests/Models/LDConfigSpec.swift index 8543d59c..c6d2bd16 100644 --- a/LaunchDarkly/LaunchDarklyTests/Models/LDConfigSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Models/LDConfigSpec.swift @@ -38,6 +38,7 @@ final class LDConfigSpec: XCTestCase { fileprivate static let wrapperName = "ReactNative" fileprivate static let wrapperVersion = "0.1.0" fileprivate static let additionalHeaders = ["Proxy-Authorization": "creds"] + fileprivate static let autoAliasingOptOut = true } let testFields: [(String, Any, (inout LDConfig, Any?) -> Void)] = @@ -64,7 +65,8 @@ final class LDConfigSpec: XCTestCase { ("diagnostic recording interval", Constants.diagnosticRecordingInterval, { c, v in c.diagnosticRecordingInterval = v as! TimeInterval }), ("wrapper name", Constants.wrapperName, { c, v in c.wrapperName = v as! String? }), ("wrapper version", Constants.wrapperVersion, { c, v in c.wrapperVersion = v as! String? }), - ("additional headers", Constants.additionalHeaders, { c, v in c.additionalHeaders = v as! [String: String]})] + ("additional headers", Constants.additionalHeaders, { c, v in c.additionalHeaders = v as! [String: String]}), + ("auto aliasing opt out", Constants.autoAliasingOptOut, { c, v in c.autoAliasingOptOut = v as! Bool })] func testInitDefault() { let config = LDConfig(mobileKey: LDConfig.Constants.mockMobileKey) @@ -92,6 +94,7 @@ final class LDConfigSpec: XCTestCase { XCTAssertEqual(config.wrapperName, LDConfig.Defaults.wrapperName) XCTAssertEqual(config.wrapperVersion, LDConfig.Defaults.wrapperVersion) XCTAssertEqual(config.additionalHeaders, LDConfig.Defaults.additionalHeaders) + XCTAssertEqual(config.autoAliasingOptOut, LDConfig.Defaults.autoAliasingOptOut) } func testInitUpdate() { @@ -126,6 +129,7 @@ final class LDConfigSpec: XCTestCase { XCTAssertEqual(config.wrapperName, Constants.wrapperName, "\(os)") XCTAssertEqual(config.wrapperVersion, Constants.wrapperVersion, "\(os)") XCTAssertEqual(config.additionalHeaders, Constants.additionalHeaders, "\(os)") + XCTAssertEqual(config.autoAliasingOptOut, Constants.autoAliasingOptOut, "\(os)") } } @@ -200,10 +204,11 @@ final class LDConfigSpec: XCTestCase { symmetricAssertEqual(defaultConfig, LDConfig(mobileKey: LDConfig.Constants.mockMobileKey)) // different mobile key symmetricAssertNotEqual(defaultConfig, LDConfig(mobileKey: LDConfig.Constants.alternateMobileKey)) + testFields.forEach { name, otherVal, setter in var otherConfig = LDConfig(mobileKey: LDConfig.Constants.mockMobileKey, environmentReporter: environmentReporter) setter(&otherConfig, otherVal) - symmetricAssertNotEqual(defaultConfig, otherConfig, "\(name) differs") + symmetricAssertNotEqual(defaultConfig, otherConfig, "\(name) is the same") } } diff --git a/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift b/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift index 4374774d..12502128 100644 --- a/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift @@ -773,6 +773,7 @@ final class DarklyServiceSpec: QuickSpec { describe("clearFlagResponseCache") { context("cached responses and etags exist") { beforeEach { + URLCache.shared.diskCapacity = 0 testContext = TestContext(mobileKeyCount: Constants.mobileKeyCount) HTTPHeaders.loadFlagRequestEtags(testContext.flagRequestEtags) flagRequestEtag = UUID().uuidString