dashi_flutter
Umami Analytics for Flutter — pageviews, custom events
and identify, with automatic screen tracking, device context and
background flushing. Works on web, mobile and desktop.
This package wraps the pure-Dart dashi core and
adds the Flutter glue. This README is self-contained — everything from the core
is repeated here, so you don't need to read both.
dashi (出汁) is the stock at the base of umami — the thing that feeds the flavor.
Install
# pubspec.yaml
dependencies:
dashi: ^1.0.0
dashi_flutter: ^1.0.0
import 'package:dashi/dashi.dart'; // the client, queue, transports
import 'package:dashi_flutter/dashi_flutter.dart'; // observer, context, lifecycle
The 60-second version
import 'package:dashi/dashi.dart';
import 'package:dashi_flutter/dashi_flutter.dart';
import 'package:flutter/material.dart';
// 1. One client for the whole app.
final umami = UmamiClient(
endpoint: Uri.parse('https://umami.example.com'),
websiteId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
hostname: 'com.example.app',
contextProvider: flutterContextProvider(appName: 'MyApp', appVersion: '1.0.0'),
);
void main() {
WidgetsFlutterBinding.ensureInitialized();
attachLifecycleFlush(umami); // 3. flush the queue when backgrounded
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
// 2. track every screen automatically.
navigatorObservers: [DashiNavigatorObserver(client: umami)],
home: const HomeScreen(),
);
}
}
Then anywhere in your app:
umami.event('subscribe_clicked', url: '/pricing'); // a custom event
umami.identify('user-42'); // who the user is
The rest of this README explains each piece.
Step 1 — Create the client
Create one UmamiClient and keep it around (top-level variable, a provider,
your DI container — your call):
final umami = UmamiClient(
// Where your Umami instance lives.
endpoint: Uri.parse('https://umami.example.com'),
// The website you're tracking:
// Umami dashboard → Settings → Websites → (your site) → "Website ID".
websiteId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
// A label for this app (your bundle id or a domain).
hostname: 'com.example.app',
// Builds the device context for you (locale, screen size, and a proper
// User-Agent on mobile/desktop — null on web). See "Context" below.
contextProvider: flutterContextProvider(appName: 'MyApp', appVersion: '1.0.0'),
);
Good to know: every tracking call is fire-and-forget — it never throws and
never blocks the UI. If the network is down, events are queued and retried (see
Offline queue). You can await them, but you don't have to.
Step 2 — Track screens
The easy way: automatic tracking with the observer
Add DashiNavigatorObserver to your app and every navigation becomes a pageview
— no manual calls:
MaterialApp(
navigatorObservers: [DashiNavigatorObserver(client: umami)],
// ...
);
It tracks push, replace and pop (when you go back, the revealed screen is
tracked), and it chains the referrer for you (each pageview knows the previous
screen). By default it tracks named routes and uses route.settings.name as the
URL.
Customize it:
DashiNavigatorObserver(
client: umami,
// Decide which routes to track (default: only routes with a name):
routeFilter: (route) => route.settings.name != null,
// Map a route to the tracked URL (default: route.settings.name):
routeNameMapper: (route) => route.settings.name ?? 'unknown',
// Don't emit a pageview when popping back:
trackPopAsPageView: false,
);
Using go_router? Register the observer on the router instead of MaterialApp:
GoRouter(
observers: [DashiNavigatorObserver(client: umami)],
routes: [/* ... */],
);
The manual way: call pageView yourself
You don't have to use the observer. A pageview = "a screen became visible", so just call it whenever that happens:
umami.pageView(url: '/home');
umami.pageView(
url: '/product/42',
title: 'Cool sneakers',
referrer: '/home', // where they came from (optional)
);
Use this for screens the observer can't see (custom flows, tabs, modals, a
PageView/IndexedStack, server-driven UI…). You can mix both freely.
Step 3 — Track a custom event
A custom event = "something happened that isn't a screen change" (a tap, a purchase…). It's a pageview with a name:
// Simplest form:
umami.event('subscribe_clicked', url: '/pricing');
// With structured data (anything JSON-serializable):
umami.event(
'purchase',
url: '/checkout',
data: {'plan': 'pro', 'amount': 49.0, 'currency': 'EUR'},
);
Call it straight from a callback:
ElevatedButton(
onPressed: () => umami.event('cta_click', url: '/home', data: {'cta': 'hero'}),
child: const Text('Subscribe'),
);
Step 4 — Identify a user (no Riverpod needed)
By default sessions are anonymous. Once you know who the user is (usually right
after login), call identify with a stable id you control. From then on, every
pageview and event is attributed to that user:
umami.identify('user-42');
// You can attach session properties:
umami.identify('user-42', data: {'plan': 'pro', 'role': 'admin'});
There's nothing more to it — call it from wherever you handle login:
Future<void> onLoginSuccess(User user) async {
umami.identify(user.id);
}
(See Wiring with Riverpod below if you use Riverpod — but it's entirely optional.)
Step 5 — Flush when the app is backgrounded
When the app goes to the background it might be killed before queued events are
sent. attachLifecycleFlush flushes the offline queue on paused/detached:
void main() {
WidgetsFlutterBinding.ensureInitialized();
attachLifecycleFlush(umami);
runApp(const MyApp());
}
It returns a DashiLifecycleFlusher; call its dispose() if you ever need to detach
the listener.
The context
flutterContextProvider builds the UmamiContext for you from the device:
contextProvider: flutterContextProvider(appName: 'MyApp', appVersion: '1.0.0'),
It reads the locale and physical screen size from PlatformDispatcher, and
builds a descriptive User-Agent like Dashi/1.0.0 (MyApp; android).
The User-Agent rule (read this — it bites everyone)
Umami silently drops requests from bots: a missing or bot-like User-Agent gets your events thrown away while still returning HTTP 200.
- On mobile/desktop: a descriptive UA is required.
flutterContextProviderproduces one for you. - On web: the UA is
nullon purpose — the browser sends its own (you're not allowed to set it), and that one passes. Again handled for you.
If you need full control, you can skip the helper and build the context by hand:
contextProvider: () => const UmamiContext(
language: 'en-US',
screen: '1080x2400',
userAgent: 'MyApp/1.0 (com.example.app; Android 14)', // null on web!
),
Offline queue
If a send fails (no network, server down…), dashi keeps the event and retries —
automatically on the next successful send, or when you call flush(). Replayed
events keep their original timestamp, so stats stay accurate.
final umami = UmamiClient(
endpoint: Uri.parse('https://umami.example.com'),
websiteId: '...',
hostname: 'com.example.app',
contextProvider: flutterContextProvider(appName: 'MyApp', appVersion: '1.0.0'),
// Defaults shown — all optional:
queue: InMemoryQueueStore(maxSize: 200), // keep up to 200 pending events
eventTtl: const Duration(hours: 48), // drop events older than this
flushChunkSize: 25, // send them in batches of 25
);
await umami.flush(); // force a flush now
Three storage modes:
| You want… | Use |
|---|---|
| Default in-memory retry (lost on app restart) | InMemoryQueueStore() (default) |
| No retry at all — drop failed events | const NoopQueueStore() |
| Persist across restarts (file, DB, prefs…) | your own UmamiQueueStore |
Transports
How dashi talks HTTP. The default is LuckyTransport (built on
Lucky Dart / dio). For a dio-free transport
backed by package:http:
UmamiClient(
// ...
transport: HttpTransport(),
);
Debug mode (don't send anything)
enabled: false runs the full pipeline (build the payload, log it) without any
network call — useful in tests or local dev. Add a logger to see what would be
sent (it also surfaces real errors, since the API never throws):
UmamiClient(
// ...
enabled: false,
logger: (r) => debugPrint('[umami] ${r.level.name}: ${r.message}'),
);
Cleaning up
dispose() does a final best-effort flush and releases resources:
await umami.dispose();
A complete example
A runnable demo (client + observer + event + identify + lifecycle) lives in the
example/ folder.
Advanced: wiring with Riverpod
Riverpod is not a dependency of this package. This is just one way to expose the client; the plain
Step 4above works without any state-management library.
final umamiProvider = Provider<UmamiClient>((ref) {
final client = UmamiClient(
endpoint: Uri.parse('https://umami.example.com'),
websiteId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
hostname: 'com.example.app',
contextProvider: flutterContextProvider(appName: 'MyApp', appVersion: '1.0.0'),
);
ref.onDispose(client.dispose);
return client;
});
// Identify automatically whenever the auth state changes:
ref.listen(authStateProvider, (previous, next) {
final user = next.user;
if (user != null) {
ref.read(umamiProvider).identify(user.id);
}
});
Requirements
A self-hosted Umami instance, version 3.1.0 or newer.
Links
- dashi — the pure-Dart core (also usable outside Flutter).
- Repository & issues.
Libraries
- dashi_flutter
- Flutter bindings for the dashi Umami Analytics client.