From 6c167ee8b4ce13a78fd3c070e873e56409acbdb4 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:35:04 +0000 Subject: [PATCH 1/4] fix(replay): warn when wireframe capture cannot record a Compose window Wireframe capture is the default (sessionReplayConfig.screenshot = false) and View.toWireframe() only walks classic Android View types, so a Jetpack Compose window produces an almost empty wireframe tree that plays back as a blank gray screen. The SDK already detects a Compose root for the screenshot mask path, but said nothing on the wireframe path. Log one warning per process when a Compose root is found while wireframe capture is on, naming sessionReplayConfig.screenshot = true as the fix. Also state the limit and the mask scope in the PostHogSessionReplayConfig KDoc. Generated-By: PostHog Desktop Task-Id: 0206a002-16b5-4fb5-822e-2085e838e802 --- .changeset/compose-wireframe-warning.md | 5 ++ .../replay/PostHogReplayIntegration.kt | 25 +++++++ .../replay/PostHogSessionReplayConfig.kt | 19 +++-- .../replay/PostHogReplayIntegrationTest.kt | 70 +++++++++++++++++++ 4 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 .changeset/compose-wireframe-warning.md diff --git a/.changeset/compose-wireframe-warning.md b/.changeset/compose-wireframe-warning.md new file mode 100644 index 000000000..bd2bc010b --- /dev/null +++ b/.changeset/compose-wireframe-warning.md @@ -0,0 +1,5 @@ +--- +"posthog-android": patch +--- + +Fix: session replay now tells you why a Jetpack Compose recording is blank. Wireframe capture, which is the default (`sessionReplayConfig.screenshot = false`), only walks classic Android View types, so a Compose window produces an almost empty wireframe tree that plays back as a gray screen. The SDK now logs one warning when it finds a Compose root while wireframe capture is on, and the warning names `sessionReplayConfig.screenshot = true` as the fix. The KDoc on `screenshot`, `maskAllTextInputs` and `maskAllImages` also states this. 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..e5989ca9f 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 @@ -734,6 +734,7 @@ public class PostHogReplayIntegration( status.drawState, ) ?: return false } else { + warnIfComposeWireframe(view, status.drawState) view.toWireframe() ?: return false } @@ -1287,6 +1288,30 @@ public class PostHogReplayIntegration( view.isComposeRooted(drawState) } + // Fires once per process: repeating it on every snapshot would flood logcat, and one line + // is enough to point the developer at the option that fixes the recording. Read before the + // Compose check too, so a warned process never pays for the tree walk again. + private val composeWireframeWarningFired = AtomicBoolean(false) + + // Wireframes are built from classic Android View types only, so a Compose-rooted window + // produces an almost empty tree that plays back as a blank screen. Say so, because the + // capture itself keeps succeeding and the developer gets no other signal. + private fun warnIfComposeWireframe( + view: View, + drawState: WindowDrawState, + ) { + if (composeWireframeWarningFired.get() || !view.isComposeRooted(drawState)) { + return + } + if (composeWireframeWarningFired.compareAndSet(false, true)) { + config.logger.log( + "Session Replay found a Jetpack Compose window, but wireframe capture is on. " + + "Wireframes only cover classic Android Views, so the recording will be blank. " + + "Set sessionReplayConfig.screenshot = true to record Compose screens.", + ) + } + } + private fun View.isComposeRooted(drawState: WindowDrawState): Boolean { drawState.composeRooted?.let { return it } if (!isComposeAvailable) { diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt index c01acac88..17c2de13c 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt @@ -12,13 +12,15 @@ public class PostHogSessionReplayConfig @JvmOverloads constructor( /** - * Enable masking of all text and text input fields - * Defaults to true + * Enable masking of all text and text input fields. + * The mask applies to wireframe capture and to screenshot capture. + * Defaults to true. */ public var maskAllTextInputs: Boolean = true, /** - * Enable masking of all images to a placeholder - * Defaults to true + * Enable masking of all images to a placeholder. + * The mask applies to wireframe capture and to screenshot capture. + * Defaults to true. */ public var maskAllImages: Boolean = true, /** @@ -33,9 +35,12 @@ public class PostHogSessionReplayConfig */ public var drawableConverter: PostHogDrawableConverter? = null, /** - * By default Session replay will capture all the views on the screen as a wireframe, - * By enabling this option, PostHog will capture the screenshot of the screen. - * The screenshot may contain sensitive information, use with caution. + * Capture each frame as a masked screenshot instead of a wireframe. + * Defaults to false, which captures the views on the screen as a wireframe. + * A wireframe only covers classic Android View types, so a screen that Jetpack Compose + * draws records as a blank screen. Set this option to true for Jetpack Compose apps. + * The mask options still apply to a screenshot, but a screenshot can show sensitive + * information that the mask options do not cover. Use with caution. */ public var screenshot: Boolean = false, /** 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..e331bde78 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 @@ -1843,6 +1843,76 @@ internal class PostHogReplayIntegrationTest { } } + private fun wireframeFixture(messages: MutableList): RealQueueFixture { + val fx = + createIntegrationWithRealQueue( + flagActive = true, + hasFetched = true, + integrationContext = ApplicationProvider.getApplicationContext(), + ) + fx.config.sessionReplayConfig.screenshot = false + fx.config.logger = + object : PostHogLogger { + override fun log(message: String) { + messages.add(message) + } + + override fun isEnabled(): Boolean = true + } + fx.sut.install(PostHogFake()) + fx.sut.start(resumeCurrent = true) + return fx + } + + @Test + fun `wireframe capture warns once about a compose rooted window`() { + // A wireframe only covers classic View types, so a Compose window records as a blank + // screen while every capture still reports success. One log line must name the option + // that fixes it, and it must not repeat on every snapshot. + val messages = Collections.synchronizedList(mutableListOf()) + val fx = wireframeFixture(messages) + try { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + shadowOf(Looper.getMainLooper()).idle() + val decorView = activity.window.decorView + makeWindowVisible(decorView) + activity.findViewById(android.R.id.content) + .addView(FakeAndroidComposeView(activity)) + fx.sut.decorViews[decorView] = ViewTreeSnapshotStatus(mock()) + + repeat(2) { + fx.sut.generateSnapshot(WeakReference(decorView), WeakReference(activity.window)) + } + + assertEquals(1, messages.count { it.contains("sessionReplayConfig.screenshot = true") }) + } finally { + fx.sut.uninstall() + } + } + + @Test + fun `wireframe capture stays quiet for a classic view window`() { + // Control for the Compose warning: a window that the wireframe walk can render must + // not tell the customer to switch capture mode. + val messages = Collections.synchronizedList(mutableListOf()) + val fx = wireframeFixture(messages) + try { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + shadowOf(Looper.getMainLooper()).idle() + val decorView = activity.window.decorView + makeWindowVisible(decorView) + activity.findViewById(android.R.id.content) + .addView(TextView(activity)) + fx.sut.decorViews[decorView] = ViewTreeSnapshotStatus(mock()) + + fx.sut.generateSnapshot(WeakReference(decorView), WeakReference(activity.window)) + + assertFalse(messages.any { it.contains("sessionReplayConfig.screenshot = true") }) + } finally { + fx.sut.uninstall() + } + } + private class OnceThrowingChildFrameLayout(context: Context) : FrameLayout(context) { private var thrown = false From 0c3728e787161e9130d597284a73c5f6e5fabe46 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:59:04 +0000 Subject: [PATCH 2/4] fix(replay): keep the compose wireframe warning until debug logging is on The once-per-process guard was spent by compareAndSet before config.logger.log ran, but PostHogAndroidLogger drops the message unless config.debug is true and debug defaults to false. A developer who called PostHog.debug(true) after the first Compose snapshot could therefore never see the warning. Check config.logger.isEnabled() before spending the guard, so the budget is only consumed on a line that is actually delivered. Reading it first also skips the Compose root check entirely while debug logging is off, which is the common case. Generated-By: PostHog Desktop Task-Id: ff7ca759-b632-4447-9bda-c11574bd8e0b --- .../replay/PostHogReplayIntegration.kt | 8 +++- .../replay/PostHogReplayIntegrationTest.kt | 44 +++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) 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 e5989ca9f..cf2a5d5db 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 @@ -1300,7 +1300,13 @@ public class PostHogReplayIntegration( view: View, drawState: WindowDrawState, ) { - if (composeWireframeWarningFired.get() || !view.isComposeRooted(drawState)) { + // The logger drops the message while debug logging is off, and PostHog.debug(true) can + // turn it on at any point, so the once-per-process budget must not be spent on a line + // nobody receives. Reading it up front also skips the Compose check while debug is off. + if (composeWireframeWarningFired.get() || + !config.logger.isEnabled() || + !view.isComposeRooted(drawState) + ) { return } if (composeWireframeWarningFired.compareAndSet(false, true)) { 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 e331bde78..57eff8eff 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 @@ -1843,7 +1843,10 @@ internal class PostHogReplayIntegrationTest { } } - private fun wireframeFixture(messages: MutableList): RealQueueFixture { + private fun wireframeFixture( + messages: MutableList, + loggerEnabled: () -> Boolean = { true }, + ): RealQueueFixture { val fx = createIntegrationWithRealQueue( flagActive = true, @@ -1851,13 +1854,17 @@ internal class PostHogReplayIntegrationTest { integrationContext = ApplicationProvider.getApplicationContext(), ) fx.config.sessionReplayConfig.screenshot = false + // Drops the message while disabled, exactly like PostHogAndroidLogger does when + // config.debug is off. fx.config.logger = object : PostHogLogger { override fun log(message: String) { - messages.add(message) + if (isEnabled()) { + messages.add(message) + } } - override fun isEnabled(): Boolean = true + override fun isEnabled(): Boolean = loggerEnabled() } fx.sut.install(PostHogFake()) fx.sut.start(resumeCurrent = true) @@ -1913,6 +1920,37 @@ internal class PostHogReplayIntegrationTest { } } + @Test + fun `wireframe compose warning is not spent while debug logging is off`() { + // Debug logging is off by default, so the first Compose snapshots happen with the sink + // dropping everything. The once-per-process guard must not be spent on those, otherwise + // turning debug on later could never surface the warning. + val messages = Collections.synchronizedList(mutableListOf()) + val debugEnabled = AtomicBoolean(false) + val fx = wireframeFixture(messages) { debugEnabled.get() } + try { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + shadowOf(Looper.getMainLooper()).idle() + val decorView = activity.window.decorView + makeWindowVisible(decorView) + activity.findViewById(android.R.id.content) + .addView(FakeAndroidComposeView(activity)) + fx.sut.decorViews[decorView] = ViewTreeSnapshotStatus(mock()) + + fx.sut.generateSnapshot(WeakReference(decorView), WeakReference(activity.window)) + assertFalse(messages.any { it.contains("sessionReplayConfig.screenshot = true") }) + + debugEnabled.set(true) + repeat(2) { + fx.sut.generateSnapshot(WeakReference(decorView), WeakReference(activity.window)) + } + + assertEquals(1, messages.count { it.contains("sessionReplayConfig.screenshot = true") }) + } finally { + fx.sut.uninstall() + } + } + private class OnceThrowingChildFrameLayout(context: Context) : FrameLayout(context) { private var thrown = false From 17b755d079e98fab1eb544b11c546fad515d8e30 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:04:34 +0000 Subject: [PATCH 3/4] fix(replay): scope the compose wireframe warning to Compose content Compose detection matches an AndroidComposeView at any depth, not just at the window root, so a hybrid screen with one interop ComposeView inside an XML layout trips the warning. On that screen View.toWireframe() still recurses over every ViewGroup child and emits real nodes for TextView, ImageView, ProgressBar, RatingBar, Switch and WebView, so only the Compose subtree comes back as an empty box. Saying "the recording will be blank" overstated it. Reword the line to say Compose content records blank, and that this is the whole recording only when Compose draws the whole screen. The screenshot recommendation is unchanged. No behaviour change. Generated-By: PostHog Desktop Task-Id: ff7ca759-b632-4447-9bda-c11574bd8e0b --- .../android/replay/PostHogReplayIntegration.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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 cf2a5d5db..2451330d3 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 @@ -1293,9 +1293,10 @@ public class PostHogReplayIntegration( // Compose check too, so a warned process never pays for the tree walk again. private val composeWireframeWarningFired = AtomicBoolean(false) - // Wireframes are built from classic Android View types only, so a Compose-rooted window - // produces an almost empty tree that plays back as a blank screen. Say so, because the - // capture itself keeps succeeding and the developer gets no other signal. + // Wireframes are built from classic Android View types only, so whatever Compose draws + // comes back as an empty box that plays back blank, up to the whole screen when Compose + // draws all of it. Say so, because the capture itself keeps succeeding and the developer + // gets no other signal. private fun warnIfComposeWireframe( view: View, drawState: WindowDrawState, @@ -1311,9 +1312,10 @@ public class PostHogReplayIntegration( } if (composeWireframeWarningFired.compareAndSet(false, true)) { config.logger.log( - "Session Replay found a Jetpack Compose window, but wireframe capture is on. " + - "Wireframes only cover classic Android Views, so the recording will be blank. " + - "Set sessionReplayConfig.screenshot = true to record Compose screens.", + "Session Replay found Jetpack Compose content, but wireframe capture is on. " + + "Wireframes only cover classic Android Views, so Compose content records " + + "blank; on a fully Compose screen that is the whole recording. " + + "Set sessionReplayConfig.screenshot = true to record Compose content.", ) } } From c026ba630ff5e33b17cb1459a0542817f17b09ce Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 4 Sep 2026 15:45:36 +0300 Subject: [PATCH 4/4] fix(replay): resolve the compose wireframe warning off the capture thread --- .../android/replay/PostHogReplayIntegration.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) 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 2451330d3..fffa37cbc 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 @@ -1304,13 +1304,19 @@ public class PostHogReplayIntegration( // The logger drops the message while debug logging is off, and PostHog.debug(true) can // turn it on at any point, so the once-per-process budget must not be spent on a line // nobody receives. Reading it up front also skips the Compose check while debug is off. - if (composeWireframeWarningFired.get() || - !config.logger.isEnabled() || - !view.isComposeRooted(drawState) - ) { + if (composeWireframeWarningFired.get() || !config.logger.isEnabled()) { return } - if (composeWireframeWarningFired.compareAndSet(false, true)) { + // Never block the capture thread for a log line: off the main thread the verdict is + // resolved there without waiting and read from the cache on the next snapshot. + val rooted = + drawState.composeRooted ?: if (Looper.myLooper() == mainHandler.handler.looper) { + view.isComposeRooted(drawState) + } else { + mainHandler.handler.post { view.isComposeRooted(drawState) } + return + } + if (rooted && composeWireframeWarningFired.compareAndSet(false, true)) { config.logger.log( "Session Replay found Jetpack Compose content, but wireframe capture is on. " + "Wireframes only cover classic Android Views, so Compose content records " +