From d1e7d60eb9069fa522eb9303254907da22aa7635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Wed, 25 Dec 2024 16:25:03 +0100 Subject: [PATCH 01/11] Outline all new signals & parameters & sprinkle TODOs to finalize --- .../PirateMetrics/SessionManager.swift | 10 ++++ .../TelemetryDeck+Acquisition.swift | 47 +++++++++++++++++ .../TelemetryDeck+Activation.swift | 32 ++++++++++++ .../TelemetryDeck+Referral.swift | 50 +++++++++++++++++++ .../PirateMetrics/TelemetryDeck+Revenue.swift | 17 +++++++ .../Presets/TelemetryDeck+Purchases.swift | 4 ++ Sources/TelemetryDeck/TelemetryClient.swift | 3 ++ 7 files changed, 163 insertions(+) create mode 100644 Sources/TelemetryDeck/PirateMetrics/SessionManager.swift create mode 100644 Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift create mode 100644 Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift create mode 100644 Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift create mode 100644 Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Revenue.swift diff --git a/Sources/TelemetryDeck/PirateMetrics/SessionManager.swift b/Sources/TelemetryDeck/PirateMetrics/SessionManager.swift new file mode 100644 index 0000000..774e38c --- /dev/null +++ b/Sources/TelemetryDeck/PirateMetrics/SessionManager.swift @@ -0,0 +1,10 @@ +import Foundation + +@MainActor +final class SessionManager { + static let shared = SessionManager() + private init() {} + + // TODO: make sure that all session start dates and their duration are persisted (use a Codable?) + // TODO: implement auto-detection of new install and send `newInstallDetected` with `firstSessionDate` +} diff --git a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift new file mode 100644 index 0000000..9c0bcd9 --- /dev/null +++ b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift @@ -0,0 +1,47 @@ +import Foundation + +public extension TelemetryDeck { + static func acquiredUser( + channel: String, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + let acquisitionParameters = ["TelemetryDeck.Acquisition.channel": channel] + + // TODO: persist channel and send with every request + + self.internalSignal( + "TelemetryDeck.Acquisition.userAcquired", + parameters: acquisitionParameters.merging(parameters) { $1 }, + customUserID: customUserID + ) + } + + static func leadStarted( + leadID: String, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + let leadParameters: [String: String] = ["TelemetryDeck.Acquisition.leadID": leadID] + + self.internalSignal( + "TelemetryDeck.Acquisition.leadStarted", + parameters: leadParameters.merging(parameters) { $1 }, + customUserID: customUserID + ) + } + + static func leadConverted( + leadID: String, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + let leadParameters: [String: String] = ["TelemetryDeck.Acquisition.leadID": leadID] + + self.internalSignal( + "TelemetryDeck.Acquisition.leadConverted", + parameters: leadParameters.merging(parameters) { $1 }, + customUserID: customUserID + ) + } +} diff --git a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift new file mode 100644 index 0000000..ce06f6f --- /dev/null +++ b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift @@ -0,0 +1,32 @@ +import Foundation + +extension TelemetryDeck { + static func onboardingCompleted( + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + let onboardingParameters: [String: String] = [:] + + self.internalSignal( + "TelemetryDeck.Activation.onboardingCompleted", + parameters: onboardingParameters.merging(parameters) { $1 }, + customUserID: customUserID + ) + } + + static func coreFeatureUsed( + featureName: String, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + let featureParameters = [ + "TelemetryDeck.Activation.featureName": featureName + ] + + self.internalSignal( + "TelemetryDeck.Activation.coreFeatureUsed", + parameters: featureParameters.merging(parameters) { $1 }, + customUserID: customUserID + ) + } +} diff --git a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift new file mode 100644 index 0000000..b51cb05 --- /dev/null +++ b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift @@ -0,0 +1,50 @@ +import Foundation + +extension TelemetryDeck { + static func referralSent( + receiversCount: Int = 1, + kind: String? = nil, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + // TODO: document all new parameters and their types in the default parameters doc + var referralParameters = ["TelemetryDeck.Referral.receiversCount": String(receiversCount)] + + if let kind { + referralParameters["TelemetryDeck.Referral.kind"] = kind + } + + self.internalSignal( + "TelemetryDeck.Referral.sent", + parameters: referralParameters.merging(parameters) { $1 }, + customUserID: customUserID + ) + } + + // TODO: explicitly mention how this can be used for NPS Score or for App Store like ratings + static func userRatingSubmitted( + rating: Int, + comment: String? = nil, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + guard (0...10).contains(rating) else { + TelemetryManager.shared.configuration.logHandler?.log(.error, message: "Rating must be between 0 and 10") + return + } + + var ratingParameters = [ + "TelemetryDeck.Referral.ratingValue": String(rating) + ] + + if let comment { + ratingParameters["TelemetryDeck.Referral.ratingComment"] = comment + } + + self.internalSignal( + "TelemetryDeck.Referral.userRatingSubmitted", + parameters: ratingParameters.merging(parameters) { $1 }, + customUserID: customUserID + ) + } +} diff --git a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Revenue.swift b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Revenue.swift new file mode 100644 index 0000000..58911ee --- /dev/null +++ b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Revenue.swift @@ -0,0 +1,17 @@ +import Foundation + +extension TelemetryDeck { + static func paywallShown( + reason: String, + parameters: [String: String] = [:], + customUserID: String? = nil + ) { + let paywallParameters = ["TelemetryDeck.Revenue.paywallShowReason": reason] + + self.internalSignal( + "TelemetryDeck.Revenue.paywallShown", + parameters: paywallParameters.merging(parameters) { $1 }, + customUserID: customUserID + ) + } +} diff --git a/Sources/TelemetryDeck/Presets/TelemetryDeck+Purchases.swift b/Sources/TelemetryDeck/Presets/TelemetryDeck+Purchases.swift index 5fb4bb7..406f508 100644 --- a/Sources/TelemetryDeck/Presets/TelemetryDeck+Purchases.swift +++ b/Sources/TelemetryDeck/Presets/TelemetryDeck+Purchases.swift @@ -19,6 +19,10 @@ extension TelemetryDeck { parameters: [String: String] = [:], customUserID: String? = nil ) { + // TODO: when a price of 0 and a subscription is detected, send `freeTrialStarted` signal + // TODO: persist free trial state + // TODO: add StoreKit integration to auto-detect free-trial conversions and send `convertedFromFreeTrial` + let priceValueInNativeCurrency = NSDecimalNumber(decimal: transaction.price ?? Decimal()).doubleValue let priceValueInUSD: Double diff --git a/Sources/TelemetryDeck/TelemetryClient.swift b/Sources/TelemetryDeck/TelemetryClient.swift index 0c9ae95..517d56e 100644 --- a/Sources/TelemetryDeck/TelemetryClient.swift +++ b/Sources/TelemetryDeck/TelemetryClient.swift @@ -71,6 +71,9 @@ public final class TelemetryManagerConfiguration: @unchecked Sendable { didSet { if sendNewSessionBeganSignal { TelemetryDeck.internalSignal("TelemetryDeck.Session.started") + + // TODO: send `totalSessionsCount` and `distinctDaysUsed` as well as `weekday`, `dayOfMonth`, and `dayOfYear` + // TODO: calculate and send `averageSessionSeconds` } } } From 107cfdcce76a2de3743bb4909ad787df3caaa798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Wed, 25 Dec 2024 16:41:27 +0100 Subject: [PATCH 02/11] Make all helper functions publicly accessible & add documentation todo --- .../{PirateMetrics => Helpers}/SessionManager.swift | 0 .../PirateMetrics/TelemetryDeck+Acquisition.swift | 11 +++++++---- .../PirateMetrics/TelemetryDeck+Activation.swift | 6 ++++-- .../PirateMetrics/TelemetryDeck+Referral.swift | 6 ++++-- .../PirateMetrics/TelemetryDeck+Revenue.swift | 3 ++- 5 files changed, 17 insertions(+), 9 deletions(-) rename Sources/TelemetryDeck/{PirateMetrics => Helpers}/SessionManager.swift (100%) diff --git a/Sources/TelemetryDeck/PirateMetrics/SessionManager.swift b/Sources/TelemetryDeck/Helpers/SessionManager.swift similarity index 100% rename from Sources/TelemetryDeck/PirateMetrics/SessionManager.swift rename to Sources/TelemetryDeck/Helpers/SessionManager.swift diff --git a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift index 9c0bcd9..02e2f80 100644 --- a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift +++ b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift @@ -1,7 +1,8 @@ import Foundation -public extension TelemetryDeck { - static func acquiredUser( +extension TelemetryDeck { + // TODO: add documentation comment with common/recommended usage examples + public static func acquiredUser( channel: String, parameters: [String: String] = [:], customUserID: String? = nil @@ -17,7 +18,8 @@ public extension TelemetryDeck { ) } - static func leadStarted( + // TODO: add documentation comment with common/recommended usage examples + public static func leadStarted( leadID: String, parameters: [String: String] = [:], customUserID: String? = nil @@ -31,7 +33,8 @@ public extension TelemetryDeck { ) } - static func leadConverted( + // TODO: add documentation comment with common/recommended usage examples + public static func leadConverted( leadID: String, parameters: [String: String] = [:], customUserID: String? = nil diff --git a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift index ce06f6f..ed9d15c 100644 --- a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift +++ b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift @@ -1,7 +1,8 @@ import Foundation extension TelemetryDeck { - static func onboardingCompleted( + // TODO: add documentation comment with common/recommended usage examples + public static func onboardingCompleted( parameters: [String: String] = [:], customUserID: String? = nil ) { @@ -14,7 +15,8 @@ extension TelemetryDeck { ) } - static func coreFeatureUsed( + // TODO: add documentation comment with common/recommended usage examples + public static func coreFeatureUsed( featureName: String, parameters: [String: String] = [:], customUserID: String? = nil diff --git a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift index b51cb05..6e65da9 100644 --- a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift +++ b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift @@ -1,7 +1,8 @@ import Foundation extension TelemetryDeck { - static func referralSent( + // TODO: add documentation comment with common/recommended usage examples + public static func referralSent( receiversCount: Int = 1, kind: String? = nil, parameters: [String: String] = [:], @@ -21,8 +22,9 @@ extension TelemetryDeck { ) } + // TODO: add documentation comment with common/recommended usage examples // TODO: explicitly mention how this can be used for NPS Score or for App Store like ratings - static func userRatingSubmitted( + public static func userRatingSubmitted( rating: Int, comment: String? = nil, parameters: [String: String] = [:], diff --git a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Revenue.swift b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Revenue.swift index 58911ee..ce2418a 100644 --- a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Revenue.swift +++ b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Revenue.swift @@ -1,7 +1,8 @@ import Foundation extension TelemetryDeck { - static func paywallShown( + // TODO: add documentation comment with common/recommended usage examples + public static func paywallShown( reason: String, parameters: [String: String] = [:], customUserID: String? = nil From 79b2b6ecfa4fc6d72d19dfa4bae0297eee861ada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Sun, 29 Dec 2024 14:22:50 +0100 Subject: [PATCH 03/11] Make customDefaults reusable in entire SDK --- Sources/TelemetryDeck/Signals/SignalManager.swift | 12 ++---------- Sources/TelemetryDeck/TelemetryDeck.swift | 9 +++++++++ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/Sources/TelemetryDeck/Signals/SignalManager.swift b/Sources/TelemetryDeck/Signals/SignalManager.swift index ed82613..a32533c 100644 --- a/Sources/TelemetryDeck/Signals/SignalManager.swift +++ b/Sources/TelemetryDeck/Signals/SignalManager.swift @@ -250,14 +250,6 @@ private extension SignalManager { // MARK: - Helpers extension SignalManager { - #if os(macOS) - /// A custom ``UserDefaults`` instance specific to TelemetryDeck and the current application. - private var customDefaults: UserDefaults? { - let appIdHash = CryptoHashing.sha256(string: configuration.telemetryAppID, salt: "") - return UserDefaults(suiteName: "com.telemetrydeck.\(appIdHash.suffix(12))") - } - #endif - /// The default user identifier. If the platform supports it, the ``identifierForVendor``. Otherwise, a self-generated `UUID` which is persisted in custom `UserDefaults` if available. @MainActor var defaultUserIdentifier: String { @@ -272,11 +264,11 @@ extension SignalManager { return "unknown user \(DefaultSignalPayload.platform) \(DefaultSignalPayload.systemVersion) \(DefaultSignalPayload.buildNumber)" } #elseif os(macOS) - if let customDefaults = customDefaults, let defaultUserIdentifier = customDefaults.string(forKey: "defaultUserIdentifier") { + if let customDefaults = TelemetryDeck.customDefaults, let defaultUserIdentifier = customDefaults.string(forKey: "defaultUserIdentifier") { return defaultUserIdentifier } else { let defaultUserIdentifier = UUID().uuidString - customDefaults?.set(defaultUserIdentifier, forKey: "defaultUserIdentifier") + TelemetryDeck.customDefaults?.set(defaultUserIdentifier, forKey: "defaultUserIdentifier") return defaultUserIdentifier } #else diff --git a/Sources/TelemetryDeck/TelemetryDeck.swift b/Sources/TelemetryDeck/TelemetryDeck.swift index 2a178f7..34b8b82 100644 --- a/Sources/TelemetryDeck/TelemetryDeck.swift +++ b/Sources/TelemetryDeck/TelemetryDeck.swift @@ -221,4 +221,13 @@ public enum TelemetryDeck { public static func generateNewSession() { TelemetryManager.shared.configuration.sessionID = UUID() } + + // MARK: - Internals + /// A custom ``UserDefaults`` instance specific to TelemetryDeck and the current application. + static var customDefaults: UserDefaults? { + guard let configuration = TelemetryManager.initializedTelemetryManager?.configuration else { return nil } + + let appIdHash = CryptoHashing.sha256(string: configuration.telemetryAppID, salt: "") + return UserDefaults(suiteName: "com.telemetrydeck.\(appIdHash.suffix(12))") + } } From 1d765477d7e360f2a8950efccdf6ce0255ccc98e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Sun, 29 Dec 2024 16:35:21 +0100 Subject: [PATCH 04/11] Implement basic session manager logic with TODOs for what's left --- .../Helpers/SessionManager.swift | 169 +++++++++++++++++- Sources/TelemetryDeck/TelemetryClient.swift | 11 +- 2 files changed, 168 insertions(+), 12 deletions(-) diff --git a/Sources/TelemetryDeck/Helpers/SessionManager.swift b/Sources/TelemetryDeck/Helpers/SessionManager.swift index 774e38c..2887a53 100644 --- a/Sources/TelemetryDeck/Helpers/SessionManager.swift +++ b/Sources/TelemetryDeck/Helpers/SessionManager.swift @@ -1,10 +1,167 @@ -import Foundation +#if canImport(WatchKit) +import WatchKit +#elseif canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +// TODO: test logic of this class in a real-world app to find edge cases (unit tests feasible?) +// TODO: add automatic sending of session lengths as default parameters +// TODO: persist save dinstinct days used count separately +// TODO: persist first install date separately + +final class SessionManager: @unchecked Sendable { + private struct StoredSession: Codable { + let startedAt: Date + let durationInSeconds: Int + } -@MainActor -final class SessionManager { static let shared = SessionManager() - private init() {} + private static let sessionsKey = "sessions" + + private var sessionsByID: [UUID: StoredSession] + + private var currentSessionID: UUID = UUID() + private var currentSessionStartetAt: Date = .distantPast + private var currentSessionDuration: TimeInterval = .zero + + private var sessionDurationUpdater: Timer? + private var sessionDurationLastUpdatedAt: Date? + + private let persistenceQueue = DispatchQueue(label: "com.telemetrydeck.sessionmanager.persistence") + + private init() { + if + let existingSessionData = TelemetryDeck.customDefaults?.data(forKey: Self.sessionsKey), + let existingSessions = try? JSONDecoder().decode([UUID: StoredSession].self, from: existingSessionData) + { + // upon app start, clean up any sessions older than 90 days to keep dict small + let cutoffDate = Date().addingTimeInterval(-(90 * 24 * 60 * 60)) + self.sessionsByID = existingSessions.filter { $0.value.startedAt > cutoffDate } + } else { + self.sessionsByID = [:] + } + + self.setupAppLifecycleObservers() + } + + func startSessionTimer() { + // stop automatic duration counting of previous session + self.stopSessionTimer() + + // TODO: when sessionsByID is empty here, then send "`newInstallDetected`" with `firstSessionDate` + + // start a new session + self.currentSessionID = UUID() + self.currentSessionStartetAt = Date() + self.currentSessionDuration = .zero + + // start automatic duration counting of new session + self.updateSessionDuration() + self.sessionDurationUpdater = Timer.scheduledTimer( + timeInterval: 1, + target: self, + selector: #selector(updateSessionDuration), + userInfo: nil, + repeats: true + ) + } + + private func stopSessionTimer() { + self.sessionDurationUpdater?.invalidate() + self.sessionDurationUpdater = nil + self.sessionDurationLastUpdatedAt = nil + } + + @objc + private func updateSessionDuration() { + if let sessionDurationLastUpdatedAt { + self.currentSessionDuration += Date().timeIntervalSince(sessionDurationLastUpdatedAt) + } + + self.sessionDurationLastUpdatedAt = Date() + self.persistCurrentSessionIfNeeded() + } + + private func persistCurrentSessionIfNeeded() { + // Ignore sessions under 1 second + guard self.currentSessionDuration >= 1.0 else { return } + + // Add or update the current session + self.sessionsByID[self.currentSessionID] = StoredSession( + startedAt: self.currentSessionStartetAt, + durationInSeconds: Int(self.currentSessionDuration) + ) + + // Save changes to UserDefaults without blocking Main thread + self.persistenceQueue.async { + guard let updatedSessionData = try? JSONEncoder().encode(self.sessionsByID) else { return } + TelemetryDeck.customDefaults?.set(updatedSessionData, forKey: Self.sessionsKey) + } + } + + @objc + private func handleDidEnterBackgroundNotification() { + self.updateSessionDuration() + self.stopSessionTimer() + } + + @objc + private func handleWillEnterForegroundNotification() { + self.updateSessionDuration() + self.sessionDurationUpdater = Timer.scheduledTimer( + timeInterval: 1, + target: self, + selector: #selector(updateSessionDuration), + userInfo: nil, + repeats: true + ) + } + + private func setupAppLifecycleObservers() { + #if canImport(WatchKit) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDidEnterBackgroundNotification), + name: WKApplication.didEnterBackgroundNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillEnterForegroundNotification), + name: WKApplication.willEnterForegroundNotification, + object: nil + ) + #elseif canImport(UIKit) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDidEnterBackgroundNotification), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillEnterForegroundNotification), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + #elseif canImport(AppKit) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDidEnterBackgroundNotification), + name: NSApplication.didResignActiveNotification, + object: nil + ) - // TODO: make sure that all session start dates and their duration are persisted (use a Codable?) - // TODO: implement auto-detection of new install and send `newInstallDetected` with `firstSessionDate` + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillEnterForegroundNotification), + name: NSApplication.willBecomeActiveNotification, + object: nil + ) + #endif + } } diff --git a/Sources/TelemetryDeck/TelemetryClient.swift b/Sources/TelemetryDeck/TelemetryClient.swift index 517d56e..0e35a3f 100644 --- a/Sources/TelemetryDeck/TelemetryClient.swift +++ b/Sources/TelemetryDeck/TelemetryClient.swift @@ -62,18 +62,17 @@ public final class TelemetryManagerConfiguration: @unchecked Sendable { /// A random identifier for the current user session. /// - /// On iOS, tvOS, and watchOS, the session identifier will automatically update whenever your app returns from background, or if it is - /// launched from cold storage. On other platforms, a new identifier will be generated each time your app launches. If you'd like + /// On iOS, tvOS, and watchOS, the session identifier will automatically update whenever your app returns from background after 5 minutes, + /// or if it is launched from cold storage. On other platforms, a new identifier will be generated each time your app launches. If you'd like /// 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` + /// Beginning a new session automatically sends a "TelemetryDeck.Session.started" Signal if `sendNewSessionBeganSignal` is `true` public var sessionID = UUID() { didSet { + SessionManager.shared.startSession() + if sendNewSessionBeganSignal { TelemetryDeck.internalSignal("TelemetryDeck.Session.started") - - // TODO: send `totalSessionsCount` and `distinctDaysUsed` as well as `weekday`, `dayOfMonth`, and `dayOfYear` - // TODO: calculate and send `averageSessionSeconds` } } } From add7de54c3880d8d97134a4555d65c33b317389d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Thu, 2 Jan 2025 12:31:35 +0100 Subject: [PATCH 05/11] Simplify session storage format to use even less space Note that this commit was manually tested in a project and confirmed to work (persist, reset & count) as expected. --- .../Helpers/SessionManager.swift | 56 +++++++++++++------ Sources/TelemetryDeck/TelemetryClient.swift | 2 +- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/Sources/TelemetryDeck/Helpers/SessionManager.swift b/Sources/TelemetryDeck/Helpers/SessionManager.swift index 2887a53..a460ab2 100644 --- a/Sources/TelemetryDeck/Helpers/SessionManager.swift +++ b/Sources/TelemetryDeck/Helpers/SessionManager.swift @@ -6,24 +6,28 @@ import UIKit import AppKit #endif -// TODO: test logic of this class in a real-world app to find edge cases (unit tests feasible?) // TODO: add automatic sending of session lengths as default parameters -// TODO: persist save dinstinct days used count separately +// TODO: persist dinstinct days used count separately // TODO: persist first install date separately final class SessionManager: @unchecked Sendable { private struct StoredSession: Codable { let startedAt: Date - let durationInSeconds: Int + var durationInSeconds: Int + + // Let's save some extra space in UserDefaults by using shorter keys. + private enum CodingKeys: String, CodingKey { + case startedAt = "st" + case durationInSeconds = "dn" + } } static let shared = SessionManager() private static let sessionsKey = "sessions" - private var sessionsByID: [UUID: StoredSession] + private var sessions: [StoredSession] - private var currentSessionID: UUID = UUID() - private var currentSessionStartetAt: Date = .distantPast + private var currentSessionStartedAt: Date = .distantPast private var currentSessionDuration: TimeInterval = .zero private var sessionDurationUpdater: Timer? @@ -31,30 +35,46 @@ final class SessionManager: @unchecked Sendable { private let persistenceQueue = DispatchQueue(label: "com.telemetrydeck.sessionmanager.persistence") + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + return decoder + }() + + private static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + // removes sub-second level precision from the start date as we don't need it + encoder.dateEncodingStrategy = .custom { date, encoder in + let timestamp = Int(date.timeIntervalSince1970) + var container = encoder.singleValueContainer() + try container.encode(timestamp) + } + return encoder + }() + private init() { if let existingSessionData = TelemetryDeck.customDefaults?.data(forKey: Self.sessionsKey), - let existingSessions = try? JSONDecoder().decode([UUID: StoredSession].self, from: existingSessionData) + let existingSessions = try? Self.decoder.decode([StoredSession].self, from: existingSessionData) { // upon app start, clean up any sessions older than 90 days to keep dict small let cutoffDate = Date().addingTimeInterval(-(90 * 24 * 60 * 60)) - self.sessionsByID = existingSessions.filter { $0.value.startedAt > cutoffDate } + self.sessions = existingSessions.filter { $0.startedAt > cutoffDate } } else { - self.sessionsByID = [:] + self.sessions = [] } self.setupAppLifecycleObservers() } - func startSessionTimer() { + func startNewSession() { // stop automatic duration counting of previous session self.stopSessionTimer() // TODO: when sessionsByID is empty here, then send "`newInstallDetected`" with `firstSessionDate` // start a new session - self.currentSessionID = UUID() - self.currentSessionStartetAt = Date() + self.currentSessionStartedAt = Date() self.currentSessionDuration = .zero // start automatic duration counting of new session @@ -89,14 +109,16 @@ final class SessionManager: @unchecked Sendable { guard self.currentSessionDuration >= 1.0 else { return } // Add or update the current session - self.sessionsByID[self.currentSessionID] = StoredSession( - startedAt: self.currentSessionStartetAt, - durationInSeconds: Int(self.currentSessionDuration) - ) + if let existingSessionIndex = self.sessions.lastIndex(where: { $0.startedAt == self.currentSessionStartedAt }) { + self.sessions[existingSessionIndex].durationInSeconds = Int(self.currentSessionDuration) + } else { + let newSession = StoredSession(startedAt: self.currentSessionStartedAt, durationInSeconds: Int(self.currentSessionDuration)) + self.sessions.append(newSession) + } // Save changes to UserDefaults without blocking Main thread self.persistenceQueue.async { - guard let updatedSessionData = try? JSONEncoder().encode(self.sessionsByID) else { return } + guard let updatedSessionData = try? Self.encoder.encode(self.sessions) else { return } TelemetryDeck.customDefaults?.set(updatedSessionData, forKey: Self.sessionsKey) } } diff --git a/Sources/TelemetryDeck/TelemetryClient.swift b/Sources/TelemetryDeck/TelemetryClient.swift index 0e35a3f..f1b8f44 100644 --- a/Sources/TelemetryDeck/TelemetryClient.swift +++ b/Sources/TelemetryDeck/TelemetryClient.swift @@ -69,7 +69,7 @@ public final class TelemetryManagerConfiguration: @unchecked Sendable { /// Beginning a new session automatically sends a "TelemetryDeck.Session.started" Signal if `sendNewSessionBeganSignal` is `true` public var sessionID = UUID() { didSet { - SessionManager.shared.startSession() + SessionManager.shared.startNewSession() if sendNewSessionBeganSignal { TelemetryDeck.internalSignal("TelemetryDeck.Session.started") From 998dd5279b55c3b5e9ce14cb8ba41fc7a5376a35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Thu, 2 Jan 2025 12:44:17 +0100 Subject: [PATCH 06/11] Implement first install date reporting & persistence --- .../Helpers/SessionManager.swift | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/Sources/TelemetryDeck/Helpers/SessionManager.swift b/Sources/TelemetryDeck/Helpers/SessionManager.swift index a460ab2..0321c86 100644 --- a/Sources/TelemetryDeck/Helpers/SessionManager.swift +++ b/Sources/TelemetryDeck/Helpers/SessionManager.swift @@ -6,9 +6,8 @@ import UIKit import AppKit #endif -// TODO: add automatic sending of session lengths as default parameters +// TODO: add automatic sending of session length, first install date, distinct days etc. as default parameters // TODO: persist dinstinct days used count separately -// TODO: persist first install date separately final class SessionManager: @unchecked Sendable { private struct StoredSession: Codable { @@ -23,17 +22,10 @@ final class SessionManager: @unchecked Sendable { } static let shared = SessionManager() + private static let sessionsKey = "sessions" - - private var sessions: [StoredSession] - - private var currentSessionStartedAt: Date = .distantPast - private var currentSessionDuration: TimeInterval = .zero - - private var sessionDurationUpdater: Timer? - private var sessionDurationLastUpdatedAt: Date? - - private let persistenceQueue = DispatchQueue(label: "com.telemetrydeck.sessionmanager.persistence") + private static let firstInstallDateKey = "firstInstallDate" + private static let distinctDaysUsedCountKey = "distinctDaysUsedCount" private static let decoder: JSONDecoder = { let decoder = JSONDecoder() @@ -52,6 +44,16 @@ final class SessionManager: @unchecked Sendable { return encoder }() + private var sessions: [StoredSession] + + private var currentSessionStartedAt: Date = .distantPast + private var currentSessionDuration: TimeInterval = .zero + + private var sessionDurationUpdater: Timer? + private var sessionDurationLastUpdatedAt: Date? + + private let persistenceQueue = DispatchQueue(label: "com.telemetrydeck.sessionmanager.persistence") + private init() { if let existingSessionData = TelemetryDeck.customDefaults?.data(forKey: Self.sessionsKey), @@ -71,7 +73,20 @@ final class SessionManager: @unchecked Sendable { // stop automatic duration counting of previous session self.stopSessionTimer() - // TODO: when sessionsByID is empty here, then send "`newInstallDetected`" with `firstSessionDate` + // if the sessions are empty, this must be the first start after installing the app + if self.sessions.isEmpty { + // this ensures we only use the date, not the time –> e.g. "2025-01-31" + let formattedDate = ISO8601DateFormatter.string(from: Date(), timeZone: .current, formatOptions: [.withFullDate]) + + TelemetryDeck.internalSignal( + "TelemetryDeck.Acquisition.newInstallDetected", + parameters: ["TelemetryDeck.Acquisition.firstSessionDate": formattedDate] + ) + + self.persistenceQueue.async { + TelemetryDeck.customDefaults?.set(formattedDate, forKey: Self.firstInstallDateKey) + } + } // start a new session self.currentSessionStartedAt = Date() From 904af58d39e5acdbc070fa9b1784f98965d46e09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Thu, 2 Jan 2025 14:04:02 +0100 Subject: [PATCH 07/11] Persist & update distinct days the app was used in --- .../Helpers/SessionManager.swift | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/Sources/TelemetryDeck/Helpers/SessionManager.swift b/Sources/TelemetryDeck/Helpers/SessionManager.swift index 0321c86..71b8d30 100644 --- a/Sources/TelemetryDeck/Helpers/SessionManager.swift +++ b/Sources/TelemetryDeck/Helpers/SessionManager.swift @@ -7,7 +7,6 @@ import AppKit #endif // TODO: add automatic sending of session length, first install date, distinct days etc. as default parameters -// TODO: persist dinstinct days used count separately final class SessionManager: @unchecked Sendable { private struct StoredSession: Codable { @@ -25,7 +24,7 @@ final class SessionManager: @unchecked Sendable { private static let sessionsKey = "sessions" private static let firstInstallDateKey = "firstInstallDate" - private static let distinctDaysUsedCountKey = "distinctDaysUsedCount" + private static let distinctDaysUsedKey = "distinctDaysUsed" private static let decoder: JSONDecoder = { let decoder = JSONDecoder() @@ -66,6 +65,7 @@ final class SessionManager: @unchecked Sendable { self.sessions = [] } + self.updateDistinctDaysUsed() self.setupAppLifecycleObservers() } @@ -76,15 +76,15 @@ final class SessionManager: @unchecked Sendable { // if the sessions are empty, this must be the first start after installing the app if self.sessions.isEmpty { // this ensures we only use the date, not the time –> e.g. "2025-01-31" - let formattedDate = ISO8601DateFormatter.string(from: Date(), timeZone: .current, formatOptions: [.withFullDate]) + let todayFormatted = ISO8601DateFormatter.string(from: Date(), timeZone: .current, formatOptions: [.withFullDate]) TelemetryDeck.internalSignal( "TelemetryDeck.Acquisition.newInstallDetected", - parameters: ["TelemetryDeck.Acquisition.firstSessionDate": formattedDate] + parameters: ["TelemetryDeck.Acquisition.firstSessionDate": todayFormatted] ) self.persistenceQueue.async { - TelemetryDeck.customDefaults?.set(formattedDate, forKey: Self.firstInstallDateKey) + TelemetryDeck.customDefaults?.set(todayFormatted, forKey: Self.firstInstallDateKey) } } @@ -133,8 +133,9 @@ final class SessionManager: @unchecked Sendable { // Save changes to UserDefaults without blocking Main thread self.persistenceQueue.async { - guard let updatedSessionData = try? Self.encoder.encode(self.sessions) else { return } - TelemetryDeck.customDefaults?.set(updatedSessionData, forKey: Self.sessionsKey) + if let updatedSessionData = try? Self.encoder.encode(self.sessions) { + TelemetryDeck.customDefaults?.set(updatedSessionData, forKey: Self.sessionsKey) + } } } @@ -156,6 +157,28 @@ final class SessionManager: @unchecked Sendable { ) } + private func updateDistinctDaysUsed() { + let todayFormatted = ISO8601DateFormatter.string(from: Date(), timeZone: .current, formatOptions: [.withFullDate]) + + var distinctDays: [String] = [] + if + let existinDaysData = TelemetryDeck.customDefaults?.data(forKey: Self.distinctDaysUsedKey), + let existingDays = try? JSONDecoder().decode([String].self, from: existinDaysData) + { + distinctDays = existingDays + } + + if distinctDays.last != todayFormatted { + distinctDays.append(todayFormatted) + + self.persistenceQueue.async { + if let updatedDistinctDaysData = try? JSONEncoder().encode(distinctDays) { + TelemetryDeck.customDefaults?.set(updatedDistinctDaysData, forKey: Self.distinctDaysUsedKey) + } + } + } + } + private func setupAppLifecycleObservers() { #if canImport(WatchKit) NotificationCenter.default.addObserver( From 696a5834975494085542d5c4d8b682a8edb38874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Thu, 2 Jan 2025 15:24:18 +0100 Subject: [PATCH 08/11] Calculate & automatically report more default parameters --- .../Helpers/SessionManager.swift | 99 ++++++++++++------- Sources/TelemetryDeck/Signals/Signal.swift | 49 +++++++++ 2 files changed, 115 insertions(+), 33 deletions(-) diff --git a/Sources/TelemetryDeck/Helpers/SessionManager.swift b/Sources/TelemetryDeck/Helpers/SessionManager.swift index 71b8d30..95e8d83 100644 --- a/Sources/TelemetryDeck/Helpers/SessionManager.swift +++ b/Sources/TelemetryDeck/Helpers/SessionManager.swift @@ -6,8 +6,6 @@ import UIKit import AppKit #endif -// TODO: add automatic sending of session length, first install date, distinct days etc. as default parameters - final class SessionManager: @unchecked Sendable { private struct StoredSession: Codable { let startedAt: Date @@ -22,8 +20,10 @@ final class SessionManager: @unchecked Sendable { static let shared = SessionManager() - private static let sessionsKey = "sessions" - private static let firstInstallDateKey = "firstInstallDate" + private static let recentSessionsKey = "recentSessions" + private static let deletedSessionsCountKey = "deletedSessionsCount" + + private static let firstSessionDateKey = "firstSessionDate" private static let distinctDaysUsedKey = "distinctDaysUsed" private static let decoder: JSONDecoder = { @@ -43,7 +43,51 @@ final class SessionManager: @unchecked Sendable { return encoder }() - private var sessions: [StoredSession] + private var recentSessions: [StoredSession] + + private var deletedSessionsCount: Int { + get { TelemetryDeck.customDefaults?.integer(forKey: Self.deletedSessionsCountKey) ?? 0 } + set { + self.persistenceQueue.async { + TelemetryDeck.customDefaults?.set(newValue, forKey: Self.deletedSessionsCountKey) + } + } + } + + var totalSessionsCount: Int { + self.recentSessions.count + self.deletedSessionsCount + } + + var averageSessionSeconds: Int { + let completedSessions = self.recentSessions.dropLast() + let totalCompletedSessionSeconds = completedSessions.map(\.durationInSeconds).reduce(into: 0) { $0 + $1 } + return totalCompletedSessionSeconds / completedSessions.count + } + + var previousSessionSeconds: Int? { + self.recentSessions.dropLast().last?.durationInSeconds + } + + var firstSessionDate: String { + get { + TelemetryDeck.customDefaults?.string(forKey: Self.firstSessionDateKey) + ?? ISO8601DateFormatter.string(from: Date(), timeZone: .current, formatOptions: [.withFullDate]) + } + set { + self.persistenceQueue.async { + TelemetryDeck.customDefaults?.set(newValue, forKey: Self.firstSessionDateKey) + } + } + } + + var distinctDaysUsed: [String] { + get { TelemetryDeck.customDefaults?.stringArray(forKey: Self.distinctDaysUsedKey) ?? [] } + set { + self.persistenceQueue.async { + TelemetryDeck.customDefaults?.set(newValue, forKey: Self.distinctDaysUsedKey) + } + } + } private var currentSessionStartedAt: Date = .distantPast private var currentSessionDuration: TimeInterval = .zero @@ -55,14 +99,17 @@ final class SessionManager: @unchecked Sendable { private init() { if - let existingSessionData = TelemetryDeck.customDefaults?.data(forKey: Self.sessionsKey), + let existingSessionData = TelemetryDeck.customDefaults?.data(forKey: Self.recentSessionsKey), let existingSessions = try? Self.decoder.decode([StoredSession].self, from: existingSessionData) { // upon app start, clean up any sessions older than 90 days to keep dict small let cutoffDate = Date().addingTimeInterval(-(90 * 24 * 60 * 60)) - self.sessions = existingSessions.filter { $0.startedAt > cutoffDate } + self.recentSessions = existingSessions.filter { $0.startedAt > cutoffDate } + + // Update deleted sessions count + self.deletedSessionsCount += existingSessions.count - self.recentSessions.count } else { - self.sessions = [] + self.recentSessions = [] } self.updateDistinctDaysUsed() @@ -73,19 +120,17 @@ final class SessionManager: @unchecked Sendable { // stop automatic duration counting of previous session self.stopSessionTimer() - // if the sessions are empty, this must be the first start after installing the app - if self.sessions.isEmpty { + // if the recent sessions are empty, this must be the first start after installing the app + if self.recentSessions.isEmpty { // this ensures we only use the date, not the time –> e.g. "2025-01-31" let todayFormatted = ISO8601DateFormatter.string(from: Date(), timeZone: .current, formatOptions: [.withFullDate]) + self.firstSessionDate = todayFormatted + TelemetryDeck.internalSignal( "TelemetryDeck.Acquisition.newInstallDetected", parameters: ["TelemetryDeck.Acquisition.firstSessionDate": todayFormatted] ) - - self.persistenceQueue.async { - TelemetryDeck.customDefaults?.set(todayFormatted, forKey: Self.firstInstallDateKey) - } } // start a new session @@ -124,17 +169,17 @@ final class SessionManager: @unchecked Sendable { guard self.currentSessionDuration >= 1.0 else { return } // Add or update the current session - if let existingSessionIndex = self.sessions.lastIndex(where: { $0.startedAt == self.currentSessionStartedAt }) { - self.sessions[existingSessionIndex].durationInSeconds = Int(self.currentSessionDuration) + if let existingSessionIndex = self.recentSessions.lastIndex(where: { $0.startedAt == self.currentSessionStartedAt }) { + self.recentSessions[existingSessionIndex].durationInSeconds = Int(self.currentSessionDuration) } else { let newSession = StoredSession(startedAt: self.currentSessionStartedAt, durationInSeconds: Int(self.currentSessionDuration)) - self.sessions.append(newSession) + self.recentSessions.append(newSession) } // Save changes to UserDefaults without blocking Main thread self.persistenceQueue.async { - if let updatedSessionData = try? Self.encoder.encode(self.sessions) { - TelemetryDeck.customDefaults?.set(updatedSessionData, forKey: Self.sessionsKey) + if let updatedSessionData = try? Self.encoder.encode(self.recentSessions) { + TelemetryDeck.customDefaults?.set(updatedSessionData, forKey: Self.recentSessionsKey) } } } @@ -160,22 +205,10 @@ final class SessionManager: @unchecked Sendable { private func updateDistinctDaysUsed() { let todayFormatted = ISO8601DateFormatter.string(from: Date(), timeZone: .current, formatOptions: [.withFullDate]) - var distinctDays: [String] = [] - if - let existinDaysData = TelemetryDeck.customDefaults?.data(forKey: Self.distinctDaysUsedKey), - let existingDays = try? JSONDecoder().decode([String].self, from: existinDaysData) - { - distinctDays = existingDays - } - + var distinctDays = self.distinctDaysUsed if distinctDays.last != todayFormatted { distinctDays.append(todayFormatted) - - self.persistenceQueue.async { - if let updatedDistinctDaysData = try? JSONEncoder().encode(distinctDays) { - TelemetryDeck.customDefaults?.set(updatedDistinctDaysData, forKey: Self.distinctDaysUsedKey) - } - } + self.distinctDaysUsed = distinctDays } } diff --git a/Sources/TelemetryDeck/Signals/Signal.swift b/Sources/TelemetryDeck/Signals/Signal.swift index 83f6684..c4f7d90 100644 --- a/Sources/TelemetryDeck/Signals/Signal.swift +++ b/Sources/TelemetryDeck/Signals/Signal.swift @@ -99,9 +99,16 @@ public struct DefaultSignalPayload: Encodable { "TelemetryDeck.UserPreference.language": Self.preferredLanguage, "TelemetryDeck.UserPreference.layoutDirection": Self.layoutDirection, "TelemetryDeck.UserPreference.region": Self.region, + + // Pirate Metrics + "TelemetryDeck.Acquisition.firstSessionDate": SessionManager.shared.firstSessionDate, + "TelemetryDeck.Retention.averageSessionSeconds": "\(SessionManager.shared.averageSessionSeconds)", + "TelemetryDeck.Retention.distinctDaysUsed": "\(SessionManager.shared.distinctDaysUsed.count)", + "TelemetryDeck.Retention.totalSessionsCount": "\(SessionManager.shared.totalSessionsCount)", ] parameters.merge(self.accessibilityParameters, uniquingKeysWith: { $1 }) + parameters.merge(self.calendarParameters, uniquingKeysWith: { $1 }) if let extensionIdentifier = Self.extensionIdentifier { // deprecated name @@ -111,6 +118,10 @@ public struct DefaultSignalPayload: Encodable { parameters["TelemetryDeck.RunContext.extensionIdentifier"] = extensionIdentifier } + if let previousSessionSeconds = SessionManager.shared.previousSessionSeconds { + parameters["TelemetryDeck.Retention.previousSessionSeconds"] = "\(previousSessionSeconds)" + } + return parameters } } @@ -118,6 +129,44 @@ public struct DefaultSignalPayload: Encodable { // MARK: - Helpers extension DefaultSignalPayload { + static var calendarParameters: [String: String] { + let calendar = Calendar(identifier: .gregorian) + let now = Date() + + // Get components for all the metrics we need + let components = calendar.dateComponents( + [.day, .weekday, .weekOfYear, .month, .hour, .quarter, .yearForWeekOfYear], + from: now + ) + + // Calculate day of year + let dayOfYear = calendar.ordinality(of: .day, in: .year, for: now) ?? -1 + + // Convert Sunday=1..Saturday=7 to Monday=1..Sunday=7 + let dayOfWeek = components.weekday.map { $0 == 1 ? 7 : $0 - 1 } ?? -1 + + // Weekend is now days 6 (Saturday) and 7 (Sunday) + let isWeekend = dayOfWeek >= 6 + + return [ + // Day-based metrics + "TelemetryDeck.Calendar.dayOfMonth": "\(components.day ?? -1)", + "TelemetryDeck.Calendar.dayOfWeek": "\(dayOfWeek)", // 1 = Monday, 7 = Sunday + "TelemetryDeck.Calendar.dayOfYear": "\(dayOfYear)", + + // Week-based metrics + "TelemetryDeck.Calendar.weekOfYear": "\(components.weekOfYear ?? -1)", + "TelemetryDeck.Calendar.isWeekend": "\(isWeekend)", + + // Month and quarter + "TelemetryDeck.Calendar.monthOfYear": "\(components.month ?? -1)", + "TelemetryDeck.Calendar.quarterOfYear": "\(components.quarter ?? -1)", + + // Hours in 1-24 format + "TelemetryDeck.Calendar.hourOfDay": "\((components.hour ?? -1) + 1)" + ] + } + @MainActor static var accessibilityParameters: [String: String] { var a11yParams: [String: String] = [:] From 9eeaeb60121079b8592fd5b06502380332fbc8a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Wed, 5 Feb 2025 22:14:31 +0100 Subject: [PATCH 09/11] Remove all TODOs and make unfinished APIs private --- .../PirateMetrics/TelemetryDeck+Acquisition.swift | 11 +++-------- .../PirateMetrics/TelemetryDeck+Activation.swift | 6 ++---- .../PirateMetrics/TelemetryDeck+Referral.swift | 8 ++------ .../PirateMetrics/TelemetryDeck+Revenue.swift | 3 +-- .../Presets/TelemetryDeck+Purchases.swift | 4 ---- 5 files changed, 8 insertions(+), 24 deletions(-) diff --git a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift index 02e2f80..bc8668f 100644 --- a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift +++ b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift @@ -1,16 +1,13 @@ import Foundation extension TelemetryDeck { - // TODO: add documentation comment with common/recommended usage examples - public static func acquiredUser( + private static func acquiredUser( channel: String, parameters: [String: String] = [:], customUserID: String? = nil ) { let acquisitionParameters = ["TelemetryDeck.Acquisition.channel": channel] - // TODO: persist channel and send with every request - self.internalSignal( "TelemetryDeck.Acquisition.userAcquired", parameters: acquisitionParameters.merging(parameters) { $1 }, @@ -18,8 +15,7 @@ extension TelemetryDeck { ) } - // TODO: add documentation comment with common/recommended usage examples - public static func leadStarted( + private static func leadStarted( leadID: String, parameters: [String: String] = [:], customUserID: String? = nil @@ -33,8 +29,7 @@ extension TelemetryDeck { ) } - // TODO: add documentation comment with common/recommended usage examples - public static func leadConverted( + private static func leadConverted( leadID: String, parameters: [String: String] = [:], customUserID: String? = nil diff --git a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift index ed9d15c..f06b40d 100644 --- a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift +++ b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift @@ -1,8 +1,7 @@ import Foundation extension TelemetryDeck { - // TODO: add documentation comment with common/recommended usage examples - public static func onboardingCompleted( + private static func onboardingCompleted( parameters: [String: String] = [:], customUserID: String? = nil ) { @@ -15,8 +14,7 @@ extension TelemetryDeck { ) } - // TODO: add documentation comment with common/recommended usage examples - public static func coreFeatureUsed( + private static func coreFeatureUsed( featureName: String, parameters: [String: String] = [:], customUserID: String? = nil diff --git a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift index 6e65da9..91fe7fd 100644 --- a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift +++ b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift @@ -1,14 +1,12 @@ import Foundation extension TelemetryDeck { - // TODO: add documentation comment with common/recommended usage examples - public static func referralSent( + private static func referralSent( receiversCount: Int = 1, kind: String? = nil, parameters: [String: String] = [:], customUserID: String? = nil ) { - // TODO: document all new parameters and their types in the default parameters doc var referralParameters = ["TelemetryDeck.Referral.receiversCount": String(receiversCount)] if let kind { @@ -22,9 +20,7 @@ extension TelemetryDeck { ) } - // TODO: add documentation comment with common/recommended usage examples - // TODO: explicitly mention how this can be used for NPS Score or for App Store like ratings - public static func userRatingSubmitted( + private static func userRatingSubmitted( rating: Int, comment: String? = nil, parameters: [String: String] = [:], diff --git a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Revenue.swift b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Revenue.swift index ce2418a..e77e22f 100644 --- a/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Revenue.swift +++ b/Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Revenue.swift @@ -1,8 +1,7 @@ import Foundation extension TelemetryDeck { - // TODO: add documentation comment with common/recommended usage examples - public static func paywallShown( + private static func paywallShown( reason: String, parameters: [String: String] = [:], customUserID: String? = nil diff --git a/Sources/TelemetryDeck/Presets/TelemetryDeck+Purchases.swift b/Sources/TelemetryDeck/Presets/TelemetryDeck+Purchases.swift index 406f508..5fb4bb7 100644 --- a/Sources/TelemetryDeck/Presets/TelemetryDeck+Purchases.swift +++ b/Sources/TelemetryDeck/Presets/TelemetryDeck+Purchases.swift @@ -19,10 +19,6 @@ extension TelemetryDeck { parameters: [String: String] = [:], customUserID: String? = nil ) { - // TODO: when a price of 0 and a subscription is detected, send `freeTrialStarted` signal - // TODO: persist free trial state - // TODO: add StoreKit integration to auto-detect free-trial conversions and send `convertedFromFreeTrial` - let priceValueInNativeCurrency = NSDecimalNumber(decimal: transaction.price ?? Decimal()).doubleValue let priceValueInUSD: Double From 3114c2939903a6b92064238ce9cbe4dc68404faf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Wed, 5 Feb 2025 22:39:19 +0100 Subject: [PATCH 10/11] Fix linter issues by splitting up Signal helpers & more --- .swiftlint.yml | 1 + .../Helpers/SessionManager.swift | 5 +- .../Signals/Signal+Helpers.swift | 427 +++++++++++++++++ Sources/TelemetryDeck/Signals/Signal.swift | 441 +----------------- Sources/TelemetryDeck/TelemetryClient.swift | 4 +- 5 files changed, 449 insertions(+), 429 deletions(-) create mode 100644 Sources/TelemetryDeck/Signals/Signal+Helpers.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 41adafd..828eb0f 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -5,6 +5,7 @@ disabled_rules: # rule identifiers turned on by default to exclude from running - trailing_comma - function_parameter_count - closure_parameter_position + - nesting opt_in_rules: # some rules are turned off by default, so you need to opt-in - empty_count # Find all the available rules by running: `swiftlint rules` diff --git a/Sources/TelemetryDeck/Helpers/SessionManager.swift b/Sources/TelemetryDeck/Helpers/SessionManager.swift index 95e8d83..799667e 100644 --- a/Sources/TelemetryDeck/Helpers/SessionManager.swift +++ b/Sources/TelemetryDeck/Helpers/SessionManager.swift @@ -6,6 +6,7 @@ import UIKit import AppKit #endif +@available(watchOS 7, *) final class SessionManager: @unchecked Sendable { private struct StoredSession: Codable { let startedAt: Date @@ -19,7 +20,7 @@ final class SessionManager: @unchecked Sendable { } static let shared = SessionManager() - + private static let recentSessionsKey = "recentSessions" private static let deletedSessionsCountKey = "deletedSessionsCount" @@ -60,7 +61,7 @@ final class SessionManager: @unchecked Sendable { var averageSessionSeconds: Int { let completedSessions = self.recentSessions.dropLast() - let totalCompletedSessionSeconds = completedSessions.map(\.durationInSeconds).reduce(into: 0) { $0 + $1 } + let totalCompletedSessionSeconds = completedSessions.map(\.durationInSeconds).reduce(into: 0) { $0 += $1 } return totalCompletedSessionSeconds / completedSessions.count } diff --git a/Sources/TelemetryDeck/Signals/Signal+Helpers.swift b/Sources/TelemetryDeck/Signals/Signal+Helpers.swift new file mode 100644 index 0000000..c0d6ce7 --- /dev/null +++ b/Sources/TelemetryDeck/Signals/Signal+Helpers.swift @@ -0,0 +1,427 @@ +import Foundation + +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +import IOKit +#elseif os(watchOS) +import WatchKit +#elseif os(tvOS) +import TVUIKit +#endif + +extension DefaultSignalPayload { + static var calendarParameters: [String: String] { + let calendar = Calendar(identifier: .gregorian) + let nowDate = Date() + + // Get components for all the metrics we need + let components = calendar.dateComponents( + [.day, .weekday, .weekOfYear, .month, .hour, .quarter, .yearForWeekOfYear], + from: nowDate + ) + + // Calculate day of year + let dayOfYear = calendar.ordinality(of: .day, in: .year, for: nowDate) ?? -1 + + // Convert Sunday=1..Saturday=7 to Monday=1..Sunday=7 + let dayOfWeek = components.weekday.map { $0 == 1 ? 7 : $0 - 1 } ?? -1 + + // Weekend is now days 6 (Saturday) and 7 (Sunday) + let isWeekend = dayOfWeek >= 6 + + return [ + // Day-based metrics + "TelemetryDeck.Calendar.dayOfMonth": "\(components.day ?? -1)", + "TelemetryDeck.Calendar.dayOfWeek": "\(dayOfWeek)", // 1 = Monday, 7 = Sunday + "TelemetryDeck.Calendar.dayOfYear": "\(dayOfYear)", + + // Week-based metrics + "TelemetryDeck.Calendar.weekOfYear": "\(components.weekOfYear ?? -1)", + "TelemetryDeck.Calendar.isWeekend": "\(isWeekend)", + + // Month and quarter + "TelemetryDeck.Calendar.monthOfYear": "\(components.month ?? -1)", + "TelemetryDeck.Calendar.quarterOfYear": "\(components.quarter ?? -1)", + + // Hours in 1-24 format + "TelemetryDeck.Calendar.hourOfDay": "\((components.hour ?? -1) + 1)" + ] + } + + @MainActor + static var accessibilityParameters: [String: String] { + var a11yParams: [String: String] = [:] + + #if os(iOS) || os(tvOS) + a11yParams["TelemetryDeck.Accessibility.isReduceMotionEnabled"] = "\(UIAccessibility.isReduceMotionEnabled)" + a11yParams["TelemetryDeck.Accessibility.isBoldTextEnabled"] = "\(UIAccessibility.isBoldTextEnabled)" + a11yParams["TelemetryDeck.Accessibility.isInvertColorsEnabled"] = "\(UIAccessibility.isInvertColorsEnabled)" + a11yParams["TelemetryDeck.Accessibility.isDarkerSystemColorsEnabled"] = "\(UIAccessibility.isDarkerSystemColorsEnabled)" + a11yParams["TelemetryDeck.Accessibility.isReduceTransparencyEnabled"] = "\(UIAccessibility.isReduceTransparencyEnabled)" + if #available(iOS 13.0, *) { + a11yParams["TelemetryDeck.Accessibility.shouldDifferentiateWithoutColor"] = "\(UIAccessibility.shouldDifferentiateWithoutColor)" + } + + // in app extensions `UIApplication.shared` is not available + if !Bundle.main.bundlePath.hasSuffix(".appex") { + a11yParams["TelemetryDeck.Accessibility.preferredContentSizeCategory"] = UIApplication.shared.preferredContentSizeCategory.rawValue + .replacingOccurrences(of: "UICTContentSizeCategory", with: "") // replaces output "UICTContentSizeCategoryL" with "L" + } + #elseif os(macOS) + if let systemPrefs = UserDefaults.standard.persistentDomain(forName: "com.apple.universalaccess") { + a11yParams["TelemetryDeck.Accessibility.isReduceMotionEnabled"] = "\(systemPrefs["reduceMotion"] as? Bool ?? false)" + a11yParams["TelemetryDeck.Accessibility.isInvertColorsEnabled"] = "\(systemPrefs["InvertColors"] as? Bool ?? false)" + } + #endif + + return a11yParams + } + + static var isSimulatorOrTestFlight: Bool { + isSimulator || isTestFlight + } + + static var isSimulator: Bool { + #if targetEnvironment(simulator) + return true + #else + return false + #endif + } + + static var isDebug: Bool { + #if DEBUG + return true + #else + return false + #endif + } + + static var isTestFlight: Bool { + guard !isDebug, let path = Bundle.main.appStoreReceiptURL?.path else { + return false + } + return path.contains("sandboxReceipt") + } + + static var isAppStore: Bool { + #if DEBUG + return false + #elseif TARGET_OS_OSX || TARGET_OS_MACCATALYST + return false + #elseif targetEnvironment(simulator) + return false + #else + return !isSimulatorOrTestFlight + #endif + } + + /// The operating system and its version + static var systemVersion: String { + let majorVersion = ProcessInfo.processInfo.operatingSystemVersion.majorVersion + let minorVersion = ProcessInfo.processInfo.operatingSystemVersion.minorVersion + let patchVersion = ProcessInfo.processInfo.operatingSystemVersion.patchVersion + return "\(platform) \(majorVersion).\(minorVersion).\(patchVersion)" + } + + /// The major system version, i.e. iOS 15 + static var majorSystemVersion: String { + return "\(platform) \(ProcessInfo.processInfo.operatingSystemVersion.majorVersion)" + } + + /// The major system version, i.e. iOS 15 + static var majorMinorSystemVersion: String { + let majorVersion = ProcessInfo.processInfo.operatingSystemVersion.majorVersion + let minorVersion = ProcessInfo.processInfo.operatingSystemVersion.minorVersion + return "\(platform) \(majorVersion).\(minorVersion)" + } + + /// The Bundle Short Version String, as described in Info.plist + static var appVersion: String { + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + return appVersion ?? "0" + } + + /// The Bundle Version String, as described in Info.plist + static var buildNumber: String { + let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String + return buildNumber ?? "0" + } + + /// The extension identifer for the active resource, if available. + /// + /// This provides a value such as `com.apple.widgetkit-extension` when TelemetryDeck is run from a widget. + static var extensionIdentifier: String? { + let container = Bundle.main.infoDictionary?["NSExtension"] as? [String: Any] + return container?["NSExtensionPointIdentifier"] as? String + } + + /// The modelname as reported by systemInfo.machine + static var modelName: String { + #if os(iOS) + if #available(iOS 14.0, *) { + if ProcessInfo.processInfo.isiOSAppOnMac { + var size = 0 + sysctlbyname("hw.model", nil, &size, nil, 0) + var machine = [CChar](repeating: 0, count: size) + sysctlbyname("hw.model", &machine, &size, nil, 0) + return String(cString: machine) + } + } + #endif + + #if os(macOS) + if #available(macOS 11, *) { + let service = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("IOPlatformExpertDevice")) + 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) { + modelIdentifier = String(cString: modelIdentifierCString) + } + } + + IOObjectRelease(service) + if let modelIdentifier = modelIdentifier { + return modelIdentifier + } + } + #endif + + var systemInfo = utsname() + uname(&systemInfo) + let machineMirror = Mirror(reflecting: systemInfo.machine) + let identifier = machineMirror.children.reduce("") { identifier, element in + guard let value = element.value as? Int8, value != 0 else { return identifier } + return identifier + String(UnicodeScalar(UInt8(value))) + } + return identifier + } + + /// The build architecture + static var architecture: String { + #if arch(x86_64) + return "x86_64" + #elseif arch(arm) + return "arm" + #elseif arch(arm64) + return "arm64" + #elseif arch(i386) + return "i386" + #elseif arch(powerpc64) + return "powerpc64" + #elseif arch(powerpc64le) + return "powerpc64le" + #elseif arch(s390x) + return "s390x" + #else + return "unknown" + #endif + } + + /// The operating system as reported by Swift. Note that this will report catalyst apps and iOS apps running on + /// macOS as "iOS". See `platform` for an alternative. + static var operatingSystem: String { + #if os(macOS) + return "macOS" + #elseif os(visionOS) + return "visionOS" + #elseif os(iOS) + return "iOS" + #elseif os(watchOS) + return "watchOS" + #elseif os(tvOS) + return "tvOS" + #else + return "Unknown Operating System" + #endif + } + + /// Based on the operating version reported by swift, but adding some smartness to better detect the actual + /// platform. Should correctly identify catalyst apps and iOS apps on macOS. + static var platform: String { + #if os(macOS) + return "macOS" + #elseif os(visionOS) + return "visionOS" + #elseif os(iOS) + #if targetEnvironment(macCatalyst) + return "macCatalyst" + #else + if #available(iOS 14.0, *), ProcessInfo.processInfo.isiOSAppOnMac { + return "isiOSAppOnMac" + } + return "iOS" + #endif + #elseif os(watchOS) + return "watchOS" + #elseif os(tvOS) + return "tvOS" + #else + return "Unknown Platform" + #endif + } + + /// The target environment as reported by swift. Either "simulator", "macCatalyst" or "native" + static var targetEnvironment: String { + #if targetEnvironment(simulator) + return "simulator" + #elseif targetEnvironment(macCatalyst) + return "macCatalyst" + #else + return "native" + #endif + } + + /// 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 color scheme set by the user. Returns `N/A` on unsupported platforms + @MainActor + static var colorScheme: String { + #if os(iOS) || os(tvOS) + switch UIScreen.main.traitCollection.userInterfaceStyle { + case .dark: return "Dark" + case .light: return "Light" + default: return "N/A" + } + #elseif os(macOS) + if #available(macOS 10.14, *) { + switch NSAppearance.current.name { + case .aqua: return "Light" + case .darkAqua: return "Dark" + default: return "N/A" + } + } else { + return "Light" + } + #else + return "N/A" + #endif + } + + /// The user-preferred layout direction (left-to-right or right-to-left) based on the current language/region settings. + @MainActor + static var layoutDirection: String { + #if os(iOS) || os(tvOS) + if Bundle.main.bundlePath.hasSuffix(".appex") { + // we're in an app extension, where `UIApplication.shared` is not available + return "N/A" + } else { + return UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? "leftToRight" : "rightToLeft" + } + #elseif os(macOS) + if let nsApp = NSApp { + return nsApp.userInterfaceLayoutDirection == .leftToRight ? "leftToRight" : "rightToLeft" + } else { + return "N/A" + } + #else + return "N/A" + #endif + } + + /// The current devices screen resolution width in points. + @MainActor + 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 "N/A" + #else + return "N/A" + #endif + } + + /// The current devices screen resolution height in points. + @MainActor + 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 "N/A" + #else + return "N/A" + #endif + } + + @MainActor + static var screenScaleFactor: String { + #if os(iOS) || os(tvOS) + return "\(UIScreen.main.scale)" + #elseif os(macOS) + if let screen = NSScreen.main { + return "\(screen.backingScaleFactor)" + } + return "N/A" + #else + return "N/A" + #endif + } + + /// The current devices screen orientation. Returns `Fixed` for devices that don't support an orientation change. + @MainActor + 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/TelemetryDeck/Signals/Signal.swift b/Sources/TelemetryDeck/Signals/Signal.swift index c4f7d90..b5499b9 100644 --- a/Sources/TelemetryDeck/Signals/Signal.swift +++ b/Sources/TelemetryDeck/Signals/Signal.swift @@ -99,12 +99,6 @@ public struct DefaultSignalPayload: Encodable { "TelemetryDeck.UserPreference.language": Self.preferredLanguage, "TelemetryDeck.UserPreference.layoutDirection": Self.layoutDirection, "TelemetryDeck.UserPreference.region": Self.region, - - // Pirate Metrics - "TelemetryDeck.Acquisition.firstSessionDate": SessionManager.shared.firstSessionDate, - "TelemetryDeck.Retention.averageSessionSeconds": "\(SessionManager.shared.averageSessionSeconds)", - "TelemetryDeck.Retention.distinctDaysUsed": "\(SessionManager.shared.distinctDaysUsed.count)", - "TelemetryDeck.Retention.totalSessionsCount": "\(SessionManager.shared.totalSessionsCount)", ] parameters.merge(self.accessibilityParameters, uniquingKeysWith: { $1 }) @@ -118,428 +112,23 @@ public struct DefaultSignalPayload: Encodable { parameters["TelemetryDeck.RunContext.extensionIdentifier"] = extensionIdentifier } - if let previousSessionSeconds = SessionManager.shared.previousSessionSeconds { - parameters["TelemetryDeck.Retention.previousSessionSeconds"] = "\(previousSessionSeconds)" - } - - return parameters - } -} - -// MARK: - Helpers - -extension DefaultSignalPayload { - static var calendarParameters: [String: String] { - let calendar = Calendar(identifier: .gregorian) - let now = Date() - - // Get components for all the metrics we need - let components = calendar.dateComponents( - [.day, .weekday, .weekOfYear, .month, .hour, .quarter, .yearForWeekOfYear], - from: now - ) - - // Calculate day of year - let dayOfYear = calendar.ordinality(of: .day, in: .year, for: now) ?? -1 - - // Convert Sunday=1..Saturday=7 to Monday=1..Sunday=7 - let dayOfWeek = components.weekday.map { $0 == 1 ? 7 : $0 - 1 } ?? -1 - - // Weekend is now days 6 (Saturday) and 7 (Sunday) - let isWeekend = dayOfWeek >= 6 - - return [ - // Day-based metrics - "TelemetryDeck.Calendar.dayOfMonth": "\(components.day ?? -1)", - "TelemetryDeck.Calendar.dayOfWeek": "\(dayOfWeek)", // 1 = Monday, 7 = Sunday - "TelemetryDeck.Calendar.dayOfYear": "\(dayOfYear)", - - // Week-based metrics - "TelemetryDeck.Calendar.weekOfYear": "\(components.weekOfYear ?? -1)", - "TelemetryDeck.Calendar.isWeekend": "\(isWeekend)", - - // Month and quarter - "TelemetryDeck.Calendar.monthOfYear": "\(components.month ?? -1)", - "TelemetryDeck.Calendar.quarterOfYear": "\(components.quarter ?? -1)", - - // Hours in 1-24 format - "TelemetryDeck.Calendar.hourOfDay": "\((components.hour ?? -1) + 1)" - ] - } - - @MainActor - static var accessibilityParameters: [String: String] { - var a11yParams: [String: String] = [:] - - #if os(iOS) || os(tvOS) - a11yParams["TelemetryDeck.Accessibility.isReduceMotionEnabled"] = "\(UIAccessibility.isReduceMotionEnabled)" - a11yParams["TelemetryDeck.Accessibility.isBoldTextEnabled"] = "\(UIAccessibility.isBoldTextEnabled)" - a11yParams["TelemetryDeck.Accessibility.isInvertColorsEnabled"] = "\(UIAccessibility.isInvertColorsEnabled)" - a11yParams["TelemetryDeck.Accessibility.isDarkerSystemColorsEnabled"] = "\(UIAccessibility.isDarkerSystemColorsEnabled)" - a11yParams["TelemetryDeck.Accessibility.isReduceTransparencyEnabled"] = "\(UIAccessibility.isReduceTransparencyEnabled)" - if #available(iOS 13.0, *) { - a11yParams["TelemetryDeck.Accessibility.shouldDifferentiateWithoutColor"] = "\(UIAccessibility.shouldDifferentiateWithoutColor)" - } - - // in app extensions `UIApplication.shared` is not available - if !Bundle.main.bundlePath.hasSuffix(".appex") { - a11yParams["TelemetryDeck.Accessibility.preferredContentSizeCategory"] = UIApplication.shared.preferredContentSizeCategory.rawValue - .replacingOccurrences(of: "UICTContentSizeCategory", with: "") // replaces output "UICTContentSizeCategoryL" with "L" - } - - #elseif os(macOS) - if let systemPrefs = UserDefaults.standard.persistentDomain(forName: "com.apple.universalaccess") { - a11yParams["TelemetryDeck.Accessibility.isReduceMotionEnabled"] = "\(systemPrefs["reduceMotion"] as? Bool ?? false)" - a11yParams["TelemetryDeck.Accessibility.isInvertColorsEnabled"] = "\(systemPrefs["InvertColors"] as? Bool ?? false)" - } - #endif - - return a11yParams - } - - static var isSimulatorOrTestFlight: Bool { - isSimulator || isTestFlight - } - - static var isSimulator: Bool { - #if targetEnvironment(simulator) - return true - #else - return false - #endif - } - - static var isDebug: Bool { - #if DEBUG - return true - #else - return false - #endif - } - - static var isTestFlight: Bool { - guard !isDebug, let path = Bundle.main.appStoreReceiptURL?.path else { - return false - } - return path.contains("sandboxReceipt") - } - - static var isAppStore: Bool { - #if DEBUG - return false - #elseif TARGET_OS_OSX || TARGET_OS_MACCATALYST - return false - #elseif targetEnvironment(simulator) - return false - #else - return !isSimulatorOrTestFlight - #endif - } - - /// The operating system and its version - static var systemVersion: String { - let majorVersion = ProcessInfo.processInfo.operatingSystemVersion.majorVersion - let minorVersion = ProcessInfo.processInfo.operatingSystemVersion.minorVersion - let patchVersion = ProcessInfo.processInfo.operatingSystemVersion.patchVersion - return "\(platform) \(majorVersion).\(minorVersion).\(patchVersion)" - } - - /// The major system version, i.e. iOS 15 - static var majorSystemVersion: String { - return "\(platform) \(ProcessInfo.processInfo.operatingSystemVersion.majorVersion)" - } - - /// The major system version, i.e. iOS 15 - static var majorMinorSystemVersion: String { - let majorVersion = ProcessInfo.processInfo.operatingSystemVersion.majorVersion - let minorVersion = ProcessInfo.processInfo.operatingSystemVersion.minorVersion - return "\(platform) \(majorVersion).\(minorVersion)" - } - - /// The Bundle Short Version String, as described in Info.plist - static var appVersion: String { - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String - return appVersion ?? "0" - } - - /// The Bundle Version String, as described in Info.plist - static var buildNumber: String { - let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String - return buildNumber ?? "0" - } - - /// The extension identifer for the active resource, if available. - /// - /// This provides a value such as `com.apple.widgetkit-extension` when TelemetryDeck is run from a widget. - static var extensionIdentifier: String? { - let container = Bundle.main.infoDictionary?["NSExtension"] as? [String: Any] - return container?["NSExtensionPointIdentifier"] as? String - } - - /// The modelname as reported by systemInfo.machine - static var modelName: String { - #if os(iOS) - if #available(iOS 14.0, *) { - if ProcessInfo.processInfo.isiOSAppOnMac { - var size = 0 - sysctlbyname("hw.model", nil, &size, nil, 0) - var machine = [CChar](repeating: 0, count: size) - sysctlbyname("hw.model", &machine, &size, nil, 0) - return String(cString: machine) - } - } - #endif - - #if os(macOS) - if #available(macOS 11, *) { - let service = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("IOPlatformExpertDevice")) - 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) { - modelIdentifier = String(cString: modelIdentifierCString) - } - } - - IOObjectRelease(service) - if let modelIdentifier = modelIdentifier { - return modelIdentifier - } - } - #endif - - var systemInfo = utsname() - uname(&systemInfo) - let machineMirror = Mirror(reflecting: systemInfo.machine) - let identifier = machineMirror.children.reduce("") { identifier, element in - guard let value = element.value as? Int8, value != 0 else { return identifier } - return identifier + String(UnicodeScalar(UInt8(value))) - } - return identifier - } - - /// The build architecture - static var architecture: String { - #if arch(x86_64) - return "x86_64" - #elseif arch(arm) - return "arm" - #elseif arch(arm64) - return "arm64" - #elseif arch(i386) - return "i386" - #elseif arch(powerpc64) - return "powerpc64" - #elseif arch(powerpc64le) - return "powerpc64le" - #elseif arch(s390x) - return "s390x" - #else - return "unknown" - #endif - } - - /// The operating system as reported by Swift. Note that this will report catalyst apps and iOS apps running on - /// macOS as "iOS". See `platform` for an alternative. - static var operatingSystem: String { - #if os(macOS) - return "macOS" - #elseif os(visionOS) - return "visionOS" - #elseif os(iOS) - return "iOS" - #elseif os(watchOS) - return "watchOS" - #elseif os(tvOS) - return "tvOS" - #else - return "Unknown Operating System" - #endif - } - - /// Based on the operating version reported by swift, but adding some smartness to better detect the actual - /// platform. Should correctly identify catalyst apps and iOS apps on macOS. - static var platform: String { - #if os(macOS) - return "macOS" - #elseif os(visionOS) - return "visionOS" - #elseif os(iOS) - #if targetEnvironment(macCatalyst) - return "macCatalyst" - #else - if #available(iOS 14.0, *), ProcessInfo.processInfo.isiOSAppOnMac { - return "isiOSAppOnMac" - } - return "iOS" - #endif - #elseif os(watchOS) - return "watchOS" - #elseif os(tvOS) - return "tvOS" - #else - return "Unknown Platform" - #endif - } - - /// The target environment as reported by swift. Either "simulator", "macCatalyst" or "native" - static var targetEnvironment: String { - #if targetEnvironment(simulator) - return "simulator" - #elseif targetEnvironment(macCatalyst) - return "macCatalyst" - #else - return "native" - #endif - } - - /// 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 color scheme set by the user. Returns `N/A` on unsupported platforms - @MainActor - static var colorScheme: String { - #if os(iOS) || os(tvOS) - switch UIScreen.main.traitCollection.userInterfaceStyle { - case .dark: return "Dark" - case .light: return "Light" - default: return "N/A" - } - #elseif os(macOS) - if #available(macOS 10.14, *) { - switch NSAppearance.current.name { - case .aqua: return "Light" - case .darkAqua: return "Dark" - default: return "N/A" - } - } else { - return "Light" - } - #else - return "N/A" - #endif - } - - /// The user-preferred layout direction (left-to-right or right-to-left) based on the current language/region settings. - @MainActor - static var layoutDirection: String { - #if os(iOS) || os(tvOS) - if Bundle.main.bundlePath.hasSuffix(".appex") { - // we're in an app extension, where `UIApplication.shared` is not available - return "N/A" - } else { - return UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? "leftToRight" : "rightToLeft" - } - #elseif os(macOS) - if let nsApp = NSApp { - return nsApp.userInterfaceLayoutDirection == .leftToRight ? "leftToRight" : "rightToLeft" - } else { - return "N/A" - } - #else - return "N/A" - #endif - } - - /// The current devices screen resolution width in points. - @MainActor - 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 "N/A" - #else - return "N/A" - #endif - } - - /// The current devices screen resolution height in points. - @MainActor - 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)" + // Pirate Metrics + if #available(watchOS 7, *) { + parameters.merge( + [ + "TelemetryDeck.Acquisition.firstSessionDate": SessionManager.shared.firstSessionDate, + "TelemetryDeck.Retention.averageSessionSeconds": "\(SessionManager.shared.averageSessionSeconds)", + "TelemetryDeck.Retention.distinctDaysUsed": "\(SessionManager.shared.distinctDaysUsed.count)", + "TelemetryDeck.Retention.totalSessionsCount": "\(SessionManager.shared.totalSessionsCount)", + ], + uniquingKeysWith: { $1 } + ) + + if let previousSessionSeconds = SessionManager.shared.previousSessionSeconds { + parameters["TelemetryDeck.Retention.previousSessionSeconds"] = "\(previousSessionSeconds)" } - return "N/A" - #else - return "N/A" - #endif - } - - @MainActor - static var screenScaleFactor: String { - #if os(iOS) || os(tvOS) - return "\(UIScreen.main.scale)" - #elseif os(macOS) - if let screen = NSScreen.main { - return "\(screen.backingScaleFactor)" } - return "N/A" - #else - return "N/A" - #endif - } - /// The current devices screen orientation. Returns `Fixed` for devices that don't support an orientation change. - @MainActor - 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)" - } + return parameters } } diff --git a/Sources/TelemetryDeck/TelemetryClient.swift b/Sources/TelemetryDeck/TelemetryClient.swift index f1b8f44..d08429f 100644 --- a/Sources/TelemetryDeck/TelemetryClient.swift +++ b/Sources/TelemetryDeck/TelemetryClient.swift @@ -69,7 +69,9 @@ public final class TelemetryManagerConfiguration: @unchecked Sendable { /// Beginning a new session automatically sends a "TelemetryDeck.Session.started" Signal if `sendNewSessionBeganSignal` is `true` public var sessionID = UUID() { didSet { - SessionManager.shared.startNewSession() + if #available(watchOS 7, *) { + SessionManager.shared.startNewSession() + } if sendNewSessionBeganSignal { TelemetryDeck.internalSignal("TelemetryDeck.Session.started") From 6ca451a71fef982675964390c6b2d8f19e4e1914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Wed, 5 Feb 2025 23:04:51 +0100 Subject: [PATCH 11/11] Fix watchOS tests not passing --- Sources/TelemetryDeck/Helpers/SessionManager.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/TelemetryDeck/Helpers/SessionManager.swift b/Sources/TelemetryDeck/Helpers/SessionManager.swift index 799667e..11e3924 100644 --- a/Sources/TelemetryDeck/Helpers/SessionManager.swift +++ b/Sources/TelemetryDeck/Helpers/SessionManager.swift @@ -60,6 +60,10 @@ final class SessionManager: @unchecked Sendable { } var averageSessionSeconds: Int { + guard self.recentSessions.count > 1 else { + return self.recentSessions.first?.durationInSeconds ?? -1 + } + let completedSessions = self.recentSessions.dropLast() let totalCompletedSessionSeconds = completedSessions.map(\.durationInSeconds).reduce(into: 0) { $0 += $1 } return totalCompletedSessionSeconds / completedSessions.count