diff --git a/README.md b/README.md index 31d2345..20f225d 100644 --- a/README.md +++ b/README.md @@ -7,30 +7,35 @@ # 🔬 Telemetry -> Telemetry is an open SDK for google analytics. Because closed source sdks are terrible for security +> Telemetry is an open-source SDK for Google Analytics. We believe in transparency and security, hence the open-source approach. -### Reasoning -- 🤖 GA is a great way to gather real engagement data and measure traction. In order to improve it. -- 🐛 GA is great for error and crash reporting. Get ahead of UX issues by getting notified if there are bugs -- 🌍 GA is great for measuring which markets that are using your app. + +## Installation +You can install Telemetry via Swift Package Manager (SPM) using the following URL: `https://github.com/sentryco/Telemetry`. + +### Why Use Telemetry? +- 🤖 Google Analytics (GA) provides valuable insights into user engagement and traction. Telemetry helps you leverage these insights to improve your application. +- 🐛 GA is an excellent tool for error and crash reporting. Stay ahead of UX issues by receiving notifications about bugs. +- 🌍 GA helps you understand the geographical distribution of your app's users. > **Warning** -> Currenly using GA3 API which is sunset in July. This lib will migrate to GA4 before that +> We currently use the GA3 API, which will be deprecated in July. We plan to migrate to GA4 before then. -### Events -With the Telemetry's event() method, you can monitor any event you want. +### Event Tracking +With Telemetry's `event()` method, you can monitor any event of interest. ```swift Telemetry.event("Authorize", action: "Access granted") ``` ### Screenviews -You should frequently keep track of the "screens" the user navigates to. Your ViewController's viewDidAppear is a logical place to do that. The Telemetry's screenView() method can be used; it performs the same function as event(). +It's important to track the "screens" that users navigate to. A logical place to do this is in your ViewController's `viewDidAppear` method. Use Telemetry's `screenView()` method for this purpose. ```swift Telemetry.screenView("Cheers") ``` ### Sessions -By calling session(start: true) when the user opens the application and session(start: false) when they close it, you can keep track of each user's individual sessions. You can do this in your UIApplicationDelegate application by following the example given here:. +By calling `session(start: true)` when the application opens and `session(start: false)` when it closes, you can track individual user sessions. Here's an example of how to do this in your `UIApplicationDelegate` application: + ```swift Telemetry.trackerID = "UA-XXXXX-XX") @@ -39,30 +44,44 @@ Telemetry.session(start: false) // applicationDidEnterBackground ``` ### Exception -Use exception to report warnings and errors +Use the `exception` method to report warnings and errors. ```swift Telemetry.exception("Error - database not available", isFatal: false) ``` ### Timing +This example tracks the time to fetch user data from a database. The 'category' parameter denotes the operation type ("Database"). The 'variable' parameter specifies the operation ("Fetch"). The 'time' parameter records elapsed time in milliseconds. The optional 'label' parameter can provide additional details. + ```swift -// Add example for this later +// Start the timer +let startTime = Date() + +// Perform some operation +// ... + +// Calculate the elapsed time +let elapsedTime = Date().timeIntervalSince(startTime) + +// Log the timing event +Telemetry.timing(category: "Database", variable: "Fetch", time: elapsedTime, label: "User data fetch") ``` ### Gotchas: -- Telemetry will automatically request that Google Analytics anonymize user IPs in order to comply with GDPR. -The token can be obtained from the admin page of the tracked Google Analytics entity. -- Firebase crashlytics is the way to go now days. Its also free to use etc. But can be over the top complex. You have to use their SDK etc. Sometimes simple is better etc. -- When setting up google analytics account. Make sure to use legacy `Universal Analytics property` and not GA4. This legacy option is under advance menu when you setup the account -- Why are closed source sdks bad? From apples app-review guidelines: `Ensure that all software frameworks and dependencies also adhere to the App Store Review Guidelines` +- Telemetry automatically requests Google Analytics to anonymize user IPs to comply with GDPR. +- Obtain the token from the admin page of the tracked Google Analytics entity. +- While Firebase Crashlytics is a popular choice, it can be complex due to the need to use their SDK. Sometimes, simplicity is key. +- When setting up your Google Analytics account, ensure to use the legacy `Universal Analytics property` and not GA4. This legacy option is under the advanced menu during account setup. +- Why are closed-source SDKs a concern? According to Apple's app-review guidelines: `Ensure that all software frameworks and dependencies also adhere to the App Store Review Guidelines`. + ### Resources: - Anonymous GA: https://stackoverflow.com/questions/50392242/how-anonymize-google-analytics-for-ios-for-gdpr-rgpd-purpose - Guide on fingerprinting in iOS: https://nshipster.com/device-identifiers/ - Guide on identifiers: https://gist.github.com/hujunfeng/6265995 -- Nice tracker project: https://github.com/kafejo/Tracker-Aggregator -- Another nice tracker project: https://github.com/devxoul/Umbrella +- Noteworthy tracker project: https://github.com/kafejo/Tracker-Aggregator +- Another noteworthy tracker project: https://github.com/devxoul/Umbrella - Using Google Analytics for Tracking SaaS: https://reflectivedata.com/using-google-analytics-for-tracking-saas/ + ### Todo: - Add documentation to this readme on how to setup Google analytics for your google account etc 🚧 diff --git a/Sources/Telemetry/Telemetry+Action.swift b/Sources/Telemetry/Telemetry+Action.swift index edb95ee..04f6d68 100644 --- a/Sources/Telemetry/Telemetry+Action.swift +++ b/Sources/Telemetry/Telemetry+Action.swift @@ -3,18 +3,24 @@ import Foundation extension Telemetry { /** * Action call that can take type - * - Remark: The class support tracking of sessions, screen/page views, events and timings with optional custom dimension parameters. - * - Remark: For a full list of all the supported parameters please refer to the [Google Analytics parameter reference](https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters) - * - Fixme: ⚠️️ Add example + * - Remark: This class supports tracking of sessions, screen/page views, events, and timings with optional custom dimension parameters. + * - Remark: For a comprehensive list of all the supported parameters, please refer to the [Google Analytics parameter reference](https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters) + * - Fixme: ⚠️️ Add an example here * - Parameters: - * - action: - Fixme: ⚠️️ Add doc - * - complete: Only use complete for the GA type + * - action: The action to be performed - Fixme: ⚠️️ Add more documentation here + * - complete: Use complete only for the GA type */ public static func action(_ action: ActionKind, complete: Complete? = nil) { + // Check if the telemetry type is Google Analytics if case TMType.ga = tmType { + // Send the action to Google Analytics send(type: action.key, parameters: action.output, complete: complete) } else if case TMType.agg(let agg) = tmType { - do { try agg.append(action: action) } + // If the telemetry type is not Google Analytics, append the action to the aggregator + do { + try agg.append(action: action) + } + // Catch and print any errors that occur when appending the action catch { Swift.print("Error: \(error.localizedDescription)") } } } @@ -23,32 +29,49 @@ extension Telemetry { * Helper */ extension Telemetry { - /** - * - Remark: If you get: swift a server with the specified hostname could not be found. Make sure to enable outgoing network in sandbox: https://stackoverflow.com/a/57292829/5389500 + /** + * - Remark: If you encounter: swift a server with the specified hostname could not be found. Ensure to enable outgoing network in sandbox: https://stackoverflow.com/a/57292829/5389500 * - Parameters: - * - parameters: custom params for the type - * - type: timing, exception, pageview, session, exception etc - * - complete: Call back with success or not. Useful for Unit-testing when dealing with async code etc + * - parameters: Custom parameters for the type + * - type: The type of telemetry data (timing, exception, pageview, session, exception etc) + * - complete: Callback with success or failure. Useful for Unit-testing when dealing with asynchronous code etc */ internal static func send(type: String?, parameters: [String: String], complete: Complete? = nil) { - var queryArgs = Self.queryArgs // Meta data + // Initialize query arguments with metadata + var queryArgs = Self.queryArgs + + // If type is not nil and not empty, add it to the query arguments if let type: String = type, !type.isEmpty { queryArgs.updateValue(type, forKey: "t") } + + // If custom dimensions are not nil, merge them into the query arguments if let customDim: [String: String] = self.customDimArgs { queryArgs.merge(customDim) { _, new in new } } + + // Update the "aip" key in the query arguments based on the anonymizeIP flag queryArgs["aip"] = anonymizeIP ? "1" : nil + + // Combine the query arguments with the parameters let arguments: [String: String] = queryArgs.combinedWith(parameters) + + // Generate a URL with the arguments, return if URL generation fails guard let url: URL = Self.getURL(with: arguments) else { return } + + // Create a data task with the URL let task = session.dataTask(with: url) { _, _, error in + // If there is an error, print it and call the completion handler with false if let errorResponse = error?.localizedDescription { - // - Fixme: ⚠️️ shorten error respons to mac 20-30 chars? Swift.print("⚠️️ Failed to deliver GA Request. ", errorResponse) complete?(false) } + + // If there is no error, call the completion handler with true complete?(true) } + + // Start the data task task.resume() } } @@ -58,6 +81,7 @@ extension Telemetry { extension Telemetry { /** * URL query (Meta data) + * - Remark: This dictionary contains the metadata for the URL query */ fileprivate static var queryArgs: [String: String] { [ @@ -74,22 +98,29 @@ extension Telemetry { } /** * URL - * - Parameter parameters: parameters to turn into a url request + * - Parameter parameters: Parameters to convert into a URL request + * - Remark: This function generates a URL from the given parameters */ fileprivate static func getURL(with parameters: [String: String]) -> URL? { + // Define the character set for URL path let characterSet = CharacterSet.urlPathAllowed + // Join the parameters into a string, encoding the values for URL let joined: String = parameters.reduce("collect?") { path, query in + // Encode the value of each parameter let value = query.value.addingPercentEncoding(withAllowedCharacters: characterSet) + // Return the path with the key-value pair appended return .init(format: "%@%@=%@&", path, query.key, value ?? "") } - // Trim the trailing & + // Trim the trailing '&' from the joined string let path: String = .init(joined[.. Void + + // Base URL for Google Analytics internal static let baseURL: URL? = .init(string: "https://www.google-analytics.com/") } + /** - * variables + * Variables for Telemetry */ extension Telemetry { /** - * Users IP should be anonymized - * - Description: In order to be GDPR compliant, Telemetry will ask Google Analytics to anonymize users IP's by default. If you wish to opt-out of this you will neeed to set anonymizeIP to false. + * Flag to anonymize user's IP + * - Description: To ensure GDPR compliance, Telemetry requests Google Analytics to anonymize user IPs by default. Set this to false to opt-out. */ public static var anonymizeIP = true + /** * Google Analytics Identifier (Tracker ID) - * - Remark: The token can be obtained from the admin page of the tracked Google Analytics entity. - * - Remark: A valid Google Analytics tracker ID of form UA-XXXXX-XX must be set before reporting any events. + * - Remark: This token can be obtained from the Google Analytics entity's admin page. + * - Remark: A valid Google Analytics tracker ID (format: UA-XXXXX-XX) must be set before reporting any events. */ public static var trackerId: String = "UA-XXXXX-XX" + /** * Custom dimension arguments - * - Description: Dictionary of custom key value pairs to add to every query. - * - Remark: Use it for custom dimensions (cd1, cd2...). - * - Note: More information on Custom Dimensions https://support.google.com/analytics/answer/2709828?hl=en + * - Description: A dictionary of custom key-value pairs to be added to every query. + * - Remark: Useful for custom dimensions (cd1, cd2...). + * - Note: For more information on Custom Dimensions, visit https://support.google.com/analytics/answer/2709828?hl=en */ public static var customDimArgs: [String: String]? + /** - * Options are: .vendor, .userdef, .keychain - * - Remark: Type of persistence - * - Fixme: ⚠️️ Rename to currentIdentifierType? or curIdType? + * Identifier type + * - Remark: Defines the type of persistence. Options are: .vendor, .userdef, .keychain + * - Fixme: Consider renaming to currentIdentifierType or curIdType */ public static var idType: IDType = .userdefault + /** - * Network, rename to urlSession + * Network session + * - Remark: Consider renaming to urlSession */ public static let session = URLSession.shared + /** * Telemetry type - * - Description: A way to switch from ga-endpoint to aggregator-endpoint - * - Fixme: ⚠️️ Rename to endPointType? EPType ? Maybe + * - Description: Allows switching between ga-endpoint and aggregator-endpoint + * - Fixme: Consider renaming to endPointType or EPType */ public static var tmType: TMType = .ga // .agg() -} +} \ No newline at end of file diff --git a/Sources/Telemetry/Telemetry.swift b/Sources/Telemetry/Telemetry.swift index c126f2a..ebf4add 100644 --- a/Sources/Telemetry/Telemetry.swift +++ b/Sources/Telemetry/Telemetry.swift @@ -1,8 +1,11 @@ import Foundation + /** - * With the help of Telemetry, Google Analytics is able to track events and screen views. The Google Analytics Measurement protocol, which is, is used in the class. (https://developers.google.com/analytics/devguides/collection/protocol/v1/reference). - * - Remark: Since Google has officially discontinued the ability to track mobile analytics through Google Analytics, new apps are urged to use Firebase. - * - Remark: This library transforms screen views into pageviews, and you must configure new tracking properties as websites in the Google Analytics admin console. - * - Remark: The app bundle identifier, which can be set to any custom value for privacy purposes, will be used as a dummy hostname for tracking pageviews and screen views. + * The Telemetry class facilitates event and screen view tracking using the Google Analytics Measurement protocol. + * More details can be found here: https://developers.google.com/analytics/devguides/collection/protocol/v1/reference + * + * - Note: Google has officially discontinued mobile analytics tracking through Google Analytics. It is recommended for new apps to use Firebase instead. + * - Note: This library converts screen views into pageviews. Therefore, new tracking properties must be configured as websites in the Google Analytics admin console. + * - Note: The app bundle identifier, which can be customized for privacy reasons, is used as a dummy hostname for tracking pageviews and screen views. */ -public class Telemetry {} +public class Telemetry {} \ No newline at end of file diff --git a/Sources/Telemetry/ext/Dict+Ext.swift b/Sources/Telemetry/ext/Dict+Ext.swift index 1e0e124..809eb9c 100644 --- a/Sources/Telemetry/ext/Dict+Ext.swift +++ b/Sources/Telemetry/ext/Dict+Ext.swift @@ -2,11 +2,11 @@ import Foundation extension Dictionary { /** - * - Fixme: ⚠️️ Add doc * - Fixme: ⚠️️ We could also use reduce like shown here: https://stackoverflow.com/a/75548503/5389500 * - Fixme: ⚠️️ We can even add it to a + operator like: `func + (a: Self, b: Self) -> Self {...}` - * - Parameter other: - Fixme: ⚠️️ Add doc - * - Returns: - Fixme: ⚠️️ Add doc + * Combines the current dictionary with another dictionary. + * - Parameter other: The dictionary to be combined with. + * - Returns: A new dictionary that contains the combined key-value pairs. */ func combinedWith(_ other: [Key: Value]) -> [Key: Value] { var dict = self diff --git a/Sources/Telemetry/ext/Keychain.swift b/Sources/Telemetry/ext/Keychain.swift index 77b52f1..94fc238 100644 --- a/Sources/Telemetry/ext/Keychain.swift +++ b/Sources/Telemetry/ext/Keychain.swift @@ -1,32 +1,38 @@ import Foundation + /** - * A simple getter and setter wrapper for keychain - * - Note: From here: https://stackoverflow.com/a/51642962/5389500 - * - Fixme: ⚠️️ Add examples + * A class that provides a simple getter and setter wrapper for keychain. + * This class is used to securely store and retrieve data from the keychain. + * - Note: This code is adapted from: https://stackoverflow.com/a/51642962/5389500 */ internal class Keychain { /** - * Set value for key in keychain + * Set value for key in keychain. + * This function is used to store a string value in the keychain associated with a given key. * - Parameters: - * - key: - Fixme: ⚠️️ add doc - * - value: - Fixme: ⚠️️ add doc + * - key: The key with which the value is associated in the keychain. + * - value: The string value to be stored in the keychain. */ internal static func set(key: String, value: String) throws { + // Convert the string value to data guard let valueData = value.data(using: .utf8) else { Swift.print("Keychain: Unable to store data, invalid input - key: \(key), value: \(value)") return } - do { // Delete old value if stored first + // Try to delete any existing value associated with the key + do { try delete(itemKey: key) } catch { Swift.print("Keychain: nothing to delete...") } + // Define the query for adding the item to the keychain let queryAdd: [String: AnyObject] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key as AnyObject, - kSecValueData as String: valueData as AnyObject, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked + kSecClass as String: kSecClassGenericPassword, // Define the class of the item that this query will add + kSecAttrAccount as String: key as AnyObject, // Define the account attribute of the item + kSecValueData as String: valueData as AnyObject, // Define the data to be stored in the keychain + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked // Define when the item is accessible ] + // Add the item to the keychain let resultCode: OSStatus = SecItemAdd(queryAdd as CFDictionary, nil) if resultCode != 0 { print("Keychain: value not added - Error: \(resultCode)") @@ -35,16 +41,21 @@ internal class Keychain { } } /** - * Get value from keychain + * Get value from keychain. + * This function is used to retrieve a string value from the keychain associated with a given key. + * - Parameter key: The key with which the value is associated in the keychain. + * - Returns: The string value associated with the key, or nil if no value is found. */ internal static func get(key: String) throws -> String? { + // Define the query for loading the item from the keychain let queryLoad: [String: AnyObject] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key as AnyObject, - kSecReturnData as String: kCFBooleanTrue, - kSecMatchLimit as String: kSecMatchLimitOne + kSecClass as String: kSecClassGenericPassword, // Define the class of the item that this query will load + kSecAttrAccount as String: key as AnyObject, // Define the account attribute of the item + kSecReturnData as String: kCFBooleanTrue, // Define that the data of the item should be returned + kSecMatchLimit as String: kSecMatchLimitOne // Define that only one item should be returned ] var result: AnyObject? + // Load the item from the keychain let resultCodeLoad = withUnsafeMutablePointer(to: &result) { SecItemCopyMatching(queryLoad as CFDictionary, UnsafeMutablePointer($0)) } @@ -52,6 +63,7 @@ internal class Keychain { print("Keychain: unable to load data - \(resultCodeLoad)") return nil } + // Convert the data to a string guard let resultVal = result as? NSData, let keyValue = NSString(data: resultVal as Data, encoding: String.Encoding.utf8.rawValue) as String? else { print("Keychain: error parsing keychain result - \(resultCodeLoad)") return nil @@ -59,18 +71,23 @@ internal class Keychain { return keyValue } } + /** - * Private helper + * An extension of the Keychain class that provides a helper function for deleting items from the keychain. */ extension Keychain { /** - * Delete + * Delete an item from the keychain. + * This function is used to delete the value associated with a given key from the keychain. + * - Parameter itemKey: The key of the item to be deleted from the keychain. */ fileprivate static func delete(itemKey: String) throws { + // Define the query for deleting the item from the keychain let queryDelete: [String: AnyObject] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: itemKey as AnyObject + kSecClass as String: kSecClassGenericPassword, // Define the class of the item that this query will delete + kSecAttrAccount as String: itemKey as AnyObject // Define the account attribute of the item to be deleted ] + // Delete the item from the keychain let resultCodeDelete = SecItemDelete(queryDelete as CFDictionary) if resultCodeDelete != 0 { print("Keychain: unable to delete from keychain: \(resultCodeDelete)") @@ -78,4 +95,4 @@ extension Keychain { print("Keychain: successfully deleted item") } } -} +} \ No newline at end of file diff --git a/Sources/Telemetry/type/Event.swift b/Sources/Telemetry/type/Event.swift index 135ba7d..c9e3f8c 100644 --- a/Sources/Telemetry/type/Event.swift +++ b/Sources/Telemetry/type/Event.swift @@ -1,20 +1,29 @@ import Foundation + /** - * Event + * `Event` is a struct that represents a telemetry event. + * It conforms to the `ActionKind` protocol. */ public struct Event: ActionKind { + // The category of the event public let category: String + + // The action of the event public let action: String + + // The label of the event public let label: String + + // A dictionary of additional parameters for the event public let params: [String: String] + /** - * Tracks an event to Google Analytics - * - Remark: Generic events are reported using `event(_:action:label:parameters:)` + * Initializes an `Event` instance. * - Parameters: * - category: The category of the event (ec) * - action: The action of the event (ea) - * - label: The label of the event (el) - * - params: A dictionary of additional parameters for the event + * - label: The label of the event (el). Default is an empty string. + * - params: A dictionary of additional parameters for the event. Default is an empty dictionary. */ public init(category: String, action: String, label: String = "", params: [String: String] = .init()) { self.category = category @@ -23,16 +32,23 @@ public struct Event: ActionKind { self.params = params } } + /** - * Ext + * `Event` extension that provides additional functionality. */ extension Event { + // A key that represents the event public var key: String { "event" } + + /** + * An output dictionary that includes the event's category, action, label, and additional parameters. + * - Returns: A dictionary that represents the event. + */ public var output: [String: String] { - var params: [String: String] = self.params - params["ec"] = category - params["ea"] = action - params["el"] = label - return params + var params: [String: String] = self.params // Copy the existing parameters + params["ec"] = category // Set the event category + params["ea"] = action // Set the event action + params["el"] = label // Set the event label + return params // Return the updated parameters } -} +} \ No newline at end of file diff --git a/Sources/Telemetry/type/Exception.swift b/Sources/Telemetry/type/Exception.swift index 9645dcc..f47a7d0 100644 --- a/Sources/Telemetry/type/Exception.swift +++ b/Sources/Telemetry/type/Exception.swift @@ -1,18 +1,25 @@ import Foundation + /** - * Exception + * `Exception` is a struct that represents an exception event in the system. + * It conforms to the `ActionKind` protocol. */ public struct Exception: ActionKind { + // A string that describes the exception. public let description: String + + // A boolean that indicates if the exception was fatal to the execution of the program. public let isFatal: Bool + + // A dictionary of additional parameters for the event. public let params: [String: String] + /** - * Tracks an exception event to Google Analytics - * - Remark: Exceptions are reported using `exception(_:isFatal:parameters:)` + * Initializes an instance of `Exception`. * - Parameters: - * - description: The description of the exception (ec) - * - isFatal: Indicates if the exception was fatal to the execution of the program (exf) - * - params: A dictionary of additional parameters for the event + * - description: The description of the exception. + * - isFatal: Indicates if the exception was fatal to the execution of the program. + * - params: A dictionary of additional parameters for the event. */ public init(description: String, isFatal: Bool, params: [String: String] = .init()) { self.description = description @@ -20,12 +27,19 @@ public struct Exception: ActionKind { self.params = params } } + extension Exception { + // A string that represents the key of the exception. public var key: String { "exception" } + + /** + * A dictionary that represents the output of the exception. + * It includes the description and the fatality of the exception, along with any additional parameters. + */ public var output: [String: String] { var params: [String: String] = self.params params["exd"] = description params["exf"] = String(isFatal) return params } -} +} \ No newline at end of file diff --git a/Sources/Telemetry/type/ScreenView.swift b/Sources/Telemetry/type/ScreenView.swift index 32acf86..3017590 100644 --- a/Sources/Telemetry/type/ScreenView.swift +++ b/Sources/Telemetry/type/ScreenView.swift @@ -1,13 +1,21 @@ import Foundation + /** - * ScreenView + * `ScreenView` is a structure that represents a screen view event in the application. + * It conforms to the `ActionKind` protocol. + * + * It contains the name of the screen and a dictionary of parameters associated with the screen view event. */ public struct ScreenView: ActionKind { + // The name of the screen. public let name: String + + // A dictionary of parameters associated with the screen view event. public let params: [String: String] + /** - * Tracks a screen view event as page view to Google Analytics by setting the required parameters - * - Remark: - Screen (page) views are reported using `screenView(_:parameters:)` with the name of the screen. + * This method tracks a screen view event as a page view to Google Analytics by setting the required parameters. + * - Remark: Screen (page) views are reported using `screenView(_:parameters:)` with the name of the screen. * - Remark: `dh` - hostname as appIdentifier and `dp` - path as screen name with leading `/` * - Remark: and optional `dt` - document title as screen name pageview parameters for valid hit request. * - Parameters: @@ -19,16 +27,23 @@ public struct ScreenView: ActionKind { self.params = params } } + /** - * Ext + * `ScreenView` extension that provides additional functionality. */ extension ScreenView { + // The key used to identify a page view event. public var key: String { "pageview" } + + /** + * This computed property generates the output dictionary for the screen view event. + * It includes the app identifier, screen name, and document title. + */ public var output: [String: String] { var params: [String: String] = self.params - params["dh"] = System.appIdentifier - params["dp"] = "/" + name - params["dt"] = name + params["dh"] = System.appIdentifier // Set the app identifier. + params["dp"] = "/" + name // Set the screen name with a leading '/'. + params["dt"] = name // Set the document title. return params } -} +} \ No newline at end of file diff --git a/Sources/Telemetry/type/Session.swift b/Sources/Telemetry/type/Session.swift index a92af25..bcf5f30 100644 --- a/Sources/Telemetry/type/Session.swift +++ b/Sources/Telemetry/type/Session.swift @@ -1,29 +1,47 @@ import Foundation + +// This file defines a Session struct and its extension in Swift. + /** * Session + * This struct represents a session in the context of Google Analytics. + * It has two properties: start and params. + * The start property is a boolean indicating whether the session has started or ended. + * The params property is a dictionary of parameters associated with the session. */ public struct Session: ActionKind { public let start: Bool public let params: [String: String] + /** - * Tracks a session start to Google Analytics by setting the `sc` parameter of the request. The `dp` parameter is set to the name of the application. + * This initializer sets up a new Session instance. + * It tracks a session start to Google Analytics by setting the `sc` parameter of the request. + * The `dp` parameter is set to the name of the application. * - Remark: Sessions are reported with `session(_:parameters:)` with the first parameter set to true for session start or false for session end * - Parameter start: true indicate session started, false - session finished. + * - Parameter params: A dictionary of parameters associated with the session. Default is an empty dictionary. */ public init(start: Bool, params: [String: String] = .init()) { self.start = start self.params = params } } + /** - * Ext + * Extension of Session + * This extension adds two computed properties to the Session struct: key and output. + * The key property is a string that represents the key for the session. + * The output property is a dictionary that represents the output of the session. */ extension Session { + // The key for the session. public var key: String { "session" } + + // The output of the session. It includes the original parameters and adds two more: "sc" and "dp". public var output: [String: String] { var params: [String: String] = self.params - params["sc"] = start ? "start" : "end" - params["dp"] = System.appName + params["sc"] = start ? "start" : "end" // "sc" indicates whether the session has started or ended. + params["dp"] = System.appName // "dp" is set to the name of the application. return params } -} +} \ No newline at end of file diff --git a/Sources/Telemetry/type/Timing.swift b/Sources/Telemetry/type/Timing.swift index 9436fc0..83dae9f 100644 --- a/Sources/Telemetry/type/Timing.swift +++ b/Sources/Telemetry/type/Timing.swift @@ -1,16 +1,28 @@ import Foundation + /** * Timing + * This struct represents a timing event that can be tracked in Google Analytics. + * It includes properties for category, name, label, time, and additional parameters. */ public struct Timing: ActionKind { + // The category of the timing event public let category: String + + // The name of the timing event public let name: String + + // The label for the timing event public let label: String + + // The time duration of the timing event in seconds public let time: TimeInterval + + // Additional parameters for the timing event public let params: [String: String] + /** - * Tracks a timing to Google Analytics. - * - Remark: Timings are reported using `timing(_:name:label:time:parameters:)` with time parameter in seconds + * Initializer for the Timing struct. * - Parameters: * - category: The category of the timing (utc) * - name: The variable name of the timing (utv) @@ -26,17 +38,33 @@ public struct Timing: ActionKind { self.params = params } } + /** - * Extension + * Extension of Timing struct. + * This extension adds two computed properties: key and output. */ extension Timing { + // A key for the timing event public var key: String { "timing" } + + // The output dictionary that includes all properties of the timing event public var output: [String: String] { + // Initialize a new variable `params` with the current instance's `params` var params: [String: String] = self.params + + // Set the `utc` key in `params` to the current instance's `category` params["utc"] = category + + // Set the `utv` key in `params` to the current instance's `name` params["utv"] = name + + // Set the `utl` key in `params` to the current instance's `label` params["utl"] = label + + // Set the `utt` key in `params` to the current instance's `time` converted to milliseconds params["utt"] = String(Int(time * 1000)) + + // Return the updated `params` return params } -} +} \ No newline at end of file diff --git a/Sources/Telemetry/type/util/ActionKind.swift b/Sources/Telemetry/type/util/ActionKind.swift index eed4533..5ae0a92 100644 --- a/Sources/Telemetry/type/util/ActionKind.swift +++ b/Sources/Telemetry/type/util/ActionKind.swift @@ -1,12 +1,20 @@ import Foundation + /** - * Protocol for structs types - * - Fixme: ⚠️️ add more doc - * - Fixme: ⚠️️ Rename to TMActionKind? or TMAction? - * - Fixme: ⚠️️ Probably remove params from json etc + * `ActionKind` is a protocol that defines a common interface for action types in the telemetry system. + * It is Codable, meaning instances of types conforming to this protocol can be encoded to or decoded from external representations like JSON. + * + * Types conforming to `ActionKind` must provide: + * - `params`: a dictionary of parameters associated with the action. The keys and values are both Strings. + * - `output`: a dictionary representing the output of the action. Again, the keys and values are both Strings. + * - `key`: a unique String key identifying the action. + * + * ## Future Improvements + * - Consider renaming to `TMActionKind` or `TMAction` for better clarity and consistency with other naming conventions in the codebase. + * - Consider removing `params` from JSON representations if they are not necessary. */ public protocol ActionKind: Codable { var params: [String: String] { get } var output: [String: String] { get } var key: String { get } -} +} \ No newline at end of file diff --git a/Sources/Telemetry/type/util/Aggregator.swift b/Sources/Telemetry/type/util/Aggregator.swift index 64cc494..a3e72c8 100644 --- a/Sources/Telemetry/type/util/Aggregator.swift +++ b/Sources/Telemetry/type/util/Aggregator.swift @@ -1,23 +1,32 @@ import Foundation import FileSugar import JSONSugar + /** * Local aggregator * - Remark: This can be used to save logs etc. - * - Remark: For testig it's better to use Console output. Se Logger repo for code etc + * - Remark: For testing, it's better to use Console output. See Logger repo for code etc. * - Fixme: ⚠️️ We could also use structured json, and store everything in one array etc? * - Fixme: ⚠️️ Add timestamping? - * - Fixme: ⚠️️ Add more advance sessions, with uuid and timestamp etc - * - Fixme: ⚠️️ Add support for storing meta data etc. Might require sqlite etc. since json file will get big and cluttered etc + * - Fixme: ⚠️️ Add more advanced sessions, with uuid and timestamp etc. + * - Fixme: ⚠️️ Add support for storing meta data etc. Might require sqlite etc. since json file will get big and cluttered etc. * - Fixme: ⚠️️ Rename to TMAggregator? */ public class Aggregator: Codable { + // File path where the data will be stored public var filePath: String + // Array of events public var events: [Event] + // Array of sessions public var sessions: [Session] + // Array of exceptions public var exceptions: [Exception] + // Array of screen views public var screenViews: [ScreenView] + // Array of timings public var timings: [Timing] + + // Initializer for the Aggregator class init(filePath: String = tempFilePath, events: [Event] = [], sessions: [Session] = [], exceptions: [Exception] = [], screenViews: [ScreenView] = [], timings: [Timing] = []) { self.filePath = filePath self.events = events @@ -27,12 +36,15 @@ public class Aggregator: Codable { self.timings = timings } } + +// Extension for the Aggregator class to add actions extension Aggregator { /** * Add action * - Fixme: ⚠️️ Rename to send? or noterize or something? */ public func append(action: ActionKind) throws { + // Depending on the type of action, append it to the corresponding array switch action { case let event as Event: events.append(event) case let session as Session: sessions.append(session) @@ -41,59 +53,74 @@ extension Aggregator { case let timing as Timing: timings.append(timing) default: Swift.print("⚠️️ Not supported") } - try persist() // (save on each call) + // Save the current state after each call + try persist() } } -/** - * Persistence - */ + +// Extension for the Aggregator class to handle persistence extension Aggregator { /** - * - Remark: If the app is sandboxed, this folder is somewhere else. Print the path in your app to get absolute path etc + * - Remark: If the app is sandboxed, this folder is somewhere else. Print the path in your app to get absolute path etc. * - Remark: Something like this path should also work: `NSTemporaryDirectory()).appendingPathComponent("store.json").path` */ public static let tempFilePath: String = "\(NSHomeDirectory())/store.json" // or use tempfolder etc + /** * Save current state to a file * - Fixme: ⚠️️ Add sqlite later, or coredata */ public func persist() throws { + // Encode the current state to Data let data: Data = try self.encode() + // Convert the data to a string guard let content: String = .init(data: data, encoding: .utf8) else { throw NSError(domain: "err str", code: 0) } + // Write the string to a file FileModifier.write(filePath, content: content) // Create new file if non exists } + /** - * Load previouse saved aggregator + * Load previously saved aggregator * - Parameters: * - filePath: Path to store file * - reset: Reset store file or not */ public static func initiate(filePath: String = tempFilePath, reset: Bool = false) throws -> Aggregator { + // If reset is true, remove the existing file if reset { try FileManager().removeItem(atPath: filePath) } + // If the file exists, load the content and decode it to an Aggregator if FileManager().fileExists(atPath: filePath) { let content: String = try .init(contentsOfFile: filePath, encoding: .utf8) return try content.decode() } else { + // If the file doesn't exist, return a new Aggregator return .init(filePath: filePath) } } } -/** - * Stats - */ + +// Extension for the Aggregator class to handle stats extension Aggregator { /** * Read Aggregator stats: * - Fixme: ⚠️️ Add exceptions-fatal: 4 (only errors) etc? */ public var stats: String { + // Initialize an empty string for the output var output: String = "" + // If there are any events, add the count to the output if !events.isEmpty { output += "💃 Events: \(events.count)\n" } + // If there are any sessions, add the count to the output if !sessions.isEmpty { output += "✍️ Sessions: \(sessions.count)\n" } + // If there are any exceptions, add the count to the output if !exceptions.isEmpty { output += "🐛 Exceptions: \(exceptions.count)\n" } // (warnings and errors) + // If there are any screen views, add the count to the output if !screenViews.isEmpty { output += "📺 ScreenViews: \(screenViews.count)\n" } + // If there are any timings, add the count to the output if !timings.isEmpty { output += "🕑 Timings: \(timings.count)\n" } + // If the output is not empty, remove the last line break if !output.isEmpty { output = String(output.dropLast()) } // remove last linebreak + // Return the output return output } -} +} \ No newline at end of file diff --git a/Sources/Telemetry/type/util/TMType.swift b/Sources/Telemetry/type/util/TMType.swift index 26b798e..54d4979 100644 --- a/Sources/Telemetry/type/util/TMType.swift +++ b/Sources/Telemetry/type/util/TMType.swift @@ -1,17 +1,21 @@ import Foundation + /** - * - Fixme: ⚠️️ Rename to EndPointType OR EPType + * This enum represents the different types of telemetry. + * - Fixme: ⚠️️ Consider renaming to EndPointType or EPType for clarity. */ public enum TMType { case ga, agg(_ agg: Aggregator) } + extension TMType { /** - * Convenient getter for aggregator (since it's stored in the type) + * This computed property provides a convenient way to get the aggregator. + * It returns the aggregator if the TMType is .agg, otherwise it returns nil. */ public var aggregator: Aggregator? { if case .agg(let aggregator) = self { return aggregator } else { return nil } } -} +} \ No newline at end of file diff --git a/Sources/Telemetry/util/Identity.swift b/Sources/Telemetry/util/Identity.swift index 7c37300..366c582 100644 --- a/Sources/Telemetry/util/Identity.swift +++ b/Sources/Telemetry/util/Identity.swift @@ -4,21 +4,27 @@ import UIKit #elseif os(macOS) import Cocoa #endif + /** + * Identity class for handling unique identifiers. +* - Fixme: ⚠️️ Rename to id? or keep Identity? * - Remark: IFA/IDFA -> Identifier for Advertisers - * - Remark: IFV/IDFV -> (Identifier for Vendor) - * - Remark: IDFA is shared between all apps of the system, but can only be used by ad-enabled apps that are really showing the ads to the end user. Also, the user can opt-out and choose to reset it or disable the “across system” UID, causing a new UID to be generated for each install. + * - Remark: IFV/IDFV -> Identifier for Vendor + * - Remark: IDFA is shared across all apps on the system, but only usable by ad-enabled apps that display ads to the user. Users can opt-out, reset, or disable the “across system” UID, causing a new UID to be generated for each install. * - Remark: IDFV is shared between apps from the same publisher, but is lost when the last app of the publisher is uninstalled. - * - Note: complete solution in objc: https://gist.github.com/miguelcma/e8f291e54b025815ca46 - * - Note: objc: https://github.com/guojunliu/XYUUID and https://github.com/mushank/ZKUDID - * - Note: Objc: https://stackoverflow.com/a/20339893/5389500 and https://developer.apple.com/forums/thread/127567 - * - Fixme: ⚠️️ Rename to id? or keep Identity? + * - Note: For a complete solution in Objective-C, refer to the following links: + * - https://gist.github.com/miguelcma/e8f291e54b025815ca46 + * - https://github.com/guojunliu/XYUUID + * - https://github.com/mushank/ZKUDID + * - https://stackoverflow.com/a/20339893/5389500 + * - https://developer.apple.com/forums/thread/127567 */ class Identity {} extension Identity { /** - * - Parameter type: type of id (vendor, userDef or keychain) + * Generates a unique user identifier. + * - Parameter type: Type of identifier (vendor, userDef or keychain) */ internal static func uniqueUserIdentifier(type: IDType) -> String { let id: String? = { @@ -31,35 +37,46 @@ extension Identity { return id ?? UUID().uuidString } } + /** - * UIDevice id + * Extension for handling UIDevice id. */ extension Identity { /** - * Source of identifer to persist + * Vendor identifier source. * - Remark: Changes on every simulator run etc (allegedly) - Fixme: ⚠️️ confirm this - * - Remark: Should persist between release app runs, but beta apps might genereate new uuid + * - Remark: Should persist between release app runs, but beta apps might generate new UUID. * - Remark: The MAC doesn't have anything equivalent to iOS's identifierForVendor or advertising Id alas. */ fileprivate static var vendorID: String? { #if os(iOS) - return UIDevice.current.identifierForVendor?.uuidString // UIDevice.current.identifierForVendor + // For iOS, we return the identifier for vendor + return UIDevice.current.identifierForVendor?.uuidString #elseif os(macOS) + // For macOS, we need to use IOServiceMatching to get the device let dev = IOServiceMatching("IOPlatformExpertDevice") - let platformExpert: io_service_t = IOServiceGetMatchingService(kIOMainPortDefault/* ⚠️️ was kIOMasterPortDefault*/, dev) + // Get the platform expert service + let platformExpert: io_service_t = IOServiceGetMatchingService(kIOMainPortDefault, dev) + // Create a property for the platform expert let serialNumberAsCFString = IORegistryEntryCreateCFProperty(platformExpert, kIOPlatformUUIDKey as CFString, kCFAllocatorDefault, 0) + // Release the platform expert object IOObjectRelease(platformExpert) + // Get the serial number as a CFTypeRef let ser: CFTypeRef = serialNumberAsCFString?.takeUnretainedValue() as CFTypeRef + // If we can cast the serial number to a String, return it if let result = ser as? String { return result } + // If we can't cast the serial number to a String, return nil return nil #else + // If the OS is not iOS or macOS, print an error message and return nil Swift.print("OS not supported") return nil #endif } } + /** - * UserDefault - (Semi peristentID) + * Extension for handling UserDefault - (Semi persistentID). */ extension Identity { /** @@ -68,7 +85,7 @@ extension Identity { * - Remark: This way, a UUID will be generated once when the app is launched for the first time, and then stored in NSUserDefaults to be retrieved on each subsequent app launch. * - Remark: Unlike advertising or vendor identifiers, these identifiers would not be shared across other apps, but for most intents and purposes, this is works just fine. * - Remark: Does not persist app reinstalls - * - Remark: Persist between consol-unit-test runs + * - Remark: Persist between console-unit-test runs */ fileprivate static var userDefaultID: String? { let userDefaults = UserDefaults.standard @@ -80,25 +97,27 @@ extension Identity { return userDefaults.value(forKey: "AppID") as? String } } + /** - * Keychain - (Persisten id) + * Extension for handling Keychain - (Persistent id). */ extension Identity { /** * Creates a new unique user identifier or retrieves the last one created * - Description: The `PersistentID` class generates and stores a persistent ID that can be used to identify a device. - * - Remark: Does not persist OS reset/reinstal. But will persist os updates and os transfers to new phone, + * - Remark: Does not persist OS reset/reinstall. But will persist OS updates and transfers to new phone, * - Remark: As long as the bundle identifier remains the same this will persist * - Remark: And no, the key will not be synchronized to iCloud by default - * - Remark: keychain works with consol-unit-tests - * - Note: Back story on keychain not persisting between app installs for ios beta 10.3: https://stackoverflow.com/questions/41016762/how-to-generate-unique-id-of-device-for-iphone-ipad-using-objective-c/41017285#41017285 - * - Note: https://github.com/fabiocaccamo/FCUUID - * - Note: gd article: https://medium.com/@miguelcma/persistent-cross-install-device-identifier-on-ios-using-keychain-ac9e4f84870f - * - Note: Keychain example (uses 3rd party) https://stackoverflow.com/a/38745743/5389500 - * - Note: Allegedly keychain can be lost if the provisioning profile is changed: https://developer.apple.com/library/ios/documentation/IDEs/Conceptual/AppDistributionGuide/MaintainingCertificates/MaintainingCertificates.html + * - Remark: Keychain works with console-unit-tests + * - Note: For more information, refer to the following links: + * - https://stackoverflow.com/questions/41016762/how-to-generate-unique-id-of-device-for-iphone-ipad-using-objective-c/41017285#41017285 + * - https://github.com/fabiocaccamo/FCUUID + * - https://medium.com/@miguelcma/persistent-cross-install-device-identifier-on-ios-using-keychain-ac9e4f84870f + * - https://stackoverflow.com/a/38745743/5389500 + * - https://developer.apple.com/library/ios/documentation/IDEs/Conceptual/AppDistributionGuide/MaintainingCertificates/MaintainingCertificates.html */ fileprivate static var keychainID: String? { - let uuidKey = "persistentAppID" // This is the key we'll use to store the uuid in the keychain + let uuidKey = "persistentAppID" // This is the key we'll use to store the uuid in the keychain if let id = try? Keychain.get(key: uuidKey) { // Check if we already have a uuid stored, if so return it return id } @@ -108,10 +127,10 @@ extension Identity { } } /** - * Peristence level + * Enum for persistence level. */ public enum IDType { case vendor // Does not work on macOS, or does it now? - Fixme: ⚠️️ confirm this case userdefault case keychain -} +} \ No newline at end of file diff --git a/Sources/Telemetry/util/System.swift b/Sources/Telemetry/util/System.swift index 9c739ee..6dab544 100644 --- a/Sources/Telemetry/util/System.swift +++ b/Sources/Telemetry/util/System.swift @@ -1,46 +1,57 @@ + + + + import Foundation #if os(iOS) import UIKit #elseif os(macOS) import Cocoa #endif + /** - * - Fixme: ⚠️️ Move to Bundle extension? + * System class provides access to various system-level properties. + * TODO: Consider moving this to a Bundle extension for better organization. */ internal class System { /** - * app name + * Provides the name of the application as defined in the Bundle. */ internal static let appName: String = { Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "(not set)" }() + /** - * App id + * Provides the unique identifier of the application as defined in the Bundle. */ internal static let appIdentifier: String = { Bundle.main.object(forInfoDictionaryKey: "CFBundleIdentifier") as? String ?? "(not set)" }() + /** - * App ver + * Provides the version of the application as defined in the Bundle. */ internal static let appVersion: String = { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "(not set)" }() + /** - * App build + * Provides the build number of the application as defined in the Bundle. */ internal static let appBuild: String = { Bundle.main.object(forInfoDictionaryKey: kCFBundleVersionKey as String) as? String ?? "(not set)" }() + /** - * ver and build + * Provides a formatted string containing both the version and build number of the application. */ internal static let formattedVersion: String = { "\(appVersion) (\(appBuild))" }() + /** - * lang - * - Fixme: ⚠️️ En-us etc? + * Provides the preferred language of the user as defined in the system settings. + * TODO: Consider handling different language formats (e.g., en-US, en-GB). */ internal static let userLanguage: String = { guard let locale = Locale.preferredLanguages.first, !locale.isEmpty else { @@ -48,45 +59,68 @@ internal class System { } return locale }() + /** - * user screen resolution + * Provides the screen resolution of the user's device. */ internal static let screenResolution: String = { + // Check if the operating system is iOS #if os(iOS) + // Get the size of the screen in native points let size = UIScreen.main.nativeBounds.size + // If the operating system is macOS #elseif os(macOS) + // Get the size of the main screen, or use zero size if the main screen is not available let size = NSScreen.main?.frame.size ?? .zero #endif + // Return the screen resolution as a string in the format "width x height" return "\(size.width)x\(size.height)" }() + /** - * userAgent + * Provides the user agent string for the current device and OS. + * This is useful for identifying the device and OS in web requests. */ internal static let userAgent: String = { - #if os(macOS) + // Check if the OS is macOS + #if os(macOS) + // Get the OS version let osVersion = ProcessInfo.processInfo.operatingSystemVersionString + // Replace "." with "_" in the version string let versionString = osVersion.replacingOccurrences(of: ".", with: "_") + // Define the user agent for macOS let fallbackAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X \(versionString)) AppleWebKit/603.2.4 (KHTML, like Gecko) \(appName)/\(appVersion)" // swiftlint:disable:this line_length #else + // If not macOS, then it's iOS. Get the device details let currentDevice = UIDevice.current + // Get the OS version let osVersion = currentDevice.systemVersion.replacingOccurrences(of: ".", with: "_") + // Define the user agent for iOS let fallbackAgent = "Mozilla/5.0 (\(currentDevice.model); CPU iPhone OS \(osVersion) like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Mobile/13T534YI" // swiftlint:disable:this line_length #endif + // Check if the app supports WebKit #if SUPPORTS_WEBKIT - // must be captured in instance variable to avoid invalidation + // Create a WKWebView instance webViewForUserAgentDetection = WKWebView() / + // Load a blank HTML string webViewForUserAgentDetection?.loadHTMLString("", baseURL: nil) + // Evaluate JavaScript to get the user agent webViewForUserAgentDetection?.evaluateJavaScript("navigator.userAgent") { [weak self] result, _ in + // Ensure self is still available guard let self = self else { return } + // If the result is a string, set it as the user agent if let agent = result as? String { - self.userAgent = agent + self.userAgent = agent } + // Clear the WKWebView instance self.webViewForUserAgentDetection = nil } + // Return the user agent return fallbackAgent #else + // If not supporting WebKit, return the fallback user agent return fallbackAgent #endif }() -} +} \ No newline at end of file diff --git a/Tests/TelemetryTests/TelemetryTests.swift b/Tests/TelemetryTests/TelemetryTests.swift index 23fe793..5ea2e6a 100644 --- a/Tests/TelemetryTests/TelemetryTests.swift +++ b/Tests/TelemetryTests/TelemetryTests.swift @@ -1,107 +1,172 @@ import XCTest @testable import Telemetry +// This class contains unit tests for the Telemetry module final class TelemetryTests: XCTestCase { + // This function tests the system and identity functionalities func testExample() throws { + // Test system functionalities Self.systemTest() + // Test identity functionalities Self.testIdentity() - // Self.basicTest(testCase: self) // only works if real tracker id is used - // Self.aggTest() - // Self.readAggStatsTest() // used to debuging telemtry aggregator log in terminal + // Uncomment the following lines to test other functionalities + // Self.basicTest(testCase: self) // only works if real tracker id is used + // Self.aggTest() + // Self.readAggStatsTest() // used to debug telemetry aggregator log in terminal } } + +// Extension to add more methods to the TelemetryTests class extension TelemetryTests { - /** - * Sys test + /** + * This function tests the system functionalities by printing various system properties */ fileprivate static func systemTest() { - Swift.print("System.appBuild: \(System.appBuild)") // 20501 - Swift.print("System.appIdentifier: \(System.appIdentifier)") // com.apple.dt.xctest.tool - Swift.print("System.appName: \(System.appName)") // xctest - Swift.print("System.screenResolution: \(System.screenResolution)") // 1440.0x900.0 - Swift.print("System.userLanguage: \(System.userLanguage)") // en-US etc - Swift.print("System.userAgent: \(System.userAgent)") // Mozilla/5.0... + // Print the build number of the application + Swift.print("System.appBuild: \(System.appBuild)") // Example output: 20501 + + // Print the unique identifier of the application + Swift.print("System.appIdentifier: \(System.appIdentifier)") // Example output: com.apple.dt.xctest.tool + + // Print the name of the application + Swift.print("System.appName: \(System.appName)") // Example output: xctest + + // Print the screen resolution of the device + Swift.print("System.screenResolution: \(System.screenResolution)") // Example output: 1440.0x900.0 + + // Print the language setting of the user's device + Swift.print("System.userLanguage: \(System.userLanguage)") // Example output: en-US etc + + // Print the user agent string of the device + Swift.print("System.userAgent: \(System.userAgent)") // Example output: Mozilla/5.0... } + /** - * ID test + * This function tests the identity functionalities by generating and comparing unique user identifiers */ fileprivate static func testIdentity() { + // Define a test function that generates and compares unique user identifiers + // Define a test function that takes an IDType as input let test: (_ type: IDType) -> Void = { type in - let id = Identity.uniqueUserIdentifier(type: type) // generates new + // Generate a new unique user identifier based on the given IDType + let id = Identity.uniqueUserIdentifier(type: type) + // Print the generated identifier Swift.print("id: \(id)") - let id2 = Identity.uniqueUserIdentifier(type: type) // gets it fro, persisnt layer + // Retrieve the identifier from the persistent layer + let id2 = Identity.uniqueUserIdentifier(type: type) + // Check if the generated identifier and the retrieved identifier are the same let isTheSame = id == id2 + // Print the result of the comparison Swift.print("\(String(describing: type)) isTheSame: \(isTheSame ? "✅" : "🚫")") + // Assert that the identifiers are the same for the test to pass XCTAssertTrue(isTheSame) } - test(.userdefault) // test userdefault - test(.keychain) // test keychain + // Run the test function with userdefault as the IDType + test(.userdefault) + // Run the test function with keychain as the IDType + test(.keychain) } - /** - * Test calling Google analytics - * - Fixme: ⚠️️ only waits for event, fix the others later + + /** + * This function tests the basic functionalities of Google analytics + * - Note: Currently, it only waits for event. Other functionalities will be fixed later */ fileprivate static func basicTest(testCase: XCTestCase) { Swift.print("basicTest") + // Set the identifier type for the Telemetry Telemetry.idType = .userdefault // vendor doesn't work on mac or command-line-unit-test, and keychain doesnt work in comandline-unit-tests in an easy way + + // Set the tracker ID for the Telemetry Telemetry.trackerId = "UA-XXXXX-XX" //"" // Use real ga-tracker-id here, to test properly etc - // In many cases you'll want to track what "screens" that the user navigates to. A natural place to do that is in your ViewControllers viewDidAppear. You can use the screenView() method of the Telemetry which works the same as event(). + + // Create an expectation for a screen view function let screenView = testCase.expectation(description: "screen view function") + + // Call the screen view function and print whether it was successful Telemetry.action(ScreenView(name: "Cheers")) { success in Swift.print("screenView complete: \(success)") - screenView.fulfill() + screenView.fulfill() // Fulfill the expectation } - // You can track individual sessions for a user by calling session(start: true) when the user opens the app and session(start: false) when they close the app. Here's an example of how to do that in your apps UIApplicationDelegate: + + // Create an expectation for a session start function let sessionStart = testCase.expectation(description: "session start function") + + // Call the session start function and print whether it was successful Telemetry.action(Session(start: true)) { success in// applicationDidBecomeActive Swift.print("session start complete: \(success)") - sessionStart.fulfill() + sessionStart.fulfill() // Fulfill the expectation } + + // Create an expectation for a session end function let sessionEnd = testCase.expectation(description: "session end function") + + // Call the session end function and print whether it was successful Telemetry.action(Session(start: false)) { success in // applicationDidEnterBackground Swift.print("session end complete: \(success)") - sessionEnd.fulfill() + sessionEnd.fulfill() // Fulfill the expectation } - // You can track any event you wish to using the event() method. Example: + + // Create an expectation for an auth function let authDone = testCase.expectation(description: "auth function") // expectation is in the XCTestCase + + // Call the auth function and print whether it was successful Telemetry.action(Event(category: "Authorize", action: "Access granted")) { success in Swift.print("event complete: \(success)") - authDone.fulfill() // call this to indicate the test was successful + authDone.fulfill() // Fulfill the expectation } + + // Create an expectation for an exception function let exceptionCalled = testCase.expectation(description: "exception function") + + // Call the exception function and print whether it was successful Telemetry.action(Exception(description: "🐛 MainView - cell error", isFatal: false)) { success in Swift.print("Exception complete: \(success)") - exceptionCalled.fulfill() + exceptionCalled.fulfill() // Fulfill the expectation } + + // Wait for all the expectations to be fulfilled with a timeout of 10 seconds testCase.wait(for: [screenView, sessionStart, sessionEnd, authDone, exceptionCalled], timeout: 10) // Add after work has been called } + /** - * Stats test with local aggregator + * This function tests the local aggregator by generating and comparing stats */ fileprivate static func aggTest() { + // Try to initiate the Telemetry type as an aggregator, resetting any previous data do { Telemetry.tmType = .agg(try .initiate(reset: true)) } catch { + // Print any error that occurs during initiation Swift.print("error: \(error.localizedDescription)") } + // Send a screen view action named "Cheers" to the Telemetry Telemetry.action(ScreenView(name: "Cheers")) + // Send another screen view action named "Howdy" to the Telemetry Telemetry.action(ScreenView(name: "Howdy")) + // Start a session in the Telemetry Telemetry.action(Session(start: true)) + // End the session in the Telemetry Telemetry.action(Session(start: false)) + // Send an event action with category "Authorize" and action "Access granted" to the Telemetry Telemetry.action(Event(category: "Authorize", action: "Access granted")) + // If there are any stats available from the aggregator, print them if let stats: String = Telemetry.tmType.aggregator?.stats { Swift.print(stats) } + // Check if the stats are correct: 2 screen views, 1 event, and 2 sessions let statsAreCorrect = { Telemetry.tmType.aggregator?.screenViews.count == 2 && Telemetry.tmType.aggregator?.events.count == 1 && Telemetry.tmType.aggregator?.sessions.count == 2 }() + // Print whether the stats are correct or not Swift.print("statsAreCorrect: \(statsAreCorrect ? "✅" : "🚫")") + // Assert that the stats are correct for the unit test to pass XCTAssertTrue(statsAreCorrect) } + /** - * read aggregator stats + * This function reads and prints the aggregator stats */ fileprivate static func readAggStatsTest() { do { @@ -113,4 +178,4 @@ extension TelemetryTests { Swift.print(stats) } } -} +} \ No newline at end of file