fix(replay): report why a held recording epoch uploads nothing - #4754
fix(replay): report why a held recording epoch uploads nothing#4754posthog[bot] wants to merge 8 commits into
Conversation
A recording epoch that starts without user interaction keeps its buffer and sends no /s/ request, but `status` still reads "active", so a held session looks the same as a shipping one. Captured events now carry `$sdk_debug_replay_flush_hold_reason` (`no_interaction_since_recording_started` or `no_interaction_since_session_rotated`), and the SDK logs the reason once per held epoch in debug mode. One writer sets the hold and its reason together, so the two cannot drift. Generated-By: PostHog Desktop Task-Id: 83989448-e0be-4fef-8a76-6de241092f0b
🦔 PostHog Review reviewed this pull requestFound 0 must fix, 1 should fix, 0 consider. Published 1 finding (view the review). Resolved comments: 1 fixed |
Replay incident risk checkThis diff touches code involved in past incidents. This is a heads-up, not a verdict: read the matched sections of INCIDENTS.md and answer their review questions before merging. For a judgment on whether this diff has the same failure mode, run the |
posthog-js Compliance ReportDate: 2026-09-05 06:48:56 UTC ✅ All Tests Passed!26/26 tests passed Capture Tests✅ 26/26 tests passed View Details
|
|
Size Change: +4.56 kB (+0.02%) Total Size: 20.4 MB 📦 View Changed
ℹ️ View Unchanged
|
|
PostHog Review alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏 |
stop() and discard() cleared the buffer but left _flushHoldReason set, so the sdkDebugProperties getter (which keeps running after teardown — the recorder is torn down, not dropped) reported a stale $sdk_debug_replay_flush_hold_reason on every later captured event. On the exact surface this PR adds for diagnosis, a stopped recorder read as "held, waiting for interaction", pointing support at user inactivity instead of the stop. Route every terminal stop/discard path through a single _releaseHoldAfterStop() that clears the hold via the existing single writer, after the flush-then-clear (clearing earlier would let the suppressed flush ship the held buffer). The log-dedup key (_lastLoggedFlushHold) is deliberately preserved across a stop: the rotation restart sets a transient fresh-start hold in start() that _restartForSessionIdChange immediately overwrites with the real rotation reason, and the surviving dedup key is what keeps that transient from double-logging (guarded by the existing "logs the hold reason once per held epoch" test). Generated-By: PostHog Desktop Task-Id: 0c4a0bd3-af42-49e3-9361-8a5acad86077
…sh-start too On an idle session-id rotation, start() sets a transient `no_interaction_since_recording_started` hold that _restartForSessionIdChange immediately overwrites with the real `no_interaction_since_session_rotated` reason. In debug mode this emitted two "holding buffer" log lines for one epoch, the first naming the wrong epoch type. The existing "logs once" test only passed because it rotates from a fresh-start epoch — the one case where the dedup key happens to absorb the transient line; after a prior interaction or an earlier rotation the key no longer matches and both lines log. _setFlushHold now defers its log and dedup bookkeeping while _isRestartingForSessionIdChange is set, so the authoritative post-restart call (with the flag already cleared) produces the single, correctly-labelled line. No behaviour or captured-event value changes — nothing is captured between the transient assignment and the overwrite, so the reported property was already correct. Adds a regression test covering the general case (a second rotation, whose dedup key does not absorb the transient hold). Generated-By: PostHog Desktop Task-Id: 0c4a0bd3-af42-49e3-9361-8a5acad86077
|
@PostHog/team-replay i am fine with the changes, do we need |
discard() released the interaction hold before _teardown() stopped rrweb, so a deferred stylesheet mutation emitted during rrweb's own teardown landed in an unheld buffer and scheduled a flush timer that teardown had already cleared. On the synchronous-compression path that shipped one $snapshot after the recording was discarded - including when remote config turns replay off. Stop the release until after teardown and clear the buffer on both sides of it, mirroring the ordering stop() already uses. Generated-By: PostHog Desktop Task-Id: d8ed0380-d4a8-4c02-b046-37db9cb4aa5a
dustinbyrne
left a comment
There was a problem hiding this comment.
Agent-led review, human-reviewed before posting.
Approved with two inline lifecycle findings for follow-up.
Problem
/e/and/flags/returning 200) saw no/s/request ever leave the page, while SDK diagnostics reportedactiveandrecording_initialized. Support chased anapi_hostmisconfiguration, which cannot produce that split:endpointFor('api', ...)rewrites/s/and/e/identically, so they fail together or not at all._flushBuffer()returns early. The hold is correct — it stops us billing for tabs nobody touched — but it is invisible.$recording_status: 'active'is the state of a very large share of sessions, so it carries no upload information. Embedded surfaces that never see a direct interaction can stay held for the whole page lifetime with nothing telling the customer, or support, why.Changes
The hold now names itself. Every captured event carries
$sdk_debug_replay_flush_hold_reasonnext to$recording_status, so a held session is distinguishable from a shipping one in the customer's own event feed — including in the ticket scenario, where analytics events flow and snapshots do not.no_interaction_since_recording_startedno_interaction_since_session_rotatedDebug mode logs the reason once per held epoch, the way
_maybeLogBufferingReasonalready names pending trigger conditions. The log happens where the hold is set, not on a flush: no flush is scheduled while held, so a flush-time log would often never run.One private writer sets the hold flag and its reason together, so the reported reason cannot drift from the hold itself.
statusis unchanged. See Agent context.Release info Sub-libraries affected
Libraries affected
Checklist
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Fully autonomous
heldvalue inSessionRecordingStatus.status === ACTIVEgates_reportStarted, and['sampled','active'].includes(status)gates the idle-exit full snapshot, so a new status value changes recording behaviour rather than only reporting it. A debug property is behaviour-free and reaches the same diagnostics._flushBuffer. Nothing schedules a flush while an epoch is held, so a hold that starts at page load would never log.Test evidence
lazy-sessionrecording.test.tscover the fresh-start reason, the rotation reason plus its clearing on interaction, and one log line per held epoch. Full browser unit suite: 5990 passed.session-recording-held-epoch.spec.tsloads a page, does not interact, and asserts an analytics event reportsactivewithno_interaction_since_recording_startedwhile no$snapshotexists; after typing, snapshots ship on/ses/and the property is gone. Wholeplaywright/mocked/session-recordingfolder: 84 passed.Created with PostHog Desktop from this inbox report.