@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leanbase-giangnd/js",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Leanbase browser SDK - event tracking, autocapture, and session replay",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- return this._tryRRWebMethod(newQueuedEvent(() => getRRWebRecord()!.addCustomEvent(tag, payload)))
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
- return this._tryRRWebMethod(newQueuedEvent(() => getRRWebRecord()!.takeFullSnapshot()))
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
- onRRwebEmit(rawEvent: eventWithTime) {
877
- this._processQueuedEvents()
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
- if (!rawEvent || !isObject(rawEvent)) {
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
- if (rawEvent.type === EventType.Meta) {
884
- const href = this._maskUrl(rawEvent.data.href)
885
- this._lastHref = href
886
- if (!href) {
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
- // Check if the URL matches any trigger patterns
895
- this._urlTriggerMatching.checkUrlTriggerConditions(
896
- () => this._pauseRecording(),
897
- () => this._resumeRecording(),
898
- (triggerType) => this._activateTrigger(triggerType)
899
- )
900
- // always have to check if the URL is blocked really early,
901
- // or you risk getting stuck in a loop
902
- if (this._urlTriggerMatching.urlBlocked && !isRecordingPausedEvent(rawEvent)) {
903
- return
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
- // we're processing a full snapshot, so we should reset the timer
907
- if (rawEvent.type === EventType.FullSnapshot) {
908
- this._scheduleFullSnapshot()
909
- // Full snapshots reset rrweb's node IDs, so clear any logged node tracking
910
- this._mutationThrottler?.reset()
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
- // Clear the buffer if waiting for a trigger and only keep data from after the current full snapshot
914
- // we always start trigger pending so need to wait for flags before we know if we're really pending
915
- if (
916
- rawEvent.type === EventType.FullSnapshot &&
917
- this._triggerMatching.triggerStatus(this.sessionId) === TRIGGER_PENDING
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
- const throttledEvent = this._mutationThrottler ? this._mutationThrottler.throttleMutations(rawEvent) : rawEvent
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
- if (!throttledEvent) {
925
- return
926
- }
965
+ const throttledEvent = this._mutationThrottler
966
+ ? this._mutationThrottler.throttleMutations(rawEvent)
967
+ : rawEvent
927
968
 
928
- // TODO: Re-add ensureMaxMessageSize once we are confident in it
929
- const event = truncateLargeConsoleLogs(throttledEvent)
969
+ if (!throttledEvent) {
970
+ return
971
+ }
930
972
 
931
- this._updateWindowAndSessionIds(event)
973
+ // TODO: Re-add ensureMaxMessageSize once we are confident in it
974
+ const event = truncateLargeConsoleLogs(throttledEvent)
932
975
 
933
- // When in an idle state we keep recording but don't capture the events,
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
- if (isSessionIdleEvent(event)) {
940
- // session idle events have a timestamp when rrweb sees them
941
- // which can artificially lengthen a session
942
- // we know when we detected it based on the payload and can correct the timestamp
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
- const eventToSend =
952
- (this._instance.config.session_recording?.compress_events ?? true) ? compressEvent(event) : event
953
- const size = estimateSize(eventToSend)
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
- const properties = {
956
- $snapshot_bytes: size,
957
- $snapshot_data: eventToSend,
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
- if (this.status === DISABLED) {
963
- this._clearBuffer()
964
- return
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
- this._captureSnapshotBuffered(properties)
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
- // :TRICKY: Make sure we batch these requests, use a custom endpoint and don't truncate the strings.
1096
- this._instance.capture('$snapshot', properties, {
1097
- _url: this._instance.requestRouter.endpointFor('api', this._endpoint),
1098
- _noTruncate: true,
1099
- _batchKey: SESSION_RECORDING_BATCH_KEY,
1100
- skip_client_rate_limiting: true,
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
- this.onRRwebEmit(event)
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) => this._rrweb.mirror.getNode(id)
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
- if (event.type !== INCREMENTAL_SNAPSHOT_EVENT_TYPE || event.data.source !== MUTATION_SOURCE_TYPE) {
70
- return event
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
- return attr
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
- // Check if every part of the mutation is empty in which case there is nothing to do
92
- const mutationCount = this._numberOfChanges(data)
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
- if (mutationCount === 0 && initialMutationCount !== mutationCount) {
95
- // If we have modified the mutation count and the remaining count is 0, then we don't need the event.
96
- return
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.3'
1
+ export const version = '0.2.4'