Skip to content

Repository files navigation

FlexTrack Banner

FlexTrack

Multi-tracker analytics orchestration for Flutter: route events, enforce consent, and debug dispatch decisions from one call site.

pub package CI License: MIT

Interactive guide · API reference


The Problem

In many Flutter apps, analytics logic drifts into multiple layers and becomes difficult to reason about.

  • Events are scattered across widgets, cubits, repositories, and services.
  • Different trackers (Firebase, Mixpanel, internal APIs) need different routing rules.
  • Consent and PII checks are often duplicated or inconsistently applied.
  • High-volume events can flood pipelines and increase cost.
  • Teams struggle to verify what was sent, where it went, and why.

This creates fragile analytics implementations that are hard to maintain and hard to trust.

The Solution

FlexTrack introduces a centralized orchestration layer for analytics in Flutter.

  • One tracking call can dispatch to multiple trackers.
  • Declarative routing rules decide target trackers per event.
  • Consent, PII, and sampling policy are enforced in one place.
  • Debug tooling helps you inspect decisions and payload flow live.

Instead of spreading analytics policy throughout the app, define it once and apply it consistently.

Visual Demo

Inspector Demo

Quick Example

await FlexTrack.track(AppOpenedEvent());

One call site, multiple tracker destinations, centralized policy.


Table of contents


Quick start

Step 1 — add the dependency:

# pubspec.yaml
dependencies:
  flex_track: ^2.2.0

Step 2 — implement your tracker (the package ships no vendor SDKs; you write a thin adapter):

// lib/trackers/firebase_tracker.dart
import 'package:flex_track/flex_track.dart';
import 'package:firebase_analytics/firebase_analytics.dart';

class FirebaseTracker extends BaseTrackerStrategy {
  FirebaseTracker() : super(id: 'firebase', name: 'Firebase Analytics');

  @override
  Future<void> doInitialize() async {
    // Firebase.initializeApp() should be called before FlexTrack.setup().
    // Put any tracker-specific init here.
  }

  @override
  Future<void> doTrack(BaseEvent event) async {
    await FirebaseAnalytics.instance.logEvent(
      name: event.name,
      parameters: event.properties,
    );
  }
}

Step 3 — set up:

// lib/main.dart
import 'package:flex_track/flex_track.dart';
import 'trackers/firebase_tracker.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();

  await FlexTrack.setup([
    ConsoleTracker(),   // built-in — prints to debug console
    FirebaseTracker(),  // your class from step 2
  ]);

  runApp(const MyApp());
}

Step 4 — track:

await FlexTrack.track(AppOpenedEvent());

That's it. Both trackers receive every event. Add routing rules when you need more control.


Compare with the usual approach

Approach Trade-offs
Direct SDK calls Fast to start, but logic becomes scattered and difficult to audit.
Custom wrappers Centralizes calls, but often lacks routing, consent policy, and sampling controls.
FlexTrack Centralized, rule-driven, consent-aware, and testable orchestration across trackers.

Why this exists

Most production apps eventually use more than one analytics destination. Teams need to blend product analytics, growth metrics, and internal reporting while still enforcing consent and performance constraints.

FlexTrack exists to make that architecture explicit, maintainable, and debuggable in Flutter.

Who this is for / not for

This is for:

  • Flutter teams using multiple analytics providers
  • Apps with consent, GDPR, or PII controls
  • Teams that want clean routing policy and testable analytics behavior

This is not for:

  • Tiny apps with one tracker and no policy requirements
  • Quick throwaway prototypes where minimal setup matters more than architecture

Features

  • Multi-tracker routing with a fluent DSL
  • Event policy controls (consent, PII, sampling, environment modifiers)
  • Event transformers to automatically attach shared context to every event
  • Debug Inspector with live event visibility
  • FlexTrackClient for dependency injection patterns
  • Widget wrappers for click, impression, mount, and route-view tracking

The normative behavior shared by the Flutter and Kotlin SDKs is defined in the FlexTrack Core MVP specification and verified with shared conformance fixtures.


Examples

