Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/gentle-clouds-drift.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'posthog': patch
'posthog-android': patch
---

Enforce 24-hour maximum session duration with automatic session rotation
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,10 @@ public class PostHogAndroid private constructor() {
val dateProvider = PostHogAndroidDateProvider()
config.dateProvider = dateProvider
TimeBasedEpochGenerator.setDateProvider(dateProvider)
PostHogSessionManager.setDateProvider(dateProvider)
} else {
TimeBasedEpochGenerator.setDateProvider(config.dateProvider)
PostHogSessionManager.setDateProvider(config.dateProvider)
}
}
config.networkStatus = config.networkStatus ?: PostHogAndroidNetworkStatus(context)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import androidx.lifecycle.ProcessLifecycleOwner
import com.posthog.PostHogIntegration
import com.posthog.PostHogInterface
import com.posthog.android.PostHogAndroidConfig
import com.posthog.internal.PostHogSessionManager
import java.util.Timer
import java.util.TimerTask
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong

/**
Expand All @@ -28,6 +30,7 @@ internal class PostHogLifecycleObserverIntegration(
private var timer = Timer(true)
private var timerTask: TimerTask? = null
private val lastUpdatedSession = AtomicLong(0L)
private val replayActiveBeforeRotation = AtomicBoolean(false)
private val sessionMaxInterval = (1000 * 60 * 30).toLong() // 30 minutes

private var postHog: PostHogInterface? = null
Expand All @@ -44,6 +47,9 @@ internal class PostHogLifecycleObserverIntegration(
}

override fun onStart(owner: LifecycleOwner) {
PostHogSessionManager.setAppInBackground(false)
// Foregrounding counts as activity (mirror iOS onDidBecomeActive).
PostHogSessionManager.touchSession()
startSession()

if (config.captureApplicationLifecycleEvents) {
Expand Down Expand Up @@ -73,6 +79,22 @@ internal class PostHogLifecycleObserverIntegration(
(lastUpdatedSession + sessionMaxInterval) <= currentTimeMillis
) {
postHog?.startSession()
// If the previous session was ended via 24h rotation in onStop,
// restart replay so it continues under the new session
if (replayActiveBeforeRotation.compareAndSet(true, false)) {
postHog?.startSessionReplay(resumeCurrent = true)
}
} else if (PostHogSessionManager.isSessionExceedingMaxDuration(currentTimeMillis)) {
// Session has been active for longer than 24 hours, rotate to a new session
if (postHog?.isSessionReplayActive() == true) {
postHog?.stopSessionReplay()

// startSessionReplay will rotate the session id internally
postHog?.startSessionReplay(resumeCurrent = false)
} else {
postHog?.endSession()
postHog?.startSession()
}
}
this.lastUpdatedSession.set(currentTimeMillis)
}
Expand All @@ -85,6 +107,12 @@ internal class PostHogLifecycleObserverIntegration(
}

private fun scheduleEndSession() {
// This timer honors the PostHogInterface.endSession docstring promise:
// "On Android, the SDK will automatically end a session when the app is
// in the background for at least 30 minutes." The getter's inactivity
// check isn't sufficient on its own because isSessionActive() reads the
// field directly β€” without this timer, a backgrounded app that fires no
// events would keep reporting an active session forever.
synchronized(timerLock) {
cancelTask()
timerTask =
Expand All @@ -98,14 +126,31 @@ internal class PostHogLifecycleObserverIntegration(
}

override fun onStop(owner: LifecycleOwner) {
val currentTimeMillis = config.dateProvider.currentTimeMillis()
// Snapshot before flipping the bg flag: once we set it, the next getActiveSessionId
// (e.g., while capturing "Application Backgrounded") may clear an expired session,
// zeroing sessionStartedAt so the 24h check below would miss it.
val wasExpired = PostHogSessionManager.isSessionExceedingMaxDuration(currentTimeMillis)

PostHogSessionManager.setAppInBackground(true)
if (config.captureApplicationLifecycleEvents) {
postHog?.capture("Application Backgrounded")
}
postHog?.flush()

val currentTimeMillis = config.dateProvider.currentTimeMillis()
lastUpdatedSession.set(currentTimeMillis)
scheduleEndSession()
// Session has been active for longer than 24 hours, rotate to a new session
if (wasExpired) {
cancelTask()
val wasReplayActive = postHog?.isSessionReplayActive() == true
postHog?.endSession()
postHog?.stopSessionReplay()
replayActiveBeforeRotation.set(wasReplayActive)
// Reset so the next onStart knows to create a fresh session
lastUpdatedSession.set(0L)
} else {
lastUpdatedSession.set(currentTimeMillis)
scheduleEndSession()
}
}

private fun add() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,8 @@ public class PostHogReplayIntegration(
private val onTouchEventListener =
TouchEventInterceptor { motionEvent, dispatch ->
val timestamp = config.dateProvider.currentTimeMillis()
// User touch counts as activity (closest equivalent to iOS UIEvent swizzling).
PostHogSessionManager.touchSession()

try {
val state = dispatch(motionEvent)
Expand Down Expand Up @@ -1662,7 +1664,8 @@ public class PostHogReplayIntegration(

/**
* Called when the session ID changes. Stops recording if event triggers are configured
* and the new session hasn't been activated yet.
* and the new session hasn't been activated yet, or re-initializes recording so the
* new session gets fresh meta + full wireframe events.
*/
override fun onSessionIdChanged() {
val postHog = this.postHog ?: return
Expand All @@ -1678,6 +1681,20 @@ public class PostHogReplayIntegration(
config.logger.log("[Session Replay] Session changed. Stopping until trigger is matched.")
stop()
}
} else if (isSessionReplayActive) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should react even if session replay is not active. Replay may be enabled in config but Session A may not have been sampled and not started. Now that we rotate to session B, sampling may return true and session replay should start?

