@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
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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.
|
|
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: !!(
|
|
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
|
|
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
|
-
|
|
584
|
-
|
|
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
|
-
|
|
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
|
|
69
|
-
|
|
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,
|
|
77
|
+
this._replaceLastEvent(key, receivedAt, opts);
|
|
77
78
|
}
|
|
78
79
|
return null;
|
|
79
80
|
}
|
|
80
81
|
this.lastEventKey = key;
|
|
81
|
-
this.lastEventTs =
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
11
|
+
export declare const SDK_VERSION = "1.11.0";
|
|
7
12
|
//# sourceMappingURL=version.d.ts.map
|