Path What it shows
example/ Flagship Flutter app: routing, consent UI, mock trackers, widget wrappers, FlexTrackRouteObserver. Run with cd example && flutter run.
examples/static_app FlexTrack.setup / FlexTrack.track only (no DI).
examples/riverpod_app FlexTrack.setup in main, then FlexTrack.instance.client from a Riverpod Provider.
examples/bloc_getit_app Same client registered in GetIt; a Cubit calls client.track.

Live web build of the flagship example ships with the doc site: from the documentation site use Live demo in the navbar (/demo/), built in CI from example/.


FlexTrackClient and dependency injection

FlexTrack.setup() still installs a singleton and FlexTrack.track() delegates to it. For constructor injection (Bloc/Cubit, Riverpod, tests without globals), build a FlexTrackClient with the same routing stack:

final analytics = await FlexTrackClient.create(
  [ConsoleTracker(), FirebaseTracker()],
  // routing: optional RoutingConfiguration; defaults to smart defaults
);

await analytics.track(AppOpenedEvent());
await analytics.dispose(); // flush trackers when tearing down (e.g. tests)

With the routing DSL (mirror of FlexTrack.setupWithRouting):

final analytics = await FlexTrackClient.createWithRouting(
  [ConsoleTracker()],
  (b) {
    b.routeDefault().toAll();
    return b;
  },
);

After FlexTrack.setup, the underlying client is available as FlexTrack.instance.client if you need both global widgets and an injectable reference to the same instance.

Riverpod

Define a provider (overridden in main and in tests):

// analytics_providers.dart — add flutter_riverpod to the app, not to flex_track
import 'package:flex_track/flex_track.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final flexTrackClientProvider = Provider<FlexTrackClient>((ref) {
  throw UnimplementedError('Override in ProviderScope');
});

Bootstrap:

final client = await FlexTrackClient.create([ConsoleTracker(), FirebaseTracker()]);

runApp(ProviderScope(
  overrides: [flexTrackClientProvider.overrideWithValue(client)],
  child: const MyApp(),
));

Use inside a notifier or service:

Future<void> onPurchaseComplete(WidgetRef ref) async {
  await ref.read(flexTrackClientProvider).track(PurchaseEvent(amount: 9.99));
}

Bloc / Cubit

Pass the client into the cubit; domain code stays testable by injecting a client backed by MockTracker.

class CheckoutCubit extends Cubit<CheckoutState> {
  CheckoutCubit(this._analytics) : super(const CheckoutState());
  final FlexTrackClient _analytics;

  Future<void> complete() async {
    await _analytics.track(OrderCompletedEvent());
    // ...
  }
}

For strict clean architecture, wrap FlexTrackClient behind your own Analytics interface in the domain module and implement the adapter in infrastructure.

Offline delivery and selective retry

Provide an application-owned queue file and connectivity signal when creating the client. The file queue uses atomic replacement and restores the processed event with its original ID and UTC timestamp after an app restart.

var online = true;
final client = await FlexTrackClient.create(
  [FirebaseTracker(), InternalApiTracker()],
  queue: FileEventQueue(File('/app-private/flextrack-queue.json')),
  onlineProvider: () => online,
);

final result = await client.track(PurchaseEvent());
print(result.queuedTrackerIds); // only destinations still awaiting delivery

final flush = await client.flush(limit: 100);
print(flush.remainingEvents);

When offline, no tracker is called and every routed destination is queued. When one tracker fails, successful trackers are never retried. Flush operations are FIFO, bounded, and serialized; retries use the stored processed event without rerunning transformers, routing, consent, or sampling. FileEventQueue is available on dart:io platforms. Web clients should inject a platform-specific EventQueue or use InMemoryEventQueue.

The normative cross-SDK behavior is documented in Runtime Delivery Specification 1.0.0.

More detail: doc/flex-track-client.md.


Design philosophy

This package does not bundle Firebase, Mixpanel, Amplitude, or any other analytics SDK. You extend BaseTrackerStrategy — the adapter lives in your codebase, under your control. This means no transitive dependency conflicts, no surprise SDK version requirements, and no opaque initialisation happening inside the package. The package routes; you decide where events go and how they get there.


Creating events

Extend BaseEvent and implement the name and properties getters. Everything else is optional.

Every event receives an immutable UUID v4 eventId and UTC timestamp when it is constructed. Those values remain unchanged through enrichment and dispatch. For replay or restored offline events, pass the original metadata to super(eventId: storedId, timestamp: storedTimestamp) from your event constructor.

