Repository navigation
feat: [SDK-5397] send a heartbeat every 30 minutes of foreground time #1773
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d81f5ae
63f846c
22ca84a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -70,12 +70,24 @@ public struct OSSessionRecord: Codable, Equatable { | |
| public internal(set) var subscriptionId: String? | ||
| /// Nil until the sessions API creates this session. | ||
| public internal(set) var serverSessionId: String? | ||
| /// Seconds since 1970 when the app last left the foreground, which becomes the session's end | ||
| /// time. Nil until then. | ||
| public internal(set) var lastUnfocusTime: TimeInterval? | ||
| /// The duration the last heartbeat reported. An app killed in the foreground never adds its | ||
| /// last visit to `activeDuration`, so the end must not report less than this. | ||
| public internal(set) var lastHeartbeatDuration: TimeInterval? | ||
| /// Seconds since 1970 when the last heartbeat was enqueued. | ||
| public internal(set) var lastHeartbeatTime: TimeInterval? | ||
| } | ||
|
|
||
| /// Owns the persisted record of the current session. Nothing reads the record while | ||
| /// `usesSessionsApi` is false, so with the flag off session behavior is unchanged. | ||
| /// While in the foreground, a sessions API session reports its duration every `heartbeatInterval` | ||
| /// of foreground time, so the backend keeps it open and a crash loses little of it. | ||
| @objc(OSSessionService) | ||
| public final class OSSessionService: NSObject { | ||
| static let heartbeatInterval: TimeInterval = 30 * 60 | ||
|
|
||
| private static let lock = NSLock() | ||
| private static var _shared: OSSessionService? | ||
|
|
||
|
|
@@ -94,26 +106,36 @@ public final class OSSessionService: NSObject { | |
| private let featureManager: () -> OSFeatureManager | ||
| private let monotonicNow: () -> TimeInterval | ||
| private let wallNow: () -> TimeInterval | ||
| private let requestQueue: () -> OSSessionRequestQueue? | ||
| private let schedule: (TimeInterval, @escaping () -> Void) -> Void | ||
|
|
||
| private let stateLock = NSLock() | ||
| private weak var userProvider: OSSessionUserProvider? | ||
| private var loaded = false | ||
| private var record: OSSessionRecord? | ||
| /// In memory only: a stamp from another process may predate a reboot. | ||
| private var focusedAt: TimeInterval? | ||
| /// Bumped whenever the heartbeat restarts or stops, so a check scheduled before then does nothing. | ||
| private var heartbeatGeneration = 0 | ||
|
|
||
| init( | ||
| storage: OneSignalUserDefaults = .initStandard(), | ||
| featureManager: @escaping () -> OSFeatureManager = { .shared }, | ||
| monotonicNow: @escaping () -> TimeInterval = { | ||
| TimeInterval(clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)) / TimeInterval(NSEC_PER_SEC) | ||
| }, | ||
| wallNow: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 } | ||
| wallNow: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }, | ||
| requestQueue: @escaping () -> OSSessionRequestQueue? = { .shared }, | ||
| schedule: @escaping (TimeInterval, @escaping () -> Void) -> Void = { delay, block in | ||
| DispatchQueue.global().asyncAfter(deadline: .now() + delay, execute: block) | ||
| } | ||
| ) { | ||
| self.storage = storage | ||
| self.featureManager = featureManager | ||
| self.monotonicNow = monotonicNow | ||
| self.wallNow = wallNow | ||
| self.requestQueue = requestQueue | ||
| self.schedule = schedule | ||
| super.init() | ||
| } | ||
|
|
||
|
|
@@ -165,7 +187,10 @@ public final class OSSessionService: NSObject { | |
| /// Leaves any open foreground interval running: the tracker reports focus before the session | ||
| /// starts asynchronously, and that visit belongs to the new session. A launch in the | ||
| /// background has no interval open, so nothing counts until the app becomes active. | ||
| /// With the sessions API, the previous session's end and the new session's create are enqueued | ||
| /// here. The queue holds them until their IDs are assigned. | ||
| public func startNewSession(userProvider: OSSessionUserProvider) { | ||
| let previousIds = pinnableIds() | ||
| let user = userProvider.sessionCurrentUser | ||
| let newRecord = OSSessionRecord( | ||
| sessionId: UUID().uuidString, | ||
|
|
@@ -177,32 +202,50 @@ public final class OSSessionService: NSObject { | |
| subscriptionId: user.pushSubscriptionId, | ||
| serverSessionId: nil | ||
| ) | ||
| stateLock.withLock { | ||
| let previous: OSSessionRecord? = stateLock.withLock { | ||
| loadIfNeeded() | ||
| fillPinnedIds(previousIds) | ||
| let previous = record | ||
| self.userProvider = userProvider | ||
| loaded = true | ||
| record = newRecord | ||
| persist() | ||
| return previous | ||
| } | ||
| if var previous, previous.usesSessionsApi { | ||
| // A session killed in the foreground never added its last visit to the duration or | ||
| // recorded an unfocus. It ends with its last heartbeat, or else at its start. | ||
| let endTime = max(previous.lastUnfocusTime ?? previous.startTime, previous.lastHeartbeatTime ?? 0) | ||
| previous.activeDuration = max(previous.activeDuration, previous.lastHeartbeatDuration ?? 0) | ||
| requestQueue()?.enqueueUpdate(record: previous, endTime: endTime) | ||
| } | ||
| if newRecord.usesSessionsApi { | ||
| requestQueue()?.enqueueCreate(record: newRecord) | ||
| } | ||
| restartHeartbeat() | ||
| } | ||
|
|
||
| func onFocus() { | ||
| let now = monotonicNow() | ||
| stateLock.withLock { focusedAt = now } | ||
| restartHeartbeat() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tracker calls |
||
| } | ||
|
|
||
| func onUnfocus() { | ||
| let now = monotonicNow() | ||
| let wallNow = wallNow() | ||
| let ids = pinnableIds() | ||
| stateLock.withLock { | ||
| guard let start = focusedAt else { | ||
| return | ||
| } | ||
| focusedAt = nil | ||
| heartbeatGeneration += 1 | ||
| loadIfNeeded() | ||
| guard record != nil else { | ||
| return | ||
| } | ||
| record?.activeDuration += max(0, now - start) | ||
| record?.lastUnfocusTime = wallNow | ||
| fillPinnedIds(ids) | ||
| persist() | ||
| } | ||
|
|
@@ -314,3 +357,60 @@ public final class OSSessionService: NSObject { | |
| storage.saveObject(forKey: OSUD_SESSION_RECORD, withValue: data) | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Heartbeat | ||
|
|
||
| extension OSSessionService { | ||
| /// Counts only foreground time: the interval resumes from the last heartbeat's duration. | ||
| private func restartHeartbeat() { | ||
| let generation: Int? = stateLock.withLock { | ||
| heartbeatGeneration += 1 | ||
| loadIfNeeded() | ||
| guard focusedAt != nil, record?.usesSessionsApi == true else { | ||
| return nil | ||
| } | ||
| return heartbeatGeneration | ||
| } | ||
| if let generation { | ||
| checkHeartbeat(generation: generation) | ||
| } | ||
| } | ||
|
|
||
| /// Rechecks the duration after every wait, since the scheduler's clock can drift from `monotonicNow`. | ||
| private func checkHeartbeat(generation: Int) { | ||
| let now = monotonicNow() | ||
| let wallNow = wallNow() | ||
| let ids = pinnableIds() | ||
| let next: (heartbeat: OSSessionRecord?, wait: TimeInterval)? = stateLock.withLock { | ||
| guard generation == heartbeatGeneration, let start = focusedAt else { | ||
| return nil | ||
| } | ||
| loadIfNeeded() | ||
| guard let current = record, current.usesSessionsApi else { | ||
| return nil | ||
| } | ||
| let duration = current.activeDuration + max(0, now - start) | ||
| let remaining = (current.lastHeartbeatDuration ?? 0) + Self.heartbeatInterval - duration | ||
| guard remaining <= 0 else { | ||
| return (nil, remaining) | ||
| } | ||
| fillPinnedIds(ids) | ||
| record?.lastHeartbeatDuration = duration | ||
| record?.lastHeartbeatTime = wallNow | ||
| persist() | ||
| var heartbeat = record | ||
| heartbeat?.activeDuration = duration | ||
| return (heartbeat, Self.heartbeatInterval) | ||
| } | ||
| guard let next else { | ||
| return | ||
| } | ||
| if let heartbeat = next.heartbeat { | ||
| OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionService heartbeat with duration \(heartbeat.activeDuration)") | ||
| requestQueue()?.enqueueUpdate(record: heartbeat) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This still enqueues through |
||
| } | ||
| schedule(next.wait) { [weak self] in | ||
| self?.checkHeartbeat(generation: generation) | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Catch-up on the next
onFocuscan setlastHeartbeatTimeto the return wall time whenactiveDurationis already past due (a visit that crossed 30 minutes was folded inonUnfocusand the lateasyncAfterwas generation-invalidated).maxthen prefers that return time overlastUnfocusTime, soend_timeincludes the whole background gap.Only let
lastHeartbeatTimewin whenlastHeartbeatDuration > activeDuration(killed in the foreground), or write the heartbeat inonUnfocuswhen the visit crosses the interval.