@repliqo/sdk-react-native 0.3.8 → 0.4.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.
Files changed (42) hide show
  1. package/INTEGRATION_GUIDE.md +1384 -1312
  2. package/android/src/main/java/com/repliqo/screencapture/MultiWindowCapture.java +242 -242
  3. package/android/src/main/java/com/repliqo/screencapture/ScreenCaptureModule.java +94 -94
  4. package/dist/components/RepliqoFlatList.d.ts +5 -0
  5. package/dist/components/RepliqoFlatList.js +39 -0
  6. package/dist/components/RepliqoScrollView.d.ts +3 -0
  7. package/dist/components/RepliqoScrollView.js +16 -265
  8. package/dist/components/useRepliqoScrollTracking.d.ts +33 -0
  9. package/dist/components/useRepliqoScrollTracking.js +304 -0
  10. package/dist/core/client.d.ts +52 -0
  11. package/dist/core/client.js +304 -16
  12. package/dist/core/config.d.ts +1 -1
  13. package/dist/core/config.js +5 -1
  14. package/dist/core/storage.d.ts +29 -0
  15. package/dist/core/storage.js +33 -0
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.js +3 -1
  18. package/dist/snapshot/capture.d.ts +7 -0
  19. package/dist/snapshot/capture.js +17 -5
  20. package/dist/trackers/error.tracker.d.ts +25 -0
  21. package/dist/trackers/error.tracker.js +114 -37
  22. package/dist/trackers/navigation.tracker.js +11 -0
  23. package/dist/transport/api.client.d.ts +34 -1
  24. package/dist/transport/api.client.js +102 -19
  25. package/dist/transport/batch-queue.d.ts +53 -1
  26. package/dist/transport/batch-queue.js +126 -19
  27. package/dist/types/events.d.ts +12 -0
  28. package/package.json +68 -64
  29. package/src/components/RepliqoFlatList.tsx +64 -0
  30. package/src/components/RepliqoScrollView.tsx +53 -302
  31. package/src/components/useRepliqoScrollTracking.ts +366 -0
  32. package/src/core/client.ts +953 -628
  33. package/src/core/config.ts +38 -30
  34. package/src/core/storage.ts +51 -0
  35. package/src/index.ts +54 -50
  36. package/src/snapshot/NativeScreenCapture.ts +62 -62
  37. package/src/snapshot/capture.ts +154 -142
  38. package/src/trackers/error.tracker.ts +211 -136
  39. package/src/trackers/navigation.tracker.ts +107 -98
  40. package/src/transport/api.client.ts +430 -327
  41. package/src/transport/batch-queue.ts +212 -95
  42. package/src/types/events.ts +72 -60
