| // 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 'is_external.dart' as ie; | |
| 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. | |
| /// | |
| /// [agent] is an optional parameter to capture the name of any AI coding | |
| /// assistant/agent executing the tool. Examples include "Claude Code", | |
| /// "Cursor", or "Copilot". | |
| factory Analytics({ | |
| required DashTool tool, | |
| required String dartVersion, | |
| String? flutterChannel, | |
| String? flutterVersion, | |
| String? clientIde, | |
| String? enabledFeatures, | |
| String? agent, | |
| 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, | |
| agent: agent, | |
| 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. | |
| /// | |
| /// [agent] is an optional parameter to capture the name of any AI coding | |
| /// assistant/agent executing the tool. Examples include "Claude Code", | |
| /// "Cursor", or "Copilot". | |
| factory Analytics.development({ | |
| required DashTool tool, | |
| required String dartVersion, | |
| String? flutterChannel, | |
| String? flutterVersion, | |
| String? clientIde, | |
| String? enabledFeatures, | |
| String? agent, | |
| 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, | |
| agent: agent, | |
| 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; | |
| /// Boolean indicating whether this instance is configured for an external | |
| /// build. | |
| bool get isExternal; | |
| /// Returns a map object with all of the tools that have been parsed | |
| /// out of the configuration file. | |
| Map<String, ToolInfo> 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<String, Map<String, Object?>> 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<void> 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<List<Survey>> 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<void> 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, | |
| String? agent, | |
| SurveyHandler? surveyHandler, | |
| GAClient? gaClient, | |
| DevicePlatform platform = DevicePlatform.linux, | |
| int toolsMessageVersion = kToolsMessageVersion, | |
| String toolsMessage = kToolsMessage, | |
| bool enableAsserts = true, | |
| bool isExternal = true, | |
| }) { | |
| final firstRun = runInitialization( | |
| homeDirectory: homeDirectory, | |
| isExternal: isExternal, | |
| ); | |
| 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, | |
| agent: agent, | |
| firstRun: firstRun, | |
| enableAsserts: enableAsserts, | |
| isExternal: isExternal, | |
| ); | |
| } | |
| } | |
| class AnalyticsImpl implements Analytics { | |
| final DashTool tool; | |
| final FileSystem fs; | |
| final int toolsMessageVersion; | |
| @override | |
| final bool isExternal; | |
| 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 = <Future<Response>>[]; | |
| /// 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<Event> _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, | |
| String? agent, | |
| this.isExternal = ie.isExternal, | |
| }) : _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, | |
| aiAgent: agent != null ? truncateStringToLength(agent, 36) : null, | |
| isExternal: isExternal, | |
| ), | |
| _enabledFeatures = enabledFeatures, | |
| _configHandler = ConfigHandler( | |
| homeDirectory: homeDirectory, | |
| configFile: homeDirectory | |
| .childDirectory(kDartToolDirectoryName) | |
| .childFile(kConfigFileName), | |
| isExternal: isExternal, | |
| ), | |
| _logHandler = LogHandler( | |
| logFile: homeDirectory | |
| .childDirectory(kDartToolDirectoryName) | |
| .childFile(kLogFileName), | |
| ) { | |
| if (!isExternal) return; | |
| // 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<String, ToolInfo> get parsedTools => _configHandler.parsedTools; | |
| @override | |
| bool get shouldShowMessage => _showMessage; | |
| @override | |
| bool get telemetryEnabled => !isExternal || _configHandler.telemetryEnabled; | |
| @override | |
| Map<String, Map<String, Object?>> get userPropertyMap => | |
| _userProperty.preparePayload(); | |
| @override | |
| void clientShowedMessage() { | |
| if (!isExternal) return; | |
| // 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<void> 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<List<Survey>> fetchAvailableSurveys() async { | |
| final surveysToShow = <Survey>[]; | |
| 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<void> setTelemetry(bool reportingBool) { | |
| if (!isExternal) return Future.value(); | |
| _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<String, Object?> 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<Event> 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.agent, | |
| super.toolsMessageVersion = kToolsMessageVersion, | |
| super.gaClient = const FakeGAClient(), | |
| super.enableAsserts = true, | |
| super.isExternal = 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 bool isExternal = ie.isExternal; | |
| @override | |
| final Map<String, ToolInfo> parsedTools = const <String, ToolInfo>{}; | |
| @override | |
| final bool shouldShowMessage = false; | |
| @override | |
| final bool telemetryEnabled = false; | |
| @override | |
| final Map<String, Map<String, Object?>> userPropertyMap = | |
| const <String, Map<String, Object?>>{}; | |
| const NoOpAnalytics(); | |
| @override | |
| String get clientId => staticClientId; | |
| @override | |
| void clientShowedMessage() {} | |
| @override | |
| Future<void> close({int delayDuration = kDelayDuration}) async {} | |
| @override | |
| Future<List<Survey>> fetchAvailableSurveys() async => const <Survey>[]; | |
| @override | |
| LogFileStats? logFileStats() => null; | |
| @override | |
| Future<Response>? send(Event event) => null; | |
| @override | |
| Future<void> setTelemetry(bool reportingBool) async {} | |
| @override | |
| void suppressTelemetry() {} | |
| @override | |
| void surveyInteracted({ | |
| required Survey survey, | |
| required SurveyButton surveyButton, | |
| }) {} | |
| @override | |
| void surveyShown(Survey survey) {} | |
| } |