class PurchaseEvent extends BaseEvent {
  final double amount;
  PurchaseEvent({required this.amount});

  @override
  String get name => 'purchase';

  @override
  Map<String, Object> get properties => {'amount': amount};

  @override
  EventCategory get category => EventCategory.business;

  @override
  bool get isEssential => true;

  @override
  bool get containsPII => false;
}

Event flags

All flags have safe defaults — override only the ones relevant to your event.

Flag Default Effect
isEssential false Bypasses consent checks and sampling — always sent
isHighVolume false Signals to routing rules that sampling should apply
containsPII false Blocks the event until PII consent is granted
requiresConsent true Blocks the event until general consent is granted

Rules of thumb:

  • Crash reporters, security events → isEssential: true
  • Scroll position, hover events → isHighVolume: true
  • Profile updates, search queries → containsPII: true
  • App open, session start → requiresConsent: false if no personal data is sent

EventCategory values

Category When to use
EventCategory.business Revenue, conversions, checkout funnel
EventCategory.user Behaviour, preferences, feature usage
EventCategory.technical Errors, performance, debug events
EventCategory.sensitive Events that combine PII with behaviour
EventCategory.marketing Campaign attribution, ad interactions
EventCategory.system App lifecycle, background tasks

Custom categories

If the predefined categories don't fit your domain, define your own with defineCategory() on the RoutingBuilder:

FlexTrack.configure(
  RoutingBuilder()
    .defineCategory('experiments', description: 'A/B test and feature flag events')
    .defineCategory('payments', description: 'Payment flow events')
    // ... rest of your config
);

Then assign it in your event:

class ExperimentEvent extends TrackableEvent {
  @override
  EventCategory get category => const EventCategory('experiments');
}

And route it like any predefined category:

.routeCategory(const EventCategory('experiments')).to(['analytics']).and()

You can also create a subcategory of an existing one:

final paymentsCategory = EventCategory.business.createSubcategory('payments');
// results in name: 'business_payments'

Event transformers

Transformers let you automatically attach common parameters to every event — or to a filtered subset — without touching each call site. A transformer is a function that receives the outgoing event and returns a (potentially enriched) event. All transformers run inside EventProcessor before routing and before trackers receive the event.

A common use case: attach the current screen name to every event so your analytics backend always knows which page the event came from.

App-wide context (setup once)

Register transformers right after FlexTrack.setup(). They stay active for the lifetime of the app.

// lib/main.dart
await FlexTrack.setup([ConsoleTracker(), FirebaseTracker()]);

// Attach static context to every event.
FlexTrack.addTransformer((event) => EnrichedEvent(event, {
  'app_version': packageInfo.version,
  'platform': Platform.operatingSystem,
}));

runApp(const MyApp());

Dynamic values (evaluated at dispatch time)

Because the transformer function is called at the moment an event is fired, dynamic values like the current route are always fresh — no stale captures.

// lib/main.dart
final routeObserver = FlexTrackRouteObserver();

// Register once after setup — currentRoute is read fresh on every event.
FlexTrack.addTransformer((event) => EnrichedEvent(event, {
  'current_route': routeObserver.currentRoute ?? 'unknown',
}));

Conditional transformers

Use conditionalTransformer to enrich only events that match a condition. Events that don't match pass through unchanged.

FlexTrack.addTransformer(
  conditionalTransformer(
    (event) => event.category == EventCategory.business,
    (event) => EnrichedEvent(event, {'checkout_experiment': 'variant_b'}),
  ),
);

Any predicate works — event name, type, category, or custom property check.

Chaining transformers

Transformers are applied in registration order. Each transformer receives the output of the previous one, so they compose naturally.

// Transformer 1 — adds global context
FlexTrack.addTransformer((event) => EnrichedEvent(event, {
  'current_route': routeObserver.currentRoute ?? 'unknown',
}));

// Transformer 2 — adds session context on top
FlexTrack.addTransformer((event) => EnrichedEvent(event, {
  'session_id': sessionManager.currentSessionId,
}));

EnrichedEvent

EnrichedEvent is a BaseEvent wrapper. It forwards all metadata from the original event (category, containsPII, requiresConsent, etc.) and overrides properties to merge the original properties with the extra ones. Extra properties win on key collision.