@@ -1,11 +1,60 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.BatchQueue = void 0;
3
+ exports.BatchQueue = exports.PartialFlushError = exports.NonRetryableFlushError = void 0;
4
+ /**
5
+ * Thrown by an onFlush callback to signal that the batch was PERMANENTLY
6
+ * rejected (e.g. HTTP 400/413 — malformed or oversized payload). The queue
7
+ * drops the batch instead of retrying it forever, which would otherwise
8
+ * block the queue and evict everything behind it.
9
+ */
10
+ class NonRetryableFlushError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = 'NonRetryableFlushError';
14
+ }
15
+ }
16
+ exports.NonRetryableFlushError = NonRetryableFlushError;
17
+ /**
18
+ * Thrown by an onFlush callback that delivers items in MULTIPLE requests
19
+ * (e.g. one events batch per session) when only a PREFIX of the items was
20
+ * consumed. Without it the queue would treat the flush as all-or-nothing:
21
+ * a transient failure after a successful sub-batch would RE-SEND the
22
+ * delivered items on retry (duplicates), and a poison sub-batch would
23
+ * DROP items belonging to never-attempted sub-batches.
24
+ *
25
+ * `consumedCount` = items delivered or permanently dropped, counted from
26
+ * the FRONT of the flushed array (sub-batches must be processed in order).
27
+ * `transient` = whether the stop reason was retryable (applies backoff and
28
+ * keeps the remaining items for retry).
29
+ */
30
+ class PartialFlushError extends Error {
31
+ constructor(consumedCount, transient, message) {
32
+ super(message ?? `Partial flush: ${consumedCount} consumed`);
33
+ this.consumedCount = consumedCount;
34
+ this.transient = transient;
35
+ this.name = 'PartialFlushError';
36
+ }
37
+ }
38
+ exports.PartialFlushError = PartialFlushError;
4
39
  class BatchQueue {
5
40
  constructor(maxSize = 20, flushIntervalMs = 30000, onFlush, maxBufferSize = 1000) {
6
41
  this.buffer = [];
7
42
  this.intervalId = null;
8
- this.flushing = false;
43
+ /**
44
+ * In-flight flush promise. flush() returns THIS promise when a flush is
45
+ * already running (instead of resolving immediately), so callers like
46
+ * endSession() that `await flush()` actually wait for delivery.
47
+ */
48
+ this.inFlight = null;
49
+ /**
50
+ * Items evicted from the FRONT of the buffer while a flush was in flight.
51
+ * Evicted items are the oldest — i.e. part of the in-flight batch — so
52
+ * after a successful flush we must remove (sent - alreadyEvicted) items,
53
+ * or we'd delete unsent NEW items that shifted to the front.
54
+ */
55
+ this.evictedDuringFlush = 0;
56
+ /** Number of items captured by the in-flight flush (0 when idle). */
57
+ this.inFlightCount = 0;
9
58
  // Exponential backoff state: on each failure we double the skip count,
10
59
  // capped at 8 ticks. A successful flush resets it to 0.
11
60
  this.consecutiveFailures = 0;
@@ -18,40 +67,86 @@ class BatchQueue {
18
67
  add(item) {
19
68
  // Evict oldest items if buffer is at max capacity
20
69
  if (this.buffer.length >= this.maxBufferSize) {
21
- this.buffer.splice(0, this.buffer.length - this.maxBufferSize + 1);
70
+ const evictCount = this.buffer.length - this.maxBufferSize + 1;
71
+ this.buffer.splice(0, evictCount);
72
+ if (this.inFlight) {
73
+ this.evictedDuringFlush += evictCount;
74
+ }
22
75
  }
23
76
  this.buffer.push(item);
24
77
  if (this.buffer.length >= this.maxSize) {
25
78
  this.flush();
26
79
  }
27
80
  }
28
- async flush() {
29
- if (this.buffer.length === 0 || this.flushing) {
30
- return;
81
+ /**
82
+ * Flush the buffer. If a flush is already in flight, returns its promise
83
+ * (never a silent no-op). Never rejects — failures are kept in the buffer
84
+ * and retried with backoff by the auto-flush timer.
85
+ */
86
+ flush() {
87
+ if (this.inFlight) {
88
+ return this.inFlight;
89
+ }
90
+ if (this.buffer.length === 0) {
91
+ return Promise.resolve();
31
92
  }
32
- this.flushing = true;
93
+ this.inFlight = this.doFlush().finally(() => {
94
+ this.inFlight = null;
95
+ });
96
+ return this.inFlight;
97
+ }
98
+ async doFlush() {
99
+ this.evictedDuringFlush = 0;
33
100
  const items = [...this.buffer];
101
+ this.inFlightCount = items.length;
102
+ /** Remove `count` in-flight items from the buffer front, net of any
103
+ * that were already evicted (or cleared) while the flush ran. */
104
+ const removeConsumed = (count) => {
105
+ const toRemove = Math.max(0, Math.min(count, items.length) - this.evictedDuringFlush);
106
+ this.buffer.splice(0, toRemove);
107
+ // Consumed items absorb the eviction credit first.
108
+ this.evictedDuringFlush = Math.max(0, this.evictedDuringFlush - Math.min(count, items.length));
109
+ };
34
110
  try {
35
111
  await this.onFlush(items);
36
- // Only clear the items that were successfully flushed.
37
- // New items might have been added during the async flush,
38
- // so we splice out only the count we sent.
39
- this.buffer.splice(0, items.length);
40
- // Successful flush resets the backoff state.
112
+ removeConsumed(items.length);
41
113
  this.consecutiveFailures = 0;
42
114
  this.ticksToSkip = 0;
43
115
  }
44
- catch {
45
- // On failure, keep events in buffer so they can be retried.
46
- // Apply exponential backoff: 1, 2, 4, 8 ticks skipped before
47
- // the next auto-flush attempt.
48
- this.consecutiveFailures++;
49
- this.ticksToSkip = Math.min(Math.pow(2, this.consecutiveFailures - 1), BatchQueue.MAX_BACKOFF_TICKS);
116
+ catch (err) {
117
+ if (err instanceof PartialFlushError) {
118
+ // A prefix of the items was delivered/dropped by a multi-request
119
+ // flush; keep only the unconsumed remainder.
120
+ removeConsumed(err.consumedCount);
121
+ if (err.transient) {
122
+ this.applyBackoff();
123
+ }
124
+ else {
125
+ this.consecutiveFailures = 0;
126
+ this.ticksToSkip = 0;
127
+ }
128
+ }
129
+ else if (err instanceof NonRetryableFlushError) {
130
+ // Poison batch: the server permanently rejected it. Drop it so it
131
+ // can't block the queue forever.
132
+ removeConsumed(items.length);
133
+ this.consecutiveFailures = 0;
134
+ this.ticksToSkip = 0;
135
+ }
136
+ else {
137
+ // Transient failure: keep events in buffer for retry with
138
+ // exponential backoff (1, 2, 4, 8 ticks skipped).
139
+ this.applyBackoff();
140
+ }
50
141
  }
51
142
  finally {
52
- this.flushing = false;
143
+ this.inFlightCount = 0;
53
144
  }
54
145
  }
146
+ applyBackoff() {
147
+ this.consecutiveFailures++;
148
+ this.ticksToSkip = Math.min(Math.pow(2, this.consecutiveFailures - 1), BatchQueue.MAX_BACKOFF_TICKS);
149
+ }
55
150
  startAutoFlush() {
56
151
  if (this.intervalId !== null) {
57
152
  return;
@@ -75,6 +170,18 @@ class BatchQueue {
75
170
  get pending() {
76
171
  return this.buffer.length;
77
172
  }
173
+ /** Read-only snapshot of the buffered items (for persistence). */
174
+ peekAll() {
175
+ return [...this.buffer];
176
+ }
177
+ /** Remove every buffered item (after persisting, or on session discard). */
178
+ clear() {
179
+ this.buffer.length = 0;
180
+ // If a flush is in flight, all of its items just left the buffer —
181
+ // account for them as evicted so the flush's completion splice does
182
+ // not delete NEW items added after this clear().
183
+ this.evictedDuringFlush = this.inFlightCount;
184
+ }
78
185
  }
79
186
  exports.BatchQueue = BatchQueue;
80
187
  BatchQueue.MAX_BACKOFF_TICKS = 8;
@@ -50,5 +50,17 @@ export interface SDKConfig {
50
50
  /** Enable full-content screen captures for heatmap backgrounds. Defaults to true when enableSnapshots is true. */
51
51
  enableHeatmapCapture?: boolean;
52
52
  enableCrashTracking?: boolean;
53
+ /**
54
+ * Async key-value storage for offline persistence (events buffered at
55
+ * backgrounding + crash reports). Pass AsyncStorage from
56
+ * @react-native-async-storage/async-storage, or any compatible object.
57
+ * If omitted, the SDK tries to auto-load AsyncStorage; if unavailable,
58
+ * persistence is disabled (in-memory only).
59
+ */
60
+ storage?: {
61
+ getItem(key: string): Promise<string | null>;
62
+ setItem(key: string, value: string): Promise<void>;
63
+ removeItem(key: string): Promise<void>;
64
+ };
53
65
  debug?: boolean;
54
66
  }
package/package.json CHANGED
@@ -1,64 +1,68 @@
1
- {
2
- "name": "@repliqo/sdk-react-native",
3
- "version": "0.3.8",
4
- "description": "Session replay & analytics SDK for React Native. Captures screenshots (including modals/alerts), navigation, screen visits, and custom events.",
5
- "main": "dist/index.js",
6
- "types": "dist/index.d.ts",
7
- "repository": {
8
- "type": "git",
9
- "url": "https://github.com/SbtnAvls/app-analytics-api",
10
- "directory": "packages/sdk-react-native"
11
- },
12
- "homepage": "https://repliqo.odmservice.site",
13
- "license": "MIT",
14
- "keywords": [
15
- "react-native",
16
- "analytics",
17
- "session-replay",
18
- "screen-capture",
19
- "heatmaps",
20
- "uxcam",
21
- "repliqo"
22
- ],
23
- "files": [
24
- "dist/",
25
- "android/src/",
26
- "android/build.gradle",
27
- "src/",
28
- "INTEGRATION_GUIDE.md"
29
- ],
30
- "scripts": {
31
- "build": "tsc",
32
- "test": "jest",
33
- "prepublishOnly": "npm run build"
34
- },
35
- "publishConfig": {
36
- "access": "public"
37
- },
38
- "peerDependencies": {
39
- "@react-navigation/native": ">=6.0.0",
40
- "react": ">=17.0.0",
41
- "react-native": ">=0.68.0",
42
- "react-native-view-shot": ">=3.0.0"
43
- },
44
- "peerDependenciesMeta": {
45
- "react-native-view-shot": {
46
- "optional": true
47
- },
48
- "@react-navigation/native": {
49
- "optional": true
50
- }
51
- },
52
- "devDependencies": {
53
- "@react-navigation/native": "^6.1.0",
54
- "@types/jest": "^29.5.0",
55
- "@types/react": "^18.2.0",
56
- "@types/react-native": "^0.72.0",
57
- "jest": "^29.7.0",
58
- "react": "^18.2.0",
59
- "react-native": "^0.73.0",
60
- "react-native-view-shot": "^4.0.3",
61
- "ts-jest": "^29.1.0",
62
- "typescript": "^5.3.0"
63
- }
64
- }
1
+ {
2
+ "name": "@repliqo/sdk-react-native",
3
+ "version": "0.4.0",
4
+ "description": "Session replay & analytics SDK for React Native. Captures screenshots (including modals/alerts), navigation, screen visits, and custom events.",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/SbtnAvls/app-analytics-api",
10
+ "directory": "packages/sdk-react-native"
11
+ },
12
+ "homepage": "https://repliqo.odmservice.site",
13
+ "license": "MIT",
14
+ "keywords": [
15
+ "react-native",
16
+ "analytics",
17
+ "session-replay",
18
+ "screen-capture",
19
+ "heatmaps",
20
+ "uxcam",
21
+ "repliqo"
22
+ ],
23
+ "files": [
24
+ "dist/",
25
+ "android/src/",
26
+ "android/build.gradle",
27
+ "src/",
28
+ "INTEGRATION_GUIDE.md"
29
+ ],
30
+ "scripts": {
31
+ "build": "tsc",
32
+ "test": "jest",
33
+ "prepublishOnly": "npm run build"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "peerDependencies": {
39
+ "@react-navigation/native": ">=6.0.0",
40
+ "react": ">=17.0.0",
41
+ "react-native": ">=0.68.0",
42
+ "react-native-view-shot": ">=3.0.0",
43
+ "@react-native-async-storage/async-storage": ">=1.17.0"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "react-native-view-shot": {
47
+ "optional": true
48
+ },
49
+ "@react-navigation/native": {
50
+ "optional": true
51
+ },
52
+ "@react-native-async-storage/async-storage": {
53
+ "optional": true
54
+ }
55
+ },
56
+ "devDependencies": {
57
+ "@react-navigation/native": "^6.1.0",
58
+ "@types/jest": "^29.5.0",
59
+ "@types/react": "^18.2.0",
60
+ "@types/react-native": "^0.72.0",
61
+ "jest": "^29.7.0",
62
+ "react": "^18.2.0",
63
+ "react-native": "^0.73.0",
64
+ "react-native-view-shot": "^4.0.3",
65
+ "ts-jest": "^29.1.0",
66
+ "typescript": "^5.3.0"
67
+ }
68
+ }
@@ -0,0 +1,64 @@
1
+ import React from 'react';
2
+ import { FlatList, type FlatListProps } from 'react-native';
3
+ import { useRepliqoScrollTracking } from './useRepliqoScrollTracking';
4
+
5
+ /**
6
+ * Drop-in replacement for React Native's FlatList with the same analytics
7
+ * behavior as RepliqoScrollView:
8
+ *
9
+ * 1. Reports scroll offset for accurate heatmap touch coords
10
+ * 2. Captures viewport tiles on scroll stops AND during continuous
11
+ * scroll for collaborative screen building
12
+ * 3. Registers its window-relative bounds with the SDK so touches can
13
+ * be auto-classified as "content" (inside the list) or "fixed" UI
14
+ *
15
+ * Note on tiles: FlatList virtualizes — off-screen rows aren't rendered —
16
+ * but that's exactly why the collaborative tile approach works here too:
17
+ * tiles are captured from what's ON screen as the user naturally scrolls.
18
+ *
19
+ * Note on contentHeight: with virtualization, RN estimates the content
20
+ * size until rows render; onContentSizeChange keeps our metadata in sync
21
+ * as the estimate refines.
22
+ */
23
+ function RepliqoFlatListInner<ItemT>(
24
+ {
25
+ onScroll,
26
+ onMomentumScrollEnd,
27
+ onScrollEndDrag,
28
+ onLayout,
29
+ onContentSizeChange,
30
+ ...props
31
+ }: FlatListProps<ItemT>,
32
+ ref: React.Ref<FlatList<ItemT>>,
33
+ ) {
34
+ const tracking = useRepliqoScrollTracking(ref, {
35
+ onScroll,
36
+ onMomentumScrollEnd,
37
+ onScrollEndDrag,
38
+ onLayout,
39
+ onContentSizeChange,
40
+ });
41
+
42
+ return (
43
+ <FlatList<ItemT>
44
+ ref={tracking.setRef as React.Ref<FlatList<ItemT>>}
45
+ {...props}
46
+ onScroll={tracking.onScroll}
47
+ onScrollEndDrag={tracking.onScrollEndDrag}
48
+ onMomentumScrollEnd={tracking.onMomentumScrollEnd}
49
+ onLayout={tracking.onLayout}
50
+ onContentSizeChange={tracking.onContentSizeChange}
51
+ scrollEventThrottle={props.scrollEventThrottle ?? 16}
52
+ />
53
+ );
54
+ }
55
+
56
+ export const RepliqoFlatList = React.forwardRef(RepliqoFlatListInner) as <
57
+ ItemT,
58
+ >(
59
+ props: FlatListProps<ItemT> & {
60
+ ref?: React.Ref<FlatList<ItemT>>;
61
+ },
62
+ ) => React.ReactElement;
63
+
64
+ (RepliqoFlatList as any).displayName = 'RepliqoFlatList';