Skip to content

feat: auto-rotate session after 24 hours #247

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
29 changes: 27 additions & 2 deletions posthog/src/main/java/com/posthog/PostHog.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -432,7 +433,7 @@ public class PostHog private constructor(
}

val mergedProperties =
buildProperties(
enforceMaxSessionLength(buildProperties(
newDistinctId,
properties = properties,
userProperties = userProperties,
Expand All @@ -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
Expand All @@ -466,6 +467,30 @@ public class PostHog private constructor(
}
}

private fun enforceMaxSessionLength(buildProperties: Map<String, Any>): Map<String, Any> {
Copy link
Member

Choose a reason for hiding this comment

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

react native has its own session id rotation so we should not do this for RN I think
https://github.com/PostHog/posthog-js-lite/blob/b8372284df5489457d95b16b0e4b12a91dd66c26/posthog-core/src/index.ts#L1309-L1315
otherwise they will be out of sync (RN uses a different session id than Android)

Copy link
Member Author

Choose a reason for hiding this comment

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

so, needs to be configurable? and then RN would turn off the Android one?
or sync up a release and RN stops doing its own?

Copy link
Member

Choose a reason for hiding this comment

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

yes, we have something similar for flutter already
today the way how it works is react native manages the session id and update the session id on android when its rotated.
now we want to do the other way around, from android to call the react native but you cant do this because the bridge only works in one direction, so its better to let the react native manage the whole session id thing otherwise they will be out of sync

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)
Comment on lines +490 to +491
Copy link
Member

Choose a reason for hiding this comment

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

this changes the session id but the session replay would need to be notified to eg send a new full snapshot with metadata etc otherwise it'll assume its already sent and the replay will be broken (missing meta event and full snapshot for the new session id)

Copy link
Member Author

Choose a reason for hiding this comment

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

🤔 in the JS SDK we have an event emitter that we pass around that can be used for this kind of "onBlah" behavior

I guess the sessionManager can have a onSessionStarted that accepts a callback?

Is there a more android-y way of doing it?

Copy link
Member

Choose a reason for hiding this comment

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

yeah a callback would be one way, since PostHogSessionManager lives in the java-only lib and most of the replay code is Android-dependent, we will need to pass callbacks around, or implement a pub/sub approach for "app is in background, app is in foreground, etc" so the java bits only can do things

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

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

can it be private instead?
if needed you can put it inside a companion object


/**
* Class that manages the Session ID
*/
Expand All @@ -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())
Copy link
Member Author

Choose a reason for hiding this comment

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

I passed the time in here...
since we have a date provider really we should pass it around everywhere

but I guess it's only for tests so I suppose this is OK

Copy link
Member

Choose a reason for hiding this comment

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

thats fine, another approach would be to make a TimeBasedEpochGenerator an interface, and the current TimeBasedEpochGenerator is the production implementation and we could have a test implementation and swap during SDK initialization just for testing, the prod code would be cleaner but we would pay the price of an extra interface

}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
67 changes: 67 additions & 0 deletions posthog/src/test/java/com/posthog/PostHogTest.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -65,6 +70,7 @@ internal class PostHogTest {
this.cachePreferences = cachePreferences
this.propertiesSanitizer = propertiesSanitizer
this.remoteConfig = remoteConfig
this.dateProvider = dateProvider
}
return PostHog.withInternal(
config,
Expand Down Expand Up @@ -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<PostHogBatchEvent>(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<PostHogBatchEvent>(content.reader())

theEvent = batch.batch.first()
val newSessionId = theEvent.properties!!["\$session_id"]
assertNotNull(newSessionId)

assertTrue(currentSessionId != newSessionId)

sut.close()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ internal class FakePostHogDateProvider : PostHogDateProvider {
}

override fun currentTimeMillis(): Long {
return System.currentTimeMillis()
return currentDate?.time ?: System.currentTimeMillis()
Copy link
Member Author

Choose a reason for hiding this comment

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

this threw me 🤣

}

override fun nanoTime(): Long {
return System.nanoTime()
return currentDate?.time?.times(1000000) ?: System.nanoTime()
Copy link
Member Author

Choose a reason for hiding this comment

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

so I updated here too

}
}
23 changes: 23 additions & 0 deletions posthog/src/test/java/com/posthog/vendor/uuid/UUIDTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading