Skip to content
260 changes: 260 additions & 0 deletions Sources/TelemetryDeck/Helpers/SessionManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
#if canImport(WatchKit)
import WatchKit
#elseif canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif

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 {
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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Foundation

extension TelemetryDeck {
// TODO: add documentation comment with common/recommended usage examples

Check failure on line 4 in Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift

View workflow job for this annotation

GitHub Actions / Lint Code

TODOs should be resolved (add documentation comment with...) (todo)
public 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

Check failure on line 12 in Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift

View workflow job for this annotation

GitHub Actions / Lint Code

TODOs should be resolved (persist channel and send with ...) (todo)

self.internalSignal(
"TelemetryDeck.Acquisition.userAcquired",
parameters: acquisitionParameters.merging(parameters) { $1 },
customUserID: customUserID
)
}

// TODO: add documentation comment with common/recommended usage examples

Check failure on line 21 in Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift

View workflow job for this annotation

GitHub Actions / Lint Code

TODOs should be resolved (add documentation comment with...) (todo)
public 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
)
}

// TODO: add documentation comment with common/recommended usage examples

Check failure on line 36 in Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Acquisition.swift

View workflow job for this annotation

GitHub Actions / Lint Code

TODOs should be resolved (add documentation comment with...) (todo)
public 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
)
}
}
34 changes: 34 additions & 0 deletions Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Foundation

extension TelemetryDeck {
// TODO: add documentation comment with common/recommended usage examples

Check failure on line 4 in Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift

View workflow job for this annotation

GitHub Actions / Lint Code

TODOs should be resolved (add documentation comment with...) (todo)
public 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
)
}

// TODO: add documentation comment with common/recommended usage examples

Check failure on line 18 in Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Activation.swift

View workflow job for this annotation

GitHub Actions / Lint Code

TODOs should be resolved (add documentation comment with...) (todo)
public 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
)
}
}
52 changes: 52 additions & 0 deletions Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import Foundation

extension TelemetryDeck {
// TODO: add documentation comment with common/recommended usage examples

Check failure on line 4 in Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift

View workflow job for this annotation

GitHub Actions / Lint Code

TODOs should be resolved (add documentation comment with...) (todo)
public 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

Check failure on line 11 in Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift

View workflow job for this annotation

GitHub Actions / Lint Code

TODOs should be resolved (document all new parameters an...) (todo)
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: add documentation comment with common/recommended usage examples

Check failure on line 25 in Sources/TelemetryDeck/PirateMetrics/TelemetryDeck+Referral.swift

View workflow job for this annotation

GitHub Actions / Lint Code

TODOs should be resolved (add documentation comment with...) (todo)
// TODO: explicitly mention how this can be used for NPS Score or for App Store like ratings
public 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
)
}
}
Loading
Loading