Skip to content

Commit 9b6b2ba

Browse files
authored
feat(core): Add rage tap detection with ui.frustration breadcrumbs (#5992)
* 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. * test(core): Add tests for rage tap detection and replay converters - New ragetap.test.ts with 10 unit tests for RageTapDetector: threshold detection, different targets, time window expiry, buffer reset, disabled mode, custom threshold/timeWindow, component name+file identity, empty path, and consecutive rage tap triggers. - 3 integration tests in touchevents.test.tsx verifying TouchEventBoundary wires the detector correctly: end-to-end detection, disabled prop, and custom threshold/timeWindow props. - Android converter test (Kotlin) and iOS converter test (Swift) for the ui.frustration breadcrumb category in RNSentryReplayBreadcrumbConverter. * docs(changelog): Add entry for rage tap detection (#5992) * fix(core): Address review feedback for rage tap detection - Fix false-positive detection: reset tap buffer when target changes instead of relying on time-window pruning, which could make non-consecutive taps appear consecutive after interleaved taps aged out (Medium severity, reported by Sentry bugbot). - Add null check for breadcrumb data in Android convertFrustrationBreadcrumb, matching the iOS implementation that already guards against nil data (Low severity). - Remove hardcoded MAX_RECENT_TAPS buffer limit that would silently break detection for thresholds > 10. The buffer is now naturally bounded by target-change resets and time-window pruning. - Deduplicate TouchedComponentInfo: export from ragetap.ts and import in touchevents.tsx instead of maintaining identical interfaces in both files. - Read rage tap props at event time via updateOptions() instead of freezing them in the constructor, consistent with how all other TouchEventBoundary props are consumed. * refactor(core): Align rage tap detection with ui.multiClick convention Rename breadcrumb category from ui.frustration to ui.multiClick and reshape the data payload to match the web JS SDK's rage click format, so the Sentry replay timeline renders rage taps with the fire icon and 'Rage Click' label automatically. Changes to the breadcrumb shape: - category: ui.frustration → ui.multiClick - type: user → default - data.tapCount → data.clickCount - data.type (rage_tap) removed - data.metric: true added (marks as metric event) - data.route added (current screen from navigation tracing) - data.node added with DOM-compatible shape: tagName, textContent, attributes (data-sentry-component, data-sentry-source-file, sentry-label) — this allows the existing stringifyNodeAttributes in the Sentry frontend to render component names for mobile taps. Native replay converters updated on both Android and iOS to handle ui.multiClick instead of ui.frustration. * fix(core): Include component name in tap identity to prevent false positives When distinct child elements share a labeled ancestor, the tap identity was based solely on the parent label, causing false rage tap detection when tapping different controls in quick succession. Now the identity always includes the root component name and file, even when a label is present (e.g. label:form|name:SubmitButton|file:form.tsx). * fix(core): Address latest review feedback - iOS: Add NSArray type check on path data in convertMultiClick to prevent runtime crash from unrecognized selector on non-array values (HIGH, Sentry bot). - Clear tap buffer when detection is disabled via updateOptions to prevent stale taps from causing false positives on re-enable (LOW, Sentry bot). - Move changelog entry from released 8.8.0 section to Unreleased (danger bot). - Add time window integration test to touchevents that varies timestamps between taps, verifying rageTapTimeWindow actually excludes old taps (sentry-warden). * fix(core): Address antonis review feedback - Drop level: 'warning' from ui.multiClick breadcrumb to match the web JS SDK which defaults to info. - Export DEFAULT_RAGE_TAP_THRESHOLD and DEFAULT_RAGE_TAP_TIME_WINDOW from ragetap.ts and import them in touchevents.tsx defaultProps for a single source of truth. - Initialize RageTapDetector with props in the constructor and sync via componentDidUpdate, instead of calling updateOptions on every tap event. - Remove incorrect @testonly annotation from Android convertMultiClickBreadcrumb since it is called from convert() in production code. - Add comment explaining id: 0 placeholder in the node object (mobile replays don't have rrweb node IDs). - Add tests for updateOptions: buffer cleared on disable, and threshold change applies immediately. - Run yarn fix for import ordering lint. * fix(android): Check path is a List in convertMultiClickBreadcrumb Align with the iOS converter which validates the path type before use. Prevents potential ClassCastException if a non-list value is passed. * docs(changelog): Move rage tap entry to Unreleased section
1 parent c0a7ee7 commit 9b6b2ba

9 files changed

Lines changed: 702 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
### Features
1212

1313
- Add `includeWebFeedback` Metro config option to exclude `@sentry-internal/feedback` from the bundle ([#6025](https://github.com/getsentry/sentry-react-native/pull/6025))
14+
- Add rage tap detection — rapid consecutive taps on the same element emit `ui.multiClick` breadcrumbs and appear on the replay timeline with the rage click icon ([#5992](https://github.com/getsentry/sentry-react-native/pull/5992))
1415

1516
### Fixes
1617

packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,34 @@ class RNSentryReplayBreadcrumbConverterTest {
9090
assertEquals(null, actual)
9191
}
9292

93+
@Test
94+
fun convertMultiClickBreadcrumb() {
95+
val converter = RNSentryReplayBreadcrumbConverter()
96+
val testBreadcrumb = Breadcrumb()
97+
testBreadcrumb.level = SentryLevel.WARNING
98+
testBreadcrumb.type = "default"
99+
testBreadcrumb.category = "ui.multiClick"
100+
testBreadcrumb.message = "Submit"
101+
testBreadcrumb.setData(
102+
"path",
103+
arrayListOf(
104+
mapOf(
105+
"name" to "SubmitButton",
106+
"label" to "Submit",
107+
"file" to "form.tsx",
108+
),
109+
),
110+
)
111+
testBreadcrumb.setData("clickCount", 3.0)
112+
testBreadcrumb.setData("metric", true)
113+
val actual = converter.convert(testBreadcrumb) as RRWebBreadcrumbEvent
114+
115+
assertRRWebBreadcrumbDefaults(actual)
116+
assertEquals(SentryLevel.WARNING, actual.level)
117+
assertEquals("ui.multiClick", actual.category)
118+
assertEquals("Submit(form.tsx)", actual.message)
119+
}
120+
93121
@Test
94122
fun convertTouchBreadcrumb() {
95123
val converter = RNSentryReplayBreadcrumbConverter()

packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,33 @@ final class RNSentryReplayBreadcrumbConverterTests: XCTestCase {
100100
XCTAssertNil(actual)
101101
}
102102

103+
func testConvertMultiClickBreadcrumb() {
104+
let converter = RNSentryReplayBreadcrumbConverter()
105+
let testBreadcrumb = Breadcrumb()
106+
testBreadcrumb.timestamp = Date()
107+
testBreadcrumb.level = .warning
108+
testBreadcrumb.type = "default"
109+
testBreadcrumb.category = "ui.multiClick"
110+
testBreadcrumb.message = "Submit"
111+
testBreadcrumb.data = [
112+
"path": [
113+
["name": "SubmitButton", "label": "Submit", "file": "form.tsx"]
114+
],
115+
"clickCount": 3,
116+
"metric": true
117+
]
118+
let actual = converter.convert(from: testBreadcrumb)
119+
120+
XCTAssertNotNil(actual)
121+
let event = actual!.serialize()
122+
let data = event["data"] as! [String: Any?]
123+
let payload = data["payload"] as! [String: Any?]
124+
assertRRWebBreadcrumbDefaults(actual: event)
125+
XCTAssertEqual("warning", payload["level"] as! String)
126+
XCTAssertEqual("ui.multiClick", payload["category"] as! String)
127+
XCTAssertEqual("Submit(form.tsx)", payload["message"] as! String)
128+
}
129+
103130
func testConvertTouchBreadcrumb() {
104131
let converter = RNSentryReplayBreadcrumbConverter()
105132
let testBreadcrumb = Breadcrumb()

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

Lines changed: 18 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.multiClick".equals(breadcrumb.getCategory())) {
36+
return convertMultiClickBreadcrumb(breadcrumb);
37+
}
3538
if ("navigation".equals(breadcrumb.getCategory())) {
3639
return convertNavigationBreadcrumb(breadcrumb);
3740
}
@@ -72,6 +75,21 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc
7275
return rrWebBreadcrumb;
7376
}
7477

78+
public @Nullable RRWebEvent convertMultiClickBreadcrumb(final @NotNull Breadcrumb breadcrumb) {
79+
if (!(breadcrumb.getData("path") instanceof List)) {
80+
return null;
81+
}
82+
83+
final RRWebBreadcrumbEvent rrWebBreadcrumb = new RRWebBreadcrumbEvent();
84+
85+
rrWebBreadcrumb.setCategory("ui.multiClick");
86+
87+
rrWebBreadcrumb.setMessage(getTouchPathMessage(breadcrumb.getData("path")));
88+
89+
setRRWebEventDefaultsFrom(rrWebBreadcrumb, breadcrumb);
90+
return rrWebBreadcrumb;
91+
}
92+
7593
@TestOnly
7694
public static @Nullable String getTouchPathMessage(final @Nullable Object maybePath) {
7795
if (!(maybePath instanceof List)) {

packages/core/ios/RNSentryReplayBreadcrumbConverter.m

Lines changed: 24 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.multiClick"]) {
39+
return [self convertMultiClick:breadcrumb];
40+
}
41+
3842
if ([breadcrumb.category isEqualToString:@"navigation"]) {
3943
return [SentrySessionReplayHybridSDK createBreadcrumbwithTimestamp:breadcrumb.timestamp
4044
category:breadcrumb.category
@@ -75,6 +79,26 @@ - (instancetype _Nonnull)init
7579
data:breadcrumb.data];
7680
}
7781

82+
- (id<SentryRRWebEvent> _Nullable)convertMultiClick:(SentryBreadcrumb *_Nonnull)breadcrumb
83+
{
84+
if (breadcrumb.data == nil) {
85+
return nil;
86+
}
87+
88+
id maybePath = [breadcrumb.data valueForKey:@"path"];
89+
if (![maybePath isKindOfClass:[NSArray class]]) {
90+
return nil;
91+
}
92+
93+
NSString *message = [RNSentryReplayBreadcrumbConverter getTouchPathMessageFrom:maybePath];
94+
95+
return [SentrySessionReplayHybridSDK createBreadcrumbwithTimestamp:breadcrumb.timestamp
96+
category:@"ui.multiClick"
97+
message:message
98+
level:breadcrumb.level
99+
data:breadcrumb.data];
100+
}
101+
78102
+ (NSString *_Nullable)getTouchPathMessageFrom:(NSArray *_Nullable)path
79103
{
80104
if (path == nil) {

packages/core/src/js/ragetap.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { addBreadcrumb, debug } from '@sentry/core';
2+
3+
import { getCurrentReactNativeTracingIntegration } from './tracing/reactnativetracing';
4+
5+
export const DEFAULT_RAGE_TAP_THRESHOLD = 3;
6+
export const DEFAULT_RAGE_TAP_TIME_WINDOW = 1000;
7+
8+
export interface TouchedComponentInfo {
9+
name?: string;
10+
label?: string;
11+
element?: string;
12+
file?: string;
13+
}
14+
15+
export interface RageTapDetectorOptions {
16+
enabled: boolean;
17+
threshold: number;
18+
timeWindow: number;
19+
}
20+
21+
interface RecentTap {
22+
identity: string;
23+
timestamp: number;
24+
}
25+
26+
/**
27+
* Detects rage taps (repeated rapid taps on the same target) and emits
28+
* `ui.multiClick` breadcrumbs when the threshold is hit.
29+
*
30+
* Uses the same breadcrumb category and data shape as the web JS SDK's
31+
* rage click detection so the Sentry replay timeline renders the fire
32+
* icon and "Rage Click" label automatically.
33+
*/
34+
export class RageTapDetector {
35+
private _recentTaps: RecentTap[] = [];
36+
private _enabled: boolean;
37+
private _threshold: number;
38+
private _timeWindow: number;
39+
40+
public constructor(options?: Partial<RageTapDetectorOptions>) {
41+
this._enabled = options?.enabled ?? true;
42+
this._threshold = options?.threshold ?? DEFAULT_RAGE_TAP_THRESHOLD;
43+
this._timeWindow = options?.timeWindow ?? DEFAULT_RAGE_TAP_TIME_WINDOW;
44+
}
45+
46+
/**
47+
* Update options at runtime (e.g. when React props change).
48+
*/
49+
public updateOptions(options: Partial<RageTapDetectorOptions>): void {
50+
if (options.enabled !== undefined) {
51+
this._enabled = options.enabled;
52+
if (!this._enabled) {
53+
this._recentTaps = [];
54+
}
55+
}
56+
if (options.threshold !== undefined) {
57+
this._threshold = options.threshold;
58+
}
59+
if (options.timeWindow !== undefined) {
60+
this._timeWindow = options.timeWindow;
61+
}
62+
}
63+
64+
/**
65+
* Call after each touch event. If a rage tap is detected, a `ui.multiClick`
66+
* breadcrumb is emitted automatically.
67+
*/
68+
public check(touchPath: TouchedComponentInfo[], label?: string): void {
69+
if (!this._enabled) {
70+
return;
71+
}
72+
73+
const root = touchPath[0];
74+
if (!root) {
75+
return;
76+
}
77+
78+
const identity = getTapIdentity(root, label);
79+
const now = Date.now();
80+
const tapCount = this._detect(identity, now);
81+
82+
if (tapCount > 0) {
83+
const message = buildTouchMessage(root, label);
84+
const node = buildNodeFromTouchPath(root, label);
85+
86+
addBreadcrumb({
87+
category: 'ui.multiClick',
88+
type: 'default',
89+
message,
90+
data: {
91+
clickCount: tapCount,
92+
metric: true,
93+
route: getCurrentRoute(),
94+
node,
95+
path: touchPath,
96+
},
97+
});
98+
99+
debug.log(`[TouchEvents] Rage tap detected: ${tapCount} taps on ${message}`);
100+
}
101+
}
102+
103+
/**
104+
* Returns the tap count if rage tap is detected, 0 otherwise.
105+
*/
106+
private _detect(identity: string, now: number): number {
107+
// If the target changed, reset the buffer — only truly consecutive
108+
// taps on the same target count. This prevents false positives where
109+
// time-window pruning removes interleaved taps on other targets.
110+
const lastTap = this._recentTaps[this._recentTaps.length - 1];
111+
if (lastTap && lastTap.identity !== identity) {
112+
this._recentTaps = [];
113+
}
114+
115+
this._recentTaps.push({ identity, timestamp: now });
116+
117+
// Prune taps outside the time window
118+
const cutoff = now - this._timeWindow;
119+
this._recentTaps = this._recentTaps.filter(tap => tap.timestamp >= cutoff);
120+
121+
if (this._recentTaps.length >= this._threshold) {
122+
const count = this._recentTaps.length;
123+
this._recentTaps = [];
124+
return count;
125+
}
126+
127+
return 0;
128+
}
129+
}
130+
131+
function getTapIdentity(root: TouchedComponentInfo, label?: string): string {
132+
const base = `name:${root.name ?? ''}|file:${root.file ?? ''}`;
133+
if (label) {
134+
return `label:${label}|${base}`;
135+
}
136+
return base;
137+
}
138+
139+
/**
140+
* Build a human-readable message matching the touch breadcrumb format.
141+
*/
142+
function buildTouchMessage(root: TouchedComponentInfo, label?: string): string {
143+
if (label) {
144+
return label;
145+
}
146+
return `${root.name}${root.file ? ` (${root.file})` : ''}`;
147+
}
148+
149+
/**
150+
* Build a node object compatible with the web SDK's `ReplayBaseDomFrameData`
151+
* so that `stringifyNodeAttributes` in the Sentry frontend can render it.
152+
*
153+
* Maps the React Native component info to the DOM-like shape:
154+
* - `tagName` → element type (e.g. "RCTView") or component name
155+
* - `attributes['data-sentry-component']` → component name from babel plugin
156+
* - `attributes['data-sentry-source-file']` → source file
157+
*/
158+
function buildNodeFromTouchPath(
159+
root: TouchedComponentInfo,
160+
label?: string,
161+
): { id: number; tagName: string; textContent: string; attributes: Record<string, string> } {
162+
const attributes: Record<string, string> = {};
163+
164+
if (root.name) {
165+
attributes['data-sentry-component'] = root.name;
166+
}
167+
if (root.file) {
168+
attributes['data-sentry-source-file'] = root.file;
169+
}
170+
if (label) {
171+
attributes['sentry-label'] = label;
172+
}
173+
174+
return {
175+
// Mobile replays don't have rrweb node IDs — 0 is a placeholder
176+
// to satisfy the ReplayBaseDomFrameData shape expected by the frontend.
177+
id: 0,
178+
tagName: root.element ?? root.name ?? 'unknown',
179+
textContent: '',
180+
attributes,
181+
};
182+
}
183+
184+
function getCurrentRoute(): string | undefined {
185+
try {
186+
return getCurrentReactNativeTracingIntegration()?.state.currentRoute;
187+
} catch {
188+
return undefined;
189+
}
190+
}

0 commit comments

Comments
 (0)