diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f4dfa72..d6fb200 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,12 +10,14 @@ on: jobs: lint: name: Lint Code - runs-on: macos-latest + runs-on: ubuntu-latest steps: - name: Repository checkout uses: actions/checkout@v4 - name: Lint - run: swiftlint + uses: norio-nomura/action-swiftlint@3.2.1 + with: + args: --strict test: name: Test Xcode ${{ matrix.xcode }} - ${{ matrix.xcodebuildCommand }} runs-on: "macos-latest" @@ -23,13 +25,13 @@ jobs: fail-fast: true matrix: xcode: - - ^14 - # - ^15 + - ^15 xcodebuildCommand: - - "-sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14'" + - "-sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15'" - "-sdk macosx -destination 'platform=macOS'" - - "-sdk appletvsimulator -destination 'platform=tvOS Simulator,name=Apple TV 4K (2nd generation)'" - - "-sdk watchsimulator -destination 'platform=watchOS Simulator,name=Apple Watch Series 8 (45mm)'" + - "-sdk xrsimulator -destination 'platform=visionOS Simulator,name=Apple Vision Pro'" + - "-sdk appletvsimulator -destination 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)'" + - "-sdk watchsimulator -destination 'platform=watchOS Simulator,name=Apple Watch Series 9 (45mm)'" steps: - name: Repository checkout uses: actions/checkout@v4 @@ -38,4 +40,4 @@ jobs: with: xcode-version: ${{ matrix.xcode }} - name: Build and Test - run: xcodebuild test -scheme TelemetryClient ${{ matrix.xcodebuildCommand }} + run: xcodebuild test -scheme TelemetryClient-Package ${{ matrix.xcodebuildCommand }} diff --git a/.swiftlint.yml b/.swiftlint.yml index 07cbce1..41adafd 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,6 +1,5 @@ # By default, SwiftLint uses a set of sensible default rules you can adjust: disabled_rules: # rule identifiers turned on by default to exclude from running - - identifier_name - function_body_length - opening_brace - trailing_comma diff --git a/Package.swift b/Package.swift index e8ea19c..d63b041 100644 --- a/Package.swift +++ b/Package.swift @@ -1,6 +1,4 @@ -// swift-tools-version:5.7 -// The swift-tools-version declares the minimum version of Swift required to build this package. - +// swift-tools-version:5.9 import PackageDescription let package = Package( @@ -9,22 +7,20 @@ let package = Package( .macOS(.v10_13), .iOS(.v12), .watchOS(.v5), - .tvOS(.v13) + .tvOS(.v13), + .visionOS(.v1), ], products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "TelemetryClient", - targets: ["TelemetryClient"] - ), - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), + .library(name: "TelemetryDeck", targets: ["TelemetryDeck"]), // new name + .library(name: "TelemetryClient", targets: ["TelemetryClient"]), // old name ], + dependencies: [], targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "TelemetryDeck", + dependencies: ["TelemetryClient"], + resources: [.copy("PrivacyInfo.xcprivacy")] + ), .target( name: "TelemetryClient", resources: [.copy("PrivacyInfo.xcprivacy")] diff --git a/Sources/TelemetryClient/CryptoHashing.swift b/Sources/TelemetryClient/CryptoHashing.swift index aa6bd20..777f196 100644 --- a/Sources/TelemetryClient/CryptoHashing.swift +++ b/Sources/TelemetryClient/CryptoHashing.swift @@ -11,8 +11,8 @@ enum CryptoHashing { /// should be preferred where available. /// [CommonCrypto](https://github.com/apple-oss-distributions/CommonCrypto) provides compatibility with older OS versions, /// apps built with Xcode versions lower than 11 and non-Apple platforms like Linux. - static func sha256(str: String, salt: String) -> String { - if let strData = (str + salt).data(using: String.Encoding.utf8) { + static func sha256(string: String, salt: String) -> String { + if let strData = (string + salt).data(using: String.Encoding.utf8) { #if canImport(CryptoKit) if #available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) { let digest = SHA256.hash(data: strData) diff --git a/Sources/TelemetryClient/Signal.swift b/Sources/TelemetryClient/Signal.swift index 99dd528..1a14ccc 100644 --- a/Sources/TelemetryClient/Signal.swift +++ b/Sources/TelemetryClient/Signal.swift @@ -40,35 +40,71 @@ internal struct SignalPostBody: Codable, Equatable { /// The default payload that is included in payloads processed by TelemetryDeck. public struct DefaultSignalPayload: Encodable { - public let platform = Self.platform - public let systemVersion = Self.systemVersion - public let majorSystemVersion = Self.majorSystemVersion - public let majorMinorSystemVersion = Self.majorMinorSystemVersion - public let appVersion = Self.appVersion - public let buildNumber = Self.buildNumber - public let isSimulator = "\(Self.isSimulator)" - public let isDebug = "\(Self.isDebug)" - public let isTestFlight = "\(Self.isTestFlight)" - public let isAppStore = "\(Self.isAppStore)" - public let modelName = Self.modelName - public let architecture = Self.architecture - public let operatingSystem = Self.operatingSystem - public let targetEnvironment = Self.targetEnvironment - public let locale = Self.locale - public let extensionIdentifier: String? = Self.extensionIdentifier - public let telemetryClientVersion = TelemetryClientVersion - - public init() { } - - public func toDictionary() -> [String: String] { - do { - let encoder = JSONEncoder() - let data = try encoder.encode(self) - let dict = try JSONSerialization.jsonObject(with: data) as? [String: String] - return dict ?? [:] - } catch { - return [:] + public static var parameters: [String: String] { + var parameters: [String: String] = [ + // deprecated names + "platform": Self.platform, + "systemVersion": Self.systemVersion, + "majorSystemVersion": Self.majorSystemVersion, + "majorMinorSystemVersion": Self.majorMinorSystemVersion, + "appVersion": Self.appVersion, + "buildNumber": Self.buildNumber, + "isSimulator": "\(Self.isSimulator)", + "isDebug": "\(Self.isDebug)", + "isTestFlight": "\(Self.isTestFlight)", + "isAppStore": "\(Self.isAppStore)", + "modelName": Self.modelName, + "architecture": Self.architecture, + "operatingSystem": Self.operatingSystem, + "targetEnvironment": Self.targetEnvironment, + "locale": Self.locale, + "region": Self.region, + "appLanguage": Self.appLanguage, + "preferredLanguage": Self.preferredLanguage, + "telemetryClientVersion": telemetryClientVersion, + + // new names + "TelemetryDeck.AppInfo.buildNumber": Self.buildNumber, + "TelemetryDeck.AppInfo.version": Self.appVersion, + "TelemetryDeck.AppInfo.versionAndBuildNumber": "\(Self.appVersion) (build \(Self.buildNumber))", + + "TelemetryDeck.Device.architecture": Self.architecture, + "TelemetryDeck.Device.modelName": Self.modelName, + "TelemetryDeck.Device.operatingSystem": Self.operatingSystem, + "TelemetryDeck.Device.orientation": Self.orientation, + "TelemetryDeck.Device.platform": Self.platform, + "TelemetryDeck.Device.screenResolutionHeight": Self.screenResolutionHeight, + "TelemetryDeck.Device.screenResolutionWidth": Self.screenResolutionWidth, + "TelemetryDeck.Device.systemMajorMinorVersion": Self.majorMinorSystemVersion, + "TelemetryDeck.Device.systemMajorVersion": Self.majorSystemVersion, + "TelemetryDeck.Device.systemVersion": Self.systemVersion, + "TelemetryDeck.Device.timeZone": Self.timeZone, + + "TelemetryDeck.RunContext.isAppStore": "\(Self.isAppStore)", + "TelemetryDeck.RunContext.isDebug": "\(Self.isDebug)", + "TelemetryDeck.RunContext.isSimulator": "\(Self.isSimulator)", + "TelemetryDeck.RunContext.isTestFlight": "\(Self.isTestFlight)", + "TelemetryDeck.RunContext.language": Self.appLanguage, + "TelemetryDeck.RunContext.locale": Self.locale, + "TelemetryDeck.RunContext.targetEnvironment": Self.targetEnvironment, + + "TelemetryDeck.SDK.name": "SwiftSDK", + "TelemetryDeck.SDK.nameAndVersion": "SwiftSDK \(telemetryClientVersion)", + "TelemetryDeck.SDK.version": telemetryClientVersion, + + "TelemetryDeck.UserPreference.language": Self.preferredLanguage, + "TelemetryDeck.UserPreference.region": Self.region, + ] + + if let extensionIdentifier = Self.extensionIdentifier { + // deprecated name + parameters["extensionIdentifier"] = extensionIdentifier + + // new name + parameters["TelemetryDeck.RunContext.extensionIdentifier"] = extensionIdentifier } + + return parameters } } @@ -174,7 +210,7 @@ extension DefaultSignalPayload { var modelIdentifier: String? if let modelData = IORegistryEntryCreateCFProperty(service, "model" as CFString, kCFAllocatorDefault, 0).takeRetainedValue() as? Data { - if let modelIdentifierCString = String(data: modelData, encoding: .utf8)?.cString(using: .utf8) { + if let modelIdentifierCString = String(decoding: modelData, as: UTF8.self).cString(using: .utf8) { modelIdentifier = String(cString: modelIdentifierCString) } } @@ -271,8 +307,94 @@ extension DefaultSignalPayload { #endif } - /// The locale identifier + /// The locale identifier the app currently runs in. E.g. `en_DE` for an app that does not support German on a device with preferences `[German, English]`, and region Germany. static var locale: String { return Locale.current.identifier } + + /// The region identifier both the user most prefers and also the app is set to. They are always the same because formatters in apps always auto-adjust to the users preferences. + static var region: String { + if #available(iOS 16, macOS 13, tvOS 16, visionOS 1, watchOS 9, *) { + return Locale.current.region?.identifier ?? Locale.current.identifier.components(separatedBy: .init(charactersIn: "-_")).last! + } else { + return Locale.current.regionCode ?? Locale.current.identifier.components(separatedBy: .init(charactersIn: "-_")).last! + } + } + + /// The language identifier the app is currently running in. This represents the language the system (or the user) has chosen for the app to run in. + static var appLanguage: String { + if #available(iOS 16, macOS 13, tvOS 16, visionOS 1, watchOS 9, *) { + return Locale.current.language.languageCode?.identifier ?? Locale.current.identifier.components(separatedBy: .init(charactersIn: "-_"))[0] + } else { + return Locale.current.languageCode ?? Locale.current.identifier.components(separatedBy: .init(charactersIn: "-_"))[0] + } + } + + /// The language identifier of the users most preferred language set on the device. Returns also languages the current app is not even localized to. + static var preferredLanguage: String { + let preferredLocaleIdentifier = Locale.preferredLanguages.first ?? "zz-ZZ" + return preferredLocaleIdentifier.components(separatedBy: .init(charactersIn: "-_"))[0] + } + + /// The current devices screen resolution width in points. + static var screenResolutionWidth: String { + #if os(iOS) || os(tvOS) + return "\(UIScreen.main.bounds.width)" + #elseif os(watchOS) + return "\(WKInterfaceDevice.current().screenBounds.width)" + #elseif os(macOS) + if let screen = NSScreen.main { + return "\(screen.frame.width)" + } + return "Unknown" + #else + return "N/A" + #endif + } + + /// The current devices screen resolution height in points. + static var screenResolutionHeight: String { + #if os(iOS) || os(tvOS) + return "\(UIScreen.main.bounds.height)" + #elseif os(watchOS) + return "\(WKInterfaceDevice.current().screenBounds.height)" + #elseif os(macOS) + if let screen = NSScreen.main { + return "\(screen.frame.height)" + } + return "Unknown" + #else + return "N/A" + #endif + } + + /// The current devices screen orientation. Returns `Fixed` for devices that don't support an orientation change. + static var orientation: String { + #if os(iOS) + switch UIDevice.current.orientation { + case .portrait, .portraitUpsideDown: + return "Portrait" + case .landscapeLeft, .landscapeRight: + return "Landscape" + default: + return "Unknown" + } + #else + return "Fixed" + #endif + } + + /// The devices current time zone in the modern `UTC` format, such as `UTC+1`, or `UTC-3:30`. + static var timeZone: String { + let secondsFromGMT = TimeZone.current.secondsFromGMT() + let hours = secondsFromGMT / 3600 + let minutes = abs(secondsFromGMT / 60 % 60) + + let sign = secondsFromGMT >= 0 ? "+" : "-" + if minutes > 0 { + return "UTC\(sign)\(hours):\(String(format: "%02d", minutes))" + } else { + return "UTC\(sign)\(hours)" + } + } } diff --git a/Sources/TelemetryClient/SignalEnricher.swift b/Sources/TelemetryClient/SignalEnricher.swift index a3423af..aedeb12 100644 --- a/Sources/TelemetryClient/SignalEnricher.swift +++ b/Sources/TelemetryClient/SignalEnricher.swift @@ -2,16 +2,16 @@ import Foundation public protocol SignalEnricher { func enrich( - signalType: TelemetrySignalType, + signalType: String, for clientUser: String?, floatValue: Double? ) -> [String: String] } extension Dictionary where Key == String, Value == String { - func applying(_ rhs: [String: String]) -> [String: String] { - merging(rhs) { _, rhs in - rhs + func applying(_ other: [String: String]) -> [String: String] { + merging(other) { _, other in + other } } diff --git a/Sources/TelemetryClient/SignalManager.swift b/Sources/TelemetryClient/SignalManager.swift index fe78026..a30aac9 100644 --- a/Sources/TelemetryClient/SignalManager.swift +++ b/Sources/TelemetryClient/SignalManager.swift @@ -11,7 +11,7 @@ import TVUIKit #endif internal protocol SignalManageable { - func processSignal(_ signalType: TelemetrySignalType, for clientUser: String?, floatValue: Double?, with additionalPayload: [String: String], configuration: TelemetryManagerConfiguration) + func processSignal(_ signalName: String, parameters: [String: String], floatValue: Double?, customUserID: String?, configuration: TelemetryManagerConfiguration) func attemptToSendNextBatchOfCachedSignals() } @@ -68,27 +68,27 @@ internal class SignalManager: SignalManageable { /// Adds a signal to the process queue func processSignal( - _ signalType: TelemetrySignalType, - for clientUser: String? = nil, - floatValue: Double? = nil, - with additionalPayload: [String: String] = [:], + _ signalName: String, + parameters: [String: String], + floatValue: Double?, + customUserID: String?, configuration: TelemetryManagerConfiguration ) { DispatchQueue.global(qos: .utility).async { let enrichedMetadata: [String: String] = configuration.metadataEnrichers - .map { $0.enrich(signalType: signalType, for: clientUser, floatValue: floatValue) } + .map { $0.enrich(signalType: signalName, for: customUserID, floatValue: floatValue) } .reduce([String: String](), { $0.applying($1) }) - let payload = DefaultSignalPayload().toDictionary() + let payload = DefaultSignalPayload.parameters .applying(enrichedMetadata) - .applying(additionalPayload) + .applying(parameters) let signalPostBody = SignalPostBody( receivedAt: Date(), appID: UUID(uuidString: configuration.telemetryAppID)!, - clientUser: CryptoHashing.sha256(str: clientUser ?? self.defaultUserIdentifier, salt: configuration.salt), + clientUser: CryptoHashing.sha256(string: customUserID ?? self.defaultUserIdentifier, salt: configuration.salt), sessionID: configuration.sessionID.uuidString, - type: "\(signalType)", + type: "\(signalName)", floatValue: floatValue, payload: payload.toMultiValueDimension(), isTestMode: configuration.testMode ? "true" : "false" @@ -130,7 +130,7 @@ internal class SignalManager: SignalManageable { } if let data = data { - configuration.logHandler?.log(.debug, message: String(data: data, encoding: .utf8)!) + configuration.logHandler?.log(.debug, message: String(decoding: data, as: UTF8.self)) } } } @@ -189,7 +189,7 @@ private extension SignalManager { } urlRequest.httpBody = body - self.configuration.logHandler?.log(.debug, message: String(data: urlRequest.httpBody!, encoding: .utf8)!) + self.configuration.logHandler?.log(.debug, message: String(decoding: urlRequest.httpBody!, as: UTF8.self)) let task = URLSession.shared.dataTask(with: urlRequest, completionHandler: completionHandler) task.resume() @@ -203,7 +203,7 @@ private extension SignalManager { #if os(macOS) /// A custom ``UserDefaults`` instance specific to TelemetryDeck and the current application. private var customDefaults: UserDefaults? { - let appIdHash = CryptoHashing.sha256(str: self.configuration.telemetryAppID, salt: "") + let appIdHash = CryptoHashing.sha256(string: self.configuration.telemetryAppID, salt: "") return UserDefaults(suiteName: "com.telemetrydeck.\(appIdHash.suffix(12))") } #endif @@ -253,13 +253,13 @@ private extension URLResponse { // Check for valid response in the 200-299 range guard (200 ... 299).contains(statusCode() ?? 0) else { if statusCode() == 401 { - return TelemetryError.Unauthorised + return TelemetryError.unauthorised } else if statusCode() == 403 { - return TelemetryError.Forbidden + return TelemetryError.forbidden } else if statusCode() == 413 { - return TelemetryError.PayloadTooLarge + return TelemetryError.payloadTooLarge } else { - return TelemetryError.InvalidStatusCode(statusCode: statusCode() ?? 0) + return TelemetryError.invalidStatusCode(statusCode: statusCode() ?? 0) } } return nil @@ -269,22 +269,22 @@ private extension URLResponse { // MARK: - Errors private enum TelemetryError: Error { - case Unauthorised - case Forbidden - case PayloadTooLarge - case InvalidStatusCode(statusCode: Int) + case unauthorised + case forbidden + case payloadTooLarge + case invalidStatusCode(statusCode: Int) } extension TelemetryError: LocalizedError { public var errorDescription: String? { switch self { - case .InvalidStatusCode(let statusCode): + case .invalidStatusCode(let statusCode): return "Invalid status code \(statusCode)" - case .Unauthorised: + case .unauthorised: return "Unauthorized (401)" - case .Forbidden: + case .forbidden: return "Forbidden (403)" - case .PayloadTooLarge: + case .payloadTooLarge: return "Payload is too large (413)" } } diff --git a/Sources/TelemetryClient/TelemetryClient.swift b/Sources/TelemetryClient/TelemetryClient.swift index d579423..12f1b24 100644 --- a/Sources/TelemetryClient/TelemetryClient.swift +++ b/Sources/TelemetryClient/TelemetryClient.swift @@ -10,9 +10,7 @@ import Foundation import TVUIKit #endif -let TelemetryClientVersion = "SwiftClient 1.5.1" - -public typealias TelemetrySignalType = String +let telemetryClientVersion = "2.0.0" /// Configuration for TelemetryManager /// @@ -55,7 +53,14 @@ public final class TelemetryManagerConfiguration { /// more fine-grained session support, write a new random session identifier into this property each time a new session begins. /// /// Beginning a new session automatically sends a "newSessionBegan" Signal if `sendNewSessionBeganSignal` is `true` - public var sessionID = UUID() { didSet { if sendNewSessionBeganSignal { TelemetryManager.send("newSessionBegan") } } } + public var sessionID = UUID() { + didSet { + if sendNewSessionBeganSignal { + TelemetryManager.send("newSessionBegan") + TelemetryDeck.signal("TelemetryDeck.Session.started") + } + } + } @available(*, deprecated, message: "Please use the testMode property instead") public var sendSignalsInDebugConfiguration: Bool = false @@ -183,6 +188,7 @@ public class TelemetryManager { initializedTelemetryManager != nil } + @available(*, deprecated, renamed: "TelemetryDeck.initialize(config:)", message: "This call was renamed to `TelemetryDeck.initialize(config:)`. Please migrate – a fix-it is available.") public static func initialize(with configuration: TelemetryManagerConfiguration) { initializedTelemetryManager = TelemetryManager(configuration: configuration) } @@ -197,13 +203,26 @@ public class TelemetryManager { initializedTelemetryManager = nil } + /// Send a Signal to TelemetryDeck, to record that an event has occurred. + /// + /// If you specify a payload, it will be sent in addition to the default payload which includes OS Version, App Version, and more. + @available(*, deprecated, renamed: "TelemetryDeck.signal(_:parameters:)", message: "This call was renamed to `TelemetryDeck.signal(_:parameters:)`. Please migrate – a fix-it is available.") + public static func send(_ signalName: String, with parameters: [String: String] = [:]) { + send(signalName, for: nil, floatValue: nil, with: parameters) + } + /// Send a Signal to TelemetryDeck, to record that an event has occurred. /// /// If you specify a user identifier here, it will take precedence over the default user identifier specified in the `TelemetryManagerConfiguration`. /// /// If you specify a payload, it will be sent in addition to the default payload which includes OS Version, App Version, and more. - public static func send(_ signalType: TelemetrySignalType, for clientUser: String? = nil, floatValue: Double? = nil, with additionalPayload: [String: String] = [:]) { - TelemetryManager.shared.send(signalType, for: clientUser, floatValue: floatValue, with: additionalPayload) + @_disfavoredOverload + @available( + *, deprecated, + message: "This call was renamed to `TelemetryDeck.signal(_:parameters:floatValue:customUserID:)`. Please migrate – no fix-it possible due to the changed order of arguments." + ) + public static func send(_ signalName: String, for customUserID: String? = nil, floatValue: Double? = nil, with parameters: [String: String] = [:]) { + TelemetryManager.shared.send(signalName, for: customUserID, floatValue: floatValue, with: parameters) } /// Do not call this method unless you really know what you're doing. The signals will automatically sync with the server at appropriate times, there's no need to call this. @@ -261,11 +280,29 @@ public class TelemetryManager { /// If you specify a user identifier here, it will take precedence over the default user identifier specified in the `TelemetryManagerConfiguration`. /// /// If you specify a payload, it will be sent in addition to the default payload which includes OS Version, App Version, and more. - public func send(_ signalType: TelemetrySignalType, for clientUser: String? = nil, floatValue: Double? = nil, with additionalPayload: [String: String] = [:]) { + @available( + *, deprecated, + message: "This call was renamed to `TelemetryDeck.signal(_:parameters:floatValue:customUserID:)`. Please migrate – no fix-it possible due to the changed order of arguments." + ) + public func send(_ signalName: String, with parameters: [String: String] = [:]) { + send(signalName, for: nil, floatValue: nil, with: parameters) + } + + /// Send a Signal to TelemetryDeck, to record that an event has occurred. + /// + /// If you specify a user identifier here, it will take precedence over the default user identifier specified in the `TelemetryManagerConfiguration`. + /// + /// If you specify a payload, it will be sent in addition to the default payload which includes OS Version, App Version, and more. + @_disfavoredOverload + @available( + *, deprecated, + message: "This call was renamed to `TelemetryDeck.signal(_:parameters:floatValue:customUserID:)`. Please migrate – no fix-it possible due to the changed order of arguments." + ) + public func send(_ signalName: String, for customUserID: String? = nil, floatValue: Double? = nil, with parameters: [String: String] = [:]) { // make sure to not send any signals when run by Xcode via SwiftUI previews guard !self.configuration.swiftUIPreviewMode, !self.configuration.analyticsDisabled else { return } - signalManager.processSignal(signalType, for: clientUser, floatValue: floatValue, with: additionalPayload, configuration: configuration) + signalManager.processSignal(signalName, parameters: parameters, floatValue: floatValue, customUserID: customUserID, configuration: configuration) } /// Do not call this method unless you really know what you're doing. The signals will automatically sync with the server at appropriate times, there's no need to call this. @@ -366,16 +403,16 @@ public final class TelemetryManagerObjCProxy: NSObject { TelemetryManager.terminate() } - @objc public static func send(_ signalType: TelemetrySignalType, for clientUser: String? = nil, with additionalPayload: [String: String] = [:]) { - TelemetryManager.send(signalType, for: clientUser, with: additionalPayload) + @objc public static func send(_ signalName: String, for clientUser: String? = nil, with additionalPayload: [String: String] = [:]) { + TelemetryManager.send(signalName, for: clientUser, with: additionalPayload) } - @objc public static func send(_ signalType: TelemetrySignalType, with additionalPayload: [String: String] = [:]) { - TelemetryManager.send(signalType, with: additionalPayload) + @objc public static func send(_ signalName: String, with additionalPayload: [String: String] = [:]) { + TelemetryManager.send(signalName, with: additionalPayload) } - @objc public static func send(_ signalType: TelemetrySignalType) { - TelemetryManager.send(signalType) + @objc public static func send(_ signalName: String) { + TelemetryManager.send(signalName) } @objc public static func updateDefaultUser(to newDefaultUser: String?) { diff --git a/Sources/TelemetryClient/TelemetryDeck.swift b/Sources/TelemetryClient/TelemetryDeck.swift new file mode 100644 index 0000000..e82c8b6 --- /dev/null +++ b/Sources/TelemetryClient/TelemetryDeck.swift @@ -0,0 +1,30 @@ +import Foundation + +/// A namespace for TelemetryDeck related functionalities. +public enum TelemetryDeck { + /// This alias makes it easier to migrate the configuration type into the TelemetryDeck namespace in future versions when deprecated code is fully removed. + public typealias Config = TelemetryManagerConfiguration + + /// Initializes TelemetryDeck with a customizable configuration. + /// + /// - Parameter configuration: An instance of `Configuration` which includes all the settings required to configure TelemetryDeck. + /// + /// This function sets up the telemetry system with the specified configuration. It is necessary to call this method before sending any telemetry signals. + /// For example, you might want to call this in your `init` method of your app's `@main` entry point. + public static func initialize(config: Config) { + TelemetryManager.initialize(with: config) + } + + /// Sends a telemetry signal with optional parameters to TelemetryDeck. + /// + /// - Parameters: + /// - signalName: The name of the signal to be sent. This is a string that identifies the type of event or action being reported. + /// - parameters: A dictionary of additional string key-value pairs that provide further context about the signal. Default is empty. + /// - floatValue: An optional floating-point number that can be used to provide numerical data about the signal. Default is `nil`. + /// - customUserID: An optional string specifying a custom user identifier. If provided, it will override the default user identifier from the configuration. Default is `nil`. + /// + /// This function wraps the `TelemetryManager.send` method, providing a streamlined way to send signals from anywhere in the app. + public static func signal(_ signalName: String, parameters: [String: String] = [:], floatValue: Double? = nil, customUserID: String? = nil) { + TelemetryManager.send(signalName, for: customUserID, floatValue: floatValue, with: parameters) + } +} diff --git a/Sources/TelemetryDeck/Exports.swift b/Sources/TelemetryDeck/Exports.swift new file mode 100644 index 0000000..88af186 --- /dev/null +++ b/Sources/TelemetryDeck/Exports.swift @@ -0,0 +1,2 @@ +// This file ensures there's a target named `TelemetryDeck` so `import TelemetryDeck` is already possible without renaming `TelemetryClient`. +@_exported import TelemetryClient diff --git a/Sources/TelemetryDeck/PrivacyInfo.xcprivacy b/Sources/TelemetryDeck/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..23592d4 --- /dev/null +++ b/Sources/TelemetryDeck/PrivacyInfo.xcprivacy @@ -0,0 +1,35 @@ + + + + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeProductInteraction + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeDeviceID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + NSPrivacyTracking + + + diff --git a/Tests/TelemetryClientTests/CryptoHashingTests.swift b/Tests/TelemetryClientTests/CryptoHashingTests.swift index 4428021..78472ce 100644 --- a/Tests/TelemetryClientTests/CryptoHashingTests.swift +++ b/Tests/TelemetryClientTests/CryptoHashingTests.swift @@ -12,7 +12,7 @@ final class CryptoHashingTests: XCTestCase { let expectedDigestString = "5b8fab7cf45fcece0e99a05950611b7b355917e4fb6daa73fd3d7590764fa53b" - XCTAssertEqual(expectedDigestString, CryptoHashing.sha256(str: stringToHash, salt: "")) + XCTAssertEqual(expectedDigestString, CryptoHashing.sha256(string: stringToHash, salt: "")) XCTAssertEqual(expectedDigestString, CryptoHashing.commonCryptoSha256(strData: dataToHash)) // even though we already test if we can import CryptoKit, somehow this still fails on iOS 12, @@ -31,9 +31,9 @@ final class CryptoHashingTests: XCTestCase { let secondSalt = "x21MTSq3MRSmLjVFsYIe" let expectedSecondDigestString = "acb027bb031c0f73de26c6b8d0441d9c98449d582a538014c44ca49b4c299aa8" - XCTAssertEqual(expectedDigestString, CryptoHashing.sha256(str: stringToHash, salt: salt)) - XCTAssertEqual(expectedSecondDigestString, CryptoHashing.sha256(str: stringToHash, salt: secondSalt)) - XCTAssertNotEqual(CryptoHashing.sha256(str: stringToHash, salt: salt), CryptoHashing.sha256(str: stringToHash, salt: secondSalt)) + XCTAssertEqual(expectedDigestString, CryptoHashing.sha256(string: stringToHash, salt: salt)) + XCTAssertEqual(expectedSecondDigestString, CryptoHashing.sha256(string: stringToHash, salt: secondSalt)) + XCTAssertNotEqual(CryptoHashing.sha256(string: stringToHash, salt: salt), CryptoHashing.sha256(string: stringToHash, salt: secondSalt)) } #endif } diff --git a/Tests/TelemetryClientTests/SignalPayloadTests.swift b/Tests/TelemetryClientTests/SignalPayloadTests.swift index 6adf25a..4d3be53 100644 --- a/Tests/TelemetryClientTests/SignalPayloadTests.swift +++ b/Tests/TelemetryClientTests/SignalPayloadTests.swift @@ -76,7 +76,7 @@ final class DefaultSignalPayloadTests: XCTestCase { #elseif os(Linux) expectedResult = "Linux" #elseif os(visionOS) - expectedResult = "VisionOS" + expectedResult = "visionOS" #else return "Unknown Operating System" #endif diff --git a/Tests/TelemetryClientTests/TelemetryClientTests.swift b/Tests/TelemetryClientTests/TelemetryClientTests.swift index 6a117ff..83e1f62 100644 --- a/Tests/TelemetryClientTests/TelemetryClientTests.swift +++ b/Tests/TelemetryClientTests/TelemetryClientTests.swift @@ -6,11 +6,11 @@ final class TelemetryClientTests: XCTestCase { func testSending() { let YOUR_APP_ID = "44e0f59a-60a2-4d4a-bf27-1f96ccb4aaa3" - let configuration = TelemetryManagerConfiguration(appID: YOUR_APP_ID) - TelemetryManager.initialize(with: configuration) - TelemetryManager.send("appOpenedRegularly") - TelemetryManager.send("userLoggedIn", for: "email") - TelemetryManager.send("databaseUpdated", with: ["numberOfDatabaseEntries": "3831"]) + let config = TelemetryManagerConfiguration(appID: YOUR_APP_ID) + TelemetryDeck.initialize(config: config) + TelemetryDeck.signal("appOpenedRegularly") + TelemetryDeck.signal("userLoggedIn", customUserID: "email") + TelemetryDeck.signal("databaseUpdated", parameters: ["numberOfDatabaseEntries": "3831"]) } func testPushAndPop() { @@ -56,7 +56,7 @@ final class TelemetryClientTests: XCTestCase { func testSignalEnrichers() throws { struct BasicEnricher: SignalEnricher { - func enrich(signalType: TelemetrySignalType, for clientUser: String?, floatValue: Double?) -> [String : String] { + func enrich(signalType: String, for clientUser: String?, floatValue: Double?) -> [String : String] { ["isTestEnricher": "true"] } } @@ -66,8 +66,8 @@ final class TelemetryClientTests: XCTestCase { let signalManager = FakeSignalManager() TelemetryManager.initialize(with: configuration, signalManager: signalManager) - TelemetryManager.send("testSignal") - + TelemetryDeck.signal("testSignal") + let bodyItems = signalManager.processedSignals XCTAssertEqual(bodyItems.count, 1) let bodyItem = try XCTUnwrap(bodyItems.first) @@ -76,7 +76,7 @@ final class TelemetryClientTests: XCTestCase { func testSignalEnrichers_precedence() throws { struct BasicEnricher: SignalEnricher { - func enrich(signalType: TelemetrySignalType, for clientUser: String?, floatValue: Double?) -> [String : String] { + func enrich(signalType: String, for clientUser: String?, floatValue: Double?) -> [String : String] { ["item": "A", "isDebug": "banana"] } } @@ -86,8 +86,8 @@ final class TelemetryClientTests: XCTestCase { let signalManager = FakeSignalManager() TelemetryManager.initialize(with: configuration, signalManager: signalManager) - TelemetryManager.send("testSignal", with: ["item": "B"]) - + TelemetryDeck.signal("testSignal", parameters: ["item": "B"]) + let bodyItems = signalManager.processedSignals XCTAssertEqual(bodyItems.count, 1) let bodyItem = try XCTUnwrap(bodyItems.first) @@ -103,7 +103,7 @@ final class TelemetryClientTests: XCTestCase { let signalManager = FakeSignalManager() TelemetryManager.initialize(with: configuration, signalManager: signalManager) - TelemetryManager.send("appOpenedRegularly") + TelemetryDeck.signal("appOpenedRegularly") XCTAssertEqual(signalManager.processedSignalTypes.count, 1) } @@ -117,7 +117,7 @@ final class TelemetryClientTests: XCTestCase { let signalManager = FakeSignalManager() TelemetryManager.initialize(with: configuration, signalManager: signalManager) - TelemetryManager.send("appOpenedRegularly") + TelemetryDeck.signal("appOpenedRegularly") XCTAssertEqual(signalManager.processedSignalTypes.count, 1) } @@ -131,7 +131,7 @@ final class TelemetryClientTests: XCTestCase { let signalManager = FakeSignalManager() TelemetryManager.initialize(with: configuration, signalManager: signalManager) - TelemetryManager.send("appOpenedRegularly") + TelemetryDeck.signal("appOpenedRegularly") XCTAssertTrue(signalManager.processedSignalTypes.isEmpty) } @@ -147,7 +147,7 @@ final class TelemetryClientTests: XCTestCase { let signalManager = FakeSignalManager() TelemetryManager.initialize(with: configuration, signalManager: signalManager) - TelemetryManager.send("appOpenedRegularly") + TelemetryDeck.signal("appOpenedRegularly") XCTAssertTrue(signalManager.processedSignalTypes.isEmpty) @@ -162,30 +162,30 @@ final class TelemetryClientTests: XCTestCase { let signalManager = FakeSignalManager() TelemetryManager.initialize(with: configuration, signalManager: signalManager) - TelemetryManager.send("appOpenedRegularly", floatValue: 42) - + TelemetryDeck.signal("appOpenedRegularly", floatValue: 42) + XCTAssertEqual(signalManager.processedSignals.first?.floatValue, 42) } } private class FakeSignalManager: SignalManageable { - var processedSignalTypes = [TelemetrySignalType]() + var processedSignalTypes = [String]() var processedSignals = [SignalPostBody]() - func processSignal(_ signalType: TelemetrySignalType, for clientUser: String?, floatValue: Double?, with additionalPayload: [String : String], configuration: TelemetryManagerConfiguration) { + func processSignal(_ signalType: String, parameters: [String : String], floatValue: Double?, customUserID: String?, configuration: TelemetryManagerConfiguration) { processedSignalTypes.append(signalType) let enrichedMetadata: [String: String] = configuration.metadataEnrichers - .map { $0.enrich(signalType: signalType, for: clientUser, floatValue: floatValue) } + .map { $0.enrich(signalType: signalType, for: customUserID, floatValue: floatValue) } .reduce([String: String](), { $0.applying($1) }) - let payload = DefaultSignalPayload().toDictionary() + let payload = DefaultSignalPayload.parameters .applying(enrichedMetadata) - .applying(additionalPayload) - + .applying(parameters) + let signalPostBody = SignalPostBody( receivedAt: Date(), appID: UUID(uuidString: configuration.telemetryAppID)!, - clientUser: clientUser ?? "no user", + clientUser: customUserID ?? "no user", sessionID: configuration.sessionID.uuidString, type: "\(signalType)", floatValue: floatValue,