Skip to content

feat(core): Add rage tap detection with ui.frustration breadcrumbs#5992

Merged
alwx merged 13 commits intomainfrom
feat/rage-tap-detection
Apr 27, 2026
Merged

feat(core): Add rage tap detection with ui.frustration breadcrumbs#5992
alwx merged 13 commits intomainfrom
feat/rage-tap-detection

Conversation

@alwx
Copy link
Copy Markdown
Contributor

@alwx alwx commented Apr 14, 2026

📢 Type of change

  • Bugfix
  • New feature
  • Enhancement
  • Refactoring

📜 Description

Detects rage taps (rapid consecutive taps on the same UI element) and surfaces them as first-class frustration signals across the SDK.

Design decisions

  • Component identity over coordinates — taps are matched by label or component name + file rather than screen coordinates. More robust for shifting layouts and list items, and reuses data already captured by TouchEventBoundary.
  • Breadcrumb-first — emits ui.frustration breadcrumbs.

📝 Checklist

  • I added tests to verify changes
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • All tests passing
  • No breaking changes

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.
@alwx alwx self-assigned this Apr 14, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 14, 2026

Semver Impact of This PR

None (no version bump detected)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


  • feat(core): Add rage tap detection with ui.frustration breadcrumbs by alwx in #5992
  • feat(core): Add GlobalErrorBoundary for non-rendering errors by alwx in #6023
  • chore(deps): update CLI to v3.4.0 by github-actions in #6026
  • feat: Expose screenshot masking options for error screenshots by antonis in #6007
  • fix(replay): Check captureReplay return value in iOS bridge by antonis in #6008
  • chore(deps): bump getsentry/craft from 2.25.2 to 2.25.4 by dependabot in #6019
  • chore(deps): bump getsentry/craft/.github/workflows/changelog-preview.yml from 2.25.2 to 2.25.4 by dependabot in #6021
  • chore(deps): bump github/codeql-action from 4.35.1 to 4.35.2 by dependabot in #6022
  • chore(deps): bump actions/setup-node from 6.3.0 to 6.4.0 by dependabot in #6020
  • ci(danger): Demote Android SDK version mismatch from fail to warn by antonis in #6018
  • chore(deps): update Android SDK to v8.39.1 by github-actions in #6010
  • chore(deps): update JavaScript SDK to v10.49.0 by github-actions in #6011
  • ci: Integrate Warden for AI-powered PR code review by antonis in #6003
  • chore(lint): Fixes lint issue on main by antonis in #6013
  • feat(expo): Warn when prebuilt native projects are missing Sentry config by alwx in #5984

🤖 This preview updates automatically when you update the PR.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 14, 2026

Fails
🚫 Pull request is not ready for merge, please add the "ready-to-merge" label to the pull request
Messages
📖 Do not forget to update Sentry-docs with your feature once the pull request gets approved.

Generated by 🚫 dangerJS against dd2b845

@alwx alwx changed the title feat(core): Add rage tap detection with ui.frustration breadcrumbs WIP: feat(core): Add rage tap detection with ui.frustration breadcrumbs Apr 14, 2026
@alwx alwx force-pushed the feat/rage-tap-detection branch from 4cfa1af to 7d06010 Compare April 15, 2026 12:51
- 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.
@alwx alwx marked this pull request as ready for review April 16, 2026 09:58
@alwx alwx changed the title WIP: feat(core): Add rage tap detection with ui.frustration breadcrumbs feat(core): Add rage tap detection with ui.frustration breadcrumbs Apr 16, 2026
@alwx
Copy link
Copy Markdown
Contributor Author

alwx commented Apr 16, 2026

@cursor review

Comment thread packages/core/src/js/ragetap.ts Outdated
Comment thread packages/core/src/js/ragetap.ts Outdated
Comment thread packages/core/src/js/ragetap.ts
Comment thread packages/core/src/js/touchevents.tsx
- 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.
Comment thread packages/core/src/js/ragetap.ts
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.
@alwx alwx requested review from antonis and romtsn April 20, 2026 12:22
…sitives

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).
Comment thread packages/core/ios/RNSentryReplayBreadcrumbConverter.m Outdated
Comment thread packages/core/test/touchevents.test.tsx
Comment thread packages/core/src/js/ragetap.ts
- 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).
Comment thread packages/core/src/js/ragetap.ts Outdated
Comment thread packages/core/src/js/ragetap.ts Outdated
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?

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 🙇

Comment thread packages/core/src/js/touchevents.tsx Outdated
Comment thread packages/core/src/js/touchevents.tsx Outdated
Comment thread packages/core/src/js/ragetap.ts
}

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?

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

Copy link
Copy Markdown
Contributor

@antonis antonis left a comment

Choose a reason for hiding this comment

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

Thank you for your work on this @alwx and the changes 🙇
Overall looks good :) I've left some comments and questions.

Comment thread packages/core/test/ragetap.test.ts
@lucas-zimerman
Copy link
Copy Markdown
Collaborator

No additional comments from my side than the one pointed by the other reviewers.

- Drop level: 'warning' from ui.multiClick breadcrumb to match the
  web JS SDK which defaults to info.

- Export DEFAULT_RAGE_TAP_THRESHOLD and DEFAULT_RAGE_TAP_TIME_WINDOW
  from ragetap.ts and import them in touchevents.tsx defaultProps for
  a single source of truth.

- Initialize RageTapDetector with props in the constructor and sync
  via componentDidUpdate, instead of calling updateOptions on every
  tap event.

- Remove incorrect @testonly annotation from Android
  convertMultiClickBreadcrumb since it is called from convert() in
  production code.

- Add comment explaining id: 0 placeholder in the node object (mobile
  replays don't have rrweb node IDs).

- Add tests for updateOptions: buffer cleared on disable, and
  threshold change applies immediately.

- Run yarn fix for import ordering lint.
@alwx alwx requested a review from antonis April 27, 2026 07:55
}

public @Nullable RRWebEvent convertMultiClickBreadcrumb(final @NotNull Breadcrumb breadcrumb) {
if (breadcrumb.getData("path") == null) {
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: Should we also check the type similar to iOS?

Copy link
Copy Markdown
Contributor

@antonis antonis left a comment

Choose a reason for hiding this comment

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

LGTM!
Iterated on the comments and added one more q but nothing blocking on my side.

alwx added 3 commits April 27, 2026 11:53
Align with the iOS converter which validates the path type before use.
Prevents potential ClassCastException if a non-list value is passed.
@alwx alwx enabled auto-merge (squash) April 27, 2026 09:57
const identity = getTapIdentity(root, label);
const now = Date.now();
const tapCount = this._detect(identity, now);

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

@alwx alwx merged commit 9b6b2ba into main Apr 27, 2026
54 of 61 checks passed
@alwx alwx deleted the feat/rage-tap-detection branch April 27, 2026 10:00
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit dd2b845. Configure here.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants