Skip to content
Documentation menuFlutter SDK (postlip_metrics)

postlip_metrics

Flutter SDK of Postlip Metrics. It speaks the Segment Tracking API Spec (see docs/api/tracking-api.md): screen views, product events, identity, consent and sessions, plus the app lifecycle ($app_open, $first_open, $app_updated), screen time ($pageleave), the install source and uncaught Dart errors ($crash). It is pure Dart — no native code, no platform channels of its own — and depends only on Flutter and path_provider. What Dart cannot read by itself (app version, device model, time zone, network, install referrer, Apple Search Ads, advertising id) comes from optional providers you wire to the packages you already use.

This page is generated at build time from packages/sdk-flutter/README.md in the repository.

The Android and iOS SDKs of Postlip Metrics send the same messages; context.library.name is postlip-metrics-flutter.

Install

YAML
dependencies:
  postlip_metrics: ^0.1.0

Android and iOS. Android release builds need <uses-permission android:name="android.permission.INTERNET"/> in android/app/src/main/AndroidManifest.xml (Flutter's template declares it only for debug and profile builds).

Quick start

DART
import 'package:flutter/material.dart';
import 'package:postlip_metrics/postlip_metrics.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  PostlipMetrics.load('pk_live_…', const PostlipMetricsOptions(consent: ConsentMode.required));
  runApp(MaterialApp(
    navigatorObservers: [PostlipMetricsObserver()], // automatic screen views
    routes: {'/': (_) => const HomePage(), '/checkout': (_) => const CheckoutPage()},
  ));
}

// Anywhere in the app:
PostlipMetrics.consent(analytics: true, marketing: true);   // the user's choice from your consent dialog
PostlipMetrics.identify(userId: 'usr_7c1e', traits: {'plan': 'pro'});
final id = PostlipMetrics.track('purchase_completed',
    properties: {'order_id': 'HP9f2c', 'value': 240, 'currency': 'TRY'}, messageId: 'purchase:HP9f2c');

load() does not need to be awaited: calls made before the SDK is ready are buffered (at most 200, oldest dropped) and sent with the time they were made. example/ is a runnable app.

API

PostlipMetrics is a static facade over one shared PostlipMetricsClient (PostlipMetrics.instance). Every call is non-blocking and never throws into the app; problems are logged with debugPrint in debug mode.

CallNotes
Future<void> load(String writeKey, [PostlipMetricsOptions options])Once per process; a second call is ignored and logged. Reads GET {host}/v1/config/{writeKey} (3 s timeout; on failure built-in defaults), restores the stored state and follows the app's lifecycle. Completes when the SDK is ready.
String track(String event, {Map<String, Object?>? properties, String? messageId})Product event. Names starting with $ are reserved for system events and refused. messageId (1–100 printable ASCII characters, no spaces) is the id a server copy of the same event carries, so the event is stored once; anything else is replaced by a new UUID. Returns the message's id — also before load() finished and when the event is refused: pass it to an ad pixel or server copy as its event id.
void screen(String name, {Map<String, Object?>? properties})Screen view (type: screen). properties['path'] (a string starting with /) is the screen's path, else / + name.
void identify({String? userId, Map<String, Object?>? traits})userId must be opaque (no e-mail or phone); it is kept (stored with analytics consent) and attached to every later message until reset(). Traits are sent as given and never kept. The identity traits (email, phone, firstName, lastName, name, address.{street, city, postalCode, country, state}) open or update the person's Mailing & SMS contact, encrypted, and are never stored with the event (Tracking API §4.1). identify(traits: {'email': …}) without a user id works for a guest.
void group(String groupId, {Map<String, Object?>? traits})Links the person to an account. An empty id is logged and nothing is sent.
void alias(String userId, {String? previousId})previousId defaults to the current user id, else the anonymous id. The kept user id does not change.
void reset()On logout: new anonymous id, no user id, new session. Consent stays.
void consent({bool? analytics, bool? marketing})Applies and remembers the user's choice (see Consent). Only the given flags change.
Future<void> flush()Sends the queue now; completes when it is empty or a failed request waits for its retry.
String? getAnonymousId(), Map<String, Object?>? getContext()getContext() returns {anonymousId, userId?, session: {id}, consent: {analytics, marketing}}: send it to your backend and pass it as the context of a server-side event, so the event joins this device's session. Both are null before load() finished.

