[dartdev] Reimplement the dartdev analytics with events and custom dimensions, matching the flutter cli analytics patterns where possible

Bug: https://github.com/dart-lang/sdk/issues/43198
Change-Id: Iff3ac619965142c10864559ff396b64130f18daf
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/161200
Reviewed-by: Devon Carew <devoncarew@google.com>
Commit-Queue: Jaime Wren <jwren@google.com>
diff --git a/pkg/dartdev/lib/dartdev.dart b/pkg/dartdev/lib/dartdev.dart
index d60260f..41f0901 100644
--- a/pkg/dartdev/lib/dartdev.dart
+++ b/pkg/dartdev/lib/dartdev.dart
@@ -22,6 +22,7 @@
 import 'src/commands/run.dart';
 import 'src/commands/test.dart';
 import 'src/core.dart';
+import 'src/events.dart';
 import 'src/experiments.dart';
 import 'src/sdk.dart';
 import 'src/utils.dart';
@@ -35,10 +36,6 @@
   final stopwatch = Stopwatch();
   int result;
 
-  // The Analytics instance used to report information back to Google Analytics,
-  // see lib/src/analytics.dart.
-  Analytics analytics;
-
   // The exit code for the dartdev process, null indicates that it has not yet
   // been set yet. The value is set in the catch and finally blocks below.
   int exitCode;
@@ -47,7 +44,9 @@
   Object exception;
   StackTrace stackTrace;
 
-  analytics =
+  // The Analytics instance used to report information back to Google Analytics,
+  // see lib/src/analytics.dart.
+  Analytics analytics =
       createAnalyticsInstance(args.contains('--disable-dartdev-analytics'));
 
   // If we have not printed the analyticsNoticeOnFirstRunMessage to stdout,
@@ -92,11 +91,13 @@
     // RunCommand.ddsHost = ddsUrl[0];
     // RunCommand.ddsPort = ddsUrl[1];
   }
+
   String commandName;
 
   try {
     stopwatch.start();
     final runner = DartdevRunner(args);
+
     // Run can't be called with the '--disable-dartdev-analytics' flag, remove
     // it if it is contained in args.
     if (args.contains('--disable-dartdev-analytics')) {
@@ -119,14 +120,6 @@
         )
         .toList();
 
-    // Before calling to run, send the first ping to analytics to have the first
-    // ping, as well as the command itself, running in parallel.
-    if (analytics.enabled) {
-      commandName = getCommandStr(args, runner.commands.keys.toList());
-      // ignore: unawaited_futures
-      analytics.sendEvent(eventCategory, commandName);
-    }
-
     // If ... help pub ... is in the args list, remove 'help', and add '--help'
     // to the end of the list.  This will make it possible to use the help
     // command to access subcommands of pub such as `dart help pub publish`, see
@@ -135,6 +128,17 @@
       args = PubUtils.modifyArgs(args);
     }
 
