Skip to content

Commit 56c148a

Browse files
committed
fix: session rotation was relying on in-memory values and so could get out of sync with reality
1 parent ef589b6 commit 56c148a

File tree

5 files changed

+188
-15
lines changed

5 files changed

+188
-15
lines changed

.changeset/calm-coats-brake.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'posthog-js': patch
3+
---
4+
5+
fix: session id rotation relied on in-memory cache which would be stale after log idle periods - particularly with multiple windows in play

packages/browser/playwright/mocked/session-recording/session-recording-idle-timeout.spec.ts

Lines changed: 117 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -146,17 +146,127 @@ test.describe('Session recording - idle timeout behavior', () => {
146146
ph?.capture('test_after_idle_restart')
147147
})
148148

149-
await page.expectCapturedEventsToBe(['$snapshot', '$snapshot', 'test_after_idle_restart'])
150149
const capturedEvents = await page.capturedEvents()
150+
const snapshots = capturedEvents.filter((e) => e.event === '$snapshot')
151+
const testEvent = capturedEvents.find((e) => e.event === 'test_after_idle_restart')
151152

152-
expect(capturedEvents[0]['properties']['$session_id']).toEqual(initialSessionId)
153-
expect(getSnapshotTimestamp(capturedEvents[0], 'last')).toBeLessThan(timestampAfterRestart)
153+
// Should have at least 2 snapshots (old session final, new session data)
154+
expect(snapshots.length).toBeGreaterThanOrEqual(2)
154155

155-
expect(capturedEvents[1]['properties']['$session_id']).toEqual(newSessionId)
156-
expect(getSnapshotTimestamp(capturedEvents[1], 'first')).toBeGreaterThan(timestampBeforeIdle)
156+
// First snapshot should be old session final data
157+
const oldSessionSnapshots = snapshots.filter((s) => s['properties']['$session_id'] === initialSessionId)
158+
expect(oldSessionSnapshots.length).toBeGreaterThanOrEqual(1)
159+
expect(getSnapshotTimestamp(oldSessionSnapshots[0], 'last')).toBeLessThan(timestampAfterRestart)
157160