PostlipMetricsObserver({PostlipMetricsClient? client, nameExtractor, routeFilter}) extends NavigatorObserver; PostlipMetricsClient is constructible for tests (fake MetricsTransport, MemoryMetricsStorage, fixed clock, bindLifecycle: false).

Options

PostlipMetricsOptions — everything is optional.

OptionDefault
hosthttps://e.postlip.comCollector origin; trailing slashes are removed.
consentremote consentMode, else requiredConsentMode.required · implied · none.
debugfalseLogs every message, drop, retry and server rejection (rejected[], warnings[]) with debugPrint. A wrong write key (401) and a second load() are logged even without it.
flushAt20Queue size that triggers a send; also the most messages per request (1–20).
flushInterval30 sThe longest a message waits before a send.
maxQueueSize1000Bound of the queue; the oldest messages are dropped first.
sessionTimeoutMinutesremote, else 30Background minutes after which the next foreground starts a new session (5–240).
trackLifecycletrue$app_open, $first_open, $app_updated.
trackScreenstrueAutomatic screen views of PostlipMetricsObserver. Adding the observer is the opt-in; false makes it inert.
captureCrashestrue$crash for uncaught Dart errors, with analytics consent.
collectAdvertisingIdtrueCalls the advertisingId provider, only with marketing consent.
remoteConfigtruefalse skips GET /v1/config (built-in defaults and these options only).
storagefiles under the app support directoryA MetricsStorage: FileMetricsStorage(directory), MemoryMetricsStorage() or your own.
app—MetricsApp(name, version, build, namespace) → context.app; version/build drive $app_updated.
device—MetricsDevice(manufacturer, model, osVersion) → context.device, context.os.version and the User-Agent.
timezone—IANA id (Europe/Istanbul) → context.timezone.
network—MetricsNetwork? Function(), read for every message → context.network {wifi, cellular}.
advertisingId—Future<String?> Function(): GAID / IDFA. Called only with marketing consent.
installReferrer—Future<String?> Function(): the Google Play install referrer (URL query form).
appleSearchAds—Future<Map<String, Object?>?> Function(): the AdServices attribution record.
installStore—Future<String?> Function(): the installer (com.android.vending, com.apple.testflight, com.apple) or a store id (google_play, app_store, testflight).

The remote config's autocapture.pageviews, pageleave and errors set to false turn the observer, $pageleave and $crash off; test: true marks every message as test traffic (context.test). Options override the other remote values.

Providers

The SDK does not depend on these packages; wire the ones you use. Values that need an await (app, device, timezone) are read before load(); calls made meanwhile are not lost, load() is simply called a few milliseconds later.

DART
import 'dart:io';

import 'package:android_play_install_referrer/android_play_install_referrer.dart';
import 'package:app_tracking_transparency/app_tracking_transparency.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:package_info_plus/package_info_plus.dart';

