Skip to content
Open
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 @@ -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

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,22 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc
return rrWebBreadcrumb;
}

@TestOnly
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.

m: since it is called from convert we could remove the @testonly annotation

public @Nullable RRWebEvent convertMultiClickBreadcrumb(final @NotNull Breadcrumb breadcrumb) {
if (breadcrumb.getData("path") == null) {
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 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;
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?


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 {
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.

l: it would be nice to test the updateOptions logic

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

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

addBreadcrumb({
category: 'ui.multiClick',
type: 'default',
level: 'warning' as SeverityLevel,
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.

Should we omit and default to info like the web?

message,
data: {
clickCount: tapCount,
metric: true,
route: getCurrentRoute(),
node,
path: touchPath,
},
});

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 {
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?

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