Analytics
AnalyticsPlugin gives every feature one way to report product events, so the app can send them to Firebase, Amplitude, or any other backend without the features knowing which one.
| Contract | AnalyticsPlugin, which forwards to AnalyticsProvider objects |
| Default | AnalyticsPlugin(providers: [NoOpAnalyticsProvider()]): nothing is sent |
| Accessor | vyuh.analytics (never null), or VyuhBinding.instance.analytics in widget mode |
| Slot | PluginDescriptor(analytics: ...) |
| Implementations | NoOpAnalyticsProvider in vyuh_core. You write providers for your backend. |
How it fits together
AnalyticsPlugin is a final class. You don't subclass it. Instead you give it a list of providers, and it passes every call to all of them.
How the pieces fit together
The plugin mixes in three things from the plugin system:
PreloadedPlugin: it is initialized before the platform starts, so analytics works while features load.InitOncePlugin: concurrentinit()calls share one initialization of the providers.RouteObservers: it collects theNavigatorObservers of all its providers, and the router adds them to the navigator. This is how providers track screen views.
Set up
Pass the plugin in the analytics slot of PluginDescriptor. If you leave the slot empty, the app uses the no-op default.
import 'package:vyuh_core/vyuh_core.dart';
void main() {
runApp(
features: () => [/* your features */],
plugins: PluginDescriptor(
analytics: AnalyticsPlugin(
providers: [
FirebaseAnalyticsProvider(),
AmplitudeAnalyticsProvider(apiKey: 'your-key'),
],
),
),
);
}FirebaseAnalyticsProvider and AmplitudeAnalyticsProvider are examples. You write them yourself; see Write your own below.
System plugins use their named slot
Don't put an AnalyticsPlugin in others. PluginDescriptor throws an ArgumentError if a system plugin is passed there.
Use it
Call vyuh.analytics from any feature once the platform has started, for example in a button handler, a feature's init, or an event handler.
import 'package:vyuh_core/vyuh_core.dart';
Future<void> onCheckout(String cartId, double total) async {
await vyuh.analytics.reportEvent(
'checkout_started',
params: {'cart_id': cartId, 'total': total},
);
}Identify the user after sign-in, and reset when they sign out:
await vyuh.analytics.identifyUser(
user.id,
traits: {'plan': 'pro', 'country': 'IN'},
);
// Later, on sign-out:
await vyuh.analytics.resetUser();The plugin has these members:
| Member | What it does |
|---|---|
providers | The List<AnalyticsProvider> you passed in. |
reportEvent(name, {params}) | Sends an event with optional parameters to every provider. |
identifyUser(userId, {traits}) | Tells every provider who the current user is. |
resetUser() | Clears the user identity in every provider. |
observers | All providers' observers, joined into one list. |
init() / dispose() | Called by the framework. They run each provider's init() / dispose(). |
Agree on event names and parameter keys across teams, keep personal data out of params, and respect your app's consent rules before reporting.
Screen tracking
You don't call anything to track screens. When the router is created, it asks every plugin that mixes in RouteObservers for its observers and passes them to GoRouter. AnalyticsPlugin returns the observers of all its providers, so a provider that returns a NavigatorObserver sees every push, pop and replace.
The router reads observers once, when it is built during startup. Return the observer instances your provider creates up front, not new ones on each call.
Behaviour and lifecycle
- Startup. Because it is preloaded, the framework calls
init()before the platform starts. That runs every provider'sinit()at the same time. - Init once.
init()comes fromInitOncePlugin. Calls made while initialization is running wait for the same result. - Restart. Once it has started, the plugin keeps running when the platform restarts, so your providers keep their state.
- Dispose.
vyuh.dispose()callsdispose(), which runs every provider'sdispose(). - One broken provider never stops the others.
init,dispose,reportEvent,identifyUserandresetUsercall each provider separately. If a provider throws, synchronously or by returning a failedFuture, the plugin printsVyuh: analytics provider <Type> failed: <error>withdebugPrintand skips it. The other providers still get the call, and theFutureyou awaited completes normally. - Default.
NoOpAnalyticsProvideraccepts every call, sends nothing and has no observers. Its description reminds you to set a real provider before going to production.
Testing
Write a small recording provider and pass it to an AnalyticsPlugin. You can test the plugin on its own, without starting an app.
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:vyuh_core/vyuh_core.dart';
final class RecordingAnalytics implements AnalyticsProvider {
final calls = <String>[];
@override
String get name => 'test.analytics';
@override
String get title => 'Recording Analytics';
@override
String get description => 'Records every call';
@override
List<NavigatorObserver> get observers => [];
@override
Future<void> init() async => calls.add('init');
@override
Future<void> dispose() async => calls.add('dispose');
@override
Future<void> reportEvent(String name, {Map<String, dynamic>? params}) async =>
calls.add('event $name $params');
@override
Future<void> identifyUser(String userId, {Map<String, dynamic>? traits}) async =>
calls.add('identify $userId');
@override
Future<void> resetUser() async => calls.add('reset');
}
void main() {
test('sends events to every provider', () async {
final first = RecordingAnalytics();
final second = RecordingAnalytics();
final analytics = AnalyticsPlugin(providers: [first, second]);
await analytics.init();
await analytics.reportEvent('clicked', params: {'id': 1});
expect(first.calls, ['init', 'event clicked {id: 1}']);
expect(second.calls, first.calls);
});
}To check that a feature reports the right events, pass PluginDescriptor(analytics: AnalyticsPlugin(providers: [recorder])) to runApp in a widget test, then wait with vyuh.getReady(tester) from vyuh_test.
Write your own
A provider implements every member of AnalyticsProvider:
abstract interface class AnalyticsProvider {
List<NavigatorObserver> get observers;
String get name;
String get title;
String get description;
Future<void> init();
Future<void> dispose();
Future<void> reportEvent(String name, {Map<String, dynamic>? params});
Future<void> identifyUser(String userId, {Map<String, dynamic>? traits});
Future<void> resetUser();
}Here is a provider in the style of Firebase or Amplitude. The _client stands in for the vendor SDK; replace its calls with the real ones.
import 'package:flutter/widgets.dart';
import 'package:vyuh_core/vyuh_core.dart';
/// Stand-in for a vendor SDK client.
class _AnalyticsClient {
Future<void> start(String apiKey) async {}
Future<void> track(String event, Map<String, Object?> properties) async {}
Future<void> setUser(String? id, Map<String, Object?> properties) async {}
Future<void> flush() async {}
}
/// Sends a `screen_view` event whenever a named route is pushed.
final class _ScreenObserver extends NavigatorObserver {
_ScreenObserver(this._client);
final _AnalyticsClient _client;
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
final screen = route.settings.name;
if (screen != null) {
_client.track('screen_view', {'screen': screen});
}
}
}
final class AmplitudeAnalyticsProvider implements AnalyticsProvider {
AmplitudeAnalyticsProvider({required this.apiKey});
final String apiKey;
final _client = _AnalyticsClient();
// Create the observer once. The router reads `observers` when it is built.
late final _screens = _ScreenObserver(_client);
@override
String get name => 'app.analytics.amplitude';
@override
String get title => 'Amplitude Analytics';
@override
String get description => 'Sends product events to Amplitude';
@override
List<NavigatorObserver> get observers => [_screens];
@override
Future<void> init() => _client.start(apiKey);
@override
Future<void> dispose() => _client.flush();
@override
Future<void> reportEvent(String name, {Map<String, dynamic>? params}) =>
_client.track(name, params ?? const {});
@override
Future<void> identifyUser(String userId, {Map<String, dynamic>? traits}) =>
_client.setUser(userId, traits ?? const {});
@override
Future<void> resetUser() => _client.setUser(null, const {});
}Tips:
- Keep
init()fast. Analytics is preloaded, so a slowinit()delays the whole app. - You don't need to catch errors in your provider: the plugin logs them and goes on. Catch them yourself only if you want to report them somewhere, such as
vyuh.telemetry. - If your backend has no screen tracking, return an empty list from
observers. - Give
namea stable, dotted id such asapp.analytics.amplitude.
See also plugins overview, writing a custom plugin, and telemetry.