diff --git a/src/constants.ts b/src/constants.ts index fc3154879..ac85e6936 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -24,6 +24,8 @@ export const SESSION_RECORDING_SAMPLE_RATE = '$replay_sample_rate' export const SESSION_RECORDING_MINIMUM_DURATION = '$replay_minimum_duration' 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 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 0f0f65bf1..95420aa5f 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -6,6 +6,8 @@ import { 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, @@ -16,7 +18,14 @@ import { truncateLargeConsoleLogs, } from './sessionrecording-utils' import { PostHog } from '../../posthog-core' -import { DecideResponse, FlagVariant, NetworkRecordOptions, NetworkRequest, Properties } from '../../types' +import { + DecideResponse, + FlagVariant, + NetworkRecordOptions, + NetworkRequest, + Properties, + SessionRecordingUrlTrigger, +} from '../../types' import { customEvent, EventType, @@ -44,7 +53,8 @@ type SessionStartReason = const BASE_ENDPOINT = '/s/' -const FIVE_MINUTES = 1000 * 60 * 5 +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 @@ -68,6 +78,9 @@ const ACTIVE_SOURCES = [ IncrementalSource.Drag, ] +const TRIGGER_STATUSES = ['trigger_activated', 'trigger_pending', 'trigger_disabled'] as const +type TriggerStatus = typeof TRIGGER_STATUSES[number] + /** * Session recording starts in buffering mode while waiting for decide response * Once the response is received it might be disabled, active or sampled @@ -233,6 +246,8 @@ export class SessionRecording { // then we can manually track href changes private _lastHref?: string + private _urlTriggers: SessionRecordingUrlTrigger[] = [] + // Util to help developers working on this feature manually override _forceAllowLocalhostNetworkCapture = false @@ -258,7 +273,11 @@ export class SessionRecording { } private get fullSnapshotIntervalMillis(): number { - return this.instance.config.session_recording?.full_snapshot_interval_millis || FIVE_MINUTES + if (this.urlTriggerStatus === 'trigger_pending') { + return ONE_MINUTE + } + + return this.instance.config.session_recording?.full_snapshot_interval_millis ?? FIVE_MINUTES } private get isSampled(): boolean | null { @@ -348,6 +367,10 @@ export class SessionRecording { return 'buffering' } + if (this.urlTriggerStatus === 'trigger_pending') { + return 'buffering' + } + if (isBoolean(this.isSampled)) { return this.isSampled ? 'sampled' : 'disabled' } else { @@ -355,6 +378,34 @@ export class SessionRecording { } } + private get urlTriggerStatus(): TriggerStatus { + if (this.receivedDecide && this._urlTriggers.length === 0) { + 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) + + 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 + } + + return '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, + }) + } + constructor(private readonly instance: PostHog) { this._captureStarted = false this._endpoint = BASE_ENDPOINT @@ -438,6 +489,9 @@ export class SessionRecording { this._onSessionIdListener = this.sessionManager.onSessionId((sessionId, windowId, changeReason) => { if (changeReason) { this._tryAddCustomEvent('$session_id_change', { sessionId, windowId, changeReason }) + + this.instance?.persistence?.unregister(SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION) + this.instance?.persistence?.unregister(SESSION_RECORDING_URL_TRIGGER_STATUS) } }) } @@ -556,6 +610,10 @@ export class SessionRecording { }) } + if (response.sessionRecording?.urlTriggers) { + this._urlTriggers = response.sessionRecording.urlTriggers + } + this.receivedDecide = true this.startIfEnabledOrStop() } @@ -921,11 +979,19 @@ export class SessionRecording { this._pageViewFallBack() } + // Check if the URL matches any trigger patterns + this._checkUrlTrigger() + // 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 + if (rawEvent.type === EventType.FullSnapshot && this.urlTriggerStatus === 'trigger_pending') { + this.clearBuffer() + } + const throttledEvent = this.mutationRateLimiter ? this.mutationRateLimiter.throttleMutations(rawEvent) : rawEvent @@ -1102,6 +1168,36 @@ export class SessionRecording { }) } + private _checkUrlTrigger() { + if (typeof window === 'undefined' || !window.location.href) { + return + } + + const url = window.location.href + + if ( + this._urlTriggers.some((trigger) => { + switch (trigger.matching) { + case 'regex': + return new RegExp(trigger.url).test(url) + default: + return false + } + }) + ) { + this._activateUrlTrigger() + } + } + + private _activateUrlTrigger() { + if (this.urlTriggerStatus === 'trigger_pending') { + this.urlTriggerStatus = 'trigger_activated' + this._tryAddCustomEvent('url trigger activated', {}) + this._flushBuffer() + logger.info(LOGGER_PREFIX + ' recording triggered by URL pattern match') + } + } + /** * 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). diff --git a/src/types.ts b/src/types.ts index bd28662cf..268265f18 100644 --- a/src/types.ts +++ b/src/types.ts @@ -396,6 +396,7 @@ export interface DecideResponse { canvasQuality?: string | null linkedFlag?: string | FlagVariant | null networkPayloadCapture?: Pick + urlTriggers?: SessionRecordingUrlTrigger[] } surveys?: boolean toolbarParams: ToolbarParams @@ -591,3 +592,27 @@ export type ErrorMetadata = { // but provided as an array of literal types, so we can constrain the level below export const severityLevels = ['fatal', 'error', 'warning', 'log', 'info', 'debug'] as const export declare type SeverityLevel = typeof severityLevels[number] + +export interface ErrorProperties { + $exception_type: string + $exception_message: string + $exception_level: SeverityLevel + $exception_source?: string + $exception_lineno?: number + $exception_colno?: number + $exception_DOMException_code?: string + $exception_is_synthetic?: boolean + $exception_stack_trace_raw?: string + $exception_handled?: boolean + $exception_personURL?: string +} + +export interface ErrorConversions { + errorToProperties: (args: ErrorEventArgs) => ErrorProperties + unhandledRejectionToProperties: (args: [ev: PromiseRejectionEvent]) => ErrorProperties +} + +export interface SessionRecordingUrlTrigger { + url: string + matching: 'regex' +}