Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b2a522d
fix: use PostHogDateProvider in PostHogSessionManager instead of Syst…
marandaneto Mar 26, 2026
6cfd32b
fix: skip session rotation for React Native in lifecycle observer
marandaneto Mar 26, 2026
d431746
chore: update PR description and changeset for 24h session limit
marandaneto Mar 26, 2026
faa882b
chore: changeset to patch
marandaneto Mar 26, 2026
b01bd5f
fix
marandaneto Mar 27, 2026
ff9b2de
fix
marandaneto Mar 27, 2026
3067a99
ref
marandaneto Mar 27, 2026
31c41df
fix: use setDateProvider() after dateProvider was made private
marandaneto Mar 27, 2026
2ab7e43
fix: restart session replay after 24h rotation in background
turnipdabeets Apr 21, 2026
aac7323
fix: rotate session in PostHogSessionManager getter after 24h
turnipdabeets Apr 21, 2026
114616b
fix: prefer caller-provided $session_id over getter in buildProperties
turnipdabeets Apr 22, 2026
53182b3
test: cover caller-provided session_id; clean up session listener on …
turnipdabeets Apr 22, 2026
396fc37
fix: stamp sessionStartedAt in setSessionId; restart replay on rotation
turnipdabeets Apr 22, 2026
d230339
fix: post replay restart to main thread; add test for rotation listener
turnipdabeets Apr 22, 2026
e4206b7
fix: rotate or clear session after 30 minutes of inactivity
turnipdabeets Apr 22, 2026
a367781
fix: address PR feedback and align session-id behavior with iOS
turnipdabeets May 1, 2026
a7fed1d
chore: address review feedback and trim narrative comments
turnipdabeets May 1, 2026
3a62ee0
test: cover restartSessionReplay sampling, onSessionIdChanged when in…
turnipdabeets May 1, 2026
7733fff
fix: harden listener invocation and dateProvider visibility
turnipdabeets May 1, 2026
ca24cd2
fix: drop duplicate listener fire and force keyframes after rotation
turnipdabeets May 1, 2026
78e39fa
refactor: inline sampling-aware restart in replay integration
turnipdabeets May 4, 2026
1c7f81c
refactor: stop shadowing session state in lifecycle observer
turnipdabeets May 4, 2026
15548f7
fix: gate replay rotation auto-start on config.sessionReplay
turnipdabeets May 4, 2026
ceea09b
fix: scope session activity tracking and RN-bypass max-duration
turnipdabeets May 5, 2026
55cf017
fix: default isAppInBackground to false; Android opts into true at SD…
turnipdabeets May 5, 2026
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 and 30-minute inactivity rotation with automatic session rotation, mirroring iOS. Note: `PostHogSessionManager.isAppInBackground` now defaults to `true` until the first lifecycle `onStart` flips it; downstream wrappers (Flutter, RN) that exercise the manager directly in tests may need to call `setAppInBackground(false)` to simulate a foregrounded process.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import com.posthog.android.internal.PostHogAppInstallIntegration
import com.posthog.android.internal.PostHogLifecycleObserverIntegration
import com.posthog.android.internal.PostHogMetaPropertiesApplier
import com.posthog.android.internal.PostHogSharedPreferences
import com.posthog.android.internal.PostHogTouchActivityIntegration
import com.posthog.android.internal.appContext
import com.posthog.android.internal.getPackageInfo
import com.posthog.android.internal.versionCodeCompat
Expand Down Expand Up @@ -107,8 +108,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 All @@ -120,6 +123,9 @@ public class PostHogAndroid private constructor() {
}

PostHogSessionManager.isReactNative = config.sdkName == "posthog-react-native"
// Mark the process as backgrounded until the first onStart fires; an expired
// session before any UI exists is cleared rather than silently rotated.
PostHogSessionManager.setAppInBackground(true)

