Skip to content

Commit

Permalink
Merge pull request #225 from TelemetryDeck/feature/pm-persist-sessions
Browse files Browse the repository at this point in the history
Introduce automatic session tracking & enhance default parameters
  • Loading branch information
Jeehut authored Jan 14, 2025
2 parents c98fd6e + dc8acc2 commit 2703fc6
Show file tree
Hide file tree
Showing 6 changed files with 326 additions and 27 deletions.
8 changes: 4 additions & 4 deletions Sources/TelemetryDeck/Helpers/DurationSignalTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ final class DurationSignalTracker {
}

private func setupAppLifecycleObservers() {
#if canImport(WatchKit)
#if canImport(WatchKit)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleDidEnterBackgroundNotification),
Expand All @@ -50,7 +50,7 @@ final class DurationSignalTracker {
name: WKApplication.willEnterForegroundNotification,
object: nil
)
#elseif canImport(UIKit)
#elseif canImport(UIKit)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleDidEnterBackgroundNotification),
Expand All @@ -64,7 +64,7 @@ final class DurationSignalTracker {
name: UIApplication.willEnterForegroundNotification,
object: nil
)
#elseif canImport(AppKit)
#elseif canImport(AppKit)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleDidEnterBackgroundNotification),
Expand All @@ -78,7 +78,7 @@ final class DurationSignalTracker {
name: NSApplication.willBecomeActiveNotification,
object: nil
)
#endif
#endif
}

@objc
Expand Down
262 changes: 256 additions & 6 deletions Sources/TelemetryDeck/Helpers/SessionManager.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,260 @@
import Foundation
#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"
}
}

@MainActor
final class SessionManager {
static let shared = SessionManager()
private init() {}

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
)

// 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
}
}
49 changes: 49 additions & 0 deletions Sources/TelemetryDeck/Signals/Signal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -111,13 +118,55 @@ 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] = [:]
Expand Down
Loading

0 comments on commit 2703fc6

Please sign in to comment.