feat(analytics): add autocapture frustration signals (click, rage click, dead click)#982
feat(analytics): add autocapture frustration signals (click, rage click, dead click)#982rahul-mixpanel wants to merge 56 commits into
Conversation
…ead click Add autocapture functionality for automatic event tracking: - Click tracking ($mp_click): Captures all touch events with semantic info - Rage click detection ($mp_rage_click): 4+ clicks within 1000ms in 44dp radius - Dead click detection ($mp_dead_click): No UI change within 500ms after click Key components: - AutocaptureManager: Main coordinator with activity lifecycle integration - TouchInterceptor: Window.Callback wrapper for touch interception - WindowSpy: Tracks all windows (dialogs, popups) via reflection - SemanticExtractor: Extracts element info from View hierarchy - RageClickTracker: Click pattern detection with spatial/temporal thresholds - DeadClickDetector: UI change monitoring with ViewTreeObserver Configuration via MixpanelOptions.Builder.autocaptureOptions() Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add test screens for validating autocapture functionality: - ComposeAutocaptureTestScreen: Jetpack Compose test screen - XmlAutocaptureTestActivity: XML Views test screen - DemoApplication: Enables autocapture during SDK initialization Test coverage includes: - $el_id resolution (contentDescription, resource ID, hash fallback) - Dead click detection (elements with no UI response) - Rage click detection (4+ rapid taps) - Excluded controls (Switch, EditText, SeekBar) - Privacy filtering (mp-sensitive, mp-no-track) Note: XML views autocapture working; Compose needs improvement. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…erent feedback Privacy filtering: - Return null (block all events) for views marked mp-sensitive or mp-no-track - Add same check for Compose via AccessibilityNodeInfo - Add debug logging when skipping sensitive elements Dead click exclusions: - Exclude EditText (keyboard appears, cursor shows) - Exclude CompoundButton (Switch, CheckBox, RadioButton - toggle animation) - Exclude SeekBar (thumb moves with drag) These controls have inherent visual feedback and should not trigger $mp_dead_click. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…ose APIs - Add ComposeSemanticHelper to extract semantics from Jetpack Compose views using Compose's SemanticsNode API directly (no reflection) - Add compileOnly dependency on Compose UI for graceful degradation - Update SemanticExtractor to detect Compose roots and use ComposeSemanticHelper - Implement $el_id resolution: contentDescription > testTag > hash fallback - Implement sensitive element detection (mp-sensitive/mp-no-track) with ancestor checking - returns SENSITIVE_BLOCKED to prevent accessibility fallback - Disable dead click detection for Compose (ViewTreeObserver cannot detect Compose UI changes) - click and rage click work correctly Note: Dead click for Compose requires semantic tree comparison (future enhancement) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Reorder property checks so EditableText is checked before OnClick. TextFields in Compose are clickable (to gain focus) but should be identified as TextField/textbox, not Button/button. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
ViewTreeObserver cannot detect Compose UI changes since Compose renders inside a single AndroidComposeView. This implements semantic tree comparison for Compose clicks: captures semantic hash at click position (text, states, bounds, children), then compares after timeout to detect UI changes. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…snapshot comparison Use semantic tree snapshot comparison for Compose dead click detection: - Capture snapshot immediately at click (nodeCount + contentHash) - At timeout, detect changes via content hash or significant node count change - Navigation detected by node count increase (new screen composables added) - Ripples don't affect semantic tree, so dead clicks are correctly detected Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add complete integration guide for autocapture feature covering click, rage click, and dead click tracking. - Add analytics/docs/autocapture.md (620 lines) - Overview and quick start with Kotlin/Java examples - Configuration options for all event types - Complete event properties reference - Element identification best practices - Full Jetpack Compose support documentation - Multi-window tracking explanation (WindowSpy) - Privacy considerations and sensitive data handling - Troubleshooting guide with debug logging - ProGuard configuration notes - Migration guide from manual tracking - FAQ section - Update analytics/README.md - Add Features section with Autocapture overview - Add quick setup examples (Kotlin + Java) - Link to detailed autocapture guide - Update table of contents Documentation provides comprehensive coverage exceeding iOS guide, with dual-language examples and complete API reference.
Add 9 instrumented tests covering: - Click event capture and property verification - Element ID resolution (contentDescription, resource ID, hash fallback) - Rage click detection with rapid touch injection - Dead click detection for unresponsive elements - Privacy filtering for mp-sensitive elements - Multiple click event generation - Token and distinct_id verification
- Add testElementIdResolutionRule3HashFallback: verifies hash fallback format (Button_view_<hex>) when both contentDescription and resource ID are absent - Add testClickEventCapturesElText: verifies $el_text captures the button's visible text content via SemanticExtractor.extractText
Add 9 instrumented tests for Jetpack Compose autocapture, mirroring
the XML test suite:
- Click event capture with contentDescription element ID
- Element ID from testTag (Compose Rule 2 fallback)
- Element ID hash fallback (no contentDescription, no testTag)
- Rage click detection with rapid sendPointerSync injection
- Dead click detection (onClick={} with no UI change)
- Privacy filtering for mp-sensitive composables
- Multiple click event generation
- $el_text capture from Text composable
- Token and distinct_id verification
Uses sendPointerSync for touch injection because compose-ui-test's
performTouchInput bypasses Window.Callback where TouchInterceptor
lives. Compose test rule is used only to find element bounds.
Remove testClickEventPropertiesComplete and testElementIdResolutionRule1 as they duplicate coverage already provided by testXmlClickEventBasic.
- Add MAX_RECURSION_DEPTH (20) to prevent StackOverflowError in countViews, computeContentHash, computeTreeHash, findNodeAtPositionRecursive, and collectTextFromChildren - Fix WeakHashMap iteration in stop() to avoid ConcurrentModificationException - Fix WindowSpy.getRootViews() returning stale data by pointing to the live delegating list instead of the replaced original - Fix ClickEvent.isComposeClick() unreliability by using a boolean flag set at construction time instead of checking WeakReference at read time - Fix AccessibilityNodeInfo leak on exception in findNodeAtPosition
tylerjroach
left a comment
There was a problem hiding this comment.
I haven't finished reviewing the code, but had these questions on autocapture doc
…tent $el_text (visible text content of tapped elements) is no longer collected by default. Users must explicitly enable it by setting captureTextContent to true in AutocaptureOptions, protecting user privacy by default.
Simplify element exclusion to a single marker. Both mp-sensitive and mp-no-track had identical behavior (full block), so only mp-no-track is needed.
…xclusion Remove text content capture entirely from autocapture. Tracking visible text can be invasive and raise privacy concerns. The complexity of nested view hierarchies can also cause text extraction to capture content from unintended views — for example, tapping a container layout might extract text from a deeply nested label unrelated to the tap. Without text capture, the mp-no-track element exclusion marker is no longer needed since the remaining properties ($el_id, $el_tag_name, $attr-aria-label, $attr-role, $elements) are purely structural UI metadata with no PII risk. Removed: captureTextContent config, $el_text property, text extraction and sanitization (CC/SSN regex), sensitive input detection, mp-no-track marker checks, and all related tests and demo elements.
Confidence Score: 3/5Several correctness issues from previous review rounds remain unresolved, including manager teardown not wired into any MixpanelAPI cleanup path and touch listeners for dialog/popup windows not removed on dismissal. The core detection pipeline works correctly in the happy path, multi-touch handling was fixed, and null guards were added to AutocaptureOptions.Builder. However, AutocaptureManager.stop() is still never called from MixpanelAPI, so the manager and its lifecycle callbacks leak on any cleanup path. Dialog/popup window touch listeners registered via onRootViewChanged are not cleaned up on dismissal. The accessibility fallback still emits visible text as $el_id and omits $attr-aria-label when a resource ID is present, contradicting the documented privacy and property contracts. AutocaptureManager.java (stop() not wired to any MixpanelAPI cleanup), SemanticExtractor.java (accessibility fallback text capture + ariaLabel omission), MixpanelAPI.java (no teardown path for mAutocaptureManager).
|
| Filename | Overview |
|---|---|
| analytics/src/main/java/com/mixpanel/android/autocapture/AutocaptureManager.java | Core lifecycle coordinator; stop() is never called from MixpanelAPI (open issue from previous review), and dialog/popup window touch listeners added via onRootViewChanged are not removed on dismissal. |
| analytics/src/main/java/com/mixpanel/android/autocapture/SemanticExtractor.java | Handles View + Compose semantics extraction; two open issues from previous review: node.getText() used as $el_id (contradicts privacy claim), and $attr-aria-label dropped when viewIdResourceName is present. |
| analytics/src/main/java/com/mixpanel/android/autocapture/CurtainsHelper.kt | Touch interception; multi-touch false-tap fix applied (ACTION_POINTER_DOWN resets downTime), single-tap filtering looks correct. |
| analytics/src/main/java/com/mixpanel/android/autocapture/DeadClickDetector.java | Baseline/hash based change detection; depth-limited tree walk for XML; Compose monitors structural hash. AccessibilityNodeInfo lifecycle appears correct. |
| analytics/src/main/java/com/mixpanel/android/autocapture/ComposeSemanticHelper.java | Compose-specific semantic extraction and snapshot hashing; coordinate conversion to window-relative space is correct; no text captured as element ID (unlike the accessibility fallback path). |
| analytics/src/main/java/com/mixpanel/android/autocapture/RageClickTracker.java | Time-window + spatial-radius rage click detection; iterator-safe pruning; injectable TimeProvider for testability. No issues found. |
| analytics/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java | trackAutocaptureEvent correctly uses 2-arg track() with isAutomaticEvent=false, so events are not gated by trackAutomaticEvents. mAutocaptureManager.stop() still not wired to any cleanup path. |
| analytics/src/main/java/com/mixpanel/android/mpmetrics/AutocaptureOptions.java | Null guards added to all three Builder setters; defaults all sub-options to enabled. Clean. |
| analytics/src/main/java/com/mixpanel/android/autocapture/WindowSpy.java | Curtains singleton; static listener list with CopyOnWriteArrayList is thread-safe. uninstall() exists but is never called from AutocaptureManager.stop(), leaving the Curtains hook as a permanent singleton (benign but intentional). |
| analytics/src/main/java/com/mixpanel/android/autocapture/ClickEvent.java | isCompose flag is now a primitive boolean set at construction (not from WeakReference), fixing the GC reliability issue called out in a previous review thread. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant User
participant CurtainsHelper
participant AutocaptureManager
participant SemanticExtractor
participant RageClickTracker
participant DeadClickDetector
participant MixpanelAPI
User->>CurtainsHelper: finger down / up (MotionEvent)
CurtainsHelper->>CurtainsHelper: "filter: single-pointer, slop, <500ms"
CurtainsHelper->>AutocaptureManager: onTap(x, y, decorView)
AutocaptureManager->>SemanticExtractor: extract(decorView, x, y)
SemanticExtractor-->>AutocaptureManager: ClickEvent.Builder
AutocaptureManager->>MixpanelAPI: trackClick(clickEvent)
AutocaptureManager->>RageClickTracker: recordClick(clickEvent)
RageClickTracker-->>AutocaptureManager: rageClick? (4+ in window)
AutocaptureManager->>MixpanelAPI: trackRageClick(rageClick)
AutocaptureManager->>DeadClickDetector: startDetection(clickEvent, rootView)
DeadClickDetector->>DeadClickDetector: captureBaseline (view hash / semantic snapshot)
Note over DeadClickDetector: wait 500ms
DeadClickDetector->>DeadClickDetector: hasChanged()?
DeadClickDetector-->>AutocaptureManager: onDeadClickDetected(clickEvent)
AutocaptureManager->>MixpanelAPI: trackDeadClick(clickEvent)
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant User
participant CurtainsHelper
participant AutocaptureManager
participant SemanticExtractor
participant RageClickTracker
participant DeadClickDetector
participant MixpanelAPI
User->>CurtainsHelper: finger down / up (MotionEvent)
CurtainsHelper->>CurtainsHelper: "filter: single-pointer, slop, <500ms"
CurtainsHelper->>AutocaptureManager: onTap(x, y, decorView)
AutocaptureManager->>SemanticExtractor: extract(decorView, x, y)
SemanticExtractor-->>AutocaptureManager: ClickEvent.Builder
AutocaptureManager->>MixpanelAPI: trackClick(clickEvent)
AutocaptureManager->>RageClickTracker: recordClick(clickEvent)
RageClickTracker-->>AutocaptureManager: rageClick? (4+ in window)
AutocaptureManager->>MixpanelAPI: trackRageClick(rageClick)
AutocaptureManager->>DeadClickDetector: startDetection(clickEvent, rootView)
DeadClickDetector->>DeadClickDetector: captureBaseline (view hash / semantic snapshot)
Note over DeadClickDetector: wait 500ms
DeadClickDetector->>DeadClickDetector: hasChanged()?
DeadClickDetector-->>AutocaptureManager: onDeadClickDetected(clickEvent)
AutocaptureManager->>MixpanelAPI: trackDeadClick(clickEvent)
Reviews (29): Last reviewed commit: "Align rage click distance with iOS/JS SD..." | Re-trigger Greptile
- Remove redundant API 19 check in WindowSpy (minSdk is 21) - Guard autocaptureOptions(null) with warning instead of NPE - Add depth limit to findViewAtPosition for parity with Compose path - Expand dead click excluded controls docs with full platform breakdown - Add Javadoc to AutocaptureOptions.Builder clarifying defaults
|
Not to overwhelm here but some notable findings to look into from Fable. 1. Autocapture events silently dropped when
|
| try { | ||
| // Choose the appropriate monitor based on click type | ||
| UiChangeMonitor monitor; | ||
| if (clickEvent.isComposeClick()) { |
There was a problem hiding this comment.
Many apps are mixed between compose and xml. Will the change monitor detect a click that goes from a compose view to an xml view since these monitors look to be specific for the view type that came from the click?
Or a better example would be if a compose view is added to an xml view. If a button came from xml but changed the contents inside compose (or vise versa) would we detect this?
Addresses Tyler's review comment about synchronization. Since all public methods require the main thread (view/window operations), the @mainthread annotation enforces the contract via lint. No lock needed — the main thread is single-threaded. Updated Javadoc to document idempotency. Added Tyler's and Greptile's review findings to the PR review report.
contentHash already catches all structural changes — computeTreeHash folds child hashes sequentially, so any node add/remove produces a different hash. The ±5 threshold was redundant and caused false dead clicks for 1-4 node changes (icon toggles, badges). Also adds defensive comment to WindowSpy Curtains try-catch explaining graceful degradation if reflection breaks on future Android versions.
The timestamp field was set in the constructor but never serialized in toProperties() or read anywhere. RageClickTracker maintains its own timestamps via ClickRecord. Removes field, constructor parameter, and Builder setter.
Replace ExtractResult wrapper and ExtractionResult enum with a plain @nullable ClickEvent.Builder return. NOT_FOUND with null builder is equivalent to returning null — the wrapper added no information. Also includes defensive comment on WindowSpy Curtains try-catch.
Nodes from AccessibilityNodeProvider.createAccessibilityNodeInfo() (Compose path) are not sealed. Calling getParent() on them throws IllegalStateException. Catch it and return the hierarchy built so far — the ancestor chain is best-effort for the $elements property.
Move rootNode/targetNode recycling into a finally block so both are always recycled — even when findNodeAtPosition or extractFromNode throws. Previously the catch block leaked both nodes, exhausting the AccessibilityNodeInfo pool on pre-API-28 devices.
Compose screen embeds an XML TextView updated by a Compose button; XML screen embeds a ComposeView button that updates an XML TextView. Both reproduce the false dead click from cross-framework UI changes.
ComposeUiChangeMonitor now also monitors the XML view hierarchy so that a Compose button click which only modifies XML views (e.g. TextView text) is correctly detected as a UI change instead of a false dead click. Changes: - ComposeUiChangeMonitor takes rootView, captures XML content hash baseline, checks both Compose semantic tree and XML hash at timeout - Attach ViewTreeObserver listeners for early cancellation on XML layout/scroll - Extract computeContentHash as shared method on DeadClickDetector - Add instrumented test (AutocaptureMixedInstrumentedTest) and test activity - Remove cross-framework section from demo XmlAutocaptureTestActivity Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reset downTime on ACTION_POINTER_DOWN and ACTION_CANCEL so the existing downTime > 0 guard rejects the subsequent ACTION_UP. Without this, two-finger gestures (pinch, rotate) produce a spurious $mp_click when the first finger lifts. Applied in both CurtainsHelper and AutocaptureManager fallback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Passing null to clickOptions(), rageClickOptions(), or deadClickOptions() would set the backing field to null and NPE on the first isEnabled() call. Added defensive null checks matching the existing MixpanelOptions.Builder pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ews" This reverts commit 22993bd.
This reverts commit e046db4.
Convert screen coordinates to window-relative coordinates before matching against Compose's getBoundsInWindow(). In split-screen or multi-window the window origin is offset from screen (0,0), causing node lookups to miss or pick the wrong element. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
attachRootViewTouchListener was a best-effort fallback for views without a PhoneWindow (Toasts, PopupWindows). In practice it captured zero real interactions: Toasts aren't clickable, and PopupWindow child views consume touches before reaching the root listener. Removing it eliminates an untracked listener leak and OnTouchListener clobbering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Combines countViews() and computeContentHash() into a single snapshotViewTree() method that captures both view count and content hash in one pass, halving the number of tree traversals per dead click check. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
nodeCount was computed but never used by hasChanged(), which only compares contentHash. Removing it simplifies computeTreeHash to return a plain int instead of int[], eliminating per-node array allocations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Tap duration filter: 800ms → 500ms to match iOS and platform long-press conventions - Dead click snapshot: add class name, CompoundButton checked state, and enabled state to hash; add alpha > 0 visibility check Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Route all autocapture events through the Autocapture class instead of direct track() calls. This ensures $mp_autocapture=true is set on all click events (was missing before) and exposes public methods for host apps to emit click events using ClickEvent + Builder. Key changes: - Add trackClick/trackRageClick/trackDeadClick accepting ClickEvent - Make ClickEvent, Builder, and toProperties() public for host apps - Require x, y, elementId in Builder constructor (mandatory fields) - Add null checks on all public method parameters - Replace EventEmitter interface with Autocapture reference - Extract trackAutocaptureEvent/mergeProperties shared helpers - Update AutocaptureManager and SemanticExtractor call sites Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace squared distance comparison with Math.sqrt() to match the Euclidean distance calculation used by iOS and JS SDKs. Functionally equivalent but improves cross-platform code consistency. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
$mp_click,$mp_rage_click, and$mp_dead_clickevent detection for both XML Views and Jetpack Compose UIsAutocaptureOptionsconfiguration (disabled by default, opt-in viaMixpanelOptionsbuilder) with per-feature options (ClickOptions,RageClickOptions,DeadClickOptions)Window.Callbackwrapping, withWindowSpyfor dialog/popup/menu window trackingSemanticsNodetree traversal (graceful degradation when Compose is not present)Privacy
Autocapture does not capture visible text content (
$el_text) from tapped elements. Tracking text can be invasive and raise privacy concerns. The complexity of nested view hierarchies can also cause text extraction to capture content from unintended views — for example, tapping aLinearLayoutcontainer might extract text from a deeply nestedTextViewthat isn't semantically related to the tap.The captured properties (
$el_id,$el_tag_name,$attr-aria-label,$attr-role,$elements,$x,$y) are purely structural UI metadata with no PII risk.Captured Properties
$x,$y$el_id$el_tag_nameButton,TextView)$attr-aria-label$attr-roleButton,Switch,Slider)$elementsArchitecture
TouchInterceptorWindow.CallbackSemanticExtractorComposeSemanticHelperRootForTest/SemanticsNodeRageClickTrackerDeadClickDetectorAutocaptureManagerWindowSpyTests
AutocaptureInstrumentedTest)ComposeAutocaptureInstrumentedTest)Demo app
mixpaneldemofor manual validationHardening (code review fixes)
StackOverflowErrorWeakHashMapiteration instop()to avoidConcurrentModificationExceptionWindowSpy.getRootViews()returning stale dataClickEvent.isComposeClick()unreliability fromWeakReferenceGCAccessibilityNodeInfoleak on exceptionTest plan
./gradlew :analytics:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.mixpanel.android.mpmetrics.AutocaptureInstrumentedTest./gradlew :analytics:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.mixpanel.android.mpmetrics.ComposeAutocaptureInstrumentedTest./gradlew :analytics:connectedAndroidTest