liquid_analytics

Vendor-neutral analytics for Flutter. Emit one typed event and fan it out to any set of providers through a middleware pipeline and a built-in consent gate — GDPR-style opt-in by default.

final liquid = Liquid(
  sinks: [
    FirebaseSink(),
    PostHogSink(apiKey: '…'),
    MixpanelSink(token: '…'),
  ],
  middleware: [
    GlobalContext({'env': 'prod', 'app_version': '2.1.0'}),
    PiiRedactor(fields: {'email', 'phone'}),
    Sampler(rate: 0.25, names: {'scroll_depth'}),
  ],
  consent: ConsentPolicy(
    requireOptIn: true, // nothing leaves the device until the user agrees
    map: {
      ConsentCategory.analytics: ['firebase', 'posthog'],
      ConsentCategory.marketing: ['mixpanel'],
    },
  ),
);
await liquid.init();

Three ways to record events

// 1. Quick string API
liquid.track('checkout_started', {'cart_value': 42.0});

// 2. Typed events — no code generation, just a class
class CheckoutStarted extends LiquidEvent {
  const CheckoutStarted({required this.cartValue});
  final double cartValue;
  @override String get name => 'checkout_started';
  @override Map<String, Object?> get properties => {'cart_value': cartValue};
}
liquid.log(const CheckoutStarted(cartValue: 42));

// 3. Identify / screen / group / alias / reset
liquid.identify('user_123', {'plan': 'pro'});
liquid.screen('Cart');
liquid.reset(); // clears the user across all sinks

Wire your consent banner straight to the controller. Events emitted before the user decides are buffered and replayed the moment they opt in; if they opt out, the buffer is discarded.

liquid.consent.grant(ConsentCategory.analytics);
liquid.consent.deny(ConsentCategory.marketing);

// ConsentController is a ChangeNotifier — drive UI from it directly.
ListenableBuilder(
  listenable: liquid.consent,
  builder: (_, __) => Switch(
    value: liquid.consent.statusOf(ConsentCategory.analytics) ==
        ConsentStatus.granted,
    onChanged: (on) => on
        ? liquid.consent.grant(ConsentCategory.analytics)
        : liquid.consent.deny(ConsentCategory.analytics),
  ),
);

Routing categories to sinks

ConsentPolicy.map decides which sinks may receive which category. A non-empty map is an explicit allowlist — a category with no entry reaches no sink at all:

ConsentPolicy(
  map: {
    ConsentCategory.analytics: ['firebase', 'posthog'],
    ConsentCategory.marketing: ['mixpanel'],
    // `personalization` is unmapped -> those events go nowhere.
  },
);

This fails closed on purpose: forgetting a category withholds data instead of broadcasting it everywhere. Debug builds log a one-time warning naming any category that was dropped this way.

Leave the map empty to turn category routing off entirely, and every capable sink receives everything the consent gate lets through.

liquid keeps consent in memory only. Seed it from storage with ConsentPolicy.defaults, and write back whenever the user changes a toggle.

// Load before constructing Liquid:
final defaults = <ConsentCategory, ConsentStatus>{
  // e.g. from SharedPreferences: ConsentStatus.values.byName(raw)
  ConsentCategory.analytics: ConsentStatus.granted,
};

final liquid = Liquid(
  sinks: [/* ... */],
  consent: ConsentPolicy(
    requireOptIn: true,
    defaults: defaults,
  ),
);
await liquid.init();

// Save after each decision:
liquid.consent.addListener(() async {
  final prefs = await SharedPreferences.getInstance();
  for (final c in ConsentCategory.values) {
    if (c == ConsentCategory.necessary) continue;
    final s = liquid.consent.statusOf(c);
    if (s == ConsentStatus.unknown) {
      await prefs.remove('consent_${c.name}');
    } else {
      await prefs.setString('consent_${c.name}', s.name);
    }
  }
});

Ready-made UI: liquid_analytics_ui (LiquidConsentBanner, showLiquidConsentSheet).

Full app bootstrap: repository Getting started.

Automatic screen tracking

MaterialApp(
  navigatorObservers: [LiquidNavigatorObserver(liquid)],
);

Reliability (offline queue + flush)

By default liquid delivers immediately after consent. If a sink throws, the message is queued with exponential backoff and retried on Liquid.flush, a timer, or when the app is backgrounded.

final liquid = Liquid(
  sinks: [PostHogSink(apiKey: '…')],
  consent: ConsentPolicy.allowAll,
  delivery: DeliveryOptions(
    // Optional durable store (file / shared_preferences / your backend):
    store: JsonDeliveryStore(
      read: () async => prefs.getString('liquid_queue'),
      write: (json) async => prefs.setString('liquid_queue', json),
    ),
    maxAttempts: 5,
    flushOnBackground: true,
  ),
);

Batch mode

Buffer consented events and drain them in bulk — useful when many HTTP sinks share a device radio:

delivery: DeliveryOptions(
  batchMode: true,
  flushPolicies: [
    CountFlushPolicy(20),
    TimerFlushPolicy(Duration(seconds: 30)),
  ],
),

Call await liquid.flush() anytime (also runs each sink's own flush).

DX packages

Package Purpose
liquid_analytics_ui Consent banner/sheet, event inspector, GoRouter helpers
liquid_codegen Optional YAML → typed LiquidEvent CLI

Testing

FakeSink records everything in memory:

final fake = FakeSink();
final liquid = Liquid(sinks: [fake], consent: ConsentPolicy.allowAll);
await liquid.init();

liquid.track('tapped_buy');
await Future<void>.delayed(Duration.zero);
expect(fake.tracks.single.name, 'tapped_buy');

Writing a provider adapter

Implement LiquidSink. DebugSink and FakeSink are the reference implementations. A real one looks like:

class MixpanelSink extends LiquidSink {
  MixpanelSink({required this.token});
  final String token;
  late final Mixpanel _mp;

  @override
  String get id => 'mixpanel';

  @override
  Future<void> init() async {
    _mp = await Mixpanel.init(token, trackAutomaticEvents: false);
  }

  @override
  Future<void> deliver(LiquidMessage m) async {
    switch (m.type) {
      case LiquidMessageType.track:
        _mp.track(m.name!, properties: Map.of(m.properties));
      case LiquidMessageType.identify:
        _mp.identify(m.name!);
      case LiquidMessageType.reset:
        _mp.reset();
      default:
        break; // ignore what this provider doesn't model
    }
  }

  @override
  Future<void> flush() async => _mp.flush();
}

Status

0.2.0 is published on pub.dev. Adapters, offline delivery, UI helpers, GoRouter tracking, and the example app ship in the same monorepo.

Upgrading from 0.1.x? Two behaviour changes need a look: ConsentPolicy.map is now an explicit allowlist, and PiiRedactor recurses into nested data. See the changelog.

Libraries

liquid_analytics
Vendor-neutral analytics for Flutter: one typed event, many destinations, with a built-in consent gate.