swetrix 4.2.0 → 4.4.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.
package/src/Lib.ts CHANGED
@@ -14,6 +14,38 @@ import {
14
14
  getPath,
15
15
  } from './utils.js'
16
16
 
17
+ type RrwebEvent = Record<string, unknown>
18
+
19
+ type RrwebEmit = (event: RrwebEvent) => void
20
+
21
+ interface RrwebRecordOptions {
22
+ emit?: RrwebEmit
23
+ [key: string]: unknown
24
+ }
25
+
26
+ interface RrwebGlobal {
27
+ record?: (options: RrwebRecordOptions) => (() => void) | undefined
28
+ Replayer?: unknown
29
+ }
30
+
31
+ type RrwebModule = RrwebGlobal & {
32
+ default?: RrwebGlobal
33
+ }
34
+
35
+ type SessionReplayPreloadOption = boolean | { rrwebUrl?: string }
36
+
37
+ interface SessionReplayStartResponse {
38
+ replayId: string
39
+ nextChunkIndex: number
40
+ }
41
+
42
+ declare global {
43
+ interface Window {
44
+ rrweb?: RrwebGlobal
45
+ __SWETRIX_RRWEB_LOADING__?: Promise<void>
46
+ }
47
+ }
48
+
17
49
  export interface LibOptions {
18
50
  /**
19
51
  * When set to `true`, localhost events will be sent to server.
@@ -39,6 +71,11 @@ export interface LibOptions {
39
71
  * If set, it will be used for all pageviews and events unless overridden per-call.
40
72
  */
41
73
  profileId?: string
74
+
75
+ /**
76
+ * Preload session replay recorder code. Recording only starts after calling startSessionReplay().
77
+ */
78
+ preloadSessionReplay?: SessionReplayPreloadOption
42
79
  }
43
80
 
44
81
  export interface TrackEventOptions {
@@ -166,6 +203,30 @@ export interface ErrorActions {
166
203
  stop: () => void
167
204
  }
168
205
 
206
+ const SESSION_REPLAY_PRIVACY_VALUES = ['total', 'normal', 'none'] as const
207
+
208
+ export type SessionReplayPrivacy =
209
+ (typeof SESSION_REPLAY_PRIVACY_VALUES)[number]
210
+
211
+ export interface SessionReplayOptions {
212
+ privacy?: SessionReplayPrivacy
213
+ rrweb?: RrwebRecordOptions
214
+ flushIntervalMs?: number
215
+ maxEventsPerChunk?: number
216
+ maxBytesPerChunk?: number
217
+ maxBytesPerEvent?: number
218
+ sampleRate?: number
219
+ maxDurationMs?: number
220
+ idleTimeoutMs?: number
221
+ maskAllText?: boolean
222
+ recordIframes?: boolean
223
+ }
224
+
225
+ export interface SessionReplayActions {
226
+ stop: () => Promise<void>
227
+ flush: () => Promise<void>
228
+ }
229
+
169
230
  export interface PageData {
170
231
  /** Current URL path. */
171
232
  path: string
@@ -228,12 +289,62 @@ export const defaultActions = {
228
289
  stop() {},
229
290
  }
230
291
 
292
+ export const defaultSessionReplayActions: SessionReplayActions = {
293
+ async stop() {},
294
+ async flush() {},
295
+ }
296
+
231
297
  const DEFAULT_API_HOST = 'https://api.swetrix.com/log'
232
298
  const DEFAULT_API_BASE = 'https://api.swetrix.com'
299
+ const DEFAULT_RRWEB_FILE = 'replaylibrary.min.js'
300
+ const DEFAULT_RRWEB_URL = `https://cdn.jsdelivr.net/npm/swetrix@latest/dist/${DEFAULT_RRWEB_FILE}`
301
+ const DEFAULT_SESSION_REPLAY_FLUSH_INTERVAL = 5000
302
+ const DEFAULT_SESSION_REPLAY_MAX_EVENTS = 100
303
+ const DEFAULT_SESSION_REPLAY_MAX_CHUNK_BYTES = 512 * 1024
304
+ const DEFAULT_SESSION_REPLAY_MAX_EVENT_BYTES = 5 * 1024 * 1024
305
+ const DEFAULT_SESSION_REPLAY_MAX_DURATION_MS = 30 * 60 * 1000
306
+ const DEFAULT_SESSION_REPLAY_PRIVACY: SessionReplayPrivacy = 'total'
307
+ const DEFAULT_SESSION_REPLAY_SAMPLING = {
308
+ mousemove: 50,
309
+ scroll: 150,
310
+ input: 'last',
311
+ }
312
+ const DEFAULT_SESSION_REPLAY_SLIM_DOM_OPTIONS = {
313
+ script: true,
314
+ comment: true,
315
+ headFavicon: true,
316
+ headWhitespace: true,
317
+ headMetaDescKeywords: true,
318
+ headMetaSocial: true,
319
+ headMetaRobots: true,
320
+ headMetaHttpEquiv: true,
321
+ headMetaAuthorship: true,
322
+ headMetaVerification: true,
323
+ }
324
+ const SESSION_REPLAY_ACTIVITY_EVENTS = [
325
+ 'click',
326
+ 'keydown',
327
+ 'mousedown',
328
+ 'mousemove',
329
+ 'scroll',
330
+ 'touchstart',
331
+ ] as const
233
332
 
234
333
  // Default cache duration: 5 minutes
235
334
  const DEFAULT_CACHE_DURATION = 5 * 60 * 1000
236
335
 
336
+ const getStringByteLength = (value: string) => {
337
+ if (typeof TextEncoder !== 'undefined') {
338
+ return new TextEncoder().encode(value).length
339
+ }
340
+
341
+ if (typeof Blob !== 'undefined') {
342
+ return new Blob([value]).size
343
+ }
344
+
345
+ return value.length
346
+ }
347
+
237
348
  export class Lib {
238
349
  private pageData: PageData | null = null
239
350
  private pageViewsOptions?: PageViewsOptions | null = null
@@ -242,11 +353,18 @@ export class Lib {
242
353
  private activePage: string | null = null
243
354
  private errorListenerExists = false
244
355
  private cachedData: CachedData | null = null
356
+ private rrwebLoader: Promise<void> | null = null
357
+ private sessionReplayActions: SessionReplayActions | null = null
358
+ private sessionReplayInitPromise: Promise<SessionReplayActions> | null = null
245
359
 
246
360
  constructor(private projectID: string, private options?: LibOptions) {
247
361
  this.trackPathChange = this.trackPathChange.bind(this)
248
362
  this.heartbeat = this.heartbeat.bind(this)
249
363
  this.captureError = this.captureError.bind(this)
364
+
365
+ if (this.getSessionReplayPreloadOption()) {
366
+ void this.preloadSessionReplay().catch(() => undefined)
367
+ }
250
368
  }
251
369
 
252
370
  captureError(event: ErrorEvent): void {
@@ -370,7 +488,7 @@ export class Lib {
370
488
  }
371
489
 
372
490
  this.pageViewsOptions = options
373
- let interval: NodeJS.Timeout
491
+ let interval: ReturnType<typeof setInterval>
374
492
 
375
493
  if (!options?.unique) {
376
494
  interval = setInterval(this.trackPathChange, 2000)
@@ -431,10 +549,10 @@ export class Lib {
431
549
  }
432
550
 
433
551
  /**
434
- * Fetches all feature flags and experiments for the project.
435
- * Results are cached for 5 minutes by default.
552
+ * Fetches all feature flags for the project.
553
+ * Results are cached for 5 minutes by default and share a cache with experiments.
436
554
  *
437
- * @param options - Options for evaluating feature flags.
555
+ * @param options - Options for evaluating feature flags (`profileId` only).
438
556
  * @param forceRefresh - If true, bypasses the cache and fetches fresh data.
439
557
  * @returns A promise that resolves to a record of flag keys to boolean values.
440
558
  */
@@ -512,8 +630,8 @@ export class Lib {
512
630
  * Gets the value of a single feature flag.
513
631
  *
514
632
  * @param key - The feature flag key.
515
- * @param options - Options for evaluating the feature flag.
516
- * @param defaultValue - Default value to return if the flag is not found. Defaults to false.
633
+ * @param options - Options for evaluating the feature flag (`profileId` only).
634
+ * @param defaultValue - Optional default value to return if the flag is not found. Defaults to false.
517
635
  * @returns A promise that resolves to the boolean value of the flag.
518
636
  */
519
637
  async getFeatureFlag(key: string, options?: FeatureFlagsOptions, defaultValue: boolean = false): Promise<boolean> {
@@ -529,16 +647,16 @@ export class Lib {
529
647
  }
530
648
 
531
649
  /**
532
- * Fetches all A/B test experiments for the project.
650
+ * Fetches variant assignments for running A/B test experiments returned by feature flag evaluation.
533
651
  * Results are cached for 5 minutes by default (shared cache with feature flags).
534
652
  *
535
- * @param options - Options for evaluating experiments.
653
+ * @param options - Options for evaluating experiments (`profileId` only).
536
654
  * @param forceRefresh - If true, bypasses the cache and fetches fresh data.
537
655
  * @returns A promise that resolves to a record of experiment IDs to variant keys.
538
656
  *
539
657
  * @example
540
658
  * ```typescript
541
- * const experiments = await getExperiments()
659
+ * const experiments = await getExperiments({ profileId: 'user-123' })
542
660
  * // experiments = { 'exp-123': 'variant-a', 'exp-456': 'control' }
543
661
  * ```
544
662
  */
@@ -571,13 +689,16 @@ export class Lib {
571
689
  * Gets the variant key for a specific A/B test experiment.
572
690
  *
573
691
  * @param experimentId - The experiment ID.
574
- * @param options - Options for evaluating the experiment.
575
- * @param defaultVariant - Default variant key to return if the experiment is not found. Defaults to null.
692
+ * @param options - Options for evaluating the experiment (`profileId` only).
693
+ * @param defaultVariant - Optional default variant key to return if the experiment is not found. Defaults to null.
576
694
  * @returns A promise that resolves to the variant key assigned to this user, or defaultVariant if not found.
577
695
  *
578
696
  * @example
579
697
  * ```typescript
580
- * const variant = await getExperiment('checkout-redesign')
698
+ * const variant = await getExperiment('checkout-redesign', { profileId: 'user-123' })
699
+ *
700
+ * // Optional fallback variant:
701
+ * const variantWithFallback = await getExperiment('checkout-redesign', undefined, 'control')
581
702
  *
582
703
  * if (variant === 'new-checkout') {
583
704
  * // Show new checkout flow
@@ -704,6 +825,259 @@ export class Lib {
704
825
  }
705
826
  }
706
827
 
828
+ async startSessionReplay(
829
+ options: SessionReplayOptions = {},
830
+ ): Promise<SessionReplayActions> {
831
+ if (this.sessionReplayActions) {
832
+ return this.sessionReplayActions
833
+ }
834
+
835
+ if (this.sessionReplayInitPromise) {
836
+ return this.sessionReplayInitPromise
837
+ }
838
+
839
+ const initPromise = this.initialiseSessionReplay(options)
840
+ this.sessionReplayInitPromise = initPromise
841
+
842
+ try {
843
+ return await initPromise
844
+ } finally {
845
+ if (this.sessionReplayInitPromise === initPromise) {
846
+ this.sessionReplayInitPromise = null
847
+ }
848
+ }
849
+ }
850
+
851
+ private async initialiseSessionReplay(
852
+ options: SessionReplayOptions,
853
+ ): Promise<SessionReplayActions> {
854
+ if (this.sessionReplayActions) {
855
+ return this.sessionReplayActions
856
+ }
857
+
858
+ if (!this.canTrack()) {
859
+ return defaultSessionReplayActions
860
+ }
861
+
862
+ const maxDurationMs =
863
+ typeof options.maxDurationMs === 'number' && options.maxDurationMs > 0
864
+ ? options.maxDurationMs
865
+ : DEFAULT_SESSION_REPLAY_MAX_DURATION_MS
866
+
867
+ if (!this.shouldSampleSessionReplay(options.sampleRate)) {
868
+ return defaultSessionReplayActions
869
+ }
870
+
871
+ try {
872
+ await this.preloadSessionReplay()
873
+ } catch {
874
+ return defaultSessionReplayActions
875
+ }
876
+
877
+ const rrweb = window.rrweb
878
+ if (!rrweb?.record) {
879
+ return defaultSessionReplayActions
880
+ }
881
+
882
+ const privacy = this.getSessionReplayPrivacy(options.privacy)
883
+ const proposedReplayId = this.createReplayId()
884
+ const started = await this.sendSessionReplayStart(proposedReplayId, privacy)
885
+
886
+ if (!started) {
887
+ return defaultSessionReplayActions
888
+ }
889
+
890
+ const replayId = started.replayId
891
+
892
+ const flushIntervalMs =
893
+ typeof options.flushIntervalMs === 'number' && options.flushIntervalMs > 0
894
+ ? options.flushIntervalMs
895
+ : DEFAULT_SESSION_REPLAY_FLUSH_INTERVAL
896
+ const maxEventsPerChunk =
897
+ typeof options.maxEventsPerChunk === 'number' &&
898
+ options.maxEventsPerChunk > 0
899
+ ? Math.floor(options.maxEventsPerChunk)
900
+ : DEFAULT_SESSION_REPLAY_MAX_EVENTS
901
+ const maxBytesPerChunkCandidate =
902
+ typeof options.maxBytesPerChunk === 'number'
903
+ ? Math.floor(options.maxBytesPerChunk)
904
+ : Number.NaN
905
+ const maxBytesPerChunk =
906
+ maxBytesPerChunkCandidate >= 1
907
+ ? maxBytesPerChunkCandidate
908
+ : DEFAULT_SESSION_REPLAY_MAX_CHUNK_BYTES
909
+ const maxBytesPerEventCandidate =
910
+ typeof options.maxBytesPerEvent === 'number'
911
+ ? Math.floor(options.maxBytesPerEvent)
912
+ : Number.NaN
913
+ const maxBytesPerEvent =
914
+ maxBytesPerEventCandidate >= 1
915
+ ? maxBytesPerEventCandidate
916
+ : DEFAULT_SESSION_REPLAY_MAX_EVENT_BYTES
917
+ const idleTimeoutMs =
918
+ typeof options.idleTimeoutMs === 'number' && options.idleTimeoutMs > 0
919
+ ? options.idleTimeoutMs
920
+ : null
921
+
922
+ let chunkIndex = started.nextChunkIndex
923
+ let stopped = false
924
+ let events: RrwebEvent[] = []
925
+ let eventsByteLength = 0
926
+ let flushing = Promise.resolve()
927
+ let maxDurationTimer: ReturnType<typeof setTimeout> | undefined
928
+ let idleTimer: ReturnType<typeof setTimeout> | undefined
929
+
930
+ const flush = async (useBeacon = false) => {
931
+ if (!events.length) return
932
+
933
+ const chunk = events
934
+ events = []
935
+ eventsByteLength = 0
936
+ const currentChunkIndex = chunkIndex++
937
+
938
+ flushing = flushing
939
+ .catch(() => undefined)
940
+ .then(() =>
941
+ this.sendSessionReplayChunk(
942
+ replayId,
943
+ privacy,
944
+ currentChunkIndex,
945
+ chunk,
946
+ useBeacon,
947
+ ),
948
+ )
949
+
950
+ await flushing
951
+ }
952
+
953
+ const userEmit = options.rrweb?.emit
954
+ const recordOptions = this.getSessionReplayRecordOptions(
955
+ privacy,
956
+ options.rrweb,
957
+ (event) => {
958
+ try {
959
+ userEmit?.(event)
960
+ } catch {}
961
+
962
+ let eventByteLength = 0
963
+
964
+ try {
965
+ eventByteLength = getStringByteLength(JSON.stringify(event))
966
+ } catch {
967
+ return
968
+ }
969
+
970
+ if (eventByteLength > maxBytesPerEvent) {
971
+ return
972
+ }
973
+
974
+ if (
975
+ events.length &&
976
+ eventsByteLength + eventByteLength > maxBytesPerChunk
977
+ ) {
978
+ void flush()
979
+ }
980
+
981
+ events.push(event)
982
+ eventsByteLength += eventByteLength
983
+ if (
984
+ events.length >= maxEventsPerChunk ||
985
+ eventsByteLength >= maxBytesPerChunk
986
+ ) {
987
+ void flush()
988
+ }
989
+ },
990
+ Boolean(options.recordIframes),
991
+ options.maskAllText,
992
+ )
993
+
994
+ const stopRecording = rrweb.record(recordOptions)
995
+ const timer = setInterval(() => void flush(), flushIntervalMs)
996
+ const flushOnPageExit = () => void flush(true)
997
+ const flushOnHidden = () => {
998
+ if (document.visibilityState === 'hidden') {
999
+ void flush(true)
1000
+ }
1001
+ }
1002
+ const clearIdleTimer = () => {
1003
+ if (idleTimer) {
1004
+ clearTimeout(idleTimer)
1005
+ idleTimer = undefined
1006
+ }
1007
+ }
1008
+ const stopSessionReplay = async () => {
1009
+ if (stopped) return
1010
+ stopped = true
1011
+ clearInterval(timer)
1012
+ if (maxDurationTimer) {
1013
+ clearTimeout(maxDurationTimer)
1014
+ }
1015
+ clearIdleTimer()
1016
+ window.removeEventListener('pagehide', flushOnPageExit)
1017
+ document.removeEventListener('visibilitychange', flushOnHidden)
1018
+ SESSION_REPLAY_ACTIVITY_EVENTS.forEach((eventName) => {
1019
+ window.removeEventListener(eventName, resetIdleTimer)
1020
+ })
1021
+ stopRecording?.()
1022
+ await flush()
1023
+ this.sessionReplayActions = null
1024
+ this.sessionReplayInitPromise = null
1025
+ }
1026
+ const resetIdleTimer = () => {
1027
+ if (!idleTimeoutMs || stopped) return
1028
+ clearIdleTimer()
1029
+ idleTimer = setTimeout(() => void stopSessionReplay(), idleTimeoutMs)
1030
+ }
1031
+
1032
+ window.addEventListener('pagehide', flushOnPageExit)
1033
+ document.addEventListener('visibilitychange', flushOnHidden)
1034
+
1035
+ maxDurationTimer = setTimeout(
1036
+ () => void stopSessionReplay(),
1037
+ maxDurationMs,
1038
+ )
1039
+
1040
+ if (idleTimeoutMs) {
1041
+ SESSION_REPLAY_ACTIVITY_EVENTS.forEach((eventName) => {
1042
+ window.addEventListener(eventName, resetIdleTimer, { passive: true })
1043
+ })
1044
+ resetIdleTimer()
1045
+ }
1046
+
1047
+ this.sessionReplayActions = {
1048
+ stop: stopSessionReplay,
1049
+ flush: async () => {
1050
+ await flush()
1051
+ },
1052
+ }
1053
+
1054
+ return this.sessionReplayActions
1055
+ }
1056
+
1057
+ private shouldSampleSessionReplay(sampleRate?: number): boolean {
1058
+ if (typeof sampleRate !== 'number') {
1059
+ return true
1060
+ }
1061
+
1062
+ if (sampleRate <= 0) {
1063
+ return false
1064
+ }
1065
+
1066
+ if (sampleRate >= 1) {
1067
+ return true
1068
+ }
1069
+
1070
+ return Math.random() < sampleRate
1071
+ }
1072
+
1073
+ private getSessionReplayPrivacy(privacy: unknown): SessionReplayPrivacy {
1074
+ return SESSION_REPLAY_PRIVACY_VALUES.includes(
1075
+ privacy as SessionReplayPrivacy,
1076
+ )
1077
+ ? (privacy as SessionReplayPrivacy)
1078
+ : DEFAULT_SESSION_REPLAY_PRIVACY
1079
+ }
1080
+
707
1081
  /**
708
1082
  * Gets the API base URL (without /log suffix).
709
1083
  */
@@ -812,6 +1186,371 @@ export class Lib {
812
1186
  return true
813
1187
  }
814
1188
 
1189
+ private getSessionReplayUrl(): string {
1190
+ const replayOption = this.getSessionReplayPreloadOption()
1191
+ if (
1192
+ replayOption &&
1193
+ typeof replayOption === 'object' &&
1194
+ replayOption.rrwebUrl
1195
+ ) {
1196
+ return replayOption.rrwebUrl
1197
+ }
1198
+
1199
+ return this.getDefaultSessionReplayUrl()
1200
+ }
1201
+
1202
+ private getSessionReplayPreloadOption(): SessionReplayPreloadOption | undefined {
1203
+ return this.options?.preloadSessionReplay
1204
+ }
1205
+
1206
+ private getDefaultSessionReplayUrl(): string {
1207
+ if (!isInBrowser()) {
1208
+ return DEFAULT_RRWEB_URL
1209
+ }
1210
+
1211
+ const trackerScript = this.getTrackerScript()
1212
+
1213
+ if (trackerScript?.src) {
1214
+ const { hostname, pathname } = new URL(trackerScript.src)
1215
+ if (
1216
+ hostname === 'swetrix.org' &&
1217
+ /^\/swetrix(\.min)?\.js$/i.test(pathname)
1218
+ ) {
1219
+ return DEFAULT_RRWEB_URL
1220
+ }
1221
+
1222
+ return new URL(DEFAULT_RRWEB_FILE, trackerScript.src).toString()
1223
+ }
1224
+
1225
+ return DEFAULT_RRWEB_URL
1226
+ }
1227
+
1228
+ private getTrackerScript(): HTMLScriptElement | undefined {
1229
+ const trackerScript = Array.from(document.scripts).find((script) => {
1230
+ if (!script.src) {
1231
+ return false
1232
+ }
1233
+
1234
+ try {
1235
+ const { pathname } = new URL(script.src)
1236
+ return /(^|\/)swetrix(\.min)?\.js$/i.test(pathname)
1237
+ } catch {
1238
+ return false
1239
+ }
1240
+ })
1241
+
1242
+ return trackerScript
1243
+ }
1244
+
1245
+ private preloadSessionReplay(): Promise<void> {
1246
+ if (!isInBrowser()) {
1247
+ return Promise.resolve()
1248
+ }
1249
+
1250
+ if (window.rrweb?.record) {
1251
+ return Promise.resolve()
1252
+ }
1253
+
1254
+ if (this.rrwebLoader) {
1255
+ return this.rrwebLoader
1256
+ }
1257
+
1258
+ if (window.__SWETRIX_RRWEB_LOADING__) {
1259
+ this.rrwebLoader = window.__SWETRIX_RRWEB_LOADING__
1260
+ void this.rrwebLoader.catch(() => {
1261
+ if (window.__SWETRIX_RRWEB_LOADING__ === this.rrwebLoader) {
1262
+ delete window.__SWETRIX_RRWEB_LOADING__
1263
+ }
1264
+ this.rrwebLoader = null
1265
+ })
1266
+ return this.rrwebLoader
1267
+ }
1268
+
1269
+ this.rrwebLoader = this.loadSessionReplayRecorder()
1270
+
1271
+ window.__SWETRIX_RRWEB_LOADING__ = this.rrwebLoader
1272
+ const loader = this.rrwebLoader
1273
+ void loader.catch(() => {
1274
+ if (window.__SWETRIX_RRWEB_LOADING__ === loader) {
1275
+ delete window.__SWETRIX_RRWEB_LOADING__
1276
+ }
1277
+ if (this.rrwebLoader === loader) {
1278
+ this.rrwebLoader = null
1279
+ }
1280
+ })
1281
+
1282
+ return this.rrwebLoader
1283
+ }
1284
+
1285
+ private async loadSessionReplayRecorder(): Promise<void> {
1286
+ const replayOption = this.getSessionReplayPreloadOption()
1287
+ const hasCustomReplayUrl =
1288
+ replayOption && typeof replayOption === 'object' && replayOption.rrwebUrl
1289
+
1290
+ if (hasCustomReplayUrl || this.getTrackerScript()) {
1291
+ await this.loadSessionReplayScript(this.getSessionReplayUrl())
1292
+ return
1293
+ }
1294
+
1295
+ if (await this.loadSessionReplayPackage()) {
1296
+ return
1297
+ }
1298
+
1299
+ await this.loadSessionReplayScript(this.getDefaultSessionReplayUrl())
1300
+ }
1301
+
1302
+ private async loadSessionReplayPackage(): Promise<boolean> {
1303
+ try {
1304
+ const rrwebModule = (await import('rrweb')) as RrwebModule
1305
+ const rrweb = rrwebModule.record ? rrwebModule : rrwebModule.default
1306
+
1307
+ if (!rrweb?.record) {
1308
+ return false
1309
+ }
1310
+
1311
+ window.rrweb = rrweb
1312
+ return true
1313
+ } catch {
1314
+ return false
1315
+ }
1316
+ }
1317
+
1318
+ private loadSessionReplayScript(url: string): Promise<void> {
1319
+ return new Promise<void>((resolve, reject) => {
1320
+ const script = document.createElement('script')
1321
+ script.async = true
1322
+ script.src = url
1323
+ script.crossOrigin = 'anonymous'
1324
+ script.onload = () => resolve()
1325
+ script.onerror = () => reject(new Error('Failed to load rrweb'))
1326
+ document.head.appendChild(script)
1327
+ })
1328
+ }
1329
+
1330
+ private getSessionReplayRecordOptions(
1331
+ privacy: unknown,
1332
+ userOptions: RrwebRecordOptions | undefined,
1333
+ emit: RrwebEmit,
1334
+ recordIframes: boolean,
1335
+ maskAllText?: boolean,
1336
+ ): RrwebRecordOptions {
1337
+ const hasUserSampling =
1338
+ userOptions &&
1339
+ Object.prototype.hasOwnProperty.call(userOptions, 'sampling')
1340
+ const hasUserSlimDOMOptions =
1341
+ userOptions &&
1342
+ Object.prototype.hasOwnProperty.call(userOptions, 'slimDOMOptions')
1343
+ const sampling =
1344
+ typeof userOptions?.sampling === 'object' && userOptions.sampling !== null
1345
+ ? {
1346
+ ...DEFAULT_SESSION_REPLAY_SAMPLING,
1347
+ ...(userOptions.sampling as Record<string, unknown>),
1348
+ }
1349
+ : hasUserSampling
1350
+ ? userOptions?.sampling
1351
+ : DEFAULT_SESSION_REPLAY_SAMPLING
1352
+ const slimDOMOptions =
1353
+ typeof userOptions?.slimDOMOptions === 'object' &&
1354
+ userOptions.slimDOMOptions !== null
1355
+ ? {
1356
+ ...DEFAULT_SESSION_REPLAY_SLIM_DOM_OPTIONS,
1357
+ ...(userOptions.slimDOMOptions as Record<string, unknown>),
1358
+ }
1359
+ : hasUserSlimDOMOptions
1360
+ ? userOptions?.slimDOMOptions
1361
+ : DEFAULT_SESSION_REPLAY_SLIM_DOM_OPTIONS
1362
+ const options: RrwebRecordOptions = {
1363
+ recordCanvas: false,
1364
+ recordCrossOriginIframes: false,
1365
+ collectFonts: false,
1366
+ inlineImages: false,
1367
+ ...userOptions,
1368
+ sampling,
1369
+ slimDOMOptions,
1370
+ emit,
1371
+ }
1372
+
1373
+ const maskInputOptions =
1374
+ typeof options.maskInputOptions === 'object' &&
1375
+ options.maskInputOptions !== null
1376
+ ? (options.maskInputOptions as Record<string, unknown>)
1377
+ : {}
1378
+
1379
+ const resolvedPrivacy = this.getSessionReplayPrivacy(privacy)
1380
+ const defaultBlockSelector = recordIframes ? undefined : 'iframe'
1381
+ const resolvedMaskAllText =
1382
+ typeof maskAllText === 'boolean'
1383
+ ? maskAllText
1384
+ : resolvedPrivacy === 'total'
1385
+ const textMaskingOptions = resolvedMaskAllText
1386
+ ? { maskTextSelector: '*' }
1387
+ : {}
1388
+
1389
+ if (resolvedPrivacy === 'total') {
1390
+ return {
1391
+ ...options,
1392
+ ...textMaskingOptions,
1393
+ maskAllInputs: true,
1394
+ blockSelector: this.mergeSelectors(
1395
+ this.mergeSelectors(options.blockSelector, defaultBlockSelector),
1396
+ 'img, picture, video, audio, canvas, svg',
1397
+ ),
1398
+ recordCanvas: false,
1399
+ inlineImages: false,
1400
+ emit,
1401
+ }
1402
+ }
1403
+
1404
+ if (resolvedPrivacy === 'normal') {
1405
+ return {
1406
+ ...options,
1407
+ ...textMaskingOptions,
1408
+ maskAllInputs: true,
1409
+ blockSelector: this.mergeSelectors(
1410
+ options.blockSelector,
1411
+ defaultBlockSelector,
1412
+ ),
1413
+ emit,
1414
+ }
1415
+ }
1416
+
1417
+ return {
1418
+ ...options,
1419
+ ...textMaskingOptions,
1420
+ blockSelector: this.mergeSelectors(
1421
+ options.blockSelector,
1422
+ defaultBlockSelector,
1423
+ ),
1424
+ maskInputOptions: {
1425
+ ...maskInputOptions,
1426
+ password: true,
1427
+ },
1428
+ emit,
1429
+ }
1430
+ }
1431
+
1432
+ private mergeSelectors(
1433
+ existing: unknown,
1434
+ required?: string,
1435
+ ): string | undefined {
1436
+ if (!required) {
1437
+ return typeof existing === 'string' ? existing : undefined
1438
+ }
1439
+
1440
+ if (typeof existing === 'string' && existing.trim()) {
1441
+ return `${existing}, ${required}`
1442
+ }
1443
+
1444
+ return required
1445
+ }
1446
+
1447
+ private createReplayId(): string {
1448
+ if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
1449
+ return crypto.randomUUID()
1450
+ }
1451
+
1452
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
1453
+ }
1454
+
1455
+ private async sendSessionReplayStart(
1456
+ replayId: string,
1457
+ privacy: SessionReplayPrivacy,
1458
+ ): Promise<SessionReplayStartResponse | null> {
1459
+ try {
1460
+ const apiBase = this.getApiBase()
1461
+ const response = await fetch(`${apiBase}/log/session-replay/start`, {
1462
+ method: 'POST',
1463
+ headers: {
1464
+ 'Content-Type': 'application/json',
1465
+ },
1466
+ body: JSON.stringify({
1467
+ pid: this.projectID,
1468
+ replayId,
1469
+ privacy,
1470
+ pg:
1471
+ this.activePage ||
1472
+ getPath({
1473
+ hash: this.pageViewsOptions?.hash,
1474
+ search: this.pageViewsOptions?.search,
1475
+ }),
1476
+ lc: getLocale(),
1477
+ tz: getTimezone(),
1478
+ profileId: this.options?.profileId,
1479
+ }),
1480
+ })
1481
+
1482
+ if (!response.ok) {
1483
+ return null
1484
+ }
1485
+
1486
+ try {
1487
+ const result = (await response.json()) as Partial<{
1488
+ replayId: unknown
1489
+ nextChunkIndex: unknown
1490
+ }>
1491
+ const resolvedReplayId =
1492
+ typeof result.replayId === 'string' && result.replayId
1493
+ ? result.replayId
1494
+ : replayId
1495
+ const resolvedChunkIndex =
1496
+ typeof result.nextChunkIndex === 'number' &&
1497
+ Number.isFinite(result.nextChunkIndex) &&
1498
+ result.nextChunkIndex >= 0
1499
+ ? Math.floor(result.nextChunkIndex)
1500
+ : 0
1501
+
1502
+ return {
1503
+ replayId: resolvedReplayId,
1504
+ nextChunkIndex: resolvedChunkIndex,
1505
+ }
1506
+ } catch {
1507
+ return {
1508
+ replayId,
1509
+ nextChunkIndex: 0,
1510
+ }
1511
+ }
1512
+ } catch {
1513
+ return null
1514
+ }
1515
+ }
1516
+
1517
+ private async sendSessionReplayChunk(
1518
+ replayId: string,
1519
+ privacy: SessionReplayPrivacy,
1520
+ chunkIndex: number,
1521
+ events: RrwebEvent[],
1522
+ useBeacon: boolean,
1523
+ ): Promise<void> {
1524
+ const apiBase = this.getApiBase()
1525
+ const url = `${apiBase}/log/session-replay/chunk`
1526
+ const payload = JSON.stringify({
1527
+ pid: this.projectID,
1528
+ replayId,
1529
+ privacy,
1530
+ chunkIndex,
1531
+ events,
1532
+ })
1533
+
1534
+ if (useBeacon && typeof navigator.sendBeacon === 'function') {
1535
+ const sent = navigator.sendBeacon(
1536
+ url,
1537
+ new Blob([payload], { type: 'application/json' }),
1538
+ )
1539
+ if (sent) return
1540
+ }
1541
+
1542
+ try {
1543
+ await fetch(url, {
1544
+ method: 'POST',
1545
+ headers: {
1546
+ 'Content-Type': 'application/json',
1547
+ },
1548
+ keepalive: useBeacon,
1549
+ body: payload,
1550
+ })
1551
+ } catch {}
1552
+ }
1553
+
815
1554
  private async sendRequest(path: string, body: object): Promise<void> {
816
1555
  const host = this.options?.apiURL || DEFAULT_API_HOST
817
1556
  await fetch(`${host}/${path}`, {