Flutter SDK for Pug analytics, profile identity, session tracking, batching, and push device registration.
The SDK sends binary protobuf payloads to the Pug backend and keeps transport, storage, and push providers injectable for tests and custom integrations.
Requires Dart >=3.7.0 <4.0.0 and Flutter >=3.29.0. The generated protobuf code targets the protobuf 6.x runtime (>=6.0.0 <7.0.0).
- Event tracking with typed property mapping.
- Persistent anonymous identity, profile identity, and session state.
- Local event queue with lock, commit, and rollback semantics.
- Batched protobuf transport with
x-api-keyauthentication. identify()profile merge semantics with first-call anonymous ID linking.- Tracking consent gate with opt in/out and optional persistence.
- Provider-neutral push registration.
- Automatic campaign capture from app links and deep links.
- Injectable storage, transport, clock, ID generator, logger, and push provider for tests.
- Well-known event constants for the shared event catalog.
- Shared-preferences-backed initialization with richer mobile auto-properties.
Add the package to your app:
dependencies:
pug_flutter: ^0.0.1
# Required for now: connectrpc 1.0.0 caps protobuf at `<5.0.0`, but this SDK's
# generated code needs the 6.x runtime. Add this override to your app so
# `pub get` resolves (dependency_overrides only apply to the root app, so the
# SDK's own override does not carry over to your project).
dependency_overrides:
protobuf: ^6.0.0import 'package:pug_flutter/pug_flutter.dart';
Future<void> configurePug() async {
await Pug.init(
'project-id',
const PugOptions(
apiKey: 'pug_api_key',
),
);
}Useful options:
await Pug.init(
'project-id',
PugOptions(
apiKey: 'pug_api_key',
autoTrack: true,
batch: const BatchConfig(
maxSize: 10,
maxWaitMs: 5000,
maxQueueSize: 1000,
),
session: const SessionConfig(
idleTimeout: Duration(minutes: 30),
maxDuration: Duration(minutes: 1440),
),
logger: const DebugPrintPugLogger(),
),
);endpoint is optional and defaults to https://api.pugs.dev; set it only to point at a different backend.
Pug.init(...) uses shared-preferences-backed storage and fuller auto-properties such as app version/build, device model, screen size, and network type by default. To opt out of persistence, provide storage: MemoryPugStorage() or another custom PugStorage.
Most public SDK calls are best-effort and do not throw. Pug.track(), Pug.reset(), Pug.rotate(), Pug.flush(), Pug.destroy(), and PugPush helpers catch and log runtime failures. Pug.init() still throws on invalid input or init failure, and Pug.identify() still throws on invalid input or transport failure. Repeated init calls and identify() calls before init are ignored with a warning.
When autoTrack is enabled, the SDK tracks app_open when the app is already
resumed at init or later enters foreground. It tracks app_close when the app
leaves foreground.
When autoPageViews is enabled (default), the SDK tracks page_view events when
the user navigates between routes. To enable this feature, host apps must register
the route observer with their Navigator:
// In your MaterialApp/Navigator setup
MaterialApp(
navigatorObservers: [
PugRouteObserver(),
],
// ...
)After a route change, every event carries $url (the current route) and $referrer (the previous route) as auto-properties — matching the web SDK — so route context is not limited to page_view events. Route state updates whenever the observer reports a change, even if autoPageViews is disabled.
Campaign capture is enabled by default. When the app receives an app link or deep link containing UTM-style query parameters, Pug stores the latest campaign context and attaches it to later events as auto-properties:
$utmSource$utmMedium$utmCampaign$utmTerm$utmContent$gclid$fbclid$msclkid$ttclid
The host app must still configure platform deep linking, such as Android App Links, iOS Universal Links, or a custom URL scheme. To disable campaign capture:
await Pug.init(
'project-id',
const PugOptions(
apiKey: 'pug_api_key',
autoCaptureCampaigns: false,
),
);Tracking consent gates event capture: Pug.track(...), the typed
Pug.track.* methods, Pug.identify(...), and automatic
lifecycle/page-view events are dropped while consent is denied,
and resume once it is granted. Automatic campaign/deep-link capture is not
gated — while denied it still records attribution locally, but transmits
nothing until consent is granted.
Consent is granted by default. To start denied (for example, until the user accepts a consent prompt) and persist their choice across launches:
await Pug.init(
'project-id',
const PugOptions(
apiKey: 'pug_api_key',
trackingConsent: TrackingConsentConfig(
defaultConsent: TrackingConsent.denied,
persist: true,
),
),
);Flip consent at runtime:
Pug.optInTracking(); // grant — events flow
Pug.optOutTracking(); // deny — events are dropped
Pug.isTrackingEnabled(); // → bool (consent only; independent of dryRun)
Pug.getTrackingConsent(); // → TrackingConsent.granted | TrackingConsent.deniedWith persist: true, opt in/out is written to __pug_<projectId>_consent__ and
restored on the next Pug.init(...). With persist: false (the default), the
choice lasts only for the current process and resets to defaultConsent on
restart. Consent is independent of dryRun, which suppresses delivery without
changing consent. Consent activity (denied drops and persistence failures) is
logged only through the configured PugLogger; the default NoopPugLogger is
silent.
Use the typed methods for well-known events — required fields, value types, and method names are all compile-time checked:
Pug.track.purchase(
productId: 'sku-1',
amount: 99.50,
currency: 'USD',
extras: {'cohort': 'A'},
);
Pug.track.signup();
Pug.track.search(query: 'shoes');For custom or dynamic event names, the untyped form remains available:
Pug.track('cart_abandoned_v2', props: {'cartId': 'c-1', 'value': 41.0});Using Pug.track(...) with a well-known event name still works, but emits a
debug-level hint suggesting the typed alternative. The hint is sent through the
configured PugLogger.debug channel, so the default DebugPrintPugLogger
drops it in release builds (via Flutter's debugPrint); custom loggers may see
it in any build mode.
Custom events can use any event name and any supported property values:
Pug.track('checkout_step_viewed');
Pug.track(
'checkout_step_viewed',
props: {
'step': 'shipping',
'cartValue': 84.50,
'hasCoupon': true,
},
);Send immediately when the event is important. If the immediate send fails with a transient error, the event is queued for retry.
Pug.track(
'purchase',
props: {
'orderId': 'order-123',
'total': 84.50,
},
options: const TrackOptions(immediate: true),
);You can also provide an explicit timestamp:
Pug.track(
'video_buffered',
props: {
'videoId': 'intro-123',
'durationMs': 900,
},
options: TrackOptions(
timestampMillis: DateTime.now().millisecondsSinceEpoch,
),
);Well-known events have a typed method on Pug.track.* with compile-time property
checking. Each method accepts an optional extras map for ad-hoc properties:
Pug.track.purchase(
productId: 'sku-123',
amount: 49.0,
currency: 'USD',
orderId: 'ord-1001',
);
Pug.track.search(query: 'running shoes');The untyped Pug.track(PugEventNames.*) path continues to work but emits a
debug-level hint recommending the typed alternative:
// Still works — emits a debug-level hint via PugLogger.debug.
// The default DebugPrintPugLogger drops it in release builds.
Pug.track(
PugEventNames.purchase,
props: {
'productId': 'sku-123',
'amount': 49.0,
'currency': 'USD',
},
);Property values supported by the SDK:
Stringbool- finite
intanddouble DateTime- maps and lists that can be JSON encoded
Unsupported or non-finite values are dropped.
await Pug.identify(
'user-123',
traits: {
'email': 'user@example.com',
'plan': 'pro',
},
);The first identify call includes the anonymous ID so the backend can merge anonymous activity into the identified profile. Later identify calls update traits without resending the anonymous ID.
The SDK stores a project-scoped device ID separately from session state. That device ID is reused for session records, identify calls, and push registration.
Rotate only the session and keep the same device ID:
Pug.rotate();Clear profile identity and create a new device ID:
Pug.reset();Release lifecycle observers and timers:
Pug.destroy();Flush explicitly when the app has a known shutdown point:
await Pug.flush();Push support is provider-neutral. Any provider can implement PushProvider.
class CustomPushProvider implements PushProvider {
@override
String get provider => 'custom';
@override
String get platform => 'android';
@override
Future<String> getToken() async => 'device-token';
@override
Future<void> deleteToken() async {}
}
await PugPush.subscribe(
CustomPushProvider(),
options: const PushSubscribeOptions(
properties: {'channel': 'stable'},
),
);Use a custom transport in tests or when embedding the SDK in a constrained runtime:
class TestTransport implements PugTransport {
final events = <Event>[];
@override
Future<void> send(Event event) async {
events.add(event);
}
@override
Future<void> sendBatch(List<Event> batch) async {
events.addAll(batch);
}
@override
Future<void> identify(IdentifyRequest request) async {}
@override
Future<void> subscribeDevice(PushSubscription subscription) async {}
}
await Pug.init(
'project-id',
PugOptions(
apiKey: 'test-key',
transport: TestTransport(),
storage: MemoryPugStorage(),
),
);Install the Dart protobuf plugin:
dart pub global activate protoc_pluginGenerate protobuf classes:
make protosRun checks:
make checkIndividual commands:
make format
make analyze
make testThe SDK keeps source protos in proto/ and generated Dart protobuf message classes in lib/src/gen/.
Licensed under the MIT License. See LICENSE.