Skip to content
Draft
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
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())) {
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
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)) {
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];

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;
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<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',
});

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 ?? ''}`;
}
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,
};

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.
*/
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