One canvas, no DOM
On the web, Flutter renders into a canvas. Tools that autocapture clicks, build heatmaps or record replays from the DOM see one element and nothing inside it. On iOS and Android, native view inspectors see one view.
Flutter draws every pixel itself, so tools built for web pages or native view hierarchies see very little of a Flutter app. This guide covers what makes Flutter awkward to measure, how to get screen names, friction and accessibility right, and how the Metrickle package handles each in pure Dart.
| Screen | Chart | Sessions | Friction |
|---|---|---|---|
| Home | 58,210 | 4% | |
| Plans | 23,940 | 9% | |
| Checkout | 9,880 | 27% | |
| Onboarding step 2 | 5,120 | 12% |
Checkout has a quarter of its sessions hitting friction, mostly rage taps on pay_button from people with large text on.
Most analytics tools assume a page made of DOM elements or a screen made of native views. Flutter apps have neither, and that changes what can be captured automatically.
On the web, Flutter renders into a canvas. Tools that autocapture clicks, build heatmaps or record replays from the DOM see one element and nothing inside it. On iOS and Android, native view inspectors see one view.
A screen view needs a name, and Flutter routes often don’t have one. Dialogs, menus and bottom sheets are routes too, and nested navigators keep their own history.
Plugins that wrap native analytics SDKs need configuration for each platform, and behave a little differently on each. Desktop and web support varies from plugin to plugin.
Flutter knows when large text, a screen reader or reduced motion is on. General analytics plugins don’t record it, so you can’t see who your layouts break for.
Events held only in memory disappear when the app is closed from the switcher or loses signal. Mobile analytics has to queue to disk and retry.
There’s no CSS selector to say which button was tapped. Without a stable identifier, a friction report can only say “something on Checkout”.
A navigator observer sees every push, pop and replace, so it’s the one place to record screen views. It takes the name from RouteSettings.name, which go_router sets from each route’s name.
Unnamed routes and popups are skipped rather than recorded as noise. If your routes don’t carry names, pass a nameExtractor that works one out.
GoRoute a name, and add an observer to every ShellRoute, because each shell has its own navigator.NavigatorObserver works the same way.screen('Onboarding step 2') for steps inside one route, like a PageView.final router = GoRouter(
observers: [MetrickleNavigatorObserver()],
routes: [
GoRoute(name: 'Home', path: '/', builder: …),
ShellRoute(
// A shell has its own navigator: observe it too
observers: [MetrickleNavigatorObserver()],
routes: [
GoRoute(name: 'Checkout', path: '/checkout', builder: …),
],
),
],
);
Some of the most useful signals in a web app translate directly to Flutter. MetrickleScope watches taps and navigation and records them without any tagging.
Rage taps are three taps within a second inside 30 logical pixels. U-turns are going from screen A to B and straight back to A within seven seconds. Form errors come from a formError() call in your validator, recording the field and the reason, never the value.
To say which widget was tapped, the scope looks for a Semantics identifier, then a ValueKey<String>, then falls back to the widget’s class. An identifier is worth adding to every button that matters: the same string finds the widget in integration tests.
// One identifier: friction reports name it,
// and integration tests can find it
Semantics(
identifier: 'pay_button',
child: FilledButton(
onPressed: _pay,
child: const Text('Pay now'),
),
);
// Validation failed? Say which field, never the value
Metrickle.instance.formError(
form: 'checkout', field: 'postcode', reason: 'invalid');
Flutter exposes the device’s accessibility settings through MediaQuery, and they’re the settings most likely to break a layout that looked fine in review. Metrickle attaches them to every event, so any funnel, screen or friction hotspot can be split by setting and compared with people using none.
This is where Flutter-specific bugs show up: a checkout that converts at 9% overall and 4% for people with large text usually has an overflow or a clipped button somewhere in it.
| MediaQuery value | Recorded as | What it often breaks |
|---|---|---|
textScaler above 1.15 | Large text | Fixed-height rows overflow, labels truncate, two buttons on one line push the second off screen |
accessibleNavigation | Screen reader | Icon buttons with no label, custom gestures with no semantic action, focus lost after a dialog closes |
boldText | Bold text | Text that fitted at regular weight wraps or overflows |
disableAnimations | Reduced motion | Flows that wait for an animation to finish before enabling the next step |
highContrast (iOS) | High contrast | Brand colours that pass contrast at normal settings and fail when the system raises it |
invertColors (iOS) | Inverted colours | Photos and illustrations inverted along with the interface |
highContrast and invertColors are only reported by iOS. On Android, high-contrast text isn’t exposed to Flutter. Large text is recorded when the system text scale is above 115%.
No platform channels and no native SDKs to configure. The same package runs on iOS, Android, web, macOS, Windows and Linux.
Events are queued on disk, up to 1,000 for 7 days, and flushed every 5 seconds, at 20 events and when the app goes to the background. Failed sends back off from 1 to 60 seconds.
App opens, returns to the foreground and backgrounding are sent automatically, so sessions mean what you’d expect on mobile.
Text field contents are never read. No advertising ids or device serials. The anonymous id is a random UUID, and cookieless: true stores nothing at all.
Surveys open in a bottom sheet that meets WCAG 2.2 AA: 48dp targets, labelled scale buttons, text scaling without truncation, high contrast and reduced motion.
Call identify() after sign-in and reset() on sign-out, and the app and your website count one person once.
Run flutter pub add metrickle and call Metrickle.init() with your app’s write key before runApp.
Add MetrickleNavigatorObserver to your router and wrap the app in MetrickleScope through MaterialApp.builder.
Screens, sessions, friction and accessibility settings arrive within seconds. Add track() calls for the actions that count as conversions.
The Flutter SDK is new. Here’s what it doesn’t do yet, so you can plan around it.
device_info_plus if you want it.Yes. They don’t share anything, so both can run in the same app. Teams often keep Firebase for install attribution and use Metrickle for friction, accessibility and what happens after release.
Any router that accepts a NavigatorObserver works. With go_router, name each GoRoute and add an observer to every ShellRoute. Where routes have no names, pass a nameExtractor.
Yes, the package runs on every Flutter platform, including web. Screens, events, friction and accessibility settings work there. The web script tag’s heatmaps and replay don’t, because Flutter web draws into a canvas they can’t look inside.
No. Text field contents are never captured, only identifiers and semantics labels. Form errors record the field and the reason you give, never the value. A beforeSend hook can scrub or drop any event before it leaves the device.
Yes. Flutter reports accessibleNavigation when VoiceOver or TalkBack is running, and Metrickle records it as a setting on each event. It never asks people about disability and can’t tell why a setting is on.
They wait in a queue on disk, up to 1,000 events for up to 7 days, and are sent when the connection returns. Sends that fail are retried with a back-off from 1 to 60 seconds.
One Dart package on every platform. Screens, friction and accessibility settings from the first launch. Free to start.
Start free