Consolidate observatory code (#3892)

* rename service_protocol.dart to protocol_discovery.dart

* add a wrapper around the obs. protocol

* use json-rpc in run

* consolidate obs. code; implement flutter run --benchmark

* review comments
diff --git a/packages/flutter_tools/lib/src/android/android_device.dart b/packages/flutter_tools/lib/src/android/android_device.dart
index 8086024..3304e8f 100644
--- a/packages/flutter_tools/lib/src/android/android_device.dart
+++ b/packages/flutter_tools/lib/src/android/android_device.dart
@@ -14,7 +14,7 @@
 import '../device.dart';
 import '../flx.dart' as flx;
 import '../globals.dart';
-import '../service_protocol.dart';
+import '../protocol_discovery.dart';
 import 'adb.dart';
 import 'android.dart';
 
@@ -243,14 +243,12 @@
 
     runCheckedSync(adbCommandForDevice(<String>['push', bundlePath, _deviceBundlePath]));
 
-    ServiceProtocolDiscovery observatoryDiscovery;
-    ServiceProtocolDiscovery diagnosticDiscovery;
+    ProtocolDiscovery observatoryDiscovery;
+    ProtocolDiscovery diagnosticDiscovery;
 
     if (options.debuggingEnabled) {
-      observatoryDiscovery = new ServiceProtocolDiscovery(
-        logReader, ServiceProtocolDiscovery.kObservatoryService);
-      diagnosticDiscovery = new ServiceProtocolDiscovery(
-        logReader, ServiceProtocolDiscovery.kDiagnosticService);
+      observatoryDiscovery = new ProtocolDiscovery(logReader, ProtocolDiscovery.kObservatoryService);
+      diagnosticDiscovery = new ProtocolDiscovery(logReader, ProtocolDiscovery.kDiagnosticService);
     }
 
     List<String> cmd = adbCommandForDevice(<String>[
@@ -295,13 +293,11 @@
         int observatoryLocalPort = await options.findBestObservatoryPort();
         // TODO(devoncarew): Remember the forwarding information (so we can later remove the
         // port forwarding).
-        await _forwardPort(ServiceProtocolDiscovery.kObservatoryService,
-            observatoryDevicePort, observatoryLocalPort);
+        await _forwardPort(ProtocolDiscovery.kObservatoryService, observatoryDevicePort, observatoryLocalPort);
         int diagnosticDevicePort = devicePorts[1];
         printTrace('diagnostic port = $diagnosticDevicePort');
         int diagnosticLocalPort = await options.findBestDiagnosticPort();
-        await _forwardPort(ServiceProtocolDiscovery.kDiagnosticService,
-            diagnosticDevicePort, diagnosticLocalPort);
+        await _forwardPort(ProtocolDiscovery.kDiagnosticService, diagnosticDevicePort, diagnosticLocalPort);
         return new LaunchResult.succeeded(
           observatoryPort: observatoryLocalPort,
           diagnosticPort: diagnosticLocalPort
diff --git a/packages/flutter_tools/lib/src/base/common.dart b/packages/flutter_tools/lib/src/base/common.dart
index d512ca2..2725d23 100644
--- a/packages/flutter_tools/lib/src/base/common.dart
+++ b/packages/flutter_tools/lib/src/base/common.dart
@@ -2,11 +2,6 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-const int defaultObservatoryPort = 8100;
-const int defaultDiagnosticPort  = 8101;
-const int defaultDrivePort       = 8183;
-
-// Names of some of the Timeline events we care about
-const String flutterEngineMainEnterEventName = 'FlutterEngineMainEnter';
-const String frameworkInitEventName = 'Framework initialization';
-const String firstUsefulFrameEventName = 'Widgets completed first useful frame';
+const int kDefaultObservatoryPort = 8100;
+const int kDefaultDiagnosticPort  = 8101;
+const int kDefaultDrivePort       = 8183;
diff --git a/packages/flutter_tools/lib/src/base/utils.dart b/packages/flutter_tools/lib/src/base/utils.dart
index a550f9c..312739a 100644
--- a/packages/flutter_tools/lib/src/base/utils.dart
+++ b/packages/flutter_tools/lib/src/base/utils.dart
@@ -3,6 +3,7 @@
 // found in the LICENSE file.
 
 import 'dart:async';
+import 'dart:convert';
 import 'dart:io';
 
 import 'package:crypto/crypto.dart';
@@ -60,6 +61,10 @@
   }
 }
 
+String toPrettyJson(Object jsonable) {
+  return new JsonEncoder.withIndent('  ').convert(jsonable) + '\n';
+}
+
 /// A class to maintain a list of items, fire events when items are added or
 /// removed, and calculate a diff of changes when a new list of items is
 /// available.
diff --git a/packages/flutter_tools/lib/src/cache.dart b/packages/flutter_tools/lib/src/cache.dart
index 4ec348f..791c1ea 100644
--- a/packages/flutter_tools/lib/src/cache.dart
+++ b/packages/flutter_tools/lib/src/cache.dart
@@ -12,7 +12,7 @@
 import 'base/os.dart';
 import 'globals.dart';
 
-/// A warpper around the `bin/cache/` directory.
+/// A wrapper around the `bin/cache/` directory.
 class Cache {
   /// [rootOverride] is configurable for testing.
   Cache({ Directory rootOverride }) {
diff --git a/packages/flutter_tools/lib/src/commands/analyze.dart b/packages/flutter_tools/lib/src/commands/analyze.dart
index ed72d52..c0c4c63 100644
--- a/packages/flutter_tools/lib/src/commands/analyze.dart
+++ b/packages/flutter_tools/lib/src/commands/analyze.dart
@@ -376,9 +376,8 @@
       'time': (stopwatch.elapsedMilliseconds / 1000.0),
       'issues': errorCount
     };
-    JsonEncoder encoder = new JsonEncoder.withIndent('  ');
-    new File(benchmarkOut).writeAsStringSync(encoder.convert(data) + '\n');
-    printStatus('Analysis benchmark written to $benchmarkOut.');
+    new File(benchmarkOut).writeAsStringSync(toPrettyJson(data));
+    printStatus('Analysis benchmark written to $benchmarkOut ($data).');
   }
 }
 
diff --git a/packages/flutter_tools/lib/src/commands/drive.dart b/packages/flutter_tools/lib/src/commands/drive.dart
index 37745ac..143235a 100644
--- a/packages/flutter_tools/lib/src/commands/drive.dart
+++ b/packages/flutter_tools/lib/src/commands/drive.dart
@@ -62,7 +62,7 @@
     );
 
     argParser.addOption('debug-port',
-      defaultsTo: defaultDrivePort.toString(),
+      defaultsTo: kDefaultDrivePort.toString(),
       help: 'Listen to the given port for a debug connection.'
     );
   }
