Module: Sentiero::Analytics::Frustration::Detectors
- Defined in:
- lib/sentiero/analytics/frustration/detectors.rb
Overview
Pure, self-contained frustration detectors — Ruby ports of the JS detectors (frontend/src/dashboard/frustration.js), pinned by ported tests so the two can't drift. FrustrationAnalyzer layers cross-session aggregation and de-noising on top of this raw detector output.
Constant Summary collapse
- RAGE_WINDOW_MS =
Detector thresholds — verbatim from frontend/src/dashboard/frustration.js.
500- RAGE_COORD_TOLERANCE_PX =
max span of a rage cluster
10- RAGE_MIN_CLICKS =
max px from the first click
3- DEAD_WINDOW_MS =
min clicks to count as rage
500- MOUSE_CLICK =
response deadline for a dead click
2- SOURCE_MUTATION =
rrweb MouseInteraction click subtype
0
Class Method Summary collapse
-
.click?(event) ⇒ Boolean
rrweb left-click mouse-interaction carrying coordinates (mirrors JS isClick).
-
.detect_dead_clicks(events) ⇒ Object
Clicks with no page response within DEAD_WINDOW_MS.
-
.detect_frustration_events(events) ⇒ Object
Combines both detectors (clicks absorbed into a rage cluster are not also reported as dead), sorted by offset from the window's first event.
- .detect_rage_clicks(events) ⇒ Object
-
.response?(event) ⇒ Boolean
Page responded to a click: DOM mutation, input change, or meta/navigation (mirrors JS isResponse).
Class Method Details
.click?(event) ⇒ Boolean
rrweb left-click mouse-interaction carrying coordinates (mirrors JS isClick).
133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/sentiero/analytics/frustration/detectors.rb', line 133 def click?(event) return false unless event.is_a?(Hash) return false unless event["type"] == Events::INCREMENTAL return false unless event["timestamp"].is_a?(Numeric) data = event["data"] data.is_a?(Hash) && data["source"] == Events::SOURCE_MOUSE_INTERACTION && data["type"] == MOUSE_CLICK && data["x"].is_a?(Numeric) && data["y"].is_a?(Numeric) end |
.detect_dead_clicks(events) ⇒ Object
Clicks with no page response within DEAD_WINDOW_MS.
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
# File 'lib/sentiero/analytics/frustration/detectors.rb', line 62 def detect_dead_clicks(events) return [] unless events.is_a?(Array) out = [] events.each_with_index do |event, i| next unless click?(event) click_ts = event["timestamp"] deadline = click_ts + DEAD_WINDOW_MS responded = false (i + 1...events.size).each do |j| ts = events[j].is_a?(Hash) ? events[j]["timestamp"] : nil next unless ts.is_a?(Numeric) break if ts > deadline if ts > click_ts && response?(events[j]) responded = true break end end unless responded out << { subtype: "dead_click", timestamp: click_ts, x: event["data"]["x"], y: event["data"]["y"], elapsed: DEAD_WINDOW_MS, event: event } end end out end |
.detect_frustration_events(events) ⇒ Object
Combines both detectors (clicks absorbed into a rage cluster are not also reported as dead), sorted by offset from the window's first event.
98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 |
# File 'lib/sentiero/analytics/frustration/detectors.rb', line 98 def detect_frustration_events(events) return [] unless events.is_a?(Array) && !events.empty? first = events.first return [] unless first.is_a?(Hash) && first["timestamp"].is_a?(Numeric) first_ts = first["timestamp"] rage = detect_rage_clicks(events) dead = detect_dead_clicks(events) = {} rage.each do |r| (r[:member_timestamps] || [r[:timestamp]]).each { |t| [t] = true } end combined = rage + dead.reject { |d| [d[:timestamp]] } combined .map do |entry| { category: "frustration", subtype: entry[:subtype], timestamp: entry[:timestamp], offset: entry[:timestamp] - first_ts, count: entry[:count], elapsed: entry[:elapsed], x: entry[:x], y: entry[:y], event: entry[:event] } end .each_with_index.sort_by { |entry, i| [entry[:offset], i] } .map { |entry, _i| entry } end |
.detect_rage_clicks(events) ⇒ Object
24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
# File 'lib/sentiero/analytics/frustration/detectors.rb', line 24 def detect_rage_clicks(events) return [] unless events.is_a?(Array) clicks = events.select { |event| click?(event) } return [] if clicks.size < RAGE_MIN_CLICKS out = [] cluster_start = 0 (1..clicks.size).each do |i| prev = clicks[i - 1] cur = clicks[i] anchor = clicks[cluster_start] continues = cur && cur["timestamp"] - prev["timestamp"] <= RAGE_WINDOW_MS && cur["timestamp"] - anchor["timestamp"] <= RAGE_WINDOW_MS && (cur["data"]["x"] - anchor["data"]["x"]).abs <= RAGE_COORD_TOLERANCE_PX && (cur["data"]["y"] - anchor["data"]["y"]).abs <= RAGE_COORD_TOLERANCE_PX next if continues count = i - cluster_start if count >= RAGE_MIN_CLICKS out << { subtype: "rage_click", timestamp: anchor["timestamp"], count: count, x: anchor["data"]["x"], y: anchor["data"]["y"], member_timestamps: clicks[cluster_start...i].map { |c| c["timestamp"] }, event: anchor } end cluster_start = i end out end |
.response?(event) ⇒ Boolean
Page responded to a click: DOM mutation, input change, or meta/navigation (mirrors JS isResponse).
147 148 149 150 151 152 153 154 |
# File 'lib/sentiero/analytics/frustration/detectors.rb', line 147 def response?(event) return false unless event.is_a?(Hash) && event["data"] return true if event["type"] == Events::META return false unless event["type"] == Events::INCREMENTAL data = event["data"] return false unless data.is_a?(Hash) data["source"] == SOURCE_MUTATION || data["source"] == Events::SOURCE_INPUT end |