Previous Article: Part 1: Building a Robust Analytics Architecture in Flutter
Now that we have our analytics foundation in place, let's implement two concrete analytics clients: a logger client for development and a server client for custom backend analytics.
App Logger Service
First we will setup our logging service using the logger package. Here is the code for a service wrapper around this package:
import 'package:clock/clock.dart';
import 'package:logger/logger.dart';
final appLogger = LogService.forClass('[DEBUG]', level: Level.debug);
class LogService {
final Logger _logger;
const LogService(this._logger);
LogService.forClass(
String className, {
Level? level,
}) : _logger = Logger(
printer: _SimpleLogPrinter(className),
level: level,
);
void debug(dynamic message, {StackTrace? stackTrace}) =>
_logger.d(message, stackTrace: stackTrace);
void info(dynamic message) => _logger.i(message);
void error(dynamic message) => _logger.e(message);
void warn(dynamic message) => _logger.w(message);
void verbose(dynamic message) => _logger.t(message);
void wtf(dynamic message) => _logger.f(message);
}
class _SimpleLogPrinter extends PrettyPrinter {
final String className;
_SimpleLogPrinter(this.className);
@override
List<String> log(LogEvent event) {
final level = event.level;
final color = PrettyPrinter.defaultLevelColors[level]!;
final emoji = PrettyPrinter.defaultLevelEmojis[level];
final message = event.message as Object?;
final date = clock.now();
var msg = '${date.year}/${date.month}/${date.day}';
msg += ' ${date.hour}:${date.minute}:${date.second}';
msg += ' $message';
return [color('$emoji $className - $msg')];
}
}
This wrapper allow nicely formatted messages to be logged to the console and also provides utility methods to log events of different priorities.
Finally there is a global appLogger
that we use for debugging purposes.
Logger Analytics Client
The LoggerAnalyticsClient
is a development-focused implementation that logs all analytics events to the console:
class LoggerAnalyticsClient implements AnalyticsClientBase {
const LoggerAnalyticsClient();
@override
Future<void> trackEvent(String eventName, [Map<String, Object>? eventData]) async {
appLogger.debug('trackEvent($eventName, $eventData)');
}
// ... other implementations
}
This client has the following characteristics:
- Implement the
AnalyticsClientBase
class to be injectable later on. - Override all the contract methods to provide own logic for logging using the
appLogger
.
This implementation serves several purposes:
- Development debugging and testing
- Documentation of analytics events in logs
- Verification of analytics integration during development
Benefits of Logger Analytics
- Immediate feedback during development
- No external service dependencies for testing
- Clear visibility of all tracked events
- Easy debugging of analytics implementation
The full code for the service is found here:
import 'dart:async'; | |
// Config | |
import '../../config/config.dart'; | |
// Monitoring | |
import 'analytics_client_base.dart'; | |
class LoggerAnalyticsClient implements AnalyticsClientBase { | |
const LoggerAnalyticsClient(); | |
@override | |
Future<void> toggleAnalyticsCollection(bool enabled) async { | |
appLogger.debug('toggleAnalyticsCollection($enabled)'); | |
} | |
@override | |
Future<void> identifyUser({ | |
required String userId, | |
required String email, | |
required String role, | |
}) async { | |
appLogger.debug('identifyUser($userId, $email, $role)'); | |
} | |
@override | |
Future<void> resetUser() async { | |
appLogger.debug('resetUser'); | |
} | |
@override | |
Future<void> trackEvent(String eventName, [Map<String, Object>? eventData]) async { | |
appLogger.debug('trackEvent($eventName, $eventData)'); | |
} | |
@override | |
Future<void> trackScreenView(String routeName, String action) async { | |
appLogger.debug('trackScreenView($routeName, $action)'); | |
} | |
@override | |
Future<void> trackBottomSheetView(String routeName, [Map<String, Object>? data]) async { | |
appLogger.debug('trackBottomSheetView($routeName, $data)'); | |
} | |
@override | |
Future<void> trackDialogView(String dialogName, [Map<String, Object>? data]) async { | |
appLogger.debug('trackDialogView($dialogName, $data)'); | |
} | |
@override | |
Future<void> trackAppForegrounded() async { | |
appLogger.debug('trackAppForegrounded'); | |
} | |
@override | |
Future<void> trackAppBackgrounded() async { | |
appLogger.debug('trackAppBackgrounded'); | |
} | |
@override | |
Future<void> trackButtonPressed(String buttonName, [Map<String, Object>? data]) async { | |
appLogger.debug('trackButtonPressed($buttonName, data: $data)'); | |
} | |
@override | |
Future<void> trackPermissionRequest(String permission, String status) async { | |
appLogger.debug('trackPermissionRequested($permission, $status)'); | |
} | |
@override | |
Future<void> trackNewAppOnboarding() async { | |
appLogger.debug('trackNewAppOnboarding'); | |
} | |
@override | |
Future<void> trackAppCreated() async { | |
appLogger.debug('trackAppCreated'); | |
} | |
@override | |
Future<void> trackAppUpdated() async { | |
appLogger.debug('trackAppUpdated'); | |
} | |
@override | |
Future<void> trackAppDeleted() async { | |
appLogger.debug('trackAppDeleted'); | |
} | |
@override | |
Future<void> trackTaskCompleted(int completedCount) async { | |
appLogger.debug('trackTaskCompleted(completedCount: $completedCount)'); | |
} | |
} |
Server Analytics Client
The ServerAnalyticsClient
sends analytics data to your custom backend:
class ServerAnalyticsClient implements AnalyticsClientBase {
final ApiService _apiService;
const ServerAnalyticsClient(this._apiService);
@override
Future<void> trackEvent(String eventName, [Map<String, Object>? eventData]) async {
return _apiService.setData(
endpoint: '/analytics',
data: {
'event': eventName,
'data': eventData,
},
converter: (res) => res.headers.isSuccess,
);
}
// ... other implementations
}
This client has the same characteristics as above:
- Implement the
AnalyticsClientBase
class to be injectable later on. - Override all the contract methods to provide own logic for sending analytics to the backend using your own api service.
It uses my custom implementation of ApiService
that I have explained in detail in my Clean Flutter Networking Architecture series. Or you can customize it to use either the Dio or http package for simplicity.
Server Analytics Features
- Custom backend integration
- Full control over analytics data
- Consistent event format
- Error handling and response validation
- Dependency injection for API service
Here is the entire code for the client:
import 'dart:async'; | |
// Networking | |
import '../networking/networking.dart'; | |
// Monitoring | |
import 'analytics_client_base.dart'; | |
class ServerAnalyticsClient implements AnalyticsClientBase { | |
final ApiService _apiService; | |
const ServerAnalyticsClient(this._apiService); | |
@override | |
Future<void> toggleAnalyticsCollection(bool enabled) async { | |
return _apiService.setData( | |
endpoint: '/analytics', | |
data: { | |
'event': enabled | |
? 'analytics_collection_enabled' | |
: 'analytics_collection_disabled', | |
}, | |
converter: (res) => res.headers.isSuccess, | |
); | |
} | |
@override | |
Future<void> identifyUser({ | |
required String userId, | |
required String email, | |
required String role, | |
}) async { | |
return _apiService.setData( | |
endpoint: '/analytics', | |
data: { | |
'event': 'user_session_identified', | |
'userId': userId, | |
'email': email, | |
'role': role, | |
}, | |
converter: (res) => res.headers.isSuccess, | |
); | |
} | |
@override | |
Future<void> resetUser() async { | |
return _apiService.setData( | |
endpoint: '/analytics', | |
data: { | |
'event': 'user_session_reset', | |
}, | |
converter: (res) => res.headers.isSuccess, | |
); | |
} | |
@override | |
Future<void> trackEvent(String eventName, [Map<String, Object>? eventData]) async { | |
return _apiService.setData( | |
endpoint: '/analytics', | |
data: { | |
'event': eventName, | |
'data': eventData, | |
}, | |
converter: (res) => res.headers.isSuccess, | |
); | |
} | |
@override | |
Future<void> trackScreenView(String routeName, String action) async { | |
return _apiService.setData( | |
endpoint: '/analytics', | |
data: { | |
'event': 'screen_view', | |
'route_name': routeName, | |
'action': action, | |
}, | |
converter: (res) => res.headers.isSuccess, | |
); | |
} | |
@override | |
Future<void> trackBottomSheetView(String routeName, [Map<String, Object>? data]) async { | |
return _apiService.setData( | |
endpoint: '/analytics', | |
data: { | |
'event': 'bottom_sheet_view', | |
'route_name': routeName, | |
'data': data, | |
}, | |
converter: (res) => res.headers.isSuccess, | |
); | |
} | |
@override | |
Future<void> trackDialogView(String dialogName, [Map<String, Object>? data]) async { | |
return _apiService.setData( | |
endpoint: '/analytics', | |
data: { | |
'event': 'dialog_view', | |
'dialog_name': dialogName, | |
'data': data, | |
}, | |
converter: (res) => res.headers.isSuccess, | |
); | |
} | |
@override | |
Future<void> trackAppForegrounded() async { | |
return _apiService.setData( | |
endpoint: '/analytics', | |
data: { | |
'event': 'app_foregrounded', | |
}, | |
converter: (res) => res.headers.isSuccess, | |
); | |
} | |
@override | |
Future<void> trackAppBackgrounded() async { | |
return _apiService.setData( | |
endpoint: '/analytics', | |
data: { | |
'event': 'app_backgrounded', | |
}, | |
converter: (res) => res.headers.isSuccess, | |
); | |
} | |
@override | |
Future<void> trackButtonPressed(String buttonName, [Map<String, Object>? data]) async { | |
return _apiService.setData( | |
endpoint: '/analytics', | |
data: { | |
'event': 'button_pressed', | |
'button_name': buttonName, | |
'data': data, | |
}, | |
converter: (res) => res.headers.isSuccess, | |
); | |
} | |
@override | |
Future<void> trackPermissionRequest(String permission, String status) async { | |
return _apiService.setData( | |
endpoint: '/analytics', | |
data: { | |
'event': 'permission_requested', | |
'permission': permission, | |
'status': status, | |
}, | |
converter: (res) => res.headers.isSuccess, | |
); | |
} | |
@override | |
Future<void> trackNewAppOnboarding() async { | |
return _apiService.setData( | |
endpoint: '/analytics', | |
data: { | |
'event': 'new_app-onboarding', | |
}, | |
converter: (res) => res.headers.isSuccess, | |
); | |
} | |
@override | |
Future<void> trackAppCreated() async { | |
return _apiService.setData( | |
endpoint: '/analytics', | |
data: { | |
'event': 'app_created', | |
}, | |
converter: (res) => res.headers.isSuccess, | |
); | |
} | |
@override | |
Future<void> trackAppUpdated() async { | |
return _apiService.setData( | |
endpoint: '/analytics', | |
data: { | |
'event': 'app_updated', | |
}, | |
converter: (res) => res.headers.isSuccess, | |
); | |
} | |
@override | |
Future<void> trackAppDeleted() async { | |
return _apiService.setData( | |
endpoint: '/analytics', | |
data: { | |
'event': 'app_deleted', | |
}, | |
converter: (res) => res.headers.isSuccess, | |
); | |
} | |
@override | |
Future<void> trackTaskCompleted(int completedCount) async { | |
return _apiService.setData( | |
endpoint: '/analytics', | |
data: { | |
'event': 'task_completed', | |
'completed_count': completedCount, | |
}, | |
converter: (res) => res.headers.isSuccess, | |
); | |
} | |
} |
Design Pattern Benefits
Both implementations demonstrate the power of our base class approach:
- Consistent Interface: Both clients implement the same methods, ensuring consistent usage
- Implementation Freedom: Each client handles events in its own way while maintaining the contract
- Single Responsibility: Each client focuses on its specific analytics target
- Easy Testing: Mock implementations can be created for testing
- Open Closed Principle: New services can be added by simple implementation of the interface, without modifying existing code.
Coming Up Next
In Part 3: Implementing PostHog Analytics in Flutter, we'll integrate PostHog, a popular open-source analytics platform. We'll cover PostHog setup, configuration, and implementation details.
Top comments (0)