From 7d060106e9ab81a591fc8a11f53de288f5493460 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 14 Apr 2026 10:38:44 +0200 Subject: [PATCH 1/7] 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. --- .../RNSentryReplayBreadcrumbConverter.java | 15 +++ .../ios/RNSentryReplayBreadcrumbConverter.m | 20 +++ packages/core/src/js/ragetap.ts | 118 ++++++++++++++++++ packages/core/src/js/touchevents.tsx | 35 ++++++ 4 files changed, 188 insertions(+) create mode 100644 packages/core/src/js/ragetap.ts 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({ From 89d1e9d1145f8d0f4e8a6c2df54b7391aafdbf38 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 16 Apr 2026 11:25:40 +0200 Subject: [PATCH 2/7] 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. --- .../RNSentryReplayBreadcrumbConverterTest.kt | 28 +++ ...SentryReplayBreadcrumbConverterTests.swift | 27 +++ packages/core/test/ragetap.test.ts | 187 ++++++++++++++++++ packages/core/test/touchevents.test.tsx | 109 ++++++++++ 4 files changed, 351 insertions(+) create mode 100644 packages/core/test/ragetap.test.ts diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt index d05d50655b..f722bab8ff 100644 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt @@ -90,6 +90,34 @@ class RNSentryReplayBreadcrumbConverterTest { assertEquals(null, actual) } + @Test + fun convertFrustrationBreadcrumb() { + val converter = RNSentryReplayBreadcrumbConverter() + val testBreadcrumb = Breadcrumb() + testBreadcrumb.level = SentryLevel.WARNING + testBreadcrumb.type = "user" + testBreadcrumb.category = "ui.frustration" + testBreadcrumb.message = "Rage tap detected on: Submit" + testBreadcrumb.setData( + "path", + arrayListOf( + mapOf( + "name" to "SubmitButton", + "label" to "Submit", + "file" to "form.tsx", + ), + ), + ) + testBreadcrumb.setData("type", "rage_tap") + testBreadcrumb.setData("tapCount", 3.0) + val actual = converter.convert(testBreadcrumb) as RRWebBreadcrumbEvent + + assertRRWebBreadcrumbDefaults(actual) + assertEquals(SentryLevel.WARNING, actual.level) + assertEquals("ui.frustration", actual.category) + assertEquals("Submit(form.tsx)", actual.message) + } + @Test fun convertTouchBreadcrumb() { val converter = RNSentryReplayBreadcrumbConverter() diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift index 00e543aad5..23b6636444 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift @@ -100,6 +100,33 @@ final class RNSentryReplayBreadcrumbConverterTests: XCTestCase { XCTAssertNil(actual) } + func testConvertFrustrationBreadcrumb() { + let converter = RNSentryReplayBreadcrumbConverter() + let testBreadcrumb = Breadcrumb() + testBreadcrumb.timestamp = Date() + testBreadcrumb.level = .warning + testBreadcrumb.type = "user" + testBreadcrumb.category = "ui.frustration" + testBreadcrumb.message = "Rage tap detected on: Submit" + testBreadcrumb.data = [ + "path": [ + ["name": "SubmitButton", "label": "Submit", "file": "form.tsx"] + ], + "type": "rage_tap", + "tapCount": 3 + ] + let actual = converter.convert(from: testBreadcrumb) + + XCTAssertNotNil(actual) + let event = actual!.serialize() + let data = event["data"] as! [String: Any?] + let payload = data["payload"] as! [String: Any?] + assertRRWebBreadcrumbDefaults(actual: event) + XCTAssertEqual("warning", payload["level"] as! String) + XCTAssertEqual("ui.frustration", payload["category"] as! String) + XCTAssertEqual("Submit(form.tsx)", payload["message"] as! String) + } + func testConvertTouchBreadcrumb() { let converter = RNSentryReplayBreadcrumbConverter() let testBreadcrumb = Breadcrumb() diff --git a/packages/core/test/ragetap.test.ts b/packages/core/test/ragetap.test.ts new file mode 100644 index 0000000000..e9ce57be79 --- /dev/null +++ b/packages/core/test/ragetap.test.ts @@ -0,0 +1,187 @@ +import * as core from '@sentry/core'; + +import { RageTapDetector } from '../src/js/ragetap'; + +describe('RageTapDetector', () => { + let addBreadcrumb: jest.SpyInstance; + + beforeEach(() => { + jest.resetAllMocks(); + addBreadcrumb = jest.spyOn(core, 'addBreadcrumb'); + jest.spyOn(Date, 'now').mockReturnValue(1000); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('emits ui.frustration breadcrumb after 3 taps on same label', () => { + const detector = new RageTapDetector(); + const path = [{ name: 'Button', label: 'submit' }]; + + detector.check(path, 'submit'); + detector.check(path, 'submit'); + detector.check(path, 'submit'); + + expect(addBreadcrumb).toHaveBeenCalledTimes(1); + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'ui.frustration', + level: 'warning', + message: 'Rage tap detected on: submit', + type: 'user', + data: expect.objectContaining({ + type: 'rage_tap', + tapCount: 3, + label: 'submit', + path, + }), + }), + ); + }); + + it('does not emit for taps on different targets', () => { + const detector = new RageTapDetector(); + + detector.check([{ name: 'A', label: 'a' }], 'a'); + detector.check([{ name: 'B', label: 'b' }], 'b'); + detector.check([{ name: 'A', label: 'a' }], 'a'); + + expect(addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('does not emit when taps are outside the time window', () => { + const detector = new RageTapDetector(); + const path = [{ name: 'Button', label: 'ok' }]; + const nowMock = jest.spyOn(Date, 'now'); + + nowMock.mockReturnValue(1000); + detector.check(path, 'ok'); + + nowMock.mockReturnValue(1500); + detector.check(path, 'ok'); + + // Third tap is beyond 1000ms from the first + nowMock.mockReturnValue(2500); + detector.check(path, 'ok'); + + expect(addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('resets buffer after rage tap is detected', () => { + const detector = new RageTapDetector(); + const path = [{ name: 'Button', label: 'ok' }]; + + // Trigger rage tap + detector.check(path, 'ok'); + detector.check(path, 'ok'); + detector.check(path, 'ok'); + expect(addBreadcrumb).toHaveBeenCalledTimes(1); + + // Two more taps should NOT re-trigger (buffer was reset) + detector.check(path, 'ok'); + detector.check(path, 'ok'); + expect(addBreadcrumb).toHaveBeenCalledTimes(1); + }); + + it('does nothing when disabled', () => { + const detector = new RageTapDetector({ enabled: false }); + const path = [{ name: 'Button', label: 'ok' }]; + + detector.check(path, 'ok'); + detector.check(path, 'ok'); + detector.check(path, 'ok'); + + expect(addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('respects custom threshold', () => { + const detector = new RageTapDetector({ threshold: 5 }); + const path = [{ name: 'Button', label: 'ok' }]; + + for (let i = 0; i < 4; i++) { + detector.check(path, 'ok'); + } + expect(addBreadcrumb).not.toHaveBeenCalled(); + + detector.check(path, 'ok'); + expect(addBreadcrumb).toHaveBeenCalledTimes(1); + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ tapCount: 5 }), + }), + ); + }); + + it('respects custom time window', () => { + const detector = new RageTapDetector({ timeWindow: 500 }); + const path = [{ name: 'Button', label: 'ok' }]; + const nowMock = jest.spyOn(Date, 'now'); + + nowMock.mockReturnValue(1000); + detector.check(path, 'ok'); + + nowMock.mockReturnValue(1200); + detector.check(path, 'ok'); + + // 600ms after first tap — outside 500ms window + nowMock.mockReturnValue(1600); + detector.check(path, 'ok'); + + expect(addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('uses component name+file as identity when no label', () => { + const detector = new RageTapDetector(); + const path = [{ name: 'SubmitButton', file: 'form.tsx' }]; + + detector.check(path); + detector.check(path); + detector.check(path); + + expect(addBreadcrumb).toHaveBeenCalledTimes(1); + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Rage tap detected on: SubmitButton (form.tsx)', + data: expect.objectContaining({ label: undefined }), + }), + ); + }); + + it('treats same name but different files as different targets', () => { + const detector = new RageTapDetector(); + + detector.check([{ name: 'Button', file: 'a.tsx' }]); + detector.check([{ name: 'Button', file: 'b.tsx' }]); + detector.check([{ name: 'Button', file: 'a.tsx' }]); + + expect(addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('does nothing for empty touch path', () => { + const detector = new RageTapDetector(); + + detector.check([]); + detector.check([]); + detector.check([]); + + expect(addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('can trigger again after buffer reset and enough new taps', () => { + const detector = new RageTapDetector(); + const path = [{ name: 'Button', label: 'ok' }]; + + // First rage tap + detector.check(path, 'ok'); + detector.check(path, 'ok'); + detector.check(path, 'ok'); + expect(addBreadcrumb).toHaveBeenCalledTimes(1); + + // Three more taps → second rage tap + detector.check(path, 'ok'); + detector.check(path, 'ok'); + detector.check(path, 'ok'); + expect(addBreadcrumb).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/core/test/touchevents.test.tsx b/packages/core/test/touchevents.test.tsx index d446457272..6b9c483bc5 100644 --- a/packages/core/test/touchevents.test.tsx +++ b/packages/core/test/touchevents.test.tsx @@ -314,6 +314,115 @@ describe('TouchEventBoundary._onTouchStart', () => { }); }); + describe('rage tap detection', () => { + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(1000); + }); + + it('emits ui.frustration breadcrumb after 3 taps on same target', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + + const event = { + _targetInst: { + elementType: { displayName: 'Button' }, + memoizedProps: { 'sentry-label': 'submit' }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + // 3 touch breadcrumbs + 1 frustration breadcrumb + expect(addBreadcrumb).toHaveBeenCalledTimes(4); + expect(addBreadcrumb).toHaveBeenLastCalledWith( + expect.objectContaining({ + category: 'ui.frustration', + level: 'warning', + message: 'Rage tap detected on: submit', + type: 'user', + data: expect.objectContaining({ + type: 'rage_tap', + tapCount: 3, + label: 'submit', + }), + }), + ); + }); + + it('does not emit frustration breadcrumb when disabled via prop', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary({ + ...defaultProps, + enableRageTapDetection: false, + }); + + const event = { + _targetInst: { + elementType: { displayName: 'Button' }, + memoizedProps: { 'sentry-label': 'submit' }, + }, + }; + + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + // Only touch breadcrumbs + expect(addBreadcrumb).toHaveBeenCalledTimes(3); + for (const call of addBreadcrumb.mock.calls) { + expect(call[0].category).toBe('touch'); + } + }); + + it('respects custom threshold and time window props', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary({ + ...defaultProps, + rageTapThreshold: 5, + rageTapTimeWindow: 2000, + }); + + const event = { + _targetInst: { + elementType: { displayName: 'Button' }, + memoizedProps: { 'sentry-label': 'submit' }, + }, + }; + + // 3 taps should not trigger with threshold=5 + for (let i = 0; i < 3; i++) { + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + } + expect(addBreadcrumb).toHaveBeenCalledTimes(3); + for (const call of addBreadcrumb.mock.calls) { + expect(call[0].category).toBe('touch'); + } + + // 2 more taps (total 5) should trigger + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + expect(addBreadcrumb).toHaveBeenCalledTimes(6); // 5 touch + 1 frustration + expect(addBreadcrumb).toHaveBeenLastCalledWith( + expect.objectContaining({ + category: 'ui.frustration', + data: expect.objectContaining({ tapCount: 5 }), + }), + ); + }); + }); + describe('sentry-span-attributes', () => { it('sets custom attributes from prop on user interaction span', () => { const { defaultProps } = TouchEventBoundary; From 12862acf3f18afa3f97439b8079728c14adf8d53 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 16 Apr 2026 12:09:40 +0200 Subject: [PATCH 3/7] docs(changelog): Add entry for rage tap detection (#5992) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12b080bd44..0d55a8d735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Add `attachAllThreads` option to attach full stack traces for all threads to captured events on iOS ([#5960](https://github.com/getsentry/sentry-react-native/issues/5960)) - Add `deeplinkIntegration` for automatic deep link breadcrumbs ([#5983](https://github.com/getsentry/sentry-react-native/pull/5983)) - Name navigation spans using dispatched action payload when `useDispatchedActionData` is enabled ([#5982](https://github.com/getsentry/sentry-react-native/pull/5982)) +- Add rage tap detection — rapid consecutive taps on the same element emit `ui.frustration` breadcrumbs and appear on the replay timeline ([#5992](https://github.com/getsentry/sentry-react-native/pull/5992)) ### Fixes From 65fe11463ef8270592c6daba4a2b9025d1c43fb5 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 16 Apr 2026 13:38:41 +0200 Subject: [PATCH 4/7] 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. --- .../RNSentryReplayBreadcrumbConverter.java | 6 ++- packages/core/src/js/ragetap.ts | 52 +++++++++++-------- packages/core/src/js/touchevents.tsx | 24 +++------ 3 files changed, 42 insertions(+), 40 deletions(-) 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 a69a892a14..5a659a7d37 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 @@ -76,7 +76,11 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc } @TestOnly - public @NotNull RRWebEvent convertFrustrationBreadcrumb(final @NotNull Breadcrumb breadcrumb) { + public @Nullable RRWebEvent convertFrustrationBreadcrumb(final @NotNull Breadcrumb breadcrumb) { + if (breadcrumb.getData("path") == null) { + return null; + } + final RRWebBreadcrumbEvent rrWebBreadcrumb = new RRWebBreadcrumbEvent(); rrWebBreadcrumb.setCategory("ui.frustration"); diff --git a/packages/core/src/js/ragetap.ts b/packages/core/src/js/ragetap.ts index 8f23e59da6..8158e02230 100644 --- a/packages/core/src/js/ragetap.ts +++ b/packages/core/src/js/ragetap.ts @@ -3,12 +3,6 @@ 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; @@ -23,6 +17,11 @@ export interface RageTapDetectorOptions { timeWindow: number; } +interface RecentTap { + identity: string; + timestamp: number; +} + /** * Detects rage taps (repeated rapid taps on the same target) and emits * `ui.frustration` breadcrumbs when the threshold is hit. @@ -39,6 +38,21 @@ export class RageTapDetector { this._timeWindow = options?.timeWindow ?? DEFAULT_RAGE_TAP_TIME_WINDOW; } + /** + * Update options at runtime (e.g. when React props change). + */ + public updateOptions(options: Partial): void { + if (options.enabled !== undefined) { + this._enabled = options.enabled; + } + if (options.threshold !== undefined) { + this._threshold = options.threshold; + } + if (options.timeWindow !== undefined) { + this._timeWindow = options.timeWindow; + } + } + /** * Call after each touch event. If a rage tap is detected, a `ui.frustration` * breadcrumb is emitted automatically. @@ -80,28 +94,22 @@ export class RageTapDetector { * 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); + // If the target changed, reset the buffer — only truly consecutive + // taps on the same target count. This prevents false positives where + // time-window pruning removes interleaved taps on other targets. + const lastTap = this._recentTaps[this._recentTaps.length - 1]; + if (lastTap && lastTap.identity !== identity) { + this._recentTaps = []; } + this._recentTaps.push({ identity, timestamp: now }); + // 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) { + if (this._recentTaps.length >= this._threshold) { + const count = this._recentTaps.length; this._recentTaps = []; return count; } diff --git a/packages/core/src/js/touchevents.tsx b/packages/core/src/js/touchevents.tsx index e66a74d4dd..c6ec9c1af0 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 type { TouchedComponentInfo } from './ragetap'; import { RageTapDetector } from './ragetap'; import { startUserInteractionSpan } from './tracing/integrations/userInteraction'; import { UI_ACTION_TOUCH } from './tracing/ops'; @@ -95,13 +96,6 @@ interface ElementInstance { return?: ElementInstance; } -interface TouchedComponentInfo { - name?: string; - label?: string; - element?: string; - file?: string; -} - interface PrivateGestureResponderEvent extends GestureResponderEvent { _targetInst?: ElementInstance; } @@ -123,16 +117,7 @@ class TouchEventBoundary extends React.Component { 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, - }); - } + private _rageTapDetector: RageTapDetector = new RageTapDetector(); /** * Registers the TouchEventBoundary as a Sentry Integration. @@ -237,6 +222,11 @@ class TouchEventBoundary extends React.Component { const label = touchPath.find(info => info.label)?.label; if (touchPath.length > 0) { this._logTouchEvent(touchPath, label); + this._rageTapDetector.updateOptions({ + enabled: this.props.enableRageTapDetection, + threshold: this.props.rageTapThreshold, + timeWindow: this.props.rageTapTimeWindow, + }); this._rageTapDetector.check(touchPath, label); } From b43a3e8abe51c8999344b38cf9abedf29430ae1d Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 20 Apr 2026 14:08:57 +0200 Subject: [PATCH 5/7] refactor(core): Align rage tap detection with ui.multiClick convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 2 +- .../RNSentryReplayBreadcrumbConverterTest.kt | 14 +-- ...SentryReplayBreadcrumbConverterTests.swift | 14 +-- .../RNSentryReplayBreadcrumbConverter.java | 8 +- .../ios/RNSentryReplayBreadcrumbConverter.m | 8 +- packages/core/src/js/ragetap.ts | 86 ++++++++++++++++--- packages/core/src/js/touchevents.tsx | 2 +- packages/core/test/ragetap.test.ts | 34 +++++--- packages/core/test/touchevents.test.tsx | 20 ++--- 9 files changed, 130 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d55a8d735..eb1c3193dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ - Add `attachAllThreads` option to attach full stack traces for all threads to captured events on iOS ([#5960](https://github.com/getsentry/sentry-react-native/issues/5960)) - Add `deeplinkIntegration` for automatic deep link breadcrumbs ([#5983](https://github.com/getsentry/sentry-react-native/pull/5983)) - Name navigation spans using dispatched action payload when `useDispatchedActionData` is enabled ([#5982](https://github.com/getsentry/sentry-react-native/pull/5982)) -- Add rage tap detection — rapid consecutive taps on the same element emit `ui.frustration` breadcrumbs and appear on the replay timeline ([#5992](https://github.com/getsentry/sentry-react-native/pull/5992)) +- 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)) ### Fixes diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt index f722bab8ff..f49085af0b 100644 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt @@ -91,13 +91,13 @@ class RNSentryReplayBreadcrumbConverterTest { } @Test - fun convertFrustrationBreadcrumb() { + fun convertMultiClickBreadcrumb() { val converter = RNSentryReplayBreadcrumbConverter() val testBreadcrumb = Breadcrumb() testBreadcrumb.level = SentryLevel.WARNING - testBreadcrumb.type = "user" - testBreadcrumb.category = "ui.frustration" - testBreadcrumb.message = "Rage tap detected on: Submit" + testBreadcrumb.type = "default" + testBreadcrumb.category = "ui.multiClick" + testBreadcrumb.message = "Submit" testBreadcrumb.setData( "path", arrayListOf( @@ -108,13 +108,13 @@ class RNSentryReplayBreadcrumbConverterTest { ), ), ) - testBreadcrumb.setData("type", "rage_tap") - testBreadcrumb.setData("tapCount", 3.0) + testBreadcrumb.setData("clickCount", 3.0) + testBreadcrumb.setData("metric", true) val actual = converter.convert(testBreadcrumb) as RRWebBreadcrumbEvent assertRRWebBreadcrumbDefaults(actual) assertEquals(SentryLevel.WARNING, actual.level) - assertEquals("ui.frustration", actual.category) + assertEquals("ui.multiClick", actual.category) assertEquals("Submit(form.tsx)", actual.message) } diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift index 23b6636444..6416b580d6 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift @@ -100,20 +100,20 @@ final class RNSentryReplayBreadcrumbConverterTests: XCTestCase { XCTAssertNil(actual) } - func testConvertFrustrationBreadcrumb() { + func testConvertMultiClickBreadcrumb() { let converter = RNSentryReplayBreadcrumbConverter() let testBreadcrumb = Breadcrumb() testBreadcrumb.timestamp = Date() testBreadcrumb.level = .warning - testBreadcrumb.type = "user" - testBreadcrumb.category = "ui.frustration" - testBreadcrumb.message = "Rage tap detected on: Submit" + testBreadcrumb.type = "default" + testBreadcrumb.category = "ui.multiClick" + testBreadcrumb.message = "Submit" testBreadcrumb.data = [ "path": [ ["name": "SubmitButton", "label": "Submit", "file": "form.tsx"] ], - "type": "rage_tap", - "tapCount": 3 + "clickCount": 3, + "metric": true ] let actual = converter.convert(from: testBreadcrumb) @@ -123,7 +123,7 @@ final class RNSentryReplayBreadcrumbConverterTests: XCTestCase { let payload = data["payload"] as! [String: Any?] assertRRWebBreadcrumbDefaults(actual: event) XCTAssertEqual("warning", payload["level"] as! String) - XCTAssertEqual("ui.frustration", payload["category"] as! String) + XCTAssertEqual("ui.multiClick", payload["category"] as! String) XCTAssertEqual("Submit(form.tsx)", payload["message"] as! String) } 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 5a659a7d37..00146efb8f 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,8 +32,8 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc if ("touch".equals(breadcrumb.getCategory())) { return convertTouchBreadcrumb(breadcrumb); } - if ("ui.frustration".equals(breadcrumb.getCategory())) { - return convertFrustrationBreadcrumb(breadcrumb); + if ("ui.multiClick".equals(breadcrumb.getCategory())) { + return convertMultiClickBreadcrumb(breadcrumb); } if ("navigation".equals(breadcrumb.getCategory())) { return convertNavigationBreadcrumb(breadcrumb); @@ -76,14 +76,14 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc } @TestOnly - public @Nullable RRWebEvent convertFrustrationBreadcrumb(final @NotNull Breadcrumb breadcrumb) { + public @Nullable RRWebEvent convertMultiClickBreadcrumb(final @NotNull Breadcrumb breadcrumb) { if (breadcrumb.getData("path") == null) { return null; } final RRWebBreadcrumbEvent rrWebBreadcrumb = new RRWebBreadcrumbEvent(); - rrWebBreadcrumb.setCategory("ui.frustration"); + rrWebBreadcrumb.setCategory("ui.multiClick"); rrWebBreadcrumb.setMessage(getTouchPathMessage(breadcrumb.getData("path"))); diff --git a/packages/core/ios/RNSentryReplayBreadcrumbConverter.m b/packages/core/ios/RNSentryReplayBreadcrumbConverter.m index 93c26eda51..e04dfb630e 100644 --- a/packages/core/ios/RNSentryReplayBreadcrumbConverter.m +++ b/packages/core/ios/RNSentryReplayBreadcrumbConverter.m @@ -35,8 +35,8 @@ - (instancetype _Nonnull)init return [self convertTouch:breadcrumb]; } - if ([breadcrumb.category isEqualToString:@"ui.frustration"]) { - return [self convertFrustration:breadcrumb]; + if ([breadcrumb.category isEqualToString:@"ui.multiClick"]) { + return [self convertMultiClick:breadcrumb]; } if ([breadcrumb.category isEqualToString:@"navigation"]) { @@ -79,7 +79,7 @@ - (instancetype _Nonnull)init data:breadcrumb.data]; } -- (id _Nullable)convertFrustration:(SentryBreadcrumb *_Nonnull)breadcrumb +- (id _Nullable)convertMultiClick:(SentryBreadcrumb *_Nonnull)breadcrumb { if (breadcrumb.data == nil) { return nil; @@ -89,7 +89,7 @@ - (instancetype _Nonnull)init NSString *message = [RNSentryReplayBreadcrumbConverter getTouchPathMessageFrom:path]; return [SentrySessionReplayHybridSDK createBreadcrumbwithTimestamp:breadcrumb.timestamp - category:@"ui.frustration" + category:@"ui.multiClick" message:message level:breadcrumb.level data:breadcrumb.data]; diff --git a/packages/core/src/js/ragetap.ts b/packages/core/src/js/ragetap.ts index 8158e02230..e3d6eeb72b 100644 --- a/packages/core/src/js/ragetap.ts +++ b/packages/core/src/js/ragetap.ts @@ -1,6 +1,8 @@ import type { SeverityLevel } from '@sentry/core'; import { addBreadcrumb, debug } from '@sentry/core'; +import { getCurrentReactNativeTracingIntegration } from './tracing/reactnativetracing'; + const DEFAULT_RAGE_TAP_THRESHOLD = 3; const DEFAULT_RAGE_TAP_TIME_WINDOW = 1000; @@ -24,7 +26,11 @@ interface RecentTap { /** * Detects rage taps (repeated rapid taps on the same target) and emits - * `ui.frustration` breadcrumbs when the threshold is hit. + * `ui.multiClick` breadcrumbs when the threshold is hit. + * + * Uses the same breadcrumb category and data shape as the web JS SDK's + * rage click detection so the Sentry replay timeline renders the fire + * icon and "Rage Click" label automatically. */ export class RageTapDetector { private _recentTaps: RecentTap[] = []; @@ -54,7 +60,7 @@ export class RageTapDetector { } /** - * Call after each touch event. If a rage tap is detected, a `ui.frustration` + * Call after each touch event. If a rage tap is detected, a `ui.multiClick` * breadcrumb is emitted automatically. */ public check(touchPath: TouchedComponentInfo[], label?: string): void { @@ -69,24 +75,27 @@ export class RageTapDetector { const identity = getTapIdentity(root, label); const now = Date.now(); - const rageTapCount = this._detect(identity, now); + const tapCount = this._detect(identity, now); + + if (tapCount > 0) { + const message = buildTouchMessage(root, label); + const node = buildNodeFromTouchPath(root, label); - if (rageTapCount > 0) { - const detail = label ? label : `${root.name}${root.file ? ` (${root.file})` : ''}`; addBreadcrumb({ - category: 'ui.frustration', + category: 'ui.multiClick', + type: 'default', + level: 'warning' as SeverityLevel, + message, data: { - type: 'rage_tap', - tapCount: rageTapCount, + clickCount: tapCount, + metric: true, + route: getCurrentRoute(), + node, 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}`); + debug.log(`[TouchEvents] Rage tap detected: ${tapCount} taps on ${message}`); } } @@ -124,3 +133,54 @@ function getTapIdentity(root: TouchedComponentInfo, label?: string): string { } return `name:${root.name ?? ''}|file:${root.file ?? ''}`; } + +/** + * Build a human-readable message matching the touch breadcrumb format. + */ +function buildTouchMessage(root: TouchedComponentInfo, label?: string): string { + if (label) { + return label; + } + return `${root.name}${root.file ? ` (${root.file})` : ''}`; +} + +/** + * Build a node object compatible with the web SDK's `ReplayBaseDomFrameData` + * so that `stringifyNodeAttributes` in the Sentry frontend can render it. + * + * Maps the React Native component info to the DOM-like shape: + * - `tagName` → element type (e.g. "RCTView") or component name + * - `attributes['data-sentry-component']` → component name from babel plugin + * - `attributes['data-sentry-source-file']` → source file + */ +function buildNodeFromTouchPath( + root: TouchedComponentInfo, + label?: string, +): { id: number; tagName: string; textContent: string; attributes: Record } { + const attributes: Record = {}; + + if (root.name) { + attributes['data-sentry-component'] = root.name; + } + if (root.file) { + attributes['data-sentry-source-file'] = root.file; + } + if (label) { + attributes['sentry-label'] = label; + } + + return { + id: 0, + tagName: root.element ?? root.name ?? 'unknown', + textContent: '', + attributes, + }; +} + +function getCurrentRoute(): string | undefined { + try { + return getCurrentReactNativeTracingIntegration()?.state.currentRoute; + } catch { + return undefined; + } +} diff --git a/packages/core/src/js/touchevents.tsx b/packages/core/src/js/touchevents.tsx index c6ec9c1af0..d47e794603 100644 --- a/packages/core/src/js/touchevents.tsx +++ b/packages/core/src/js/touchevents.tsx @@ -52,7 +52,7 @@ export type TouchEventBoundaryProps = { spanAttributes?: Record; /** * Enable rage tap detection. When enabled, rapid consecutive taps on the - * same element are detected and emitted as `ui.frustration` breadcrumbs. + * same element are detected and emitted as `ui.multiClick` breadcrumbs. * * @default true */ diff --git a/packages/core/test/ragetap.test.ts b/packages/core/test/ragetap.test.ts index e9ce57be79..0991096dd5 100644 --- a/packages/core/test/ragetap.test.ts +++ b/packages/core/test/ragetap.test.ts @@ -15,7 +15,7 @@ describe('RageTapDetector', () => { jest.restoreAllMocks(); }); - it('emits ui.frustration breadcrumb after 3 taps on same label', () => { + it('emits ui.multiClick breadcrumb after 3 taps on same label', () => { const detector = new RageTapDetector(); const path = [{ name: 'Button', label: 'submit' }]; @@ -26,15 +26,21 @@ describe('RageTapDetector', () => { expect(addBreadcrumb).toHaveBeenCalledTimes(1); expect(addBreadcrumb).toHaveBeenCalledWith( expect.objectContaining({ - category: 'ui.frustration', + category: 'ui.multiClick', level: 'warning', - message: 'Rage tap detected on: submit', - type: 'user', + type: 'default', + message: 'submit', data: expect.objectContaining({ - type: 'rage_tap', - tapCount: 3, - label: 'submit', + clickCount: 3, + metric: true, path, + node: expect.objectContaining({ + tagName: 'Button', + attributes: expect.objectContaining({ + 'data-sentry-component': 'Button', + 'sentry-label': 'submit', + }), + }), }), }), ); @@ -108,7 +114,7 @@ describe('RageTapDetector', () => { expect(addBreadcrumb).toHaveBeenCalledTimes(1); expect(addBreadcrumb).toHaveBeenCalledWith( expect.objectContaining({ - data: expect.objectContaining({ tapCount: 5 }), + data: expect.objectContaining({ clickCount: 5 }), }), ); }); @@ -142,8 +148,16 @@ describe('RageTapDetector', () => { expect(addBreadcrumb).toHaveBeenCalledTimes(1); expect(addBreadcrumb).toHaveBeenCalledWith( expect.objectContaining({ - message: 'Rage tap detected on: SubmitButton (form.tsx)', - data: expect.objectContaining({ label: undefined }), + message: 'SubmitButton (form.tsx)', + data: expect.objectContaining({ + node: expect.objectContaining({ + tagName: 'SubmitButton', + attributes: expect.objectContaining({ + 'data-sentry-component': 'SubmitButton', + 'data-sentry-source-file': 'form.tsx', + }), + }), + }), }), ); }); diff --git a/packages/core/test/touchevents.test.tsx b/packages/core/test/touchevents.test.tsx index 6b9c483bc5..be29b24c8e 100644 --- a/packages/core/test/touchevents.test.tsx +++ b/packages/core/test/touchevents.test.tsx @@ -319,7 +319,7 @@ describe('TouchEventBoundary._onTouchStart', () => { jest.spyOn(Date, 'now').mockReturnValue(1000); }); - it('emits ui.frustration breadcrumb after 3 taps on same target', () => { + it('emits ui.multiClick breadcrumb after 3 taps on same target', () => { const { defaultProps } = TouchEventBoundary; const boundary = new TouchEventBoundary(defaultProps); @@ -337,18 +337,16 @@ describe('TouchEventBoundary._onTouchStart', () => { // @ts-expect-error Calling private member boundary._onTouchStart(event); - // 3 touch breadcrumbs + 1 frustration breadcrumb + // 3 touch breadcrumbs + 1 multiClick breadcrumb expect(addBreadcrumb).toHaveBeenCalledTimes(4); expect(addBreadcrumb).toHaveBeenLastCalledWith( expect.objectContaining({ - category: 'ui.frustration', + category: 'ui.multiClick', level: 'warning', - message: 'Rage tap detected on: submit', - type: 'user', + type: 'default', data: expect.objectContaining({ - type: 'rage_tap', - tapCount: 3, - label: 'submit', + clickCount: 3, + metric: true, }), }), ); @@ -413,11 +411,11 @@ describe('TouchEventBoundary._onTouchStart', () => { // @ts-expect-error Calling private member boundary._onTouchStart(event); - expect(addBreadcrumb).toHaveBeenCalledTimes(6); // 5 touch + 1 frustration + expect(addBreadcrumb).toHaveBeenCalledTimes(6); // 5 touch + 1 multiClick expect(addBreadcrumb).toHaveBeenLastCalledWith( expect.objectContaining({ - category: 'ui.frustration', - data: expect.objectContaining({ tapCount: 5 }), + category: 'ui.multiClick', + data: expect.objectContaining({ clickCount: 5 }), }), ); }); From 1eb262cdd6638ae7af11b00d8a2b6dd28f7f5c6d Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 20 Apr 2026 14:24:37 +0200 Subject: [PATCH 6/7] 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). --- packages/core/src/js/ragetap.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/js/ragetap.ts b/packages/core/src/js/ragetap.ts index e3d6eeb72b..ad811f4978 100644 --- a/packages/core/src/js/ragetap.ts +++ b/packages/core/src/js/ragetap.ts @@ -128,10 +128,11 @@ export class RageTapDetector { } function getTapIdentity(root: TouchedComponentInfo, label?: string): string { + const base = `name:${root.name ?? ''}|file:${root.file ?? ''}`; if (label) { - return `label:${label}`; + return `label:${label}|${base}`; } - return `name:${root.name ?? ''}|file:${root.file ?? ''}`; + return base; } /** From 7cbafd313e605fc96c5b132373d13875db1b007f Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 20 Apr 2026 17:12:34 +0200 Subject: [PATCH 7/7] 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). --- CHANGELOG.md | 2 +- .../ios/RNSentryReplayBreadcrumbConverter.m | 8 +++-- packages/core/src/js/ragetap.ts | 3 ++ packages/core/test/touchevents.test.tsx | 32 +++++++++++++++++++ 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbee18bf85..99f458c5c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Expose screenshot masking options (`screenshot.maskAllText`, `screenshot.maskAllImages`, `screenshot.maskedViewClasses`, `screenshot.unmaskedViewClasses`) for error screenshots ([#6007](https://github.com/getsentry/sentry-react-native/pull/6007)) - Warn Expo users at Metro startup when prebuilt native projects are missing Sentry configuration ([#5984](https://github.com/getsentry/sentry-react-native/pull/5984)) - Add `Sentry.GlobalErrorBoundary` component (and `withGlobalErrorBoundary` HOC) that renders a fallback UI for fatal non-rendering JS errors routed through `ErrorUtils` in addition to the render-phase errors caught by `Sentry.ErrorBoundary`. Opt-in flags `includeNonFatalGlobalErrors` and `includeUnhandledRejections` extend the fallback to non-fatal errors and unhandled promise rejections respectively. ([#6023](https://github.com/getsentry/sentry-react-native/pull/6023)) +- 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)) ### Fixes @@ -39,7 +40,6 @@ - Add `strictTraceContinuation` and `orgId` options for trace continuation validation ([#5829](https://github.com/getsentry/sentry-react-native/pull/5829)) - Add `deeplinkIntegration` for automatic deep link breadcrumbs ([#5983](https://github.com/getsentry/sentry-react-native/pull/5983)) - Name navigation spans using dispatched action payload when `useDispatchedActionData` is enabled ([#5982](https://github.com/getsentry/sentry-react-native/pull/5982)) -- 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)) ### Fixes diff --git a/packages/core/ios/RNSentryReplayBreadcrumbConverter.m b/packages/core/ios/RNSentryReplayBreadcrumbConverter.m index e04dfb630e..6bc5366481 100644 --- a/packages/core/ios/RNSentryReplayBreadcrumbConverter.m +++ b/packages/core/ios/RNSentryReplayBreadcrumbConverter.m @@ -85,8 +85,12 @@ - (instancetype _Nonnull)init return nil; } - NSMutableArray *path = [breadcrumb.data valueForKey:@"path"]; - NSString *message = [RNSentryReplayBreadcrumbConverter getTouchPathMessageFrom:path]; + id maybePath = [breadcrumb.data valueForKey:@"path"]; + if (![maybePath isKindOfClass:[NSArray class]]) { + return nil; + } + + NSString *message = [RNSentryReplayBreadcrumbConverter getTouchPathMessageFrom:maybePath]; return [SentrySessionReplayHybridSDK createBreadcrumbwithTimestamp:breadcrumb.timestamp category:@"ui.multiClick" diff --git a/packages/core/src/js/ragetap.ts b/packages/core/src/js/ragetap.ts index ad811f4978..af230ea8d6 100644 --- a/packages/core/src/js/ragetap.ts +++ b/packages/core/src/js/ragetap.ts @@ -50,6 +50,9 @@ export class RageTapDetector { public updateOptions(options: Partial): void { if (options.enabled !== undefined) { this._enabled = options.enabled; + if (!this._enabled) { + this._recentTaps = []; + } } if (options.threshold !== undefined) { this._threshold = options.threshold; diff --git a/packages/core/test/touchevents.test.tsx b/packages/core/test/touchevents.test.tsx index be29b24c8e..6840bed56f 100644 --- a/packages/core/test/touchevents.test.tsx +++ b/packages/core/test/touchevents.test.tsx @@ -419,6 +419,38 @@ describe('TouchEventBoundary._onTouchStart', () => { }), ); }); + + it('does not trigger when taps are outside the time window', () => { + const { defaultProps } = TouchEventBoundary; + const boundary = new TouchEventBoundary(defaultProps); + const nowMock = jest.spyOn(Date, 'now'); + + const event = { + _targetInst: { + elementType: { displayName: 'Button' }, + memoizedProps: { 'sentry-label': 'submit' }, + }, + }; + + nowMock.mockReturnValue(1000); + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + nowMock.mockReturnValue(1500); + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + // Third tap beyond 1000ms default window + nowMock.mockReturnValue(2500); + // @ts-expect-error Calling private member + boundary._onTouchStart(event); + + // Only touch breadcrumbs, no multiClick + expect(addBreadcrumb).toHaveBeenCalledTimes(3); + for (const call of addBreadcrumb.mock.calls) { + expect(call[0].category).toBe('touch'); + } + }); }); describe('sentry-span-attributes', () => {