Future<void> startMetrics() async {
  final info = await PackageInfo.fromPlatform();
  final plugin = DeviceInfoPlugin();
  MetricsDevice? device;
  if (Platform.isAndroid) {
    final a = await plugin.androidInfo;
    device = MetricsDevice(manufacturer: a.manufacturer, model: a.model, osVersion: a.version.release);
  } else if (Platform.isIOS) {
    final i = await plugin.iosInfo;
    device = MetricsDevice(manufacturer: 'Apple', model: i.utsname.machine, osVersion: i.systemVersion);
  }
  // flutter_timezone: a String, or a TimezoneInfo (`.identifier`) in newer versions.
  final timezone = await FlutterTimezone.getLocalTimezone();

  var network = const MetricsNetwork();
  MetricsNetwork toNetwork(List<ConnectivityResult> r) =>
      MetricsNetwork(wifi: r.contains(ConnectivityResult.wifi), cellular: r.contains(ConnectivityResult.mobile));
  network = toNetwork(await Connectivity().checkConnectivity());
  Connectivity().onConnectivityChanged.listen((r) => network = toNetwork(r));

  PostlipMetrics.load(
    'pk_live_…',
    PostlipMetricsOptions(
      app: MetricsApp(name: info.appName, version: info.version, build: info.buildNumber, namespace: info.packageName),
      device: device,
      timezone: timezone,
      network: () => network,
      installStore: () async => info.installerStore,
      installReferrer: () async =>
          Platform.isAndroid ? (await AndroidPlayInstallReferrer.installReferrer).installReferrer : null,
      advertisingId: () async {
        if (Platform.isIOS) {
          // The SDK never asks for App Tracking Transparency: your app requests it (with its own explanation, at a
          // moment you choose) and the IDFA is only read once the user authorized it.
          final status = await AppTrackingTransparency.trackingAuthorizationStatus;
          if (status != TrackingStatus.authorized) return null;
          return AppTrackingTransparency.getAdvertisingIdentifier();
        }
        return readGoogleAdvertisingId(); // an advertising-id package of your choice
      },
    ),
  );
}
  • Advertising id. The provider is called only with marketing consent (and collectAdvertisingId); an all-zero id (limited ad tracking, ATT not authorized) counts as none. On iOS your app is responsible for the ATT prompt and must declare NSUserTrackingUsageDescription; return null unless the status is authorized. After the prompt, call PostlipMetrics.consent(marketing: true) again (or wait for the next foreground) and the SDK asks the provider again.
  • Apple Search Ads. AAAttribution.attributionToken() (AdServices, iOS 14.3+) is native: expose it through a small method channel of your app, then post it to https://api-adservices.apple.com/api/v1/ as a text/plain body and return the JSON object (retry a 404 up to 3 times, 5 s apart; anything else → null). The token itself is never sent; only attribution, orgId, campaignId, adGroupId, keywordId, adId, countryOrRegion, conversionType, claimType and clickDate are kept.
  • Providers run with a timeout (install referrer and store 10 s, Apple Search Ads 20 s, advertising id 10 s); a provider that throws or times out counts as unknown. Unknown fields are left out of the messages.
ModeBefore consent()
required (default)Analytics and marketing are off. Messages are still sent (screens, $app_open, track, identify) and counted cookieless by the server; ids, session and queue live in memory only, so every launch is a new anonymous visitor. No $first_open, $app_updated, $pageleave or $crash, no advertising id, and the install referrer is not read.
impliedEverything is on until the user declines.
noneEverything is on (the app does not ask).
  • The stored decision wins over the mode at the next start. The decision is the only thing stored without analytics consent.
  • analytics controls persistence (ids, session, the offline queue, lifecycle markers), $first_open / $app_updated, $pageleave and $crash.
  • marketing controls the advertising id and the click ids (gclid, gbraid, wbraid, dclid, fbclid, msclkid, ttclid, li_fat_id, yclid) of the install referrer. Without it nothing reaches Meta or TikTok.
  • Granting analytics later stores the same anonymous id, session and queue, so the earlier messages join; then the install checks run. Revoking it deletes everything stored except the decision; the ids stay in memory for the rest of the process. Revoking marketing drops the advertising id at once.
  • Every message carries context.consent.

Automatic capture

EventWhen
$app_openCold start (when load() runs with the app in the foreground, else at the first foreground) and every return to the foreground; {from_background}. Not sent twice for one foreground. Sent with or without analytics consent.
$first_openOnce per install, with analytics consent. Its time is the launch's start (the load() call, also when consent comes later in that launch), so it is never later than the launch's other messages and its install source becomes the session's touch; it waits for the install source (§ below) and carries context.install. If the app is killed before, the next launch sends it with the stored time and session.
$app_updatedAt start, with analytics consent, when app.version or app.build differs from the stored one: {previous_version, previous_build, version, build}.
screenPostlipMetricsObserver: every named PageRoute that becomes visible (push, replace, pop back to it). The name is RouteSettings.name; a name starting with / is also the path. Dialogs and other popup routes are not screens. Each screen view has a pageview_id.
$pageleaveWith analytics consent, when the next screen view starts (final: true) and when the app goes to the background (final: false; the view resumes in the foreground): {path, pageview_id, active_ms, total_ms, final}. active_ms is foreground time.
$crashUncaught Dart errors (see Crashes).

Sessions are foreground based: a session does not expire while the app is in the foreground; returning after more than sessionTimeoutMinutes in the background (or a message in the background after that long, e.g. from a push handler) starts a new one, and the first message of a session carries session.start: true. On such a rotation the current automatic screen is sent again in the new session.