diff --git a/packages/flutter_tools/lib/src/commands/run.dart b/packages/flutter_tools/lib/src/commands/run.dart
index a1fe71b..def7580 100644
--- a/packages/flutter_tools/lib/src/commands/run.dart
+++ b/packages/flutter_tools/lib/src/commands/run.dart
@@ -3,7 +3,6 @@
 // found in the LICENSE file.
 
 import 'dart:async';
-import 'dart:convert';
 import 'dart:io';
 
 import 'package:path/path.dart' as path;
@@ -11,12 +10,15 @@
 import '../application_package.dart';
 import '../base/common.dart';
 import '../base/logger.dart';
+import '../base/utils.dart';
 import '../build_info.dart';
 import '../device.dart';
 import '../globals.dart';
+import '../observatory.dart';
 import '../runner/flutter_command.dart';
 import 'build_apk.dart';
 import 'install.dart';
+import 'trace.dart';
 
 abstract class RunCommandBase extends FlutterCommand {
   RunCommandBase() {
@@ -62,7 +64,7 @@
         negatable: false,
         help: 'Start in a paused mode and wait for a debugger to connect.');
     argParser.addOption('debug-port',
-        help: 'Listen to the given port for a debug connection (defaults to $defaultObservatoryPort).');
+        help: 'Listen to the given port for a debug connection (defaults to $kDefaultObservatoryPort).');
     usesPubOption();
 
     // A temporary, hidden flag to experiment with a different run style.
@@ -72,6 +74,12 @@
         negatable: false,
         hide: true,
         help: 'Stay resident after running the app.');
+
+    // Hidden option to enable a benchmarking mode. This will run the given
+    // application, measure the startup time and the app restart time, write the
+    // results out to 'refresh_benchmark.json', and exit. This flag is intended
+    // for use in generating automated flutter benchmarks.
+    argParser.addFlag('benchmark', negatable: false, hide: true);
   }
 
   @override
@@ -107,7 +115,7 @@
       options = new DebuggingOptions.disabled();
     } else {
       options = new DebuggingOptions.enabled(
-        // TODO(devoncarew): Check this to 'getBuildMode() == BuildMode.debug'.
+        // TODO(devoncarew): Change this to 'getBuildMode() == BuildMode.debug'.
         checked: argResults['checked'],
         startPaused: argResults['start-paused'],
         observatoryPort: debugPort
@@ -119,11 +127,10 @@
         deviceForCommand,
         target: target,
         debuggingOptions: options,
-        traceStartup: traceStartup,
         buildMode: getBuildMode()
       );
 
-      return runner.run();
+      return runner.run(traceStartup: traceStartup, benchmark: argResults['benchmark']);
     } else {
       return startApp(
         deviceForCommand,
@@ -132,6 +139,7 @@
         install: true,
         debuggingOptions: options,
         traceStartup: traceStartup,
+        benchmark: argResults['benchmark'],
         route: route,
         buildMode: getBuildMode()
       );
@@ -146,6 +154,7 @@
   bool install: true,
   DebuggingOptions debuggingOptions,
   bool traceStartup: false,
+  bool benchmark: false,
   String route,
   BuildMode buildMode: BuildMode.debug
 }) async {
@@ -169,6 +178,8 @@
     return 1;
   }
 
+  Stopwatch stopwatch = new Stopwatch()..start();
+
   // TODO(devoncarew): We shouldn't have to do type checks here.
   if (install && device is AndroidDevice) {
     printTrace('Running build command.');
@@ -221,10 +232,22 @@
     platformArgs: platformArgs
   );
 
-  if (!result.started)
+  stopwatch.stop();
+
+  if (!result.started) {
     printError('Error running application on ${device.name}.');
-  else if (traceStartup)
-    await _downloadStartupTrace(result.observatoryPort, device);
+  } else if (traceStartup) {
+    try {
+      Observatory observatory = await Observatory.connect(result.observatoryPort);
+      await _downloadStartupTrace(observatory);
+    } catch (error) {
+      printError('Error connecting to observatory: $error');
+      return 1;
+    }
+  }
+
+  if (benchmark)
+    _writeBenchmark(stopwatch);
 
   return result.started ? 0 : 2;
 }
@@ -241,87 +264,6 @@
     return targetPath;
 }
 
-Future<Null> _downloadStartupTrace(int observatoryPort, Device device) async {
-  Map<String, dynamic> timeline = await device.stopTracingAndDownloadTimeline(
-    observatoryPort,
-    waitForFirstFrame: true
-  );
-
-  int extractInstantEventTimestamp(String eventName) {
-    List<Map<String, dynamic>> events = timeline['traceEvents'];
-    Map<String, dynamic> event = events
-        .firstWhere((Map<String, dynamic> event) => event['name'] == eventName, orElse: () => null);
-    if (event == null)
-      return null;
-    return event['ts'];
-  }
-
-  int engineEnterTimestampMicros = extractInstantEventTimestamp(flutterEngineMainEnterEventName);
-  int frameworkInitTimestampMicros = extractInstantEventTimestamp(frameworkInitEventName);
-  int firstFrameTimestampMicros = extractInstantEventTimestamp(firstUsefulFrameEventName);
-
-  if (engineEnterTimestampMicros == null) {
-    printError('Engine start event is missing in the timeline. Cannot compute startup time.');
-    return null;
-  }
-
-  if (firstFrameTimestampMicros == null) {
-    printError('First frame event is missing in the timeline. Cannot compute startup time.');
-    return null;
-  }
-
-  File traceInfoFile = new File('build/start_up_info.json');
-  int timeToFirstFrameMicros = firstFrameTimestampMicros - engineEnterTimestampMicros;
-  Map<String, dynamic> traceInfo = <String, dynamic>{
-    'engineEnterTimestampMicros': engineEnterTimestampMicros,
-    'timeToFirstFrameMicros': timeToFirstFrameMicros,
-  };
-
-  if (frameworkInitTimestampMicros != null) {
-    traceInfo['timeToFrameworkInitMicros'] = frameworkInitTimestampMicros - engineEnterTimestampMicros;
-    traceInfo['timeAfterFrameworkInitMicros'] = firstFrameTimestampMicros - frameworkInitTimestampMicros;
-  }
-
-  await traceInfoFile.writeAsString(JSON.encode(traceInfo));
-
-  String timeToFirstFrameMessage;
-  if (timeToFirstFrameMicros > 1000000) {
-    timeToFirstFrameMessage = '${(timeToFirstFrameMicros / 1000000).toStringAsFixed(2)} seconds';
-  } else {
-    timeToFirstFrameMessage = '${timeToFirstFrameMicros ~/ 1000} milliseconds';
-  }
-
-  printStatus('Time to first frame $timeToFirstFrameMessage');
-  printStatus('Saved startup trace info in ${traceInfoFile.path}');
-}
-
-/// Delay until the Observatory / service protocol is available.
-///
-/// This does not fail if we're unable to connect, and times out after the given
-/// [timeout].
-Future<Null> delayUntilObservatoryAvailable(String host, int port, {
-  Duration timeout: const Duration(seconds: 10)
-}) async {
-  printTrace('Waiting until Observatory is available (port $port).');
-
-  final String url = 'ws://$host:$port/ws';
-  printTrace('Looking for the observatory at $url.');
-  Stopwatch stopwatch = new Stopwatch()..start();
-
-  while (stopwatch.elapsed <= timeout) {
-    try {
-      WebSocket ws = await WebSocket.connect(url);
-      printTrace('Connected to the observatory port.');
-      ws.close().catchError((dynamic error) => null);
-      return;
-    } catch (error) {
-      await new Future<Null>.delayed(new Duration(milliseconds: 250));
-    }
-  }
-
-  printTrace('Unable to connect to the observatory.');
-}
-
 String _getMissingPackageHintForPlatform(TargetPlatform platform) {
   switch (platform) {
     case TargetPlatform.android_arm:
@@ -346,25 +288,22 @@
     this.device, {
     this.target,
     this.debuggingOptions,
-    this.traceStartup : false,
     this.buildMode : BuildMode.debug
   });
 
   final Device device;
   final String target;
   final DebuggingOptions debuggingOptions;
-  final bool traceStartup;
   final BuildMode buildMode;
 
   Completer<int> _exitCompleter;
   StreamSubscription<String> _loggingSubscription;
 
-  WebSocket _observatoryConnection;
+  Observatory observatory;
   String _isolateId;
-  int _messageId = 0;
 
   /// Start the app and keep the process running during its lifetime.
-  Future<int> run() async {
+  Future<int> run({ bool traceStartup: false, bool benchmark: false }) async {
     String mainPath = findMainDartFile(target);
     if (!FileSystemEntity.isFileSync(mainPath)) {
       String message = 'Tried to run $mainPath, but that file does not exist.';
@@ -385,6 +324,8 @@
       return 1;
     }
 
+    Stopwatch stopwatch = new Stopwatch()..start();
+
     // TODO(devoncarew): We shouldn't have to do type checks here.
     if (device is AndroidDevice) {
       printTrace('Running build command.');
@@ -440,89 +381,79 @@
       return 2;
     }
 
+    stopwatch.stop();
+
     _exitCompleter = new Completer<int>();
 
     // Connect to observatory.
     if (debuggingOptions.debuggingEnabled) {
-      final String localhost = InternetAddress.LOOPBACK_IP_V4.address;
-      final String url = 'ws://$localhost:${result.observatoryPort}/ws';
-
-      _observatoryConnection = await WebSocket.connect(url);
+      observatory = await Observatory.connect(result.observatoryPort);
       printTrace('Connected to observatory port: ${result.observatoryPort}.');
 
-      // Listen for observatory connection close.
-      _observatoryConnection.listen((dynamic data) {
-        if (data is String) {
-          Map<String, dynamic> json = JSON.decode(data);
+      observatory.onIsolateEvent.listen((Event event) {
+        if (event['isolate'] != null)
+          _isolateId = event['isolate']['id'];
+      });
+      observatory.streamListen('Isolate');
 
-          if (json['method'] == 'streamNotify') {
-            Map<String, dynamic> event = json['params']['event'];
-            if (event['isolate'] != null && _isolateId == null)
-              _isolateId = event['isolate']['id'];
-          } else if (json['result'] != null && json['result']['type'] == 'VM') {
-            // isolates: [{
-            //   type: @Isolate, fixedId: true, id: isolates/724543296, name: dev.flx$main, number: 724543296
-            // }]
-            List<dynamic> isolates = json['result']['isolates'];
-            if (isolates.isNotEmpty)
-              _isolateId = isolates.first['id'];
-          } else if (json['error'] != null) {
-            printError('Error: ${json['error']['message']}.');
-            printTrace(data);
-          }
-        }
-      }, onDone: () {
+      // Listen for observatory connection close.
+      observatory.done.whenComplete(() {
         _handleExit();
       });
 
-      _observatoryConnection.add(JSON.encode(<String, dynamic>{
-        'method': 'streamListen',
-        'params': <String, dynamic>{ 'streamId': 'Isolate' },
-        'id': _messageId++
-      }));
-
-      _observatoryConnection.add(JSON.encode(<String, dynamic>{
-        'method': 'getVM',
-        'id': _messageId++
-      }));
+      observatory.getVM().then((VM vm) {
+        if (vm.isolates.isNotEmpty)
+          _isolateId = vm.isolates.first['id'];
+      });
     }
 
     printStatus('Application running.');
-    _printHelp();
 
-    terminal.singleCharMode = true;
+    if (observatory != null && traceStartup) {
+      printStatus('Downloading startup trace info...');
 
-    terminal.onCharInput.listen((String code) {
-      String lower = code.toLowerCase();
+      await _downloadStartupTrace(observatory);
 
-      if (lower == 'h' || code == AnsiTerminal.KEY_F1) {
-        // F1, help
-        _printHelp();
-      } else if (lower == 'r' || code == AnsiTerminal.KEY_F5) {
-        // F5, refresh
-        _handleRefresh();
-      } else if (lower == 'q' || code == AnsiTerminal.KEY_F10) {
-        // F10, exit
+      _handleExit();
+    } else {
+      _printHelp();
+
+      terminal.singleCharMode = true;
+
+      terminal.onCharInput.listen((String code) {
+        String lower = code.toLowerCase();
+
+        if (lower == 'h' || code == AnsiTerminal.KEY_F1) {
+          // F1, help
+          _printHelp();
+        } else if (lower == 'r' || code == AnsiTerminal.KEY_F5) {
+          // F5, refresh
+          _handleRefresh();
+        } else if (lower == 'q' || code == AnsiTerminal.KEY_F10) {
+          // F10, exit
+          _handleExit();
+        }
+      });
+
+      ProcessSignal.SIGINT.watch().listen((ProcessSignal signal) {
         _handleExit();
-      }
-    });
+      });
+      ProcessSignal.SIGTERM.watch().listen((ProcessSignal signal) {
+        _handleExit();
+      });
+    }
 
-    ProcessSignal.SIGINT.watch().listen((ProcessSignal signal) {
-      _handleExit();
-    });
-    ProcessSignal.SIGTERM.watch().listen((ProcessSignal signal) {
-      _handleExit();
-    });
+    if (benchmark) {
+      _writeBenchmark(stopwatch);
+      new Future<Null>.delayed(new Duration(seconds: 2)).then((_) {
+        _handleExit();
+      });
+    }
 
     return _exitCompleter.future.then((int exitCode) async {
-      if (_observatoryConnection != null &&
-          _observatoryConnection.readyState == WebSocket.OPEN &&
-          _isolateId != null) {
-        _observatoryConnection.add(JSON.encode(<String, dynamic>{
-          'method': 'ext.flutter.exit',
-          'params': <String, dynamic>{ 'isolateId': _isolateId },
-          'id': _messageId++
-        }));
+      if (observatory != null && !observatory.isClosed && _isolateId != null) {
+        observatory.flutterExit(_isolateId);
+
         // WebSockets do not have a flush() method.
         await new Future<Null>.delayed(new Duration(milliseconds: 100));
       }
@@ -536,27 +467,80 @@
   }
 
   void _handleRefresh() {
-    if (_observatoryConnection == null) {
+    if (observatory == null) {
       printError('Debugging is not enabled.');
     } else {
       printStatus('Re-starting application...');
 
-      // TODO(devoncarew): Show an error if the isolate reload fails.
-      _observatoryConnection.add(JSON.encode(<String, dynamic>{
-        'method': 'isolateReload',
-        'params': <String, dynamic>{ 'isolateId': _isolateId },
-        'id': _messageId++
-      }));
+      observatory.isolateReload(_isolateId).catchError((dynamic error) {
+        printError('Error restarting app: $error');
+      });
     }
   }
 
   void _handleExit() {
+    terminal.singleCharMode = false;
+
     if (!_exitCompleter.isCompleted) {
       _loggingSubscription?.cancel();
-      printStatus('');
       printStatus('Application finished.');
-      terminal.singleCharMode = false;
       _exitCompleter.complete(0);
     }
   }
 }
+
+Future<Null> _downloadStartupTrace(Observatory observatory) async {
+  Tracing tracing = new Tracing(observatory);
+
+  Map<String, dynamic> timeline = await tracing.stopTracingAndDownloadTimeline(
+    waitForFirstFrame: true
+  );
+
+  int extractInstantEventTimestamp(String eventName) {
+    List<Map<String, dynamic>> events = timeline['traceEvents'];
+    Map<String, dynamic> event = events.firstWhere(
+      (Map<String, dynamic> event) => event['name'] == eventName, orElse: () => null
+    );
+    return event == null ? null : event['ts'];
+  }
+
+  int engineEnterTimestampMicros = extractInstantEventTimestamp(kFlutterEngineMainEnterEventName);
+  int frameworkInitTimestampMicros = extractInstantEventTimestamp(kFrameworkInitEventName);
+  int firstFrameTimestampMicros = extractInstantEventTimestamp(kFirstUsefulFrameEventName);
+
+  if (engineEnterTimestampMicros == null) {
+    printError('Engine start event is missing in the timeline. Cannot compute startup time.');
+    return null;
+  }
+
+  if (firstFrameTimestampMicros == null) {
+    printError('First frame event is missing in the timeline. Cannot compute startup time.');
+    return null;
+  }
+
+  File traceInfoFile = new File('build/start_up_info.json');
+  int timeToFirstFrameMicros = firstFrameTimestampMicros - engineEnterTimestampMicros;
+  Map<String, dynamic> traceInfo = <String, dynamic>{
+    'engineEnterTimestampMicros': engineEnterTimestampMicros,
+    'timeToFirstFrameMicros': timeToFirstFrameMicros,
+  };
+
+  if (frameworkInitTimestampMicros != null) {
+    traceInfo['timeToFrameworkInitMicros'] = frameworkInitTimestampMicros - engineEnterTimestampMicros;
+    traceInfo['timeAfterFrameworkInitMicros'] = firstFrameTimestampMicros - frameworkInitTimestampMicros;
+  }
+
+  traceInfoFile.writeAsStringSync(toPrettyJson(traceInfo));
+
+  printStatus('Time to first frame: ${timeToFirstFrameMicros ~/ 1000}ms.');
+  printStatus('Saved startup trace info in ${traceInfoFile.path}.');
+}
+
+void _writeBenchmark(Stopwatch stopwatch) {
+  final String benchmarkOut = 'refresh_benchmark.json';
+  Map<String, dynamic> data = <String, dynamic>{
+    'time': stopwatch.elapsedMilliseconds
+  };
+  new File(benchmarkOut).writeAsStringSync(toPrettyJson(data));
+  printStatus('Run benchmark written to $benchmarkOut ($data).');
+}
diff --git a/packages/flutter_tools/lib/src/commands/skia.dart b/packages/flutter_tools/lib/src/commands/skia.dart
index e5a6431..4ff7f06 100644
--- a/packages/flutter_tools/lib/src/commands/skia.dart
+++ b/packages/flutter_tools/lib/src/commands/skia.dart
@@ -16,7 +16,7 @@
     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.');
     argParser.addOption('diagnostic-port',
-        defaultsTo: defaultDiagnosticPort.toString(),
+        defaultsTo: kDefaultDiagnosticPort.toString(),
         help: 'Local port where the diagnostic server is listening.');
   }
 
diff --git a/packages/flutter_tools/lib/src/commands/trace.dart b/packages/flutter_tools/lib/src/commands/trace.dart
index 6e4e437..bed77a9 100644
--- a/packages/flutter_tools/lib/src/commands/trace.dart
+++ b/packages/flutter_tools/lib/src/commands/trace.dart
@@ -8,10 +8,15 @@
 
 import '../base/common.dart';
 import '../base/utils.dart';
-import '../device.dart';
 import '../globals.dart';
+import '../observatory.dart';
 import '../runner/flutter_command.dart';
 
+// Names of some of the Timeline events we care about.
+const String kFlutterEngineMainEnterEventName = 'FlutterEngineMainEnter';
+const String kFrameworkInitEventName = 'Framework initialization';
+const String kFirstUsefulFrameEventName = 'Widgets completed first useful frame';
+
 class TraceCommand extends FlutterCommand {
   TraceCommand() {
     argParser.addFlag('start', negatable: false, help: 'Start tracing.');
@@ -20,7 +25,7 @@
     argParser.addOption('duration',
         defaultsTo: '10', abbr: 'd', help: 'Duration in seconds to trace.');
     argParser.addOption('debug-port',
-        defaultsTo: defaultObservatoryPort.toString(),
+        defaultsTo: kDefaultObservatoryPort.toString(),
         help: 'Local port where the observatory is listening.');
   }
 
@@ -41,38 +46,104 @@
 
   @override
   Future<int> runInProject() async {
-    Device device = deviceForCommand;
     int observatoryPort = int.parse(argResults['debug-port']);
 
+    Tracing tracing;
+
+    try {
+      tracing = await Tracing.connect(observatoryPort);
+    } catch (error) {
+      printError('Error connecting to observatory: $error');
+      return 1;
+    }
+
     if ((!argResults['start'] && !argResults['stop']) ||
         (argResults['start'] && argResults['stop'])) {
       // Setting neither flags or both flags means do both commands and wait
       // duration seconds in between.
-      await device.startTracing(observatoryPort);
+      await tracing.startTracing();
       await new Future<Null>.delayed(
         new Duration(seconds: int.parse(argResults['duration'])),
-        () => _stopTracing(device, observatoryPort)
+        () => _stopTracing(tracing)
       );
     } else if (argResults['stop']) {
-      await _stopTracing(device, observatoryPort);
+      await _stopTracing(tracing);
     } else {
-      await device.startTracing(observatoryPort);
+      await tracing.startTracing();
     }
+
     return 0;
   }
 
-  Future<Null> _stopTracing(Device device, int observatoryPort) async {
-    Map<String, dynamic> timeline = await device.stopTracingAndDownloadTimeline(observatoryPort);
-
-    String outPath = argResults['out'];
+  Future<Null> _stopTracing(Tracing tracing) async {
+    Map<String, dynamic> timeline = await tracing.stopTracingAndDownloadTimeline();
     File localFile;
-    if (outPath != null) {
-      localFile = new File(outPath);
+
+    if (argResults['out'] != null) {
+      localFile = new File(argResults['out']);
     } else {
       localFile = getUniqueFile(Directory.current, 'trace', 'json');
     }
 
     await localFile.writeAsString(JSON.encode(timeline));
+
     printStatus('Trace file saved to ${localFile.path}');
   }
 }
+
+class Tracing {
+  Tracing(this.observatory);
+
+  static Future<Tracing> connect(int port) {
+    return Observatory.connect(port).then((Observatory observatory) => new Tracing(observatory));
+  }
+
+  final Observatory observatory;
+
+  Future<Null> startTracing() async {
+    await observatory.setVMTimelineFlags(<String>['Compiler', 'Dart', 'Embedder', 'GC']);
+    await observatory.clearVMTimeline();
+  }
+
+  /// Stops tracing; optionally wait for first frame.
+  Future<Map<String, dynamic>> stopTracingAndDownloadTimeline({
+    bool waitForFirstFrame: false
+  }) async {
+    Response timeline;
+
+    if (!waitForFirstFrame) {
+      // Stop tracing immediately and get the timeline
+      await observatory.setVMTimelineFlags(<String>[]);
+      timeline = await observatory.getVMTimeline();
+    } else {
+      Completer<Null> whenFirstFrameRendered = new Completer<Null>();
+
+      observatory.onTimelineEvent.listen((Event timelineEvent) {
+        List<Map<String, dynamic>> events = timelineEvent['timelineEvents'];
+        for (Map<String, dynamic> event in events) {
+          if (event['name'] == kFirstUsefulFrameEventName)
+            whenFirstFrameRendered.complete();
+        }
+      });
+      await observatory.streamListen('Timeline');
+
+      await whenFirstFrameRendered.future.timeout(
+        const Duration(seconds: 10),
+        onTimeout: () {
+          printError(
+            'Timed out waiting for the first frame event. Either the '
+            'application failed to start, or the event was missed because '
+            '"flutter run" took too long to subscribe to timeline events.'
+          );
+          return null;
+        }
+      );
+
+      timeline = await observatory.getVMTimeline();
+
+      await observatory.setVMTimelineFlags(<String>[]);
+    }
+
+    return timeline.response;
+  }
+}
diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart
index ce443ca..519cd81 100644
--- a/packages/flutter_tools/lib/src/device.dart
+++ b/packages/flutter_tools/lib/src/device.dart
@@ -6,9 +6,6 @@
 import 'dart:io';
 import 'dart:math' as math;
 
-import 'package:json_rpc_2/json_rpc_2.dart' as rpc;
-import 'package:web_socket_channel/io.dart';
-
 import 'android/android_device.dart';
 import 'application_package.dart';
 import 'base/common.dart';
@@ -225,82 +222,6 @@
         '${getNameForTargetPlatform(device.platform)}$supportIndicator');
     }
   }
-
-
-  Future<rpc.Peer> _connectToObservatory(int observatoryPort) async {
-    Uri uri = new Uri(scheme: 'ws', host: '127.0.0.1', port: observatoryPort, path: 'ws');
-    WebSocket ws = await WebSocket.connect(uri.toString());
-    rpc.Peer peer = new rpc.Peer(new IOWebSocketChannel(ws));
-    peer.listen();
-    return peer;
-  }
-
-  Future<Null> startTracing(int observatoryPort) async {
-    rpc.Client client;
-    try {
-      client = await _connectToObservatory(observatoryPort);
-    } catch (e) {
-      printError('Error connecting to observatory: $e');
-      return;
-    }
-
-    await client.sendRequest('_setVMTimelineFlags',
-        <String, dynamic>{'recordedStreams': <String>['Compiler', 'Dart', 'Embedder', 'GC']}
-    );
-    await client.sendRequest('_clearVMTimeline');
-  }
-
-  /// Stops tracing, optionally waiting
-  Future<Map<String, dynamic>> stopTracingAndDownloadTimeline(int observatoryPort, {bool waitForFirstFrame: false}) async {
-    rpc.Peer peer;
-    try {
-      peer = await _connectToObservatory(observatoryPort);
-    } catch (e) {
-      printError('Error connecting to observatory: $e');
-      return null;
-    }
-
-    Future<Map<String, dynamic>> fetchTimeline() async {
-      return await peer.sendRequest('_getVMTimeline');
-    }
-
-    Map<String, dynamic> timeline;
-
-    if (!waitForFirstFrame) {
-      // Stop tracing immediately and get the timeline
-      await peer.sendRequest('_setVMTimelineFlags', <String, dynamic>{'recordedStreams': '[]'});
-      timeline = await fetchTimeline();
-    } else {
-      Completer<Null> whenFirstFrameRendered = new Completer<Null>();
-      peer.registerMethod('streamNotify', (rpc.Parameters params) {
-        Map<String, dynamic> data = params.asMap;
-        if (data['streamId'] == 'Timeline') {
-          List<Map<String, dynamic>> events = data['event']['timelineEvents'];
-          for (Map<String, dynamic> event in events) {
-            if (event['name'] == firstUsefulFrameEventName) {
-              whenFirstFrameRendered.complete();
-            }
-          }
-        }
-      });
-      await peer.sendRequest('streamListen', <String, dynamic>{'streamId': 'Timeline'});
-      await whenFirstFrameRendered.future.timeout(
-        const Duration(seconds: 10),
-        onTimeout: () {
-          printError(
-            'Timed out waiting for the first frame event. Either the '
-            'application failed to start, or the event was missed because '
-            '"flutter run" took too long to subscribe to timeline events.'
-          );
-          return null;
-        }
-      );
-      timeline = await fetchTimeline();
-      await peer.sendRequest('_setVMTimelineFlags', <String, dynamic>{'recordedStreams': '[]'});
-    }
-
-    return timeline;
-  }
 }
 
 class DebuggingOptions {
@@ -332,7 +253,7 @@
   Future<int> findBestObservatoryPort() {
     if (hasObservatoryPort)
       return new Future<int>.value(observatoryPort);
-    return findPreferredPort(observatoryPort ?? defaultObservatoryPort);
+    return findPreferredPort(observatoryPort ?? kDefaultObservatoryPort);
   }
 
   bool get hasDiagnosticPort => diagnosticPort != null;
@@ -340,7 +261,7 @@
   /// Return the user specified diagnostic port. If that isn't available,
   /// return [defaultObservatoryPort], or a port close to that one.
   Future<int> findBestDiagnosticPort() {
-    return findPreferredPort(diagnosticPort ?? defaultDiagnosticPort);
+    return findPreferredPort(diagnosticPort ?? kDefaultDiagnosticPort);
   }
 }
 
diff --git a/packages/flutter_tools/lib/src/ios/simulators.dart b/packages/flutter_tools/lib/src/ios/simulators.dart
index 2494cc9..02e47b1 100644
--- a/packages/flutter_tools/lib/src/ios/simulators.dart
+++ b/packages/flutter_tools/lib/src/ios/simulators.dart
@@ -15,7 +15,7 @@
 import '../device.dart';
 import '../flx.dart' as flx;
 import '../globals.dart';
-import '../service_protocol.dart';
+import '../protocol_discovery.dart';
 import 'mac.dart';
 
 const String _xcrunPath = '/usr/bin/xcrun';
@@ -395,20 +395,17 @@
     RegExp versionExp = new RegExp(r'iPhone ([0-9])+');
     Match match = versionExp.firstMatch(name);
 
-    if (match == null) {
-      // Not an iPhone. All available non-iPhone simulators are compatible.
+    // Not an iPhone. All available non-iPhone simulators are compatible.
+    if (match == null)
       return true;
-    }
 
-    if (int.parse(match.group(1)) > 5) {
-      // iPhones 6 and above are always fine.
+    // iPhones 6 and above are always fine.
+    if (int.parse(match.group(1)) > 5)
       return true;
-    }
 
     // The 's' subtype of 5 is compatible.
-    if (name.contains('iPhone 5s')) {
+    if (name.contains('iPhone 5s'))
       return true;
-    }
 
     _supportMessage = "The simulator version is too old. Choose an iPhone 5s or above.";
     return false;
@@ -447,12 +444,10 @@
     if (!(await _setupUpdatedApplicationBundle(app)))
       return new LaunchResult.failed();
 
-    ServiceProtocolDiscovery observatoryDiscovery;
+    ProtocolDiscovery observatoryDiscovery;
 
-    if (debuggingOptions.debuggingEnabled) {
-      observatoryDiscovery = new ServiceProtocolDiscovery(
-        logReader, ServiceProtocolDiscovery.kObservatoryService);
-    }
+    if (debuggingOptions.debuggingEnabled)
+      observatoryDiscovery = new ProtocolDiscovery(logReader, ProtocolDiscovery.kObservatoryService);
 
     // Prepare launch arguments.
     List<String> args = <String>[
diff --git a/packages/flutter_tools/lib/src/observatory.dart b/packages/flutter_tools/lib/src/observatory.dart
new file mode 100644
index 0000000..27b83a4
--- /dev/null
+++ b/packages/flutter_tools/lib/src/observatory.dart
@@ -0,0 +1,130 @@
+// 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 'dart:io';
+
+import 'package:json_rpc_2/json_rpc_2.dart' as rpc;
+import 'package:web_socket_channel/io.dart';
+
+class Observatory {
+  Observatory._(this.peer, this.port) {
+    peer.registerMethod('streamNotify', (rpc.Parameters event) {
+      _handleStreamNotify(event.asMap);
+    });
+  }
+
+  static Future<Observatory> connect(int port) async {
+    Uri uri = new Uri(scheme: 'ws', host: '127.0.0.1', port: port, path: 'ws');
+    WebSocket ws = await WebSocket.connect(uri.toString());
+    rpc.Peer peer = new rpc.Peer(new IOWebSocketChannel(ws));
+    peer.listen();
+    return new Observatory._(peer, port);
+  }
+
+  final rpc.Peer peer;
+  final int port;
+
+  Map<String, StreamController<Event>> _eventControllers = <String, StreamController<Event>>{};
+
+  bool get isClosed => peer.isClosed;
+  Future<Null> get done => peer.done;
+
+  // Events
+
+  // IsolateStart, IsolateRunnable, IsolateExit, IsolateUpdate, ServiceExtensionAdded
+  Stream<Event> get onIsolateEvent => _getEventController('Isolate').stream;
+  Stream<Event> get onTimelineEvent => _getEventController('Timeline').stream;
+
+  // Listen for a specific event name.
+  Stream<Event> onEvent(String streamName) => _getEventController(streamName).stream;
+
+  StreamController<Event> _getEventController(String eventName) {
+    StreamController<Event> controller = _eventControllers[eventName];
+    if (controller == null) {
+      controller = new StreamController<Event>.broadcast();
+      _eventControllers[eventName] = controller;
+    }
+    return controller;
+  }
+
+  void _handleStreamNotify(Map<String, dynamic> data) {
+    Event event = new Event(data['event']);
+    _getEventController(data['streamId']).add(event);
+  }
+
+  // Requests
+
+  Future<Response> sendRequest(String method, [Map<String, dynamic> args]) {
+    return peer.sendRequest(method, args).then((dynamic result) => new Response(result));
+  }
+
+  Future<Response> streamListen(String streamId) {
+    return sendRequest('streamListen', <String, dynamic>{
+      'streamId': streamId
+    });
+  }
+
+  Future<VM> getVM() {
+    return peer.sendRequest('getVM').then((dynamic result) {
+      return new VM(result);
+    });
+  }
+
+  Future<Response> isolateReload(String isolateId) {
+    return sendRequest('isolateReload', <String, dynamic>{
+      'isolateId': isolateId
+    });
+  }
+
+  Future<Response> clearVMTimeline() => sendRequest('_clearVMTimeline');
+
+  Future<Response> setVMTimelineFlags(List<String> recordedStreams) {
+    assert(recordedStreams != null);
+
+    return sendRequest('_setVMTimelineFlags', <String, dynamic> {
+      'recordedStreams': recordedStreams
+    });
+  }
+
+  Future<Response> getVMTimeline() => sendRequest('_getVMTimeline');
+
+  // Flutter extension methods.
+
+  Future<Response> flutterExit(String isolateId) {
+    return peer.sendRequest('ext.flutter.exit', <String, dynamic>{
+      'isolateId': isolateId
+    }).then((dynamic result) => new Response(result));
+  }
+}
+
+class Response {
+  Response(this.response);
+
+  final Map<String, dynamic> response;
+
+  dynamic operator[](String key) => response[key];
+
+  @override
+  String toString() => response.toString();
+}
+
+class VM extends Response {
+  VM(Map<String, dynamic> response) : super(response);
+
+  List<dynamic> get isolates => response['isolates'];
+}
+
+class Event {
+  Event(this.event);
+
+  final Map<String, dynamic> event;
+
+  String get kind => event['kind'];
+
+  dynamic operator[](String key) => event[key];
+
+  @override
+  String toString() => event.toString();
+}
diff --git a/packages/flutter_tools/lib/src/service_protocol.dart b/packages/flutter_tools/lib/src/protocol_discovery.dart
similarity index 93%
rename from packages/flutter_tools/lib/src/service_protocol.dart
rename to packages/flutter_tools/lib/src/protocol_discovery.dart
index 1ce825a..7c27b13 100644
--- a/packages/flutter_tools/lib/src/service_protocol.dart
+++ b/packages/flutter_tools/lib/src/protocol_discovery.dart
@@ -7,9 +7,9 @@
 import 'device.dart';
 
 /// Discover service protocol ports on devices.
-class ServiceProtocolDiscovery {
+class ProtocolDiscovery {
   /// [logReader] - a [DeviceLogReader] to look for service messages in.
-  ServiceProtocolDiscovery(DeviceLogReader logReader, String serviceName)
+  ProtocolDiscovery(DeviceLogReader logReader, String serviceName)
       : _logReader = logReader, _serviceName = serviceName {
     assert(_logReader != null);
     _subscription = _logReader.logLines.listen(_onLine);
diff --git a/packages/flutter_tools/test/all.dart b/packages/flutter_tools/test/all.dart
index 1d1afeb..d929af8 100644
--- a/packages/flutter_tools/test/all.dart
+++ b/packages/flutter_tools/test/all.dart
@@ -23,8 +23,8 @@
 import 'listen_test.dart' as listen_test;
 import 'logs_test.dart' as logs_test;
 import 'os_utils_test.dart' as os_utils_test;
+import 'protocol_discovery_test.dart' as protocol_discovery_test;
 import 'run_test.dart' as run_test;
-import 'service_protocol_test.dart' as service_protocol_test;
 import 'stop_test.dart' as stop_test;
 import 'toolchain_test.dart' as toolchain_test;
 import 'trace_test.dart' as trace_test;
@@ -47,8 +47,8 @@
   listen_test.main();
   logs_test.main();
   os_utils_test.main();
+  protocol_discovery_test.main();
   run_test.main();
-  service_protocol_test.main();
   stop_test.main();
   toolchain_test.main();
   trace_test.main();
diff --git a/packages/flutter_tools/test/service_protocol_test.dart b/packages/flutter_tools/test/protocol_discovery_test.dart
similarity index 88%
rename from packages/flutter_tools/test/service_protocol_test.dart
rename to packages/flutter_tools/test/protocol_discovery_test.dart
index 567517b..bd0317d 100644
--- a/packages/flutter_tools/test/service_protocol_test.dart
+++ b/packages/flutter_tools/test/protocol_discovery_test.dart
@@ -3,9 +3,9 @@
 // found in the LICENSE file.
 
 import 'dart:async';
-import 'package:test/test.dart';
 
-import 'package:flutter_tools/src/service_protocol.dart';
+import 'package:flutter_tools/src/protocol_discovery.dart';
+import 'package:test/test.dart';
 
 import 'src/mocks.dart';
 
@@ -13,8 +13,8 @@
   group('service_protocol', () {
     test('Discovery Heartbeat', () async {
       MockDeviceLogReader logReader = new MockDeviceLogReader();
-      ServiceProtocolDiscovery discoverer =
-          new ServiceProtocolDiscovery(logReader, ServiceProtocolDiscovery.kObservatoryService);
+      ProtocolDiscovery discoverer =
+          new ProtocolDiscovery(logReader, ProtocolDiscovery.kObservatoryService);
 
       // Get next port future.
       Future<int> nextPort = discoverer.nextPort();