Analytics often begins with one call inside a button:
mixpanel.track('course_opened');
Soon the project contains several spellings of the same event, different property keys, vendor-specific code in widgets, and no reliable way to test what a user action records.
The problem is not sending an event. It is maintaining an analytics contract across the application.
Represent events in application language#
sealed class TrackingEvent {
const TrackingEvent();
String get name;
Map<String, Object> get properties;
}
final class CourseOpened extends TrackingEvent {
const CourseOpened({
required this.courseId,
required this.source,
});
final String courseId;
final String source;
@override
String get name => 'course_opened';
@override
Map<String, Object> get properties => {
'course_id': courseId,
'source': source,
};
}
Callers cannot forget course_id or invent origin instead of source.
Hide providers behind an interface#
abstract interface class AnalyticsProvider {
Future<void> track(TrackingEvent event);
Future<void> identify(String userId);
Future<void> reset();
}
Adapters translate the event for Mixpanel or Firebase Analytics. Product code depends on AnalyticsProvider, not either SDK.
If events must go to both:
class AnalyticsDispatcher implements AnalyticsProvider {
AnalyticsDispatcher(this._providers);
final List<AnalyticsProvider> _providers;
@override
Future<void> track(TrackingEvent event) async {
await Future.wait(
_providers.map((provider) => provider.track(event)),
);
}
}
Decide whether one provider failure should affect the others. Analytics should rarely block the user's action.
Track at the layer that knows the meaning#
A widget knows that a button was tapped. A BLoC or use case may know that a purchase actually succeeded.
Track intent and outcome separately:
analytics.track(
CheckoutStarted(cartId: cart.id),
);
final result = await checkout();
result.fold(
(failure) => analytics.track(
CheckoutFailed(reason: failure.analyticsCode),
),
(receipt) => analytics.track(
CheckoutCompleted(orderId: receipt.orderId),
),
);
Do not count a button tap as a completed business action.
Add shared context centrally#
App version, platform, environment, locale, and anonymous installation ID are common properties. Add them in the adapter or dispatcher instead of every event.
Keep personally identifiable information out unless there is a reviewed need and consent basis. Avoid emails, names, free-form text, authentication data, and full URLs with sensitive parameters.
Make source values controlled#
Use enums or constants for properties used in reports:
enum CourseOpenSource {
home,
search,
favourites,
notification,
}
Free-form strings eventually create separate dashboard rows for favorite, favourite, and favorites.
Identity has a lifecycle#
Identify only after authentication is established. On logout:
- flush or finish required tracking;
- reset the provider identity;
- start a new anonymous session if appropriate.
Otherwise events from the next user can be attached to the previous profile on a shared device.
Test emitted events#
test('tracks completion only after checkout succeeds', () async {
final analytics = FakeAnalytics();
final controller = CheckoutController(
successfulCheckout,
analytics,
);
await controller.submit();
expect(
analytics.events.whereType<CheckoutCompleted>(),
hasLength(1),
);
});
Also validate event names and property schemas in a dedicated test. Renaming a property can silently break dashboards even while the application compiles.
An analytics abstraction is valuable when it creates consistency, privacy boundaries, and testability. A generic track(String, Map) wrapper that every screen uses differently only hides the vendor without solving the real problem.