Lifecycle comes from the widgets binding (resumed is the foreground; hidden, paused and detached the background).

Install attribution

$first_open carries context.install:

  • installer: google_play, app_store, testflight, or the installer package name.
  • referrer: the Google Play install referrer (utm_source=…&utm_medium=…&gclid=…). Click ids stay only with marketing consent at the time the message is made; without it they are removed and the utm_* stay.
  • appleSearchAds: the AdServices record (see Providers).

The install source is resolved once per install, with analytics consent, and never delays load().

Crashes

With analytics consent and captureCrashes, the SDK chains itself into FlutterError.onError and PlatformDispatcher.instance.onError (the previous handlers keep running, nothing is swallowed) and queues track $crash at once:

JSON
{ "path": "/checkout", "kind": "error", "fatal": false, "type": "StateError", "message": "Bad state: [redacted]",
  "stack": "#0 …", "stack_hash": "<32 hex>", "thread": "main", "app_version": "1.4.0", "app_build": "140" }
  • Before anything is written, message and stack lose e-mail addresses and runs of 7+ digits ([redacted]), URL query strings, and user paths (/Users/<name>/, /home/<name>/, the iOS app container → ~/). The server scrubs again.
  • stack_hash groups the same error across launches and builds: SHA-256 of the type and each frame's symbol, without line numbers, addresses or offsets.
  • Each screen view reports a stack hash once and at most 20 errors; FlutterErrorDetails.silent errors are skipped.
  • Set your own FlutterError.onError / PlatformDispatcher.onError before the SDK gets analytics consent (or chain the previous handler yourself), otherwise yours replaces the SDK's.
  • Pure Dart cannot see native crashes or ANRs/hangs (the process is gone or the Dart thread is blocked). Apps that need them use the Android / iOS SDKs of Postlip Metrics next to this one, or the platform's crash tooling.

Privacy

  • The SDK reads no IP address and never sends a device id (no ANDROID_ID, no IDFV). The advertising id is sent only with marketing consent and only if your provider returns it.
  • The User-Agent (PostlipMetrics/0.1.0 (Linux; Android 14; Mobile), PostlipMetrics/0.1.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X)) never contains the app's name, the bundle id or the device model.
  • Traits are never kept in the SDK's state. Until a message is delivered it sits in the offline queue (on disk with analytics consent), identify traits included.
  • Without analytics consent only the consent decision is written.
  • Storage: FileMetricsStorage.defaultLocation() — on Android no_backup/postlip_metrics next to the app's files (never restored to another device), on iOS Application Support/postlip_metrics. Pure Dart cannot set isExcludedFromBackup on iOS, so an iCloud backup restored to a new device carries the anonymous id over; pass your own storage to change that. Writes are atomic (temporary file, then rename) and run on Dart's I/O threads.
  • The server sees the request's IP address and user agent and stores neither with the event. For a conversion with marketing consent, while the app has a Meta or TikTok conversion destination, both are kept encrypted until the conversion is delivered to that platform, then deleted (Tracking API §9).

Transport

Messages are batched into POST {host}/v1/batch (Content-Type: application/json, Authorization: Basic base64("<writeKey>:"), User-Agent), body { "batch": [...], "sentAt": "…" } with sentAt set at every attempt (the server corrects clock skew with it). Sends happen at flushAt messages, after flushInterval, when the app goes to the background, on flush() and once after load(). One request is in flight at a time, with at most 20 messages and 512 000 bytes; a message over 32 KB is dropped (logged).

AnswerAction
200Done; in debug mode rejected[] (never resent) and warnings[] are logged.
400, 403Dropped (logged).
401Kept and retried with backoff; logged every time (check the write key).
413Split in two; a single message is dropped.
429Kept; the same messages are resent after Retry-After seconds (else backoff).
5xx, network error, timeoutKept; backoff min(24 h, 10 s × 4^attempt) ±20 %.

Kept messages leave the queue when delivered, when older than 30 days or through maxQueueSize; the backoff starts over after a success and when the app returns to the foreground. Timeouts: connect 10 s, response 20 s. With analytics consent the queue survives restarts (offline queue). Flutter cannot ask the OS for extra background time, so a send started at the background transition may finish only at the next start.