158-
expect(capturedEvents[2]['properties']['$session_id']).toEqual(newSessionId)
159-
expect(capturedEvents[2]['properties']['$session_recording_start_reason']).toEqual('session_id_changed')
161+
// New session snapshots should exist
162+
const newSessionSnapshots = snapshots.filter((s) => s['properties']['$session_id'] === newSessionId)
163+
expect(newSessionSnapshots.length).toBeGreaterThanOrEqual(1)
164+
expect(getSnapshotTimestamp(newSessionSnapshots[0], 'first')).toBeGreaterThan(timestampBeforeIdle)
165+
166+
// Test event should be on new session with correct start reason
167+
expect(testEvent?.['properties']['$session_id']).toEqual(newSessionId)
168+
expect(testEvent?.['properties']['$session_recording_start_reason']).toEqual('session_id_changed')
169+
})
170+
171+
test('rotates session when event timestamp shows idle timeout exceeded (frozen tab scenario)', async ({ page }) => {
172+
// This tests the scenario where:
173+
// 1. A browser tab is frozen/backgrounded for a long time
174+
// 2. The forcedIdleReset timer never fires (because JS timers don't run when tab is frozen)
175+
// 3. When the tab unfreezes, rrweb emits events with timestamps far in the future
176+
// 4. We should detect this via timestamp-based idle detection and rotate the session
177+
178+
// Start recording normally
179+
await ensureActivitySendsSnapshots(page)
180+
181+
const initialSessionId = await page.evaluate(() => {
182+
const ph = (window as WindowWithPostHog).posthog
183+
return ph?.get_session_id()
184+
})
185+
expect(initialSessionId).toBeDefined()
186+
187+
await page.resetCapturedEvents()
188+
189+
// Simulate "frozen tab" scenario:
190+
// Make the session appear to have been inactive for 35+ minutes
191+
// by manipulating the lastActivityTimestamp in persistence and clearing the in-memory cache
192+
// This simulates what happens when a tab is frozen and the forcedIdleReset timer never fires
193+
await page.evaluate(() => {
194+
const ph = (window as WindowWithPostHog).posthog
195+
const persistence = ph?.persistence as any
196+
const sessionManager = ph?.sessionManager as any
197+
198+
if (!persistence) {
199+
throw new Error('Persistence not available')
200+
}
201+
202+
if (!sessionManager) {
203+
throw new Error('SessionManager not available')
204+
}
205+
206+
// Get current session data (stored as [lastActivityTimestamp, sessionId, sessionStartTimestamp])
207+
const sessionIdKey = '$sesid'
208+
const currentSessionData = persistence.props[sessionIdKey]
209+
210+
if (!currentSessionData) {
211+
throw new Error('Session data not found')
212+
}
213+
214+
// Set the lastActivityTimestamp to 35 minutes ago
215+
// This simulates a frozen tab where no activity was recorded
216+
const thirtyFiveMinutesAgo = Date.now() - 35 * 60 * 1000
217+
currentSessionData[0] = thirtyFiveMinutesAgo
218+
219+
// Write back the modified session data
220+
persistence.register({ [sessionIdKey]: currentSessionData })
221+
222+
// Also clear the session manager's in-memory cache so it reads from persistence
223+
// This simulates what happens when a tab unfreezes and state needs to be re-read
224+
sessionManager._sessionActivityTimestamp = null
225+
})
226+
227+
// Now trigger user activity
228+
// This should detect that the session has been idle too long and rotate
229+
await page.waitingForNetworkCausedBy({
230+
urlPatternsToWaitFor: ['**/ses/*'],
231+
action: async () => {
232+
await page.locator('[data-cy-input]').type('activity after simulated freeze!')
233+
},
234+
})
235+
236+
const newSessionId = await page.evaluate(() => {
237+
const ph = (window as WindowWithPostHog).posthog
238+
return ph?.get_session_id()
239+
})
240+
241+
// The session should have rotated because we exceeded the idle timeout
242+
expect(newSessionId).not.toEqual(initialSessionId)
243+
244+
// Capture all snapshot data to see exactly what happened
245+
const capturedEvents = await page.capturedEvents()
246+
const snapshots = capturedEvents.filter((e) => e.event === '$snapshot')
247+
248+
// Collapse to essential fields: session_id, type, tag (for custom events), timestamp
249+
const snapshotSummary = snapshots.flatMap((snapshot) => {
250+
const sessionId = snapshot['properties']['$session_id']
251+
const snapshotData = snapshot['properties']['$snapshot_data'] as any[]
252+
return snapshotData.map((event) => ({
253+
sessionId: sessionId === initialSessionId ? 'initial' : sessionId === newSessionId ? 'new' : 'unknown',
254+
type: event.type,
255+
tag: event.data?.tag || null,
256+
timestamp: event.timestamp,
257+
}))
258+
})
259+
260+
// The key assertion: bootup events ($session_options, $posthog_config, $remote_config_received)
261+
// should be on the NEW session, not the initial one
262+
const bootupEventsOnInitialSession = snapshotSummary.filter(
263+
(e) =>
264+
e.sessionId === 'initial' &&
265+
e.type === 5 && // CustomEvent type
266+
['$session_options', '$posthog_config', '$remote_config_received'].includes(e.tag)
267+
)
268+
269+
expect(bootupEventsOnInitialSession).toEqual([])
160270
})
161271
})
162272

packages/browser/src/__tests__/sessionid.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,52 @@ describe('Session ID manager', () => {
524524
})
525525
})
526526

