@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
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** One physical interaction, shared by replay and analytics projections. */
|
|
2
|
+
export declare const INTERACTION_PROTOCOL_VERSION = 1;
|
|
3
|
+
export type InteractionStateStatus = 'captured_empty' | 'captured_nonempty' | 'not_captured' | 'not_instrumented';
|
|
4
|
+
export interface InteractionStartContext {
|
|
5
|
+
interactionId: string;
|
|
6
|
+
occurredAt: number;
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
target?: string;
|
|
10
|
+
targetId?: string;
|
|
11
|
+
screenName?: string;
|
|
12
|
+
ui?: string;
|
|
13
|
+
stateStatus: InteractionStateStatus;
|
|
14
|
+
emitAutomaticAnalytics: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function generateInteractionId(): string;
|
|
17
|
+
/** Match a native bridge event to the JS evidence sampled at the same finger-down. */
|
|
18
|
+
export declare function nearestInteractionStart<T extends InteractionStartContext>(starts: readonly T[], occurredAt: number, toleranceMs?: number): T | undefined;
|
|
19
|
+
/** Analytics is a projection of the same evidence; no second click is invented. */
|
|
20
|
+
export declare function automaticInteractionProperties(payload: Record<string, unknown>, screenName: string | undefined, canonicalMirror: boolean): Record<string, unknown>;
|
|
21
|
+
//# sourceMappingURL=interactionProtocol.d.ts.map
|
|
@@ -50,6 +50,7 @@ export declare class JourneyEventPipeline {
|
|
|
50
50
|
journeyId?: string;
|
|
51
51
|
/** Frame that was on screen when this happened. Resolved by the caller; see sessionTypes. */
|
|
52
52
|
frameId?: string;
|
|
53
|
+
timestamp?: number;
|
|
53
54
|
}): JourneyEvent | null;
|
|
54
55
|
/**
|
|
55
56
|
* Anchor a crash event — protect nearby events from eviction.
|
|
@@ -17,6 +17,7 @@ import type { DebugTransport } from '../../debug/transport';
|
|
|
17
17
|
import type { SessionMetadata, SessionReplayConfig, JourneyEventType, JourneyEventSeverity, JourneyEventSource, CrashAnchor, NativeSyncMetadata, SyncDecisionCallback } from './sessionTypes';
|
|
18
18
|
import { type NativeCaptureFn } from './ReplayCaptureManager';
|
|
19
19
|
import type { BackendSessionAdapter } from './BackendSessionAdapter';
|
|
20
|
+
import { type InteractionStateStatus } from '../journey/interactionProtocol';
|
|
20
21
|
export interface SessionManagerConfig {
|
|
21
22
|
sessionReplay?: Partial<SessionReplayConfig>;
|
|
22
23
|
syncPolicy?: SyncDecisionCallback;
|
|
@@ -43,15 +44,6 @@ export declare class SessionManager {
|
|
|
43
44
|
* lane even when no replay recording is active. Additive, opt-in (default off).
|
|
44
45
|
*/
|
|
45
46
|
private _captureInteractionHeatmap;
|
|
46
|
-
/**
|
|
47
|
-
* Sampling cap for the analytics-lane heatmap emission. Now that capture is
|
|
48
|
-
* ON by default, an unbounded one-event-per-gesture stream could materially
|
|
49
|
-
* inflate ingest volume. We cap emitted interactions per analytics-session
|
|
50
|
-
* window (finalize-scoped per foreground): the first N gestures define the
|
|
51
|
-
* hotspot shape; the long tail is dropped. Resets when the window changes.
|
|
52
|
-
*/
|
|
53
|
-
private _heatmapWindowSessionId;
|
|
54
|
-
private _heatmapWindowCount;
|
|
55
47
|
private session;
|
|
56
48
|
private active;
|
|
57
49
|
private timeoutTimer;
|
|
@@ -207,6 +199,8 @@ export declare class SessionManager {
|
|
|
207
199
|
traceId?: string;
|
|
208
200
|
source?: JourneyEventSource;
|
|
209
201
|
journeyId?: string;
|
|
202
|
+
/** Original observation time; interaction capture may resolve asynchronously. */
|
|
203
|
+
timestamp?: number;
|
|
210
204
|
}): void;
|
|
211
205
|
/**
|
|
212
206
|
* Anchor a crash in the session context.
|
|
@@ -223,7 +217,10 @@ export declare class SessionManager {
|
|
|
223
217
|
* Notify of a user interaction. Called by ScaleBunDebugRoot touch handlers.
|
|
224
218
|
* Also triggers frame capture for desktop-initiated recordings.
|
|
225
219
|
*/
|
|
226
|
-
onUserAction(subtype: string, payload?: Record<string, unknown
|
|
220
|
+
onUserAction(subtype: string, payload?: Record<string, unknown>, context?: {
|
|
221
|
+
screen?: string;
|
|
222
|
+
timestamp?: number;
|
|
223
|
+
}): void;
|
|
227
224
|
/**
|
|
228
225
|
* Detailed gesture detection — called by ScaleBunDebugRoot touch handlers.
|
|
229
226
|
* Emits a USER_ACTION event with gesture-specific subtype and payload.
|
|
@@ -263,6 +260,14 @@ export declare class SessionManager {
|
|
|
263
260
|
screenWidth?: number;
|
|
264
261
|
screenHeight?: number;
|
|
265
262
|
platform?: string;
|
|
263
|
+
interactionId?: string;
|
|
264
|
+
interactionProtocol?: number;
|
|
265
|
+
occurredAt?: number;
|
|
266
|
+
ui?: string;
|
|
267
|
+
stateStatus?: InteractionStateStatus;
|
|
268
|
+
targetId?: string;
|
|
269
|
+
screenName?: string;
|
|
270
|
+
emitAutomaticAnalytics?: boolean;
|
|
266
271
|
}): void;
|
|
267
272
|
/**
|
|
268
273
|
* Manual frame capture — triggered by Desktop "Capture Step" button.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scalebun/react-native",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "React Native SDK for ScaleBun",
|
|
5
5
|
"main": "lib/commonjs/index",
|
|
6
6
|
"module": "lib/module/index",
|
|
@@ -154,6 +154,7 @@
|
|
|
154
154
|
"watch": "bob build --watch",
|
|
155
155
|
"test:screens": "node --experimental-transform-types --import ./scripts/rn-globals.mjs --import ./scripts/register-ts-ext.mjs --test scripts/screen-detection.test.ts",
|
|
156
156
|
"test:uistate": "node --experimental-transform-types --import ./scripts/rn-globals.mjs --import ./scripts/register-ts-ext.mjs --test scripts/ui-state.test.ts",
|
|
157
|
-
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json"
|
|
157
|
+
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
|
|
158
|
+
"test:protocol": "node --experimental-transform-types --import ./scripts/rn-globals.mjs --import ./scripts/register-ts-ext.mjs --test scripts/interaction-protocol.test.ts"
|
|
158
159
|
}
|
|
159
160
|
}
|
|
@@ -172,7 +172,7 @@ export class EventTracker {
|
|
|
172
172
|
this.started = true;
|
|
173
173
|
if (this.cfg.automaticEventTracking) {
|
|
174
174
|
this.automaticEventsUnsubscribe = subscribeAutomaticEvents((event) => {
|
|
175
|
-
this.track(event.name, event.properties);
|
|
175
|
+
this.track(event.name, event.properties, event.timestamp);
|
|
176
176
|
});
|
|
177
177
|
}
|
|
178
178
|
if (this.cfg.autoLifecycleEvents) {
|
|
@@ -284,9 +284,9 @@ export class EventTracker {
|
|
|
284
284
|
|
|
285
285
|
// ─── tracking ────────────────────────────────────────────────────────────
|
|
286
286
|
|
|
287
|
-
track(eventName: string, properties?: Record<string, any
|
|
287
|
+
track(eventName: string, properties?: Record<string, any>, timestamp?: number): void {
|
|
288
288
|
try {
|
|
289
|
-
this.enqueue(this.buildEnvelope(eventName, properties));
|
|
289
|
+
this.enqueue(this.buildEnvelope(eventName, properties, timestamp));
|
|
290
290
|
try { this.cfg.onEvent?.(eventName); } catch { /* no-throw */ }
|
|
291
291
|
} catch (err) {
|
|
292
292
|
logger.warn(`[ScaleBun.events] track failed: ${(err as Error)?.message}`);
|
|
@@ -408,7 +408,7 @@ export class EventTracker {
|
|
|
408
408
|
|
|
409
409
|
// ─── internals ─────────────────────────────────────────────────────────────
|
|
410
410
|
|
|
411
|
-
private buildEnvelope(eventName: string, properties?: Record<string, any
|
|
411
|
+
private buildEnvelope(eventName: string, properties?: Record<string, any>, timestamp?: number): Envelope {
|
|
412
412
|
const ctx = this.cfg.context ?? {};
|
|
413
413
|
let canonicalSessionId: string | null | undefined;
|
|
414
414
|
try {
|
|
@@ -419,7 +419,7 @@ export class EventTracker {
|
|
|
419
419
|
const env: Envelope = {
|
|
420
420
|
event_id: uuid(),
|
|
421
421
|
event_name: eventName,
|
|
422
|
-
event_time: Date.now(),
|
|
422
|
+
event_time: timestamp ?? Date.now(),
|
|
423
423
|
app_id: this.cfg.appId,
|
|
424
424
|
platform: this.cfg.platform ?? resolveEventPlatform(),
|
|
425
425
|
installation_id: this.installationId,
|
|
@@ -39,6 +39,8 @@ export type AutomaticEventName =
|
|
|
39
39
|
export interface AutomaticEvent {
|
|
40
40
|
name: AutomaticEventName;
|
|
41
41
|
properties: Record<string, unknown>;
|
|
42
|
+
/** Original observation time. Delivery can be delayed while native coordinates resolve. */
|
|
43
|
+
timestamp?: number;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
type AutomaticEventListener = (event: AutomaticEvent) => void;
|
|
@@ -60,10 +62,12 @@ function compactProperties(
|
|
|
60
62
|
export function emitAutomaticEvent(
|
|
61
63
|
name: AutomaticEventName,
|
|
62
64
|
properties?: Record<string, unknown>,
|
|
65
|
+
timestamp?: number,
|
|
63
66
|
): void {
|
|
64
67
|
const event: AutomaticEvent = {
|
|
65
68
|
name,
|
|
66
69
|
properties: compactProperties(properties),
|
|
70
|
+
timestamp,
|
|
67
71
|
};
|
|
68
72
|
if (listeners.size === 0) {
|
|
69
73
|
pending.push(event);
|
|
@@ -1,6 +1,11 @@
|
|
|
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';
|
|
@@ -28,9 +28,14 @@ import React, {
|
|
|
28
28
|
type ReactNode,
|
|
29
29
|
} from 'react';
|
|
30
30
|
import { View, NativeEventEmitter, NativeModules } from 'react-native';
|
|
31
|
-
import { emitAutomaticEvent } from '../../analytics/automaticEvents';
|
|
32
31
|
import { resolveTouchTarget, describeTouchTarget } from './touchTarget';
|
|
33
32
|
import { resolvePlatformOS } from '../../core/context/device';
|
|
33
|
+
import {
|
|
34
|
+
INTERACTION_PROTOCOL_VERSION,
|
|
35
|
+
generateInteractionId,
|
|
36
|
+
nearestInteractionStart,
|
|
37
|
+
type InteractionStartContext,
|
|
38
|
+
} from './interactionProtocol';
|
|
34
39
|
|
|
35
40
|
// ─── Lazy imports to avoid native module cascade at load time ────────────────
|
|
36
41
|
// We only import TYPE references at the top level; actual modules are require()'d
|
|
@@ -60,6 +65,11 @@ interface ScaleBunDebugRootProps {
|
|
|
60
65
|
suppressDeprecationWarning?: boolean;
|
|
61
66
|
}
|
|
62
67
|
|
|
68
|
+
interface TouchStartContext extends InteractionStartContext {
|
|
69
|
+
locationX?: number;
|
|
70
|
+
locationY?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
63
73
|
export function ScaleBunDebugRoot({
|
|
64
74
|
children,
|
|
65
75
|
navigationRef,
|
|
@@ -270,7 +280,9 @@ export function ScaleBunDebugRoot({
|
|
|
270
280
|
// and forwards them to SessionManager as USER_ACTION events.
|
|
271
281
|
const lastNativeTouchTsRef = useRef<number>(0);
|
|
272
282
|
const nativeTrackingConfirmedRef = useRef<boolean>(false);
|
|
273
|
-
const
|
|
283
|
+
const touchStartRef = useRef<TouchStartContext | null>(null);
|
|
284
|
+
const interactionStartsRef = useRef<TouchStartContext[]>([]);
|
|
285
|
+
const pendingJsEmitsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
274
286
|
|
|
275
287
|
useEffect(() => {
|
|
276
288
|
let subscription: any = null;
|
|
@@ -284,10 +296,19 @@ export function ScaleBunDebugRoot({
|
|
|
284
296
|
lastNativeTouchTsRef.current = Date.now();
|
|
285
297
|
nativeTrackingConfirmedRef.current = true;
|
|
286
298
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
299
|
+
const nativeOccurredAt =
|
|
300
|
+
typeof event.occurredAt === 'number'
|
|
301
|
+
? event.occurredAt
|
|
302
|
+
: (typeof event.timestamp === 'number' ? event.timestamp : Date.now()) -
|
|
303
|
+
(typeof event.durationMs === 'number' ? event.durationMs : 0);
|
|
304
|
+
const start = nearestInteractionStart(interactionStartsRef.current, nativeOccurredAt);
|
|
305
|
+
|
|
306
|
+
// Cancel only this physical touch's fallback. A single timer used to cancel a
|
|
307
|
+
// previous rapid tap and silently lose it on devices without native tracking.
|
|
308
|
+
if (start) {
|
|
309
|
+
const pending = pendingJsEmitsRef.current.get(start.interactionId);
|
|
310
|
+
if (pending !== undefined) clearTimeout(pending);
|
|
311
|
+
pendingJsEmitsRef.current.delete(start.interactionId);
|
|
291
312
|
}
|
|
292
313
|
|
|
293
314
|
const { SessionManager } = require('../session/SessionManager');
|
|
@@ -306,6 +327,15 @@ export function ScaleBunDebugRoot({
|
|
|
306
327
|
} catch { /* no-throw */ }
|
|
307
328
|
|
|
308
329
|
(sm as any).onGestureDetected(event.gestureType || 'tap', {
|
|
330
|
+
interactionId: event.interactionId || start?.interactionId || generateInteractionId(),
|
|
331
|
+
interactionProtocol: event.interactionProtocol || INTERACTION_PROTOCOL_VERSION,
|
|
332
|
+
occurredAt: nativeOccurredAt,
|
|
333
|
+
ui: start?.ui,
|
|
334
|
+
stateStatus: start?.stateStatus ?? 'not_captured',
|
|
335
|
+
target: start?.target,
|
|
336
|
+
targetId: start?.targetId,
|
|
337
|
+
screenName: start?.screenName,
|
|
338
|
+
emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
|
|
309
339
|
x: event.rawX,
|
|
310
340
|
y: event.rawY,
|
|
311
341
|
// Use the actual native end coords when present, not the down
|
|
@@ -334,11 +364,12 @@ export function ScaleBunDebugRoot({
|
|
|
334
364
|
|
|
335
365
|
return () => {
|
|
336
366
|
try { subscription?.remove(); } catch { /* no-throw */ }
|
|
367
|
+
for (const timer of pendingJsEmitsRef.current.values()) clearTimeout(timer);
|
|
368
|
+
pendingJsEmitsRef.current.clear();
|
|
337
369
|
};
|
|
338
|
-
}, []);
|
|
370
|
+
}, [captureAutomaticInteractions]);
|
|
339
371
|
|
|
340
372
|
// ─── Touch Tracking State ───────────────────────────────────────────
|
|
341
|
-
const touchStartRef = useRef<{ x: number; y: number; ts: number } | null>(null);
|
|
342
373
|
|
|
343
374
|
// On-screen rect of the root view — the EXACT view the native screenshot
|
|
344
375
|
// captures. Normalizing pageX/pageY against this rect makes JS-fallback taps
|
|
@@ -377,7 +408,36 @@ export function ScaleBunDebugRoot({
|
|
|
377
408
|
const handleTouchStart = (e: any) => {
|
|
378
409
|
try {
|
|
379
410
|
const touch = e.nativeEvent;
|
|
380
|
-
|
|
411
|
+
const target = resolveTouchTarget(e);
|
|
412
|
+
let ui: string | undefined;
|
|
413
|
+
let screenName: string | undefined;
|
|
414
|
+
try {
|
|
415
|
+
const { uiStateSignature } = require('./uiState');
|
|
416
|
+
ui = uiStateSignature();
|
|
417
|
+
} catch { /* no-throw */ }
|
|
418
|
+
try {
|
|
419
|
+
const { AutoScreenDetector } = require('../navigation/AutoScreenDetector');
|
|
420
|
+
screenName = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
|
|
421
|
+
} catch { /* no-throw */ }
|
|
422
|
+
const start: TouchStartContext = {
|
|
423
|
+
interactionId: generateInteractionId(),
|
|
424
|
+
occurredAt: Date.now(),
|
|
425
|
+
x: touch.pageX,
|
|
426
|
+
y: touch.pageY,
|
|
427
|
+
locationX: touch.locationX,
|
|
428
|
+
locationY: touch.locationY,
|
|
429
|
+
target: describeTouchTarget(target),
|
|
430
|
+
targetId: target?.testID,
|
|
431
|
+
screenName,
|
|
432
|
+
ui,
|
|
433
|
+
stateStatus: ui ? 'captured_nonempty' : 'not_instrumented',
|
|
434
|
+
emitAutomaticAnalytics: captureAutomaticInteractions,
|
|
435
|
+
};
|
|
436
|
+
touchStartRef.current = start;
|
|
437
|
+
interactionStartsRef.current = interactionStartsRef.current
|
|
438
|
+
.filter((candidate) => start.occurredAt - candidate.occurredAt < 5000)
|
|
439
|
+
.concat(start)
|
|
440
|
+
.slice(-8);
|
|
381
441
|
} catch { /* no-throw */ }
|
|
382
442
|
};
|
|
383
443
|
|
|
@@ -413,16 +473,24 @@ export function ScaleBunDebugRoot({
|
|
|
413
473
|
// WHAT was tapped, resolved from the React fiber on the touch event. Without this the tap
|
|
414
474
|
// carries only coordinates, so every tap in the app groups into one row per gesture type.
|
|
415
475
|
// Defensive by construction — returns undefined rather than throwing (see touchTarget.ts).
|
|
416
|
-
const tapped = describeTouchTarget(resolveTouchTarget(e));
|
|
476
|
+
const tapped = start?.target ?? describeTouchTarget(resolveTouchTarget(e));
|
|
417
477
|
|
|
418
478
|
const gestureDetails: any = {
|
|
419
479
|
target: tapped,
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
480
|
+
interactionId: start?.interactionId ?? generateInteractionId(),
|
|
481
|
+
interactionProtocol: INTERACTION_PROTOCOL_VERSION,
|
|
482
|
+
occurredAt: start?.occurredAt ?? Date.now(),
|
|
483
|
+
ui: start?.ui,
|
|
484
|
+
stateStatus: start?.stateStatus ?? 'not_captured',
|
|
485
|
+
targetId: start?.targetId,
|
|
486
|
+
screenName: start?.screenName,
|
|
487
|
+
emitAutomaticAnalytics: start?.emitAutomaticAnalytics ?? captureAutomaticInteractions,
|
|
488
|
+
x: start?.x ?? touch.pageX,
|
|
489
|
+
y: start?.y ?? touch.pageY,
|
|
490
|
+
pageX: start?.x ?? touch.pageX,
|
|
491
|
+
pageY: start?.y ?? touch.pageY,
|
|
492
|
+
locationX: start?.locationX ?? touch.locationX,
|
|
493
|
+
locationY: start?.locationY ?? touch.locationY,
|
|
426
494
|
viewportWidth: vpW > 0 ? vpW : undefined,
|
|
427
495
|
viewportHeight: vpH > 0 ? vpH : undefined,
|
|
428
496
|
};
|
|
@@ -434,8 +502,8 @@ export function ScaleBunDebugRoot({
|
|
|
434
502
|
// measureInWindow returns rootRect.y = statusBarHeight, so subtracting it
|
|
435
503
|
// double-counts the status bar and pushes every marker upward.
|
|
436
504
|
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));
|
|
505
|
+
const nx = Math.max(0, Math.min(1, (start?.x ?? touch.pageX) / rootRect.w));
|
|
506
|
+
const ny = Math.max(0, Math.min(1, (start?.y ?? touch.pageY) / rootRect.h));
|
|
439
507
|
gestureDetails.normalizedX = nx;
|
|
440
508
|
gestureDetails.normalizedY = ny;
|
|
441
509
|
gestureDetails.normalizedPrecomputed = true;
|
|
@@ -472,7 +540,7 @@ export function ScaleBunDebugRoot({
|
|
|
472
540
|
const dx = touch.pageX - start.x;
|
|
473
541
|
const dy = touch.pageY - start.y;
|
|
474
542
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
475
|
-
const duration = Date.now() - start.
|
|
543
|
+
const duration = Date.now() - start.occurredAt;
|
|
476
544
|
gestureDetails.duration = duration;
|
|
477
545
|
|
|
478
546
|
if (dist >= 15) {
|
|
@@ -494,66 +562,17 @@ export function ScaleBunDebugRoot({
|
|
|
494
562
|
}
|
|
495
563
|
}
|
|
496
564
|
|
|
497
|
-
const targetInfo = _extractTarget(e);
|
|
498
|
-
gestureDetails.target = targetInfo?.testId || targetInfo?.accessibilityLabel;
|
|
499
|
-
|
|
500
|
-
if (captureAutomaticInteractions) {
|
|
501
|
-
let screen: string | undefined;
|
|
502
|
-
try {
|
|
503
|
-
const { AutoScreenDetector } = require('../navigation/AutoScreenDetector');
|
|
504
|
-
screen = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
|
|
505
|
-
} catch { /* no-throw */ }
|
|
506
|
-
/**
|
|
507
|
-
* UI STATE, read HERE and not later.
|
|
508
|
-
*
|
|
509
|
-
* A tap belongs to the surface that was on screen when the finger landed: tapping the
|
|
510
|
-
* filter button while the sheet is DOWN belongs to `closed`, because that is what the
|
|
511
|
-
* user was looking at when they reached for it. Reading it after the handler has run
|
|
512
|
-
* moves every "open the thing" tap into the state it created — the one state it
|
|
513
|
-
* certainly does not belong to.
|
|
514
|
-
*
|
|
515
|
-
* Declared-only on this platform (see uiState.ts): absent means not captured, which
|
|
516
|
-
* the dashboard keeps distinct from "nothing was open".
|
|
517
|
-
*/
|
|
518
|
-
let ui: string | undefined;
|
|
519
|
-
try {
|
|
520
|
-
const { uiStateSignature } = require('./uiState');
|
|
521
|
-
ui = uiStateSignature();
|
|
522
|
-
} catch { /* no-throw: a tap must never be lost to state capture */ }
|
|
523
|
-
emitAutomaticEvent('element_interacted', {
|
|
524
|
-
gesture_type: gestureType,
|
|
525
|
-
screen_name: screen,
|
|
526
|
-
/* THIS MAP ENUMERATES. A field added to the payload and forgotten here reaches the
|
|
527
|
-
backend as undefined with no error anywhere — the recurring defect class in this
|
|
528
|
-
codebase. `ui` is the key the grid aggregate reads for state, byte-identical to
|
|
529
|
-
the web SDK's, so one dashboard control queries both platforms. */
|
|
530
|
-
ui,
|
|
531
|
-
// testID/nativeID is an author-controlled stable identifier.
|
|
532
|
-
// Accessibility labels and rendered text are deliberately omitted.
|
|
533
|
-
target_id: targetInfo?.testId,
|
|
534
|
-
normalized_x: gestureDetails.normalizedX,
|
|
535
|
-
normalized_y: gestureDetails.normalizedY,
|
|
536
|
-
direction: gestureDetails.direction,
|
|
537
|
-
duration_ms: gestureDetails.duration,
|
|
538
|
-
});
|
|
539
|
-
}
|
|
540
|
-
|
|
541
565
|
// Feed the Engage gesture detector (rage_tap / dead_tap) — only committed
|
|
542
566
|
// taps, not swipes/scrolls/long-presses. Pure JS, cross-platform; emits the
|
|
543
567
|
// unified `$gesture` signal the engage trigger engine listens for.
|
|
544
568
|
if (gestureType === 'tap') {
|
|
545
569
|
try {
|
|
546
570
|
const { gestureTriggerDetector } = require('../engage/gestureTriggerDetector');
|
|
547
|
-
let screen: string | undefined;
|
|
548
|
-
try {
|
|
549
|
-
const { AutoScreenDetector } = require('../navigation/AutoScreenDetector');
|
|
550
|
-
screen = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
|
|
551
|
-
} catch { /* no-throw */ }
|
|
552
571
|
gestureTriggerDetector.recordTap({
|
|
553
|
-
x: touch.pageX,
|
|
554
|
-
y: touch.pageY,
|
|
555
|
-
hasTarget: !!(
|
|
556
|
-
screen,
|
|
572
|
+
x: start?.x ?? touch.pageX,
|
|
573
|
+
y: start?.y ?? touch.pageY,
|
|
574
|
+
hasTarget: !!(start?.targetId || start?.target),
|
|
575
|
+
screen: start?.screenName,
|
|
557
576
|
});
|
|
558
577
|
} catch { /* no-throw */ }
|
|
559
578
|
}
|
|
@@ -568,7 +587,7 @@ export function ScaleBunDebugRoot({
|
|
|
568
587
|
// screenshot capture view). Three-tier suppression:
|
|
569
588
|
// 1. Native confirmed active → skip JS entirely (native handles all touches)
|
|
570
589
|
// 2. Native event within 300ms → skip (existing dedup)
|
|
571
|
-
// 3. First touch (native not yet confirmed) → defer
|
|
590
|
+
// 3. First touch (native not yet confirmed) → defer 600ms to let native arrive
|
|
572
591
|
const sinceNative = Date.now() - lastNativeTouchTsRef.current;
|
|
573
592
|
if (nativeTrackingConfirmedRef.current) {
|
|
574
593
|
// Native tracker is active — skip JS path entirely
|
|
@@ -579,11 +598,9 @@ export function ScaleBunDebugRoot({
|
|
|
579
598
|
// If native fires during the delay, it cancels this pending emit.
|
|
580
599
|
const capturedType = gestureType;
|
|
581
600
|
const capturedDetails = { ...gestureDetails };
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
pendingJsEmitRef.current = setTimeout(() => {
|
|
586
|
-
pendingJsEmitRef.current = null;
|
|
601
|
+
const interactionId = capturedDetails.interactionId as string;
|
|
602
|
+
const timer = setTimeout(() => {
|
|
603
|
+
pendingJsEmitsRef.current.delete(interactionId);
|
|
587
604
|
if (nativeTrackingConfirmedRef.current) return;
|
|
588
605
|
try {
|
|
589
606
|
const { SessionManager } = require('../session/SessionManager');
|
|
@@ -605,6 +622,7 @@ export function ScaleBunDebugRoot({
|
|
|
605
622
|
// devices with no native tracker at all it merely delays a background emission,
|
|
606
623
|
// which nothing user-visible waits on.
|
|
607
624
|
}, 600);
|
|
625
|
+
pendingJsEmitsRef.current.set(interactionId, timer);
|
|
608
626
|
}
|
|
609
627
|
|
|
610
628
|
touchStartRef.current = null;
|
|
@@ -724,25 +742,6 @@ export function useScaleBunScreen(name: string): void {
|
|
|
724
742
|
}, [name, manager]);
|
|
725
743
|
}
|
|
726
744
|
|
|
727
|
-
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
728
|
-
|
|
729
|
-
function _extractTarget(e: any): { testId?: string; accessibilityLabel?: string; text?: string } | undefined {
|
|
730
|
-
try {
|
|
731
|
-
const target = e?.target;
|
|
732
|
-
if (!target) return undefined;
|
|
733
|
-
|
|
734
|
-
// React Native nativeEvent target properties
|
|
735
|
-
const props = target._internalFiberInstanceHandleDEV?.memoizedProps ?? {};
|
|
736
|
-
return {
|
|
737
|
-
testId: props.testID || props.nativeID || undefined,
|
|
738
|
-
accessibilityLabel: props.accessibilityLabel || undefined,
|
|
739
|
-
text: undefined,
|
|
740
|
-
};
|
|
741
|
-
} catch {
|
|
742
|
-
return undefined;
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
|
|
746
745
|
// ─── Styles ─────────────────────────────────────────────────────────────────
|
|
747
746
|
// IMPORTANT: Do NOT use StyleSheet.create() here.
|
|
748
747
|
// In RN 0.84 bridgeless mode, StyleSheet.create() calls
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** One physical interaction, shared by replay and analytics projections. */
|
|
2
|
+
export const INTERACTION_PROTOCOL_VERSION = 1;
|
|
3
|
+
|
|
4
|
+
export type InteractionStateStatus =
|
|
5
|
+
| 'captured_empty'
|
|
6
|
+
| 'captured_nonempty'
|
|
7
|
+
| 'not_captured'
|
|
8
|
+
| 'not_instrumented';
|
|
9
|
+
|
|
10
|
+
export interface InteractionStartContext {
|
|
11
|
+
interactionId: string;
|
|
12
|
+
occurredAt: number;
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
target?: string;
|
|
16
|
+
targetId?: string;
|
|
17
|
+
screenName?: string;
|
|
18
|
+
ui?: string;
|
|
19
|
+
stateStatus: InteractionStateStatus;
|
|
20
|
+
emitAutomaticAnalytics: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function generateInteractionId(): string {
|
|
24
|
+
return `ixj-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Match a native bridge event to the JS evidence sampled at the same finger-down. */
|
|
28
|
+
export function nearestInteractionStart<T extends InteractionStartContext>(
|
|
29
|
+
starts: readonly T[],
|
|
30
|
+
occurredAt: number,
|
|
31
|
+
toleranceMs = 1500,
|
|
32
|
+
): T | undefined {
|
|
33
|
+
let best: T | undefined;
|
|
34
|
+
let bestDelta = toleranceMs + 1;
|
|
35
|
+
for (const start of starts) {
|
|
36
|
+
const delta = Math.abs(start.occurredAt - occurredAt);
|
|
37
|
+
if (delta < bestDelta) {
|
|
38
|
+
best = start;
|
|
39
|
+
bestDelta = delta;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return bestDelta <= toleranceMs ? best : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Analytics is a projection of the same evidence; no second click is invented. */
|
|
46
|
+
export function automaticInteractionProperties(
|
|
47
|
+
payload: Record<string, unknown>,
|
|
48
|
+
screenName: string | undefined,
|
|
49
|
+
canonicalMirror: boolean,
|
|
50
|
+
): Record<string, unknown> {
|
|
51
|
+
return {
|
|
52
|
+
gesture_type: payload.gestureType,
|
|
53
|
+
screen_name: screenName,
|
|
54
|
+
interaction_id: payload.interaction_id,
|
|
55
|
+
interaction_protocol: payload.interaction_protocol,
|
|
56
|
+
state_status: payload.state_status,
|
|
57
|
+
ui: payload.ui,
|
|
58
|
+
target_id: payload.target_id,
|
|
59
|
+
normalized_x: payload.normalizedX,
|
|
60
|
+
normalized_y: payload.normalizedY,
|
|
61
|
+
direction: payload.direction,
|
|
62
|
+
duration_ms: payload.durationMs,
|
|
63
|
+
canonical_mirror: canonicalMirror || undefined,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -76,9 +76,14 @@ export function uiStateSignature(): string | undefined {
|
|
|
76
76
|
if (!declared.size) return undefined;
|
|
77
77
|
/* SORTED, or the same surface produces different signatures depending on the order the host happened
|
|
78
78
|
to declare things in, and every count fragments into several that mean nothing. */
|
|
79
|
-
|
|
79
|
+
const pairs = [...declared.keys()]
|
|
80
80
|
.sort()
|
|
81
|
-
.map((k) => `${k}:${declared.get(k)}`)
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
.map((k) => `${k}:${declared.get(k)}`);
|
|
82
|
+
let signature = '';
|
|
83
|
+
for (const pair of pairs) {
|
|
84
|
+
const next = signature ? `${signature};${pair}` : pair;
|
|
85
|
+
if (next.length > 96) break;
|
|
86
|
+
signature = next;
|
|
87
|
+
}
|
|
88
|
+
return signature || undefined;
|
|
84
89
|
}
|
|
@@ -93,31 +93,33 @@ export class JourneyEventPipeline {
|
|
|
93
93
|
journeyId?: string;
|
|
94
94
|
/** Frame that was on screen when this happened. Resolved by the caller; see sessionTypes. */
|
|
95
95
|
frameId?: string;
|
|
96
|
+
timestamp?: number;
|
|
96
97
|
},
|
|
97
98
|
): JourneyEvent | null {
|
|
98
99
|
try {
|
|
99
100
|
const key = `${type}:${opts?.subtype ?? ''}`;
|
|
100
|
-
const
|
|
101
|
-
|
|
101
|
+
const receivedAt = Date.now();
|
|
102
|
+
const occurredAt = opts?.timestamp ?? receivedAt;
|
|
103
|
+
if (key === this.lastEventKey && (receivedAt - this.lastEventTs) < this.config.dedupeWindowMs) {
|
|
102
104
|
// Within dedup window — allow high-confidence native events to
|
|
103
105
|
// REPLACE a prior low-confidence JS event for the same gesture.
|
|
104
106
|
// This prevents the race where JS fires first and the pipeline
|
|
105
107
|
// drops the native event that has more accurate coordinates.
|
|
106
108
|
const incomingConfidence = (opts?.payload as any)?.confidence;
|
|
107
109
|
if (incomingConfidence === 'high' && this.lastEventConfidence !== 'high') {
|
|
108
|
-
this._replaceLastEvent(key,
|
|
110
|
+
this._replaceLastEvent(key, receivedAt, opts);
|
|
109
111
|
}
|
|
110
112
|
return null;
|
|
111
113
|
}
|
|
112
114
|
this.lastEventKey = key;
|
|
113
|
-
this.lastEventTs =
|
|
115
|
+
this.lastEventTs = receivedAt;
|
|
114
116
|
this.lastEventConfidence = (opts?.payload as any)?.confidence ?? null;
|
|
115
117
|
|
|
116
118
|
const event: JourneyEvent = {
|
|
117
119
|
eventId: generateEventId(),
|
|
118
120
|
sessionId: this.sessionId,
|
|
119
121
|
journeyId: opts?.journeyId,
|
|
120
|
-
ts:
|
|
122
|
+
ts: occurredAt,
|
|
121
123
|
type,
|
|
122
124
|
subtype: opts?.subtype,
|
|
123
125
|
severity: opts?.severity ?? inferSeverity(type),
|