@@ -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
0 commit comments