572 lines
16 KiB
Dart
572 lines
16 KiB
Dart
// 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(null);
|
|
}
|
|
parameters ??= <String, String>{};
|
|
parameters['viewName'] = viewName;
|
|
parameters.addAll(_sessionValues);
|
|
_sendController.add(parameters);
|
|
logFile.writeAsStringSync('screenView $parameters\n', mode: FileMode.append);
|
|
return Future<void>.value(null);
|
|
}
|
|
|
|
@override
|
|
Future<void> sendEvent(String category, String action,
|
|
{String? label, int? value, Map<String, String>? parameters}) {
|
|
if (!enabled) {
|
|
return Future<void>.value(null);
|
|
}
|
|
parameters ??= <String, String>{};
|
|
parameters['category'] = category;
|
|
parameters['action'] = action;
|
|
_sendController.add(parameters);
|
|
logFile.writeAsStringSync('event $parameters\n', mode: FileMode.append);
|
|
return Future<void>.value(null);
|
|
}
|
|
|
|
@override
|
|
Future<void> sendTiming(String variableName, int time,
|
|
{String? category, String? label}) {
|
|
if (!enabled) {
|
|
return Future<void>.value(null);
|
|
}
|
|
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(null);
|
|
}
|
|
|
|
@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;
|
|
}
|