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/README.md +49 -8
- package/dist/esnext/Lib.d.ts +73 -11
- package/dist/esnext/Lib.js +414 -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 +441 -25
- package/dist/swetrix.cjs.js.map +1 -1
- package/dist/swetrix.es5.js +441 -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 +579 -12
- package/src/index.ts +29 -14
- package/src/types/rrweb-shim.d.ts +11 -0
- package/tests/sessionReplay.test.ts +389 -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,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:
|
|
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
|
|
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 -
|
|
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
|
|
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 -
|
|
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}`, {
|