Type-based routes remain anchored to the original event through any number of EnrichedEvent wrappers. A route<PurchaseEvent>() rule therefore continues to match enriched purchases and subclasses of PurchaseEvent, while property-based routes can still match properties added by transformers.

// Extra properties override originals on the same key.
EnrichedEvent(originalEvent, {'source': 'transformer'})

You never need to modify your event classes. EnrichedEvent is the only class you need to produce from a transformer.

Removing a transformer:

To remove a transformer later (e.g. when the user logs out), keep a reference to the function and pass it to removeTransformer:

BaseEvent _sessionTransformer(BaseEvent event) =>
    EnrichedEvent(event, {'session_id': sessionManager.currentSessionId});

// Add
FlexTrack.addTransformer(_sessionTransformer);

// Remove later
FlexTrack.removeTransformer(_sessionTransformer);

FlexTrack.clearTransformers() removes all transformers at once.

With FlexTrackClient (dependency injection):

The same API is available on the injectable client:

final client = await FlexTrackClient.create([ConsoleTracker()]);
client.addTransformer((event) => EnrichedEvent(event, {'tenant': tenantId}));

Creating trackers

All trackers extend BaseTrackerStrategy. Implement doInitialize() and doTrack(). The base class handles:

  • Guarding against double-initialisation
  • Catching and wrapping errors from your implementation
  • Enabling/disabling at runtime
class MyTracker extends BaseTrackerStrategy {
  MyTracker() : super(id: 'my_tracker', name: 'My Tracker');

  @override
  Future<void> doInitialize() async {
    // Called once by FlexTrack.setup(). Perform SDK init here.
  }

  @override
  Future<void> doTrack(BaseEvent event) async {
    // Called for each routed event.
    // event.name       → String
    // event.properties → Map<String, Object>
    // event.category        → EventCategory?
  }
}

The id you pass to super is how routing rules reference this tracker:

.to(['my_tracker'])   // matches super(id: 'my_tracker', ...)

Use a stable, lowercase slug. Changing the id breaks any routing rule that references it.

Firebase example

// lib/trackers/firebase_tracker.dart
import 'package:flex_track/flex_track.dart';
import 'package:firebase_analytics/firebase_analytics.dart';

class FirebaseTracker extends BaseTrackerStrategy {
  FirebaseTracker() : super(id: 'firebase', name: 'Firebase Analytics');

  @override
  Future<void> doInitialize() async {
    // Firebase.initializeApp() should already be called before FlexTrack.setup().
  }

  @override
  Future<void> doTrack(BaseEvent event) async {
    await FirebaseAnalytics.instance.logEvent(
      name: event.name,
      parameters: event.properties,
    );
  }
}

Mixpanel example

// lib/trackers/mixpanel_tracker.dart
import 'package:flex_track/flex_track.dart';
import 'package:mixpanel_flutter/mixpanel_flutter.dart';

class MixpanelTracker extends BaseTrackerStrategy {
  MixpanelTracker() : super(id: 'mixpanel', name: 'Mixpanel');

  late Mixpanel _mixpanel;

  @override
  Future<void> doInitialize() async {
    _mixpanel = await Mixpanel.init('YOUR_PROJECT_TOKEN', trackAutomaticEvents: false);
  }

  @override
  Future<void> doTrack(BaseEvent event) async {
    _mixpanel.track(event.name, properties: event.properties);
  }
}

Use both in setup:

// lib/main.dart
import 'trackers/firebase_tracker.dart';
import 'trackers/mixpanel_tracker.dart';

await FlexTrack.setup([
  ConsoleTracker(),
  FirebaseTracker(),
  MixpanelTracker(),
]);

Smart routing

Without a routing config, every event goes to every tracker. Routing rules let you control which events go where, with what sampling, and under what consent conditions.

Routing DSL

