Skip to content

Wireframe replay: all windows share one $window_id (= session id), so any Dialog's full snapshot wipes the Activity for the rest of the session #752

Description

@kleyow

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

  1. (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.
  2. (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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions