swetrix 4.1.0 → 4.3.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
@@ -5,6 +5,7 @@ import {
5
5
  getLocale,
6
6
  getTimezone,
7
7
  getReferrer,
8
+ getQueryString,
8
9
  getUTMCampaign,
9
10
  getUTMMedium,
10
11
  getUTMSource,
@@ -13,6 +14,33 @@ import {
13
14
  getPath,
14
15
  } from './utils.js'
15
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
+ declare global {
38
+ interface Window {
39
+ rrweb?: RrwebGlobal
40
+ __SWETRIX_RRWEB_LOADING__?: Promise<void>
41
+ }
42
+ }
43
+
16
44
  export interface LibOptions {
17
45
  /**
18
46
  * When set to `true`, localhost events will be sent to server.
@@ -38,6 +66,11 @@ export interface LibOptions {
38
66
  * If set, it will be used for all pageviews and events unless overridden per-call.
39
67
  */
40
68
  profileId?: string
69
+
70
+ /**
71
+ * Preload session replay recorder code. Recording only starts after calling startSessionReplay().
72
+ */
73
+ preloadSessionReplay?: SessionReplayPreloadOption
41
74
  }
42
75
 
43
76
  export interface TrackEventOptions {
@@ -68,6 +101,13 @@ export interface IPageViewPayload {
68
101
  co?: string
69
102
  pg?: string | null
70
103
 
104
+ /**
105
+ * Raw URL query string of the landing page (without the leading `?`).
106
+ * Used server-side to recover the traffic source from ad/social click
107
+ * IDs (gclid, fbclid, etc.) when the browser stripped the referrer.
108
+ */
109
+ qs?: string
110
+
71
111
  /** Pageview-related metadata object with string values. */
72
112
  meta?: {
73
113
  [key: string]: string | number | boolean | null | undefined
@@ -158,6 +198,26 @@ export interface ErrorActions {
158
198
  stop: () => void
159
199
  }
160
200
 
201
+ const SESSION_REPLAY_PRIVACY_VALUES = ['total', 'normal', 'none'] as const
202
+
203
+ export type SessionReplayPrivacy =
204
+ (typeof SESSION_REPLAY_PRIVACY_VALUES)[number]
205
+
206
+ export interface SessionReplayOptions {
207
+ privacy?: SessionReplayPrivacy
208
+ rrweb?: RrwebRecordOptions
209
+ flushIntervalMs?: number
210
+ maxEventsPerChunk?: number
211
+ sampleRate?: number
212
+ maxDurationMs?: number
213
+ idleTimeoutMs?: number
214
+ }
215
+
216
+ export interface SessionReplayActions {
217
+ stop: () => Promise<void>
218
+ flush: () => Promise<void>
219
+ }
220
+
161
221
  export interface PageData {
162
222
  /** Current URL path. */
163
223
  path: string
@@ -220,8 +280,26 @@ export const defaultActions = {
220
280
  stop() {},
221
281
  }
222
282
 
283
+ export const defaultSessionReplayActions: SessionReplayActions = {
284
+ async stop() {},
285
+ async flush() {},
286
+ }
287
+
223
288
  const DEFAULT_API_HOST = 'https://api.swetrix.com/log'
224
289
  const DEFAULT_API_BASE = 'https://api.swetrix.com'
290
+ const DEFAULT_RRWEB_FILE = 'replaylibrary.min.js'
291
+ const DEFAULT_RRWEB_URL = `https://cdn.jsdelivr.net/npm/swetrix@latest/dist/${DEFAULT_RRWEB_FILE}`
292
+ const DEFAULT_SESSION_REPLAY_FLUSH_INTERVAL = 5000
293
+ const DEFAULT_SESSION_REPLAY_MAX_EVENTS = 100
294
+ const DEFAULT_SESSION_REPLAY_PRIVACY: SessionReplayPrivacy = 'total'
295
+ const SESSION_REPLAY_ACTIVITY_EVENTS = [
296
+ 'click',
297
+ 'keydown',
298
+ 'mousedown',
299
+ 'mousemove',
300
+ 'scroll',
301
+ 'touchstart',
302
+ ] as const
225
303
 
226
304
  // Default cache duration: 5 minutes
227
305
  const DEFAULT_CACHE_DURATION = 5 * 60 * 1000
@@ -234,11 +312,18 @@ export class Lib {
234
312
  private activePage: string | null = null
235
313
  private errorListenerExists = false
236
314
  private cachedData: CachedData | null = null
315
+ private rrwebLoader: Promise<void> | null = null
316
+ private sessionReplayActions: SessionReplayActions | null = null
317
+ private sessionReplayInitPromise: Promise<SessionReplayActions> | null = null
237
318
 
238
319
  constructor(private projectID: string, private options?: LibOptions) {
239
320
  this.trackPathChange = this.trackPathChange.bind(this)
240
321
  this.heartbeat = this.heartbeat.bind(this)
241
322
  this.captureError = this.captureError.bind(this)
323
+
324
+ if (this.getSessionReplayPreloadOption()) {
325
+ void this.preloadSessionReplay().catch(() => undefined)
326
+ }
242
327
  }
243
328
 
244
329
  captureError(event: ErrorEvent): void {
@@ -346,6 +431,7 @@ export class Lib {
346
431
  ca: getUTMCampaign(),
347
432
  te: getUTMTerm(),
348
433
  co: getUTMContent(),
434
+ qs: getQueryString(),
349
435
  profileId: event.profileId ?? this.options?.profileId,
350
436
  }
351
437
  await this.sendRequest('custom', data)
@@ -361,7 +447,7 @@ export class Lib {
361
447
  }
362
448
 
363
449
  this.pageViewsOptions = options
364
- let interval: NodeJS.Timeout
450
+ let interval: ReturnType<typeof setInterval>
365
451
 
366
452
  if (!options?.unique) {
367
453
  interval = setInterval(this.trackPathChange, 2000)
@@ -422,10 +508,10 @@ export class Lib {
422
508
  }
423
509
 
424
510
  /**
425
- * Fetches all feature flags and experiments for the project.
426
- * Results are cached for 5 minutes by default.
511
+ * Fetches all feature flags for the project.
512
+ * Results are cached for 5 minutes by default and share a cache with experiments.
427
513
  *
428
- * @param options - Options for evaluating feature flags.
514
+ * @param options - Options for evaluating feature flags (`profileId` only).
429
515
  * @param forceRefresh - If true, bypasses the cache and fetches fresh data.
430
516
  * @returns A promise that resolves to a record of flag keys to boolean values.
431
517
  */
@@ -503,8 +589,8 @@ export class Lib {
503
589
  * Gets the value of a single feature flag.
504
590
  *
505
591
  * @param key - The feature flag key.
506
- * @param options - Options for evaluating the feature flag.
507
- * @param defaultValue - Default value to return if the flag is not found. Defaults to false.
592
+ * @param options - Options for evaluating the feature flag (`profileId` only).
593
+ * @param defaultValue - Optional default value to return if the flag is not found. Defaults to false.
508
594
  * @returns A promise that resolves to the boolean value of the flag.
509
595
  */
510
596
  async getFeatureFlag(key: string, options?: FeatureFlagsOptions, defaultValue: boolean = false): Promise<boolean> {
@@ -520,16 +606,16 @@ export class Lib {
520
606
  }
521
607
 
522
608
  /**
523
- * Fetches all A/B test experiments for the project.
609
+ * Fetches variant assignments for running A/B test experiments returned by feature flag evaluation.
524
610
  * Results are cached for 5 minutes by default (shared cache with feature flags).
525
611
  *
526
- * @param options - Options for evaluating experiments.
612
+ * @param options - Options for evaluating experiments (`profileId` only).
527
613
  * @param forceRefresh - If true, bypasses the cache and fetches fresh data.
528
614
  * @returns A promise that resolves to a record of experiment IDs to variant keys.
529
615
  *
530
616
  * @example
531
617
  * ```typescript
532
- * const experiments = await getExperiments()
618
+ * const experiments = await getExperiments({ profileId: 'user-123' })
533
619
  * // experiments = { 'exp-123': 'variant-a', 'exp-456': 'control' }
534
620
  * ```
535
621
  */
@@ -562,13 +648,16 @@ export class Lib {
562
648
  * Gets the variant key for a specific A/B test experiment.
563
649
  *
564
650
  * @param experimentId - The experiment ID.
565
- * @param options - Options for evaluating the experiment.
566
- * @param defaultVariant - Default variant key to return if the experiment is not found. Defaults to null.
651
+ * @param options - Options for evaluating the experiment (`profileId` only).
652
+ * @param defaultVariant - Optional default variant key to return if the experiment is not found. Defaults to null.
567
653
  * @returns A promise that resolves to the variant key assigned to this user, or defaultVariant if not found.
568
654
  *
569
655
  * @example
570
656
  * ```typescript
571
- * const variant = await getExperiment('checkout-redesign')
657
+ * const variant = await getExperiment('checkout-redesign', { profileId: 'user-123' })
658
+ *
659
+ * // Optional fallback variant:
660
+ * const variantWithFallback = await getExperiment('checkout-redesign', undefined, 'control')
572
661
  *
573
662
  * if (variant === 'new-checkout') {
574
663
  * // Show new checkout flow
@@ -695,6 +784,215 @@ export class Lib {
695
784
  }
696
785
  }
697
786
 
787
+ async startSessionReplay(
788
+ options: SessionReplayOptions = {},
789
+ ): Promise<SessionReplayActions> {
790
+ if (this.sessionReplayActions) {
791
+ return this.sessionReplayActions
792
+ }
793
+
794
+ if (this.sessionReplayInitPromise) {
795
+ return this.sessionReplayInitPromise
796
+ }
797
+
798
+ const initPromise = this.initialiseSessionReplay(options)
799
+ this.sessionReplayInitPromise = initPromise
800
+
801
+ try {
802
+ return await initPromise
803
+ } finally {
804
+ if (this.sessionReplayInitPromise === initPromise) {
805
+ this.sessionReplayInitPromise = null
806
+ }
807
+ }
808
+ }
809
+
810
+ private async initialiseSessionReplay(
811
+ options: SessionReplayOptions,
812
+ ): Promise<SessionReplayActions> {
813
+ if (this.sessionReplayActions) {
814
+ return this.sessionReplayActions
815
+ }
816
+
817
+ if (!this.canTrack()) {
818
+ return defaultSessionReplayActions
819
+ }
820
+
821
+ if (!this.shouldSampleSessionReplay(options.sampleRate)) {
822
+ return defaultSessionReplayActions
823
+ }
824
+
825
+ try {
826
+ await this.preloadSessionReplay()
827
+ } catch {
828
+ return defaultSessionReplayActions
829
+ }
830
+
831
+ const rrweb = window.rrweb
832
+ if (!rrweb?.record) {
833
+ return defaultSessionReplayActions
834
+ }
835
+
836
+ const privacy = this.getSessionReplayPrivacy(options.privacy)
837
+ const replayId = this.createReplayId()
838
+ const started = await this.sendSessionReplayStart(replayId, privacy)
839
+
840
+ if (!started) {
841
+ return defaultSessionReplayActions
842
+ }
843
+
844
+ const flushIntervalMs =
845
+ typeof options.flushIntervalMs === 'number' && options.flushIntervalMs > 0
846
+ ? options.flushIntervalMs
847
+ : DEFAULT_SESSION_REPLAY_FLUSH_INTERVAL
848
+ const maxEventsPerChunk =
849
+ typeof options.maxEventsPerChunk === 'number' &&
850
+ options.maxEventsPerChunk > 0
851
+ ? Math.floor(options.maxEventsPerChunk)
852
+ : DEFAULT_SESSION_REPLAY_MAX_EVENTS
853
+ const maxDurationMs =
854
+ typeof options.maxDurationMs === 'number' && options.maxDurationMs > 0
855
+ ? options.maxDurationMs
856
+ : null
857
+ const idleTimeoutMs =
858
+ typeof options.idleTimeoutMs === 'number' && options.idleTimeoutMs > 0
859
+ ? options.idleTimeoutMs
860
+ : null
861
+
862
+ let chunkIndex = 0
863
+ let stopped = false
864
+ let events: RrwebEvent[] = []
865
+ let flushing = Promise.resolve()
866
+ let maxDurationTimer: ReturnType<typeof setTimeout> | undefined
867
+ let idleTimer: ReturnType<typeof setTimeout> | undefined
868
+
869
+ const flush = async (useBeacon = false) => {
870
+ if (!events.length) return
871
+
872
+ const chunk = events
873
+ events = []
874
+ const currentChunkIndex = chunkIndex++
875
+
876
+ flushing = flushing
877
+ .catch(() => undefined)
878
+ .then(() =>
879
+ this.sendSessionReplayChunk(
880
+ replayId,
881
+ privacy,
882
+ currentChunkIndex,
883
+ chunk,
884
+ useBeacon,
885
+ ),
886
+ )
887
+
888
+ await flushing
889
+ }
890
+
891
+ const userEmit = options.rrweb?.emit
892
+ const recordOptions = this.getSessionReplayRecordOptions(
893
+ privacy,
894
+ options.rrweb,
895
+ (event) => {
896
+ try {
897
+ userEmit?.(event)
898
+ } catch {}
899
+
900
+ events.push(event)
901
+ if (events.length >= maxEventsPerChunk) {
902
+ void flush()
903
+ }
904
+ },
905
+ )
906
+
907
+ const stopRecording = rrweb.record(recordOptions)
908
+ const timer = setInterval(() => void flush(), flushIntervalMs)
909
+ const flushOnPageExit = () => void flush(true)
910
+ const flushOnHidden = () => {
911
+ if (document.visibilityState === 'hidden') {
912
+ void flush(true)
913
+ }
914
+ }
915
+ const clearIdleTimer = () => {
916
+ if (idleTimer) {
917
+ clearTimeout(idleTimer)
918
+ idleTimer = undefined
919
+ }
920
+ }
921
+ const stopSessionReplay = async () => {
922
+ if (stopped) return
923
+ stopped = true
924
+ clearInterval(timer)
925
+ if (maxDurationTimer) {
926
+ clearTimeout(maxDurationTimer)
927
+ }
928
+ clearIdleTimer()
929
+ window.removeEventListener('pagehide', flushOnPageExit)
930
+ document.removeEventListener('visibilitychange', flushOnHidden)
931
+ SESSION_REPLAY_ACTIVITY_EVENTS.forEach((eventName) => {
932
+ window.removeEventListener(eventName, resetIdleTimer)
933
+ })
934
+ stopRecording?.()
935
+ await flush()
936
+ this.sessionReplayActions = null
937
+ this.sessionReplayInitPromise = null
938
+ }
939
+ const resetIdleTimer = () => {
940
+ if (!idleTimeoutMs || stopped) return
941
+ clearIdleTimer()
942
+ idleTimer = setTimeout(() => void stopSessionReplay(), idleTimeoutMs)
943
+ }
944
+
945
+ window.addEventListener('pagehide', flushOnPageExit)
946
+ document.addEventListener('visibilitychange', flushOnHidden)
947
+
948
+ if (maxDurationMs) {
949
+ maxDurationTimer = setTimeout(
950
+ () => void stopSessionReplay(),
951
+ maxDurationMs,
952
+ )
953
+ }
954
+
955
+ if (idleTimeoutMs) {
956
+ SESSION_REPLAY_ACTIVITY_EVENTS.forEach((eventName) => {
957
+ window.addEventListener(eventName, resetIdleTimer, { passive: true })
958
+ })
959
+ resetIdleTimer()
960
+ }
961
+
962
+ this.sessionReplayActions = {
963
+ stop: stopSessionReplay,
964
+ flush: async () => {
965
+ await flush()
966
+ },
967
+ }
968
+
969
+ return this.sessionReplayActions
970
+ }
971
+
972
+ private shouldSampleSessionReplay(sampleRate?: number): boolean {
973
+ if (typeof sampleRate !== 'number') {
974
+ return true
975
+ }
976
+
977
+ if (sampleRate <= 0) {
978
+ return false
979
+ }
980
+
981
+ if (sampleRate >= 1) {
982
+ return true
983
+ }
984
+
985
+ return Math.random() < sampleRate
986
+ }
987
+
988
+ private getSessionReplayPrivacy(privacy: unknown): SessionReplayPrivacy {
989
+ return SESSION_REPLAY_PRIVACY_VALUES.includes(
990
+ privacy as SessionReplayPrivacy,
991
+ )
992
+ ? (privacy as SessionReplayPrivacy)
993
+ : DEFAULT_SESSION_REPLAY_PRIVACY
994
+ }
995
+
698
996
  /**
699
997
  * Gets the API base URL (without /log suffix).
700
998
  */
@@ -767,6 +1065,7 @@ export class Lib {
767
1065
  ca: getUTMCampaign(),
768
1066
  te: getUTMTerm(),
769
1067
  co: getUTMContent(),
1068
+ qs: getQueryString(),
770
1069
  profileId: this.options?.profileId,
771
1070
  ...payload,
772
1071
  }
@@ -802,6 +1101,284 @@ export class Lib {
802
1101
  return true
803
1102
  }
804
1103
 
1104
+ private getSessionReplayUrl(): string {
1105
+ const replayOption = this.getSessionReplayPreloadOption()
1106
+ if (
1107
+ replayOption &&
1108
+ typeof replayOption === 'object' &&
1109
+ replayOption.rrwebUrl
1110
+ ) {
1111
+ return replayOption.rrwebUrl
1112
+ }
1113
+
1114
+ return this.getDefaultSessionReplayUrl()
1115
+ }
1116
+
1117
+ private getSessionReplayPreloadOption(): SessionReplayPreloadOption | undefined {
1118
+ return this.options?.preloadSessionReplay
1119
+ }
1120
+
1121
+ private getDefaultSessionReplayUrl(): string {
1122
+ if (!isInBrowser()) {
1123
+ return DEFAULT_RRWEB_URL
1124
+ }
1125
+
1126
+ const trackerScript = this.getTrackerScript()
1127
+
1128
+ if (trackerScript?.src) {
1129
+ const { hostname, pathname } = new URL(trackerScript.src)
1130
+ if (
1131
+ hostname === 'swetrix.org' &&
1132
+ /^\/swetrix(\.min)?\.js$/i.test(pathname)
1133
+ ) {
1134
+ return DEFAULT_RRWEB_URL
1135
+ }
1136
+
1137
+ return new URL(DEFAULT_RRWEB_FILE, trackerScript.src).toString()
1138
+ }
1139
+
1140
+ return DEFAULT_RRWEB_URL
1141
+ }
1142
+
1143
+ private getTrackerScript(): HTMLScriptElement | undefined {
1144
+ const trackerScript = Array.from(document.scripts).find((script) => {
1145
+ if (!script.src) {
1146
+ return false
1147
+ }
1148
+
1149
+ try {
1150
+ const { pathname } = new URL(script.src)
1151
+ return /(^|\/)swetrix(\.min)?\.js$/i.test(pathname)
1152
+ } catch {
1153
+ return false
1154
+ }
1155
+ })
1156
+
1157
+ return trackerScript
1158
+ }
1159
+
1160
+ private preloadSessionReplay(): Promise<void> {
1161
+ if (!isInBrowser()) {
1162
+ return Promise.resolve()
1163
+ }
1164
+
1165
+ if (window.rrweb?.record) {
1166
+ return Promise.resolve()
1167
+ }
1168
+
1169
+ if (this.rrwebLoader) {
1170
+ return this.rrwebLoader
1171
+ }
1172
+
1173
+ if (window.__SWETRIX_RRWEB_LOADING__) {
1174
+ this.rrwebLoader = window.__SWETRIX_RRWEB_LOADING__
1175
+ void this.rrwebLoader.catch(() => {
1176
+ if (window.__SWETRIX_RRWEB_LOADING__ === this.rrwebLoader) {
1177
+ delete window.__SWETRIX_RRWEB_LOADING__
1178
+ }
1179
+ this.rrwebLoader = null
1180
+ })
1181
+ return this.rrwebLoader
1182
+ }
1183
+
1184
+ this.rrwebLoader = this.loadSessionReplayRecorder()
1185
+
1186
+ window.__SWETRIX_RRWEB_LOADING__ = this.rrwebLoader
1187
+ const loader = this.rrwebLoader
1188
+ void loader.catch(() => {
1189
+ if (window.__SWETRIX_RRWEB_LOADING__ === loader) {
1190
+ delete window.__SWETRIX_RRWEB_LOADING__
1191
+ }
1192
+ if (this.rrwebLoader === loader) {
1193
+ this.rrwebLoader = null
1194
+ }
1195
+ })
1196
+
1197
+ return this.rrwebLoader
1198
+ }
1199
+
1200
+ private async loadSessionReplayRecorder(): Promise<void> {
1201
+ const replayOption = this.getSessionReplayPreloadOption()
1202
+ const hasCustomReplayUrl =
1203
+ replayOption && typeof replayOption === 'object' && replayOption.rrwebUrl
1204
+
1205
+ if (hasCustomReplayUrl || this.getTrackerScript()) {
1206
+ await this.loadSessionReplayScript(this.getSessionReplayUrl())
1207
+ return
1208
+ }
1209
+
1210
+ if (await this.loadSessionReplayPackage()) {
1211
+ return
1212
+ }
1213
+
1214
+ await this.loadSessionReplayScript(this.getDefaultSessionReplayUrl())
1215
+ }
1216
+
1217
+ private async loadSessionReplayPackage(): Promise<boolean> {
1218
+ try {
1219
+ const rrwebModule = (await import('rrweb')) as RrwebModule
1220
+ const rrweb = rrwebModule.record ? rrwebModule : rrwebModule.default
1221
+
1222
+ if (!rrweb?.record) {
1223
+ return false
1224
+ }
1225
+
1226
+ window.rrweb = rrweb
1227
+ return true
1228
+ } catch {
1229
+ return false
1230
+ }
1231
+ }
1232
+
1233
+ private loadSessionReplayScript(url: string): Promise<void> {
1234
+ return new Promise<void>((resolve, reject) => {
1235
+ const script = document.createElement('script')
1236
+ script.async = true
1237
+ script.src = url
1238
+ script.crossOrigin = 'anonymous'
1239
+ script.onload = () => resolve()
1240
+ script.onerror = () => reject(new Error('Failed to load rrweb'))
1241
+ document.head.appendChild(script)
1242
+ })
1243
+ }
1244
+
1245
+ private getSessionReplayRecordOptions(
1246
+ privacy: unknown,
1247
+ userOptions: RrwebRecordOptions | undefined,
1248
+ emit: RrwebEmit,
1249
+ ): RrwebRecordOptions {
1250
+ const options: RrwebRecordOptions = {
1251
+ ...userOptions,
1252
+ emit,
1253
+ }
1254
+
1255
+ const maskInputOptions =
1256
+ typeof options.maskInputOptions === 'object' &&
1257
+ options.maskInputOptions !== null
1258
+ ? (options.maskInputOptions as Record<string, unknown>)
1259
+ : {}
1260
+
1261
+ const resolvedPrivacy = this.getSessionReplayPrivacy(privacy)
1262
+
1263
+ if (resolvedPrivacy === 'total') {
1264
+ return {
1265
+ ...options,
1266
+ maskAllInputs: true,
1267
+ maskTextSelector: '*',
1268
+ blockSelector: this.mergeSelectors(
1269
+ options.blockSelector,
1270
+ 'img, picture, video, audio, canvas, svg',
1271
+ ),
1272
+ recordCanvas: false,
1273
+ inlineImages: false,
1274
+ emit,
1275
+ }
1276
+ }
1277
+
1278
+ if (resolvedPrivacy === 'normal') {
1279
+ return {
1280
+ ...options,
1281
+ maskAllInputs: true,
1282
+ emit,
1283
+ }
1284
+ }
1285
+
1286
+ return {
1287
+ ...options,
1288
+ maskInputOptions: {
1289
+ ...maskInputOptions,
1290
+ password: true,
1291
+ },
1292
+ emit,
1293
+ }
1294
+ }
1295
+
1296
+ private mergeSelectors(existing: unknown, required: string): string {
1297
+ if (typeof existing === 'string' && existing.trim()) {
1298
+ return `${existing}, ${required}`
1299
+ }
1300
+
1301
+ return required
1302
+ }
1303
+
1304
+ private createReplayId(): string {
1305
+ if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
1306
+ return crypto.randomUUID()
1307
+ }
1308
+
1309
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
1310
+ }
1311
+
1312
+ private async sendSessionReplayStart(
1313
+ replayId: string,
1314
+ privacy: SessionReplayPrivacy,
1315
+ ): Promise<boolean> {
1316
+ try {
1317
+ const apiBase = this.getApiBase()
1318
+ const response = await fetch(`${apiBase}/log/session-replay/start`, {
1319
+ method: 'POST',
1320
+ headers: {
1321
+ 'Content-Type': 'application/json',
1322
+ },
1323
+ body: JSON.stringify({
1324
+ pid: this.projectID,
1325
+ replayId,
1326
+ privacy,
1327
+ pg:
1328
+ this.activePage ||
1329
+ getPath({
1330
+ hash: this.pageViewsOptions?.hash,
1331
+ search: this.pageViewsOptions?.search,
1332
+ }),
1333
+ lc: getLocale(),
1334
+ tz: getTimezone(),
1335
+ profileId: this.options?.profileId,
1336
+ }),
1337
+ })
1338
+
1339
+ return response.ok
1340
+ } catch {
1341
+ return false
1342
+ }
1343
+ }
1344
+
1345
+ private async sendSessionReplayChunk(
1346
+ replayId: string,
1347
+ privacy: SessionReplayPrivacy,
1348
+ chunkIndex: number,
1349
+ events: RrwebEvent[],
1350
+ useBeacon: boolean,
1351
+ ): Promise<void> {
1352
+ const apiBase = this.getApiBase()
1353
+ const url = `${apiBase}/log/session-replay/chunk`
1354
+ const payload = JSON.stringify({
1355
+ pid: this.projectID,
1356
+ replayId,
1357
+ privacy,
1358
+ chunkIndex,
1359
+ events,
1360
+ })
1361
+
1362
+ if (useBeacon && typeof navigator.sendBeacon === 'function') {
1363
+ const sent = navigator.sendBeacon(
1364
+ url,
1365
+ new Blob([payload], { type: 'application/json' }),
1366
+ )
1367
+ if (sent) return
1368
+ }
1369
+
1370
+ try {
1371
+ await fetch(url, {
1372
+ method: 'POST',
1373
+ headers: {
1374
+ 'Content-Type': 'application/json',
1375
+ },
1376
+ keepalive: useBeacon,
1377
+ body: payload,
1378
+ })
1379
+ } catch {}
1380
+ }
1381
+
805
1382
  private async sendRequest(path: string, body: object): Promise<void> {
806
1383
  const host = this.options?.apiURL || DEFAULT_API_HOST
807
1384
  await fetch(`${host}/${path}`, {