Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: lazy load session recording #1588

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 82 additions & 81 deletions src/__tests__/extensions/replay/sessionrecording.test.ts
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to add tests on the loader separately from the loaded file

Large diffs are not rendered by default.

34 changes: 27 additions & 7 deletions src/__tests__/posthog-core.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { mockLogger } from './helpers/mock-logger'

import { Info } from '../utils/event-utils'
import { document, window } from '../utils/globals'
import { uuidv7 } from '../uuidv7'
import * as globals from '../utils/globals'
import { assignableWindow, document, window } from '../utils/globals'
import { uuidv7 } from '../uuidv7'
import { ENABLE_PERSON_PROCESSING, USER_STATE } from '../constants'
import { createPosthogInstance, defaultPostHog } from './helpers/posthog-instance'
import { PostHogConfig, RemoteConfig } from '../types'
import { PostHog } from '../posthog-core'
import { OnlyValidKeys, PostHog } from '../posthog-core'
import { PostHogPersistence } from '../posthog-persistence'
import { SessionIdManager } from '../sessionid'
import { RequestQueue } from '../request-queue'
import { SessionRecording } from '../extensions/replay/sessionrecording'
import { PostHogFeatureFlags } from '../posthog-featureflags'
import { SessionRecordingLoader } from '../extensions/replay/session-recording-loader'

