Add a stack chain class to the stack trace package.

This class uses zones to track stack traces across asynchronous boundaries. It
will aid considerably in debugging errors in heavily-asynchronous programs.

R=floitsch@google.com, rnystrom@google.com
BUG=7040

Review URL: https://codereview.chromium.org//75863004

git-svn-id: https://dart.googlecode.com/svn/branches/bleeding_edge/dart/pkg/stack_trace@30738 260f80e4-7a28-3924-810f-c04153c831b5
diff --git a/pkgs/stack_trace/lib/src/chain.dart b/pkgs/stack_trace/lib/src/chain.dart
new file mode 100644
index 0000000..f6e2f3f
--- /dev/null
+++ b/pkgs/stack_trace/lib/src/chain.dart
@@ -0,0 +1,173 @@
+// Copyright (c) 2013, 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.
+
+library stack_trace.chain;
+
+import 'dart:async';
+import 'dart:collection';
+
+import 'stack_zone_specification.dart';
+import 'trace.dart';
+import 'utils.dart';
+
+/// A function that handles errors in the zone wrapped by [Chain.capture].
+typedef void ChainHandler(error, Chain chain);
+
+/// A chain of stack traces.
+///
+/// A stack chain is a collection of one or more stack traces that collectively
+/// represent the path from [main] through nested function calls to a particular
+/// code location, usually where an error was thrown. Multiple stack traces are
+/// necessary when using asynchronous functions, since the program's stack is
+/// reset before each asynchronous callback is run.
+///
+/// Stack chains can be automatically tracked using [Chain.capture]. This sets
+/// up a new [Zone] in which the current stack chain is tracked and can be
+/// accessed using [new Chain.current]. Any errors that would be top-leveled in
+/// the zone can be handled, along with their associated chains, with the
+/// `onError` callback.
+///
+/// For the most part [Chain.capture] will notice when an error is thrown and
+/// associate the correct stack chain with it; the chain can be accessed using
+/// [new Chain.forTrace]. However, there are some cases where exceptions won't
+/// be automatically detected: any [Future] constructor,
+/// [Completer.completeError], [Stream.addError], and libraries that use these.
+/// For these, all you need to do is wrap the Future or Stream in a call to
+/// [Chain.track] and the errors will be tracked correctly.
+class Chain implements StackTrace {
+  /// The line used in the string representation of stack chains to represent
+  /// the gap between traces.
+  static const _GAP = '===== asynchronous gap ===========================\n';
+
+  /// The stack traces that make up this chain.
+  ///
+  /// Like the frames in a stack trace, the traces are ordered from most local
+  /// to least local. The first one is the trace where the actual exception was
+  /// raised, the second one is where that callback was scheduled, and so on.
+  final List<Trace> traces;
+
+  /// The [StackZoneSpecification] for the current zone.
+  static StackZoneSpecification get _currentSpec =>
+    Zone.current[#stack_trace.stack_zone.spec];
+
+  /// Runs [callback] in a [Zone] in which the current stack chain is tracked
+  /// and automatically associated with (most) errors.
+  ///
+  /// If [onError] is passed, any error in the zone that would otherwise go
+  /// unhandled is passed to it, along with the [Chain] associated with that
+  /// error. Note that if [callback] produces multiple unhandled errors,
+  /// [onError] may be called more than once. If [onError] isn't passed, the
+  /// parent Zone's `unhandledErrorHandler` will be called with the error and
+  /// its chain.
+  ///
+  /// For the most part an error thrown in the zone will have the correct stack
+  /// chain associated with it. However, there are some cases where exceptions
+  /// won't be automatically detected: any [Future] constructor,
+  /// [Completer.completeError], [Stream.addError], and libraries that use
+  /// these. For these, all you need to do is wrap the Future or Stream in a
+  /// call to [Chain.track] and the errors will be tracked correctly.
+  ///
+  /// Note that even if [onError] isn't passed, this zone will still be an error
+  /// zone. This means that any errors that would cross the zone boundary are
+  /// considered unhandled.
+  ///
+  /// If [callback] returns a value, it will be returned by [capture] as well.
+  ///
+  /// Currently, capturing stack chains doesn't work when using dart2js due to
+  /// issues [15171] and [15105]. Stack chains reported on dart2js will contain
+  /// only one trace.
+  ///
+  /// [15171]: https://code.google.com/p/dart/issues/detail?id=15171
+  /// [15105]: https://code.google.com/p/dart/issues/detail?id=15105
+  static capture(callback(), {ChainHandler onError}) {
+    var spec = new StackZoneSpecification(onError);
+    return runZoned(callback, zoneSpecification: spec.toSpec(), zoneValues: {
+      #stack_trace.stack_zone.spec: spec
+    });
+  }
+
+  /// Ensures that any errors emitted by [futureOrStream] have the correct stack
+  /// chain information associated with them.
+  ///
+  /// For the most part an error thrown within a [capture] zone will have the
+  /// correct stack chain automatically associated with it. However, there are
+  /// some cases where exceptions won't be automatically detected: any [Future]
+  /// constructor, [Completer.completeError], [Stream.addError], and libraries
+  /// that use these.
+  ///
+  /// This returns a [Future] or [Stream] that will emit the same values and
+  /// errors as [futureOrStream]. The only exception is that if [futureOrStream]
+  /// emits an error without a stack trace, one will be added in the return
+  /// value.
+  ///
+  /// If this is called outside of a [capture] zone, it just returns
+  /// [futureOrStream] as-is.
+  ///
+  /// As the name suggests, [futureOrStream] may be either a [Future] or a
+  /// [Stream].
+  static track(futureOrStream) {
+    if (_currentSpec == null) return futureOrStream;
+    if (futureOrStream is Future) {
+      return _currentSpec.trackFuture(futureOrStream, 1);
+    } else {
+      return _currentSpec.trackStream(futureOrStream, 1);
+    }
+  }
+
+  /// Returns the current stack chain.
+  ///
+  /// By default, the first frame of the first trace will be the line where
+  /// [Chain.current] is called. If [level] is passed, the first trace will
+  /// start that many frames up instead.
+  ///
+  /// If this is called outside of a [capture] zone, it just returns a
+  /// single-trace chain.
+  factory Chain.current([int level=0]) {
+    if (_currentSpec != null) return _currentSpec.currentChain(level + 1);
+    return new Chain([new Trace.current(level + 1)]);
+  }
+
+  /// Returns the stack chain associated with [trace].
+  ///
+  /// The first stack trace in the returned chain will always be [trace]
+  /// (converted to a [Trace] if necessary). If there is no chain associated
+  /// with [trace] or if this is called outside of a [capture] zone, this just
+  /// returns a single-trace chain containing [trace].
+  ///
+  /// If [trace] is already a [Chain], it will be returned as-is.
+  factory Chain.forTrace(StackTrace trace) {
+    if (trace is Chain) return trace;
+    if (_currentSpec == null) return new Chain([new Trace.from(trace)]);
+    return _currentSpec.chainFor(trace);
+  }
+
+  /// Parses a string representation of a stack chain.
+  ///
+  /// Specifically, this parses the output of [Chain.toString].
+  factory Chain.parse(String chain) =>
+    new Chain(chain.split(_GAP).map((trace) => new Trace.parseFriendly(trace)));
+
+  /// Returns a new [Chain] comprised of [traces].
+  Chain(Iterable<Trace> traces)
+      : traces = new UnmodifiableListView<Trace>(traces.toList());
+
+  /// Returns a terser version of [this].
+  ///
+  /// This calls [Trace.terse] on every trace in [traces], and discards any
+  /// trace that contain only internal frames.
+  Chain get terse {
+    return new Chain(traces.map((trace) => trace.terse).where((trace) {
+      // Ignore traces that contain only internal processing.
+      return trace.frames.length > 1;
+    }));
+  }
+
+  /// Converts [this] to a [Trace].
+  ///
+  /// The trace version of a chain is just the concatenation of all the traces
+  /// in the chain.
+  Trace toTrace() => new Trace(flatten(traces.map((trace) => trace.frames)));
+
+  String toString() => traces.join(_GAP);
+}
diff --git a/pkgs/stack_trace/lib/src/stack_zone_specification.dart b/pkgs/stack_trace/lib/src/stack_zone_specification.dart
new file mode 100644
index 0000000..36e0717
--- /dev/null
+++ b/pkgs/stack_trace/lib/src/stack_zone_specification.dart
@@ -0,0 +1,205 @@
+// Copyright (c) 2013, 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.
+
+library stack_trace.stack_zone_specification;
+
+import 'dart:async';
+
+import 'trace.dart';
+import 'chain.dart';
+
+/// A class encapsulating the zone specification for a [Chain.capture] zone.
+///
+/// Until they're materialized and exposed to the user, stack chains are tracked
+/// as linked lists of [Trace]s using the [_Node] class. These nodes are stored
+/// in three distinct ways:
+///
+/// * When a callback is registered, a node is created and stored as a captured
+///   local variable until the callback is run.
+///
+/// * When a callback is run, its captured node is set as the [_currentNode] so
+///   it can be available to [Chain.current] and to be linked into additional
+///   chains when more callbacks are scheduled.
+///
+/// * When a callback throws an error or a [Chain.track]ed Future or Stream
+///   emits an error, the current node is associated with that error's stack
+///   trace using the [_chains] expando.
+///
+/// Since [ZoneSpecification] can't be extended or even implemented, in order to
+/// get a real [ZoneSpecification] instance it's necessary to call [toSpec].
+class StackZoneSpecification {
+  /// The expando that associates stack chains with [StackTrace]s.
+  ///
+  /// The chains are associated with stack traces rather than errors themselves
+  /// because it's a common practice to throw strings as errors, which can't be
+  /// used with expandos.
+  ///
+  /// The chain associated with a given stack trace doesn't contain a node for
+  /// that stack trace.
+  final _chains = new Expando<_Node>("stack chains");
+
+  /// The error handler for the zone.
+  ///
+  /// If this is null, that indicates that any unhandled errors should be passed
+  /// to the parent zone.
+  final ChainHandler _onError;
+
+  /// The most recent node of the current stack chain.
+  _Node _currentNode;
+
+  StackZoneSpecification([this._onError]);
+
+  /// Converts [this] to a real [ZoneSpecification].
+  ZoneSpecification toSpec() {
+    return new ZoneSpecification(
+        handleUncaughtError: handleUncaughtError,
+        registerCallback: registerCallback,
+        registerUnaryCallback: registerUnaryCallback,
+        registerBinaryCallback: registerBinaryCallback);
+  }
+
+  /// Returns the current stack chain.
+  ///
+  /// By default, the first frame of the first trace will be the line where
+  /// [currentChain] is called. If [level] is passed, the first trace will start
+  /// that many frames up instead.
+  Chain currentChain([int level=0]) => _createNode(level + 1).toChain();
+
+  /// Returns the stack chain associated with [trace], if one exists.
+  ///
+  /// The first stack trace in the returned chain will always be [trace]
+  /// (converted to a [Trace] if necessary). If there is no chain associated
+  /// with [trace], this just returns a single-trace chain containing [trace].
+  Chain chainFor(StackTrace trace) {
+    if (trace is Chain) return trace;
+    var previous = trace == null ? null : _chains[trace];
+    return new _Node(trace, previous).toChain();
+  }
+
+  /// Ensures that an error emitted by [future] has the correct stack
+  /// information associated with it.
+  ///
+  /// By default, the first frame of the first trace will be the line where
+  /// [trackFuture] is called. If [level] is passed, the first trace will start
+  /// that many frames up instead.
+  Future trackFuture(Future future, [int level=0]) {
+    var completer = new Completer.sync();
+    var node = _createNode(level + 1);
+    future.then(completer.complete).catchError((e, stackTrace) {
+      if (stackTrace == null) stackTrace = new Trace.current();
+      if (_chains[stackTrace] == null) _chains[stackTrace] = node;
+      completer.completeError(e, stackTrace);
+    });
+    return completer.future;
+  }
+
+  /// Ensures that any errors emitted by [stream] have the correct stack
+  /// information associated with them.
+  ///
+  /// By default, the first frame of the first trace will be the line where
+  /// [trackStream] is called. If [level] is passed, the first trace will start
+  /// that many frames up instead.
+  Stream trackStream(Stream stream, [int level=0]) {
+    var node = _createNode(level + 1);
+    return stream.transform(new StreamTransformer.fromHandlers(
+        handleError: (error, stackTrace, sink) {
+      if (stackTrace == null) stackTrace = new Trace.current();
+      if (_chains[stackTrace] == null) _chains[stackTrace] = node;
+      sink.addError(error, stackTrace);
+    }));
+  }
+
+  /// Tracks the current stack chain so it can be set to [_currentChain] when
+  /// [f] is run.
+  ZoneCallback registerCallback(Zone self, ZoneDelegate parent, Zone zone,
+      Function f) {
+    if (f == null) return parent.registerCallback(zone, null);
+    var node = _createNode(1);
+    return parent.registerCallback(zone, () => _run(f, node));
+  }
+
+  /// Tracks the current stack chain so it can be set to [_currentChain] when
+  /// [f] is run.
+  ZoneUnaryCallback registerUnaryCallback(Zone self, ZoneDelegate parent,
+      Zone zone, Function f) {
+    if (f == null) return parent.registerUnaryCallback(zone, null);
+    var node = _createNode(1);
+    return parent.registerUnaryCallback(zone, (arg) {
+      return _run(() => f(arg), node);
+    });
+  }
+
+  /// Tracks the current stack chain so it can be set to [_currentChain] when
+  /// [f] is run.
+  ZoneBinaryCallback registerBinaryCallback(Zone self, ZoneDelegate parent,
+      Zone zone, Function f) {
+    if (f == null) return parent.registerBinaryCallback(zone, null);
+    var node = _createNode(1);
+    return parent.registerBinaryCallback(zone, (arg1, arg2) {
+      return _run(() => f(arg1, arg2), node);
+    });
+  }
+
+  /// Looks up the chain associated with [stackTrace] and passes it either to
+  /// [_onError] or [parent]'s error handler.
+  handleUncaughtError(Zone self, ZoneDelegate parent, Zone zone, error,
+      StackTrace stackTrace) {
+    if (_onError == null) {
+      return parent.handleUncaughtError(zone, error, chainFor(stackTrace));
+    } else {
+      _onError(error, chainFor(stackTrace));
+    }
+  }
+
+  /// Creates a [_Node] with the current stack trace and linked to
+  /// [_currentNode].
+  ///
+  /// By default, the first frame of the first trace will be the line where
+  /// [_createNode] is called. If [level] is passed, the first trace will start
+  /// that many frames up instead.
+  _Node _createNode([int level=0]) =>
+    new _Node(new Trace.current(level + 1), _currentNode);
+
+  // TODO(nweiz): use a more robust way of detecting and tracking errors when
+  // issue 15105 is fixed.
+  /// Runs [f] with [_currentNode] set to [node].
+  ///
+  /// If [f] throws an error, this associates [node] with that error's stack
+  /// trace.
+  _run(Function f, _Node node) {
+    var previousNode = _currentNode;
+    _currentNode = node;
+    try {
+      return f();
+    } catch (e, stackTrace) {
+      _chains[stackTrace] = node;
+      rethrow;
+    } finally {
+      _currentNode = previousNode;
+    }
+  }
+}
+
+/// A linked list node representing a single entry in a stack chain.
+class _Node {
+  /// The stack trace for this link of the chain.
+  final Trace trace;
+
+  /// The previous node in the chain.
+  final _Node previous;
+
+  _Node(StackTrace trace, [this.previous])
+      : trace = trace == null ? new Trace.current() : new Trace.from(trace);
+
+  /// Converts this to a [Chain].
+  Chain toChain() {
+    var nodes = <Trace>[];
+    var node = this;
+    while (node != null) {
+      nodes.add(node.trace);
+      node = node.previous;
+    }
+    return new Chain(nodes);
+  }
+}
diff --git a/pkgs/stack_trace/lib/src/trace.dart b/pkgs/stack_trace/lib/src/trace.dart
index c3db43e..88b9275 100644
--- a/pkgs/stack_trace/lib/src/trace.dart
+++ b/pkgs/stack_trace/lib/src/trace.dart
@@ -7,6 +7,7 @@
 import 'dart:collection';
 import 'dart:math' as math;
 
+import 'chain.dart';
 import 'frame.dart';
 import 'lazy_trace.dart';
 import 'utils.dart';
@@ -92,6 +93,7 @@
   /// a [Trace], it will be returned as-is.
   factory Trace.from(StackTrace trace) {
     if (trace is Trace) return trace;
+    if (trace is Chain) return trace.toTrace();
     return new LazyTrace(() => new Trace.parse(trace.toString()));
   }
 
@@ -172,9 +174,14 @@
           .where((line) => line != '[native code]')
           .map((line) => new Frame.parseFirefox(line)));
 