+    // For the commands format and migrate, dartdev itself sends the
+    // sendScreenView notification to analytics, for all other
+    // dartdev commands (instances of DartdevCommand) the commands send this
+    // to analytics.
+    commandName = ArgParserUtils.getCommandStr(args);
+    if (analytics.enabled &&
+        (commandName == formatCmdName || commandName == migrateCmdName)) {
+      // ignore: unawaited_futures
+      analytics.sendScreenView(commandName);
+    }
+
     // Finally, call the runner to execute the command, see DartdevRunner.
     result = await runner.run(args);
   } catch (e, st) {
@@ -157,7 +161,22 @@
 
     // Send analytics before exiting
     if (analytics.enabled) {
-      analytics.setSessionValue(exitCodeParam, exitCode);
+      // For commands that are not DartdevCommand instances, we manually create
+      // and send the UsageEvent from here:
+      if (commandName == formatCmdName) {
+        // ignore: unawaited_futures
+        FormatUsageEvent(
+          exitCode: exitCode,
+          args: args,
+        ).send(analyticsInstance);
+      } else if (commandName == migrateCmdName) {
+        // ignore: unawaited_futures
+        MigrateUsageEvent(
+          exitCode: exitCode,
+          args: args,
+        ).send(analyticsInstance);
+      }
+
       // ignore: unawaited_futures
       analytics.sendTiming(commandName, stopwatch.elapsedMilliseconds,
           category: 'commands');
diff --git a/pkg/dartdev/lib/src/analytics.dart b/pkg/dartdev/lib/src/analytics.dart
index 5a1037a..595fec0 100644
--- a/pkg/dartdev/lib/src/analytics.dart
+++ b/pkg/dartdev/lib/src/analytics.dart
@@ -26,7 +26,6 @@
   ║ `dart --enable-analytics`                                                  ║
   ╚════════════════════════════════════════════════════════════════════════════╝
 ''';
-const String _unknownCommand = '<unknown>';
 const String _appName = 'dartdev';
 const String _dartDirectoryName = '.dart';
 const String _settingsFileName = 'dartdev.json';
@@ -40,13 +39,15 @@
 const String eventCategory = 'dartdev';
 const String exitCodeParam = 'exitCode';
 
-Analytics instance;
+Analytics _instance;
+
+Analytics get analyticsInstance => _instance;
 
 /// Create and return an [Analytics] instance, this value is cached and returned
 /// on subsequent calls.
 Analytics createAnalyticsInstance(bool disableAnalytics) {
-  if (instance != null) {
-    return instance;
+  if (_instance != null) {
+    return _instance;
   }
 
   // Dartdev tests pass a hidden 'disable-dartdev-analytics' flag which is
@@ -54,16 +55,16 @@
   // Also, stdout.hasTerminal is checked, if there is no terminal we infer that
   // a machine is running dartdev so we return analytics shouldn't be set.
   if (disableAnalytics) {
-    instance = DisabledAnalytics(_trackingId, _appName);
-    return instance;
+    _instance = DisabledAnalytics(_trackingId, _appName);
+    return _instance;
   }
 
   var settingsDir = getDartStorageDirectory();
   if (settingsDir == null) {
     // Some systems don't support user home directories; for those, fail
     // gracefully by returning a disabled analytics object.
-    instance = DisabledAnalytics(_trackingId, _appName);
-    return instance;
+    _instance = DisabledAnalytics(_trackingId, _appName);
+    return _instance;
   }
 
   if (!settingsDir.existsSync()) {
@@ -72,8 +73,8 @@
     } catch (e) {
       // If we can't create the directory for the analytics settings, fail
       // gracefully by returning a disabled analytics object.
-      instance = DisabledAnalytics(_trackingId, _appName);
-      return instance;
+      _instance = DisabledAnalytics(_trackingId, _appName);
+      return _instance;
     }
   }
 
@@ -85,22 +86,8 @@
   }
 
   var settingsFile = File(path.join(settingsDir.path, _settingsFileName));
-  instance = DartdevAnalytics(_trackingId, settingsFile, _appName);
-  return instance;
-}
-
-/// Return the first member from [args] that occurs in [allCommands], otherwise
-/// '<unknown>' is returned.
-///
-/// 'help' is special cased to have 'dart analyze help', 'dart help analyze',
-/// and 'dart analyze --help' all be recorded as a call to 'help' instead of
-/// 'help' and 'analyze'.
-String getCommandStr(List<String> args, List<String> allCommands) {
-  if (args.contains('help') || args.contains('-h') || args.contains('--help')) {
-    return 'help';
-  }
-  return args.firstWhere((arg) => allCommands.contains(arg),
-      orElse: () => _unknownCommand);
+  _instance = DartdevAnalytics(_trackingId, settingsFile, _appName);
+  return _instance;
 }
 
 /// The directory used to store the analytics settings file.
diff --git a/pkg/dartdev/lib/src/commands/analyze.dart b/pkg/dartdev/lib/src/commands/analyze.dart
index 139d262..08d8590 100644
--- a/pkg/dartdev/lib/src/commands/analyze.dart
+++ b/pkg/dartdev/lib/src/commands/analyze.dart
@@ -5,14 +5,18 @@
 import 'dart:async';
 import 'dart:io';
 
+import 'package:meta/meta.dart';
 import 'package:path/path.dart' as path;
 
 import '../core.dart';
+import '../events.dart';
 import '../sdk.dart';
 import '../utils.dart';
 import 'analyze_impl.dart';
 
 class AnalyzeCommand extends DartdevCommand<int> {
+  static const String cmdName = 'analyze';
+
   /// The maximum length of any of the existing severity labels.
   static const int _severityWidth = 7;
 
@@ -20,7 +24,7 @@
   /// message. The width left for the severity label plus the separator width.
   static const int _bodyIndentWidth = _severityWidth + 3;
 
-  AnalyzeCommand() : super('analyze', "Analyze the project's Dart code.") {
+  AnalyzeCommand() : super(cmdName, "Analyze the project's Dart code.") {
     argParser
       ..addFlag('fatal-infos',
           help: 'Treat info level issues as fatal.', negatable: false)
@@ -32,7 +36,7 @@
   String get invocation => '${super.invocation} [<directory>]';
 
   @override
-  FutureOr<int> run() async {
+  FutureOr<int> runImpl() async {
     if (argResults.rest.length > 1) {
       usageException('Only one directory is expected.');
     }
@@ -153,4 +157,19 @@
       return 0;
     }
   }
+
+  @override
+  UsageEvent createUsageEvent(int exitCode) => AnalyzeUsageEvent(
+        usagePath,
+        exitCode: exitCode,
+        args: argResults.arguments,
+      );
+}
+
+/// The [UsageEvent] for the analyze command.
+class AnalyzeUsageEvent extends UsageEvent {
+  AnalyzeUsageEvent(String usagePath,
+      {String label, @required int exitCode, @required List<String> args})
+      : super(AnalyzeCommand.cmdName, usagePath,
+            label: label, args: args, exitCode: exitCode);
 }
diff --git a/pkg/dartdev/lib/src/commands/compile.dart b/pkg/dartdev/lib/src/commands/compile.dart
index 5da7168..491d7ae 100644
--- a/pkg/dartdev/lib/src/commands/compile.dart
+++ b/pkg/dartdev/lib/src/commands/compile.dart
@@ -6,9 +6,11 @@
 import 'dart:io';
 
 import 'package:dart2native/generate.dart';
+import 'package:meta/meta.dart';
 import 'package:path/path.dart' as path;
 
 import '../core.dart';
+import '../events.dart';
 import '../sdk.dart';
 import '../vm_interop_handler.dart';
 
@@ -39,12 +41,13 @@
     stderr.flush();
     return false;
   }
-
   return true;
 }
 
-class CompileJSCommand extends DartdevCommand<int> {
-  CompileJSCommand() : super('js', 'Compile Dart to JavaScript.') {
+class CompileJSCommand extends CompileSubcommandCommand {
+  static const String cmdName = 'js';
+
+  CompileJSCommand() : super(cmdName, 'Compile Dart to JavaScript.') {
     argParser
       ..addOption(
         commonOptions['outputFile'].flag,
@@ -63,7 +66,7 @@
   String get invocation => '${super.invocation} <dart entry point>';
 
   @override
-  FutureOr<int> run() async {
+  FutureOr<int> runImpl() async {
     if (!Sdk.checkArtifactExists(sdk.dart2jsSnapshot)) {
       return 255;
     }
@@ -97,7 +100,10 @@
   }
 }
 
-class CompileSnapshotCommand extends DartdevCommand<int> {
+class CompileSnapshotCommand extends CompileSubcommandCommand {
+  static const String jitSnapshotCmdName = 'jit-snapshot';
+  static const String kernelCmdName = 'kernel';
+
   final String commandName;
   final String help;
   final String fileExt;
@@ -121,7 +127,7 @@
   String get invocation => '${super.invocation} <dart entry point>';
 
   @override
-  FutureOr<int> run() async {
+  FutureOr<int> runImpl() async {
     // We expect a single rest argument; the dart entry point.
     if (argResults.rest.length != 1) {
       // This throws.
@@ -157,7 +163,10 @@
   }
 }
 
-class CompileNativeCommand extends DartdevCommand<int> {
+class CompileNativeCommand extends CompileSubcommandCommand {
+  static const String exeCmdName = 'exe';
+  static const String aotSnapshotCmdName = 'aot-snapshot';
+
   final String commandName;
   final String format;
   final String help;
@@ -194,7 +203,7 @@
   String get invocation => '${super.invocation} <dart entry point>';
 
   @override
-  FutureOr<int> run() async {
+  FutureOr<int> runImpl() async {
     if (!Sdk.checkArtifactExists(genKernel) ||
         !Sdk.checkArtifactExists(genSnapshot)) {
       return 255;
@@ -230,30 +239,64 @@
   }
 }
 
-class CompileCommand extends DartdevCommand {
-  CompileCommand() : super('compile', 'Compile Dart to various formats.') {
+abstract class CompileSubcommandCommand extends DartdevCommand<int> {
+  CompileSubcommandCommand(String name, String description,
+      {bool hidden = false})
+      : super(name, description, hidden: hidden);
+
+  @override
+  UsageEvent createUsageEvent(int exitCode) => CompileUsageEvent(
+        usagePath,
+        exitCode: exitCode,
+        args: argResults.arguments,
+      );
+}
+
+class CompileCommand extends DartdevCommand<int> {
+  static const String cmdName = 'compile';
+
+  CompileCommand() : super(cmdName, 'Compile Dart to various formats.') {
     addSubcommand(CompileJSCommand());
     addSubcommand(CompileSnapshotCommand(
-      commandName: 'jit-snapshot',
+      commandName: CompileSnapshotCommand.jitSnapshotCmdName,
       help: 'to a JIT snapshot.',
       fileExt: 'jit',
       formatName: 'app-jit',
     ));
     addSubcommand(CompileSnapshotCommand(
-      commandName: 'kernel',
+      commandName: CompileSnapshotCommand.kernelCmdName,
       help: 'to a kernel snapshot.',
       fileExt: 'dill',
       formatName: 'kernel',
     ));
     addSubcommand(CompileNativeCommand(
-      commandName: 'exe',
+      commandName: CompileNativeCommand.exeCmdName,
       help: 'to a self-contained executable.',
       format: 'exe',
     ));
     addSubcommand(CompileNativeCommand(
-      commandName: 'aot-snapshot',
+      commandName: CompileNativeCommand.aotSnapshotCmdName,
       help: 'to an AOT snapshot.',
       format: 'aot',
     ));
   }
+
+  @override
+  UsageEvent createUsageEvent(int exitCode) => null;
+
+  @override
+  FutureOr<int> runImpl() {
+    // do nothing, this command is never run
+    return 0;
+  }
+}
+
+/// The [UsageEvent] for all compile commands, we could have each compile
+/// event be its own class instance, but for the time being [usagePath] takes
+/// care of the only difference.
+class CompileUsageEvent extends UsageEvent {
+  CompileUsageEvent(String usagePath,
+      {String label, @required int exitCode, @required List<String> args})
+      : super(CompileCommand.cmdName, usagePath,
+            label: label, exitCode: exitCode, args: args);
 }
diff --git a/pkg/dartdev/lib/src/commands/create.dart b/pkg/dartdev/lib/src/commands/create.dart
index 4eda721..920de13 100644
--- a/pkg/dartdev/lib/src/commands/create.dart
+++ b/pkg/dartdev/lib/src/commands/create.dart
@@ -7,14 +7,18 @@
 import 'dart:io' as io;
 import 'dart:math' as math;
 
+import 'package:meta/meta.dart';
 import 'package:path/path.dart' as p;
 import 'package:stagehand/stagehand.dart' as stagehand;
 
 import '../core.dart';
+import '../events.dart';
 import '../sdk.dart';
 
 /// A command to create a new project from a set of templates.
-class CreateCommand extends DartdevCommand {
+class CreateCommand extends DartdevCommand<int> {
+  static const String cmdName = 'create';
+
   static String defaultTemplateId = 'console-simple';
 
   static List<String> legalTemplateIds = [
@@ -31,7 +35,7 @@
       stagehand.getGenerator(templateId);
 
   CreateCommand({bool verbose = false})
-      : super('create', 'Create a new project.') {
+      : super(cmdName, 'Create a new project.') {
     argParser.addOption(
       'template',
       allowed: legalTemplateIds,
@@ -60,7 +64,7 @@
   String get invocation => '${super.invocation} <directory>';
 
   @override
-  FutureOr<int> run() async {
+  FutureOr<int> runImpl() async {
     if (argResults['list-templates']) {
       log.stdout(_availableTemplatesJson());
       return 0;
@@ -139,6 +143,13 @@
   }
 
   @override
+  UsageEvent createUsageEvent(int exitCode) => CreateUsageEvent(
+        usagePath,
+        exitCode: exitCode,
+        args: argResults.arguments,
+      );
+
+  @override
   String get usageFooter {
     int width = legalTemplateIds.map((s) => s.length).reduce(math.max);
     String desc = generators.map((g) {
@@ -169,6 +180,14 @@
   }
 }
 
+/// The [UsageEvent] for the create command.
+class CreateUsageEvent extends UsageEvent {
+  CreateUsageEvent(String usagePath,
+      {String label, @required int exitCode, @required List<String> args})
+      : super(CreateCommand.cmdName, usagePath,
+            label: label, exitCode: exitCode, args: args);
+}
+
 class DirectoryGeneratorTarget extends stagehand.GeneratorTarget {
   final stagehand.Generator generator;
   final io.Directory dir;
diff --git a/pkg/dartdev/lib/src/commands/fix.dart b/pkg/dartdev/lib/src/commands/fix.dart
index 36a8579..338158e 100644
--- a/pkg/dartdev/lib/src/commands/fix.dart
+++ b/pkg/dartdev/lib/src/commands/fix.dart
@@ -6,19 +6,23 @@
 import 'dart:io';
 
 import 'package:analysis_server_client/protocol.dart' hide AnalysisError;
+import 'package:meta/meta.dart';
 import 'package:path/path.dart' as path;
 
 import '../core.dart';
+import '../events.dart';
 import '../sdk.dart';
 import '../utils.dart';
 import 'analyze_impl.dart';
 
-class FixCommand extends DartdevCommand {
+class FixCommand extends DartdevCommand<int> {
+  static const String cmdName = 'fix';
+
   // This command is hidden as its currently experimental.
-  FixCommand() : super('fix', 'Fix Dart source code.', hidden: true);
+  FixCommand() : super(cmdName, 'Fix Dart source code.', hidden: true);
 
   @override
-  FutureOr<int> run() async {
+  FutureOr<int> runImpl() async {
     log.stdout('\n*** The `fix` command is provisional and subject to change '
         'or removal in future releases. ***\n');
 
@@ -76,7 +80,21 @@
       }
       log.stdout('Done.');
     }
-
     return 0;
   }
+
+  @override
+  UsageEvent createUsageEvent(int exitCode) => FixUsageEvent(
+        usagePath,
+        exitCode: exitCode,
+        args: argResults.arguments,
+      );
+}
+
+/// The [UsageEvent] for the fix command.
+class FixUsageEvent extends UsageEvent {
+  FixUsageEvent(String usagePath,
+      {String label, @required int exitCode, @required List<String> args})
+      : super(FixCommand.cmdName, usagePath,
+            label: label, exitCode: exitCode, args: args);
 }
diff --git a/pkg/dartdev/lib/src/commands/pub.dart b/pkg/dartdev/lib/src/commands/pub.dart
index 6b40ebc..c30bc61 100644
--- a/pkg/dartdev/lib/src/commands/pub.dart
+++ b/pkg/dartdev/lib/src/commands/pub.dart
@@ -5,14 +5,37 @@
 import 'dart:async';
 
 import 'package:args/args.dart';
+import 'package:meta/meta.dart';
 
 import '../core.dart';
+import '../events.dart';
 import '../experiments.dart';
 import '../sdk.dart';
 import '../vm_interop_handler.dart';
 
 class PubCommand extends DartdevCommand<int> {
-  PubCommand() : super('pub', 'Work with packages.');
+  static const String cmdName = 'pub';
+
+  PubCommand() : super(cmdName, 'Work with packages.');
+
+  // TODO(jwren) as soon as pub commands are are implemented directly in
+  //  dartdev, remove this static list.
+  /// A list of all subcommands, used only for the implementation of
+  /// [usagePath], see below.
+  static List<String> pubSubcommands = [
+    'cache',
+    'deps',
+    'downgrade',
+    'get',
+    'global',
+    'logout',
+    'outdated',
+    'publish',
+    'run',
+    'upgrade',
+    'uploader',
+    'version',
+  ];
 
   @override
   ArgParser createArgParser() => ArgParser.allowAnything();
@@ -34,8 +57,26 @@
     VmInteropHandler.run(command, args);
   }
 
+  /// Since the pub subcommands are not subclasses of DartdevCommand, we
+  /// override [usagePath] here as a special case to cover the first subcommand
+  /// under pub, i.e. we will have the path pub/cache
   @override
-  FutureOr<int> run() async {
+  String get usagePath {
+    if (argResults == null) {
+      return name;
+    }
+    var args = argResults.arguments;
+    var cmdIndex = args.indexOf(name) ?? 0;
+    for (int i = cmdIndex + 1; i < args.length; i++) {
+      if (pubSubcommands.contains(args[i])) {
+        return '$name/${args[i]}';
+      }
+    }
+    return name;
+  }
+
+  @override
+  FutureOr<int> runImpl() async {
     if (!Sdk.checkArtifactExists(sdk.pubSnapshot)) {
       return 255;
     }
@@ -65,4 +106,26 @@
     VmInteropHandler.run(command, args);
     return 0;
   }
+
+  @override
+  UsageEvent createUsageEvent(int exitCode) => PubUsageEvent(
+        usagePath,
+        exitCode: exitCode,
+        specifiedExperiments: specifiedExperiments,
+        args: argResults.arguments,
+      );
+}
+
+/// The [UsageEvent] for the pub command.
+class PubUsageEvent extends UsageEvent {
+  PubUsageEvent(String usagePath,
+      {String label,
+      @required int exitCode,
+      @required List<String> specifiedExperiments,
+      @required List<String> args})
+      : super(PubCommand.cmdName, usagePath,
+            label: label,
+            exitCode: exitCode,
+            specifiedExperiments: specifiedExperiments,
+            args: args);
 }
diff --git a/pkg/dartdev/lib/src/commands/run.dart b/pkg/dartdev/lib/src/commands/run.dart
index feeb99c..9fa39e6 100644
--- a/pkg/dartdev/lib/src/commands/run.dart
+++ b/pkg/dartdev/lib/src/commands/run.dart
@@ -8,15 +8,19 @@
 import 'dart:io';
 
 import 'package:args/args.dart';
+import 'package:meta/meta.dart';
 import 'package:path/path.dart';
 
 import '../core.dart';
+import '../events.dart';
 import '../experiments.dart';
 import '../sdk.dart';
 import '../utils.dart';
 import '../vm_interop_handler.dart';
 
 class RunCommand extends DartdevCommand<int> {
+  static const String cmdName = 'run';
+
   static bool launchDds = false;
   static String ddsHost;
   static String ddsPort;
@@ -41,7 +45,7 @@
 
   RunCommand({this.verbose = false})
       : super(
-          'run',
+          cmdName,
           'Run a Dart program.',
         ) {
     // NOTE: When updating this list of flags, be sure to add any VM flags to
@@ -153,7 +157,7 @@
   String get invocation => '${super.invocation} <dart file | package target>';
 
   @override
-  FutureOr<int> run() async {
+  FutureOr<int> runImpl() async {
     // The command line arguments after 'run'
     var args = argResults.arguments.toList();
 
@@ -236,6 +240,14 @@
     VmInteropHandler.run(path, runArgs);
     return 0;
   }
+
+  @override
+  UsageEvent createUsageEvent(int exitCode) => RunUsageEvent(
+        usagePath,
+        exitCode: exitCode,
+        specifiedExperiments: specifiedExperiments,
+        args: argResults.arguments,
+      );
 }
 
 class _DebuggingSession {
@@ -282,3 +294,17 @@
     }
   }
 }
+
+/// The [UsageEvent] for the run command.
+class RunUsageEvent extends UsageEvent {
+  RunUsageEvent(String usagePath,
+      {String label,
+      @required int exitCode,
+      @required List<String> specifiedExperiments,
+      @required List<String> args})
+      : super(RunCommand.cmdName, usagePath,
+            label: label,
+            exitCode: exitCode,
+            specifiedExperiments: specifiedExperiments,
+            args: args);
+}
diff --git a/pkg/dartdev/lib/src/commands/test.dart b/pkg/dartdev/lib/src/commands/test.dart
index dcb5a7f..beba8c8 100644
--- a/pkg/dartdev/lib/src/commands/test.dart
+++ b/pkg/dartdev/lib/src/commands/test.dart
@@ -5,8 +5,10 @@
 import 'dart:async';
 
 import 'package:args/args.dart';
+import 'package:meta/meta.dart';
 
 import '../core.dart';
+import '../events.dart';
 import '../experiments.dart';
 import '../sdk.dart';
 import '../vm_interop_handler.dart';
@@ -15,7 +17,9 @@
 ///
 /// This command largely delegates to `pub run test`.
 class TestCommand extends DartdevCommand<int> {
-  TestCommand() : super('test', 'Run tests in this package.');
+  static const String cmdName = 'test';
+
+  TestCommand() : super(cmdName, 'Run tests in this package.');
 
   @override
   final ArgParser argParser = ArgParser.allowAnything();
@@ -26,7 +30,7 @@
   }
 
   @override
-  FutureOr<int> run() async {
+  FutureOr<int> runImpl() async {
     return _runImpl(argResults.arguments.toList());
   }
 
@@ -70,6 +74,14 @@
     return 0;
   }
 
+  @override
+  UsageEvent createUsageEvent(int exitCode) => TestUsageEvent(
+        usagePath,
+        exitCode: exitCode,
+        specifiedExperiments: specifiedExperiments,
+        args: argResults.arguments,
+      );
+
   void _printNoPubspecMessage(bool wasHelpCommand) {
     log.stdout('''
 No pubspec.yaml file found; please run this command from the root of your project.
@@ -118,6 +130,20 @@
   }
 }
 
