| // Copyright 2014 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| part of reporting; |
| |
| const String _kFlutterUA = 'UA-67589403-6'; |
| |
| abstract class Usage { |
| /// Create a new Usage instance; [versionOverride], [configDirOverride], and |
| /// [logFile] are used for testing. |
| factory Usage({ |
| String settingsName = 'flutter', |
| String? versionOverride, |
| String? configDirOverride, |
| String? logFile, |
| AnalyticsFactory? analyticsIOFactory, |
| FirstRunMessenger? firstRunMessenger, |
| required bool runningOnBot, |
| }) => |
| _DefaultUsage.initialize( |
| settingsName: settingsName, |
| versionOverride: versionOverride, |
| configDirOverride: configDirOverride, |
| logFile: logFile, |
| analyticsIOFactory: analyticsIOFactory, |
| runningOnBot: runningOnBot, |
| firstRunMessenger: firstRunMessenger, |
| ); |
| |
| /// Uses the global [Usage] instance to send a 'command' to analytics. |
| static void command(String command, { |
| CustomDimensions? parameters, |
| }) => globals.flutterUsage.sendCommand(command, parameters: parameters); |
| |
| /// Whether analytics reporting should be suppressed. |
| bool get suppressAnalytics; |
| |
| /// Suppress analytics for this session. |
| set suppressAnalytics(bool value); |
| |
| /// Whether analytics reporting is enabled. |
| bool get enabled; |
| |
| /// Enable or disable reporting analytics. |
| set enabled(bool value); |
| |
| /// A stable randomly generated UUID used to deduplicate multiple identical |
| /// reports coming from the same computer. |
| String get clientId; |
| |
| /// Sends a 'command' to the underlying analytics implementation. |
| /// |
| /// Note that using [command] above is preferred to ensure that the parameter |
| /// keys are well-defined in [CustomDimensions] above. |
| void sendCommand( |
| String command, { |
| CustomDimensions? parameters, |
| }); |
| |
| /// Sends an 'event' to the underlying analytics implementation. |
| /// |
| /// Note that this method should not be used directly, instead see the |
| /// event types defined in this directory in events.dart. |
| @visibleForOverriding |
| @visibleForTesting |
| void sendEvent( |
| String category, |
| String parameter, { |
| String? label, |
| int? value, |
| CustomDimensions? parameters, |
| }); |
| |
| /// Sends timing information to the underlying analytics implementation. |
| void sendTiming( |
| String category, |
| String variableName, |
| Duration duration, { |
| String? label, |
| }); |
| |
| /// Sends an exception to the underlying analytics implementation. |
| void sendException(dynamic exception); |
| |
| /// Fires whenever analytics data is sent over the network. |
| @visibleForTesting |
| Stream<Map<String, dynamic>> get onSend; |
| |
| /// Returns when the last analytics event has been sent, or after a fixed |
| /// (short) delay, whichever is less. |
| Future<void> ensureAnalyticsSent(); |
| |
| /// Prints a welcome message that informs the tool user about the collection |
| /// of anonymous usage information. |
| void printWelcome(); |
| } |
| |
| typedef AnalyticsFactory = Analytics Function( |
| String trackingId, |
| String applicationName, |
| String applicationVersion, { |
| String analyticsUrl, |
| Directory? documentDirectory, |
| }); |
| |
| Analytics _defaultAnalyticsIOFactory( |
| String trackingId, |
| String applicationName, |
| String applicationVersion, { |
| String? analyticsUrl, |
| Directory? documentDirectory, |
| }) { |
| return AnalyticsIO( |
| trackingId, |
| applicationName, |
| applicationVersion, |
| analyticsUrl: analyticsUrl, |
| documentDirectory: documentDirectory, |
| ); |
| } |
| |
| class _DefaultUsage implements Usage { |
| _DefaultUsage._({ |
| required bool suppressAnalytics, |
| required Analytics analytics, |
| required this.firstRunMessenger, |
| required SystemClock clock, |
| }) : _suppressAnalytics = suppressAnalytics, |
| _analytics = analytics, |
| _clock = clock; |
| |
| static _DefaultUsage initialize({ |
| String settingsName = 'flutter', |
| String? versionOverride, |
| String? configDirOverride, |
| String? logFile, |
| AnalyticsFactory? analyticsIOFactory, |
| required FirstRunMessenger? firstRunMessenger, |
| required bool runningOnBot, |
| }) { |
| final FlutterVersion flutterVersion = globals.flutterVersion; |
| final String version = versionOverride ?? flutterVersion.getVersionString(redactUnknownBranches: true); |
| final bool suppressEnvFlag = globals.platform.environment['FLUTTER_SUPPRESS_ANALYTICS'] == 'true'; |
| final String? logFilePath = logFile ?? globals.platform.environment['FLUTTER_ANALYTICS_LOG_FILE']; |
| final bool usingLogFile = logFilePath != null && logFilePath.isNotEmpty; |
| |
| final AnalyticsFactory analyticsFactory = analyticsIOFactory ?? _defaultAnalyticsIOFactory; |
| bool suppressAnalytics = false; |
| bool skipAnalyticsSessionSetup = false; |
| Analytics? setupAnalytics; |
| if (// To support testing, only allow other signals to suppress analytics |
| // when analytics are not being shunted to a file. |
| !usingLogFile && ( |
| // Ignore local user branches. |
| version.startsWith('[user-branch]') || |
| // Many CI systems don't do a full git checkout. |
| version.endsWith('/unknown') || |
| // Ignore bots. |
| runningOnBot || |
| // Ignore when suppressed by FLUTTER_SUPPRESS_ANALYTICS. |
| suppressEnvFlag |
| )) { |
| // If we think we're running on a CI system, suppress sending analytics. |
| suppressAnalytics = true; |
| setupAnalytics = AnalyticsMock(); |
| skipAnalyticsSessionSetup = true; |
| } |
| if (usingLogFile) { |
| setupAnalytics ??= LogToFileAnalytics(logFilePath); |
| } else { |
| try { |
| ErrorHandlingFileSystem.noExitOnFailure(() { |
| setupAnalytics = analyticsFactory( |
| _kFlutterUA, |
| settingsName, |
| version, |
| documentDirectory: configDirOverride != null |
| ? globals.fs.directory(configDirOverride) |
| : null, |
| ); |
| }); |
| } on Exception catch (e) { |
| globals.printTrace('Failed to initialize analytics reporting: $e'); |
| suppressAnalytics = true; |
| setupAnalytics ??= AnalyticsMock(); |
| skipAnalyticsSessionSetup = true; |
| } |
| } |
| |
| final Analytics analytics = setupAnalytics!; |
| if (!skipAnalyticsSessionSetup) { |
| // Report a more detailed OS version string than package:usage does by default. |
| analytics.setSessionValue( |
| cdKey(CustomDimensionsEnum.sessionHostOsDetails), |
| globals.os.name, |
| ); |
| // Send the branch name as the "channel". |
| analytics.setSessionValue( |
| cdKey(CustomDimensionsEnum.sessionChannelName), |
| flutterVersion.getBranchName(redactUnknownBranches: true), |
| ); |
| // For each flutter experimental feature, record a session value in a comma |
| // separated list. |
| final String enabledFeatures = allFeatures |
| .where((Feature feature) { |
| final String? configSetting = feature.configSetting; |
| return configSetting != null && globals.config.getValue(configSetting) == true; |
| }) |
| .map((Feature feature) => feature.configSetting) |
| .join(','); |
| analytics.setSessionValue( |
| cdKey(CustomDimensionsEnum.enabledFlutterFeatures), |
| enabledFeatures, |
| ); |
| |
| // Record the host as the application installer ID - the context that flutter_tools is running in. |
| if (globals.platform.environment.containsKey('FLUTTER_HOST')) { |
| analytics.setSessionValue('aiid', globals.platform.environment['FLUTTER_HOST']); |
| } |
| analytics.analyticsOpt = AnalyticsOpt.optOut; |
| } |
| |
| return _DefaultUsage._( |
| suppressAnalytics: suppressAnalytics, |
| analytics: analytics, |
| firstRunMessenger: firstRunMessenger, |
| clock: globals.systemClock, |
| ); |
| } |
| |
| final Analytics _analytics; |
| final FirstRunMessenger? firstRunMessenger; |
| |
| bool _printedWelcome = false; |
| bool _suppressAnalytics = false; |
| final SystemClock _clock; |
| |
| @override |
| bool get suppressAnalytics => _suppressAnalytics || _analytics.firstRun; |
| |
| @override |
| set suppressAnalytics(bool value) { |
| _suppressAnalytics = value; |
| } |
| |
| @override |
| bool get enabled => _analytics.enabled; |
| |
| @override |
| set enabled(bool value) { |
| _analytics.enabled = value; |
| } |
| |
| @override |
| String get clientId => _analytics.clientId; |
| |
| @override |
| void sendCommand(String command, { CustomDimensions? parameters }) { |
| if (suppressAnalytics) { |
| return; |
| } |
| |
| _analytics.sendScreenView( |
| command, |
| parameters: CustomDimensions(localTime: formatDateTime(_clock.now())) |
| .merge(parameters) |
| .toMap(), |
| ); |
| } |
| |
| @override |
| void sendEvent( |
| String category, |
| String parameter, { |
| String? label, |
| int? value, |
| CustomDimensions? parameters, |
| }) { |
| if (suppressAnalytics) { |
| return; |
| } |
| |
| _analytics.sendEvent( |
| category, |
| parameter, |
| label: label, |
| value: value, |
| parameters: CustomDimensions(localTime: formatDateTime(_clock.now())) |
| .merge(parameters) |
| .toMap(), |
| ); |
| } |
| |
| @override |
| void sendTiming( |
| String category, |
| String variableName, |
| Duration duration, { |
| String? label, |
| }) { |
| if (suppressAnalytics) { |
| return; |
| } |
| _analytics.sendTiming( |
| variableName, |
| duration.inMilliseconds, |
| category: category, |
| label: label, |
| ); |
| } |
| |
| @override |
| void sendException(dynamic exception) { |
| if (suppressAnalytics) { |
| return; |
| } |
| _analytics.sendException(exception.runtimeType.toString()); |
| } |
| |
| @override |
| Stream<Map<String, dynamic>> get onSend => _analytics.onSend; |
| |
| @override |
| Future<void> ensureAnalyticsSent() async { |
| // TODO(devoncarew): This may delay tool exit and could cause some analytics |
| // events to not be reported. Perhaps we could send the analytics pings |
| // out-of-process from flutter_tools? |
| await _analytics.waitForLastPing(timeout: const Duration(milliseconds: 250)); |
| } |
| |
| @override |
| void printWelcome() { |
| // Only print once per run. |
| if (_printedWelcome) { |
| return; |
| } |
| // Display the welcome message if this is the first run of the tool or if |
| // the license terms have changed since it was last displayed. |
| final FirstRunMessenger? messenger = firstRunMessenger; |
| if (messenger != null && messenger.shouldDisplayLicenseTerms()) { |
| globals.printStatus(''); |
| globals.printStatus(messenger.licenseTerms, emphasis: true); |
| _printedWelcome = true; |
| messenger.confirmLicenseTermsDisplayed(); |
| } |
| } |
| } |
| |
| // An Analytics mock that logs to file. Unimplemented methods goes to stdout. |
| // But stdout can't be used for testing since wrapper scripts like |
| // xcode_backend.sh etc manipulates them. |
| class LogToFileAnalytics extends AnalyticsMock { |
| LogToFileAnalytics(String logFilePath) : |
| logFile = globals.fs.file(logFilePath)..createSync(recursive: true), |
| super(true); |
| |
| final File logFile; |
| final Map<String, String> _sessionValues = <String, String>{}; |
| |
| final StreamController<Map<String, dynamic>> _sendController = |
| StreamController<Map<String, dynamic>>.broadcast(sync: true); |
| |
| @override |
| Stream<Map<String, dynamic>> get onSend => _sendController.stream; |
| |
| @override |
| Future<void> sendScreenView(String viewName, { |
| Map<String, String>? parameters, |
| }) { |
| if (!enabled) { |
| return Future<void>.value(); |
| } |
| parameters ??= <String, String>{}; |
| parameters['viewName'] = viewName; |
| parameters.addAll(_sessionValues); |
| _sendController.add(parameters); |
| logFile.writeAsStringSync('screenView $parameters\n', mode: FileMode.append); |
| return Future<void>.value(); |
| } |
| |
| @override |
| Future<void> sendEvent(String category, String action, |
| {String? label, int? value, Map<String, String>? parameters}) { |
| if (!enabled) { |
| return Future<void>.value(); |
| } |
| parameters ??= <String, String>{}; |
| parameters['category'] = category; |
| parameters['action'] = action; |
| _sendController.add(parameters); |
| logFile.writeAsStringSync('event $parameters\n', mode: FileMode.append); |
| return Future<void>.value(); |
| } |
| |
| @override |
| Future<void> sendTiming(String variableName, int time, |
| {String? category, String? label}) { |
| if (!enabled) { |
| return Future<void>.value(); |
| } |
| final Map<String, String> parameters = <String, String>{ |
| 'variableName': variableName, |
| 'time': '$time', |
| if (category != null) 'category': category, |
| if (label != null) 'label': label, |
| }; |
| _sendController.add(parameters); |
| logFile.writeAsStringSync('timing $parameters\n', mode: FileMode.append); |
| return Future<void>.value(); |
| } |
| |
| @override |
| void setSessionValue(String param, dynamic value) { |
| _sessionValues[param] = value.toString(); |
| } |
| } |
| |
| |
| /// Create a testing Usage instance. |
| /// |
| /// All sent events, exceptions, timings, and pages are |
| /// buffered on the object and can be inspected later. |
| @visibleForTesting |
| class TestUsage implements Usage { |
| final List<TestUsageCommand> commands = <TestUsageCommand>[]; |
| final List<TestUsageEvent> events = <TestUsageEvent>[]; |
| final List<dynamic> exceptions = <dynamic>[]; |
| final List<TestTimingEvent> timings = <TestTimingEvent>[]; |
| int ensureAnalyticsSentCalls = 0; |
| |
| @override |
| bool enabled = true; |
| |
| @override |
| bool suppressAnalytics = false; |
| |
| @override |
| String get clientId => 'test-client'; |
| |
| @override |
| Future<void> ensureAnalyticsSent() async { |
| ensureAnalyticsSentCalls++; |
| } |
| |
| @override |
| Stream<Map<String, dynamic>> get onSend => throw UnimplementedError(); |
| |
| @override |
| void printWelcome() { } |
| |
| @override |
| void sendCommand(String command, {CustomDimensions? parameters}) { |
| commands.add(TestUsageCommand(command, parameters: parameters)); |
| } |
| |
| @override |
| void sendEvent(String category, String parameter, {String? label, int? value, CustomDimensions? parameters}) { |
| events.add(TestUsageEvent(category, parameter, label: label, value: value, parameters: parameters)); |
| } |
| |
| @override |
| void sendException(dynamic exception) { |
| exceptions.add(exception); |
| } |
| |
| @override |
| void sendTiming(String category, String variableName, Duration duration, {String? label}) { |
| timings.add(TestTimingEvent(category, variableName, duration, label: label)); |
| } |
| } |
| |
| @visibleForTesting |
| @immutable |
| class TestUsageCommand { |
| const TestUsageCommand(this.command, {this.parameters}); |
| |
| final String command; |
| final CustomDimensions? parameters; |
| |
| @override |
| bool operator ==(Object other) { |
| return other is TestUsageCommand && |
| other.command == command && |
| other.parameters == parameters; |
| } |
| |
| @override |
| int get hashCode => Object.hash(command, parameters); |
| |
| @override |
| String toString() => 'TestUsageCommand($command, parameters:$parameters)'; |
| } |
| |
| @visibleForTesting |
| @immutable |
| class TestUsageEvent { |
| const TestUsageEvent(this.category, this.parameter, {this.label, this.value, this.parameters}); |
| |
| final String category; |
| final String parameter; |
| final String? label; |
| final int? value; |
| final CustomDimensions? parameters; |
| |
| @override |
| bool operator ==(Object other) { |
| return other is TestUsageEvent && |
| other.category == category && |
| other.parameter == parameter && |
| other.label == label && |
| other.value == value && |
| other.parameters == parameters; |
| } |
| |
| @override |
| int get hashCode => Object.hash(category, parameter, label, value, parameters); |
| |
| @override |
| String toString() => 'TestUsageEvent($category, $parameter, label:$label, value:$value, parameters:$parameters)'; |
| } |
| |
| @visibleForTesting |
| @immutable |
| class TestTimingEvent { |
| const TestTimingEvent(this.category, this.variableName, this.duration, {this.label}); |
| |
| final String category; |
| final String variableName; |
| final Duration duration; |
| final String? label; |
| |
| @override |
| bool operator ==(Object other) { |
| return other is TestTimingEvent && |
| other.category == category && |
| other.variableName == variableName && |
| other.duration == duration && |
| other.label == label; |
| } |
| |
| @override |
| int get hashCode => Object.hash(category, variableName, duration, label); |
| |
| @override |
| String toString() => 'TestTimingEvent($category, $variableName, $duration, label:$label)'; |
| } |
| |
| bool _mapsEqual(Map<dynamic, dynamic>? a, Map<dynamic, dynamic>? b) { |
| if (a == b) { |
| return true; |
| } |
| if (a == null || b == null) { |
| return false; |
| } |
| if (a.length != b.length) { |
| return false; |
| } |
| |
| for (final dynamic k in a.keys) { |
| final dynamic bValue = b[k]; |
| if (bValue == null && !b.containsKey(k)) { |
| return false; |
| } |
| if (bValue != a[k]) { |
| return false; |
| } |
| } |
| |
| return true; |
| } |