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/README.md +84 -8
- package/dist/esnext/Lib.d.ts +77 -11
- package/dist/esnext/Lib.js +536 -11
- package/dist/esnext/Lib.js.map +1 -1
- package/dist/esnext/index.d.ts +18 -15
- package/dist/esnext/index.js +23 -15
- package/dist/esnext/index.js.map +1 -1
- package/dist/replaylibrary.min.js +173 -0
- package/dist/swetrix.cjs.js +563 -25
- package/dist/swetrix.cjs.js.map +1 -1
- package/dist/swetrix.es5.js +563 -26
- package/dist/swetrix.es5.js.map +1 -1
- package/dist/swetrix.js +1 -1
- package/dist/swetrix.js.map +1 -1
- package/jest.config.js +2 -0
- package/package.json +10 -7
- package/rollup.config.mjs +20 -0
- package/src/Lib.ts +751 -12
- package/src/index.ts +29 -14
- package/src/types/rrweb-shim.d.ts +11 -0
- package/tests/sessionReplay.test.ts +600 -0
- package/tsconfig.esnext.json +5 -1
- package/tsconfig.json +6 -1
- package/tsconfig.test.json +7 -0
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:
|
|
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
|
|
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 -
|
|
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
|
|
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 -
|
|
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}`, {
|