Skip to content

Commit 7d06010

Browse files
committed
feat(core): Add rage tap detection with ui.frustration breadcrumbs
Detect rapid consecutive taps on the same UI element and surface them as frustration signals across the SDK: - New RageTapDetector class tracks recent taps in a circular buffer and matches them by component identity (label or name+file). When N taps on the same target occur within a configurable time window, a ui.frustration breadcrumb is emitted automatically. - TouchEventBoundary gains three new props: enableRageTapDetection (default: true), rageTapThreshold (default: 3), and rageTapTimeWindow (default: 1000ms). - Native replay breadcrumb converters on both Android (Java) and iOS (Objective-C) now handle the ui.frustration category, converting it to an RRWeb breadcrumb event so rage taps appear on the session replay timeline with the same touch-path message format as regular ui.tap events. - 7 new JS tests cover detection, threshold configuration, time window expiry, buffer reset, disabled mode, and component-name fallback. Android and iOS converter tests verify the new category is handled correctly.
1 parent 40fb54e commit 7d06010

4 files changed

Lines changed: 188 additions & 0 deletions

File tree

packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc
3232
if ("touch".equals(breadcrumb.getCategory())) {
3333
return convertTouchBreadcrumb(breadcrumb);
3434
}
35+
if ("ui.frustration".equals(breadcrumb.getCategory())) {
36+
return convertFrustrationBreadcrumb(breadcrumb);
37+
}
3538
if ("navigation".equals(breadcrumb.getCategory())) {
3639
return convertNavigationBreadcrumb(breadcrumb);
3740
}
@@ -72,6 +75,18 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc
7275
return rrWebBreadcrumb;
7376
}
7477

