diff --git a/.changeset/brave-windows-replay.md b/.changeset/brave-windows-replay.md new file mode 100644 index 000000000..3d1448b15 --- /dev/null +++ b/.changeset/brave-windows-replay.md @@ -0,0 +1,6 @@ +--- +"posthog": patch +"posthog-android": patch +--- + +Assign stable session replay window IDs to Android decor views so dialogs do not replace activity wireframes during playback. diff --git a/posthog-android/api/posthog-android.api b/posthog-android/api/posthog-android.api index 80883e1e6..8fbd89193 100644 --- a/posthog-android/api/posthog-android.api +++ b/posthog-android/api/posthog-android.api @@ -85,6 +85,7 @@ public final class com/posthog/android/replay/PostHogReplayIntegration : com/pos public static final field PH_NO_MASK_LABEL Ljava/lang/String; public fun (Landroid/content/Context;Lcom/posthog/android/PostHogAndroidConfig;Lcom/posthog/android/internal/MainHandler;)V public final fun captureSessionReplaySnapshot (Landroid/view/View;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Z + public fun getCurrentWindowId ()Ljava/lang/String; public fun install (Lcom/posthog/PostHogInterface;)V public fun isActive ()Z public fun onEvent (Ljava/lang/String;Ljava/util/Map;)V diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt index 1ebc1d138..c17fab9ee 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt @@ -58,6 +58,7 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import com.posthog.PostHogIntegration import com.posthog.PostHogInterface +import com.posthog.PostHogInternal import com.posthog.android.PostHogAndroidConfig import com.posthog.android.internal.MainHandler import com.posthog.android.internal.densityValue @@ -92,6 +93,8 @@ import com.posthog.internal.replay.RRRemovedNode import com.posthog.internal.replay.RRStyle import com.posthog.internal.replay.RRWireframe import com.posthog.internal.replay.capture +import com.posthog.internal.replay.captureInWindow +import com.posthog.vendor.uuid.TimeBasedEpochGenerator import curtains.Curtains import curtains.OnRootViewsChangedListener import curtains.TouchEventInterceptor @@ -203,6 +206,13 @@ public class PostHogReplayIntegration( @Volatile private var replaySessionId: String? = null + // Acquire decorViews before this lock when an operation needs both. + private val foregroundWindowsLock = Any() + private val foregroundWindowIds = mutableListOf() + + @Volatile + private var foregroundWindowId: String? = null + // Minimum duration buffering state private val bufferingLock = Any() @@ -303,69 +313,139 @@ public class PostHogReplayIntegration( } } + private fun markWindowForeground(windowId: String) { + synchronized(foregroundWindowsLock) { + foregroundWindowIds.remove(windowId) + foregroundWindowIds.add(windowId) + foregroundWindowId = windowId + } + } + + private fun forgetWindow(windowId: String) { + synchronized(foregroundWindowsLock) { + foregroundWindowIds.remove(windowId) + if (foregroundWindowId == windowId) { + foregroundWindowId = foregroundWindowIds.lastOrNull() + } + } + } + + private fun clearForegroundWindows() { + synchronized(foregroundWindowsLock) { + foregroundWindowIds.clear() + foregroundWindowId = null + } + } + + private fun updateWindowFocus( + decorView: View, + windowId: String, + hasFocus: Boolean, + ) { + synchronized(decorViews) { + if (decorViews[decorView]?.windowId != windowId) { + return + } + if (hasFocus) { + markWindowForeground(windowId) + } else { + forgetWindow(windowId) + } + } + } + + private fun findTrackedDecorView(view: View): Pair? { + val window = view.phoneWindow + val candidates = listOfNotNull(view, view.rootView, window?.peekDecorView()) + return synchronized(decorViews) { + candidates.firstNotNullOfOrNull { decorView -> + decorViews[decorView]?.let { decorView to it } + } ?: window?.let { target -> + decorViews.entries.firstOrNull { it.value.windowRef?.get() === target }?.toPair() + } + } + } + private fun addView( view: View, added: Boolean = true, ) { try { - view.phoneWindow?.let { window -> - var hasDecorView = false - - // react native already has the window attached - // so we check if the decor view exists otherwise we need the onDecorViewReady anyways - window.peekDecorView()?.let { decorView -> - hasDecorView = decorViews[decorView] != null + if (!added) { + findTrackedDecorView(view)?.let { (decorView, status) -> + clearViewListeners(decorView, status) } - if (added) { - if (view.windowAttachCount == 0 || !hasDecorView) { - window.onDecorViewReady { decorView -> - try { - // Captured by the listeners directly so no draw can be missed - // before the decorViews map insertion. - val drawState = WindowDrawState() - val listener = - decorView.onNextDraw( - mainHandler, - config.dateProvider, - config.sessionReplayConfig.throttleDelayMs, - { onDrawCallback(decorView, drawState) }, - ) { - if (!isActive() || !isNativeSdk) { - return@onNextDraw - } - - executor.submit { - try { - generateSnapshot(WeakReference(decorView), WeakReference(window)) - } catch (e: Throwable) { - config.logger.log("Session Replay generateSnapshot failed: $e.") - } - } - } + return + } + + val window = view.phoneWindow ?: return + val hasDecorView = window.peekDecorView()?.let { decorViews[it] != null } == true + + // React Native can attach the window before replay is installed. In that case we still + // need onDecorViewReady when its decor view has not been registered yet. + if (view.windowAttachCount != 0 && hasDecorView) { + config.logger.log("Session Replay already has onDecorViewReady.") + return + } - val layoutListener = - ViewTreeObserver.OnGlobalLayoutListener { drawState.recordLayout() } - decorView.viewTreeObserver?.addOnGlobalLayoutListener(layoutListener) + window.onDecorViewReady { decorView -> + try { + if (decorViews[decorView] != null) { + return@onDecorViewReady + } - val status = ViewTreeSnapshotStatus(listener, layoutListener, drawState = drawState) - decorViews[decorView] = status - } catch (e: Throwable) { - config.logger.log("Session Replay onDecorViewReady failed: $e.") + val windowId = TimeBasedEpochGenerator.generate().toString() + val touchEventInterceptor = createTouchEventListener(windowId) + // Captured by the listeners directly so no draw can be missed before the + // decorViews map insertion. + val drawState = WindowDrawState() + val listener = + decorView.onNextDraw( + mainHandler, + config.dateProvider, + config.sessionReplayConfig.throttleDelayMs, + { onDrawCallback(decorView, drawState) }, + ) { + if (!isActive() || !isNativeSdk) { + return@onNextDraw + } + + executor.submit { + try { + generateSnapshot(WeakReference(decorView), WeakReference(window)) + } catch (e: Throwable) { + config.logger.log("Session Replay generateSnapshot failed: $e.") + } } } - window.touchEventInterceptors += onTouchEventListener - // TODO: can check if user pressed hardware back button (KEYCODE_BACK) - // window.keyEventInterceptors - } else { - config.logger.log("Session Replay already has onDecorViewReady.") - } - } else { - window.peekDecorView()?.let { decorView -> - decorViews[decorView]?.let { status -> - clearViewListeners(decorView, status) + val layoutListener = ViewTreeObserver.OnGlobalLayoutListener { drawState.recordLayout() } + val windowFocusListener = + ViewTreeObserver.OnWindowFocusChangeListener { hasFocus -> + updateWindowFocus(decorView, windowId, hasFocus) } + decorView.viewTreeObserver?.apply { + addOnGlobalLayoutListener(layoutListener) + addOnWindowFocusChangeListener(windowFocusListener) + } + + val status = + ViewTreeSnapshotStatus( + listener, + layoutListener, + drawState = drawState, + windowId = windowId, + touchEventInterceptor = touchEventInterceptor, + windowFocusListener = windowFocusListener, + windowRef = WeakReference(window), + ) + decorViews[decorView] = status + window.touchEventInterceptors += touchEventInterceptor + if (decorView.hasWindowFocus()) { + updateWindowFocus(decorView, windowId, hasFocus = true) } + } catch (e: Throwable) { + config.logger.log("Session Replay onDecorViewReady failed: $e.") } } } catch (e: Throwable) { @@ -407,9 +487,12 @@ public class PostHogReplayIntegration( return Pair(imeVisible, event) } - internal val onTouchEventListener = + internal fun createTouchEventListener(windowId: String): TouchEventInterceptor = TouchEventInterceptor { motionEvent, dispatch -> try { + if (isActive()) { + markWindowForeground(windowId) + } val state = dispatch(motionEvent) try { if (!isActive()) { @@ -425,12 +508,23 @@ public class PostHogReplayIntegration( if (!isActive()) { return@submit } + val replayWindowId = windowId.takeIf { isNativeSdk } when (safeMotionEvent.action.and(MotionEvent.ACTION_MASK)) { MotionEvent.ACTION_DOWN -> { - generateMouseInteractions(timestamp, safeMotionEvent, RRMouseInteraction.TouchStart) + generateMouseInteractions( + timestamp, + safeMotionEvent, + RRMouseInteraction.TouchStart, + replayWindowId, + ) } MotionEvent.ACTION_UP -> { - generateMouseInteractions(timestamp, safeMotionEvent, RRMouseInteraction.TouchEnd) + generateMouseInteractions( + timestamp, + safeMotionEvent, + RRMouseInteraction.TouchEnd, + replayWindowId, + ) } } } catch (e: Throwable) { @@ -453,6 +547,7 @@ public class PostHogReplayIntegration( timestamp: Long, motionEvent: MotionEvent, type: RRMouseInteraction, + windowId: String?, ) { val mouseInteractions = mutableListOf() for (index in 0 until motionEvent.pointerCount) { @@ -481,7 +576,8 @@ public class PostHogReplayIntegration( // 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(postHog) + windowId?.let { mouseInteractions.captureInWindow(it, postHog) } + ?: mouseInteractions.capture(postHog) } } @@ -497,6 +593,11 @@ public class PostHogReplayIntegration( view: View, status: ViewTreeSnapshotStatus, ) { + synchronized(decorViews) { + decorViews.remove(view) + forgetWindow(status.windowId) + } + if (view.isAliveAndAttachedToWindow()) { mainHandler.handler.post { // 2nd check to avoid: @@ -505,8 +606,11 @@ public class PostHogReplayIntegration( if (view.isAliveAndAttachedToWindow()) { try { // swallow the exception because we still wanna remove it from the decorViews - view.viewTreeObserver?.removeOnDrawListener(status.listener) - status.layoutListener?.let { view.viewTreeObserver?.removeOnGlobalLayoutListener(it) } + view.viewTreeObserver?.apply { + removeOnDrawListener(status.listener) + status.layoutListener?.let { removeOnGlobalLayoutListener(it) } + status.windowFocusListener?.let { removeOnWindowFocusChangeListener(it) } + } } catch (e: Throwable) { config.logger.log("Removing the viewTreeObserver failed: $e.") } @@ -514,11 +618,9 @@ public class PostHogReplayIntegration( } } - view.phoneWindow?.let { window -> - window.touchEventInterceptors -= onTouchEventListener + status.windowRef?.get()?.let { window -> + status.touchEventInterceptor?.let { window.touchEventInterceptors -= it } } - - decorViews.remove(view) } @Synchronized @@ -592,6 +694,7 @@ public class PostHogReplayIntegration( } catch (e: Throwable) { config.logger.log("Session Replay uninstall failed: $e.") } finally { + clearForegroundWindows() ownsInstallation = false integrationInstalled.set(false) } @@ -826,7 +929,7 @@ public class PostHogReplayIntegration( } if (events.isNotEmpty()) { - events.capture(postHog) + events.captureInWindow(status.windowId, postHog) } status.lastSnapshot = wireframe @@ -2304,6 +2407,11 @@ public class PostHogReplayIntegration( return isSessionReplayActive } + @PostHogInternal + override fun getCurrentWindowId(): String? { + return foregroundWindowId.takeIf { isNativeSdk } + } + /** * Called when an event is captured. Checks if the event matches any configured triggers * and starts session recording if so. diff --git a/posthog-android/src/main/java/com/posthog/android/replay/internal/PostHogLogCatIntegration.kt b/posthog-android/src/main/java/com/posthog/android/replay/internal/PostHogLogCatIntegration.kt index e894f030f..cb4f3a1c6 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/internal/PostHogLogCatIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/internal/PostHogLogCatIntegration.kt @@ -5,8 +5,10 @@ import com.posthog.PostHogInterface import com.posthog.PostHogVisibleForTesting import com.posthog.android.PostHogAndroidConfig import com.posthog.internal.interruptSafely +import com.posthog.internal.replay.PostHogSessionReplayHandler import com.posthog.internal.replay.RRPluginEvent import com.posthog.internal.replay.capture +import com.posthog.internal.replay.captureInWindow import java.util.concurrent.atomic.AtomicBoolean internal class PostHogLogCatIntegration(private val config: PostHogAndroidConfig) : PostHogIntegration { @@ -18,6 +20,13 @@ internal class PostHogLogCatIntegration(private val config: PostHogAndroidConfig private val isSessionReplayActive: Boolean get() = postHog?.isSessionReplayActive() ?: false + private val currentReplayWindowId: String? + get() = + config.integrations + .filterIsInstance() + .firstOrNull() + ?.getCurrentWindowId() + private var postHog: PostHogInterface? = null private var ownsInstallation = false @@ -73,9 +82,8 @@ internal class PostHogLogCatIntegration(private val config: PostHogAndroidConfig val content = log.text?.trim() ?: "" props["payload"] = listOf("$tag: $content") val time = log.time?.time?.time ?: config.dateProvider.currentTimeMillis() - val event = RRPluginEvent("rrweb/console@1", props, time) // TODO: batch events - listOf(event).capture(postHog) + captureEvent(RRPluginEvent("rrweb/console@1", props, time)) } } catch (e: Throwable) { // ignore @@ -91,6 +99,14 @@ internal class PostHogLogCatIntegration(private val config: PostHogAndroidConfig logcatThread?.start() } + @PostHogVisibleForTesting + internal fun captureEvent(event: RRPluginEvent) { + val events = listOf(event) + currentReplayWindowId?.let { windowId -> + events.captureInWindow(windowId, postHog) + } ?: events.capture(postHog) + } + override fun onRemoteConfig(loaded: Boolean) { // Only react to a live config; a failed attempt applies no fresh values. if (!loaded) { diff --git a/posthog-android/src/main/java/com/posthog/android/replay/internal/ViewTreeSnapshotStatus.kt b/posthog-android/src/main/java/com/posthog/android/replay/internal/ViewTreeSnapshotStatus.kt index c320dc8d1..090cfb35b 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/internal/ViewTreeSnapshotStatus.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/internal/ViewTreeSnapshotStatus.kt @@ -2,14 +2,19 @@ package com.posthog.android.replay.internal import android.graphics.Rect import android.view.ViewTreeObserver +import android.view.Window import com.posthog.internal.replay.RRWireframe +import com.posthog.vendor.uuid.TimeBasedEpochGenerator +import curtains.TouchEventInterceptor +import java.lang.ref.WeakReference import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger private const val CONSECUTIVE_DISCARD_WARNING_THRESHOLD: Int = 3 private const val COMPOSE_ROOT_RECHECK_INTERVAL_NANOS: Long = 1_000_000_000 -// if you add any new property, remember to clear the state from resetViewSnapshotStates +// Snapshot fields are cleared by resetViewSnapshotStates. Window identity and listener ownership +// last for the lifetime of the tracked decor view. internal class ViewTreeSnapshotStatus( val listener: NextDrawListener, val layoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null, @@ -18,6 +23,10 @@ internal class ViewTreeSnapshotStatus( var keyboardVisible: Boolean = false, var lastSnapshot: RRWireframe? = null, val drawState: WindowDrawState = WindowDrawState(), + val windowId: String = TimeBasedEpochGenerator.generate().toString(), + val touchEventInterceptor: TouchEventInterceptor? = null, + val windowFocusListener: ViewTreeObserver.OnWindowFocusChangeListener? = null, + val windowRef: WeakReference? = null, ) internal data class MaskCaptureToken( diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidEventSnapshotsTest.kt b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidEventSnapshotsTest.kt index 0442c8b2d..821dab615 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidEventSnapshotsTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidEventSnapshotsTest.kt @@ -130,7 +130,7 @@ internal class PostHogAndroidEventSnapshotsTest { properties = linkedMapOf( "\$session_id" to SESSION_ID.toString(), - "\$window_id" to SESSION_ID.toString(), + "\$window_id" to WINDOW_ID.toString(), "\$snapshot_data" to listOf( linkedMapOf( @@ -394,6 +394,7 @@ internal class PostHogAndroidEventSnapshotsTest { private const val FIXED_MILLIS = 1_700_000_000_123L private val FIXED_DATE = Date(FIXED_MILLIS) private val SESSION_ID = UUID.fromString("018bcfe5-687b-7abc-8def-0123456789ab") + private val WINDOW_ID = UUID.fromString("018bcfe5-687b-7abc-8def-0123456789ac") private const val FIXED_TIMESTAMP = "2023-11-14T22:13:20.123Z" private const val FLAGS_RESPONSE = """{ diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt index c7c7c0fa9..d476df88c 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt @@ -1,6 +1,7 @@ package com.posthog.android.replay import android.app.Activity +import android.app.AlertDialog import android.content.Context import android.graphics.Bitmap import android.graphics.Point @@ -41,6 +42,7 @@ import com.posthog.internal.PostHogQueueInterface import com.posthog.internal.PostHogRemoteConfig import com.posthog.internal.PostHogSessionManager import curtains.DispatchState +import curtains.touchEventInterceptors import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith @@ -78,6 +80,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @@ -258,7 +261,7 @@ internal class PostHogReplayIntegrationTest { assertFalse(sut.isActive()) val event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, 0f, 0f, 0) - val state = sut.onTouchEventListener.intercept(event) { DispatchState.Consumed } + val state = sut.createTouchEventListener("test-window").intercept(event) { DispatchState.Consumed } event.recycle() awaitReplayExecutors() @@ -287,7 +290,7 @@ internal class PostHogReplayIntegrationTest { assertTrue(sut.isActive()) val event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, 0f, 0f, 0) - val state = sut.onTouchEventListener.intercept(event) { DispatchState.Consumed } + val state = sut.createTouchEventListener("test-window").intercept(event) { DispatchState.Consumed } event.recycle() awaitReplayExecutors() @@ -299,6 +302,116 @@ internal class PostHogReplayIntegrationTest { } } + @Test + fun `touch replay uses the owning window id`() { + val config = configWithSampling(flagActive = true, samplingPasses = true) + val sut = + PostHogReplayIntegration( + ApplicationProvider.getApplicationContext(), + config, + MainHandler(), + createReplayExecutor(), + ) + val fake = createPostHogFake() + fake.sessionReplayActive = false + sut.install(fake) + try { + PostHogSessionManager.startSession() + sut.onSessionIdChanged() + shadowOf(Looper.getMainLooper()).idle() + + val activityTouch = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, 1f, 1f, 0) + sut.createTouchEventListener("activity-window").intercept(activityTouch) { DispatchState.Consumed } + activityTouch.recycle() + awaitReplayExecutors() + assertEquals("activity-window", fake.properties?.get("\$window_id")) + assertEquals("activity-window", sut.getCurrentWindowId()) + + val dialogTouch = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, 2f, 2f, 0) + sut.createTouchEventListener("dialog-window").intercept(dialogTouch) { DispatchState.Consumed } + dialogTouch.recycle() + awaitReplayExecutors() + assertEquals("dialog-window", fake.properties?.get("\$window_id")) + assertEquals("dialog-window", sut.getCurrentWindowId()) + } finally { + sut.uninstall() + } + } + + @Test + fun `window removal during touch dispatch wins foreground routing`() { + val appContext = ApplicationProvider.getApplicationContext() + val config = configWithSampling(flagActive = true, samplingPasses = true) + val sut = PostHogReplayIntegration(appContext, config, MainHandler(), createReplayExecutor()) + val fake = createPostHogFake() + fake.sessionReplayActive = false + sut.install(fake) + try { + PostHogSessionManager.startSession() + sut.onSessionIdChanged() + shadowOf(Looper.getMainLooper()).idle() + + val activityView = View(appContext) + val dialogView = View(appContext) + sut.decorViews[activityView] = + ViewTreeSnapshotStatus(mock(), windowId = "activity-window") + sut.decorViews[dialogView] = + ViewTreeSnapshotStatus(mock(), windowId = "dialog-window") + + val activityTouch = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, 1f, 1f, 0) + sut.createTouchEventListener("activity-window").intercept(activityTouch) { DispatchState.Consumed } + activityTouch.recycle() + awaitReplayExecutors() + + val dialogTouch = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_UP, 2f, 2f, 0) + sut.createTouchEventListener("dialog-window").intercept(dialogTouch) { + notifyRootView(sut, dialogView, added = false) + DispatchState.Consumed + } + dialogTouch.recycle() + awaitReplayExecutors() + + assertEquals("dialog-window", fake.properties?.get("\$window_id")) + assertEquals("activity-window", sut.getCurrentWindowId()) + assertNull(sut.decorViews[dialogView]) + } finally { + sut.uninstall() + } + } + + @Test + fun `flutter touch replay keeps the session window fallback`() { + val config = + configWithSampling(flagActive = true, samplingPasses = true).apply { + sdkName = "posthog-flutter" + } + val sut = + PostHogReplayIntegration( + ApplicationProvider.getApplicationContext(), + config, + MainHandler(), + createReplayExecutor(), + ) + val fake = createPostHogFake() + fake.sessionReplayActive = false + sut.install(fake) + try { + PostHogSessionManager.startSession() + sut.onSessionIdChanged() + shadowOf(Looper.getMainLooper()).idle() + + val touch = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, 1f, 1f, 0) + sut.createTouchEventListener("native-window").intercept(touch) { DispatchState.Consumed } + touch.recycle() + awaitReplayExecutors() + + assertFalse(fake.properties.orEmpty().containsKey("\$window_id")) + assertNull(sut.getCurrentWindowId()) + } finally { + sut.uninstall() + } + } + @Test fun `onSessionIdChanged starts replay when previously inactive and sampling passes`() { // The prior session may have been sampled out; rotation must re-evaluate sampling and @@ -1362,6 +1475,7 @@ internal class PostHogReplayIntegrationTest { sentFullSnapshot = true, sentMetaEvent = true, ) + val windowId = status.windowId fx.sut.decorViews[view] = status // Flag turns off mid-session -> stop. stop() intentionally does NOT clear per-view state. @@ -1379,6 +1493,7 @@ internal class PostHogReplayIntegrationTest { assertTrue(fx.sut.isActive()) assertFalse(status.sentFullSnapshot) assertFalse(status.sentMetaEvent) + assertEquals(windowId, status.windowId) } finally { fx.sut.uninstall() } @@ -1631,6 +1746,19 @@ internal class PostHogReplayIntegrationTest { // Robolectric never runs the ViewRootImpl traversal that copies the window's // visibility into AttachInfo, so getWindowVisibility() stays GONE and the // integration treats the decor view as invisible. Set it directly. + private fun notifyRootView( + sut: PostHogReplayIntegration, + view: View, + added: Boolean, + ) { + ReflectionHelpers.callInstanceMethod( + sut, + "addView", + ReflectionHelpers.ClassParameter.from(View::class.java, view), + ReflectionHelpers.ClassParameter.from(Boolean::class.javaPrimitiveType, added), + ) + } + private fun makeWindowVisible(decorView: View) { val attachInfo = View::class.java.getDeclaredField("mAttachInfo") @@ -1656,6 +1784,133 @@ internal class PostHogReplayIntegrationTest { return fx to fake } + @Test + fun `activity and dialog keep stable replay windows through focus and removal`() { + val appContext = ApplicationProvider.getApplicationContext() + val fx = + createIntegrationWithRealQueue( + flagActive = true, + hasFetched = true, + integrationContext = appContext, + ) + val fake = PostHogFake() + fx.sut.install(fake) + fx.sut.start(resumeCurrent = true) + try { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + shadowOf(Looper.getMainLooper()).idle() + val activityDecor = activity.window.decorView + notifyRootView(fx.sut, activityDecor, added = true) + shadowOf(Looper.getMainLooper()).idle() + awaitCondition { fx.sut.decorViews[activityDecor] != null } + val activityStatus = assertNotNull(fx.sut.decorViews[activityDecor]) + val activityFocusListener = assertNotNull(activityStatus.windowFocusListener) + activityFocusListener.onWindowFocusChanged(true) + assertTrue(activityStatus.touchEventInterceptor in activity.window.touchEventInterceptors) + + val dialog = AlertDialog.Builder(activity).setTitle("Dialog").setMessage("Content").create() + dialog.show() + shadowOf(Looper.getMainLooper()).idle() + val dialogDecor = dialog.window?.decorView ?: error("Dialog has no decor view") + notifyRootView(fx.sut, dialogDecor, added = true) + shadowOf(Looper.getMainLooper()).idle() + awaitCondition { fx.sut.decorViews[dialogDecor] != null } + val dialogStatus = assertNotNull(fx.sut.decorViews[dialogDecor]) + val dialogFocusListener = assertNotNull(dialogStatus.windowFocusListener) + + assertNotEquals(activityStatus.windowId, dialogStatus.windowId) + activityFocusListener.onWindowFocusChanged(false) + dialogFocusListener.onWindowFocusChanged(true) + assertEquals(dialogStatus.windowId, fx.sut.getCurrentWindowId()) + assertTrue(dialogStatus.touchEventInterceptor in dialog.window?.touchEventInterceptors.orEmpty()) + + // A Dialog can lose focus while it stays tracked, so focus must restore the Activity. + dialogFocusListener.onWindowFocusChanged(false) + activityFocusListener.onWindowFocusChanged(true) + assertEquals(activityStatus.windowId, fx.sut.getCurrentWindowId()) + assertNotNull(fx.sut.decorViews[dialogDecor]) + + dialog.dismiss() + shadowOf(Looper.getMainLooper()).idle() + notifyRootView(fx.sut, dialogDecor, added = false) + shadowOf(Looper.getMainLooper()).idle() + awaitCondition { fx.sut.decorViews[dialogDecor] == null } + + assertEquals(activityStatus.windowId, fx.sut.decorViews[activityDecor]?.windowId) + assertEquals(activityStatus.windowId, fx.sut.getCurrentWindowId()) + assertFalse(dialogStatus.touchEventInterceptor in dialog.window?.touchEventInterceptors.orEmpty()) + + // A focus callback queued before cleanup must not restore a removed window. + dialogFocusListener.onWindowFocusChanged(true) + assertEquals(activityStatus.windowId, fx.sut.getCurrentWindowId()) + } finally { + fx.sut.uninstall() + } + } + + @Test + fun `new decor snapshot states have unique window ids`() { + val first = ViewTreeSnapshotStatus(mock()) + val second = ViewTreeSnapshotStatus(mock()) + + assertTrue(first.windowId.isNotBlank()) + assertTrue(second.windowId.isNotBlank()) + assertNotEquals(first.windowId, second.windowId) + } + + @Test + fun `separate decor snapshot streams retain their window ids`() { + val appContext = ApplicationProvider.getApplicationContext() + val fx = + createIntegrationWithRealQueue( + flagActive = true, + hasFetched = true, + integrationContext = appContext, + ) + fx.config.sessionReplayConfig.screenshot = false + val fake = PostHogFake() + fx.sut.install(fake) + fx.sut.start(resumeCurrent = true) + try { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val activityContent = FrameLayout(activity).apply { addView(TextView(activity).apply { text = "Activity" }) } + activity.setContentView(activityContent) + val activityDecor = activity.window.decorView + + val dialogActivity = Robolectric.buildActivity(Activity::class.java).setup().get() + val dialogContent = FrameLayout(dialogActivity).apply { addView(TextView(dialogActivity).apply { text = "Dialog" }) } + dialogActivity.setContentView(dialogContent) + val dialogDecor = dialogActivity.window.decorView + + shadowOf(Looper.getMainLooper()).idle() + makeWindowVisible(activityDecor) + makeWindowVisible(dialogDecor) + + fx.sut.decorViews[activityDecor] = + ViewTreeSnapshotStatus(mock(), windowId = "activity-window") + fx.sut.decorViews[dialogDecor] = + ViewTreeSnapshotStatus(mock(), windowId = "dialog-window") + + val emittedWindowIds = mutableListOf() + assertTrue(fx.sut.generateSnapshot(WeakReference(activityDecor), WeakReference(activity.window))) + emittedWindowIds.add(fake.properties?.get("\$window_id") as String) + + assertTrue(fx.sut.generateSnapshot(WeakReference(dialogDecor), WeakReference(dialogActivity.window))) + emittedWindowIds.add(fake.properties?.get("\$window_id") as String) + + activityContent.addView(TextView(activity).apply { text = "Added after dialog" }) + shadowOf(Looper.getMainLooper()).idle() + makeWindowVisible(activityDecor) + assertTrue(fx.sut.generateSnapshot(WeakReference(activityDecor), WeakReference(activity.window))) + emittedWindowIds.add(fake.properties?.get("\$window_id") as String) + + assertEquals(listOf("activity-window", "dialog-window", "activity-window"), emittedWindowIds) + assertEquals(3, fake.captures) + } finally { + fx.sut.uninstall() + } + } + @Test fun `null bitmap drawable is not masked`() { val resources = ApplicationProvider.getApplicationContext().resources @@ -1689,6 +1944,32 @@ internal class PostHogReplayIntegrationTest { } } + @Test + @Config(sdk = [26], shadows = [ShadowPixelCopy::class]) + fun `flutter bridge snapshot uses the native decor window id`() { + val (fx, fake) = screenshotFixture() + fx.config.sdkName = "posthog-flutter" + fx.config.sessionReplayConfig.screenshot = false + try { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + shadowOf(Looper.getMainLooper()).idle() + val decorView = activity.window.decorView + makeWindowVisible(decorView) + fx.sut.decorViews[decorView] = + ViewTreeSnapshotStatus(mock(), windowId = "native-window") + + fx.sut.generateSnapshot( + WeakReference(decorView), + WeakReference(activity.window), + forceScreenshot = true, + ) + + assertEquals("native-window", fake.properties?.get("\$window_id")) + } finally { + fx.sut.uninstall() + } + } + @Test @Config(sdk = [26], shadows = [ShadowPixelCopy::class]) fun `screenshot mode emits meta and full snapshot when the capture succeeds`() { diff --git a/posthog-android/src/test/java/com/posthog/android/replay/internal/PostHogLogCatIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/internal/PostHogLogCatIntegrationTest.kt index ce86e11b8..b06ba2b7c 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/internal/PostHogLogCatIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/internal/PostHogLogCatIntegrationTest.kt @@ -1,12 +1,19 @@ package com.posthog.android.replay.internal import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.posthog.PostHogIntegration import com.posthog.PostHogInterface import com.posthog.android.API_KEY import com.posthog.android.PostHogAndroidConfig import com.posthog.internal.PostHogRemoteConfig +import com.posthog.internal.replay.PostHogSessionReplayHandler +import com.posthog.internal.replay.RRPluginEvent import org.junit.runner.RunWith +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config import kotlin.test.BeforeTest @@ -21,6 +28,23 @@ internal class PostHogLogCatIntegrationTest { private val mockPostHog = mock() private val mockRemoteConfig = mock() + private class WindowReplayHandler(var windowId: String?) : PostHogIntegration, PostHogSessionReplayHandler { + override fun start(resumeCurrent: Boolean) = Unit + + override fun stop() = Unit + + override fun isActive(): Boolean = true + + override fun getCurrentWindowId(): String? = windowId + + override fun onEvent( + event: String, + properties: Map?, + ) = Unit + + override fun onSessionIdChanged() = Unit + } + private fun createConfig(captureLogcat: Boolean = true): PostHogAndroidConfig { return PostHogAndroidConfig(API_KEY).apply { sessionReplayConfig.captureLogcat = captureLogcat @@ -145,6 +169,50 @@ internal class PostHogLogCatIntegrationTest { assertFalse(sut.isInstalled()) } + @Test + fun `console replay event uses the foreground window`() { + val config = createConfig(captureLogcat = false) + config.addIntegration(WindowReplayHandler("dialog-window")) + val sut = getSut(config) + sut.install(mockPostHog) + + sut.captureEvent(RRPluginEvent("rrweb/console@1", emptyMap(), 1L)) + + val properties = argumentCaptor>() + verify(mockPostHog).capture( + eq("\$snapshot"), + anyOrNull(), + properties.capture(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + assertEquals("dialog-window", properties.firstValue["\$window_id"]) + } + + @Test + fun `console replay event keeps the session fallback without a foreground window`() { + val config = createConfig(captureLogcat = false) + config.addIntegration(WindowReplayHandler(null)) + val sut = getSut(config) + sut.install(mockPostHog) + + sut.captureEvent(RRPluginEvent("rrweb/console@1", emptyMap(), 1L)) + + val properties = argumentCaptor>() + verify(mockPostHog).capture( + eq("\$snapshot"), + anyOrNull(), + properties.capture(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + assertFalse(properties.firstValue.containsKey("\$window_id")) + } + @Test fun `onRemoteConfig can re-install after being disabled`() { val config = createConfig() diff --git a/posthog-android/src/test/resources/json/snapshots/session-replay-request.json b/posthog-android/src/test/resources/json/snapshots/session-replay-request.json index b56cfc2b5..c6508dea6 100644 --- a/posthog-android/src/test/resources/json/snapshots/session-replay-request.json +++ b/posthog-android/src/test/resources/json/snapshots/session-replay-request.json @@ -15,7 +15,7 @@ "$lib": "posthog-android", "$lib_version": "", "$session_id": "018bcfe5-687b-7abc-8def-0123456789ab", - "$window_id": "018bcfe5-687b-7abc-8def-0123456789ab", + "$window_id": "018bcfe5-687b-7abc-8def-0123456789ac", "$snapshot_data": [ { "type": 2, diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index ec32a74d9..1822a9e74 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -1194,6 +1194,7 @@ public final class com/posthog/internal/errortracking/ThrowableCoercer { } public abstract interface class com/posthog/internal/replay/PostHogSessionReplayHandler { + public abstract fun getCurrentWindowId ()Ljava/lang/String; public abstract fun isActive ()Z public abstract fun onEvent (Ljava/lang/String;Ljava/util/Map;)V public abstract fun onSessionIdChanged ()V @@ -1433,6 +1434,8 @@ public final class com/posthog/internal/replay/RRUtilsKt { public static final fun capture (Ljava/util/List;)V public static final fun capture (Ljava/util/List;Lcom/posthog/PostHogInterface;)V public static synthetic fun capture$default (Ljava/util/List;Lcom/posthog/PostHogInterface;ILjava/lang/Object;)V + public static final fun captureInWindow (Ljava/util/List;Ljava/lang/String;Lcom/posthog/PostHogInterface;)V + public static synthetic fun captureInWindow$default (Ljava/util/List;Ljava/lang/String;Lcom/posthog/PostHogInterface;ILjava/lang/Object;)V } public final class com/posthog/internal/replay/RRWireframe { diff --git a/posthog/src/main/java/com/posthog/PostHogOkHttpInterceptor.kt b/posthog/src/main/java/com/posthog/PostHogOkHttpInterceptor.kt index cc18f81e2..148825296 100644 --- a/posthog/src/main/java/com/posthog/PostHogOkHttpInterceptor.kt +++ b/posthog/src/main/java/com/posthog/PostHogOkHttpInterceptor.kt @@ -1,7 +1,9 @@ package com.posthog +import com.posthog.internal.replay.PostHogSessionReplayHandler import com.posthog.internal.replay.RRPluginEvent import com.posthog.internal.replay.capture +import com.posthog.internal.replay.captureInWindow import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response @@ -30,6 +32,14 @@ public class PostHogOkHttpInterceptor( private val isSessionReplayActive: Boolean get() = postHog?.isSessionReplayActive() ?: PostHog.isSessionReplayActive() + private val currentReplayWindowId: String? + get() = + currentPostHog.getConfig() + ?.integrations + ?.filterIsInstance() + ?.firstOrNull() + ?.getCurrentWindowId() + private val isNetworkCaptureEnabled: Boolean get() { val config = currentPostHog.getConfig() ?: return true @@ -101,8 +111,11 @@ public class PostHogOkHttpInterceptor( val events = listOf(RRPluginEvent("rrweb/network@1", payload, end)) - // its not guaranteed that the posthog instance is set - events.capture(postHog) + // Attribute session-wide telemetry to the foreground window at response emission time. + // This is playback correlation, not the window that initiated the request. + currentReplayWindowId?.let { windowId -> + events.captureInWindow(windowId, postHog) + } ?: events.capture(postHog) } } diff --git a/posthog/src/main/java/com/posthog/internal/replay/PostHogSessionReplayHandler.kt b/posthog/src/main/java/com/posthog/internal/replay/PostHogSessionReplayHandler.kt index 250710c32..163c81ef2 100644 --- a/posthog/src/main/java/com/posthog/internal/replay/PostHogSessionReplayHandler.kt +++ b/posthog/src/main/java/com/posthog/internal/replay/PostHogSessionReplayHandler.kt @@ -10,6 +10,12 @@ public interface PostHogSessionReplayHandler { public fun isActive(): Boolean + /** + * Returns the replay window currently in the foreground, or null when replay events should + * use the session ID fallback. + */ + public fun getCurrentWindowId(): String? + /** * Called when an event is captured. * Used for event trigger matching to start session recording. diff --git a/posthog/src/main/java/com/posthog/internal/replay/RRUtils.kt b/posthog/src/main/java/com/posthog/internal/replay/RRUtils.kt index 5e4055c16..6f31392eb 100644 --- a/posthog/src/main/java/com/posthog/internal/replay/RRUtils.kt +++ b/posthog/src/main/java/com/posthog/internal/replay/RRUtils.kt @@ -8,26 +8,33 @@ import com.posthog.PostHogInternal // used by react native and flutter with the static instance @PostHogInternal public fun List.capture() { - val properties = - mutableMapOf( - "\$snapshot_data" to this, - "\$snapshot_source" to "mobile", - ) - PostHog.capture(PostHogEventName.SNAPSHOT.event, properties = properties) + captureReplayEvents() } @PostHogInternal public fun List.capture(postHog: PostHogInterface? = null) { + captureReplayEvents(postHog) +} + +@PostHogInternal +public fun List.captureInWindow( + windowId: String, + postHog: PostHogInterface? = null, +) { + captureReplayEvents(postHog, windowId.takeIf { it.isNotBlank() }) +} + +private fun List.captureReplayEvents( + postHog: PostHogInterface? = null, + windowId: String? = null, +) { val properties = - mutableMapOf( + mutableMapOf( "\$snapshot_data" to this, "\$snapshot_source" to "mobile", ) + windowId?.let { properties["\$window_id"] = it } // its not guaranteed that the posthog instance is set - if (postHog != null) { - postHog.capture(PostHogEventName.SNAPSHOT.event, properties = properties) - } else { - this.capture() - } + (postHog ?: PostHog).capture(PostHogEventName.SNAPSHOT.event, properties = properties) } diff --git a/posthog/src/test/java/com/posthog/PostHogOkHttpInterceptorTest.kt b/posthog/src/test/java/com/posthog/PostHogOkHttpInterceptorTest.kt index 0c246fce7..e38381d05 100644 --- a/posthog/src/test/java/com/posthog/PostHogOkHttpInterceptorTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogOkHttpInterceptorTest.kt @@ -5,17 +5,25 @@ import com.posthog.internal.PostHogThreadFactory import okhttp3.Dns import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest import org.junit.Rule import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import java.net.InetAddress import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse internal class PostHogOkHttpInterceptorTest { private data class ExpectedHeaders( @@ -105,6 +113,77 @@ internal class PostHogOkHttpInterceptorTest { } } + @Test + fun `network replay event uses the foreground window at response emission`() { + val replayHandler = PostHogSessionReplayHandlerFake(isActive = true).apply { replayWindowId = "activity-window" } + val config = PostHogConfig(API_KEY, "http://localhost").apply { addIntegration(replayHandler) } + val postHog = mock() + whenever(postHog.distinctId()).thenReturn("test-user") + whenever(postHog.isSessionReplayActive()).thenReturn(true) + whenever(postHog.getConfig()).thenReturn(config) + + withServer { server -> + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + replayHandler.replayWindowId = "dialog-window" + return MockResponse().setBody("ok") + } + } + + val client = newClient(postHog, captureNetworkTelemetry = true) + try { + executeRequest(client, server, PRIMARY_HOST) + } finally { + client.shutdown() + } + } + + val properties = argumentCaptor>() + verify(postHog).capture( + eq("\$snapshot"), + anyOrNull(), + properties.capture(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + assertEquals("dialog-window", properties.firstValue["\$window_id"]) + } + + @Test + fun `network replay event keeps the session fallback without a foreground window`() { + val replayHandler = PostHogSessionReplayHandlerFake(isActive = true) + val config = PostHogConfig(API_KEY, "http://localhost").apply { addIntegration(replayHandler) } + val postHog = mock() + whenever(postHog.distinctId()).thenReturn("test-user") + whenever(postHog.isSessionReplayActive()).thenReturn(true) + whenever(postHog.getConfig()).thenReturn(config) + + withServer { server -> + server.enqueue(MockResponse().setBody("ok")) + val client = newClient(postHog, captureNetworkTelemetry = true) + try { + executeRequest(client, server, PRIMARY_HOST) + } finally { + client.shutdown() + } + } + + val properties = argumentCaptor>() + verify(postHog).capture( + eq("\$snapshot"), + anyOrNull(), + properties.capture(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + assertFalse(properties.firstValue.containsKey("\$window_id")) + } + private fun assertHeaders( recordedRequest: RecordedRequest, expectedHeaders: ExpectedHeaders, diff --git a/posthog/src/test/java/com/posthog/PostHogSessionReplayHandlerFake.kt b/posthog/src/test/java/com/posthog/PostHogSessionReplayHandlerFake.kt index 89a7341a8..6b06cc0e7 100644 --- a/posthog/src/test/java/com/posthog/PostHogSessionReplayHandlerFake.kt +++ b/posthog/src/test/java/com/posthog/PostHogSessionReplayHandlerFake.kt @@ -10,6 +10,7 @@ public class PostHogSessionReplayHandlerFake(private var isActive: Boolean) : Po public var lastEventName: String? = null public var lastEventProperties: Map? = null public var onSessionIdChangedCalled: Boolean = false + public var replayWindowId: String? = null public fun reset() { stopCalled = false @@ -19,6 +20,7 @@ public class PostHogSessionReplayHandlerFake(private var isActive: Boolean) : Po lastEventName = null lastEventProperties = null onSessionIdChangedCalled = false + replayWindowId = null } override fun start(resumeCurrent: Boolean) { @@ -36,6 +38,10 @@ public class PostHogSessionReplayHandlerFake(private var isActive: Boolean) : Po return isActive } + override fun getCurrentWindowId(): String? { + return replayWindowId + } + override fun onEvent( event: String, properties: Map?,