-
Notifications
You must be signed in to change notification settings - Fork 32
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #230 from TelemetryDeck/feature/new-user
Introduce automatic session tracking & enhance default parameters (targeting `main`)
- Loading branch information
Showing
11 changed files
with
869 additions
and
391 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,265 @@ | ||
#if canImport(WatchKit) | ||
import WatchKit | ||
#elseif canImport(UIKit) | ||
import UIKit | ||
#elseif canImport(AppKit) | ||
import AppKit | ||
#endif | ||
|
||
@available(watchOS 7, *) | ||
final class SessionManager: @unchecked Sendable { | ||
private struct StoredSession: Codable { | ||
let startedAt: Date | ||
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 recentSessionsKey = "recentSessions" | ||
private static let deletedSessionsCountKey = "deletedSessionsCount" | ||
|
||
private static let firstSessionDateKey = "firstSessionDate" | ||
private static let distinctDaysUsedKey = "distinctDaysUsed" | ||
|
||
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 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 { | ||
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 | ||
} | ||
|
||
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 | ||
|
||
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.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.recentSessions = existingSessions.filter { $0.startedAt > cutoffDate } | ||
|
||
// Update deleted sessions count | ||
self.deletedSessionsCount += existingSessions.count - self.recentSessions.count | ||
} else { | ||
self.recentSessions = [] | ||
} | ||
|
||
self.updateDistinctDaysUsed() | ||
self.setupAppLifecycleObservers() | ||
} | ||
|
||
func startNewSession() { | ||
// stop automatic duration counting of previous session | ||
self.stopSessionTimer() | ||
|
||
// 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] | ||
) | ||
} | ||
|
||
// start a new session | ||
self.currentSessionStartedAt = 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 | ||
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.recentSessions.append(newSession) | ||
} | ||
|
||
// Save changes to UserDefaults without blocking Main thread | ||
self.persistenceQueue.async { | ||
if let updatedSessionData = try? Self.encoder.encode(self.recentSessions) { | ||
TelemetryDeck.customDefaults?.set(updatedSessionData, forKey: Self.recentSessionsKey) | ||
} | ||
} | ||
} | ||
|
||
@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 updateDistinctDaysUsed() { | ||
let todayFormatted = ISO8601DateFormatter.string(from: Date(), timeZone: .current, formatOptions: [.withFullDate]) | ||
|
||
var distinctDays = self.distinctDaysUsed | ||
if distinctDays.last != todayFormatted { | ||
distinctDays.append(todayFormatted) | ||
self.distinctDaysUsed = distinctDays | ||
} | ||
} | ||
|
||
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 | ||
) | ||
|
||
NotificationCenter.default.addObserver( | ||
self, | ||
selector: #selector(handleWillEnterForegroundNotification), | ||
name: NSApplication.willBecomeActiveNotification, | ||
object: nil | ||
) | ||
#endif | ||
} | ||
} |
45 changes: 45 additions & 0 deletions
45
Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
import Foundation | ||
|
||
extension TelemetryDeck { | ||
private static func acquiredUser( | ||
channel: String, | ||
parameters: [String: String] = [:], | ||
customUserID: String? = nil | ||
) { | ||
let acquisitionParameters = ["TelemetryDeck.Acquisition.channel": channel] | ||
|
||
self.internalSignal( | ||
"TelemetryDeck.Acquisition.userAcquired", | ||
parameters: acquisitionParameters.merging(parameters) { $1 }, | ||
customUserID: customUserID | ||
) | ||
} | ||
|
||
private 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 | ||
) | ||
} | ||
|
||
private 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 | ||
) | ||
} | ||
} |
32 changes: 32 additions & 0 deletions
32
Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import Foundation | ||
|
||
extension TelemetryDeck { | ||
private 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 | ||
) | ||
} | ||
|
||
private 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 | ||
) | ||
} | ||
} |
Oops, something went wrong.