From 2d2d192385dc439c5876a360f355b757e3fb5e60 Mon Sep 17 00:00:00 2001 From: Marvin Liu Date: Wed, 23 Nov 2022 22:27:45 -0800 Subject: [PATCH 1/7] feat: add persistent storage for events and key values --- .swiftlint.yml | 2 + .../xcschemes/Amplitude-Swift.xcscheme | 91 +++++++ Sources/Amplitude/Amplitude.swift | 2 +- Sources/Amplitude/Configuration.swift | 4 +- Sources/Amplitude/Constants.swift | 3 +- Sources/Amplitude/Events/BaseEvent.swift | 147 ++++++++++- .../Amplitude/Plugins/Vendors/AppUtil.swift | 33 +-- .../Amplitude/Storages/InMemoryStorage.swift | 14 +- .../Storages/PersistentStorage.swift | 237 +++++++++++++++++- Sources/Amplitude/Types.swift | 9 +- .../Utilities/CodableExtension.swift | 187 ++++++++++++++ .../Utilities/OutputFileStream.swift | 81 ++++++ Tests/AmplitudeTests/AmplitudeTests.swift | 7 +- .../Events/BaseEventTests.swift | 43 ++++ .../Storages/PersistentStorageTests.swift | 46 ++++ .../{Timeline.swift => TimelineTests.swift} | 2 +- 16 files changed, 870 insertions(+), 38 deletions(-) create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/Amplitude-Swift.xcscheme create mode 100644 Sources/Amplitude/Utilities/CodableExtension.swift create mode 100644 Sources/Amplitude/Utilities/OutputFileStream.swift create mode 100644 Tests/AmplitudeTests/Events/BaseEventTests.swift create mode 100644 Tests/AmplitudeTests/Storages/PersistentStorageTests.swift rename Tests/AmplitudeTests/{Timeline.swift => TimelineTests.swift} (97%) diff --git a/.swiftlint.yml b/.swiftlint.yml index 527f7a2d..4b2234f9 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,6 +1,8 @@ disabled_rules: - function_body_length + - type_body_length - trailing_comma + - opening_brace - todo identifier_name: allowed_symbols: "_" diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Amplitude-Swift.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Amplitude-Swift.xcscheme new file mode 100644 index 00000000..6f1b50ee --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Amplitude-Swift.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sources/Amplitude/Amplitude.swift b/Sources/Amplitude/Amplitude.swift index 0f7c6693..91dcbb98 100644 --- a/Sources/Amplitude/Amplitude.swift +++ b/Sources/Amplitude/Amplitude.swift @@ -5,7 +5,7 @@ public class Amplitude { var instanceName: String internal var inForeground = false - lazy var storage: Storage = { + lazy var storage: any Storage = { return self.configuration.storageProvider }() lazy var timeline: Timeline = { diff --git a/Sources/Amplitude/Configuration.swift b/Sources/Amplitude/Configuration.swift index 34e21996..4bd45dd5 100644 --- a/Sources/Amplitude/Configuration.swift +++ b/Sources/Amplitude/Configuration.swift @@ -13,7 +13,7 @@ public class Configuration { var flushIntervalMillis: Int var instanceName: String var optOut: Bool - var storageProvider: Storage + var storageProvider: any Storage var logLevel: LogLevelEnum var loggerProvider: any Logger var minIdLength: Int? @@ -38,7 +38,7 @@ public class Configuration { flushIntervalMillis: Int = Constants.Configuration.FLUSH_INTERVAL_MILLIS, instanceName: String = Constants.Configuration.DEFAULT_INSTANCE, optOut: Bool = false, - storageProvider: Storage = PersistentStorage(), + storageProvider: any Storage = PersistentStorage(), logLevel: LogLevelEnum = LogLevelEnum.WARN, loggerProvider: any Logger = ConsoleLogger(), minIdLength: Int? = nil, diff --git a/Sources/Amplitude/Constants.swift b/Sources/Amplitude/Constants.swift index 3f9d051a..8677e735 100644 --- a/Sources/Amplitude/Constants.swift +++ b/Sources/Amplitude/Constants.swift @@ -58,7 +58,6 @@ public struct Constants { static let MIN_TIME_BETWEEN_SESSIONS_MILLIS = 300000 } - struct Storage { - static let STORAGE_PREFIX = "amplitude-swift" + public struct Storage { } } diff --git a/Sources/Amplitude/Events/BaseEvent.swift b/Sources/Amplitude/Events/BaseEvent.swift index 09eaccf5..d49ce2d8 100644 --- a/Sources/Amplitude/Events/BaseEvent.swift +++ b/Sources/Amplitude/Events/BaseEvent.swift @@ -7,13 +7,55 @@ import Foundation -public class BaseEvent: EventOptions { +public class BaseEvent: EventOptions, Codable { public var eventType: String public var eventProperties: [String: Any]? public var userProperties: [String: Any]? public var groups: [String: Any]? public var groupProperties: [String: Any]? + enum CodingKeys: String, CodingKey { + case eventType = "event_type" + case eventProperties = "event_properties" + case userProperties = "user_properties" + case groups + case groupProperties = "group_properties" + case userId = "user_id" + case deviceId = "device_id" + case timestamp = "time" + case eventId = "event_id" + case sessionId = "session_id" + case locationLat = "location_lat" + case locationLng = "location_lng" + case appVersion = "app_version" + case versionName = "version_name" + case platform + case osName = "os_name" + case osVersion = "os_version" + case deviceBrand = "device_brand" + case deviceManufacturer = "device_manufacturer" + case deviceModel = "device_model" + case carrier + case country + case region + case city + case dma + case idfa + case idfv + case adid + case language + case library + case ip + case plan + case ingestionMetadata = "ingestion_metadata" + case revenue + case price + case quantity + case productId = "product_id" + case revenueType = "revenue_type" + case partnerId = "partner_id" + } + init( userId: String? = nil, deviceId: String? = nil, @@ -147,4 +189,107 @@ public class BaseEvent: EventOptions { func isValid() -> Bool { return userId != nil || deviceId != nil } + + required public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + eventType = try values.decode(String.self, forKey: .eventType) + eventProperties = try values.decode([String: Any].self, forKey: .eventProperties) + userProperties = try values.decode([String: Any].self, forKey: .userProperties) + groups = try values.decode([String: Any].self, forKey: .groups) + groupProperties = try values.decode([String: Any].self, forKey: .groupProperties) + super.init() + userId = try values.decode(String.self, forKey: .userId) + deviceId = try values.decode(String.self, forKey: .deviceId) + timestamp = try values.decode(Double.self, forKey: .timestamp) + eventId = try values.decode(Double.self, forKey: .eventId) + sessionId = try values.decode(Double.self, forKey: .sessionId) + locationLat = try values.decode(Double.self, forKey: .locationLat) + locationLng = try values.decode(Double.self, forKey: .locationLng) + appVersion = try values.decode(String.self, forKey: .appVersion) + versionName = try values.decode(String.self, forKey: .versionName) + platform = try values.decode(String.self, forKey: .platform) + osName = try values.decode(String.self, forKey: .osName) + osVersion = try values.decode(String.self, forKey: .osVersion) + deviceBrand = try values.decode(String.self, forKey: .deviceBrand) + deviceManufacturer = try values.decode(String.self, forKey: .deviceManufacturer) + deviceModel = try values.decode(String.self, forKey: .deviceModel) + carrier = try values.decode(String.self, forKey: .carrier) + country = try values.decode(String.self, forKey: .country) + region = try values.decode(String.self, forKey: .region) + city = try values.decode(String.self, forKey: .city) + dma = try values.decode(String.self, forKey: .dma) + idfa = try values.decode(String.self, forKey: .idfa) + idfv = try values.decode(String.self, forKey: .idfv) + adid = try values.decode(String.self, forKey: .adid) + language = try values.decode(String.self, forKey: .language) + library = try values.decode(String.self, forKey: .library) + ip = try values.decode(String.self, forKey: .ip) + plan = try values.decode(Plan.self, forKey: .plan) + ingestionMetadata = try values.decode(IngestionMetadata.self, forKey: .ingestionMetadata) + revenue = try values.decode(Double.self, forKey: .revenue) + price = try values.decode(Double.self, forKey: .price) + quantity = try values.decode(Int.self, forKey: .quantity) + productId = try values.decode(String.self, forKey: .productId) + revenueType = try values.decode(String.self, forKey: .revenueType) + partnerId = try values.decode(String.self, forKey: .partnerId) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(eventType, forKey: .eventType) + try container.encodeIfPresent(eventProperties, forKey: .eventProperties) + try container.encodeIfPresent(userProperties, forKey: .userProperties) + try container.encodeIfPresent(groups, forKey: .groups) + try container.encodeIfPresent(groupProperties, forKey: .groupProperties) + try container.encode(userId, forKey: .userId) + try container.encode(deviceId, forKey: .deviceId) + try container.encode(timestamp, forKey: .timestamp) + try container.encode(eventId, forKey: .eventId) + try container.encode(sessionId, forKey: .sessionId) + try container.encode(locationLat, forKey: .locationLat) + try container.encode(locationLng, forKey: .locationLng) + try container.encode(appVersion, forKey: .appVersion) + try container.encode(versionName, forKey: .versionName) + try container.encode(platform, forKey: .platform) + try container.encode(osName, forKey: .osName) + try container.encode(osVersion, forKey: .osVersion) + try container.encode(deviceBrand, forKey: .deviceBrand) + try container.encode(deviceManufacturer, forKey: .deviceManufacturer) + try container.encode(deviceModel, forKey: .deviceModel) + try container.encode(carrier, forKey: .carrier) + try container.encode(country, forKey: .country) + try container.encode(region, forKey: .region) + try container.encode(city, forKey: .city) + try container.encode(dma, forKey: .dma) + try container.encode(idfa, forKey: .idfa) + try container.encode(idfv, forKey: .idfv) + try container.encode(adid, forKey: .adid) + try container.encode(language, forKey: .language) + try container.encode(library, forKey: .library) + try container.encode(ip, forKey: .ip) + try container.encodeIfPresent(plan, forKey: .plan) + try container.encodeIfPresent(ingestionMetadata, forKey: .ingestionMetadata) + try container.encode(revenue, forKey: .revenue) + try container.encode(price, forKey: .price) + try container.encode(quantity, forKey: .quantity) + try container.encode(productId, forKey: .productId) + try container.encode(revenueType, forKey: .revenueType) + try container.encode(partnerId, forKey: .partnerId) + } +} + +extension BaseEvent { + func toString() -> String { + var returnString = "" + do { + let encoder = JSONEncoder() + let json = try encoder.encode(self) + if let printed = String(data: json, encoding: .utf8) { + returnString = printed + } + } catch { + returnString = error.localizedDescription + } + return returnString + } } diff --git a/Sources/Amplitude/Plugins/Vendors/AppUtil.swift b/Sources/Amplitude/Plugins/Vendors/AppUtil.swift index ec7ac253..e6cc93cf 100644 --- a/Sources/Amplitude/Plugins/Vendors/AppUtil.swift +++ b/Sources/Amplitude/Plugins/Vendors/AppUtil.swift @@ -83,10 +83,12 @@ import Foundation } override var os_version: String { - return String(format: "%ld.%ld.%ld", - device.operatingSystemVersion.majorVersion, - device.operatingSystemVersion.minorVersion, - device.operatingSystemVersion.patchVersion) + return String( + format: "%ld.%ld.%ld", + device.operatingSystemVersion.majorVersion, + device.operatingSystemVersion.minorVersion, + device.operatingSystemVersion.patchVersion + ) } override var requiredPlugin: Plugin { @@ -109,12 +111,12 @@ import Foundation return getDeviceModel(platform: platform) } - private func macAddress(bsd : String) -> String? { + private func macAddress(bsd: String) -> String? { let MAC_ADDRESS_LENGTH = 6 let separator = ":" - var length : size_t = 0 - var buffer : [CChar] + var length: size_t = 0 + var buffer: [CChar] let bsdIndex = Int32(if_nametoindex(bsd)) if bsdIndex == 0 { @@ -124,16 +126,19 @@ import Foundation var managementInfoBase = [CTL_NET, AF_ROUTE, 0, AF_LINK, NET_RT_IFLIST, bsdIndex] if sysctl(&managementInfoBase, 6, nil, &length, nil, 0) < 0 { - return nil; + return nil } - buffer = [CChar](unsafeUninitializedCapacity: length, initializingWith: {buffer, initializedCount in - for x in 0.. Any? { + func read(key: StorageKey) async -> T? { return nil } @@ -20,3 +20,13 @@ class InMemoryStorage: Storage { } } + +extension InMemoryStorage { + enum StorageKey: String, CaseIterable { + case LAST_EVENT_ID = "last_event_id" + case PREVIOUS_SESSION_ID = "previous_session_id" + case LAST_EVENT_TIME = "last_event_time" + case OPT_OUT = "opt_out" + case EVENTS = "events" + } +} diff --git a/Sources/Amplitude/Storages/PersistentStorage.swift b/Sources/Amplitude/Storages/PersistentStorage.swift index 5eb7e44f..22c40f6c 100644 --- a/Sources/Amplitude/Storages/PersistentStorage.swift +++ b/Sources/Amplitude/Storages/PersistentStorage.swift @@ -11,22 +11,245 @@ actor PersistentStorage: Storage { let storagePrefix: String let userDefaults: UserDefaults? let fileManager: FileManager? + private var outputStream: OutputFileStream? + internal weak var amplitude: Amplitude? - init(storagePrefix: String = Constants.Storage.STORAGE_PREFIX) { - self.storagePrefix = storagePrefix - self.userDefaults = UserDefaults(suiteName: "com.amplitude.storage.\(storagePrefix)") + init(apiKey: String = "") { + self.storagePrefix = "\(PersistentStorage.DEFAULT_STORAGE_PREFIX)-\(apiKey)" + self.userDefaults = UserDefaults(suiteName: "\(PersistentStorage.AMP_STORAGE_PREFIX).\(storagePrefix)") self.fileManager = FileManager.default } - func write(key: String, value: Any?) async { - + func write(key: StorageKey, value: Any?) async throws { + switch key { + case .EVENTS: + if let event = value as? BaseEvent { + let eventStoreFile = getCurrentFile() + self.storeEvent(toFile: eventStoreFile, event: event) + } + default: + if isBasicType(value: value) { + userDefaults?.set(value, forKey: key.rawValue) + } else { + throw Exception.unsupportedType + } + } } - func read(key: String) async -> Any? { - return nil + func read(key: StorageKey) async -> T? { + var result: T? + switch key { + case .EVENTS: + result = getEventFiles() as? T + default: + result = userDefaults?.object(forKey: key.rawValue) as? T + } + return result } func reset() async { + let urls = getEventFiles(includeUnfinished: true) + let keys = userDefaults?.dictionaryRepresentation().keys + keys?.forEach { key in + userDefaults?.removeObject(forKey: key) + } + for url in urls { + try? fileManager!.removeItem(atPath: url.path) + } + } + + func isBasicType(value: Any?) -> Bool { + var result = false + if value == nil { + result = true + } else { + switch value { + case is NSNull, is Decimal, is NSNumber, is Bool, is String: + result = true + default: + break + } + } + return result + } +} + +extension PersistentStorage { + static let DEFAULT_STORAGE_PREFIX = "amplitude-swift" + static let AMP_STORAGE_PREFIX = "com.amplitude.storage" + static let MAX_FILE_SIZE = 975000 // 975KB + static let TEMP_FILE_EXTENSION = "tmp" + + enum StorageKey: String, CaseIterable { + case LAST_EVENT_ID = "last_event_id" + case PREVIOUS_SESSION_ID = "previous_session_id" + case LAST_EVENT_TIME = "last_event_time" + case OPT_OUT = "opt_out" + case EVENTS = "events" + } + + enum Exception: Error { + case unsupportedType + } +} + +extension PersistentStorage { + private var eventsFileKey: String { + return "\(storagePrefix).\(StorageKey.EVENTS.rawValue).index" + } + + private func getCurrentFile() -> URL { + var currentFileIndex = 0 + let index: Int = userDefaults?.integer(forKey: eventsFileKey) ?? 0 + userDefaults?.set(index, forKey: eventsFileKey) + currentFileIndex = index + return getEventsFile(index: currentFileIndex) + } + + private func getEventsFile(index: Int) -> URL { + let dir = getEventsStorageDirectory() + let fileURL = dir.appendingPathComponent("\(index)").appendingPathExtension( + PersistentStorage.TEMP_FILE_EXTENSION + ) + return fileURL + } + + private func getEventFiles(includeUnfinished: Bool = false) -> [URL] { + var result = [URL]() + + // finish out any file in progress + let index = userDefaults?.integer(forKey: eventsFileKey) ?? 0 + finish(file: getEventsFile(index: index)) + + let allFiles = try? fileManager!.contentsOfDirectory( + at: getEventsStorageDirectory(), + includingPropertiesForKeys: [], + options: .skipsHiddenFiles + ) + var files = allFiles + if includeUnfinished == false { + files = allFiles?.filter { (file) -> Bool in + return file.pathExtension != PersistentStorage.TEMP_FILE_EXTENSION + } + } + let sorted = files?.sorted { (left, right) -> Bool in + return left.lastPathComponent > right.lastPathComponent + } + if let s = sorted { + result = s + } + return result + } + + private func getEventsStorageDirectory() -> URL { + // tvOS doesn't have access to document + // macOS /Documents dir might be synced with iCloud + #if os(tvOS) || os(macOS) + let searchPathDirectory = FileManager.SearchPathDirectory.cachesDirectory + #else + let searchPathDirectory = FileManager.SearchPathDirectory.documentDirectory + #endif + + let urls = fileManager!.urls(for: searchPathDirectory, in: .userDomainMask) + let docUrl = urls[0] + let storageUrl = docUrl.appendingPathComponent("amplitude/\(eventsFileKey)/") + // try to create it, will fail if already exists. + // tvOS, watchOS regularly clear out data. + try? FileManager.default.createDirectory(at: storageUrl, withIntermediateDirectories: true, attributes: nil) + return storageUrl + } + + private func storeEvent(toFile file: URL, event: BaseEvent) { + var storeFile = file + + var newFile = false + if fileManager?.fileExists(atPath: storeFile.path) == false { + start(file: storeFile) + newFile = true + } else if outputStream == nil { + // this can happen if an instance was terminated before finishing a file. + open(file: storeFile) + } + + // Verify file size isn't too large + if let attributes = try? fileManager?.attributesOfItem(atPath: storeFile.path), + let fileSize = attributes[FileAttributeKey.size] as? UInt64, + fileSize >= PersistentStorage.MAX_FILE_SIZE + { + finish(file: storeFile) + // Set the new file path + storeFile = getCurrentFile() + start(file: storeFile) + newFile = true + } + + let jsonString = event.toString() + do { + if outputStream == nil { + amplitude?.logger.error(message: "OutputStream is nil with file: \(storeFile)") + } + if newFile == false { + // prepare for the next entry + try outputStream?.write(",") + } + try outputStream?.write(jsonString) + } catch { + amplitude?.logger.error(message: error.localizedDescription) + } + } + + private func start(file: URL) { + let contents = "[" + do { + outputStream = try OutputFileStream(fileURL: file) + try outputStream?.create() + try outputStream?.write(contents) + } catch { + amplitude?.logger.error(message: error.localizedDescription) + } + } + + private func open(file: URL) { + if outputStream == nil { + // this can happen if an instance was terminated before finishing a file. + do { + outputStream = try OutputFileStream(fileURL: file) + } catch { + amplitude?.logger.error(message: error.localizedDescription) + } + } + + if let outputStream = outputStream { + do { + try outputStream.open() + } catch { + amplitude?.logger.error(message: error.localizedDescription) + } + } + } + + private func finish(file: URL) { + guard let outputStream = self.outputStream else { + return + } + + let fileEnding = "]" + do { + try outputStream.write(fileEnding) + } catch { + amplitude?.logger.error(message: error.localizedDescription) + } + outputStream.close() + self.outputStream = nil + + let fileWithoutTemp = file.deletingPathExtension() + do { + try fileManager?.moveItem(at: file, to: fileWithoutTemp) + } catch { + amplitude?.logger.error(message: "Unable to rename file: \(file.path)") + } + let currentFileIndex: Int = (userDefaults?.integer(forKey: eventsFileKey) ?? 0) + 1 + userDefaults?.set(currentFileIndex, forKey: eventsFileKey) } } diff --git a/Sources/Amplitude/Types.swift b/Sources/Amplitude/Types.swift index 668d10be..491e8200 100644 --- a/Sources/Amplitude/Types.swift +++ b/Sources/Amplitude/Types.swift @@ -5,14 +5,14 @@ // Created by Marvin Liu on 10/27/22. // -public struct Plan { +public struct Plan: Codable { var branch: String? var source: String? var version: String? var versionId: String? } -public struct IngestionMetadata { +public struct IngestionMetadata: Codable { var sourceName: String? var sourceVersion: String? } @@ -22,8 +22,9 @@ public protocol EventCallBack { } public protocol Storage { - func write(key: String, value: Any?) async - func read(key: String) async -> Any? + associatedtype StorageKey: RawRepresentable where StorageKey.RawValue: StringProtocol + func write(key: StorageKey, value: Any?) async throws + func read(key: StorageKey) async -> T? func reset() async } diff --git a/Sources/Amplitude/Utilities/CodableExtension.swift b/Sources/Amplitude/Utilities/CodableExtension.swift new file mode 100644 index 00000000..3d9f479f --- /dev/null +++ b/Sources/Amplitude/Utilities/CodableExtension.swift @@ -0,0 +1,187 @@ +// +// CodableExtension.swift +// +// +// Created by Marvin Liu on 11/23/22. +// This file extends the current Codable to support custom JSON, like [String: Any]. + +import Foundation + +struct JSONCodingKeys: CodingKey { + var stringValue: String + var intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + } + + init?(intValue: Int) { + self.init(stringValue: "\(intValue)") + self.intValue = intValue + } +} + +extension KeyedDecodingContainer { + func decode(_ type: [String: Any].Type, forKey key: K) throws -> [String: Any] { + let container = try self.nestedContainer(keyedBy: JSONCodingKeys.self, forKey: key) + return try container.decode(type) + } + + func decode(_ type: [[String: Any]].Type, forKey key: K) throws -> [[String: Any]] { + var container = try self.nestedUnkeyedContainer(forKey: key) + if let decodedData = try container.decode([Any].self) as? [[String: Any]] { + return decodedData + } else { + return [] + } + } + + func decodeIfPresent(_ type: [String: Any].Type, forKey key: K) throws -> [String: Any]? { + guard contains(key) else { + return nil + } + guard try decodeNil(forKey: key) == false else { + return nil + } + return try decode(type, forKey: key) + } + + func decode(_ type: [Any].Type, forKey key: K) throws -> [Any] { + var container = try self.nestedUnkeyedContainer(forKey: key) + return try container.decode(type) + } + + func decodeIfPresent(_ type: [Any].Type, forKey key: K) throws -> [Any]? { + guard contains(key) else { + return nil + } + guard try decodeNil(forKey: key) == false else { + return nil + } + return try decode(type, forKey: key) + } + + func decode(_ type: [String: Any].Type) throws -> [String: Any] { + var dictionary = [String: Any]() + for key in allKeys { + if let boolValue = try? decode(Bool.self, forKey: key) { + dictionary[key.stringValue] = boolValue + } else if let stringValue = try? decode(String.self, forKey: key) { + dictionary[key.stringValue] = stringValue + } else if let intValue = try? decode(Int.self, forKey: key) { + dictionary[key.stringValue] = intValue + } else if let doubleValue = try? decode(Double.self, forKey: key) { + dictionary[key.stringValue] = doubleValue + } else if let nestedDictionary = try? decode(Dictionary.self, forKey: key) { + dictionary[key.stringValue] = nestedDictionary + } else if let nestedArray = try? decode(Array.self, forKey: key) { + dictionary[key.stringValue] = nestedArray + } + } + return dictionary + } +} + +extension UnkeyedDecodingContainer { + mutating func decode(_ type: [Any].Type) throws -> [Any] { + var array: [Any] = [] + while isAtEnd == false { + // See if the current value in the JSON array is `null` first + // and prevent infite recursion with nested arrays. + if try decodeNil() { + continue + } else if let value = try? decode(Bool.self) { + array.append(value) + } else if let value = try? decode(Double.self) { + array.append(value) + } else if let value = try? decode(String.self) { + array.append(value) + } else if let nestedDictionary = try? decode(Dictionary.self) { + array.append(nestedDictionary) + } else if let nestedArray = try? decode(Array.self) { + array.append(nestedArray) + } + } + return array + } + + mutating func decode(_ type: [String: Any].Type) throws -> [String: Any] { + let nestedContainer = try self.nestedContainer(keyedBy: JSONCodingKeys.self) + return try nestedContainer.decode(type) + } +} + +extension KeyedEncodingContainer { + mutating func encodeIfPresent(_ value: [String: Any]?, forKey key: KeyedEncodingContainer.Key) throws { + guard let safeValue = value, !safeValue.isEmpty else { + return + } + var container = self.nestedContainer(keyedBy: JSONCodingKeys.self, forKey: key) + for item in safeValue { + if let val = item.value as? Int { + try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) + } else if let val = item.value as? String { + try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) + } else if let val = item.value as? Double { + try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) + } else if let val = item.value as? Float { + try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) + } else if let val = item.value as? Bool { + try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) + } else if let val = item.value as? [Any] { + try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) + } else if let val = item.value as? [String: Any] { + try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) + } + } + } + + mutating func encodeIfPresent(_ value: [Any]?, forKey key: KeyedEncodingContainer.Key) throws { + guard let safeValue = value else { + return + } + if let val = safeValue as? [Int] { + try self.encodeIfPresent(val, forKey: key) + } else if let val = safeValue as? [String] { + try self.encodeIfPresent(val, forKey: key) + } else if let val = safeValue as? [Double] { + try self.encodeIfPresent(val, forKey: key) + } else if let val = safeValue as? [Float] { + try self.encodeIfPresent(val, forKey: key) + } else if let val = safeValue as? [Bool] { + try self.encodeIfPresent(val, forKey: key) + } else if let val = value as? [[String: Any]] { + var container = self.nestedUnkeyedContainer(forKey: key) + try container.encode(contentsOf: val) + } + } +} + +extension UnkeyedEncodingContainer { + mutating func encode(contentsOf sequence: [[String: Any]]) throws { + for dict in sequence { + try self.encodeIfPresent(dict) + } + } + + mutating func encodeIfPresent(_ value: [String: Any]) throws { + var container = self.nestedContainer(keyedBy: JSONCodingKeys.self) + for item in value { + if let val = item.value as? Int { + try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) + } else if let val = item.value as? String { + try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) + } else if let val = item.value as? Double { + try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) + } else if let val = item.value as? Float { + try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) + } else if let val = item.value as? Bool { + try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) + } else if let val = item.value as? [Any] { + try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) + } else if let val = item.value as? [String: Any] { + try container.encodeIfPresent(val, forKey: JSONCodingKeys(stringValue: item.key)!) + } + } + } +} diff --git a/Sources/Amplitude/Utilities/OutputFileStream.swift b/Sources/Amplitude/Utilities/OutputFileStream.swift new file mode 100644 index 00000000..5d4309de --- /dev/null +++ b/Sources/Amplitude/Utilities/OutputFileStream.swift @@ -0,0 +1,81 @@ +// +// OutputFileStream.swift +// +// +// Created by Marvin Liu on 11/22/22. +// +// Originally from Segment: https://github.com/segmentio/analytics-swift, under MIT license. +// Use C library file operations to avoid Swift API deprecation and related bugs. + +import Foundation + +#if os(Linux) + import Glibc +#else + import Darwin.C +#endif + +internal class OutputFileStream { + enum OutputStreamError: Error { + case invalidPath(String) + case unableToOpen(String) + case unableToWrite(String) + case unableToCreate(String) + } + + var filePointer: UnsafeMutablePointer? + let fileURL: URL + + init(fileURL: URL) throws { + self.fileURL = fileURL + let path = fileURL.path + guard path.isEmpty == false else { throw OutputStreamError.invalidPath(path) } + } + + func create() throws { + let path = fileURL.path + if FileManager.default.fileExists(atPath: path) { + throw OutputStreamError.unableToCreate(path) + } else { + let created = FileManager.default.createFile(atPath: fileURL.path, contents: nil) + if created == false { + throw OutputStreamError.unableToCreate(path) + } else { + try open() + } + } + } + + func open() throws { + if filePointer != nil { return } + let path = fileURL.path + path.withCString { file in + filePointer = fopen(file, "w") + } + guard filePointer != nil else { throw OutputStreamError.unableToOpen(path) } + } + + func write(_ data: Data) throws { + guard let string = String(data: data, encoding: .utf8) else { return } + try write(string) + } + + func write(_ string: String) throws { + guard string.isEmpty == false else { return } + _ = try string.utf8.withContiguousStorageIfAvailable { str in + if let baseAddr = str.baseAddress { + fwrite(baseAddr, 1, str.count, filePointer) + } else { + throw OutputStreamError.unableToWrite(fileURL.path) + } + if ferror(filePointer) != 0 { + throw OutputStreamError.unableToWrite(fileURL.path) + } + } + } + + func close() { + fclose(filePointer) + filePointer = nil + } +} diff --git a/Tests/AmplitudeTests/AmplitudeTests.swift b/Tests/AmplitudeTests/AmplitudeTests.swift index ecff4f8a..bac81517 100644 --- a/Tests/AmplitudeTests/AmplitudeTests.swift +++ b/Tests/AmplitudeTests/AmplitudeTests.swift @@ -26,11 +26,10 @@ final class AmplitudeTests: XCTestCase { let lastEvent = outputReader.lastEvent XCTAssertEqual(lastEvent?.library, "\(Constants.SDK_LIBRARY)/\(Constants.SDK_VERSION)") XCTAssertEqual(lastEvent?.deviceManufacturer, "Apple") - XCTAssertEqual(lastEvent?.deviceModel, "Simulator") + XCTAssertEqual(lastEvent?.deviceModel!.isEmpty, false) XCTAssertEqual(lastEvent?.ip, "$remote") XCTAssertNil(lastEvent?.country) - XCTAssertNotNil(lastEvent?.platform) - XCTAssertNotNil(lastEvent?.language) + XCTAssertEqual(lastEvent?.platform!.isEmpty, false) + XCTAssertEqual(lastEvent?.language!.isEmpty, false) } - } diff --git a/Tests/AmplitudeTests/Events/BaseEventTests.swift b/Tests/AmplitudeTests/Events/BaseEventTests.swift new file mode 100644 index 00000000..146e8b56 --- /dev/null +++ b/Tests/AmplitudeTests/Events/BaseEventTests.swift @@ -0,0 +1,43 @@ +// +// BaseEventTests.swift +// +// +// Created by Marvin Liu on 11/23/22. +// + +import XCTest + +@testable import Amplitude_Swift + +final class BaseEventTests: XCTestCase { + func testToString() async { + let baseEvent = BaseEvent( + eventType: "test", + eventProperties: [ + "integer": 1, + "string": "stringValue", + "array": [1, 2, 3], + ] + ) + + let baseEventData = baseEvent.toString().data(using: .utf8)! + let baseEventDict = + try? JSONSerialization.jsonObject(with: baseEventData, options: .mutableContainers) as? [String: AnyObject] + XCTAssertEqual( + baseEventDict!["event_type"] as! String, // swiftlint:disable:this force_cast + "test" + ) + XCTAssertEqual( + baseEventDict!["event_properties"]!["integer" as NSString] as! Int, // swiftlint:disable:this force_cast + 1 + ) + XCTAssertEqual( + baseEventDict!["event_properties"]!["string" as NSString] as! String, // swiftlint:disable:this force_cast + "stringValue" + ) + XCTAssertEqual( + baseEventDict!["event_properties"]!["array" as NSString] as! Array, // swiftlint:disable:this force_cast + [1, 2, 3] + ) + } +} diff --git a/Tests/AmplitudeTests/Storages/PersistentStorageTests.swift b/Tests/AmplitudeTests/Storages/PersistentStorageTests.swift new file mode 100644 index 00000000..69ffa4d3 --- /dev/null +++ b/Tests/AmplitudeTests/Storages/PersistentStorageTests.swift @@ -0,0 +1,46 @@ +// +// PersistentStorageTests.swift +// +// +// Created by Marvin Liu on 11/21/22. +// + +import XCTest + +@testable import Amplitude_Swift + +final class PersistentStorageTests: XCTestCase { + func testIsBasicType() async { + let persistentStorage = PersistentStorage() + var isValueBasicType = await persistentStorage.isBasicType(value: 111) + XCTAssertEqual(isValueBasicType, true) + + isValueBasicType = await persistentStorage.isBasicType(value: true) + XCTAssertEqual(isValueBasicType, true) + + isValueBasicType = await persistentStorage.isBasicType(value: "test") + XCTAssertEqual(isValueBasicType, true) + + isValueBasicType = await persistentStorage.isBasicType(value: nil) + XCTAssertEqual(isValueBasicType, true) + + isValueBasicType = await persistentStorage.isBasicType(value: Date()) + XCTAssertEqual(isValueBasicType, false) + } + + func testWrite() async { + let persistentStorage = PersistentStorage(apiKey: "xxx-api-key") + try? await persistentStorage.write( + key: PersistentStorage.StorageKey.EVENTS, + value: BaseEvent(eventType: "test1") + ) + try? await persistentStorage.write( + key: PersistentStorage.StorageKey.EVENTS, + value: BaseEvent(eventType: "test2") + ) + let eventFiles: [URL]? = await persistentStorage.read(key: PersistentStorage.StorageKey.EVENTS) + XCTAssertEqual(eventFiles?[0].absoluteString.contains("xxx-api-key.events.index"), true) + XCTAssertNotEqual(eventFiles?[0].pathExtension, PersistentStorage.TEMP_FILE_EXTENSION) + await persistentStorage.reset() + } +} diff --git a/Tests/AmplitudeTests/Timeline.swift b/Tests/AmplitudeTests/TimelineTests.swift similarity index 97% rename from Tests/AmplitudeTests/Timeline.swift rename to Tests/AmplitudeTests/TimelineTests.swift index 98ec2f84..53a1ba91 100644 --- a/Tests/AmplitudeTests/Timeline.swift +++ b/Tests/AmplitudeTests/TimelineTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Amplitude_Swift -final class TimelineTest: XCTestCase { +final class TimelineTests: XCTestCase { private var timeline: Timeline! func testTimeline() { From cae6c48cead2896ddd624794328abf6f8c56ec4f Mon Sep 17 00:00:00 2001 From: Marvin Liu Date: Fri, 25 Nov 2022 16:53:39 -0800 Subject: [PATCH 2/7] feat: add httpclient --- .../Amplitude/Utilities/EventPipeline.swift | 3 +- Sources/Amplitude/Utilities/HttpClient.swift | 86 ++++++++++++++++++- .../Utilities/HttpClientTests.swift | 63 ++++++++++++++ 3 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 Tests/AmplitudeTests/Utilities/HttpClientTests.swift diff --git a/Sources/Amplitude/Utilities/EventPipeline.swift b/Sources/Amplitude/Utilities/EventPipeline.swift index 072c0632..e0af17c8 100644 --- a/Sources/Amplitude/Utilities/EventPipeline.swift +++ b/Sources/Amplitude/Utilities/EventPipeline.swift @@ -9,10 +9,11 @@ import Foundation class EventPipeline { var amplitude: Amplitude - var httpClient: HttpClient = HttpClient() + var httpClient: HttpClient init(amplitude: Amplitude) { self.amplitude = amplitude + self.httpClient = HttpClient(configuration: amplitude.configuration) } func put(event: BaseEvent) { diff --git a/Sources/Amplitude/Utilities/HttpClient.swift b/Sources/Amplitude/Utilities/HttpClient.swift index 8b0490ec..30f088e6 100644 --- a/Sources/Amplitude/Utilities/HttpClient.swift +++ b/Sources/Amplitude/Utilities/HttpClient.swift @@ -1,5 +1,5 @@ // -// File.swift +// HttpClient.swift // // // Created by Marvin Liu on 10/28/22. @@ -8,8 +8,88 @@ import Foundation class HttpClient { + let configuration: Configuration + internal var session: URLSession - func send(event: [BaseEvent]) -> HTTPURLResponse { - return HTTPURLResponse() + init(configuration: Configuration) { + self.configuration = configuration + // shared instance has limitations but think we are not affected + // https://developer.apple.com/documentation/foundation/urlsession/1409000-shared + self.session = URLSession.shared + } + + func upload(events: String, completion: @escaping (_ result: Result) -> Void) { + do { + let request = try getRequest() + let requestData = getRequestData(events: events) + + let sessionTask = session.uploadTask(with: request, from: requestData) { data, response, error in + if error != nil { + completion(.failure(error!)) + } else if let httpResponse = response as? HTTPURLResponse { + switch httpResponse.statusCode { + case 1..<300: + completion(.success(true)) + default: + completion(.failure(Exception.httpError(code: httpResponse.statusCode, data: data))) + } + } + } + sessionTask.resume() + } catch { + completion(.failure(Exception.httpError(code: 500, data: nil))) + } + } + + func getUrl() -> String { + if let url = configuration.serverUrl, !url.isEmpty { + return url + } + if configuration.serverZone == ServerZone.EU { + return configuration.useBatch ? Constants.EU_BATCH_API_HOST : Constants.EU_DEFAULT_API_HOST + } + return configuration.useBatch ? Constants.BATCH_API_HOST : Constants.DEFAULT_API_HOST + } + + func getRequest() throws -> URLRequest { + let url = getUrl() + guard let requestUrl = URL(string: url) else { + throw Exception.invalidUrl(url: url) + } + var request = URLRequest(url: requestUrl, timeoutInterval: 60) + request.httpMethod = "POST" + request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") + request.addValue("application/json", forHTTPHeaderField: "Accept") + return request + } + + func getRequestData(events: String) -> Data? { + let apiKey = configuration.apiKey + var requestPayload = """ + {"api_key":"\(apiKey)","events":\(events) + """ + if let minIdLength = configuration.minIdLength { + requestPayload += """ + ,"options":{"min_id_length":\(minIdLength)} + """ + } + requestPayload += "}" + return requestPayload.data(using: .utf8) + } +} + +extension HttpClient { + enum HttpStatus: Int { + case SUCCESS = 200 + case BAD_REQUEST = 400 + case TIMEOUT = 408 + case PAYLOAD_TOO_LARGE = 413 + case TOO_MANY_REQUESTS = 429 + case FAILED = 500 + } + + enum Exception: Error { + case invalidUrl(url: String) + case httpError(code: Int, data: Data?) } } diff --git a/Tests/AmplitudeTests/Utilities/HttpClientTests.swift b/Tests/AmplitudeTests/Utilities/HttpClientTests.swift new file mode 100644 index 00000000..e2da9c0b --- /dev/null +++ b/Tests/AmplitudeTests/Utilities/HttpClientTests.swift @@ -0,0 +1,63 @@ +// +// HttpClientTests.swift +// +// +// Created by Marvin Liu on 11/24/22. +// + +import XCTest + +@testable import Amplitude_Swift + +final class HttpClientTests: XCTestCase { + private var configuration: Configuration! + + override func setUp() { + super.setUp() + configuration = Configuration(apiKey: "testApiKey") + } + + func testGetUrlWithDefault() { + let httpClient = HttpClient(configuration: configuration) + XCTAssertEqual(httpClient.getUrl(), Constants.DEFAULT_API_HOST) + } + + func testGetUrlWithCustomUrl() { + let customUrl = "https//localhost.test" + configuration.serverUrl = customUrl + let httpClient = HttpClient(configuration: configuration) + XCTAssertEqual(httpClient.getUrl(), customUrl) + } + + func testGetRequestWithInvalidUrl() { + let invalidUrl = "local host" + configuration.serverUrl = invalidUrl + let httpClient = HttpClient(configuration: configuration) + + XCTAssertThrowsError(try httpClient.getRequest()) { error in + guard case HttpClient.Exception.invalidUrl(let url) = error else { + return XCTFail("not getting invalidUrl error") + } + XCTAssertEqual(url, invalidUrl) + } + } + + func testUploadWithInvalidApiKey() { + // TODO: currently this test is sending request to real Amplitude host, update to mock for better stability + let httpClient = HttpClient(configuration: configuration) + let asyncExpectation = expectation(description: "Async function") + let event1 = BaseEvent(userId: "unit-test user", deviceId: "unit-test device", eventType: "unit-test event") + httpClient.upload(events: "[\(event1.toString())]") { result in + guard case .failure(let error) = result else { + return XCTFail("not getting upload failure") + } + guard case HttpClient.Exception.httpError(let code, let data) = error else { + return XCTFail("not getting httpError error") + } + XCTAssertEqual(code, 400) + XCTAssertTrue(String(decoding: data!, as: UTF8.self).contains("Invalid API key: testApiKey")) + asyncExpectation.fulfill() + } + waitForExpectations(timeout: 15) + } +} From 4a0686e33666f87ef47a395c34d7053d207e6a00 Mon Sep 17 00:00:00 2001 From: Marvin Liu Date: Mon, 28 Nov 2022 09:55:04 -0800 Subject: [PATCH 3/7] fix: use XCTWaiter to make test stable --- Tests/AmplitudeTests/Utilities/HttpClientTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/AmplitudeTests/Utilities/HttpClientTests.swift b/Tests/AmplitudeTests/Utilities/HttpClientTests.swift index e2da9c0b..21aa8a0a 100644 --- a/Tests/AmplitudeTests/Utilities/HttpClientTests.swift +++ b/Tests/AmplitudeTests/Utilities/HttpClientTests.swift @@ -58,6 +58,6 @@ final class HttpClientTests: XCTestCase { XCTAssertTrue(String(decoding: data!, as: UTF8.self).contains("Invalid API key: testApiKey")) asyncExpectation.fulfill() } - waitForExpectations(timeout: 15) + _ = XCTWaiter.wait(for: [asyncExpectation], timeout: 5) } } From ad5494da835bf5eb31fbeb974b479a4d2518f87c Mon Sep 17 00:00:00 2001 From: Marvin Liu Date: Wed, 30 Nov 2022 00:23:05 -0800 Subject: [PATCH 4/7] feat: connect components together --- .swiftlint.yml | 7 ++ Examples/Apps/IOSExample/README.md | 4 + Sources/Amplitude/Amplitude.swift | 8 +- Sources/Amplitude/Configuration.swift | 4 +- Sources/Amplitude/Events/BaseEvent.swift | 12 +- Sources/Amplitude/Events/EventOptions.swift | 14 +-- Sources/Amplitude/Mediator.swift | 4 +- .../Plugins/AmplitudeDestinationPlugin.swift | 1 - Sources/Amplitude/Plugins/ContextPlugin.swift | 2 +- .../Plugins/Mac/MacOSLifecycleMonitor.swift | 2 +- .../Plugins/iOS/IOSLifecycleMonitor.swift | 2 +- .../watchOS/WatchOSLifecycleMonitor.swift | 2 +- .../Amplitude/Storages/InMemoryStorage.swift | 14 +-- .../Storages/PersistentStorage.swift | 48 +++++--- Sources/Amplitude/Timeline.swift | 2 - Sources/Amplitude/Types.swift | 11 +- Sources/Amplitude/Utilities/Atomic.swift | 35 ++++++ .../Amplitude/Utilities/EventPipeline.swift | 112 +++++++++++++++++- Sources/Amplitude/Utilities/HttpClient.swift | 8 +- Sources/Amplitude/Utilities/QueueTimer.swift | 85 +++++++++++++ .../Storages/PersistentStorageTests.swift | 6 +- .../Utilities/HttpClientTests.swift | 2 +- 22 files changed, 323 insertions(+), 62 deletions(-) create mode 100644 Examples/Apps/IOSExample/README.md create mode 100644 Sources/Amplitude/Utilities/Atomic.swift create mode 100644 Sources/Amplitude/Utilities/QueueTimer.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 4b2234f9..da57db44 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -8,3 +8,10 @@ identifier_name: allowed_symbols: "_" min_length: 1 cyclomatic_complexity: 25 +nesting: + type_level: + warning: 3 + error: 6 + function_level: + warning: 5 + error: 10 diff --git a/Examples/Apps/IOSExample/README.md b/Examples/Apps/IOSExample/README.md new file mode 100644 index 00000000..4dbc429c --- /dev/null +++ b/Examples/Apps/IOSExample/README.md @@ -0,0 +1,4 @@ +## iOS Example +TODO: This is the location to put iOS Example. + +However, got some problem when creating a new Swift Project here. Tried to convert the SDK Package to Project then delete the folder reference to only track file changes by git. Got some other problems when converting the Package to Project. Leave a comment here to revisit. diff --git a/Sources/Amplitude/Amplitude.swift b/Sources/Amplitude/Amplitude.swift index 91dcbb98..7a0fe8b7 100644 --- a/Sources/Amplitude/Amplitude.swift +++ b/Sources/Amplitude/Amplitude.swift @@ -11,7 +11,7 @@ public class Amplitude { lazy var timeline: Timeline = { return Timeline() }() - lazy var logger: any Logger = { + lazy var logger: (any Logger)? = { return self.configuration.loggerProvider }() @@ -108,7 +108,7 @@ public class Amplitude { return self } - func onEnterForeground(timestamp: Double) { + func onEnterForeground(timestamp: Int64) { inForeground = true let dummySessionStartEvent = BaseEvent(eventType: "session_start") @@ -131,10 +131,10 @@ public class Amplitude { private func process(event: BaseEvent) { if configuration.optOut { - logger.log(message: "Skip event based on opt out configuration") + logger?.log(message: "Skip event based on opt out configuration") return } - event.timestamp = event.timestamp ?? NSDate().timeIntervalSince1970 + event.timestamp = event.timestamp ?? Int64(NSDate().timeIntervalSince1970 * 1000) timeline.process(event: event) } } diff --git a/Sources/Amplitude/Configuration.swift b/Sources/Amplitude/Configuration.swift index 4bd45dd5..6bb4fa97 100644 --- a/Sources/Amplitude/Configuration.swift +++ b/Sources/Amplitude/Configuration.swift @@ -38,7 +38,7 @@ public class Configuration { flushIntervalMillis: Int = Constants.Configuration.FLUSH_INTERVAL_MILLIS, instanceName: String = Constants.Configuration.DEFAULT_INSTANCE, optOut: Bool = false, - storageProvider: any Storage = PersistentStorage(), + storageProvider: (any Storage)? = nil, logLevel: LogLevelEnum = LogLevelEnum.WARN, loggerProvider: any Logger = ConsoleLogger(), minIdLength: Int? = nil, @@ -63,7 +63,7 @@ public class Configuration { self.flushIntervalMillis = flushIntervalMillis self.instanceName = instanceName self.optOut = optOut - self.storageProvider = storageProvider + self.storageProvider = storageProvider ?? PersistentStorage(apiKey: apiKey) self.logLevel = logLevel self.loggerProvider = loggerProvider self.minIdLength = minIdLength diff --git a/Sources/Amplitude/Events/BaseEvent.swift b/Sources/Amplitude/Events/BaseEvent.swift index d49ce2d8..8b124a2c 100644 --- a/Sources/Amplitude/Events/BaseEvent.swift +++ b/Sources/Amplitude/Events/BaseEvent.swift @@ -59,9 +59,9 @@ public class BaseEvent: EventOptions, Codable { init( userId: String? = nil, deviceId: String? = nil, - timestamp: Double? = nil, - eventId: Double? = nil, - sessionId: Double? = -1, + timestamp: Int64? = nil, + eventId: Int64? = nil, + sessionId: Int64? = -1, insertId: String? = nil, locationLat: Double? = nil, locationLng: Double? = nil, @@ -200,9 +200,9 @@ public class BaseEvent: EventOptions, Codable { super.init() userId = try values.decode(String.self, forKey: .userId) deviceId = try values.decode(String.self, forKey: .deviceId) - timestamp = try values.decode(Double.self, forKey: .timestamp) - eventId = try values.decode(Double.self, forKey: .eventId) - sessionId = try values.decode(Double.self, forKey: .sessionId) + timestamp = try values.decode(Int64.self, forKey: .timestamp) + eventId = try values.decode(Int64.self, forKey: .eventId) + sessionId = try values.decode(Int64.self, forKey: .sessionId) locationLat = try values.decode(Double.self, forKey: .locationLat) locationLng = try values.decode(Double.self, forKey: .locationLng) appVersion = try values.decode(String.self, forKey: .appVersion) diff --git a/Sources/Amplitude/Events/EventOptions.swift b/Sources/Amplitude/Events/EventOptions.swift index 2e754a96..427be31a 100644 --- a/Sources/Amplitude/Events/EventOptions.swift +++ b/Sources/Amplitude/Events/EventOptions.swift @@ -10,9 +10,9 @@ import Foundation public class EventOptions { var userId: String? var deviceId: String? - var timestamp: Double? - var eventId: Double? - var sessionId: Double? = -1 + var timestamp: Int64? + var eventId: Int64? + var sessionId: Int64? = -1 var insertId: String? var locationLat: Double? var locationLng: Double? @@ -45,14 +45,14 @@ public class EventOptions { var extra: [String: Any]? var callback: EventCallBack? var partnerId: String? - private var attempts: Int + internal var attempts: Int init( userId: String? = nil, deviceId: String? = nil, - timestamp: Double? = nil, - eventId: Double? = nil, - sessionId: Double? = -1, + timestamp: Int64? = nil, + eventId: Int64? = nil, + sessionId: Int64? = -1, insertId: String? = nil, locationLat: Double? = nil, locationLng: Double? = nil, diff --git a/Sources/Amplitude/Mediator.swift b/Sources/Amplitude/Mediator.swift index db22d378..05854231 100644 --- a/Sources/Amplitude/Mediator.swift +++ b/Sources/Amplitude/Mediator.swift @@ -23,8 +23,8 @@ internal class Mediator { var result: BaseEvent? = event plugins.forEach { plugin in if let r = result { - if plugin is DestinationPlugin { - _ = plugin.execute(event: r) + if let p = plugin as? DestinationPlugin { + _ = p.process(event: r) } else if let p = plugin as? EventPlugin { result = p.execute(event: r) if let rr = result { diff --git a/Sources/Amplitude/Plugins/AmplitudeDestinationPlugin.swift b/Sources/Amplitude/Plugins/AmplitudeDestinationPlugin.swift index c427661b..3cda8acc 100644 --- a/Sources/Amplitude/Plugins/AmplitudeDestinationPlugin.swift +++ b/Sources/Amplitude/Plugins/AmplitudeDestinationPlugin.swift @@ -19,7 +19,6 @@ public class AmplitudeDestinationPlugin: DestinationPlugin { logger?.error(message: "Event is invalid for missing information like userId and deviceId") } } - } public func track(event: BaseEvent) -> BaseEvent? { diff --git a/Sources/Amplitude/Plugins/ContextPlugin.swift b/Sources/Amplitude/Plugins/ContextPlugin.swift index 5cc731ac..7e679303 100644 --- a/Sources/Amplitude/Plugins/ContextPlugin.swift +++ b/Sources/Amplitude/Plugins/ContextPlugin.swift @@ -76,7 +76,7 @@ class ContextPlugin: Plugin { internal func mergeContext(event: BaseEvent, context: [String: Any]) { if event.timestamp == nil { - event.timestamp = NSDate().timeIntervalSince1970 * 1000 + event.timestamp = Int64(NSDate().timeIntervalSince1970 * 1000) } if event.insertId == nil { event.insertId = NSUUID().uuidString diff --git a/Sources/Amplitude/Plugins/Mac/MacOSLifecycleMonitor.swift b/Sources/Amplitude/Plugins/Mac/MacOSLifecycleMonitor.swift index 58a9349c..e5d0cc79 100644 --- a/Sources/Amplitude/Plugins/Mac/MacOSLifecycleMonitor.swift +++ b/Sources/Amplitude/Plugins/Mac/MacOSLifecycleMonitor.swift @@ -82,7 +82,7 @@ extension AmplitudeDestinationPlugin: MacOSLifecycle { public func applicationDidBecomeActive() { - let timestamp = NSDate().timeIntervalSince1970 + let timestamp = Int64(NSDate().timeIntervalSince1970 * 1000) self.amplitude?.onEnterForeground(timestamp: timestamp) } diff --git a/Sources/Amplitude/Plugins/iOS/IOSLifecycleMonitor.swift b/Sources/Amplitude/Plugins/iOS/IOSLifecycleMonitor.swift index e2f5638b..03589672 100644 --- a/Sources/Amplitude/Plugins/iOS/IOSLifecycleMonitor.swift +++ b/Sources/Amplitude/Plugins/iOS/IOSLifecycleMonitor.swift @@ -94,7 +94,7 @@ extension AmplitudeDestinationPlugin: IOSLifecycle { public func applicationWillEnterForeground(application: UIApplication?) { - let timestamp = NSDate().timeIntervalSince1970 + let timestamp = Int64(NSDate().timeIntervalSince1970 * 1000) self.amplitude?.onEnterForeground(timestamp: timestamp) } diff --git a/Sources/Amplitude/Plugins/watchOS/WatchOSLifecycleMonitor.swift b/Sources/Amplitude/Plugins/watchOS/WatchOSLifecycleMonitor.swift index 29ebe3dc..89839c09 100644 --- a/Sources/Amplitude/Plugins/watchOS/WatchOSLifecycleMonitor.swift +++ b/Sources/Amplitude/Plugins/watchOS/WatchOSLifecycleMonitor.swift @@ -88,7 +88,7 @@ extension AmplitudeDestinationPlugin: WatchOSLifecycle { public func applicationWillEnterForeground(watchExtension: WKExtension) { - let timestamp = NSDate().timeIntervalSince1970 + let timestamp = Int64(NSDate().timeIntervalSince1970 * 1000) self.amplitude?.onEnterForeground(timestamp: timestamp) } diff --git a/Sources/Amplitude/Storages/InMemoryStorage.swift b/Sources/Amplitude/Storages/InMemoryStorage.swift index 250cd91c..2e49a119 100644 --- a/Sources/Amplitude/Storages/InMemoryStorage.swift +++ b/Sources/Amplitude/Storages/InMemoryStorage.swift @@ -19,14 +19,12 @@ class InMemoryStorage: Storage { func reset() async { } -} -extension InMemoryStorage { - enum StorageKey: String, CaseIterable { - case LAST_EVENT_ID = "last_event_id" - case PREVIOUS_SESSION_ID = "previous_session_id" - case LAST_EVENT_TIME = "last_event_time" - case OPT_OUT = "opt_out" - case EVENTS = "events" + func rollover() async { + + } + + func getEventsString(eventBlock: Any) async -> String? { + return nil } } diff --git a/Sources/Amplitude/Storages/PersistentStorage.swift b/Sources/Amplitude/Storages/PersistentStorage.swift index 22c40f6c..bfe573e1 100644 --- a/Sources/Amplitude/Storages/PersistentStorage.swift +++ b/Sources/Amplitude/Storages/PersistentStorage.swift @@ -1,5 +1,5 @@ // -// File.swift +// PersistentStorage.swift // // // Created by Marvin Liu on 10/28/22. @@ -47,6 +47,17 @@ actor PersistentStorage: Storage { return result } + func getEventsString(eventBlock: Any) async -> String? { + var content: String? + guard let eventBlock = eventBlock as? URL else { return content } + do { + content = try String(contentsOf: eventBlock, encoding: .utf8) + } catch { + amplitude?.logger?.error(message: error.localizedDescription) + } + return content + } + func reset() async { let urls = getEventFiles(includeUnfinished: true) let keys = userDefaults?.dictionaryRepresentation().keys @@ -58,6 +69,19 @@ actor PersistentStorage: Storage { } } + func rollover() async { + let currentFile = getCurrentFile() + if fileManager?.fileExists(atPath: currentFile.path) == false { + return + } + if let attributes = try? fileManager?.attributesOfItem(atPath: currentFile.path), + let fileSize = attributes[FileAttributeKey.size] as? UInt64, + fileSize >= 0 + { + finish(file: currentFile) + } + } + func isBasicType(value: Any?) -> Bool { var result = false if value == nil { @@ -80,14 +104,6 @@ extension PersistentStorage { static let MAX_FILE_SIZE = 975000 // 975KB static let TEMP_FILE_EXTENSION = "tmp" - enum StorageKey: String, CaseIterable { - case LAST_EVENT_ID = "last_event_id" - case PREVIOUS_SESSION_ID = "previous_session_id" - case LAST_EVENT_TIME = "last_event_time" - case OPT_OUT = "opt_out" - case EVENTS = "events" - } - enum Exception: Error { case unsupportedType } @@ -186,7 +202,7 @@ extension PersistentStorage { let jsonString = event.toString() do { if outputStream == nil { - amplitude?.logger.error(message: "OutputStream is nil with file: \(storeFile)") + amplitude?.logger?.error(message: "OutputStream is nil with file: \(storeFile)") } if newFile == false { // prepare for the next entry @@ -194,7 +210,7 @@ extension PersistentStorage { } try outputStream?.write(jsonString) } catch { - amplitude?.logger.error(message: error.localizedDescription) + amplitude?.logger?.error(message: error.localizedDescription) } } @@ -205,7 +221,7 @@ extension PersistentStorage { try outputStream?.create() try outputStream?.write(contents) } catch { - amplitude?.logger.error(message: error.localizedDescription) + amplitude?.logger?.error(message: error.localizedDescription) } } @@ -215,7 +231,7 @@ extension PersistentStorage { do { outputStream = try OutputFileStream(fileURL: file) } catch { - amplitude?.logger.error(message: error.localizedDescription) + amplitude?.logger?.error(message: error.localizedDescription) } } @@ -223,7 +239,7 @@ extension PersistentStorage { do { try outputStream.open() } catch { - amplitude?.logger.error(message: error.localizedDescription) + amplitude?.logger?.error(message: error.localizedDescription) } } } @@ -237,7 +253,7 @@ extension PersistentStorage { do { try outputStream.write(fileEnding) } catch { - amplitude?.logger.error(message: error.localizedDescription) + amplitude?.logger?.error(message: error.localizedDescription) } outputStream.close() self.outputStream = nil @@ -246,7 +262,7 @@ extension PersistentStorage { do { try fileManager?.moveItem(at: file, to: fileWithoutTemp) } catch { - amplitude?.logger.error(message: "Unable to rename file: \(file.path)") + amplitude?.logger?.error(message: "Unable to rename file: \(file.path)") } let currentFileIndex: Int = (userDefaults?.integer(forKey: eventsFileKey) ?? 0) + 1 diff --git a/Sources/Amplitude/Timeline.swift b/Sources/Amplitude/Timeline.swift index 7d957f93..1eb85ae3 100644 --- a/Sources/Amplitude/Timeline.swift +++ b/Sources/Amplitude/Timeline.swift @@ -21,7 +21,6 @@ public class Timeline { let beforeResult = applyPlugin(pluginType: PluginType.before, event: event) let enrichmentResult = applyPlugin(pluginType: PluginType.enrichment, event: beforeResult) _ = applyPlugin(pluginType: PluginType.destination, event: enrichmentResult) - } internal func applyPlugin(pluginType: PluginType, event: BaseEvent?) -> BaseEvent? { @@ -30,7 +29,6 @@ public class Timeline { result = mediator.execute(event: event!) } return result - } internal func add(plugin: Plugin) { diff --git a/Sources/Amplitude/Types.swift b/Sources/Amplitude/Types.swift index 491e8200..b25244b0 100644 --- a/Sources/Amplitude/Types.swift +++ b/Sources/Amplitude/Types.swift @@ -22,12 +22,21 @@ public protocol EventCallBack { } public protocol Storage { - associatedtype StorageKey: RawRepresentable where StorageKey.RawValue: StringProtocol func write(key: StorageKey, value: Any?) async throws func read(key: StorageKey) async -> T? + func getEventsString(eventBlock: Any) async -> String? + func rollover() async func reset() async } +public enum StorageKey: String, CaseIterable { + case LAST_EVENT_ID = "last_event_id" + case PREVIOUS_SESSION_ID = "previous_session_id" + case LAST_EVENT_TIME = "last_event_time" + case OPT_OUT = "opt_out" + case EVENTS = "events" +} + public protocol Logger { associatedtype LogLevel: RawRepresentable var logLevel: Int? { get set } diff --git a/Sources/Amplitude/Utilities/Atomic.swift b/Sources/Amplitude/Utilities/Atomic.swift new file mode 100644 index 00000000..4e19ba1a --- /dev/null +++ b/Sources/Amplitude/Utilities/Atomic.swift @@ -0,0 +1,35 @@ +// +// Atomic.swift +// +// +// Created by Marvin Liu on 11/29/22. +// + +import Foundation + +@propertyWrapper +public struct Atomic { + var value: T + private let lock = NSLock() + + public init(wrappedValue value: T) { + self.value = value + } + + public var wrappedValue: T { + get { return load() } + set { store(newValue: newValue) } + } + + func load() -> T { + lock.lock() + defer { lock.unlock() } + return value + } + + mutating func store(newValue: T) { + lock.lock() + defer { lock.unlock() } + value = newValue + } +} diff --git a/Sources/Amplitude/Utilities/EventPipeline.swift b/Sources/Amplitude/Utilities/EventPipeline.swift index e0af17c8..96d13240 100644 --- a/Sources/Amplitude/Utilities/EventPipeline.swift +++ b/Sources/Amplitude/Utilities/EventPipeline.swift @@ -8,23 +8,131 @@ import Foundation class EventPipeline { - var amplitude: Amplitude - var httpClient: HttpClient + let amplitude: Amplitude + let httpClient: HttpClient + var storage: Storage? { amplitude.storage } + @Atomic internal var eventCount: Int = 0 + @Atomic internal var flushSizeDivider: Int = 1 + internal var flushTimer: QueueTimer? + private let uploadsQueue = DispatchQueue(label: "uploadsQueue.amplitude.com") + + internal struct UploadTaskInfo { + let events: String + let task: URLSessionDataTask + // set/used via an extension in iOSLifecycleMonitor.swift + typealias CleanupClosure = () -> Void + var cleanup: CleanupClosure? + } + private var uploads = [UploadTaskInfo]() init(amplitude: Amplitude) { self.amplitude = amplitude self.httpClient = HttpClient(configuration: amplitude.configuration) + self.flushTimer = QueueTimer(interval: getFlushInterval()) { [weak self] in + self?.flush() + } } func put(event: BaseEvent) { + guard let storage = self.storage else { return } + event.attempts += 1 + Task { + do { + try await storage.write(key: StorageKey.EVENTS, value: event) + eventCount += 1 + if eventCount >= getFlushCount() { + flush() + } + } catch { + amplitude.logger?.error(message: "Error when storing event: \(error.localizedDescription)") + } + } } func flush() { + Task { + guard let storage = self.storage else { return } + await storage.rollover() + guard let eventFiles: [URL]? = await storage.read(key: StorageKey.EVENTS) else { return } + amplitude.logger?.log(message: "Start flushing \(eventCount) events") + eventCount = 0 + for eventFile in eventFiles! { + guard let eventsString = await storage.getEventsString(eventBlock: eventFile) else { + continue + } + if eventsString.isEmpty { + continue + } + let uploadTask = httpClient.upload(events: eventsString) { [weak self] result in + // TODO: handle response + switch result { + case .success(let status): + self?.amplitude.logger?.log(message: "Upload event success: \(status)") + case .failure(let error): + switch error { + case HttpClient.Exception.httpError(let code, let data): + self?.amplitude.logger?.log( + message: "Upload event error \(code): \(String(decoding: data!, as: UTF8.self))" + ) + default: + self?.amplitude.logger?.log(message: "\(error.localizedDescription)") + } + } + } + if let upload = uploadTask { + add(uploadTask: UploadTaskInfo(events: eventsString, task: upload)) + } + } + } } func start() { + flushTimer?.resume() } func stop() { + flushTimer?.suspend() + } + + private func getFlushInterval() -> TimeInterval { + return TimeInterval.milliseconds(amplitude.configuration.flushIntervalMillis) + } + + private func getFlushCount() -> Int { + let count = amplitude.configuration.flushQueueSize / flushSizeDivider + return count != 0 ? count : 1 + } +} + +extension EventPipeline { + internal func cleanupUploads() { + uploadsQueue.sync { + let before = uploads.count + var newPending = uploads + newPending.removeAll { uploadInfo in + let shouldRemove = uploadInfo.task.state != .running + if shouldRemove, let cleanup = uploadInfo.cleanup { + cleanup() + } + return shouldRemove + } + uploads = newPending + let after = uploads.count + amplitude.logger?.log(message: "Cleaned up \(before - after) non-running uploads.") + } + } + + internal var pendingUploads: Int { + var uploadsCount = 0 + uploadsQueue.sync { + uploadsCount = uploads.count + } + return uploadsCount + } + + internal func add(uploadTask: UploadTaskInfo) { + uploadsQueue.sync { + uploads.append(uploadTask) + } } } diff --git a/Sources/Amplitude/Utilities/HttpClient.swift b/Sources/Amplitude/Utilities/HttpClient.swift index 30f088e6..40f99092 100644 --- a/Sources/Amplitude/Utilities/HttpClient.swift +++ b/Sources/Amplitude/Utilities/HttpClient.swift @@ -18,12 +18,13 @@ class HttpClient { self.session = URLSession.shared } - func upload(events: String, completion: @escaping (_ result: Result) -> Void) { + func upload(events: String, completion: @escaping (_ result: Result) -> Void) -> URLSessionDataTask? { + var sessionTask: URLSessionDataTask? do { let request = try getRequest() let requestData = getRequestData(events: events) - let sessionTask = session.uploadTask(with: request, from: requestData) { data, response, error in + sessionTask = session.uploadTask(with: request, from: requestData) { data, response, error in if error != nil { completion(.failure(error!)) } else if let httpResponse = response as? HTTPURLResponse { @@ -35,10 +36,11 @@ class HttpClient { } } } - sessionTask.resume() + sessionTask!.resume() } catch { completion(.failure(Exception.httpError(code: 500, data: nil))) } + return sessionTask } func getUrl() -> String { diff --git a/Sources/Amplitude/Utilities/QueueTimer.swift b/Sources/Amplitude/Utilities/QueueTimer.swift new file mode 100644 index 00000000..83e29420 --- /dev/null +++ b/Sources/Amplitude/Utilities/QueueTimer.swift @@ -0,0 +1,85 @@ +// +// QueueTimer.swift +// +// +// Created by Marvin Liu on 11/29/22. +// + +import Foundation + +internal class QueueTimer { + enum State { + case suspended + case resumed + } + + let interval: TimeInterval + let timer: DispatchSourceTimer + let queue: DispatchQueue + let handler: () -> Void + + @Atomic var state: State = .suspended + + static var timers = [QueueTimer]() + + static func schedule(interval: TimeInterval, queue: DispatchQueue = .main, handler: @escaping () -> Void) { + let timer = QueueTimer(interval: interval, queue: queue, handler: handler) + Self.timers.append(timer) + } + + init(interval: TimeInterval, queue: DispatchQueue = .main, handler: @escaping () -> Void) { + self.interval = interval + self.queue = queue + self.handler = handler + + timer = DispatchSource.makeTimerSource(flags: [], queue: queue) + timer.schedule(deadline: .now() + self.interval, repeating: self.interval) + timer.setEventHandler { [weak self] in + self?.handler() + } + resume() + } + + deinit { + timer.setEventHandler { + // do nothing ... + } + // if timer is suspended, we must resume if we're going to cancel. + timer.cancel() + resume() + } + + func suspend() { + if state == .suspended { + return + } + state = .suspended + timer.suspend() + } + + func resume() { + if state == .resumed { + return + } + state = .resumed + timer.resume() + } +} + +extension TimeInterval { + static func milliseconds(_ value: Int) -> TimeInterval { + return TimeInterval(value / 1000) + } + + static func seconds(_ value: Int) -> TimeInterval { + return TimeInterval(value) + } + + static func hours(_ value: Int) -> TimeInterval { + return TimeInterval(60 * value) + } + + static func days(_ value: Int) -> TimeInterval { + return TimeInterval((60 * value) * 24) + } +} diff --git a/Tests/AmplitudeTests/Storages/PersistentStorageTests.swift b/Tests/AmplitudeTests/Storages/PersistentStorageTests.swift index 69ffa4d3..b681d066 100644 --- a/Tests/AmplitudeTests/Storages/PersistentStorageTests.swift +++ b/Tests/AmplitudeTests/Storages/PersistentStorageTests.swift @@ -31,14 +31,14 @@ final class PersistentStorageTests: XCTestCase { func testWrite() async { let persistentStorage = PersistentStorage(apiKey: "xxx-api-key") try? await persistentStorage.write( - key: PersistentStorage.StorageKey.EVENTS, + key: StorageKey.EVENTS, value: BaseEvent(eventType: "test1") ) try? await persistentStorage.write( - key: PersistentStorage.StorageKey.EVENTS, + key: StorageKey.EVENTS, value: BaseEvent(eventType: "test2") ) - let eventFiles: [URL]? = await persistentStorage.read(key: PersistentStorage.StorageKey.EVENTS) + let eventFiles: [URL]? = await persistentStorage.read(key: StorageKey.EVENTS) XCTAssertEqual(eventFiles?[0].absoluteString.contains("xxx-api-key.events.index"), true) XCTAssertNotEqual(eventFiles?[0].pathExtension, PersistentStorage.TEMP_FILE_EXTENSION) await persistentStorage.reset() diff --git a/Tests/AmplitudeTests/Utilities/HttpClientTests.swift b/Tests/AmplitudeTests/Utilities/HttpClientTests.swift index 21aa8a0a..030faca5 100644 --- a/Tests/AmplitudeTests/Utilities/HttpClientTests.swift +++ b/Tests/AmplitudeTests/Utilities/HttpClientTests.swift @@ -47,7 +47,7 @@ final class HttpClientTests: XCTestCase { let httpClient = HttpClient(configuration: configuration) let asyncExpectation = expectation(description: "Async function") let event1 = BaseEvent(userId: "unit-test user", deviceId: "unit-test device", eventType: "unit-test event") - httpClient.upload(events: "[\(event1.toString())]") { result in + _ = httpClient.upload(events: "[\(event1.toString())]") { result in guard case .failure(let error) = result else { return XCTFail("not getting upload failure") } From a2b9fcf30d631a41c54f41a7b27d55d71389b239 Mon Sep 17 00:00:00 2001 From: Marvin Liu Date: Wed, 30 Nov 2022 15:26:56 -0800 Subject: [PATCH 5/7] address comments, add unit tests --- .../Storages/PersistentStorage.swift | 13 ++-- .../Amplitude/Utilities/EventPipeline.swift | 10 ++-- .../Storages/PersistentStorageTests.swift | 6 ++ .../Supports/TestUtilities.swift | 60 +++++++++++++++++++ .../Utilities/EventPipelineTests.swift | 58 ++++++++++++++++++ 5 files changed, 134 insertions(+), 13 deletions(-) create mode 100644 Tests/AmplitudeTests/Utilities/EventPipelineTests.swift diff --git a/Sources/Amplitude/Storages/PersistentStorage.swift b/Sources/Amplitude/Storages/PersistentStorage.swift index bfe573e1..e32b6d3b 100644 --- a/Sources/Amplitude/Storages/PersistentStorage.swift +++ b/Sources/Amplitude/Storages/PersistentStorage.swift @@ -88,7 +88,7 @@ actor PersistentStorage: Storage { result = true } else { switch value { - case is NSNull, is Decimal, is NSNumber, is Bool, is String: + case is NSNull, is Int, is Float, is Double, is Decimal, is NSNumber, is Bool, is String, is NSString: result = true default: break @@ -230,14 +230,9 @@ extension PersistentStorage { // this can happen if an instance was terminated before finishing a file. do { outputStream = try OutputFileStream(fileURL: file) - } catch { - amplitude?.logger?.error(message: error.localizedDescription) - } - } - - if let outputStream = outputStream { - do { - try outputStream.open() + if let outputStream = outputStream { + try outputStream.open() + } } catch { amplitude?.logger?.error(message: error.localizedDescription) } diff --git a/Sources/Amplitude/Utilities/EventPipeline.swift b/Sources/Amplitude/Utilities/EventPipeline.swift index 96d13240..ccc4acbe 100644 --- a/Sources/Amplitude/Utilities/EventPipeline.swift +++ b/Sources/Amplitude/Utilities/EventPipeline.swift @@ -9,7 +9,7 @@ import Foundation class EventPipeline { let amplitude: Amplitude - let httpClient: HttpClient + var httpClient: HttpClient var storage: Storage? { amplitude.storage } @Atomic internal var eventCount: Int = 0 @Atomic internal var flushSizeDivider: Int = 1 @@ -33,7 +33,7 @@ class EventPipeline { } } - func put(event: BaseEvent) { + func put(event: BaseEvent, completion: (() -> Void)? = nil) { guard let storage = self.storage else { return } event.attempts += 1 Task { @@ -43,13 +43,14 @@ class EventPipeline { if eventCount >= getFlushCount() { flush() } + completion?() } catch { amplitude.logger?.error(message: "Error when storing event: \(error.localizedDescription)") } } } - func flush() { + func flush(completion: (() -> Void)? = nil) { Task { guard let storage = self.storage else { return } await storage.rollover() @@ -64,7 +65,7 @@ class EventPipeline { continue } let uploadTask = httpClient.upload(events: eventsString) { [weak self] result in - // TODO: handle response + // TODO: handle response and add retry logic switch result { case .success(let status): self?.amplitude.logger?.log(message: "Upload event success: \(status)") @@ -83,6 +84,7 @@ class EventPipeline { add(uploadTask: UploadTaskInfo(events: eventsString, task: upload)) } } + completion?() } } diff --git a/Tests/AmplitudeTests/Storages/PersistentStorageTests.swift b/Tests/AmplitudeTests/Storages/PersistentStorageTests.swift index b681d066..0ca58b54 100644 --- a/Tests/AmplitudeTests/Storages/PersistentStorageTests.swift +++ b/Tests/AmplitudeTests/Storages/PersistentStorageTests.swift @@ -15,12 +15,18 @@ final class PersistentStorageTests: XCTestCase { var isValueBasicType = await persistentStorage.isBasicType(value: 111) XCTAssertEqual(isValueBasicType, true) + isValueBasicType = await persistentStorage.isBasicType(value: 11.11) + XCTAssertEqual(isValueBasicType, true) + isValueBasicType = await persistentStorage.isBasicType(value: true) XCTAssertEqual(isValueBasicType, true) isValueBasicType = await persistentStorage.isBasicType(value: "test") XCTAssertEqual(isValueBasicType, true) + isValueBasicType = await persistentStorage.isBasicType(value: NSString("test")) + XCTAssertEqual(isValueBasicType, true) + isValueBasicType = await persistentStorage.isBasicType(value: nil) XCTAssertEqual(isValueBasicType, true) diff --git a/Tests/AmplitudeTests/Supports/TestUtilities.swift b/Tests/AmplitudeTests/Supports/TestUtilities.swift index 6cdfc85f..24f62c59 100644 --- a/Tests/AmplitudeTests/Supports/TestUtilities.swift +++ b/Tests/AmplitudeTests/Supports/TestUtilities.swift @@ -1,3 +1,5 @@ +import Foundation + @testable import Amplitude_Swift class TestEnrichmentPlugin: Plugin { @@ -44,3 +46,61 @@ class OutputReaderPlugin: Plugin { return event } } + +actor FakeInMemoryStorage: Storage { + var keyValueStore = [String: Any?]() + var eventsStore = [URL: [BaseEvent]]() + var index = URL(string: "0")! + + func write(key: StorageKey, value: Any?) async throws { + switch key { + case .EVENTS: + if let event = value as? BaseEvent { + var chunk = eventsStore[index, default: [BaseEvent]()] + chunk.append(event) + eventsStore[index] = chunk + } + default: + keyValueStore[key.rawValue] = value + } + } + + func read(key: StorageKey) async -> T? { + var result: T? + switch key { + case .EVENTS: + result = Array(eventsStore.keys) as? T + default: + result = keyValueStore[key.rawValue] as? T + } + return result + } + + func getEventsString(eventBlock: Any) async -> String? { + var content: String? + guard let eventBlock = eventBlock as? URL else { return content } + content = "[" + content = content! + (eventsStore[eventBlock] ?? []).map { $0.toString() }.joined(separator: ", ") + content = content! + "]" + return content + } + + func rollover() async { + } + + func reset() async { + keyValueStore.removeAll() + eventsStore.removeAll() + } +} + +class FakeHttpClient: HttpClient { + var isUploadCalled: Bool = false + + override func upload(events: String, completion: @escaping (_ result: Result) -> Void) + -> URLSessionDataTask? + { + isUploadCalled = true + return nil + } +} diff --git a/Tests/AmplitudeTests/Utilities/EventPipelineTests.swift b/Tests/AmplitudeTests/Utilities/EventPipelineTests.swift new file mode 100644 index 00000000..e0f99077 --- /dev/null +++ b/Tests/AmplitudeTests/Utilities/EventPipelineTests.swift @@ -0,0 +1,58 @@ +// +// EventPipelineTests.swift +// +// +// Created by Marvin Liu on 11/30/22. +// + +import XCTest + +@testable import Amplitude_Swift + +final class EventPipelineTests: XCTestCase { + private var configuration: Configuration! + private var amplitude: Amplitude! + private var eventPipeline: EventPipeline! + + override func setUp() { + super.setUp() + configuration = Configuration( + apiKey: "testApiKey", + flushIntervalMillis: 1000, + storageProvider: FakeInMemoryStorage() + ) + amplitude = Amplitude(configuration: configuration) + eventPipeline = EventPipeline(amplitude: amplitude) + } + + func testInit() { + XCTAssertEqual(eventPipeline.amplitude.configuration.apiKey, amplitude.configuration.apiKey) + } + + func testPutEvent() { + let testEvent = BaseEvent(userId: "unit-test", deviceId: "unit-test-machine", eventType: "testEvent") + + let asyncExpectation = expectation(description: "Async function") + eventPipeline.put(event: testEvent) { + asyncExpectation.fulfill() + XCTAssertEqual(self.eventPipeline.eventCount, 1) + } + XCTAssertEqual(testEvent.attempts, 1) + _ = XCTWaiter.wait(for: [asyncExpectation], timeout: 3) + } + + func testFlush() async { + let testEvent = BaseEvent(userId: "unit-test", deviceId: "unit-test-machine", eventType: "testEvent") + try? await eventPipeline.storage?.write(key: StorageKey.EVENTS, value: testEvent) + + let fakeHttpClient = FakeHttpClient(configuration: configuration) + eventPipeline.httpClient = fakeHttpClient as HttpClient + + let asyncExpectation = expectation(description: "Async function") + eventPipeline.flush { + asyncExpectation.fulfill() + XCTAssertEqual(fakeHttpClient.isUploadCalled, true) + } + _ = XCTWaiter.wait(for: [asyncExpectation], timeout: 3) + } +} From 3c0ec2eff7375833b8333455da8f9f77473e3b40 Mon Sep 17 00:00:00 2001 From: Marvin Liu Date: Wed, 30 Nov 2022 16:02:16 -0800 Subject: [PATCH 6/7] remove example readme --- Examples/Apps/IOSExample/README.md | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 Examples/Apps/IOSExample/README.md diff --git a/Examples/Apps/IOSExample/README.md b/Examples/Apps/IOSExample/README.md deleted file mode 100644 index 4dbc429c..00000000 --- a/Examples/Apps/IOSExample/README.md +++ /dev/null @@ -1,4 +0,0 @@ -## iOS Example -TODO: This is the location to put iOS Example. - -However, got some problem when creating a new Swift Project here. Tried to convert the SDK Package to Project then delete the folder reference to only track file changes by git. Got some other problems when converting the Package to Project. Leave a comment here to revisit. From 9d4589c9d3064705132af53f9d829451a01f5515 Mon Sep 17 00:00:00 2001 From: Marvin Liu Date: Thu, 1 Dec 2022 13:18:42 -0800 Subject: [PATCH 7/7] update mediator, remove flushSizeDivider --- Sources/Amplitude/Mediator.swift | 1 - Sources/Amplitude/Utilities/EventPipeline.swift | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Sources/Amplitude/Mediator.swift b/Sources/Amplitude/Mediator.swift index 05854231..9b2e23ce 100644 --- a/Sources/Amplitude/Mediator.swift +++ b/Sources/Amplitude/Mediator.swift @@ -26,7 +26,6 @@ internal class Mediator { if let p = plugin as? DestinationPlugin { _ = p.process(event: r) } else if let p = plugin as? EventPlugin { - result = p.execute(event: r) if let rr = result { if let identifyEvent = rr as? IdentifyEvent { result = p.identify(event: identifyEvent) diff --git a/Sources/Amplitude/Utilities/EventPipeline.swift b/Sources/Amplitude/Utilities/EventPipeline.swift index ccc4acbe..c82d1075 100644 --- a/Sources/Amplitude/Utilities/EventPipeline.swift +++ b/Sources/Amplitude/Utilities/EventPipeline.swift @@ -12,7 +12,6 @@ class EventPipeline { var httpClient: HttpClient var storage: Storage? { amplitude.storage } @Atomic internal var eventCount: Int = 0 - @Atomic internal var flushSizeDivider: Int = 1 internal var flushTimer: QueueTimer? private let uploadsQueue = DispatchQueue(label: "uploadsQueue.amplitude.com") @@ -101,7 +100,7 @@ class EventPipeline { } private func getFlushCount() -> Int { - let count = amplitude.configuration.flushQueueSize / flushSizeDivider + let count = amplitude.configuration.flushQueueSize return count != 0 ? count : 1 } }