await FlexTrack.setupWithRouting([
  ConsoleTracker(),
  FirebaseTracker(),    // id: 'firebase'
  MixpanelTracker(),   // id: 'mixpanel'
], (routing) => routing

  // Business events: everywhere, never sampled.
  .routeCategory(EventCategory.business)
  .toAll()
  .noSampling()
  .withPriority(20)
  .and()

  // High-volume events: firebase only, 1% sampling.
  .routeHighVolume()
  .to(['firebase'])
  .heavySampling()
  .and()

  // PII events: require explicit PII consent.
  .routePII()
  .to(['internal'])
  .requirePIIConsent()
  .and()

  // Essential events: always fire, no consent check.
  .routeEssential()
  .toAll()
  .skipConsent()
  .and()

  // Events with an internal_metric property: api tracker only.
  .routeWithProperty('internal_metric')
  .to(['api'])
  .and()

  // Specific name pattern.
  .routeMatching(RegExp(r'purchase_.*'))
  .toAll()
  .noSampling()
  .and()

  // Exact name match.
  .routeNamed('app_start')
  .to(['firebase'])
  .and()

  // Catch-all — always include this.
  .routeDefault()
  .toAll()
);

Rules are evaluated in priority order. The first matching rule wins.

Full list of matchers:

Matcher Matches when
.routeCategory(EventCategory.x) event.category == x
.routeHighVolume() event.isHighVolume == true
.routeEssential() event.isEssential == true
.routePII() event.containsPII == true
.routeMatching(RegExp(...)) event name matches the regex
.routeNamed('pattern') event name contains the substring
.routeWithProperty('key') properties contains the key
.routeDefault() catch-all (put this last)

Full list of targets:

Target Sends to
.toAll() Every registered tracker
.to(['id1', 'id2']) Specific trackers by id
.toGroupNamed('name') A named group (see below)

Consent modifiers:

Modifier Effect
.requireConsent() Blocked until general: true (default behaviour)
.requirePIIConsent() Blocked until pii: true
.skipConsent() Always fires regardless of consent state

Tracker groups

Name a fixed set of trackers and reference them across rules:

await FlexTrack.setupWithRouting([
  ConsoleTracker(),
  FirebaseTracker(),
  MixpanelTracker(),
], (routing) => routing
  .defineGroup('paid', ['mixpanel', 'amplitude'])
  .defineGroup('internal', ['console', 'api'])

  .routeCategory(EventCategory.user)
  .toGroupNamed('paid')
  .and()

  .routeCategory(EventCategory.technical)
  .toGroupNamed('internal')
  .and()

  .routeDefault()
  .toAll()
);

Priority

Higher priority rules are evaluated first. Rules with equal priority are evaluated in the order they were added. The default priority is 0.

routing
  .routeCategory(EventCategory.business).toAll().withPriority(20).and()
  .routeHighVolume().to(['firebase']).withPriority(10).and()
  .routeDefault().toAll()
  // business events (priority 20) are matched before high-volume (priority 10)

This matters when an event matches multiple rules — only the first match is applied.

Environment modifiers

// Only in debug builds (kDebugMode == true).
.routeCategory(EventCategory.technical).to(['console']).onlyInDebug().and()

// Only in release builds.
.routeCategory(EventCategory.marketing).toAll().onlyInProduction().and()

These work per-rule, so you can send the same event to the console in debug and to Firebase in production:

routing
  .routeCategory(EventCategory.technical).to(['console']).onlyInDebug().and()
  .routeDefault().toAll()

Widget wrappers

All wrappers call FlexTrack.track internally and no-op safely if FlexTrack.setup was never called.

FlexClickTrack

Wraps any widget and fires an event on tap. Uses GestureDetector with HitTestBehavior.translucent, so child interactive widgets (ElevatedButton, InkWell, TextButton) still receive their own tap events as well. You do not need to remove existing onPressed handlers.

// Works on non-interactive widgets directly:
FlexClickTrack(
  event: BannerClickEvent(),
  child: Image.network('https://example.com/banner.png'),
)

// Also works when the child is already interactive — both fire:
FlexClickTrack(
  event: SignUpButtonClickedEvent(),
  child: ElevatedButton(
    onPressed: _navigateToSignUp,
    child: const Text('Sign up'),
  ),
)

FlexImpressionTrack

Fires when the child widget crosses a visibility threshold. Requires the visibility_detector package in your app.

