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
dependencies:
postlip_metrics: ^0.1.0Android 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
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.
PostlipMetricsObserver({PostlipMetricsClient? client, nameExtractor, routeFilter}) extends NavigatorObserver; PostlipMetricsClient is constructible for tests (fake MetricsTransport, MemoryMetricsStorage, fixed clock, bindLifecycle: false).
Options
PostlipMetricsOptions — everything is optional.
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.
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 declareNSUserTrackingUsageDescription; returnnullunless the status isauthorized. After the prompt, callPostlipMetrics.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 tohttps://api-adservices.apple.com/api/v1/as atext/plainbody and return the JSON object (retry a 404 up to 3 times, 5 s apart; anything else →null). The token itself is never sent; onlyattribution,orgId,campaignId,adGroupId,keywordId,adId,countryOrRegion,conversionType,claimTypeandclickDateare 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.
Consent
- The stored decision wins over the mode at the next start. The decision is the only thing stored without analytics consent.
analyticscontrols persistence (ids, session, the offline queue, lifecycle markers),$first_open/$app_updated,$pageleaveand$crash.marketingcontrols 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
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 theutm_*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:
{ "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,
messageandstacklose 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_hashgroups 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.silenterrors are skipped. - Set your own
FlutterError.onError/PlatformDispatcher.onErrorbefore 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 Androidno_backup/postlip_metricsnext to the app's files (never restored to another device), on iOSApplication Support/postlip_metrics. Pure Dart cannot setisExcludedFromBackupon iOS, so an iCloud backup restored to a new device carries the anonymous id over; pass your ownstorageto 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).
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.