Commit 66ed8de7 authored by xster's avatar xster Committed by GitHub

Record `flutter run` success/fail, build mode, platform, start time in analytics (#9597)

FlutterCommand.runCommand subclasses can optionally return a FlutterCommandResult which is used to append additional analytics. 

Fix flutter run timing report and add a bunch of dimensional data
parent a4992f0e
...@@ -8,7 +8,9 @@ import 'dart:math' show Random; ...@@ -8,7 +8,9 @@ import 'dart:math' show Random;
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:quiver/time.dart';
import 'context.dart';
import 'file_system.dart'; import 'file_system.dart';
import 'platform.dart'; import 'platform.dart';
...@@ -203,3 +205,5 @@ class Uuid { ...@@ -203,3 +205,5 @@ class Uuid {
String _printDigits(int value, int count) => String _printDigits(int value, int count) =>
value.toRadixString(16).padLeft(count, '0'); value.toRadixString(16).padLeft(count, '0');
} }
Clock get clock => context.putIfAbsent(Clock, () => const Clock());
...@@ -200,7 +200,7 @@ class RunCommand extends RunCommandBase { ...@@ -200,7 +200,7 @@ class RunCommand extends RunCommandBase {
bool get stayResident => argResults['resident']; bool get stayResident => argResults['resident'];
@override @override
Future<Null> verifyThenRunCommand() async { Future<FlutterCommandResult> verifyThenRunCommand() async {
commandValidator(); commandValidator();
devices = await findAllTargetDevices(); devices = await findAllTargetDevices();
if (devices == null) if (devices == null)
...@@ -225,7 +225,7 @@ class RunCommand extends RunCommandBase { ...@@ -225,7 +225,7 @@ class RunCommand extends RunCommandBase {
} }
@override @override
Future<Null> runCommand() async { Future<FlutterCommandResult> runCommand() async {
Cache.releaseLockEarly(); Cache.releaseLockEarly();
// Enable hot mode by default if `--no-hot` was not passed and we are in // Enable hot mode by default if `--no-hot` was not passed and we are in
...@@ -250,10 +250,15 @@ class RunCommand extends RunCommandBase { ...@@ -250,10 +250,15 @@ class RunCommand extends RunCommandBase {
} catch (error) { } catch (error) {
throwToolExit(error.toString()); throwToolExit(error.toString());
} }
final DateTime appStartedTime = clock.now();
final int result = await app.runner.waitForAppToFinish(); final int result = await app.runner.waitForAppToFinish();
if (result != 0) if (result != 0)
throwToolExit(null, exitCode: result); throwToolExit(null, exitCode: result);
return null; return new FlutterCommandResult(
ExitStatus.success,
analyticsParameters: <String>['daemon'],
endTimeOverride: appStartedTime,
);
} }
for (Device device in devices) { for (Device device in devices) {
...@@ -303,11 +308,34 @@ class RunCommand extends RunCommandBase { ...@@ -303,11 +308,34 @@ class RunCommand extends RunCommandBase {
); );
} }
DateTime appStartedTime;
// Sync completer so the completing agent attaching to the resident doesn't
// need to know about analytics.
//
// Do not add more operations to the future.
final Completer<Null> appStartedTimeRecorder = new Completer<Null>.sync();
appStartedTimeRecorder.future.then(
(_) { appStartedTime = clock.now(); }
);
final int result = await runner.run( final int result = await runner.run(
appStartedCompleter: appStartedTimeRecorder,
route: route, route: route,
shouldBuild: !runningWithPrebuiltApplication && argResults['build'], shouldBuild: !runningWithPrebuiltApplication && argResults['build'],
); );
if (result != 0) if (result != 0)
throwToolExit(null, exitCode: result); throwToolExit(null, exitCode: result);
return new FlutterCommandResult(
ExitStatus.success,
analyticsParameters: <String>[
hotMode ? 'hot' : 'cold',
getModeName(getBuildMode()),
devices.length == 1
? getNameForTargetPlatform(await devices[0].targetPlatform)
: 'multiple',
devices.length == 1 && await devices[0].isLocalEmulator ? 'emulator' : null
],
endTimeOverride: appStartedTime,
);
} }
} }
...@@ -6,10 +6,12 @@ import 'dart:async'; ...@@ -6,10 +6,12 @@ import 'dart:async';
import 'package:args/command_runner.dart'; import 'package:args/command_runner.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:quiver/strings.dart';
import '../application_package.dart'; import '../application_package.dart';
import '../base/common.dart'; import '../base/common.dart';
import '../base/file_system.dart'; import '../base/file_system.dart';
import '../base/utils.dart';
import '../build_info.dart'; import '../build_info.dart';
import '../dart/package_map.dart'; import '../dart/package_map.dart';
import '../dart/pub.dart'; import '../dart/pub.dart';
...@@ -22,6 +24,40 @@ import 'flutter_command_runner.dart'; ...@@ -22,6 +24,40 @@ import 'flutter_command_runner.dart';
typedef void Validator(); typedef void Validator();
enum ExitStatus {
success,
warning,
fail,
}
/// [FlutterCommand]s' subclasses' [FlutterCommand.runCommand] can optionally
/// provide a [FlutterCommandResult] to furnish additional information for
/// analytics.
class FlutterCommandResult {
FlutterCommandResult(
this.exitStatus, {
this.analyticsParameters,
this.endTimeOverride,
}) {
assert(exitStatus != null);
}
final ExitStatus exitStatus;
/// Optional dimension data that can be appended to the timing event.
/// https://developers.google.com/analytics/devguides/collection/analyticsjs/field-reference#timingLabel
/// Do not add PII.
final List<String> analyticsParameters;
/// Optional epoch time when the command's non-interactive wait time is
/// complete during the command's execution. Use to measure user perceivable
/// latency without measuring user interaction time.
///
/// [FlutterCommand] will automatically measure and report the command's
/// complete time if not overriden.
final DateTime endTimeOverride;
}
abstract class FlutterCommand extends Command<Null> { abstract class FlutterCommand extends Command<Null> {
FlutterCommand() { FlutterCommand() {
commandValidator = commonCommandValidator; commandValidator = commonCommandValidator;
...@@ -111,18 +147,37 @@ abstract class FlutterCommand extends Command<Null> { ...@@ -111,18 +147,37 @@ abstract class FlutterCommand extends Command<Null> {
/// and [runCommand] to execute the command /// and [runCommand] to execute the command
/// so that this method can record and report the overall time to analytics. /// so that this method can record and report the overall time to analytics.
@override @override
Future<Null> run() { Future<Null> run() async {
final Stopwatch stopwatch = new Stopwatch()..start(); final DateTime startTime = clock.now();
final UsageTimer analyticsTimer = usagePath == null ? null : flutterUsage.startTimer(name);
if (flutterUsage.isFirstRun) if (flutterUsage.isFirstRun)
flutterUsage.printWelcome(); flutterUsage.printWelcome();
return verifyThenRunCommand().whenComplete(() { final FlutterCommandResult commandResult = await verifyThenRunCommand();
final int ms = stopwatch.elapsedMilliseconds;
printTrace("'flutter $name' took ${ms}ms."); final DateTime endTime = clock.now();
analyticsTimer?.finish(); printTrace("'flutter $name' took ${getElapsedAsMilliseconds(endTime.difference(startTime))}.");
}); if (usagePath != null) {
final List<String> labels = <String>[];
if (commandResult?.exitStatus != null)
labels.add(getEnumName(commandResult.exitStatus));
if (commandResult?.analyticsParameters?.isNotEmpty ?? false)
labels.addAll(commandResult.analyticsParameters);
final String label = labels
.where((String label) => !isBlank(label))
.join('-');
flutterUsage.sendTiming(
'flutter',
name,
// If the command provides its own end time, use it. Otherwise report
// the duration of the entire execution.
(commandResult?.endTimeOverride ?? endTime).difference(startTime),
// Report in the form of `success-[parameter1-parameter2]`, all of which
// can be null if the command doesn't provide a FlutterCommandResult.
label: label == '' ? null : label,
);
}
} }
/// Perform validation then call [runCommand] to execute the command. /// Perform validation then call [runCommand] to execute the command.
...@@ -133,7 +188,7 @@ abstract class FlutterCommand extends Command<Null> { ...@@ -133,7 +188,7 @@ abstract class FlutterCommand extends Command<Null> {
/// then call this method to execute the command /// then call this method to execute the command
/// rather than calling [runCommand] directly. /// rather than calling [runCommand] directly.
@mustCallSuper @mustCallSuper
Future<Null> verifyThenRunCommand() async { Future<FlutterCommandResult> verifyThenRunCommand() async {
// Populate the cache. We call this before pub get below so that the sky_engine // Populate the cache. We call this before pub get below so that the sky_engine
// package is available in the flutter cache for pub to find. // package is available in the flutter cache for pub to find.
if (shouldUpdateCache) if (shouldUpdateCache)
...@@ -147,12 +202,13 @@ abstract class FlutterCommand extends Command<Null> { ...@@ -147,12 +202,13 @@ abstract class FlutterCommand extends Command<Null> {
final String commandPath = await usagePath; final String commandPath = await usagePath;
if (commandPath != null) if (commandPath != null)
flutterUsage.sendCommand(commandPath); flutterUsage.sendCommand(commandPath);
return runCommand();
await runCommand();
} }
/// Subclasses must implement this to execute the command. /// Subclasses must implement this to execute the command.
Future<Null> runCommand(); /// Optionally provide a [FlutterCommandResult] to send more details about the
/// execution for analytics.
Future<FlutterCommandResult> runCommand();
/// Find and return all target [Device]s based upon currently connected /// Find and return all target [Device]s based upon currently connected
/// devices and criteria entered by the user on the command line. /// devices and criteria entered by the user on the command line.
......
...@@ -81,15 +81,20 @@ class Usage { ...@@ -81,15 +81,20 @@ class Usage {
_analytics.sendEvent(category, parameter); _analytics.sendEvent(category, parameter);
} }
void sendTiming(String category, String variableName, Duration duration) { void sendTiming(
_analytics.sendTiming(variableName, duration.inMilliseconds, category: category); String category,
} String variableName,
Duration duration, {
UsageTimer startTimer(String event) { String label,
if (suppressAnalytics) }) {
return new _MockUsageTimer(); if (!suppressAnalytics) {
else _analytics.sendTiming(
return new UsageTimer._(event, _analytics.startTimer(event, category: 'flutter')); variableName,
duration.inMilliseconds,
category: category,
label: label,
);
}
} }
void sendException(dynamic exception, StackTrace trace) { void sendException(dynamic exception, StackTrace trace) {
...@@ -138,24 +143,3 @@ class Usage { ...@@ -138,24 +143,3 @@ class Usage {
''', emphasis: true); ''', emphasis: true);
} }
} }
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() { }
}
...@@ -9,6 +9,8 @@ import 'package:flutter_tools/src/commands/create.dart'; ...@@ -9,6 +9,8 @@ import 'package:flutter_tools/src/commands/create.dart';
import 'package:flutter_tools/src/commands/config.dart'; import 'package:flutter_tools/src/commands/config.dart';
import 'package:flutter_tools/src/commands/doctor.dart'; import 'package:flutter_tools/src/commands/doctor.dart';
import 'package:flutter_tools/src/usage.dart'; import 'package:flutter_tools/src/usage.dart';
import 'package:mockito/mockito.dart';
import 'package:quiver/time.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
import 'src/common.dart'; import 'src/common.dart';
...@@ -75,6 +77,38 @@ void main() { ...@@ -75,6 +77,38 @@ void main() {
}); });
}); });
group('analytics with mocks', () {
Usage mockUsage;
Clock mockClock;
List<int> mockTimes;
setUp(() {
mockUsage = new MockUsage();
when(mockUsage.isFirstRun).thenReturn(false);
mockClock = new MockClock();
when(mockClock.now()).thenAnswer(
(Invocation _) => new DateTime.fromMillisecondsSinceEpoch(mockTimes.removeAt(0))
);
});
testUsingContext('flutter commands send timing events', () async {
mockTimes = <int>[1000, 2000];
final DoctorCommand command = new DoctorCommand();
final CommandRunner<Null> runner = createTestCommandRunner(command);
await runner.run(<String>['doctor']);
verify(mockClock.now()).called(2);
expect(
verify(mockUsage.sendTiming(captureAny, captureAny, captureAny, label: captureAny)).captured,
<dynamic>['flutter', 'doctor', const Duration(milliseconds: 1000), null]
);
}, overrides: <Type, Generator>{
Clock: () => mockClock,
Usage: () => mockUsage,
});
});
group('analytics bots', () { group('analytics bots', () {
testUsingContext('don\'t send on bots', () async { testUsingContext('don\'t send on bots', () async {
int count = 0; int count = 0;
...@@ -90,3 +124,5 @@ void main() { ...@@ -90,3 +124,5 @@ void main() {
}); });
}); });
} }
class MockUsage extends Mock implements Usage {}
...@@ -23,6 +23,7 @@ import 'package:flutter_tools/src/usage.dart'; ...@@ -23,6 +23,7 @@ import 'package:flutter_tools/src/usage.dart';
import 'package:flutter_tools/src/version.dart'; import 'package:flutter_tools/src/version.dart';
import 'package:mockito/mockito.dart'; import 'package:mockito/mockito.dart';
import 'package:process/process.dart'; import 'package:process/process.dart';
import 'package:quiver/time.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
import 'common.dart'; import 'common.dart';
...@@ -55,7 +56,8 @@ void _defaultInitializeContext(AppContext testContext) { ...@@ -55,7 +56,8 @@ void _defaultInitializeContext(AppContext testContext) {
}) })
..putIfAbsent(SimControl, () => new MockSimControl()) ..putIfAbsent(SimControl, () => new MockSimControl())
..putIfAbsent(Usage, () => new MockUsage()) ..putIfAbsent(Usage, () => new MockUsage())
..putIfAbsent(FlutterVersion, () => new MockFlutterVersion()); ..putIfAbsent(FlutterVersion, () => new MockFlutterVersion())
..putIfAbsent(Clock, () => const Clock());
} }
void testUsingContext(String description, dynamic testMethod(), { void testUsingContext(String description, dynamic testMethod(), {
...@@ -220,10 +222,7 @@ class MockUsage implements Usage { ...@@ -220,10 +222,7 @@ class MockUsage implements Usage {
void sendEvent(String category, String parameter) { } void sendEvent(String category, String parameter) { }
@override @override
void sendTiming(String category, String variableName, Duration duration) { } void sendTiming(String category, String variableName, Duration duration, { String label }) { }
@override
UsageTimer startTimer(String event) => new _MockUsageTimer(event);
@override @override
void sendException(dynamic exception, StackTrace trace) { } void sendException(dynamic exception, StackTrace trace) { }
...@@ -238,14 +237,6 @@ class MockUsage implements Usage { ...@@ -238,14 +237,6 @@ class MockUsage implements Usage {
void printWelcome() { } void printWelcome() { }
} }
class _MockUsageTimer implements UsageTimer {
_MockUsageTimer(this.event);
@override
final String event;
@override
void finish() { }
}
class MockFlutterVersion extends Mock implements FlutterVersion {} class MockFlutterVersion extends Mock implements FlutterVersion {}
class MockClock extends Mock implements Clock {}
...@@ -5,8 +5,10 @@ ...@@ -5,8 +5,10 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/cache.dart';
import 'package:flutter_tools/src/usage.dart';
import 'package:flutter_tools/src/runner/flutter_command.dart'; import 'package:flutter_tools/src/runner/flutter_command.dart';
import 'package:mockito/mockito.dart'; import 'package:mockito/mockito.dart';
import 'package:quiver/time.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
import '../context.dart'; import '../context.dart';
...@@ -16,9 +18,18 @@ void main() { ...@@ -16,9 +18,18 @@ void main() {
group('Flutter Command', () { group('Flutter Command', () {
MockCache cache; MockCache cache;
MockClock clock;
MockUsage usage;
List<int> mockTimes;
setUp(() { setUp(() {
cache = new MockCache(); cache = new MockCache();
clock = new MockClock();
usage = new MockUsage();
when(usage.isFirstRun).thenReturn(false);
when(clock.now()).thenAnswer(
(Invocation _) => new DateTime.fromMillisecondsSinceEpoch(mockTimes.removeAt(0))
);
}); });
testUsingContext('honors shouldUpdateCache false', () async { testUsingContext('honors shouldUpdateCache false', () async {
...@@ -38,12 +49,84 @@ void main() { ...@@ -38,12 +49,84 @@ void main() {
overrides: <Type, Generator>{ overrides: <Type, Generator>{
Cache: () => cache, Cache: () => cache,
}); });
testUsingContext('report execution timing by default', () async {
// Crash if called a third time which is unexpected.
mockTimes = <int>[1000, 2000];
final DummyFlutterCommand flutterCommand = new DummyFlutterCommand();
await flutterCommand.run();
verify(clock.now()).called(2);
expect(
verify(usage.sendTiming(captureAny, captureAny, captureAny, label: captureAny)).captured,
<dynamic>['flutter', 'dummy', const Duration(milliseconds: 1000), null]
);
},
overrides: <Type, Generator>{
Clock: () => clock,
Usage: () => usage,
});
testUsingContext('no timing report without usagePath', () async {
// Crash if called a third time which is unexpected.
mockTimes = <int>[1000, 2000];
final DummyFlutterCommand flutterCommand =
new DummyFlutterCommand(noUsagePath: true);
await flutterCommand.run();
verify(clock.now()).called(2);
verifyNever(usage.sendTiming(captureAny, captureAny, captureAny, label: captureAny));
},
overrides: <Type, Generator>{
Clock: () => clock,
Usage: () => usage,
});
testUsingContext('report additional FlutterCommandResult data', () async {
// Crash if called a third time which is unexpected.
mockTimes = <int>[1000, 2000];
final FlutterCommandResult commandResult = new FlutterCommandResult(
ExitStatus.fail,
// nulls should be cleaned up.
analyticsParameters: <String> ['blah1', 'blah2', null, 'blah3'],
endTimeOverride: new DateTime.fromMillisecondsSinceEpoch(1500)
);
final DummyFlutterCommand flutterCommand =
new DummyFlutterCommand(flutterCommandResult: commandResult);
await flutterCommand.run();
verify(clock.now()).called(2);
expect(
verify(usage.sendTiming(captureAny, captureAny, captureAny, label: captureAny)).captured,
<dynamic>[
'flutter',
'dummy',
const Duration(milliseconds: 500), // FlutterCommandResult's end time used instead.
'fail-blah1-blah2-blah3',
],
);
},
overrides: <Type, Generator>{
Clock: () => clock,
Usage: () => usage,
});
}); });
} }
class DummyFlutterCommand extends FlutterCommand { class DummyFlutterCommand extends FlutterCommand {
DummyFlutterCommand({this.shouldUpdateCache}); DummyFlutterCommand({
this.shouldUpdateCache : false,
this.noUsagePath : false,
this.flutterCommandResult
});
final bool noUsagePath;
final FlutterCommandResult flutterCommandResult;
@override @override
final bool shouldUpdateCache; final bool shouldUpdateCache;
...@@ -51,13 +134,18 @@ class DummyFlutterCommand extends FlutterCommand { ...@@ -51,13 +134,18 @@ class DummyFlutterCommand extends FlutterCommand {
@override @override
String get description => 'does nothing'; String get description => 'does nothing';
@override
Future<String> get usagePath => noUsagePath ? null : super.usagePath;
@override @override
String get name => 'dummy'; String get name => 'dummy';
@override @override
Future<Null> runCommand() async { Future<FlutterCommandResult> runCommand() async {
// does nothing. return flutterCommandResult;
} }
} }
class MockCache extends Mock implements Cache {} class MockCache extends Mock implements Cache {}
\ No newline at end of file
class MockUsage extends Mock implements Usage {}
\ No newline at end of file
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