FlexImpressionTrack(
  // Must be stable for the same logical slot across rebuilds.
  // Use item id, not list index.
  visibilityKey: ValueKey('banner_${banner.id}'),

  event: BannerImpressionEvent(bannerId: banner.id),

  // Fire when 50% of the widget is visible (default: 0.5).
  visibleFractionThreshold: 0.5,

  // Optional: widget must stay visible for this long before firing.
  minVisibleDuration: const Duration(milliseconds: 500),

  // Default: true — fires at most once per widget lifecycle.
  // Set false to re-fire after the widget leaves and re-enters view.
  fireOnce: true,

  child: BannerWidget(banner: banner),
)

Two things to get right:

  1. visibilityKey must be stable for the same content. If your list rebuilds and a different Key instance refers to the same banner slot, a new impression fires. Use ValueKey(item.id), not ValueKey(index).

  2. fireOnce: true (default) is per State instance. The event fires once and won't fire again even if the user scrolls away and back. Set fireOnce: false if you want re-impression tracking on each visibility streak.

FlexMountTrack

Fires exactly once after the widget is first inserted into the widget tree. Useful in ListView.builder to know when an item was built (rendered on scroll), not necessarily seen.

// Each time this ProductTile is built by ListView.builder, one event fires.
FlexMountTrack(
  event: ProductTileRenderedEvent(productId: product.id),
  child: ProductTile(product: product),
)

FlexMountTrack fires on mount, not on visibility. The widget may be off-screen when it mounts (e.g. Flutter over-renders scroll views). Use FlexImpressionTrack when you need confirmed visibility.

FlexTrackRouteViewMixin

Tracks screen views via Flutter's navigator observer. Register one FlexTrackRouteObserver instance on MaterialApp, then apply the mixin to each screen's State.

// lib/main.dart — register once
final routeObserver = FlexTrackRouteObserver();

MaterialApp(
  navigatorObservers: [routeObserver],
  home: const HomeScreen(),
)
// lib/screens/home_screen.dart
import 'package:flex_track/flex_track.dart';
import '../main.dart' show routeObserver;

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});
  @override State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> with FlexTrackRouteViewMixin {
  @override
  FlexTrackRouteObserver get flexTrackRouteObserver => routeObserver;

  @override
  BaseEvent get routeViewEvent => HomeViewedEvent();

  /// Set true to fire another view event when the user pops back to this screen.
  @override
  bool get trackWhenReturningFromChildRoute => false;

  @override
  Widget build(BuildContext context) => const Scaffold(/* ... */);
}

The mixin subscribes via RouteAware and fires routeViewEvent on push, and optionally on pop-back. It unsubscribes automatically in dispose — no cleanup needed.


GDPR and consent

// Call this after the user responds to your consent dialog.
FlexTrack.setConsent(general: true, pii: false);

// Read the current state.
final status = FlexTrack.getConsentStatus();
// Returns: {'general': true, 'pii': false}

Until consent is set, general and pii both default to false. Events that require consent are dropped silently — no exception is thrown.

Consent interaction table:

Event flag Rule modifier What happens
requiresConsent: true (default) .requireConsent() (default) Dropped until general: true
requiresConsent: false Always fires
isEssential: true Always fires regardless of any consent state
containsPII: true .requirePIIConsent() Dropped until pii: true

GDPR presets:

GDPRDefaults.apply adds pre-configured rules for PII, sensitive, user, marketing, and system categories. Pass compliantTrackers to limit PII events to specific backends.

await FlexTrack.setupWithRouting([
  ConsoleTracker(),
  FirebaseTracker(),        // id: 'firebase' — you've verified it's GDPR compliant
  InternalApiTracker(),     // id: 'internal'
], (routing) {
  GDPRDefaults.apply(routing, compliantTrackers: ['firebase', 'internal']);
  return routing;
});

For stricter controls (e.g. EU deployment), use GDPRDefaults.applyStrict:

GDPRDefaults.applyStrict(routing, compliantTrackers: ['internal']);

applyStrict additionally requires consent for behavioral events (click, view, scroll patterns) and restricts more property keys.


Sampling and performance

Sampling is applied per rule and is deterministic by default. FlexTrack hashes the first non-empty value from event.userId, event.sessionId, and event.name with FNV-1a over UTF-8 bytes. The resulting stable bucket is compared with the rule's rate, so the same identity receives the same decision across launches and SDK implementations. Essential events always bypass sampling.

