From dc8acc23e0157e2344f4481393b4a4818143449f 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] 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 d327e03..ea3ebca 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] = [:]