@leanbase-giangnd/js 0.2.2 → 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,47 +1,48 @@
1
1
  {
2
- "name": "@leanbase-giangnd/js",
3
- "version": "0.2.2",
4
- "description": "Leanbase browser SDK - event tracking, autocapture, and session replay",
5
- "repository": {
6
- "type": "git",
7
- "directory": "packages/leanbase"
8
- },
9
- "author": "leanbase",
10
- "license": "Copyrighted by Leanflag Limited",
11
- "main": "dist/index.cjs",
12
- "module": "dist/index.mjs",
13
- "types": "dist/index.d.ts",
14
- "unpkg": "dist/leanbase.iife.js",
15
- "jsdelivr": "dist/leanbase.iife.js",
16
- "files": [
17
- "dist",
18
- "src",
19
- "README.md"
20
- ],
21
- "publishConfig": {
22
- "access": "public"
23
- },
24
- "dependencies": {
25
- "@rrweb/record": "2.0.0-alpha.17",
26
- "fflate": "^0.4.8",
27
- "@posthog/core": "1.3.1"
28
- },
29
- "devDependencies": {
30
- "jest": "^29.7.0",
31
- "jest-environment-jsdom": "^29.7.0",
32
- "rollup": "^4.44.1",
33
- "rimraf": "^6.0.1",
34
- "@posthog-tooling/tsconfig-base": "1.0.0",
35
- "@posthog-tooling/rollup-utils": "1.0.0"
36
- },
37
- "scripts": {
38
- "clean": "rimraf dist coverage",
39
- "test:unit": "jest -c jest.config.js",
40
- "lint": "eslint src test",
41
- "lint:fix": "eslint src test --fix",
42
- "prebuild": "node -p \"'export const version = \\'' + require('./package.json').version + '\\''\" > src/version.ts",
43
- "build": "rollup -c",
44
- "dev": "rollup -c -w",
45
- "package": "mkdir -p ../../target && pnpm pack --pack-destination ../../target"
46
- }
47
- }
2
+ "name": "@leanbase-giangnd/js",
3
+ "version": "0.2.4",
4
+ "description": "Leanbase browser SDK - event tracking, autocapture, and session replay",
5
+ "repository": {
6
+ "type": "git",
7
+ "directory": "packages/leanbase"
8
+ },
9
+ "author": "leanbase",
10
+ "license": "Copyrighted by Leanflag Limited",
11
+ "main": "dist/index.cjs",
12
+ "module": "dist/index.mjs",
13
+ "types": "dist/index.d.ts",
14
+ "unpkg": "dist/leanbase.iife.js",
15
+ "jsdelivr": "dist/leanbase.iife.js",
16
+ "scripts": {
17
+ "clean": "rimraf dist coverage",
18
+ "test:unit": "jest -c jest.config.js",
19
+ "lint": "eslint src test",
20
+ "lint:fix": "eslint src test --fix",
21
+ "prebuild": "node -p \"'export const version = \\'' + require('./package.json').version + '\\''\" > src/version.ts",
22
+ "build": "rollup -c",
23
+ "dev": "rollup -c -w",
24
+ "prepublishOnly": "pnpm lint && pnpm test:unit && pnpm build",
25
+ "package": "mkdir -p ../../target && pnpm pack --pack-destination ../../target"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "src",
30
+ "README.md"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "dependencies": {
36
+ "@posthog/core": "workspace:*",
37
+ "@rrweb/record": "2.0.0-alpha.17",
38
+ "fflate": "^0.4.8"
39
+ },
40
+ "devDependencies": {
41
+ "@posthog-tooling/tsconfig-base": "workspace:*",
42
+ "@posthog-tooling/rollup-utils": "workspace:*",
43
+ "jest": "catalog:",
44
+ "jest-environment-jsdom": "catalog:",
45
+ "rollup": "catalog:",
46
+ "rimraf": "^6.0.1"
47
+ }
48
+ }
@@ -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')
@@ -759,6 +774,16 @@ export class LazyLoadedSessionRecording {
759
774
  this._makeSamplingDecision(this.sessionId)
760
775
  await this._startRecorder()
761
776
 
777
+ // If rrweb failed to load/start, do not proceed further.
778
+ // This prevents installing listeners that assume rrweb is active.
779
+ if (!this.isStarted) {
780
+ return
781
+ }
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
+
762
787
  // calling addEventListener multiple times is safe and will not add duplicates
763
788
  addEventListener(window, 'beforeunload', this._onBeforeUnload)
764
789
  addEventListener(window, 'offline', this._onOffline)
@@ -864,101 +889,130 @@ export class LazyLoadedSessionRecording {
864
889
  this._stopRrweb?.()
865
890
  this._stopRrweb = undefined
866
891
 
892
+ this._isFullyReady = false
893
+
867
894
  logger.info('stopped')
868
895
  }
869
896
 
870
- onRRwebEmit(rawEvent: eventWithTime) {
871
- 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
+ }
908
+
909
+ private _canCaptureSnapshots(): boolean {
910
+ return !!this._snapshotIngestionUrl()
911
+ }
872
912
 
873
- if (!rawEvent || !isObject(rawEvent)) {
913
+ onRRwebEmit(rawEvent: eventWithTime) {
914
+ // Never process rrweb emits until we're fully ready.
915
+ if (!this._isFullyReady || !this.isStarted) {
874
916
  return
875
917
  }
876
918
 
877
- if (rawEvent.type === EventType.Meta) {
878
- const href = this._maskUrl(rawEvent.data.href)
879
- this._lastHref = href
880
- if (!href) {
919
+ try {
920
+ this._processQueuedEvents()
921
+
922
+ if (!rawEvent || !isObject(rawEvent)) {
881
923
  return
882
924
  }
883
- rawEvent.data.href = href
884
- } else {
885
- this._pageViewFallBack()
886
- }
887
925
 
888
- // Check if the URL matches any trigger patterns
889
- this._urlTriggerMatching.checkUrlTriggerConditions(
890
- () => this._pauseRecording(),
891
- () => this._resumeRecording(),
892
- (triggerType) => this._activateTrigger(triggerType)
893
- )
894
- // always have to check if the URL is blocked really early,
895
- // or you risk getting stuck in a loop
896
- if (this._urlTriggerMatching.urlBlocked && !isRecordingPausedEvent(rawEvent)) {
897
- return
898
- }
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
+ }
899
936
 
900
- // we're processing a full snapshot, so we should reset the timer
901
- if (rawEvent.type === EventType.FullSnapshot) {
902
- this._scheduleFullSnapshot()
903
- // Full snapshots reset rrweb's node IDs, so clear any logged node tracking
904
- this._mutationThrottler?.reset()
905
- }
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
+ }
906
948
 
907
- // Clear the buffer if waiting for a trigger and only keep data from after the current full snapshot
908
- // we always start trigger pending so need to wait for flags before we know if we're really pending
909
- if (
910
- rawEvent.type === EventType.FullSnapshot &&
911
- this._triggerMatching.triggerStatus(this.sessionId) === TRIGGER_PENDING
912
- ) {
913
- this._clearBufferBeforeMostRecentMeta()
914
- }
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
+ }
915
955
 
916
- 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
+ }
917
964
 
918
- if (!throttledEvent) {
919
- return
920
- }
965
+ const throttledEvent = this._mutationThrottler
966
+ ? this._mutationThrottler.throttleMutations(rawEvent)
967
+ : rawEvent
921
968
 
922
- // TODO: Re-add ensureMaxMessageSize once we are confident in it
923
- const event = truncateLargeConsoleLogs(throttledEvent)
969
+ if (!throttledEvent) {
970
+ return
971
+ }
924
972
 
925
- this._updateWindowAndSessionIds(event)
973
+ // TODO: Re-add ensureMaxMessageSize once we are confident in it
974
+ const event = truncateLargeConsoleLogs(throttledEvent)
926
975
 
927
- // When in an idle state we keep recording but don't capture the events,
928
- // we don't want to return early if idle is 'unknown'
929
- if (this._isIdle === true && !isSessionIdleEvent(event)) {
930
- return
931
- }
976
+ this._updateWindowAndSessionIds(event)
932
977
 
933
- if (isSessionIdleEvent(event)) {
934
- // session idle events have a timestamp when rrweb sees them
935
- // which can artificially lengthen a session
936
- // we know when we detected it based on the payload and can correct the timestamp
937
- const payload = event.data.payload as SessionIdlePayload
938
- if (payload) {
939
- const lastActivity = payload.lastActivityTimestamp
940
- const threshold = payload.threshold
941
- 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
942
982
  }
943
- }
944
983
 
945
- const eventToSend =
946
- (this._instance.config.session_recording?.compress_events ?? true) ? compressEvent(event) : event
947
- 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
+ }
948
995
 
