From a3a0fbdc93043d785aa63cc29419be2e3f36668e Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 May 2025 15:37:12 +0100 Subject: [PATCH 1/6] feat: auto-rotate session after 24 hours --- posthog/src/main/java/com/posthog/PostHog.kt | 29 +++++++- .../posthog/internal/PostHogSessionManager.kt | 6 +- .../vendor/uuid/TimeBasedEpochGenerator.kt | 31 +++++++++ .../src/test/java/com/posthog/PostHogTest.kt | 67 +++++++++++++++++++ .../internal/FakePostHogDateProvider.kt | 4 +- .../java/com/posthog/vendor/uuid/UUIDTest.kt | 23 +++++++ 6 files changed, 154 insertions(+), 6 deletions(-) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 81274825..0ce86580 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -1,5 +1,6 @@ package com.posthog +import com.posthog.internal.MAX_SESSION_AGE_MILLIS import com.posthog.internal.PostHogApi import com.posthog.internal.PostHogApiEndpoint import com.posthog.internal.PostHogMemoryPreferences @@ -432,7 +433,7 @@ public class PostHog private constructor( } val mergedProperties = - buildProperties( + enforceMaxSessionLength(buildProperties( newDistinctId, properties = properties, userProperties = userProperties, @@ -442,7 +443,7 @@ public class PostHog private constructor( appendSharedProps = !snapshotEvent, // only append groups if not a group identify event and not a snapshot appendGroups = !groupIdentify, - ) + )) // sanitize the properties or fallback to the original properties val sanitizedProperties = config?.propertiesSanitizer?.sanitize(mergedProperties.toMutableMap()) ?: mergedProperties @@ -466,6 +467,30 @@ public class PostHog private constructor( } } + private fun enforceMaxSessionLength(buildProperties: Map): Map { + val sessionId = buildProperties["\$session_id"] ?: return buildProperties + + val sessionStartTimestamp = + TimeBasedEpochGenerator.getTimestampFromUuid(UUID.fromString(sessionId.toString())) + ?: return buildProperties + + val currentTimeMillis = + config?.dateProvider?.currentTimeMillis() ?: System.currentTimeMillis() + val sessionAge = currentTimeMillis - sessionStartTimestamp + if (sessionAge > MAX_SESSION_AGE_MILLIS) { + // rotate session + // update properties + PostHogSessionManager.endSession() + PostHogSessionManager.startSession(currentTimeMillis) + val newSessionId = PostHogSessionManager.getActiveSessionId().toString() + val newProps = buildProperties.toMutableMap() + newProps["\$session_id"] = newSessionId + newProps["\$window_id"] = newSessionId + return newProps + } + return buildProperties + } + public override fun optIn() { if (!isEnabled()) { return diff --git a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt index 0f7b08eb..252efaf1 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt @@ -4,6 +4,8 @@ import com.posthog.PostHogInternal import com.posthog.vendor.uuid.TimeBasedEpochGenerator import java.util.UUID +public const val MAX_SESSION_AGE_MILLIS: Int = 24 * 60 * 60 * 60 + /** * Class that manages the Session ID */ @@ -16,10 +18,10 @@ public object PostHogSessionManager { private var sessionId = sessionIdNone - public fun startSession() { + public fun startSession(sessionStartTime: Long? = null) { synchronized(sessionLock) { if (sessionId == sessionIdNone) { - sessionId = TimeBasedEpochGenerator.generate() + sessionId = TimeBasedEpochGenerator.generate(sessionStartTime ?: System.currentTimeMillis()) } } } diff --git a/posthog/src/main/java/com/posthog/vendor/uuid/TimeBasedEpochGenerator.kt b/posthog/src/main/java/com/posthog/vendor/uuid/TimeBasedEpochGenerator.kt index 72054bb0..78bbe64a 100644 --- a/posthog/src/main/java/com/posthog/vendor/uuid/TimeBasedEpochGenerator.kt +++ b/posthog/src/main/java/com/posthog/vendor/uuid/TimeBasedEpochGenerator.kt @@ -142,4 +142,35 @@ internal object TimeBasedEpochGenerator { lock.unlock() } } + + /** + * Extracts the Unix epoch timestamp in milliseconds from a UUID generated by this generator. + * + * @param uuid The UUID to extract the timestamp from. + * @return The Unix epoch timestamp in milliseconds, or null if the UUID is not a valid + * Version 7 UUID. + */ + fun getTimestampFromUuid(uuid: UUID): Long? { + // Check if it's a Version 7 UUID (version field is 7) + val version = (uuid.mostSignificantBits shr 12) and 0x0FL + if (version != TIME_BASED_EPOCH_RAW.toLong()) { + return null // Not a Version 7 UUID generated by this class + } + + // Check if it's the standard UUID variant 10xx + val variant = (uuid.leastSignificantBits shr 62) and 0x03L + if (variant != 2L) { + return null // Not the standard UUID variant + } + + // Split the UUID into its components + val parts = uuid.toString().split("-") + + // The first part and first 4 chars of second part contain the high bits of the timestamp + val highBitsHex = parts[0] + parts[1].substring(0, 4) + + // Convert the high bits from hex to decimal + // The UUID v7 timestamp is the number of milliseconds since Unix epoch + return highBitsHex.toLong(16) + } } diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 5ab89979..9a23aab9 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -1,6 +1,9 @@ package com.posthog +import com.posthog.internal.FakePostHogDateProvider import com.posthog.internal.PostHogBatchEvent +import com.posthog.internal.PostHogDateProvider +import com.posthog.internal.PostHogDeviceDateProvider import com.posthog.internal.PostHogMemoryPreferences import com.posthog.internal.PostHogPreferences.Companion.GROUPS import com.posthog.internal.PostHogPrintLogger @@ -12,6 +15,7 @@ import okhttp3.mockwebserver.MockResponse import org.junit.Rule import org.junit.rules.TemporaryFolder import java.io.File +import java.util.Date import java.util.concurrent.Executors import kotlin.test.AfterTest import kotlin.test.Test @@ -49,6 +53,7 @@ internal class PostHogTest { remoteConfig: Boolean = false, cachePreferences: PostHogMemoryPreferences = PostHogMemoryPreferences(), propertiesSanitizer: PostHogPropertiesSanitizer? = null, + dateProvider: PostHogDateProvider = PostHogDeviceDateProvider(), ): PostHogInterface { config = PostHogConfig(API_KEY, host).apply { @@ -65,6 +70,7 @@ internal class PostHogTest { this.cachePreferences = cachePreferences this.propertiesSanitizer = propertiesSanitizer this.remoteConfig = remoteConfig + this.dateProvider = dateProvider } return PostHog.withInternal( config, @@ -1446,4 +1452,65 @@ internal class PostHogTest { sut.close() } + + @Test + fun `reset session id after 24 hours`() { + val http = mockHttp() + val url = http.url("/") + + val fakePostHogDateProvider = FakePostHogDateProvider() + fakePostHogDateProvider.setCurrentDate(Date()) + + val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false, dateProvider = fakePostHogDateProvider) + + sut.capture( + EVENT, + DISTINCT_ID, + props, + userProperties = userProps, + userPropertiesSetOnce = userPropsOnce, + groups = groups, + ) + + queueExecutor.awaitExecution() + + var request = http.takeRequest() + + assertEquals(1, http.requestCount) + var content = request.body.unGzip() + var batch = serializer.deserialize(content.reader()) + + var theEvent = batch.batch.first() + val currentSessionId = theEvent.properties!!["\$session_id"] + assertNotNull(currentSessionId) + + // jump forward by 24 hours + val oneDayLater = Date(fakePostHogDateProvider.currentDate().time + (24 * 60 * 60 * 1000)) + fakePostHogDateProvider.setCurrentDate(oneDayLater) + + sut.capture( + EVENT, + DISTINCT_ID, + props, + userProperties = userProps, + userPropertiesSetOnce = userPropsOnce, + groups = groups, + ) + + queueExecutor.shutdownAndAwaitTermination() + + request = http.takeRequest() + + assertEquals(2, http.requestCount) + content = request.body.unGzip() + batch = serializer.deserialize(content.reader()) + + theEvent = batch.batch.first() + val newSessionId = theEvent.properties!!["\$session_id"] + assertNotNull(newSessionId) + + assertTrue(currentSessionId != newSessionId) + + sut.close() + } } diff --git a/posthog/src/test/java/com/posthog/internal/FakePostHogDateProvider.kt b/posthog/src/test/java/com/posthog/internal/FakePostHogDateProvider.kt index 9c68b60a..cd1ad28a 100644 --- a/posthog/src/test/java/com/posthog/internal/FakePostHogDateProvider.kt +++ b/posthog/src/test/java/com/posthog/internal/FakePostHogDateProvider.kt @@ -29,10 +29,10 @@ internal class FakePostHogDateProvider : PostHogDateProvider { } override fun currentTimeMillis(): Long { - return System.currentTimeMillis() + return currentDate?.time ?: System.currentTimeMillis() } override fun nanoTime(): Long { - return System.nanoTime() + return currentDate?.time?.times(1000000) ?: System.nanoTime() } } diff --git a/posthog/src/test/java/com/posthog/vendor/uuid/UUIDTest.kt b/posthog/src/test/java/com/posthog/vendor/uuid/UUIDTest.kt index 6128f821..8b71b833 100644 --- a/posthog/src/test/java/com/posthog/vendor/uuid/UUIDTest.kt +++ b/posthog/src/test/java/com/posthog/vendor/uuid/UUIDTest.kt @@ -37,4 +37,27 @@ internal class UUIDTest { assertEquals(uuid.toString(), javaUuid.toString()) } + + @Test + fun `test getTimestampFromUuid can convert back to timestamp`() { + val uuid = TimeBasedEpochGenerator.generate() + assertNotNull(uuid) + + val timestamp = TimeBasedEpochGenerator.getTimestampFromUuid(uuid) + assertNotNull(timestamp) + } + + @Test + fun `test that a known UUID from posthog-js has the expected timestamp`() { + val uuid = UUID.fromString("0196a5a9-1a29-7eaf-8f1d-81d156d4819e") + val timestamp = TimeBasedEpochGenerator.getTimestampFromUuid(uuid) + assertEquals(1746536045097, timestamp) + } + + @Test + fun `test that a known UUID from posthog-android has the expected timestamp`() { + val uuid = UUID.fromString("0196a5d6-ec0e-792c-a483-4a69cd57bba8") + val timestamp = TimeBasedEpochGenerator.getTimestampFromUuid(uuid) + assertEquals(1746539047950, timestamp) + } } From 4f4eb307b59bb04a7b0e4732f69751beb97ef720 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 May 2025 15:39:18 +0100 Subject: [PATCH 2/6] fix --- posthog/src/main/java/com/posthog/PostHog.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 0ce86580..5a4767e4 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -467,6 +467,11 @@ public class PostHog private constructor( } } + /** + * Because we use UUIDv7 + * we can convert a session id into the session start timestamp + * and then use that to enforce the maximum session length + */ private fun enforceMaxSessionLength(buildProperties: Map): Map { val sessionId = buildProperties["\$session_id"] ?: return buildProperties From 187b9a4e1f818a0013062cf8fdb5a574c72b4e3d Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 May 2025 15:46:18 +0100 Subject: [PATCH 3/6] fix --- posthog/src/main/java/com/posthog/PostHog.kt | 24 ++++++++++--------- .../vendor/uuid/TimeBasedEpochGenerator.kt | 4 ++-- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 5a4767e4..eef48297 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -433,17 +433,19 @@ public class PostHog private constructor( } val mergedProperties = - enforceMaxSessionLength(buildProperties( - newDistinctId, - properties = properties, - userProperties = userProperties, - userPropertiesSetOnce = userPropertiesSetOnce, - groups = groups, - // only append shared props if not a snapshot event - appendSharedProps = !snapshotEvent, - // only append groups if not a group identify event and not a snapshot - appendGroups = !groupIdentify, - )) + enforceMaxSessionLength( + buildProperties( + newDistinctId, + properties = properties, + userProperties = userProperties, + userPropertiesSetOnce = userPropertiesSetOnce, + groups = groups, + // only append shared props if not a snapshot event + appendSharedProps = !snapshotEvent, + // only append groups if not a group identify event and not a snapshot + appendGroups = !groupIdentify, + ), + ) // sanitize the properties or fallback to the original properties val sanitizedProperties = config?.propertiesSanitizer?.sanitize(mergedProperties.toMutableMap()) ?: mergedProperties diff --git a/posthog/src/main/java/com/posthog/vendor/uuid/TimeBasedEpochGenerator.kt b/posthog/src/main/java/com/posthog/vendor/uuid/TimeBasedEpochGenerator.kt index 78bbe64a..40c4f492 100644 --- a/posthog/src/main/java/com/posthog/vendor/uuid/TimeBasedEpochGenerator.kt +++ b/posthog/src/main/java/com/posthog/vendor/uuid/TimeBasedEpochGenerator.kt @@ -165,10 +165,10 @@ internal object TimeBasedEpochGenerator { // Split the UUID into its components val parts = uuid.toString().split("-") - + // The first part and first 4 chars of second part contain the high bits of the timestamp val highBitsHex = parts[0] + parts[1].substring(0, 4) - + // Convert the high bits from hex to decimal // The UUID v7 timestamp is the number of milliseconds since Unix epoch return highBitsHex.toLong(16) From f56b644ff8fed04ec6231ae277dcf865c138c630 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 May 2025 15:47:25 +0100 Subject: [PATCH 4/6] fix --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 411d0596..b123211d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +- auto-rotate session after 24 hours ([#247](https://github.com/PostHog/posthog-android/pull/247)) + ## 3.14.1 - 2025-04-23 - fix: Send correct `$feature_flag_response` for the `$feature_flag_called` event when calling `isFeatureEnabled` ([#244](https://github.com/PostHog/posthog-android/pull/244)) From 9a01a72d7a34ad9429fb08d32202e287d1f8b51c Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 May 2025 16:19:07 +0100 Subject: [PATCH 5/6] fix --- posthog/api/posthog.api | 7 ++++++- .../java/com/posthog/internal/PostHogSessionManager.kt | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index f959a77a..53bd39c4 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -348,7 +348,12 @@ public final class com/posthog/internal/PostHogSessionManager { public final fun getActiveSessionId ()Ljava/util/UUID; public final fun isSessionActive ()Z public final fun setSessionId (Ljava/util/UUID;)V - public final fun startSession ()V + public final fun startSession (Ljava/lang/Long;)V + public static synthetic fun startSession$default (Lcom/posthog/internal/PostHogSessionManager;Ljava/lang/Long;ILjava/lang/Object;)V +} + +public final class com/posthog/internal/PostHogSessionManagerKt { + public static final field MAX_SESSION_AGE_MILLIS I } public final class com/posthog/internal/PostHogThreadFactory : java/util/concurrent/ThreadFactory { diff --git a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt index 252efaf1..7f11644e 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt @@ -21,7 +21,11 @@ public object PostHogSessionManager { public fun startSession(sessionStartTime: Long? = null) { synchronized(sessionLock) { if (sessionId == sessionIdNone) { - sessionId = TimeBasedEpochGenerator.generate(sessionStartTime ?: System.currentTimeMillis()) + sessionId = if (sessionStartTime != null) { + TimeBasedEpochGenerator.generate(sessionStartTime) + } else { + TimeBasedEpochGenerator.generate() + } } } } From 46db594f4b491ee4cc2d2fecb127c38f67cb9f18 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 May 2025 17:09:00 +0100 Subject: [PATCH 6/6] fix --- .../com/posthog/internal/PostHogSessionManager.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt index 7f11644e..975e0baa 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSessionManager.kt @@ -21,11 +21,12 @@ public object PostHogSessionManager { public fun startSession(sessionStartTime: Long? = null) { synchronized(sessionLock) { if (sessionId == sessionIdNone) { - sessionId = if (sessionStartTime != null) { - TimeBasedEpochGenerator.generate(sessionStartTime) - } else { - TimeBasedEpochGenerator.generate() - } + sessionId = + if (sessionStartTime != null) { + TimeBasedEpochGenerator.generate(sessionStartTime) + } else { + TimeBasedEpochGenerator.generate() + } } } }