diff --git a/src/__tests__/extensions/replay/sessionrecording.test.ts b/src/__tests__/extensions/replay/sessionrecording.test.ts index a0c676588..a29bd759c 100644 --- a/src/__tests__/extensions/replay/sessionrecording.test.ts +++ b/src/__tests__/extensions/replay/sessionrecording.test.ts @@ -27,11 +27,6 @@ import { SessionRecordingOptions, } from '../../../types' import { uuidv7 } from '../../../uuidv7' -import { - RECORDING_IDLE_THRESHOLD_MS, - RECORDING_MAX_EVENT_SIZE, - SessionRecording, -} from '../../../extensions/replay/sessionrecording' import { assignableWindow, window } from '../../../utils/globals' import { RequestRouter } from '../../../utils/request-router' import { @@ -49,6 +44,11 @@ import Mock = jest.Mock import { ConsentManager } from '../../../consent' import { waitFor } from '@testing-library/preact' import { SimpleEventEmitter } from '../../../utils/simple-event-emitter' +import LazyLoadedSessionRecording, { + RECORDING_IDLE_THRESHOLD_MS, + RECORDING_MAX_EVENT_SIZE, +} from '../../../entrypoints/session-recorder' +import SessionRecorder from '../../../entrypoints/session-recorder' // Type and source defined here designate a non-user-generated recording event @@ -177,7 +177,7 @@ describe('SessionRecording', () => { const loadScriptMock = jest.fn() let _emit: any let posthog: PostHog - let sessionRecording: SessionRecording + let sessionRecording: LazyLoadedSessionRecording let sessionId: string let sessionManager: SessionIdManager let config: PostHogConfig @@ -285,7 +285,7 @@ describe('SessionRecording', () => { [SESSION_RECORDING_IS_SAMPLED]: undefined, }) - sessionRecording = new SessionRecording(posthog) + sessionRecording = new LazyLoadedSessionRecording(posthog) }) afterEach(() => { @@ -400,38 +400,38 @@ describe('SessionRecording', () => { ) }) - describe('startIfEnabledOrStop', () => { + describe('start', () => { beforeEach(() => { // need to cast as any to mock private methods jest.spyOn(sessionRecording as any, '_startCapture') - jest.spyOn(sessionRecording, 'stopRecording') + jest.spyOn(sessionRecording, 'stop') jest.spyOn(sessionRecording as any, '_tryAddCustomEvent') }) it('call _startCapture if its enabled', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect((sessionRecording as any)._startCapture).toHaveBeenCalled() }) it('sets the pageview capture hook once', () => { expect(sessionRecording['_removePageViewCaptureHook']).toBeUndefined() - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(sessionRecording['_removePageViewCaptureHook']).not.toBeUndefined() expect(posthog.on).toHaveBeenCalledTimes(1) // calling a second time doesn't add another capture hook - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(posthog.on).toHaveBeenCalledTimes(1) }) it('removes the pageview capture hook on stop', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(sessionRecording['_removePageViewCaptureHook']).not.toBeUndefined() expect(removePageviewCaptureHookMock).not.toHaveBeenCalled() - sessionRecording.stopRecording() + sessionRecording.stop() expect(removePageviewCaptureHookMock).toHaveBeenCalledTimes(1) expect(sessionRecording['_removePageViewCaptureHook']).toBeUndefined() @@ -442,7 +442,7 @@ describe('SessionRecording', () => { const addEventListener = jest.fn().mockImplementation(() => () => {}) window.addEventListener = addEventListener - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(sessionRecording['_onBeforeUnload']).not.toBeNull() // we register 4 event listeners expect(window.addEventListener).toHaveBeenCalledTimes(4) @@ -452,7 +452,7 @@ describe('SessionRecording', () => { }) it('emits an options event', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect((sessionRecording as any)['_tryAddCustomEvent']).toHaveBeenCalledWith('$session_options', { activePlugins: [], sessionRecordingOptions: { @@ -473,16 +473,16 @@ describe('SessionRecording', () => { }) }) - it('call stopRecording if its not enabled', () => { + it('call stop if its not enabled', () => { posthog.config.disable_session_recording = true - sessionRecording.startIfEnabledOrStop() - expect(sessionRecording.stopRecording).toHaveBeenCalled() + sessionRecording.start() + expect(sessionRecording.stop).toHaveBeenCalled() }) }) describe('afterDecideResponse()', () => { beforeEach(() => { - jest.spyOn(sessionRecording, 'startIfEnabledOrStop') + jest.spyOn(sessionRecording, 'start') }) it('loads script based on script config', () => { @@ -495,7 +495,7 @@ describe('SessionRecording', () => { }) it('when the first event is a meta it does not take a manual full snapshot', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(loadScriptMock).toHaveBeenCalled() expect(sessionRecording['status']).toBe('buffering') expect(sessionRecording['buffer']).toEqual({ @@ -515,7 +515,7 @@ describe('SessionRecording', () => { }) it('when the first event is a full snapshot it does not take a manual full snapshot', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(loadScriptMock).toHaveBeenCalled() expect(sessionRecording['status']).toBe('buffering') expect(sessionRecording['buffer']).toEqual({ @@ -535,7 +535,7 @@ describe('SessionRecording', () => { }) it('buffers snapshots until decide is received and drops them if disabled', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(loadScriptMock).toHaveBeenCalled() expect(sessionRecording['status']).toBe('buffering') expect(sessionRecording['buffer']).toEqual({ @@ -560,7 +560,7 @@ describe('SessionRecording', () => { }) it('emit is not active until decide is called', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(loadScriptMock).toHaveBeenCalled() expect(sessionRecording['status']).toBe('buffering') @@ -569,7 +569,7 @@ describe('SessionRecording', () => { }) it('sample rate is null when decide does not return it', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(loadScriptMock).toHaveBeenCalled() expect(sessionRecording['isSampled']).toBe(null) @@ -630,7 +630,7 @@ describe('SessionRecording', () => { }) ) - expect(sessionRecording.startIfEnabledOrStop).toHaveBeenCalled() + expect(sessionRecording.start).toHaveBeenCalled() expect(loadScriptMock).toHaveBeenCalled() expect(posthog.get_property(SESSION_RECORDING_ENABLED_SERVER_SIDE)).toBe(true) expect(sessionRecording['_endpoint']).toEqual('/ses/') @@ -640,7 +640,7 @@ describe('SessionRecording', () => { describe('recording', () => { describe('sampling', () => { it('does not emit to capture if the sample rate is 0', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() sessionRecording.onRemoteConfig( makeDecideResponse({ @@ -655,7 +655,7 @@ describe('SessionRecording', () => { }) it('does emit to capture if the sample rate is null', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() sessionRecording.onRemoteConfig( makeDecideResponse({ @@ -667,7 +667,7 @@ describe('SessionRecording', () => { }) it('stores excluded session when excluded', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() sessionRecording.onRemoteConfig( makeDecideResponse({ @@ -679,7 +679,7 @@ describe('SessionRecording', () => { }) it('does emit to capture if the sample rate is 1', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() _emit(createIncrementalSnapshot({ data: { source: 1 } })) expect(posthog.capture).not.toHaveBeenCalled() @@ -702,7 +702,7 @@ describe('SessionRecording', () => { }) it('sets emit as expected when sample rate is 0.5', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() sessionRecording.onRemoteConfig( makeDecideResponse({ @@ -740,7 +740,7 @@ describe('SessionRecording', () => { }, }) - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() sessionRecording['_onScriptLoaded']() expect(assignableWindow.__PosthogExtensions__.rrweb.record).toHaveBeenCalledWith( @@ -756,7 +756,7 @@ describe('SessionRecording', () => { }) it('skips when any config variable is missing', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() sessionRecording.onRemoteConfig( makeDecideResponse({ @@ -776,7 +776,7 @@ describe('SessionRecording', () => { it('calls rrweb.record with the right options', () => { posthog.persistence?.register({ [CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE]: false }) - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() // maskAllInputs should change from default // someUnregisteredProp should not be present expect(assignableWindow.__PosthogExtensions__.rrweb.record).toHaveBeenCalledWith({ @@ -806,7 +806,7 @@ describe('SessionRecording', () => { ['password set to false', { maskInputOptions: { password: false } } as SessionRecordingOptions, false], ])('%s', (_name: string, session_recording: SessionRecordingOptions, expected: boolean) => { posthog.config.session_recording = session_recording - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(assignableWindow.__PosthogExtensions__.rrweb.record).toHaveBeenCalledWith( expect.objectContaining({ maskInputOptions: expect.objectContaining({ password: expected }), @@ -816,7 +816,7 @@ describe('SessionRecording', () => { }) it('records events emitted before and after starting recording', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(loadScriptMock).toHaveBeenCalled() _emit(createIncrementalSnapshot({ data: { source: 1 } })) @@ -870,7 +870,7 @@ describe('SessionRecording', () => { it('buffers emitted events', () => { sessionRecording.onRemoteConfig(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(loadScriptMock).toHaveBeenCalled() _emit(createIncrementalSnapshot({ data: { source: 1 } })) @@ -905,7 +905,7 @@ describe('SessionRecording', () => { it('flushes buffer if the size of the buffer hits the limit', () => { sessionRecording.onRemoteConfig(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(loadScriptMock).toHaveBeenCalled() const bigData = 'a'.repeat(RECORDING_MAX_EVENT_SIZE * 0.8) @@ -924,7 +924,7 @@ describe('SessionRecording', () => { }) it('maintains the buffer if the recording is buffering', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(loadScriptMock).toHaveBeenCalled() const bigData = 'a'.repeat(RECORDING_MAX_EVENT_SIZE * 0.8) @@ -950,7 +950,7 @@ describe('SessionRecording', () => { it('flushes buffer if the session_id changes', () => { sessionRecording.onRemoteConfig(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(sessionRecording['buffer'].sessionId).toEqual(sessionId) @@ -1001,12 +1001,12 @@ describe('SessionRecording', () => { it("doesn't load recording script if already loaded", () => { addRRwebToWindow() - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(loadScriptMock).not.toHaveBeenCalled() }) it('loads recording script from right place', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(loadScriptMock).toHaveBeenCalledWith(expect.anything(), 'recorder', expect.anything()) }) @@ -1014,7 +1014,7 @@ describe('SessionRecording', () => { it('loads script after `_startCapture` if not previously loaded', () => { posthog.persistence?.register({ [SESSION_RECORDING_ENABLED_SERVER_SIDE]: false }) - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(loadScriptMock).not.toHaveBeenCalled() sessionRecording['_startCapture']() @@ -1025,7 +1025,7 @@ describe('SessionRecording', () => { it('does not load script if disable_session_recording passed', () => { posthog.config.disable_session_recording = true - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() sessionRecording['_startCapture']() expect(loadScriptMock).not.toHaveBeenCalled() @@ -1034,12 +1034,12 @@ describe('SessionRecording', () => { it('session recording can be turned on and off', () => { expect(sessionRecording['stopRrweb']).toEqual(undefined) - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(sessionRecording.started).toEqual(true) expect(sessionRecording['stopRrweb']).not.toEqual(undefined) - sessionRecording.stopRecording() + sessionRecording.stop() expect(sessionRecording['stopRrweb']).toEqual(undefined) expect(sessionRecording.started).toEqual(false) @@ -1048,12 +1048,12 @@ describe('SessionRecording', () => { it('session recording can be turned on after being turned off', () => { expect(sessionRecording['stopRrweb']).toEqual(undefined) - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(sessionRecording.started).toEqual(true) expect(sessionRecording['stopRrweb']).not.toEqual(undefined) - sessionRecording.stopRecording() + sessionRecording.stop() expect(sessionRecording['stopRrweb']).toEqual(undefined) expect(sessionRecording.started).toEqual(false) @@ -1062,7 +1062,7 @@ describe('SessionRecording', () => { it('can emit when there are circular references', () => { posthog.config.session_recording.compress_events = false sessionRecording.onRemoteConfig(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() const someObject = { emit: 1 } // the same object can be there multiple times @@ -1105,7 +1105,7 @@ describe('SessionRecording', () => { it('if not enabled, plugin is not used', () => { posthog.config.enable_recording_console_log = false - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect( assignableWindow.__PosthogExtensions__.rrwebPlugins.getRecordConsolePlugin @@ -1115,7 +1115,7 @@ describe('SessionRecording', () => { it('if enabled, plugin is used', () => { posthog.config.enable_recording_console_log = true - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(assignableWindow.__PosthogExtensions__.rrwebPlugins.getRecordConsolePlugin).toHaveBeenCalled() }) @@ -1151,7 +1151,7 @@ describe('SessionRecording', () => { expect(mockCallback).not.toHaveBeenCalled() - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() sessionRecording['_startCapture']() expect(mockCallback).toHaveBeenCalledTimes(1) @@ -1236,7 +1236,7 @@ describe('SessionRecording', () => { } as unknown as PostHog) posthog.sessionManager = sessionManager - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() sessionRecording['_startCapture']() }) @@ -1260,8 +1260,8 @@ describe('SessionRecording', () => { it('restarts recording if the session is rotated because session has been inactive for 30 minutes', () => { const startingSessionId = sessionManager['_getSessionId']()[1] - sessionRecording.stopRecording = jest.fn() - sessionRecording.startIfEnabledOrStop = jest.fn() + sessionRecording.stop = jest.fn() + sessionRecording.start = jest.fn() emitAtDateTime(startingDate) emitAtDateTime( @@ -1284,15 +1284,15 @@ describe('SessionRecording', () => { emitAtDateTime(inactivityThresholdLater) expect(sessionManager['_getSessionId']()[1]).not.toEqual(startingSessionId) - expect(sessionRecording.stopRecording).toHaveBeenCalled() - expect(sessionRecording.startIfEnabledOrStop).toHaveBeenCalled() + expect(sessionRecording.stop).toHaveBeenCalled() + expect(sessionRecording.start).toHaveBeenCalled() }) it('restarts recording if the session is rotated because max time has passed', () => { const startingSessionId = sessionManager['_getSessionId']()[1] - sessionRecording.stopRecording = jest.fn() - sessionRecording.startIfEnabledOrStop = jest.fn() + sessionRecording.stop = jest.fn() + sessionRecording.start = jest.fn() emitAtDateTime(startingDate) emitAtDateTime( @@ -1315,8 +1315,8 @@ describe('SessionRecording', () => { expect(sessionManager['_getSessionId']()[1]).not.toEqual(startingSessionId) - expect(sessionRecording.stopRecording).toHaveBeenCalled() - expect(sessionRecording.startIfEnabledOrStop).toHaveBeenCalled() + expect(sessionRecording.stop).toHaveBeenCalled() + expect(sessionRecording.start).toHaveBeenCalled() }) }) }) @@ -1361,7 +1361,7 @@ describe('SessionRecording', () => { } beforeEach(() => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() sessionRecording.onRemoteConfig(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) expect(sessionRecording['status']).toEqual('active') @@ -1843,13 +1843,13 @@ describe('SessionRecording', () => { describe('buffering minimum duration', () => { it('can report no duration when no data', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(sessionRecording['status']).toBe('buffering') expect(sessionRecording['sessionDuration']).toBe(null) }) it('can report zero duration', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(sessionRecording['status']).toBe('buffering') const { sessionStartTimestamp } = sessionManager.checkAndGetSessionAndWindowId(true) _emit(createIncrementalSnapshot({ data: { source: 1 }, timestamp: sessionStartTimestamp })) @@ -1857,7 +1857,7 @@ describe('SessionRecording', () => { }) it('can report a duration', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(sessionRecording['status']).toBe('buffering') const { sessionStartTimestamp } = sessionManager.checkAndGetSessionAndWindowId(true) _emit(createIncrementalSnapshot({ data: { source: 1 }, timestamp: sessionStartTimestamp + 100 })) @@ -1865,7 +1865,7 @@ describe('SessionRecording', () => { }) it('starts with an undefined minimum duration', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(sessionRecording['minimumDuration']).toBe(null) }) @@ -1884,7 +1884,7 @@ describe('SessionRecording', () => { sessionRecording: { minimumDurationMilliseconds: 1500 }, }) ) - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(sessionRecording['status']).toBe('active') const { sessionStartTimestamp } = sessionManager.checkAndGetSessionAndWindowId(true) _emit(createIncrementalSnapshot({ data: { source: 1 }, timestamp: sessionStartTimestamp + 100 })) @@ -1904,7 +1904,7 @@ describe('SessionRecording', () => { sessionRecording: { minimumDurationMilliseconds: 1500 }, }) ) - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(sessionRecording['status']).toBe('active') const { sessionStartTimestamp } = sessionManager.checkAndGetSessionAndWindowId(true) @@ -1929,7 +1929,7 @@ describe('SessionRecording', () => { sessionRecording: { minimumDurationMilliseconds: 1500 }, }) ) - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(sessionRecording['status']).toBe('active') const { sessionStartTimestamp } = sessionManager.checkAndGetSessionAndWindowId(true) _emit(createIncrementalSnapshot({ data: { source: 1 }, timestamp: sessionStartTimestamp + 100 })) @@ -1968,23 +1968,24 @@ describe('SessionRecording', () => { loadScriptMock.mockImplementation((_ph, _path, callback) => { callback() }) - sessionRecording = new SessionRecording(posthog) + sessionRecording = new SessionRecorder(posthog) sessionRecording.onRemoteConfig(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(loadScriptMock).toHaveBeenCalled() - expect(sessionRecording['queuedRRWebEvents']).toHaveLength(0) + // called with `recording_initialized` event + expect(sessionRecording['queuedRRWebEvents']).toHaveLength(1) sessionRecording['_tryAddCustomEvent']('test', { test: 'test' }) }) it('queues events', () => { - expect(sessionRecording['queuedRRWebEvents']).toHaveLength(1) + expect(sessionRecording['queuedRRWebEvents']).toHaveLength(2) }) it('limits the queue of events', () => { - expect(sessionRecording['queuedRRWebEvents']).toHaveLength(1) + expect(sessionRecording['queuedRRWebEvents']).toHaveLength(2) for (let i = 0; i < 100; i++) { sessionRecording['_tryAddCustomEvent']('test', { test: 'test' }) @@ -2009,12 +2010,12 @@ describe('SessionRecording', () => { }) it('does not schedule a snapshot on start', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() expect(sessionRecording['_fullSnapshotTimer']).toBe(undefined) }) it('schedules a snapshot, when we take a full snapshot', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() const startTimer = sessionRecording['_fullSnapshotTimer'] _emit(createFullSnapshot()) @@ -2028,7 +2029,7 @@ describe('SessionRecording', () => { beforeEach(() => { jest.spyOn(sessionRecording as any, '_tryAddCustomEvent') posthog.config.capture_pageview = false - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() // clear the spy calls ;(sessionRecording as any)._tryAddCustomEvent.mockClear() }) @@ -2067,7 +2068,7 @@ describe('SessionRecording', () => { beforeEach(() => { jest.spyOn(sessionRecording as any, '_tryAddCustomEvent') posthog.config.capture_pageview = true - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() // clear the spy calls ;(sessionRecording as any)._tryAddCustomEvent.mockClear() }) @@ -2090,7 +2091,7 @@ describe('SessionRecording', () => { beforeEach(() => { posthog.config.session_recording.compress_events = true sessionRecording.onRemoteConfig(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() }) it('compresses full snapshot data', () => { @@ -2270,7 +2271,7 @@ describe('SessionRecording', () => { describe('URL blocking', () => { beforeEach(() => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() sessionRecording.onRemoteConfig( makeDecideResponse({ sessionRecording: { @@ -2339,7 +2340,7 @@ describe('SessionRecording', () => { describe('Event triggering', () => { beforeEach(() => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.start() }) it('flushes buffer and starts when sees event', async () => { diff --git a/src/__tests__/posthog-core.ts b/src/__tests__/posthog-core.ts index 0579706b4..df423036a 100644 --- a/src/__tests__/posthog-core.ts +++ b/src/__tests__/posthog-core.ts @@ -1,18 +1,18 @@ import { mockLogger } from './helpers/mock-logger' import { Info } from '../utils/event-utils' -import { document, window } from '../utils/globals' -import { uuidv7 } from '../uuidv7' import * as globals from '../utils/globals' +import { assignableWindow, document, window } from '../utils/globals' +import { uuidv7 } from '../uuidv7' import { ENABLE_PERSON_PROCESSING, USER_STATE } from '../constants' import { createPosthogInstance, defaultPostHog } from './helpers/posthog-instance' import { PostHogConfig, RemoteConfig } from '../types' -import { PostHog } from '../posthog-core' +import { OnlyValidKeys, PostHog } from '../posthog-core' import { PostHogPersistence } from '../posthog-persistence' import { SessionIdManager } from '../sessionid' import { RequestQueue } from '../request-queue' -import { SessionRecording } from '../extensions/replay/sessionrecording' import { PostHogFeatureFlags } from '../posthog-featureflags' +import { SessionRecordingLoader } from '../extensions/replay/session-recording-loader' describe('posthog core', () => { const baseUTCDateTime = new Date(Date.UTC(2020, 0, 1, 0, 0, 0)) @@ -31,6 +31,23 @@ describe('posthog core', () => { beforeEach(() => { jest.useFakeTimers().setSystemTime(baseUTCDateTime) + + assignableWindow.__PosthogExtensions__ = assignableWindow.__PosthogExtensions__ || {} + assignableWindow.__PosthogExtensions__.initSessionRecording = () => ({ + start: jest.fn(), + stop: jest.fn(), + onRemoteConfig: jest.fn(), + status: 'buffering', + started: true, + overrideLinkedFlag: jest.fn(), + overrideSampling: jest.fn(), + overrideTrigger: jest.fn(), + }) + assignableWindow.__PosthogExtensions__.loadExternalDependency = jest + .fn() + .mockImplementation(() => (_ph: PostHog, _name: string, cb: (err?: Error) => void) => { + cb() + }) }) afterEach(() => { @@ -419,6 +436,7 @@ describe('posthog core', () => { }, overrides ) + posthog.sessionRecording = new SessionRecordingLoader(posthog, () => true) }) it('returns calculated properties', () => { @@ -455,7 +473,6 @@ describe('posthog core', () => { $lib_custom_api_host: 'https://custom.posthog.com', $is_identified: false, $process_person_profile: false, - $recording_status: 'buffering', }) }) @@ -825,7 +842,10 @@ describe('posthog core', () => { posthog.sessionRecording = { afterDecideResponse: jest.fn(), startIfEnabledOrStop: jest.fn(), - } as unknown as SessionRecording + } as OnlyValidKeys< + Partial, + Partial + > as SessionRecordingLoader posthog.persistence = { register: jest.fn(), update_config: jest.fn(), @@ -1136,7 +1156,7 @@ describe('posthog core', () => { instance._send_request = jest.fn() instance._loaded() - expect(instance._send_request.mock.calls[0][0]).toMatchObject({ + expect(jest.mocked(instance._send_request).mock.calls[0][0]).toMatchObject({ url: 'http://localhost/decide/?v=3', }) expect(instance.featureFlags.setReloadingPaused).toHaveBeenCalledWith(true) diff --git a/src/__tests__/sessionid.test.ts b/src/__tests__/sessionid.test.ts index 720e908af..e472c1453 100644 --- a/src/__tests__/sessionid.test.ts +++ b/src/__tests__/sessionid.test.ts @@ -43,6 +43,7 @@ describe('Session ID manager', () => { disabled: false, } ;(sessionStore.is_supported as jest.Mock).mockReturnValue(true) + // @ts-expect-error - typescript gets confused about type of Date here jest.spyOn(global, 'Date').mockImplementation(() => new originalDate(now)) ;(uuidv7 as jest.Mock).mockReturnValue('newUUID') ;(uuid7ToTimestampMs as jest.Mock).mockReturnValue(timestamp) diff --git a/src/__tests__/site-apps.ts b/src/__tests__/site-apps.ts index a1f1d6bd1..a64484e95 100644 --- a/src/__tests__/site-apps.ts +++ b/src/__tests__/site-apps.ts @@ -3,7 +3,7 @@ import { mockLogger } from './helpers/mock-logger' import { SiteApps } from '../site-apps' import { PostHogPersistence } from '../posthog-persistence' import { RequestRouter } from '../utils/request-router' -import { PostHog } from '../posthog-core' +import { OnlyValidKeys, PostHog } from '../posthog-core' import { DecideResponse, PostHogConfig, Properties, CaptureResult } from '../types' import { assignableWindow } from '../utils/globals' import '../entrypoints/external-scripts-loader' @@ -131,7 +131,10 @@ describe('SiteApps', () => { siteAppsInstance.enabled = true siteAppsInstance.loaded = false - const eventPayload = { event: 'test_event', properties: { prop1: 'value1' } } as CaptureResult + const eventPayload = { event: 'test_event', properties: { prop1: 'value1' } } as OnlyValidKeys< + Partial, + Partial + > as CaptureResult jest.spyOn(siteAppsInstance, 'globalsForEvent').mockReturnValue({ some: 'globals' }) @@ -147,7 +150,10 @@ describe('SiteApps', () => { siteAppsInstance.missedInvocations = new Array(1000).fill({}) - const eventPayload = { event: 'test_event', properties: { prop1: 'value1' } } as CaptureResult + const eventPayload = { event: 'test_event', properties: { prop1: 'value1' } } as OnlyValidKeys< + Partial, + Partial + > as CaptureResult jest.spyOn(siteAppsInstance, 'globalsForEvent').mockReturnValue({ some: 'globals' }) diff --git a/src/extensions/replay/sessionrecording.ts b/src/entrypoints/session-recorder.ts similarity index 96% rename from src/extensions/replay/sessionrecording.ts rename to src/entrypoints/session-recorder.ts index 030501aab..b16a5ca56 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/entrypoints/session-recorder.ts @@ -9,7 +9,7 @@ import { SESSION_RECORDING_SAMPLE_RATE, SESSION_RECORDING_SCRIPT_CONFIG, SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION, -} from '../../constants' +} from '../constants' import { estimateSize, INCREMENTAL_SNAPSHOT_EVENT_TYPE, @@ -17,8 +17,8 @@ import { rrwebRecord, splitBuffer, truncateLargeConsoleLogs, -} from './sessionrecording-utils' -import { PostHog } from '../../posthog-core' +} from '../extensions/replay/sessionrecording-utils' +import { PostHog } from '../posthog-core' import { CaptureResult, FlagVariant, @@ -26,8 +26,12 @@ import { NetworkRequest, Properties, RemoteConfig, + SessionRecordingStatus, SessionRecordingUrlTrigger, -} from '../../types' + SessionStartReason, + SnapshotBuffer, + TriggerType, +} from '../types' import { customEvent, EventType, @@ -37,29 +41,25 @@ import { RecordPlugin, } from '@rrweb/types' -import { isBoolean, isFunction, isNullish, isNumber, isObject, isString, isUndefined } from '../../utils/type-utils' -import { createLogger } from '../../utils/logger' -import { assignableWindow, document, PostHogExtensionKind, window } from '../../utils/globals' -import { buildNetworkRequestOptions } from './config' -import { isLocalhost } from '../../utils/request-utils' -import { MutationRateLimiter } from './mutation-rate-limiter' +import { isBoolean, isFunction, isNullish, isNumber, isObject, isString, isUndefined } from '../utils/type-utils' +import { createLogger } from '../utils/logger' +import { + assignableWindow, + document, + LazyLoadedSessionRecordingInterface, + PostHogExtensionKind, + window, +} from '../utils/globals' +import { buildNetworkRequestOptions } from '../extensions/replay/config' +import { isLocalhost } from '../utils/request-utils' +import { MutationRateLimiter } from '../extensions/replay/mutation-rate-limiter' import { gzipSync, strFromU8, strToU8 } from 'fflate' -import { clampToRange } from '../../utils/number-utils' -import { includes } from '../../utils' +import { clampToRange } from '../utils/number-utils' +import { includes } from '../utils' const LOGGER_PREFIX = '[SessionRecording]' const logger = createLogger(LOGGER_PREFIX) -type SessionStartReason = - | 'sampling_overridden' - | 'recording_initialized' - | 'linked_flag_matched' - | 'linked_flag_overridden' - | 'sampled' - | 'session_id_changed' - | 'url_trigger_matched' - | 'event_trigger_matched' - const BASE_ENDPOINT = '/s/' const ONE_MINUTE = 1000 * 60 @@ -83,24 +83,8 @@ const ACTIVE_SOURCES = [ IncrementalSource.Drag, ] -export type TriggerType = 'url' | 'event' type TriggerStatus = 'trigger_activated' | 'trigger_pending' | 'trigger_disabled' -/** - * Session recording starts in buffering mode while waiting for decide response - * Once the response is received it might be disabled, active or sampled - * When sampled that means a sample rate is set and the last time the session id was rotated - * the sample rate determined this session should be sent to the server. - */ -type SessionRecordingStatus = 'disabled' | 'sampled' | 'active' | 'buffering' | 'paused' - -export interface SnapshotBuffer { - size: number - data: any[] - sessionId: string - windowId: string -} - interface QueuedRRWebEvent { rrwebMethod: () => void attempt: number @@ -235,7 +219,7 @@ function isRecordingPausedEvent(e: eventWithTime) { return e.type === EventType.Custom && e.data.tag === 'recording paused' } -export class SessionRecording { +export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInterface { private _endpoint: string private flushBufferTimer?: any @@ -483,7 +467,7 @@ export class SessionRecording { } } - startIfEnabledOrStop(startReason?: SessionStartReason) { + start(startReason?: SessionStartReason) { if (this.isRecordingEnabled) { this._startCapture(startReason) @@ -532,11 +516,11 @@ export class SessionRecording { }) } } else { - this.stopRecording() + this.stop() } } - stopRecording() { + stop() { if (this._captureStarted && this.stopRrweb) { this.stopRrweb() this.stopRrweb = undefined @@ -656,7 +640,7 @@ export class SessionRecording { } this.receivedDecide = true - this.startIfEnabledOrStop() + this.start() } /** @@ -709,7 +693,7 @@ export class SessionRecording { } log(message: string, level: 'log' | 'warn' | 'error' = 'log') { - this.instance.sessionRecording?.onRRwebEmit({ + this.onRRwebEmit({ type: 6, data: { plugin: 'rrweb/console@1', @@ -847,8 +831,8 @@ export class SessionRecording { this.sessionId = sessionId if (sessionIdChanged || windowIdChanged) { - this.stopRecording() - this.startIfEnabledOrStop('session_id_changed') + this.stop() + this.start('session_id_changed') } else if (returningFromIdle) { this._scheduleFullSnapshot() } @@ -1351,8 +1335,13 @@ export class SessionRecording { $session_recording_start_reason: startReason, }) logger.info(startReason.replace('_', ' '), tagPayload) - if (!includes(['recording_initialized', 'session_id_changed'], startReason)) { + if (!includes(['session_id_changed'], startReason)) { this._tryAddCustomEvent(startReason, tagPayload) } } } + +assignableWindow.__PosthogExtensions__ = assignableWindow.__PosthogExtensions__ || {} +assignableWindow.__PosthogExtensions__.initSessionRecording = (ph) => new LazyLoadedSessionRecording(ph) + +export default LazyLoadedSessionRecording diff --git a/src/extensions/replay/session-recording-loader.ts b/src/extensions/replay/session-recording-loader.ts new file mode 100644 index 000000000..cfc1a214b --- /dev/null +++ b/src/extensions/replay/session-recording-loader.ts @@ -0,0 +1,89 @@ +import { assignableWindow, document, LazyLoadedSessionRecordingInterface, window } from '../../utils/globals' +import { PostHog } from '../../posthog-core' +import { RemoteConfig } from '../../types' +import { createLogger } from '../../utils/logger' +import { SESSION_RECORDING_ENABLED_SERVER_SIDE } from '../../constants' +import { isBoolean, isUndefined } from '../../utils/type-utils' + +const logger = createLogger('[Session-Recording-Loader]') + +export const isSessionRecordingEnabled = (loader: SessionRecordingLoader) => { + const enabled_server_side = !!loader.instance.get_property(SESSION_RECORDING_ENABLED_SERVER_SIDE) + const enabled_client_side = !loader.instance.config.disable_session_recording + return !!window && enabled_server_side && enabled_client_side +} + +export class SessionRecordingLoader { + _forceAllowLocalhostNetworkCapture = false + + get lazyLoaded(): LazyLoadedSessionRecordingInterface | undefined { + return this._lazyLoadedSessionRecording + } + + private _lazyLoadedSessionRecording: LazyLoadedSessionRecordingInterface | undefined + + constructor(readonly instance: PostHog, readonly isEnabled: (srl: SessionRecordingLoader) => boolean) { + this.startIfEnabled() + } + + public onRemoteConfig(response: RemoteConfig) { + if (this.instance.persistence) { + this._lazyLoadedSessionRecording?.onRemoteConfig(response) + } + this.startIfEnabled() + } + + public startIfEnabled() { + if (this.isEnabled(this)) { + this.loadScript(() => { + this.start() + }) + } + } + + private loadScript(cb: () => void): void { + if (assignableWindow.__PosthogExtensions__?.initSessionRecording) { + // already loaded + cb() + } + assignableWindow.__PosthogExtensions__?.loadExternalDependency?.(this.instance, 'session-recorder', (err) => { + if (err) { + logger.error('failed to load script', err) + return + } + cb() + }) + } + + private start() { + if (!document) { + logger.error('`document` not found. Cannot start.') + return + } + + if (!this._lazyLoadedSessionRecording && assignableWindow.__PosthogExtensions__?.initSessionRecording) { + if ( + isUndefined(this.instance.config.session_recording._forceAllowLocalhostNetworkCapture) && + isBoolean(this._forceAllowLocalhostNetworkCapture) + ) { + logger.warn( + '`_forceAllowLocalhostNetworkCapture` has moved to `session_recording` config. Copying your setting over.' + ) + this.instance.config.session_recording._forceAllowLocalhostNetworkCapture = + this._forceAllowLocalhostNetworkCapture + } + + this._lazyLoadedSessionRecording = assignableWindow.__PosthogExtensions__.initSessionRecording( + this.instance + ) + this._lazyLoadedSessionRecording.start() + } + } + + stop() { + if (this._lazyLoadedSessionRecording) { + this._lazyLoadedSessionRecording.stop() + this._lazyLoadedSessionRecording = undefined + } + } +} diff --git a/src/extensions/replay/sessionrecording-utils.ts b/src/extensions/replay/sessionrecording-utils.ts index 02270bdf3..5c73377dd 100644 --- a/src/extensions/replay/sessionrecording-utils.ts +++ b/src/extensions/replay/sessionrecording-utils.ts @@ -2,7 +2,7 @@ import type { eventWithTime, listenerHandler, pluginEvent } from '@rrweb/types' import type { record } from '@rrweb/record' import { isObject } from '../../utils/type-utils' -import { SnapshotBuffer } from './sessionrecording' +import { SnapshotBuffer } from '../../types' // taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value#circular_references export function circularReferenceReplacer() { diff --git a/src/posthog-core.ts b/src/posthog-core.ts index a310f84e5..1d7c467c3 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -20,7 +20,6 @@ import { USER_STATE, ENABLE_PERSON_PROCESSING, } from './constants' -import { SessionRecording } from './extensions/replay/sessionrecording' import { Decide } from './decide' import { Toolbar } from './extensions/toolbar' import { localStore } from './storage' @@ -82,6 +81,7 @@ import { WebExperiments } from './web-experiments' import { PostHogExceptions } from './posthog-exceptions' import { SiteApps } from './site-apps' import { DeadClicksAutocapture, isDeadClicksEnabledForAutocapture } from './extensions/dead-clicks-autocapture' +import { isSessionRecordingEnabled, SessionRecordingLoader } from './extensions/replay/session-recording-loader' /* SIMPLE STYLE GUIDE: @@ -101,7 +101,11 @@ Globals should be all caps * That's a really tricky mistake to spot. * The OnlyValidKeys type ensures that only keys that are valid in the PostHogConfig type are allowed. */ -type OnlyValidKeys = T extends Shape ? (Exclude extends never ? T : never) : never +export type OnlyValidKeys = T extends Shape + ? Exclude extends never + ? T + : never + : never const instances: Record = {} @@ -268,7 +272,7 @@ export class PostHog { _requestQueue?: RequestQueue _retryQueue?: RetryQueue - sessionRecording?: SessionRecording + sessionRecording?: SessionRecordingLoader webPerformance = new DeprecatedWebPerformanceObserver() _initialPageviewCaptured: boolean @@ -437,8 +441,8 @@ export class PostHog { this.siteApps = new SiteApps(this) this.siteApps?.init() - this.sessionRecording = new SessionRecording(this) - this.sessionRecording.startIfEnabledOrStop() + this.sessionRecording = new SessionRecordingLoader(this, isSessionRecordingEnabled) + this.sessionRecording.startIfEnabled() if (!this.config.disable_scroll_properties) { this.scrollManager.startMeasuringScrollPosition() @@ -937,7 +941,7 @@ export class PostHog { } if (this.sessionRecording) { - properties['$recording_status'] = this.sessionRecording.status + properties['$recording_status'] = this.sessionRecording.lazyLoaded?.status } if (this.requestRouter.region === RequestRouterRegion.CUSTOM) { @@ -1817,7 +1821,7 @@ export class PostHog { }) } - this.sessionRecording?.startIfEnabledOrStop() + this.sessionRecording?.startIfEnabled() this.autocapture?.startIfEnabled() this.heatmaps?.startIfEnabled() this.surveys.loadIfEnabled() @@ -1849,19 +1853,19 @@ export class PostHog { this.sessionManager?.checkAndGetSessionAndWindowId() if (overrideConfig.sampling) { - this.sessionRecording?.overrideSampling() + this.sessionRecording?.lazyLoaded?.overrideSampling() } if (overrideConfig.linked_flag) { - this.sessionRecording?.overrideLinkedFlag() + this.sessionRecording?.lazyLoaded?.overrideLinkedFlag() } if (overrideConfig.url_trigger) { - this.sessionRecording?.overrideTrigger('url') + this.sessionRecording?.lazyLoaded?.overrideTrigger('url') } if (overrideConfig.event_trigger) { - this.sessionRecording?.overrideTrigger('event') + this.sessionRecording?.lazyLoaded?.overrideTrigger('event') } } @@ -1881,7 +1885,7 @@ export class PostHog { * is currently running */ sessionRecordingStarted(): boolean { - return !!this.sessionRecording?.started + return !!this.sessionRecording?.lazyLoaded?.started } /** Capture a caught exception manually */ diff --git a/src/types.ts b/src/types.ts index e821a2a8b..88c19e35f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -384,31 +384,42 @@ export interface SessionRecordingOptions { maskNetworkRequestFn?: ((data: NetworkRequest) => NetworkRequest | null | undefined) | null /** Modify the network request before it is captured. Returning null or undefined stops it being captured */ maskCapturedNetworkRequestFn?: ((data: CapturedNetworkRequest) => CapturedNetworkRequest | null | undefined) | null - // our settings here only support a subset of those proposed for rrweb's network capture plugin + /** + * our settings here only support a subset of those proposed for rrweb's network capture plugin + */ recordHeaders?: boolean recordBody?: boolean - // ADVANCED: while a user is active we take a full snapshot of the browser every interval. For very few sites playback performance might be better with different interval. Set to 0 to disable + /** + * ADVANCED: while a user is active we take a full snapshot of the browser every interval. For very few sites playback performance might be better with different interval. Set to 0 to disable + */ full_snapshot_interval_millis?: number - /* + /** ADVANCED: whether to partially compress rrweb events before sending them to the server, defaults to true, can be set to false to disable partial compression NB requests are still compressed when sent to the server regardless of this setting */ compress_events?: boolean - /* + /** ADVANCED: alters the threshold before a recording considers a user has become idle. Normally only altered alongside changes to session_idle_timeout_ms. Default is 5 minutes. */ session_idle_threshold_ms?: number - /* + /** + * ADVANCED: allow capture of network performance and payload data when running on localhost + * (still requires other config to enable). This is disabled by default for performance reasons. + * Normally only useful for debugging and development. But can be necessary for some frameworks + * such as capacitor that run on localhost. + */ + _forceAllowLocalhostNetworkCapture?: boolean + /** ADVANCED: alters the refill rate for the token bucket mutation throttling Normally only altered alongside posthog support guidance. Accepts values between 0 and 100 Default is 10. */ __mutationRateLimiterRefillRate?: number - /* + /** ADVANCED: alters the bucket size for the token bucket mutation throttling Normally only altered alongside posthog support guidance. Accepts values between 0 and 100 @@ -423,6 +434,33 @@ export type SessionIdChangedCallback = ( changeReason?: { noSessionId: boolean; activityTimeout: boolean; sessionPastMaximumLength: boolean } ) => void +export type SessionStartReason = + | 'recording_initialized' + | 'sampling_overridden' + | 'linked_flag_matched' + | 'linked_flag_overridden' + | 'sampled' + | 'session_id_changed' + | 'url_trigger_matched' + | 'event_trigger_matched' + +/** + * Session recording starts in buffering mode while waiting for decide response + * Once the response is received it might be disabled, active or sampled + * When sampled that means a sample rate is set and the last time the session id was rotated + * the sample rate determined this session should be sent to the server. + */ +export type SessionRecordingStatus = 'disabled' | 'sampled' | 'active' | 'buffering' | 'paused' + +export type TriggerType = 'url' | 'event' + +export interface SnapshotBuffer { + size: number + data: any[] + sessionId: string + windowId: string +} + export enum Compression { GZipJS = 'gzip-js', Base64 = 'base64', diff --git a/src/utils/globals.ts b/src/utils/globals.ts index 23ca7a1ba..94bb85764 100644 --- a/src/utils/globals.ts +++ b/src/utils/globals.ts @@ -1,7 +1,16 @@ import { ErrorProperties } from '../extensions/exception-autocapture/error-conversion' import type { PostHog } from '../posthog-core' import { SessionIdManager } from '../sessionid' -import { DeadClicksAutoCaptureConfig, ErrorEventArgs, ErrorMetadata, Properties, RemoteConfig } from '../types' +import { + DeadClicksAutoCaptureConfig, + ErrorEventArgs, + ErrorMetadata, + Properties, + RemoteConfig, + SessionRecordingStatus, + SessionStartReason, + TriggerType, +} from '../types' /* * Global helpers to protect access to browser globals in a way that is safer for different targets @@ -33,12 +42,24 @@ export type PostHogExtensionKind = | 'toolbar' | 'exception-autocapture' | 'web-vitals' + | 'session-recorder' | 'recorder' | 'tracing-headers' | 'surveys' | 'dead-clicks-autocapture' | 'remote-config' +export interface LazyLoadedSessionRecordingInterface { + start: (startReason?: SessionStartReason) => void + stop: () => void + onRemoteConfig(response: RemoteConfig): void + get status(): SessionRecordingStatus + overrideTrigger: (triggerType: TriggerType) => void + overrideSampling: () => void + overrideLinkedFlag: () => void + get started(): boolean +} + export interface LazyLoadedDeadClicksAutocaptureInterface { start: (observerTarget: Node) => void stop: () => void @@ -79,6 +100,7 @@ interface PostHogExtensions { ph: PostHog, config: DeadClicksAutoCaptureConfig ) => LazyLoadedDeadClicksAutocaptureInterface + initSessionRecording?: (ph: PostHog) => LazyLoadedSessionRecordingInterface } const global: typeof globalThis | undefined = typeof globalThis !== 'undefined' ? globalThis : win