swetrix 4.2.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
@@ -14,6 +14,33 @@ 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
+ declare global {
38
+ interface Window {
39
+ rrweb?: RrwebGlobal
40
+ __SWETRIX_RRWEB_LOADING__?: Promise<void>
41
+ }
42
+ }
43
+
17
44
  export interface LibOptions {
18
45
  /**
19
46
  * When set to `true`, localhost events will be sent to server.
@@ -39,6 +66,11 @@ export interface LibOptions {
39
66
  * If set, it will be used for all pageviews and events unless overridden per-call.
40
67
  */
41
68
  profileId?: string
69
+
70
+ /**
71
+ * Preload session replay recorder code. Recording only starts after calling startSessionReplay().
72
+ */
73
+ preloadSessionReplay?: SessionReplayPreloadOption
42
74
  }
43
75
 
44
76
  export interface TrackEventOptions {
@@ -166,6 +198,26 @@ export interface ErrorActions {
166
198
  stop: () => void
167
199
  }
168
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
+
169
221
  export interface PageData {
170
222
  /** Current URL path. */
171
223
  path: string
@@ -228,8 +280,26 @@ export const defaultActions = {
228
280
  stop() {},
229
281
  }
230
282
 
283
+ export const defaultSessionReplayActions: SessionReplayActions = {
284
+ async stop() {},
285
+ async flush() {},
286
+ }
287
+
231
288
  const DEFAULT_API_HOST = 'https://api.swetrix.com/log'
232
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
233
303
 
234
304
  // Default cache duration: 5 minutes
235
305
  const DEFAULT_CACHE_DURATION = 5 * 60 * 1000
@@ -242,11 +312,18 @@ export class Lib {
242
312
  private activePage: string | null = null
243
313
  private errorListenerExists = false
244
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
245
318
 
246
319
  constructor(private projectID: string, private options?: LibOptions) {
247
320
  this.trackPathChange = this.trackPathChange.bind(this)
248
321
  this.heartbeat = this.heartbeat.bind(this)
249
322
  this.captureError = this.captureError.bind(this)
323
+
324
+ if (this.getSessionReplayPreloadOption()) {
325
+ void this.preloadSessionReplay().catch(() => undefined)
326
+ }
250
327
  }
251
328
 
252
329
  captureError(event: ErrorEvent): void {
@@ -370,7 +447,7 @@ export class Lib {
370
447
  }
371
448
 
372
449
  this.pageViewsOptions = options
373
- let interval: NodeJS.Timeout
450
+ let interval: ReturnType<typeof setInterval>
374
451
 
375
452
  if (!options?.unique) {
376
453
  interval = setInterval(this.trackPathChange, 2000)
@@ -431,10 +508,10 @@ export class Lib {
431
508
  }
432
509
 
433
510
  /**
434
- * Fetches all feature flags and experiments for the project.
435
- * 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.
436
513
  *
437
- * @param options - Options for evaluating feature flags.
514
+ * @param options - Options for evaluating feature flags (`profileId` only).
438
515
  * @param forceRefresh - If true, bypasses the cache and fetches fresh data.
439
516
  * @returns A promise that resolves to a record of flag keys to boolean values.
440
517
  */
@@ -512,8 +589,8 @@ export class Lib {
512
589
  * Gets the value of a single feature flag.
513
590
  *
514
591
  * @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.
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.
517
594
  * @returns A promise that resolves to the boolean value of the flag.
518
595
  */
519
596
  async getFeatureFlag(key: string, options?: FeatureFlagsOptions, defaultValue: boolean = false): Promise<boolean> {
@@ -529,16 +606,16 @@ export class Lib {
529
606
  }
530
607
 
531
608
  /**
532
- * Fetches all A/B test experiments for the project.
609
+ * Fetches variant assignments for running A/B test experiments returned by feature flag evaluation.
533
610
  * Results are cached for 5 minutes by default (shared cache with feature flags).
534
611
  *
535
- * @param options - Options for evaluating experiments.
612
+ * @param options - Options for evaluating experiments (`profileId` only).
536
613
  * @param forceRefresh - If true, bypasses the cache and fetches fresh data.
537
614
  * @returns A promise that resolves to a record of experiment IDs to variant keys.
538
615
  *
539
616
  * @example
540
617
  * ```typescript
541
- * const experiments = await getExperiments()
618
+ * const experiments = await getExperiments({ profileId: 'user-123' })
542
619
  * // experiments = { 'exp-123': 'variant-a', 'exp-456': 'control' }
543
620
  * ```
544
621
  */
@@ -571,13 +648,16 @@ export class Lib {
571
648
  * Gets the variant key for a specific A/B test experiment.
572
649
  *
573
650
  * @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.
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.
576
653
  * @returns A promise that resolves to the variant key assigned to this user, or defaultVariant if not found.
577
654
  *
578
655
  * @example
579
656
  * ```typescript
580
- * 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')
581
661
  *
582
662
  * if (variant === 'new-checkout') {
583
663
  * // Show new checkout flow
@@ -704,6 +784,215 @@ export class Lib {
704
784
  }
705
785
  }
706
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
+
707
996
  /**
708
997
  * Gets the API base URL (without /log suffix).
709
998
  */
@@ -812,6 +1101,284 @@ export class Lib {
812
1101
  return true
813
1102
  }
814
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
+
815
1382
  private async sendRequest(path: string, body: object): Promise<void> {
816
1383
  const host = this.options?.apiURL || DEFAULT_API_HOST
817
1384
  await fetch(`${host}/${path}`, {