diff --git a/src/__tests__/extensions/replay/sessionrecording.test.ts b/src/__tests__/extensions/replay/sessionrecording.test.ts index 1409004fc..bb86771f7 100644 --- a/src/__tests__/extensions/replay/sessionrecording.test.ts +++ b/src/__tests__/extensions/replay/sessionrecording.test.ts @@ -9,7 +9,11 @@ import { SESSION_RECORDING_RECORDER_VERSION_SERVER_SIDE, } from '../../../constants' import { SessionIdManager } from '../../../sessionid' -import { INCREMENTAL_SNAPSHOT_EVENT_TYPE, META_EVENT_TYPE } from '../../../extensions/replay/sessionrecording-utils' +import { + FULL_SNAPSHOT_EVENT_TYPE, + INCREMENTAL_SNAPSHOT_EVENT_TYPE, + META_EVENT_TYPE, +} from '../../../extensions/replay/sessionrecording-utils' import { PostHog } from '../../../posthog-core' import { DecideResponse, PostHogConfig, Property, SessionIdChangedCallback } from '../../../types' import { uuidv7 } from '../../../uuidv7' @@ -29,6 +33,19 @@ jest.mock('../../../utils', () => ({ })) jest.mock('../../../config', () => ({ LIB_VERSION: 'v0.0.1' })) +const EMPTY_BUFFER = { + data: [], + sessionId: null, + size: 0, + windowId: null, +} + +const createFullSnapshot = (event = {}) => ({ + type: FULL_SNAPSHOT_EVENT_TYPE, + data: {}, + ...event, +}) + const createIncrementalSnapshot = (event = {}) => ({ type: INCREMENTAL_SNAPSHOT_EVENT_TYPE, data: { @@ -58,7 +75,10 @@ describe('SessionRecording', () => { _emit = emit return () => {} }) - assignableWindow.rrwebRecord.takeFullSnapshot = jest.fn() + assignableWindow.rrwebRecord.takeFullSnapshot = jest.fn(() => { + // we pretend to be rrweb and call emit + _emit(createFullSnapshot()) + }) assignableWindow.rrwebRecord.addCustomEvent = _addCustomEvent assignableWindow.rrwebConsoleRecord = { @@ -229,9 +249,16 @@ describe('SessionRecording', () => { sessionRecording.startRecordingIfEnabled() expect(loadScript).toHaveBeenCalled() expect(sessionRecording['status']).toBe('buffering') + expect(sessionRecording['buffer']).toEqual(EMPTY_BUFFER) - _emit(createIncrementalSnapshot({ data: { source: 1 } })) - expect(sessionRecording['buffer']?.data.length).toEqual(1) + const incrementalSnapshot = createIncrementalSnapshot({ data: { source: 1 } }) + _emit(incrementalSnapshot) + expect(sessionRecording['buffer']).toEqual({ + data: [createFullSnapshot(), incrementalSnapshot], + sessionId: sessionId, + size: 50, + windowId: 'windowId', + }) sessionRecording.afterDecideResponse(makeDecideResponse({ sessionRecording: undefined })) expect(sessionRecording['status']).toBe('disabled') @@ -427,6 +454,7 @@ describe('SessionRecording', () => { expect(sessionRecording['buffer']).toEqual({ data: [ + createFullSnapshot(), { data: { source: 1, @@ -436,7 +464,7 @@ describe('SessionRecording', () => { ], // session id and window id are not null 🚀 sessionId: sessionId, - size: 30, + size: 50, windowId: 'windowId', }) @@ -454,8 +482,9 @@ describe('SessionRecording', () => { expect(posthog.capture).toHaveBeenCalledWith( '$snapshot', { - $snapshot_bytes: 60, + $snapshot_bytes: 80, $snapshot_data: [ + createFullSnapshot(), { type: 3, data: { source: 1 } }, { type: 3, data: { source: 2 } }, ], @@ -493,8 +522,9 @@ describe('SessionRecording', () => { { $session_id: sessionId, $window_id: 'windowId', - $snapshot_bytes: 60, + $snapshot_bytes: 80, $snapshot_data: [ + createFullSnapshot(), { type: 3, data: { source: 1 } }, { type: 3, data: { source: 2 } }, ], @@ -521,7 +551,7 @@ describe('SessionRecording', () => { _emit(createIncrementalSnapshot({ data: { source: 1, payload: 2 } })) expect(posthog.capture).not.toHaveBeenCalled() - expect(sessionRecording['buffer']).toMatchObject({ size: 755101 }) + expect(sessionRecording['buffer']).toMatchObject({ size: 755121 }) // Another big event means the old data will be flushed _emit(createIncrementalSnapshot({ data: { source: 1, payload: bigData } })) @@ -537,21 +567,22 @@ describe('SessionRecording', () => { const bigData = 'a'.repeat(RECORDING_MAX_EVENT_SIZE * 0.8) _emit(createIncrementalSnapshot({ data: { source: 1, payload: bigData } })) - expect(sessionRecording['buffer']).toMatchObject({ size: 755017 }) // the size of the big data event + expect(sessionRecording['buffer']).toMatchObject({ size: 755037 }) // the size of the big data event + expect(sessionRecording['buffer']?.data.length).toEqual(2) // full snapshot and a big event _emit(createIncrementalSnapshot({ data: { source: 1, payload: 1 } })) _emit(createIncrementalSnapshot({ data: { source: 1, payload: 2 } })) expect(posthog.capture).not.toHaveBeenCalled() - expect(sessionRecording['buffer']).toMatchObject({ size: 755101 }) + expect(sessionRecording['buffer']).toMatchObject({ size: 755121 }) // Another big event means the old data will be flushed _emit(createIncrementalSnapshot({ data: { source: 1, payload: bigData } })) // but the recording is still buffering expect(sessionRecording['status']).toBe('buffering') expect(posthog.capture).not.toHaveBeenCalled() - expect(sessionRecording['buffer']?.data.length).toEqual(4) // The new event - expect(sessionRecording['buffer']).toMatchObject({ size: 755017 + 755101 }) // the size of the big data event + expect(sessionRecording['buffer']?.data.length).toEqual(5) // + the new event + expect(sessionRecording['buffer']).toMatchObject({ size: 755037 + 755101 }) // the size of the big data event }) it('flushes buffer if the session_id changes', () => { @@ -564,7 +595,10 @@ describe('SessionRecording', () => { expect(posthog.capture).not.toHaveBeenCalled() expect(sessionRecording['buffer']?.sessionId).not.toEqual(null) - expect(sessionRecording['buffer']?.data).toEqual([{ data: { source: 1 }, emit: 1, type: 3 }]) + expect(sessionRecording['buffer']?.data).toEqual([ + createFullSnapshot(), + { data: { source: 1 }, emit: 1, type: 3 }, + ]) // Not exactly right but easier to test than rotating the session id // this simulates as the session id changing _after_ it has initially been set @@ -577,8 +611,8 @@ describe('SessionRecording', () => { { $session_id: 'otherSessionId', $window_id: 'windowId', - $snapshot_data: [{ data: { source: 1 }, emit: 1, type: 3 }], - $snapshot_bytes: 39, + $snapshot_data: [createFullSnapshot(), { data: { source: 1 }, emit: 1, type: 3 }], + $snapshot_bytes: 59, }, { method: 'POST', @@ -969,77 +1003,255 @@ describe('SessionRecording', () => { expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(2) }) }) + }) + }) - describe('idle timeouts', () => { - it("enters idle state if the activity is non-user generated and there's no activity for 5 seconds", () => { - sessionRecording.startRecordingIfEnabled() - const lastActivityTimestamp = sessionRecording['_lastActivityTimestamp'] - expect(lastActivityTimestamp).toBeGreaterThan(0) + describe('idle timeouts', () => { + let startingTimestamp = -1 - expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(0) + function emitInactiveEvent(activityTimestamp: number, expectIdle: boolean = false) { + const snapshotEvent = { + event: 123, + type: INCREMENTAL_SNAPSHOT_EVENT_TYPE, + data: { + source: 0, + }, + timestamp: activityTimestamp, + } + _emit(snapshotEvent) + expect(sessionRecording['isIdle']).toEqual(expectIdle) + return snapshotEvent + } - _emit({ - event: 123, - type: INCREMENTAL_SNAPSHOT_EVENT_TYPE, - data: { - source: 1, - }, - timestamp: lastActivityTimestamp + 100, - }) - expect(sessionRecording['isIdle']).toEqual(false) - expect(sessionRecording['_lastActivityTimestamp']).toEqual(lastActivityTimestamp + 100) + function emitActiveEvent(activityTimestamp: number) { + const snapshotEvent = { + event: 123, + type: INCREMENTAL_SNAPSHOT_EVENT_TYPE, + data: { + source: 1, + }, + timestamp: activityTimestamp, + } + _emit(snapshotEvent) + expect(sessionRecording['isIdle']).toEqual(false) + expect(sessionRecording['_lastActivityTimestamp']).toEqual(activityTimestamp) + return snapshotEvent + } - expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) + beforeEach(() => { + sessionRecording.startRecordingIfEnabled() + sessionRecording.afterDecideResponse(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) + expect(sessionRecording['status']).toEqual('active') - _emit({ - event: 123, - type: INCREMENTAL_SNAPSHOT_EVENT_TYPE, - data: { - source: 0, - }, - timestamp: lastActivityTimestamp + 200, - }) - expect(sessionRecording['isIdle']).toEqual(false) - expect(sessionRecording['_lastActivityTimestamp']).toEqual(lastActivityTimestamp + 100) - expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) + startingTimestamp = sessionRecording['_lastActivityTimestamp'] + expect(startingTimestamp).toBeGreaterThan(0) - // this triggers idle state and isn't a user interaction so does not take a full snapshot - _emit({ - event: 123, - type: INCREMENTAL_SNAPSHOT_EVENT_TYPE, - data: { - source: 0, - }, - timestamp: lastActivityTimestamp + RECORDING_IDLE_ACTIVITY_TIMEOUT_MS + 1000, - }) - expect(sessionRecording['isIdle']).toEqual(true) - expect(_addCustomEvent).toHaveBeenCalledWith('sessionIdle', { - reason: 'user inactivity', - threshold: 300000, - timeSinceLastActive: 300900, - }) - expect(sessionRecording['_lastActivityTimestamp']).toEqual(lastActivityTimestamp + 100) - expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(0) - // this triggers idle state _and_ is a user interaction, so we take a full snapshot - _emit({ - event: 123, - type: INCREMENTAL_SNAPSHOT_EVENT_TYPE, - data: { - source: 1, - }, - timestamp: lastActivityTimestamp + RECORDING_IDLE_ACTIVITY_TIMEOUT_MS + 2000, - }) - expect(sessionRecording['isIdle']).toEqual(false) - expect(_addCustomEvent).toHaveBeenCalledWith('sessionNoLongerIdle', { - reason: 'user activity', - type: INCREMENTAL_SNAPSHOT_EVENT_TYPE, - }) - expect(sessionRecording['_lastActivityTimestamp']).toEqual( - lastActivityTimestamp + RECORDING_IDLE_ACTIVITY_TIMEOUT_MS + 2000 - ) - expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(2) - }) + // the buffer starts out empty + expect(sessionRecording['buffer']).toEqual({ + data: [], + sessionId: null, + size: 0, + windowId: null, + }) + + // options will have been emitted + expect(_addCustomEvent).toHaveBeenCalled() + _addCustomEvent.mockClear() + }) + + it("enters idle state within one session if the activity is non-user generated and there's no activity for (RECORDING_IDLE_ACTIVITY_TIMEOUT_MS) 5 minutes", () => { + const firstActivityTimestamp = startingTimestamp + 100 + const secondActivityTimestamp = startingTimestamp + 200 + const thirdActivityTimestamp = startingTimestamp + RECORDING_IDLE_ACTIVITY_TIMEOUT_MS + 1000 + const fourthActivityTimestamp = startingTimestamp + RECORDING_IDLE_ACTIVITY_TIMEOUT_MS + 2000 + + const firstSnapshotEvent = emitActiveEvent(firstActivityTimestamp) + // event was active so activity timestamp is updated + expect(sessionRecording['_lastActivityTimestamp']).toEqual(firstActivityTimestamp) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) + + // after the first emit the buffer has been initialised but not flushed + const firstSessionId = sessionRecording['sessionId'] + expect(sessionRecording['buffer']).toEqual({ + data: [createFullSnapshot(), firstSnapshotEvent], + sessionId: firstSessionId, + size: 88, + windowId: expect.any(String), + }) + + // the session id generator returns a fixed value, but we want it to rotate in part of this test + sessionIdGeneratorMock.mockClear() + const rotatedSessionId = 'rotated-session-id' + sessionIdGeneratorMock.mockImplementation(() => rotatedSessionId) + + const secondSnapshot = emitInactiveEvent(secondActivityTimestamp, false) + // event was not active so activity timestamp is not updated + expect(sessionRecording['_lastActivityTimestamp']).toEqual(firstActivityTimestamp) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) + + // the second snapshot remains buffered in memory + expect(sessionRecording['buffer']).toEqual({ + data: [createFullSnapshot(), firstSnapshotEvent, secondSnapshot], + sessionId: firstSessionId, + size: 156, + windowId: expect.any(String), + }) + + // this triggers idle state and isn't a user interaction so does not take a full snapshot + emitInactiveEvent(thirdActivityTimestamp, true) + expect(_addCustomEvent).toHaveBeenCalledWith('sessionIdle', { + reason: 'user inactivity', + threshold: 300000, + timeSinceLastActive: 300900, + }) + // event was not active so activity timestamp is not updated + expect(sessionRecording['_lastActivityTimestamp']).toEqual(firstActivityTimestamp) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) + + // the custom event doesn't show here since there's not a real rrweb to emit it + expect(sessionRecording['buffer']).toEqual({ + data: [ + createFullSnapshot(), + firstSnapshotEvent, + secondSnapshot, + // the third snapshot is dropped since it switches the session to idle + ], + sessionId: firstSessionId, + size: 156, + windowId: expect.any(String), + }) + + // this triggers exit from idle state _and_ is a user interaction, so we take a full snapshot + + const fourthSnapshot = emitActiveEvent(fourthActivityTimestamp) + expect(_addCustomEvent).toHaveBeenCalledWith('sessionNoLongerIdle', { + reason: 'user activity', + type: INCREMENTAL_SNAPSHOT_EVENT_TYPE, + }) + expect(sessionRecording['_lastActivityTimestamp']).toEqual(fourthActivityTimestamp) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(2) + + // the fourth snapshot should not trigger a flush because the session id has not changed... + expect(sessionRecording['buffer']).toEqual({ + // as we return from idle we will capture a full snapshot _before_ the fourth snapshot + data: [createFullSnapshot(), firstSnapshotEvent, secondSnapshot, createFullSnapshot(), fourthSnapshot], + sessionId: firstSessionId, + size: 244, + windowId: expect.any(String), + }) + + // because not enough time passed while idle we still have the same session id at the end of this sequence + const endingSessionId = sessionRecording['sessionId'] + expect(endingSessionId).toEqual(firstSessionId) + }) + + it('rotates session if idle for (MAX_SESSION_IDLE_TIMEOUT) 30 minutes', () => { + const firstActivityTimestamp = startingTimestamp + 100 + const secondActivityTimestamp = startingTimestamp + 200 + const thirdActivityTimestamp = sessionManager['_sessionTimeoutMs'] + startingTimestamp + 1 + const fourthActivityTimestamp = sessionManager['_sessionTimeoutMs'] + startingTimestamp + 1000 + + const firstSnapshotEvent = emitActiveEvent(firstActivityTimestamp) + // event was active so activity timestamp is updated + expect(sessionRecording['_lastActivityTimestamp']).toEqual(firstActivityTimestamp) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) + + // after the first emit the buffer has been initialised but not flushed + const firstSessionId = sessionRecording['sessionId'] + expect(sessionRecording['buffer']).toEqual({ + data: [createFullSnapshot(), firstSnapshotEvent], + sessionId: firstSessionId, + size: 88, + windowId: expect.any(String), + }) + + // the session id generator returns a fixed value, but we want it to rotate in part of this test + sessionIdGeneratorMock.mockClear() + const rotatedSessionId = 'rotated-session-id' + sessionIdGeneratorMock.mockImplementation(() => rotatedSessionId) + + const secondSnapshot = emitInactiveEvent(secondActivityTimestamp, false) + // event was not active so activity timestamp is not updated + expect(sessionRecording['_lastActivityTimestamp']).toEqual(firstActivityTimestamp) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) + + // the second snapshot remains buffered in memory + expect(sessionRecording['buffer']).toEqual({ + data: [createFullSnapshot(), firstSnapshotEvent, secondSnapshot], + sessionId: firstSessionId, + size: 156, + windowId: expect.any(String), + }) + + // this triggers idle state and isn't a user interaction so does not take a full snapshot + + emitInactiveEvent(thirdActivityTimestamp, true) + expect(_addCustomEvent).toHaveBeenCalledWith('sessionIdle', { + reason: 'user inactivity', + threshold: 300000, + timeSinceLastActive: 1799901, + }) + // event was not active so activity timestamp is not updated + expect(sessionRecording['_lastActivityTimestamp']).toEqual(firstActivityTimestamp) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(1) + + // the third snapshot is dropped since it switches the session to idle + // the custom event doesn't show here since there's not a real rrweb to emit it + expect(sessionRecording['buffer']).toEqual({ + data: [ + createFullSnapshot(), + firstSnapshotEvent, + secondSnapshot, + // the third snapshot is dropped since it switches the session to idle + ], + sessionId: firstSessionId, + size: 156, + windowId: expect.any(String), + }) + + // at this point nothing has caused the session to be flushed to the backend + expect(posthog.capture).not.toHaveBeenCalled() + + // this triggers exit from idle state _and_ is a user interaction, so we take a full snapshot + + const fourthSnapshot = emitActiveEvent(fourthActivityTimestamp) + expect(_addCustomEvent).toHaveBeenCalledWith('sessionNoLongerIdle', { + reason: 'user activity', + type: INCREMENTAL_SNAPSHOT_EVENT_TYPE, + }) + expect(sessionRecording['_lastActivityTimestamp']).toEqual(fourthActivityTimestamp) + expect(assignableWindow.rrwebRecord.takeFullSnapshot).toHaveBeenCalledTimes(2) + + // the fourth snapshot causes the session id to change + expect(sessionIdGeneratorMock).toHaveBeenCalledTimes(1) + const endingSessionId = sessionRecording['sessionId'] + expect(endingSessionId).toEqual(rotatedSessionId) + + // the buffer is flushed, and a full snapshot is taken + expect(posthog.capture).toHaveBeenCalledWith( + '$snapshot', + { + $snapshot_data: [createFullSnapshot(), firstSnapshotEvent, secondSnapshot], + $session_id: firstSessionId, + $snapshot_bytes: 156, + $window_id: expect.any(String), + }, + { + _batchKey: 'recordings', + _metrics: { rrweb_full_snapshot: false }, + _noTruncate: true, + endpoint: '/s/', + method: 'POST', + transport: 'XHR', + } + ) + expect(sessionRecording['buffer']).toEqual({ + data: [createFullSnapshot(), fourthSnapshot], + sessionId: rotatedSessionId, + size: 88, + windowId: expect.any(String), }) }) }) @@ -1119,7 +1331,7 @@ describe('SessionRecording', () => { expect(sessionRecording['sessionDuration']).toBe(100) expect(sessionRecording['_minimumDuration']).toBe(1500) - expect(sessionRecording['buffer']?.data.length).toBe(1) + expect(sessionRecording['buffer']?.data.length).toBe(2) // full snapshot and the emitted incremental event // call the private method to avoid waiting for the timer sessionRecording['_flushBuffer']() @@ -1144,7 +1356,7 @@ describe('SessionRecording', () => { expect(sessionRecording['sessionDuration']).toBe(-1000) expect(sessionRecording['_minimumDuration']).toBe(1500) - expect(sessionRecording['buffer']?.data.length).toBe(1) + expect(sessionRecording['buffer']?.data.length).toBe(2) // full snapshot and the emitted incremental event // call the private method to avoid waiting for the timer sessionRecording['_flushBuffer']() @@ -1164,7 +1376,7 @@ describe('SessionRecording', () => { expect(sessionRecording['sessionDuration']).toBe(100) expect(sessionRecording['_minimumDuration']).toBe(1500) - expect(sessionRecording['buffer']?.data.length).toBe(1) + expect(sessionRecording['buffer']?.data.length).toBe(2) // full snapshot and the emitted incremental event // call the private method to avoid waiting for the timer sessionRecording['_flushBuffer']() @@ -1172,7 +1384,7 @@ describe('SessionRecording', () => { _emit(createIncrementalSnapshot({ data: { source: 1 }, timestamp: sessionStartTimestamp + 1501 })) - expect(sessionRecording['buffer']?.data.length).toBe(2) + expect(sessionRecording['buffer']?.data.length).toBe(3) // full snapshot and two emitted incremental events // call the private method to avoid waiting for the timer sessionRecording['_flushBuffer']() diff --git a/src/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts index d3c71eeab..3b0f8a75d 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -397,6 +397,7 @@ export class SessionRecording { } } + let returningFromIdle = false if (isUserInteraction) { this._lastActivityTimestamp = event.timestamp if (this.isIdle) { @@ -406,7 +407,7 @@ export class SessionRecording { reason: 'user activity', type: event.type, }) - this._tryTakeFullSnapshot() + returningFromIdle = true } } @@ -423,15 +424,16 @@ export class SessionRecording { const sessionIdChanged = this.sessionId !== sessionId const windowIdChanged = this.windowId !== windowId + this.windowId = windowId + this.sessionId = sessionId + if ( - [FULL_SNAPSHOT_EVENT_TYPE, META_EVENT_TYPE].indexOf(event.type) === -1 && - (windowIdChanged || sessionIdChanged) + returningFromIdle || + ([FULL_SNAPSHOT_EVENT_TYPE, META_EVENT_TYPE].indexOf(event.type) === -1 && + (windowIdChanged || sessionIdChanged)) ) { this._tryTakeFullSnapshot() } - - this.windowId = windowId - this.sessionId = sessionId } private _tryRRwebMethod(rrwebMethod: () => void): boolean {