diff --git a/.changeset/gentle-wings-retire.md b/.changeset/gentle-wings-retire.md new file mode 100644 index 0000000000..3f1d4ae6ad --- /dev/null +++ b/.changeset/gentle-wings-retire.md @@ -0,0 +1,5 @@ +--- +'posthog-js': minor +--- + +feat: remove eager loaded replay and reduce bundle size by 14.8% diff --git a/packages/browser/src/__tests__/extensions/replay/config.test.ts b/packages/browser/src/__tests__/extensions/replay/config.test.ts index 81de52bf48..100dec5520 100644 --- a/packages/browser/src/__tests__/extensions/replay/config.test.ts +++ b/packages/browser/src/__tests__/extensions/replay/config.test.ts @@ -1,5 +1,5 @@ import { defaultConfig } from '../../../posthog-core' -import { buildNetworkRequestOptions } from '../../../extensions/replay/config' +import { buildNetworkRequestOptions } from '../../../extensions/replay/external/config' import { CapturedNetworkRequest } from '../../../types' describe('config', () => { diff --git a/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts b/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts index f3ca4cfbea..7bc29482df 100644 --- a/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts +++ b/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts @@ -14,7 +14,7 @@ import { FULL_SNAPSHOT_EVENT_TYPE, INCREMENTAL_SNAPSHOT_EVENT_TYPE, META_EVENT_TYPE, -} from '../../../extensions/replay/sessionrecording-utils' +} from '../../../extensions/replay/external/sessionrecording-utils' import { PostHog } from '../../../posthog-core' import { FlagsResponse, @@ -25,7 +25,6 @@ import { SessionRecordingOptions, } from '../../../types' import { uuidv7 } from '../../../uuidv7' -import { RECORDING_IDLE_THRESHOLD_MS, RECORDING_MAX_EVENT_SIZE } from '../../../extensions/replay/sessionrecording' import { assignableWindow, window } from '../../../utils/globals' import { RequestRouter } from '../../../utils/request-router' import { @@ -42,8 +41,12 @@ import { import { ConsentManager } from '../../../consent' import { SimpleEventEmitter } from '../../../utils/simple-event-emitter' import Mock = jest.Mock -import { SessionRecordingWrapper } from '../../../extensions/replay/sessionrecording-wrapper' -import { LazyLoadedSessionRecording } from '../../../extensions/replay/external/lazy-loaded-session-recorder' +import { SessionRecording } from '../../../extensions/replay/session-recording' +import { + LazyLoadedSessionRecording, + RECORDING_IDLE_THRESHOLD_MS, + RECORDING_MAX_EVENT_SIZE, +} from '../../../extensions/replay/external/lazy-loaded-session-recorder' // Type and source defined here designate a non-user-generated recording event @@ -173,7 +176,7 @@ describe('Lazy SessionRecording', () => { const loadScriptMock = jest.fn() let _emit: any let posthog: PostHog - let sessionRecording: SessionRecordingWrapper + let sessionRecording: SessionRecording let sessionId: string let sessionManager: SessionIdManager let config: PostHogConfig @@ -287,7 +290,7 @@ describe('Lazy SessionRecording', () => { [SESSION_RECORDING_IS_SAMPLED]: undefined, }) - sessionRecording = new SessionRecordingWrapper(posthog) + sessionRecording = new SessionRecording(posthog) }) afterEach(() => { diff --git a/packages/browser/src/__tests__/extensions/replay/mutation-throttler.test.ts b/packages/browser/src/__tests__/extensions/replay/mutation-throttler.test.ts index 0ee8264c5c..d9b92ee974 100644 --- a/packages/browser/src/__tests__/extensions/replay/mutation-throttler.test.ts +++ b/packages/browser/src/__tests__/extensions/replay/mutation-throttler.test.ts @@ -1,8 +1,8 @@ -import { MutationThrottler } from '../../../extensions/replay/mutation-throttler' +import { MutationThrottler } from '../../../extensions/replay/external/mutation-throttler' import { INCREMENTAL_SNAPSHOT_EVENT_TYPE, MUTATION_SOURCE_TYPE, -} from '../../../extensions/replay/sessionrecording-utils' +} from '../../../extensions/replay/external/sessionrecording-utils' import type { rrwebRecord } from '../../../extensions/replay/types/rrweb' import { jest } from '@jest/globals' import type { eventWithTime, mutationData } from '@rrweb/types' diff --git a/packages/browser/src/__tests__/extensions/replay/sessionRecording-onRemoteConfig.test.ts b/packages/browser/src/__tests__/extensions/replay/sessionRecording-onRemoteConfig.test.ts index d143a81a07..0207028ca9 100644 --- a/packages/browser/src/__tests__/extensions/replay/sessionRecording-onRemoteConfig.test.ts +++ b/packages/browser/src/__tests__/extensions/replay/sessionRecording-onRemoteConfig.test.ts @@ -3,32 +3,16 @@ import '@testing-library/jest-dom' import { PostHogPersistence } from '../../../posthog-persistence' -import { - CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE, - SESSION_RECORDING_CANVAS_RECORDING, - SESSION_RECORDING_ENABLED_SERVER_SIDE, - SESSION_RECORDING_IS_SAMPLED, - SESSION_RECORDING_MASKING, - SESSION_RECORDING_SAMPLE_RATE, -} from '../../../constants' +import { SESSION_RECORDING_REMOTE_CONFIG } from '../../../constants' import { SessionIdManager } from '../../../sessionid' -import { - FULL_SNAPSHOT_EVENT_TYPE, - INCREMENTAL_SNAPSHOT_EVENT_TYPE, - META_EVENT_TYPE, -} from '../../../extensions/replay/sessionrecording-utils' +import { FULL_SNAPSHOT_EVENT_TYPE, META_EVENT_TYPE } from '../../../extensions/replay/external/sessionrecording-utils' import { PostHog } from '../../../posthog-core' import { FlagsResponse, PostHogConfig, Property } from '../../../types' import { uuidv7 } from '../../../uuidv7' -import { SessionRecording } from '../../../extensions/replay/sessionrecording' +import { SessionRecording } from '../../../extensions/replay/session-recording' import { assignableWindow, window } from '../../../utils/globals' import { RequestRouter } from '../../../utils/request-router' -import { - type fullSnapshotEvent, - type incrementalData, - type incrementalSnapshotEvent, - type metaEvent, -} from '@rrweb/types' +import { type fullSnapshotEvent, type metaEvent } from '@rrweb/types' import Mock = jest.Mock import { ConsentManager } from '../../../consent' import { SimpleEventEmitter } from '../../../utils/simple-event-emitter' @@ -36,10 +20,9 @@ import { allMatchSessionRecordingStatus, AndTriggerMatching, anyMatchSessionRecordingStatus, - nullMatchSessionRecordingStatus, OrTriggerMatching, - PendingTriggerMatching, -} from '../../../extensions/replay/triggerMatching' +} from '../../../extensions/replay/external/triggerMatching' +import { LazyLoadedSessionRecording } from '../../../extensions/replay/external/lazy-loaded-session-recorder' // Type and source defined here designate a non-user-generated recording event @@ -68,14 +51,6 @@ const createFullSnapshot = (event = {}): fullSnapshotEvent => ...event, }) as fullSnapshotEvent -const createIncrementalSnapshot = (event = {}): incrementalSnapshotEvent => ({ - type: INCREMENTAL_SNAPSHOT_EVENT_TYPE, - data: { - source: 1, - } as Partial as incrementalData, - ...event, -}) - function makeFlagsResponse(partialResponse: Partial) { return partialResponse as unknown as FlagsResponse } @@ -185,12 +160,9 @@ describe('SessionRecording', () => { assignableWindow.__PosthogExtensions__.loadExternalDependency = loadScriptMock - // defaults - posthog.persistence?.register({ - [SESSION_RECORDING_ENABLED_SERVER_SIDE]: true, - [CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE]: false, - [SESSION_RECORDING_IS_SAMPLED]: undefined, - }) + assignableWindow.__PosthogExtensions__.initSessionRecording = () => { + return new LazyLoadedSessionRecording(posthog) + } sessionRecording = new SessionRecording(posthog) }) @@ -205,11 +177,6 @@ describe('SessionRecording', () => { jest.spyOn(sessionRecording, 'startIfEnabledOrStop') }) - it('has null status matcher before remote config', () => { - expect(sessionRecording['_statusMatcher']).toBe(nullMatchSessionRecordingStatus) - expect(sessionRecording['_triggerMatching']).toBeInstanceOf(PendingTriggerMatching) - }) - it('loads script based on script config', () => { sessionRecording.onRemoteConfig( makeFlagsResponse({ @@ -225,8 +192,12 @@ describe('SessionRecording', () => { sessionRecording: { endpoint: '/s/', triggerMatchType: 'any' }, }) ) - expect(sessionRecording['_statusMatcher']).toBe(anyMatchSessionRecordingStatus) - expect(sessionRecording['_triggerMatching']).toBeInstanceOf(OrTriggerMatching) + expect(sessionRecording['_lazyLoadedSessionRecording']['_statusMatcher']).toBe( + anyMatchSessionRecordingStatus + ) + expect(sessionRecording['_lazyLoadedSessionRecording']['_triggerMatching']).toBeInstanceOf( + OrTriggerMatching + ) }) it('uses allMatchSessionRecordingStatus when triggerMatching is "all"', () => { @@ -235,8 +206,12 @@ describe('SessionRecording', () => { sessionRecording: { endpoint: '/s/', triggerMatchType: 'all' }, }) ) - expect(sessionRecording['_statusMatcher']).toBe(allMatchSessionRecordingStatus) - expect(sessionRecording['_triggerMatching']).toBeInstanceOf(AndTriggerMatching) + expect(sessionRecording['_lazyLoadedSessionRecording']['_statusMatcher']).toBe( + allMatchSessionRecordingStatus + ) + expect(sessionRecording['_lazyLoadedSessionRecording']['_triggerMatching']).toBeInstanceOf( + AndTriggerMatching + ) }) it('uses most restrictive when triggerMatching is not specified', () => { @@ -245,15 +220,23 @@ describe('SessionRecording', () => { sessionRecording: { endpoint: '/s/' }, }) ) - expect(sessionRecording['_statusMatcher']).toBe(allMatchSessionRecordingStatus) - expect(sessionRecording['_triggerMatching']).toBeInstanceOf(AndTriggerMatching) + expect(sessionRecording['_lazyLoadedSessionRecording']['_statusMatcher']).toBe( + allMatchSessionRecordingStatus + ) + expect(sessionRecording['_lazyLoadedSessionRecording']['_triggerMatching']).toBeInstanceOf( + AndTriggerMatching + ) }) it('when the first event is a meta it does not take a manual full snapshot', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.onRemoteConfig( + makeFlagsResponse({ + sessionRecording: { endpoint: '/s/' }, + }) + ) expect(loadScriptMock).toHaveBeenCalled() - expect(sessionRecording['status']).toBe('buffering') - expect(sessionRecording['_buffer']).toEqual({ + expect(sessionRecording['status']).toBe('active') + expect(sessionRecording['_lazyLoadedSessionRecording']['_buffer']).toEqual({ ...EMPTY_BUFFER, sessionId: sessionId, windowId: 'windowId', @@ -261,7 +244,7 @@ describe('SessionRecording', () => { const metaSnapshot = createMetaSnapshot({ data: { href: 'https://example.com' } }) _emit(metaSnapshot) - expect(sessionRecording['_buffer']).toEqual({ + expect(sessionRecording['_lazyLoadedSessionRecording']['_buffer']).toEqual({ data: [metaSnapshot], sessionId: sessionId, size: 48, @@ -270,10 +253,14 @@ describe('SessionRecording', () => { }) it('when the first event is a full snapshot it does not take a manual full snapshot', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.onRemoteConfig( + makeFlagsResponse({ + sessionRecording: { endpoint: '/s/' }, + }) + ) expect(loadScriptMock).toHaveBeenCalled() - expect(sessionRecording['status']).toBe('buffering') - expect(sessionRecording['_buffer']).toEqual({ + expect(sessionRecording['status']).toBe('active') + expect(sessionRecording['_lazyLoadedSessionRecording']['_buffer']).toEqual({ ...EMPTY_BUFFER, sessionId: sessionId, windowId: 'windowId', @@ -281,7 +268,7 @@ describe('SessionRecording', () => { const fullSnapshot = createFullSnapshot() _emit(fullSnapshot) - expect(sessionRecording['_buffer']).toEqual({ + expect(sessionRecording['_lazyLoadedSessionRecording']['_buffer']).toEqual({ data: [fullSnapshot], sessionId: sessionId, size: 20, @@ -289,59 +276,33 @@ describe('SessionRecording', () => { }) }) - it('buffers snapshots until flags is received and drops them if disabled', () => { - sessionRecording.startIfEnabledOrStop() - expect(loadScriptMock).toHaveBeenCalled() - expect(sessionRecording['status']).toBe('buffering') - expect(sessionRecording['_buffer']).toEqual({ - ...EMPTY_BUFFER, - sessionId: sessionId, - windowId: 'windowId', - }) - - const incrementalSnapshot = createIncrementalSnapshot({ data: { source: 1 } }) - _emit(incrementalSnapshot) - expect(sessionRecording['_buffer']).toEqual({ - data: [incrementalSnapshot], - sessionId: sessionId, - size: 30, - windowId: 'windowId', - }) - - sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: undefined })) - expect(sessionRecording['status']).toBe('disabled') - expect(sessionRecording['_buffer'].data.length).toEqual(0) - expect(posthog.capture).not.toHaveBeenCalled() - }) - it('emit is not active until flags is called', () => { - sessionRecording.startIfEnabledOrStop() - expect(loadScriptMock).toHaveBeenCalled() - expect(sessionRecording['status']).toBe('buffering') + expect(sessionRecording['status']).toBe('lazy_loading') sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: { endpoint: '/s/' } })) expect(sessionRecording['status']).toBe('active') }) it('sample rate is null when flags does not return it', () => { - sessionRecording.startIfEnabledOrStop() + sessionRecording.onRemoteConfig( + makeFlagsResponse({ + sessionRecording: { endpoint: '/s/' }, + }) + ) expect(loadScriptMock).toHaveBeenCalled() - expect(sessionRecording['_isSampled']).toBe(null) - - sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: { endpoint: '/s/' } })) - expect(sessionRecording['_isSampled']).toBe(null) + expect(sessionRecording['_lazyLoadedSessionRecording']['_isSampled']).toBe(null) }) it('stores true in persistence if recording is enabled from the server', () => { - posthog.persistence?.register({ [SESSION_RECORDING_ENABLED_SERVER_SIDE]: undefined }) + posthog.persistence?.register({ [SESSION_RECORDING_REMOTE_CONFIG]: undefined }) sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: { endpoint: '/s/' } })) - expect(posthog.get_property(SESSION_RECORDING_ENABLED_SERVER_SIDE)).toBe(true) + expect(posthog.get_property(SESSION_RECORDING_REMOTE_CONFIG).enabled).toBe(true) }) it('stores true in persistence if canvas is enabled from the server', () => { - posthog.persistence?.register({ [SESSION_RECORDING_CANVAS_RECORDING]: undefined }) + posthog.persistence?.register({ [SESSION_RECORDING_REMOTE_CONFIG]: undefined }) sessionRecording.onRemoteConfig( makeFlagsResponse({ @@ -349,15 +310,13 @@ describe('SessionRecording', () => { }) ) - expect(posthog.get_property(SESSION_RECORDING_CANVAS_RECORDING)).toEqual({ - enabled: true, - fps: 6, - quality: '0.2', - }) + expect(posthog.get_property(SESSION_RECORDING_REMOTE_CONFIG).recordCanvas).toBe(true) + expect(posthog.get_property(SESSION_RECORDING_REMOTE_CONFIG).canvasFps).toBe(6) + expect(posthog.get_property(SESSION_RECORDING_REMOTE_CONFIG).canvasQuality).toBe('0.2') }) it('stores masking config in persistence if set on the server', () => { - posthog.persistence?.register({ [SESSION_RECORDING_MASKING]: undefined }) + posthog.persistence?.register({ [SESSION_RECORDING_REMOTE_CONFIG]: undefined }) sessionRecording.onRemoteConfig( makeFlagsResponse({ @@ -365,22 +324,31 @@ describe('SessionRecording', () => { }) ) - expect(posthog.get_property(SESSION_RECORDING_MASKING)).toEqual({ + expect(posthog.get_property(SESSION_RECORDING_REMOTE_CONFIG).masking).toEqual({ maskAllInputs: true, maskTextSelector: '*', }) }) - it('stores false in persistence if recording is not enabled from the server', () => { - posthog.persistence?.register({ [SESSION_RECORDING_ENABLED_SERVER_SIDE]: undefined }) + it('stores nothing in persistence if recording is not returned from the server', () => { + posthog.persistence?.register({ [SESSION_RECORDING_REMOTE_CONFIG]: undefined }) sessionRecording.onRemoteConfig(makeFlagsResponse({})) - expect(posthog.get_property(SESSION_RECORDING_ENABLED_SERVER_SIDE)).toBe(false) + expect(posthog.get_property(SESSION_RECORDING_REMOTE_CONFIG)).toBe(undefined) + expect(sessionRecording.status).toBe('lazy_loading') + }) + + it('stores response in persistence if recording is false from the server', () => { + posthog.persistence?.register({ [SESSION_RECORDING_REMOTE_CONFIG]: undefined }) + + sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: false })) + + expect(sessionRecording.status).toBe('disabled') }) it('stores sample rate', () => { - posthog.persistence?.register({ SESSION_RECORDING_SAMPLE_RATE: undefined }) + posthog.persistence?.register({ SESSION_RECORDING_REMOTE_CONFIG: undefined }) sessionRecording.onRemoteConfig( makeFlagsResponse({ @@ -388,12 +356,12 @@ describe('SessionRecording', () => { }) ) - expect(sessionRecording['_sampleRate']).toBe(0.7) - expect(posthog.get_property(SESSION_RECORDING_SAMPLE_RATE)).toBe(0.7) + expect(posthog.get_property(SESSION_RECORDING_REMOTE_CONFIG).sampleRate).toBe(0.7) + expect(sessionRecording['_lazyLoadedSessionRecording']['_sampleRate']).toBe(0.7) }) it('starts session recording, saves setting and endpoint when enabled', () => { - posthog.persistence?.register({ [SESSION_RECORDING_ENABLED_SERVER_SIDE]: undefined }) + posthog.persistence?.register({ [SESSION_RECORDING_REMOTE_CONFIG]: undefined }) sessionRecording.onRemoteConfig( makeFlagsResponse({ sessionRecording: { endpoint: '/ses/' }, @@ -402,8 +370,8 @@ describe('SessionRecording', () => { expect(sessionRecording.startIfEnabledOrStop).toHaveBeenCalled() expect(loadScriptMock).toHaveBeenCalled() - expect(posthog.get_property(SESSION_RECORDING_ENABLED_SERVER_SIDE)).toBe(true) - expect(sessionRecording['_endpoint']).toEqual('/ses/') + expect(posthog.get_property(SESSION_RECORDING_REMOTE_CONFIG).enabled).toBe(true) + expect(sessionRecording['_lazyLoadedSessionRecording']['_endpoint']).toEqual('/ses/') }) }) }) diff --git a/packages/browser/src/__tests__/extensions/replay/sessionRecordingStatus.test.ts b/packages/browser/src/__tests__/extensions/replay/sessionRecordingStatus.test.ts index eba1473b37..0760f11fe7 100644 --- a/packages/browser/src/__tests__/extensions/replay/sessionRecordingStatus.test.ts +++ b/packages/browser/src/__tests__/extensions/replay/sessionRecordingStatus.test.ts @@ -14,7 +14,7 @@ import { TRIGGER_DISABLED, TRIGGER_PENDING, URLTriggerMatching, -} from '../../../extensions/replay/triggerMatching' +} from '../../../extensions/replay/external/triggerMatching' import { PostHog } from '../../../posthog-core' type TestConfig = { diff --git a/packages/browser/src/__tests__/extensions/replay/sessionrecording-utils.test.ts b/packages/browser/src/__tests__/extensions/replay/sessionrecording-utils.test.ts index 3fb7170fea..766801d620 100644 --- a/packages/browser/src/__tests__/extensions/replay/sessionrecording-utils.test.ts +++ b/packages/browser/src/__tests__/extensions/replay/sessionrecording-utils.test.ts @@ -9,7 +9,7 @@ import { SEVEN_MEGABYTES, estimateSize, circularReferenceReplacer, -} from '../../../extensions/replay/sessionrecording-utils' +} from '../../../extensions/replay/external/sessionrecording-utils' import { largeString, threeMBAudioURI, threeMBImageURI } from '../test_data/sessionrecording-utils-test-data' import type { eventWithTime } from '@rrweb/types' diff --git a/packages/browser/src/__tests__/extensions/replay/sessionrecording.test.ts b/packages/browser/src/__tests__/extensions/replay/sessionrecording.test.ts deleted file mode 100644 index fefb783807..0000000000 --- a/packages/browser/src/__tests__/extensions/replay/sessionrecording.test.ts +++ /dev/null @@ -1,2633 +0,0 @@ -/// - -import '@testing-library/jest-dom' - -import { PostHogPersistence } from '../../../posthog-persistence' -import { - CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE, - SESSION_RECORDING_CANVAS_RECORDING, - SESSION_RECORDING_ENABLED_SERVER_SIDE, - SESSION_RECORDING_IS_SAMPLED, - SESSION_RECORDING_MASKING, - SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE, -} from '../../../constants' -import { SessionIdManager } from '../../../sessionid' -import { - FULL_SNAPSHOT_EVENT_TYPE, - INCREMENTAL_SNAPSHOT_EVENT_TYPE, - META_EVENT_TYPE, -} from '../../../extensions/replay/sessionrecording-utils' -import { PostHog } from '../../../posthog-core' -import { - FlagsResponse, - PerformanceCaptureConfig, - PostHogConfig, - Property, - SessionIdChangedCallback, - 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 { - type customEvent, - EventType, - type eventWithTime, - type fullSnapshotEvent, - type incrementalData, - type incrementalSnapshotEvent, - IncrementalSource, - type metaEvent, - type pluginEvent, -} from '@rrweb/types' -import { ConsentManager } from '../../../consent' -import { SimpleEventEmitter } from '../../../utils/simple-event-emitter' -import Mock = jest.Mock - -// Type and source defined here designate a non-user-generated recording event - -jest.mock('../../../config', () => ({ LIB_VERSION: '0.0.1' })) - -const EMPTY_BUFFER = { - data: [], - sessionId: null, - size: 0, - windowId: null, -} - -const createMetaSnapshot = (event = {}): metaEvent => - ({ - type: META_EVENT_TYPE, - data: { - href: 'https://has-to-be-present-or-invalid.com', - }, - ...event, - }) as metaEvent - -const createStyleSnapshot = (event = {}): incrementalSnapshotEvent => - ({ - type: INCREMENTAL_SNAPSHOT_EVENT_TYPE, - data: { - source: IncrementalSource.StyleDeclaration, - }, - ...event, - }) as incrementalSnapshotEvent - -const createFullSnapshot = (event = {}): fullSnapshotEvent => - ({ - type: FULL_SNAPSHOT_EVENT_TYPE, - data: {}, - ...event, - }) as fullSnapshotEvent - -const createIncrementalSnapshot = (event = {}): incrementalSnapshotEvent => ({ - type: INCREMENTAL_SNAPSHOT_EVENT_TYPE, - data: { - source: 1, - } as Partial as incrementalData, - ...event, -}) - -const createIncrementalMouseEvent = () => { - return createIncrementalSnapshot({ - data: { - source: 2, - positions: [ - { - id: 1, - x: 100, - y: 200, - timeOffset: 100, - }, - ], - }, - }) -} - -const createIncrementalMutationEvent = (mutations?: { texts: any[] }) => { - const mutationData = { - texts: mutations?.texts || [], - attributes: [], - removes: [], - adds: [], - isAttachIframe: true, - } - return createIncrementalSnapshot({ - data: { - source: 0, - ...mutationData, - }, - }) -} - -const createIncrementalStyleSheetEvent = (mutations?: { adds: any[] }) => { - return createIncrementalSnapshot({ - data: { - // doesn't need to be a valid style sheet event - source: 8, - id: 1, - styleId: 1, - removes: [], - adds: mutations.adds || [], - replace: 'something', - replaceSync: 'something', - }, - }) -} - -const createCustomSnapshot = (event = {}, payload = {}, tag: string = 'custom'): customEvent => ({ - type: EventType.Custom, - data: { - tag: tag, - payload: { - ...payload, - }, - }, - ...event, -}) - -const createPluginSnapshot = (event = {}): pluginEvent => ({ - type: EventType.Plugin, - data: { - plugin: 'plugin', - payload: {}, - }, - ...event, -}) - -function makeFlagsResponse(partialResponse: Partial) { - return partialResponse as unknown as FlagsResponse -} - -const originalLocation = window!.location - -function fakeNavigateTo(href: string) { - delete (window as any).location - window!.location = { href } as Location -} - -describe('SessionRecording', () => { - const _addCustomEvent = jest.fn() - const loadScriptMock = jest.fn() - let _emit: any - let posthog: PostHog - let sessionRecording: SessionRecording - let sessionId: string - let sessionManager: SessionIdManager - let config: PostHogConfig - let sessionIdGeneratorMock: Mock - let windowIdGeneratorMock: Mock - let onFeatureFlagsCallback: ((flags: string[], variants: Record) => void) | null - let removePageviewCaptureHookMock: Mock - let simpleEventEmitter: SimpleEventEmitter - - const addRRwebToWindow = () => { - assignableWindow.__PosthogExtensions__.rrweb = { - record: jest.fn(({ emit }) => { - _emit = emit - return () => {} - }), - version: 'fake', - } - assignableWindow.__PosthogExtensions__.rrweb.record.takeFullSnapshot = jest.fn(() => { - // we pretend to be rrweb and call emit - _emit(createFullSnapshot()) - }) - assignableWindow.__PosthogExtensions__.rrweb.record.addCustomEvent = _addCustomEvent - - assignableWindow.__PosthogExtensions__.rrwebPlugins = { - getRecordConsolePlugin: jest.fn(), - } - } - - beforeEach(() => { - removePageviewCaptureHookMock = jest.fn() - sessionId = 'sessionId' + uuidv7() - - config = { - api_host: 'https://test.com', - disable_session_recording: false, - enable_recording_console_log: false, - autocapture: false, // Assert that session recording works even if `autocapture = false` - session_recording: { - maskAllInputs: false, - // not the default but makes for easier test assertions - compress_events: false, - }, - persistence: 'memory', - } as unknown as PostHogConfig - - assignableWindow.__PosthogExtensions__ = { - rrweb: undefined, - rrwebPlugins: { - getRecordConsolePlugin: undefined, - getRecordNetworkPlugin: undefined, - }, - } - - sessionIdGeneratorMock = jest.fn().mockImplementation(() => sessionId) - windowIdGeneratorMock = jest.fn().mockImplementation(() => 'windowId') - - const postHogPersistence = new PostHogPersistence(config) - postHogPersistence.clear() - - sessionManager = new SessionIdManager( - { config, persistence: postHogPersistence, register: jest.fn() } as unknown as PostHog, - sessionIdGeneratorMock, - windowIdGeneratorMock - ) - - simpleEventEmitter = new SimpleEventEmitter() - // TODO we really need to make this a real posthog instance :cry: - posthog = { - get_property: (property_key: string): Property | undefined => { - return postHogPersistence?.props[property_key] - }, - config: config, - capture: jest.fn(), - persistence: postHogPersistence, - onFeatureFlags: ( - cb: (flags: string[], variants: Record) => void - ): (() => void) => { - onFeatureFlagsCallback = cb - return () => {} - }, - sessionManager: sessionManager, - requestRouter: new RequestRouter({ config } as any), - consent: { - isOptedOut(): boolean { - return false - }, - } as unknown as ConsentManager, - register_for_session() {}, - _internalEventEmitter: simpleEventEmitter, - on: jest.fn().mockImplementation((event, cb) => { - const unsubscribe = simpleEventEmitter.on(event, cb) - return removePageviewCaptureHookMock.mockImplementation(unsubscribe) - }), - } as Partial as PostHog - - loadScriptMock.mockImplementation((_ph, _path, callback) => { - addRRwebToWindow() - callback() - }) - - assignableWindow.__PosthogExtensions__.loadExternalDependency = loadScriptMock - - // defaults - posthog.persistence?.register({ - [SESSION_RECORDING_ENABLED_SERVER_SIDE]: true, - [CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE]: false, - [SESSION_RECORDING_IS_SAMPLED]: undefined, - }) - - sessionRecording = new SessionRecording(posthog) - }) - - afterEach(() => { - window!.location = originalLocation - }) - - describe('isRecordingEnabled', () => { - it('is enabled if both the server and client config says enabled', () => { - posthog.persistence?.register({ [SESSION_RECORDING_ENABLED_SERVER_SIDE]: true }) - expect(sessionRecording['_isRecordingEnabled']).toBeTruthy() - }) - - it('is disabled if the server is disabled', () => { - posthog.persistence?.register({ [SESSION_RECORDING_ENABLED_SERVER_SIDE]: false }) - expect(sessionRecording['_isRecordingEnabled']).toBe(false) - }) - - it('is disabled if the client config is disabled', () => { - posthog.config.disable_session_recording = true - expect(sessionRecording['_isRecordingEnabled']).toBe(false) - }) - }) - - describe('isConsoleLogCaptureEnabled', () => { - it.each([ - ['enabled when both enabled', true, true, true], - ['uses client side setting when set to false', true, false, false], - ['uses client side setting when set to true', false, true, true], - ['disabled when both disabled', false, false, false], - ['uses client side setting (disabled) if server side setting is not set', undefined, false, false], - ['uses client side setting (enabled) if server side setting is not set', undefined, true, true], - ['is disabled when nothing is set', undefined, undefined, false], - ['uses server side setting (disabled) if client side setting is not set', undefined, false, false], - ['uses server side setting (enabled) if client side setting is not set', undefined, true, true], - ])( - '%s', - (_name: string, serverSide: boolean | undefined, clientSide: boolean | undefined, expected: boolean) => { - posthog.persistence?.register({ [CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE]: serverSide }) - posthog.config.enable_recording_console_log = clientSide - expect(sessionRecording['_isConsoleLogCaptureEnabled']).toBe(expected) - } - ) - }) - - describe('is canvas enabled', () => { - it.each([ - ['enabled when both enabled', true, true, true], - ['uses client side setting when set to false', true, false, false], - ['uses client side setting when set to true', false, true, true], - ['disabled when both disabled', false, false, false], - ['uses client side setting (disabled) if server side setting is not set', undefined, false, false], - ['uses client side setting (enabled) if server side setting is not set', undefined, true, true], - ['is disabled when nothing is set', undefined, undefined, false], - ['uses server side setting (disabled) if client side setting is not set', undefined, false, false], - ['uses server side setting (enabled) if client side setting is not set', undefined, true, true], - ])( - '%s', - (_name: string, serverSide: boolean | undefined, clientSide: boolean | undefined, expected: boolean) => { - posthog.persistence?.register({ - [SESSION_RECORDING_CANVAS_RECORDING]: { enabled: serverSide, fps: 4, quality: '0.1' }, - }) - posthog.config.session_recording.captureCanvas = { recordCanvas: clientSide } - expect(sessionRecording['_canvasRecording']).toMatchObject({ enabled: expected, fps: 4, quality: 0.1 }) - } - ) - - it.each([ - ['max fps and quality', 12, '1.0', 12, 1], - ['min fps and quality', 0, '0.0', 0, 0], - ['mid fps and quality', 6, '0.5', 6, 0.5], - ['null fps and quality', null, null, 4, 0.4], - ['undefined fps and quality', undefined, undefined, 4, 0.4], - ['string fps and quality', '12', '1.0', 4, 1], - ['over max fps and quality', 15, '1.5', 12, 1], - ])( - '%s', - ( - _name: string, - fps: number | string | null | undefined, - quality: string | null | undefined, - expectedFps: number, - expectedQuality: number - ) => { - posthog.persistence?.register({ - [SESSION_RECORDING_CANVAS_RECORDING]: { enabled: true, fps, quality }, - }) - - expect(sessionRecording['_canvasRecording']).toMatchObject({ - enabled: true, - fps: expectedFps, - quality: expectedQuality, - }) - } - ) - }) - - describe('network timing capture config', () => { - it.each([ - ['enabled when both enabled', true, true, true], - // returns undefined when nothing is enabled - ['uses client side setting when set to false - even if remotely enabled', true, false, undefined], - ['uses client side setting when set to true', false, true, true], - // returns undefined when nothing is enabled - ['disabled when both disabled', false, false, undefined], - // returns undefined when nothing is enabled - ['uses client side setting (disabled) if server side setting is not set', undefined, false, undefined], - ['uses client side setting (enabled) if server side setting is not set', undefined, true, true], - // returns undefined when nothing is enabled - ['is disabled when nothing is set', undefined, undefined, undefined], - // returns undefined when nothing is enabled - ['can be disabled when client object config only is set', undefined, { network_timing: false }, undefined], - [ - 'can be disabled when client object config only is disabled - even if remotely enabled', - true, - { network_timing: false }, - undefined, - ], - ['can be enabled when client object config only is set', undefined, { network_timing: true }, true], - [ - 'can be disabled when client object config makes no decision', - undefined, - { network_timing: undefined }, - undefined, - ], - ['uses server side setting (disabled) if client side setting is not set', false, undefined, undefined], - ['uses server side setting (enabled) if client side setting is not set', true, undefined, true], - ])( - '%s', - ( - _name: string, - serverSide: boolean | undefined, - clientSide: boolean | PerformanceCaptureConfig | undefined, - expected: boolean | undefined - ) => { - posthog.persistence?.register({ - [SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE]: { capturePerformance: serverSide }, - }) - posthog.config.capture_performance = clientSide - expect(sessionRecording['_networkPayloadCapture']?.recordPerformance).toBe(expected) - } - ) - }) - - describe('masking config', () => { - it.each([ - [ - 'enabled when both enabled', - { maskAllInputs: true, maskTextSelector: '*' }, - { maskAllInputs: true, maskTextSelector: '*' }, - { maskAllInputs: true, maskTextSelector: '*' }, - ], - [ - 'disabled when both disabled', - { maskAllInputs: false }, - { maskAllInputs: false }, - { maskAllInputs: false }, - ], - ['is undefined when nothing is set', undefined, undefined, undefined], - [ - 'uses client config when set if server config is not set', - undefined, - { maskAllInputs: true, maskTextSelector: '#client' }, - { maskAllInputs: true, maskTextSelector: '#client' }, - ], - [ - 'uses server config when set if client config is not set', - { maskAllInputs: false, maskTextSelector: '#server' }, - undefined, - { maskAllInputs: false, maskTextSelector: '#server' }, - ], - [ - 'overrides server config with client config if both are set', - { maskAllInputs: false, maskTextSelector: '#server' }, - { maskAllInputs: true, maskTextSelector: '#client' }, - { maskAllInputs: true, maskTextSelector: '#client' }, - ], - [ - 'partially overrides server config with client config if both are set', - { maskAllInputs: true, maskTextSelector: '*' }, - { maskAllInputs: false }, - { maskAllInputs: false, maskTextSelector: '*' }, - ], - [ - 'mask inputs default is correct if client sets text selector', - undefined, - { maskTextSelector: '*' }, - { maskAllInputs: true, maskTextSelector: '*' }, - ], - [ - 'can set blockSelector to img', - undefined, - { blockSelector: 'img' }, - { maskAllInputs: true, maskTextSelector: undefined, blockSelector: 'img' }, - ], - [ - 'can set blockSelector to some other selector', - undefined, - { blockSelector: 'div' }, - { maskAllInputs: true, maskTextSelector: undefined, blockSelector: 'div' }, - ], - ])( - '%s', - ( - _name: string, - serverConfig: - | { maskAllInputs?: boolean; maskTextSelector?: string; blockSelector?: string } - | undefined, - clientConfig: - | { maskAllInputs?: boolean; maskTextSelector?: string; blockSelector?: string } - | undefined, - expected: { maskAllInputs: boolean; maskTextSelector?: string; blockSelector?: string } | undefined - ) => { - posthog.persistence?.register({ - [SESSION_RECORDING_MASKING]: serverConfig, - }) - - posthog.config.session_recording.maskAllInputs = clientConfig?.maskAllInputs - posthog.config.session_recording.maskTextSelector = clientConfig?.maskTextSelector - posthog.config.session_recording.blockSelector = clientConfig?.blockSelector - - expect(sessionRecording['_masking']).toEqual(expected) - } - ) - }) - - describe('startIfEnabledOrStop', () => { - beforeEach(() => { - // need to cast as any to mock private methods - jest.spyOn(sessionRecording as any, '_startCapture') - jest.spyOn(sessionRecording, 'stopRecording') - jest.spyOn(sessionRecording, 'tryAddCustomEvent') - }) - - it('call _startCapture if its enabled', () => { - sessionRecording.startIfEnabledOrStop() - expect((sessionRecording as any)._startCapture).toHaveBeenCalled() - }) - - it('sets the pageview capture hook once', () => { - expect(sessionRecording['_removePageViewCaptureHook']).toBeUndefined() - - sessionRecording.startIfEnabledOrStop() - - expect(sessionRecording['_removePageViewCaptureHook']).not.toBeUndefined() - expect(posthog.on).toHaveBeenCalledTimes(1) - - // calling a second time doesn't add another capture hook - sessionRecording.startIfEnabledOrStop() - expect(posthog.on).toHaveBeenCalledTimes(1) - }) - - it('removes the pageview capture hook on stop', () => { - sessionRecording.startIfEnabledOrStop() - expect(sessionRecording['_removePageViewCaptureHook']).not.toBeUndefined() - - expect(removePageviewCaptureHookMock).not.toHaveBeenCalled() - sessionRecording.stopRecording() - - expect(removePageviewCaptureHookMock).toHaveBeenCalledTimes(1) - expect(sessionRecording['_removePageViewCaptureHook']).toBeUndefined() - }) - - it('sets the window event listeners', () => { - //mock window add event listener to check if it is called - window.addEventListener = jest.fn().mockImplementation(() => () => {}) - - sessionRecording.startIfEnabledOrStop() - expect(sessionRecording['_onBeforeUnload']).not.toBeNull() - // we register 4 event listeners - expect(window.addEventListener).toHaveBeenCalledTimes(4) - - // window.addEventListener('blah', someFixedListenerInstance) is safe to call multiple times, - // so we don't need to test if the addEvenListener registrations are called multiple times - }) - - it('emits an options event', () => { - sessionRecording.startIfEnabledOrStop() - expect(sessionRecording.tryAddCustomEvent).toHaveBeenCalledWith('$session_options', { - activePlugins: [], - sessionRecordingOptions: { - blockClass: 'ph-no-capture', - blockSelector: undefined, - collectFonts: false, - ignoreClass: 'ph-ignore-input', - inlineStylesheet: true, - maskAllInputs: false, - maskInputFn: undefined, - maskInputOptions: { password: true }, - maskTextClass: 'ph-mask', - maskTextFn: undefined, - maskTextSelector: undefined, - recordCrossOriginIframes: false, - slimDOMOptions: {}, - }, - }) - }) - - it('call stopRecording if its not enabled', () => { - posthog.config.disable_session_recording = true - sessionRecording.startIfEnabledOrStop() - expect(sessionRecording.stopRecording).toHaveBeenCalled() - }) - }) - - describe('recording', () => { - describe('sampling', () => { - it('does not emit to capture if the sample rate is 0', () => { - sessionRecording.startIfEnabledOrStop() - - sessionRecording.onRemoteConfig( - makeFlagsResponse({ - sessionRecording: { endpoint: '/s/', sampleRate: '0.00' }, - }) - ) - expect(sessionRecording.status).toBe('disabled') - - _emit(createIncrementalSnapshot({ data: { source: 1 } })) - expect(posthog.capture).not.toHaveBeenCalled() - expect(sessionRecording.status).toBe('disabled') - }) - - it('does emit to capture if the sample rate is null', () => { - sessionRecording.startIfEnabledOrStop() - - sessionRecording.onRemoteConfig( - makeFlagsResponse({ - sessionRecording: { endpoint: '/s/', sampleRate: null }, - }) - ) - - expect(sessionRecording.status).toBe('active') - }) - - it('stores excluded session when excluded', () => { - sessionRecording.startIfEnabledOrStop() - - sessionRecording.onRemoteConfig( - makeFlagsResponse({ - sessionRecording: { endpoint: '/s/', sampleRate: '0.00' }, - }) - ) - - expect(sessionRecording['_isSampled']).toStrictEqual(false) - }) - - it('does emit to capture if the sample rate is 1', () => { - sessionRecording.startIfEnabledOrStop() - - _emit(createIncrementalSnapshot({ data: { source: 1 } })) - expect(posthog.capture).not.toHaveBeenCalled() - - sessionRecording.onRemoteConfig( - makeFlagsResponse({ - sessionRecording: { endpoint: '/s/', sampleRate: '1.00' }, - }) - ) - _emit(createIncrementalSnapshot({ data: { source: 1 } })) - - expect(sessionRecording.status).toBe('sampled') - expect(sessionRecording['_isSampled']).toStrictEqual(true) - - // don't wait two seconds for the flush timer - sessionRecording['_flushBuffer']() - - _emit(createIncrementalSnapshot({ data: { source: 1 } })) - expect(posthog.capture).toHaveBeenCalled() - }) - - it('sets emit as expected when sample rate is 0.5', () => { - sessionRecording.startIfEnabledOrStop() - - sessionRecording.onRemoteConfig( - makeFlagsResponse({ - sessionRecording: { endpoint: '/s/', sampleRate: '0.50' }, - }) - ) - const emitValues: string[] = [] - let lastSessionId = sessionRecording['_sessionId'] - - for (let i = 0; i < 100; i++) { - // force change the session ID - sessionManager.resetSessionId() - sessionId = 'session-id-' + uuidv7() - _emit(createIncrementalSnapshot({ data: { source: 1 } })) - - expect(sessionRecording['_sessionId']).not.toBe(lastSessionId) - lastSessionId = sessionRecording['_sessionId'] - - emitValues.push(sessionRecording.status) - } - - // the random number generator won't always be exactly 0.5, but it should be close - expect(emitValues.filter((v) => v === 'sampled').length).toBeGreaterThan(30) - expect(emitValues.filter((v) => v === 'disabled').length).toBeGreaterThan(30) - }) - - it('turning sample rate to null, means sessions are no longer sampled out', () => { - sessionRecording.startIfEnabledOrStop() - // set sample rate to 0, i.e. no sessions will run - sessionRecording.onRemoteConfig( - makeFlagsResponse({ sessionRecording: { endpoint: '/s/', sampleRate: '0.00' } }) - ) - // then check that a session is sampled (i.e. storage is false not true or null) - expect(posthog.get_property(SESSION_RECORDING_IS_SAMPLED)).toBe(false) - expect(sessionRecording.status).toBe('disabled') - - // then turn sample rate to null - sessionRecording.onRemoteConfig( - makeFlagsResponse({ sessionRecording: { endpoint: '/s/', sampleRate: null } }) - ) - - // then check that a session is no longer sampled out (i.e. storage is cleared not false) - expect(posthog.get_property(SESSION_RECORDING_IS_SAMPLED)).toBe(undefined) - expect(sessionRecording.status).toBe('active') - }) - - it('turning sample rate from null to 0, resets values as expected', () => { - sessionRecording.startIfEnabledOrStop() - - // first turn sample rate to null - sessionRecording.onRemoteConfig( - makeFlagsResponse({ sessionRecording: { endpoint: '/s/', sampleRate: null } }) - ) - - // then check that a session is no longer sampled out (i.e. storage is cleared not false) - expect(posthog.get_property(SESSION_RECORDING_IS_SAMPLED)).toBe(undefined) - expect(sessionRecording.status).toBe('active') - - // set sample rate to 0, i.e. no sessions will run - sessionRecording.onRemoteConfig( - makeFlagsResponse({ sessionRecording: { endpoint: '/s/', sampleRate: '0.00' } }) - ) - // then check that a session is sampled (i.e. storage is false not true or null) - expect(posthog.get_property(SESSION_RECORDING_IS_SAMPLED)).toBe(false) - expect(sessionRecording.status).toBe('disabled') - }) - }) - - describe('canvas', () => { - it('passes the remote config to rrweb', () => { - posthog.persistence?.register({ - [SESSION_RECORDING_CANVAS_RECORDING]: { - enabled: true, - fps: 6, - quality: 0.2, - }, - }) - - sessionRecording.startIfEnabledOrStop() - - sessionRecording['_onScriptLoaded']() - expect(assignableWindow.__PosthogExtensions__.rrweb.record).toHaveBeenCalledWith( - expect.objectContaining({ - recordCanvas: true, - sampling: { canvas: 6 }, - dataURLOptions: { - type: 'image/webp', - quality: 0.2, - }, - }) - ) - }) - - it('skips when any config variable is missing', () => { - sessionRecording.startIfEnabledOrStop() - - sessionRecording.onRemoteConfig( - makeFlagsResponse({ - sessionRecording: { endpoint: '/s/', recordCanvas: null, canvasFps: null, canvasQuality: null }, - }) - ) - - sessionRecording['_onScriptLoaded']() - - const mockParams = assignableWindow.__PosthogExtensions__.rrweb.record.mock.calls[0][0] - expect(mockParams).not.toHaveProperty('recordCanvas') - expect(mockParams).not.toHaveProperty('canvasFps') - expect(mockParams).not.toHaveProperty('canvasQuality') - }) - }) - - it('calls rrweb.record with the right options', () => { - posthog.persistence?.register({ [CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE]: false }) - - sessionRecording.startIfEnabledOrStop() - // maskAllInputs should change from default - // someUnregisteredProp should not be present - expect(assignableWindow.__PosthogExtensions__.rrweb.record).toHaveBeenCalledWith({ - emit: expect.anything(), - maskAllInputs: false, - blockClass: 'ph-no-capture', - blockSelector: undefined, - ignoreClass: 'ph-ignore-input', - maskTextClass: 'ph-mask', - maskTextSelector: undefined, - maskInputOptions: { password: true }, - maskInputFn: undefined, - slimDOMOptions: {}, - collectFonts: false, - plugins: [], - inlineStylesheet: true, - recordCrossOriginIframes: false, - }) - }) - - describe('masking', () => { - it('passes remote masking options to rrweb', () => { - posthog.config.session_recording.maskAllInputs = undefined - - posthog.persistence?.register({ - [SESSION_RECORDING_MASKING]: { maskAllInputs: true, maskTextSelector: '*' }, - }) - - sessionRecording.startIfEnabledOrStop() - - sessionRecording['_onScriptLoaded']() - - expect(assignableWindow.__PosthogExtensions__.rrweb.record).toHaveBeenCalledWith( - expect.objectContaining({ - maskAllInputs: true, - maskTextSelector: '*', - }) - ) - }) - }) - - describe('capturing passwords', () => { - it.each([ - ['no masking options', {} as SessionRecordingOptions, true], - ['empty masking options', { maskInputOptions: {} } as SessionRecordingOptions, true], - ['password not set', { maskInputOptions: { input: true } } as SessionRecordingOptions, true], - ['password set to true', { maskInputOptions: { password: true } } as SessionRecordingOptions, true], - ['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() - expect(assignableWindow.__PosthogExtensions__.rrweb.record).toHaveBeenCalledWith( - expect.objectContaining({ - maskInputOptions: expect.objectContaining({ password: expected }), - }) - ) - }) - }) - - it('records events emitted before and after starting recording', () => { - sessionRecording.startIfEnabledOrStop() - expect(loadScriptMock).toHaveBeenCalled() - - _emit(createIncrementalSnapshot({ data: { source: 1 } })) - expect(posthog.capture).not.toHaveBeenCalled() - - expect(sessionRecording['_buffer']).toEqual({ - data: [ - { - data: { - source: 1, - }, - type: 3, - }, - ], - size: 30, - // session id and window id are not null 🚀 - sessionId: sessionId, - windowId: 'windowId', - }) - - sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: { endpoint: '/s/' } })) - - // next call to emit won't flush the buffer - // the events aren't big enough - _emit(createIncrementalSnapshot({ data: { source: 2 } })) - - // access private method 🤯so we don't need to wait for the timer - sessionRecording['_flushBuffer']() - expect(sessionRecording['_buffer'].data.length).toEqual(0) - - expect(posthog.capture).toHaveBeenCalledTimes(1) - expect(posthog.capture).toHaveBeenCalledWith( - '$snapshot', - { - $snapshot_bytes: 60, - $snapshot_data: [ - { type: 3, data: { source: 1 } }, - { type: 3, data: { source: 2 } }, - ], - $session_id: sessionId, - $window_id: 'windowId', - $lib: 'web', - $lib_version: '0.0.1', - }, - { - _url: 'https://test.com/s/', - _noTruncate: true, - _batchKey: 'recordings', - skip_client_rate_limiting: true, - } - ) - }) - - it('buffers emitted events', () => { - sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: { endpoint: '/s/' } })) - sessionRecording.startIfEnabledOrStop() - expect(loadScriptMock).toHaveBeenCalled() - - _emit(createIncrementalSnapshot({ data: { source: 1 } })) - _emit(createIncrementalSnapshot({ data: { source: 2 } })) - - expect(posthog.capture).not.toHaveBeenCalled() - expect(sessionRecording['_flushBufferTimer']).not.toBeUndefined() - - sessionRecording['_flushBuffer']() - expect(sessionRecording['_flushBufferTimer']).toBeUndefined() - - expect(posthog.capture).toHaveBeenCalledTimes(1) - expect(posthog.capture).toHaveBeenCalledWith( - '$snapshot', - { - $session_id: sessionId, - $window_id: 'windowId', - $snapshot_bytes: 60, - $snapshot_data: [ - { type: 3, data: { source: 1 } }, - { type: 3, data: { source: 2 } }, - ], - $lib: 'web', - $lib_version: '0.0.1', - }, - { - _url: 'https://test.com/s/', - _noTruncate: true, - _batchKey: 'recordings', - skip_client_rate_limiting: true, - } - ) - }) - - it('flushes buffer if the size of the buffer hits the limit', () => { - sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: { endpoint: '/s/' } })) - sessionRecording.startIfEnabledOrStop() - expect(loadScriptMock).toHaveBeenCalled() - const bigData = 'a'.repeat(RECORDING_MAX_EVENT_SIZE * 0.8) - - _emit(createIncrementalSnapshot({ data: { source: 1, payload: bigData } })) - _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 }) - - // Another big event means the old data will be flushed - _emit(createIncrementalSnapshot({ data: { source: 1, payload: bigData } })) - expect(posthog.capture).toHaveBeenCalled() - expect(sessionRecording['_buffer'].data.length).toEqual(1) // The new event - expect(sessionRecording['_buffer']).toMatchObject({ size: 755017 }) - }) - - it('maintains the buffer if the recording is buffering', () => { - sessionRecording.startIfEnabledOrStop() - expect(loadScriptMock).toHaveBeenCalled() - - 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'].data.length).toEqual(1) // 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 }) - - // 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 - }) - - it('flushes buffer if the session_id changes', () => { - sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: { endpoint: '/s/' } })) - sessionRecording.startIfEnabledOrStop() - - expect(sessionRecording['_buffer'].sessionId).toEqual(sessionId) - - _emit(createIncrementalSnapshot({ emit: 1 })) - - expect(posthog.capture).not.toHaveBeenCalled() - expect(sessionRecording['_buffer'].sessionId).not.toEqual(null) - expect(sessionRecording['_buffer'].data).toEqual([{ 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 - // i.e. the data in the buffer should be sent with 'otherSessionId' - sessionRecording['_buffer']!.sessionId = 'otherSessionId' - _emit(createIncrementalSnapshot({ emit: 2 })) - - expect(posthog.capture).toHaveBeenCalledWith( - '$snapshot', - { - $session_id: 'otherSessionId', - $window_id: 'windowId', - $snapshot_data: [{ data: { source: 1 }, emit: 1, type: 3 }], - $snapshot_bytes: 39, - $lib: 'web', - $lib_version: '0.0.1', - }, - { - _url: 'https://test.com/s/', - _noTruncate: true, - _batchKey: 'recordings', - skip_client_rate_limiting: true, - } - ) - - // and the rrweb event emitted _after_ the session id change should be sent yet - expect(sessionRecording['_buffer']).toEqual({ - data: [ - { - data: { - source: 1, - }, - emit: 2, - type: 3, - }, - ], - sessionId: sessionId, - size: 39, - windowId: 'windowId', - }) - }) - - it("doesn't load recording script if already loaded", () => { - addRRwebToWindow() - sessionRecording.startIfEnabledOrStop() - expect(loadScriptMock).not.toHaveBeenCalled() - }) - - it('loads recording script from right place', () => { - sessionRecording.startIfEnabledOrStop() - - expect(loadScriptMock).toHaveBeenCalledWith(expect.anything(), 'recorder', expect.anything()) - }) - - it('loads script after `_startCapture` if not previously loaded', () => { - posthog.persistence?.register({ [SESSION_RECORDING_ENABLED_SERVER_SIDE]: false }) - - sessionRecording.startIfEnabledOrStop() - expect(loadScriptMock).not.toHaveBeenCalled() - - sessionRecording['_startCapture']() - - expect(loadScriptMock).toHaveBeenCalled() - }) - - it('does not load script if disable_session_recording passed', () => { - posthog.config.disable_session_recording = true - - sessionRecording.startIfEnabledOrStop() - sessionRecording['_startCapture']() - - expect(loadScriptMock).not.toHaveBeenCalled() - }) - - it('session recording can be turned on and off', () => { - expect(sessionRecording['_stopRrweb']).toEqual(undefined) - - sessionRecording.startIfEnabledOrStop() - - expect(sessionRecording.started).toEqual(true) - expect(sessionRecording['_stopRrweb']).not.toEqual(undefined) - - sessionRecording.stopRecording() - - expect(sessionRecording['_stopRrweb']).toEqual(undefined) - expect(sessionRecording.started).toEqual(false) - }) - - it('session recording can be turned on after being turned off', () => { - expect(sessionRecording['_stopRrweb']).toEqual(undefined) - - sessionRecording.startIfEnabledOrStop() - - expect(sessionRecording.started).toEqual(true) - expect(sessionRecording['_stopRrweb']).not.toEqual(undefined) - - sessionRecording.stopRecording() - - expect(sessionRecording['_stopRrweb']).toEqual(undefined) - expect(sessionRecording.started).toEqual(false) - }) - - it('can emit when there are circular references', () => { - sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: { endpoint: '/s/' } })) - sessionRecording.startIfEnabledOrStop() - - const someObject = { emit: 1 } - // the same object can be there multiple times - const circularObject: Record = { emit: someObject, again: someObject } - // but a circular reference will be replaced - circularObject.circularReference = circularObject - _emit(createFullSnapshot(circularObject)) - - expect(sessionRecording['_buffer']).toEqual({ - data: [ - { - again: { - emit: 1, - }, - circularReference: { - again: { - emit: 1, - }, - // the circular reference is captured to the buffer, - // but it didn't explode when estimating size - circularReference: expect.any(Object), - emit: { - emit: 1, - }, - }, - data: {}, - emit: { - emit: 1, - }, - type: 2, - }, - ], - sessionId: sessionId, - size: 149, - windowId: 'windowId', - }) - }) - - describe('console logs', () => { - it('if not enabled, plugin is not used', () => { - posthog.config.enable_recording_console_log = false - - sessionRecording.startIfEnabledOrStop() - - expect( - assignableWindow.__PosthogExtensions__.rrwebPlugins.getRecordConsolePlugin - ).not.toHaveBeenCalled() - }) - - it('if enabled, plugin is used', () => { - posthog.config.enable_recording_console_log = true - - sessionRecording.startIfEnabledOrStop() - - expect(assignableWindow.__PosthogExtensions__.rrwebPlugins.getRecordConsolePlugin).toHaveBeenCalled() - }) - }) - - describe('the session id manager', () => { - const startingDate = new Date() - - const emitAtDateTime = (date: Date, source = 1) => - _emit({ - event: 123, - type: INCREMENTAL_SNAPSHOT_EVENT_TYPE, - timestamp: date.getTime(), - data: { - source, - }, - }) - - describe('onSessionId Callbacks', () => { - let mockCallback: Mock - let unsubscribeCallback: () => void - - beforeEach(() => { - sessionManager = new SessionIdManager({ - config, - persistence: new PostHogPersistence(config), - register: jest.fn(), - } as unknown as PostHog) - posthog.sessionManager = sessionManager - - mockCallback = jest.fn() - unsubscribeCallback = sessionManager.onSessionId(mockCallback) - - expect(mockCallback).not.toHaveBeenCalled() - - sessionRecording.startIfEnabledOrStop() - sessionRecording['_startCapture']() - - expect(mockCallback).toHaveBeenCalledTimes(1) - }) - - afterEach(() => { - jest.useRealTimers() - }) - - it('calls the callback when the session id changes', () => { - const startingSessionId = sessionManager['_getSessionId']()[1] - - emitAtDateTime(startingDate) - - emitAtDateTime( - new Date( - startingDate.getFullYear(), - startingDate.getMonth(), - startingDate.getDate(), - startingDate.getHours(), - startingDate.getMinutes() + 1 - ) - ) - - const inactivityThresholdLater = new Date( - startingDate.getFullYear(), - startingDate.getMonth(), - startingDate.getDate(), - startingDate.getHours(), - startingDate.getMinutes() + 32 - ) - - // restarting the session checks the session id using "now" so we need to fix that - jest.useFakeTimers().setSystemTime(inactivityThresholdLater) - emitAtDateTime(inactivityThresholdLater) - - expect(sessionManager['_getSessionId']()[1]).not.toEqual(startingSessionId) - - expect(mockCallback).toHaveBeenCalledTimes(2) - // last call received the new session id - expect(mockCallback.mock.calls[1][0]).toEqual(sessionManager['_getSessionId']()[1]) - }) - - it('does not calls the callback when the session id changes after unsubscribe', () => { - unsubscribeCallback() - - const startingSessionId = sessionManager['_getSessionId']()[1] - emitAtDateTime(startingDate) - emitAtDateTime( - new Date( - startingDate.getFullYear(), - startingDate.getMonth(), - startingDate.getDate(), - startingDate.getHours(), - startingDate.getMinutes() + 1 - ) - ) - - const inactivityThresholdLater = new Date( - startingDate.getFullYear(), - startingDate.getMonth(), - startingDate.getDate(), - startingDate.getHours(), - startingDate.getMinutes() + 32 - ) - emitAtDateTime(inactivityThresholdLater) - - expect(sessionManager['_getSessionId']()[1]).not.toEqual(startingSessionId) - - expect(mockCallback).toHaveBeenCalledTimes(1) - // the only call received the original session id - expect(mockCallback.mock.calls[0][0]).toEqual(startingSessionId) - }) - }) - - describe('with a real session id manager', () => { - beforeEach(() => { - sessionManager = new SessionIdManager({ - config, - persistence: new PostHogPersistence(config), - register: jest.fn(), - } as unknown as PostHog) - posthog.sessionManager = sessionManager - - sessionRecording.startIfEnabledOrStop() - sessionRecording['_startCapture']() - }) - - it('does not change session id for a second _emit', () => { - const startingSessionId = sessionManager['_getSessionId']()[1] - - emitAtDateTime(startingDate) - emitAtDateTime( - new Date( - startingDate.getFullYear(), - startingDate.getMonth(), - startingDate.getDate(), - startingDate.getHours(), - startingDate.getMinutes() + 1 - ) - ) - - expect(sessionManager['_getSessionId']()[1]).toEqual(startingSessionId) - }) - - 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() - - emitAtDateTime(startingDate) - emitAtDateTime( - new Date( - startingDate.getFullYear(), - startingDate.getMonth(), - startingDate.getDate(), - startingDate.getHours(), - startingDate.getMinutes() + 1 - ) - ) - - const inactivityThresholdLater = new Date( - startingDate.getFullYear(), - startingDate.getMonth(), - startingDate.getDate(), - startingDate.getHours(), - startingDate.getMinutes() + 32 - ) - emitAtDateTime(inactivityThresholdLater) - - expect(sessionManager['_getSessionId']()[1]).not.toEqual(startingSessionId) - expect(sessionRecording.stopRecording).toHaveBeenCalled() - expect(sessionRecording.startIfEnabledOrStop).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() - - emitAtDateTime(startingDate) - emitAtDateTime( - new Date( - startingDate.getFullYear(), - startingDate.getMonth(), - startingDate.getDate(), - startingDate.getHours(), - startingDate.getMinutes() + 1 - ) - ) - - const moreThanADayLater = new Date( - startingDate.getFullYear(), - startingDate.getMonth(), - startingDate.getDate() + 1, - startingDate.getHours() + 1 - ) - emitAtDateTime(moreThanADayLater) - - expect(sessionManager['_getSessionId']()[1]).not.toEqual(startingSessionId) - - expect(sessionRecording.stopRecording).toHaveBeenCalled() - expect(sessionRecording.startIfEnabledOrStop).toHaveBeenCalled() - }) - }) - }) - }) - - describe('idle timeouts', () => { - let startingTimestamp = -1 - - function emitInactiveEvent(activityTimestamp: number, expectIdle: boolean | 'unknown' = false) { - const snapshotEvent = { - event: 123, - type: INCREMENTAL_SNAPSHOT_EVENT_TYPE, - data: { - source: 0, - adds: [], - attributes: [], - removes: [], - texts: [], - }, - timestamp: activityTimestamp, - } - _emit(snapshotEvent) - expect(sessionRecording['_isIdle']).toEqual(expectIdle) - return snapshotEvent - } - - function emitActiveEvent(activityTimestamp: number, expectedMatchingActivityTimestamp: boolean = true) { - const snapshotEvent = { - event: 123, - type: INCREMENTAL_SNAPSHOT_EVENT_TYPE, - data: { - source: 1, - }, - timestamp: activityTimestamp, - } - _emit(snapshotEvent) - expect(sessionRecording['_isIdle']).toEqual(false) - if (expectedMatchingActivityTimestamp) { - expect(sessionRecording['_lastActivityTimestamp']).toEqual(activityTimestamp) - } - return snapshotEvent - } - - beforeEach(() => { - sessionRecording.startIfEnabledOrStop() - sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: { endpoint: '/s/' } })) - expect(sessionRecording.status).toEqual('active') - - startingTimestamp = sessionRecording['_lastActivityTimestamp'] - expect(startingTimestamp).toBeGreaterThan(0) - - expect(assignableWindow.__PosthogExtensions__.rrweb.record.takeFullSnapshot).toHaveBeenCalledTimes(0) - - // the buffer starts out empty - expect(sessionRecording['_buffer']).toEqual({ - data: [], - sessionId: sessionId, - size: 0, - windowId: 'windowId', - }) - - // options will have been emitted - expect(_addCustomEvent).toHaveBeenCalled() - _addCustomEvent.mockClear() - }) - - afterEach(() => { - jest.useRealTimers() - }) - - it('starts neither idle nor active', () => { - expect(sessionRecording['_isIdle']).toEqual('unknown') - }) - - it('does not emit events until after first active event', () => { - const a = emitInactiveEvent(startingTimestamp + 100, 'unknown') - const b = emitInactiveEvent(startingTimestamp + 110, 'unknown') - const c = emitInactiveEvent(startingTimestamp + 120, 'unknown') - - _emit(createFullSnapshot({}), 'unknown') - expect(sessionRecording['_isIdle']).toEqual('unknown') - expect(posthog.capture).not.toHaveBeenCalled() - - const d = emitActiveEvent(startingTimestamp + 200) - expect(sessionRecording['_isIdle']).toEqual(false) - // but all events are buffered - expect(sessionRecording['_buffer']).toEqual({ - data: [a, b, c, createFullSnapshot({}), d], - sessionId: sessionId, - size: 442, - windowId: expect.any(String), - }) - }) - - it('does not emit plugin events when idle', () => { - const emptyBuffer = { - ...EMPTY_BUFFER, - sessionId: sessionId, - windowId: 'windowId', - } - - // force idle state - sessionRecording['_isIdle'] = true - // buffer is empty - expect(sessionRecording['_buffer']).toEqual(emptyBuffer) - - sessionRecording.onRRwebEmit(createPluginSnapshot({}) as eventWithTime) - - // a plugin event doesn't count as returning from idle - expect(sessionRecording['_isIdle']).toEqual(true) - expect(sessionRecording['_buffer']).toEqual({ - ...EMPTY_BUFFER, - sessionId: sessionId, - windowId: 'windowId', - }) - }) - - it('active incremental events return from idle', () => { - const emptyBuffer = { - ...EMPTY_BUFFER, - sessionId: sessionId, - windowId: 'windowId', - } - - // force idle state - sessionRecording['_isIdle'] = true - // buffer is empty - expect(sessionRecording['_buffer']).toEqual(emptyBuffer) - - sessionRecording.onRRwebEmit(createIncrementalSnapshot({}) as eventWithTime) - - // an incremental event counts as returning from idle - expect(sessionRecording['_isIdle']).toEqual(false) - // buffer contains event allowed when idle - expect(sessionRecording['_buffer']).toEqual({ - data: [createIncrementalSnapshot({})], - sessionId: sessionId, - size: 30, - windowId: 'windowId', - }) - }) - - it('does not emit buffered custom events while idle even when over buffer max size', () => { - // force idle state - sessionRecording['_isIdle'] = true - // buffer is empty - expect(sessionRecording['_buffer']).toEqual({ - ...EMPTY_BUFFER, - sessionId: sessionId, - windowId: 'windowId', - }) - - // ensure buffer isn't empty - sessionRecording.onRRwebEmit(createCustomSnapshot({}) as eventWithTime) - - // fake having a large buffer - // in reality we would need a very long idle period emitting custom events to reach 1MB of buffer data - // particularly since we flush the buffer on entering idle - sessionRecording['_buffer'].size = RECORDING_MAX_EVENT_SIZE - 1 - sessionRecording.onRRwebEmit(createCustomSnapshot({}) as eventWithTime) - - // we're still idle - expect(sessionRecording['_isIdle']).toBe(true) - // return from idle - - // we did not capture - expect(posthog.capture).not.toHaveBeenCalled() - }) - - it('drops full snapshots when idle - so we must make sure not to take them while idle!', () => { - // force idle state - sessionRecording['_isIdle'] = true - // buffer is empty - expect(sessionRecording['_buffer']).toEqual({ - ...EMPTY_BUFFER, - sessionId: sessionId, - windowId: 'windowId', - }) - - sessionRecording.onRRwebEmit(createFullSnapshot({}) as eventWithTime) - - expect(sessionRecording['_buffer']).toEqual({ - data: [], - sessionId: sessionId, - size: 0, - windowId: 'windowId', - }) - }) - - it('does not emit meta snapshot events when idle - so we must make sure not to take them while idle!', () => { - // force idle state - sessionRecording['_isIdle'] = true - // buffer is empty - expect(sessionRecording['_buffer']).toEqual({ - ...EMPTY_BUFFER, - sessionId: sessionId, - windowId: 'windowId', - }) - - sessionRecording.onRRwebEmit(createMetaSnapshot({}) as eventWithTime) - - expect(sessionRecording['_buffer']).toEqual({ - data: [], - sessionId: sessionId, - size: 0, - windowId: 'windowId', - }) - }) - - it('does not emit style snapshot events when idle - so we must make sure not to take them while idle!', () => { - // force idle state - sessionRecording['_isIdle'] = true - // buffer is empty - expect(sessionRecording['_buffer']).toEqual({ - ...EMPTY_BUFFER, - sessionId: sessionId, - windowId: 'windowId', - }) - - sessionRecording.onRRwebEmit(createStyleSnapshot({}) as eventWithTime) - - expect(sessionRecording['_buffer']).toEqual({ - data: [], - sessionId: sessionId, - size: 0, - windowId: 'windowId', - }) - }) - - 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_THRESHOLD_MS + 1000 - const fourthActivityTimestamp = startingTimestamp + RECORDING_IDLE_THRESHOLD_MS + 2000 - - const firstSnapshotEvent = emitActiveEvent(firstActivityTimestamp) - // event was active so activity timestamp is updated - expect(sessionRecording['_lastActivityTimestamp']).toEqual(firstActivityTimestamp) - - // after the first emit the buffer has been initialised but not flushed - const firstSessionId = sessionRecording['_sessionId'] - expect(sessionRecording['_buffer']).toEqual({ - data: [firstSnapshotEvent], - sessionId: firstSessionId, - size: 68, - 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) - - // the second snapshot remains buffered in memory - expect(sessionRecording['_buffer']).toEqual({ - data: [firstSnapshotEvent, secondSnapshot], - sessionId: firstSessionId, - size: 186, - windowId: expect.any(String), - }) - - // this triggers idle state and isn't a user interaction so does not take a full snapshot - emitInactiveEvent(thirdActivityTimestamp, true) - - // event was not active so activity timestamp is not updated - expect(sessionRecording['_lastActivityTimestamp']).toEqual(firstActivityTimestamp) - - // the custom event doesn't show here since there's not a real rrweb to emit it - expect(sessionRecording['_buffer']).toEqual({ - data: [ - // buffer is flushed on switch to idle - ], - sessionId: firstSessionId, - size: 0, - windowId: expect.any(String), - }) - expect(posthog.capture).toHaveBeenCalledWith( - '$snapshot', - { - $snapshot_data: [firstSnapshotEvent, secondSnapshot], - $session_id: firstSessionId, - $snapshot_bytes: 186, - $window_id: expect.any(String), - $lib: 'web', - $lib_version: '0.0.1', - }, - { - _batchKey: 'recordings', - _noTruncate: true, - _url: 'https://test.com/s/', - skip_client_rate_limiting: true, - } - ) - - // this triggers exit from idle state _and_ is a user interaction, so we take a full snapshot - const fourthSnapshot = emitActiveEvent(fourthActivityTimestamp) - - expect(sessionRecording['_lastActivityTimestamp']).toEqual(fourthActivityTimestamp) - - // 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: [fourthSnapshot], - sessionId: firstSessionId, - size: 68, - 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) - - // after the first emit the buffer has been initialised but not flushed - const firstSessionId = sessionRecording['_sessionId'] - expect(sessionRecording['_buffer']).toEqual({ - data: [firstSnapshotEvent], - sessionId: firstSessionId, - size: 68, - 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) - - // the second snapshot remains buffered in memory - expect(sessionRecording['_buffer']).toEqual({ - data: [firstSnapshotEvent, secondSnapshot], - sessionId: firstSessionId, - size: 186, - windowId: expect.any(String), - }) - - // this triggers idle state and isn't a user interaction so does not take a full snapshot - - emitInactiveEvent(thirdActivityTimestamp, true) - - // event was not active so activity timestamp is not updated - expect(sessionRecording['_lastActivityTimestamp']).toEqual(firstActivityTimestamp) - - // 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: [ - // the buffer is flushed on switch to idle - ], - sessionId: firstSessionId, - size: 0, - windowId: expect.any(String), - }) - - // the buffer is flushed on switch to idle - expect(posthog.capture).toHaveBeenCalledWith( - '$snapshot', - { - $snapshot_data: [firstSnapshotEvent, secondSnapshot], - $session_id: firstSessionId, - $snapshot_bytes: 186, - $window_id: expect.any(String), - $lib: 'web', - $lib_version: '0.0.1', - }, - { - _batchKey: 'recordings', - _noTruncate: true, - _url: 'https://test.com/s/', - skip_client_rate_limiting: true, - } - ) - - // this triggers exit from idle state as it is a user interaction - // this will restart the session so the activity timestamp won't match - // restarting the session checks the id with "now" so we need to freeze that, or we'll start a second new session - jest.useFakeTimers().setSystemTime(new Date(fourthActivityTimestamp)) - const fourthSnapshot = emitActiveEvent(fourthActivityTimestamp, false) - 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: [firstSnapshotEvent, secondSnapshot], - $session_id: firstSessionId, - $snapshot_bytes: 186, - $window_id: expect.any(String), - $lib: 'web', - $lib_version: '0.0.1', - }, - { - _batchKey: 'recordings', - _noTruncate: true, - _url: 'https://test.com/s/', - skip_client_rate_limiting: true, - } - ) - expect(sessionRecording['_buffer']).toEqual({ - data: [fourthSnapshot], - sessionId: rotatedSessionId, - size: 68, - windowId: expect.any(String), - }) - }) - }) - - describe('linked flags', () => { - it('stores the linked flag on flags response', () => { - expect(sessionRecording['_linkedFlagMatching'].linkedFlag).toEqual(null) - expect(sessionRecording['_linkedFlagMatching'].linkedFlagSeen).toEqual(false) - - sessionRecording.onRemoteConfig( - makeFlagsResponse({ sessionRecording: { endpoint: '/s/', linkedFlag: 'the-flag-key' } }) - ) - - expect(sessionRecording['_linkedFlagMatching'].linkedFlag).toEqual('the-flag-key') - expect(sessionRecording['_linkedFlagMatching'].linkedFlagSeen).toEqual(false) - expect(sessionRecording.status).toEqual('buffering') - - expect(onFeatureFlagsCallback).not.toBeNull() - - onFeatureFlagsCallback?.(['the-flag-key'], { 'the-flag-key': true }) - expect(sessionRecording['_linkedFlagMatching'].linkedFlagSeen).toEqual(true) - expect(sessionRecording.status).toEqual('active') - - onFeatureFlagsCallback?.(['different', 'keys'], { different: true, keys: true }) - expect(sessionRecording['_linkedFlagMatching'].linkedFlagSeen).toEqual(false) - expect(sessionRecording.status).toEqual('buffering') - }) - - it('does not react to flags that are present but false', () => { - sessionRecording.onRemoteConfig( - makeFlagsResponse({ sessionRecording: { endpoint: '/s/', linkedFlag: 'the-flag-key' } }) - ) - - expect(sessionRecording.status).toEqual('buffering') - - expect(onFeatureFlagsCallback).not.toBeNull() - - onFeatureFlagsCallback?.(['the-flag-key'], { 'the-flag-key': false }) - expect(sessionRecording['_linkedFlagMatching'].linkedFlagSeen).toEqual(false) - expect(sessionRecording.status).toEqual('buffering') - }) - - it('can handle linked flags with variants', () => { - expect(sessionRecording['_linkedFlagMatching'].linkedFlag).toEqual(null) - expect(sessionRecording['_linkedFlagMatching'].linkedFlagSeen).toEqual(false) - - sessionRecording.onRemoteConfig( - makeFlagsResponse({ - sessionRecording: { endpoint: '/s/', linkedFlag: { flag: 'the-flag-key', variant: 'test-a' } }, - }) - ) - - expect(sessionRecording['_linkedFlagMatching'].linkedFlag).toEqual({ - flag: 'the-flag-key', - variant: 'test-a', - }) - expect(sessionRecording['_linkedFlagMatching'].linkedFlagSeen).toEqual(false) - expect(sessionRecording.status).toEqual('buffering') - - expect(onFeatureFlagsCallback).not.toBeNull() - - onFeatureFlagsCallback?.(['the-flag-key'], { 'the-flag-key': 'test-a' }) - expect(sessionRecording['_linkedFlagMatching'].linkedFlagSeen).toEqual(true) - expect(sessionRecording.status).toEqual('active') - - onFeatureFlagsCallback?.(['the-flag-key'], { 'the-flag-key': 'control' }) - expect(sessionRecording['_linkedFlagMatching'].linkedFlagSeen).toEqual(false) - expect(sessionRecording.status).toEqual('buffering') - }) - - it('can handle linked flags with any variants', () => { - expect(sessionRecording['_linkedFlagMatching'].linkedFlag).toEqual(null) - expect(sessionRecording['_linkedFlagMatching'].linkedFlagSeen).toEqual(false) - - sessionRecording.onRemoteConfig( - makeFlagsResponse({ - // when the variant is any we only send the key - sessionRecording: { endpoint: '/s/', linkedFlag: 'the-flag-key' }, - }) - ) - - expect(sessionRecording['_linkedFlagMatching'].linkedFlag).toEqual('the-flag-key') - expect(sessionRecording['_linkedFlagMatching'].linkedFlagSeen).toEqual(false) - expect(sessionRecording.status).toEqual('buffering') - - expect(onFeatureFlagsCallback).not.toBeNull() - - onFeatureFlagsCallback?.(['the-flag-key'], { 'the-flag-key': 'literally-anything' }) - expect(sessionRecording['_linkedFlagMatching'].linkedFlagSeen).toEqual(true) - expect(sessionRecording.status).toEqual('active') - - onFeatureFlagsCallback?.(['not-the-flag-key'], { 'not-the-flag-key': 'literally-anything' }) - expect(sessionRecording['_linkedFlagMatching'].linkedFlagSeen).toEqual(false) - expect(sessionRecording.status).toEqual('buffering') - }) - - it('can be overriden', () => { - expect(sessionRecording['_linkedFlagMatching'].linkedFlag).toEqual(null) - expect(sessionRecording['_linkedFlagMatching'].linkedFlagSeen).toEqual(false) - - sessionRecording.onRemoteConfig( - makeFlagsResponse({ sessionRecording: { endpoint: '/s/', linkedFlag: 'the-flag-key' } }) - ) - - expect(sessionRecording['_linkedFlagMatching'].linkedFlag).toEqual('the-flag-key') - expect(sessionRecording['_linkedFlagMatching'].linkedFlagSeen).toEqual(false) - expect(sessionRecording.status).toEqual('buffering') - - sessionRecording.overrideLinkedFlag() - - expect(sessionRecording['_linkedFlagMatching'].linkedFlagSeen).toEqual(true) - expect(sessionRecording.status).toEqual('active') - }) - - /** - * this is partly a regression test, with a running rrweb, - * if you don't pause while buffering - * the browser can be trapped in an infinite loop of pausing - * while trying to report it is paused 🙈 - */ - it('can be paused while waiting for flag', () => { - fakeNavigateTo('https://test.com/blocked') - - expect(sessionRecording['_linkedFlagMatching'].linkedFlag).toEqual(null) - expect(sessionRecording['_linkedFlagMatching'].linkedFlagSeen).toEqual(false) - expect(sessionRecording.status).toEqual('buffering') - - sessionRecording.onRemoteConfig( - makeFlagsResponse({ - sessionRecording: { - endpoint: '/s/', - linkedFlag: 'the-flag-key', - urlBlocklist: [ - { - matching: 'regex', - url: '/blocked', - }, - ], - }, - }) - ) - - expect(sessionRecording['_linkedFlagMatching'].linkedFlag).toEqual('the-flag-key') - expect(sessionRecording['_linkedFlagMatching'].linkedFlagSeen).toEqual(false) - expect(sessionRecording.status).toEqual('buffering') - expect(sessionRecording['paused']).toBeUndefined() - - const snapshotEvent = { - event: 123, - type: INCREMENTAL_SNAPSHOT_EVENT_TYPE, - data: { - source: 1, - }, - timestamp: new Date().getTime(), - } - _emit(snapshotEvent) - - expect(sessionRecording['_linkedFlagMatching'].linkedFlag).toEqual('the-flag-key') - expect(sessionRecording['_linkedFlagMatching'].linkedFlagSeen).toEqual(false) - expect(sessionRecording.status).toEqual('paused') - - sessionRecording.overrideLinkedFlag() - - expect(sessionRecording['_linkedFlagMatching'].linkedFlagSeen).toEqual(true) - expect(sessionRecording.status).toEqual('paused') - - fakeNavigateTo('https://test.com/allowed') - - expect(sessionRecording.status).toEqual('paused') - - _emit(snapshotEvent) - expect(sessionRecording.status).toEqual('active') - }) - }) - - describe('buffering minimum duration', () => { - it('can report no duration when no data', () => { - sessionRecording.startIfEnabledOrStop() - expect(sessionRecording.status).toBe('buffering') - expect(sessionRecording['_sessionDuration']).toBe(null) - }) - - it('can report zero duration', () => { - sessionRecording.startIfEnabledOrStop() - expect(sessionRecording.status).toBe('buffering') - const { sessionStartTimestamp } = sessionManager.checkAndGetSessionAndWindowId(true) - _emit(createIncrementalSnapshot({ data: { source: 1 }, timestamp: sessionStartTimestamp })) - expect(sessionRecording['_sessionDuration']).toBe(0) - }) - - it('can report a duration', () => { - sessionRecording.startIfEnabledOrStop() - expect(sessionRecording.status).toBe('buffering') - const { sessionStartTimestamp } = sessionManager.checkAndGetSessionAndWindowId(true) - _emit(createIncrementalSnapshot({ data: { source: 1 }, timestamp: sessionStartTimestamp + 100 })) - expect(sessionRecording['_sessionDuration']).toBe(100) - }) - - it('starts with an undefined minimum duration', () => { - sessionRecording.startIfEnabledOrStop() - expect(sessionRecording['_minimumDuration']).toBe(null) - }) - - it('can set minimum duration from flags response', () => { - sessionRecording.onRemoteConfig( - makeFlagsResponse({ - sessionRecording: { minimumDurationMilliseconds: 1500 }, - }) - ) - expect(sessionRecording['_minimumDuration']).toBe(1500) - }) - - it('does not flush if below the minimum duration', () => { - sessionRecording.onRemoteConfig( - makeFlagsResponse({ - sessionRecording: { minimumDurationMilliseconds: 1500 }, - }) - ) - sessionRecording.startIfEnabledOrStop() - expect(sessionRecording.status).toBe('active') - const { sessionStartTimestamp } = sessionManager.checkAndGetSessionAndWindowId(true) - _emit(createIncrementalSnapshot({ data: { source: 1 }, timestamp: sessionStartTimestamp + 100 })) - expect(sessionRecording['_sessionDuration']).toBe(100) - expect(sessionRecording['_minimumDuration']).toBe(1500) - - expect(sessionRecording['_buffer'].data.length).toBe(1) // the emitted incremental event - // call the private method to avoid waiting for the timer - sessionRecording['_flushBuffer']() - - expect(posthog.capture).not.toHaveBeenCalled() - }) - - it('does flush if session duration is negative', () => { - sessionRecording.onRemoteConfig( - makeFlagsResponse({ - sessionRecording: { minimumDurationMilliseconds: 1500 }, - }) - ) - sessionRecording.startIfEnabledOrStop() - expect(sessionRecording.status).toBe('active') - const { sessionStartTimestamp } = sessionManager.checkAndGetSessionAndWindowId(true) - - // if we have some data in the buffer and the buffer has a session id but then the session id changes - // then the session duration will be negative, and we will never flush the buffer - // this setup isn't quite that but does simulate the behaviour closely enough - _emit(createIncrementalSnapshot({ data: { source: 1 }, timestamp: sessionStartTimestamp - 1000 })) - - expect(sessionRecording['_sessionDuration']).toBe(-1000) - expect(sessionRecording['_minimumDuration']).toBe(1500) - - expect(sessionRecording['_buffer'].data.length).toBe(1) // the emitted incremental event - // call the private method to avoid waiting for the timer - sessionRecording['_flushBuffer']() - - expect(posthog.capture).toHaveBeenCalled() - }) - - it('does not stay buffering after the minimum duration', () => { - sessionRecording.onRemoteConfig( - makeFlagsResponse({ - sessionRecording: { minimumDurationMilliseconds: 1500 }, - }) - ) - sessionRecording.startIfEnabledOrStop() - expect(sessionRecording.status).toBe('active') - const { sessionStartTimestamp } = sessionManager.checkAndGetSessionAndWindowId(true) - _emit(createIncrementalSnapshot({ data: { source: 1 }, timestamp: sessionStartTimestamp + 100 })) - expect(sessionRecording['_sessionDuration']).toBe(100) - expect(sessionRecording['_minimumDuration']).toBe(1500) - - expect(sessionRecording['_buffer'].data.length).toBe(1) // the emitted incremental event - // call the private method to avoid waiting for the timer - sessionRecording['_flushBuffer']() - - expect(posthog.capture).not.toHaveBeenCalled() - - _emit(createIncrementalSnapshot({ data: { source: 1 }, timestamp: sessionStartTimestamp + 1501 })) - - expect(sessionRecording['_buffer'].data.length).toBe(2) // two emitted incremental events - // call the private method to avoid waiting for the timer - sessionRecording['_flushBuffer']() - - expect(posthog.capture).toHaveBeenCalled() - expect(sessionRecording['_buffer'].data.length).toBe(0) - expect(sessionRecording['_sessionDuration']).toBe(null) - _emit(createIncrementalSnapshot({ data: { source: 1 }, timestamp: sessionStartTimestamp + 1502 })) - expect(sessionRecording['_buffer'].data.length).toBe(1) - expect(sessionRecording['_sessionDuration']).toBe(1502) - // call the private method to avoid waiting for the timer - sessionRecording['_flushBuffer']() - - expect(posthog.capture).toHaveBeenCalled() - expect(sessionRecording['_buffer'].data.length).toBe(0) - }) - }) - - describe('when rrweb is not available', () => { - beforeEach(() => { - // Fake rrweb not being available - loadScriptMock.mockImplementation((_ph, _path, callback) => { - callback() - }) - sessionRecording = new SessionRecording(posthog) - - expect(sessionRecording['_queuedRRWebEvents']).toHaveLength(0) - - sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: { endpoint: '/s/' } })) - - expect(sessionRecording['_queuedRRWebEvents']).toHaveLength(1) - - sessionRecording.startIfEnabledOrStop() - expect(loadScriptMock).toHaveBeenCalled() - }) - - it('queues events', () => { - sessionRecording.tryAddCustomEvent('test', { test: 'test' }) - - expect(sessionRecording['_queuedRRWebEvents']).toHaveLength(2) - }) - - it('limits the queue of events', () => { - sessionRecording.tryAddCustomEvent('test', { test: 'test' }) - - expect(sessionRecording['_queuedRRWebEvents']).toHaveLength(2) - - for (let i = 0; i < 100; i++) { - sessionRecording.tryAddCustomEvent('test', { test: 'test' }) - } - - expect(sessionRecording['_queuedRRWebEvents']).toHaveLength(10) - }) - - it('processes the queue when rrweb is available again', () => { - addRRwebToWindow() - - sessionRecording.onRRwebEmit(createIncrementalSnapshot({ data: { source: 1 } }) as any) - - expect(sessionRecording['_queuedRRWebEvents']).toHaveLength(0) - }) - }) - - describe('scheduled full snapshots', () => { - it('starts out unscheduled', () => { - expect(sessionRecording['_fullSnapshotTimer']).toBe(undefined) - }) - - it('does not schedule a snapshot on start', () => { - sessionRecording.startIfEnabledOrStop() - expect(sessionRecording['_fullSnapshotTimer']).toBe(undefined) - }) - - it('schedules a snapshot, when we take a full snapshot', () => { - sessionRecording.startIfEnabledOrStop() - const startTimer = sessionRecording['_fullSnapshotTimer'] - - _emit(createFullSnapshot()) - - expect(sessionRecording['_fullSnapshotTimer']).not.toBe(undefined) - expect(sessionRecording['_fullSnapshotTimer']).not.toBe(startTimer) - }) - }) - - describe('when pageview capture is disabled', () => { - beforeEach(() => { - jest.spyOn(sessionRecording, 'tryAddCustomEvent') - posthog.config.capture_pageview = false - sessionRecording.startIfEnabledOrStop() - // clear the spy calls - ;(sessionRecording.tryAddCustomEvent as any).mockClear() - }) - - it('does not capture pageview on meta event', () => { - _emit(createIncrementalSnapshot({ type: META_EVENT_TYPE })) - - expect(sessionRecording.tryAddCustomEvent).not.toHaveBeenCalled() - }) - - it('captures pageview as expected on non-meta event', () => { - fakeNavigateTo('https://test.com') - - _emit(createIncrementalSnapshot({ type: 3 })) - - expect(sessionRecording.tryAddCustomEvent).toHaveBeenCalledWith('$url_changed', { - href: 'https://test.com', - }) - ;(sessionRecording.tryAddCustomEvent as any).mockClear() - - _emit(createIncrementalSnapshot({ type: 3 })) - // the window href has not changed, so we don't capture another pageview - expect(sessionRecording.tryAddCustomEvent).not.toHaveBeenCalled() - - fakeNavigateTo('https://test.com/other') - _emit(createIncrementalSnapshot({ type: 3 })) - - // the window href has changed, so we capture another pageview - expect(sessionRecording.tryAddCustomEvent).toHaveBeenCalledWith('$url_changed', { - href: 'https://test.com/other', - }) - }) - }) - - describe('when pageview capture is enabled', () => { - beforeEach(() => { - jest.spyOn(sessionRecording, 'tryAddCustomEvent') - posthog.config.capture_pageview = true - sessionRecording.startIfEnabledOrStop() - // clear the spy calls - ;(sessionRecording.tryAddCustomEvent as any).mockClear() - }) - - it('does not capture pageview on rrweb events', () => { - _emit(createIncrementalSnapshot({ type: 3 })) - - expect(sessionRecording.tryAddCustomEvent).not.toHaveBeenCalled() - }) - }) - - describe('when compression is active', () => { - const captureOptions = { - _batchKey: 'recordings', - _noTruncate: true, - _url: 'https://test.com/s/', - skip_client_rate_limiting: true, - } - - beforeEach(() => { - posthog.config.session_recording.compress_events = true - sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: { endpoint: '/s/' } })) - sessionRecording.startIfEnabledOrStop() - // need to have active event to start recording - _emit(createIncrementalSnapshot({ type: 3 })) - sessionRecording['_flushBuffer']() - }) - - it('compresses full snapshot data', () => { - _emit( - createFullSnapshot({ - data: { - content: Array(30).fill(uuidv7()).join(''), - }, - }) - ) - sessionRecording['_flushBuffer']() - - expect(posthog.capture).toHaveBeenCalledWith( - '$snapshot', - { - $snapshot_data: [ - { - data: expect.any(String), - cv: '2024-10', - type: 2, - }, - ], - $session_id: sessionId, - $snapshot_bytes: expect.any(Number), - $window_id: 'windowId', - $lib: 'web', - $lib_version: '0.0.1', - }, - captureOptions - ) - }) - - it('compresses incremental snapshot mutation data', () => { - _emit(createIncrementalMutationEvent({ texts: [Array(30).fill(uuidv7()).join('')] })) - sessionRecording['_flushBuffer']() - - expect(posthog.capture).toHaveBeenCalledWith( - '$snapshot', - { - $snapshot_data: [ - { - cv: '2024-10', - data: { - adds: expect.any(String), - texts: expect.any(String), - removes: expect.any(String), - attributes: expect.any(String), - isAttachIframe: true, - source: 0, - }, - type: 3, - }, - ], - $session_id: sessionId, - $snapshot_bytes: expect.any(Number), - $window_id: 'windowId', - $lib: 'web', - $lib_version: '0.0.1', - }, - captureOptions - ) - }) - - it('compresses incremental snapshot style data', () => { - _emit(createIncrementalStyleSheetEvent({ adds: [Array(30).fill(uuidv7()).join('')] })) - sessionRecording['_flushBuffer']() - - expect(posthog.capture).toHaveBeenCalledWith( - '$snapshot', - { - $snapshot_data: [ - { - data: { - adds: expect.any(String), - id: 1, - removes: expect.any(String), - replace: 'something', - replaceSync: 'something', - source: 8, - styleId: 1, - }, - cv: '2024-10', - type: 3, - }, - ], - $session_id: sessionId, - $snapshot_bytes: expect.any(Number), - $window_id: 'windowId', - $lib: 'web', - $lib_version: '0.0.1', - }, - captureOptions - ) - }) - - it('does not compress small incremental snapshot data', () => {}) - - it('does not compress incremental snapshot non full data', () => { - const mouseEvent = createIncrementalMouseEvent() - _emit(mouseEvent) - sessionRecording['_flushBuffer']() - - expect(posthog.capture).toHaveBeenCalledWith( - '$snapshot', - { - $snapshot_data: [mouseEvent], - $session_id: sessionId, - $snapshot_bytes: 86, - $window_id: 'windowId', - $lib: 'web', - $lib_version: '0.0.1', - }, - captureOptions - ) - }) - - it('does not compress custom events', () => { - _emit(createCustomSnapshot(undefined, { tag: 'wat' })) - sessionRecording['_flushBuffer']() - - expect(posthog.capture).toHaveBeenCalledWith( - '$snapshot', - { - $snapshot_data: [ - { - data: { - payload: { tag: 'wat' }, - tag: 'custom', - }, - type: 5, - }, - ], - $session_id: sessionId, - $snapshot_bytes: 58, - $window_id: 'windowId', - $lib: 'web', - $lib_version: '0.0.1', - }, - captureOptions - ) - }) - - it('does not compress meta events', () => { - _emit(createMetaSnapshot()) - sessionRecording['_flushBuffer']() - - expect(posthog.capture).toHaveBeenCalledWith( - '$snapshot', - { - $snapshot_data: [ - { - type: META_EVENT_TYPE, - data: { - href: 'https://has-to-be-present-or-invalid.com', - }, - }, - ], - $session_id: sessionId, - $snapshot_bytes: 69, - $window_id: 'windowId', - $lib: 'web', - $lib_version: '0.0.1', - }, - captureOptions - ) - }) - }) - - describe('URL blocking', () => { - beforeEach(() => { - sessionRecording.startIfEnabledOrStop() - jest.spyOn(sessionRecording, 'tryAddCustomEvent') - }) - - it('does not flush buffer and includes pause event when hitting blocked URL', async () => { - sessionRecording.onRemoteConfig( - makeFlagsResponse({ - sessionRecording: { - endpoint: '/s/', - urlBlocklist: [ - { - matching: 'regex', - url: '/blocked', - }, - ], - }, - }) - ) - - // Emit some events before hitting blocked URL - _emit(createIncrementalSnapshot({ data: { source: 1 } })) - _emit(createIncrementalSnapshot({ data: { source: 2 } })) - - // Simulate URL change to blocked URL - fakeNavigateTo('https://test.com/blocked') - - expect(posthog.capture).not.toHaveBeenCalled() - - // Verify subsequent events are not captured while on blocked URL - _emit(createIncrementalSnapshot({ data: { source: 3 } })) - _emit(createIncrementalSnapshot({ data: { source: 4 } })) - - expect(sessionRecording['_buffer'].data).toEqual([ - { - data: { - source: 1, - }, - type: 3, - }, - { - data: { - source: 2, - }, - type: 3, - }, - ]) - - // Simulate URL change to allowed URL - fakeNavigateTo('https://test.com/allowed') - - // Verify recording resumes with resume event - _emit(createIncrementalSnapshot({ data: { source: 5 } })) - - expect(sessionRecording['_buffer'].data).toStrictEqual([ - { - data: { - source: 1, - }, - type: 3, - }, - { - data: { - source: 2, - }, - type: 3, - }, - // restarts with a snapshot - expect.objectContaining({ - type: 2, - }), - expect.objectContaining({ - type: 3, - data: { source: 5 }, - }), - ]) - }) - - it('only pauses once when sampling determines session should not record', () => { - sessionRecording.onRemoteConfig( - makeFlagsResponse({ - sessionRecording: { - endpoint: '/s/', - sampleRate: '0.00', - urlBlocklist: [ - { - matching: 'regex', - url: '/blocked', - }, - ], - }, - }) - ) - expect(sessionRecording.status).toBe('disabled') - expect(sessionRecording['_urlTriggerMatching']['urlBlocked']).toBe(false) - expect(sessionRecording['_buffer'].data).toHaveLength(0) - - fakeNavigateTo('https://test.com/blocked') - // check is trigger by rrweb emit, not the navigation per se, so... - _emit(createFullSnapshot({ data: { source: 1 } })) - - expect(posthog.capture).not.toHaveBeenCalled() - expect(sessionRecording.status).toBe('paused') - expect(sessionRecording['_urlTriggerMatching']['urlBlocked']).toBe(true) - expect(sessionRecording['_buffer'].data).toHaveLength(0) - expect(sessionRecording.tryAddCustomEvent).toHaveBeenCalledWith('recording paused', { - reason: 'url blocker', - }) - ;(sessionRecording.tryAddCustomEvent as any).mockClear() - - _emit(createIncrementalSnapshot({ data: { source: 1 } })) - // regression: to check we've not accidentally got stuck in a pausing loop - expect(sessionRecording.tryAddCustomEvent).not.toHaveBeenCalledWith('recording paused', { - reason: 'url blocker', - }) - }) - }) - - describe('Event triggering', () => { - beforeEach(() => { - sessionRecording.startIfEnabledOrStop() - }) - - it('flushes buffer and starts when sees event', async () => { - sessionRecording.onRemoteConfig( - makeFlagsResponse({ - sessionRecording: { - endpoint: '/s/', - eventTriggers: ['$exception'], - }, - }) - ) - - expect(sessionRecording.status).toBe('buffering') - - // Emit some events before hitting blocked URL - _emit(createIncrementalSnapshot({ data: { source: 1 } })) - _emit(createIncrementalSnapshot({ data: { source: 2 } })) - - expect(sessionRecording['_buffer'].data).toHaveLength(2) - - simpleEventEmitter.emit('eventCaptured', { event: 'not-$exception' }) - - expect(sessionRecording.status).toBe('buffering') - - simpleEventEmitter.emit('eventCaptured', { event: '$exception' }) - - expect(sessionRecording.status).toBe('active') - expect(sessionRecording['_buffer'].data).toHaveLength(0) - }) - - it('starts if sees an event but still waiting for a URL when in OR', async () => { - sessionRecording.onRemoteConfig( - makeFlagsResponse({ - sessionRecording: { - endpoint: '/s/', - eventTriggers: ['$exception'], - urlTriggers: [{ url: 'start-on-me', matching: 'regex' }], - triggerMatchType: 'any', - }, - }) - ) - - expect(sessionRecording.status).toBe('buffering') - - // Emit some events before hitting blocked URL - _emit(createIncrementalSnapshot({ data: { source: 1 } })) - _emit(createIncrementalSnapshot({ data: { source: 2 } })) - - expect(sessionRecording['_buffer'].data).toHaveLength(2) - - simpleEventEmitter.emit('eventCaptured', { event: 'not-$exception' }) - - expect(sessionRecording.status).toBe('buffering') - - simpleEventEmitter.emit('eventCaptured', { event: '$exception' }) - - // even though still waiting for URL to trigger - expect(sessionRecording.status).toBe('active') - }) - - it('does not start if sees an event but still waiting for a URL when in AND', async () => { - sessionRecording.onRemoteConfig( - makeFlagsResponse({ - sessionRecording: { - endpoint: '/s/', - eventTriggers: ['$exception'], - urlTriggers: [{ url: 'start-on-me', matching: 'regex' }], - triggerMatchType: 'all', - }, - }) - ) - - expect(sessionRecording.status).toBe('buffering') - - // Emit some events before hitting blocked URL - _emit(createIncrementalSnapshot({ data: { source: 1 } })) - _emit(createIncrementalSnapshot({ data: { source: 2 } })) - - expect(sessionRecording['_buffer'].data).toHaveLength(2) - - simpleEventEmitter.emit('eventCaptured', { event: 'not-$exception' }) - - expect(sessionRecording.status).toBe('buffering') - - simpleEventEmitter.emit('eventCaptured', { event: '$exception' }) - - // because still waiting for URL to trigger - expect(sessionRecording.status).toBe('buffering') - }) - - it('never sends data when sampling is false regardless of event triggers', async () => { - // this is a regression test for https://posthoghelp.zendesk.com/agent/tickets/24373 - // where the buffered data was sent to capture when the event trigger fired - // before the sample rate was taken into account - // and then would immediately stop - - sessionRecording.onRemoteConfig( - makeFlagsResponse({ - sessionRecording: { - endpoint: '/s/', - eventTriggers: ['$exception'], - sampleRate: '0.00', // i.e. never send recording - triggerMatchType: 'all', - }, - }) - ) - - expect(sessionRecording.status).toBe('buffering') - expect(sessionRecording['_buffer'].data).toHaveLength(0) - - // Emit some events before hitting event trigger - _emit(createIncrementalSnapshot({ data: { source: 1 } })) - _emit(createIncrementalSnapshot({ data: { source: 2 } })) - - simpleEventEmitter.emit('eventCaptured', { event: '$exception' }) - expect(sessionRecording.status).toBe('disabled') - expect(posthog.capture).not.toHaveBeenCalled() - }) - - it('sends data when sampling is false and there is an event triggers in OR mode', async () => { - // this is a regression test for https://posthoghelp.zendesk.com/agent/tickets/24373 - // where the buffered data was sent to capture when the event trigger fired - // before the sample rate was taken into account - // and then would immediately stop - - sessionRecording.onRemoteConfig( - makeFlagsResponse({ - sessionRecording: { - endpoint: '/s/', - eventTriggers: ['$exception'], - sampleRate: '0.00', // i.e. never send recording - triggerMatchType: 'any', - }, - }) - ) - - expect(sessionRecording.status).toBe('buffering') - expect(sessionRecording['_buffer'].data).toHaveLength(0) - - // Emit some events before hitting event trigger - _emit(createIncrementalSnapshot({ data: { source: 1 } })) - _emit(createIncrementalSnapshot({ data: { source: 2 } })) - - simpleEventEmitter.emit('eventCaptured', { event: '$exception' }) - expect(sessionRecording.status).toBe('active') - expect(posthog.capture).toHaveBeenCalled() - }) - }) -}) diff --git a/packages/browser/src/__tests__/posthog-core-also.test.ts b/packages/browser/src/__tests__/posthog-core-also.test.ts index 5f0120b6bd..b02063787c 100644 --- a/packages/browser/src/__tests__/posthog-core-also.test.ts +++ b/packages/browser/src/__tests__/posthog-core-also.test.ts @@ -10,7 +10,7 @@ import { 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 { SessionRecording } from '../extensions/replay/session-recording' import { SessionPropsManager } from '../session-props' let mockGetProperties: jest.Mock diff --git a/packages/browser/src/extensions/replay/config.ts b/packages/browser/src/extensions/replay/external/config.ts similarity index 97% rename from packages/browser/src/extensions/replay/config.ts rename to packages/browser/src/extensions/replay/external/config.ts index 75340a9610..919db45bb5 100644 --- a/packages/browser/src/extensions/replay/config.ts +++ b/packages/browser/src/extensions/replay/external/config.ts @@ -1,9 +1,9 @@ -import { CapturedNetworkRequest, NetworkRecordOptions, PostHogConfig } from '../../types' +import { CapturedNetworkRequest, NetworkRecordOptions, PostHogConfig } from '../../../types' import { isFunction, isNullish, isString, isUndefined } from '@posthog/core' -import { convertToURL } from '../../utils/request-utils' -import { logger } from '../../utils/logger' -import { shouldCaptureValue } from '../../autocapture-utils' -import { each } from '../../utils' +import { convertToURL } from '../../../utils/request-utils' +import { logger } from '../../../utils/logger' +import { shouldCaptureValue } from '../../../autocapture-utils' +import { each } from '../../../utils' const LOGGER_PREFIX = '[SessionRecording]' diff --git a/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts b/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts index ab1d7ee842..cae3f06ba0 100644 --- a/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts +++ b/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts @@ -7,7 +7,7 @@ import { type listenerHandler, RecordPlugin, } from '@rrweb/types' -import { buildNetworkRequestOptions } from '../config' +import { buildNetworkRequestOptions } from './config' import { ACTIVE, allMatchSessionRecordingStatus, @@ -28,12 +28,12 @@ import { TriggerStatusMatching, TriggerType, URLTriggerMatching, -} from '../triggerMatching' -import { estimateSize, INCREMENTAL_SNAPSHOT_EVENT_TYPE, truncateLargeConsoleLogs } from '../sessionrecording-utils' +} from './triggerMatching' +import { estimateSize, INCREMENTAL_SNAPSHOT_EVENT_TYPE, truncateLargeConsoleLogs } from './sessionrecording-utils' import { gzipSync, strFromU8, strToU8 } from 'fflate' import { assignableWindow, LazyLoadedSessionRecordingInterface, window, document } from '../../../utils/globals' import { addEventListener } from '../../../utils' -import { MutationThrottler } from '../mutation-throttler' +import { MutationThrottler } from './mutation-throttler' import { createLogger } from '../../../utils/logger' import { clampToRange, @@ -332,8 +332,6 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt private _statusMatcher: (triggersStatus: RecordingTriggersStatus) => SessionRecordingStatus = nullMatchSessionRecordingStatus - private _receivedFlags: boolean = false - private _onSessionIdListener: (() => void) | undefined = undefined private _onSessionIdleResetForcedListener: (() => void) | undefined = undefined private _samplingSessionListener: (() => void) | undefined = undefined diff --git a/packages/browser/src/extensions/replay/mutation-throttler.ts b/packages/browser/src/extensions/replay/external/mutation-throttler.ts similarity index 97% rename from packages/browser/src/extensions/replay/mutation-throttler.ts rename to packages/browser/src/extensions/replay/external/mutation-throttler.ts index 7553a0ff19..d1deb2b62b 100644 --- a/packages/browser/src/extensions/replay/mutation-throttler.ts +++ b/packages/browser/src/extensions/replay/external/mutation-throttler.ts @@ -1,8 +1,8 @@ import type { eventWithTime, mutationCallbackParam } from '@rrweb/types' import { INCREMENTAL_SNAPSHOT_EVENT_TYPE, MUTATION_SOURCE_TYPE } from './sessionrecording-utils' -import type { rrwebRecord } from './types/rrweb' +import type { rrwebRecord } from '../types/rrweb' import { BucketedRateLimiter } from '@posthog/core' -import { logger } from '../../utils/logger' +import { logger } from '../../../utils/logger' export class MutationThrottler { private _loggedTracker: Record = {} diff --git a/packages/browser/src/extensions/replay/external/network-plugin.ts b/packages/browser/src/extensions/replay/external/network-plugin.ts index 62b6b77bff..6c187afc09 100644 --- a/packages/browser/src/extensions/replay/external/network-plugin.ts +++ b/packages/browser/src/extensions/replay/external/network-plugin.ts @@ -17,7 +17,7 @@ import { createLogger } from '../../../utils/logger' import { formDataToQuery } from '../../../utils/request-utils' import { patch } from '../rrweb-plugins/patch' import { isHostOnDenyList } from '../../../extensions/replay/external/denylist' -import { defaultNetworkOptions } from '../config' +import { defaultNetworkOptions } from './config' const logger = createLogger('[Recorder]') diff --git a/packages/browser/src/extensions/replay/sessionrecording-utils.ts b/packages/browser/src/extensions/replay/external/sessionrecording-utils.ts similarity index 98% rename from packages/browser/src/extensions/replay/sessionrecording-utils.ts rename to packages/browser/src/extensions/replay/external/sessionrecording-utils.ts index d5d9d855e5..c021d7ee70 100644 --- a/packages/browser/src/extensions/replay/sessionrecording-utils.ts +++ b/packages/browser/src/extensions/replay/external/sessionrecording-utils.ts @@ -1,7 +1,7 @@ import type { eventWithTime, pluginEvent } from '@rrweb/types' -import { SnapshotBuffer } from './sessionrecording' import { isObject } from '@posthog/core' +import { SnapshotBuffer } from './lazy-loaded-session-recorder' // taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value#circular_references export function circularReferenceReplacer() { diff --git a/packages/browser/src/extensions/replay/triggerMatching.ts b/packages/browser/src/extensions/replay/external/triggerMatching.ts similarity index 98% rename from packages/browser/src/extensions/replay/triggerMatching.ts rename to packages/browser/src/extensions/replay/external/triggerMatching.ts index afb1027130..fa212fea85 100644 --- a/packages/browser/src/extensions/replay/triggerMatching.ts +++ b/packages/browser/src/extensions/replay/external/triggerMatching.ts @@ -1,11 +1,11 @@ import { SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION, SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION, -} from '../../constants' -import { PostHog } from '../../posthog-core' -import { FlagVariant, RemoteConfig, SessionRecordingPersistedConfig, SessionRecordingUrlTrigger } from '../../types' +} from '../../../constants' +import { PostHog } from '../../../posthog-core' +import { FlagVariant, RemoteConfig, SessionRecordingPersistedConfig, SessionRecordingUrlTrigger } from '../../../types' import { isNullish, isBoolean, isString, isObject } from '@posthog/core' -import { window } from '../../utils/globals' +import { window } from '../../../utils/globals' export const DISABLED = 'disabled' export const SAMPLED = 'sampled' diff --git a/packages/browser/src/extensions/replay/sessionrecording-wrapper.ts b/packages/browser/src/extensions/replay/session-recording.ts similarity index 96% rename from packages/browser/src/extensions/replay/sessionrecording-wrapper.ts rename to packages/browser/src/extensions/replay/session-recording.ts index c1a4aff0c2..9043afe39c 100644 --- a/packages/browser/src/extensions/replay/sessionrecording-wrapper.ts +++ b/packages/browser/src/extensions/replay/session-recording.ts @@ -11,16 +11,12 @@ import { PostHogExtensionKind, window, } from '../../utils/globals' -import { DISABLED, LAZY_LOADING, SessionRecordingStatus, TriggerType } from './triggerMatching' +import { DISABLED, LAZY_LOADING, SessionRecordingStatus, TriggerType } from './external/triggerMatching' const LOGGER_PREFIX = '[SessionRecording]' const logger = createLogger(LOGGER_PREFIX) -/** - * This only exists to let us test changes to sessionrecording.ts before rolling them out to everyone - * it should not be depended on in other ways, since i'm going to delete it long before the end of September 2025 - */ -export class SessionRecordingWrapper { +export class SessionRecording { _forceAllowLocalhostNetworkCapture: boolean = false private _receivedFlags: boolean = false @@ -195,7 +191,6 @@ export class SessionRecordingWrapper { this._persistRemoteConfig(response) this._receivedFlags = true - // TODO how do we send a custom message with the received remote config like we used to for debug this.startIfEnabledOrStop() } diff --git a/packages/browser/src/extensions/replay/sessionrecording.ts b/packages/browser/src/extensions/replay/sessionrecording.ts deleted file mode 100644 index e7f7a79fb3..0000000000 --- a/packages/browser/src/extensions/replay/sessionrecording.ts +++ /dev/null @@ -1,1427 +0,0 @@ -import { - CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE, - SESSION_RECORDING_CANVAS_RECORDING, - SESSION_RECORDING_ENABLED_SERVER_SIDE, - SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION, - SESSION_RECORDING_IS_SAMPLED, - SESSION_RECORDING_MASKING, - SESSION_RECORDING_MINIMUM_DURATION, - SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE, - SESSION_RECORDING_SAMPLE_RATE, - SESSION_RECORDING_SCRIPT_CONFIG, - SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION, -} from '../../constants' -import { - estimateSize, - INCREMENTAL_SNAPSHOT_EVENT_TYPE, - splitBuffer, - truncateLargeConsoleLogs, -} from './sessionrecording-utils' -import type { recordOptions, rrwebRecord } from './types/rrweb' -import { PostHog } from '../../posthog-core' -import { - CaptureResult, - NetworkRecordOptions, - NetworkRequest, - Properties, - RemoteConfig, - type SessionRecordingOptions, - SessionStartReason, -} from '../../types' -import { - type customEvent, - EventType, - type eventWithTime, - IncrementalSource, - type listenerHandler, - RecordPlugin, -} from '@rrweb/types' - -import { createLogger } from '../../utils/logger' -import { assignableWindow, document, PostHogExtensionKind, window } from '../../utils/globals' -import { buildNetworkRequestOptions } from './config' -import { isLocalhost } from '../../utils/request-utils' -import { MutationThrottler } from './mutation-throttler' -import { gzipSync, strFromU8, strToU8 } from 'fflate' -import { - clampToRange, - includes, - isBoolean, - isFunction, - isNullish, - isNumber, - isObject, - isUndefined, -} from '@posthog/core' -import Config from '../../config' -import { addEventListener } from '../../utils' -import { sampleOnProperty } from '../sampling' -import { - ACTIVE, - allMatchSessionRecordingStatus, - AndTriggerMatching, - anyMatchSessionRecordingStatus, - BUFFERING, - DISABLED, - EventTriggerMatching, - LinkedFlagMatching, - nullMatchSessionRecordingStatus, - OrTriggerMatching, - PAUSED, - PendingTriggerMatching, - RecordingTriggersStatus, - SAMPLED, - SessionRecordingStatus, - TRIGGER_PENDING, - TriggerStatusMatching, - TriggerType, - URLTriggerMatching, -} from './triggerMatching' - -const LOGGER_PREFIX = '[SessionRecording]' -const logger = createLogger(LOGGER_PREFIX) - -function getRRWebRecord(): rrwebRecord | undefined { - return assignableWindow?.__PosthogExtensions__?.rrweb?.record -} - -const BASE_ENDPOINT = '/s/' - -const ONE_MINUTE = 1000 * 60 -const FIVE_MINUTES = ONE_MINUTE * 5 -const TWO_SECONDS = 2000 -export const RECORDING_IDLE_THRESHOLD_MS = FIVE_MINUTES -const ONE_KB = 1024 - -export const RECORDING_MAX_EVENT_SIZE = ONE_KB * ONE_KB * 0.9 // ~1mb (with some wiggle room) -export const RECORDING_BUFFER_TIMEOUT = 2000 // 2 seconds -export const SESSION_RECORDING_BATCH_KEY = 'recordings' -const DEFAULT_CANVAS_QUALITY = 0.4 -const DEFAULT_CANVAS_FPS = 4 -const MAX_CANVAS_FPS = 12 -const MAX_CANVAS_QUALITY = 1 - -const ACTIVE_SOURCES = [ - IncrementalSource.MouseMove, - IncrementalSource.MouseInteraction, - IncrementalSource.Scroll, - IncrementalSource.ViewportResize, - IncrementalSource.Input, - IncrementalSource.TouchMove, - IncrementalSource.MediaInteraction, - IncrementalSource.Drag, -] - -export interface SnapshotBuffer { - size: number - data: any[] - sessionId: string - windowId: string -} - -interface QueuedRRWebEvent { - rrwebMethod: () => void - attempt: number - // the timestamp this was first put into this queue - enqueuedAt: number -} - -interface SessionIdlePayload { - eventTimestamp: number - lastActivityTimestamp: number - threshold: number - bufferLength: number - bufferSize: number -} - -const newQueuedEvent = (rrwebMethod: () => void): QueuedRRWebEvent => ({ - rrwebMethod, - enqueuedAt: Date.now(), - attempt: 1, -}) - -export type compressedFullSnapshotEvent = { - type: EventType.FullSnapshot - data: string -} - -export type compressedIncrementalSnapshotEvent = { - type: EventType.IncrementalSnapshot - data: { - source: IncrementalSource - texts: string - attributes: string - removes: string - adds: string - } -} - -export type compressedIncrementalStyleSnapshotEvent = { - type: EventType.IncrementalSnapshot - data: { - source: IncrementalSource.StyleSheetRule - id?: number - styleId?: number - replace?: string - replaceSync?: string - adds?: string - removes?: string - } -} - -export type compressedEvent = - | compressedIncrementalStyleSnapshotEvent - | compressedFullSnapshotEvent - | compressedIncrementalSnapshotEvent -export type compressedEventWithTime = compressedEvent & { - timestamp: number - delay?: number - // marker for compression version - cv: '2024-10' -} - -function gzipToString(data: unknown): string { - return strFromU8(gzipSync(strToU8(JSON.stringify(data))), true) -} - -/** - * rrweb's packer takes an event and returns a string or the reverse on `unpack`. - * but we want to be able to inspect metadata during ingestion. - * and don't want to compress the entire event, - * so we have a custom packer that only compresses part of some events - */ -function compressEvent(event: eventWithTime): eventWithTime | compressedEventWithTime { - try { - if (event.type === EventType.FullSnapshot) { - return { - ...event, - data: gzipToString(event.data), - cv: '2024-10', - } - } - if (event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.Mutation) { - return { - ...event, - cv: '2024-10', - data: { - ...event.data, - texts: gzipToString(event.data.texts), - attributes: gzipToString(event.data.attributes), - removes: gzipToString(event.data.removes), - adds: gzipToString(event.data.adds), - }, - } - } - if (event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.StyleSheetRule) { - return { - ...event, - cv: '2024-10', - data: { - ...event.data, - adds: event.data.adds ? gzipToString(event.data.adds) : undefined, - removes: event.data.removes ? gzipToString(event.data.removes) : undefined, - }, - } - } - } catch (e) { - logger.error('could not compress event - will use uncompressed event', e) - } - return event -} - -function isSessionIdleEvent(e: eventWithTime): e is eventWithTime & customEvent { - return e.type === EventType.Custom && e.data.tag === 'sessionIdle' -} - -/** When we put the recording into a paused state, we add a custom event. - * However, in the paused state, events are dropped and never make it to the buffer, - * so we need to manually let this one through */ -function isRecordingPausedEvent(e: eventWithTime) { - return e.type === EventType.Custom && e.data.tag === 'recording paused' -} - -export class SessionRecording { - private _endpoint: string - private _flushBufferTimer?: any - - private _statusMatcher: (triggersStatus: RecordingTriggersStatus) => SessionRecordingStatus = - nullMatchSessionRecordingStatus - - private _receivedFlags: boolean = false - - // we have a buffer - that contains PostHog snapshot events ready to be sent to the server - private _buffer: SnapshotBuffer - // and a queue - that contains rrweb events that we want to send to rrweb, but rrweb wasn't able to accept them yet - private _queuedRRWebEvents: QueuedRRWebEvent[] = [] - - private _mutationThrottler?: MutationThrottler - private _captureStarted: boolean - private _stopRrweb: listenerHandler | undefined - private _isIdle: boolean | 'unknown' = 'unknown' - - private _lastActivityTimestamp: number = Date.now() - private _windowId: string - private _sessionId: string - get sessionId(): string { - return this._sessionId - } - - private _linkedFlagMatching: LinkedFlagMatching - private _urlTriggerMatching: URLTriggerMatching - private _eventTriggerMatching: EventTriggerMatching - // we need to be able to check the state of the event and url triggers separately - // as we make some decisions based on them without referencing LinkedFlag etc - private _triggerMatching: TriggerStatusMatching = new PendingTriggerMatching() - - private _fullSnapshotTimer?: ReturnType - - private _removePageViewCaptureHook: (() => void) | undefined = undefined - private _onSessionIdListener: (() => void) | undefined = undefined - private _persistFlagsOnSessionListener: (() => void) | undefined = undefined - private _samplingSessionListener: (() => void) | undefined = undefined - - // if pageview capture is disabled, - // then we can manually track href changes - private _lastHref?: string - - private _removeEventTriggerCaptureHook: (() => void) | undefined = undefined - - // Util to help developers working on this feature manually override - _forceAllowLocalhostNetworkCapture = false - - private get _sessionIdleThresholdMilliseconds(): number { - return this._instance.config.session_recording.session_idle_threshold_ms || RECORDING_IDLE_THRESHOLD_MS - } - - public get started(): boolean { - // TODO could we use status instead of _captureStarted? - return this._captureStarted - } - - private get _sessionManager() { - if (!this._instance.sessionManager) { - throw new Error(LOGGER_PREFIX + ' must be started with a valid sessionManager.') - } - - return this._instance.sessionManager - } - - private get _fullSnapshotIntervalMillis(): number { - if (this._triggerMatching.triggerStatus(this.sessionId) === TRIGGER_PENDING) { - return ONE_MINUTE - } - - return this._instance.config.session_recording?.full_snapshot_interval_millis ?? FIVE_MINUTES - } - - private get _isSampled(): boolean | null { - const currentValue = this._instance.get_property(SESSION_RECORDING_IS_SAMPLED) - return isBoolean(currentValue) ? currentValue : null - } - - private get _sessionDuration(): number | null { - const mostRecentSnapshot = this._buffer?.data[this._buffer?.data.length - 1] - const { sessionStartTimestamp } = this._sessionManager.checkAndGetSessionAndWindowId(true) - return mostRecentSnapshot ? mostRecentSnapshot.timestamp - sessionStartTimestamp : null - } - - private get _isRecordingEnabled() { - const enabled_server_side = !!this._instance.get_property(SESSION_RECORDING_ENABLED_SERVER_SIDE) - const enabled_client_side = !this._instance.config.disable_session_recording - return window && enabled_server_side && enabled_client_side - } - - private get _isConsoleLogCaptureEnabled() { - const enabled_server_side = !!this._instance.get_property(CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE) - const enabled_client_side = this._instance.config.enable_recording_console_log - return enabled_client_side ?? enabled_server_side - } - - private get _canvasRecording(): { enabled: boolean; fps: number; quality: number } { - const canvasRecording_client_side = this._instance.config.session_recording.captureCanvas - const canvasRecording_server_side = this._instance.get_property(SESSION_RECORDING_CANVAS_RECORDING) - - const enabled: boolean = - canvasRecording_client_side?.recordCanvas ?? canvasRecording_server_side?.enabled ?? false - const fps: number = - canvasRecording_client_side?.canvasFps ?? canvasRecording_server_side?.fps ?? DEFAULT_CANVAS_FPS - let quality: string | number = - canvasRecording_client_side?.canvasQuality ?? canvasRecording_server_side?.quality ?? DEFAULT_CANVAS_QUALITY - if (typeof quality === 'string') { - const parsed = parseFloat(quality) - quality = isNaN(parsed) ? 0.4 : parsed - } - - return { - enabled, - fps: clampToRange(fps, 0, MAX_CANVAS_FPS, logger.createLogger('canvas recording fps'), DEFAULT_CANVAS_FPS), - quality: clampToRange( - quality, - 0, - MAX_CANVAS_QUALITY, - logger.createLogger('canvas recording quality'), - DEFAULT_CANVAS_QUALITY - ), - } - } - - // network payload capture config has three parts - // each can be configured server side or client side - private get _networkPayloadCapture(): - | Pick - | undefined { - const networkPayloadCapture_server_side = this._instance.get_property(SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE) - const networkPayloadCapture_client_side = { - recordHeaders: this._instance.config.session_recording?.recordHeaders, - recordBody: this._instance.config.session_recording?.recordBody, - } - const headersEnabled = - networkPayloadCapture_client_side?.recordHeaders || networkPayloadCapture_server_side?.recordHeaders - const bodyEnabled = - networkPayloadCapture_client_side?.recordBody || networkPayloadCapture_server_side?.recordBody - const clientConfigForPerformanceCapture = isObject(this._instance.config.capture_performance) - ? this._instance.config.capture_performance.network_timing - : this._instance.config.capture_performance - const networkTimingEnabled = !!(isBoolean(clientConfigForPerformanceCapture) - ? clientConfigForPerformanceCapture - : networkPayloadCapture_server_side?.capturePerformance) - - return headersEnabled || bodyEnabled || networkTimingEnabled - ? { recordHeaders: headersEnabled, recordBody: bodyEnabled, recordPerformance: networkTimingEnabled } - : undefined - } - - private get _masking(): - | Pick - | undefined { - const masking_server_side = this._instance.get_property(SESSION_RECORDING_MASKING) - const masking_client_side = { - maskAllInputs: this._instance.config.session_recording?.maskAllInputs, - maskTextSelector: this._instance.config.session_recording?.maskTextSelector, - blockSelector: this._instance.config.session_recording?.blockSelector, - } - - const maskAllInputs = masking_client_side?.maskAllInputs ?? masking_server_side?.maskAllInputs - const maskTextSelector = masking_client_side?.maskTextSelector ?? masking_server_side?.maskTextSelector - const blockSelector = masking_client_side?.blockSelector ?? masking_server_side?.blockSelector - - return !isUndefined(maskAllInputs) || !isUndefined(maskTextSelector) || !isUndefined(blockSelector) - ? { - maskAllInputs: maskAllInputs ?? true, - maskTextSelector, - blockSelector, - } - : undefined - } - - private get _sampleRate(): number | null { - const rate = this._instance.get_property(SESSION_RECORDING_SAMPLE_RATE) - return isNumber(rate) ? rate : null - } - - private get _minimumDuration(): number | null { - const duration = this._instance.get_property(SESSION_RECORDING_MINIMUM_DURATION) - return isNumber(duration) ? duration : null - } - - /** - * defaults to buffering mode until a flags response is received - * once a flags response is received status can be disabled, active or sampled - */ - get status(): SessionRecordingStatus { - if (!this._receivedFlags) { - return BUFFERING - } - - return this._statusMatcher({ - receivedFlags: this._receivedFlags, - isRecordingEnabled: this._isRecordingEnabled, - isSampled: this._isSampled, - urlTriggerMatching: this._urlTriggerMatching, - eventTriggerMatching: this._eventTriggerMatching, - linkedFlagMatching: this._linkedFlagMatching, - sessionId: this.sessionId, - }) - } - - constructor(private readonly _instance: PostHog) { - this._captureStarted = false - this._endpoint = BASE_ENDPOINT - this._stopRrweb = undefined - this._receivedFlags = false - - if (!this._instance.sessionManager) { - logger.error('started without valid sessionManager') - throw new Error(LOGGER_PREFIX + ' started without valid sessionManager. This is a bug.') - } - if (this._instance.config.cookieless_mode === 'always') { - throw new Error(LOGGER_PREFIX + ' cannot be used with cookieless_mode="always"') - } - - this._linkedFlagMatching = new LinkedFlagMatching(this._instance) - this._urlTriggerMatching = new URLTriggerMatching(this._instance) - this._eventTriggerMatching = new EventTriggerMatching(this._instance) - - // we know there's a sessionManager, so don't need to start without a session id - const { sessionId, windowId } = this._sessionManager.checkAndGetSessionAndWindowId() - this._sessionId = sessionId - this._windowId = windowId - - this._buffer = this._clearBuffer() - - if (this._sessionIdleThresholdMilliseconds >= this._sessionManager.sessionTimeoutMs) { - logger.warn( - `session_idle_threshold_ms (${this._sessionIdleThresholdMilliseconds}) is greater than the session timeout (${this._sessionManager.sessionTimeoutMs}). Session will never be detected as idle` - ) - } - } - - private _onBeforeUnload = (): void => { - this._flushBuffer() - } - - private _onOffline = (): void => { - this.tryAddCustomEvent('browser offline', {}) - } - - private _onOnline = (): void => { - this.tryAddCustomEvent('browser online', {}) - } - - private _onVisibilityChange = (): void => { - if (document?.visibilityState) { - const label = 'window ' + document.visibilityState - this.tryAddCustomEvent(label, {}) - } - } - - startIfEnabledOrStop(startReason?: SessionStartReason) { - if (this._isRecordingEnabled) { - this._startCapture(startReason) - - // calling addEventListener multiple times is safe and will not add duplicates - addEventListener(window, 'beforeunload', this._onBeforeUnload) - addEventListener(window, 'offline', this._onOffline) - addEventListener(window, 'online', this._onOnline) - addEventListener(window, 'visibilitychange', this._onVisibilityChange) - - // on reload there might be an already sampled session that should be continued before flags response, - // so we call this here _and_ in the flags response - this._setupSampling() - - this._addEventTriggerListener() - - if (isNullish(this._removePageViewCaptureHook)) { - // :TRICKY: rrweb does not capture navigation within SPA-s, so hook into our $pageview events to get access to all events. - // Dropping the initial event is fine (it's always captured by rrweb). - this._removePageViewCaptureHook = this._instance.on('eventCaptured', (event) => { - // If anything could go wrong here, - // it has the potential to block the main loop, - // so we catch all errors. - try { - if (event.event === '$pageview') { - const href = event?.properties.$current_url - ? this._maskUrl(event?.properties.$current_url) - : '' - if (!href) { - return - } - this.tryAddCustomEvent('$pageview', { href }) - } - } catch (e) { - logger.error('Could not add $pageview to rrweb session', e) - } - }) - } - - if (!this._onSessionIdListener) { - this._onSessionIdListener = this._sessionManager.onSessionId((sessionId, windowId, changeReason) => { - if (changeReason) { - this.tryAddCustomEvent('$session_id_change', { sessionId, windowId, changeReason }) - - this._instance?.persistence?.unregister(SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION) - this._instance?.persistence?.unregister(SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION) - } - }) - } - } else { - this.stopRecording() - } - } - - stopRecording() { - if (this._captureStarted && this._stopRrweb) { - this._stopRrweb() - this._stopRrweb = undefined - this._captureStarted = false - - window?.removeEventListener('beforeunload', this._onBeforeUnload) - window?.removeEventListener('offline', this._onOffline) - window?.removeEventListener('online', this._onOnline) - window?.removeEventListener('visibilitychange', this._onVisibilityChange) - - this._clearBuffer() - clearInterval(this._fullSnapshotTimer) - - this._removePageViewCaptureHook?.() - this._removePageViewCaptureHook = undefined - this._removeEventTriggerCaptureHook?.() - this._removeEventTriggerCaptureHook = undefined - this._onSessionIdListener?.() - this._onSessionIdListener = undefined - this._samplingSessionListener?.() - this._samplingSessionListener = undefined - - this._eventTriggerMatching.stop() - this._urlTriggerMatching.stop() - this._linkedFlagMatching.stop() - logger.info('stopped') - } - } - - private _resetSampling() { - this._instance.persistence?.unregister(SESSION_RECORDING_IS_SAMPLED) - } - - private _makeSamplingDecision(sessionId: string): void { - const sessionIdChanged = this._sessionId !== sessionId - - // capture the current sample rate - // because it is re-used multiple times - // and the bundler won't minimize any of the references - const currentSampleRate = this._sampleRate - - if (!isNumber(currentSampleRate)) { - this._resetSampling() - return - } - - const storedIsSampled = this._isSampled - - /** - * if we get this far, then we should make a sampling decision. - * When the session id changes or there is no stored sampling decision for this session id - * then we should make a new decision. - * - * Otherwise, we should use the stored decision. - */ - const makeDecision = sessionIdChanged || !isBoolean(storedIsSampled) - const shouldSample = makeDecision ? sampleOnProperty(sessionId, currentSampleRate) : storedIsSampled - - if (makeDecision) { - if (shouldSample) { - this._reportStarted(SAMPLED) - } else { - logger.warn( - `Sample rate (${currentSampleRate}) has determined that this sessionId (${sessionId}) will not be sent to the server.` - ) - } - - this.tryAddCustomEvent('samplingDecisionMade', { - sampleRate: currentSampleRate, - isSampled: shouldSample, - }) - } - - this._instance.persistence?.register({ - [SESSION_RECORDING_IS_SAMPLED]: shouldSample, - }) - } - - onRemoteConfig(response: RemoteConfig) { - this.tryAddCustomEvent('$remote_config_received', response) - this._receivedFlags = true - - this._persistRemoteConfig(response) - - if (response.sessionRecording) { - if (response.sessionRecording?.endpoint) { - this._endpoint = response.sessionRecording?.endpoint - } - - this._setupSampling() - - if (response.sessionRecording?.triggerMatchType === 'any') { - this._statusMatcher = anyMatchSessionRecordingStatus - this._triggerMatching = new OrTriggerMatching([this._eventTriggerMatching, this._urlTriggerMatching]) - } else { - // either the setting is "ALL" - // or we default to the most restrictive - this._statusMatcher = allMatchSessionRecordingStatus - this._triggerMatching = new AndTriggerMatching([this._eventTriggerMatching, this._urlTriggerMatching]) - } - this._instance.register_for_session({ - $sdk_debug_replay_remote_trigger_matching_config: response.sessionRecording?.triggerMatchType, - }) - - this._urlTriggerMatching.onConfig(response) - this._eventTriggerMatching.onConfig(response) - this._linkedFlagMatching.onConfig(response, (flag, variant) => { - this._reportStarted('linked_flag_matched', { - flag, - variant, - }) - }) - } - - this.startIfEnabledOrStop() - } - - /** - * This might be called more than once so needs to be idempotent - */ - private _setupSampling() { - if (isNumber(this._sampleRate) && isNullish(this._samplingSessionListener)) { - this._samplingSessionListener = this._sessionManager.onSessionId((sessionId) => { - this._makeSamplingDecision(sessionId) - }) - } - } - - private _persistRemoteConfig(response: RemoteConfig): void { - if (this._instance.persistence) { - const persistence = this._instance.persistence - - const persistResponse = () => { - const receivedConfig = response.sessionRecording === false ? undefined : response.sessionRecording - const receivedSampleRate = receivedConfig?.sampleRate - - const parsedSampleRate = isNullish(receivedSampleRate) ? null : parseFloat(receivedSampleRate) - if (isNullish(parsedSampleRate)) { - this._resetSampling() - } - - const receivedMinimumDuration = receivedConfig?.minimumDurationMilliseconds - - persistence.register({ - [SESSION_RECORDING_ENABLED_SERVER_SIDE]: !!response['sessionRecording'], - [CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE]: receivedConfig?.consoleLogRecordingEnabled, - [SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE]: { - capturePerformance: response.capturePerformance, - ...receivedConfig?.networkPayloadCapture, - }, - [SESSION_RECORDING_MASKING]: receivedConfig?.masking, - [SESSION_RECORDING_CANVAS_RECORDING]: { - enabled: receivedConfig?.recordCanvas, - fps: receivedConfig?.canvasFps, - quality: receivedConfig?.canvasQuality, - }, - [SESSION_RECORDING_SAMPLE_RATE]: parsedSampleRate, - [SESSION_RECORDING_MINIMUM_DURATION]: isUndefined(receivedMinimumDuration) - ? null - : receivedMinimumDuration, - [SESSION_RECORDING_SCRIPT_CONFIG]: receivedConfig?.scriptConfig, - }) - } - - persistResponse() - - // in case we see multiple flags responses, we should only use the response from the most recent one - this._persistFlagsOnSessionListener?.() - this._persistFlagsOnSessionListener = this._sessionManager.onSessionId(persistResponse) - } - } - - log(message: string, level: 'log' | 'warn' | 'error' = 'log') { - this._instance.sessionRecording?.onRRwebEmit({ - type: 6, - data: { - plugin: 'rrweb/console@1', - payload: { - level, - trace: [], - // Even though it is a string, we stringify it as that's what rrweb expects - payload: [JSON.stringify(message)], - }, - }, - timestamp: Date.now(), - }) - } - - private _startCapture(startReason?: SessionStartReason) { - if (isUndefined(Object.assign) || isUndefined(Array.from)) { - // According to the rrweb docs, rrweb is not supported on IE11 and below: - // "rrweb does not support IE11 and below because it uses the MutationObserver API, which was supported by these browsers." - // https://github.com/rrweb-io/rrweb/blob/master/guide.md#compatibility-note - // - // However, MutationObserver does exist on IE11, it just doesn't work well and does not detect all changes. - // Instead, when we load "recorder.js", the first JS error is about "Object.assign" and "Array.from" being undefined. - // Thus instead of MutationObserver, we look for this function and block recording if it's undefined. - return - } - - // We do not switch recorder versions midway through a recording. - // do not start if explicitly disabled or if the user has opted out - if ( - this._captureStarted || - this._instance.config.disable_session_recording || - this._instance.consent.isOptedOut() - ) { - return - } - - this._captureStarted = true - // We want to ensure the sessionManager is reset if necessary on loading the recorder - this._sessionManager.checkAndGetSessionAndWindowId() - - // If recorder.js is already loaded (if array.full.js snippet is used or posthog-js/dist/recorder is - // imported), don't load the script. Otherwise, remotely import recorder.js from cdn since it hasn't been loaded. - if (!getRRWebRecord()) { - assignableWindow.__PosthogExtensions__?.loadExternalDependency?.( - this._instance, - this._scriptName, - (err) => { - if (err) { - return logger.error('could not load recorder', err) - } - - this._onScriptLoaded() - } - ) - } else { - this._onScriptLoaded() - } - - logger.info('starting') - if (this.status === ACTIVE) { - this._reportStarted(startReason || 'recording_initialized') - } - } - - private get _scriptName(): PostHogExtensionKind { - return ( - (this._instance?.persistence?.get_property(SESSION_RECORDING_SCRIPT_CONFIG) - ?.script as PostHogExtensionKind) || 'recorder' - ) - } - - private _isInteractiveEvent(event: eventWithTime) { - return ( - event.type === INCREMENTAL_SNAPSHOT_EVENT_TYPE && - ACTIVE_SOURCES.indexOf(event.data?.source as IncrementalSource) !== -1 - ) - } - - private _updateWindowAndSessionIds(event: eventWithTime) { - // Some recording events are triggered by non-user events (e.g. "X minutes ago" text updating on the screen). - // We don't want to extend the session or trigger a new session in these cases. These events are designated by event - // type -> incremental update, and source -> mutation. - - const isUserInteraction = this._isInteractiveEvent(event) - - if (!isUserInteraction && !this._isIdle) { - // We check if the lastActivityTimestamp is old enough to go idle - const timeSinceLastActivity = event.timestamp - this._lastActivityTimestamp - if (timeSinceLastActivity > this._sessionIdleThresholdMilliseconds) { - // we mark as idle right away, - // or else we get multiple idle events - // if there are lots of non-user activity events being emitted - this._isIdle = true - - // don't take full snapshots while idle - clearInterval(this._fullSnapshotTimer) - - this.tryAddCustomEvent('sessionIdle', { - eventTimestamp: event.timestamp, - lastActivityTimestamp: this._lastActivityTimestamp, - threshold: this._sessionIdleThresholdMilliseconds, - bufferLength: this._buffer.data.length, - bufferSize: this._buffer.size, - }) - - // proactively flush the buffer in case the session is idle for a long time - this._flushBuffer() - } - } - - let returningFromIdle = false - if (isUserInteraction) { - this._lastActivityTimestamp = event.timestamp - if (this._isIdle) { - const idleWasUnknown = this._isIdle === 'unknown' - // Remove the idle state - this._isIdle = false - // if the idle state was unknown, we don't want to add an event, since we're just in bootup - // whereas if it was true, we know we've been idle for a while, and we can mark ourselves as returning from idle - if (!idleWasUnknown) { - this.tryAddCustomEvent('sessionNoLongerIdle', { - reason: 'user activity', - type: event.type, - }) - returningFromIdle = true - } - } - } - - if (this._isIdle) { - return - } - - // We only want to extend the session if it is an interactive event. - const { windowId, sessionId } = this._sessionManager.checkAndGetSessionAndWindowId( - !isUserInteraction, - event.timestamp - ) - - const sessionIdChanged = this._sessionId !== sessionId - const windowIdChanged = this._windowId !== windowId - - this._windowId = windowId - this._sessionId = sessionId - - if (sessionIdChanged || windowIdChanged) { - this.stopRecording() - this.startIfEnabledOrStop('session_id_changed') - } else if (returningFromIdle) { - this._scheduleFullSnapshot() - } - } - - private _tryRRWebMethod(queuedRRWebEvent: QueuedRRWebEvent): boolean { - try { - queuedRRWebEvent.rrwebMethod() - return true - } catch (e) { - // Sometimes a race can occur where the recorder is not fully started yet - if (this._queuedRRWebEvents.length < 10) { - this._queuedRRWebEvents.push({ - enqueuedAt: queuedRRWebEvent.enqueuedAt || Date.now(), - attempt: queuedRRWebEvent.attempt++, - rrwebMethod: queuedRRWebEvent.rrwebMethod, - }) - } else { - logger.warn('could not emit queued rrweb event.', e, queuedRRWebEvent) - } - - return false - } - } - - /** - * This adds a custom event to the session recording - * - * It is not intended for arbitrary public use - playback only displays known custom events - * And is exposed on the public interface only so that other parts of the SDK are able to use it - * - * if you are calling this from client code, you're probably looking for `posthog.capture('$custom_event', {...})` - */ - tryAddCustomEvent(tag: string, payload: any): boolean { - return this._tryRRWebMethod(newQueuedEvent(() => getRRWebRecord()!.addCustomEvent(tag, payload))) - } - - private _tryTakeFullSnapshot(): boolean { - return this._tryRRWebMethod(newQueuedEvent(() => getRRWebRecord()!.takeFullSnapshot())) - } - - private _onScriptLoaded() { - // rrweb config info: https://github.com/rrweb-io/rrweb/blob/7d5d0033258d6c29599fb08412202d9a2c7b9413/src/record/index.ts#L28 - const sessionRecordingOptions: recordOptions = { - // a limited set of the rrweb config options that we expose to our users. - // see https://github.com/rrweb-io/rrweb/blob/master/guide.md - blockClass: 'ph-no-capture', - blockSelector: undefined, - ignoreClass: 'ph-ignore-input', - maskTextClass: 'ph-mask', - maskTextSelector: undefined, - maskTextFn: undefined, - maskAllInputs: true, - maskInputOptions: { password: true }, - maskInputFn: undefined, - slimDOMOptions: {}, - collectFonts: false, - inlineStylesheet: true, - recordCrossOriginIframes: false, - } - - // only allows user to set our allowlisted options - const userSessionRecordingOptions = this._instance.config.session_recording - for (const [key, value] of Object.entries(userSessionRecordingOptions || {})) { - if (key in sessionRecordingOptions) { - if (key === 'maskInputOptions') { - // ensure password config is set if not included - sessionRecordingOptions.maskInputOptions = { password: true, ...value } - } else { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - sessionRecordingOptions[key] = value - } - } - } - - if (this._canvasRecording && this._canvasRecording.enabled) { - sessionRecordingOptions.recordCanvas = true - sessionRecordingOptions.sampling = { canvas: this._canvasRecording.fps } - sessionRecordingOptions.dataURLOptions = { type: 'image/webp', quality: this._canvasRecording.quality } - } - - if (this._masking) { - sessionRecordingOptions.maskAllInputs = this._masking.maskAllInputs ?? true - sessionRecordingOptions.maskTextSelector = this._masking.maskTextSelector ?? undefined - sessionRecordingOptions.blockSelector = this._masking.blockSelector ?? undefined - } - - const rrwebRecord = getRRWebRecord() - if (!rrwebRecord) { - logger.error( - 'onScriptLoaded was called but rrwebRecord is not available. This indicates something has gone wrong.' - ) - return - } - - this._mutationThrottler = - this._mutationThrottler ?? - new MutationThrottler(rrwebRecord, { - refillRate: this._instance.config.session_recording.__mutationThrottlerRefillRate, - bucketSize: this._instance.config.session_recording.__mutationThrottlerBucketSize, - onBlockedNode: (id, node) => { - const message = `Too many mutations on node '${id}'. Rate limiting. This could be due to SVG animations or something similar` - logger.info(message, { - node: node, - }) - - this.log(LOGGER_PREFIX + ' ' + message, 'warn') - }, - }) - - const activePlugins = this._gatherRRWebPlugins() - this._stopRrweb = rrwebRecord({ - emit: (event) => { - this.onRRwebEmit(event) - }, - plugins: activePlugins, - ...sessionRecordingOptions, - }) - - // We reset the last activity timestamp, resetting the idle timer - this._lastActivityTimestamp = Date.now() - // stay unknown if we're not sure if we're idle or not - this._isIdle = isBoolean(this._isIdle) ? this._isIdle : 'unknown' - - this.tryAddCustomEvent('$session_options', { - sessionRecordingOptions, - activePlugins: activePlugins.map((p) => p?.name), - }) - - this.tryAddCustomEvent('$posthog_config', { - config: this._instance.config, - }) - } - - private _scheduleFullSnapshot(): void { - if (this._fullSnapshotTimer) { - clearInterval(this._fullSnapshotTimer) - } - // we don't schedule snapshots while idle - if (this._isIdle === true) { - return - } - - const interval = this._fullSnapshotIntervalMillis - if (!interval) { - return - } - - this._fullSnapshotTimer = setInterval(() => { - this._tryTakeFullSnapshot() - }, interval) - } - - private _gatherRRWebPlugins() { - const plugins: RecordPlugin[] = [] - - const recordConsolePlugin = assignableWindow.__PosthogExtensions__?.rrwebPlugins?.getRecordConsolePlugin - if (recordConsolePlugin && this._isConsoleLogCaptureEnabled) { - plugins.push(recordConsolePlugin()) - } - - const networkPlugin = assignableWindow.__PosthogExtensions__?.rrwebPlugins?.getRecordNetworkPlugin - if (this._networkPayloadCapture && isFunction(networkPlugin)) { - const canRecordNetwork = !isLocalhost() || this._forceAllowLocalhostNetworkCapture - - if (canRecordNetwork) { - plugins.push( - networkPlugin(buildNetworkRequestOptions(this._instance.config, this._networkPayloadCapture)) - ) - } else { - logger.info('NetworkCapture not started because we are on localhost.') - } - } - - return plugins - } - - onRRwebEmit(rawEvent: eventWithTime) { - this._processQueuedEvents() - - if (!rawEvent || !isObject(rawEvent)) { - return - } - - if (rawEvent.type === EventType.Meta) { - const href = this._maskUrl(rawEvent.data.href) - this._lastHref = href - if (!href) { - return - } - rawEvent.data.href = href - } else { - this._pageViewFallBack() - } - - // Check if the URL matches any trigger patterns - this._urlTriggerMatching.checkUrlTriggerConditions( - () => this._pauseRecording(), - () => this._resumeRecording(), - (triggerType) => this._activateTrigger(triggerType) - ) - // always have to check if the URL is blocked really early, - // or you risk getting stuck in a loop - if (this._urlTriggerMatching.urlBlocked && !isRecordingPausedEvent(rawEvent)) { - return - } - - // we're processing a full snapshot, so we should reset the timer - if (rawEvent.type === EventType.FullSnapshot) { - this._scheduleFullSnapshot() - } - - // Clear the buffer if waiting for a trigger and only keep data from after the current full snapshot - // we always start trigger pending so need to wait for flags before we know if we're really pending - if ( - rawEvent.type === EventType.FullSnapshot && - this._receivedFlags && - this._triggerMatching.triggerStatus(this.sessionId) === TRIGGER_PENDING - ) { - this._clearBufferBeforeMostRecentMeta() - } - - const throttledEvent = this._mutationThrottler ? this._mutationThrottler.throttleMutations(rawEvent) : rawEvent - - if (!throttledEvent) { - return - } - - // TODO: Re-add ensureMaxMessageSize once we are confident in it - const event = truncateLargeConsoleLogs(throttledEvent) - - this._updateWindowAndSessionIds(event) - - // When in an idle state we keep recording but don't capture the events, - // we don't want to return early if idle is 'unknown' - if (this._isIdle === true && !isSessionIdleEvent(event)) { - return - } - - if (isSessionIdleEvent(event)) { - // session idle events have a timestamp when rrweb sees them - // which can artificially lengthen a session - // we know when we detected it based on the payload and can correct the timestamp - const payload = event.data.payload as SessionIdlePayload - if (payload) { - const lastActivity = payload.lastActivityTimestamp - const threshold = payload.threshold - event.timestamp = lastActivity + threshold - } - } - - const eventToSend = - (this._instance.config.session_recording.compress_events ?? true) ? compressEvent(event) : event - const size = estimateSize(eventToSend) - - const properties = { - $snapshot_bytes: size, - $snapshot_data: eventToSend, - $session_id: this._sessionId, - $window_id: this._windowId, - } - - if (this.status === DISABLED) { - this._clearBuffer() - return - } - - this._captureSnapshotBuffered(properties) - } - - private _pageViewFallBack() { - if (this._instance.config.capture_pageview || !window) { - return - } - const currentUrl = this._maskUrl(window.location.href) - if (this._lastHref !== currentUrl) { - this.tryAddCustomEvent('$url_changed', { href: currentUrl }) - this._lastHref = currentUrl - } - } - - private _processQueuedEvents() { - if (this._queuedRRWebEvents.length) { - // if rrweb isn't ready to accept events earlier, then we queued them up. - // now that `emit` has been called rrweb should be ready to accept them. - // so, before we process this event, we try our queued events _once_ each - // we don't want to risk queuing more things and never exiting this loop! - // if they fail here, they'll be pushed into a new queue - // and tried on the next loop. - // there is a risk of this queue growing in an uncontrolled manner. - // so its length is limited elsewhere - // for now this is to help us ensure we can capture events that happen - // and try to identify more about when it is failing - const itemsToProcess = [...this._queuedRRWebEvents] - this._queuedRRWebEvents = [] - itemsToProcess.forEach((queuedRRWebEvent) => { - if (Date.now() - queuedRRWebEvent.enqueuedAt <= TWO_SECONDS) { - this._tryRRWebMethod(queuedRRWebEvent) - } - }) - } - } - - private _maskUrl(url: string): string | undefined { - const userSessionRecordingOptions = this._instance.config.session_recording - - if (userSessionRecordingOptions.maskNetworkRequestFn) { - let networkRequest: NetworkRequest | null | undefined = { - url, - } - - // TODO we should deprecate this and use the same function for this masking and the rrweb/network plugin - // TODO or deprecate this and provide a new clearer name so this would be `maskURLPerformanceFn` or similar - networkRequest = userSessionRecordingOptions.maskNetworkRequestFn(networkRequest) - - return networkRequest?.url - } - - return url - } - - private _clearBufferBeforeMostRecentMeta(): SnapshotBuffer { - if (!this._buffer || this._buffer.data.length === 0) { - return this._clearBuffer() - } - - // Find the last meta event index by iterating backwards - let lastMetaIndex = -1 - for (let i = this._buffer.data.length - 1; i >= 0; i--) { - if (this._buffer.data[i].type === EventType.Meta) { - lastMetaIndex = i - break - } - } - if (lastMetaIndex >= 0) { - this._buffer.data = this._buffer.data.slice(lastMetaIndex) - this._buffer.size = this._buffer.data.reduce((acc, curr) => acc + estimateSize(curr), 0) - return this._buffer - } else { - return this._clearBuffer() - } - } - - private _clearBuffer(): SnapshotBuffer { - this._buffer = { - size: 0, - data: [], - sessionId: this._sessionId, - windowId: this._windowId, - } - return this._buffer - } - - private _flushBuffer(): SnapshotBuffer { - if (this._flushBufferTimer) { - clearTimeout(this._flushBufferTimer) - this._flushBufferTimer = undefined - } - - const minimumDuration = this._minimumDuration - const sessionDuration = this._sessionDuration - // if we have old data in the buffer but the session has rotated, then the - // session duration might be negative. In that case we want to flush the buffer - const isPositiveSessionDuration = isNumber(sessionDuration) && sessionDuration >= 0 - const isBelowMinimumDuration = - isNumber(minimumDuration) && isPositiveSessionDuration && sessionDuration < minimumDuration - - if (this.status === BUFFERING || this.status === PAUSED || this.status === DISABLED || isBelowMinimumDuration) { - this._flushBufferTimer = setTimeout(() => { - this._flushBuffer() - }, RECORDING_BUFFER_TIMEOUT) - return this._buffer - } - - if (this._buffer.data.length > 0) { - const snapshotEvents = splitBuffer(this._buffer) - snapshotEvents.forEach((snapshotBuffer) => { - this._captureSnapshot({ - $snapshot_bytes: snapshotBuffer.size, - $snapshot_data: snapshotBuffer.data, - $session_id: snapshotBuffer.sessionId, - $window_id: snapshotBuffer.windowId, - $lib: 'web', - $lib_version: Config.LIB_VERSION, - }) - }) - } - - // buffer is empty, we clear it in case the session id has changed - return this._clearBuffer() - } - - private _captureSnapshotBuffered(properties: Properties) { - const additionalBytes = 2 + (this._buffer?.data.length || 0) // 2 bytes for the array brackets and 1 byte for each comma - if ( - !this._isIdle && // we never want to flush when idle - (this._buffer.size + properties.$snapshot_bytes + additionalBytes > RECORDING_MAX_EVENT_SIZE || - this._buffer.sessionId !== this._sessionId) - ) { - this._buffer = this._flushBuffer() - } - - this._buffer.size += properties.$snapshot_bytes - this._buffer.data.push(properties.$snapshot_data) - - if (!this._flushBufferTimer && !this._isIdle) { - this._flushBufferTimer = setTimeout(() => { - this._flushBuffer() - }, RECORDING_BUFFER_TIMEOUT) - } - } - - private _captureSnapshot(properties: Properties) { - // :TRICKY: Make sure we batch these requests, use a custom endpoint and don't truncate the strings. - this._instance.capture('$snapshot', properties, { - _url: this._instance.requestRouter.endpointFor('api', this._endpoint), - _noTruncate: true, - _batchKey: SESSION_RECORDING_BATCH_KEY, - skip_client_rate_limiting: true, - }) - } - - private _activateTrigger(triggerType: TriggerType) { - if (this._triggerMatching.triggerStatus(this.sessionId) === TRIGGER_PENDING) { - // status is stored separately for URL and event triggers - this._instance?.persistence?.register({ - [triggerType === 'url' - ? SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION - : SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION]: this._sessionId, - }) - - this._flushBuffer() - this._reportStarted((triggerType + '_trigger_matched') as SessionStartReason) - } - } - - private _pauseRecording() { - // we check _urlBlocked not status, since more than one thing can affect status - if (this._urlTriggerMatching.urlBlocked) { - return - } - - // we can't flush the buffer here since someone might be starting on a blocked page. - // and we need to be sure that we don't record that page, - // so we might not get the below custom event, but events will report the paused status. - // which will allow debugging of sessions that start on blocked pages - this._urlTriggerMatching.urlBlocked = true - - // Clear the snapshot timer since we don't want new snapshots while paused - clearInterval(this._fullSnapshotTimer) - - logger.info('recording paused due to URL blocker') - this.tryAddCustomEvent('recording paused', { reason: 'url blocker' }) - } - - private _resumeRecording() { - // we check _urlBlocked not status, since more than one thing can affect status - if (!this._urlTriggerMatching.urlBlocked) { - return - } - - this._urlTriggerMatching.urlBlocked = false - - this._tryTakeFullSnapshot() - this._scheduleFullSnapshot() - - this.tryAddCustomEvent('recording resumed', { reason: 'left blocked url' }) - logger.info('recording resumed') - } - - private _addEventTriggerListener() { - if (this._eventTriggerMatching._eventTriggers.length === 0 || !isNullish(this._removeEventTriggerCaptureHook)) { - return - } - - this._removeEventTriggerCaptureHook = this._instance.on('eventCaptured', (event: CaptureResult) => { - // If anything could go wrong here, it has the potential to block the main loop, - // so we catch all errors. - try { - if (this._eventTriggerMatching._eventTriggers.includes(event.event)) { - this._activateTrigger('event') - } - } catch (e) { - logger.error('Could not activate event trigger', e) - } - }) - } - - /** - * this ignores the linked flag config and (if other conditions are met) causes capture to start - * - * It is not usual to call this directly, - * instead call `posthog.startSessionRecording({linked_flag: true})` - * */ - public overrideLinkedFlag() { - this._linkedFlagMatching.linkedFlagSeen = true - this._tryTakeFullSnapshot() - this._reportStarted('linked_flag_overridden') - } - - /** - * this ignores the sampling config and (if other conditions are met) causes capture to start - * - * It is not usual to call this directly, - * instead call `posthog.startSessionRecording({sampling: true})` - * */ - public overrideSampling() { - this._instance.persistence?.register({ - // short-circuits the `makeSamplingDecision` function in the session recording module - [SESSION_RECORDING_IS_SAMPLED]: true, - }) - this._tryTakeFullSnapshot() - this._reportStarted('sampling_overridden') - } - - /** - * this ignores the URL/Event trigger config and (if other conditions are met) causes capture to start - * - * It is not usual to call this directly, - * instead call `posthog.startSessionRecording({trigger: 'url' | 'event'})` - * */ - public overrideTrigger(triggerType: TriggerType) { - this._activateTrigger(triggerType) - } - - private _reportStarted(startReason: SessionStartReason, tagPayload?: Record) { - this._instance.register_for_session({ - $session_recording_start_reason: startReason, - }) - logger.info(startReason.replace('_', ' '), tagPayload) - if (!includes(['recording_initialized', 'session_id_changed'], startReason)) { - this.tryAddCustomEvent(startReason, tagPayload) - } - } - - /* - * whenever we capture an event, we add these properties to the event - * these are used to debug issues with the session recording - * when looking at the event feed for a session - */ - get sdkDebugProperties(): Properties { - const { sessionStartTimestamp } = this._sessionManager.checkAndGetSessionAndWindowId(true) - - return { - $recording_status: this.status, - $sdk_debug_replay_internal_buffer_length: this._buffer.data.length, - $sdk_debug_replay_internal_buffer_size: this._buffer.size, - $sdk_debug_current_session_duration: this._sessionDuration, - $sdk_debug_session_start: sessionStartTimestamp, - } - } -} diff --git a/packages/browser/src/posthog-core.ts b/packages/browser/src/posthog-core.ts index f344121673..5053f7b90f 100644 --- a/packages/browser/src/posthog-core.ts +++ b/packages/browser/src/posthog-core.ts @@ -14,7 +14,6 @@ import { import { DeadClicksAutocapture, isDeadClicksEnabledForAutocapture } from './extensions/dead-clicks-autocapture' import { ExceptionObserver } from './extensions/exception-autocapture' import { HistoryAutocapture } from './extensions/history-autocapture' -import { SessionRecording } from './extensions/replay/sessionrecording' import { setupSegmentIntegration } from './extensions/segment-integration' import { SentryIntegration, sentryIntegration, SentryIntegrationOptions } from './extensions/sentry-integration' import { Toolbar } from './extensions/toolbar' @@ -102,7 +101,7 @@ import { import { uuidv7 } from './uuidv7' import { WebExperiments } from './web-experiments' import { ExternalIntegrations } from './extensions/external-integration' -import { SessionRecordingWrapper } from './extensions/replay/sessionrecording-wrapper' +import { SessionRecording } from './extensions/replay/session-recording' /* SIMPLE STYLE GUIDE: @@ -323,7 +322,7 @@ export class PostHog { _requestQueue?: RequestQueue _retryQueue?: RetryQueue - sessionRecording?: SessionRecording | SessionRecordingWrapper + sessionRecording?: SessionRecording externalIntegrations?: ExternalIntegrations webPerformance = new DeprecatedWebPerformanceObserver() @@ -537,11 +536,7 @@ export class PostHog { this.siteApps?.init() if (!startInCookielessMode) { - if (this.config.__preview_eager_load_replay) { - this.sessionRecording = new SessionRecording(this) - } else { - this.sessionRecording = new SessionRecordingWrapper(this) - } + this.sessionRecording = new SessionRecording(this) this.sessionRecording.startIfEnabledOrStop() } diff --git a/packages/browser/src/types.ts b/packages/browser/src/types.ts index d586f8d49f..13c551fd77 100644 --- a/packages/browser/src/types.ts +++ b/packages/browser/src/types.ts @@ -3,7 +3,9 @@ import type { SegmentAnalytics } from './extensions/segment-integration' import { PostHog } from './posthog-core' import { KnownUnsafeEditableEvent } from '@posthog/core' import { Survey } from './posthog-surveys-types' -import { SAMPLED } from './extensions/replay/triggerMatching' +// only importing types here, so won't affect the bundle +// eslint-disable-next-line posthog-js/no-external-replay-imports +import type { SAMPLED } from './extensions/replay/external/triggerMatching' export type Property = any export type Properties = Record diff --git a/packages/browser/src/utils/globals.ts b/packages/browser/src/utils/globals.ts index 052a2de941..5d1020a5b2 100644 --- a/packages/browser/src/utils/globals.ts +++ b/packages/browser/src/utils/globals.ts @@ -8,7 +8,9 @@ import { SiteAppLoader, SessionStartReason, } from '../types' -import { SessionRecordingStatus, TriggerType } from '../extensions/replay/triggerMatching' +// only importing types here, so won't affect the bundle +// eslint-disable-next-line posthog-js/no-external-replay-imports +import type { SessionRecordingStatus, TriggerType } from '../extensions/replay/external/triggerMatching' import { eventWithTime } from '@rrweb/types' import { ErrorTracking } from '@posthog/core' @@ -154,6 +156,7 @@ export type PostHogExtensionKind = | 'exception-autocapture' | 'web-vitals' | 'recorder' + | 'lazy-recorder' | 'tracing-headers' | 'surveys' | 'dead-clicks-autocapture' diff --git a/packages/browser/terser-mangled-names.json b/packages/browser/terser-mangled-names.json index 56cff84d96..2281f98a5b 100644 --- a/packages/browser/terser-mangled-names.json +++ b/packages/browser/terser-mangled-names.json @@ -36,7 +36,6 @@ "_capturePageview", "_captureSnapshot", "_captureSnapshotBuffered", - "_captureStarted", "_checkAction", "_checkClickTimer", "_checkClicks", @@ -279,7 +278,6 @@ "_setupEventBasedSurveys", "_setupListeners", "_setupPopstateListener", - "_setupSampling", "_setupSiteApps", "_shouldCapturePageleave", "_shouldDisableFlags", @@ -287,7 +285,6 @@ "_showPreviewWebExperiment", "_sortSurveysByAppearanceDelay", "_start", - "_startCapture", "_startCapturing", "_startClickObserver", "_startMutationObserver",