+/// The [UsageEvent] for the test command.
+class TestUsageEvent extends UsageEvent {
+  TestUsageEvent(String usagePath,
+      {String label,
+      @required int exitCode,
+      @required List<String> specifiedExperiments,
+      @required List<String> args})
+      : super(TestCommand.cmdName, usagePath,
+            label: label,
+            exitCode: exitCode,
+            specifiedExperiments: specifiedExperiments,
+            args: args);
+}
+
 const String _terseHelp = 'Run tests in this package.';
 
 const String _usageHelp = 'Usage: dart test [files or directories...]';
diff --git a/pkg/dartdev/lib/src/core.dart b/pkg/dartdev/lib/src/core.dart
index cc3a505..5694415 100644
--- a/pkg/dartdev/lib/src/core.dart
+++ b/pkg/dartdev/lib/src/core.dart
@@ -2,6 +2,7 @@
 // for details. 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 'dart:convert';
 import 'dart:io';
 
@@ -10,6 +11,8 @@
 import 'package:cli_util/cli_logging.dart';
 import 'package:path/path.dart' as path;
 
+import 'analytics.dart';
+import 'events.dart';
 import 'experiments.dart';
 import 'sdk.dart';
 import 'utils.dart';
@@ -39,6 +42,55 @@
   @override
   ArgParser get argParser => _argParser ??= createArgParser();
 
