@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.
- package/android/src/main/java/com/scalebun/replaysdk/tracking/InteractionTracker.kt +25 -24
- package/dist/scalebun.full.js +236 -187
- package/dist/scalebun.slim.js +235 -186
- package/ios/Capture/InteractionTracker.swift +8 -4
- package/lib/commonjs/analytics/EventTracker.js +5 -5
- package/lib/commonjs/analytics/automaticEvents.js +3 -2
- package/lib/commonjs/core/constants/version.js +7 -2
- package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +80 -103
- package/lib/commonjs/features/journey/interactionProtocol.js +47 -0
- package/lib/commonjs/features/journey/uiState.js +8 -1
- package/lib/commonjs/features/session/JourneyEventPipeline.js +6 -5
- package/lib/commonjs/features/session/SessionManager.js +37 -38
- package/lib/module/analytics/EventTracker.js +5 -5
- package/lib/module/analytics/automaticEvents.js +3 -2
- package/lib/module/core/constants/version.js +7 -2
- package/lib/module/features/journey/ScaleBunDebugRoot.js +80 -103
- package/lib/module/features/journey/interactionProtocol.js +38 -0
- package/lib/module/features/journey/uiState.js +8 -1
- package/lib/module/features/session/JourneyEventPipeline.js +6 -5
- package/lib/module/features/session/SessionManager.js +37 -38
- package/lib/typescript/analytics/EventTracker.d.ts +1 -1
- package/lib/typescript/analytics/automaticEvents.d.ts +3 -1
- package/lib/typescript/core/constants/version.d.ts +7 -2
- package/lib/typescript/features/journey/interactionProtocol.d.ts +21 -0
- package/lib/typescript/features/session/JourneyEventPipeline.d.ts +1 -0
- package/lib/typescript/features/session/SessionManager.d.ts +15 -10
- package/package.json +3 -2
- package/src/analytics/EventTracker.ts +5 -5
- package/src/analytics/automaticEvents.ts +4 -0
- package/src/core/constants/version.ts +7 -2
- package/src/features/journey/ScaleBunDebugRoot.tsx +96 -97
- package/src/features/journey/interactionProtocol.ts +65 -0
- package/src/features/journey/uiState.ts +9 -4
- package/src/features/session/JourneyEventPipeline.ts +7 -5
- 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
|
|
21
|
-
private
|
|
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
|
|
59
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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.
|
|
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: !!(
|
|
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
|
|
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
|
-
|
|
592
|
-
|
|
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
|
-
|
|
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
|
|
74
|
-
|
|
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,
|
|
82
|
+
this._replaceLastEvent(key, receivedAt, opts);
|
|
82
83
|
}
|
|
83
84
|
return null;
|
|
84
85
|
}
|
|
85
86
|
this.lastEventKey = key;
|
|
86
|
-
this.lastEventTs =
|
|
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:
|
|
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:
|
|
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
|
|
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
|
/**
|