liquid_analytics 0.1.0
liquid_analytics: ^0.1.0 copied to clipboard
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.
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
Consent is first-class #
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.
AnimatedBuilder(
animation: 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),
),
);
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.1.0 — core is functional and tested. Official provider adapters
(liquid_firebase, liquid_posthog, …) and offline transport are on the
roadmap. See the repository root.