Flutter (Dart)
package:http, install_id in the Keychain and shared_preferences, a file-backed queue, and sessions via WidgetsBindingObserver.
A recipe for Flutter on Dart 3: iOS, Android, macOS, Windows, and Linux. Networking is
package:http, the queue is a file in the getApplicationSupportDirectory() directory from
path_provider, install_id is flutter_secure_storage on iOS and macOS and shared_preferences
on the other platforms, the version and model come from package_info_plus and
device_info_plus, and UUIDs come from uuid. The code is split into three files: install_id,
an offline queue with sending, and a session tracker.
flutter pub add http path_provider flutter_secure_storage shared_preferences \
package_info_plus device_info_plus uuidWhere to create the tracker and how to subscribe to the lifecycle is covered in
Where to initialize analytics. This code doesn't work for
Flutter web: the browser has no dart:io, and website sessions follow tab visibility — see the
web recipe.
A Flutter app is one app in appmetrica.online with several platforms: iOS and Android, plus macOS,
Windows, and Linux if you also build for desktop. Select all the platforms in a single app instead
of creating an app per build. Each platform has its own key on the Integration tab: the code picks
the key and context.platform at runtime from Platform.isIOS, Platform.isAndroid, and so on. A
person with the same user_id on a phone and a laptop is counted once, and reports are split by
platform — see Event platform.
install_id
install_id is created on first launch and never changes. On iOS and macOS it's stored in the
Keychain via flutter_secure_storage: the item survives deleting and reinstalling the app, as in
the iOS recipe. On Android it's stored in
shared_preferences, which Auto Backup keeps, as in the
Android recipe. flutter_secure_storage doesn't fit
there: its encryption key lives in the Android Keystore and isn't restored from a backup. On
Windows and Linux, shared_preferences is a file in the user's profile.
import 'dart:io';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
abstract final class InstallId {
static const _key = 'appmetrica.install_id';
static const _keychain = FlutterSecureStorage(
// As in the iOS recipe: readable on background launches, never moves to a new device.
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device),
mOptions: MacOsOptions(accessibility: KeychainAccessibility.first_unlock_this_device),
);
static Future<String>? _value;
/// Reads install_id or creates it on first launch. The Future is cached per process.
static Future<String> get() => _value ??= _load();
static Future<String> _load() async {
if (Platform.isIOS || Platform.isMacOS) {
return await _keychain.read(key: _key) ??
await _create((id) => _keychain.write(key: _key, value: id));
}
final prefs = SharedPreferencesAsync();
return await prefs.getString(_key) ?? await _create((id) => prefs.setString(_key, id));
}
static Future<String> _create(Future<void> Function(String id) save) async {
final id = const Uuid().v4();
await save(id);
return id;
}
}On macOS, flutter_secure_storage writes to the data protection keychain, so the app needs the
Keychain Sharing entitlement (keychain-access-groups) — the setup is described in the package
README.
Offline queue and sending
EventQueue stores events in a file and sends them in batches of up to 500. Dart runs code on a
single thread, so the queue state changes without locks, and file writes happen strictly one at a
time. The UI never waits on the network: an event is written to disk first, and sending happens in
the background. How to handle each response follows Errors and retries:
200— remove the sent events, except those rejected withretryable: true: retry those in a minute;400,413,415— drop the batch: retrying won't help;401,403— stop sending until the app restarts: the key was revoked or the app is archived;429,503— retry afterRetry-After;- anything else and network errors — retry with a backoff from 1 s to 300 s and ±20% jitter.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
/// The event platform and its key: each platform of the app has its own key on the Integration tab.
final appPlatform = _detectPlatform();
({String name, String writeKey}) _detectPlatform() {
if (Platform.isIOS) return (name: 'ios', writeKey: 'pk_…'); // iOS platform key
if (Platform.isAndroid) return (name: 'android', writeKey: 'pk_…'); // Android platform key
if (Platform.isMacOS) return (name: 'macos', writeKey: 'pk_…'); // macOS platform key
if (Platform.isWindows) return (name: 'windows', writeKey: 'pk_…'); // Windows platform key
if (Platform.isLinux) return (name: 'linux', writeKey: 'pk_…'); // Linux platform key
throw UnsupportedError('appmetrica: platform ${Platform.operatingSystem} is not supported');
}
class EventQueue {
static const _endpoint = 'https://i.analytics.example.com/v1/events';
static const _maxBatch = 500;
static const _maxAge = Duration(days: 7);
static const _maxBackoff = Duration(seconds: 300);
final _random = Random();
late final Future<File> _file = _openFile();
late final Future<List<Map<String, dynamic>>> _events = _read();
Future<void> _saving = Future.value();
bool _sending = false;
bool _stopped = false;
DateTime _retryAt = DateTime(0);
Duration _backoff = const Duration(seconds: 1);
Timer? _timer;
Future<void> enqueue(Map<String, dynamic> event) async {
(await _events).add(event);
await _save(); // disk first: the event survives the app being closed
await flush();
}
Future<void> flush() async {
final events = await _events;
final minTime = DateTime.now().subtract(_maxAge);
events.removeWhere((e) => DateTime.parse(e['timestamp'] as String).isBefore(minTime));
if (_sending || _stopped || events.isEmpty || DateTime.now().isBefore(_retryAt)) return;
_sending = true;
final batch = events.take(_maxBatch).toList();
final sent = {for (final e in batch) e['insert_id'] as String};
try {
final response = await http
.post(
Uri.parse(_endpoint),
headers: {'Content-Type': 'application/json', 'X-App-Key': appPlatform.writeKey},
// sent_at is this attempt's time: the server uses it to correct a skewed device clock.
body: jsonEncode({'sent_at': _now(), 'events': batch}),
)
.timeout(const Duration(seconds: 15));
switch (response.statusCode) {
case 200:
final rejected = (jsonDecode(response.body) as Map<String, dynamic>)['rejected'] as List;
final retry = {
for (final r in rejected.cast<Map<String, dynamic>>())
if (r['retryable'] == true) r['insert_id'],
};
final done = sent.difference(retry);
events.removeWhere((e) => done.contains(e['insert_id']));
_backoff = const Duration(seconds: 1);
if (retry.isNotEmpty) _retryLater(const Duration(minutes: 1));
case 400 || 413 || 415:
events.removeWhere((e) => sent.contains(e['insert_id'])); // retrying won't help
case 401 || 403:
_stopped = true; // key revoked or app archived
case 429 || 503:
final seconds = int.tryParse(response.headers['retry-after'] ?? '') ?? 5;
_retryLater(Duration(seconds: seconds));
default:
_scheduleBackoff();
}
} catch (_) {
_scheduleBackoff(); // network error, timeout, unexpected response
} finally {
_sending = false;
await _save();
}
}
void _retryLater(Duration delay) {
_retryAt = DateTime.now().add(delay);
_timer?.cancel();
_timer = Timer(delay, flush);
}
void _scheduleBackoff() {
_retryLater(_backoff * (0.8 + _random.nextDouble() * 0.4));
_backoff = _backoff * 2 > _maxBackoff ? _maxBackoff : _backoff * 2;
}
/// Writes the queue via a temp file. Writes run one at a time: two at once would corrupt it.
Future<void> _save() => _saving = _saving.then((_) async {
final file = await _file;
final tmp = File('${file.path}.tmp');
await tmp.writeAsString(jsonEncode(await _events));
await tmp.rename(file.path);
}).catchError((_) {}); // disk error: the queue stays in memory until the next write
Future<File> _openFile() async {
final dir = await getApplicationSupportDirectory(); // created if it doesn't exist
return File('${dir.path}/appmetrica-queue.json');
}
Future<List<Map<String, dynamic>>> _read() async {
try {
final saved = jsonDecode(await (await _file).readAsString()) as List;
return saved.cast<Map<String, dynamic>>().toList();
} catch (_) {
return []; // first launch or a corrupted file
}
}
}
/// RFC 3339 in UTC with a Z suffix — the server accepts this time as is.
String _now() => DateTime.now().toUtc().toIso8601String();The queue keeps timestamp as is, so an event sent three days late lands in the report for the day
it happened (see Time and the offline queue).
Sessions
SessionTracker creates the events. Each event carries its own context, so queued events go out
with the app version they happened in. Duration is measured with Stopwatch, a monotonic clock
that doesn't change when the user changes the system time.
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:ui';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:uuid/uuid.dart';
import 'event_queue.dart';
import 'install_id.dart';
/// One tracker per process.
final analytics = SessionTracker();
class SessionTracker {
/// Account ID after sign-in; null if the user isn't signed in. Not an email or phone number.
String? userId;
final _queue = EventQueue();
final _stopwatch = Stopwatch();
late final Future<Map<String, String>> _context = _loadContext();
String? _sessionId;
/// Sends the events left in the queue from previous launches.
void flush() => unawaited(_queue.flush());
void start() {
if (_sessionId != null) return;
final sessionId = const Uuid().v4();
_sessionId = sessionId;
_stopwatch
..reset()
..start();
unawaited(_track('session_start', sessionId));
}
void end() {
final sessionId = _sessionId;
if (sessionId == null) return;
_sessionId = null;
final durationMs = min(_stopwatch.elapsedMilliseconds, 86400000);
unawaited(_track('session_end', sessionId, durationMs: durationMs));
}
Future<void> _track(String name, String sessionId, {int? durationMs}) async {
// Time and user_id are captured when the event happens, before the first await.
final event = <String, dynamic>{
'insert_id': const Uuid().v4(),
'name': name,
'timestamp': DateTime.now().toUtc().toIso8601String(),
'session_id': sessionId,
if (userId != null) 'user_id': userId,
if (durationMs != null) 'duration_ms': durationMs,
};
event['install_id'] = await InstallId.get();
event['context'] = await _context;
await _queue.enqueue(event);
}
Future<Map<String, String>> _loadContext() async {
final package = await PackageInfo.fromPlatform();
final device = await _deviceInfo();
return {
'platform': appPlatform.name,
'os_name': device.osName,
'os_version': device.osVersion,
'app_version': package.version,
'app_build': package.buildNumber,
if (device.model != null) 'device_model': device.model!,
'device_type': _deviceType(),
'locale': PlatformDispatcher.instance.locale.toLanguageTag(),
'environment': kDebugMode ? 'development' : 'production',
};
}
}
typedef _Device = ({String osName, String osVersion, String? model});
Future<_Device> _deviceInfo() async {
return switch (await DeviceInfoPlugin().deviceInfo) {
AndroidDeviceInfo(:final version, :final manufacturer, :final model) =>
(osName: 'Android', osVersion: version.release, model: '$manufacturer $model'),
// On iOS, a machine name like iPhone17,3: the device name often contains the owner's name.
IosDeviceInfo(:final systemName, :final systemVersion, :final utsname) =>
(osName: systemName, osVersion: systemVersion, model: utsname.machine),
MacOsDeviceInfo(:final majorVersion, :final minorVersion, :final patchVersion, :final model) =>
(osName: 'macOS', osVersion: '$majorVersion.$minorVersion.$patchVersion', model: model),
WindowsDeviceInfo(:final majorVersion, :final minorVersion, :final buildNumber) =>
(osName: 'Windows', osVersion: '$majorVersion.$minorVersion.$buildNumber', model: null),
LinuxDeviceInfo(:final name, :final versionId) =>
(osName: name, osVersion: versionId ?? '', model: null),
_ => (osName: Platform.operatingSystem, osVersion: '', model: null),
};
}
/// Like smallestScreenWidthDp in the Android recipe: 600 logical pixels or more is a tablet.
String _deviceType() {
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) return 'desktop';
final view = PlatformDispatcher.instance.implicitView;
if (view == null) return 'phone';
final shortestSide = view.physicalSize.shortestSide / view.devicePixelRatio;
return shortestSide >= 600 ? 'tablet' : 'phone';
}Hook the tracker up in main(). resumed opens a session, and paused closes it on iOS and
Android. On desktop, Flutter never sends paused: a minimized or hidden window is hidden, and on
macOS, Windows, and Linux that's what closes the session. inactive — the notification shade, an
incoming call, a window without focus — doesn't interrupt the session. Calling start() again
while a session is open, or end() with no session, does nothing.
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'analytics/session_tracker.dart';
import 'app.dart';
final _desktop = Platform.isMacOS || Platform.isWindows || Platform.isLinux;
void main() {
WidgetsFlutterBinding.ensureInitialized();
analytics.flush(); // the launch may be a background one, so no session_start here
WidgetsBinding.instance.addObserver(AnalyticsLifecycle());
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) {
analytics.start(); // the app is already on screen
}
runApp(const NotesApp());
}
class AnalyticsLifecycle with WidgetsBindingObserver {
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
analytics.start();
case AppLifecycleState.paused:
analytics.end(); // iOS and Android: the app went to the background
case AppLifecycleState.hidden when _desktop:
analytics.end(); // desktop: the window is minimized; paused never comes here
default:
break; // inactive: notification shade, call, unfocused window — the session goes on
}
}
}A mobile app gets little time in the background: if session_end doesn't go out in time, it stays
in the queue file and is sent on the next launch. A desktop app may exit when its window is closed
without ever going hidden — then the session has no session_end. The session is still counted
from session_start, but it isn't included in the average duration (see
Sessions).
user_id after sign-in
After sign-in, set analytics.userId to the account ID from your backend; after sign-out, set it
to null. Anonymous events from before sign-in are attributed to that user (see
Identity). If the user signed in during a previous run, set userId in
main() before the first session_start.
analytics.userId = account.id; // "a3f9c2d1-77b4-…", not an emailVerify
- Run the app in debug mode with
flutter run: withkDebugMode, events go out withenvironment: "development". - Minimize the app and open it again.
session_startandsession_endshow up on the Integration tab with the current OS as the platform. In reports, turn on the development environment.