// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. import 'dart:io' as io; import 'package:file/file.dart'; import 'package:file/local.dart'; import 'package:file/memory.dart'; import 'package:http/http.dart'; import 'package:meta/meta.dart'; import 'asserts.dart'; import 'config_handler.dart'; import 'constants.dart'; import 'enums.dart'; import 'event.dart'; import 'ga_client.dart'; import 'initializer.dart'; import 'log_handler.dart'; import 'survey_handler.dart'; import 'user_property.dart'; import 'utils.dart'; abstract class Analytics { /// The default factory constructor that will return an implementation /// of the [Analytics] abstract class using the [LocalFileSystem]. /// /// If [enableAsserts] is set to `true`, then asserts for GA4 limitations /// will be enabled. /// /// [flutterChannel] and [flutterVersion] are nullable in case the client /// using this package is unable to resolve those values. /// /// An optional parameter [clientIde] is also available for dart and flutter /// tooling that are running from IDEs can be resolved. Such as "VSCode" /// running the flutter-tool. /// /// [enabledFeatures] is also an optional field that can be added to collect /// any features that are enabled for a user. For example, /// "enable-linux-desktop,cli-animations" are two features that can be enabled /// for the flutter-tool. factory Analytics({ required DashTool tool, required String dartVersion, String? flutterChannel, String? flutterVersion, String? clientIde, String? enabledFeatures, bool enableAsserts = false, }) { // Create the instance of the file system so clients don't need // resolve on their own const FileSystem fs = LocalFileSystem(); // Ensure that the home directory has permissions enabled to write final homeDirectory = getHomeDirectory(fs); if (homeDirectory == null || !checkDirectoryForWritePermissions(homeDirectory)) { return const NoOpAnalytics(); } // Resolve the OS using dart:io final DevicePlatform platform; if (io.Platform.operatingSystem == 'linux') { platform = DevicePlatform.linux; } else if (io.Platform.operatingSystem == 'macos') { platform = DevicePlatform.macos; } else { platform = DevicePlatform.windows; } // Create the instance of the GA Client which will create // an [http.Client] to send requests final gaClient = GAClient( measurementId: kGoogleAnalyticsMeasurementId, apiSecret: kGoogleAnalyticsApiSecret, ); final firstRun = runInitialization(homeDirectory: homeDirectory); return AnalyticsImpl( tool: tool, homeDirectory: homeDirectory, flutterChannel: flutterChannel, flutterVersion: flutterVersion, dartVersion: dartVersion, platform: platform, toolsMessageVersion: kToolsMessageVersion, fs: fs, gaClient: gaClient, surveyHandler: SurveyHandler( dismissedSurveyFile: homeDirectory .childDirectory(kDartToolDirectoryName) .childFile(kDismissedSurveyFileName), ), enableAsserts: enableAsserts, clientIde: clientIde, enabledFeatures: enabledFeatures, firstRun: firstRun, ); } /// Factory constructor to return the [AnalyticsImpl] class with /// Google Analytics credentials that point to a test instance and /// not the production instance where live data will be sent. /// /// By default, [enableAsserts] is set to `true` to check against /// GA4 limitations. /// /// [flutterChannel] and [flutterVersion] are nullable in case the client /// using this package is unable to resolve those values. factory Analytics.development({ required DashTool tool, required String dartVersion, String? flutterChannel, String? flutterVersion, String? clientIde, String? enabledFeatures, bool enableAsserts = true, }) { // Create the instance of the file system so clients don't need // resolve on their own const FileSystem fs = LocalFileSystem(); // Ensure that the home directory has permissions enabled to write final homeDirectory = getHomeDirectory(fs); if (homeDirectory == null) { throw Exception('Unable to determine the home directory, ' 'ensure it is available in the environment'); } if (!checkDirectoryForWritePermissions(homeDirectory)) { throw Exception('Permissions error on the home directory!'); } // Resolve the OS using dart:io final DevicePlatform platform; if (io.Platform.operatingSystem == 'linux') { platform = DevicePlatform.linux; } else if (io.Platform.operatingSystem == 'macos') { platform = DevicePlatform.macos; } else { platform = DevicePlatform.windows; } // Credentials defined below for the test Google Analytics instance const kTestMeasurementId = 'G-N1NXG28J5B'; const kTestApiSecret = '4yT8__oER3Cd84dtx6r-_A'; // Create the instance of the GA Client which will create // an [http.Client] to send requests final gaClient = GAClient( measurementId: kTestMeasurementId, apiSecret: kTestApiSecret, ); final firstRun = runInitialization(homeDirectory: homeDirectory); return AnalyticsImpl( tool: tool, homeDirectory: homeDirectory, flutterChannel: flutterChannel, flutterVersion: flutterVersion, dartVersion: dartVersion, platform: platform, toolsMessageVersion: kToolsMessageVersion, fs: fs, gaClient: gaClient, surveyHandler: SurveyHandler( dismissedSurveyFile: homeDirectory .childDirectory(kDartToolDirectoryName) .childFile(kDismissedSurveyFileName), ), enableAsserts: enableAsserts, clientIde: clientIde, enabledFeatures: enabledFeatures, firstRun: firstRun, ); } /// The shared identifier for Flutter and Dart related tooling using /// package:unified_analytics. String get clientId; /// Retrieves the consent message to prompt users with on first /// run or when the message has been updated. String get getConsentMessage; /// Returns true if it is OK to send an analytics message. Do not cache, /// as this depends on factors that can change, such as the configuration /// file contents. bool get okToSend; /// Returns a map object with all of the tools that have been parsed /// out of the configuration file. Map get parsedTools; /// Boolean that lets the client know if they should display the message. bool get shouldShowMessage; /// Boolean indicating whether or not telemetry is enabled. bool get telemetryEnabled; /// Returns a map representation of the [UserProperty] for the [Analytics] /// instance. /// /// This is what will get sent to Google Analytics with every request. Map> get userPropertyMap; /// Method to be invoked by the client using this package to confirm /// that the client has shown the message and that it can be added to /// the config file and start sending events the next time it starts up. void clientShowedMessage(); /// Call this method when the tool using this package is closed. /// /// Prevents the tool from hanging when if there are still requests /// that need to be sent off. /// /// Providing [delayDuration] in milliseconds will allow the instance /// to wait the provided time before closing the http connection. Keeping /// the connection open for some time will allow any pending events that /// are waiting to be sent to the Google Analytics server. Default value /// of 250 ms applied. Future close({int delayDuration = kDelayDuration}); /// Method to fetch surveys from the endpoint [kContextualSurveyUrl]. /// /// Any survey that is returned by this method has already passed /// the survey conditions specified in the remote survey metadata file. /// /// If the method returns an empty list, then there are no surveys to be /// shared with the user. Future> fetchAvailableSurveys(); /// Query the persisted event data stored on the user's machine. /// /// Returns null if there are no persisted logs. LogFileStats? logFileStats(); /// Send preconfigured events using specific named constructors /// on the [Event] class. /// /// Example /// ```dart /// analytics.send(Event.memory(periodSec: 123)); /// ``` void send(Event event); /// Pass a boolean to either enable or disable telemetry and make /// the necessary changes in the persisted configuration file. /// /// Setting the telemetry status will also send an event to GA /// indicating the latest status of the telemetry from [reportingBool]. Future setTelemetry(bool reportingBool); /// Calling this will result in telemetry collection being suppressed for /// the current invocation. /// /// If you would like to permanently disable telemetry /// collection use: /// /// ```dart /// analytics.setTelemetry(false) /// ``` void suppressTelemetry(); /// Method to run after interacting with a [Survey] instance. /// /// Pass a [Survey] instance which can be retrieved from /// [Analytics.fetchAvailableSurveys]. /// /// [surveyButton] is the button that was interacted with by the user. void surveyInteracted({ required Survey survey, required SurveyButton surveyButton, }); /// Method to be called after a survey has been shown to the user. /// /// Calling this will snooze the survey so it won't be shown immediately. /// /// The snooze period is defined by the [Survey.snoozeForMinutes] field. void surveyShown(Survey survey); /// Returns an instance of [FakeAnalytics] which can be used in tests to check /// for certain [Event] instances within [FakeAnalytics.sentEvents]. @visibleForTesting static FakeAnalytics fake({ required DashTool tool, required Directory homeDirectory, required String dartVersion, required MemoryFileSystem fs, String? flutterChannel, String? flutterVersion, String? clientIde, String? enabledFeatures, SurveyHandler? surveyHandler, GAClient? gaClient, DevicePlatform platform = DevicePlatform.linux, int toolsMessageVersion = kToolsMessageVersion, String toolsMessage = kToolsMessage, bool enableAsserts = true, }) { final firstRun = runInitialization(homeDirectory: homeDirectory); return FakeAnalytics._( tool: tool, homeDirectory: homeDirectory, flutterChannel: flutterChannel, toolsMessageVersion: toolsMessageVersion, flutterVersion: flutterVersion, dartVersion: dartVersion, platform: platform, fs: fs, surveyHandler: surveyHandler ?? FakeSurveyHandler.fromList( dismissedSurveyFile: homeDirectory .childDirectory(kDartToolDirectoryName) .childFile(kDismissedSurveyFileName), initializedSurveys: [], ), gaClient: gaClient ?? const FakeGAClient(), clientIde: clientIde, enabledFeatures: enabledFeatures, firstRun: firstRun, enableAsserts: enableAsserts, ); } } class AnalyticsImpl implements Analytics { final DashTool tool; final FileSystem fs; final int toolsMessageVersion; final ConfigHandler _configHandler; final GAClient _gaClient; final SurveyHandler _surveyHandler; final File _clientIdFile; final UserProperty _userProperty; final LogHandler _logHandler; final String? _enabledFeatures; /// Tells the client if they need to show a message to the /// user; this will return true if it is the first time the /// package is being used for a developer or if the consent /// message has been updated by the package. bool _showMessage = false; /// When set to `true`, various assert statements will be enabled /// to ensure usage of this class is within GA4 limitations. final bool _enableAsserts; /// Telemetry suppression flag that is set via [Analytics.suppressTelemetry]. bool _telemetrySuppressed = false; /// Indicates if this is the first run for a given tool. bool _firstRun = false; /// The list of futures that will contain all of the send events /// from the [GAClient]. final _futures = >[]; /// Internal value for the client id which will be lazily loaded. String? _clientId; /// Internal collection of [Event]s that have been sent /// for errors encountered within package:unified_analytics. /// /// Stores each of the events that have been sent to GA4 so that the /// same error doesn't get sent twice. final Set _sentErrorEvents = {}; AnalyticsImpl({ required this.tool, required Directory homeDirectory, required String? flutterChannel, required String? flutterVersion, required String? clientIde, required String? enabledFeatures, required String dartVersion, required DevicePlatform platform, required this.toolsMessageVersion, required this.fs, required GAClient gaClient, required SurveyHandler surveyHandler, required bool enableAsserts, required bool firstRun, }) : _gaClient = gaClient, _surveyHandler = surveyHandler, _enableAsserts = enableAsserts, _clientIdFile = homeDirectory .childDirectory(kDartToolDirectoryName) .childFile(kClientIdFileName), _userProperty = UserProperty( sessionFile: homeDirectory .childDirectory(kDartToolDirectoryName) .childFile(kSessionFileName), flutterChannel: flutterChannel, host: platform.label, flutterVersion: flutterVersion, dartVersion: dartVersion, tool: tool.label, // We truncate this to a maximum of 36 characters since this can // a very long string for some operating systems hostOsVersion: truncateStringToLength(io.Platform.operatingSystemVersion, 36), locale: io.Platform.localeName, clientIde: clientIde, ), _enabledFeatures = enabledFeatures, _configHandler = ConfigHandler( homeDirectory: homeDirectory, configFile: homeDirectory .childDirectory(kDartToolDirectoryName) .childFile(kConfigFileName), ), _logHandler = LogHandler( logFile: homeDirectory .childDirectory(kDartToolDirectoryName) .childFile(kLogFileName), ) { // This initializer class will let the instance know // if it was the first run; if it is, nothing will be sent // on the first run if (firstRun) { _showMessage = true; _firstRun = true; } else { _showMessage = false; _firstRun = false; } // Check if the tool has already been onboarded, and if it // has, check if the latest message version is greater to // prompt the client to show a message // // If the tool has not been added to the config file, then // we will show the message as well final currentVersion = _configHandler.parsedTools[tool.label]?.versionNumber ?? -1; if (currentVersion < toolsMessageVersion) { _showMessage = true; // If the message version has been updated, it will be considered // as if it was a first run and any events attempting to get sent // will be blocked _firstRun = true; } } @override String get clientId { if (!_clientIdFile.existsSync()) { createClientIdFile(clientIdFile: _clientIdFile); } _clientId ??= _clientIdFile.readAsStringSync(); return _clientId!; } @override String get getConsentMessage { // The command to swap in the consent message final commandString = tool == DashTool.flutterTool || tool == DashTool.devtools ? 'flutter' : 'dart'; return kToolsMessage .replaceAll('{{ toolDescription }}', tool.description) .replaceAll('{{ toolName }}', commandString); } /// Checking the [telemetryEnabled] boolean reflects what the /// config file reflects. /// /// Checking the [_showMessage] boolean indicates if the consent /// message has been shown for the user, this boolean is set to `true` /// when the tool using this package invokes the [clientShowedMessage] /// method. /// /// If the user has suppressed telemetry [_telemetrySuppressed] will /// return `true` to prevent events from being sent for current invocation. /// /// Checking if it is the first time a tool is running with this package /// as indicated by [_firstRun]. @override bool get okToSend => telemetryEnabled && !_showMessage && !_telemetrySuppressed && !_firstRun; @override Map get parsedTools => _configHandler.parsedTools; @override bool get shouldShowMessage => _showMessage; @override bool get telemetryEnabled => _configHandler.telemetryEnabled; @override Map> get userPropertyMap => _userProperty.preparePayload(); @override void clientShowedMessage() { // Check the tool needs to be added to the config file if (!_configHandler.parsedTools.containsKey(tool.label)) { _configHandler.addTool( tool: tool.label, versionNumber: toolsMessageVersion, ); } // When the tool already exists but the consent message version // has been updated if (_configHandler.parsedTools[tool.label]!.versionNumber < toolsMessageVersion) { _configHandler.incrementToolVersion( tool: tool.label, newVersionNumber: toolsMessageVersion, ); } _showMessage = false; } @override Future close({int delayDuration = kDelayDuration}) async { // Collect any errors encountered and send _sendPendingErrorEvents(); await Future.wait(_futures).timeout( Duration(milliseconds: delayDuration), onTimeout: () => [], ); _gaClient.close(); } @override Future> fetchAvailableSurveys() async { final surveysToShow = []; if (!okToSend) return surveysToShow; final logFileStats = _logHandler.logFileStats(); // Call for surveys that have already been dismissed from // persisted survey ids on disk final persistedSurveyMap = _surveyHandler.fetchPersistedSurveys(); for (final survey in await _surveyHandler.fetchSurveyList()) { // If the survey has listed the tool running this package in the exclude // list, it will not be returned if (survey.excludeDashToolList.contains(tool)) continue; // Apply the survey's sample rate; if the generated value from // the client id and survey's uniqueId are less, it will not get // sent to the user if (survey.samplingRate < sampleRate(clientId, survey.uniqueId)) { continue; } // If the survey has been permanently dismissed or has temporarily // been snoozed, skip it if (surveySnoozedOrDismissed(survey, persistedSurveyMap)) continue; // Counter to check each survey condition, if all are met, then // this integer will be equal to the number of conditions in // [Survey.conditionList] var conditionsMet = 0; if (logFileStats != null) { for (final condition in survey.conditionList) { // Retrieve the value from the [LogFileStats] with // the label provided in the condtion final logFileStatsValue = logFileStats.getValueByString(condition.field); if (logFileStatsValue == null) continue; switch (condition.operatorString) { case '>=': if (logFileStatsValue >= condition.value) conditionsMet++; case '<=': if (logFileStatsValue <= condition.value) conditionsMet++; case '>': if (logFileStatsValue > condition.value) conditionsMet++; case '<': if (logFileStatsValue < condition.value) conditionsMet++; case '==': if (logFileStatsValue == condition.value) conditionsMet++; case '!=': if (logFileStatsValue != condition.value) conditionsMet++; } } } if (conditionsMet == survey.conditionList.length) { surveysToShow.add(survey); } } return surveysToShow; } @override LogFileStats? logFileStats() => _logHandler.logFileStats(); @override void send(Event event) { if (!okToSend) return; // Construct the body of the request final body = generateRequestBody( clientId: clientId, eventName: event.eventName, eventData: event.eventData, userProperty: _userProperty, enabledFeatures: _enabledFeatures, ); if (_enableAsserts) checkBody(body); _logHandler.save(data: body); final gaClientFuture = _gaClient.sendData(body); _futures.add(gaClientFuture); gaClientFuture.whenComplete(() => _futures.remove(gaClientFuture)); } @override Future setTelemetry(bool reportingBool) { _configHandler.setTelemetry(reportingBool); // Creation of the [Event] for opting out final collectionEvent = Event.analyticsCollectionEnabled(status: reportingBool); // The body of the request that will be sent to GA4 final Map body; if (reportingBool) { // Recreate the session and client id file; no need to // recreate the log file since it will only receives events // to persist from events sent createClientIdFile(clientIdFile: _clientIdFile); createSessionFile(sessionFile: _userProperty.sessionFile); // Reread the client ID string so an empty string is not being // sent to GA4 since the persisted files are cleared when a user // decides to opt out of telemetry collection _clientId = _clientIdFile.readAsStringSync(); // We must construct the body at this point after we have read in the // new client id string that was generated body = generateRequestBody( clientId: clientId, eventName: collectionEvent.eventName, eventData: collectionEvent.eventData, userProperty: _userProperty, enabledFeatures: _enabledFeatures, ); _logHandler.save(data: body); } else { // Construct the body of the request to signal // telemetry status toggling body = generateRequestBody( clientId: clientId, eventName: collectionEvent.eventName, eventData: collectionEvent.eventData, enabledFeatures: _enabledFeatures, userProperty: _userProperty, ); // For opted out users, data in the persisted files is cleared _userProperty.sessionFile.writeAsStringSync(''); _logHandler.logFile.writeAsStringSync(''); _clientIdFile.writeAsStringSync(''); _clientId = ''; } // Pass to the google analytics client to send with a // timeout incase http clients hang return _gaClient.sendData(body).timeout( const Duration(milliseconds: kDelayDuration), onTimeout: () => Response('', 200), ); } @override void suppressTelemetry() => _telemetrySuppressed = true; @override void surveyInteracted({ required Survey survey, required SurveyButton surveyButton, }) { // Any action, except for 'snooze' will permanently dismiss a given survey final permanentlyDismissed = surveyButton.action == 'snooze' ? false : true; _surveyHandler.dismiss(survey, permanentlyDismissed); send(Event.surveyAction( surveyId: survey.uniqueId, status: surveyButton.action, )); } @override void surveyShown(Survey survey) { _surveyHandler.dismiss(survey, false); send(Event.surveyShown(surveyId: survey.uniqueId)); } /// Send any pending error events, useful for tests to avoid closing /// the connection. /// /// In the main implementation, [AnalyticsImpl], error events are only /// sent on exit when [close] is invoked. This helper method can instead /// have those error events sent immediately to help with tests that check /// [FakeAnalytics.sentEvents]. void _sendPendingErrorEvents() { // Collect any errors encountered and send final errorEvents = {..._userProperty.errorSet, ..._logHandler.errorSet}; errorEvents .where((event) => event.eventName == DashEvent.analyticsException && !_sentErrorEvents.contains(event)) .forEach(send); // Ensure the same event doesn't get sent again _sentErrorEvents.addAll(errorEvents); // Clear error sets _userProperty.errorSet.clear(); _logHandler.errorSet.clear(); } } /// This fake instance of [Analytics] is intended to be used by clients of /// this package for testing purposes. It exposes a list [sentEvents] that /// keeps track of all events that have been sent. /// /// This is useful for confirming that events are being sent for a given /// workflow. Invoking the [send] method on this instance will not make any /// network requests to Google Analytics. class FakeAnalytics extends AnalyticsImpl { /// Use this list to check for events that have been emitted when /// invoking the send method final List sentEvents = []; /// Class to use when you want to see which events were sent FakeAnalytics._({ required super.tool, required super.homeDirectory, required super.dartVersion, required super.platform, required super.fs, required super.surveyHandler, required super.firstRun, super.flutterChannel, super.flutterVersion, super.clientIde, super.enabledFeatures, super.toolsMessageVersion = kToolsMessageVersion, super.gaClient = const FakeGAClient(), super.enableAsserts = true, }); /// Getter to reference the private [UserProperty]. UserProperty get userProperty => _userProperty; @override void send(Event event) { if (!okToSend) return; // Construct the body of the request final body = generateRequestBody( clientId: clientId, eventName: event.eventName, eventData: event.eventData, userProperty: _userProperty, enabledFeatures: _enabledFeatures, ); if (_enableAsserts) checkBody(body); _logHandler.save(data: body); // Using this list to validate that events are being sent // for internal methods in the `Analytics` instance sentEvents.add(event); } /// Public instance method to invoke private method that sends any /// pending error events. /// /// If this is never invoked, any pending error events will be sent /// when invoking the [close] method. void sendPendingErrorEvents() => _sendPendingErrorEvents(); } /// An implementation that will never send events. /// /// This is for clients that opt to either not send analytics, or will migrate /// to use [AnalyticsImpl] at a later time. class NoOpAnalytics implements Analytics { /// The hard-coded client ID value for each NoOp instance. static String get staticClientId => 'xxxx-xxxx'; @override final String getConsentMessage = ''; @override final bool okToSend = false; @override final Map parsedTools = const {}; @override final bool shouldShowMessage = false; @override final bool telemetryEnabled = false; @override final Map> userPropertyMap = const >{}; const NoOpAnalytics(); @override String get clientId => staticClientId; @override void clientShowedMessage() {} @override Future close({int delayDuration = kDelayDuration}) async {} @override Future> fetchAvailableSurveys() async => const []; @override LogFileStats? logFileStats() => null; @override Future? send(Event event) => null; @override Future setTelemetry(bool reportingBool) async {} @override void suppressTelemetry() {} @override void surveyInteracted({ required Survey survey, required SurveyButton surveyButton, }) {} @override void surveyShown(Survey survey) {} }