add google analytics to flutter_tools (#3523) * add google analytics * send in the run target type * track device type targets * use the real GA code * review comments * rev to usage 2.0 * rev to 2.2.0 of usage; add tests * review comments
diff --git a/packages/flutter_tools/lib/executable.dart b/packages/flutter_tools/lib/executable.dart index 1c80023..f51a038 100644 --- a/packages/flutter_tools/lib/executable.dart +++ b/packages/flutter_tools/lib/executable.dart
@@ -15,6 +15,7 @@ import 'src/base/utils.dart'; import 'src/commands/analyze.dart'; import 'src/commands/build.dart'; +import 'src/commands/config.dart'; import 'src/commands/create.dart'; import 'src/commands/daemon.dart'; import 'src/commands/devices.dart'; @@ -56,6 +57,7 @@ FlutterCommandRunner runner = new FlutterCommandRunner(verboseHelp: verboseHelp) ..addCommand(new AnalyzeCommand()) ..addCommand(new BuildCommand()) + ..addCommand(new ConfigCommand()) ..addCommand(new CreateCommand()) ..addCommand(new DaemonCommand(hidden: !verboseHelp)) ..addCommand(new DevicesCommand()) @@ -82,10 +84,18 @@ context[DeviceManager] = new DeviceManager(); Doctor.initGlobal(); + if (flutterUsage.isFirstRun) { + printStatus( + 'The Flutter tool anonymously reports feature usage statistics and basic crash reports to Google to\n' + 'help Google contribute improvements to Flutter over time. Use "flutter config" to control this\n' + 'behavior. See Google\'s privacy policy: https://www.google.com/intl/en/policies/privacy/\n' + ); + } + dynamic result = await runner.run(args); if (result is int) - exit(result); + _exit(result); }, onError: (dynamic error, Chain chain) { if (error is UsageException) { stderr.writeln(error.message); @@ -93,14 +103,16 @@ stderr.writeln("Run 'flutter -h' (or 'flutter <command> -h') for available " "flutter commands and options."); // Argument error exit code. - exit(64); + _exit(64); } else if (error is ProcessExit) { // We've caught an exit code. - exit(error.exitCode); + _exit(error.exitCode); } else { // We've crashed; emit a log report. stderr.writeln(); + flutterUsage.sendException(error, chain); + if (Platform.environment.containsKey('FLUTTER_DEV')) { // If we're working on the tools themselves, just print the stack trace. stderr.writeln('$error'); @@ -118,7 +130,7 @@ 'please let us know at https://github.com/flutter/flutter/issues.'); } - exit(1); + _exit(1); } }); } @@ -159,3 +171,21 @@ return 'encountered exception: $error\n\n${trace.toString().trim()}\n'; } } + +Future<Null> _exit(int code) async { + // Send any last analytics calls that are in progress without overly delaying + // the tool's exit (we wait a maximum of 250ms). + if (flutterUsage.enabled) { + Stopwatch stopwatch = new Stopwatch()..start(); + await flutterUsage.ensureAnalyticsSent(); + printTrace('ensureAnalyticsSent: ${stopwatch.elapsedMilliseconds}ms'); + } + + // Write any buffered output. + logger.flush(); + + // Give the task / timer queue one cycle through before we hard exit. + await Timer.run(() { + exit(code); + }); +}
diff --git a/packages/flutter_tools/lib/src/commands/config.dart b/packages/flutter_tools/lib/src/commands/config.dart new file mode 100644 index 0000000..b789955 --- /dev/null +++ b/packages/flutter_tools/lib/src/commands/config.dart
@@ -0,0 +1,50 @@ +// Copyright 2016 The Chromium Authors. 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:async'; + +import '../globals.dart'; +import '../runner/flutter_command.dart'; + +class ConfigCommand extends FlutterCommand { + ConfigCommand() { + String usageStatus = flutterUsage.enabled ? 'enabled' : 'disabled'; + + argParser.addFlag('analytics', + negatable: true, + help: 'Enable or disable reporting anonymously tool usage statistics and crash reports.\n(currently $usageStatus)'); + } + + @override + final String name = 'config'; + + @override + final String description = + 'Configure Flutter settings.\n\n' + 'The Flutter tool anonymously reports feature usage statistics and basic crash reports to help improve\n' + 'Flutter tools over time. See Google\'s privacy policy: www.google.com/intl/en/policies/privacy'; + + @override + final List<String> aliases = <String>['configure']; + + @override + bool get requiresProjectRoot => false; + + /// Return `null` to disable tracking of the `config` command. + @override + String get usagePath => null; + + @override + Future<int> runInProject() async { + if (argResults.wasParsed('analytics')) { + bool value = argResults['analytics']; + flutterUsage.enabled = value; + printStatus('Analytics reporting ${value ? 'enabled' : 'disabled'}.'); + } else { + printStatus(usage); + } + + return 0; + } +}
diff --git a/packages/flutter_tools/lib/src/commands/create.dart b/packages/flutter_tools/lib/src/commands/create.dart index 6a2269a..122294c 100644 --- a/packages/flutter_tools/lib/src/commands/create.dart +++ b/packages/flutter_tools/lib/src/commands/create.dart
@@ -5,7 +5,6 @@ import 'dart:async'; import 'dart:io'; -import 'package:args/command_runner.dart'; import 'package:path/path.dart' as path; import '../android/android.dart' as android; @@ -14,19 +13,10 @@ import '../cache.dart'; import '../dart/pub.dart'; import '../globals.dart'; +import '../runner/flutter_command.dart'; import '../template.dart'; -class CreateCommand extends Command { - @override - final String name = 'create'; - - @override - final String description = 'Create a new Flutter project.\n\n' - 'If run on a project that already exists, this will repair the project, recreating any files that are missing.'; - - @override - final List<String> aliases = <String>['init']; - +class CreateCommand extends FlutterCommand { CreateCommand() { argParser.addFlag('pub', defaultsTo: true, @@ -46,10 +36,23 @@ } @override + final String name = 'create'; + + @override + final String description = 'Create a new Flutter project.\n\n' + 'If run on a project that already exists, this will repair the project, recreating any files that are missing.'; + + @override + final List<String> aliases = <String>['init']; + + @override + bool get requiresProjectRoot => false; + + @override String get invocation => "${runner.executableName} $name <output directory>"; @override - Future<int> run() async { + Future<int> runInProject() async { if (argResults.rest.isEmpty) { printStatus('No option specified for the output directory.'); printStatus(usage);
diff --git a/packages/flutter_tools/lib/src/commands/precache.dart b/packages/flutter_tools/lib/src/commands/precache.dart index 6b950c2..192b055 100644 --- a/packages/flutter_tools/lib/src/commands/precache.dart +++ b/packages/flutter_tools/lib/src/commands/precache.dart
@@ -4,11 +4,10 @@ import 'dart:async'; -import 'package:args/command_runner.dart'; - import '../globals.dart'; +import '../runner/flutter_command.dart'; -class PrecacheCommand extends Command { +class PrecacheCommand extends FlutterCommand { @override final String name = 'precache'; @@ -16,7 +15,10 @@ final String description = 'Populates the Flutter tool\'s cache of binary artifacts.'; @override - Future<int> run() async { + bool get requiresProjectRoot => false; + + @override + Future<int> runInProject() async { if (cache.isUpToDate()) printStatus('Already up-to-date.'); else
diff --git a/packages/flutter_tools/lib/src/commands/run.dart b/packages/flutter_tools/lib/src/commands/run.dart index c45110b..7911eb8 100644 --- a/packages/flutter_tools/lib/src/commands/run.dart +++ b/packages/flutter_tools/lib/src/commands/run.dart
@@ -83,6 +83,17 @@ bool get requiresDevice => true; @override + String get usagePath { + Device device = deviceForCommand; + + if (device == null) + return name; + + // Return 'run/ios'. + return '$name/${getNameForTargetPlatform(device.platform)}'; + } + + @override Future<int> runInProject() async { bool clearLogs = argResults['clear-logs'];
diff --git a/packages/flutter_tools/lib/src/commands/skia.dart b/packages/flutter_tools/lib/src/commands/skia.dart index 3e5f0f4..1f1bb8c 100644 --- a/packages/flutter_tools/lib/src/commands/skia.dart +++ b/packages/flutter_tools/lib/src/commands/skia.dart
@@ -12,12 +12,6 @@ import '../runner/flutter_command.dart'; class SkiaCommand extends FlutterCommand { - @override - final String name = 'skia'; - - @override - final String description = 'Retrieve the last frame rendered by a Flutter app as a Skia picture.'; - SkiaCommand() { argParser.addOption('output-file', help: 'Write the Skia picture file to this path.'); argParser.addOption('skiaserve', help: 'Post the picture to a skiaserve debugger at this URL.'); @@ -27,6 +21,12 @@ } @override + final String name = 'skia'; + + @override + final String description = 'Retrieve the last frame rendered by a Flutter app as a Skia picture.'; + + @override Future<int> runInProject() async { File outputFile; Uri skiaserveUri;
diff --git a/packages/flutter_tools/lib/src/globals.dart b/packages/flutter_tools/lib/src/globals.dart index dfe72ed..934d01e 100644 --- a/packages/flutter_tools/lib/src/globals.dart +++ b/packages/flutter_tools/lib/src/globals.dart
@@ -9,13 +9,15 @@ import 'device.dart'; import 'doctor.dart'; import 'toolchain.dart'; +import 'usage.dart'; DeviceManager get deviceManager => context[DeviceManager]; Logger get logger => context[Logger]; AndroidSdk get androidSdk => context[AndroidSdk]; -Doctor get doctor => context[Doctor]; Cache get cache => Cache.instance; +Doctor get doctor => context[Doctor]; ToolConfiguration get tools => ToolConfiguration.instance; +Usage get flutterUsage => Usage.instance; /// Display an error level message to the user. Commands should use this if they /// fail in some way.
diff --git a/packages/flutter_tools/lib/src/runner/flutter_command.dart b/packages/flutter_tools/lib/src/runner/flutter_command.dart index 432682e6..1ebc038 100644 --- a/packages/flutter_tools/lib/src/runner/flutter_command.dart +++ b/packages/flutter_tools/lib/src/runner/flutter_command.dart
@@ -15,6 +15,7 @@ import '../globals.dart'; import '../package_map.dart'; import '../toolchain.dart'; +import '../usage.dart'; import 'flutter_command_runner.dart'; typedef bool Validator(); @@ -94,13 +95,19 @@ applicationPackages ??= new ApplicationPackageStore(); } + /// The path to send to Google Analytics. Return `null` here to disable + /// tracking of the command. + String get usagePath => name; + @override Future<int> run() { Stopwatch stopwatch = new Stopwatch()..start(); + UsageTimer analyticsTimer = usagePath == null ? null : flutterUsage.startTimer(name); return _run().then((int exitCode) { int ms = stopwatch.elapsedMilliseconds; printTrace("'flutter $name' took ${ms}ms; exiting with code $exitCode."); + analyticsTimer?.finish(); return exitCode; }); } @@ -160,6 +167,10 @@ _setupToolchain(); _setupApplicationPackages(); + String commandPath = usagePath; + if (commandPath != null) + flutterUsage.sendCommand(usagePath); + return await runInProject(); }
diff --git a/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart b/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart index a050656..955eee1 100644 --- a/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart +++ b/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart
@@ -84,13 +84,15 @@ argParser.addOption('host-debug-build-path', hide: !verboseHelp, help: - 'Path to your host Debug out directory (i.e. the one that runs on your workstation, not a device), if you are building Flutter locally.\n' + 'Path to your host Debug out directory (i.e. the one that runs on your workstation, not a device),\n' + 'if you are building Flutter locally.\n' 'This path is relative to --engine-src-path. Not normally required.', defaultsTo: 'out/Debug/'); argParser.addOption('host-release-build-path', hide: !verboseHelp, help: - 'Path to your host Release out directory (i.e. the one that runs on your workstation, not a device), if you are building Flutter locally.\n' + 'Path to your host Release out directory (i.e. the one that runs on your workstation, not a device),\n' + 'if you are building Flutter locally.\n' 'This path is relative to --engine-src-path. Not normally required.', defaultsTo: 'out/Release/'); @@ -232,6 +234,7 @@ } if (globalResults['version']) { + flutterUsage.sendCommand('version'); printStatus(FlutterVersion.getVersion(ArtifactStore.flutterRoot).toString()); return new Future<int>.value(0); }
diff --git a/packages/flutter_tools/lib/src/runner/version.dart b/packages/flutter_tools/lib/src/runner/version.dart index eb0a53b..dba2f7e 100644 --- a/packages/flutter_tools/lib/src/runner/version.dart +++ b/packages/flutter_tools/lib/src/runner/version.dart
@@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:io'; + import '../artifacts.dart'; import '../base/process.dart'; @@ -54,4 +56,21 @@ static FlutterVersion getVersion([String flutterRoot]) { return new FlutterVersion(flutterRoot != null ? flutterRoot : ArtifactStore.flutterRoot); } + + static String getVersionString() { + final String cwd = ArtifactStore.flutterRoot; + + String commit = _runSync('git', <String>['rev-parse', 'HEAD'], cwd); + if (commit.length > 8) + commit = commit.substring(0, 8); + + String branch = _runSync('git', <String>['rev-parse', '--abbrev-ref', 'HEAD'], cwd); + + return '$commit/$branch'; + } +} + +String _runSync(String executable, List<String> arguments, String cwd) { + ProcessResult results = Process.runSync(executable, arguments, workingDirectory: cwd); + return results.exitCode == 0 ? results.stdout.trim() : ''; }
diff --git a/packages/flutter_tools/lib/src/usage.dart b/packages/flutter_tools/lib/src/usage.dart new file mode 100644 index 0000000..94a409a --- /dev/null +++ b/packages/flutter_tools/lib/src/usage.dart
@@ -0,0 +1,94 @@ +// Copyright 2016 The Chromium Authors. 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:async'; + +import 'package:usage/src/usage_impl_io.dart'; +import 'package:usage/usage.dart'; + +import 'base/context.dart'; +import 'runner/version.dart'; + +// TODO(devoncarew): We'll need to do some work on the user agent in order to +// correctly track usage by operating system (dart-lang/usage/issues/70). + +// TODO(devoncarew): We'll want to find a way to send (sanitized) command parameters. + +const String _kFlutterUA = 'UA-67589403-5'; + +class Usage { + Usage() { + _analytics = new AnalyticsIO(_kFlutterUA, 'flutter', FlutterVersion.getVersionString()); + _analytics.analyticsOpt = AnalyticsOpt.optOut; + } + + /// Returns [Usage] active in the current app context. + static Usage get instance => context[Usage] ?? (context[Usage] = new Usage()); + + Analytics _analytics; + + bool get isFirstRun => _analytics.firstRun; + + bool get enabled => _analytics.enabled; + + /// Enable or disable reporting analytics. + set enabled(bool value) { + _analytics.enabled = value; + } + + void sendCommand(String command) { + if (!isFirstRun) + _analytics.sendScreenView(command); + } + + void sendEvent(String category, String parameter) { + if (!isFirstRun) + _analytics.sendEvent(category, parameter); + } + + UsageTimer startTimer(String event) { + if (isFirstRun) + return new _MockUsageTimer(); + else + return new UsageTimer._(event, _analytics.startTimer(event)); + } + + void sendException(dynamic exception, StackTrace trace) { + if (!isFirstRun) + _analytics.sendException('${exception.runtimeType}; ${sanitizeStacktrace(trace)}'); + } + + /// Fires whenever analytics data is sent over the network; public for testing. + Stream<Map<String, dynamic>> get onSend => _analytics.onSend; + + /// Returns when the last analytics event has been sent, or after a fixed + /// (short) delay, whichever is less. + Future<Null> ensureAnalyticsSent() { + // 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? + return _analytics.waitForLastPing(timeout: new Duration(milliseconds: 250)); + } +} + +class UsageTimer { + UsageTimer._(this.event, this._timer); + + final String event; + final AnalyticsTimer _timer; + + void finish() { + _timer.finish(); + } +} + +class _MockUsageTimer implements UsageTimer { + @override + String event; + @override + AnalyticsTimer _timer; + + @override + void finish() { } +}
diff --git a/packages/flutter_tools/pubspec.yaml b/packages/flutter_tools/pubspec.yaml index ad65998..2b8abef 100644 --- a/packages/flutter_tools/pubspec.yaml +++ b/packages/flutter_tools/pubspec.yaml
@@ -20,6 +20,7 @@ path: ^1.3.0 pub_semver: ^1.0.0 stack_trace: ^1.4.0 + usage: ^2.2.0 web_socket_channel: ^1.0.0 xml: ^2.4.1 yaml: ^2.1.3
diff --git a/packages/flutter_tools/test/all.dart b/packages/flutter_tools/test/all.dart index 76cea4f..1d1afeb 100644 --- a/packages/flutter_tools/test/all.dart +++ b/packages/flutter_tools/test/all.dart
@@ -8,6 +8,7 @@ // fix lands. import 'adb_test.dart' as adb_test; +import 'analytics_test.dart' as analytics_test; import 'analyze_duplicate_names_test.dart' as analyze_duplicate_names_test; import 'analyze_test.dart' as analyze_test; import 'android_device_test.dart' as android_device_test; @@ -31,6 +32,7 @@ void main() { adb_test.main(); + analytics_test.main(); analyze_duplicate_names_test.main(); analyze_test.main(); android_device_test.main();
diff --git a/packages/flutter_tools/test/analytics_test.dart b/packages/flutter_tools/test/analytics_test.dart new file mode 100644 index 0000000..4cd2489 --- /dev/null +++ b/packages/flutter_tools/test/analytics_test.dart
@@ -0,0 +1,81 @@ +// Copyright 2016 The Chromium Authors. 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'; + +import 'package:args/command_runner.dart'; +import 'package:flutter_tools/src/artifacts.dart'; +import 'package:flutter_tools/src/commands/create.dart'; +import 'package:flutter_tools/src/commands/config.dart'; +import 'package:flutter_tools/src/commands/doctor.dart'; +import 'package:flutter_tools/src/globals.dart'; +import 'package:flutter_tools/src/usage.dart'; +import 'package:test/test.dart'; + +import 'src/common.dart'; +import 'src/context.dart'; + +void main() { + group('analytics', () { + Directory temp; + bool wasEnabled; + + setUp(() { + ArtifactStore.flutterRoot = '../..'; + wasEnabled = flutterUsage.enabled; + temp = Directory.systemTemp.createTempSync('flutter_tools'); + }); + + tearDown(() { + flutterUsage.enabled = wasEnabled; + temp.deleteSync(recursive: true); + }); + + // Ensure we don't send anything when analytics is disabled. + testUsingContext('doesn\'t send when disabled', () async { + int count = 0; + flutterUsage.onSend.listen((Map<String, dynamic> data) => count++); + + flutterUsage.enabled = false; + CreateCommand command = new CreateCommand(); + CommandRunner runner = createTestCommandRunner(command); + int code = await runner.run(<String>['create', '--no-pub', temp.path]); + expect(code, equals(0)); + expect(count, 0); + + flutterUsage.enabled = true; + code = await runner.run(<String>['create', '--no-pub', temp.path]); + expect(code, equals(0)); + expect(count, flutterUsage.isFirstRun ? 0 : 2); + + count = 0; + flutterUsage.enabled = false; + DoctorCommand doctorCommand = new DoctorCommand(); + runner = createTestCommandRunner(doctorCommand); + code = await runner.run(<String>['doctor']); + expect(code, equals(0)); + expect(count, 0); + }, overrides: <Type, dynamic>{ + Usage: new Usage() + }); + + // Ensure we con't send for the 'flutter config' command. + testUsingContext('config doesn\'t send', () async { + int count = 0; + flutterUsage.onSend.listen((Map<String, dynamic> data) => count++); + + flutterUsage.enabled = false; + ConfigCommand command = new ConfigCommand(); + CommandRunner runner = createTestCommandRunner(command); + await runner.run(<String>['config']); + expect(count, 0); + + flutterUsage.enabled = true; + await runner.run(<String>['config']); + expect(count, 0); + }, overrides: <Type, dynamic>{ + Usage: new Usage() + }); + }); +}
diff --git a/packages/flutter_tools/test/create_test.dart b/packages/flutter_tools/test/create_test.dart index f30f4c3..ec44bed 100644 --- a/packages/flutter_tools/test/create_test.dart +++ b/packages/flutter_tools/test/create_test.dart
@@ -11,6 +11,7 @@ import 'package:path/path.dart' as path; import 'package:test/test.dart'; +import 'src/common.dart'; import 'src/context.dart'; void main() { @@ -37,9 +38,9 @@ // Verify that we can regenerate over an existing project. testUsingContext('can re-gen over existing project', () async { ArtifactStore.flutterRoot = '../..'; + CreateCommand command = new CreateCommand(); - CommandRunner runner = new CommandRunner('test_flutter', '') - ..addCommand(command); + CommandRunner runner = createTestCommandRunner(command); int code = await runner.run(<String>['create', '--no-pub', temp.path]); expect(code, equals(0)); @@ -52,8 +53,7 @@ testUsingContext('fails when file exists', () async { ArtifactStore.flutterRoot = '../..'; CreateCommand command = new CreateCommand(); - CommandRunner runner = new CommandRunner('test_flutter', '') - ..addCommand(command); + CommandRunner runner = createTestCommandRunner(command); File existingFile = new File("${temp.path.toString()}/bad"); if (!existingFile.existsSync()) existingFile.createSync(); int code = await runner.run(<String>['create', existingFile.path]); @@ -65,8 +65,7 @@ Future<Null> _createAndAnalyzeProject(Directory dir, List<String> createArgs) async { ArtifactStore.flutterRoot = '../..'; CreateCommand command = new CreateCommand(); - CommandRunner runner = new CommandRunner('test_flutter', '') - ..addCommand(command); + CommandRunner runner = createTestCommandRunner(command); List<String> args = <String>['create']; args.addAll(createArgs); args.add(dir.path);
diff --git a/packages/flutter_tools/test/src/context.dart b/packages/flutter_tools/test/src/context.dart index c06c0b7..f0ac2f0 100644 --- a/packages/flutter_tools/test/src/context.dart +++ b/packages/flutter_tools/test/src/context.dart
@@ -12,7 +12,7 @@ import 'package:flutter_tools/src/doctor.dart'; import 'package:flutter_tools/src/ios/mac.dart'; import 'package:flutter_tools/src/ios/simulators.dart'; - +import 'package:flutter_tools/src/usage.dart'; import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; @@ -45,6 +45,9 @@ if (!overrides.containsKey(SimControl)) testContext[SimControl] = new MockSimControl(); + if (!overrides.containsKey(Usage)) + testContext[Usage] = new MockUsage(); + if (!overrides.containsKey(OperatingSystemUtils)) { MockOperatingSystemUtils os = new MockOperatingSystemUtils(); when(os.isWindows).thenReturn(false); @@ -112,3 +115,42 @@ class MockOperatingSystemUtils extends Mock implements OperatingSystemUtils {} class MockIOSSimulatorUtils extends Mock implements IOSSimulatorUtils {} + +class MockUsage implements Usage { + @override + bool get isFirstRun => false; + + @override + bool get enabled => true; + + @override + set enabled(bool value) { } + + @override + void sendCommand(String command) { } + + @override + void sendEvent(String category, String parameter) { } + + @override + UsageTimer startTimer(String event) => new _MockUsageTimer(event); + + @override + void sendException(dynamic exception, StackTrace trace) { } + + @override + Stream<Map<String, dynamic>> get onSend => null; + + @override + Future<Null> ensureAnalyticsSent() => new Future<Null>.value(); +} + +class _MockUsageTimer implements UsageTimer { + _MockUsageTimer(this.event); + + @override + final String event; + + @override + void finish() { } +}