SIGN IN SIGN UP

feat(replay): count attribute mutations the throttler drops (#4658)

* fix(replay): heal recordings when the mutation throttler drops attributes

The mutation throttler drops per-node attribute mutations once a node burns
through its token bucket. A dropped class or style change (e.g. the one that
hides an outgoing subtree) never reaches the player, so replay shows DOM that
already left the live page. Nothing counted the drop and nothing healed it.

Count dropped attribute mutations and, while awake, take a debounced full
snapshot to re-sync the player's mirror. The debounce coalesces a dense burst
into one snapshot and leaves sustained animation to the periodic snapshot.
Report the count as `$sdk_debug_replay_throttled_mutations_dropped` so the
path is measurable.

Also fix `BucketedRateLimiter.consumeRateLimit`, which returned `true` on the
call that spent the last token and so rate limited one request more than the
bucket size.

Generated-By: PostHog Desktop
Task-Id: e2bd5bea-bdff-4ffb-ac49-eb5ff8f2cd7c

Generated-By: PostHog Desktop
Task-Id: e2bd5bea-bdff-4ffb-ac49-eb5ff8f2cd7c

* fix(replay): reset throttler drop count on session rotation

_throttledMutationsDropped is reported as $sdk_debug_replay_throttled_mutations_dropped
with a comment promising a count "cumulative across the session", but
_restartForSessionIdChange reset only the snapshot cost metrics and left this
accumulator alone. A long-lived tab that rotates (activity timeout or 24h cap) then
carried the old session's drop count into the new session's events, so a session with
no drops reported a non-zero count and a session with drops reported an inflated one —
sending an engineer to a recording that has no duplicate-DOM risk.

Reset the counter alongside the other per-session metrics, after the old recorder stops
(so its teardown flush still attributes drops to the old session) and before the new one
starts. Adds a rotation test asserting the new session starts at zero.

Files affected: packages/browser (web)

Generated-By: PostHog Desktop
Task-Id: f7ba0fd1-833d-4278-a0ba-f62adf476e34

* fix(replay): heal throttler drops for trigger-pending recordings

The awake throttle-drop heal only fired when status was 'sampled' or 'active', so a
trigger-pending recorder (status 'buffering') got no heal. But a trigger-pending buffer
ships on activation via _activateTrigger, which flushes the held buffer without taking a
fresh snapshot — so if a URL/event/linked-flag trigger fired before the next pending-
interval snapshot, the shipped recording still showed the dropped-attribute duplicate DOM
this PR exists to remove.

The sibling idle-wake heal already handles this exact state with
`['sampled', 'active'].includes(status) || _strategy?.hasPendingTriggers(sessionId)`.
Align the throttle-drop heal's gate with that same bufferCanShip condition so the two
heals in the same file agree. Taking a full snapshot while trigger-pending re-bases the
buffer via _clearBufferBeforeMostRecentMeta — the established pending-buffer behaviour,
not a new side effect.

Adds a parametrized test covering both buffering cases (pending trigger heals, no pending
trigger does not).

Files affected: packages/browser (web)

Generated-By: PostHog Desktop
Task-Id: f7ba0fd1-833d-4278-a0ba-f62adf476e34

* fix(replay): don't fire throttle heal snapshot after going idle

The throttle-drop heal timer only checked _isIdle when scheduling, not when firing. The
same rrweb emit that reports a dropped attribute (via the throttler callback) runs the
idle check just afterwards, so one event can schedule the heal and then flip the recorder
to idle. The idle transition cleared only _fullSnapshotTimer, leaving the heal timer to
fire ~1s later and run a full DOM snapshot — the synchronous "freeze a user would notice"
task — on an idle page, where the idle gate then discards the event. Dense throttler
bursts on animating pages make a pending timer likely at exactly the moment of the flip.

Two-part fix, matching the file's own conventions:
- the timer callback bails out when _isIdle === true (fire-time safety net), and
- the idle transition cancels the pending heal timer alongside _fullSnapshotTimer.

The mirror still repairs itself on wake via the existing wake heal. Adds tests for both
the clear-on-idle path and the callback's fire-time guard.

Files affected: packages/browser (web)

Generated-By: PostHog Desktop
Task-Id: f7ba0fd1-833d-4278-a0ba-f62adf476e34

* refactor(replay): count throttler-dropped mutations without the heal snapshot

Drops the debounced full-snapshot heal and the BucketedRateLimiter
off-by-one, leaving only the drop counter and its debug property.

* chore(changeset): bump to minor for the new debug property

* chore(changeset): reword for developers reading the changelog

* chore: regenerate mangled property names

* chore(replay): prove the drop-count reset against the path that increments it

---------

Co-authored-by: posthog[bot] <206114724+posthog[bot]@users.noreply.github.com>
Co-authored-by: Anna Garcia <anna.g@posthog.com>
Co-authored-by: Tue Haulund <tue@posthog.com>
P
posthog[bot] committed
db8687c46bc34d1364c092a030adaef7c67c6c67
Parent: 1959a2e
Committed by GitHub <noreply@github.com> on 8/27/2026, 1:54:47 PM