diff --git a/.swiftlint.yml b/.swiftlint.yml index 527f7a2d..da57db44 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,8 +1,17 @@ disabled_rules: - function_body_length + - type_body_length - trailing_comma + - opening_brace - todo 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/.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..7a0fe8b7 100644 --- a/Sources/Amplitude/Amplitude.swift +++ b/Sources/Amplitude/Amplitude.swift @@ -5,13 +5,13 @@ 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 = { 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 34e21996..6bb4fa97 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)? = 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/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..8b124a2c 100644 --- a/Sources/Amplitude/Events/BaseEvent.swift +++ b/Sources/Amplitude/Events/BaseEvent.swift @@ -7,19 +7,61 @@ 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, - 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, @@ -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(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) + 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/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..9b2e23ce 100644 --- a/Sources/Amplitude/Mediator.swift +++ b/Sources/Amplitude/Mediator.swift @@ -23,10 +23,9 @@ 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 { if let identifyEvent = rr as? IdentifyEvent { result = p.identify(event: identifyEvent) 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/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 } func reset() async { } + + 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 5eb7e44f..e32b6d3b 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. @@ -11,22 +11,256 @@ 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: 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 read(key: String) async -> Any? { - return nil + 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 + keys?.forEach { key in + userDefaults?.removeObject(forKey: key) + } + for url in urls { + try? fileManager!.removeItem(atPath: url.path) + } + } + + 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 { + result = true + } else { + switch value { + case is NSNull, is Int, is Float, is Double, is Decimal, is NSNumber, is Bool, is String, is NSString: + 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 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) + if let outputStream = outputStream { + 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/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 668d10be..b25244b0 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,11 +22,21 @@ public protocol EventCallBack { } public protocol Storage { - func write(key: String, value: Any?) async - func read(key: String) async -> Any? + 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/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/EventPipeline.swift b/Sources/Amplitude/Utilities/EventPipeline.swift index 072c0632..c82d1075 100644 --- a/Sources/Amplitude/Utilities/EventPipeline.swift +++ b/Sources/Amplitude/Utilities/EventPipeline.swift @@ -8,22 +8,132 @@ import Foundation class EventPipeline { - var amplitude: Amplitude - var httpClient: HttpClient = HttpClient() + let amplitude: Amplitude + var httpClient: HttpClient + var storage: Storage? { amplitude.storage } + @Atomic internal var eventCount: Int = 0 + 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) { + func put(event: BaseEvent, completion: (() -> Void)? = nil) { + 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() + } + 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() + 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 and add retry logic + 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)) + } + } + completion?() + } } 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 + 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 8b0490ec..40f99092 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,90 @@ 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) -> URLSessionDataTask? { + var sessionTask: URLSessionDataTask? + do { + let request = try getRequest() + let requestData = getRequestData(events: events) + + 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))) + } + return sessionTask + } + + 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/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/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/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..0ca58b54 --- /dev/null +++ b/Tests/AmplitudeTests/Storages/PersistentStorageTests.swift @@ -0,0 +1,52 @@ +// +// 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: 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) + + isValueBasicType = await persistentStorage.isBasicType(value: Date()) + XCTAssertEqual(isValueBasicType, false) + } + + func testWrite() async { + let persistentStorage = PersistentStorage(apiKey: "xxx-api-key") + try? await persistentStorage.write( + key: StorageKey.EVENTS, + value: BaseEvent(eventType: "test1") + ) + try? await persistentStorage.write( + key: StorageKey.EVENTS, + value: BaseEvent(eventType: "test2") + ) + 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/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/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() { 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) + } +} diff --git a/Tests/AmplitudeTests/Utilities/HttpClientTests.swift b/Tests/AmplitudeTests/Utilities/HttpClientTests.swift new file mode 100644 index 00000000..030faca5 --- /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() + } + _ = XCTWaiter.wait(for: [asyncExpectation], timeout: 5) + } +}