@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
@@ -17,8 +17,8 @@ class InteractionTracker: NSObject, UIGestureRecognizerDelegate {
17
17
 
18
18
  private var touchDownLocation: CGPoint = .zero
19
19
  private var touchDownTime: TimeInterval = 0
20
- private var lastEmitTime: TimeInterval = 0
21
- private let throttleInterval: TimeInterval = 0.15
20
+ private var touchInteractionId = ""
21
+ private var lastEmittedInteractionId = ""
22
22
 
23
23
  func attach(to window: UIWindow, sendEvent: @escaping (String, [String: Any]) -> Void) {
24
24
  guard !isActive else { return }
@@ -29,6 +29,7 @@ class InteractionTracker: NSObject, UIGestureRecognizerDelegate {
29
29
  onBegan: { [weak self] location in
30
30
  self?.touchDownLocation = location
31
31
  self?.touchDownTime = Date().timeIntervalSince1970
32
+ self?.touchInteractionId = "ixn-i-\(UUID().uuidString.lowercased())"
32
33
  },
33
34
  onEnded: { [weak self] location in
34
35
  self?.handleTouchEnded(at: location, in: window)
@@ -55,8 +56,8 @@ class InteractionTracker: NSObject, UIGestureRecognizerDelegate {
55
56
 
56
57
  private func handleTouchEnded(at location: CGPoint, in window: UIWindow) {
57
58
  let now = Date().timeIntervalSince1970
58
- guard now - lastEmitTime >= throttleInterval else { return }
59
- lastEmitTime = now
59
+ guard touchInteractionId != lastEmittedInteractionId else { return }
60
+ lastEmittedInteractionId = touchInteractionId
60
61
 
61
62
  let durationMs = (now - touchDownTime) * 1000
62
63
  let dx = location.x - touchDownLocation.x
@@ -104,6 +105,9 @@ class InteractionTracker: NSObject, UIGestureRecognizerDelegate {
104
105
 
105
106
  let event: [String: Any] = [
106
107
  "gestureType": gestureType,
108
+ "interactionId": touchInteractionId,
109
+ "interactionProtocol": 1,
110
+ "occurredAt": touchDownTime * 1000,
107
111
  "rawX": downInCapture.x,
108
112
  "rawY": downInCapture.y,
109
113
  "normalizedX": nX,
@@ -103,7 +103,7 @@ class EventTracker {
103
103
  this.started = true;
104
104
  if (this.cfg.automaticEventTracking) {
105
105
  this.automaticEventsUnsubscribe = (0, _automaticEvents.subscribeAutomaticEvents)(event => {
106
- this.track(event.name, event.properties);
106
+ this.track(event.name, event.properties, event.timestamp);
107
107
  });
108
108
  }
109
109
  if (this.cfg.autoLifecycleEvents) {
@@ -226,9 +226,9 @@ class EventTracker {
226
226
 
227
227
  // ─── tracking ────────────────────────────────────────────────────────────
228
228
 
229
- track(eventName, properties) {
229
+ track(eventName, properties, timestamp) {
230
230
  try {
231
- this.enqueue(this.buildEnvelope(eventName, properties));
231
+ this.enqueue(this.buildEnvelope(eventName, properties, timestamp));
232
232
  try {
233
233
  this.cfg.onEvent?.(eventName);
234
234
  } catch {/* no-throw */}
@@ -365,7 +365,7 @@ class EventTracker {
365
365
 
366
366
  // ─── internals ─────────────────────────────────────────────────────────────
367
367
 
368
- buildEnvelope(eventName, properties) {
368
+ buildEnvelope(eventName, properties, timestamp) {
369
369
  const ctx = this.cfg.context ?? {};
370
370
  let canonicalSessionId;
371
371
  try {
@@ -376,7 +376,7 @@ class EventTracker {
376
376
  const env = {
377
377
  event_id: uuid(),
378
378
  event_name: eventName,
379
- event_time: Date.now(),
379
+ event_time: timestamp ?? Date.now(),
380
380
  app_id: this.cfg.appId,
381
381
  platform: this.cfg.platform ?? (0, _device.resolveEventPlatform)(),
382
382
  installation_id: this.installationId,
@@ -27,10 +27,11 @@ function compactProperties(properties) {
27
27
  }
28
28
  return out;
29
29
  }
30
- function emitAutomaticEvent(name, properties) {
30
+ function emitAutomaticEvent(name, properties, timestamp) {
31
31
  const event = {
32
32
  name,
33
- properties: compactProperties(properties)
33
+ properties: compactProperties(properties),
34
+ timestamp
34
35
  };
35
36
  if (listeners.size === 0) {
36
37
  pending.push(event);
@@ -7,7 +7,12 @@ exports.SDK_VERSION = void 0;
7
7
  /**
8
8
  * ScaleBun SDK version. Sent with the session-start envelope so the dashboard
9
9
  * can attribute telemetry to the SDK build that produced it.
10
- * Keep in sync with package.json "version".
10
+ * Keep in sync with package.json "version" — `version.test.ts` fails when they drift.
11
+ *
12
+ * Why the test matters: the 1.11.0 bump missed this line, so the build would have reported itself as
13
+ * 1.10.6. Every "is the release live, and on what share of traffic" question is answered from this
14
+ * value, so a stale one makes a rollout unobservable — which is the exact problem sending an SDK
15
+ * version was introduced to solve.
11
16
  */
12
- const SDK_VERSION = exports.SDK_VERSION = '1.10.7';
17
+ const SDK_VERSION = exports.SDK_VERSION = '1.11.0';
13
18
  //# sourceMappingURL=version.js.map
@@ -8,9 +8,9 @@ exports.ScaleBunScreen = ScaleBunScreen;
8
8
  exports.useScaleBunScreen = useScaleBunScreen;
9
9
  var _react = _interopRequireWildcard(require("react"));
10
10
  var _reactNative = require("react-native");
11
- var _automaticEvents = require("../../analytics/automaticEvents");
12
11
  var _touchTarget = require("./touchTarget");
13
12
  var _device = require("../../core/context/device");
13
+ var _interactionProtocol = require("./interactionProtocol");
14
14
  var _jsxRuntime = require("react/jsx-runtime");
15
15
  function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
16
16
  /**
@@ -264,7 +264,9 @@ function ScaleBunDebugRoot({
264
264
  // and forwards them to SessionManager as USER_ACTION events.
265
265
  const lastNativeTouchTsRef = (0, _react.useRef)(0);
266
266
  const nativeTrackingConfirmedRef = (0, _react.useRef)(false);
267
- const pendingJsEmitRef = (0, _react.useRef)(null);
267
+ const touchStartRef = (0, _react.useRef)(null);
268
+ const interactionStartsRef = (0, _react.useRef)([]);
269
+ const pendingJsEmitsRef = (0, _react.useRef)(new Map());
268
270
  (0, _react.useEffect)(() => {
269
271
  let subscription = null;
270
272
  try {
@@ -276,11 +278,15 @@ function ScaleBunDebugRoot({
276
278
  // Record timestamp to suppress duplicate JS touch events
277
279
  lastNativeTouchTsRef.current = Date.now();
278
280
  nativeTrackingConfirmedRef.current = true;
279
-
280
- // Cancel any pending JS emission — native event wins
281
- if (pendingJsEmitRef.current !== null) {
282
- clearTimeout(pendingJsEmitRef.current);
283
- pendingJsEmitRef.current = null;
281
+ const nativeOccurredAt = typeof event.occurredAt === 'number' ? event.occurredAt : (typeof event.timestamp === 'number' ? event.timestamp : Date.now()) - (typeof event.durationMs === 'number' ? event.durationMs : 0);
282
+ const start = (0, _interactionProtocol.nearestInteractionStart)(interactionStartsRef.current, nativeOccurredAt);
283
+
284
+ // Cancel only this physical touch's fallback. A single timer used to cancel a
285
+ // previous rapid tap and silently lose it on devices without native tracking.
286
+ if (start) {
287
+ const pending = pendingJsEmitsRef.current.get(start.interactionId);
288
+ if (pending !== undefined) clearTimeout(pending);
289
+ pendingJsEmitsRef.current.delete(start.interactionId);
284
290
  }
285
291
  const {
286
292
  SessionManager
@@ -302,6 +308,15 @@ function ScaleBunDebugRoot({
302
308
  _diagWinH = _d.height;
303
309
  } catch {/* no-throw */}
304
310
  sm.onGestureDetected(event.gestureType || 'tap', {
311
+ interactionId: event.interactionId || start?.interactionId || (0, _interactionProtocol.generateInteractionId)(),
312
+ interactionProtocol: event.interactionProtocol || _interactionProtocol.INTERACTION_PROTOCOL_VERSION,
313
+ occurredAt: nativeOccurredAt,
314
+ ui: start?.ui,
315
+ stateStatus: start?.stateStatus ?? 'not_captured',
316
+ target: start?.target,
317
+ targetId: start?.targetId,
318
+ screenName: start?.screenName,
319
+ emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
305
320
  x: event.rawX,
306
321
  y: event.rawY,
307
322
  // Use the actual native end coords when present, not the down
@@ -331,11 +346,12 @@ function ScaleBunDebugRoot({
331
346
  try {
332
347
  subscription?.remove();
333
348
  } catch {/* no-throw */}
349
+ for (const timer of pendingJsEmitsRef.current.values()) clearTimeout(timer);
350
+ pendingJsEmitsRef.current.clear();
334
351
  };
335
- }, []);
352
+ }, [captureAutomaticInteractions]);
336
353
 
337
354
  // ─── Touch Tracking State ───────────────────────────────────────────
338
- const touchStartRef = (0, _react.useRef)(null);
339
355
 
340
356
  // On-screen rect of the root view — the EXACT view the native screenshot
341
357
  // captures. Normalizing pageX/pageY against this rect makes JS-fallback taps
@@ -379,11 +395,37 @@ function ScaleBunDebugRoot({
379
395
  const handleTouchStart = e => {
380
396
  try {
381
397
  const touch = e.nativeEvent;
382
- touchStartRef.current = {
398
+ const target = (0, _touchTarget.resolveTouchTarget)(e);
399
+ let ui;
400
+ let screenName;
401
+ try {
402
+ const {
403
+ uiStateSignature
404
+ } = require('./uiState');
405
+ ui = uiStateSignature();
406
+ } catch {/* no-throw */}
407
+ try {
408
+ const {
409
+ AutoScreenDetector
410
+ } = require('../navigation/AutoScreenDetector');
411
+ screenName = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
412
+ } catch {/* no-throw */}
413
+ const start = {
414
+ interactionId: (0, _interactionProtocol.generateInteractionId)(),
415
+ occurredAt: Date.now(),
383
416
  x: touch.pageX,
384
417
  y: touch.pageY,
385
- ts: Date.now()
418
+ locationX: touch.locationX,
419
+ locationY: touch.locationY,
420
+ target: (0, _touchTarget.describeTouchTarget)(target),
421
+ targetId: target?.testID,
422
+ screenName,
423
+ ui,
424
+ stateStatus: ui ? 'captured_nonempty' : 'not_instrumented',
425
+ emitAutomaticAnalytics: captureAutomaticInteractions
386
426
  };
427
+ touchStartRef.current = start;
428
+ interactionStartsRef.current = interactionStartsRef.current.filter(candidate => start.occurredAt - candidate.occurredAt < 5000).concat(start).slice(-8);
387
429
  } catch {/* no-throw */}
388
430
  };
389
431
  const handleTouchEnd = e => {
@@ -422,15 +464,23 @@ function ScaleBunDebugRoot({
422
464
  // WHAT was tapped, resolved from the React fiber on the touch event. Without this the tap
423
465
  // carries only coordinates, so every tap in the app groups into one row per gesture type.
424
466
  // Defensive by construction — returns undefined rather than throwing (see touchTarget.ts).
425
- const tapped = (0, _touchTarget.describeTouchTarget)((0, _touchTarget.resolveTouchTarget)(e));
467
+ const tapped = start?.target ?? (0, _touchTarget.describeTouchTarget)((0, _touchTarget.resolveTouchTarget)(e));
426
468
  const gestureDetails = {
427
469
  target: tapped,
428
- x: touch.pageX,
429
- y: touch.pageY,
430
- pageX: touch.pageX,
431
- pageY: touch.pageY,
432
- locationX: touch.locationX,
433
- locationY: touch.locationY,
470
+ interactionId: start?.interactionId ?? (0, _interactionProtocol.generateInteractionId)(),
471
+ interactionProtocol: _interactionProtocol.INTERACTION_PROTOCOL_VERSION,
472
+ occurredAt: start?.occurredAt ?? Date.now(),
473
+ ui: start?.ui,
474
+ stateStatus: start?.stateStatus ?? 'not_captured',
475
+ targetId: start?.targetId,
476
+ screenName: start?.screenName,
477
+ emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
478
+ x: start?.x ?? touch.pageX,
479
+ y: start?.y ?? touch.pageY,
480
+ pageX: start?.x ?? touch.pageX,
481
+ pageY: start?.y ?? touch.pageY,
482
+ locationX: start?.locationX ?? touch.locationX,
483
+ locationY: start?.locationY ?? touch.locationY,
434
484
  viewportWidth: vpW > 0 ? vpW : undefined,
435
485
  viewportHeight: vpH > 0 ? vpH : undefined
436
486
  };
@@ -442,8 +492,8 @@ function ScaleBunDebugRoot({
442
492
  // measureInWindow returns rootRect.y = statusBarHeight, so subtracting it
443
493
  // double-counts the status bar and pushes every marker upward.
444
494
  if (rootRect && rootRect.w > 0 && rootRect.h > 0) {
445
- const nx = Math.max(0, Math.min(1, touch.pageX / rootRect.w));
446
- const ny = Math.max(0, Math.min(1, touch.pageY / rootRect.h));
495
+ const nx = Math.max(0, Math.min(1, (start?.x ?? touch.pageX) / rootRect.w));
496
+ const ny = Math.max(0, Math.min(1, (start?.y ?? touch.pageY) / rootRect.h));
447
497
  gestureDetails.normalizedX = nx;
448
498
  gestureDetails.normalizedY = ny;
449
499
  gestureDetails.normalizedPrecomputed = true;
@@ -475,7 +525,7 @@ function ScaleBunDebugRoot({
475
525
  const dx = touch.pageX - start.x;
476
526
  const dy = touch.pageY - start.y;
477
527
  const dist = Math.sqrt(dx * dx + dy * dy);
478
- const duration = Date.now() - start.ts;
528
+ const duration = Date.now() - start.occurredAt;
479
529
  gestureDetails.duration = duration;
480
530
  if (dist >= 15) {
481
531
  // Movement gesture: swipe or scroll
@@ -494,52 +544,6 @@ function ScaleBunDebugRoot({
494
544
  gestureType = 'long_press';
495
545
  }
496
546
  }
497
- const targetInfo = _extractTarget(e);
498
- gestureDetails.target = targetInfo?.testId || targetInfo?.accessibilityLabel;
499
- if (captureAutomaticInteractions) {
500
- let screen;
501
- try {
502
- const {
503
- AutoScreenDetector
504
- } = require('../navigation/AutoScreenDetector');
505
- screen = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
506
- } catch {/* no-throw */}
507
- /**
508
- * UI STATE, read HERE and not later.
509
- *
510
- * A tap belongs to the surface that was on screen when the finger landed: tapping the
511
- * filter button while the sheet is DOWN belongs to `closed`, because that is what the
512
- * user was looking at when they reached for it. Reading it after the handler has run
513
- * moves every "open the thing" tap into the state it created — the one state it
514
- * certainly does not belong to.
515
- *
516
- * Declared-only on this platform (see uiState.ts): absent means not captured, which
517
- * the dashboard keeps distinct from "nothing was open".
518
- */
519
- let ui;
520
- try {
521
- const {
522
- uiStateSignature
523
- } = require('./uiState');
524
- ui = uiStateSignature();
525
- } catch {/* no-throw: a tap must never be lost to state capture */}
526
- (0, _automaticEvents.emitAutomaticEvent)('element_interacted', {
527
- gesture_type: gestureType,
528
- screen_name: screen,
529
- /* THIS MAP ENUMERATES. A field added to the payload and forgotten here reaches the
530
- backend as undefined with no error anywhere — the recurring defect class in this
531
- codebase. `ui` is the key the grid aggregate reads for state, byte-identical to
532
- the web SDK's, so one dashboard control queries both platforms. */
533
- ui,
534
- // testID/nativeID is an author-controlled stable identifier.
535
- // Accessibility labels and rendered text are deliberately omitted.
536
- target_id: targetInfo?.testId,
537
- normalized_x: gestureDetails.normalizedX,
538
- normalized_y: gestureDetails.normalizedY,
539
- direction: gestureDetails.direction,
540
- duration_ms: gestureDetails.duration
541
- });
542
- }
543
547
 
544
548
  // Feed the Engage gesture detector (rage_tap / dead_tap) — only committed
545
549
  // taps, not swipes/scrolls/long-presses. Pure JS, cross-platform; emits the
@@ -549,18 +553,11 @@ function ScaleBunDebugRoot({
549
553
  const {
550
554
  gestureTriggerDetector
551
555
  } = require('../engage/gestureTriggerDetector');
552
- let screen;
553
- try {
554
- const {
555
- AutoScreenDetector
556
- } = require('../navigation/AutoScreenDetector');
557
- screen = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
558
- } catch {/* no-throw */}
559
556
  gestureTriggerDetector.recordTap({
560
- x: touch.pageX,
561
- y: touch.pageY,
562
- hasTarget: !!(targetInfo?.testId || targetInfo?.accessibilityLabel),
563
- screen
557
+ x: start?.x ?? touch.pageX,
558
+ y: start?.y ?? touch.pageY,
559
+ hasTarget: !!(start?.targetId || start?.target),
560
+ screen: start?.screenName
564
561
  });
565
562
  } catch {/* no-throw */}
566
563
  }
@@ -575,7 +572,7 @@ function ScaleBunDebugRoot({
575
572
  // screenshot capture view). Three-tier suppression:
576
573
  // 1. Native confirmed active → skip JS entirely (native handles all touches)
577
574
  // 2. Native event within 300ms → skip (existing dedup)
578
- // 3. First touch (native not yet confirmed) → defer 200ms to let native arrive
575
+ // 3. First touch (native not yet confirmed) → defer 600ms to let native arrive
579
576
  const sinceNative = Date.now() - lastNativeTouchTsRef.current;
580
577
  if (nativeTrackingConfirmedRef.current) {
581
578
  // Native tracker is active — skip JS path entirely
@@ -588,11 +585,9 @@ function ScaleBunDebugRoot({
588
585
  const capturedDetails = {
589
586
  ...gestureDetails
590
587
  };
591
- if (pendingJsEmitRef.current !== null) {
592
- clearTimeout(pendingJsEmitRef.current);
593
- }
594
- pendingJsEmitRef.current = setTimeout(() => {
595
- pendingJsEmitRef.current = null;
588
+ const interactionId = capturedDetails.interactionId;
589
+ const timer = setTimeout(() => {
590
+ pendingJsEmitsRef.current.delete(interactionId);
596
591
  if (nativeTrackingConfirmedRef.current) return;
597
592
  try {
598
593
  const {
@@ -616,6 +611,7 @@ function ScaleBunDebugRoot({
616
611
  // devices with no native tracker at all it merely delays a background emission,
617
612
  // which nothing user-visible waits on.
618
613
  }, 600);
614
+ pendingJsEmitsRef.current.set(interactionId, timer);
619
615
  }
620
616
  touchStartRef.current = null;
621
617
  } catch {/* no-throw */}
@@ -730,25 +726,6 @@ function useScaleBunScreen(name) {
730
726
  }, [name, manager]);
731
727
  }
732
728
 
733
- // ─── Helpers ────────────────────────────────────────────────────────────────
734
-
735
- function _extractTarget(e) {
736
- try {
737
- const target = e?.target;
738
- if (!target) return undefined;
739
-
740
- // React Native nativeEvent target properties
741
- const props = target._internalFiberInstanceHandleDEV?.memoizedProps ?? {};
742
- return {
743
- testId: props.testID || props.nativeID || undefined,
744
- accessibilityLabel: props.accessibilityLabel || undefined,
745
- text: undefined
746
- };
747
- } catch {
748
- return undefined;
749
- }
750
- }
751
-
752
729
  // ─── Styles ─────────────────────────────────────────────────────────────────
753
730
  // IMPORTANT: Do NOT use StyleSheet.create() here.
754
731
  // In RN 0.84 bridgeless mode, StyleSheet.create() calls
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.INTERACTION_PROTOCOL_VERSION = void 0;
7
+ exports.automaticInteractionProperties = automaticInteractionProperties;
8
+ exports.generateInteractionId = generateInteractionId;
9
+ exports.nearestInteractionStart = nearestInteractionStart;
10
+ /** One physical interaction, shared by replay and analytics projections. */
11
+ const INTERACTION_PROTOCOL_VERSION = exports.INTERACTION_PROTOCOL_VERSION = 1;
12
+ function generateInteractionId() {
13
+ return `ixj-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
14
+ }
15
+
16
+ /** Match a native bridge event to the JS evidence sampled at the same finger-down. */
17
+ function nearestInteractionStart(starts, occurredAt, toleranceMs = 1500) {
18
+ let best;
19
+ let bestDelta = toleranceMs + 1;
20
+ for (const start of starts) {
21
+ const delta = Math.abs(start.occurredAt - occurredAt);
22
+ if (delta < bestDelta) {
23
+ best = start;
24
+ bestDelta = delta;
25
+ }
26
+ }
27
+ return bestDelta <= toleranceMs ? best : undefined;
28
+ }
29
+
30
+ /** Analytics is a projection of the same evidence; no second click is invented. */
31
+ function automaticInteractionProperties(payload, screenName, canonicalMirror) {
32
+ return {
33
+ gesture_type: payload.gestureType,
34
+ screen_name: screenName,
35
+ interaction_id: payload.interaction_id,
36
+ interaction_protocol: payload.interaction_protocol,
37
+ state_status: payload.state_status,
38
+ ui: payload.ui,
39
+ target_id: payload.target_id,
40
+ normalized_x: payload.normalizedX,
41
+ normalized_y: payload.normalizedY,
42
+ direction: payload.direction,
43
+ duration_ms: payload.durationMs,
44
+ canonical_mirror: canonicalMirror || undefined
45
+ };
46
+ }
47
+ //# sourceMappingURL=interactionProtocol.js.map
@@ -82,6 +82,13 @@ function uiStateSignature() {
82
82
  if (!declared.size) return undefined;
83
83
  /* SORTED, or the same surface produces different signatures depending on the order the host happened
84
84
  to declare things in, and every count fragments into several that mean nothing. */
85
- return [...declared.keys()].sort().map(k => `${k}:${declared.get(k)}`).join(';').slice(0, 96);
85
+ const pairs = [...declared.keys()].sort().map(k => `${k}:${declared.get(k)}`);
86
+ let signature = '';
87
+ for (const pair of pairs) {
88
+ const next = signature ? `${signature};${pair}` : pair;
89
+ if (next.length > 96) break;
90
+ signature = next;
91
+ }
92
+ return signature || undefined;
86
93
  }
87
94
  //# sourceMappingURL=uiState.js.map
@@ -70,26 +70,27 @@ class JourneyEventPipeline {
70
70
  emit(type, opts) {
71
71
  try {
72
72
  const key = `${type}:${opts?.subtype ?? ''}`;
73
- const now = Date.now();
74
- if (key === this.lastEventKey && now - this.lastEventTs < this.config.dedupeWindowMs) {
73
+ const receivedAt = Date.now();
74
+ const occurredAt = opts?.timestamp ?? receivedAt;
75
+ if (key === this.lastEventKey && receivedAt - this.lastEventTs < this.config.dedupeWindowMs) {
75
76
  // Within dedup window — allow high-confidence native events to
76
77
  // REPLACE a prior low-confidence JS event for the same gesture.
77
78
  // This prevents the race where JS fires first and the pipeline
78
79
  // drops the native event that has more accurate coordinates.
79
80
  const incomingConfidence = opts?.payload?.confidence;
80
81
  if (incomingConfidence === 'high' && this.lastEventConfidence !== 'high') {
81
- this._replaceLastEvent(key, now, opts);
82
+ this._replaceLastEvent(key, receivedAt, opts);
82
83
  }
83
84
  return null;
84
85
  }
85
86
  this.lastEventKey = key;
86
- this.lastEventTs = now;
87
+ this.lastEventTs = receivedAt;
87
88
  this.lastEventConfidence = opts?.payload?.confidence ?? null;
88
89
  const event = {
89
90
  eventId: generateEventId(),
90
91
  sessionId: this.sessionId,
91
92
  journeyId: opts?.journeyId,
92
- ts: now,
93
+ ts: occurredAt,
93
94
  type,
94
95
  subtype: opts?.subtype,
95
96
  severity: opts?.severity ?? inferSeverity(type),
@@ -17,6 +17,8 @@ var _bridgeAdapter = require("../replay/bridge/adapters/bridgeAdapter");
17
17
  var _redaction = require("../../debug/redaction");
18
18
  var _calibrationContext = require("../journey/calibrationContext");
19
19
  var _device = require("../../core/context/device");
20
+ var _automaticEvents = require("../../analytics/automaticEvents");
21
+ var _interactionProtocol = require("../journey/interactionProtocol");
20
22
  /**
21
23
  * ScaleBun SDK — Session Manager
22
24
  *
@@ -35,12 +37,6 @@ var _device = require("../../core/context/device");
35
37
 
36
38
  // ─── Types ──────────────────────────────────────────────────────────────────
37
39
 
38
- /**
39
- * Max analytics-lane heatmap interactions emitted per foreground (analytics
40
- * session) window. Bounds ingest volume now that capture defaults ON; enough to
41
- * resolve hotspot density, the long tail is dropped (drop-newest beyond cap).
42
- */
43
- const HEATMAP_MAX_INTERACTIONS_PER_WINDOW = 200;
44
40
  // ─── Singleton ──────────────────────────────────────────────────────────────
45
41
 
46
42
  let _instance = null;
@@ -56,15 +52,6 @@ class SessionManager {
56
52
  * lane even when no replay recording is active. Additive, opt-in (default off).
57
53
  */
58
54
  _captureInteractionHeatmap = false;
59
- /**
60
- * Sampling cap for the analytics-lane heatmap emission. Now that capture is
61
- * ON by default, an unbounded one-event-per-gesture stream could materially
62
- * inflate ingest volume. We cap emitted interactions per analytics-session
63
- * window (finalize-scoped per foreground): the first N gestures define the
64
- * hotspot shape; the long tail is dropped. Resets when the window changes.
65
- */
66
- _heatmapWindowSessionId = null;
67
- _heatmapWindowCount = 0;
68
55
  session = null;
69
56
  active = false;
70
57
  timeoutTimer = null;
@@ -666,21 +653,11 @@ class SessionManager {
666
653
  * injected sensitive keys (e.g. a label) are stripped before transport.
667
654
  * - Never throws.
668
655
  */
669
- _emitInteractionToAnalytics(gestureType, payload) {
670
- if (!this._captureInteractionHeatmap) return;
671
- if (this.active) return; // recording lane already carries this tap
656
+ _emitInteractionToAnalytics(gestureType, payload, occurredAt, screenName) {
657
+ if (!this._captureInteractionHeatmap) return false;
658
+ if (this.active) return false; // recording lane already carries this tap
672
659
  const adapter = this._backendTransport;
673
- if (!adapter) return;
674
-
675
- // Per-foreground sampling cap. Reset the counter when the analytics
676
- // session window rolls over, then drop anything past the cap.
677
- const windowId = adapter.analyticsSessionId ?? '';
678
- if (windowId !== this._heatmapWindowSessionId) {
679
- this._heatmapWindowSessionId = windowId;
680
- this._heatmapWindowCount = 0;
681
- }
682
- if (this._heatmapWindowCount >= HEATMAP_MAX_INTERACTIONS_PER_WINDOW) return;
683
- this._heatmapWindowCount++;
660
+ if (!adapter) return false;
684
661
  try {
685
662
  // Normalize coords in place (same logic as the recording lane).
686
663
  this._normalizeInteractionPayload(payload);
@@ -707,16 +684,18 @@ class SessionManager {
707
684
  eventId: (0, _sessionId.generateEventId)(),
708
685
  // sessionId is (re)stamped by the analytics lane at flush time.
709
686
  sessionId: adapter.analyticsSessionId ?? '',
710
- ts: Date.now(),
687
+ ts: occurredAt,
711
688
  type: 'USER_ACTION',
712
689
  subtype: `gesture:${gestureType}`,
713
- screen: this._lastKnownScreen ?? undefined,
690
+ screen: screenName ?? this._lastKnownScreen ?? undefined,
714
691
  payload: safePayload,
715
692
  source: 'user'
716
693
  };
717
694
  adapter.trackEvent(event);
695
+ return true;
718
696
  } catch (err) {
719
697
  _internalLogger.logger.error('[SessionManager] heatmap analytics emit failed:', err);
698
+ return false;
720
699
  }
721
700
  }
722
701
 
@@ -936,13 +915,14 @@ class SessionManager {
936
915
  * Notify of a user interaction. Called by ScaleBunDebugRoot touch handlers.
937
916
  * Also triggers frame capture for desktop-initiated recordings.
938
917
  */
939
- onUserAction(subtype, payload) {
918
+ onUserAction(subtype, payload, context) {
940
919
  if (!this.active) return; // Defense-in-depth: no emission when recording is OFF
941
920
  this.emitEvent('USER_ACTION', {
942
921
  subtype,
943
- screen: this._lastKnownScreen ?? undefined,
922
+ screen: context?.screen ?? this._lastKnownScreen ?? undefined,
944
923
  payload,
945
- source: 'user'
924
+ source: 'user',
925
+ timestamp: context?.timestamp
946
926
  });
947
927
  // NOTE: Do NOT call captureManager.onInteraction() here.
948
928
  // emitEvent() already triggers onInteraction() for USER_ACTION events (line ~405).
@@ -957,7 +937,8 @@ class SessionManager {
957
937
  // Exact-duplicate suppression — see _lastGestureSig. Signature is the gesture type plus
958
938
  // raw coordinates verbatim; String() keeps undefined coords distinct from 0 (a payload
959
939
  // with no coords never collides with a real origin tap).
960
- const dedupSig = `${gestureType}|${String(details?.x)}|${String(details?.y)}|${String(details?.endX)}|${String(details?.endY)}`;
940
+ const suppliedInteractionId = details?.interactionId;
941
+ const dedupSig = suppliedInteractionId ? `id:${suppliedInteractionId}` : `${gestureType}|${String(details?.x)}|${String(details?.y)}|${String(details?.endX)}|${String(details?.endY)}`;
961
942
  const nowTs = Date.now();
962
943
  if (dedupSig === this._lastGestureSig && nowTs - this._lastGestureTs <= SessionManager.GESTURE_DEDUP_WINDOW_MS) {
963
944
  this._lastGestureTs = nowTs; // a burst of 3 stays suppressed even if gaps chain past the window
@@ -969,7 +950,15 @@ class SessionManager {
969
950
  // Build the canonical gesture payload ONCE so the recording lane and the
970
951
  // (additive) analytics lane carry byte-identical keys (normalizedX/Y,
971
952
  // gestureType, etc.). Same object shape that was previously inlined.
953
+ const interactionId = suppliedInteractionId ?? (0, _interactionProtocol.generateInteractionId)();
954
+ const occurredAt = details?.occurredAt ?? Date.now();
955
+ const stateStatus = details?.stateStatus ?? (details?.ui ? 'captured_nonempty' : 'not_captured');
972
956
  const payload = {
957
+ interaction_id: interactionId,
958
+ interaction_protocol: details?.interactionProtocol ?? _interactionProtocol.INTERACTION_PROTOCOL_VERSION,
959
+ state_status: stateStatus,
960
+ ui: details?.ui,
961
+ target_id: details?.targetId,
973
962
  gestureType,
974
963
  x: details?.x,
975
964
  y: details?.y,
@@ -1014,9 +1003,16 @@ class SessionManager {
1014
1003
  // when the flag is OFF, no backend transport is attached, or recording is
1015
1004
  // active (the recording path below already carries this tap). Uses a fresh
1016
1005
  // payload clone so analytics normalization never mutates the recording one.
1017
- this._emitInteractionToAnalytics(gestureType, {
1006
+ const analyticsReplayCarrier = this._emitInteractionToAnalytics(gestureType, {
1018
1007
  ...payload
1019
- });
1008
+ }, occurredAt, details?.screenName);
1009
+ const replayCarrier = !!this._backendTransport && (this.active || analyticsReplayCarrier);
1010
+ if (details?.emitAutomaticAnalytics) {
1011
+ try {
1012
+ const safePayload = (0, _redaction.redactBody)(payload);
1013
+ (0, _automaticEvents.emitAutomaticEvent)('element_interacted', (0, _interactionProtocol.automaticInteractionProperties)(safePayload, details?.screenName ?? this._lastKnownScreen ?? undefined, replayCarrier), occurredAt);
1014
+ } catch {/* automatic projection must never affect interaction capture */}
1015
+ }
1020
1016
  if (!this.active) return; // Defense-in-depth: no emission when recording is OFF
1021
1017
  // The subtype becomes `ReplayEvent.label`, which is what the Events explorer GROUPS BY. Naming
1022
1018
  // the tapped control here is what splits taps per control instead of collapsing every tap in the
@@ -1026,7 +1022,10 @@ class SessionManager {
1026
1022
  // ⚠️ `gestureType` also travels in the payload, and the backend's `resolveGesture` reads THAT
1027
1023
  // first — so enriching the label cannot change heatmap gesture classification.
1028
1024
  const target = typeof details?.target === 'string' ? details.target.trim() : '';
1029
- this.onUserAction(target ? `gesture:${gestureType} · ${target}` : `gesture:${gestureType}`, payload);
1025
+ this.onUserAction(target ? `gesture:${gestureType} · ${target}` : `gesture:${gestureType}`, payload, {
1026
+ screen: details?.screenName,
1027
+ timestamp: occurredAt
1028
+ });
1030
1029
  }
1031
1030
 
1032
1031
  /**