diff --git a/.cspell/flutter-words.txt b/.cspell/flutter-words.txt index a50ae05..1649354 100644 --- a/.cspell/flutter-words.txt +++ b/.cspell/flutter-words.txt @@ -21,6 +21,7 @@ lerp ltrb LTWH mockito +mocktail nullsafety Podfile postbootstrap diff --git a/packages/flutterfire_tracker/.gitignore b/packages/flutterfire_tracker/.gitignore new file mode 100644 index 0000000..96486fd --- /dev/null +++ b/packages/flutterfire_tracker/.gitignore @@ -0,0 +1,30 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/packages/flutterfire_tracker/.metadata b/packages/flutterfire_tracker/.metadata new file mode 100644 index 0000000..6176c00 --- /dev/null +++ b/packages/flutterfire_tracker/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d211f42860350d914a5ad8102f9ec32764dc6d06" + channel: "stable" + +project_type: package diff --git a/packages/flutterfire_tracker/CHANGELOG.md b/packages/flutterfire_tracker/CHANGELOG.md new file mode 100644 index 0000000..61e46ea --- /dev/null +++ b/packages/flutterfire_tracker/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + +* initial release. diff --git a/packages/flutterfire_tracker/README.md b/packages/flutterfire_tracker/README.md new file mode 100644 index 0000000..02fe8ec --- /dev/null +++ b/packages/flutterfire_tracker/README.md @@ -0,0 +1,39 @@ + + +TODO: Put a short description of the package here that helps potential users +know whether this package might be useful for them. + +## Features + +TODO: List what your package can do. Maybe include images, gifs, or videos. + +## Getting started + +TODO: List prerequisites and provide or point to information on how to +start using the package. + +## Usage + +TODO: Include short and useful examples for package users. Add longer examples +to `/example` folder. + +```dart +const like = 'sample'; +``` + +## Additional information + +TODO: Tell users more about the package: where to find more information, how to +contribute to the package, how to file issues, what response they can expect +from the package authors, and more. diff --git a/packages/flutterfire_tracker/analysis_options.yaml b/packages/flutterfire_tracker/analysis_options.yaml new file mode 100644 index 0000000..4446f0e --- /dev/null +++ b/packages/flutterfire_tracker/analysis_options.yaml @@ -0,0 +1 @@ +include: package:altive_lints/altive_lints.yaml diff --git a/packages/flutterfire_tracker/lib/src/trackable.dart b/packages/flutterfire_tracker/lib/src/trackable.dart new file mode 100644 index 0000000..2b298bd --- /dev/null +++ b/packages/flutterfire_tracker/lib/src/trackable.dart @@ -0,0 +1,51 @@ +/// Abstract class that defines the interface for tracking events and errors. +/// +/// Implementations of this class can send tracking information to various +/// analytics and monitoring services. This class should be implemented +/// by any service that wants to handle the application's tracking data, +/// such as events and errors. +abstract interface class Trackable { + /// Tracks an error. + /// + /// Use this method to record errors that occur in the application. + /// You can mark an error as fatal by setting [fatal] to true. + /// + /// Parameters: + /// [error] - The error object that was caught. + /// [stackTrace] - The stack trace associated with the error. + /// [fatal] - A flag to indicate if the error is fatal. Defaults to false. + Future trackError( + dynamic error, + StackTrace? stackTrace, { + bool fatal = false, + }); + + /// Tracks an event. + /// + /// This method allows for logging of custom events within the application. + /// Events can be anything from user actions to system events. + /// + /// Parameters: + /// [name] - The name of the event to track. + /// [parameters] - Additional parameters or context to log with the event. + /// This is optional and can be null. + Future trackEvent( + String name, { + Map? parameters, + }); + + /// Sets the user ID. + /// + /// This method allows for setting the user ID for the current user. + /// This is useful for associating events with a specific user. + /// + /// Parameters: + /// [userId] - The user ID to set. + Future setUserId(String userId); + + /// Clears the user ID. + /// + /// This method allows for clearing the user ID for the current user. + /// This is useful for dissociating events with a specific user. + Future clearUserId(); +} diff --git a/packages/flutterfire_tracker/lib/src/tracker.dart b/packages/flutterfire_tracker/lib/src/tracker.dart new file mode 100644 index 0000000..1c2b497 --- /dev/null +++ b/packages/flutterfire_tracker/lib/src/tracker.dart @@ -0,0 +1,171 @@ +import 'dart:async'; +import 'dart:isolate'; + +import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:flutter/material.dart'; + +import 'trackable.dart'; + +/// A class that wraps a package for sending events to Analytics. +/// Its role is to "send necessary events to Analytics." +/// +/// It exposes methods for sending analytic events and for configuration. +class Tracker { + Tracker({ + FirebaseCrashlytics? crashlytics, + FirebaseAnalytics? analytics, + List trackers = const [], + }) : _crashlytics = crashlytics ?? FirebaseCrashlytics.instance, + _analytics = analytics ?? FirebaseAnalytics.instance, + _trackers = trackers; + + final FirebaseCrashlytics _crashlytics; + + final FirebaseAnalytics _analytics; + + final List _trackers; + + /// A callback for logging errors caught by the Flutter framework. + /// + /// Usage example: + /// ```dart + /// FlutterError.onError = tracker.onFlutterError; + /// ``` + /// + Future onFlutterError( + FlutterErrorDetails flutterErrorDetails, { + bool fatal = false, + }) async { + FlutterError.presentError(flutterErrorDetails); + await _crashlytics.recordFlutterError(flutterErrorDetails, fatal: fatal); + } + + /// A callback to log asynchronous errors + /// that are not caught by the Flutter framework. + /// + /// + /// Usage example: + /// ```dart + /// PlatformDispatcher.instance.onError = tracker.onPlatformError; + /// ``` + bool onPlatformError(Object error, StackTrace stack) { + unawaited(_crashlytics.recordError(error, stack, fatal: true)); + for (final tracker in _trackers) { + unawaited(tracker.trackError(error, stack, fatal: true)); + } + return true; + } + + /// A listener to register with [Isolate.current] + /// to record errors outside of Flutter. + /// + /// Usage example: + /// ```dart + /// Isolate.current.addErrorListener( + /// tracker.isolateErrorListener() + /// ); + /// ``` + SendPort isolateErrorListener() { + return RawReceivePort((List pair) async { + final errorAndStacktrace = pair; + await Future.wait([ + _crashlytics.recordError( + errorAndStacktrace.first, + errorAndStacktrace.last as StackTrace, + fatal: true, + ), + ..._trackers.map( + (tracker) => tracker.trackError( + errorAndStacktrace.first, + errorAndStacktrace.last as StackTrace, + fatal: true, + ), + ), + ]); + }).sendPort; + } + + /// Record a Dart or native error that caused the application to crash. + Future recordError( + Object exception, + StackTrace stack, { + bool fatal = false, + }) async { + await Future.wait([ + _crashlytics.recordError( + exception, + stack, + fatal: fatal, + ), + ..._trackers.map( + (tracker) => tracker.trackError( + exception, + stack, + fatal: fatal, + ), + ), + ]); + } + + /// Set the user ID. + Future setUserId(String userId) async { + await Future.wait([ + _crashlytics.setUserIdentifier(userId), + _analytics.setUserId(id: userId), + ..._trackers.map((tracker) => tracker.setUserId(userId)), + ]); + } + + /// Clear the set user ID. + Future clearUserId() async { + await Future.wait([ + _crashlytics.setUserIdentifier(''), + _analytics.setUserId(), + ..._trackers.map((tracker) => tracker.clearUserId()), + ]); + } + + /// Set the user's properties. + Future setUserProperties(Map properties) async { + for (final property in properties.entries) { + await _analytics.setUserProperty( + name: property.key, + value: property.value, + ); + } + } + + /// Returns a list of NavigatorObservers to register with Navigator. + /// Use [nameExtractor] to set the parameter value to send. + List navigatorObservers({ + required String? Function(RouteSettings) nameExtractor, + }) { + return [ + // Returns a NavigatorObserver of FirebaseAnalytics. + FirebaseAnalyticsObserver( + analytics: _analytics, + nameExtractor: nameExtractor, + ), + ]; + } + + /// Send an event to Analytics. + Future logEvent( + String eventName, { + Map? parameters, + }) async { + await Future.wait([ + _analytics.logEvent( + name: eventName, + parameters: parameters, + ), + ..._trackers.map( + (tracker) => tracker.trackEvent( + eventName, + parameters: parameters, + ), + ), + ]); + } +} diff --git a/packages/flutterfire_tracker/lib/tracker.dart b/packages/flutterfire_tracker/lib/tracker.dart new file mode 100644 index 0000000..041dde1 --- /dev/null +++ b/packages/flutterfire_tracker/lib/tracker.dart @@ -0,0 +1,5 @@ +/// flutterfire_tracker with FlutterFire Analytics and Crashlytics. +library flutterfire_tracker; + +export 'src/trackable.dart'; +export 'src/tracker.dart'; diff --git a/packages/flutterfire_tracker/pubspec.yaml b/packages/flutterfire_tracker/pubspec.yaml new file mode 100644 index 0000000..90ada63 --- /dev/null +++ b/packages/flutterfire_tracker/pubspec.yaml @@ -0,0 +1,19 @@ +name: flutterfire_tracker +description: flutterfire_tracker with FlutterFire Analytics and Crashlytics. +publish_to: "none" +version: 0.1.0 + +environment: + sdk: ^3.0.0 + +dependencies: + firebase_analytics: ^10.6.3 + firebase_crashlytics: ^3.4.3 + flutter: + sdk: flutter + +dev_dependencies: + altive_lints: ^1.8.1 + flutter_test: + sdk: flutter + mocktail: ^1.0.1 diff --git a/packages/flutterfire_tracker/test/src/tracker_test.dart b/packages/flutterfire_tracker/test/src/tracker_test.dart new file mode 100644 index 0000000..0ae4744 --- /dev/null +++ b/packages/flutterfire_tracker/test/src/tracker_test.dart @@ -0,0 +1,308 @@ +import 'dart:isolate'; + +import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutterfire_tracker/tracker.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockFirebaseCrashlytics extends Mock implements FirebaseCrashlytics {} + +class MockFirebaseAnalytics extends Mock implements FirebaseAnalytics {} + +class MockTrackable extends Mock implements Trackable {} + +void main() { + group('Tracker', () { + test('onFlutterError should call recordFlutterError on crashlytics', + () async { + final crashlytics = MockFirebaseCrashlytics(); + final analytics = MockFirebaseAnalytics(); + final tracker = Tracker( + crashlytics: crashlytics, + analytics: analytics, + ); + + final flutterErrorDetails = FlutterErrorDetails( + exception: Exception('Test exception'), + ); + when( + () => crashlytics.recordFlutterError(flutterErrorDetails), + ).thenAnswer((_) async {}); + + await tracker.onFlutterError(flutterErrorDetails); + + verify(() => crashlytics.recordFlutterError(flutterErrorDetails)) + .called(1); + verifyNoMoreInteractions(crashlytics); + verifyNoMoreInteractions(analytics); + }); + + test('onPlatformError should call recordError on crashlytics and trackers', + () async { + final crashlytics = MockFirebaseCrashlytics(); + final analytics = MockFirebaseAnalytics(); + final trackable = MockTrackable(); + final tracker = Tracker( + crashlytics: crashlytics, + analytics: analytics, + trackers: [trackable], + ); + + final error = Exception('Test exception'); + final stack = StackTrace.fromString('Test stack trace'); + when( + () => crashlytics.recordError(error, stack, fatal: true), + ).thenAnswer((_) async {}); + when( + () => trackable.trackError(error, stack, fatal: true), + ).thenAnswer((_) async {}); + + final got = tracker.onPlatformError(error, stack); + + expect(got, true); + verify(() => crashlytics.recordError(error, stack, fatal: true)) + .called(1); + verify(() => trackable.trackError(error, stack, fatal: true)).called(1); + verifyNoMoreInteractions(crashlytics); + verifyNoMoreInteractions(analytics); + verifyNoMoreInteractions(trackable); + }); + + test( + 'isolateErrorListener should call recordError ' + 'on crashlytics and trackers', () async { + final crashlytics = MockFirebaseCrashlytics(); + final analytics = MockFirebaseAnalytics(); + final trackable = MockTrackable(); + final tracker = Tracker( + crashlytics: crashlytics, + analytics: analytics, + trackers: [trackable], + ); + + final error = Exception('Test exception'); + final stack = StackTrace.fromString('Test stack trace'); + when( + () => crashlytics.recordError( + any(), + any(), + fatal: any(named: 'fatal'), + ), + ).thenAnswer((_) async {}); + when( + () => trackable.trackError( + any(), + any(), + fatal: any(named: 'fatal'), + ), + ).thenAnswer((_) async {}); + + final got = tracker.isolateErrorListener(); + + expect(got, isA()); + + got.send([error, stack]); + + await pumpEventQueue(); + + verify( + () => crashlytics.recordError( + any(), + any(), + fatal: any(named: 'fatal'), + ), + ).called(1); + verify( + () => trackable.trackError( + any(), + any(), + fatal: any(named: 'fatal'), + ), + ).called(1); + verifyNoMoreInteractions(crashlytics); + verifyNoMoreInteractions(analytics); + verifyNoMoreInteractions(trackable); + }); + + test('recordError should call recordError on crashlytics and trackers', + () async { + final crashlytics = MockFirebaseCrashlytics(); + final analytics = MockFirebaseAnalytics(); + final trackable = MockTrackable(); + final tracker = Tracker( + crashlytics: crashlytics, + analytics: analytics, + trackers: [trackable], + ); + + final error = Exception('Test exception'); + final stack = StackTrace.fromString('Test stack trace'); + const fatal = true; + when( + () => crashlytics.recordError(error, stack, fatal: fatal), + ).thenAnswer((_) async {}); + when( + () => trackable.trackError(error, stack, fatal: fatal), + ).thenAnswer((_) async {}); + + await tracker.recordError(error, stack, fatal: fatal); + + verify( + () => crashlytics.recordError(error, stack, fatal: fatal), + ).called(1); + verify(() => trackable.trackError(error, stack, fatal: fatal)).called(1); + verifyNoMoreInteractions(crashlytics); + verifyNoMoreInteractions(analytics); + verifyNoMoreInteractions(trackable); + }); + + test( + 'setUserId should call setUserIdentifier ' + 'on crashlytics, analytics and trackers', () async { + final crashlytics = MockFirebaseCrashlytics(); + final analytics = MockFirebaseAnalytics(); + final trackable = MockTrackable(); + final tracker = Tracker( + crashlytics: crashlytics, + analytics: analytics, + trackers: [trackable], + ); + + const userId = 'b979be1991444eeb814acdccd594'; + when(() => crashlytics.setUserIdentifier(userId)) + .thenAnswer((_) async {}); + when(() => analytics.setUserId(id: userId)).thenAnswer((_) async {}); + when(() => trackable.setUserId(userId)).thenAnswer((_) async {}); + + await tracker.setUserId(userId); + + verify(() => crashlytics.setUserIdentifier(userId)).called(1); + verify(() => analytics.setUserId(id: userId)).called(1); + verify(() => trackable.setUserId(userId)).called(1); + verifyNoMoreInteractions(crashlytics); + verifyNoMoreInteractions(analytics); + verifyNoMoreInteractions(trackable); + }); + + test( + 'clearUserId should call setUserIdentifier ' + 'on crashlytics, analytics and trackers', () async { + final crashlytics = MockFirebaseCrashlytics(); + final analytics = MockFirebaseAnalytics(); + final trackable = MockTrackable(); + final tracker = Tracker( + crashlytics: crashlytics, + analytics: analytics, + trackers: [trackable], + ); + + const userId = ''; + when( + () => crashlytics.setUserIdentifier(userId), + ).thenAnswer((_) async {}); + when(analytics.setUserId).thenAnswer((_) async {}); + when(trackable.clearUserId).thenAnswer((_) async {}); + + await tracker.clearUserId(); + + verify(() => crashlytics.setUserIdentifier(userId)).called(1); + verify(analytics.setUserId).called(1); + verify(trackable.clearUserId).called(1); + verifyNoMoreInteractions(crashlytics); + verifyNoMoreInteractions(analytics); + verifyNoMoreInteractions(trackable); + }); + + test('setUserProperties should call setUserProperty on analytics', + () async { + final crashlytics = MockFirebaseCrashlytics(); + final analytics = MockFirebaseAnalytics(); + final tracker = Tracker( + crashlytics: crashlytics, + analytics: analytics, + ); + + const properties = {'key': 'value'}; + when( + () => analytics.setUserProperty( + name: any(named: 'name'), + value: any(named: 'value'), + ), + ).thenAnswer((_) async {}); + + await tracker.setUserProperties(properties); + + verify( + () => analytics.setUserProperty( + name: any(named: 'name'), + value: any(named: 'value'), + ), + ).called(1); + verifyNoMoreInteractions(crashlytics); + verifyNoMoreInteractions(analytics); + }); + + test('navigatorObservers should return a list of NavigatorObservers', + () async { + final crashlytics = MockFirebaseCrashlytics(); + final analytics = MockFirebaseAnalytics(); + final tracker = Tracker( + crashlytics: crashlytics, + analytics: analytics, + ); + + String nameExtractor(RouteSettings settings) => settings.name!; + final got = tracker.navigatorObservers(nameExtractor: nameExtractor); + + expect(got, isA>()); + expect(got.length, 1); + expect(got[0], isA()); + }); + + test('logEvent should call logEvent on analytics and trackers', () async { + final crashlytics = MockFirebaseCrashlytics(); + final analytics = MockFirebaseAnalytics(); + final trackable = MockTrackable(); + final tracker = Tracker( + crashlytics: crashlytics, + analytics: analytics, + trackers: [trackable], + ); + + const eventName = 'event'; + const parameters = {'key': 'value'}; + when( + () => analytics.logEvent( + name: eventName, + parameters: parameters, + ), + ).thenAnswer((_) async {}); + when( + () => trackable.trackEvent( + eventName, + parameters: parameters, + ), + ).thenAnswer((_) async {}); + + await tracker.logEvent(eventName, parameters: parameters); + + verify( + () => analytics.logEvent( + name: eventName, + parameters: parameters, + ), + ).called(1); + verify( + () => trackable.trackEvent( + eventName, + parameters: parameters, + ), + ).called(1); + verifyNoMoreInteractions(crashlytics); + verifyNoMoreInteractions(analytics); + verifyNoMoreInteractions(trackable); + }); + }); +}