describe('posthog core', () => {
const baseUTCDateTime = new Date(Date.UTC(2020, 0, 1, 0, 0, 0))
Expand All @@ -31,6 +31,23 @@ describe('posthog core', () => {

beforeEach(() => {
jest.useFakeTimers().setSystemTime(baseUTCDateTime)

assignableWindow.__PosthogExtensions__ = assignableWindow.__PosthogExtensions__ || {}
assignableWindow.__PosthogExtensions__.initSessionRecording = () => ({
start: jest.fn(),
stop: jest.fn(),
onRemoteConfig: jest.fn(),
status: 'buffering',
started: true,
overrideLinkedFlag: jest.fn(),
overrideSampling: jest.fn(),
overrideTrigger: jest.fn(),
})
assignableWindow.__PosthogExtensions__.loadExternalDependency = jest
.fn()
.mockImplementation(() => (_ph: PostHog, _name: string, cb: (err?: Error) => void) => {
cb()
})
})

afterEach(() => {
Expand Down Expand Up @@ -419,6 +436,7 @@ describe('posthog core', () => {
},
overrides
)
posthog.sessionRecording = new SessionRecordingLoader(posthog, () => true)
})

it('returns calculated properties', () => {
Expand Down Expand Up @@ -455,7 +473,6 @@ describe('posthog core', () => {
$lib_custom_api_host: 'https://custom.posthog.com',
$is_identified: false,
$process_person_profile: false,
$recording_status: 'buffering',
})
})

Expand Down Expand Up @@ -825,7 +842,10 @@ describe('posthog core', () => {
posthog.sessionRecording = {
afterDecideResponse: jest.fn(),
startIfEnabledOrStop: jest.fn(),
} as unknown as SessionRecording
} as OnlyValidKeys<
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting... i expected this change to highlight the incorrect setup here that the as unknown is hiding

Partial<SessionRecordingLoader>,
Partial<SessionRecordingLoader>
> as SessionRecordingLoader
posthog.persistence = {
register: jest.fn(),
update_config: jest.fn(),
Expand Down Expand Up @@ -1136,7 +1156,7 @@ describe('posthog core', () => {
instance._send_request = jest.fn()
instance._loaded()

expect(instance._send_request.mock.calls[0][0]).toMatchObject({
expect(jest.mocked(instance._send_request).mock.calls[0][0]).toMatchObject({
url: 'http://localhost/decide/?v=3',
})
expect(instance.featureFlags.setReloadingPaused).toHaveBeenCalledWith(true)
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/sessionid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ describe('Session ID manager', () => {
disabled: false,
}
;(sessionStore.is_supported as jest.Mock).mockReturnValue(true)
// @ts-expect-error - typescript gets confused about type of Date here
jest.spyOn(global, 'Date').mockImplementation(() => new originalDate(now))
;(uuidv7 as jest.Mock).mockReturnValue('newUUID')
;(uuid7ToTimestampMs as jest.Mock).mockReturnValue(timestamp)
Expand Down
12 changes: 9 additions & 3 deletions src/__tests__/site-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mockLogger } from './helpers/mock-logger'
import { SiteApps } from '../site-apps'
import { PostHogPersistence } from '../posthog-persistence'
import { RequestRouter } from '../utils/request-router'
import { PostHog } from '../posthog-core'
import { OnlyValidKeys, PostHog } from '../posthog-core'
import { DecideResponse, PostHogConfig, Properties, CaptureResult } from '../types'
import { assignableWindow } from '../utils/globals'
import '../entrypoints/external-scripts-loader'
Expand Down Expand Up @@ -131,7 +131,10 @@ describe('SiteApps', () => {
siteAppsInstance.enabled = true
siteAppsInstance.loaded = false

const eventPayload = { event: 'test_event', properties: { prop1: 'value1' } } as CaptureResult
const eventPayload = { event: 'test_event', properties: { prop1: 'value1' } } as OnlyValidKeys<
Partial<CaptureResult>,
Partial<CaptureResult>
> as CaptureResult

jest.spyOn(siteAppsInstance, 'globalsForEvent').mockReturnValue({ some: 'globals' })

Expand All @@ -147,7 +150,10 @@ describe('SiteApps', () => {

siteAppsInstance.missedInvocations = new Array(1000).fill({})

const eventPayload = { event: 'test_event', properties: { prop1: 'value1' } } as CaptureResult
const eventPayload = { event: 'test_event', properties: { prop1: 'value1' } } as OnlyValidKeys<
Partial<CaptureResult>,
Partial<CaptureResult>
> as CaptureResult

jest.spyOn(siteAppsInstance, 'globalsForEvent').mockReturnValue({ some: 'globals' })

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,29 @@ 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,
recordOptions,
rrwebRecord,
splitBuffer,
truncateLargeConsoleLogs,
} from './sessionrecording-utils'
import { PostHog } from '../../posthog-core'
} from '../extensions/replay/sessionrecording-utils'
import { PostHog } from '../posthog-core'
import {
CaptureResult,
FlagVariant,
NetworkRecordOptions,
NetworkRequest,
Properties,
RemoteConfig,
SessionRecordingStatus,
SessionRecordingUrlTrigger,
} from '../../types'
SessionStartReason,
SnapshotBuffer,
TriggerType,
} from '../types'
import {
customEvent,
EventType,
Expand All @@ -37,29 +41,25 @@ import {
RecordPlugin,
} from '@rrweb/types'

import { isBoolean, isFunction, isNullish, isNumber, isObject, isString, isUndefined } from '../../utils/type-utils'
import { createLogger } from '../../utils/logger'
import { assignableWindow, document, PostHogExtensionKind, window } from '../../utils/globals'
import { buildNetworkRequestOptions } from './config'
import { isLocalhost } from '../../utils/request-utils'
import { MutationRateLimiter } from './mutation-rate-limiter'
import { isBoolean, isFunction, isNullish, isNumber, isObject, isString, isUndefined } from '../utils/type-utils'
import { createLogger } from '../utils/logger'
import {
assignableWindow,
document,
LazyLoadedSessionRecordingInterface,
PostHogExtensionKind,
window,
} from '../utils/globals'
import { buildNetworkRequestOptions } from '../extensions/replay/config'
import { isLocalhost } from '../utils/request-utils'
import { MutationRateLimiter } from '../extensions/replay/mutation-rate-limiter'
import { gzipSync, strFromU8, strToU8 } from 'fflate'
import { clampToRange } from '../../utils/number-utils'
import { includes } from '../../utils'
import { clampToRange } from '../utils/number-utils'
import { includes } from '../utils'

const LOGGER_PREFIX = '[SessionRecording]'
const logger = createLogger(LOGGER_PREFIX)

type SessionStartReason =
| 'sampling_overridden'
| 'recording_initialized'
| 'linked_flag_matched'
| 'linked_flag_overridden'
| 'sampled'
| 'session_id_changed'
| 'url_trigger_matched'
| 'event_trigger_matched'

const BASE_ENDPOINT = '/s/'

const ONE_MINUTE = 1000 * 60
Expand All @@ -83,24 +83,8 @@ const ACTIVE_SOURCES = [
IncrementalSource.Drag,
]

export type TriggerType = 'url' | 'event'
type TriggerStatus = 'trigger_activated' | 'trigger_pending' | 'trigger_disabled'

/**
* Session recording starts in buffering mode while waiting for decide response
* Once the response is received it might be disabled, active or sampled
* When sampled that means a sample rate is set and the last time the session id was rotated
* the sample rate determined this session should be sent to the server.
*/
type SessionRecordingStatus = 'disabled' | 'sampled' | 'active' | 'buffering' | 'paused'

export interface SnapshotBuffer {
size: number
data: any[]
sessionId: string
windowId: string
}

interface QueuedRRWebEvent {
rrwebMethod: () => void
attempt: number
Expand Down Expand Up @@ -235,7 +219,7 @@ function isRecordingPausedEvent(e: eventWithTime) {
return e.type === EventType.Custom && e.data.tag === 'recording paused'
}

export class SessionRecording {
export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInterface {
private _endpoint: string
private flushBufferTimer?: any

Expand Down Expand Up @@ -483,7 +467,7 @@ export class SessionRecording {
}
}

startIfEnabledOrStop(startReason?: SessionStartReason) {
start(startReason?: SessionStartReason) {
if (this.isRecordingEnabled) {
this._startCapture(startReason)

Expand Down Expand Up @@ -532,11 +516,11 @@ export class SessionRecording {
})
}
} else {
this.stopRecording()
this.stop()
}
}

stopRecording() {
stop() {
if (this._captureStarted && this.stopRrweb) {
this.stopRrweb()
this.stopRrweb = undefined
Expand Down Expand Up @@ -656,7 +640,7 @@ export class SessionRecording {
}

this.receivedDecide = true
this.startIfEnabledOrStop()
this.start()
}

/**
Expand Down Expand Up @@ -709,7 +693,7 @@ export class SessionRecording {
}

log(message: string, level: 'log' | 'warn' | 'error' = 'log') {
this.instance.sessionRecording?.onRRwebEmit({
this.onRRwebEmit({
type: 6,
data: {
plugin: 'rrweb/console@1',
Expand Down Expand Up @@ -847,8 +831,8 @@ export class SessionRecording {
this.sessionId = sessionId

if (sessionIdChanged || windowIdChanged) {
this.stopRecording()
this.startIfEnabledOrStop('session_id_changed')
this.stop()
this.start('session_id_changed')
} else if (returningFromIdle) {
this._scheduleFullSnapshot()
}
Expand Down Expand Up @@ -1351,8 +1335,13 @@ export class SessionRecording {
$session_recording_start_reason: startReason,
})
logger.info(startReason.replace('_', ' '), tagPayload)
if (!includes(['recording_initialized', 'session_id_changed'], startReason)) {
if (!includes(['session_id_changed'], startReason)) {
this._tryAddCustomEvent(startReason, tagPayload)
}
}
}

assignableWindow.__PosthogExtensions__ = assignableWindow.__PosthogExtensions__ || {}
assignableWindow.__PosthogExtensions__.initSessionRecording = (ph) => new LazyLoadedSessionRecording(ph)

export default LazyLoadedSessionRecording
89 changes: 89 additions & 0 deletions src/extensions/replay/session-recording-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { assignableWindow, document, LazyLoadedSessionRecordingInterface, window } from '../../utils/globals'
import { PostHog } from '../../posthog-core'
import { RemoteConfig } from '../../types'
import { createLogger } from '../../utils/logger'
import { SESSION_RECORDING_ENABLED_SERVER_SIDE } from '../../constants'
import { isBoolean, isUndefined } from '../../utils/type-utils'

const logger = createLogger('[Session-Recording-Loader]')

export const isSessionRecordingEnabled = (loader: SessionRecordingLoader) => {
const enabled_server_side = !!loader.instance.get_property(SESSION_RECORDING_ENABLED_SERVER_SIDE)
const enabled_client_side = !loader.instance.config.disable_session_recording
return !!window && enabled_server_side && enabled_client_side
}

export class SessionRecordingLoader {
_forceAllowLocalhostNetworkCapture = false

get lazyLoaded(): LazyLoadedSessionRecordingInterface | undefined {
return this._lazyLoadedSessionRecording
}

private _lazyLoadedSessionRecording: LazyLoadedSessionRecordingInterface | undefined

constructor(readonly instance: PostHog, readonly isEnabled: (srl: SessionRecordingLoader) => boolean) {
this.startIfEnabled()
}

public onRemoteConfig(response: RemoteConfig) {
if (this.instance.persistence) {
this._lazyLoadedSessionRecording?.onRemoteConfig(response)
}
this.startIfEnabled()
}

public startIfEnabled() {
if (this.isEnabled(this)) {
this.loadScript(() => {
this.start()
})
}
}

private loadScript(cb: () => void): void {
if (assignableWindow.__PosthogExtensions__?.initSessionRecording) {
// already loaded
cb()
}
assignableWindow.__PosthogExtensions__?.loadExternalDependency?.(this.instance, 'session-recorder', (err) => {
if (err) {
logger.error('failed to load script', err)
return
}
cb()
})
}

private start() {
if (!document) {
logger.error('`document` not found. Cannot start.')
return
}

if (!this._lazyLoadedSessionRecording && assignableWindow.__PosthogExtensions__?.initSessionRecording) {
if (
isUndefined(this.instance.config.session_recording._forceAllowLocalhostNetworkCapture) &&
isBoolean(this._forceAllowLocalhostNetworkCapture)
) {
logger.warn(
'`_forceAllowLocalhostNetworkCapture` has moved to `session_recording` config. Copying your setting over.'
)
this.instance.config.session_recording._forceAllowLocalhostNetworkCapture =
this._forceAllowLocalhostNetworkCapture
}

this._lazyLoadedSessionRecording = assignableWindow.__PosthogExtensions__.initSessionRecording(
this.instance
)
this._lazyLoadedSessionRecording.start()
}
}

stop() {
if (this._lazyLoadedSessionRecording) {
this._lazyLoadedSessionRecording.stop()
this._lazyLoadedSessionRecording = undefined
}
}
}
Loading
Loading