diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java index 6a3d2872a6..a69a892a14 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java @@ -32,6 +32,9 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc if ("touch".equals(breadcrumb.getCategory())) { return convertTouchBreadcrumb(breadcrumb); } + if ("ui.frustration".equals(breadcrumb.getCategory())) { + return convertFrustrationBreadcrumb(breadcrumb); + } if ("navigation".equals(breadcrumb.getCategory())) { return convertNavigationBreadcrumb(breadcrumb); } @@ -72,6 +75,18 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc return rrWebBreadcrumb; } + @TestOnly + public @NotNull RRWebEvent convertFrustrationBreadcrumb(final @NotNull Breadcrumb breadcrumb) { + final RRWebBreadcrumbEvent rrWebBreadcrumb = new RRWebBreadcrumbEvent(); + + rrWebBreadcrumb.setCategory("ui.frustration"); + + rrWebBreadcrumb.setMessage(getTouchPathMessage(breadcrumb.getData("path"))); + + setRRWebEventDefaultsFrom(rrWebBreadcrumb, breadcrumb); + return rrWebBreadcrumb; + } + @TestOnly public static @Nullable String getTouchPathMessage(final @Nullable Object maybePath) { if (!(maybePath instanceof List)) { diff --git a/packages/core/ios/RNSentryReplayBreadcrumbConverter.m b/packages/core/ios/RNSentryReplayBreadcrumbConverter.m index 59bf29e0e6..93c26eda51 100644 --- a/packages/core/ios/RNSentryReplayBreadcrumbConverter.m +++ b/packages/core/ios/RNSentryReplayBreadcrumbConverter.m @@ -35,6 +35,10 @@ - (instancetype _Nonnull)init return [self convertTouch:breadcrumb]; } + if ([breadcrumb.category isEqualToString:@"ui.frustration"]) { + return [self convertFrustration:breadcrumb]; + } + if ([breadcrumb.category isEqualToString:@"navigation"]) { return [SentrySessionReplayHybridSDK createBreadcrumbwithTimestamp:breadcrumb.timestamp category:breadcrumb.category @@ -75,6 +79,22 @@ - (instancetype _Nonnull)init data:breadcrumb.data]; } +- (id _Nullable)convertFrustration:(SentryBreadcrumb *_Nonnull)breadcrumb +{ + if (breadcrumb.data == nil) { + return nil; + } + + NSMutableArray *path = [breadcrumb.data valueForKey:@"path"]; + NSString *message = [RNSentryReplayBreadcrumbConverter getTouchPathMessageFrom:path]; + + return [SentrySessionReplayHybridSDK createBreadcrumbwithTimestamp:breadcrumb.timestamp + category:@"ui.frustration" + message:message + level:breadcrumb.level + data:breadcrumb.data]; +} + + (NSString *_Nullable)getTouchPathMessageFrom:(NSArray *_Nullable)path { if (path == nil) { diff --git a/packages/core/src/js/ragetap.ts b/packages/core/src/js/ragetap.ts new file mode 100644 index 0000000000..8f23e59da6 --- /dev/null +++ b/packages/core/src/js/ragetap.ts @@ -0,0 +1,118 @@ +import type { SeverityLevel } from '@sentry/core'; +import { addBreadcrumb, debug } from '@sentry/core'; + +const DEFAULT_RAGE_TAP_THRESHOLD = 3; +const DEFAULT_RAGE_TAP_TIME_WINDOW = 1000; +const MAX_RECENT_TAPS = 10; + +interface RecentTap { + identity: string; + timestamp: number; +} + +export interface TouchedComponentInfo { + name?: string; + label?: string; + element?: string; + file?: string; +} + +export interface RageTapDetectorOptions { + enabled: boolean; + threshold: number; + timeWindow: number; +} + +/** + * Detects rage taps (repeated rapid taps on the same target) and emits + * `ui.frustration` breadcrumbs when the threshold is hit. + */ +export class RageTapDetector { + private _recentTaps: RecentTap[] = []; + private _enabled: boolean; + private _threshold: number; + private _timeWindow: number; + + public constructor(options?: Partial) { + this._enabled = options?.enabled ?? true; + this._threshold = options?.threshold ?? DEFAULT_RAGE_TAP_THRESHOLD; + this._timeWindow = options?.timeWindow ?? DEFAULT_RAGE_TAP_TIME_WINDOW; + } + + /** + * Call after each touch event. If a rage tap is detected, a `ui.frustration` + * breadcrumb is emitted automatically. + */ + public check(touchPath: TouchedComponentInfo[], label?: string): void { + if (!this._enabled) { + return; + } + + const root = touchPath[0]; + if (!root) { + return; + } + + const identity = getTapIdentity(root, label); + const now = Date.now(); + const rageTapCount = this._detect(identity, now); + + if (rageTapCount > 0) { + const detail = label ? label : `${root.name}${root.file ? ` (${root.file})` : ''}`; + addBreadcrumb({ + category: 'ui.frustration', + data: { + type: 'rage_tap', + tapCount: rageTapCount, + path: touchPath, + label, + }, + level: 'warning' as SeverityLevel, + message: `Rage tap detected on: ${detail}`, + type: 'user', + }); + + debug.log(`[TouchEvents] Rage tap detected: ${rageTapCount} taps on ${detail}`); + } + } + + /** + * Returns the tap count if rage tap is detected, 0 otherwise. + */ + private _detect(identity: string, now: number): number { + this._recentTaps.push({ identity, timestamp: now }); + + // Keep buffer bounded + if (this._recentTaps.length > MAX_RECENT_TAPS) { + this._recentTaps = this._recentTaps.slice(-MAX_RECENT_TAPS); + } + + // Prune taps outside the time window + const cutoff = now - this._timeWindow; + this._recentTaps = this._recentTaps.filter(tap => tap.timestamp >= cutoff); + + // Count consecutive taps on the same target (from the end) + let count = 0; + for (let i = this._recentTaps.length - 1; i >= 0; i--) { + if (this._recentTaps[i]?.identity === identity) { + count++; + } else { + break; + } + } + + if (count >= this._threshold) { + this._recentTaps = []; + return count; + } + + return 0; + } +} + +function getTapIdentity(root: TouchedComponentInfo, label?: string): string { + if (label) { + return `label:${label}`; + } + return `name:${root.name ?? ''}|file:${root.file ?? ''}`; +} diff --git a/packages/core/src/js/touchevents.tsx b/packages/core/src/js/touchevents.tsx index 27c31d0c33..e66a74d4dd 100644 --- a/packages/core/src/js/touchevents.tsx +++ b/packages/core/src/js/touchevents.tsx @@ -6,6 +6,7 @@ import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import { createIntegration } from './integrations/factory'; +import { RageTapDetector } from './ragetap'; import { startUserInteractionSpan } from './tracing/integrations/userInteraction'; import { UI_ACTION_TOUCH } from './tracing/ops'; import { SPAN_ORIGIN_AUTO_INTERACTION } from './tracing/origin'; @@ -48,6 +49,25 @@ export type TouchEventBoundaryProps = { * @experimental This API is experimental and may change in future releases. */ spanAttributes?: Record; + /** + * Enable rage tap detection. When enabled, rapid consecutive taps on the + * same element are detected and emitted as `ui.frustration` breadcrumbs. + * + * @default true + */ + enableRageTapDetection?: boolean; + /** + * Number of taps within the time window to trigger a rage tap. + * + * @default 3 + */ + rageTapThreshold?: number; + /** + * Time window in milliseconds for rage tap detection. + * + * @default 1000 + */ + rageTapTimeWindow?: number; }; const touchEventStyles = StyleSheet.create({ @@ -96,10 +116,24 @@ class TouchEventBoundary extends React.Component { breadcrumbType: DEFAULT_BREADCRUMB_TYPE, ignoreNames: [], maxComponentTreeSize: DEFAULT_MAX_COMPONENT_TREE_SIZE, + enableRageTapDetection: true, + rageTapThreshold: 3, + rageTapTimeWindow: 1000, }; public readonly name: string = 'TouchEventBoundary'; + private _rageTapDetector: RageTapDetector; + + public constructor(props: TouchEventBoundaryProps) { + super(props); + this._rageTapDetector = new RageTapDetector({ + enabled: props.enableRageTapDetection, + threshold: props.rageTapThreshold, + timeWindow: props.rageTapTimeWindow, + }); + } + /** * Registers the TouchEventBoundary as a Sentry Integration. */ @@ -203,6 +237,7 @@ class TouchEventBoundary extends React.Component { const label = touchPath.find(info => info.label)?.label; if (touchPath.length > 0) { this._logTouchEvent(touchPath, label); + this._rageTapDetector.check(touchPath, label); } const span = startUserInteractionSpan({