+  /// This method should not be overridden by subclasses, instead classes should
+  /// override [runImpl] and [createUsageEvent]. If analytics is enabled by this
+  /// command and the user, a [sendScreenView] is called to analytics, and then
+  /// after the command is run, an event is sent to analytics.
+  ///
+  /// If analytics is not enabled by this command or the user, then [runImpl] is
+  /// called and the exitCode value is returned.
+  @override
+  FutureOr<int> run() async {
+    var path = usagePath;
+    if (path != null &&
+        analyticsInstance != null &&
+        analyticsInstance.enabled) {
+      // Send the screen view to analytics
+      // ignore: unawaited_futures
+      analyticsInstance.sendScreenView(path);
+
+      // Run this command
+      var exitCode = await runImpl();
+
+      // Send the event to analytics
+      // ignore: unawaited_futures
+      createUsageEvent(exitCode)?.send(analyticsInstance);
+
+      // Finally return the exit code
+      return exitCode;
+    } else {
+      // Analytics is not enabled, run the command and return the exit code
+      return runImpl();
+    }
+  }
+
+  UsageEvent createUsageEvent(int exitCode);
+
+  FutureOr<int> runImpl();
+
+  /// The command name path to send to Google Analytics. Return null to disable
+  /// tracking of the command.
+  String get usagePath {
+    if (parent is DartdevCommand) {
+      final commandParent = parent as DartdevCommand;
+      final parentPath = commandParent.usagePath;
+      // Don't report for parents that return null for usagePath.
+      return parentPath == null ? null : '$parentPath/$name';
+    } else {
+      return name;
+    }
+  }
+
   /// Create the ArgParser instance for this command.
   ///
   /// Subclasses can override this in order to create a customized ArgParser.
