Skip to content

Commit

Permalink
Correlate anonymous users (#129)
Browse files Browse the repository at this point in the history
  • Loading branch information
apache-hb authored Feb 13, 2021
1 parent a93efe1 commit a08f2ce
Show file tree
Hide file tree
Showing 11 changed files with 218 additions and 22 deletions.
37 changes: 35 additions & 2 deletions LaunchDarkly/LaunchDarkly/LDClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
}
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions LaunchDarkly/LaunchDarkly/Models/DiagnosticEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ struct DiagnosticSdk: Encodable {
}

struct DiagnosticConfig: Codable {
let autoAliasingOptOut: Bool
let customBaseURI: Bool
let customEventsURI: Bool
let customStreamURI: Bool
Expand All @@ -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
Expand Down
63 changes: 52 additions & 11 deletions LaunchDarkly/LaunchDarkly/Models/Event.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
}
Expand Down
8 changes: 8 additions & 0 deletions LaunchDarkly/LaunchDarkly/Models/LDConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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?

Expand All @@ -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()
Expand Down Expand Up @@ -367,6 +374,7 @@ extension LDConfig: Equatable {
&& lhs.wrapperName == rhs.wrapperName
&& lhs.wrapperVersion == rhs.wrapperVersion
&& lhs.additionalHeaders == rhs.additionalHeaders
&& lhs.autoAliasingOptOut == rhs.autoAliasingOptOut
}
}

Expand Down
1 change: 1 addition & 0 deletions LaunchDarkly/LaunchDarkly/Models/User/LDUser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions LaunchDarkly/LaunchDarkly/ObjectiveC/ObjcLDClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 65 additions & 0 deletions LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit a08f2ce

Please sign in to comment.