527+
describe('persistence is source of truth over in-memory cache', () => {
528+
// This test verifies that when persistence is updated (e.g., by another tab or after a frozen tab thaws),
529+
// the session manager reads from persistence rather than trusting stale in-memory cache
530+
531+
const memoryConfig = {
532+
persistence_name: 'test-session-memory',
533+
persistence: 'memory',
534+
token: 'test-token',
535+
} as PostHogConfig
536+
537+
it.each([
538+
{ description: 'with stale timestamp from simulated frozen tab', offsetMs: 1000 },
539+
{ description: 'with exactly expired timestamp', offsetMs: 1 },
540+
])('should detect activity timeout $description', ({ offsetMs }) => {
541+
const realPersistence = new PostHogPersistence(memoryConfig)
542+
const testTimestamp = 1603107479471
543+
544+
const sessionIdManager = new SessionIdManager(
545+
createMockPostHog({
546+
config: memoryConfig,
547+
persistence: realPersistence,
548+
register: jest.fn(),
549+
}),
550+
() => 'newUUID',
551+
() => 'newUUID'
552+
)
553+
554+
// First call establishes the session
555+
const firstResult = sessionIdManager.checkAndGetSessionAndWindowId(false, testTimestamp)
556+
expect(firstResult.sessionId).toBe('newUUID')
557+
558+
// Simulate persistence being updated externally to have a stale timestamp
559+
// In a frozen tab scenario, another tab might have updated persistence,
560+
// or time passed while the tab was frozen
561+
const staleTimestamp = testTimestamp - (DEFAULT_SESSION_IDLE_TIMEOUT_SECONDS * 1000 + offsetMs)
562+
realPersistence.register({ [SESSION_ID]: [staleTimestamp, 'oldSessionID', staleTimestamp] })
563+
564+
// Second call should read from persistence and detect the activity timeout
565+
const secondResult = sessionIdManager.checkAndGetSessionAndWindowId(false, testTimestamp)
566+
567+
// The session SHOULD rotate because persistence shows idle timeout exceeded
568+
expect(secondResult.changeReason?.activityTimeout).toBe(true)
569+
expect(secondResult.sessionId).not.toBe('oldSessionID')
570+
})
571+
})
572+
527573
describe('destroy()', () => {
528574
it('clears the idle timeout timer', () => {
529575
jest.useFakeTimers()

packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,7 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt
687687
}
688688

689689
// We want to ensure the sessionManager is reset if necessary on loading the recorder
690+
// This is idempotent - if session already rotated, it returns the same session ID
690691
const { sessionId, windowId } = this._sessionManager.checkAndGetSessionAndWindowId()
691692
this._sessionId = sessionId
692693
this._windowId = windowId
@@ -794,15 +795,20 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt
794795
private _onSessionIdCallback: SessionIdChangedCallback = (sessionId, windowId, changeReason) => {
795796
if (!changeReason) return
796797

798+
// Skip if session hasn't actually changed (callback might fire multiple times)
799+
if (sessionId === this._sessionId && windowId === this._windowId) {
800+
return
801+
}
802+
797803
const wasLikelyReset = changeReason.noSessionId
798804
const shouldLinkSessions =
799805
!wasLikelyReset && (changeReason.activityTimeout || changeReason.sessionPastMaximumLength)
800806

801-
let oldSessionId, oldWindowId
807+
// Capture old IDs before start() updates them
808+
const oldSessionId = this._sessionId
809+
const oldWindowId = this._windowId
802810

803811
if (shouldLinkSessions) {
804-
oldSessionId = this._sessionId
805-
oldWindowId = this._windowId
806812
this._tryAddCustomEvent('$session_ending', {
807813
nextSessionId: sessionId,
808814
nextWindowId: windowId,
@@ -823,9 +829,15 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt
823829

824830
this._clearConditionalRecordingPersistence()
825831

826-
if (!this._stopRrweb) {
827-
this.start('session_id_changed')
832+
// Restart recording to ensure new session gets proper bootup events
833+
// If rrweb IS running, we need to stop and restart to flush the old buffer
834+
// and emit fresh bootup events for the new session
835+
if (this._stopRrweb) {
836+
this.stop()
828837
}
838+
// start() calls checkAndGetSessionAndWindowId() which is idempotent -
839+
// session already rotated so it will return the same new session ID
840+
this.start('session_id_changed')
829841

830842
if (shouldLinkSessions) {
831843
this._tryAddCustomEvent('$session_starting', {

packages/browser/src/sessionid.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,9 +180,9 @@ export class SessionIdManager {
180180
}
181181

182182
private _getSessionId(): [number, string, number] {
183-
if (this._sessionId && this._sessionActivityTimestamp && this._sessionStartTimestamp) {
184-
return [this._sessionActivityTimestamp, this._sessionId, this._sessionStartTimestamp]
185-
}
183+
// Always read from persistence - it's the source of truth
184+
// The in-memory cache could become stale (e.g., in a frozen tab scenario where
185+
// time passes but the cache isn't updated)
186186
const sessionIdInfo = this._persistence.props[SESSION_ID]
187187

188188
if (isArray(sessionIdInfo) && sessionIdInfo.length === 2) {

0 commit comments

Comments
 (0)