@leanbase-giangnd/js 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +143 -0
  2. package/dist/index.cjs +6012 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.ts +1484 -0
  5. package/dist/index.mjs +6010 -0
  6. package/dist/index.mjs.map +1 -0
  7. package/dist/leanbase.iife.js +13431 -0
  8. package/dist/leanbase.iife.js.map +1 -0
  9. package/package.json +48 -0
  10. package/src/autocapture-utils.ts +550 -0
  11. package/src/autocapture.ts +415 -0
  12. package/src/config.ts +8 -0
  13. package/src/constants.ts +108 -0
  14. package/src/extensions/rageclick.ts +34 -0
  15. package/src/extensions/replay/external/config.ts +278 -0
  16. package/src/extensions/replay/external/denylist.ts +32 -0
  17. package/src/extensions/replay/external/lazy-loaded-session-recorder.ts +1376 -0
  18. package/src/extensions/replay/external/mutation-throttler.ts +109 -0
  19. package/src/extensions/replay/external/network-plugin.ts +701 -0
  20. package/src/extensions/replay/external/sessionrecording-utils.ts +141 -0
  21. package/src/extensions/replay/external/triggerMatching.ts +422 -0
  22. package/src/extensions/replay/rrweb-plugins/patch.ts +39 -0
  23. package/src/extensions/replay/session-recording.ts +285 -0
  24. package/src/extensions/replay/types/rrweb-types.ts +575 -0
  25. package/src/extensions/replay/types/rrweb.ts +114 -0
  26. package/src/extensions/sampling.ts +26 -0
  27. package/src/iife.ts +87 -0
  28. package/src/index.ts +2 -0
  29. package/src/leanbase-logger.ts +26 -0
  30. package/src/leanbase-persistence.ts +374 -0
  31. package/src/leanbase.ts +457 -0
  32. package/src/page-view.ts +124 -0
  33. package/src/scroll-manager.ts +103 -0
  34. package/src/session-props.ts +114 -0
  35. package/src/sessionid.ts +330 -0
  36. package/src/storage.ts +410 -0
  37. package/src/types/fflate.d.ts +5 -0
  38. package/src/types/rrweb-record.d.ts +8 -0
  39. package/src/types.ts +807 -0
  40. package/src/utils/blocked-uas.ts +162 -0
  41. package/src/utils/element-utils.ts +50 -0
  42. package/src/utils/event-utils.ts +304 -0
  43. package/src/utils/index.ts +222 -0
  44. package/src/utils/logger.ts +26 -0
  45. package/src/utils/request-utils.ts +128 -0
  46. package/src/utils/simple-event-emitter.ts +27 -0
  47. package/src/utils/user-agent-utils.ts +357 -0
  48. package/src/uuidv7.ts +268 -0
  49. package/src/version.ts +1 -0
