Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc
if ("touch".equals(breadcrumb.getCategory())) {
return convertTouchBreadcrumb(breadcrumb);
}
if ("ui.frustration".equals(breadcrumb.getCategory())) {
Comment thread
alwx marked this conversation as resolved.
Outdated
return convertFrustrationBreadcrumb(breadcrumb);
}
if ("navigation".equals(breadcrumb.getCategory())) {
return convertNavigationBreadcrumb(breadcrumb);
}
Expand Down Expand Up @@ -72,6 +75,18 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc
return rrWebBreadcrumb;
}

@TestOnly
Comment thread
alwx marked this conversation as resolved.
Outdated
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;
}
Comment thread
sentry[bot] marked this conversation as resolved.
Outdated

@TestOnly
public static @Nullable String getTouchPathMessage(final @Nullable Object maybePath) {
if (!(maybePath instanceof List)) {
Expand Down
20 changes: 20 additions & 0 deletions packages/core/ios/RNSentryReplayBreadcrumbConverter.m
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -75,6 +79,22 @@ - (instancetype _Nonnull)init
data:breadcrumb.data];
}

- (id<SentryRRWebEvent> _Nullable)convertFrustration:(SentryBreadcrumb *_Nonnull)breadcrumb
{
if (breadcrumb.data == nil) {
return nil;
}

NSMutableArray *path = [breadcrumb.data valueForKey:@"path"];
NSString *message = [RNSentryReplayBreadcrumbConverter getTouchPathMessageFrom:path];
Comment thread
sentry[bot] marked this conversation as resolved.
Outdated

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) {
Expand Down
118 changes: 118 additions & 0 deletions packages/core/src/js/ragetap.ts
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: Wdyt of increasing this to 7s like the web?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm curious how this works for the case of app hanging (i.e. dead clicks) -- I'm guessing we won't be able to detect the following touches after the first one that triggered a hang -- the main thread would be occupied/congested, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@romtsn that's the correct assumption β€” rage taps (rapid taps that register) and dead taps (taps during a hang) are different signals. Dead tap detection would need native-side work.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@antonis the web SDK's 7s is more like "how long to wait before emitting the breadcrumb", not "how close clicks need to be". So the direct comparison of numbers doesn't seem applicable β€”Β in our case it's like a "rolling" time window.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good πŸ‘ Let's add a comment explaining the reasoning behind the 1s window if possible πŸ™‡

const MAX_RECENT_TAPS = 10;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

interface RecentTap {
identity: string;
timestamp: number;
}

export interface TouchedComponentInfo {
name?: string;
label?: string;
element?: string;
file?: string;
}
Comment thread
cursor[bot] marked this conversation as resolved.

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<RageTapDetectorOptions>) {
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',
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rage tap breadcrumb missing level: 'warning'

Medium Severity

The addBreadcrumb call in ragetap.ts omits the level property. Regular touch breadcrumbs in _logTouchEvent explicitly set level: 'info', and both Android and iOS native converter tests set up multi-click breadcrumbs with level = SentryLevel.WARNING / .warning and assert this level is preserved. Without setting level: 'warning' on the JS breadcrumb, the replay breadcrumb event will carry the wrong severity, potentially affecting how the Sentry UI displays this frustration signal.

Additional Locations (2)
Fix in CursorΒ Fix in Web

Reviewed by Cursor Bugbot for commit dd2b845. Configure here.


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;
Comment thread
sentry[bot] marked this conversation as resolved.
Outdated
}

return 0;
}
}

function getTapIdentity(root: TouchedComponentInfo, label?: string): string {
if (label) {
return `label:${label}`;
}
return `name:${root.name ?? ''}|file:${root.file ?? ''}`;
}
Comment thread
sentry[bot] marked this conversation as resolved.
35 changes: 35 additions & 0 deletions packages/core/src/js/touchevents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -48,6 +49,25 @@ export type TouchEventBoundaryProps = {
* @experimental This API is experimental and may change in future releases.
*/
spanAttributes?: Record<string, SpanAttributeValue>;
/**
* 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({
Expand Down Expand Up @@ -96,10 +116,24 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
breadcrumbType: DEFAULT_BREADCRUMB_TYPE,
ignoreNames: [],
maxComponentTreeSize: DEFAULT_MAX_COMPONENT_TREE_SIZE,
enableRageTapDetection: true,
rageTapThreshold: 3,
rageTapTimeWindow: 1000,
Comment thread
alwx marked this conversation as resolved.
Outdated
};

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,
});
}
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* Registers the TouchEventBoundary as a Sentry Integration.
*/
Expand Down Expand Up @@ -203,6 +237,7 @@ class TouchEventBoundary extends React.Component<TouchEventBoundaryProps> {
const label = touchPath.find(info => info.label)?.label;
if (touchPath.length > 0) {
this._logTouchEvent(touchPath, label);
this._rageTapDetector.check(touchPath, label);
}

const span = startUserInteractionSpan({
Expand Down
Loading
Loading