Skip to main content
Configure automatic screen and interaction capture for Flutter applications using the Userpilot Flutter SDK. The Userpilot Flutter SDK can automatically capture screen views and user interactions without any explicit instrumentation in your app. Auto Capture runs entirely in Dart — no build-time plugin is required, and the same Dart integration is used on Android and iOS.

SDK Configuration

Auto capture needs three integration points:
  1. Userpilot.initialize(..., UserpilotOptions(...)) — starts the native Userpilot SDK and, when autocapture options are provided, starts the Dart autocapture pipeline.
  2. UserpilotAutocaptureWidget(child: ...) — wraps your app so the SDK can attach a global pointer route and listen for scroll settle notifications (PageView, TabBarView, wheel pickers).
  3. UserpilotAutocaptureNavigatorObserver() — added to each Navigator you want tracked for $screen and dialog/bottom-sheet view_presented events.
Call Userpilot.initialize(...) when your app boots (often from an initState / splash path after WidgetsFlutterBinding.ensureInitialized() if you await it before runApp).
NoteAuto Capture automatically tracks screen views and ignores manually sent screen events while enabled.If you are migrating from manual screen tracking, please review the migration guide here.

Screen Capture

UserpilotAutocaptureNavigatorObserver() returns a plain NavigatorObserver. Whenever Flutter pushes, pops, or replaces a named PageRoute, the observer emits a $screen event whose title is the route’s RouteSettings.name. Routes without an explicit name are ignored so framework-generated route classes do not leak into screen analytics. The observer is stateless and safe to instantiate multiple times. Add it to every Navigator you want tracked (root navigator, shell navigators, tab branch navigators).
Manual screensUserpilot.screen('<title>') is a no-op while screen autocapture is enabled. To send screens manually, leave enableScreenAutoCapture: false in UserpilotOptions.

GoRouter

Add the observer to GoRouter’s observers list. For nested navigators (ShellRoute, StatefulShellBranch), add a fresh observer instance per navigator:
GoRouter limitations:
  • StatefulShellRoute.indexedStack keeps every branch alive in an IndexedStack. Switching tabs does not push or pop, so $screen only fires on the first visit to each branch. Use a non-stateful ShellRoute if you need a screen event on every tab swap.
  • CustomTransitionPage must be constructed with key: state.pageKey and name: state.matchedLocation. Without name, the generated page route is unnamed and no $screen event is emitted.

AutoRoute

Pass a fresh observer instance through AutoRoute’s navigatorObservers builder. The builder is invoked once per nested router (root, every AutoTabsRouter branch, etc.), so every navigator in the tree gets its own observer with no extra setup:

Interaction Capture

When enableInteractionAutoCapture: true, the SDK emits an event for the following user interactions: Each interaction maps to a high-level analytics category (tap, text_change, selection_change, value_change, view_presented) — that is the event name that reaches your analytics backend.

Component Value Gating

By default, user-input values are stripped from event payloads — slider values, toggle states, picked dates and times, and dropdown selections do not leave the device. To include them, pass enableInteractionValueCapture: true in UserpilotOptions. Position and configuration fields (selected_index, min, max, direction) are always captured because they don’t reveal user input.

Privacy Controls

Wire Metadata Keys

Interaction events use the keys in payload_schema.dart (_allowedKeys). Common fields: Screen events ($screen) still use a single title field on the native bridge.

hierarchy Format

Built from the hit element up to maxHierarchyDepth, then the current screen name. Each segment looks like: WidgetType:attr__desc="…",attr__id="…",attr__index="n"
  • attr__desc — only the semantics accessibility label for that ancestor. Omitted when enableInteractionAccessibilityLabelCapture: false, under UserpilotRedactText, or when no label exists.
  • attr__id — Semantics.identifier or ValueKey value when present. Semantics.identifier is omitted when enableInteractionAccessibilityLabelCapture: false or under UserpilotRedactText; ValueKey remains structural metadata.
  • attr__index — the widget’s index among its siblings. For a child of a lazy list (ListView.builder, SliverList), this is the item’s index in the list, so the same row reports the same value at any scroll position.
