diff --git a/src/__tests__/extensions/replay/sessionrecording.test.ts b/src/__tests__/extensions/replay/sessionrecording.test.ts index a0c676588..12bed408b 100644 --- a/src/__tests__/extensions/replay/sessionrecording.test.ts +++ b/src/__tests__/extensions/replay/sessionrecording.test.ts @@ -31,7 +31,7 @@ import { RECORDING_IDLE_THRESHOLD_MS, RECORDING_MAX_EVENT_SIZE, SessionRecording, -} from '../../../extensions/replay/sessionrecording' +} from '../../../entrypoints/sessionrecording' import { assignableWindow, window } from '../../../utils/globals' import { RequestRouter } from '../../../utils/request-router' import { diff --git a/src/__tests__/posthog-core.ts b/src/__tests__/posthog-core.ts index 0579706b4..67f9f5da3 100644 --- a/src/__tests__/posthog-core.ts +++ b/src/__tests__/posthog-core.ts @@ -11,7 +11,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 '../entrypoints/sessionrecording' import { PostHogFeatureFlags } from '../posthog-featureflags' describe('posthog core', () => { diff --git a/src/extensions/replay/sessionrecording.ts b/src/entrypoints/session-recorder.ts similarity index 96% rename from src/extensions/replay/sessionrecording.ts rename to src/entrypoints/session-recorder.ts index 030501aab..f8f2d0fc2 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/entrypoints/session-recorder.ts @@ -9,7 +9,7 @@ import { SESSION_RECORDING_SAMPLE_RATE, SESSION_RECORDING_SCRIPT_CONFIG, SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION, -} from '../../constants' +} from '../constants' import { estimateSize, INCREMENTAL_SNAPSHOT_EVENT_TYPE, @@ -17,8 +17,8 @@ import { rrwebRecord, splitBuffer, truncateLargeConsoleLogs, -} from './sessionrecording-utils' -import { PostHog } from '../../posthog-core' +} from '../extensions/replay/sessionrecording-utils' +import { PostHog } from '../posthog-core' import { CaptureResult, FlagVariant, @@ -26,8 +26,11 @@ import { NetworkRequest, Properties, RemoteConfig, + SessionRecordingStatus, SessionRecordingUrlTrigger, -} from '../../types' + SessionStartReason, + TriggerType, +} from '../types' import { customEvent, EventType, @@ -37,29 +40,25 @@ import { RecordPlugin, } from '@rrweb/types' -import { isBoolean, isFunction, isNullish, isNumber, isObject, isString, isUndefined } from '../../utils/type-utils' -import { createLogger } from '../../utils/logger' -import { assignableWindow, document, PostHogExtensionKind, window } from '../../utils/globals' -import { buildNetworkRequestOptions } from './config' -import { isLocalhost } from '../../utils/request-utils' -import { MutationRateLimiter } from './mutation-rate-limiter' +import { isBoolean, isFunction, isNullish, isNumber, isObject, isString, isUndefined } from '../utils/type-utils' +import { createLogger } from '../utils/logger' +import { + assignableWindow, + document, + LazyLoadedSessionRecordingInterface, + PostHogExtensionKind, + window, +} from '../utils/globals' +import { buildNetworkRequestOptions } from '../extensions/replay/config' +import { isLocalhost } from '../utils/request-utils' +import { MutationRateLimiter } from '../extensions/replay/mutation-rate-limiter' import { gzipSync, strFromU8, strToU8 } from 'fflate' -import { clampToRange } from '../../utils/number-utils' -import { includes } from '../../utils' +import { clampToRange } from '../utils/number-utils' +import { includes } from '../utils' const LOGGER_PREFIX = '[SessionRecording]' const logger = createLogger(LOGGER_PREFIX) -type SessionStartReason = - | 'sampling_overridden' - | 'recording_initialized' - | 'linked_flag_matched' - | 'linked_flag_overridden' - | 'sampled' - | 'session_id_changed' - | 'url_trigger_matched' - | 'event_trigger_matched' - const BASE_ENDPOINT = '/s/' const ONE_MINUTE = 1000 * 60 @@ -83,17 +82,8 @@ const ACTIVE_SOURCES = [ IncrementalSource.Drag, ] -export type TriggerType = 'url' | 'event' type TriggerStatus = 'trigger_activated' | 'trigger_pending' | 'trigger_disabled' -/** - * Session recording starts in buffering mode while waiting for decide response - * Once the response is received it might be disabled, active or sampled - * When sampled that means a sample rate is set and the last time the session id was rotated - * the sample rate determined this session should be sent to the server. - */ -type SessionRecordingStatus = 'disabled' | 'sampled' | 'active' | 'buffering' | 'paused' - export interface SnapshotBuffer { size: number data: any[] @@ -235,7 +225,7 @@ function isRecordingPausedEvent(e: eventWithTime) { return e.type === EventType.Custom && e.data.tag === 'recording paused' } -export class SessionRecording { +export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInterface { private _endpoint: string private flushBufferTimer?: any @@ -483,7 +473,7 @@ export class SessionRecording { } } - startIfEnabledOrStop(startReason?: SessionStartReason) { + start(startReason?: SessionStartReason) { if (this.isRecordingEnabled) { this._startCapture(startReason) @@ -532,11 +522,11 @@ export class SessionRecording { }) } } else { - this.stopRecording() + this.stop() } } - stopRecording() { + stop() { if (this._captureStarted && this.stopRrweb) { this.stopRrweb() this.stopRrweb = undefined @@ -656,7 +646,7 @@ export class SessionRecording { } this.receivedDecide = true - this.startIfEnabledOrStop() + this.start() } /** @@ -709,7 +699,7 @@ export class SessionRecording { } log(message: string, level: 'log' | 'warn' | 'error' = 'log') { - this.instance.sessionRecording?.onRRwebEmit({ + this.onRRwebEmit({ type: 6, data: { plugin: 'rrweb/console@1', @@ -847,8 +837,8 @@ export class SessionRecording { this.sessionId = sessionId if (sessionIdChanged || windowIdChanged) { - this.stopRecording() - this.startIfEnabledOrStop('session_id_changed') + this.stop() + this.start('session_id_changed') } else if (returningFromIdle) { this._scheduleFullSnapshot() } @@ -1351,8 +1341,13 @@ export class SessionRecording { $session_recording_start_reason: startReason, }) logger.info(startReason.replace('_', ' '), tagPayload) - if (!includes(['recording_initialized', 'session_id_changed'], startReason)) { + if (!includes(['session_id_changed'], startReason)) { this._tryAddCustomEvent(startReason, tagPayload) } } } + +assignableWindow.__PosthogExtensions__ = assignableWindow.__PosthogExtensions__ || {} +assignableWindow.__PosthogExtensions__.initSessionRecording = (ph) => new LazyLoadedSessionRecording(ph) + +export default LazyLoadedSessionRecording diff --git a/src/extensions/replay/session-recording-loader.ts b/src/extensions/replay/session-recording-loader.ts new file mode 100644 index 000000000..c57b22a49 --- /dev/null +++ b/src/extensions/replay/session-recording-loader.ts @@ -0,0 +1,75 @@ +import { assignableWindow, document, LazyLoadedSessionRecordingInterface, window } from '../../utils/globals' +import { PostHog } from '../../posthog-core' +import { RemoteConfig } from '../../types' +import { createLogger } from '../../utils/logger' +import { SESSION_RECORDING_ENABLED_SERVER_SIDE } from '../../constants' + +const logger = createLogger('[Session-Recording-Loader]') + +export const isSessionRecordingEnabled = (loader: SessionRecordingLoader) => { + const enabled_server_side = !!loader.instance.get_property(SESSION_RECORDING_ENABLED_SERVER_SIDE) + const enabled_client_side = !loader.instance.config.disable_session_recording + return !!window && enabled_server_side && enabled_client_side +} + +export class SessionRecordingLoader { + get lazyLoaded(): LazyLoadedSessionRecordingInterface | undefined { + return this._lazyLoadedSessionRecording + } + + private _lazyLoadedSessionRecording: LazyLoadedSessionRecordingInterface | undefined + + constructor(readonly instance: PostHog, readonly isEnabled: (srl: SessionRecordingLoader) => boolean) { + this.startIfEnabled() + } + + public onRemoteConfig(response: RemoteConfig) { + if (this.instance.persistence) { + this._lazyLoadedSessionRecording?.onRemoteConfig(response) + } + this.startIfEnabled() + } + + public startIfEnabled() { + if (this.isEnabled(this)) { + this.loadScript(() => { + this.start() + }) + } + } + + private loadScript(cb: () => void): void { + if (assignableWindow.__PosthogExtensions__?.initSessionRecording) { + // already loaded + cb() + } + assignableWindow.__PosthogExtensions__?.loadExternalDependency?.(this.instance, 'session-recording', (err) => { + if (err) { + logger.error('failed to load script', err) + return + } + cb() + }) + } + + private start() { + if (!document) { + logger.error('`document` not found. Cannot start.') + return + } + + if (!this._lazyLoadedSessionRecording && assignableWindow.__PosthogExtensions__?.initSessionRecording) { + this._lazyLoadedSessionRecording = assignableWindow.__PosthogExtensions__.initSessionRecording( + this.instance + ) + this._lazyLoadedSessionRecording.start() + } + } + + stop() { + if (this._lazyLoadedSessionRecording) { + this._lazyLoadedSessionRecording.stop() + this._lazyLoadedSessionRecording = undefined + } + } +} diff --git a/src/extensions/replay/sessionrecording-utils.ts b/src/extensions/replay/sessionrecording-utils.ts index 02270bdf3..cfdfd07f2 100644 --- a/src/extensions/replay/sessionrecording-utils.ts +++ b/src/extensions/replay/sessionrecording-utils.ts @@ -2,7 +2,7 @@ import type { eventWithTime, listenerHandler, pluginEvent } from '@rrweb/types' import type { record } from '@rrweb/record' import { isObject } from '../../utils/type-utils' -import { SnapshotBuffer } from './sessionrecording' +import { SnapshotBuffer } from '../../entrypoints/sessionrecording' // taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value#circular_references export function circularReferenceReplacer() { diff --git a/src/posthog-core.ts b/src/posthog-core.ts index a310f84e5..c6871407d 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -20,7 +20,6 @@ import { USER_STATE, ENABLE_PERSON_PROCESSING, } from './constants' -import { SessionRecording } from './extensions/replay/sessionrecording' import { Decide } from './decide' import { Toolbar } from './extensions/toolbar' import { localStore } from './storage' @@ -82,6 +81,7 @@ import { WebExperiments } from './web-experiments' import { PostHogExceptions } from './posthog-exceptions' import { SiteApps } from './site-apps' import { DeadClicksAutocapture, isDeadClicksEnabledForAutocapture } from './extensions/dead-clicks-autocapture' +import { isSessionRecordingEnabled, SessionRecordingLoader } from './extensions/replay/session-recording-loader' /* SIMPLE STYLE GUIDE: @@ -268,7 +268,7 @@ export class PostHog { _requestQueue?: RequestQueue _retryQueue?: RetryQueue - sessionRecording?: SessionRecording + sessionRecording?: SessionRecordingLoader webPerformance = new DeprecatedWebPerformanceObserver() _initialPageviewCaptured: boolean @@ -437,8 +437,8 @@ export class PostHog { this.siteApps = new SiteApps(this) this.siteApps?.init() - this.sessionRecording = new SessionRecording(this) - this.sessionRecording.startIfEnabledOrStop() + this.sessionRecording = new SessionRecordingLoader(this, isSessionRecordingEnabled) + this.sessionRecording.startIfEnabled() if (!this.config.disable_scroll_properties) { this.scrollManager.startMeasuringScrollPosition() @@ -937,7 +937,7 @@ export class PostHog { } if (this.sessionRecording) { - properties['$recording_status'] = this.sessionRecording.status + properties['$recording_status'] = this.sessionRecording.lazyLoaded?.status } if (this.requestRouter.region === RequestRouterRegion.CUSTOM) { @@ -1817,7 +1817,7 @@ export class PostHog { }) } - this.sessionRecording?.startIfEnabledOrStop() + this.sessionRecording?.startIfEnabled() this.autocapture?.startIfEnabled() this.heatmaps?.startIfEnabled() this.surveys.loadIfEnabled() @@ -1849,19 +1849,19 @@ export class PostHog { this.sessionManager?.checkAndGetSessionAndWindowId() if (overrideConfig.sampling) { - this.sessionRecording?.overrideSampling() + this.sessionRecording?.lazyLoaded?.overrideSampling() } if (overrideConfig.linked_flag) { - this.sessionRecording?.overrideLinkedFlag() + this.sessionRecording?.lazyLoaded?.overrideLinkedFlag() } if (overrideConfig.url_trigger) { - this.sessionRecording?.overrideTrigger('url') + this.sessionRecording?.lazyLoaded?.overrideTrigger('url') } if (overrideConfig.event_trigger) { - this.sessionRecording?.overrideTrigger('event') + this.sessionRecording?.lazyLoaded?.overrideTrigger('event') } } @@ -1881,7 +1881,7 @@ export class PostHog { * is currently running */ sessionRecordingStarted(): boolean { - return !!this.sessionRecording?.started + return !!this.sessionRecording?.lazyLoaded?.started } /** Capture a caught exception manually */ diff --git a/src/types.ts b/src/types.ts index e821a2a8b..889067dfc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -423,6 +423,26 @@ export type SessionIdChangedCallback = ( changeReason?: { noSessionId: boolean; activityTimeout: boolean; sessionPastMaximumLength: boolean } ) => void +export type SessionStartReason = + | 'recording_initialized' + | 'sampling_overridden' + | 'linked_flag_matched' + | 'linked_flag_overridden' + | 'sampled' + | 'session_id_changed' + | 'url_trigger_matched' + | 'event_trigger_matched' + +/** + * Session recording starts in buffering mode while waiting for decide response + * Once the response is received it might be disabled, active or sampled + * When sampled that means a sample rate is set and the last time the session id was rotated + * the sample rate determined this session should be sent to the server. + */ +export type SessionRecordingStatus = 'disabled' | 'sampled' | 'active' | 'buffering' | 'paused' + +export type TriggerType = 'url' | 'event' + export enum Compression { GZipJS = 'gzip-js', Base64 = 'base64', diff --git a/src/utils/globals.ts b/src/utils/globals.ts index 23ca7a1ba..9c3a1934a 100644 --- a/src/utils/globals.ts +++ b/src/utils/globals.ts @@ -1,7 +1,16 @@ import { ErrorProperties } from '../extensions/exception-autocapture/error-conversion' import type { PostHog } from '../posthog-core' import { SessionIdManager } from '../sessionid' -import { DeadClicksAutoCaptureConfig, ErrorEventArgs, ErrorMetadata, Properties, RemoteConfig } from '../types' +import { + DeadClicksAutoCaptureConfig, + ErrorEventArgs, + ErrorMetadata, + Properties, + RemoteConfig, + SessionRecordingStatus, + SessionStartReason, + TriggerType, +} from '../types' /* * Global helpers to protect access to browser globals in a way that is safer for different targets @@ -33,12 +42,24 @@ export type PostHogExtensionKind = | 'toolbar' | 'exception-autocapture' | 'web-vitals' + | 'session-recording' | 'recorder' | 'tracing-headers' | 'surveys' | 'dead-clicks-autocapture' | 'remote-config' +export interface LazyLoadedSessionRecordingInterface { + start: (startReason?: SessionStartReason) => void + stop: () => void + onRemoteConfig(response: RemoteConfig): void + get status(): SessionRecordingStatus + overrideTrigger: (triggerType: TriggerType) => void + overrideSampling: () => void + overrideLinkedFlag: () => void + get started(): boolean +} + export interface LazyLoadedDeadClicksAutocaptureInterface { start: (observerTarget: Node) => void stop: () => void @@ -79,6 +100,7 @@ interface PostHogExtensions { ph: PostHog, config: DeadClicksAutoCaptureConfig ) => LazyLoadedDeadClicksAutocaptureInterface + initSessionRecording?: (ph: PostHog) => LazyLoadedSessionRecordingInterface } const global: typeof globalThis | undefined = typeof globalThis !== 'undefined' ? globalThis : win