/// trackd.one Flutter SDK — privacy-first analytics for apps & games. /// /// - No device identifiers, no advertising ids, no device model, no fingerprinting. /// - Nothing is written to disk: events and the session id live in memory only. /// - Events are batched and retried automatically. The SDK never throws into your app. /// /// ```dart /// void main() { /// WidgetsFlutterBinding.ensureInitialized(); /// Trackd.init(websiteId: 'YOUR-WEBSITE-ID', appVersion: '1.2.0'); /// runApp(MaterialApp(navigatorObservers: [TrackdNavigatorObserver()], home: const Home())); /// } /// /// Trackd.screen('Home'); /// Trackd.event('level_complete', {'level': 5, 'score': 1200}); /// Trackd.revenue(4.99, currency: 'EUR', orderId: 'order-123'); /// ``` library trackd; import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:http/http.dart' as http; /// Static facade around a shared [TrackdClient]. class Trackd { Trackd._(); static const String version = '1.1.0'; /// The shared client used by the static methods. static final TrackdClient instance = TrackdClient(); static _LifecycleObserver? _observer; /// Initialize the SDK. Call once, e.g. in `main()` before `runApp`. /// Events tracked before `init` are kept in memory and sent afterwards. /// /// [osVersion] is optional (e.g. `"Android 15"` / `"iOS 18.1"` from `device_info_plus`); /// [platform], [screen], [language] and [device] are detected automatically when omitted. /// Queued events are sent at the latest after [flushInterval] (default 30 s) or as soon as /// [flushAt] events are queued (default 20). static void init({ required String websiteId, String host = 'https://trackd.one', String? appVersion, String? osVersion, String? platform, String? screen, String? language, String? device, bool debug = false, Duration flushInterval = TrackdClient.defaultFlushInterval, int flushAt = TrackdClient.defaultFlushAt, }) { try { WidgetsFlutterBinding.ensureInitialized(); final detected = _detectContext(); instance.configure( websiteId: websiteId, host: host, debug: debug, flushInterval: flushInterval, flushAt: flushAt, context: { 'platform': platform ?? detected['platform'], 'app_version': appVersion, 'os_version': osVersion, 'screen': screen ?? detected['screen'], 'language': language ?? detected['language'], 'device': device ?? detected['device'], }, ); if (_observer == null) { final observer = _LifecycleObserver(instance); WidgetsBinding.instance.addObserver(observer); _observer = observer; } } catch (e) { if (debug) debugPrint('[trackd] init failed: $e'); } } /// Track a screen view (the app equivalent of a pageview), e.g. `"Home"` or `"Settings/Profile"`. static void screen(String name, {String? title}) => instance.screen(name, title: title); /// Alias of [screen], kept for backward compatibility. static void screenView(String name, {String? title}) => instance.screen(name, title: title); /// Track a custom event. [data] must be flat: String, num or bool values. /// Nested values, nulls and non-finite numbers are dropped; the JSON is capped at 4 KB. static void event(String name, [Map? data]) => instance.event(name, data); /// Track revenue — sent as event `purchase` with `{amount, currency, order_id}`. static void revenue(num amount, {String currency = 'EUR', String? orderId}) => instance.revenue(amount, currency: currency, orderId: orderId); /// Send all queued events now. Never throws. static Future flush() => instance.flush(); /// Enable or disable tracking. Disabling clears all queued events immediately. static void setEnabled(bool enabled) => instance.setEnabled(enabled); /// Whether tracking is currently enabled. static bool get isEnabled => instance.isEnabled; // ── Game & media helpers ────────────────────────────────────────────────── static void gameStart({int? level, String? mode}) => event('game_start', {'level': level, 'mode': mode}); static void levelComplete(int level, {int? score, int? timeMs}) => event('level_complete', {'level': level, 'score': score, 'time_ms': timeMs}); static void gameOver(int level, {String reason = 'died'}) => event('game_over', {'level': level, 'reason': reason}); static void videoWatch(String videoId, {int? durationMs, bool completed = false}) => event('video_watch', {'video_id': videoId, 'duration_ms': durationMs, 'completed': completed}); static void adView(String adType, {String? adId}) => event('ad_view', {'ad_type': adType, 'ad_id': adId}); static void tutorialStep(int step, int total, {bool skipped = false}) => event('tutorial_step', {'step': step, 'total': total, 'skipped': skipped}); /// Coarse, non-identifying context from the Flutter engine. static Map _detectContext() { final out = {}; try { final dispatcher = ui.PlatformDispatcher.instance; final locale = dispatcher.locale; final tag = locale.toLanguageTag(); if (tag.isNotEmpty && tag != 'und') out['language'] = tag; final views = dispatcher.views; double? shortestLogical; if (views.isNotEmpty) { final view = views.first; final size = view.physicalSize; if (size.width > 0 && size.height > 0) { final w = min(size.width, size.height).round(); final h = max(size.width, size.height).round(); out['screen'] = '${w}x$h'; if (view.devicePixelRatio > 0) shortestLogical = min(size.width, size.height) / view.devicePixelRatio; } } final isTabletSize = shortestLogical != null && shortestLogical >= 600; if (kIsWeb) { out['platform'] = 'web'; out['device'] = shortestLogical == null ? 'desktop' : (shortestLogical < 600 ? 'mobile' : 'desktop'); } else { switch (defaultTargetPlatform) { case TargetPlatform.android: out['platform'] = 'android'; out['device'] = isTabletSize ? 'tablet' : 'mobile'; break; case TargetPlatform.iOS: out['platform'] = isTabletSize ? 'ipados' : 'ios'; out['device'] = isTabletSize ? 'tablet' : 'mobile'; break; case TargetPlatform.macOS: out['platform'] = 'macos'; out['device'] = 'desktop'; break; case TargetPlatform.windows: out['platform'] = 'windows'; out['device'] = 'desktop'; break; case TargetPlatform.linux: out['platform'] = 'linux'; out['device'] = 'desktop'; break; case TargetPlatform.fuchsia: out['platform'] = 'fuchsia'; out['device'] = 'mobile'; break; } } } catch (_) { out['platform'] ??= 'flutter'; } return out; } } enum _Outcome { ok, drop, retry } class _Result { const _Result(this.outcome, [this.code = 0, this.retryAfterMs = 0]); final _Outcome outcome; final int code; final int retryAfterMs; } class _Item { _Item(this.event, this.session, this.bytes, this.timestamp); final Map event; final String session; final int bytes; final int timestamp; /// Set once the item was part of a sent batch — a retry resends exactly that batch (same id). String? batchId; } class _Batch { _Batch(this.items, this.session, this.id); final List<_Item> items; final String session; final String id; } /// The SDK engine. Use the static [Trackd] facade unless you need several instances or tests. class TrackdClient { /// [httpClient], [now] (wall clock, ms epoch) and [monotonic] (ms, for session timeouts) can be /// injected for tests. TrackdClient({http.Client? httpClient, int Function()? now, int Function()? monotonic}) : _http = httpClient ?? http.Client(), _now = now ?? (() => DateTime.now().millisecondsSinceEpoch), _monotonic = monotonic ?? _stopwatchClock(); static const int defaultFlushAt = 20; static const Duration defaultFlushInterval = Duration(seconds: 30); static const String userAgent = 'trackd-flutter/${Trackd.version}'; static const int maxBatch = 50; static const int maxQueue = 500; static const int sessionTimeoutMs = 30 * 60 * 1000; static const int _maxBodyBytes = 60 * 1024; static const int _maxEventDataBytes = 4096; static const int _maxEventName = 200; static const int _maxScreenName = 300; // server keeps 300 chars static const int _maxKey = 100; static const int _maxString = 500; // server keeps 500 chars per event_data string static const int _maxEventDataKeys = 50; // server keeps the first 50 entries static const Map _contextLimits = { 'platform': 32, 'app_version': 50, 'os_version': 50, 'screen': 20, 'language': 35, 'device': 10, }; static const int _maxEventAgeMs = 72 * 60 * 60 * 1000; static const int _maxBackoffMs = 5 * 60 * 1000; static const int _maxRetryAfterMs = 60 * 60 * 1000; static const int _batchIdLength = 24; static const String _idChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; final http.Client _http; final int Function() _now; final int Function() _monotonic; final Random _random = _secureRandom(); int _flushAt = defaultFlushAt; Duration _flushInterval = defaultFlushInterval; String _websiteId = ''; Uri? _endpoint; Map _context = const {}; bool _debug = false; bool _enabled = true; final List<_Item> _queue = <_Item>[]; String _sessionId = ''; int _lastActivity = 0; // monotonic ms — a changed wall clock must not end / extend sessions int? _backgroundAt; // monotonic ms Timer? _flushTimer; Timer? _retryTimer; int _retryAt = 0; int _attempt = 0; int _epoch = 0; Future? _flushing; bool get isEnabled => _enabled; /// Number of events waiting to be sent. int get queueSize => _queue.length; void configure({ required String websiteId, required String host, bool debug = false, Duration flushInterval = defaultFlushInterval, int flushAt = defaultFlushAt, Map context = const {}, }) { _debug = debug; _flushInterval = flushInterval > Duration.zero ? flushInterval : defaultFlushInterval; _flushAt = flushAt > 0 ? flushAt : defaultFlushAt; final id = websiteId.trim(); var base = host.trim(); while (base.endsWith('/')) { base = base.substring(0, base.length - 1); } final uri = Uri.tryParse('$base/api/collect'); if (id.isEmpty) return _log('init(): websiteId is empty — nothing will be sent'); if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) { return _log('init(): invalid host "$host" — nothing will be sent'); } _websiteId = id; _endpoint = uri; final ctx = {}; context.forEach((key, value) { final v = value?.trim(); final limit = _contextLimits[key]; if (v != null && v.isNotEmpty && limit != null) ctx[key] = _truncate(v, limit); }); _context = ctx; _log('initialized: endpoint=$uri context=$ctx'); if (_queue.isNotEmpty) _scheduleFlush(); } void screen(String name, {String? title}) { try { final n = name.trim(); if (n.isEmpty) return _log('screen(): name is empty'); final e = {'type': 'screen_view', 'name': _truncate(n, _maxScreenName)}; final t = title?.trim(); if (t != null && t.isNotEmpty && t != n) e['title'] = _truncate(t, _maxScreenName); _track(e); } catch (err) { _log('screen() failed: $err'); } } void event(String name, [Map? data]) { try { final n = name.trim(); if (n.isEmpty) return _log('event(): name is empty'); final e = {'type': 'event', 'event_name': _truncate(n, _maxEventName)}; final clean = sanitizeEventData(data); if (clean != null) e['event_data'] = clean; _track(e); } catch (err) { _log('event() failed: $err'); } } void revenue(num amount, {String currency = 'EUR', String? orderId}) { try { if (!amount.isFinite) return _log('revenue(): amount must be finite'); final cur = _truncate(currency.trim(), 3).toUpperCase(); event('purchase', { 'amount': amount, 'currency': cur.isEmpty ? 'EUR' : cur, if (orderId != null && orderId.isNotEmpty) 'order_id': _truncate(orderId, 100), }); } catch (err) { _log('revenue() failed: $err'); } } void setEnabled(bool enabled) { if (!enabled) { _epoch++; _queue.clear(); _cancelTimers(); _retryAt = 0; _attempt = 0; } _enabled = enabled; _log(enabled ? 'tracking enabled' : 'tracking disabled, queue cleared'); } /// Send all queued events now. Completes when the queue is drained or a retry is scheduled. Future flush() { final running = _flushing; if (running != null) return running; if (!_enabled || _queue.isEmpty) return Future.value(); _flushTimer?.cancel(); _flushTimer = null; final future = _drain().whenComplete(() { _flushing = null; if (_enabled && _queue.isNotEmpty && _retryTimer == null) _scheduleFlush(); }); _flushing = future; return future; } /// Called when the app goes to the background: flush and remember the time. void onBackground() { _backgroundAt = _monotonic(); flush(); } /// Called when the app returns: new session after 30 min in the background. void onForeground() { final bg = _backgroundAt; if (bg != null && _monotonic() - bg >= sessionTimeoutMs && _sessionId.isNotEmpty) { _rotateSession('background timeout'); } _backgroundAt = null; } /// Builds the next request body without sending it (for tests / debugging). @visibleForTesting Map? takeBatchPayload() { final batch = _takeBatch(); return batch == null ? null : _buildPayload(batch); } // ── Internals ───────────────────────────────────────────────────────────── void _track(Map e) { if (!_enabled) return; final now = _now(); final mono = _monotonic(); if (_sessionId.isEmpty) { _sessionId = _newSessionId(); } else if (mono - _lastActivity >= sessionTimeoutMs) { _rotateSession('inactivity'); } _lastActivity = mono; e['timestamp'] = now; final bytes = utf8.encode(jsonEncode(e)).length; _queue.add(_Item(e, _sessionId, bytes, now)); if (_queue.length > maxQueue) { final dropped = _queue.length - maxQueue; _queue.removeRange(0, dropped); _log('queue full, dropped $dropped oldest event(s)'); } _log('queued $e'); if (_queue.length >= _flushAt) { flush(); } else { _scheduleFlush(); } } String _newSessionId() => _randomId(20); String _randomId(int length) { final sb = StringBuffer(); for (var i = 0; i < length; i++) { sb.write(_idChars[_random.nextInt(_idChars.length)]); } return sb.toString(); } void _rotateSession(String reason) { _sessionId = _newSessionId(); _log('new session ($reason)'); } void _scheduleFlush() { if (_flushTimer != null || _retryTimer != null) return; _flushTimer = Timer(_flushInterval, () { _flushTimer = null; flush(); }); } void _scheduleRetry(int delayMs) { _flushTimer?.cancel(); _flushTimer = null; if (_retryTimer != null) return; _retryTimer = Timer(Duration(milliseconds: max(0, delayMs)), () { _retryTimer = null; flush(); }); } void _cancelTimers() { _flushTimer?.cancel(); _retryTimer?.cancel(); _flushTimer = null; _retryTimer = null; } Future _drain() async { try { final epoch = _epoch; while (_enabled && epoch == _epoch && _queue.isNotEmpty) { final endpoint = _endpoint; if (endpoint == null || _websiteId.isEmpty) return; // init() not called yet — keep events final now = _now(); if (_retryAt > now) { _scheduleRetry(_retryAt - now); return; } final batch = _takeBatch(); if (batch == null) return; final result = await _post(endpoint, jsonEncode(_buildPayload(batch))); if (result.outcome == _Outcome.ok) { _attempt = 0; _retryAt = 0; _log('sent ${batch.items.length} event(s)'); continue; } if (result.outcome == _Outcome.drop) { _attempt = 0; _retryAt = 0; _log('server rejected batch (HTTP ${result.code}) — ${batch.items.length} event(s) dropped'); continue; } // tracking was disabled meanwhile: the batch is discarded and no retry pause is kept if (!_enabled || epoch != _epoch) return; _queue.insertAll(0, batch.items); if (_queue.length > maxQueue) _queue.removeRange(0, _queue.length - maxQueue); final backoff = min(_maxBackoffMs, 1000 * pow(2, _attempt).toInt()); final jittered = (backoff * (0.8 + _random.nextDouble() * 0.4)).round(); final delay = min(_maxRetryAfterMs, max(jittered, result.retryAfterMs)); _attempt = min(_attempt + 1, 20); _retryAt = _now() + delay; _log('send failed (${result.code > 0 ? 'HTTP ${result.code}' : 'network'}), retry in ${delay ~/ 1000}s'); _scheduleRetry(delay); return; } } catch (err) { _log('flush failed: $err'); } } _Batch? _takeBatch() { final minTs = _now() - _maxEventAgeMs; while (_queue.isNotEmpty && _queue.first.timestamp < minTs) { _queue.removeAt(0); } if (_queue.isEmpty) return null; final session = _queue.first.session; // a batch that failed before is resent unchanged with the same batch_id (server-side dedupe) final retryId = _queue.first.batchId; final items = <_Item>[]; var bytes = 512; while (_queue.isNotEmpty && items.length < maxBatch) { final next = _queue.first; if (next.session != session || next.batchId != retryId) break; if (next.timestamp < minTs) { _queue.removeAt(0); continue; } if (items.isNotEmpty && bytes + next.bytes + 1 > _maxBodyBytes) break; bytes += next.bytes + 1; items.add(_queue.removeAt(0)); } if (items.isEmpty) return null; final id = retryId ?? _randomId(_batchIdLength); for (final i in items) { i.batchId = id; } return _Batch(items, session, id); } Map _buildPayload(_Batch batch) => { 'website_id': _websiteId, 'batch_id': batch.id, 'sent_at': _now(), // lets the server correct a wrong device clock 'context': {..._context, 'session_id': batch.session, 'sdk': userAgent}, 'events': batch.items.map((i) => i.event).toList(), }; Future<_Result> _post(Uri endpoint, String body) async { try { // On the web, text/plain avoids a CORS preflight (the server parses the body as JSON regardless). // Browsers don't allow a custom User-Agent; elsewhere it replaces "Dart/ (dart:io)". final headers = kIsWeb ? const {'Content-Type': 'text/plain'} : const {'Content-Type': 'application/json', 'User-Agent': userAgent}; final res = await _http.post(endpoint, headers: headers, body: body).timeout(const Duration(seconds: 15)); final code = res.statusCode; if (code >= 200 && code < 300) return _Result(_Outcome.ok, code); if (code == 408 || code == 429 || code >= 500) { return _Result(_Outcome.retry, code, parseRetryAfter(res.headers['retry-after'], _now())); } return _Result(_Outcome.drop, code); } catch (err) { _log('network error: $err'); return const _Result(_Outcome.retry); } } /// Keeps only flat String / finite num / bool values (max 50) and skips entries that would exceed /// 4 KB, measured like the server does (2 + sum of |key| + |value| + 2, here in UTF-8 bytes). static Map? sanitizeEventData(Map? data) { if (data == null || data.isEmpty) return null; final out = {}; var size = 2; for (final entry in data.entries) { if (out.length >= _maxEventDataKeys) break; final key = _truncate(entry.key, _maxKey); if (key.isEmpty || out.containsKey(key)) continue; final value = entry.value; Object clean; if (value is String) { clean = _truncate(value, _maxString); } else if (value is bool) { clean = value; } else if (value is num && value.isFinite) { clean = value; } else { continue; // null, maps, lists, DateTime, custom objects … are dropped } final entrySize = utf8.encode(jsonEncode(key)).length + utf8.encode(jsonEncode(clean)).length + 2; if (size + entrySize > _maxEventDataBytes) continue; size += entrySize; out[key] = clean; } return out.isEmpty ? null : out; } static const List _months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; /// `Retry-After` as delay in ms: delta-seconds (`120`) or an HTTP date (`Wed, 21 Oct 2026 07:28:00 GMT`). @visibleForTesting static int parseRetryAfter(String? header, int nowMs) { final h = header?.trim() ?? ''; if (h.isEmpty) return 0; final secs = int.tryParse(h); if (secs != null) return max(0, secs * 1000); final m = RegExp(r'^\w{3}, (\d{1,2}) (\w{3}) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$').firstMatch(h); if (m == null) return 0; final month = _months.indexOf(m.group(2)!.toLowerCase()); if (month < 0) return 0; final date = DateTime.utc(int.parse(m.group(3)!), month + 1, int.parse(m.group(1)!), int.parse(m.group(4)!), int.parse(m.group(5)!), int.parse(m.group(6)!)); return max(0, date.millisecondsSinceEpoch - nowMs); } static int Function() _stopwatchClock() { final sw = Stopwatch()..start(); return () => sw.elapsedMilliseconds; } static String _truncate(String s, int maxLength) => s.length > maxLength ? s.substring(0, maxLength) : s; static Random _secureRandom() { try { return Random.secure(); } catch (_) { return Random(); } } void _log(String message) { if (_debug) debugPrint('[trackd] $message'); } } class _LifecycleObserver with WidgetsBindingObserver { _LifecycleObserver(this.client); final TrackdClient client; @override void didChangeAppLifecycleState(AppLifecycleState state) { try { // `hidden` (Flutter ≥ 3.13; compared by name to keep 3.10 support) is the only signal on web / desktop if (state == AppLifecycleState.paused || state == AppLifecycleState.detached || state.name == 'hidden') { client.onBackground(); } else if (state == AppLifecycleState.resumed) { client.onForeground(); } } catch (_) { // never crash the host app } } } /// Tracks a screen view for every page route that becomes visible. /// /// ```dart /// MaterialApp(navigatorObservers: [TrackdNavigatorObserver()], routes: {...}) /// ``` /// /// By default the route name (`RouteSettings.name`) is used and routes without a name are skipped. /// Never put user data into route names — use [nameExtractor] to map e.g. `/user/42` → `/user`. class TrackdNavigatorObserver extends NavigatorObserver { TrackdNavigatorObserver({ this.nameExtractor = _defaultName, this.routeFilter = _defaultFilter, }); /// Maps route settings to a screen name; return null to skip the route. final String? Function(RouteSettings settings) nameExtractor; /// Which routes are tracked. Default: page routes only (no dialogs, bottom sheets, popups). final bool Function(Route route) routeFilter; static String? _defaultName(RouteSettings settings) => settings.name; static bool _defaultFilter(Route route) => route is PageRoute; void _track(Route? route) { try { if (route == null || !routeFilter(route)) return; final name = nameExtractor(route.settings); if (name != null && name.isNotEmpty) Trackd.screen(name); } catch (_) { // never crash the host app } } @override void didPush(Route route, Route? previousRoute) => _track(route); @override void didReplace({Route? newRoute, Route? oldRoute}) => _track(newRoute); @override void didPop(Route route, Route? previousRoute) { // closing a dialog / bottom sheet must not count the page below as a new screen view try { if (routeFilter(route)) _track(previousRoute); } catch (_) { // never crash the host app } } }