Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/session-replay-heal-retry.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
Expand Down
1 change: 1 addition & 0 deletions packages/browser/terser-mangled-names.json
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@
"_formatQueue",
"_freshestActivityTimestamp",
"_fullSnapshotHealAttemptedFor",
"_fullSnapshotHealAttempts",
"_fullSnapshotIntervalMillis",
"_fullSnapshotTimer",
"_fullSnapshotTimestamps",
Expand Down