@scalebun/react-native 1.10.7 → 1.11.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 (35) hide show
  1. package/android/src/main/java/com/scalebun/replaysdk/tracking/InteractionTracker.kt +25 -24
  2. package/dist/scalebun.full.js +236 -187
  3. package/dist/scalebun.slim.js +235 -186
  4. package/ios/Capture/InteractionTracker.swift +8 -4
  5. package/lib/commonjs/analytics/EventTracker.js +5 -5
  6. package/lib/commonjs/analytics/automaticEvents.js +3 -2
  7. package/lib/commonjs/core/constants/version.js +7 -2
  8. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +80 -103
  9. package/lib/commonjs/features/journey/interactionProtocol.js +47 -0
  10. package/lib/commonjs/features/journey/uiState.js +8 -1
  11. package/lib/commonjs/features/session/JourneyEventPipeline.js +6 -5
  12. package/lib/commonjs/features/session/SessionManager.js +37 -38
  13. package/lib/module/analytics/EventTracker.js +5 -5
  14. package/lib/module/analytics/automaticEvents.js +3 -2
  15. package/lib/module/core/constants/version.js +7 -2
  16. package/lib/module/features/journey/ScaleBunDebugRoot.js +80 -103
  17. package/lib/module/features/journey/interactionProtocol.js +38 -0
  18. package/lib/module/features/journey/uiState.js +8 -1
  19. package/lib/module/features/session/JourneyEventPipeline.js +6 -5
  20. package/lib/module/features/session/SessionManager.js +37 -38
  21. package/lib/typescript/analytics/EventTracker.d.ts +1 -1
  22. package/lib/typescript/analytics/automaticEvents.d.ts +3 -1
  23. package/lib/typescript/core/constants/version.d.ts +7 -2
  24. package/lib/typescript/features/journey/interactionProtocol.d.ts +21 -0
  25. package/lib/typescript/features/session/JourneyEventPipeline.d.ts +1 -0
  26. package/lib/typescript/features/session/SessionManager.d.ts +15 -10
  27. package/package.json +3 -2
  28. package/src/analytics/EventTracker.ts +5 -5
  29. package/src/analytics/automaticEvents.ts +4 -0
  30. package/src/core/constants/version.ts +7 -2
  31. package/src/features/journey/ScaleBunDebugRoot.tsx +96 -97
  32. package/src/features/journey/interactionProtocol.ts +65 -0
  33. package/src/features/journey/uiState.ts +9 -4
  34. package/src/features/session/JourneyEventPipeline.ts +7 -5
  35. package/src/features/session/SessionManager.ts +75 -38
@@ -41,16 +41,16 @@ import { bridgeAdapter } from '../replay/bridge/adapters/bridgeAdapter';
41
41
  import { redactBody } from '../../debug/redaction';
42
42
  import { consumeCalibrationTarget } from '../journey/calibrationContext';
43
43
  import { resolvePlatformOS } from '../../core/context/device';
44
+ import { emitAutomaticEvent } from '../../analytics/automaticEvents';
45
+ import {
46
+ INTERACTION_PROTOCOL_VERSION,
47
+ automaticInteractionProperties,
48
+ generateInteractionId,
49
+ type InteractionStateStatus,
50
+ } from '../journey/interactionProtocol';
44
51
 
45
52
  // ─── Types ──────────────────────────────────────────────────────────────────
46
53
 