Segments are joined with ;. Under UserpilotRedactText, hierarchy is preserved but text/accessibility attributes are omitted so redacted text does not appear in the path.

Tuning maxHierarchyDepth

maxHierarchyDepth controls how many ancestors a captured hierarchy includes, and since 1.2.0 it also bounds how far up the accessibility fields are read from. Choose it for the elements you need to target on. Since 1.2.0, building a hierarchy no longer scales with the size of the screen — on a 400-row screen the cost of resolving a tap measured 71.8ms down to 23.2ms, and is now flat rather than growing with the row count. The work this setting bounds is a small fixed amount, roughly one step per level, so lowering it is not a meaningful performance lever.
What tells two repeated widgets apart is the attr__index on a layout ancestor. A shallow depth can stop before reaching that ancestor, and then every row of a list produces an identical hierarchy and the rows are treated as one element.
Verify before you keep a lower depthTwo failures here are silent. Interactions on different rows can arrive as one, so you can no longer target a single row. And an accessibility label declared further above the element than the depth allows is no longer captured, so a rule matching it stops matching.After lowering the setting, tap two different rows of the same list and confirm in Userpilot that they report different hierarchy values, and check that any accessibility_label or accessibility_identifier your rules match on is still present. If either fails, return to the default 25.

UserpilotRedactText

Wraps a subtree so autocapture replaces every text label it would otherwise capture (target_text, placeholder, dialog titles, trigger source names, accessibility strings, etc.) with ****. The interaction event still fires — the widget is still identified by type, position, and structure — only the human-readable text is masked. Capture configuration is applied before redacted values leave the SDK. If enableInteractionTextCapture: false, text fields such as target_text are removed from the payload instead of being sent as ****. If enableInteractionAccessibilityLabelCapture: false, accessibility fields are removed from the payload and from hierarchy attributes.
hierarchy remains on redacted events, but text/accessibility attributes such as attr__desc and Semantics.identifier are omitted. Remove the wrapper to disable masking for that subtree.

UserpilotIgnoreInteractions

Wraps a subtree so autocapture skips interaction events whose hit-tested widget lies inside it (taps, value changes, scroll selections, debounced text-field updates tied to that field, etc.). Route-driven $screen events are not suppressed by UserpilotIgnoreInteractions — they come from the navigator observer, not from widget hit testing. The host app’s own gesture handlers (onPressed, onTap, …) still run — only autocapture ignores those interactions.
Use UserpilotIgnoreInteractions for interactions whose mere occurrence would leak intent (e.g. tapping a row in a contacts list, where row position correlates 1:1 with contact identity). Both widgets are passive markers — their build returns child unchanged. Wrap the outermost widget you want affected; descendants of the wrapper are covered, ancestors are not.

Runtime Controls

To stop or resume collection without changing options — for example a “private mode” — use:
Both calls are idempotent. They preserve the options supplied to Userpilot.initialize(...). While stopped, no screen or interaction events are recorded regardless of other configuration. This is a global toggle that overrides per-subtree settings.

Limitations

  • Routes pushed without RouteSettings.name are ignored for screen autocapture. Always pass a name (or use named routes via onGenerateRoute) when a route should be tracked as a screen.
  • Multiple Navigator instances — every nested Navigator has its own observers list. Add UserpilotAutocaptureNavigatorObserver() to each navigator you want tracked.
  • Non-PageRoute transitions — the observer only treats PageRoute subclasses as screens. Custom transitions that extend TransitionRoute directly are not captured. Both go_router and auto_route produce PageRoute subclasses for their default page builders, so this only affects bespoke routing implementations.
  • Text-field attachment — editable fields are discovered when autocapture is registered and when focus moves (and during internal rescans). A field that appears without a focus change may not be watched until the next scan trigger.
  • Duplicate taps — identical tap payloads within a short window (~500 ms) may be dropped to avoid double events from rebuilds or pointer routing.