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/README.md +245 -14
- package/dist/esnext/Lib.d.ts +79 -11
- package/dist/esnext/Lib.js +417 -12
- 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/esnext/utils.d.ts +9 -0
- package/dist/esnext/utils.js +20 -0
- package/dist/esnext/utils.js.map +1 -1
- package/dist/replaylibrary.min.js +173 -0
- package/dist/swetrix.cjs.js +463 -25
- package/dist/swetrix.cjs.js.map +1 -1
- package/dist/swetrix.es5.js +463 -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 +3 -1
- package/package.json +43 -40
- package/rollup.config.mjs +20 -0
- package/src/Lib.ts +589 -12
- package/src/index.ts +29 -14
- package/src/types/rrweb-shim.d.ts +11 -0
- package/src/utils.ts +22 -0
- package/tests/errors.test.ts +2 -9
- package/tests/events.test.ts +2 -9
- package/tests/experiments.test.ts +2 -9
- package/tests/initialisation.test.ts +5 -18
- package/tests/jsdomEnvironment.ts +20 -0
- package/tests/pageview.test.ts +3 -9
- package/tests/sessionReplay.test.ts +389 -0
- package/tests/testUtils.ts +27 -0
- package/tests/utils.test.ts +37 -114
- package/tsconfig.esnext.json +5 -1
- package/tsconfig.json +6 -1
- package/tsconfig.test.json +7 -0
- package/.github/funding.yml +0 -2
- package/.github/workflows/test.yml +0 -32
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:
|
|
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
|
|
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 -
|
|
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
|
|
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 -
|
|
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}`, {
|