Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 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 @@ -8,6 +8,7 @@ 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
Expand Down Expand Up @@ -73,6 +74,17 @@ internal class PostHogLifecycleObserverIntegration(
(lastUpdatedSession + sessionMaxInterval) <= currentTimeMillis
) {
postHog?.startSession()
} 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)
Comment on lines +79 to +83
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

i need to do this here so all the snapshots and caching are cleared up from the previous sessions and wont leak to the new one

} else {
postHog?.endSession()
postHog?.startSession()
}
}
this.lastUpdatedSession.set(currentTimeMillis)
}
Expand Down Expand Up @@ -104,8 +116,18 @@ internal class PostHogLifecycleObserverIntegration(
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 (PostHogSessionManager.isSessionExceedingMaxDuration(currentTimeMillis)) {
cancelTask()
postHog?.endSession()
postHog?.stopSessionReplay()
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

i have to check if i need to call postHog?.startSessionReplay during onStart now

// 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 @@ -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 Down Expand Up @@ -171,13 +172,15 @@ 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,19 @@ import com.posthog.android.FakeLifecycle
import com.posthog.android.PostHogAndroidConfig
import com.posthog.android.createPostHogFake
import com.posthog.android.mockPackageInfo
import com.posthog.internal.PostHogDateProvider
import com.posthog.internal.PostHogDeviceDateProvider
import com.posthog.internal.PostHogSessionManager
import org.junit.runner.RunWith
import org.mockito.kotlin.mock
import java.util.Calendar
import java.util.Date
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull

@RunWith(AndroidJUnit4::class)
internal class PostHogLifecycleObserverIntegrationTest {
Expand All @@ -30,6 +38,14 @@ internal class PostHogLifecycleObserverIntegrationTest {
@BeforeTest
fun `set up`() {
PostHog.resetSharedInstance()
PostHogSessionManager.endSession()
}

@AfterTest
fun `tear down`() {
PostHogSessionManager.isReactNative = false
PostHogSessionManager.dateProvider = PostHogDeviceDateProvider()
PostHogSessionManager.endSession()
}

@Test
Expand Down Expand Up @@ -139,4 +155,151 @@ internal class PostHogLifecycleObserverIntegrationTest {

sut.uninstall()
}

@Test
fun `onStart rotates session when session exceeds 24 hours`() {
val baseTime = System.currentTimeMillis()
val fakeDateProvider = FakeDateProviderForTest(baseTime)
PostHogSessionManager.dateProvider = fakeDateProvider
val config =
PostHogAndroidConfig(API_KEY).apply {
dateProvider = fakeDateProvider
captureApplicationLifecycleEvents = false
}
val mainHandler = MainHandler()
val sut = PostHogLifecycleObserverIntegration(context, config, mainHandler, lifecycle = fakeLifecycle)
val fake = createPostHogFake()
sut.install(fake)

// Start a session (simulates first app open)
PostHogSessionManager.startSession()
val firstSessionId = PostHogSessionManager.getActiveSessionId()
assertNotNull(firstSessionId)

// First onStart at current time - this sets lastUpdatedSession
sut.onStart(ProcessLifecycleOwner.get())

// Simulate app going to background and coming back within 30 min interval
// but the total session duration exceeds 24 hours.
// We advance time by 25 minutes (within 30 min interval) repeatedly
// to simulate many short background/foreground cycles over 24+ hours.
// For the test, we just advance the clock by 24h+1min but keep lastUpdatedSession recent
// by doing a stop/start cycle at 24h+1min - 10min, then at 24h+1min
val twentyFourHoursMs = 1000L * 60 * 60 * 24
val tenMinutesMs = 1000L * 60 * 10
val oneMinuteMs = 1000L * 60

// Advance to 24h - 10 min (session still under 24h, within 30 min interval doesn't matter
// since we're simulating continuous use)
fakeDateProvider.currentTimeMs = baseTime + twentyFourHoursMs - tenMinutesMs
sut.onStop(ProcessLifecycleOwner.get())
sut.onStart(ProcessLifecycleOwner.get()) // updates lastUpdatedSession

// Now advance to 24h + 1 min (11 min after last update, within 30 min interval)
// Session started at baseTime, so it's now > 24 hours old
fakeDateProvider.currentTimeMs = baseTime + twentyFourHoursMs + oneMinuteMs
sut.onStop(ProcessLifecycleOwner.get())
sut.onStart(ProcessLifecycleOwner.get())

// Session should have been rotated
val secondSessionId = PostHogSessionManager.getActiveSessionId()
assertNotNull(secondSessionId)
assertNotEquals(firstSessionId, secondSessionId)

sut.uninstall()
}

@Test
fun `onStart does not rotate session when session is under 24 hours`() {
val baseTime = System.currentTimeMillis()
val fakeDateProvider = FakeDateProviderForTest(baseTime)
PostHogSessionManager.dateProvider = fakeDateProvider
val config =
PostHogAndroidConfig(API_KEY).apply {
dateProvider = fakeDateProvider
captureApplicationLifecycleEvents = false
}
val mainHandler = MainHandler()
val sut = PostHogLifecycleObserverIntegration(context, config, mainHandler, lifecycle = fakeLifecycle)
val fake = createPostHogFake()
sut.install(fake)

// Start a session
PostHogSessionManager.startSession()
val firstSessionId = PostHogSessionManager.getActiveSessionId()
assertNotNull(firstSessionId)

// First onStart
sut.onStart(ProcessLifecycleOwner.get())

// Simulate returning within 5 minutes (well within both 30 min and 24 hour limits)
val fiveMinutesMs = 1000L * 60 * 5
fakeDateProvider.currentTimeMs = baseTime + fiveMinutesMs

sut.onStop(ProcessLifecycleOwner.get())
sut.onStart(ProcessLifecycleOwner.get())

// Session should NOT have been rotated
val secondSessionId = PostHogSessionManager.getActiveSessionId()
assertEquals(firstSessionId, secondSessionId)

sut.uninstall()
}

@Test
fun `onStart does not rotate session when React Native even if session exceeds 24 hours`() {
PostHogSessionManager.isReactNative = true
val baseTime = System.currentTimeMillis()
val fakeDateProvider = FakeDateProviderForTest(baseTime)
PostHogSessionManager.dateProvider = fakeDateProvider
val config =
PostHogAndroidConfig(API_KEY).apply {
dateProvider = fakeDateProvider
captureApplicationLifecycleEvents = false
}
val mainHandler = MainHandler()
val sut = PostHogLifecycleObserverIntegration(context, config, mainHandler, lifecycle = fakeLifecycle)
val fake = createPostHogFake()
sut.install(fake)

// RN sets its own session id
val sessionId = java.util.UUID.randomUUID()
PostHogSessionManager.setSessionId(sessionId)

// First onStart
sut.onStart(ProcessLifecycleOwner.get())

// Advance past 24 hours
val twentyFourHoursMs = 1000L * 60 * 60 * 24
val oneMinuteMs = 1000L * 60
fakeDateProvider.currentTimeMs = baseTime + twentyFourHoursMs + oneMinuteMs

sut.onStop(ProcessLifecycleOwner.get())
sut.onStart(ProcessLifecycleOwner.get())

// Session should NOT have been rotated since RN manages its own session
assertEquals(sessionId, PostHogSessionManager.getActiveSessionId())

sut.uninstall()
}

/**
* A simple fake date provider for testing time-dependent behavior.
*/
private class FakeDateProviderForTest(initialTimeMs: Long = System.currentTimeMillis()) : PostHogDateProvider {
var currentTimeMs: Long = initialTimeMs

override fun currentDate(): Date = Date(currentTimeMs)

override fun addSecondsToCurrentDate(seconds: Int): Date {
val cal = Calendar.getInstance()
cal.timeInMillis = currentTimeMs
cal.add(Calendar.SECOND, seconds)
return cal.time
}

override fun currentTimeMillis(): Long = currentTimeMs

override fun nanoTime(): Long = System.nanoTime()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ class MyApp : Application() {
sessionReplayConfig.maskAllImages = false
sessionReplayConfig.captureLogcat = true
sessionReplayConfig.screenshot = true
surveys = true
errorTrackingConfig.autoCapture = true
surveys = false
errorTrackingConfig.autoCapture = false
}
PostHogAndroid.setup(this, config)
}
Expand Down
5 changes: 5 additions & 0 deletions posthog/api/posthog.api
Original file line number Diff line number Diff line change
Expand Up @@ -875,8 +875,13 @@ public final class com/posthog/internal/PostHogSessionManager {
public static final field INSTANCE Lcom/posthog/internal/PostHogSessionManager;
public final fun endSession ()V
public final fun getActiveSessionId ()Ljava/util/UUID;
public final fun getDateProvider ()Lcom/posthog/internal/PostHogDateProvider;
public final fun getSessionStartedAt ()J
public final fun isReactNative ()Z
public final fun isSessionActive ()Z
public final fun isSessionExceedingMaxDuration (J)Z
public final fun rotateSession ()V
public final fun setDateProvider (Lcom/posthog/internal/PostHogDateProvider;)V
public final fun setReactNative (Z)V
public final fun setSessionId (Ljava/util/UUID;)V
public final fun startSession ()V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ public object PostHogSessionManager {

private var sessionId = sessionIdNone

public var dateProvider: PostHogDateProvider = PostHogDeviceDateProvider()

/**
* Timestamp (in milliseconds) when the current session was started.
* Reset to 0 when the session ends.
*/
private var sessionStartedAt: Long = 0L

@Volatile
public var isReactNative: Boolean = false

Expand All @@ -28,6 +36,7 @@ public object PostHogSessionManager {
synchronized(sessionLock) {
if (sessionId == sessionIdNone) {
sessionId = TimeBasedEpochGenerator.generate()
sessionStartedAt = dateProvider.currentTimeMillis()
}
}
}
Expand All @@ -40,9 +49,48 @@ public object PostHogSessionManager {

synchronized(sessionLock) {
sessionId = sessionIdNone
sessionStartedAt = 0L
}
}

/**
* Atomically ends the current session and starts a new one.
* This is used when the session exceeds the maximum allowed duration (e.g. 24 hours).
*/
public fun rotateSession() {
if (isReactNative) {
// RN manages its own session
return
}

synchronized(sessionLock) {
sessionId = TimeBasedEpochGenerator.generate()
sessionStartedAt = dateProvider.currentTimeMillis()
}
}

/**
* Returns the timestamp (in milliseconds) when the current session was started,
* or 0 if no session is active.
*/
public fun getSessionStartedAt(): Long {
synchronized(sessionLock) {
return sessionStartedAt
}
}

/**
* Returns true if the current session has been active for longer than 24 hours.
*/
public fun isSessionExceedingMaxDuration(currentTimeMillis: Long): Boolean {
synchronized(sessionLock) {
return sessionStartedAt > 0L &&
(sessionStartedAt + SESSION_MAX_DURATION) <= currentTimeMillis
}
}

private val SESSION_MAX_DURATION = (1000L * 60 * 60 * 24) // 24 hours

public fun getActiveSessionId(): UUID? {
var tempSessionId: UUID?
synchronized(sessionLock) {
Expand Down
Loading
Loading