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: start session recording on url trigger #1451

Merged
merged 14 commits into from
Oct 17, 2024
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
102 changes: 99 additions & 3 deletions src/extensions/replay/sessionrecording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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 {
Expand Down Expand Up @@ -348,13 +367,45 @@ export class SessionRecording {
return 'buffering'
}

if (this.urlTriggerStatus === 'trigger_pending') {
return 'buffering'
}

if (isBoolean(this.isSampled)) {
return this.isSampled ? 'sampled' : 'disabled'
} else {
return 'active'
}
}

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({
richard-better marked this conversation as resolved.
Show resolved Hide resolved
[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
Expand Down Expand Up @@ -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)
}
})
}
Expand Down Expand Up @@ -556,6 +610,10 @@ export class SessionRecording {
})
}

if (response.sessionRecording?.urlTriggers) {
richard-better marked this conversation as resolved.
Show resolved Hide resolved
this._urlTriggers = response.sessionRecording.urlTriggers
}

this.receivedDecide = true
this.startIfEnabledOrStop()
}
Expand Down Expand Up @@ -921,11 +979,19 @@ export class SessionRecording {
this._pageViewFallBack()
}

// Check if the URL matches any trigger patterns
this._checkUrlTrigger()
Copy link
Member

Choose a reason for hiding this comment

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

we could check this only when the URL changes (we have a hook in this class already)

or return early in checkUrlTrigger if there's no reason to run the check

wandering towards early optimisation though - not blocking at all


// 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()
pauldambra marked this conversation as resolved.
Show resolved Hide resolved
}

const throttledEvent = this.mutationRateLimiter
? this.mutationRateLimiter.throttleMutations(rawEvent)
: rawEvent
Expand Down Expand Up @@ -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':
richard-better marked this conversation as resolved.
Show resolved Hide resolved
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).
Expand Down
25 changes: 25 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ export interface DecideResponse {
canvasQuality?: string | null
linkedFlag?: string | FlagVariant | null
networkPayloadCapture?: Pick<NetworkRecordOptions, 'recordBody' | 'recordHeaders'>
urlTriggers?: SessionRecordingUrlTrigger[]
}
surveys?: boolean
toolbarParams: ToolbarParams
Expand Down Expand Up @@ -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'
}
Loading