// Session rotated/cleared silently (e.g., 24h max duration via getter).
// Posting to main: getter can be invoked from any thread that calls capture(),
// and start(resumeCurrent = false) iterates a non-thread-safe WeakHashMap.
if (currentSessionId == null) {
config.logger.log("[Session Replay] Session cleared. Stopping recording.")
mainHandler.handler.post { stop() }
} else {
config.logger.log("[Session Replay] Session changed. Re-initializing recording for new session.")
mainHandler.handler.post {
stop()
start(resumeCurrent = false)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the comment above, this will skip sampling check and start the session anyway? Maybe we can route through PostHog.startSessionReplay() which checks sampling?

}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.posthog.FeatureFlagResult
import com.posthog.PostHogConfig
import com.posthog.PostHogInterface
import com.posthog.PostHogOnFeatureFlags
import com.posthog.internal.PostHogSessionManager
import java.util.Date
import java.util.UUID

Expand All @@ -13,6 +14,9 @@ public class PostHogFake : PostHogInterface {
public var properties: Map<String, Any>? = null
public var captures: Int = 0
public var flushes: Int = 0
public var sessionReplayActive: Boolean = false
public var startSessionReplayCalls: Int = 0
public var stopSessionReplayCalls: Int = 0

override fun <T : PostHogConfig> setup(config: T) {
}
Expand Down Expand Up @@ -179,23 +183,29 @@ public class PostHogFake : PostHogInterface {
}

override fun startSession() {
PostHogSessionManager.startSession()
}

override fun endSession() {
PostHogSessionManager.endSession()
}

override fun isSessionActive(): Boolean {
return false
return PostHogSessionManager.isSessionActive()
}

override fun isSessionReplayActive(): Boolean {
return false
return sessionReplayActive
}

override fun startSessionReplay(resumeCurrent: Boolean) {
startSessionReplayCalls++
sessionReplayActive = true
}

override fun stopSessionReplay() {
stopSessionReplayCalls++
sessionReplayActive = false
}

override fun getSessionId(): UUID? {
Expand Down
Loading
Loading