Skip to content

Commit

Permalink
Merge pull request #184 from launchdarkly/gw/sc-144845/encodable-events
Browse files Browse the repository at this point in the history
  • Loading branch information
gwhelanLD authored Mar 21, 2022
2 parents b40e3d2 + f6ebd86 commit 6e67675
Show file tree
Hide file tree
Showing 12 changed files with 593 additions and 936 deletions.
4 changes: 0 additions & 4 deletions LaunchDarkly/LaunchDarkly/Extensions/Dictionary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,6 @@ extension Dictionary where Key == String {
}
return differingKeys.union(matchingKeysWithDifferentValues).sorted()
}

var base64UrlEncodedString: String? {
jsonData?.base64UrlEncodedString
}
}

extension Dictionary where Key == String, Value == Any {
Expand Down
72 changes: 34 additions & 38 deletions LaunchDarkly/LaunchDarkly/Models/Event.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ func userType(_ user: LDUser) -> String {
return user.isAnonymous ? "anonymousUser" : "user"
}

struct Event {
struct Event: Encodable {
enum CodingKeys: String, CodingKey {
case key, previousKey, kind, creationDate, user, userKey,
value, defaultValue = "default", variation, version,
data, endDate, reason, metricValue,
data, startDate, endDate, features, reason, metricValue,
// for aliasing
contextKind, previousContextKind
}
Expand Down Expand Up @@ -114,52 +114,48 @@ struct Event {
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)
struct UserInfoKeys {
static let inlineUserInEvents = CodingUserInfoKey(rawValue: "LD_inlineUserInEvents")!
}

func encode(to encoder: Encoder) throws {
let inlineUserInEvents = encoder.userInfo[UserInfoKeys.inlineUserInEvents] as? Bool ?? false

var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(kind.rawValue, forKey: .kind)
try container.encodeIfPresent(key, forKey: .key)
try container.encodeIfPresent(previousKey, forKey: .previousKey)
try container.encodeIfPresent(creationDate, forKey: .creationDate)
if kind.isAlwaysInlineUserKind || inlineUserInEvents {
try container.encodeIfPresent(user, forKey: .user)
} else {
eventDictionary[CodingKeys.userKey.rawValue] = user?.key
try container.encodeIfPresent(user?.key, forKey: .userKey)
}
if kind.isAlwaysIncludeValueKinds {
eventDictionary[CodingKeys.value.rawValue] = value.toAny() ?? NSNull()
eventDictionary[CodingKeys.defaultValue.rawValue] = defaultValue.toAny() ?? NSNull()
try container.encode(value, forKey: .value)
try container.encode(defaultValue, forKey: .defaultValue)
}
try container.encodeIfPresent(featureFlag?.variation, forKey: .variation)
try container.encodeIfPresent(featureFlag?.versionForEvents, forKey: .version)
if data != .null {
try container.encode(data, forKey: .data)
}
eventDictionary[CodingKeys.variation.rawValue] = featureFlag?.variation
// If the flagVersion exists, it is reported as the "version". If not, the version is reported using the "version" key.
eventDictionary[CodingKeys.version.rawValue] = featureFlag?.flagVersion ?? featureFlag?.version
eventDictionary[CodingKeys.data.rawValue] = data.toAny()
if let flagRequestTracker = flagRequestTracker {
eventDictionary.merge(flagRequestTracker.dictionaryValue) { _, trackerItem in
trackerItem // This should never happen because the eventDictionary does not use any conflicting keys with the flagRequestTracker
}
try container.encode(flagRequestTracker.startDate, forKey: .startDate)
try container.encode(flagRequestTracker.flagCounters, forKey: .features)
}
eventDictionary[CodingKeys.endDate.rawValue] = endDate?.millisSince1970
eventDictionary[CodingKeys.reason.rawValue] = includeReason || featureFlag?.trackReason ?? false ? featureFlag?.reason : nil
eventDictionary[CodingKeys.metricValue.rawValue] = metricValue

try container.encodeIfPresent(endDate, forKey: .endDate)
if let reason = includeReason || featureFlag?.trackReason ?? false ? featureFlag?.reason : nil {
try container.encode(LDValue.fromAny(reason), forKey: .reason)
}
try container.encodeIfPresent(metricValue, forKey: .metricValue)
if kind.needsContextKind && (user?.isAnonymous == true) {
eventDictionary[CodingKeys.contextKind.rawValue] = "anonymousUser"
try container.encode("anonymousUser", forKey: .contextKind)
}

if kind == .alias {
eventDictionary[CodingKeys.contextKind.rawValue] = self.contextKind
eventDictionary[CodingKeys.previousContextKind.rawValue] = self.previousContextKind
try container.encodeIfPresent(self.contextKind, forKey: .contextKind)
try container.encodeIfPresent(self.previousContextKind, forKey: .previousContextKind)
}

return eventDictionary
}
}

extension Array where Element == [String: Any] {
var jsonData: Data? {
guard JSONSerialization.isValidJSONObject(self)
else { return nil }
return try? JSONSerialization.data(withJSONObject: self, options: [])
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import Foundation

struct FlagRequestTracker {
enum CodingKeys: String, CodingKey {
case startDate, features
}

let startDate = Date()
var flagCounters: [LDFlagKey: FlagCounter] = [:]

Expand All @@ -23,23 +19,22 @@ struct FlagRequestTracker {
+ "\n\tdefaultValue: \(defaultValue)\n")
}

var dictionaryValue: [String: Any] {
[CodingKeys.startDate.rawValue: startDate.millisSince1970,
CodingKeys.features.rawValue: flagCounters.mapValues { $0.dictionaryValue }]
}

var hasLoggedRequests: Bool { !flagCounters.isEmpty }
}

extension FlagRequestTracker: TypeIdentifying { }

final class FlagCounter {
final class FlagCounter: Encodable {
enum CodingKeys: String, CodingKey {
case defaultValue = "default", counters, value, variation, version, unknown, count
case defaultValue = "default", counters
}

enum CounterCodingKeys: String, CodingKey {
case value, variation, version, unknown, count
}

var defaultValue: LDValue = .null
var flagValueCounters: [CounterKey: CounterValue] = [:]
private(set) var defaultValue: LDValue = .null
private(set) var flagValueCounters: [CounterKey: CounterValue] = [:]

func trackRequest(reportedValue: LDValue, featureFlag: FeatureFlag?, defaultValue: LDValue) {
self.defaultValue = defaultValue
Expand All @@ -51,20 +46,20 @@ final class FlagCounter {
}
}

var dictionaryValue: [String: Any] {
let counters: [[String: Any]] = flagValueCounters.map { (key, value) in
var res: [String: Any] = [CodingKeys.value.rawValue: value.value.toAny() ?? NSNull(),
CodingKeys.count.rawValue: value.count,
CodingKeys.variation.rawValue: key.variation ?? NSNull()]
if let version = key.version {
res[CodingKeys.version.rawValue] = version
} else {
res[CodingKeys.unknown.rawValue] = true
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(defaultValue, forKey: .defaultValue)
var countersContainer = container.nestedUnkeyedContainer(forKey: .counters)
try flagValueCounters.forEach { (key, value) in
var counterContainer = countersContainer.nestedContainer(keyedBy: CounterCodingKeys.self)
try counterContainer.encodeIfPresent(key.version, forKey: .version)
try counterContainer.encodeIfPresent(key.variation, forKey: .variation)
try counterContainer.encode(value.count, forKey: .count)
try counterContainer.encode(value.value, forKey: .value)
if key.version == nil {
try counterContainer.encode(true, forKey: .unknown)
}
return res
}
return [CodingKeys.defaultValue.rawValue: defaultValue.toAny() ?? NSNull(),
CodingKeys.counters.rawValue: counters]
}
}

Expand All @@ -75,7 +70,7 @@ struct CounterKey: Equatable, Hashable {

class CounterValue {
let value: LDValue
var count: Int = 1
private(set) var count: Int = 1

init(value: LDValue) {
self.value = value
Expand Down
9 changes: 2 additions & 7 deletions LaunchDarkly/LaunchDarkly/Networking/DarklyService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ protocol DarklyServiceProvider: AnyObject {
func getFeatureFlags(useReport: Bool, completion: ServiceCompletionHandler?)
func clearFlagResponseCache()
func createEventSource(useReport: Bool, handler: EventHandler, errorHandler: ConnectionErrorHandler?) -> DarklyStreamingProvider
func publishEventDictionaries(_ eventDictionaries: [[String: Any]], _ payloadId: String, completion: ServiceCompletionHandler?)
func publishEventData(_ eventData: Data, _ payloadId: String, completion: ServiceCompletionHandler?)
func publishDiagnostic<T: DiagnosticEvent & Encodable>(diagnosticEvent: T, completion: ServiceCompletionHandler?)
}

Expand Down Expand Up @@ -173,13 +173,8 @@ final class DarklyService: DarklyServiceProvider {

// MARK: Publish Events

func publishEventDictionaries(_ eventDictionaries: [[String: Any]], _ payloadId: String, completion: ServiceCompletionHandler?) {
func publishEventData(_ eventData: Data, _ payloadId: String, completion: ServiceCompletionHandler?) {
guard hasMobileKey(#function) else { return }
guard !eventDictionaries.isEmpty, let eventData = eventDictionaries.jsonData
else {
return Log.debug(typeName(and: #function, appending: ": ") + "Aborting. No event dictionary.")
}

let url = config.eventsUrl.appendingPathComponent(EventRequestPath.bulk)
let headers = [HTTPHeaders.HeaderKey.eventPayloadIDHeader: payloadId].merging(httpHeaders.eventRequestHeaders) { $1 }
doPublish(url: url, headers: headers, body: eventData, completion: completion)
Expand Down
29 changes: 22 additions & 7 deletions LaunchDarkly/LaunchDarkly/ServiceObjects/EventReporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,29 @@ class EventReporter: EventReporting {
}

private func publish(_ events: [Event], _ payloadId: String, _ completion: CompletionClosure?) {
let eventDictionaries = events.map { $0.dictionaryValue(config: service.config) }
self.service.publishEventDictionaries(eventDictionaries, payloadId) { _, urlResponse, error in
let shouldRetry = self.processEventResponse(sentEvents: eventDictionaries, response: urlResponse as? HTTPURLResponse, error: error, isRetry: false)
let encodingConfig: [CodingUserInfoKey: Any] =
[Event.UserInfoKeys.inlineUserInEvents: service.config.inlineUserInEvents,
LDUser.UserInfoKeys.allAttributesPrivate: service.config.allUserAttributesPrivate,
LDUser.UserInfoKeys.globalPrivateAttributes: service.config.privateUserAttributes.map { $0.name }]
let encoder = JSONEncoder()
encoder.userInfo = encodingConfig
encoder.dateEncodingStrategy = .custom { date, encoder in
var container = encoder.singleValueContainer()
try container.encode(date.millisSince1970)
}
guard let eventData = try? encoder.encode(events)
else {
Log.debug(self.typeName(and: #function) + "Failed to serialize event(s) for publication: \(events)")
completion?()
return
}
self.service.publishEventData(eventData, payloadId) { _, urlResponse, error in
let shouldRetry = self.processEventResponse(sentEvents: events.count, response: urlResponse as? HTTPURLResponse, error: error, isRetry: false)
if shouldRetry {
Log.debug("Retrying event post after delay.")
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1.0) {
self.service.publishEventDictionaries(eventDictionaries, payloadId) { _, urlResponse, error in
_ = self.processEventResponse(sentEvents: eventDictionaries, response: urlResponse as? HTTPURLResponse, error: error, isRetry: true)
self.service.publishEventData(eventData, payloadId) { _, urlResponse, error in
_ = self.processEventResponse(sentEvents: events.count, response: urlResponse as? HTTPURLResponse, error: error, isRetry: true)
completion?()
}
}
Expand All @@ -155,10 +170,10 @@ class EventReporter: EventReporting {
}
}

private func processEventResponse(sentEvents: [[String: Any]], response: HTTPURLResponse?, error: Error?, isRetry: Bool) -> Bool {
private func processEventResponse(sentEvents: Int, response: HTTPURLResponse?, error: Error?, isRetry: Bool) -> Bool {
if error == nil && (200..<300).contains(response?.statusCode ?? 0) {
self.lastEventResponseDate = response?.headerDate ?? self.lastEventResponseDate
Log.debug(self.typeName(and: #function) + "Completed sending \(sentEvents.count) event(s)")
Log.debug(self.typeName(and: #function) + "Completed sending \(sentEvents) event(s)")
self.reportSyncComplete(nil)
return false
}
Expand Down
52 changes: 5 additions & 47 deletions LaunchDarkly/LaunchDarklyTests/Mocks/DarklyServiceMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,6 @@ final class DarklyServiceMock: DarklyServiceProvider {
static let dictionary: [String: Any] = ["sub-flag-a": false, "sub-flag-b": 3, "sub-flag-c": 2.71828]
static let null = NSNull()

static var knownFlags: [Any] {
[bool, int, double, string, array, dictionary, null]
}

static func value(from flagKey: LDFlagKey) -> Any? {
switch flagKey {
case FlagKeys.bool: return FlagValues.bool
Expand Down Expand Up @@ -73,13 +69,6 @@ final class DarklyServiceMock: DarklyServiceProvider {
}

struct Constants {
static var streamData: Data {
let featureFlags = stubFeatureFlags(includeNullValue: false)
let featureFlagDictionaries = featureFlags.dictionaryValue
let eventStreamString = "event: put\ndata:\(featureFlagDictionaries.jsonString!)"

return eventStreamString.data(using: .utf8)!
}
static let error = NSError(domain: NSURLErrorDomain, code: Int(CFNetworkErrors.cfurlErrorResourceUnavailable.rawValue), userInfo: nil)
static let jsonErrorString = "Bad json data"
static let errorData = jsonErrorString.data(using: .utf8)!
Expand Down Expand Up @@ -230,17 +219,11 @@ final class DarklyServiceMock: DarklyServiceProvider {
}

var stubbedEventResponse: ServiceResponse?
var publishEventDictionariesCallCount = 0
var publishedEventDictionaries: [[String: Any]]?
var publishedEventDictionaryKeys: [String]? {
publishedEventDictionaries?.compactMap { $0.eventKey }
}
var publishedEventDictionaryKinds: [Event.Kind]? {
publishedEventDictionaries?.compactMap { $0.eventKind }
}
func publishEventDictionaries(_ eventDictionaries: [[String: Any]], _ payloadId: String, completion: ServiceCompletionHandler?) {
publishEventDictionariesCallCount += 1
publishedEventDictionaries = eventDictionaries
var publishEventDataCallCount = 0
var publishedEventData: Data?
func publishEventData(_ eventData: Data, _ payloadId: String, completion: ServiceCompletionHandler?) {
publishEventDataCallCount += 1
publishedEventData = eventData
completion?(stubbedEventResponse ?? (nil, nil, nil))
}

Expand Down Expand Up @@ -322,31 +305,6 @@ extension DarklyServiceMock {
"\(Constants.stubNameFlag) using method \(useReport ? URLRequest.HTTPMethods.report : URLRequest.HTTPMethods.get) with response status code \(statusCode)"
}

// MARK: Stream

var streamHost: String? {
config.streamUrl.host
}
var getStreamRequestStubTest: HTTPStubsTestBlock {
isScheme(Constants.schemeHttps) && isHost(streamHost!) && isMethodGET()
}
var reportStreamRequestStubTest: HTTPStubsTestBlock {
isScheme(Constants.schemeHttps) && isHost(streamHost!) && isMethodREPORT()
}

/// Use when testing requires the mock service to actually make an event source connection request
func stubStreamRequest(useReport: Bool, success: Bool, onActivation activate: ((URLRequest, HTTPStubsDescriptor, HTTPStubsResponse) -> Void)? = nil) {
var stubResponse: HTTPStubsResponseBlock = { _ in
HTTPStubsResponse(error: Constants.error)
}
if success {
stubResponse = { _ in
HTTPStubsResponse(data: Constants.streamData, statusCode: Int32(HTTPURLResponse.StatusCodes.ok), headers: nil)
}
}
stubRequest(passingTest: useReport ? reportStreamRequestStubTest : getStreamRequestStubTest, stub: stubResponse, name: Constants.stubNameStream, onActivation: activate)
}

// MARK: Publish Event

var eventHost: String? {
Expand Down
Loading

0 comments on commit 6e67675

Please sign in to comment.