val releaseIdentifierFallback = "$packageName@$versionName+$buildNumber"
val metaPropertiesApplier = PostHogMetaPropertiesApplier()
Expand All @@ -130,6 +136,7 @@ public class PostHogAndroid private constructor() {

val mainHandler = MainHandler()
config.addIntegration(PostHogReplayIntegration(context, config, mainHandler))
config.addIntegration(PostHogTouchActivityIntegration(config))
config.addIntegration(PostHogLogCatIntegration(config))
if (context is Application) {
if (config.captureDeepLinks || config.captureScreenViews || config.sessionReplay) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ 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.AtomicLong

/**
* Captures app opened and backgrounded events
Expand All @@ -27,8 +27,9 @@ internal class PostHogLifecycleObserverIntegration(
private val timerLock = Any()
private var timer = Timer(true)
private var timerTask: TimerTask? = null
private val lastUpdatedSession = AtomicLong(0L)
private val sessionMaxInterval = (1000 * 60 * 30).toLong() // 30 minutes

// Bg timeout for forcing endSession when no events fire to drive the manager's getter check.
private val bgEndSessionDelayMs = (1000 * 60 * 30).toLong() // 30 minutes

private var postHog: PostHogInterface? = null

Expand All @@ -44,7 +45,13 @@ internal class PostHogLifecycleObserverIntegration(
}

override fun onStart(owner: LifecycleOwner) {
startSession()
cancelTask()
PostHogSessionManager.setAppInBackground(false)
// touchSession rotates an idle session; startSession creates a fresh one if the
// session was cleared during bg. Both fire the manager's session-id-changed listener,
// which drives the sampling-aware replay restart in the replay integration.
PostHogSessionManager.touchSession()
postHog?.startSession()

if (config.captureApplicationLifecycleEvents) {
val props = mutableMapOf<String, Any>()
Expand All @@ -63,20 +70,6 @@ internal class PostHogLifecycleObserverIntegration(
}
}

private fun startSession() {
cancelTask()

val currentTimeMillis = config.dateProvider.currentTimeMillis()
val lastUpdatedSession = lastUpdatedSession.get()

if (lastUpdatedSession == 0L ||
(lastUpdatedSession + sessionMaxInterval) <= currentTimeMillis
) {
postHog?.startSession()
}
this.lastUpdatedSession.set(currentTimeMillis)
}

private fun cancelTask() {
synchronized(timerLock) {
timerTask?.cancel()
Expand All @@ -85,6 +78,9 @@ internal class PostHogLifecycleObserverIntegration(
}

private fun scheduleEndSession() {
// Backgrounded apps may fire no events, so the getter's idle check never runs;
// this timer guarantees the session ends after 30 min of bg per the
// PostHogInterface.endSession docstring.
synchronized(timerLock) {
cancelTask()
timerTask =
Expand All @@ -93,19 +89,35 @@ internal class PostHogLifecycleObserverIntegration(
postHog?.endSession()
}
}
timer.schedule(timerTask, sessionMaxInterval)
timer.schedule(timerTask, bgEndSessionDelayMs)
}
}

override fun onStop(owner: LifecycleOwner) {
val currentTimeMillis = config.dateProvider.currentTimeMillis()
// Capture expiry before flipping the bg flag: once bg=true, getActiveSessionId
// would clear an expired session and zero sessionStartedAt, hiding it from the
// wasExpired branch below.
val wasExpired = PostHogSessionManager.isSessionExceedingMaxDuration(currentTimeMillis)
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.

Will this return false on RN (since any session rotation logic should be no-op for RN)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch β€” added the RN bypass (returns false when isReactNative)


// Touch while still foregrounded so the activity timestamp moves forward; doing
// this after setAppInBackground(true) would no-op.
PostHogSessionManager.touchSession()
PostHogSessionManager.setAppInBackground(true)
if (config.captureApplicationLifecycleEvents) {
postHog?.capture("Application Backgrounded")
}
postHog?.flush()

val currentTimeMillis = config.dateProvider.currentTimeMillis()
lastUpdatedSession.set(currentTimeMillis)
scheduleEndSession()
if (wasExpired) {
cancelTask()
// Force the rotation now and stop replay synchronously β€” process may suspend
// before the listener's main-thread post can run.
postHog?.endSession()
postHog?.stopSessionReplay()
} else {
scheduleEndSession()
}
}

private fun add() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package com.posthog.android.internal

import android.os.Build
import com.posthog.PostHogIntegration
import com.posthog.PostHogInterface
import com.posthog.android.PostHogAndroidConfig
import com.posthog.internal.PostHogSessionManager
import curtains.Curtains
import curtains.OnRootViewsChangedListener
import curtains.TouchEventInterceptor
import curtains.phoneWindow
import curtains.touchEventInterceptors

/**
* Marks user touches as session activity by calling [PostHogSessionManager.touchSession]
* on every dispatched MotionEvent.
*
* Decoupled from session replay so apps with replay disabled or sampled out still get
* touch-driven inactivity rotation; otherwise session-id rotation behaviour would depend
* on whether the user happened to be sampled, which session metrics rely on being stable.
*/
internal class PostHogTouchActivityIntegration(
private val config: PostHogAndroidConfig,
) : PostHogIntegration {
private companion object {
@Volatile
private var integrationInstalled = false
}

private val touchInterceptor =
TouchEventInterceptor { motionEvent, dispatch ->
try {
PostHogSessionManager.touchSession()
} catch (e: Throwable) {
config.logger.log("PostHogTouchActivityIntegration touchSession failed: $e.")
}
dispatch(motionEvent)
}

private val onRootViewsChangedListener =
OnRootViewsChangedListener { view, added ->
try {
val window = view.phoneWindow ?: return@OnRootViewsChangedListener
if (added) {
if (touchInterceptor !in window.touchEventInterceptors) {
window.touchEventInterceptors += touchInterceptor
}
} else {
window.touchEventInterceptors -= touchInterceptor
}
} catch (e: Throwable) {
config.logger.log("PostHogTouchActivityIntegration root view changed failed: $e.")
}
}

override fun install(postHog: PostHogInterface) {
if (integrationInstalled || !isSupported()) {
return
}
integrationInstalled = true
try {
Curtains.rootViews.forEach { view ->
view.phoneWindow?.let { window ->
if (touchInterceptor !in window.touchEventInterceptors) {
window.touchEventInterceptors += touchInterceptor
}
}
}
Curtains.onRootViewsChangedListeners += onRootViewsChangedListener
} catch (e: Throwable) {
config.logger.log("PostHogTouchActivityIntegration install failed: $e.")
}
}

override fun uninstall() {
try {
Curtains.onRootViewsChangedListeners -= onRootViewsChangedListener
Curtains.rootViews.forEach { view ->
view.phoneWindow?.let { window ->
window.touchEventInterceptors -= touchInterceptor
}
}
} catch (e: Throwable) {
config.logger.log("PostHogTouchActivityIntegration uninstall failed: $e.")
} finally {
integrationInstalled = false
}
}

private fun isSupported(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
}
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,6 @@ public class PostHogReplayIntegration(
private val onTouchEventListener =
TouchEventInterceptor { motionEvent, dispatch ->
val timestamp = config.dateProvider.currentTimeMillis()

try {
val state = dispatch(motionEvent)
try {
Expand Down Expand Up @@ -1606,6 +1605,15 @@ public class PostHogReplayIntegration(
}

isSessionReplayActive = true

if (!resumeCurrent) {
// Without this, on a static UI the first user-driven onDraw can be tens of seconds
// away β€” and incremental events (type:3) would ship under the new session before
// the meta + full-snapshot keyframes (type:4 + type:2) needed to render them.
mainHandler.handler.post {
decorViews.keys.forEach { it.postInvalidate() }
}
}
}

private fun clearSnapshotStates() {
Expand Down Expand Up @@ -1662,22 +1670,60 @@ 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-initialises recording so the
* new session gets fresh meta + full wireframe events.
*/
override fun onSessionIdChanged() {
val postHog = this.postHog ?: return
if (this.postHog == null) return

val currentSessionId = postHog.getSessionId()?.toString()
// Read-only: getActiveSessionId() can rotate the session and would re-fire this listener.
val currentSessionId = PostHogSessionManager.peekSessionId()?.toString()

val triggers = config.remoteConfigHolder?.getEventTriggers()
val remoteConfig = config.remoteConfigHolder
val triggers = remoteConfig?.getEventTriggers()
val activatedSession = synchronized(eventTriggersLock) { triggerActivatedSessionId }

// If triggers are configured and this session hasn't been activated, stop the integration
if (!triggers.isNullOrEmpty() && activatedSession != currentSessionId) {
if (isSessionReplayActive) {
config.logger.log("[Session Replay] Session changed. Stopping until trigger is matched.")
stop()
}
return
}

// The listener can fire from any thread that calls capture(); replay state writes
// (snapshot WeakHashMap, isSessionReplayActive) must happen on main.
if (currentSessionId == null) {
if (isSessionReplayActive) {
config.logger.log("[Session Replay] Session cleared. Stopping recording.")
mainHandler.handler.post { stop() }
}
return
}

// Run regardless of isSessionReplayActive: the prior session may have been sampled out
// and the new one may now pass. Sampling is re-evaluated for the (already-current)
// session without rotating the id (the silent rotation that fired this already
// rotated; going through PostHog.startSessionReplay(false) would double-rotate).
config.logger.log("[Session Replay] Session changed. Re-initializing recording for new session.")
mainHandler.handler.post {
// config.sessionReplay is the customer-facing master switch: a config-level disable
// must not be overridden by remote flag + sampling. Manual PostHog.startSessionReplay
// calls and trigger-matched starts go through different code paths and are unaffected.
if (!config.sessionReplay) {
if (isSessionReplayActive) stop()
return@post
}
if (remoteConfig?.isSessionReplayFlagActive() != true) {
if (isSessionReplayActive) stop()
return@post
}
if (remoteConfig.makeSamplingDecision(currentSessionId).not()) {
if (isSessionReplayActive) stop()
return@post
}
if (isSessionReplayActive) 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.

In iOS, session replay is only started automatically if config.sessionReplay is true (this is a simplification, we actually won't install the integration if it's false).

On main, we don't start automatically recording at any point unless config.sessionReplay is true (isSessionReplayConfigEnabled()).

This onSessionIdChanged implementation will start automatic recording on session rotation if session replay is enabled via remote config. It doesn't automatically start on initialization because the listener isn't bound yet.

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.

Worth a test case or two

Copy link
Copy Markdown
Contributor Author

@turnipdabeets turnipdabeets May 4, 2026

Choose a reason for hiding this comment

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

Added a check for config.sessionReplay plus a test

}

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