@@ -0,0 +1,1376 @@
1
+ import { record as rrwebRecord } from '@rrweb/record'
2
+ import { clampToRange, includes, isBoolean, isNullish, isNumber, isObject, isString, isUndefined } from '@posthog/core'
3
+ import type { recordOptions, rrwebRecord as rrwebRecordType } from '../types/rrweb'
4
+ import {
5
+ type customEvent,
6
+ EventType,
7
+ eventWithTime,
8
+ IncrementalSource,
9
+ type listenerHandler,
10
+ RecordPlugin,
11
+ } from '../types/rrweb-types'
12
+ import { buildNetworkRequestOptions } from './config'
13
+ import { getRecordNetworkPlugin } from './network-plugin'
14
+ import {
15
+ ACTIVE,
16
+ allMatchSessionRecordingStatus,
17
+ AndTriggerMatching,
18
+ anyMatchSessionRecordingStatus,
19
+ BUFFERING,
20
+ DISABLED,
21
+ EventTriggerMatching,
22
+ LinkedFlagMatching,
23
+ nullMatchSessionRecordingStatus,
24
+ OrTriggerMatching,
25
+ PAUSED,
26
+ PendingTriggerMatching,
27
+ RecordingTriggersStatus,
28
+ SAMPLED,
29
+ SessionRecordingStatus,
30
+ TRIGGER_PENDING,
31
+ TriggerStatusMatching,
32
+ TriggerType,
33
+ URLTriggerMatching,
34
+ } from './triggerMatching'
35
+ import { estimateSize, INCREMENTAL_SNAPSHOT_EVENT_TYPE, truncateLargeConsoleLogs } from './sessionrecording-utils'
36
+ import { gzipSync, strFromU8, strToU8 } from 'fflate'
37
+ import { window, document, addEventListener } from '../../../utils'
38
+ import { MutationThrottler } from './mutation-throttler'
39
+ import { createLogger } from '../../../utils/logger'
40
+ import {
41
+ SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION,
42
+ SESSION_RECORDING_IS_SAMPLED,
43
+ SESSION_RECORDING_REMOTE_CONFIG,
44
+ SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION,
45
+ } from '../../../constants'
46
+ import { Leanbase } from '../../../leanbase'
47
+ import {
48
+ CaptureResult,
49
+ NetworkRecordOptions,
50
+ NetworkRequest,
51
+ Properties,
52
+ SessionIdChangedCallback,
53
+ SessionRecordingOptions,
54
+ SessionRecordingPersistedConfig,
55
+ SessionStartReason,
56
+ } from '../../../types'
57
+ import { isLocalhost } from '../../../utils/request-utils'
58
+ import Config from '../../../config'
59
+ import { sampleOnProperty } from '../../sampling'
60
+
61
+ const BASE_ENDPOINT = '/s/'
62
+ const DEFAULT_CANVAS_QUALITY = 0.4
63
+ const DEFAULT_CANVAS_FPS = 4
64
+ const MAX_CANVAS_FPS = 12
65
+ const MAX_CANVAS_QUALITY = 1
66
+ const TWO_SECONDS = 2000
67
+ const ONE_KB = 1024
68
+
69
+ const ONE_MINUTE = 1000 * 60
70
+ const FIVE_MINUTES = ONE_MINUTE * 5
71
+
72
+ export const RECORDING_IDLE_THRESHOLD_MS = FIVE_MINUTES
73
+
74
+ export const RECORDING_MAX_EVENT_SIZE = ONE_KB * ONE_KB * 0.9 // ~1mb (with some wiggle room)
75
+ export const RECORDING_BUFFER_TIMEOUT = 2000 // 2 seconds
76
+ export const SESSION_RECORDING_BATCH_KEY = 'recordings'
77
+
78
+ const LOGGER_PREFIX = '[SessionRecording]'
79
+ const logger = createLogger(LOGGER_PREFIX)
80
+
81
+ interface QueuedRRWebEvent {
82
+ rrwebMethod: () => void
83
+ attempt: number
84
+ // the timestamp this was first put into this queue
85
+ enqueuedAt: number
86
+ }
87
+
88
+ interface SessionIdlePayload {
89
+ eventTimestamp: number
90
+ lastActivityTimestamp: number
91
+ threshold: number
92
+ bufferLength: number
93
+ bufferSize: number
94
+ }
95
+
96
+ export interface SnapshotBuffer {
97
+ size: number
98
+ data: any[]
99
+ sessionId: string
100
+ windowId: string
101
+ }
102
+
103
+ const ACTIVE_SOURCES = [
104
+ IncrementalSource.MouseMove,
105
+ IncrementalSource.MouseInteraction,
106
+ IncrementalSource.Scroll,
107
+ IncrementalSource.ViewportResize,
108
+ IncrementalSource.Input,
109
+ IncrementalSource.TouchMove,
110
+ IncrementalSource.MediaInteraction,
111
+ IncrementalSource.Drag,
112
+ ]
113
+
114
+ const newQueuedEvent = (rrwebMethod: () => void): QueuedRRWebEvent => ({
115
+ rrwebMethod,
116
+ enqueuedAt: Date.now(),
117
+ attempt: 1,
118
+ })
119
+
120
+ function getRRWebRecord(): rrwebRecordType | undefined {
121
+ return rrwebRecord as unknown as rrwebRecordType
122
+ }
123
+
124
+ export type compressedFullSnapshotEvent = {
125
+ type: EventType.FullSnapshot
126
+ data: string
127
+ }
128
+
129
+ export type compressedIncrementalSnapshotEvent = {
130
+ type: EventType.IncrementalSnapshot
131
+ data: {
132
+ source: IncrementalSource
133
+ texts: string
134
+ attributes: string
135
+ removes: string
136
+ adds: string
137
+ }
138
+ }
139
+
140
+ export type compressedIncrementalStyleSnapshotEvent = {
141
+ type: EventType.IncrementalSnapshot
142
+ data: {
143
+ source: IncrementalSource.StyleSheetRule
144
+ id?: number
145
+ styleId?: number
146
+ replace?: string
147
+ replaceSync?: string
148
+ adds?: string
149
+ removes?: string
150
+ }
151
+ }
152
+
153
+ export type compressedEvent =
154
+ | compressedIncrementalStyleSnapshotEvent
155
+ | compressedFullSnapshotEvent
156
+ | compressedIncrementalSnapshotEvent
157
+ export type compressedEventWithTime = compressedEvent & {
158
+ timestamp: number
159
+ delay?: number
160
+ // marker for compression version
161
+ cv: '2024-10'
162
+ }
163
+
164
+ function gzipToString(data: unknown): string {
165
+ return strFromU8(gzipSync(strToU8(JSON.stringify(data))), true)
166
+ }
167
+
168
+ /**
169
+ * rrweb's packer takes an event and returns a string or the reverse on `unpack`.
170
+ * but we want to be able to inspect metadata during ingestion.
171
+ * and don't want to compress the entire event,
172
+ * so we have a custom packer that only compresses part of some events
173
+ */
174
+ function compressEvent(event: eventWithTime): eventWithTime | compressedEventWithTime {
175
+ try {
176
+ if (event.type === EventType.FullSnapshot) {
177
+ return {
178
+ ...event,
179
+ data: gzipToString(event.data),
180
+ cv: '2024-10',
181
+ }
182
+ }
183
+ if (event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.Mutation) {
184
+ return {
185
+ ...event,
186
+ cv: '2024-10',
187
+ data: {
188
+ ...event.data,
189
+ texts: gzipToString(event.data.texts),
190
+ attributes: gzipToString(event.data.attributes),
191
+ removes: gzipToString(event.data.removes),
192
+ adds: gzipToString(event.data.adds),
193
+ },
194
+ }
195
+ }
196
+ if (event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.StyleSheetRule) {
197
+ return {
198
+ ...event,
199
+ cv: '2024-10',
200
+ data: {
201
+ ...event.data,
202
+ adds: event.data.adds ? gzipToString(event.data.adds) : undefined,
203
+ removes: event.data.removes ? gzipToString(event.data.removes) : undefined,
204
+ },
205
+ }
206
+ }
207
+ } catch (e) {
208
+ logger.error('could not compress event - will use uncompressed event', e)
209
+ }
210
+ return event
211
+ }
212
+
213
+ function isSessionIdleEvent(e: eventWithTime): e is eventWithTime & customEvent {
214
+ return e.type === EventType.Custom && e.data.tag === 'sessionIdle'
215
+ }
216
+
217
+ /** When we put the recording into a paused state, we add a custom event.
218
+ * However, in the paused state, events are dropped and never make it to the buffer,
219
+ * so we need to manually let this one through */
220
+ function isRecordingPausedEvent(e: eventWithTime) {
221
+ return e.type === EventType.Custom && e.data.tag === 'recording paused'
222
+ }
223
+
224
+ export const SEVEN_MEGABYTES = 1024 * 1024 * 7 * 0.9 // ~7mb (with some wiggle room)
225
+
226
+ // recursively splits large buffers into smaller ones
227
+ // uses a pretty high size limit to avoid splitting too much
228
+ export function splitBuffer(buffer: SnapshotBuffer, sizeLimit: number = SEVEN_MEGABYTES): SnapshotBuffer[] {
229
+ if (buffer.size >= sizeLimit && buffer.data.length > 1) {
230
+ const half = Math.floor(buffer.data.length / 2)
231
+ const firstHalf = buffer.data.slice(0, half)
232
+ const secondHalf = buffer.data.slice(half)
233
+ return [
234
+ splitBuffer({
235
+ size: estimateSize(firstHalf),
236
+ data: firstHalf,
237
+ sessionId: buffer.sessionId,
238
+ windowId: buffer.windowId,
239
+ }),
240
+ splitBuffer({
241
+ size: estimateSize(secondHalf),
242
+ data: secondHalf,
243
+ sessionId: buffer.sessionId,
244
+ windowId: buffer.windowId,
245
+ }),
246
+ ].flatMap((x) => x)
247
+ } else {
248
+ return [buffer]
249
+ }
250
+ }
251
+
252
+ export class LazyLoadedSessionRecording {
253
+ private _endpoint: string = BASE_ENDPOINT
254
+ private _mutationThrottler?: MutationThrottler
255
+ /**
256
+ * Util to help developers working on this feature manually override
257
+ */
258
+ private _forceAllowLocalhostNetworkCapture = false
259
+ private _stopRrweb: listenerHandler | undefined = undefined
260
+ private _lastActivityTimestamp: number = Date.now()
261
+ /**
262
+ * if pageview capture is disabled,
263
+ * then we can manually track href changes
264
+ */
265
+ private _lastHref?: string
266
+ /**
267
+ * and a queue - that contains rrweb events that we want to send to rrweb, but rrweb wasn't able to accept them yet
268
+ */
269
+ private _queuedRRWebEvents: QueuedRRWebEvent[] = []
270
+ private _isIdle: boolean | 'unknown' = 'unknown'
271
+
272
+ private _linkedFlagMatching: LinkedFlagMatching
273
+ private _urlTriggerMatching: URLTriggerMatching
274
+ private _eventTriggerMatching: EventTriggerMatching
275
+ // we need to be able to check the state of the event and url triggers separately
276
+ // as we make some decisions based on them without referencing LinkedFlag etc
277
+ private _triggerMatching: TriggerStatusMatching = new PendingTriggerMatching()
278
+ private _fullSnapshotTimer?: ReturnType<typeof setInterval>
279
+
280
+ private _windowId: string
281
+ private _sessionId: string
282
+ get sessionId(): string {
283
+ return this._sessionId
284
+ }
285
+
286
+ private _flushBufferTimer?: any
287
+ // we have a buffer - that contains PostHog snapshot events ready to be sent to the server
288
+ private _buffer: SnapshotBuffer
289
+
290
+ private _removePageViewCaptureHook: (() => void) | undefined = undefined
291
+
292
+ private _removeEventTriggerCaptureHook: (() => void) | undefined = undefined
293
+
294
+ private get _sessionManager() {
295
+ if (!this._instance.sessionManager) {
296
+ throw new Error(LOGGER_PREFIX + ' must be started with a valid sessionManager.')
297
+ }
298
+
299
+ return this._instance.sessionManager
300
+ }
301
+
302
+ private get _sessionIdleThresholdMilliseconds(): number {
303
+ return this._instance.config.session_recording?.session_idle_threshold_ms || RECORDING_IDLE_THRESHOLD_MS
304
+ }
305
+
306
+ private get _isSampled(): boolean | null {
307
+ const currentValue = this._instance.get_property(SESSION_RECORDING_IS_SAMPLED)
308
+ // originally we would store `true` or `false` or nothing,
309
+ // but that would mean sometimes we would carry on recording on session id change
310
+ return isBoolean(currentValue) ? currentValue : isString(currentValue) ? currentValue === this.sessionId : null
311
+ }
312
+
313
+ private get _sampleRate(): number | null {
314
+ const rate = this._remoteConfig?.sampleRate
315
+ return isNumber(rate) ? rate : null
316
+ }
317
+
318
+ private get _minimumDuration(): number | null {
319
+ const duration = this._remoteConfig?.minimumDurationMilliseconds
320
+ return isNumber(duration) ? duration : null
321
+ }
322
+
323
+ private _statusMatcher: (triggersStatus: RecordingTriggersStatus) => SessionRecordingStatus =
324
+ nullMatchSessionRecordingStatus
325
+
326
+ private _onSessionIdListener: (() => void) | undefined = undefined
327
+ private _onSessionIdleResetForcedListener: (() => void) | undefined = undefined
328
+ private _samplingSessionListener: (() => void) | undefined = undefined
329
+ private _forceIdleSessionIdListener: (() => void) | undefined = undefined
330
+
331
+ constructor(private readonly _instance: Leanbase) {
332
+ // we know there's a sessionManager, so don't need to start without a session id
333
+ const { sessionId, windowId } = this._sessionManager.checkAndGetSessionAndWindowId()
334
+ this._sessionId = sessionId
335
+ this._windowId = windowId
336
+
337
+ this._linkedFlagMatching = new LinkedFlagMatching(this._instance)
338
+ this._urlTriggerMatching = new URLTriggerMatching(this._instance)
339
+ this._eventTriggerMatching = new EventTriggerMatching(this._instance)
340
+
341
+ this._buffer = this._clearBuffer()
342
+
343
+ if (this._sessionIdleThresholdMilliseconds >= this._sessionManager.sessionTimeoutMs) {
344
+ logger.warn(
345
+ `session_idle_threshold_ms (${this._sessionIdleThresholdMilliseconds}) is greater than the session timeout (${this._sessionManager.sessionTimeoutMs}). Session will never be detected as idle`
346
+ )
347
+ }
348
+ }
349
+
350
+ private get _masking():
351
+ | Pick<SessionRecordingOptions, 'maskAllInputs' | 'maskTextSelector' | 'blockSelector'>
352
+ | undefined {
353
+ const masking_server_side = this._remoteConfig?.masking
354
+ const masking_client_side = {
355
+ maskAllInputs: this._instance.config.session_recording?.maskAllInputs,
356
+ maskTextSelector: this._instance.config.session_recording?.maskTextSelector,
357
+ blockSelector: this._instance.config.session_recording?.blockSelector,
358
+ }
359
+
360
+ const maskAllInputs = masking_client_side?.maskAllInputs ?? masking_server_side?.maskAllInputs
361
+ const maskTextSelector = masking_client_side?.maskTextSelector ?? masking_server_side?.maskTextSelector
362
+ const blockSelector = masking_client_side?.blockSelector ?? masking_server_side?.blockSelector
363
+
364
+ return !isUndefined(maskAllInputs) || !isUndefined(maskTextSelector) || !isUndefined(blockSelector)
365
+ ? {
366
+ maskAllInputs: maskAllInputs ?? true,
367
+ maskTextSelector,
368
+ blockSelector,
369
+ }
370
+ : undefined
371
+ }
372
+
373
+ private get _canvasRecording(): { enabled: boolean; fps: number; quality: number } {
374
+ const canvasRecording_client_side = this._instance.config.session_recording?.captureCanvas
375
+ const canvasRecording_server_side = this._remoteConfig?.canvasRecording
376
+
377
+ const enabled: boolean =
378
+ canvasRecording_client_side?.recordCanvas ?? canvasRecording_server_side?.enabled ?? false
379
+ const fps: number =
380
+ canvasRecording_client_side?.canvasFps ?? canvasRecording_server_side?.fps ?? DEFAULT_CANVAS_FPS
381
+ let quality: string | number =
382
+ canvasRecording_client_side?.canvasQuality ?? canvasRecording_server_side?.quality ?? DEFAULT_CANVAS_QUALITY
383
+ if (typeof quality === 'string') {
384
+ const parsed = parseFloat(quality)
385
+ quality = isNaN(parsed) ? 0.4 : parsed
386
+ }
387
+
388
+ return {
389
+ enabled,
390
+ fps: clampToRange(fps, 0, MAX_CANVAS_FPS, createLogger('canvas recording fps'), DEFAULT_CANVAS_FPS),
391
+ quality: clampToRange(
392
+ quality,
393
+ 0,
394
+ MAX_CANVAS_QUALITY,
395
+ createLogger('canvas recording quality'),
396
+ DEFAULT_CANVAS_QUALITY
397
+ ),
398
+ }
399
+ }
400
+
401
+ private get _isConsoleLogCaptureEnabled() {
402
+ const enabled_server_side = !!this._remoteConfig?.consoleLogRecordingEnabled
403
+ const enabled_client_side = this._instance.config.enable_recording_console_log
404
+ return enabled_client_side ?? enabled_server_side
405
+ }
406
+
407
+ // network payload capture config has three parts
408
+ // each can be configured server side or client side
409
+ private get _networkPayloadCapture():
410
+ | Pick<NetworkRecordOptions, 'recordHeaders' | 'recordBody' | 'recordPerformance' | 'payloadHostDenyList'>
411
+ | undefined {
412
+ const networkPayloadCapture_server_side = this._remoteConfig?.networkPayloadCapture
413
+ const networkPayloadCapture_client_side = {
414
+ recordHeaders: this._instance.config.session_recording?.recordHeaders,
415
+ recordBody: this._instance.config.session_recording?.recordBody,
416
+ }
417
+ const headersOptIn = networkPayloadCapture_client_side?.recordHeaders === true
418
+ const bodyOptIn = networkPayloadCapture_client_side?.recordBody === true
419
+ const clientPerformanceConfig = this._instance.config.capture_performance
420
+ const clientPerformanceOptIn = isObject(clientPerformanceConfig)
421
+ ? !!clientPerformanceConfig.network_timing
422
+ : !!clientPerformanceConfig
423
+ const serverAllowsHeaders = networkPayloadCapture_server_side?.recordHeaders ?? true
424
+ const serverAllowsBody = networkPayloadCapture_server_side?.recordBody ?? true
425
+ const capturePerfResponse = networkPayloadCapture_server_side?.capturePerformance
426
+ const serverAllowsPerformance = (() => {
427
+ if (isObject(capturePerfResponse)) {
428
+ return !!capturePerfResponse.network_timing
429
+ }
430
+ return capturePerfResponse ?? true
431
+ })()
432
+
433
+ const headersEnabled = headersOptIn && serverAllowsHeaders
434
+ const bodyEnabled = bodyOptIn && serverAllowsBody
435
+ const networkTimingEnabled = clientPerformanceOptIn && serverAllowsPerformance
436
+
437
+ if (!headersEnabled && !bodyEnabled && !networkTimingEnabled) {
438
+ return undefined
439
+ }
440
+
441
+ return {
442
+ recordHeaders: headersEnabled,
443
+ recordBody: bodyEnabled,
444
+ recordPerformance: networkTimingEnabled,
445
+ payloadHostDenyList: networkPayloadCapture_server_side?.payloadHostDenyList,
446
+ }
447
+ }
448
+
449
+ private _gatherRRWebPlugins() {
450
+ const plugins: RecordPlugin[] = []
451
+
452
+ if (this._isConsoleLogCaptureEnabled) {
453
+ logger.info('Console log capture requested but console plugin is not bundled in this build yet.')
454
+ }
455
+
456
+ if (this._networkPayloadCapture) {
457
+ const canRecordNetwork = !isLocalhost() || this._forceAllowLocalhostNetworkCapture
458
+
459
+ if (canRecordNetwork) {
460
+ plugins.push(
461
+ getRecordNetworkPlugin(
462
+ buildNetworkRequestOptions(this._instance.config, this._networkPayloadCapture)
463
+ )
464
+ )
465
+ } else {
466
+ logger.info('NetworkCapture not started because we are on localhost.')
467
+ }
468
+ }
469
+
470
+ return plugins
471
+ }
472
+
473
+ private _maskUrl(url: string): string | undefined {
474
+ const userSessionRecordingOptions = this._instance.config.session_recording || {}
475
+
476
+ if (userSessionRecordingOptions.maskNetworkRequestFn) {
477
+ let networkRequest: NetworkRequest | null | undefined = {
478
+ url,
479
+ }
480
+
481
+ // TODO we should deprecate this and use the same function for this masking and the rrweb/network plugin
482
+ // TODO or deprecate this and provide a new clearer name so this would be `maskURLPerformanceFn` or similar
483
+ networkRequest = userSessionRecordingOptions.maskNetworkRequestFn(networkRequest)
484
+
485
+ return networkRequest?.url
486
+ }
487
+
488
+ return url
489
+ }
490
+
491
+ private _tryRRWebMethod(queuedRRWebEvent: QueuedRRWebEvent): boolean {
492
+ try {
493
+ queuedRRWebEvent.rrwebMethod()
494
+ return true
495
+ } catch (e) {
496
+ // Sometimes a race can occur where the recorder is not fully started yet
497
+ if (this._queuedRRWebEvents.length < 10) {
498
+ this._queuedRRWebEvents.push({
499
+ enqueuedAt: queuedRRWebEvent.enqueuedAt || Date.now(),
500
+ attempt: queuedRRWebEvent.attempt + 1,
501
+ rrwebMethod: queuedRRWebEvent.rrwebMethod,
502
+ })
503
+ } else {
504
+ logger.warn('could not emit queued rrweb event.', e, queuedRRWebEvent)
505
+ }
506
+
507
+ return false
508
+ }
509
+ }
510
+
511
+ private _tryAddCustomEvent(tag: string, payload: any): boolean {
512
+ return this._tryRRWebMethod(newQueuedEvent(() => getRRWebRecord()!.addCustomEvent(tag, payload)))
513
+ }
514
+
515
+ private _pageViewFallBack() {
516
+ try {
517
+ if (this._instance.config.capture_pageview || !window) {
518
+ return
519
+ }
520
+ // Strip hash parameters from URL since they often aren't helpful
521
+ // Use URL constructor for proper parsing to handle edge cases
522
+ // recording doesn't run in IE11, so we don't need compat here
523
+ // eslint-disable-next-line compat/compat
524
+ const url = new URL(window.location.href)
525
+ const hrefWithoutHash = url.origin + url.pathname + url.search
526
+ const currentUrl = this._maskUrl(hrefWithoutHash)
527
+ if (this._lastHref !== currentUrl) {
528
+ this._lastHref = currentUrl
529
+ this._tryAddCustomEvent('$url_changed', { href: currentUrl })
530
+ }
531
+ } catch {
532
+ // If URL processing fails, don't capture anything
533
+ }
534
+ }
535
+
536
+ private _processQueuedEvents() {
537
+ if (this._queuedRRWebEvents.length) {
538
+ // if rrweb isn't ready to accept events earlier, then we queued them up.
539
+ // now that `emit` has been called rrweb should be ready to accept them.
540
+ // so, before we process this event, we try our queued events _once_ each
541
+ // we don't want to risk queuing more things and never exiting this loop!
542
+ // if they fail here, they'll be pushed into a new queue
543
+ // and tried on the next loop.
544
+ // there is a risk of this queue growing in an uncontrolled manner.
545
+ // so its length is limited elsewhere
546
+ // for now this is to help us ensure we can capture events that happen
547
+ // and try to identify more about when it is failing
548
+ const itemsToProcess = [...this._queuedRRWebEvents]
549
+ this._queuedRRWebEvents = []
550
+ itemsToProcess.forEach((queuedRRWebEvent) => {
551
+ if (Date.now() - queuedRRWebEvent.enqueuedAt <= TWO_SECONDS) {
552
+ this._tryRRWebMethod(queuedRRWebEvent)
553
+ }
554
+ })
555
+ }
556
+ }
557
+
558
+ private _tryTakeFullSnapshot(): boolean {
559
+ return this._tryRRWebMethod(newQueuedEvent(() => getRRWebRecord()!.takeFullSnapshot()))
560
+ }
561
+
562
+ private get _fullSnapshotIntervalMillis(): number {
563
+ if (
564
+ this._triggerMatching.triggerStatus(this.sessionId) === TRIGGER_PENDING &&
565
+ !['sampled', 'active'].includes(this.status)
566
+ ) {
567
+ return ONE_MINUTE
568
+ }
569
+
570
+ return this._instance.config.session_recording?.full_snapshot_interval_millis ?? FIVE_MINUTES
571
+ }
572
+
573
+ private _scheduleFullSnapshot(): void {
574
+ if (this._fullSnapshotTimer) {
575
+ clearInterval(this._fullSnapshotTimer)
576
+ }
577
+ // we don't schedule snapshots while idle
578
+ if (this._isIdle === true) {
579
+ return
580
+ }
581
+
582
+ const interval = this._fullSnapshotIntervalMillis
583
+ if (!interval) {
584
+ return
585
+ }
586
+
587
+ this._fullSnapshotTimer = setInterval(() => {
588
+ this._tryTakeFullSnapshot()
589
+ }, interval)
590
+ }
591
+
592
+ private _pauseRecording() {
593
+ // we check _urlBlocked not status, since more than one thing can affect status
594
+ if (this._urlTriggerMatching.urlBlocked) {
595
+ return
596
+ }
597
+
598
+ // we can't flush the buffer here since someone might be starting on a blocked page.
599
+ // and we need to be sure that we don't record that page,
600
+ // so we might not get the below custom event, but events will report the paused status.
601
+ // which will allow debugging of sessions that start on blocked pages
602
+ this._urlTriggerMatching.urlBlocked = true
603
+
604
+ // Clear the snapshot timer since we don't want new snapshots while paused
605
+ clearInterval(this._fullSnapshotTimer)
606
+
607
+ logger.info('recording paused due to URL blocker')
608
+ this._tryAddCustomEvent('recording paused', { reason: 'url blocker' })
609
+ }
610
+
611
+ private _resumeRecording() {
612
+ // we check _urlBlocked not status, since more than one thing can affect status
613
+ if (!this._urlTriggerMatching.urlBlocked) {
614
+ return
615
+ }
616
+
617
+ this._urlTriggerMatching.urlBlocked = false
618
+
619
+ this._tryTakeFullSnapshot()
620
+ this._scheduleFullSnapshot()
621
+
622
+ this._tryAddCustomEvent('recording resumed', { reason: 'left blocked url' })
623
+ logger.info('recording resumed')
624
+ }
625
+
626
+ private _activateTrigger(triggerType: TriggerType) {
627
+ if (this._triggerMatching.triggerStatus(this.sessionId) === TRIGGER_PENDING) {
628
+ // status is stored separately for URL and event triggers
629
+ this._instance?.persistence?.register({
630
+ [triggerType === 'url'
631
+ ? SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION
632
+ : SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION]: this._sessionId,
633
+ })
634
+
635
+ this._flushBuffer()
636
+ this._reportStarted((triggerType + '_trigger_matched') as SessionStartReason)
637
+ }
638
+ }
639
+
640
+ get isStarted(): boolean {
641
+ return !!this._stopRrweb
642
+ }
643
+
644
+ get _remoteConfig(): SessionRecordingPersistedConfig | undefined {
645
+ const persistedConfig: any = this._instance.get_property(SESSION_RECORDING_REMOTE_CONFIG)
646
+ if (!persistedConfig) {
647
+ return undefined
648
+ }
649
+ const parsedConfig = isObject(persistedConfig) ? persistedConfig : JSON.parse(persistedConfig)
650
+ return parsedConfig as SessionRecordingPersistedConfig
651
+ }
652
+
653
+ start(startReason?: SessionStartReason) {
654
+ const config = this._remoteConfig
655
+ if (!config) {
656
+ logger.info('remote config must be stored in persistence before recording can start')
657
+ return
658
+ }
659
+
660
+ // We want to ensure the sessionManager is reset if necessary on loading the recorder
661
+ this._sessionManager.checkAndGetSessionAndWindowId()
662
+
663
+ if (config?.endpoint) {
664
+ this._endpoint = config?.endpoint
665
+ }
666
+
667
+ if (config?.triggerMatchType === 'any') {
668
+ this._statusMatcher = anyMatchSessionRecordingStatus
669
+ this._triggerMatching = new OrTriggerMatching([this._eventTriggerMatching, this._urlTriggerMatching])
670
+ } else {
671
+ // either the setting is "ALL"
672
+ // or we default to the most restrictive
673
+ this._statusMatcher = allMatchSessionRecordingStatus
674
+ this._triggerMatching = new AndTriggerMatching([this._eventTriggerMatching, this._urlTriggerMatching])
675
+ }
676
+ this._instance.registerForSession({
677
+ $sdk_debug_replay_remote_trigger_matching_config: config?.triggerMatchType ?? null,
678
+ })
679
+
680
+ this._urlTriggerMatching.onConfig(config)
681
+
682
+ this._eventTriggerMatching.onConfig(config)
683
+ this._removeEventTriggerCaptureHook?.()
684
+ this._addEventTriggerListener()
685
+
686
+ this._linkedFlagMatching.onConfig(config, (flag, variant) => {
687
+ this._reportStarted('linked_flag_matched', {
688
+ flag,
689
+ variant,
690
+ })
691
+ })
692
+
693
+ this._makeSamplingDecision(this.sessionId)
694
+ this._startRecorder()
695
+
696
+ // calling addEventListener multiple times is safe and will not add duplicates
697
+ addEventListener(window, 'beforeunload', this._onBeforeUnload)
698
+ addEventListener(window, 'offline', this._onOffline)
699
+ addEventListener(window, 'online', this._onOnline)
700
+ addEventListener(window, 'visibilitychange', this._onVisibilityChange)
701
+
702
+ if (!this._onSessionIdListener) {
703
+ this._onSessionIdListener = this._sessionManager.onSessionId(this._onSessionIdCallback)
704
+ }
705
+
706
+ if (!this._onSessionIdleResetForcedListener) {
707
+ this._onSessionIdleResetForcedListener = this._sessionManager.on('forcedIdleReset', () => {
708
+ // a session was forced to reset due to idle timeout and lack of activity
709
+ this._clearConditionalRecordingPersistence()
710
+ this._isIdle = 'unknown'
711
+ this.stop()
712
+ // then we want a session id listener to restart the recording when a new session starts
713
+ this._forceIdleSessionIdListener = this._sessionManager.onSessionId(
714
+ (sessionId, windowId, changeReason) => {
715
+ // this should first unregister itself
716
+ this._forceIdleSessionIdListener?.()
717
+ this._forceIdleSessionIdListener = undefined
718
+ this._onSessionIdCallback(sessionId, windowId, changeReason)
719
+ }
720
+ )
721
+ })
722
+ }
723
+
724
+ if (isNullish(this._removePageViewCaptureHook)) {
725
+ // :TRICKY: rrweb does not capture navigation within SPA-s, so hook into our $pageview events to get access to all events.
726
+ // Dropping the initial event is fine (it's always captured by rrweb).
727
+ this._removePageViewCaptureHook = this._instance.on('eventCaptured', (event) => {
728
+ // If anything could go wrong here,
729
+ // it has the potential to block the main loop,
730
+ // so we catch all errors.
731
+ try {
732
+ if (event.event === '$pageview') {
733
+ const href = event?.properties.$current_url ? this._maskUrl(event?.properties.$current_url) : ''
734
+ if (!href) {
735
+ return
736
+ }
737
+ this._tryAddCustomEvent('$pageview', { href })
738
+ }
739
+ } catch (e) {
740
+ logger.error('Could not add $pageview to rrweb session', e)
741
+ }
742
+ })
743
+ }
744
+
745
+ if (this.status === ACTIVE) {
746
+ this._reportStarted(startReason || 'recording_initialized')
747
+ }
748
+ }
749
+
750
+ private _onSessionIdCallback: SessionIdChangedCallback = (sessionId, windowId, changeReason) => {
751
+ if (changeReason) {
752
+ this._tryAddCustomEvent('$session_id_change', { sessionId, windowId, changeReason })
753
+
754
+ this._clearConditionalRecordingPersistence()
755
+
756
+ if (!this._stopRrweb) {
757
+ this.start('session_id_changed')
758
+ }
759
+
760
+ if (isNumber(this._sampleRate) && isNullish(this._samplingSessionListener)) {
761
+ this._makeSamplingDecision(sessionId)
762
+ }
763
+ }
764
+ }
765
+
766
+ stop() {
767
+ window?.removeEventListener('beforeunload', this._onBeforeUnload)
768
+ window?.removeEventListener('offline', this._onOffline)
769
+ window?.removeEventListener('online', this._onOnline)
770
+ window?.removeEventListener('visibilitychange', this._onVisibilityChange)
771
+
772
+ this._clearBuffer()
773
+ clearInterval(this._fullSnapshotTimer)
774
+ this._clearFlushBufferTimer()
775
+
776
+ this._removePageViewCaptureHook?.()
777
+ this._removePageViewCaptureHook = undefined
778
+ this._removeEventTriggerCaptureHook?.()
779
+ this._removeEventTriggerCaptureHook = undefined
780
+ this._onSessionIdListener?.()
781
+ this._onSessionIdListener = undefined
782
+ this._onSessionIdleResetForcedListener?.()
783
+ this._onSessionIdleResetForcedListener = undefined
784
+ this._samplingSessionListener?.()
785
+ this._samplingSessionListener = undefined
786
+ this._forceIdleSessionIdListener?.()
787
+ this._forceIdleSessionIdListener = undefined
788
+
789
+ this._eventTriggerMatching.stop()
790
+ this._urlTriggerMatching.stop()
791
+ this._linkedFlagMatching.stop()
792
+
793
+ this._mutationThrottler?.stop()
794
+
795
+ // Clear any queued rrweb events to prevent memory leaks from closures
796
+ this._queuedRRWebEvents = []
797
+
798
+ this._stopRrweb?.()
799
+ this._stopRrweb = undefined
800
+
801
+ logger.info('stopped')
802
+ }
803
+
804
+ onRRwebEmit(rawEvent: eventWithTime) {
805
+ this._processQueuedEvents()
806
+
807
+ if (!rawEvent || !isObject(rawEvent)) {
808
+ return
809
+ }
810
+
811
+ if (rawEvent.type === EventType.Meta) {
812
+ const href = this._maskUrl(rawEvent.data.href)
813
+ this._lastHref = href
814
+ if (!href) {
815
+ return
816
+ }
817
+ rawEvent.data.href = href
818
+ } else {
819
+ this._pageViewFallBack()
820
+ }
821
+
822
+ // Check if the URL matches any trigger patterns
823
+ this._urlTriggerMatching.checkUrlTriggerConditions(
824
+ () => this._pauseRecording(),
825
+ () => this._resumeRecording(),
826
+ (triggerType) => this._activateTrigger(triggerType)
827
+ )
828
+ // always have to check if the URL is blocked really early,
829
+ // or you risk getting stuck in a loop
830
+ if (this._urlTriggerMatching.urlBlocked && !isRecordingPausedEvent(rawEvent)) {
831
+ return
832
+ }
833
+
834
+ // we're processing a full snapshot, so we should reset the timer
835
+ if (rawEvent.type === EventType.FullSnapshot) {
836
+ this._scheduleFullSnapshot()
837
+ // Full snapshots reset rrweb's node IDs, so clear any logged node tracking
838
+ this._mutationThrottler?.reset()
839
+ }
840
+
841
+ // Clear the buffer if waiting for a trigger and only keep data from after the current full snapshot
842
+ // we always start trigger pending so need to wait for flags before we know if we're really pending
843
+ if (
844
+ rawEvent.type === EventType.FullSnapshot &&
845
+ this._triggerMatching.triggerStatus(this.sessionId) === TRIGGER_PENDING
846
+ ) {
847
+ this._clearBufferBeforeMostRecentMeta()
848
+ }
849
+
850
+ const throttledEvent = this._mutationThrottler ? this._mutationThrottler.throttleMutations(rawEvent) : rawEvent
851
+
852
+ if (!throttledEvent) {
853
+ return
854
+ }
855
+
856
+ // TODO: Re-add ensureMaxMessageSize once we are confident in it
857
+ const event = truncateLargeConsoleLogs(throttledEvent)
858
+
859
+ this._updateWindowAndSessionIds(event)
860
+
861
+ // When in an idle state we keep recording but don't capture the events,
862
+ // we don't want to return early if idle is 'unknown'
863
+ if (this._isIdle === true && !isSessionIdleEvent(event)) {
864
+ return
865
+ }
866
+
867
+ if (isSessionIdleEvent(event)) {
868
+ // session idle events have a timestamp when rrweb sees them
869
+ // which can artificially lengthen a session
870
+ // we know when we detected it based on the payload and can correct the timestamp
871
+ const payload = event.data.payload as SessionIdlePayload
872
+ if (payload) {
873
+ const lastActivity = payload.lastActivityTimestamp
874
+ const threshold = payload.threshold
875
+ event.timestamp = lastActivity + threshold
876
+ }
877
+ }
878
+
879
+ const eventToSend =
880
+ (this._instance.config.session_recording?.compress_events ?? true) ? compressEvent(event) : event
881
+ const size = estimateSize(eventToSend)
882
+
883
+ const properties = {
884
+ $snapshot_bytes: size,
885
+ $snapshot_data: eventToSend,
886
+ $session_id: this._sessionId,
887
+ $window_id: this._windowId,
888
+ }
889
+
890
+ if (this.status === DISABLED) {
891
+ this._clearBuffer()
892
+ return
893
+ }
894
+
895
+ this._captureSnapshotBuffered(properties)
896
+ }
897
+
898
+ get status(): SessionRecordingStatus {
899
+ return this._statusMatcher({
900
+ // can't get here without recording being enabled...
901
+ receivedFlags: true,
902
+ isRecordingEnabled: true,
903
+ // things that do still vary
904
+ isSampled: this._isSampled,
905
+ urlTriggerMatching: this._urlTriggerMatching,
906
+ eventTriggerMatching: this._eventTriggerMatching,
907
+ linkedFlagMatching: this._linkedFlagMatching,
908
+ sessionId: this.sessionId,
909
+ })
910
+ }
911
+
912
+ log(message: string, level: 'log' | 'warn' | 'error' = 'log') {
913
+ this._instance.sessionRecording?.onRRwebEmit({
914
+ type: 6,
915
+ data: {
916
+ plugin: 'rrweb/console@1',
917
+ payload: {
918
+ level,
919
+ trace: [],
920
+ // Even though it is a string, we stringify it as that's what rrweb expects
921
+ payload: [JSON.stringify(message)],
922
+ },
923
+ },
924
+ timestamp: Date.now(),
925
+ })
926
+ }
927
+
928
+ public overrideLinkedFlag() {
929
+ this._linkedFlagMatching.linkedFlagSeen = true
930
+ this._tryTakeFullSnapshot()
931
+ this._reportStarted('linked_flag_overridden')
932
+ }
933
+
934
+ /**
935
+ * this ignores the sampling config and (if other conditions are met) causes capture to start
936
+ *
937
+ * It is not usual to call this directly,
938
+ * instead call `posthog.startSessionRecording({sampling: true})`
939
+ * */
940
+ public overrideSampling() {
941
+ this._instance.persistence?.register({
942
+ // short-circuits the `makeSamplingDecision` function in the session recording module
943
+ [SESSION_RECORDING_IS_SAMPLED]: this.sessionId,
944
+ })
945
+ this._tryTakeFullSnapshot()
946
+ this._reportStarted('sampling_overridden')
947
+ }
948
+
949
+ /**
950
+ * this ignores the URL/Event trigger config and (if other conditions are met) causes capture to start
951
+ *
952
+ * It is not usual to call this directly,
953
+ * instead call `posthog.startSessionRecording({trigger: 'url' | 'event'})`
954
+ * */
955
+ public overrideTrigger(triggerType: TriggerType) {
956
+ this._activateTrigger(triggerType)
957
+ }
958
+
959
+ private _clearFlushBufferTimer() {
960
+ if (this._flushBufferTimer) {
961
+ clearTimeout(this._flushBufferTimer)
962
+ this._flushBufferTimer = undefined
963
+ }
964
+ }
965
+
966
+ private _flushBuffer(): SnapshotBuffer {
967
+ this._clearFlushBufferTimer()
968
+
969
+ const minimumDuration = this._minimumDuration
970
+ const sessionDuration = this._sessionDuration
971
+ // if we have old data in the buffer but the session has rotated, then the
972
+ // session duration might be negative. In that case we want to flush the buffer
973
+ const isPositiveSessionDuration = isNumber(sessionDuration) && sessionDuration >= 0
974
+ const isBelowMinimumDuration =
975
+ isNumber(minimumDuration) && isPositiveSessionDuration && sessionDuration < minimumDuration
976
+
977
+ if (this.status === BUFFERING || this.status === PAUSED || this.status === DISABLED || isBelowMinimumDuration) {
978
+ this._flushBufferTimer = setTimeout(() => {
979
+ this._flushBuffer()
980
+ }, RECORDING_BUFFER_TIMEOUT)
981
+ return this._buffer
982
+ }
983
+
984
+ if (this._buffer.data.length > 0) {
985
+ const snapshotEvents = splitBuffer(this._buffer)
986
+ snapshotEvents.forEach((snapshotBuffer) => {
987
+ this._captureSnapshot({
988
+ $snapshot_bytes: snapshotBuffer.size,
989
+ $snapshot_data: snapshotBuffer.data,
990
+ $session_id: snapshotBuffer.sessionId,
991
+ $window_id: snapshotBuffer.windowId,
992
+ $lib: 'web',
993
+ $lib_version: Config.LIB_VERSION,
994
+ })
995
+ })
996
+ }
997
+
998
+ // buffer is empty, we clear it in case the session id has changed
999
+ return this._clearBuffer()
1000
+ }
1001
+
1002
+ private _captureSnapshotBuffered(properties: Properties) {
1003
+ const additionalBytes = 2 + (this._buffer?.data.length || 0) // 2 bytes for the array brackets and 1 byte for each comma
1004
+ if (
1005
+ !this._isIdle && // we never want to flush when idle
1006
+ (this._buffer.size + properties.$snapshot_bytes + additionalBytes > RECORDING_MAX_EVENT_SIZE ||
1007
+ this._buffer.sessionId !== this._sessionId)
1008
+ ) {
1009
+ this._buffer = this._flushBuffer()
1010
+ }
1011
+
1012
+ this._buffer.size += properties.$snapshot_bytes
1013
+ this._buffer.data.push(properties.$snapshot_data)
1014
+
1015
+ if (!this._flushBufferTimer && !this._isIdle) {
1016
+ this._flushBufferTimer = setTimeout(() => {
1017
+ this._flushBuffer()
1018
+ }, RECORDING_BUFFER_TIMEOUT)
1019
+ }
1020
+ }
1021
+
1022
+ private _captureSnapshot(properties: Properties) {
1023
+ // :TRICKY: Make sure we batch these requests, use a custom endpoint and don't truncate the strings.
1024
+ this._instance.capture('$snapshot', properties, {
1025
+ _url: this._snapshotUrl(),
1026
+ _noTruncate: true,
1027
+ _batchKey: SESSION_RECORDING_BATCH_KEY,
1028
+ skip_client_rate_limiting: true,
1029
+ })
1030
+ }
1031
+
1032
+ private _snapshotUrl(): string {
1033
+ const host = this._instance.config.host || ''
1034
+ try {
1035
+ // eslint-disable-next-line compat/compat
1036
+ return new URL(this._endpoint, host).href
1037
+ } catch {
1038
+ const normalizedHost = host.endsWith('/') ? host.slice(0, -1) : host
1039
+ const normalizedEndpoint = this._endpoint.startsWith('/') ? this._endpoint.slice(1) : this._endpoint
1040
+ return `${normalizedHost}/${normalizedEndpoint}`
1041
+ }
1042
+ }
1043
+
1044
+ private get _sessionDuration(): number | null {
1045
+ const mostRecentSnapshot = this._buffer?.data[this._buffer?.data.length - 1]
1046
+ const { sessionStartTimestamp } = this._sessionManager.checkAndGetSessionAndWindowId(true)
1047
+ return mostRecentSnapshot ? mostRecentSnapshot.timestamp - sessionStartTimestamp : null
1048
+ }
1049
+
1050
+ private _clearBufferBeforeMostRecentMeta(): SnapshotBuffer {
1051
+ if (!this._buffer || this._buffer.data.length === 0) {
1052
+ return this._clearBuffer()
1053
+ }
1054
+
1055
+ // Find the last meta event index by iterating backwards
1056
+ let lastMetaIndex = -1
1057
+ for (let i = this._buffer.data.length - 1; i >= 0; i--) {
1058
+ if (this._buffer.data[i].type === EventType.Meta) {
1059
+ lastMetaIndex = i
1060
+ break
1061
+ }
1062
+ }
1063
+ if (lastMetaIndex >= 0) {
1064
+ this._buffer.data = this._buffer.data.slice(lastMetaIndex)
1065
+ this._buffer.size = this._buffer.data.reduce((acc, curr) => acc + estimateSize(curr), 0)
1066
+ return this._buffer
1067
+ } else {
1068
+ return this._clearBuffer()
1069
+ }
1070
+ }
1071
+
1072
+ private _clearBuffer(): SnapshotBuffer {
1073
+ this._buffer = {
1074
+ size: 0,
1075
+ data: [],
1076
+ sessionId: this._sessionId,
1077
+ windowId: this._windowId,
1078
+ }
1079
+ return this._buffer
1080
+ }
1081
+
1082
+ private _onBeforeUnload = (): void => {
1083
+ this._flushBuffer()
1084
+ }
1085
+
1086
+ private _onOffline = (): void => {
1087
+ this._tryAddCustomEvent('browser offline', {})
1088
+ }
1089
+
1090
+ private _onOnline = (): void => {
1091
+ this._tryAddCustomEvent('browser online', {})
1092
+ }
1093
+
1094
+ private _onVisibilityChange = (): void => {
1095
+ if (document?.visibilityState) {
1096
+ const label = 'window ' + document.visibilityState
1097
+ this._tryAddCustomEvent(label, {})
1098
+ }
1099
+ }
1100
+
1101
+ private _reportStarted(startReason: SessionStartReason, tagPayload?: Record<string, any>) {
1102
+ this._instance.registerForSession({
1103
+ $session_recording_start_reason: startReason,
1104
+ })
1105
+ logger.info(startReason.replace('_', ' '), tagPayload)
1106
+ if (!includes(['recording_initialized', 'session_id_changed'], startReason)) {
1107
+ this._tryAddCustomEvent(startReason, tagPayload)
1108
+ }
1109
+ }
1110
+
1111
+ private _isInteractiveEvent(event: eventWithTime) {
1112
+ return (
1113
+ event.type === INCREMENTAL_SNAPSHOT_EVENT_TYPE &&
1114
+ ACTIVE_SOURCES.indexOf(event.data?.source as IncrementalSource) !== -1
1115
+ )
1116
+ }
1117
+
1118
+ private _updateWindowAndSessionIds(event: eventWithTime) {
1119
+ // Some recording events are triggered by non-user events (e.g. "X minutes ago" text updating on the screen).
1120
+ // We don't want to extend the session or trigger a new session in these cases. These events are designated by event
1121
+ // type -> incremental update, and source -> mutation.
1122
+
1123
+ const isUserInteraction = this._isInteractiveEvent(event)
1124
+
1125
+ if (!isUserInteraction && !this._isIdle) {
1126
+ // We check if the lastActivityTimestamp is old enough to go idle
1127
+ const timeSinceLastActivity = event.timestamp - this._lastActivityTimestamp
1128
+ if (timeSinceLastActivity > this._sessionIdleThresholdMilliseconds) {
1129
+ // we mark as idle right away,
1130
+ // or else we get multiple idle events
1131
+ // if there are lots of non-user activity events being emitted
1132
+ this._isIdle = true
1133
+
1134
+ // don't take full snapshots while idle
1135
+ clearInterval(this._fullSnapshotTimer)
1136
+
1137
+ this._tryAddCustomEvent('sessionIdle', {
1138
+ eventTimestamp: event.timestamp,
1139
+ lastActivityTimestamp: this._lastActivityTimestamp,
1140
+ threshold: this._sessionIdleThresholdMilliseconds,
1141
+ bufferLength: this._buffer.data.length,
1142
+ bufferSize: this._buffer.size,
1143
+ })
1144
+
1145
+ // proactively flush the buffer in case the session is idle for a long time
1146
+ this._flushBuffer()
1147
+ }
1148
+ }
1149
+
1150
+ let returningFromIdle = false
1151
+ if (isUserInteraction) {
1152
+ this._lastActivityTimestamp = event.timestamp
1153
+ if (this._isIdle) {
1154
+ const idleWasUnknown = this._isIdle === 'unknown'
1155
+ // Remove the idle state
1156
+ this._isIdle = false
1157
+ // if the idle state was unknown, we don't want to add an event, since we're just in bootup
1158
+ // whereas if it was true, we know we've been idle for a while, and we can mark ourselves as returning from idle
1159
+ if (!idleWasUnknown) {
1160
+ this._tryAddCustomEvent('sessionNoLongerIdle', {
1161
+ reason: 'user activity',
1162
+ type: event.type,
1163
+ })
1164
+ returningFromIdle = true
1165
+ }
1166
+ }
1167
+ }
1168
+
1169
+ if (this._isIdle) {
1170
+ return
1171
+ }
1172
+
1173
+ // We only want to extend the session if it is an interactive event.
1174
+ const { windowId, sessionId } = this._sessionManager.checkAndGetSessionAndWindowId(
1175
+ !isUserInteraction,
1176
+ event.timestamp
1177
+ )
1178
+
1179
+ const sessionIdChanged = this._sessionId !== sessionId
1180
+ const windowIdChanged = this._windowId !== windowId
1181
+
1182
+ this._windowId = windowId
1183
+ this._sessionId = sessionId
1184
+
1185
+ if (sessionIdChanged || windowIdChanged) {
1186
+ this.stop()
1187
+ this.start('session_id_changed')
1188
+ } else if (returningFromIdle) {
1189
+ this._scheduleFullSnapshot()
1190
+ }
1191
+ }
1192
+
1193
+ private _clearConditionalRecordingPersistence(): void {
1194
+ this._instance?.persistence?.unregister(SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION)
1195
+ this._instance?.persistence?.unregister(SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION)
1196
+ this._instance?.persistence?.unregister(SESSION_RECORDING_IS_SAMPLED)
1197
+ }
1198
+
1199
+ private _makeSamplingDecision(sessionId: string): void {
1200
+ const sessionIdChanged = this._sessionId !== sessionId
1201
+
1202
+ // capture the current sample rate
1203
+ // because it is re-used multiple times
1204
+ // and the bundler won't minimize any of the references
1205
+ const currentSampleRate = this._sampleRate
1206
+
1207
+ if (!isNumber(currentSampleRate)) {
1208
+ this._instance.persistence?.unregister(SESSION_RECORDING_IS_SAMPLED)
1209
+ return
1210
+ }
1211
+
1212
+ const storedIsSampled = this._isSampled
1213
+
1214
+ /**
1215
+ * if we get this far, then we should make a sampling decision.
1216
+ * When the session id changes or there is no stored sampling decision for this session id
1217
+ * then we should make a new decision.
1218
+ *
1219
+ * Otherwise, we should use the stored decision.
1220
+ */
1221
+ const makeDecision = sessionIdChanged || !isBoolean(storedIsSampled)
1222
+ const shouldSample = makeDecision ? sampleOnProperty(sessionId, currentSampleRate) : storedIsSampled
1223
+
1224
+ if (makeDecision) {
1225
+ if (shouldSample) {
1226
+ this._reportStarted(SAMPLED)
1227
+ } else {
1228
+ logger.warn(
1229
+ `Sample rate (${currentSampleRate}) has determined that this sessionId (${sessionId}) will not be sent to the server.`
1230
+ )
1231
+ }
1232
+
1233
+ this._tryAddCustomEvent('samplingDecisionMade', {
1234
+ sampleRate: currentSampleRate,
1235
+ isSampled: shouldSample,
1236
+ })
1237
+ }
1238
+
1239
+ this._instance.persistence?.register({
1240
+ [SESSION_RECORDING_IS_SAMPLED]: shouldSample ? sessionId : false,
1241
+ })
1242
+ }
1243
+
1244
+ private _addEventTriggerListener() {
1245
+ if (this._eventTriggerMatching._eventTriggers.length === 0 || !isNullish(this._removeEventTriggerCaptureHook)) {
1246
+ return
1247
+ }
1248
+
1249
+ this._removeEventTriggerCaptureHook = this._instance.on('eventCaptured', (event: CaptureResult) => {
1250
+ // If anything could go wrong here, it has the potential to block the main loop,
1251
+ // so we catch all errors.
1252
+ try {
1253
+ if (this._eventTriggerMatching._eventTriggers.includes(event.event)) {
1254
+ this._activateTrigger('event')
1255
+ }
1256
+ } catch (e) {
1257
+ logger.error('Could not activate event trigger', e)
1258
+ }
1259
+ })
1260
+ }
1261
+
1262
+ get sdkDebugProperties(): Properties {
1263
+ const { sessionStartTimestamp } = this._sessionManager.checkAndGetSessionAndWindowId(true)
1264
+
1265
+ return {
1266
+ $recording_status: this.status,
1267
+ $sdk_debug_replay_internal_buffer_length: this._buffer.data.length,
1268
+ $sdk_debug_replay_internal_buffer_size: this._buffer.size,
1269
+ $sdk_debug_current_session_duration: this._sessionDuration,
1270
+ $sdk_debug_session_start: sessionStartTimestamp,
1271
+ }
1272
+ }
1273
+
1274
+ private _startRecorder() {
1275
+ if (this._stopRrweb) {
1276
+ return
1277
+ }
1278
+
1279
+ // rrweb config info: https://github.com/rrweb-io/rrweb/blob/7d5d0033258d6c29599fb08412202d9a2c7b9413/src/record/index.ts#L28
1280
+ const sessionRecordingOptions: recordOptions = {
1281
+ // a limited set of the rrweb config options that we expose to our users.
1282
+ // see https://github.com/rrweb-io/rrweb/blob/master/guide.md
1283
+ blockClass: 'ph-no-capture',
1284
+ blockSelector: undefined,
1285
+ ignoreClass: 'ph-ignore-input',
1286
+ maskTextClass: 'ph-mask',
1287
+ maskTextSelector: undefined,
1288
+ maskTextFn: undefined,
1289
+ maskAllInputs: true,
1290
+ maskInputOptions: { password: true },
1291
+ maskInputFn: undefined,
1292
+ slimDOMOptions: {},
1293
+ collectFonts: false,
1294
+ inlineStylesheet: true,
1295
+ recordCrossOriginIframes: false,
1296
+ }
1297
+
1298
+ // only allows user to set our allowlisted options
1299
+ const userSessionRecordingOptions = this._instance.config.session_recording
1300
+ for (const [key, value] of Object.entries(userSessionRecordingOptions || {})) {
1301
+ if (key in sessionRecordingOptions) {
1302
+ if (key === 'maskInputOptions') {
1303
+ // ensure password config is set if not included
1304
+ sessionRecordingOptions.maskInputOptions = { password: true, ...value }
1305
+ } else {
1306
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1307
+ // @ts-ignore
1308
+ sessionRecordingOptions[key] = value
1309
+ }
1310
+ }
1311
+ }
1312
+
1313
+ if (this._canvasRecording && this._canvasRecording.enabled) {
1314
+ sessionRecordingOptions.recordCanvas = true
1315
+ sessionRecordingOptions.sampling = { canvas: this._canvasRecording.fps }
1316
+ sessionRecordingOptions.dataURLOptions = { type: 'image/webp', quality: this._canvasRecording.quality }
1317
+ }
1318
+
1319
+ if (this._masking) {
1320
+ sessionRecordingOptions.maskAllInputs = this._masking.maskAllInputs ?? true
1321
+ sessionRecordingOptions.maskTextSelector = this._masking.maskTextSelector ?? undefined
1322
+ sessionRecordingOptions.blockSelector = this._masking.blockSelector ?? undefined
1323
+ }
1324
+
1325
+ const rrwebRecord = getRRWebRecord()
1326
+ if (!rrwebRecord) {
1327
+ logger.error(
1328
+ '_startRecorder was called but rrwebRecord is not available. This indicates something has gone wrong.'
1329
+ )
1330
+ return
1331
+ }
1332
+
1333
+ this._mutationThrottler =
1334
+ this._mutationThrottler ??
1335
+ new MutationThrottler(rrwebRecord, {
1336
+ refillRate: this._instance.config.session_recording?.__mutationThrottlerRefillRate,
1337
+ bucketSize: this._instance.config.session_recording?.__mutationThrottlerBucketSize,
1338
+ onBlockedNode: (id, node) => {
1339
+ const message = `Too many mutations on node '${id}'. Rate limiting. This could be due to SVG animations or something similar`
1340
+ logger.info(message, {
1341
+ node: node,
1342
+ })
1343
+
1344
+ this.log(LOGGER_PREFIX + ' ' + message, 'warn')
1345
+ },
1346
+ })
1347
+
1348
+ const activePlugins = this._gatherRRWebPlugins()
1349
+ this._stopRrweb = rrwebRecord({
1350
+ emit: (event) => {
1351
+ this.onRRwebEmit(event)
1352
+ },
1353
+ plugins: activePlugins,
1354
+ ...sessionRecordingOptions,
1355
+ })
1356
+
1357
+ // We reset the last activity timestamp, resetting the idle timer
1358
+ this._lastActivityTimestamp = Date.now()
1359
+ // stay unknown if we're not sure if we're idle or not
1360
+ this._isIdle = isBoolean(this._isIdle) ? this._isIdle : 'unknown'
1361
+
1362
+ this.tryAddCustomEvent('$remote_config_received', this._remoteConfig)
1363
+ this._tryAddCustomEvent('$session_options', {
1364
+ sessionRecordingOptions,
1365
+ activePlugins: activePlugins.map((p) => p?.name),
1366
+ })
1367
+
1368
+ this._tryAddCustomEvent('$posthog_config', {
1369
+ config: this._instance.config,
1370
+ })
1371
+ }
1372
+
1373
+ tryAddCustomEvent(tag: string, payload: any): boolean {
1374
+ return this._tryAddCustomEvent(tag, payload)
1375
+ }
1376
+ }