Commit adac9275 authored by Devon Carew's avatar Devon Carew

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
parent 51b1550d
......@@ -15,6 +15,7 @@ import 'src/base/process.dart';
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 @@ Future<Null> main(List<String> args) async {
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 @@ Future<Null> main(List<String> args) async {
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 @@ Future<Null> main(List<String> args) async {
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 @@ Future<Null> main(List<String> args) async {
'please let us know at https://github.com/flutter/flutter/issues.');
}
exit(1);
_exit(1);
}
});
}
......@@ -159,3 +171,21 @@ String _doctorText() {
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);
});
}
// 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;
}
}
......@@ -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 '../base/utils.dart';
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,
......@@ -45,11 +35,24 @@ 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'];
@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);
......
......@@ -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 @@ class PrecacheCommand extends Command {
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
......
......@@ -82,6 +82,17 @@ class RunCommand extends RunCommandBase {
@override
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'];
......
......@@ -12,12 +12,6 @@ import '../globals.dart';
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.');
......@@ -26,6 +20,12 @@ class SkiaCommand extends FlutterCommand {
help: 'Local port where the diagnostic server is listening.');
}
@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;
......
......@@ -9,13 +9,15 @@ import 'cache.dart';
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.
......
......@@ -15,6 +15,7 @@ import '../flx.dart' as flx;
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 @@ abstract class FlutterCommand extends Command {
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 @@ abstract class FlutterCommand extends Command {
_setupToolchain();
_setupApplicationPackages();
String commandPath = usagePath;
if (commandPath != null)
flutterUsage.sendCommand(usagePath);
return await runInProject();
}
......
......@@ -84,13 +84,15 @@ class FlutterCommandRunner extends CommandRunner {
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 @@ class FlutterCommandRunner extends CommandRunner {
}
if (globalResults['version']) {
flutterUsage.sendCommand('version');
printStatus(FlutterVersion.getVersion(ArtifactStore.flutterRoot).toString());
return new Future<int>.value(0);
}
......
......@@ -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 @@ class FlutterVersion {
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() : '';
}
// 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() { }
}
......@@ -20,6 +20,7 @@ dependencies:
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
......
......@@ -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 @@ import 'upgrade_test.dart' as upgrade_test;
void main() {
adb_test.main();
analytics_test.main();
analyze_duplicate_names_test.main();
analyze_test.main();
android_device_test.main();
......
// 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()
});
});
}
......@@ -11,6 +11,7 @@ import 'package:flutter_tools/src/commands/create.dart';
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 @@ void main() {
// 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 @@ void main() {
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 @@ void main() {
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);
......
......@@ -12,7 +12,7 @@ import 'package:flutter_tools/src/device.dart';
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 @@ void testUsingContext(String description, dynamic testMethod(), {
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 MockSimControl extends Mock implements SimControl {
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() { }
}
......@@ -3,4 +3,8 @@ set -ex
# Download dependencies flutter
./bin/flutter --version
# Disable analytics on the bots (to avoid skewing analytics data).
./bin/flutter config --no-analytics
./bin/flutter update-packages
#!/bin/bash
set -ex
export PATH="$PWD/bin:$PATH"
export PATH="$PWD/bin:$PWD/bin/cache/dart-sdk/bin:$PATH"
# analyze all the Dart code in the repo
flutter analyze --flutter-repo
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment