From 538f4e73558034366ea6a0dece4f949cc07a3f54 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:18:05 +0000 Subject: [PATCH 1/2] fix(replay): retry the full-snapshot heal on rotated sessions A rotated session can start with incrementals and no full snapshot, which the player cannot render. The heal path requested one snapshot per target session id and latched off, so a heal that never produced a snapshot left the rest of the recording unplayable. Count heal attempts per target session and retry up to a bounded cap instead of latching after one. The count resets when the heal target changes, so each rotated session gets its own budget. Generated-By: PostHog Desktop Task-Id: c41cc2d8-36c0-48ff-a653-e1e6c34695df --- .changeset/session-replay-heal-retry.md | 5 ++ .../lazy-sessionrecording-compression.test.ts | 52 ++++++++++++++++++- .../external/lazy-loaded-session-recorder.ts | 29 +++++++++-- 3 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 .changeset/session-replay-heal-retry.md diff --git a/.changeset/session-replay-heal-retry.md b/.changeset/session-replay-heal-retry.md new file mode 100644 index 0000000000..c585ffbe83 --- /dev/null +++ b/.changeset/session-replay-heal-retry.md @@ -0,0 +1,5 @@ +--- +'posthog-js': patch +--- + +fix(replay): retry the full-snapshot heal when a rotated session ships incrementals first, so a single failed heal no longer leaves the rest of the recording unplayable diff --git a/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording-compression.test.ts b/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording-compression.test.ts index d5d1b2cd0e..791543ccf3 100644 --- a/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording-compression.test.ts +++ b/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording-compression.test.ts @@ -355,15 +355,63 @@ describe('LazyLoadedSessionRecording compression paths', () => { await lazyLoadedSessionRecording['_compressionQueue'] expect(takeFullSnapshot).toHaveBeenCalledTimes(1) - // only healed once per session id, even if the requested snapshot has not landed yet + // the requested snapshot has not landed, so the next incremental retries the heal emit(createIncrementalSnapshot(200)) await lazyLoadedSessionRecording['_compressionQueue'] - expect(takeFullSnapshot).toHaveBeenCalledTimes(1) + expect(takeFullSnapshot).toHaveBeenCalledTimes(2) // once the healed full snapshot ships, incrementals stop triggering healing emit(createFullSnapshot({ content: 'healed' })) emit(createIncrementalSnapshot(300)) await lazyLoadedSessionRecording['_compressionQueue'] + expect(takeFullSnapshot).toHaveBeenCalledTimes(2) + }) + + it('stops healing a session after a bounded number of failed attempts', async () => { + const { emit, posthog, lazyLoadedSessionRecording } = await setupLazyLoadedSessionRecording({ + gzipSupported: true, + }) + const { assignableWindow } = require('../../../utils/globals') + const takeFullSnapshot = assignableWindow.__PosthogExtensions__.rrweb.record.takeFullSnapshot + + emit(createFullSnapshot({ content: 'initial' })) + await lazyLoadedSessionRecording['_compressionQueue'] + + posthog.sessionManager['_setSessionId']('rotated-session-id', 123, 123) + lazyLoadedSessionRecording['_isIdle'] = 'unknown' + lazyLoadedSessionRecording['_sessionId'] = 'rotated-session-id' + + // the heal never produces a full snapshot, so each incremental retries up to the cap, then stops + for (let i = 0; i < 10; i++) { + emit(createIncrementalSnapshot(100 + i)) + await lazyLoadedSessionRecording['_compressionQueue'] + } + expect(takeFullSnapshot).toHaveBeenCalledTimes(5) + }) + + it('heals again after a second rotation exhausts the first session budget', async () => { + const { emit, posthog, lazyLoadedSessionRecording } = await setupLazyLoadedSessionRecording({ + gzipSupported: true, + }) + const { assignableWindow } = require('../../../utils/globals') + const takeFullSnapshot = assignableWindow.__PosthogExtensions__.rrweb.record.takeFullSnapshot + + emit(createFullSnapshot({ content: 'initial' })) + await lazyLoadedSessionRecording['_compressionQueue'] + + // first rotation heals but never lands a full snapshot + posthog.sessionManager['_setSessionId']('rotated-session-id', 123, 123) + lazyLoadedSessionRecording['_isIdle'] = 'unknown' + lazyLoadedSessionRecording['_sessionId'] = 'rotated-session-id' + emit(createIncrementalSnapshot(100)) + await lazyLoadedSessionRecording['_compressionQueue'] expect(takeFullSnapshot).toHaveBeenCalledTimes(1) + + // a second rotation gets its own heal budget rather than staying broken + posthog.sessionManager['_setSessionId']('rotated-session-id-2', 456, 456) + lazyLoadedSessionRecording['_sessionId'] = 'rotated-session-id-2' + emit(createIncrementalSnapshot(200)) + await lazyLoadedSessionRecording['_compressionQueue'] + expect(takeFullSnapshot).toHaveBeenCalledTimes(2) }) }) diff --git a/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts b/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts index 049ecc2f7f..c5b731bb77 100644 --- a/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts +++ b/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts @@ -108,6 +108,11 @@ const ONE_HOUR = ONE_MINUTE * 60 const MIN_TRIGGER_PENDING_BUFFER_INTERVAL_MILLIS = 1000 const MAX_TRIGGER_PENDING_BUFFER_INTERVAL_MILLIS = ONE_HOUR +// A heal request can fail to produce a full snapshot (rrweb not ready, the take dropped), so +// retry a few times for the same session before giving up, to keep the retries bounded and +// avoid a loop when every take keeps failing. +const MAX_FULL_SNAPSHOT_HEAL_ATTEMPTS = 5 + // A full snapshot runs as one uninterruptible task, so anything at this scale is a // visible freeze - no rendering, scrolling, or cursor movement - and worth a warning. const SLOW_FULL_SNAPSHOT_THRESHOLD_MS = 500 @@ -549,7 +554,10 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt // the marker-only flush guard (unlike _fullSnapshotTimestamps, which records emit-time debug // telemetry). Capture-time, not ship-time: a buffer cleared before it flushed leaves this set. private _lastFullSnapshotSessionId: string | undefined = undefined + // the session heal attempts are counted against, plus the count so far, so a heal that never + // produces a full snapshot retries a bounded number of times instead of latching off after one private _fullSnapshotHealAttemptedFor: string | undefined = undefined + private _fullSnapshotHealAttempts: number = 0 private _windowId: string private _sessionId: string @@ -1578,7 +1586,11 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt } } - // A session whose incrementals ship before any FullSnapshot is unplayable until the next periodic snapshot, so request one from rrweb (once per session id, to avoid loops if taking one keeps failing). + // A session whose incrementals ship before any FullSnapshot is unplayable until the next periodic + // snapshot, so request one from rrweb. A heal request can fail to produce a snapshot, and the session + // can rotate again, so retry up to MAX_FULL_SNAPSHOT_HEAL_ATTEMPTS per session id rather than latching + // off after one attempt. The count resets when the heal target changes, so each rotated session gets + // its own budget. private _ensureFullSnapshotForSession(event: eventWithTime, targetSessionId: string) { if (event.type === EventType.FullSnapshot) { this._lastFullSnapshotSessionId = targetSessionId @@ -1592,15 +1604,24 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt if ( // deliberately conservative: only heal after this recorder shipped a FullSnapshot to another session (the rotation signature), since on a fresh start rrweb's init snapshot is always ordered ahead of any incremental isUndefined(this._lastFullSnapshotSessionId) || - this._lastFullSnapshotSessionId === targetSessionId || - this._fullSnapshotHealAttemptedFor === targetSessionId + this._lastFullSnapshotSessionId === targetSessionId ) { return } - this._fullSnapshotHealAttemptedFor = targetSessionId + if (this._fullSnapshotHealAttemptedFor !== targetSessionId) { + this._fullSnapshotHealAttemptedFor = targetSessionId + this._fullSnapshotHealAttempts = 0 + } + + if (this._fullSnapshotHealAttempts >= MAX_FULL_SNAPSHOT_HEAL_ATTEMPTS) { + return + } + + this._fullSnapshotHealAttempts += 1 logger.info('incremental snapshot for a session with no full snapshot - requesting one', { sessionId: targetSessionId, + attempt: this._fullSnapshotHealAttempts, }) this._tryTakeFullSnapshot() } From 553bb6672ebbb3c1dad264d9ed98305f61c44cd8 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:34:33 +0000 Subject: [PATCH 2/2] chore(replay): record new mangled property name for heal retry The bounded heal retry adds a private _fullSnapshotHealAttempts field. Register it in terser-mangled-names.json so the mangled-property consistency check passes. Generated-By: PostHog Desktop Task-Id: c41cc2d8-36c0-48ff-a653-e1e6c34695df --- packages/browser/terser-mangled-names.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/browser/terser-mangled-names.json b/packages/browser/terser-mangled-names.json index ae9b700af2..132fc68114 100644 --- a/packages/browser/terser-mangled-names.json +++ b/packages/browser/terser-mangled-names.json @@ -228,6 +228,7 @@ "_formatQueue", "_freshestActivityTimestamp", "_fullSnapshotHealAttemptedFor", + "_fullSnapshotHealAttempts", "_fullSnapshotIntervalMillis", "_fullSnapshotTimer", "_fullSnapshotTimestamps",