@leanbase-giangnd/js 0.2.3 → 0.2.4
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/dist/index.cjs +174 -95
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +174 -95
- package/dist/index.mjs.map +1 -1
- package/dist/leanbase.iife.js +175 -96
- package/dist/leanbase.iife.js.map +1 -1
- package/package.json +1 -1
- package/src/extensions/replay/external/lazy-loaded-session-recorder.ts +158 -82
- package/src/extensions/replay/external/mutation-throttler.ts +40 -27
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -331,6 +331,10 @@ export class LazyLoadedSessionRecording {
|
|
|
331
331
|
private _queuedRRWebEvents: QueuedRRWebEvent[] = []
|
|
332
332
|
private _isIdle: boolean | 'unknown' = 'unknown'
|
|
333
333
|
|
|
334
|
+
// Replay should not process rrweb emits until all critical dependencies are ready.
|
|
335
|
+
private _isFullyReady: boolean = false
|
|
336
|
+
private _loggedMissingEndpointFor: boolean = false
|
|
337
|
+
|
|
334
338
|
private _linkedFlagMatching: LinkedFlagMatching
|
|
335
339
|
private _urlTriggerMatching: URLTriggerMatching
|
|
336
340
|
private _eventTriggerMatching: EventTriggerMatching
|
|
@@ -575,7 +579,12 @@ export class LazyLoadedSessionRecording {
|
|
|
575
579
|
}
|
|
576
580
|
|
|
577
581
|
private _tryAddCustomEvent(tag: string, payload: any): boolean {
|
|
578
|
-
|
|
582
|
+
const rrwebRecord = getRRWebRecord()
|
|
583
|
+
if (!rrwebRecord || typeof (rrwebRecord as any).addCustomEvent !== 'function') {
|
|
584
|
+
return false
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return this._tryRRWebMethod(newQueuedEvent(() => (rrwebRecord as any).addCustomEvent(tag, payload)))
|
|
579
588
|
}
|
|
580
589
|
|
|
581
590
|
private _pageViewFallBack() {
|
|
@@ -622,7 +631,12 @@ export class LazyLoadedSessionRecording {
|
|
|
622
631
|
}
|
|
623
632
|
|
|
624
633
|
private _tryTakeFullSnapshot(): boolean {
|
|
625
|
-
|
|
634
|
+
const rrwebRecord = getRRWebRecord()
|
|
635
|
+
if (!rrwebRecord || typeof (rrwebRecord as any).takeFullSnapshot !== 'function') {
|
|
636
|
+
return false
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return this._tryRRWebMethod(newQueuedEvent(() => (rrwebRecord as any).takeFullSnapshot()))
|
|
626
640
|
}
|
|
627
641
|
|
|
628
642
|
private get _fullSnapshotIntervalMillis(): number {
|
|
@@ -717,6 +731,7 @@ export class LazyLoadedSessionRecording {
|
|
|
717
731
|
}
|
|
718
732
|
|
|
719
733
|
async start(startReason?: SessionStartReason) {
|
|
734
|
+
this._isFullyReady = false
|
|
720
735
|
const config = this._remoteConfig
|
|
721
736
|
if (!config) {
|
|
722
737
|
logger.info('remote config must be stored in persistence before recording can start')
|
|
@@ -765,6 +780,10 @@ export class LazyLoadedSessionRecording {
|
|
|
765
780
|
return
|
|
766
781
|
}
|
|
767
782
|
|
|
783
|
+
// Only start processing rrweb emits once the ingestion endpoint is available.
|
|
784
|
+
// If it isn't available, we must degrade to a no-op (never crash the host app).
|
|
785
|
+
this._isFullyReady = this._canCaptureSnapshots()
|
|
786
|
+
|
|
768
787
|
// calling addEventListener multiple times is safe and will not add duplicates
|
|
769
788
|
addEventListener(window, 'beforeunload', this._onBeforeUnload)
|
|
770
789
|
addEventListener(window, 'offline', this._onOffline)
|
|
@@ -870,101 +889,130 @@ export class LazyLoadedSessionRecording {
|
|
|
870
889
|
this._stopRrweb?.()
|
|
871
890
|
this._stopRrweb = undefined
|
|
872
891
|
|
|
892
|
+
this._isFullyReady = false
|
|
893
|
+
|
|
873
894
|
logger.info('stopped')
|
|
874
895
|
}
|
|
875
896
|
|
|
876
|
-
|
|
877
|
-
this.
|
|
897
|
+
private _snapshotIngestionUrl(): string | null {
|
|
898
|
+
const endpointFor = (this._instance as any)?.requestRouter?.endpointFor
|
|
899
|
+
if (typeof endpointFor !== 'function') {
|
|
900
|
+
return null
|
|
901
|
+
}
|
|
902
|
+
try {
|
|
903
|
+
return endpointFor('api', this._endpoint)
|
|
904
|
+
} catch {
|
|
905
|
+
return null
|
|
906
|
+
}
|
|
907
|
+
}
|
|
878
908
|
|
|
879
|
-
|
|
909
|
+
private _canCaptureSnapshots(): boolean {
|
|
910
|
+
return !!this._snapshotIngestionUrl()
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
onRRwebEmit(rawEvent: eventWithTime) {
|
|
914
|
+
// Never process rrweb emits until we're fully ready.
|
|
915
|
+
if (!this._isFullyReady || !this.isStarted) {
|
|
880
916
|
return
|
|
881
917
|
}
|
|
882
918
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
if (!
|
|
919
|
+
try {
|
|
920
|
+
this._processQueuedEvents()
|
|
921
|
+
|
|
922
|
+
if (!rawEvent || !isObject(rawEvent)) {
|
|
887
923
|
return
|
|
888
924
|
}
|
|
889
|
-
rawEvent.data.href = href
|
|
890
|
-
} else {
|
|
891
|
-
this._pageViewFallBack()
|
|
892
|
-
}
|
|
893
925
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
}
|
|
926
|
+
if (rawEvent.type === EventType.Meta) {
|
|
927
|
+
const href = this._maskUrl(rawEvent.data.href)
|
|
928
|
+
this._lastHref = href
|
|
929
|
+
if (!href) {
|
|
930
|
+
return
|
|
931
|
+
}
|
|
932
|
+
rawEvent.data.href = href
|
|
933
|
+
} else {
|
|
934
|
+
this._pageViewFallBack()
|
|
935
|
+
}
|
|
905
936
|
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
937
|
+
// Check if the URL matches any trigger patterns
|
|
938
|
+
this._urlTriggerMatching.checkUrlTriggerConditions(
|
|
939
|
+
() => this._pauseRecording(),
|
|
940
|
+
() => this._resumeRecording(),
|
|
941
|
+
(triggerType) => this._activateTrigger(triggerType)
|
|
942
|
+
)
|
|
943
|
+
// always have to check if the URL is blocked really early,
|
|
944
|
+
// or you risk getting stuck in a loop
|
|
945
|
+
if (this._urlTriggerMatching.urlBlocked && !isRecordingPausedEvent(rawEvent)) {
|
|
946
|
+
return
|
|
947
|
+
}
|
|
912
948
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
this._clearBufferBeforeMostRecentMeta()
|
|
920
|
-
}
|
|
949
|
+
// we're processing a full snapshot, so we should reset the timer
|
|
950
|
+
if (rawEvent.type === EventType.FullSnapshot) {
|
|
951
|
+
this._scheduleFullSnapshot()
|
|
952
|
+
// Full snapshots reset rrweb's node IDs, so clear any logged node tracking
|
|
953
|
+
this._mutationThrottler?.reset()
|
|
954
|
+
}
|
|
921
955
|
|
|
922
|
-
|
|
956
|
+
// Clear the buffer if waiting for a trigger and only keep data from after the current full snapshot
|
|
957
|
+
// we always start trigger pending so need to wait for flags before we know if we're really pending
|
|
958
|
+
if (
|
|
959
|
+
rawEvent.type === EventType.FullSnapshot &&
|
|
960
|
+
this._triggerMatching.triggerStatus(this.sessionId) === TRIGGER_PENDING
|
|
961
|
+
) {
|
|
962
|
+
this._clearBufferBeforeMostRecentMeta()
|
|
963
|
+
}
|
|
923
964
|
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
965
|
+
const throttledEvent = this._mutationThrottler
|
|
966
|
+
? this._mutationThrottler.throttleMutations(rawEvent)
|
|
967
|
+
: rawEvent
|
|
927
968
|
|
|
928
|
-
|
|
929
|
-
|
|
969
|
+
if (!throttledEvent) {
|
|
970
|
+
return
|
|
971
|
+
}
|
|
930
972
|
|
|
931
|
-
|
|
973
|
+
// TODO: Re-add ensureMaxMessageSize once we are confident in it
|
|
974
|
+
const event = truncateLargeConsoleLogs(throttledEvent)
|
|
932
975
|
|
|
933
|
-
|
|
934
|
-
// we don't want to return early if idle is 'unknown'
|
|
935
|
-
if (this._isIdle === true && !isSessionIdleEvent(event)) {
|
|
936
|
-
return
|
|
937
|
-
}
|
|
976
|
+
this._updateWindowAndSessionIds(event)
|
|
938
977
|
|
|
939
|
-
|
|
940
|
-
//
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
const payload = event.data.payload as SessionIdlePayload
|
|
944
|
-
if (payload) {
|
|
945
|
-
const lastActivity = payload.lastActivityTimestamp
|
|
946
|
-
const threshold = payload.threshold
|
|
947
|
-
event.timestamp = lastActivity + threshold
|
|
978
|
+
// When in an idle state we keep recording but don't capture the events,
|
|
979
|
+
// we don't want to return early if idle is 'unknown'
|
|
980
|
+
if (this._isIdle === true && !isSessionIdleEvent(event)) {
|
|
981
|
+
return
|
|
948
982
|
}
|
|
949
|
-
}
|
|
950
983
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
984
|
+
if (isSessionIdleEvent(event)) {
|
|
985
|
+
// session idle events have a timestamp when rrweb sees them
|
|
986
|
+
// which can artificially lengthen a session
|
|
987
|
+
// we know when we detected it based on the payload and can correct the timestamp
|
|
988
|
+
const payload = event.data.payload as SessionIdlePayload
|
|
989
|
+
if (payload) {
|
|
990
|
+
const lastActivity = payload.lastActivityTimestamp
|
|
991
|
+
const threshold = payload.threshold
|
|
992
|
+
event.timestamp = lastActivity + threshold
|
|
993
|
+
}
|
|
994
|
+
}
|
|
954
995
|
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
$session_id: this._sessionId,
|
|
959
|
-
$window_id: this._windowId,
|
|
960
|
-
}
|
|
996
|
+
const eventToSend =
|
|
997
|
+
(this._instance.config.session_recording?.compress_events ?? true) ? compressEvent(event) : event
|
|
998
|
+
const size = estimateSize(eventToSend)
|
|
961
999
|
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1000
|
+
const properties = {
|
|
1001
|
+
$snapshot_bytes: size,
|
|
1002
|
+
$snapshot_data: eventToSend,
|
|
1003
|
+
$session_id: this._sessionId,
|
|
1004
|
+
$window_id: this._windowId,
|
|
1005
|
+
}
|
|
966
1006
|
|
|
967
|
-
|
|
1007
|
+
if (this.status === DISABLED) {
|
|
1008
|
+
this._clearBuffer()
|
|
1009
|
+
return
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
this._captureSnapshotBuffered(properties)
|
|
1013
|
+
} catch (e) {
|
|
1014
|
+
logger.error('error processing rrweb event', e)
|
|
1015
|
+
}
|
|
968
1016
|
}
|
|
969
1017
|
|
|
970
1018
|
get status(): SessionRecordingStatus {
|
|
@@ -1053,6 +1101,17 @@ export class LazyLoadedSessionRecording {
|
|
|
1053
1101
|
return this._buffer
|
|
1054
1102
|
}
|
|
1055
1103
|
|
|
1104
|
+
if (!this._canCaptureSnapshots()) {
|
|
1105
|
+
if (!this._loggedMissingEndpointFor) {
|
|
1106
|
+
this._loggedMissingEndpointFor = true
|
|
1107
|
+
logger.warn('snapshot capture skipped because requestRouter.endpointFor is unavailable')
|
|
1108
|
+
}
|
|
1109
|
+
this._flushBufferTimer = setTimeout(() => {
|
|
1110
|
+
this._flushBuffer()
|
|
1111
|
+
}, RECORDING_BUFFER_TIMEOUT)
|
|
1112
|
+
return this._buffer
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1056
1115
|
if (this._buffer.data.length > 0) {
|
|
1057
1116
|
const snapshotEvents = splitBuffer(this._buffer)
|
|
1058
1117
|
snapshotEvents.forEach((snapshotBuffer) => {
|
|
@@ -1092,13 +1151,26 @@ export class LazyLoadedSessionRecording {
|
|
|
1092
1151
|
}
|
|
1093
1152
|
|
|
1094
1153
|
private _captureSnapshot(properties: Properties) {
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1154
|
+
const url = this._snapshotIngestionUrl()
|
|
1155
|
+
if (!url) {
|
|
1156
|
+
if (!this._loggedMissingEndpointFor) {
|
|
1157
|
+
this._loggedMissingEndpointFor = true
|
|
1158
|
+
logger.warn('snapshot capture skipped because requestRouter.endpointFor is unavailable')
|
|
1159
|
+
}
|
|
1160
|
+
return
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
try {
|
|
1164
|
+
// :TRICKY: Make sure we batch these requests, use a custom endpoint and don't truncate the strings.
|
|
1165
|
+
this._instance.capture('$snapshot', properties, {
|
|
1166
|
+
_url: url,
|
|
1167
|
+
_noTruncate: true,
|
|
1168
|
+
_batchKey: SESSION_RECORDING_BATCH_KEY,
|
|
1169
|
+
skip_client_rate_limiting: true,
|
|
1170
|
+
})
|
|
1171
|
+
} catch (e) {
|
|
1172
|
+
logger.error('failed to capture snapshot', e)
|
|
1173
|
+
}
|
|
1102
1174
|
}
|
|
1103
1175
|
|
|
1104
1176
|
private _snapshotUrl(): string {
|
|
@@ -1427,7 +1499,11 @@ export class LazyLoadedSessionRecording {
|
|
|
1427
1499
|
try {
|
|
1428
1500
|
this._stopRrweb = rrwebRecord({
|
|
1429
1501
|
emit: (event) => {
|
|
1430
|
-
|
|
1502
|
+
try {
|
|
1503
|
+
this.onRRwebEmit(event)
|
|
1504
|
+
} catch (e) {
|
|
1505
|
+
logger.error('error in rrweb emit handler', e)
|
|
1506
|
+
}
|
|
1431
1507
|
},
|
|
1432
1508
|
plugins: activePlugins,
|
|
1433
1509
|
...sessionRecordingOptions,
|
|
@@ -54,7 +54,17 @@ export class MutationThrottler {
|
|
|
54
54
|
return [id, node]
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
private _getNode = (id: number) =>
|
|
57
|
+
private _getNode = (id: number | null | undefined): Node | null => {
|
|
58
|
+
// eslint-disable-next-line posthog-js/no-direct-undefined-check, posthog-js/no-direct-null-check
|
|
59
|
+
if (id === null || id === undefined) {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
return this._rrweb.mirror.getNode(id) ?? null
|
|
64
|
+
} catch {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
}
|
|
58
68
|
|
|
59
69
|
private _numberOfChanges = (data: Partial<mutationCallbackParam>) => {
|
|
60
70
|
return (
|
|
@@ -66,36 +76,39 @@ export class MutationThrottler {
|
|
|
66
76
|
}
|
|
67
77
|
|
|
68
78
|
public throttleMutations = (event: eventWithTime) => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const data = event.data as Partial<mutationCallbackParam>
|
|
74
|
-
const initialMutationCount = this._numberOfChanges(data)
|
|
75
|
-
|
|
76
|
-
if (data.attributes) {
|
|
77
|
-
// Most problematic mutations come from attrs where the style or minor properties are changed rapidly
|
|
78
|
-
data.attributes = data.attributes.filter((attr) => {
|
|
79
|
-
const [nodeId] = this._getNodeOrRelevantParent(attr.id)
|
|
80
|
-
|
|
81
|
-
const isRateLimited = this._rateLimiter.consumeRateLimit(nodeId)
|
|
82
|
-
|
|
83
|
-
if (isRateLimited) {
|
|
84
|
-
return false
|
|
85
|
-
}
|
|
79
|
+
try {
|
|
80
|
+
if (event.type !== INCREMENTAL_SNAPSHOT_EVENT_TYPE || event.data.source !== MUTATION_SOURCE_TYPE) {
|
|
81
|
+
return event
|
|
82
|
+
}
|
|
86
83
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
84
|
+
const data = event.data as Partial<mutationCallbackParam>
|
|
85
|
+
const initialMutationCount = this._numberOfChanges(data)
|
|
86
|
+
|
|
87
|
+
if (data.attributes) {
|
|
88
|
+
// Most problematic mutations come from attrs where the style or minor properties are changed rapidly
|
|
89
|
+
data.attributes = data.attributes.filter((attr: any) => {
|
|
90
|
+
const id = (attr as any)?.id
|
|
91
|
+
if (typeof id !== 'number') {
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const [nodeId] = this._getNodeOrRelevantParent(id)
|
|
96
|
+
const isRateLimited = this._rateLimiter.consumeRateLimit(nodeId)
|
|
97
|
+
return !isRateLimited
|
|
98
|
+
})
|
|
99
|
+
}
|
|
90
100
|
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
// Check if every part of the mutation is empty in which case there is nothing to do
|
|
102
|
+
const mutationCount = this._numberOfChanges(data)
|
|
93
103
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
104
|
+
if (mutationCount === 0 && initialMutationCount !== mutationCount) {
|
|
105
|
+
// If we have modified the mutation count and the remaining count is 0, then we don't need the event.
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
return event
|
|
109
|
+
} catch {
|
|
110
|
+
return event
|
|
97
111
|
}
|
|
98
|
-
return event
|
|
99
112
|
}
|
|
100
113
|
|
|
101
114
|
public reset() {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = '0.2.
|
|
1
|
+
export const version = '0.2.4'
|