949
- const properties = {
950
- $snapshot_bytes: size,
951
- $snapshot_data: eventToSend,
952
- $session_id: this._sessionId,
953
- $window_id: this._windowId,
954
- }
996
+ const eventToSend =
997
+ (this._instance.config.session_recording?.compress_events ?? true) ? compressEvent(event) : event
998
+ const size = estimateSize(eventToSend)
955
999
 
956
- if (this.status === DISABLED) {
957
- this._clearBuffer()
958
- return
959
- }
1000
+ const properties = {
1001
+ $snapshot_bytes: size,
1002
+ $snapshot_data: eventToSend,
1003
+ $session_id: this._sessionId,
1004
+ $window_id: this._windowId,
1005
+ }
960
1006
 
961
- 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
+ }
962
1016
  }
963
1017
 
964
1018
  get status(): SessionRecordingStatus {
@@ -1047,6 +1101,17 @@ export class LazyLoadedSessionRecording {
1047
1101
  return this._buffer
1048
1102
  }
1049
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
+
1050
1115
  if (this._buffer.data.length > 0) {
1051
1116
  const snapshotEvents = splitBuffer(this._buffer)
1052
1117
  snapshotEvents.forEach((snapshotBuffer) => {
@@ -1086,13 +1151,26 @@ export class LazyLoadedSessionRecording {
1086
1151
  }
1087
1152
 
1088
1153
  private _captureSnapshot(properties: Properties) {
1089
- // :TRICKY: Make sure we batch these requests, use a custom endpoint and don't truncate the strings.
1090
- this._instance.capture('$snapshot', properties, {
1091
- _url: this._instance.requestRouter.endpointFor('api', this._endpoint),
1092
- _noTruncate: true,
1093
- _batchKey: SESSION_RECORDING_BATCH_KEY,
1094
- skip_client_rate_limiting: true,
1095
- })
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
+ }
1096
1174
  }
1097
1175
 
1098
1176
  private _snapshotUrl(): string {
@@ -1418,13 +1496,23 @@ export class LazyLoadedSessionRecording {
1418
1496
  })
1419
1497
 
1420
1498
  const activePlugins = this._gatherRRWebPlugins()
1421
- this._stopRrweb = rrwebRecord({
1422
- emit: (event) => {
1423
- this.onRRwebEmit(event)
1424
- },
1425
- plugins: activePlugins,
1426
- ...sessionRecordingOptions,
1427
- })
1499
+ try {
1500
+ this._stopRrweb = rrwebRecord({
1501
+ emit: (event) => {
1502
+ try {
1503
+ this.onRRwebEmit(event)
1504
+ } catch (e) {
1505
+ logger.error('error in rrweb emit handler', e)
1506
+ }
1507
+ },
1508
+ plugins: activePlugins,
1509
+ ...sessionRecordingOptions,
1510
+ })
1511
+ } catch (e) {
1512
+ logger.error('failed to start rrweb recorder', e)
1513
+ this._stopRrweb = undefined
1514
+ return
1515
+ }
1428
1516
 
1429
1517
  // We reset the last activity timestamp, resetting the idle timer
1430
1518
  this._lastActivityTimestamp = Date.now()
@@ -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() {
@@ -213,22 +213,29 @@ export class SessionRecording {
213
213
  // If extensions provide an init function, use it. Otherwise, fall back to the local LazyLoadedSessionRecording
214
214
  if (assignableWindow.__PosthogExtensions__?.initSessionRecording) {
215
215
  if (!this._lazyLoadedSessionRecording) {
216
- this._lazyLoadedSessionRecording = assignableWindow.__PosthogExtensions__?.initSessionRecording(
217
- this._instance
218
- )
219
- ;(this._lazyLoadedSessionRecording as any)._forceAllowLocalhostNetworkCapture =
220
- this._forceAllowLocalhostNetworkCapture
216
+ const maybeRecording = assignableWindow.__PosthogExtensions__?.initSessionRecording(this._instance)
217
+ if (maybeRecording && typeof (maybeRecording as any).start === 'function') {
218
+ this._lazyLoadedSessionRecording = maybeRecording
219
+ ;(this._lazyLoadedSessionRecording as any)._forceAllowLocalhostNetworkCapture =
220
+ this._forceAllowLocalhostNetworkCapture
221
+ } else {
222
+ log.warn(
223
+ 'initSessionRecording was present but did not return a recorder instance; falling back to local recorder'
224
+ )
225
+ }
221
226
  }
222
227
 
223
- try {
224
- const maybePromise: any = this._lazyLoadedSessionRecording!.start(startReason)
225
- if (maybePromise && typeof maybePromise.catch === 'function') {
226
- maybePromise.catch((e: any) => logger.error('error starting session recording', e))
228
+ if (this._lazyLoadedSessionRecording) {
229
+ try {
230
+ const maybePromise: any = this._lazyLoadedSessionRecording.start(startReason)
231
+ if (maybePromise && typeof maybePromise.catch === 'function') {
232
+ maybePromise.catch((e: any) => logger.error('error starting session recording', e))
233
+ }
234
+ } catch (e: any) {
235
+ logger.error('error starting session recording', e)
227
236
  }
228
- } catch (e: any) {
229
- logger.error('error starting session recording', e)
237
+ return
230
238
  }
231
- return
232
239
  }
233
240
 
234
241
  if (!this._lazyLoadedSessionRecording) {