47
- /**
48
- * Max analytics-lane heatmap interactions emitted per foreground (analytics
49
- * session) window. Bounds ingest volume now that capture defaults ON; enough to
50
- * resolve hotspot density, the long tail is dropped (drop-newest beyond cap).
51
- */
52
- const HEATMAP_MAX_INTERACTIONS_PER_WINDOW = 200;
53
-
54
54
  export interface SessionManagerConfig {
55
55
  sessionReplay?: Partial<SessionReplayConfig>;
56
56
  syncPolicy?: SyncDecisionCallback;
@@ -84,16 +84,6 @@ export class SessionManager {
84
84
  * lane even when no replay recording is active. Additive, opt-in (default off).
85
85
  */
86
86
  private _captureInteractionHeatmap = false;
87
- /**
88
- * Sampling cap for the analytics-lane heatmap emission. Now that capture is
89
- * ON by default, an unbounded one-event-per-gesture stream could materially
90
- * inflate ingest volume. We cap emitted interactions per analytics-session
91
- * window (finalize-scoped per foreground): the first N gestures define the
92
- * hotspot shape; the long tail is dropped. Resets when the window changes.
93
- */
94
- private _heatmapWindowSessionId: string | null = null;
95
- private _heatmapWindowCount = 0;
96
-
97
87
  private session: Session | null = null;
98
88
  private active = false;
99
89
  private timeoutTimer: ReturnType<typeof setTimeout> | null = null;
@@ -740,21 +730,16 @@ export class SessionManager {
740
730
  * injected sensitive keys (e.g. a label) are stripped before transport.
741
731
  * - Never throws.
742
732
  */
743
- private _emitInteractionToAnalytics(gestureType: string, payload: Record<string, unknown>): void {
744
- if (!this._captureInteractionHeatmap) return;
745
- if (this.active) return; // recording lane already carries this tap
733
+ private _emitInteractionToAnalytics(
734
+ gestureType: string,
735
+ payload: Record<string, unknown>,
736
+ occurredAt: number,
737
+ screenName?: string,
738
+ ): boolean {
739
+ if (!this._captureInteractionHeatmap) return false;
740
+ if (this.active) return false; // recording lane already carries this tap
746
741
  const adapter = this._backendTransport;
747
- if (!adapter) return;
748
-
749
- // Per-foreground sampling cap. Reset the counter when the analytics
750
- // session window rolls over, then drop anything past the cap.
751
- const windowId = adapter.analyticsSessionId ?? '';
752
- if (windowId !== this._heatmapWindowSessionId) {
753
- this._heatmapWindowSessionId = windowId;
754
- this._heatmapWindowCount = 0;
755
- }
756
- if (this._heatmapWindowCount >= HEATMAP_MAX_INTERACTIONS_PER_WINDOW) return;
757
- this._heatmapWindowCount++;
742
+ if (!adapter) return false;
758
743
 
759
744
  try {
760
745
  // Normalize coords in place (same logic as the recording lane).
@@ -782,16 +767,18 @@ export class SessionManager {
782
767
  eventId: generateEventId(),
783
768
  // sessionId is (re)stamped by the analytics lane at flush time.
784
769
  sessionId: adapter.analyticsSessionId ?? '',
785
- ts: Date.now(),
770
+ ts: occurredAt,
786
771
  type: 'USER_ACTION',
787
772
  subtype: `gesture:${gestureType}`,
788
- screen: this._lastKnownScreen ?? undefined,
773
+ screen: screenName ?? this._lastKnownScreen ?? undefined,
789
774
  payload: safePayload,
790
775
  source: 'user',
791
776
  };
792
777
  adapter.trackEvent(event);
778
+ return true;
793
779
  } catch (err) {
794
780
  logger.error('[SessionManager] heatmap analytics emit failed:', err);
781
+ return false;
795
782
  }
796
783
  }
797
784
 
@@ -810,6 +797,8 @@ export class SessionManager {
810
797
  traceId?: string;
811
798
  source?: JourneyEventSource;
812
799
  journeyId?: string;
800
+ /** Original observation time; interaction capture may resolve asynchronously. */
801
+ timestamp?: number;
813
802
  },
814
803
  ): void {
815
804
  if (!this.active) return;
@@ -1046,13 +1035,18 @@ export class SessionManager {
1046
1035
  * Notify of a user interaction. Called by ScaleBunDebugRoot touch handlers.
1047
1036
  * Also triggers frame capture for desktop-initiated recordings.
1048
1037
  */
1049
- onUserAction(subtype: string, payload?: Record<string, unknown>): void {
1038
+ onUserAction(
1039
+ subtype: string,
1040
+ payload?: Record<string, unknown>,
1041
+ context?: { screen?: string; timestamp?: number },
1042
+ ): void {
1050
1043
  if (!this.active) return; // Defense-in-depth: no emission when recording is OFF
1051
1044
  this.emitEvent('USER_ACTION', {
1052
1045
  subtype,
1053
- screen: this._lastKnownScreen ?? undefined,
1046
+ screen: context?.screen ?? this._lastKnownScreen ?? undefined,
1054
1047
  payload,
1055
1048
  source: 'user',
1049
+ timestamp: context?.timestamp,
1056
1050
  });
1057
1051
  // NOTE: Do NOT call captureManager.onInteraction() here.
1058
1052
  // emitEvent() already triggers onInteraction() for USER_ACTION events (line ~405).
@@ -1103,11 +1097,22 @@ export class SessionManager {
1103
1097
  screenWidth?: number;
1104
1098
  screenHeight?: number;
1105
1099
  platform?: string;
1100
+ interactionId?: string;
1101
+ interactionProtocol?: number;
1102
+ occurredAt?: number;
1103
+ ui?: string;
1104
+ stateStatus?: InteractionStateStatus;
1105
+ targetId?: string;
1106
+ screenName?: string;
1107
+ emitAutomaticAnalytics?: boolean;
1106
1108
  }): void {
1107
1109
  // Exact-duplicate suppression — see _lastGestureSig. Signature is the gesture type plus
1108
1110
  // raw coordinates verbatim; String() keeps undefined coords distinct from 0 (a payload
1109
1111
  // with no coords never collides with a real origin tap).
1110
- const dedupSig = `${gestureType}|${String(details?.x)}|${String(details?.y)}|${String(details?.endX)}|${String(details?.endY)}`;
1112
+ const suppliedInteractionId = details?.interactionId;
1113
+ const dedupSig = suppliedInteractionId
1114
+ ? `id:${suppliedInteractionId}`
1115
+ : `${gestureType}|${String(details?.x)}|${String(details?.y)}|${String(details?.endX)}|${String(details?.endY)}`;
1111
1116
  const nowTs = Date.now();
1112
1117
  if (
1113
1118
  dedupSig === this._lastGestureSig &&
@@ -1122,7 +1127,15 @@ export class SessionManager {
1122
1127
  // Build the canonical gesture payload ONCE so the recording lane and the
1123
1128
  // (additive) analytics lane carry byte-identical keys (normalizedX/Y,
1124
1129
  // gestureType, etc.). Same object shape that was previously inlined.
1130
+ const interactionId = suppliedInteractionId ?? generateInteractionId();
1131
+ const occurredAt = details?.occurredAt ?? Date.now();
1132
+ const stateStatus = details?.stateStatus ?? (details?.ui ? 'captured_nonempty' : 'not_captured');
1125
1133
  const payload: Record<string, unknown> = {
1134
+ interaction_id: interactionId,
1135
+ interaction_protocol: details?.interactionProtocol ?? INTERACTION_PROTOCOL_VERSION,
1136
+ state_status: stateStatus,
1137
+ ui: details?.ui,
1138
+ target_id: details?.targetId,
1126
1139
  gestureType,
1127
1140
  x: details?.x,
1128
1141
  y: details?.y,
@@ -1167,7 +1180,27 @@ export class SessionManager {
1167
1180
  // when the flag is OFF, no backend transport is attached, or recording is
1168
1181
  // active (the recording path below already carries this tap). Uses a fresh
1169
1182
  // payload clone so analytics normalization never mutates the recording one.
1170
- this._emitInteractionToAnalytics(gestureType, { ...payload });
1183
+ const analyticsReplayCarrier = this._emitInteractionToAnalytics(
1184
+ gestureType,
1185
+ { ...payload },
1186
+ occurredAt,
1187
+ details?.screenName,
1188
+ );
1189
+ const replayCarrier = !!this._backendTransport && (this.active || analyticsReplayCarrier);
1190
+ if (details?.emitAutomaticAnalytics) {
1191
+ try {
1192
+ const safePayload = redactBody(payload) as Record<string, unknown>;
1193
+ emitAutomaticEvent(
1194
+ 'element_interacted',
1195
+ automaticInteractionProperties(
1196
+ safePayload,
1197
+ details?.screenName ?? this._lastKnownScreen ?? undefined,
1198
+ replayCarrier,
1199
+ ),
1200
+ occurredAt,
1201
+ );
1202
+ } catch { /* automatic projection must never affect interaction capture */ }
1203
+ }
1171
1204
 
1172
1205
  if (!this.active) return; // Defense-in-depth: no emission when recording is OFF
1173
1206
  // The subtype becomes `ReplayEvent.label`, which is what the Events explorer GROUPS BY. Naming
@@ -1178,7 +1211,11 @@ export class SessionManager {
1178
1211
  // ⚠️ `gestureType` also travels in the payload, and the backend's `resolveGesture` reads THAT
1179
1212
  // first — so enriching the label cannot change heatmap gesture classification.
1180
1213
  const target = typeof details?.target === 'string' ? details.target.trim() : '';
1181
- this.onUserAction(target ? `gesture:${gestureType} · ${target}` : `gesture:${gestureType}`, payload);
1214
+ this.onUserAction(
1215
+ target ? `gesture:${gestureType} · ${target}` : `gesture:${gestureType}`,
1216
+ payload,
1217
+ { screen: details?.screenName, timestamp: occurredAt },
1218
+ );
1182
1219
  }
1183
1220
 
1184
1221
  /**