Repository navigation
feat: [SDK-5397] send a heartbeat every 30 minutes of foreground time - #1773
fadi-george wants to merge 3 commits into
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… starts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Multi-model review
Models: Claude Opus 5.5, GPT 5.6 Sol, Grok 4.7.
Act on
- A missed heartbeat (visit folded in
onUnfocus, lateasyncAfterdropped) makes the nextonFocuscatch-up writelastHeartbeatTime = now. Tracker order isonFocusthen asyncstartNewSession, somax(lastUnfocusTime, lastHeartbeatTime)ends the previous session at return time and the background gap is included. (Opus, Grok)
Consider
reset()/resetAndClearStoredRecord()do not retire an in-flightcheckHeartbeat; it can persist the old record and enqueue on the new app-id queue. (3/3; rare)- Consent revoke while foregrounded: tracker skips
onUnfocus, so the timer keeps running and can count background time. (Opus) - Close the interval / bump generation before
pinnableIds()inonUnfocusso a concurrent due check cannot write a later wall time. (Sol)
Noted
testHeartbeatRepeatsEveryThirtyMinutesInTheForegroundcannot tell two fires from one coalesced late fire. SwiftLintfile_lengthwarning at 416 lines.
Dismissed
- Generation not bumped in the same lock as
record = newRecord: the window is only untilrestartHeartbeat(), and a new record’s duration is ~0 on the usual focus-then-session-start path.
Sent by Cursor Automation: PR Reviews
| 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) |
There was a problem hiding this comment.
Catch-up on the next onFocus can set lastHeartbeatTime to the return wall time when activeDuration is already past due (a visit that crossed 30 minutes was folded in onUnfocus and the late asyncAfter was generation-invalidated). max then prefers that return time over lastUnfocusTime, so end_time includes the whole background gap.
Only let lastHeartbeatTime win when lastHeartbeatDuration > activeDuration (killed in the foreground), or write the heartbeat in onUnfocus when the visit crosses the interval.
| func onFocus() { | ||
| let now = monotonicNow() | ||
| stateLock.withLock { focusedAt = now } | ||
| restartHeartbeat() |
There was a problem hiding this comment.
Tracker calls [OSSessionService onFocus] before async startNewSession. This restartHeartbeat → checkHeartbeat therefore runs against the outgoing record. If that record is already past due, this is what stamps lastHeartbeatTime to now — the value the endTime max above then prefers.
abdulraqeeb33
left a comment
There was a problem hiding this comment.
Requesting changes.
This branch forked before the operation-repo rewrite. Its merge-base with current #1772 is 65000d08. It is missing the operation repo, the Identity Verification exemption, dropping users that will never be created, and foreground/network retry. It still calls OSSessionRequestQueue, which #1772 deleted.
Rebase onto current #1772 and enqueue the heartbeat through enqueueSessionUpdate. The 30-minute foreground timer and the end that uses max(activeDuration, lastHeartbeatDuration) are the right shape. Android #2782 already does that on a clean parent.
| } | ||
| if let heartbeat = next.heartbeat { | ||
| OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSessionService heartbeat with duration \(heartbeat.activeDuration)") | ||
| requestQueue()?.enqueueUpdate(record: heartbeat) |
There was a problem hiding this comment.
This still enqueues through OSSessionRequestQueue. Current #1772 deleted that queue and sends updates through enqueueSessionUpdate. Rebase onto that branch and call enqueueSessionUpdate here.


Description
One Line Summary
When the sessions API flag is on, enqueue a duration-only session update every 30 minutes of foreground time.
Details
Motivation
Part of the iOS SDK Sessions stack (SDK-5397). Keeps long foreground sessions open on the server and limits lost duration if the app is killed in the foreground. Based on #1772. Android counterpart: OneSignal/OneSignal-Android-SDK#2782.
Scope
OSSessionServicestarts the heartbeat on focus and when a new session starts, only for records whoseusesSessionsApiis true, and stops it on unfocus. Flag off: no timer.end_time. Existing coalescing inOSSessionRequestQueuefolds unsent heartbeats and discards them once the end is queued. No queue changes.OSSessionRecord.lastHeartbeatDuration/lastHeartbeatTime. An app killed in the foreground never adds its last visit toactiveDuration, so the end now uses the larger ofactiveDurationand the last heartbeat's duration, and the later of the last unfocus (or start) and the last heartbeat asend_time.Testing
Unit testing
Added
OSSessionHeartbeatTestson a virtual clock: one heartbeat after 30 minutes with the wall time recorded, cumulative duration after 60 minutes, background pauses the interval, a new session restarts it, no timer with the flag off, and a session killed after a heartbeat ends with the heartbeat's duration and time.UnitTestApp_TestPlan_Reducedpasses locally. SwiftLint:OSSessionService.swiftnow exceeds the 400-linefile_lengthwarning (416); no other new violations.Manual testing
Not tested on device yet; end-to-end verification is planned in the iOS rollout and QA ticket.
Affected code checklist
Checklist
Overview
Testing
Final pass
🤖 Generated with Claude Code