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 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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] = [:]