-
-
Notifications
You must be signed in to change notification settings - Fork 359
feat(core): Add rage tap detection with ui.frustration breadcrumbs #5992
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
7d06010
89d1e9d
a34b7f2
12862ac
65fe114
b43a3e8
1eb262c
234b1c6
7cbafd3
ab099f2
caee29c
a8c6294
dd2b845
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Q: Wdyt of increasing this to 7s like the web?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
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; | ||
| } | ||
|
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', | ||
| }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rage tap breadcrumb missing
|
||
|
|
||
| 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; | ||
|
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 ?? ''}`; | ||
| } | ||
|
sentry[bot] marked this conversation as resolved.
|
||


Uh oh!
There was an error while loading. Please reload this page.