diff --git a/pkg/dartdev/lib/src/events.dart b/pkg/dartdev/lib/src/events.dart
new file mode 100644
index 0000000..6cc5547
--- /dev/null
+++ b/pkg/dartdev/lib/src/events.dart
@@ -0,0 +1,204 @@
+// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:meta/meta.dart';
+import 'package:usage/usage.dart';
+
+import 'commands/analyze.dart';
+import 'commands/compile.dart';
+import 'commands/create.dart';
+import 'commands/fix.dart';
+import 'commands/pub.dart';
+import 'commands/run.dart';
+
+/// A list of all commands under dartdev.
+const List<String> allCommands = [
+  'help',
+  AnalyzeCommand.cmdName,
+  CreateCommand.cmdName,
+  CompileCommand.cmdName,
+  FixCommand.cmdName,
+  formatCmdName,
+  migrateCmdName,
+  PubCommand.cmdName,
+  RunCommand.cmdName,
+  'test'
+];
+
+/// The [String] identifier `dartdev`, used as the category in the events sent
+/// to analytics.
+const String _dartdev = 'dartdev';
+
+/// The [String] identifier `format`.
+const String formatCmdName = 'format';
+
+/// The [String] identifier `migrate`.
+const String migrateCmdName = 'migrate';
+
+/// The separator used to for joining the flag sets sent to analytics.
+const String _flagSeparator = ',';
+
+/// When some unknown command is used, for instance `dart foo`, the command is
+/// designated with this identifier.
+const String _unknownCommand = '<unknown>';
+
+/// The collection of custom dimensions understood by the analytics backend.
+/// When adding to this list, first ensure that the custom dimension is
+/// defined in the backend, or will be defined shortly after the relevant PR
+/// lands. The pattern here matches the flutter cli.
+enum CustomDimensions {
+  commandExitCode, // cd1
+  enabledExperiments, // cd2
+  commandFlags, // cd3
+}
+
+String cdKey(CustomDimensions cd) => 'cd${cd.index + 1}';
+
+Map<String, String> _useCdKeys(Map<CustomDimensions, String> parameters) {
+  return parameters.map(
+      (CustomDimensions k, String v) => MapEntry<String, String>(cdKey(k), v));
+}
+
+/// Utilities for parsing arguments passed to dartdev.  These utilities have all
+/// been marked as static to assist with testing, see events_test.dart.
+class ArgParserUtils {
+  /// Return the first member from [args] that occurs in [allCommands],
+  /// otherwise '<unknown>' is returned.
+  ///
+  /// 'help' is special cased to have 'dart analyze help', 'dart help analyze',
+  /// and 'dart analyze --help' all be recorded as a call to 'help' instead of
+  /// 'help' and 'analyze'.
+  static String getCommandStr(List<String> args) {
+    if (args.contains('help') ||
+        args.contains('-h') ||
+        args.contains('--help')) {
+      return 'help';
+    }
+    return args.firstWhere((arg) => allCommands.contains(arg),
+        orElse: () => _unknownCommand);
+  }
+
+  /// Return true if the first character of the passed [String] is '-'.
+  static bool isFlag(String arg) => arg != null && arg.startsWith('-');
+
+  /// Returns true if and only if the passed argument equals 'help', '--help' or
+  /// '-h'.
+  static bool isHelp(String arg) =>
+      arg == 'help' || arg == '--help' || arg == '-h';
+
+  /// Given some command in args, return the set of flags after the command.
+  static List<String> parseCommandFlags(String command, List<String> args) {
+    var result = <String>[];
+    if (args == null || args.isEmpty) {
+      return result;
+    }
+
+    var indexOfCmd = args.indexOf(command);
+    if (indexOfCmd < 0) {
+      return result;
+    }
+
+    for (var i = indexOfCmd + 1; i < args.length; i++) {
+      if (!isHelp(args[i]) && isFlag(args[i])) {
+        result.add(sanitizeFlag(args[i]));
+      }
+    }
+    return result;
+  }
+
+  /// Return the passed flag, only if it is considered a flag, see [isFlag], and
+  /// if '=' is in the flag, return only the contents of the left hand side of
+  /// the '='.
+  static String sanitizeFlag(String arg) {
+    if (isFlag(arg)) {
+      if (arg.contains('=')) {
+        return arg.substring(0, arg.indexOf('='));
+      } else {
+        return arg;
+      }
+    }
+    return '';
+  }
+}
+
+/// The [UsageEvent] for the format command.
+class FormatUsageEvent extends UsageEvent {
+  FormatUsageEvent(
+      {String label, @required int exitCode, @required List<String> args})
+      : super(formatCmdName, formatCmdName,
+            label: label, exitCode: exitCode, args: args);
+}
+
+/// The [UsageEvent] for the migrate command.
+class MigrateUsageEvent extends UsageEvent {
+  MigrateUsageEvent(
+      {String label, @required int exitCode, @required List<String> args})
+      : super(migrateCmdName, migrateCmdName,
+            label: label, exitCode: exitCode, args: args);
+}
+
+/// The superclass for all dartdev events, see the [send] method to see what is
+/// sent to analytics.
+abstract class UsageEvent {
+  /// The category stores the name of this cli tool, 'dartdev'. This matches the
+  /// pattern from the flutter cli tool which always passes 'flutter' as the
+  /// category.
+  final String category;
+
+  /// The action is the command, and optionally the subcommand, joined with '/',
+  /// an example here is 'pub/get'. The usagePath getter in each of the
+  final String action;
+
+  /// The command name being executed here, 'analyze' and 'pub' are examples.
+  final String command;
+
+  /// Labels are not used yet used when reporting dartdev analytics, but the API
+  /// is included here for possible future use.
+  final String label;
+
+  /// The [String] list of arguments passed to dartdev, the list of args is not
+  /// passed back via analytics itself, but is used to compute other values such
+  /// as the [enabledExperiments] which are passed back as part of analytics.
+  final List<String> args;
+
+  /// The exit code returned from this invocation of dartdev.
+  final int exitCode;
+
+  /// A comma separated list of enabled experiments passed into the dartdev
+  /// command. If the command doesn't use the experiments, they are not reported
+  /// in the [UsageEvent].
+  final String enabledExperiments;
+
+  /// A comma separated list of flags on this commands
+  final String commandFlags;
+
+  UsageEvent(
+    this.command,
+    this.action, {
+    this.label,
+    List<String> specifiedExperiments,
+    @required this.exitCode,
+    @required this.args,
+  })  : category = _dartdev,
+        enabledExperiments = specifiedExperiments?.join(_flagSeparator),
+        commandFlags = ArgParserUtils.parseCommandFlags(command, args)
+            .join(_flagSeparator);
+
+  Future send(Analytics analytics) {
+    final Map<String, String> parameters =
+        _useCdKeys(<CustomDimensions, String>{
+      if (exitCode != null)
+        CustomDimensions.commandExitCode: exitCode.toString(),
+      if (enabledExperiments != null)
+        CustomDimensions.enabledExperiments: enabledExperiments,
+      if (commandFlags != null) CustomDimensions.commandFlags: commandFlags,
+    });
+    return analytics.sendEvent(
+      category,
+      action,
+      label: label,
+      parameters: parameters,
+    );
+  }
+}
diff --git a/pkg/dartdev/pubspec.yaml b/pkg/dartdev/pubspec.yaml
index be43698..ecc93eb 100644
--- a/pkg/dartdev/pubspec.yaml
+++ b/pkg/dartdev/pubspec.yaml
@@ -15,6 +15,8 @@
   dart2native:
     path: ../dart2native
   dart_style: any
