dashi_flutter

pub package CI License: MIT

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. flutterContextProvider produces one for you.
  • On web: the UA is null on 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 4 above 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.

Libraries

dashi_flutter
Flutter bindings for the dashi Umami Analytics client.