Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -11,6 +11,7 @@
### Features

- Add `includeWebFeedback` Metro config option to exclude `@sentry-internal/feedback` from the bundle ([#6025](https://github.com/getsentry/sentry-react-native/pull/6025))
- 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

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 convertMultiClickBreadcrumb() {
val converter = RNSentryReplayBreadcrumbConverter()
val testBreadcrumb = Breadcrumb()
testBreadcrumb.level = SentryLevel.WARNING
testBreadcrumb.type = "default"
testBreadcrumb.category = "ui.multiClick"
testBreadcrumb.message = "Submit"
testBreadcrumb.setData(
"path",
arrayListOf(
mapOf(
"name" to "SubmitButton",
"label" to "Submit",
"file" to "form.tsx",
),
),
)
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.multiClick", 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 testConvertMultiClickBreadcrumb() {
let converter = RNSentryReplayBreadcrumbConverter()
let testBreadcrumb = Breadcrumb()
testBreadcrumb.timestamp = Date()
testBreadcrumb.level = .warning
testBreadcrumb.type = "default"
testBreadcrumb.category = "ui.multiClick"
testBreadcrumb.message = "Submit"
testBreadcrumb.data = [
"path": [
["name": "SubmitButton", "label": "Submit", "file": "form.tsx"]
],
"clickCount": 3,
"metric": true
]
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.multiClick", 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.multiClick".equals(breadcrumb.getCategory())) {
return convertMultiClickBreadcrumb(breadcrumb);
}
if ("navigation".equals(breadcrumb.getCategory())) {
return convertNavigationBreadcrumb(breadcrumb);
}
Expand Down Expand Up @@ -72,6 +75,21 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc
return rrWebBreadcrumb;
}

public @Nullable RRWebEvent convertMultiClickBreadcrumb(final @NotNull Breadcrumb breadcrumb) {
if (!(breadcrumb.getData("path") instanceof List)) {
return null;
}

final RRWebBreadcrumbEvent rrWebBreadcrumb = new RRWebBreadcrumbEvent();

rrWebBreadcrumb.setCategory("ui.multiClick");

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)) {
Expand Down
24 changes: 24 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.multiClick"]) {
return [self convertMultiClick:breadcrumb];
}

if ([breadcrumb.category isEqualToString:@"navigation"]) {
return [SentrySessionReplayHybridSDK createBreadcrumbwithTimestamp:breadcrumb.timestamp
category:breadcrumb.category
Expand Down Expand Up @@ -75,6 +79,26 @@ - (instancetype _Nonnull)init
data:breadcrumb.data];
}

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

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"
message:message
level:breadcrumb.level
data:breadcrumb.data];
}

+ (NSString *_Nullable)getTouchPathMessageFrom:(NSArray *_Nullable)path
{
if (path == nil) {
Expand Down
190 changes: 190 additions & 0 deletions packages/core/src/js/ragetap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { addBreadcrumb, debug } from '@sentry/core';

import { getCurrentReactNativeTracingIntegration } from './tracing/reactnativetracing';

export const DEFAULT_RAGE_TAP_THRESHOLD = 3;
export const DEFAULT_RAGE_TAP_TIME_WINDOW = 1000;

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;
}

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

/**
* Detects rage taps (repeated rapid taps on the same target) and emits
* `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[] = [];
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;
}

/**
* Update options at runtime (e.g. when React props change).
*/
public updateOptions(options: Partial<RageTapDetectorOptions>): void {
Comment thread
alwx marked this conversation as resolved.
if (options.enabled !== undefined) {
this._enabled = options.enabled;
if (!this._enabled) {
this._recentTaps = [];
}
}
if (options.threshold !== undefined) {
Comment thread
sentry[bot] marked this conversation as resolved.
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.multiClick`
* 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 tapCount = this._detect(identity, now);

Check warning on line 81 in packages/core/src/js/ragetap.ts

View check run for this annotation

@sentry/warden / warden: code-review

Inconsistent breadcrumb category: code emits 'ui.multiClick' but PR description and type say 'ui.frustration'

The PR title and description state that this feature emits `ui.frustration` breadcrumbs, but the actual implementation emits `ui.multiClick`. The JSDoc on the class also references `ui.multiClick` to match the web SDK. This is a backwards-compatibility/API contract concern: downstream consumers (Sentry frontend, alerting rules, docs) need a single, agreed-upon category. The discrepancy will cause user-visible confusion about what category to filter on.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Inconsistent breadcrumb category: code emits 'ui.multiClick' but PR description and type say 'ui.frustration'

The PR title and description state that this feature emits ui.frustration breadcrumbs, but the actual implementation emits ui.multiClick. The JSDoc on the class also references ui.multiClick to match the web SDK. This is a backwards-compatibility/API contract concern: downstream consumers (Sentry frontend, alerting rules, docs) need a single, agreed-upon category. The discrepancy will cause user-visible confusion about what category to filter on.

Verification

Compared the PR title/description ('ui.frustration breadcrumbs') against the literal category strings in ragetap.ts at lines 30 (JSDoc), 60 (JSDoc), and 81 (addBreadcrumb call). All three use 'ui.multiClick'. No other category is emitted in this file.

Identified by Warden code-review Β· PHV-YSD

if (tapCount > 0) {
const message = buildTouchMessage(root, label);
const node = buildNodeFromTouchPath(root, label);

addBreadcrumb({
category: 'ui.multiClick',
type: 'default',
message,
data: {
clickCount: tapCount,
metric: true,
route: getCurrentRoute(),
node,
path: touchPath,
},
});
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: ${tapCount} taps on ${message}`);
}
}

/**
* Returns the tap count if rage tap is detected, 0 otherwise.
*/
private _detect(identity: string, now: number): number {
// 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);

if (this._recentTaps.length >= this._threshold) {
const count = this._recentTaps.length;
this._recentTaps = [];
return count;
}

return 0;
}
}

function getTapIdentity(root: TouchedComponentInfo, label?: string): string {
const base = `name:${root.name ?? ''}|file:${root.file ?? ''}`;
if (label) {
return `label:${label}|${base}`;
}
return base;
}

/**
* 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<string, string> } {
const attributes: Record<string, string> = {};

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 {
// Mobile replays don't have rrweb node IDs β€” 0 is a placeholder
// to satisfy the ReplayBaseDomFrameData shape expected by the frontend.
id: 0,
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: why do we pass 0 here?

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.

Web SDK uses actual rrweb node IDs for DOM element mapping. On mobile we don't have rrweb node IDs so 0 is no more than just a placeholder (and ReplayBaseDomFrameData type requires id: number)

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 πŸ‘ We should double check that this get's through on the backend

tagName: root.element ?? root.name ?? 'unknown',
textContent: '',
attributes,
};
}

function getCurrentRoute(): string | undefined {
try {
return getCurrentReactNativeTracingIntegration()?.state.currentRoute;
} catch {
return undefined;
}
}
Comment thread
sentry[bot] marked this conversation as resolved.
Loading
Loading