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
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ 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_recording_url_trigger_activated'
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
88 changes: 86 additions & 2 deletions src/extensions/replay/sessionrecording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
SESSION_RECORDING_MINIMUM_DURATION,
SESSION_RECORDING_NETWORK_PAYLOAD_CAPTURE,
SESSION_RECORDING_SAMPLE_RATE,
SESSION_RECORDING_URL_TRIGGER_ACTIVATED,
} from '../../constants'
import {
estimateSize,
Expand All @@ -16,7 +17,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 All @@ -36,6 +44,7 @@ import { gzipSync, strFromU8, strToU8 } from 'fflate'

const BASE_ENDPOINT = '/s/'

const ONE_MINUTE = 1000 * 60
const FIVE_MINUTES = 1000 * 60 * 5
Copy link
Member

Choose a reason for hiding this comment

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

very picky... and will have almost no impact individually but a little code golf helps the bundle size over time so you could have

Suggested change
const FIVE_MINUTES = 1000 * 60 * 5
const FIVE_MINUTES = ONE_MINUTE * 5

i tend to dislike that style of writing and would rather fix the bigger problems in the bundle sizing but little things like that tend to help (although the bundler can behave surprisingly 🤣)

const TWO_SECONDS = 2000
export const RECORDING_IDLE_THRESHOLD_MS = FIVE_MINUTES
Expand All @@ -60,6 +69,9 @@ const ACTIVE_SOURCES = [
IncrementalSource.Drag,
]

const TRIGGER_STATUSES = ['activated', 'pending', '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 @@ -228,6 +240,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 @@ -253,7 +267,11 @@ export class SessionRecording {
}

private get fullSnapshotIntervalMillis(): number {
return this.instance.config.session_recording?.full_snapshot_interval_millis || FIVE_MINUTES
if (this.urlTriggerStatus === '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 @@ -343,13 +361,37 @@ export class SessionRecording {
return 'buffering'
}

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

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

private get urlTriggerStatus(): 'activated' | 'pending' | 'disabled' {
if (this.receivedDecide && this._urlTriggers.length === 0) {
return 'disabled'
richard-better marked this conversation as resolved.
Show resolved Hide resolved
}

const currentValue = this.instance?.get_property(SESSION_RECORDING_URL_TRIGGER_ACTIVATED)

if (TRIGGER_STATUSES.includes(currentValue)) {
return currentValue as TriggerStatus
}

return '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]: status,
richard-better marked this conversation as resolved.
Show resolved Hide resolved
})
}

constructor(private readonly instance: PostHog) {
this._captureStarted = false
this._endpoint = BASE_ENDPOINT
Expand Down Expand Up @@ -544,6 +586,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 @@ -909,11 +955,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 === '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 @@ -1090,6 +1144,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 === 'pending') {
this.urlTriggerStatus = '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
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,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 @@ -594,3 +595,8 @@ export interface ErrorConversions {
errorToProperties: (args: ErrorEventArgs) => ErrorProperties
unhandledRejectionToProperties: (args: [ev: PromiseRejectionEvent]) => ErrorProperties
}

export interface SessionRecordingUrlTrigger {
url: string
matching: 'regex'
}
Loading