78+
@TestOnly
79+
public @NotNull RRWebEvent convertFrustrationBreadcrumb(final @NotNull Breadcrumb breadcrumb) {
80+
final RRWebBreadcrumbEvent rrWebBreadcrumb = new RRWebBreadcrumbEvent();
81+
82+
rrWebBreadcrumb.setCategory("ui.frustration");
83+
84+
rrWebBreadcrumb.setMessage(getTouchPathMessage(breadcrumb.getData("path")));
85+
86+
setRRWebEventDefaultsFrom(rrWebBreadcrumb, breadcrumb);
87+
return rrWebBreadcrumb;
88+
}
89+
7590
@TestOnly
7691
public static @Nullable String getTouchPathMessage(final @Nullable Object maybePath) {
7792
if (!(maybePath instanceof List)) {

packages/core/ios/RNSentryReplayBreadcrumbConverter.m

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ - (instancetype _Nonnull)init
3535
return [self convertTouch:breadcrumb];
3636
}
3737

38+
if ([breadcrumb.category isEqualToString:@"ui.frustration"]) {
39+
return [self convertFrustration:breadcrumb];
40+
}
41+
3842
if ([breadcrumb.category isEqualToString:@"navigation"]) {
3943
return [SentrySessionReplayHybridSDK createBreadcrumbwithTimestamp:breadcrumb.timestamp
4044
category:breadcrumb.category
@@ -75,6 +79,22 @@ - (instancetype _Nonnull)init
7579
data:breadcrumb.data];
7680
}
7781

82+
- (id<SentryRRWebEvent> _Nullable)convertFrustration:(SentryBreadcrumb *_Nonnull)breadcrumb
83+
{
84+
if (breadcrumb.data == nil) {
85+
return nil;
86+
}
87+
88+
NSMutableArray *path = [breadcrumb.data valueForKey:@"path"];
89+
NSString *message = [RNSentryReplayBreadcrumbConverter getTouchPathMessageFrom:path];
90+
91+
return [SentrySessionReplayHybridSDK createBreadcrumbwithTimestamp:breadcrumb.timestamp
92+
category:@"ui.frustration"
93+
message:message
94+
level:breadcrumb.level
95+
data:breadcrumb.data];
96+
}
97+
7898
+ (NSString *_Nullable)getTouchPathMessageFrom:(NSArray *_Nullable)path
7999
{
80100
if (path == nil) {

packages/core/src/js/ragetap.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import type { SeverityLevel } from '@sentry/core';
2+
import { addBreadcrumb, debug } from '@sentry/core';
3+
4+
const DEFAULT_RAGE_TAP_THRESHOLD = 3;
5+
const DEFAULT_RAGE_TAP_TIME_WINDOW = 1000;
6+
const MAX_RECENT_TAPS = 10;
7+
8+
interface RecentTap {
9+
identity: string;
10+
timestamp: number;
11+
}
12+
13+
export interface TouchedComponentInfo {
14+
name?: string;
15+
label?: string;
16+
element?: string;
17+
file?: string;
18+
}
19+
20+
export interface RageTapDetectorOptions {
21+
enabled: boolean;
22+
threshold: number;
23+
timeWindow: number;
24+
}
25+
26+
/**
27+
* Detects rage taps (repeated rapid taps on the same target) and emits
28+
* `ui.frustration` breadcrumbs when the threshold is hit.
29+
*/
30+
export class RageTapDetector {
31+
private _recentTaps: RecentTap[] = [];
32+
private _enabled: boolean;
33+
private _threshold: number;
34+
private _timeWindow: number;
35+
36+
public constructor(options?: Partial<RageTapDetectorOptions>) {
37+
this._enabled = options?.enabled ?? true;
38+
this._threshold = options?.threshold ?? DEFAULT_RAGE_TAP_THRESHOLD;
39+
this._timeWindow = options?.timeWindow ?? DEFAULT_RAGE_TAP_TIME_WINDOW;
40+
}
41+
42+
/**
43+
* Call after each touch event. If a rage tap is detected, a `ui.frustration`
44+
* breadcrumb is emitted automatically.
45+
*/
46+
public check(touchPath: TouchedComponentInfo[], label?: string): void {
47+
if (!this._enabled) {
48+
return;
49+
}
50+
51+
const root = touchPath[0];
52+
if (!root) {
53+
return;
54+
}
55+
56+
const identity = getTapIdentity(root, label);
57+
const now = Date.now();
58+
const rageTapCount = this._detect(identity, now);
59+
60+
if (rageTapCount > 0) {
61+
const detail = label ? label : `${root.name}${root.file ? ` (${root.file})` : ''}`;
62+
addBreadcrumb({
63+
category: 'ui.frustration',
64+
data: {
65+
type: 'rage_tap',
66+
tapCount: rageTapCount,
67+
path: touchPath,
68+
label,
69+
},
70+
level: 'warning' as SeverityLevel,
71+
message: `Rage tap detected on: ${detail}`,
72+
type: 'user',
73+
});
74+
75+
debug.log(`[TouchEvents] Rage tap detected: ${rageTapCount} taps on ${detail}`);
76+
}
77+
}
78+
79+
/**
80+
* Returns the tap count if rage tap is detected, 0 otherwise.
81+
*/
82+
private _detect(identity: string, now: number): number {
83+
this._recentTaps.push({ identity, timestamp: now });
84+
85+
// Keep buffer bounded
86+
if (this._recentTaps.length > MAX_RECENT_TAPS) {
87+
this._recentTaps = this._recentTaps.slice(-MAX_RECENT_TAPS);
88+
}
89+
90+
// Prune taps outside the time window
91+
const cutoff = now - this._timeWindow;
92+
this._recentTaps = this._recentTaps.filter(tap => tap.timestamp >= cutoff);
93+
94+
// Count consecutive taps on the same target (from the end)
95+
let count = 0;
96+
for (let i = this._recentTaps.length - 1; i >= 0; i--) {
97+
if (this._recentTaps[i]?.identity === identity) {
98+
count++;
99+
} else {
100+
break;
101+
}
102+
}
103+
104+
if (count >= this._threshold) {
105+
this._recentTaps = [];
106+
return count;
107+
}
108+
109+
return 0;
110+
}
111+
}
112+
113+
function getTapIdentity(root: TouchedComponentInfo, label?: string): string {
114+
if (label) {
115+
return `label:${label}`;
116+
}
117+
return `name:${root.name ?? ''}|file:${root.file ?? ''}`;
118+
}

packages/core/src/js/touchevents.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as React from 'react';
66
import { StyleSheet, View } from 'react-native';
77

88
import { createIntegration } from './integrations/factory';
9+
import { RageTapDetector } from './ragetap';
910
import { startUserInteractionSpan } from './tracing/integrations/userInteraction';
1011
import { UI_ACTION_TOUCH } from './tracing/ops';
1112
import { SPAN_ORIGIN_AUTO_INTERACTION } from './tracing/origin';
@@ -48,6 +49,25 @@ export type TouchEventBoundaryProps = {
4849
* @experimental This API is experimental and may change in future releases.
4950
*/
5051
spanAttributes?: Record<string, SpanAttributeValue>;
52+
/**
53+
* Enable rage tap detection. When enabled, rapid consecutive taps on the
54+
* same element are detected and emitted as `ui.frustration` breadcrumbs.
55+
*
56+
* @default true
57+
*/
58+
enableRageTapDetection?: boolean;
59+
/**
60+
* Number of taps within the time window to trigger a rage tap.
61+
*
62+
* @default 3
63+
*/
64+
rageTapThreshold?: number;
65+
/**
66+
* Time window in milliseconds for rage tap detection.
67+
*
68+
* @default 1000
69+
*/
70+
rageTapTimeWindow?: number;
5171
};
5272

5373
const touchEventStyles = StyleSheet.create({
@@ -96,10 +116,24 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
96116
breadcrumbType: DEFAULT_BREADCRUMB_TYPE,
97117
ignoreNames: [],
98118
maxComponentTreeSize: DEFAULT_MAX_COMPONENT_TREE_SIZE,
119+
enableRageTapDetection: true,
120+
rageTapThreshold: 3,
121+
rageTapTimeWindow: 1000,
99122
};
100123

101124
public readonly name: string = 'TouchEventBoundary';
102125

126+
private _rageTapDetector: RageTapDetector;
127+
128+
public constructor(props: TouchEventBoundaryProps) {
129+
super(props);
130+
this._rageTapDetector = new RageTapDetector({
131+
enabled: props.enableRageTapDetection,
132+
threshold: props.rageTapThreshold,
133+
timeWindow: props.rageTapTimeWindow,
134+
});
135+
}
136+
103137
/**
104138
* Registers the TouchEventBoundary as a Sentry Integration.
105139
*/
@@ -203,6 +237,7 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
203237
const label = touchPath.find(info => info.label)?.label;
204238
if (touchPath.length > 0) {
205239
this._logTouchEvent(touchPath, label);
240+
this._rageTapDetector.check(touchPath, label);
206241
}
207242

208243
const span = startUserInteractionSpan({

0 commit comments

Comments
 (0)