Skip to content
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

Feature/189/session replay min duration #199

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
Draft
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
2 changes: 2 additions & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@ val config = PostHogAndroidConfig(apiKey).apply {
sessionReplayConfig.screenshot = false
// debouncerDelayMs is 500ms by default
sessionReplayConfig.debouncerDelayMs = 1000
// minSessionDurationMs is 0ms by default
sessionReplayConfig.minSessionDurationMs = 2000
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import com.posthog.android.internal.screenSize
import com.posthog.android.replay.internal.NextDrawListener.Companion.onNextDraw
import com.posthog.android.replay.internal.ViewTreeSnapshotStatus
import com.posthog.android.replay.internal.isAliveAndAttachedToWindow
import com.posthog.internal.PostHogSessionManager
import com.posthog.internal.PostHogThreadFactory
import com.posthog.internal.replay.RRCustomEvent
import com.posthog.internal.replay.RREvent
Expand All @@ -82,6 +83,7 @@ import curtains.touchEventInterceptors
import curtains.windowAttachCount
import java.io.ByteArrayOutputStream
import java.lang.ref.WeakReference
import java.util.UUID
import java.util.WeakHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
Expand Down Expand Up @@ -118,6 +120,15 @@ public class PostHogReplayIntegration(
private val isSessionReplayEnabled: Boolean
get() = PostHog.isSessionReplayActive()

private val sessionStartTimes = mutableMapOf<UUID?, Long?>()
private val events = mutableMapOf<UUID?, MutableList<RREvent>>()
private val mouseInteractions =
mutableMapOf<UUID?, MutableList<RRIncrementalMouseInteractionEvent>>()
private val minSessionDuration by lazy {
config.minReplaySessionDurationMs
?: config.sessionReplayConfig.minSessionDurationMs ?: 0
}

private fun addView(
view: View,
added: Boolean = true,
Expand Down Expand Up @@ -148,7 +159,11 @@ public class PostHogReplayIntegration(

executor.submit {
try {
generateSnapshot(WeakReference(decorView), WeakReference(window), timestamp)
generateSnapshot(
karntrehan marked this conversation as resolved.
Show resolved Hide resolved
WeakReference(decorView),
WeakReference(window),
timestamp
)
} catch (e: Throwable) {
config.logger.log("Session Replay generateSnapshot failed: $e.")
}
Expand Down Expand Up @@ -229,10 +244,19 @@ public class PostHogReplayIntegration(
}
when (motionEvent.action.and(MotionEvent.ACTION_MASK)) {
MotionEvent.ACTION_DOWN -> {
generateMouseInteractions(timestamp, motionEvent, RRMouseInteraction.TouchStart)
generateMouseInteractions(
timestamp,
motionEvent,
RRMouseInteraction.TouchStart
)
}

MotionEvent.ACTION_UP -> {
generateMouseInteractions(timestamp, motionEvent, RRMouseInteraction.TouchEnd)
generateMouseInteractions(
timestamp,
motionEvent,
RRMouseInteraction.TouchEnd
)
}
}
} catch (e: Throwable) {
Expand All @@ -251,7 +275,7 @@ public class PostHogReplayIntegration(
motionEvent: MotionEvent,
type: RRMouseInteraction,
) {
val mouseInteractions = mutableListOf<RRIncrementalMouseInteractionEvent>()
val currentSessionId = PostHogSessionManager.getActiveSessionId()
for (index in 0 until motionEvent.pointerCount) {
// if the id is 0, BE transformer will set it to the virtual bodyId
val id = motionEvent.getPointerId(index)
Expand All @@ -265,23 +289,53 @@ public class PostHogReplayIntegration(
x = absX,
y = absY,
)
val mouseInteraction = RRIncrementalMouseInteractionEvent(mouseInteractionData, timestamp)
mouseInteractions.add(mouseInteraction)
val mouseInteraction =
RRIncrementalMouseInteractionEvent(mouseInteractionData, timestamp)
handleSessionStarts(currentSessionId)
insertMouseInteractions(currentSessionId, mouseInteraction)
}

if (mouseInteractions.isNotEmpty()) {
// TODO: we can probably batch those
// if we batch them, we need to be aware that the order of the events matters
// also because if we send a mouse interaction later, it might be attached to the wrong
// screen
mouseInteractions.capture()
tryFlushMouseInteractions()
}

private fun handleSessionStarts(currentSessionId: UUID?) {
config.logger.log("sessionstarts: $currentSessionId")
if (sessionStartTimes.containsKey(currentSessionId)) return

config.logger.log("Session added: $currentSessionId")
sessionStartTimes[currentSessionId] =
PostHogSessionManager.getActiveSessionStartTime()
}

private fun insertMouseInteractions(
currentSessionId: UUID?,
mouseInteraction: RRIncrementalMouseInteractionEvent
) {
config.logger.log("session: insertMouseInteraction")
val currentSessionMouseInteractions =
mouseInteractions[currentSessionId] ?: mutableListOf()
currentSessionMouseInteractions.add(mouseInteraction)
mouseInteractions[currentSessionId] = currentSessionMouseInteractions
}

private fun tryFlushMouseInteractions() {
mouseInteractions.forEach {
config.logger.log("session: tryFlushMouseInteractions:" + it.key + ": " + it.value)
val sessionId = it.key
val sessionIdStartTime = sessionStartTimes[sessionId] ?: 0
if (System.currentTimeMillis() - sessionIdStartTime >= minSessionDuration) {
config.logger.log("Session replay mouse events captured: " + it.value.size)
it.value.capture()
mouseInteractions.remove(sessionId)
}
}
}

private fun cleanSessionState(
view: View,
status: ViewTreeSnapshotStatus,
) {
config.logger.log("cleanSessionState")
if (view.isAliveAndAttachedToWindow()) {
mainHandler.handler.post {
// 2nd check to avoid:
Expand All @@ -303,6 +357,9 @@ public class PostHogReplayIntegration(
}

decorViews.remove(view)

tryFlushEvents()
tryFlushMouseInteractions()
}

override fun install() {
Expand All @@ -312,6 +369,7 @@ public class PostHogReplayIntegration(

// workaround for react native that is started after the window is added
// Curtains.rootViews should be empty for normal apps yet

Curtains.rootViews.forEach { view ->
addView(view)
}
Expand Down Expand Up @@ -373,10 +431,12 @@ public class PostHogReplayIntegration(
}
}

val events = mutableListOf<RREvent>()
val currentSessionId = PostHogSessionManager.getActiveSessionId()
handleSessionStarts(currentSessionId)

if (!status.sentMetaEvent) {
val title = view.phoneWindow?.attributes?.title?.toString()?.substringAfter("/") ?: ""
val title =
view.phoneWindow?.attributes?.title?.toString()?.substringAfter("/") ?: ""
// TODO: cache and compare, if size changes, we send a ViewportResize event

val screenSizeInfo = view.context.screenSize() ?: return
Expand All @@ -388,7 +448,7 @@ public class PostHogReplayIntegration(
height = screenSizeInfo.height,
timestamp = timestamp,
)
events.add(metaEvent)
insertEvent(currentSessionId, metaEvent)
status.sentMetaEvent = true
}

Expand All @@ -400,7 +460,7 @@ public class PostHogReplayIntegration(
initialOffsetLeft = 0,
timestamp = timestamp,
)
events.add(event)
insertEvent(currentSessionId, event)
status.sentFullSnapshot = true
} else {
val lastSnapshot = status.lastSnapshot
Expand Down Expand Up @@ -442,24 +502,46 @@ public class PostHogReplayIntegration(
mutationData = incrementalMutationData,
timestamp = timestamp,
)
events.add(incrementalSnapshotEvent)
insertEvent(currentSessionId, incrementalSnapshotEvent)
}
}

// detect keyboard visibility
val (visible, event) = detectKeyboardVisibility(view, status.keyboardVisible)
status.keyboardVisible = visible
event?.let {
events.add(it)
insertEvent(currentSessionId, it)
}

if (events.isNotEmpty()) {
events.capture()
}
tryFlushEvents()

status.lastSnapshot = wireframe
}

private fun insertEvent(
currentSessionId: UUID?,
event: RREvent
) {
config.logger.log("session: insertEvent: $currentSessionId")
val currentSessionEvents =
events[currentSessionId] ?: mutableListOf()
currentSessionEvents.add(event)
events[currentSessionId] = currentSessionEvents
}

private fun tryFlushEvents() {
events.forEach {
config.logger.log("session: tryFlushEvents:" + it.key + ": " + it.value)
val sessionId = it.key
val sessionIdStartTime = sessionStartTimes[sessionId] ?: 0
if (System.currentTimeMillis() - sessionIdStartTime >= minSessionDuration) {
config.logger.log("Session replay mouse events captured: " + it.value.size)
it.value.capture()
events.remove(sessionId)
}
}
}

private fun View.isVisible(): Boolean {
// TODO: also check for getGlobalVisibleRect intersects the display
val visible = isShown && width >= 0 && height >= 0 && this !is ViewStub
Expand Down Expand Up @@ -742,14 +824,17 @@ public class PostHogReplayIntegration(
style.verticalAlign = "center"
style.horizontalAlign = "center"
}

View.TEXT_ALIGNMENT_TEXT_END, View.TEXT_ALIGNMENT_VIEW_END -> {
style.verticalAlign = "center"
style.horizontalAlign = "right"
}

View.TEXT_ALIGNMENT_TEXT_START, View.TEXT_ALIGNMENT_VIEW_START -> {
style.verticalAlign = "center"
style.horizontalAlign = "left"
}

View.TEXT_ALIGNMENT_GRAVITY -> {
val horizontalAlignment =
when (view.gravity.and(Gravity.HORIZONTAL_GRAVITY_MASK)) {
Expand All @@ -769,6 +854,7 @@ public class PostHogReplayIntegration(
}
style.verticalAlign = verticalAlignment
}

else -> {
style.verticalAlign = "center"
style.horizontalAlign = "left"
Expand All @@ -792,7 +878,8 @@ public class PostHogReplayIntegration(
// Do not set padding if the text is centered, otherwise the padding will be off
if (style.verticalAlign != "center") {
style.paddingTop = view.totalPaddingTop.densityValue(displayMetrics.density)
style.paddingBottom = view.totalPaddingBottom.densityValue(displayMetrics.density)
style.paddingBottom =
view.totalPaddingBottom.densityValue(displayMetrics.density)
}
if (style.horizontalAlign != "center") {
style.paddingLeft = view.totalPaddingLeft.densityValue(displayMetrics.density)
Expand Down Expand Up @@ -1156,7 +1243,8 @@ public class PostHogReplayIntegration(
}

private fun View.isNoCapture(maskInput: Boolean = false): Boolean {
return maskInput || (tag as? String)?.lowercase()?.contains(PH_NO_CAPTURE_LABEL) == true ||
return maskInput || (tag as? String)?.lowercase()
?.contains(PH_NO_CAPTURE_LABEL) == true ||
contentDescription?.toString()?.lowercase()?.contains(PH_NO_CAPTURE_LABEL) == true
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,11 @@ public class PostHogSessionReplayConfig
*/
@PostHogExperimental
public var debouncerDelayMs: Long = 500,
/**
* Define the minimum duration for sessions to be recorded.
* This is useful if you want to exclude sessions that are too short to be useful.
* Defaults to null
*/
@PostHogExperimental
public var minSessionDurationMs: Long? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class MyApp : Application() {
sessionReplayConfig.maskAllImages = false
sessionReplayConfig.captureLogcat = true
sessionReplayConfig.screenshot = true
sessionReplayConfig.minSessionDurationMs = 5000
}
PostHogAndroid.setup(this, config)
}
Expand Down
3 changes: 2 additions & 1 deletion posthog/src/main/java/com/posthog/PostHog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -853,7 +853,8 @@ public class PostHog private constructor(
// this is used in cases where we know the session is already active
// so we spare another locker
private fun isSessionReplayFlagActive(): Boolean {
return config?.sessionReplay == true && featureFlags?.isSessionReplayFlagActive() == true
//FIXME -> Remove this true hardcoding
return true || config?.sessionReplay == true && featureFlags?.isSessionReplayFlagActive() == true
Comment on lines +856 to +857
Copy link
Member

Choose a reason for hiding this comment

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

rollback

}

override fun isSessionReplayActive(): Boolean {
Expand Down
3 changes: 3 additions & 0 deletions posthog/src/main/java/com/posthog/PostHogConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ public open class PostHogConfig(
@PostHogInternal
public var snapshotEndpoint: String = "/s/"

@PostHogInternal
public var minReplaySessionDurationMs: Long? = null

@PostHogInternal
public var dateProvider: PostHogDateProvider = PostHogDeviceDateProvider()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ internal class PostHogFeatureFlags(
config.snapshotEndpoint = it["endpoint"] as? String
?: config.snapshotEndpoint

config.minReplaySessionDurationMs = (it["minimumDurationMilliseconds"] as? Number)?.toLong() ?: config.minReplaySessionDurationMs

sessionReplayFlagActive = isRecordingActive(this.featureFlags ?: mapOf(), it)
config.cachePreferences?.setValue(SESSION_REPLAY, it)

Expand Down Expand Up @@ -178,6 +180,8 @@ internal class PostHogFeatureFlags(

config.snapshotEndpoint = sessionRecording["endpoint"] as? String
?: config.snapshotEndpoint

config.minReplaySessionDurationMs = (sessionRecording["minimumDurationMilliseconds"] as? Number)?.toLong() ?: config.minReplaySessionDurationMs
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,21 @@ public object PostHogSessionManager {
private val sessionIdNone = UUID(0, 0)

private var sessionId = sessionIdNone
private var sessionStartTime: Long = 0

public fun startSession() {
synchronized(sessionLock) {
if (sessionId == sessionIdNone) {
sessionId = TimeBasedEpochGenerator.generate()
sessionStartTime = System.currentTimeMillis()
}
}
}

public fun endSession() {
synchronized(sessionLock) {
sessionId = sessionIdNone
sessionStartTime = 0
}
}

Expand All @@ -38,9 +41,18 @@ public object PostHogSessionManager {
return tempSessionId
}

public fun getActiveSessionStartTime(): Long? {
var tempSessionStartTime: Long?
synchronized(sessionLock) {
tempSessionStartTime = if (sessionStartTime != 0L) sessionStartTime else null
}
return tempSessionStartTime
}

public fun setSessionId(sessionId: UUID) {
synchronized(sessionLock) {
this.sessionId = sessionId
this.sessionStartTime = System.currentTimeMillis()
}
}

Expand Down