-  /// Parses this package's a string representation of a stack trace.
+  /// Parses this package's string representation of a stack trace.
+  ///
+  /// This also parses string representations of [Chain]s. They parse to the
+  /// same trace that [Chain.toTrace] would return.
   Trace.parseFriendly(String trace)
       : this(trace.trim().split("\n")
+          // Filter out asynchronous gaps from [Chain]s.
+          .where((line) => !line.startsWith('====='))
           .map((line) => new Frame.parseFriendly(line)));
 
   /// Returns a new [Trace] comprised of [frames].
@@ -191,10 +198,13 @@
   /// Returns a terser version of [this].
   ///
   /// This is accomplished by folding together multiple stack frames from the
-  /// core library, as in [foldFrames]. Remaining core library frames have their
-  /// libraries, "-patch" suffixes, and line numbers removed.
+  /// core library or from this package, as in [foldFrames]. Remaining core
+  /// library frames have their libraries, "-patch" suffixes, and line numbers
+  /// removed.
   Trace get terse {
-    return new Trace(foldFrames((frame) => frame.isCore).frames.map((frame) {
+    return new Trace(foldFrames((frame) {
+      return frame.isCore || frame.package == 'stack_trace';
+    }).frames.map((frame) {
       if (!frame.isCore) return frame;
       var library = frame.library.replaceAll(_terseRegExp, '');
       return new Frame(Uri.parse(library), null, null, frame.member);
diff --git a/pkgs/stack_trace/lib/src/utils.dart b/pkgs/stack_trace/lib/src/utils.dart
index 08b3b96..62a2820 100644
--- a/pkgs/stack_trace/lib/src/utils.dart
+++ b/pkgs/stack_trace/lib/src/utils.dart
@@ -18,3 +18,19 @@
   return result.toString();
 }
 
+/// Flattens nested lists inside an iterable into a single list containing only
+/// non-list elements.
+List flatten(Iterable nested) {
+  var result = [];
+  helper(list) {
+    for (var element in list) {
+      if (element is List) {
+        helper(element);
+      } else {
+        result.add(element);
+      }
+    }
+  }
+  helper(nested);
+  return result;
+}
diff --git a/pkgs/stack_trace/lib/stack_trace.dart b/pkgs/stack_trace/lib/stack_trace.dart
index dba95e9..ac875a9 100644
--- a/pkgs/stack_trace/lib/stack_trace.dart
+++ b/pkgs/stack_trace/lib/stack_trace.dart
@@ -25,3 +25,4 @@
 
 export 'src/trace.dart';
 export 'src/frame.dart';
+export 'src/chain.dart';
diff --git a/pkgs/stack_trace/test/chain_test.dart b/pkgs/stack_trace/test/chain_test.dart
new file mode 100644
index 0000000..4a58a64
--- /dev/null
+++ b/pkgs/stack_trace/test/chain_test.dart
@@ -0,0 +1,618 @@
+// Copyright (c) 2013, 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.
+
+library chain_test;
+
+import 'dart:async';
+
+import 'package:stack_trace/stack_trace.dart';
+import 'package:unittest/unittest.dart';
+
+import 'utils.dart';
+
+void main() {
+  group('capture() with onError catches exceptions', () {
+    test('thrown in a microtask', () {
+      return captureFuture(() => inMicrotask(() => throw 'error'))
+          .then((chain) {
+        // Since there was only one asynchronous operation, there should be only
+        // two traces in the chain.
+        expect(chain.traces, hasLength(2));
+
+        // The first frame of the first trace should be the line on which the
+        // actual error was thrown.
+        expect(chain.traces[0].frames.first, frameMember(startsWith('main')));
+
+        // The second trace should describe the stack when the error callback
+        // was scheduled.
+        expect(chain.traces[1].frames,
+            contains(frameMember(startsWith('inMicrotask'))));
+      });
+    });
+
+    test('thrown in a one-shot timer', () {
+      return captureFuture(() => inOneShotTimer(() => throw 'error'))
+          .then((chain) {
+        expect(chain.traces, hasLength(2));
+        expect(chain.traces[0].frames.first, frameMember(startsWith('main')));
+        expect(chain.traces[1].frames,
+            contains(frameMember(startsWith('inOneShotTimer'))));
+      });
+    });
+
+    test('thrown in a periodic timer', () {
+      return captureFuture(() => inPeriodicTimer(() => throw 'error'))
+          .then((chain) {
+        expect(chain.traces, hasLength(2));
+        expect(chain.traces[0].frames.first, frameMember(startsWith('main')));
+        expect(chain.traces[1].frames,
+            contains(frameMember(startsWith('inPeriodicTimer'))));
+      });
+    });
+
+    test('thrown in a nested series of asynchronous operations', () {
+      return captureFuture(() {
+        inPeriodicTimer(() {
+          inOneShotTimer(() => inMicrotask(() => throw 'error'));
+        });
+      }).then((chain) {
+        expect(chain.traces, hasLength(4));
+        expect(chain.traces[0].frames.first, frameMember(startsWith('main')));
+        expect(chain.traces[1].frames,
+            contains(frameMember(startsWith('inMicrotask'))));
+        expect(chain.traces[2].frames,
+            contains(frameMember(startsWith('inOneShotTimer'))));
+        expect(chain.traces[3].frames,
+            contains(frameMember(startsWith('inPeriodicTimer'))));
+      });
+    });
+
+    test('thrown in a long future chain', () {
+      return captureFuture(() => inFutureChain(() => throw 'error'))
+          .then((chain) {
+        // Despite many asynchronous operations, there's only one level of
+        // nested calls, so there should be only two traces in the chain. This
+        // is important; programmers expect stack trace memory consumption to be
+        // O(depth of program), not O(length of program).
+        expect(chain.traces, hasLength(2));
+
+        expect(chain.traces[0].frames.first, frameMember(startsWith('main')));
+        expect(chain.traces[1].frames,
+            contains(frameMember(startsWith('inFutureChain'))));
+      });
+    });
+
+    test('multiple times', () {
+      var completer = new Completer();
+      var first = true;
+
+      Chain.capture(() {
+        inMicrotask(() => throw 'first error');
+        inPeriodicTimer(() => throw 'second error');
+      }, onError: (error, chain) {
+        if (first) {
+          expect(error, equals('first error'));
+          expect(chain.traces[1].frames,
+              contains(frameMember(startsWith('inMicrotask'))));
+          first = false;
+        } else {
+          expect(error, equals('second error'));
+          expect(chain.traces[1].frames,
+              contains(frameMember(startsWith('inPeriodicTimer'))));
+          completer.complete();
+        }
+      });
+
+      return completer.future;
+    });
+  });
+
+  test('capture() without onError passes exceptions to parent zone', () {
+    var completer = new Completer();
+
+    runZoned(() {
+      Chain.capture(() => inMicrotask(() => throw 'error'));
+    }, onError: (error, chain) {
+      expect(error, equals('error'));
+      expect(chain, new isInstanceOf<Chain>());
+      expect(chain.traces[1].frames,
+          contains(frameMember(startsWith('inMicrotask'))));
+      completer.complete();
+    });
+
+    return completer.future;
+  });
+
+  group('current() within capture()', () {
+    test('called in a microtask', () {
+      var completer = new Completer();
+      Chain.capture(() {
+        inMicrotask(() => completer.complete(new Chain.current()));
+      });
+
+      return completer.future.then((chain) {
+        expect(chain.traces, hasLength(2));
+        expect(chain.traces[0].frames.first, frameMember(startsWith('main')));
+        expect(chain.traces[1].frames,
+            contains(frameMember(startsWith('inMicrotask'))));
+      });
+    });
+
+    test('called in a one-shot timer', () {
+      var completer = new Completer();
+      Chain.capture(() {
+        inOneShotTimer(() => completer.complete(new Chain.current()));
+      });
+
+      return completer.future.then((chain) {
+        expect(chain.traces, hasLength(2));
+        expect(chain.traces[0].frames.first, frameMember(startsWith('main')));
+        expect(chain.traces[1].frames,
+            contains(frameMember(startsWith('inOneShotTimer'))));
+      });
+    });
+
+    test('called in a periodic timer', () {
+      var completer = new Completer();
+      Chain.capture(() {
+        inPeriodicTimer(() => completer.complete(new Chain.current()));
+      });
+
+      return completer.future.then((chain) {
+        expect(chain.traces, hasLength(2));
+        expect(chain.traces[0].frames.first, frameMember(startsWith('main')));
+        expect(chain.traces[1].frames,
+            contains(frameMember(startsWith('inPeriodicTimer'))));
+      });
+    });
+
+    test('called in a nested series of asynchronous operations', () {
+      var completer = new Completer();
+      Chain.capture(() {
+        inPeriodicTimer(() {
+          inOneShotTimer(() {
+            inMicrotask(() => completer.complete(new Chain.current()));
+          });
+        });
+      });
+
+      return completer.future.then((chain) {
+        expect(chain.traces, hasLength(4));
+        expect(chain.traces[0].frames.first, frameMember(startsWith('main')));
+        expect(chain.traces[1].frames,
+            contains(frameMember(startsWith('inMicrotask'))));
+        expect(chain.traces[2].frames,
+            contains(frameMember(startsWith('inOneShotTimer'))));
+        expect(chain.traces[3].frames,
+            contains(frameMember(startsWith('inPeriodicTimer'))));
+      });
+    });
+
+    test('called in a long future chain', () {
+      var completer = new Completer();
+      Chain.capture(() {
+        inFutureChain(() => completer.complete(new Chain.current()));
+      });
+
+      return completer.future.then((chain) {
+        expect(chain.traces, hasLength(2));
+        expect(chain.traces[0].frames.first, frameMember(startsWith('main')));
+        expect(chain.traces[1].frames,
+            contains(frameMember(startsWith('inFutureChain'))));
+      });
+    });
+  });
+
+  test('current() outside of capture() returns a chain wrapping the current '
+      'trace', () {
+    var completer = new Completer();
+    inMicrotask(() => completer.complete(new Chain.current()));
+
+    return completer.future.then((chain) {
+      // Since the chain wasn't loaded within [Chain.capture], the full stack
+      // chain isn't available and it just returns the current stack when
+      // called.
+      expect(chain.traces, hasLength(1));
+      expect(chain.traces.first.frames.first, frameMember(startsWith('main')));
+    });
+  });
+
+  group('forTrace() within capture()', () {
+    test('called for a stack trace from a microtask', () {
+      return Chain.capture(() {
+        return chainForTrace(inMicrotask, () => throw 'error');
+      }).then((chain) {
+        // Because [chainForTrace] has to set up a future chain to capture the
+        // stack trace while still showing it to the zone specification, it adds
+        // an additional level of async nesting and so an additional trace.
+        expect(chain.traces, hasLength(3));
+        expect(chain.traces[0].frames.first, frameMember(startsWith('main')));
+        expect(chain.traces[1].frames,
+            contains(frameMember(startsWith('chainForTrace'))));
+        expect(chain.traces[2].frames,
+            contains(frameMember(startsWith('inMicrotask'))));
+      });
+    });
+
+    test('called for a stack trace from a one-shot timer', () {
+      return Chain.capture(() {
+        return chainForTrace(inOneShotTimer, () => throw 'error');
+      }).then((chain) {
+        expect(chain.traces, hasLength(3));
+        expect(chain.traces[0].frames.first, frameMember(startsWith('main')));
+        expect(chain.traces[1].frames,
+            contains(frameMember(startsWith('chainForTrace'))));
+        expect(chain.traces[2].frames,
+            contains(frameMember(startsWith('inOneShotTimer'))));
+      });
+    });
+
+    test('called for a stack trace from a periodic timer', () {
+      return Chain.capture(() {
+        return chainForTrace(inPeriodicTimer, () => throw 'error');
+      }).then((chain) {
+        expect(chain.traces, hasLength(3));
+        expect(chain.traces[0].frames.first, frameMember(startsWith('main')));
+        expect(chain.traces[1].frames,
+            contains(frameMember(startsWith('chainForTrace'))));
+        expect(chain.traces[2].frames,
+            contains(frameMember(startsWith('inPeriodicTimer'))));
+      });
+    });
+
+    test('called for a stack trace from a nested series of asynchronous '
+        'operations', () {
+      return Chain.capture(() {
+        return chainForTrace((callback) {
+          inPeriodicTimer(() => inOneShotTimer(() => inMicrotask(callback)));
+        }, () => throw 'error');
+      }).then((chain) {
+        expect(chain.traces, hasLength(5));
+        expect(chain.traces[0].frames.first, frameMember(startsWith('main')));
+        expect(chain.traces[1].frames,
+            contains(frameMember(startsWith('chainForTrace'))));
+        expect(chain.traces[2].frames,
+            contains(frameMember(startsWith('inMicrotask'))));
+        expect(chain.traces[3].frames,
+            contains(frameMember(startsWith('inOneShotTimer'))));
+        expect(chain.traces[4].frames,
+            contains(frameMember(startsWith('inPeriodicTimer'))));
+      });
+    });
+
+    test('called for a stack trace from a long future chain', () {
+      return Chain.capture(() {
+        return chainForTrace(inFutureChain, () => throw 'error');
+      }).then((chain) {
+        expect(chain.traces, hasLength(3));
+        expect(chain.traces[0].frames.first, frameMember(startsWith('main')));
+        expect(chain.traces[1].frames,
+            contains(frameMember(startsWith('chainForTrace'))));
+        expect(chain.traces[2].frames,
+            contains(frameMember(startsWith('inFutureChain'))));
+      });
+    });
+
+    test('called for an unregistered stack trace returns a chain wrapping that '
+        'trace', () {
+      var trace;
+      var chain = Chain.capture(() {
+        try {
+          throw 'error';
+        } catch (_, stackTrace) {
+          trace = stackTrace;
+          return new Chain.forTrace(stackTrace);
+        }
+      });
+
+      expect(chain.traces, hasLength(1));
+      expect(chain.traces.first.toString(),
+          equals(new Trace.from(trace).toString()));
+    });
+  });
+
+  test('forTrace() outside of capture() returns a chain wrapping the given '
+      'trace', () {
+    var trace;
+    var chain = Chain.capture(() {
+      try {
+        throw 'error';
+      } catch (_, stackTrace) {
+        trace = stackTrace;
+        return new Chain.forTrace(stackTrace);
+      }
+    });
+
+    expect(chain.traces, hasLength(1));
+    expect(chain.traces.first.toString(),
+        equals(new Trace.from(trace).toString()));
+  });
+
+  test('Chain.parse() parses a real Chain', () {
+    return captureFuture(() => inMicrotask(() => throw 'error')).then((chain) {
+      expect(new Chain.parse(chain.toString()).toString(),
+          equals(chain.toString()));
+    });
+  });
+
+  group('Chain.terse', () {
+    test('makes each trace terse', () {
+      var chain = new Chain([
+        new Trace.parse(
+            'dart:core 10:11       Foo.bar\n'
+            'dart:core 10:11       Bar.baz\n'
+            'user/code.dart 10:11  Bang.qux\n'
+            'dart:core 10:11       Zip.zap\n'
+            'dart:core 10:11       Zop.zoop'),
+        new Trace.parse(
+            'user/code.dart 10:11                        Bang.qux\n'
+            'dart:core 10:11                             Foo.bar\n'
+            'package:stack_trace/stack_trace.dart 10:11  Bar.baz\n'
+            'dart:core 10:11                             Zip.zap\n'
+            'user/code.dart 10:11                        Zop.zoop')
+      ]);
+
+      expect(chain.terse.toString(), equals(
+          'dart:core             Bar.baz\n'
+          'user/code.dart 10:11  Bang.qux\n'
+          'dart:core             Zop.zoop\n'
+          '===== asynchronous gap ===========================\n'
+          'user/code.dart 10:11  Bang.qux\n'
+          'dart:core             Zip.zap\n'
+          'user/code.dart 10:11  Zop.zoop\n'));
+    });
+
+    test('eliminates internal-only traces', () {
+      var chain = new Chain([
+        new Trace.parse(
+            'user/code.dart 10:11  Foo.bar\n'
+            'dart:core 10:11       Bar.baz'),
+        new Trace.parse(
+            'dart:core 10:11                             Foo.bar\n'
+            'package:stack_trace/stack_trace.dart 10:11  Bar.baz\n'
+            'dart:core 10:11                             Zip.zap'),
+        new Trace.parse(
+            'user/code.dart 10:11  Foo.bar\n'
+            'dart:core 10:11       Bar.baz')
+      ]);
+
+      expect(chain.terse.toString(), equals(
+          'user/code.dart 10:11  Foo.bar\n'
+          'dart:core             Bar.baz\n'
+          '===== asynchronous gap ===========================\n'
+          'user/code.dart 10:11  Foo.bar\n'
+          'dart:core             Bar.baz\n'));
+    });
+  });
+
+  test('Chain.toTrace eliminates asynchronous gaps', () {
+    var trace = new Chain([
+      new Trace.parse(
+          'user/code.dart 10:11  Foo.bar\n'
+          'dart:core 10:11       Bar.baz'),
+      new Trace.parse(
+          'user/code.dart 10:11  Foo.bar\n'
+          'dart:core 10:11       Bar.baz')
+    ]).toTrace();
+
+    expect(trace.toString(), equals(
+        'user/code.dart 10:11  Foo.bar\n'
+        'dart:core 10:11       Bar.baz\n'
+        'user/code.dart 10:11  Foo.bar\n'
+        'dart:core 10:11       Bar.baz\n'));
+  });
+
+  group('Chain.track(Future)', () {
+    test('associates the current chain with a manually-reported exception with '
+        'a stack trace', () {
+      var trace = new Trace.current();
+      return captureFuture(() {
+        inMicrotask(() => trackedErrorFuture(trace));
+      }).then((chain) {
+        expect(chain.traces, hasLength(3));
+
+        // The first trace is the trace that was manually reported for the
+        // error.
+        expect(chain.traces.first.toString(), equals(trace.toString()));
+
+        // The second trace is the trace that was captured when [Chain.track]
+        // was called.
+        expect(chain.traces[1].frames.first,
+            frameMember(startsWith('trackedErrorFuture')));
+
+        // The third trace is the automatically-captured trace from when the
+        // microtask was scheduled.
+        expect(chain.traces[2].frames,
+            contains(frameMember(startsWith('inMicrotask'))));
+      });
+    });
+
+    test('associates the current chain with a manually-reported exception with '
+        'no stack trace', () {
+      return captureFuture(() {
+        inMicrotask(() => trackedErrorFuture());
+      }).then((chain) {
+        expect(chain.traces, hasLength(3));
+
+        // The first trace is the one captured by
+        // [StackZoneSpecification.trackFuture], which should contain only
+        // stack_trace and dart: frames.
+        expect(chain.traces.first.frames,
+            everyElement(frameLibrary(isNot(contains('chain_test')))));
+
+        expect(chain.traces[1].frames.first,
+            frameMember(startsWith('trackedErrorFuture')));
+        expect(chain.traces[2].frames,
+            contains(frameMember(startsWith('inMicrotask'))));
+      });
+    });
+
+    test('forwards the future value within Chain.capture()', () {
+      Chain.capture(() {
+        expect(Chain.track(new Future.value('value')),
+            completion(equals('value')));
+
+        var trace = new Trace.current();
+        expect(Chain.track(new Future.error('error', trace))
+            .catchError((e, stackTrace) {
+          expect(e, equals('error'));
+          expect(stackTrace.toString(), equals(trace.toString()));
+        }), completes);
+      });
+    });
+
+    test('forwards the future value outside of Chain.capture()', () {
+      expect(Chain.track(new Future.value('value')),
+          completion(equals('value')));
+
+      var trace = new Trace.current();
+      expect(Chain.track(new Future.error('error', trace))
+          .catchError((e, stackTrace) {
+        expect(e, equals('error'));
+        expect(stackTrace.toString(), equals(trace.toString()));
+      }), completes);
+    });
+  });
+
+  group('Chain.track(Stream)', () {
+    test('associates the current chain with a manually-reported exception with '
+        'a stack trace', () {
+      var trace = new Trace.current();
+      return captureFuture(() {
+        inMicrotask(() => trackedErrorStream(trace).listen(null));
+      }).then((chain) {
+        expect(chain.traces, hasLength(3));
+        expect(chain.traces.first.toString(), equals(trace.toString()));
+        expect(chain.traces[1].frames.first,
+            frameMember(startsWith('trackedErrorStream')));
+        expect(chain.traces[2].frames,
+            contains(frameMember(startsWith('inMicrotask'))));
+      });
+    });
+
+    test('associates the current chain with a manually-reported exception with '
+        'no stack trace', () {
+      return captureFuture(() {
+        inMicrotask(() => trackedErrorStream().listen(null));
+      }).then((chain) {
+        expect(chain.traces, hasLength(3));
+        expect(chain.traces.first.frames,
+            everyElement(frameLibrary(isNot(contains('chain_test')))));
+        expect(chain.traces[1].frames.first,
+            frameMember(startsWith('trackedErrorStream')));
+        expect(chain.traces[2].frames,
+            contains(frameMember(startsWith('inMicrotask'))));
+      });
+    });
+
+    test('forwards stream values within Chain.capture()', () {
+      Chain.capture(() {
+        var controller = new StreamController()
+            ..add(1)..add(2)..add(3)..close();
+        expect(Chain.track(controller.stream).toList(),
+            completion(equals([1, 2, 3])));
+
+        var trace = new Trace.current();
+        controller = new StreamController()..addError('error', trace);
+        expect(Chain.track(controller.stream).toList()
+            .catchError((e, stackTrace) {
+          expect(e, equals('error'));
+          expect(stackTrace.toString(), equals(trace.toString()));
+        }), completes);
+      });
+    });
+
+    test('forwards stream values outside of Chain.capture()', () {
+      Chain.capture(() {
+        var controller = new StreamController()
+            ..add(1)..add(2)..add(3)..close();
+        expect(Chain.track(controller.stream).toList(),
+            completion(equals([1, 2, 3])));
+
+        var trace = new Trace.current();
+        controller = new StreamController()..addError('error', trace);
+        expect(Chain.track(controller.stream).toList()
+            .catchError((e, stackTrace) {
+          expect(e, equals('error'));
+          expect(stackTrace.toString(), equals(trace.toString()));
+        }), completes);
+      });
+    });
+  });
+}
+
+/// Runs [callback] in a microtask callback.
+void inMicrotask(callback()) => scheduleMicrotask(callback);
+
+/// Runs [callback] in a one-shot timer callback.
+void inOneShotTimer(callback()) => Timer.run(callback);
+
+/// Runs [callback] once in a periodic timer callback.
+void inPeriodicTimer(callback()) {
+  var count = 0;
+  new Timer.periodic(new Duration(milliseconds: 1), (timer) {
+    count++;
+    if (count != 5) return;
+    timer.cancel();
+    callback();
+  });
+}
+
+/// Runs [callback] within a long asynchronous Future chain.
+void inFutureChain(callback()) {
+  new Future(() {})
+      .then((_) => new Future(() {}))
+      .then((_) => new Future(() {}))
+      .then((_) => new Future(() {}))
+      .then((_) => new Future(() {}))
+      .then((_) => callback())
+      .then((_) => new Future(() {}));
+}
+
+/// Returns a Future that completes to an error and is wrapped in [Chain.track].
+///
+/// If [trace] is passed, it's used as the stack trace for the error.
+Future trackedErrorFuture([StackTrace trace]) {
+  var completer = new Completer();
+  completer.completeError('error', trace);
+  return Chain.track(completer.future);
+}
+
+/// Returns a Stream that emits an error and is wrapped in [Chain.track].
+///
+/// If [trace] is passed, it's used as the stack trace for the error.
+Stream trackedErrorStream([StackTrace trace]) {
+  var controller = new StreamController();
+  controller.addError('error', trace);
+  return Chain.track(controller.stream);
+}
+
+/// Runs [callback] within [asyncFn], then converts any errors raised into a
+/// [Chain] with [Chain.forTrace].
+Future<Chain> chainForTrace(asyncFn(callback()), callback()) {
+  var completer = new Completer();
+  asyncFn(() {
+    // We use `new Future.value().then(...)` here as opposed to [new Future] or
+    // [new Future.sync] because those methods don't pass the exception through
+    // the zone specification before propagating it, so there's no chance to
+    // attach a chain to its stack trace. See issue 15105.
+    new Future.value().then((_) => callback())
+        .catchError(completer.completeError);
+  });
+  return completer.future
+      .catchError((_, stackTrace) => new Chain.forTrace(stackTrace));
+}
+
+/// Runs [callback] in a [Chain.capture] zone and returns a Future that
+/// completes to the stack chain for an error thrown by [callback].
+///
+/// [callback] is expected to throw the string `"error"`.
+Future<Chain> captureFuture(callback()) {
+  var completer = new Completer<Chain>();
+  Chain.capture(callback, onError: (error, chain) {
+    expect(error, equals('error'));
+    completer.complete(chain);
+  });
+  return completer.future;
+}
diff --git a/pkgs/stack_trace/test/trace_test.dart b/pkgs/stack_trace/test/trace_test.dart
index ad916f1..4c3278f 100644
--- a/pkgs/stack_trace/test/trace_test.dart
+++ b/pkgs/stack_trace/test/trace_test.dart
@@ -171,6 +171,24 @@
           equals(Uri.parse("http://dartlang.org/foo/baz.dart")));
     });
 
+    test('parses a package:stack_trace stack chain correctly', () {
+      var trace = new Trace.parse(
+          'http://dartlang.org/foo/bar.dart 10:11  Foo.<fn>.bar\n'
+          'http://dartlang.org/foo/baz.dart        Foo.<fn>.bar\n'
+          '===== asynchronous gap ===========================\n'
+          'http://dartlang.org/foo/bang.dart 10:11  Foo.<fn>.bar\n'
+          'http://dartlang.org/foo/quux.dart        Foo.<fn>.bar');
+
+      expect(trace.frames[0].uri,
+          equals(Uri.parse("http://dartlang.org/foo/bar.dart")));
+      expect(trace.frames[1].uri,
+          equals(Uri.parse("http://dartlang.org/foo/baz.dart")));
+      expect(trace.frames[2].uri,
+          equals(Uri.parse("http://dartlang.org/foo/bang.dart")));
+      expect(trace.frames[3].uri,
+          equals(Uri.parse("http://dartlang.org/foo/quux.dart")));
+    });
+
     test('parses a real package:stack_trace stack trace correctly', () {
       var traceString = new Trace.current().toString();
       expect(new Trace.parse(traceString).toString(), equals(traceString));
diff --git a/pkgs/stack_trace/test/utils.dart b/pkgs/stack_trace/test/utils.dart
new file mode 100644
index 0000000..ed75631
--- /dev/null
+++ b/pkgs/stack_trace/test/utils.dart
@@ -0,0 +1,37 @@
+// Copyright (c) 2013, 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.
+
+library stack_trace.test.utils;
+
+import 'package:unittest/unittest.dart';
+
+/// Returns a matcher that runs [matcher] against a [Frame]'s `member` field.
+Matcher frameMember(matcher) =>
+  transform((frame) => frame.member, matcher, 'member');
+
+/// Returns a matcher that runs [matcher] against a [Frame]'s `library` field.
+Matcher frameLibrary(matcher) =>
+  transform((frame) => frame.library, matcher, 'library');
+
+/// Returns a matcher that runs [transformation] on its input, then matches
+/// the output against [matcher].
+///
+/// [description] should be a noun phrase that describes the relation of the
+/// output of [transformation] to its input.
+Matcher transform(transformation(value), matcher, String description) =>
+  new _TransformMatcher(transformation, wrapMatcher(matcher), description);
+
+class _TransformMatcher extends Matcher {
+  final Function _transformation;
+  final Matcher _matcher;
+  final String _description;
+
+  _TransformMatcher(this._transformation, this._matcher, this._description);
+
+  bool matches(item, Map matchState) =>
+    _matcher.matches(_transformation(item), matchState);
+
+  Description describe(Description description) =>
+    description.add(_description).add(' ').addDescriptionOf(_matcher);
+}