When no user or session identity is available, all events with the same name share a decision. Supply a stable user or session id when you need a representative user-level sample. The cross-platform vectors are published in test/fixtures/sampling_vectors.json.

Method Rate
.noSampling() 100% — all events
.lightSampling() 10%
.mediumSampling() 50%
.heavySampling() 1%
.sample(0.25) Custom rate (0.0–1.0)

isEssential: true on the event bypasses sampling for that event regardless of the rule's sample rate.

Performance presets:

await FlexTrack.setupWithRouting(trackers, (routing) {
  PerformanceDefaults.apply(routing);
  return routing;
});

// More aggressive for mobile:
PerformanceDefaults.applyMobileOptimized(routing);

PerformanceDefaults.apply applies heavy sampling to high-volume and UI interaction events, and no sampling to critical business events like purchases and errors.

Batch tracking:

// Track multiple events in one call — more efficient than sequential track() calls.
await FlexTrack.trackAll([
  PageViewEvent(page: 'checkout'),
  CheckoutStartedEvent(cartValue: 49.99),
]);

Pausing and resuming:

// Pause all tracking (e.g. user opts out mid-session).
FlexTrack.disable();

// Resume.
FlexTrack.enable();

Debugging

FlexTrack Inspector

In debug builds on mobile/desktop (not Flutter Web), you can start a local dashboard that streams events as they are dispatched. The console prints a URL such as:

FlexTrack Inspector (open in browser): http://127.0.0.1:7788

Open that address in a browser to inspect the live event list, tracker status, consent snapshot, and per-event JSON.

FlexTrack Inspector dashboard with the flagship example app

import 'package:flex_track/flex_track_inspector.dart';
import 'package:flutter/foundation.dart';

// After FlexTrack.setup — e.g. in kDebugMode only:
if (kDebugMode) {
  final inspectorUrl = await FlexTrackInspector.start(port: 7788);
  if (inspectorUrl != null) {
    debugPrint('FlexTrack Inspector (open in browser): $inspectorUrl');
  }
}

See the flagship example/ app (example/lib/utils/analytics_setup.dart). On Flutter Web, FlexTrackInspector.start is a no-op (no embedded HTTP server).


Print a summary of registered trackers, consent state, and routing config:

FlexTrack.printDebugInfo();

Dry-run an event through the routing engine without sending it:

final result = FlexTrack.debugEvent(PurchaseEvent(amount: 9.99));
// Shows which rule matched, which trackers would receive the event,
// whether consent passes, and whether sampling would drop it.

Validate the routing config for common mistakes (unreachable rules, missing default, etc.):

final errors = FlexTrack.validate();
if (errors.isNotEmpty) {
  for (final e in errors) debugPrint('Config error: $e');
}

ConsoleTracker options:

ConsoleTracker is the only built-in tracker. It accepts a few options:

ConsoleTracker(
  showProperties: true,   // print event properties (default: true)
  showTimestamps: true,   // prefix with HH:mm:ss.ms (default: true)
  colorOutput: true,      // ANSI colours in terminal (default: true)
  prefix: '📊 Analytics', // default: '📊 FlexTrack'
)

Use it in production builds alongside your real trackers if you want a local audit log, or only include it conditionally:

await FlexTrack.setup([
  if (kDebugMode) ConsoleTracker(),
  FirebaseTracker(),
]);

Testing

Global singleton (existing pattern): use setupFlexTrackForTesting() and FlexTrack.reset() in tearDown.

Injectable client (no global): create a FlexTrackClient with a MockTracker, pass it into your class under test, and call await client.dispose() in tearDown. See doc/flex-track-client.md.

import 'package:flutter_test/flutter_test.dart';
import 'package:flex_track/flex_track.dart';

