pug_flutter captures events from Flutter apps on mobile, desktop and the web.
It authenticates with your public key (pub_...), which is write-scoped
(events + identify) and safe to ship in the app, and follows the
same model as every Pug SDK: initialize once, track events, identify
users, reset on sign-out.
Install
flutter pub add pug_flutterflutter pub add pug_flutter adds it to pubspec.yaml and fetches it; to pin
the version yourself, add it under dependencies instead:
dependencies:
pug_flutter: ^0.0.5| Requirement | Minimum |
|---|---|
| Dart SDK | >=3.7.0 <4.0.0 |
| Flutter | >=3.29.0 |
Import it wherever you call the SDK:
import 'package:pug_flutter/pug_flutter.dart';Smoke-test by adding a minimal Pug.init in main() (see
Initialize) and running the app - with auto-tracking on, an
app_open event appears in Live within a few seconds. (page_view events
need the PugRouteObserver - see Auto-tracking.)
Working with a coding agent? Paste this instead - it points the agent at the markdown copy of this page, so it works from the reference below rather than from memory. More on the approach in Install with AI.
Add Pug product analytics to this Flutter app.
Read these first - they are the current source of truth, and more recent than your training data:
- https://docs.pug.sh/sdks/flutter/index.md - SDK reference
- https://docs.pug.sh/reference/well-known-events/index.md - the well-known event catalog
Then read this codebase before you write anything, and work out what the app actually does: its screens and navigation graph, its main models, and the handful of things a user opens the app to complete. The events worth tracking are the moments the app exists to produce - an account created, an order placed, a workout finished - not every tap. If you cannot state the core action in one sentence, keep reading.
Then:
1. Add pug_flutter to pubspec.yaml.
2. Await Pug.init() in main(), after WidgetsFlutterBinding.ensureInitialized() and before runApp().
3. Keep the project ID and public key (pub_...) out of source - pass them with --dart-define and read them with String.fromEnvironment.
4. Register PugRouteObserver on the app's navigatorObservers, otherwise screen views never fire.
5. Add tracking for the three or four actions you identified - the ones someone would look at in a weekly review.
6. If this app has authentication, call Pug.identify() after sign-in and Pug.reset() on sign-out.
Name events from the catalog before you invent one. Flutter gets a generated named-argument method for all 127 well-known events - Pug.track.purchase(productId: ..., amount: ...) - so the compiler checks the properties for you, and the dashboards those events drive stay empty for a custom name. Reach for Pug.track.<event> first, search the catalog for every action you are about to track, and pass schema-external properties through extras. Use Pug.track(kind, props: {...}) with a custom snake_case name only when nothing in the catalog fits, and when you do, tell me which event you considered and why it did not.
Flutter consent has two states and starts granted, which is not the same as the Web SDK - check the consent section before you add a banner. Do not use any option that is not in the SDK reference above. When you are done, tell me in one sentence what you concluded this app does, then list the files you changed, the event names you chose, and the dart-defines I need to pass.Initialize
Await Pug.init once in main(), before runApp(), with your project ID
and public key - every other call needs it first. Find your project ID in the
dashboard URL (/p/<projectId>), and your public key on your project
Overview or under Settings > API Keys.
import 'package:pug_flutter/pug_flutter.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Pug.init(
'YOUR_PROJECT_ID',
const PugOptions(
apiKey: 'pub_your_public_key',
),
);
runApp(const MyApp());
}Pug.init returns Future<void> and throws ArgumentError if projectId
or apiKey is empty. A second init while running is silently ignored (a
warning is logged via PugLogger); call Pug.destroy() first to reinitialize.
| Option | Type | Default | Description |
|---|---|---|---|
apiKey |
String |
required | Public key (pub_...), sent as x-api-key. |
endpoint |
String |
https://api.pugs.dev |
API base URL: set it only when self-hosting. |
batch |
BatchConfig |
maxSize 10, maxWaitMs 5000, maxQueueSize 1000 |
Queue size and flush timing; persisted to SharedPreferences. |
session |
SessionConfig |
idleTimeout 30 min, maxDuration 24 h |
Session timeouts as Durations. |
autoTrack |
bool |
true |
Emit app_open and app_close on lifecycle changes. Page views and install events have their own switches. |
autoPageViews |
bool |
true |
Emit a navigation event on route changes: screen_view on mobile, page_view on web (needs PugRouteObserver). |
autoInstallEvents |
bool |
true |
Emit app_install and app_update at launch. See Auto-tracking. |
trackingConsent |
TrackingConsentConfig |
const TrackingConsentConfig() |
Consent gate: two states, granted by default, unlike the Web SDK. See Tracking consent. |
dryRun |
bool |
false |
Log via PugLogger without sending. |
Advanced overrides (logger, storage, transport, autoPropertyProvider)
let you inject test doubles or custom collection.
Track events
Events are the foundation of every analytic in Pug. The first argument is the
event name (the kind): custom names work without a schema, while
well-known events add typed properties and
richer dashboards. Names starting with pug. are reserved.
// Custom event - any name
Pug.track('button_clicked', props: {'label': 'Sign up', 'screen': 'pricing'});
// Typed well-known event - compile-time checked
Pug.track.purchase(productId: 'sku-1', amount: 99.50, currency: 'USD');Pug.track(...) never throws - calls are wrapped in try/catch and queued in
the background. The generated Pug.track.* namespace has a typed method per
well-known event; pass schema-external properties via extras: {...} (on a key
collision, the named argument wins). For runtime-determined names, call
Pug.track(kind, props: {...}) directly.
Property values accept String (truncated to 1024 UTF-8 bytes), bool,
int, finite double/num (non-finite dropped), and DateTime (UTC epoch
ms). Iterable/Map are JSON-encoded to a string; null is dropped - omit the
key instead.
TrackOptions (on Pug.track and every typed method):
| Option | Type | Default | Description |
|---|---|---|---|
immediate |
bool |
false |
Bypass the queue and send now (falls back to the queue on transient failure). |
timestampMillis |
int? |
wall clock | Override the event time as epoch milliseconds. |
Pug.track.purchase(
productId: 'sku-789', amount: 99.0, currency: 'USD',
options: const TrackOptions(immediate: true),
);Identify and sessions
Identity turns anonymous events into actionable profiles. The pattern: events
accumulate against an anonymous ID, you Pug.identify() on sign-in to merge
that history into a known profile, and Pug.reset() on sign-out. Use a
stable, unique external ID - your database user ID, not an email (which can
change). Only the first identify after an anonymous session merges the prior
activity; the same external ID always resolves to the same profile, and traits
are merged (never removed) server-side.
// On sign-in
await Pug.identify('user-123', traits: {'email': 'user@example.com', 'plan': 'pro'});
// On sign-out
Pug.reset();
// New session ID, identity kept
Pug.rotate();Pug.identify(externalId, {traits}) returns Future<void> and never throws -
invalid input and transport failures are logged and the future completes
normally; a call before Pug.init() is ignored with a warning. reset() is
synchronous - it generates a new device ID, a new anonymous ID, and a new
session, and removes the stored externalId (the profile stays in the
dashboard). Drain the queue with await Pug.flush(); fully tear down with
Pug.destroy().
| Function | Clears identity | New device ID | New session ID |
|---|---|---|---|
rotate() |
No | No | Yes |
reset() |
Yes | Yes | Yes |
Sessions
Sessions group events within a single visit. The SDK creates a session ID on the first event, extends it on each event within the idle window, and starts a new one once the idle or max-duration timeout passes - identity is preserved across the boundary.
The session is persisted to SharedPreferences and restored on cold start
(re-checking the timeouts). Configure the timeouts at init with
session (idleTimeout/maxDuration). For the anonymous->identified model,
see Core concepts.
Auto-tracking
With auto-tracking on (the default), the SDK captures app lifecycle and
installs for you, and navigation once you wire a PugRouteObserver - no manual
Pug.track() calls. Every auto-tracked event
also carries the standard auto-properties, and the
server attaches enrichment properties to every event.
Navigation is captured when you wire a PugRouteObserver into your
MaterialApp:
MaterialApp(
navigatorObservers: [PugRouteObserver()],
// ... rest of your config
)On each route change the SDK emits a navigation event: screen_view (with
screenName set to the route name) on iOS/Android, and page_view on
web; desktop targets emit neither. The navigation event itself carries no
url props - instead every event picks up the current and previous route as the
$url and $referrer auto-properties, so route
context is present even when autoPageViews is off. Route names come from
route.settings.name, falling back to the route’s runtime type.
App lifecycle is observed automatically (no wiring): app_open when the app
enters the foreground, and app_close when it is backgrounded (hidden,
paused or detached) - sent with immediate: true so it arrives before the OS
kills the process. A transient inactive state is not a close, since the app is
still visible: an incoming call, the app switcher, a control-centre swipe.
Installs and updates (autoInstallEvents, on by default) are checked once at
launch: app_install on the first launch the SDK sees on a device, and
app_update, carrying previousVersion, on the first launch after the app
version changes. The check compares the $appVersion
auto-property with the version stored at the
last launch, so a custom PugAutoPropertyProvider that supplies no
$appVersion turns it off. An app that already used Pug doesn’t report its
existing users as installs, but Pug.destroy() clears the stored version along
with the rest of Pug’s state, so a destroy-and-re-init cycle does look like a
fresh install.
Consent and privacy
Tracking consent
pug_flutter gates capture with two states (TrackingConsent.granted and
TrackingConsent.denied) and starts granted. There is no cookieless middle
state and no isConsentPending(): the Web SDK’s three-state model is a browser
concern, where the identifier is a cookie and the banner is a page element.
await Pug.init(
'YOUR_PROJECT_ID',
const PugOptions(
apiKey: 'pub_YOUR_PUBLIC_KEY',
trackingConsent: TrackingConsentConfig(
defaultConsent: TrackingConsent.denied,
persist: true,
),
),
);
Pug.optInTracking();
Pug.optOutTracking();
Pug.isTrackingEnabled();
Pug.getTrackingConsent();Note the field is defaultConsent, not the Web SDK’s initial.
| Field | Type | Default | Description |
|---|---|---|---|
defaultConsent |
TrackingConsent |
granted |
Starting state, used when nothing is persisted. |
persist |
bool |
false |
Write the choice to __pug_<projectId>_consent__ and restore it on the next Pug.init(). Otherwise the choice lasts for the process and resets to defaultConsent on restart. |
While denied, Pug.track(...), the typed Pug.track.* methods, Pug.identify(...)
and the automatic lifecycle, page-view and install events are all dropped,
resuming once consent is granted.
Consent is independent of dryRun, which suppresses delivery without changing
consent. Consent activity (denied drops, persistence failures) is reported only
through the configured PugLogger, and the default NoopPugLogger is silent, so
pass DebugPrintPugLogger() while you’re wiring a prompt up.
Redacting PII
pug_flutter has no device-side redaction - no beforeSend, redactUrlParams
or element-text capture - so keep PII out of the properties you pass to
Pug.track(...) yourself.
Upgrading
v0.0.5
Campaign capture and push device registration leave the public API until the backend accepts them. The code stays in the package, to be switched back on in a later release, but code that used either no longer compiles:
| Removed | Fix |
|---|---|
PugOptions.autoCaptureCampaigns and linkProvider |
Delete them. No $utm* or click-id auto-properties are attached any more |
PugPush, PushProvider, PushSubscription, PushSubscribeOptions |
Delete the registration code - the devices endpoint isn’t live, so it could not succeed |
PugLinkProvider, AppLinksPugLinkProvider, campaignPropertiesFromUri |
Delete them |
PugTransport.subscribeDevice |
Delete it from a custom transport, which no longer has to implement it |
The notification events are unaffected. Pug.track.notificationClicked(...),
notificationReceived and notificationDismissed still work alongside your own
push integration - send them with TrackOptions(immediate: true), since those
callbacks often fire while the app is backgrounded and the OS can suspend it
before the next flush.
app_installandapp_updateare tracked for you, behind the newautoInstallEventsoption (default on). An app that already used Pug reports no installs on upgrade, and its firstapp_updatecomes with the release after the one that ships 0.0.5. If you already track either event yourself, setautoInstallEvents: false, or each is counted twice.app_closeno longer fires oninactive. An incoming call, the app switcher or a control-centre swipe used to log anapp_close/app_openpair; now onlyhidden,pausedanddetachedcount as leaving, so expect fewer of both.
v0.0.4
Nothing here breaks compilation - the changes are all in the auto-properties each event carries.
$deviceTypeis new:tv,mobileordesktop. Android TV is detected from the device’s system features rather than the OS, which reports plainandroidthere, so$platformstaysandroidand matches the other SDKs. Omitted rather than guessed on web and on any target it can’t classify.$deviceModelno longer ships placeholders.device_info_plusreturns literal"Unknown device"/"Unknown Model"strings when its marketing-name lookup misses; those now fall back to the device identifier (iPhone,MacBookPro18,3). macOS was affected on any Mac absent from the plugin’s table.$osVersionis omitted when the device-info payload carries no version, rather than sent as an empty string that overwrote the fallback.$deviceManufactureris no longer sent on Windows, which exposes no manufacturer - the value being sent was the host name.$platformnow derives fromPlatform.operatingSystemrather thandefaultTargetPlatform. No value changes on any supported platform.