import { Dimensions, Platform } from 'react-native'; import { trace, SpanStatusCode, type Span } from '@opentelemetry/api'; import { logger } from '../utils'; import { type GestureEvent, type RecorderConfig, type EventRecorder, } from '../types'; import { MouseInteractions, type eventWithTime, EventType, IncrementalSource, } from '@rrweb/types'; import { type NativeGestureEvent, SessionRecorderNative, gestureEventEmitter, } from '../native'; // Force TypeScript recompilation export class GestureRecorder implements EventRecorder { // @ts-ignore private config?: RecorderConfig; private isRecording = false; private events: GestureEvent[] = []; private screenDimensions: { width: number; height: number } | null = null; private lastGestureTime: number = 0; private gestureThrottleMs: number = 50; // Throttle gestures to avoid spam private lastTouchTime: number = 0; private touchThrottleMs: number = 100; // Throttle touch events to max 10 per second private eventRecorder?: EventRecorder; private imageNodeId: number = 1; // ID of the image node for touch interactions private screenRecorder?: any; // Reference to screen recorder for force capture private gestureEventListener?: any; // Native event listener private currentPanSpan?: Span; // Aggregated span for pan gesture private panMoveCount: number = 0; init( config: RecorderConfig, eventRecorder?: EventRecorder, screenRecorder?: any ): void { this.config = config; this.eventRecorder = eventRecorder; this.screenRecorder = screenRecorder; this._getScreenDimensions(); } start(): void { logger.info('GestureRecorder', 'Native gesture recording started'); this.isRecording = true; this.events = []; // Check if we're on web platform if (Platform.OS === 'web') { logger.warn( 'GestureRecorder', 'Native gesture recording not available on web platform' ); return; } // Start native gesture recording SessionRecorderNative.startGestureRecording() .then(() => { logger.info( 'GestureRecorder', 'Native gesture recording started successfully' ); this._setupGestureEventListener(); }) .catch((error) => { logger.error( 'GestureRecorder', 'Failed to start native gesture recording', error ); }); } stop(): void { this.isRecording = false; this._removeGestureEventListener(); // Check if we're on web platform if (Platform.OS === 'web') { logger.warn( 'GestureRecorder', 'Native gesture recording not available on web platform' ); return; } // Stop native gesture recording SessionRecorderNative.stopGestureRecording() .then(() => { logger.info( 'GestureRecorder', 'Native gesture recording stopped successfully' ); }) .catch((error) => { logger.error( 'GestureRecorder', 'Failed to stop native gesture recording', error ); }); } private _getScreenDimensions(): void { try { this.screenDimensions = Dimensions.get('window'); } catch (error) { // Failed to get screen dimensions - silently continue this.screenDimensions = { width: 375, height: 667 }; // Default fallback } } private _setupGestureEventListener(): void { if (!gestureEventEmitter) { logger.warn('GestureRecorder', 'Gesture event emitter not available'); return; } this.gestureEventListener = gestureEventEmitter.addListener( 'onGestureDetected', (nativeGesture: any) => { this._handleNativeGesture(nativeGesture as NativeGestureEvent); } ); } private _removeGestureEventListener(): void { if (this.gestureEventListener) { this.gestureEventListener.remove(); this.gestureEventListener = undefined; } } private _handleNativeGesture(nativeGesture: NativeGestureEvent): void { if (!this.isRecording) return; // Throttle gestures to avoid spam const now = Date.now(); if (now - this.lastGestureTime < this.gestureThrottleMs) { return; } this.lastGestureTime = now; // Convert native gesture to our format const gesture: GestureEvent = { type: nativeGesture.type as any, timestamp: nativeGesture.timestamp, coordinates: { x: nativeGesture.x, y: nativeGesture.y }, target: nativeGesture.target, targetInfo: nativeGesture.targetInfo, // Pass through targetInfo from native metadata: { ...nativeGesture.metadata, screenWidth: this.screenDimensions?.width, screenHeight: this.screenDimensions?.height, }, }; this.events.push(gesture); this._sendEvent(gesture); this._recordOpenTelemetrySpan(gesture); // Handle specific gesture types switch (nativeGesture.type) { case 'tap': this._handleTapGesture(nativeGesture); break; case 'pan_start': this._handlePanStartGesture(nativeGesture); break; case 'pan_move': this._handlePanMoveGesture(nativeGesture); break; case 'pan_end': this._handlePanEndGesture(nativeGesture); break; case 'long_press': this._handleLongPressGesture(nativeGesture); break; case 'pinch': this._handlePinchGesture(nativeGesture); break; case 'swipe': this._handleSwipeGesture(nativeGesture); break; } } private _handleTapGesture(nativeGesture: NativeGestureEvent): void { this.recordTouchStart( nativeGesture.x, nativeGesture.y, nativeGesture.target ); this.recordTouchEnd(nativeGesture.x, nativeGesture.y, nativeGesture.target); } private _handlePanStartGesture(nativeGesture: NativeGestureEvent): void { this.recordTouchStart( nativeGesture.x, nativeGesture.y, nativeGesture.target ); } private _handlePanMoveGesture(nativeGesture: NativeGestureEvent): void { this.recordTouchMove( nativeGesture.x, nativeGesture.y, nativeGesture.target ); } private _handlePanEndGesture(nativeGesture: NativeGestureEvent): void { this.recordTouchEnd(nativeGesture.x, nativeGesture.y, nativeGesture.target); } private _handleLongPressGesture(nativeGesture: NativeGestureEvent): void { this.recordTouchStart( nativeGesture.x, nativeGesture.y, nativeGesture.target ); this.recordTouchEnd(nativeGesture.x, nativeGesture.y, nativeGesture.target); } private _handlePinchGesture(nativeGesture: NativeGestureEvent): void { this.recordPinch( nativeGesture.metadata?.scale || 1.0, nativeGesture.target, nativeGesture.metadata?.velocity || 0 ); } private _handleSwipeGesture(nativeGesture: NativeGestureEvent): void { this.recordSwipe( nativeGesture.metadata?.direction || 'unknown', nativeGesture.target, nativeGesture.metadata?.velocity || 0 ); } private _sendEvent(event: GestureEvent): void { // Send event to backend or store locally logger.debug('GestureRecorder', 'Gesture event recorded', { type: event.type, target: event.target, }); } private _recordOpenTelemetrySpan(event: GestureEvent): void { try { logger.debug('GestureRecorder', 'Creating OTEL span for native gesture', { type: event.type, target: event.target, targetInfo: event.targetInfo, hasTargetInfo: !!event.targetInfo, }); // Special handling to aggregate pan gestures into a single span if (event.type === 'pan_start') { // End any previously dangling pan span defensively if (this.currentPanSpan) { this.currentPanSpan.setStatus({ code: SpanStatusCode.OK }); this.currentPanSpan.end(); } this.panMoveCount = 0; const panSpan = trace .getTracer('@opentelemetry/instrumentation-user-interaction') .startSpan('NativeGesture.pan', { startTime: event.timestamp, }); panSpan.setAttribute('gesture.type', 'pan'); panSpan.setAttribute('gesture.timestamp', event.timestamp); panSpan.setAttribute('gesture.platform', 'react-native'); panSpan.setAttribute('gesture.source', 'native-module'); if (event.coordinates) { panSpan.setAttribute('gesture.start.x', event.coordinates.x); panSpan.setAttribute('gesture.start.y', event.coordinates.y); } if (event.target) { panSpan.setAttribute( 'gesture.target', this._truncateText(event.target, 50) ); } // Enrich with target info if provided const info = event.targetInfo; if (info) { if (info.label) { const truncatedLabel = this._truncateText(String(info.label), 50); panSpan.setAttribute('gesture.target.label', truncatedLabel); } if (info.role) { panSpan.setAttribute('gesture.target.role', String(info.role)); } if (info.testId) { panSpan.setAttribute('gesture.target.test_id', String(info.testId)); } if (info.text) { const truncatedText = this._truncateText(String(info.text), 50); panSpan.setAttribute('gesture.target.text', truncatedText); } } // Save the span for subsequent move/end events this.currentPanSpan = panSpan; return; } if (event.type === 'pan_move') { if (this.currentPanSpan) { this.panMoveCount += 1; this.currentPanSpan.setAttribute( 'gesture.pan.move_count', this.panMoveCount ); if (event.coordinates) { this.currentPanSpan.setAttribute( 'gesture.last.x', event.coordinates.x ); this.currentPanSpan.setAttribute( 'gesture.last.y', event.coordinates.y ); } // Don't end the span here; just update it return; } // If we received a move without a start, fall through to single-shot span below } if (event.type === 'pan_end') { if (this.currentPanSpan) { if (event.coordinates) { this.currentPanSpan.setAttribute( 'gesture.end.x', event.coordinates.x ); this.currentPanSpan.setAttribute( 'gesture.end.y', event.coordinates.y ); } this.currentPanSpan.setStatus({ code: SpanStatusCode.OK }); this.currentPanSpan.end(); this.currentPanSpan = undefined; this.panMoveCount = 0; return; } // If no current span, fall through and create a single-shot span for the end event } // Default behavior: create a short-lived span per non-pan event const span = trace .getTracer('@opentelemetry/instrumentation-user-interaction') .startSpan(`NativeGesture.${event.type}`, { startTime: event.timestamp, attributes: { 'gesture.type': event.type, 'gesture.timestamp': event.timestamp, 'gesture.platform': 'react-native', 'gesture.source': 'native-module', }, }); if (event.coordinates) { span.setAttribute('gesture.coordinates.x', event.coordinates.x); span.setAttribute('gesture.coordinates.y', event.coordinates.y); // Calculate relative position if (this.screenDimensions) { const relativeX = event.coordinates.x / this.screenDimensions.width; const relativeY = event.coordinates.y / this.screenDimensions.height; span.setAttribute('gesture.coordinates.relative_x', relativeX); span.setAttribute('gesture.coordinates.relative_y', relativeY); } } if (event.target) { span.setAttribute( 'gesture.target', this._truncateText(event.target, 100) ); } // Enrich with target info if provided const info = event.targetInfo; if (info) { if (info.label) { const truncatedLabel = this._truncateText(String(info.label), 100); span.setAttribute('gesture.target.label', truncatedLabel); } if (info.role) { span.setAttribute('gesture.target.role', String(info.role)); } if (info.testId) { span.setAttribute('gesture.target.test_id', String(info.testId)); } if (info.text) { const truncatedText = this._truncateText(String(info.text), 200); span.setAttribute('gesture.target.text', truncatedText); } } if (event.metadata) { Object.entries(event.metadata).forEach(([key, value]) => { span.setAttribute(`gesture.metadata.${key}`, String(value)); }); } span.setStatus({ code: SpanStatusCode.OK }); span.end(); logger.debug( 'GestureRecorder', 'OTEL span created and ended successfully' ); } catch (error) { logger.error( 'GestureRecorder', 'Failed to record OpenTelemetry span for native gesture', error ); } } // Public methods for manual event recording (same as before) recordTap( x: number, y: number, target?: string, pressure?: number, timestamp?: number ): void { const event: GestureEvent = { type: 'tap', timestamp: timestamp || Date.now(), coordinates: { x, y }, target, metadata: { pressure: pressure || 1.0, screenWidth: this.screenDimensions?.width, screenHeight: this.screenDimensions?.height, }, }; this._recordEvent(event); } recordSwipe( direction: string, target?: string, velocity?: number, distance?: number ): void { const event: GestureEvent = { type: 'swipe', timestamp: Date.now(), target, metadata: { direction, velocity: velocity || 0, distance: distance || 0, screenWidth: this.screenDimensions?.width, screenHeight: this.screenDimensions?.height, }, }; this._recordEvent(event); } recordPinch(scale: number, target?: string, velocity?: number): void { const event: GestureEvent = { type: 'pinch', timestamp: Date.now(), target, metadata: { scale, velocity: velocity || 0, screenWidth: this.screenDimensions?.width, screenHeight: this.screenDimensions?.height, }, }; this._recordEvent(event); } recordPan( deltaX: number, deltaY: number, target?: string, velocity?: number ): void { const event: GestureEvent = { type: 'pan', timestamp: Date.now(), target, metadata: { deltaX, deltaY, velocity: velocity || 0, screenWidth: this.screenDimensions?.width, screenHeight: this.screenDimensions?.height, }, }; this._recordEvent(event); } recordLongPress(duration: number, target?: string, pressure?: number): void { const event: GestureEvent = { type: 'longPress', timestamp: Date.now(), target, metadata: { duration, pressure: pressure || 1.0, screenWidth: this.screenDimensions?.width, screenHeight: this.screenDimensions?.height, }, }; this._recordEvent(event); } recordDoubleTap(x: number, y: number, target?: string): void { const event: GestureEvent = { type: 'doubleTap', timestamp: Date.now(), coordinates: { x, y }, target, metadata: { screenWidth: this.screenDimensions?.width, screenHeight: this.screenDimensions?.height, }, }; this._recordEvent(event); } recordRotate(rotation: number, target?: string, velocity?: number): void { const event: GestureEvent = { type: 'rotate', timestamp: Date.now(), target, metadata: { rotation, velocity: velocity || 0, screenWidth: this.screenDimensions?.width, screenHeight: this.screenDimensions?.height, }, }; this._recordEvent(event); } recordFling(direction: string, velocity: number, target?: string): void { const event: GestureEvent = { type: 'fling', timestamp: Date.now(), target, metadata: { direction, velocity, screenWidth: this.screenDimensions?.width, screenHeight: this.screenDimensions?.height, }, }; this._recordEvent(event); } recordMultiTouch(touchCount: number, target?: string): void { const event: GestureEvent = { type: 'multiTouch', timestamp: Date.now(), target, metadata: { touchCount, screenWidth: this.screenDimensions?.width, screenHeight: this.screenDimensions?.height, }, }; this._recordEvent(event); } recordScroll( direction: string, distance: number, velocity: number, target?: string ): void { const event: GestureEvent = { type: 'scroll', timestamp: Date.now(), target, metadata: { direction, distance, velocity, screenWidth: this.screenDimensions?.width, screenHeight: this.screenDimensions?.height, }, }; this._recordEvent(event); } recordZoom(scale: number, target?: string, velocity?: number): void { const event: GestureEvent = { type: 'zoom', timestamp: Date.now(), target, metadata: { scale, velocity: velocity || 0, screenWidth: this.screenDimensions?.width, screenHeight: this.screenDimensions?.height, }, }; this._recordEvent(event); } // Touch event methods (same as before) recordTouchStart( x: number, y: number, target?: string, pressure?: number ): void { // Throttle touch events to prevent spam const now = Date.now(); if (now - this.lastTouchTime < this.touchThrottleMs) { logger.debug( 'GestureRecorder', `Touch start throttled (${now - this.lastTouchTime}ms < ${this.touchThrottleMs}ms)` ); return; } this.lastTouchTime = now; logger.debug('GestureRecorder', 'Touch start recorded', { x, y, target, pressure, }); this._createMouseInteractionEvent(x, y, MouseInteractions.TouchStart, now); } recordTouchMove( x: number, y: number, target?: string, pressure?: number ): void { // Throttle touch move events more aggressively const now = Date.now(); if (now - this.lastTouchTime < this.touchThrottleMs * 2) { // 200ms throttle for move events logger.debug( 'GestureRecorder', `Touch move throttled (${now - this.lastTouchTime}ms < ${this.touchThrottleMs * 2}ms)` ); return; } this.lastTouchTime = now; logger.debug('GestureRecorder', 'Touch move recorded', { x, y, target, pressure, }); this._createMouseMoveEvent(x, y, target); } recordTouchEnd( x: number, y: number, target?: string, pressure?: number ): void { const timestamp = Date.now(); logger.debug('GestureRecorder', 'Touch end recorded', { x, y, target, pressure, timestamp, }); this.recordTap(x, y, target, pressure, timestamp); this._createMouseInteractionEvent( x, y, MouseInteractions.TouchEnd, timestamp ); // Only force screen capture on touch end (not on every touch event) this.screenRecorder?.forceCapture(timestamp); } recordTouchCancel(x: number, y: number, _target?: string): void { this._createMouseInteractionEvent(x, y, MouseInteractions.TouchCancel); } setImageNodeId(nodeId: number): void { this.imageNodeId = nodeId; } private _recordEvent(event: GestureEvent): void { if (!this.isRecording) return; // Throttle gestures to avoid spam const now = Date.now(); if (now - this.lastGestureTime < this.gestureThrottleMs) { return; } this.lastGestureTime = now; this.events.push(event); this._sendEvent(event); this._recordOpenTelemetrySpan(event); } private _createMouseInteractionEvent( x: number, y: number, interactionType: MouseInteractions, timestamp?: number ): void { const incrementalSnapshotEvent: eventWithTime = { type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.MouseInteraction, type: interactionType, id: this.imageNodeId, // Reference to the image node x: x, // Preserve decimal precision like web rrweb y: y, // Preserve decimal precision like web rrweb pointerType: 2, // 2 = Touch for React Native (0=Mouse, 1=Pen, 2=Touch) }, timestamp: timestamp || Date.now(), }; this.recordEvent(incrementalSnapshotEvent); } private _createMouseMoveEvent(x: number, y: number, _target?: string): void { const incrementalSnapshotEvent: eventWithTime = { type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.TouchMove, // Use MouseMove instead of MouseInteraction positions: [ { x: x, // Preserve decimal precision like web rrweb y: y, // Preserve decimal precision like web rrweb id: this.imageNodeId, // Reference to the image node timeOffset: 0, // No time offset for single position }, ], }, timestamp: Date.now(), }; this.recordEvent(incrementalSnapshotEvent); } recordEvent(event: any): void { if (this.eventRecorder) { this.eventRecorder.recordEvent(event); } } // Get recorded events getEvents(): GestureEvent[] { return [...this.events]; } // Clear events clearEvents(): void { this.events = []; } // Get event statistics getEventStats(): Record { const stats: Record = {}; this.events.forEach((event) => { stats[event.type] = (stats[event.type] || 0) + 1; }); return stats; } // Set gesture throttle setGestureThrottle(throttleMs: number): void { this.gestureThrottleMs = throttleMs; } // Get recording status isRecordingEnabled(): boolean { return this.isRecording; } /** * Truncate text to prevent large span attributes * @param text - Text to truncate * @param maxLength - Maximum length (default: 100) * @returns Truncated text with ellipsis if needed */ private _truncateText(text: string, maxLength: number = 100): string { if (!text || text.length <= maxLength) { return text; } return text.substring(0, maxLength - 3) + '...'; } }