void main() {
  late MockTracker mock;

  setUp(() async {
    // Returns a MockTracker pre-wired to FlexTrack.
    // Consent checks and sampling are both disabled.
    mock = await setupFlexTrackForTesting();
  });

  tearDown(() async {
    // FlexTrack is a singleton — always reset between tests.
    await FlexTrack.reset();
  });

  test('tracks a purchase event', () async {
    await FlexTrack.track(PurchaseEvent(amount: 9.99));

    expect(mock.capturedEvents, hasLength(1));
    expect(mock.capturedEvents.single.name, 'purchase');
    expect(mock.capturedEvents.single.properties['amount'], 9.99);
  });

  test('does not send event without consent', () async {
    // For consent tests, set up with a real routing config instead.
    await FlexTrack.reset();
    final customMock = MockTracker();
    await FlexTrack.setupWithRouting([customMock], (r) => r
      .routeDefault().toAll().requireConsent().and());

    FlexTrack.setConsent(general: false);
    await FlexTrack.track(PageViewEvent());

    expect(customMock.capturedEvents, isEmpty);
  });
}

MockTracker exposes:

  • capturedEventsList<BaseEvent> of every event that reached the tracker
  • Use mock.capturedEvents.single when you expect exactly one event
  • Use mock.capturedEvents.where(...) to filter by name or type

setupFlexTrackForTesting() disables consent checking and sampling so your assertions aren't affected by those concerns unless you're specifically testing them. For those cases, bypass it and set up manually as shown above.


Common pitfalls

1. Calling track() before setup()

// ❌ Throws ConfigurationException — FlexTrack is not set up.
await FlexTrack.track(MyEvent());
// ✅ Always await setup() before tracking.
await FlexTrack.setup([ConsoleTracker(), FirebaseTracker()]);
await FlexTrack.track(MyEvent());

FlexTrack.setup() must complete before any call to track(), setConsent(), or other instance methods.


2. Forgetting FlexTrack.reset() between tests

// ❌ Second test throws 'FlexTrack is already set up'.
setUp(() async { await setupFlexTrackForTesting(); });
// ✅ Always reset in tearDown.
setUp(() async { mock = await setupFlexTrackForTesting(); });
tearDown(() async { await FlexTrack.reset(); });

FlexTrack is a singleton. Without reset(), setup() in the second test throws because the instance from the first test is still alive.


3. Unstable visibilityKey in FlexImpressionTrack

// ❌ Using list index as key — impression re-fires when items shift position.
FlexImpressionTrack(
  visibilityKey: ValueKey(index),   // wrong
  event: ProductImpressionEvent(product.id),
  child: ProductTile(product: product),
)
// ✅ Use a stable identifier tied to the content, not its position.
FlexImpressionTrack(
  visibilityKey: ValueKey(product.id),  // correct
  event: ProductImpressionEvent(product.id),
  child: ProductTile(product: product),
)

When the key changes, FlexImpressionTrack treats it as a new widget and fires another impression — even if the same product was already shown.


4. Missing .routeDefault() at the end of a routing config

// ❌ Events not matched by any rule are silently dropped.
await FlexTrack.setupWithRouting(trackers, (routing) => routing
  .routeCategory(EventCategory.business).toAll().and()
  .routeHighVolume().to(['firebase']).and()
  // No default — any other event is dropped.
);
// ✅ Always end with routeDefault().
await FlexTrack.setupWithRouting(trackers, (routing) => routing
  .routeCategory(EventCategory.business).toAll().and()
  .routeHighVolume().to(['firebase']).and()
  .routeDefault().toAll()  // catches everything else
);

Run FlexTrack.validate() during development — it will flag a missing default rule.


5. Transformer added with an anonymous function can't be removed

// ❌ The lambda is a new object each call — removeTransformer won't find it.
FlexTrack.addTransformer((e) => EnrichedEvent(e, {'route': '/home'}));
FlexTrack.removeTransformer((e) => EnrichedEvent(e, {'route': '/home'})); // no-op
// ✅ Keep a reference to the exact function instance.
BaseEvent _routeTransformer(BaseEvent e) =>
    EnrichedEvent(e, {'route': routeObserver.currentRoute ?? 'unknown'});

FlexTrack.addTransformer(_routeTransformer);
// Later:
FlexTrack.removeTransformer(_routeTransformer);

If you never need to remove a transformer individually, anonymous lambdas are fine. Use clearTransformers() to remove everything at once.


Contributing and license

Source: github.com/alirezat66/flex_track

Issues and pull requests are welcome. Please open an issue before starting large changes.

License: MIT


Support

If this project helps you:

  • ⭐ Star the repo
  • 💬 Share feedback
  • 🐛 Open issues

About

A package to help developers send events dynamically

Resources

Stars

8 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages