Environment
com.posthog:posthog-android:3.60.5 (core com.posthog:posthog:6.13.0)
- Session replay in wireframe mode (
config.sessionReplayConfig.screenshot = false)
- Reproduced against 3.43.1, 3.60.4, and 3.60.5 — the behaviour is identical across all three (details below verified against the 3.60.5 AAR bytecode).
Summary
An Android Dialog is a separate window with its own decor view. posthog-android snapshots each window independently and correctly, but every window's events are emitted under one $window_id, which is just the session id. PostHog's player keeps one document per window id, and an rrweb full snapshot resets that document (mirror.reset() + document.open()/close()). So the moment a dialog emits its full snapshot, it replaces the Activity's tree in the player, and the Activity never re-sends a full snapshot to recover — every subsequent mutation targets node ids that no longer exist and is dropped. In wireframe mode (one ~50-node tree + diffs) this loses the rest of the session; the more an app uses dialogs, the emptier its replays.
Mechanism (verified against 3.60.5 bytecode)
SDK side — each window is tracked separately, but there is no window id.
PostHogReplayIntegration holds private final Map<View, ViewTreeSnapshotStatus> decorViews — one entry per decor view (i.e. per window).
ViewTreeSnapshotStatus carries a per-window private boolean sentFullSnapshot (+ getSentFullSnapshot()/setSentFullSnapshot()) and its own lastSnapshot: RRWireframe.
generateSnapshot$posthog_android_release(WeakReference<View>, WeakReference<Window>, boolean) reads getSentFullSnapshot() and sets it — so each window emits one full snapshot, then diffs against its own private tree. Internally consistent per window.
- There is no window-id concept anywhere in the replay integration — grepping every class in
com/posthog/android/replay/ for window_id/windowId returns nothing. The $window_id property is set once, in core com.posthog.PostHog, to PostHogSessionManager.getActiveSessionId() — i.e. the session id, identical for every window in the session.
Player side — a full snapshot is absolute, so the dialog wipes the Activity.
- One
Replayer per $window_id; since every window shares the session id, the Activity tree and every dialog tree land in the same document/timeline.
- A full snapshot there runs
this.mirror.reset() then rebuilds, and the Document node does doc.close(); doc.open() — the entire prior document is thrown away.
- The Activity's own
sentFullSnapshot is still true, so it never re-sends a full tree the player could anchor to. Its later mutations reference dead ids, and applyMutation drops orphaned adds silently (parentIdInCurrentBatch ? queue.push(mutation) : undefined); removes only log a warning that's suppressed by default. The mobile→web transformer turns each update into a remove plus a re-add, so one update to a stomped node yields two orphans.
Evidence
Measured on 24 production wireframe sessions (>10 interactions each). Each session's mutations were replayed twice — once against a single merged document (what the player does), once against one document per window (what a correct window id would give):
| model |
dropped mutations |
| merged (current player behaviour) — median |
97% |
| merged — 21 of 24 sessions |
≥ 45% |
| per-window control — 22 of 24 sessions |
0% |
The per-window 0% is the load-bearing number: the SDK's per-window streams are internally consistent, and the shared window id is what destroys them in the merge.
A minimal synthetic before/after over PostHog's own mobile→web transformer shows the same fingerprint — a stomped capture is 80% unappliable merged / 0% per-window, a healthy one is 0%/0%.
(Raw session payloads are user data and are deliberately not attached; the repro below reproduces it from scratch.)
Minimal reproduction
A single Activity that shows a Dialog a few seconds after it's on screen. Point it at any project, open the replay: the Activity content vanishes the instant the dialog appears and never returns. In the payload, both the Activity's and the dialog's full snapshots carry the same $window_id.
// App.kt
class App : Application() {
override fun onCreate() {
super.onCreate()
val config = PostHogAndroidConfig(apiKey = "<YOUR_PROJECT_API_KEY>", host = "https://us.i.posthog.com").apply {
sessionReplay = true
sessionReplayConfig.screenshot = false // wireframe mode
}
PostHogAndroid.setup(this, config)
}
}
// MainActivity.kt
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// A recognisable Activity tree so the stomp is obvious in the replay.
val root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
repeat(12) { i -> root.addView(TextView(this).apply { text = "Activity row $i" }) }
setContentView(root)
// Show a Dialog AFTER the Activity has been snapshotted. Its full snapshot,
// under the same $window_id, wipes the Activity from the player's document.
Handler(Looper.getMainLooper()).postDelayed({
AlertDialog.Builder(this)
.setTitle("A dialog")
.setMessage("This dialog's full snapshot replaces the Activity in the replay.")
.setPositiveButton("OK", null)
.show()
}, 4000)
}
}
AndroidManifest.xml: android:name=".App", MainActivity as launcher. Dependency: implementation("com.posthog:posthog-android:3.60.5").
Observed: the replay shows the 12-row Activity until ~4s, then the dialog appears and the Activity content disappears for the remainder — matching the production symptom.
Proposed fix
- (Real fix) Emit a distinct
$window_id per decor view. This matches the semantics the player already implements — one Replayer per window id, which knows how to segment between windows — and makes dialogs render as their own segment instead of overwriting the Activity. The SDK already tracks windows separately (decorViews), so it has the identity to hand; it just isn't put on the events.
- (Minimal mitigation) When the set of tracked windows changes, reset the other windows'
sentFullSnapshot so the stomped tree re-sends a full snapshot and the player recovers. Cheaper, but still only one window renders at a time.
Option 1 is the correct fix. Neither 3.43.1, 3.60.4 nor 3.60.5 behaves differently here, so this isn't addressed by any recent release. Happy to help verify a fix — we filed #721 (→ #722, shipped in 3.60.5) from the same app and can re-run our per-window measurement against a patched build.
Environment
com.posthog:posthog-android:3.60.5(corecom.posthog:posthog:6.13.0)config.sessionReplayConfig.screenshot = false)Summary
An Android
Dialogis a separate window with its own decor view. posthog-android snapshots each window independently and correctly, but every window's events are emitted under one$window_id, which is just the session id. PostHog's player keeps one document per window id, and an rrweb full snapshot resets that document (mirror.reset()+document.open()/close()). So the moment a dialog emits its full snapshot, it replaces the Activity's tree in the player, and the Activity never re-sends a full snapshot to recover — every subsequent mutation targets node ids that no longer exist and is dropped. In wireframe mode (one ~50-node tree + diffs) this loses the rest of the session; the more an app uses dialogs, the emptier its replays.Mechanism (verified against 3.60.5 bytecode)
SDK side — each window is tracked separately, but there is no window id.
PostHogReplayIntegrationholdsprivate final Map<View, ViewTreeSnapshotStatus> decorViews— one entry per decor view (i.e. per window).ViewTreeSnapshotStatuscarries a per-windowprivate boolean sentFullSnapshot(+getSentFullSnapshot()/setSentFullSnapshot()) and its ownlastSnapshot: RRWireframe.generateSnapshot$posthog_android_release(WeakReference<View>, WeakReference<Window>, boolean)readsgetSentFullSnapshot()and sets it — so each window emits one full snapshot, then diffs against its own private tree. Internally consistent per window.com/posthog/android/replay/forwindow_id/windowIdreturns nothing. The$window_idproperty is set once, in corecom.posthog.PostHog, toPostHogSessionManager.getActiveSessionId()— i.e. the session id, identical for every window in the session.Player side — a full snapshot is absolute, so the dialog wipes the Activity.
Replayerper$window_id; since every window shares the session id, the Activity tree and every dialog tree land in the same document/timeline.this.mirror.reset()then rebuilds, and the Document node doesdoc.close(); doc.open()— the entire prior document is thrown away.sentFullSnapshotis stilltrue, so it never re-sends a full tree the player could anchor to. Its later mutations reference dead ids, andapplyMutationdrops orphaned adds silently (parentIdInCurrentBatch ? queue.push(mutation) : undefined); removes only log a warning that's suppressed by default. The mobile→web transformer turns each update into a remove plus a re-add, so one update to a stomped node yields two orphans.Evidence
Measured on 24 production wireframe sessions (>10 interactions each). Each session's mutations were replayed twice — once against a single merged document (what the player does), once against one document per window (what a correct window id would give):
The per-window 0% is the load-bearing number: the SDK's per-window streams are internally consistent, and the shared window id is what destroys them in the merge.
A minimal synthetic before/after over PostHog's own mobile→web transformer shows the same fingerprint — a stomped capture is 80% unappliable merged / 0% per-window, a healthy one is 0%/0%.
(Raw session payloads are user data and are deliberately not attached; the repro below reproduces it from scratch.)
Minimal reproduction
A single Activity that shows a
Dialoga few seconds after it's on screen. Point it at any project, open the replay: the Activity content vanishes the instant the dialog appears and never returns. In the payload, both the Activity's and the dialog's full snapshots carry the same$window_id.AndroidManifest.xml:android:name=".App",MainActivityas launcher. Dependency:implementation("com.posthog:posthog-android:3.60.5").Observed: the replay shows the 12-row Activity until ~4s, then the dialog appears and the Activity content disappears for the remainder — matching the production symptom.
Proposed fix
$window_idper decor view. This matches the semantics the player already implements — oneReplayerper window id, which knows how to segment between windows — and makes dialogs render as their own segment instead of overwriting the Activity. The SDK already tracks windows separately (decorViews), so it has the identity to hand; it just isn't put on the events.sentFullSnapshotso the stomped tree re-sends a full snapshot and the player recovers. Cheaper, but still only one window renders at a time.Option 1 is the correct fix. Neither 3.43.1, 3.60.4 nor 3.60.5 behaves differently here, so this isn't addressed by any recent release. Happy to help verify a fix — we filed #721 (→ #722, shipped in 3.60.5) from the same app and can re-run our per-window measurement against a patched build.