diff --git a/cypress/e2e/session-recording.cy.ts b/cypress/e2e/session-recording.cy.ts index f247b383d..5eb87b162 100644 --- a/cypress/e2e/session-recording.cy.ts +++ b/cypress/e2e/session-recording.cy.ts @@ -654,7 +654,7 @@ describe('Session recording', () => { cy.posthog().invoke('capture', 'test_registered_property') cy.phCaptures({ full: true }).then((captures) => { expect((captures || []).map((c) => c.event)).to.deep.equal(['$pageview', 'test_registered_property']) - expect(captures[1]['properties']['$session_recording_start_reason']).to.equal('sampling_override') + expect(captures[1]['properties']['$session_recording_start_reason']).to.equal('sampling_overridden') }) cy.resetPhCaptures() diff --git a/src/__tests__/extensions/replay/sessionrecording.test.ts b/src/__tests__/extensions/replay/sessionrecording.test.ts index 3b4cadfbf..53199fbf8 100644 --- a/src/__tests__/extensions/replay/sessionrecording.test.ts +++ b/src/__tests__/extensions/replay/sessionrecording.test.ts @@ -48,6 +48,7 @@ import { import Mock = jest.Mock import { ConsentManager } from '../../../consent' import { waitFor } from '@testing-library/preact' +import { SimpleEventEmitter } from '../../../utils/simple-event-emitter' // Type and source defined here designate a non-user-generated recording event @@ -185,6 +186,7 @@ describe('SessionRecording', () => { let onFeatureFlagsCallback: ((flags: string[], variants: Record) => void) | null let removeCaptureHookMock: Mock let addCaptureHookMock: Mock + let simpleEventEmitter: SimpleEventEmitter const addRRwebToWindow = () => { assignableWindow.__PosthogExtensions__.rrweb = { @@ -239,6 +241,8 @@ describe('SessionRecording', () => { removeCaptureHookMock = jest.fn() addCaptureHookMock = jest.fn().mockImplementation(() => removeCaptureHookMock) + 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] @@ -261,6 +265,10 @@ describe('SessionRecording', () => { }, } as unknown as ConsentManager, register_for_session() {}, + _internalEventEmitter: simpleEventEmitter, + on: (event, cb) => { + return simpleEventEmitter.on(event, cb) + }, } as Partial as PostHog loadScriptMock.mockImplementation((_ph, _path, callback) => { @@ -1883,6 +1891,7 @@ describe('SessionRecording', () => { loadScriptMock.mockImplementation((_ph, _path, callback) => { callback() }) + sessionRecording = new SessionRecording(posthog) sessionRecording.afterDecideResponse(makeDecideResponse({ sessionRecording: { endpoint: '/s/' } })) sessionRecording.startIfEnabledOrStop() @@ -2250,4 +2259,67 @@ describe('SessionRecording', () => { ]) }) }) + + describe('Event triggering', () => { + beforeEach(() => { + sessionRecording.startIfEnabledOrStop() + }) + + it('flushes buffer and starts when sees event', async () => { + sessionRecording.afterDecideResponse( + makeDecideResponse({ + 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', async () => { + sessionRecording.afterDecideResponse( + makeDecideResponse({ + sessionRecording: { + endpoint: '/s/', + eventTriggers: ['$exception'], + urlTriggers: [{ url: 'start-on-me', matching: 'regex' }], + }, + }) + ) + + 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') + }) + }) }) diff --git a/src/constants.ts b/src/constants.ts index f86c52ee4..f058ffce5 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -27,6 +27,8 @@ export const SESSION_ID = '$sesid' export const SESSION_RECORDING_IS_SAMPLED = '$session_is_sampled' export const SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION = '$session_recording_url_trigger_activated_session' export const SESSION_RECORDING_URL_TRIGGER_STATUS = '$session_recording_url_trigger_status' +export const SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION = '$session_recording_event_trigger_activated_session' +export const SESSION_RECORDING_EVENT_TRIGGER_STATUS = '$session_recording_event_trigger_status' export const ENABLED_FEATURE_FLAGS = '$enabled_feature_flags' export const PERSISTENCE_EARLY_ACCESS_FEATURES = '$early_access_features' export const STORED_PERSON_PROPERTIES_KEY = '$stored_person_properties' diff --git a/src/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts index f4354d1eb..55c154494 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -2,12 +2,12 @@ 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_MINIMUM_DURATION, SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE, SESSION_RECORDING_SAMPLE_RATE, SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION, - SESSION_RECORDING_URL_TRIGGER_STATUS, } from '../../constants' import { estimateSize, @@ -19,6 +19,7 @@ import { } from './sessionrecording-utils' import { PostHog } from '../../posthog-core' import { + CaptureResult, DecideResponse, FlagVariant, NetworkRecordOptions, @@ -43,14 +44,17 @@ import { isLocalhost } from '../../utils/request-utils' import { MutationRateLimiter } from './mutation-rate-limiter' import { gzipSync, strFromU8, strToU8 } from 'fflate' import { clampToRange } from '../../utils/number-utils' +import { includes } from '../../utils' type SessionStartReason = - | 'sampling_override' + | 'sampling_overridden' | 'recording_initialized' - | 'linked_flag_match' - | 'linked_flag_override' - | 'sampling' + | 'linked_flag_matched' + | 'linked_flag_overridden' + | 'sampled' | 'session_id_changed' + | 'url_trigger_matched' + | 'event_trigger_matched' const BASE_ENDPOINT = '/s/' @@ -75,8 +79,8 @@ const ACTIVE_SOURCES = [ IncrementalSource.Drag, ] -const TRIGGER_STATUSES = ['trigger_activated', 'trigger_pending', 'trigger_disabled'] as const -type TriggerStatus = typeof TRIGGER_STATUSES[number] +export type TriggerType = 'url' | 'event' +type TriggerStatus = 'trigger_activated' | 'trigger_pending' | 'trigger_disabled' /** * Session recording starts in buffering mode while waiting for decide response @@ -266,6 +270,9 @@ export class SessionRecording { private _urlBlocked: boolean = false + private _eventTriggers: string[] = [] + private _removeEventTriggerCaptureHook: (() => void) | undefined = undefined + // Util to help developers working on this feature manually override _forceAllowLocalhostNetworkCapture = false @@ -291,7 +298,7 @@ export class SessionRecording { } private get fullSnapshotIntervalMillis(): number { - if (this.urlTriggerStatus === 'trigger_pending') { + if (this.triggerStatus === 'trigger_pending') { return ONE_MINUTE } @@ -389,7 +396,7 @@ export class SessionRecording { return 'buffering' } - if (this.urlTriggerStatus === 'trigger_pending') { + if (this.triggerStatus === 'trigger_pending') { return 'buffering' } @@ -409,27 +416,31 @@ export class SessionRecording { return 'trigger_disabled' } - const currentStatus = this.instance?.get_property(SESSION_RECORDING_URL_TRIGGER_STATUS) const currentTriggerSession = this.instance?.get_property(SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION) + return currentTriggerSession === this.sessionId ? 'trigger_activated' : 'trigger_pending' + } - if (currentTriggerSession !== this.sessionId) { - this.instance?.persistence?.unregister(SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION) - this.instance?.persistence?.unregister(SESSION_RECORDING_URL_TRIGGER_STATUS) - return 'trigger_pending' - } - - if (TRIGGER_STATUSES.includes(currentStatus)) { - return currentStatus as TriggerStatus + private get eventTriggerStatus(): TriggerStatus { + if (this._eventTriggers.length === 0) { + return 'trigger_disabled' } - return 'trigger_pending' + const currentTriggerSession = this.instance?.get_property(SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION) + return currentTriggerSession === this.sessionId ? 'trigger_activated' : 'trigger_pending' } - private set urlTriggerStatus(status: TriggerStatus) { - this.instance?.persistence?.register({ - [SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION]: this.sessionId, - [SESSION_RECORDING_URL_TRIGGER_STATUS]: status, - }) + /** + * Any one trigger can activate the session + * So, if they are all the same - return that value + * If either is disabled return the other's valu + * @private + */ + private get triggerStatus(): TriggerStatus { + const eitherIsActivated = + this.eventTriggerStatus === 'trigger_activated' || this.urlTriggerStatus === 'trigger_activated' + const eitherIsPending = + this.eventTriggerStatus === 'trigger_pending' || this.urlTriggerStatus === 'trigger_pending' + return eitherIsActivated ? 'trigger_activated' : eitherIsPending ? 'trigger_pending' : 'trigger_disabled' } constructor(private readonly instance: PostHog) { @@ -491,6 +502,8 @@ export class SessionRecording { // so we call this here _and_ in the decide 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). @@ -516,8 +529,8 @@ export class SessionRecording { 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) - this.instance?.persistence?.unregister(SESSION_RECORDING_URL_TRIGGER_STATUS) } }) } @@ -542,6 +555,8 @@ export class SessionRecording { this._removePageViewCaptureHook?.() this._removePageViewCaptureHook = undefined + this._removeEventTriggerCaptureHook?.() + this._removeEventTriggerCaptureHook = undefined this._onSessionIdListener?.() this._onSessionIdListener = undefined this._samplingSessionListener?.() @@ -586,7 +601,7 @@ export class SessionRecording { if (makeDecision) { if (shouldSample) { - this._reportStarted('sampling') + this._reportStarted('sampled') } else { logger.warn( LOGGER_PREFIX + @@ -623,14 +638,10 @@ export class SessionRecording { const flagIsPresent = isObject(variants) && linkedFlag in variants const linkedFlagMatches = linkedVariant ? variants[linkedFlag] === linkedVariant : flagIsPresent if (linkedFlagMatches) { - const payload = { + this._reportStarted('linked_flag_matched', { linkedFlag, linkedVariant, - } - const tag = 'linked flag matched' - logger.info(LOGGER_PREFIX + ' ' + tag, payload) - this._tryAddCustomEvent(tag, payload) - this._reportStarted('linked_flag_match') + }) } this._linkedFlagSeen = linkedFlagMatches }) @@ -644,6 +655,10 @@ export class SessionRecording { this._urlBlocklist = response.sessionRecording.urlBlocklist } + if (response.sessionRecording?.eventTriggers) { + this._eventTriggers = response.sessionRecording.eventTriggers + } + this.receivedDecide = true this.startIfEnabledOrStop() } @@ -1012,7 +1027,7 @@ export class SessionRecording { } // Check if the URL matches any trigger patterns - this._checkTriggerConditions() + this._checkUrlTriggerConditions() if (this.status === 'paused' && !isRecordingPausedEvent(rawEvent)) { return @@ -1024,7 +1039,7 @@ export class SessionRecording { } // Clear the buffer if waiting for a trigger, and only keep data from after the current full snapshot - if (rawEvent.type === EventType.FullSnapshot && this.urlTriggerStatus === 'trigger_pending') { + if (rawEvent.type === EventType.FullSnapshot && this.triggerStatus === 'trigger_pending') { this.clearBuffer() } @@ -1205,7 +1220,7 @@ export class SessionRecording { }) } - private _checkTriggerConditions() { + private _checkUrlTriggerConditions() { if (typeof window === 'undefined' || !window.location.href) { return } @@ -1222,16 +1237,21 @@ export class SessionRecording { } if (sessionRecordingUrlTriggerMatches(url, this._urlTriggers)) { - this._activateUrlTrigger() + this._activateTrigger('url') } } - private _activateUrlTrigger() { - if (this.urlTriggerStatus === 'trigger_pending') { - this.urlTriggerStatus = 'trigger_activated' - this._tryAddCustomEvent('url trigger activated', {}) + private _activateTrigger(triggerType: TriggerType) { + if (this.triggerStatus === '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() - logger.info(LOGGER_PREFIX + ' recording triggered by URL pattern match') + this._reportStarted((triggerType + '_trigger_matched') as SessionStartReason) } } @@ -1239,9 +1259,6 @@ export class SessionRecording { if (this.status === 'paused') { return } - logger.info(LOGGER_PREFIX + ' recording paused due to URL blocker') - - this._tryAddCustomEvent('recording paused', { reason: 'url blocker' }) this._urlBlocked = true document?.body?.classList?.add('ph-no-capture') @@ -1253,6 +1270,9 @@ export class SessionRecording { setTimeout(() => { this._flushBuffer() }, 100) + + logger.info(LOGGER_PREFIX + ' recording paused due to URL blocker') + this._tryAddCustomEvent('recording paused', { reason: 'url blocker' }) } private _resumeRecording() { @@ -1264,27 +1284,43 @@ export class SessionRecording { document?.body?.classList?.remove('ph-no-capture') this._tryTakeFullSnapshot() - this._scheduleFullSnapshot() + this._tryAddCustomEvent('recording resumed', { reason: 'left blocked url' }) logger.info(LOGGER_PREFIX + ' recording resumed') } + private _addEventTriggerListener() { + if (this._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._eventTriggers.includes(event.event)) { + this._activateTrigger('event') + } + } catch (e) { + logger.error(LOGGER_PREFIX + 'Could not activate event trigger', e) + } + }) + } + /** - * this ignores the linked flag config and causes capture to start - * (if recording would have started had the flag been received i.e. it does not override other config). + * 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._linkedFlagSeen = true - this._reportStarted('linked_flag_override') + this._reportStarted('linked_flag_overridden') } /** - * this ignores the sampling config and causes capture to start - * (if recording would have started had the flag been received i.e. it does not override other config). + * 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})` @@ -1294,14 +1330,26 @@ export class SessionRecording { // short-circuits the `makeSamplingDecision` function in the session recording module [SESSION_RECORDING_IS_SAMPLED]: true, }) - this._reportStarted('sampling_override') + this._reportStarted('sampling_overridden') } - private _reportStarted(startReason: SessionStartReason, shouldReport: () => boolean = () => true) { - if (shouldReport()) { - this.instance.register_for_session({ - $session_recording_start_reason: startReason, - }) + /** + * 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(LOGGER_PREFIX + ' ' + startReason.replace('_', ' '), tagPayload) + if (!includes(['recording_initialized', 'session_id_changed'], startReason)) { + this._tryAddCustomEvent(startReason, tagPayload) } } } diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 457a41159..2a87ba0f7 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -54,7 +54,6 @@ import { uuidv7 } from './uuidv7' import { Survey, SurveyCallback, SurveyQuestionBranchingType } from './posthog-surveys-types' import { isArray, - isBoolean, isEmptyObject, isEmptyString, isFunction, @@ -1814,22 +1813,42 @@ export class PostHog { * turns session recording on, and updates the config option `disable_session_recording` to false * @param override.sampling - optional boolean to override the default sampling behavior - ensures the next session recording to start will not be skipped by sampling config. * @param override.linked_flag - optional boolean to override the default linked_flag behavior - ensures the next session recording to start will not be skipped by linked_flag config. + * @param override.url_trigger - optional boolean to override the default url_trigger behavior - ensures the next session recording to start will not be skipped by url_trigger config. + * @param override.event_trigger - optional boolean to override the default event_trigger behavior - ensures the next session recording to start will not be skipped by event_trigger config. * @param override - optional boolean to override the default sampling behavior - ensures the next session recording to start will not be skipped by sampling or linked_flag config. `true` is shorthand for { sampling: true, linked_flag: true } */ - startSessionRecording(override?: { sampling?: boolean; linked_flag?: boolean } | true): void { - const overrideAll = isBoolean(override) && override - if (overrideAll || override?.sampling || override?.linked_flag) { + startSessionRecording( + override?: { sampling?: boolean; linked_flag?: boolean; url_trigger?: true; event_trigger?: true } | true + ): void { + const overrideAll = override === true + const overrideConfig = { + sampling: overrideAll || !!override?.sampling, + linked_flag: overrideAll || !!override?.linked_flag, + url_trigger: overrideAll || !!override?.url_trigger, + event_trigger: overrideAll || !!override?.event_trigger, + } + + if (Object.values(overrideConfig).some(Boolean)) { // allow the session id check to rotate session id if necessary - const ids = this.sessionManager?.checkAndGetSessionAndWindowId() - if (overrideAll || override?.sampling) { + this.sessionManager?.checkAndGetSessionAndWindowId() + + if (overrideConfig.sampling) { this.sessionRecording?.overrideSampling() - logger.info('Session recording started with sampling override for session: ', ids?.sessionId) } - if (overrideAll || override?.linked_flag) { + + if (overrideConfig.linked_flag) { this.sessionRecording?.overrideLinkedFlag() - logger.info('Session recording started with linked_flags override') + } + + if (overrideConfig.url_trigger) { + this.sessionRecording?.overrideTrigger('url') + } + + if (overrideConfig.event_trigger) { + this.sessionRecording?.overrideTrigger('event') } } + this.set_config({ disable_session_recording: false }) } diff --git a/src/types.ts b/src/types.ts index d54c161c1..98695a8b6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -452,6 +452,7 @@ export interface DecideResponse { networkPayloadCapture?: Pick urlTriggers?: SessionRecordingUrlTrigger[] urlBlocklist?: SessionRecordingUrlTrigger[] + eventTriggers?: string[] } surveys?: boolean toolbarParams: ToolbarParams @@ -670,7 +671,6 @@ export interface ErrorConversions { } export interface SessionRecordingUrlTrigger { - urlBlockList?: SessionRecordingUrlTrigger[] url: string matching: 'regex' }