+  meta:
+    path: ../meta
   nnbd_migration:
     path: ../nnbd_migration
   path: ^1.0.0
diff --git a/pkg/dartdev/test/analytics_test.dart b/pkg/dartdev/test/analytics_test.dart
index 3ce90de..31355df7 100644
--- a/pkg/dartdev/test/analytics_test.dart
+++ b/pkg/dartdev/test/analytics_test.dart
@@ -7,7 +7,6 @@
 
 void main() {
   group('DisabledAnalytics', disabledAnalyticsObject);
-  group('utils', utils);
 }
 
 void disabledAnalyticsObject() {
@@ -19,21 +18,3 @@
     expect(diabledAnalytics.firstRun, isFalse);
   });
 }
-
-void utils() {
-  test('getCommandStr', () {
-    var commands = <String>['help', 'foo', 'bar', 'baz'];
-
-    // base cases
-    expect(getCommandStr(['help'], commands), 'help');
-    expect(getCommandStr(['bar', 'help'], commands), 'help');
-    expect(getCommandStr(['help', 'bar'], commands), 'help');
-    expect(getCommandStr(['bar', '-h'], commands), 'help');
-    expect(getCommandStr(['bar', '--help'], commands), 'help');
-
-    // non-trivial tests
-    expect(getCommandStr(['foo'], commands), 'foo');
-    expect(getCommandStr(['bar', 'baz'], commands), 'bar');
-    expect(getCommandStr(['bazz'], commands), '<unknown>');
-  });
-}
diff --git a/pkg/dartdev/test/core_test.dart b/pkg/dartdev/test/core_test.dart
index bc10cb9..0944f88 100644
--- a/pkg/dartdev/test/core_test.dart
+++ b/pkg/dartdev/test/core_test.dart
@@ -5,6 +5,13 @@
 import 'dart:convert';
 import 'dart:io';
 
