---
title: "Flutter SDK"
description: "Typed event tracking, screen and lifecycle auto-tracking, and identity for Flutter apps with pug_flutter."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.pug.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Flutter SDK

`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](/sdks): initialize once, track events, identify
users, reset on sign-out.

## Install

```bash
flutter pub add pug_flutter
```

[View `pug_flutter` on pub.dev](https://pub.dev/packages/pug_flutter)

`flutter pub add pug_flutter` adds it to `pubspec.yaml` and fetches it; to pin
the version yourself, add it under `dependencies` instead:

```yaml title="pubspec.yaml"
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:

```dart
import 'package:pug_flutter/pug_flutter.dart';
```

Smoke-test by adding a minimal `Pug.init` in `main()` (see
[Initialize](#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](#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](/get-started/ai-setup).

```text
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**.

```dart
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 `Duration`s. |
| `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](#auto-tracking). |
| `trackingConsent` | `TrackingConsentConfig` | `const TrackingConsentConfig()` | Consent gate: **two states, granted by default**, unlike the Web SDK. See [Tracking consent](#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](/reference/well-known-events) add typed properties and
richer dashboards. Names starting with `pug.` are reserved.

```dart
// 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**. |

```dart
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.

```dart
// 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.

1. **First event** (session ID created)
2. **Events within timeout** (same session ID)
3. **Idle past timeout** (session expires)
4. **Next event** (new session ID (identity kept))

The session is persisted to `SharedPreferences` and restored on cold start
(re-checking the timeouts). Configure the timeouts at [init](#initialize) with
`session` (`idleTimeout`/`maxDuration`). For the anonymous->identified model,
see [Core concepts](/get-started/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](/reference/auto-properties), and the
**server** attaches enrichment properties to *every* event.

> **Auto-property keys are camelCase**
>
> `$deviceType`, `$osVersion`, `$networkType`. The only snake\_case keys are the
> two server-set bot signals, `$bot_score` and `$verified_bot`. See
> [Auto-properties](/reference/auto-properties#sdk-set-properties-flutter) for the
> full catalog.

**Navigation** is captured when you wire a `PugRouteObserver` into your
`MaterialApp`:

```dart
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](/reference/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](/reference/auto-properties#app) 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

> **Not the Web SDK's default**
>
> **Flutter defaults to granted** and writes identity from the first event. The
> [Web SDK](/sdks/web#tracking-consent) defaults to `'cookieless'`: events flow,
> nothing is stored on the device. They are not interchangeable, so an app that
> ships both surfaces needs the Flutter side configured explicitly if you want the
> Web behaviour.

### 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.

```dart
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_install` and `app_update` are tracked for you**, behind the new
  [`autoInstallEvents`](#auto-tracking) option (default on). An app that already
  used Pug reports no installs on upgrade, and its first `app_update` comes with
  the release after the one that ships 0.0.5. If you already track either event
  yourself, set `autoInstallEvents: false`, or each is counted twice.
- **`app_close` no longer fires on `inactive`.** An incoming call, the app
  switcher or a control-centre swipe used to log an `app_close`/`app_open` pair;
  now only `hidden`, `paused` and `detached` count as leaving, so expect fewer of
  both.

### v0.0.4

Nothing here breaks compilation - the changes are all in the
[auto-properties](/reference/auto-properties) each event carries.

- **`$deviceType` is new**: `tv`, `mobile` or `desktop`. Android TV is detected
  from the device's system features rather than the OS, which reports plain
  `android` there, so `$platform` stays `android` and matches the other SDKs.
  Omitted rather than guessed on web and on any target it can't classify.
- **`$deviceModel` no longer ships placeholders.** `device_info_plus` returns
  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.
- **`$osVersion` is omitted** when the device-info payload carries no version,
  rather than sent as an empty string that overwrote the fallback.
- **`$deviceManufacturer` is no longer sent on Windows**, which exposes no
  manufacturer - the value being sent was the host name.
- `$platform` now derives from `Platform.operatingSystem` rather than
  `defaultTargetPlatform`. No value changes on any supported platform.

> **v0.0.3 changed $platform values - check your saved queries**
>
> Before 0.0.3 the Flutter SDK sent `defaultTargetPlatform.name`, keeping the
> enum's casing: `iOS` and `macOS` where every other Pug SDK sends `ios` and
> `macos`. The backend promotes `$platform` into its own column verbatim, so the
> mismatch split one platform into per-SDK buckets in any cross-SDK breakdown.
> Flutter **web** separately reported the browser's host OS, making a web app on an
> iPhone indistinguishable from native iOS traffic.
>
> Both are fixed, but the fix is a **data** change: dashboards, filters and saved
> segments matching the old `iOS`/`macOS` casing (or treating Flutter web traffic
> as iOS) need updating to the lowercase set.

Source: https://docs.pug.sh/sdks/flutter/index.mdx
