@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
@@ -98,7 +98,7 @@ export class EventTracker {
98
98
  this.started = true;
99
99
  if (this.cfg.automaticEventTracking) {
100
100
  this.automaticEventsUnsubscribe = subscribeAutomaticEvents(event => {
101
- this.track(event.name, event.properties);
101
+ this.track(event.name, event.properties, event.timestamp);
102
102
  });
103
103
  }
104
104
  if (this.cfg.autoLifecycleEvents) {
@@ -221,9 +221,9 @@ export class EventTracker {
221
221
 
222
222
  // ─── tracking ────────────────────────────────────────────────────────────
223
223
 
224
- track(eventName, properties) {
224
+ track(eventName, properties, timestamp) {
225
225
  try {
226
- this.enqueue(this.buildEnvelope(eventName, properties));
226
+ this.enqueue(this.buildEnvelope(eventName, properties, timestamp));
227
227
  try {
228
228
  this.cfg.onEvent?.(eventName);
229
229
  } catch {/* no-throw */}
@@ -360,7 +360,7 @@ export class EventTracker {
360
360
 
361
361
  // ─── internals ─────────────────────────────────────────────────────────────
362
362
 
363
- buildEnvelope(eventName, properties) {
363
+ buildEnvelope(eventName, properties, timestamp) {
364
364
  const ctx = this.cfg.context ?? {};
365
365
  let canonicalSessionId;
366
366
  try {
@@ -371,7 +371,7 @@ export class EventTracker {
371
371
  const env = {
372
372
  event_id: uuid(),
373
373
  event_name: eventName,
374
- event_time: Date.now(),
374
+ event_time: timestamp ?? Date.now(),
375
375
  app_id: this.cfg.appId,
376
376
  platform: this.cfg.platform ?? resolveEventPlatform(),
377
377
  installation_id: this.installationId,
@@ -19,10 +19,11 @@ function compactProperties(properties) {
19
19
  }
20
20
  return out;
21
21
  }
22
- export function emitAutomaticEvent(name, properties) {
22
+ export function emitAutomaticEvent(name, properties, timestamp) {
23
23
  const event = {
24
24
  name,
25
- properties: compactProperties(properties)
25
+ properties: compactProperties(properties),
26
+ timestamp
26
27
  };
27
28
  if (listeners.size === 0) {
28
29
  pending.push(event);
@@ -1,7 +1,12 @@
1
1
  /**
2
2
  * ScaleBun SDK version. Sent with the session-start envelope so the dashboard
3
3
  * can attribute telemetry to the SDK build that produced it.
4
- * Keep in sync with package.json "version".
4
+ * Keep in sync with package.json "version" — `version.test.ts` fails when they drift.
5
+ *
6
+ * Why the test matters: the 1.11.0 bump missed this line, so the build would have reported itself as
7
+ * 1.10.6. Every "is the release live, and on what share of traffic" question is answered from this
8
+ * value, so a stale one makes a rollout unobservable — which is the exact problem sending an SDK
9
+ * version was introduced to solve.
5
10
  */
6
- export const SDK_VERSION = '1.10.7';
11
+ export const SDK_VERSION = '1.11.0';
7
12
  //# sourceMappingURL=version.js.map
@@ -22,9 +22,9 @@
22
22
 
23
23
  import React, { useEffect, useRef, createContext, useContext } from 'react';
24
24
  import { View, NativeEventEmitter, NativeModules } from 'react-native';
25
- import { emitAutomaticEvent } from '../../analytics/automaticEvents';
26
25
  import { resolveTouchTarget, describeTouchTarget } from './touchTarget';
27
26
  import { resolvePlatformOS } from '../../core/context/device';
27
+ import { INTERACTION_PROTOCOL_VERSION, generateInteractionId, nearestInteractionStart } from './interactionProtocol';
28
28
 
29
29
  // ─── Lazy imports to avoid native module cascade at load time ────────────────
30
30
  // We only import TYPE references at the top level; actual modules are require()'d
@@ -256,7 +256,9 @@ export function ScaleBunDebugRoot({
256
256
  // and forwards them to SessionManager as USER_ACTION events.
257
257
  const lastNativeTouchTsRef = useRef(0);
258
258
  const nativeTrackingConfirmedRef = useRef(false);
259
- const pendingJsEmitRef = useRef(null);
259
+ const touchStartRef = useRef(null);
260
+ const interactionStartsRef = useRef([]);
261
+ const pendingJsEmitsRef = useRef(new Map());
260
262
  useEffect(() => {
261
263
  let subscription = null;
262
264
  try {
@@ -268,11 +270,15 @@ export function ScaleBunDebugRoot({
268
270
  // Record timestamp to suppress duplicate JS touch events
269
271
  lastNativeTouchTsRef.current = Date.now();
270
272
  nativeTrackingConfirmedRef.current = true;
271
-
272
- // Cancel any pending JS emission — native event wins
273
- if (pendingJsEmitRef.current !== null) {
274
- clearTimeout(pendingJsEmitRef.current);
275
- pendingJsEmitRef.current = null;
273
+ const nativeOccurredAt = typeof event.occurredAt === 'number' ? event.occurredAt : (typeof event.timestamp === 'number' ? event.timestamp : Date.now()) - (typeof event.durationMs === 'number' ? event.durationMs : 0);
274
+ const start = nearestInteractionStart(interactionStartsRef.current, nativeOccurredAt);
275
+
276
+ // Cancel only this physical touch's fallback. A single timer used to cancel a
277
+ // previous rapid tap and silently lose it on devices without native tracking.
278
+ if (start) {
279
+ const pending = pendingJsEmitsRef.current.get(start.interactionId);
280
+ if (pending !== undefined) clearTimeout(pending);
281
+ pendingJsEmitsRef.current.delete(start.interactionId);
276
282
  }
277
283
  const {
278
284
  SessionManager
@@ -294,6 +300,15 @@ export function ScaleBunDebugRoot({
294
300
  _diagWinH = _d.height;
295
301
  } catch {/* no-throw */}
296
302
  sm.onGestureDetected(event.gestureType || 'tap', {
303
+ interactionId: event.interactionId || start?.interactionId || generateInteractionId(),
304
+ interactionProtocol: event.interactionProtocol || INTERACTION_PROTOCOL_VERSION,
305
+ occurredAt: nativeOccurredAt,
306
+ ui: start?.ui,
307
+ stateStatus: start?.stateStatus ?? 'not_captured',
308
+ target: start?.target,
309
+ targetId: start?.targetId,
310
+ screenName: start?.screenName,
311
+ emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
297
312
  x: event.rawX,
298
313
  y: event.rawY,
299
314
  // Use the actual native end coords when present, not the down
@@ -323,11 +338,12 @@ export function ScaleBunDebugRoot({
323
338
  try {
324
339
  subscription?.remove();
325
340
  } catch {/* no-throw */}
341
+ for (const timer of pendingJsEmitsRef.current.values()) clearTimeout(timer);
342
+ pendingJsEmitsRef.current.clear();
326
343
  };
327
- }, []);
344
+ }, [captureAutomaticInteractions]);
328
345
 
329
346
  // ─── Touch Tracking State ───────────────────────────────────────────
330
- const touchStartRef = useRef(null);
331
347
 
332
348
  // On-screen rect of the root view — the EXACT view the native screenshot
333
349
  // captures. Normalizing pageX/pageY against this rect makes JS-fallback taps
@@ -371,11 +387,37 @@ export function ScaleBunDebugRoot({
371
387
  const handleTouchStart = e => {
372
388
  try {
373
389
  const touch = e.nativeEvent;
374
- touchStartRef.current = {
390
+ const target = resolveTouchTarget(e);
391
+ let ui;
392
+ let screenName;
393
+ try {
394
+ const {
395
+ uiStateSignature
396
+ } = require('./uiState');
397
+ ui = uiStateSignature();
398
+ } catch {/* no-throw */}
399
+ try {
400
+ const {
401
+ AutoScreenDetector
402
+ } = require('../navigation/AutoScreenDetector');
403
+ screenName = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
404
+ } catch {/* no-throw */}
405
+ const start = {
406
+ interactionId: generateInteractionId(),
407
+ occurredAt: Date.now(),
375
408
  x: touch.pageX,
376
409
  y: touch.pageY,
377
- ts: Date.now()
410
+ locationX: touch.locationX,
411
+ locationY: touch.locationY,
412
+ target: describeTouchTarget(target),
413
+ targetId: target?.testID,
414
+ screenName,
415
+ ui,
416
+ stateStatus: ui ? 'captured_nonempty' : 'not_instrumented',
417
+ emitAutomaticAnalytics: captureAutomaticInteractions
378
418
  };
419
+ touchStartRef.current = start;
420
+ interactionStartsRef.current = interactionStartsRef.current.filter(candidate => start.occurredAt - candidate.occurredAt < 5000).concat(start).slice(-8);
379
421
  } catch {/* no-throw */}
380
422
  };
381
423
  const handleTouchEnd = e => {
@@ -414,15 +456,23 @@ export function ScaleBunDebugRoot({
414
456
  // WHAT was tapped, resolved from the React fiber on the touch event. Without this the tap
415
457
  // carries only coordinates, so every tap in the app groups into one row per gesture type.
416
458
  // Defensive by construction — returns undefined rather than throwing (see touchTarget.ts).
417
- const tapped = describeTouchTarget(resolveTouchTarget(e));
459
+ const tapped = start?.target ?? describeTouchTarget(resolveTouchTarget(e));
418
460
  const gestureDetails = {
419
461
  target: tapped,
420
- x: touch.pageX,
421
- y: touch.pageY,
422
- pageX: touch.pageX,
423
- pageY: touch.pageY,
424
- locationX: touch.locationX,
425
- locationY: touch.locationY,
462
+ interactionId: start?.interactionId ?? generateInteractionId(),
463
+ interactionProtocol: INTERACTION_PROTOCOL_VERSION,
464
+ occurredAt: start?.occurredAt ?? Date.now(),
465
+ ui: start?.ui,
466
+ stateStatus: start?.stateStatus ?? 'not_captured',
467
+ targetId: start?.targetId,
468
+ screenName: start?.screenName,
469
+ emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
470
+ x: start?.x ?? touch.pageX,
471
+ y: start?.y ?? touch.pageY,
472
+ pageX: start?.x ?? touch.pageX,
473
+ pageY: start?.y ?? touch.pageY,
474
+ locationX: start?.locationX ?? touch.locationX,
475
+ locationY: start?.locationY ?? touch.locationY,
426
476
  viewportWidth: vpW > 0 ? vpW : undefined,
427
477
  viewportHeight: vpH > 0 ? vpH : undefined
428
478
  };
@@ -434,8 +484,8 @@ export function ScaleBunDebugRoot({
434
484
  // measureInWindow returns rootRect.y = statusBarHeight, so subtracting it
435
485
  // double-counts the status bar and pushes every marker upward.
436
486
  if (rootRect && rootRect.w > 0 && rootRect.h > 0) {
437
- const nx = Math.max(0, Math.min(1, touch.pageX / rootRect.w));
438
- const ny = Math.max(0, Math.min(1, touch.pageY / rootRect.h));
487
+ const nx = Math.max(0, Math.min(1, (start?.x ?? touch.pageX) / rootRect.w));
488
+ const ny = Math.max(0, Math.min(1, (start?.y ?? touch.pageY) / rootRect.h));
439
489
  gestureDetails.normalizedX = nx;
440
490
  gestureDetails.normalizedY = ny;
441
491
  gestureDetails.normalizedPrecomputed = true;
@@ -467,7 +517,7 @@ export function ScaleBunDebugRoot({
467
517
  const dx = touch.pageX - start.x;
468
518
  const dy = touch.pageY - start.y;
469
519
  const dist = Math.sqrt(dx * dx + dy * dy);
470
- const duration = Date.now() - start.ts;
520
+ const duration = Date.now() - start.occurredAt;
471
521
  gestureDetails.duration = duration;
472
522
  if (dist >= 15) {
473
523
  // Movement gesture: swipe or scroll
@@ -486,52 +536,6 @@ export function ScaleBunDebugRoot({
486
536
  gestureType = 'long_press';
487
537
  }
488
538
  }
489
- const targetInfo = _extractTarget(e);
490
- gestureDetails.target = targetInfo?.testId || targetInfo?.accessibilityLabel;
491
- if (captureAutomaticInteractions) {
492
- let screen;
493
- try {
494
- const {
495
- AutoScreenDetector
496
- } = require('../navigation/AutoScreenDetector');
497
- screen = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
498
- } catch {/* no-throw */}
499
- /**
500
- * UI STATE, read HERE and not later.
501
- *
502
- * A tap belongs to the surface that was on screen when the finger landed: tapping the
503
- * filter button while the sheet is DOWN belongs to `closed`, because that is what the
504
- * user was looking at when they reached for it. Reading it after the handler has run
505
- * moves every "open the thing" tap into the state it created — the one state it
506
- * certainly does not belong to.
507
- *
508
- * Declared-only on this platform (see uiState.ts): absent means not captured, which
509
- * the dashboard keeps distinct from "nothing was open".
510
- */
511
- let ui;
512
- try {
513
- const {
514
- uiStateSignature
515
- } = require('./uiState');
516
- ui = uiStateSignature();
517
- } catch {/* no-throw: a tap must never be lost to state capture */}
518
- emitAutomaticEvent('element_interacted', {
519
- gesture_type: gestureType,
520
- screen_name: screen,
521
- /* THIS MAP ENUMERATES. A field added to the payload and forgotten here reaches the
522
- backend as undefined with no error anywhere — the recurring defect class in this
523
- codebase. `ui` is the key the grid aggregate reads for state, byte-identical to
524
- the web SDK's, so one dashboard control queries both platforms. */
525
- ui,
526
- // testID/nativeID is an author-controlled stable identifier.
527
- // Accessibility labels and rendered text are deliberately omitted.
528
- target_id: targetInfo?.testId,
529
- normalized_x: gestureDetails.normalizedX,
530
- normalized_y: gestureDetails.normalizedY,
531
- direction: gestureDetails.direction,
532
- duration_ms: gestureDetails.duration
533
- });
534
- }
535
539
 
536
540
  // Feed the Engage gesture detector (rage_tap / dead_tap) — only committed
537
541
  // taps, not swipes/scrolls/long-presses. Pure JS, cross-platform; emits the
@@ -541,18 +545,11 @@ export function ScaleBunDebugRoot({
541
545
  const {
542
546
  gestureTriggerDetector
543
547
  } = require('../engage/gestureTriggerDetector');
544
- let screen;
545
- try {
546
- const {
547
- AutoScreenDetector
548
- } = require('../navigation/AutoScreenDetector');
549
- screen = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
550
- } catch {/* no-throw */}
551
548
  gestureTriggerDetector.recordTap({
552
- x: touch.pageX,
553
- y: touch.pageY,
554
- hasTarget: !!(targetInfo?.testId || targetInfo?.accessibilityLabel),
555
- screen
549
+ x: start?.x ?? touch.pageX,
550
+ y: start?.y ?? touch.pageY,
551
+ hasTarget: !!(start?.targetId || start?.target),
552
+ screen: start?.screenName
556
553
  });
557
554
  } catch {/* no-throw */}
558
555
  }
@@ -567,7 +564,7 @@ export function ScaleBunDebugRoot({
567
564
  // screenshot capture view). Three-tier suppression:
568
565
  // 1. Native confirmed active → skip JS entirely (native handles all touches)
569
566
  // 2. Native event within 300ms → skip (existing dedup)
570
- // 3. First touch (native not yet confirmed) → defer 200ms to let native arrive
567
+ // 3. First touch (native not yet confirmed) → defer 600ms to let native arrive
571
568
  const sinceNative = Date.now() - lastNativeTouchTsRef.current;
572
569
  if (nativeTrackingConfirmedRef.current) {
573
570
  // Native tracker is active — skip JS path entirely
@@ -580,11 +577,9 @@ export function ScaleBunDebugRoot({
580
577
  const capturedDetails = {
581
578
  ...gestureDetails
582
579
  };
583
- if (pendingJsEmitRef.current !== null) {
584
- clearTimeout(pendingJsEmitRef.current);
585
- }
586
- pendingJsEmitRef.current = setTimeout(() => {
587
- pendingJsEmitRef.current = null;
580
+ const interactionId = capturedDetails.interactionId;
581
+ const timer = setTimeout(() => {
582
+ pendingJsEmitsRef.current.delete(interactionId);
588
583
  if (nativeTrackingConfirmedRef.current) return;
589
584
  try {
590
585
  const {
@@ -608,6 +603,7 @@ export function ScaleBunDebugRoot({
608
603
  // devices with no native tracker at all it merely delays a background emission,
609
604
  // which nothing user-visible waits on.
610
605
  }, 600);
606
+ pendingJsEmitsRef.current.set(interactionId, timer);
611
607
  }
612
608
  touchStartRef.current = null;
613
609
  } catch {/* no-throw */}
@@ -722,25 +718,6 @@ export function useScaleBunScreen(name) {
722
718
  }, [name, manager]);
723
719
  }
724
720
 
725
- // ─── Helpers ────────────────────────────────────────────────────────────────
726
-
727
- function _extractTarget(e) {
728
- try {
729
- const target = e?.target;
730
- if (!target) return undefined;
731
-
732
- // React Native nativeEvent target properties
733
- const props = target._internalFiberInstanceHandleDEV?.memoizedProps ?? {};
734
- return {
735
- testId: props.testID || props.nativeID || undefined,
736
- accessibilityLabel: props.accessibilityLabel || undefined,
737
- text: undefined
738
- };
739
- } catch {
740
- return undefined;
741
- }
742
- }
743
-
744
721
  // ─── Styles ─────────────────────────────────────────────────────────────────
745
722
  // IMPORTANT: Do NOT use StyleSheet.create() here.
746
723
  // In RN 0.84 bridgeless mode, StyleSheet.create() calls
@@ -0,0 +1,38 @@
1
+ /** One physical interaction, shared by replay and analytics projections. */
2
+ export const INTERACTION_PROTOCOL_VERSION = 1;
3
+ export function generateInteractionId() {
4
+ return `ixj-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
5
+ }
6
+
7
+ /** Match a native bridge event to the JS evidence sampled at the same finger-down. */
8
+ export function nearestInteractionStart(starts, occurredAt, toleranceMs = 1500) {
9
+ let best;
10
+ let bestDelta = toleranceMs + 1;
11
+ for (const start of starts) {
12
+ const delta = Math.abs(start.occurredAt - occurredAt);
13
+ if (delta < bestDelta) {
14
+ best = start;
15
+ bestDelta = delta;
16
+ }
17
+ }
18
+ return bestDelta <= toleranceMs ? best : undefined;
19
+ }
20
+
21
+ /** Analytics is a projection of the same evidence; no second click is invented. */
22
+ export function automaticInteractionProperties(payload, screenName, canonicalMirror) {
23
+ return {
24
+ gesture_type: payload.gestureType,
25
+ screen_name: screenName,
26
+ interaction_id: payload.interaction_id,
27
+ interaction_protocol: payload.interaction_protocol,
28
+ state_status: payload.state_status,
29
+ ui: payload.ui,
30
+ target_id: payload.target_id,
31
+ normalized_x: payload.normalizedX,
32
+ normalized_y: payload.normalizedY,
33
+ direction: payload.direction,
34
+ duration_ms: payload.durationMs,
35
+ canonical_mirror: canonicalMirror || undefined
36
+ };
37
+ }
38
+ //# sourceMappingURL=interactionProtocol.js.map
@@ -74,6 +74,13 @@ export function uiStateSignature() {
74
74
  if (!declared.size) return undefined;
75
75
  /* SORTED, or the same surface produces different signatures depending on the order the host happened
76
76
  to declare things in, and every count fragments into several that mean nothing. */
77
- return [...declared.keys()].sort().map(k => `${k}:${declared.get(k)}`).join(';').slice(0, 96);
77
+ const pairs = [...declared.keys()].sort().map(k => `${k}:${declared.get(k)}`);
78
+ let signature = '';
79
+ for (const pair of pairs) {
80
+ const next = signature ? `${signature};${pair}` : pair;
81
+ if (next.length > 96) break;
82
+ signature = next;
83
+ }
84
+ return signature || undefined;
78
85
  }
79
86
  //# sourceMappingURL=uiState.js.map
@@ -65,26 +65,27 @@ export class JourneyEventPipeline {
65
65
  emit(type, opts) {
66
66
  try {
67
67
  const key = `${type}:${opts?.subtype ?? ''}`;
68
- const now = Date.now();
69
- if (key === this.lastEventKey && now - this.lastEventTs < this.config.dedupeWindowMs) {
68
+ const receivedAt = Date.now();
69
+ const occurredAt = opts?.timestamp ?? receivedAt;
70
+ if (key === this.lastEventKey && receivedAt - this.lastEventTs < this.config.dedupeWindowMs) {
70
71
  // Within dedup window — allow high-confidence native events to
71
72
  // REPLACE a prior low-confidence JS event for the same gesture.
72
73
  // This prevents the race where JS fires first and the pipeline
73
74
  // drops the native event that has more accurate coordinates.
74
75
  const incomingConfidence = opts?.payload?.confidence;
75
76
  if (incomingConfidence === 'high' && this.lastEventConfidence !== 'high') {
76
- this._replaceLastEvent(key, now, opts);
77
+ this._replaceLastEvent(key, receivedAt, opts);
77
78
  }
78
79
  return null;
79
80
  }
80
81
  this.lastEventKey = key;
81
- this.lastEventTs = now;
82
+ this.lastEventTs = receivedAt;
82
83
  this.lastEventConfidence = opts?.payload?.confidence ?? null;
83
84
  const event = {
84
85
  eventId: generateEventId(),
85
86
  sessionId: this.sessionId,
86
87
  journeyId: opts?.journeyId,
87
- ts: now,
88
+ ts: occurredAt,
88
89
  type,
89
90
  subtype: opts?.subtype,
90
91
  severity: opts?.severity ?? inferSeverity(type),
@@ -27,15 +27,11 @@ import { bridgeAdapter } from '../replay/bridge/adapters/bridgeAdapter';
27
27
  import { redactBody } from '../../debug/redaction';
28
28
  import { consumeCalibrationTarget } from '../journey/calibrationContext';
29
29
  import { resolvePlatformOS } from '../../core/context/device';
30
+ import { emitAutomaticEvent } from '../../analytics/automaticEvents';
31
+ import { INTERACTION_PROTOCOL_VERSION, automaticInteractionProperties, generateInteractionId } from '../journey/interactionProtocol';
30
32
 
31
33
  // ─── Types ──────────────────────────────────────────────────────────────────
32
34
 
33
- /**
34
- * Max analytics-lane heatmap interactions emitted per foreground (analytics
35
- * session) window. Bounds ingest volume now that capture defaults ON; enough to
36
- * resolve hotspot density, the long tail is dropped (drop-newest beyond cap).
37
- */
38
- const HEATMAP_MAX_INTERACTIONS_PER_WINDOW = 200;
39
35
  // ─── Singleton ──────────────────────────────────────────────────────────────
40
36
 
41
37
  let _instance = null;
@@ -51,15 +47,6 @@ export class SessionManager {
51
47
  * lane even when no replay recording is active. Additive, opt-in (default off).
52
48
  */
53
49
  _captureInteractionHeatmap = false;
54
- /**
55
- * Sampling cap for the analytics-lane heatmap emission. Now that capture is
56
- * ON by default, an unbounded one-event-per-gesture stream could materially
57
- * inflate ingest volume. We cap emitted interactions per analytics-session
58
- * window (finalize-scoped per foreground): the first N gestures define the
59
- * hotspot shape; the long tail is dropped. Resets when the window changes.
60
- */
61
- _heatmapWindowSessionId = null;
62
- _heatmapWindowCount = 0;
63
50
  session = null;
64
51
  active = false;
65
52
  timeoutTimer = null;
@@ -661,21 +648,11 @@ export class SessionManager {
661
648
  * injected sensitive keys (e.g. a label) are stripped before transport.
662
649
  * - Never throws.
663
650
  */
664
- _emitInteractionToAnalytics(gestureType, payload) {
665
- if (!this._captureInteractionHeatmap) return;
666
- if (this.active) return; // recording lane already carries this tap
651
+ _emitInteractionToAnalytics(gestureType, payload, occurredAt, screenName) {
652
+ if (!this._captureInteractionHeatmap) return false;
653
+ if (this.active) return false; // recording lane already carries this tap
667
654
  const adapter = this._backendTransport;
668
- if (!adapter) return;
669
-
670
- // Per-foreground sampling cap. Reset the counter when the analytics
671
- // session window rolls over, then drop anything past the cap.
672
- const windowId = adapter.analyticsSessionId ?? '';
673
- if (windowId !== this._heatmapWindowSessionId) {
674
- this._heatmapWindowSessionId = windowId;
675
- this._heatmapWindowCount = 0;
676
- }
677
- if (this._heatmapWindowCount >= HEATMAP_MAX_INTERACTIONS_PER_WINDOW) return;
678
- this._heatmapWindowCount++;
655
+ if (!adapter) return false;
679
656
  try {
680
657
  // Normalize coords in place (same logic as the recording lane).
681
658
  this._normalizeInteractionPayload(payload);
@@ -702,16 +679,18 @@ export class SessionManager {
702
679
  eventId: generateEventId(),
703
680
  // sessionId is (re)stamped by the analytics lane at flush time.
704
681
  sessionId: adapter.analyticsSessionId ?? '',
705
- ts: Date.now(),
682
+ ts: occurredAt,
706
683
  type: 'USER_ACTION',
707
684
  subtype: `gesture:${gestureType}`,
708
- screen: this._lastKnownScreen ?? undefined,
685
+ screen: screenName ?? this._lastKnownScreen ?? undefined,
709
686
  payload: safePayload,
710
687
  source: 'user'
711
688
  };
712
689
  adapter.trackEvent(event);
690
+ return true;
713
691
  } catch (err) {
714
692
  logger.error('[SessionManager] heatmap analytics emit failed:', err);
693
+ return false;
715
694
  }
716
695
  }
717
696
 
@@ -931,13 +910,14 @@ export class SessionManager {
931
910
  * Notify of a user interaction. Called by ScaleBunDebugRoot touch handlers.
932
911
  * Also triggers frame capture for desktop-initiated recordings.
933
912
  */
934
- onUserAction(subtype, payload) {
913
+ onUserAction(subtype, payload, context) {
935
914
  if (!this.active) return; // Defense-in-depth: no emission when recording is OFF
936
915
  this.emitEvent('USER_ACTION', {
937
916
  subtype,
938
- screen: this._lastKnownScreen ?? undefined,
917
+ screen: context?.screen ?? this._lastKnownScreen ?? undefined,
939
918
  payload,
940
- source: 'user'
919
+ source: 'user',
920
+ timestamp: context?.timestamp
941
921
  });
942
922
  // NOTE: Do NOT call captureManager.onInteraction() here.
943
923
  // emitEvent() already triggers onInteraction() for USER_ACTION events (line ~405).
@@ -952,7 +932,8 @@ export class SessionManager {
952
932
  // Exact-duplicate suppression — see _lastGestureSig. Signature is the gesture type plus
953
933
  // raw coordinates verbatim; String() keeps undefined coords distinct from 0 (a payload
954
934
  // with no coords never collides with a real origin tap).
955
- const dedupSig = `${gestureType}|${String(details?.x)}|${String(details?.y)}|${String(details?.endX)}|${String(details?.endY)}`;
935
+ const suppliedInteractionId = details?.interactionId;
936
+ const dedupSig = suppliedInteractionId ? `id:${suppliedInteractionId}` : `${gestureType}|${String(details?.x)}|${String(details?.y)}|${String(details?.endX)}|${String(details?.endY)}`;
956
937
  const nowTs = Date.now();
957
938
  if (dedupSig === this._lastGestureSig && nowTs - this._lastGestureTs <= SessionManager.GESTURE_DEDUP_WINDOW_MS) {
958
939
  this._lastGestureTs = nowTs; // a burst of 3 stays suppressed even if gaps chain past the window
@@ -964,7 +945,15 @@ export class SessionManager {
964
945
  // Build the canonical gesture payload ONCE so the recording lane and the
965
946
  // (additive) analytics lane carry byte-identical keys (normalizedX/Y,
966
947
  // gestureType, etc.). Same object shape that was previously inlined.
948
+ const interactionId = suppliedInteractionId ?? generateInteractionId();
949
+ const occurredAt = details?.occurredAt ?? Date.now();
950
+ const stateStatus = details?.stateStatus ?? (details?.ui ? 'captured_nonempty' : 'not_captured');
967
951
  const payload = {
952
+ interaction_id: interactionId,
953
+ interaction_protocol: details?.interactionProtocol ?? INTERACTION_PROTOCOL_VERSION,
954
+ state_status: stateStatus,
955
+ ui: details?.ui,
956
+ target_id: details?.targetId,
968
957
  gestureType,
969
958
  x: details?.x,
970
959
  y: details?.y,
@@ -1009,9 +998,16 @@ export class SessionManager {
1009
998
  // when the flag is OFF, no backend transport is attached, or recording is
1010
999
  // active (the recording path below already carries this tap). Uses a fresh
1011
1000
  // payload clone so analytics normalization never mutates the recording one.
1012
- this._emitInteractionToAnalytics(gestureType, {
1001
+ const analyticsReplayCarrier = this._emitInteractionToAnalytics(gestureType, {
1013
1002
  ...payload
1014
- });
1003
+ }, occurredAt, details?.screenName);
1004
+ const replayCarrier = !!this._backendTransport && (this.active || analyticsReplayCarrier);
1005
+ if (details?.emitAutomaticAnalytics) {
1006
+ try {
1007
+ const safePayload = redactBody(payload);
1008
+ emitAutomaticEvent('element_interacted', automaticInteractionProperties(safePayload, details?.screenName ?? this._lastKnownScreen ?? undefined, replayCarrier), occurredAt);
1009
+ } catch {/* automatic projection must never affect interaction capture */}
1010
+ }
1015
1011
  if (!this.active) return; // Defense-in-depth: no emission when recording is OFF
1016
1012
  // The subtype becomes `ReplayEvent.label`, which is what the Events explorer GROUPS BY. Naming
1017
1013
  // the tapped control here is what splits taps per control instead of collapsing every tap in the
@@ -1021,7 +1017,10 @@ export class SessionManager {
1021
1017
  // ⚠️ `gestureType` also travels in the payload, and the backend's `resolveGesture` reads THAT
1022
1018
  // first — so enriching the label cannot change heatmap gesture classification.
1023
1019
  const target = typeof details?.target === 'string' ? details.target.trim() : '';
1024
- this.onUserAction(target ? `gesture:${gestureType} · ${target}` : `gesture:${gestureType}`, payload);
1020
+ this.onUserAction(target ? `gesture:${gestureType} · ${target}` : `gesture:${gestureType}`, payload, {
1021
+ screen: details?.screenName,
1022
+ timestamp: occurredAt
1023
+ });
1025
1024
  }
1026
1025
 
1027
1026
  /**
@@ -125,7 +125,7 @@ export declare class EventTracker {
125
125
  * Persisted so it survives the click→install→open gap. Call BEFORE start() ideally.
126
126
  */
127
127
  setAttributionClickId(clickId: string): void;
128
- track(eventName: string, properties?: Record<string, any>): void;
128
+ track(eventName: string, properties?: Record<string, any>, timestamp?: number): void;
129
129
  trackPurchase(input: {
130
130
  revenue: number;
131
131
  currency: string;
@@ -18,9 +18,11 @@ export type AutomaticEventName = 'app_foregrounded' | 'app_backgrounded' | 'scre
18
18
  export interface AutomaticEvent {
19
19
  name: AutomaticEventName;
20
20
  properties: Record<string, unknown>;
21
+ /** Original observation time. Delivery can be delayed while native coordinates resolve. */
22
+ timestamp?: number;
21
23
  }
22
24
  type AutomaticEventListener = (event: AutomaticEvent) => void;
23
- export declare function emitAutomaticEvent(name: AutomaticEventName, properties?: Record<string, unknown>): void;
25
+ export declare function emitAutomaticEvent(name: AutomaticEventName, properties?: Record<string, unknown>, timestamp?: number): void;
24
26
  export declare function subscribeAutomaticEvents(listener: AutomaticEventListener): () => void;
25
27
  /** Test-only reset; intentionally not exported from the package entry point. */
26
28
  export declare function resetAutomaticEventsForTests(): void;
@@ -1,7 +1,12 @@
1
1
  /**
2
2
  * ScaleBun SDK version. Sent with the session-start envelope so the dashboard
3
3
  * can attribute telemetry to the SDK build that produced it.
4
- * Keep in sync with package.json "version".
4
+ * Keep in sync with package.json "version" — `version.test.ts` fails when they drift.
5
+ *
6
+ * Why the test matters: the 1.11.0 bump missed this line, so the build would have reported itself as
7
+ * 1.10.6. Every "is the release live, and on what share of traffic" question is answered from this
8
+ * value, so a stale one makes a rollout unobservable — which is the exact problem sending an SDK
9
+ * version was introduced to solve.
5
10
  */
6
- export declare const SDK_VERSION = "1.10.7";
11
+ export declare const SDK_VERSION = "1.11.0";
7
12
  //# sourceMappingURL=version.d.ts.map