+import 'package:dartdev/src/commands/analyze.dart';
+import 'package:dartdev/src/commands/compile.dart';
+import 'package:dartdev/src/commands/create.dart';
+import 'package:dartdev/src/commands/fix.dart';
+import 'package:dartdev/src/commands/pub.dart';
+import 'package:dartdev/src/commands/run.dart';
+import 'package:dartdev/src/commands/test.dart';
 import 'package:dartdev/src/core.dart';
 import 'package:path/path.dart' as path;
 import 'package:test/test.dart';
@@ -12,10 +19,82 @@
 import 'utils.dart';
 
 void main() {
+  group('DartdevCommand', _dartdevCommand);
   group('PackageConfig', _packageConfig);
   group('Project', _project);
 }
 
+void _dartdevCommand() {
+  void _assertDartdevCommandProperties(
+      DartdevCommand command, String name, String usagePath,
+      [int subcommandCount = 0]) {
+    expect(command, isNotNull);
+    expect(command.name, name);
+    expect(command.description, isNotEmpty);
+    expect(command.project, isNotNull);
+    expect(command.argParser, isNotNull);
+    expect(command.usagePath, usagePath);
+    expect(command.subcommands.length, subcommandCount);
+  }
+
+  test('analyze', () {
+    _assertDartdevCommandProperties(AnalyzeCommand(), 'analyze', 'analyze');
+  });
+
+  test('compile', () {
+    _assertDartdevCommandProperties(CompileCommand(), 'compile', 'compile', 5);
+  });
+
+  test('compile/js', () {
+    _assertDartdevCommandProperties(
+        CompileCommand().subcommands['js'], 'js', 'compile/js');
+  });
+
+  test('compile/jit-snapshot', () {
+    _assertDartdevCommandProperties(
+        CompileCommand().subcommands['jit-snapshot'],
+        'jit-snapshot',
+        'compile/jit-snapshot');
+  });
+
+  test('compile/kernel', () {
+    _assertDartdevCommandProperties(
+        CompileCommand().subcommands['kernel'], 'kernel', 'compile/kernel');
+  });
+
+  test('compile/exe', () {
+    _assertDartdevCommandProperties(
+        CompileCommand().subcommands['exe'], 'exe', 'compile/exe');
+  });
+
+  test('compile/aot-snapshot', () {
+    _assertDartdevCommandProperties(
+        CompileCommand().subcommands['aot-snapshot'],
+        'aot-snapshot',
+        'compile/aot-snapshot');
+  });
+
+  test('create', () {
+    _assertDartdevCommandProperties(CreateCommand(), 'create', 'create');
+  });
+
+  test('fix', () {
+    _assertDartdevCommandProperties(FixCommand(), 'fix', 'fix');
+  });
+
+  test('pub', () {
+    _assertDartdevCommandProperties(PubCommand(), 'pub', 'pub');
+  });
+
+  test('run', () {
+    _assertDartdevCommandProperties(RunCommand(), 'run', 'run');
+  });
+
+  test('test', () {
+    _assertDartdevCommandProperties(TestCommand(), 'test', 'test');
+  });
+}
+
 void _packageConfig() {
   test('packages', () {
     PackageConfig packageConfig = PackageConfig(jsonDecode(_packageData));
diff --git a/pkg/dartdev/test/events_test.dart b/pkg/dartdev/test/events_test.dart
new file mode 100644
index 0000000..c39e7c4
--- /dev/null
+++ b/pkg/dartdev/test/events_test.dart
@@ -0,0 +1,130 @@
+// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:dartdev/dartdev.dart';
+import 'package:dartdev/src/events.dart';
+import 'package:test/test.dart';
+
+void main() {
+  group('ArgParserUtils', _argParserUtils);
+  group('event constant', _constants);
+}
+
+void _argParserUtils() {
+  test('getCommandStr help', () {
+    expect(ArgParserUtils.getCommandStr(['help']), 'help');
+    expect(ArgParserUtils.getCommandStr(['analyze', 'help']), 'help');
+    expect(ArgParserUtils.getCommandStr(['help', 'analyze']), 'help');
+    expect(ArgParserUtils.getCommandStr(['analyze', '-h']), 'help');
+    expect(ArgParserUtils.getCommandStr(['analyze', '--help']), 'help');
+  });
+
+  test('getCommandStr command', () {
+    expect(ArgParserUtils.getCommandStr(['analyze']), 'analyze');
+    expect(ArgParserUtils.getCommandStr(['analyze', 'foo']), 'analyze');
+    expect(ArgParserUtils.getCommandStr(['foo', 'bar']), '<unknown>');
+    expect(ArgParserUtils.getCommandStr([]), '<unknown>');
+    expect(ArgParserUtils.getCommandStr(['']), '<unknown>');
+  });
+
+  test('isHelp false', () {
+    expect(ArgParserUtils.isHelp(null), isFalse);
+    expect(ArgParserUtils.isHelp(''), isFalse);
+    expect(ArgParserUtils.isHelp(' '), isFalse);
+    expect(ArgParserUtils.isHelp('-help'), isFalse);
+    expect(ArgParserUtils.isHelp('--HELP'), isFalse);
+    expect(ArgParserUtils.isHelp('--Help'), isFalse);
+    expect(ArgParserUtils.isHelp('Help'), isFalse);
+    expect(ArgParserUtils.isHelp('HELP'), isFalse);
+    expect(ArgParserUtils.isHelp('foo'), isFalse);
+  });
+
+  test('isHelp true', () {
+    expect(ArgParserUtils.isHelp('help'), isTrue);
+    expect(ArgParserUtils.isHelp('--help'), isTrue);
+    expect(ArgParserUtils.isHelp('-h'), isTrue);
+  });
+
+  test('isFlag false', () {
+    expect(ArgParserUtils.isFlag(null), isFalse);
+    expect(ArgParserUtils.isFlag(''), isFalse);
+    expect(ArgParserUtils.isFlag(' '), isFalse);
+    expect(ArgParserUtils.isFlag('help'), isFalse);
+    expect(ArgParserUtils.isFlag('_flag'), isFalse);
+  });
+
+  test('isFlag true', () {
+    expect(ArgParserUtils.isFlag('-'), isTrue);
+    expect(ArgParserUtils.isFlag('--'), isTrue);
+    expect(ArgParserUtils.isFlag('--flag'), isTrue);
+    expect(ArgParserUtils.isFlag('--help'), isTrue);
+    expect(ArgParserUtils.isFlag('-h'), isTrue);
+  });
+
+  test('parseCommandFlags analyze', () {
+    expect(
+        ArgParserUtils.parseCommandFlags('analyze', [
+          '-g',
+          'analyze',
+        ]),
+        <String>[]);
+    expect(
+        ArgParserUtils.parseCommandFlags('analyze', [
+          '-g',
+          'analyze',
+          '--one',
+          '--two',
+          '--three=bar',
+          '-f',
+          '--fatal-infos',
+          '-h',
+          'five'
+        ]),
+        <String>['--one', '--two', '--three', '-f', '--fatal-infos']);
+  });
+
+  test('parseCommandFlags trivial', () {
+    expect(ArgParserUtils.parseCommandFlags('foo', []), <String>[]);
+    expect(ArgParserUtils.parseCommandFlags('foo', ['']), <String>[]);
+    expect(
+        ArgParserUtils.parseCommandFlags('foo', ['bar', '-flag']), <String>[]);
+    expect(
+        ArgParserUtils.parseCommandFlags('foo', ['--global', 'bar', '-flag']),
+        <String>[]);
+    expect(ArgParserUtils.parseCommandFlags('foo', ['--global', 'fo', '-flag']),
+        <String>[]);
+    expect(
+        ArgParserUtils.parseCommandFlags('foo', ['--global', 'FOO', '-flag']),
+        <String>[]);
+  });
+
+  test('parseCommandFlags exclude help', () {
+    expect(
+        ArgParserUtils.parseCommandFlags(
+            'analyze', ['-g', 'analyze', '--flag', '--help']),
+        <String>['--flag']);
+    expect(
+        ArgParserUtils.parseCommandFlags(
+            'analyze', ['-g', 'analyze', '--flag', '-h']),
+        <String>['--flag']);
+    expect(
+        ArgParserUtils.parseCommandFlags(
+            'analyze', ['-g', 'analyze', '--flag', 'help']),
+        <String>['--flag']);
+  });
+
+  test('sanitizeFlag', () {
+    expect(ArgParserUtils.sanitizeFlag(null), '');
+    expect(ArgParserUtils.sanitizeFlag(''), '');
+    expect(ArgParserUtils.sanitizeFlag('foo'), '');
+    expect(ArgParserUtils.sanitizeFlag('--foo'), '--foo');
+    expect(ArgParserUtils.sanitizeFlag('--foo=bar'), '--foo');
+  });
+}
+
+void _constants() {
+  test('allCommands', () {
+    expect(allCommands, DartdevRunner([]).commands.keys.toList());
+  });
+}
diff --git a/pkg/dartdev/test/test_all.dart b/pkg/dartdev/test/test_all.dart
index a3b6db4..ca092a4 100644
--- a/pkg/dartdev/test/test_all.dart
+++ b/pkg/dartdev/test/test_all.dart
@@ -17,6 +17,7 @@
 import 'commands/run_test.dart' as run;
 import 'commands/test_test.dart' as test;
 import 'core_test.dart' as core;
+import 'events_test.dart' as events;
 import 'experiments_test.dart' as experiments;
 import 'sdk_test.dart' as sdk;
 import 'utils_test.dart' as utils;
@@ -26,6 +27,7 @@
     analytics.main();
     analyze.main();
     create.main();
+    events.main();
     experiments.main();
     fix.main();
     flag.main();