Repository: GoogleChrome/lighthouse
Pull request #8956 (2019-05-14 23:01:56)

core(tracehouse): move files to core/lib/tracehouse/
**Summary**
https://github.com/aslushnikov/tracium is basically a port of our files with a few things stripped out that we're encouraging external folks to use when trying to make sense of a trace. Keeping them in sync is going to be a nightmare unless we can publish from the source of truth. This is step 1 towards that goal.


1. (This PR) Move the required files to a `lib/tracium` directory 
2. (Followup) Eliminate any dependencies from `lib/tracium` to the rest of LH
3. (Followup) Port back the patches in https://github.com/aslushnikov/tracium.
4. Retire https://github.com/aslushnikov/tracium / decide our monorepo strategy / do something to make external consumption *and* internal maintenance of tracium easier
5. Continue to make ongoing generic improvements as necessary

cc @aslushnikov

**IMPORTANT FOR MERGE** Before merging we want to keep at least two commits separate to preserve history, all the file moves are in `core(tracium): move files to lib/tracium/` commit which should allow us not to lose blame history, etc


aslushnikov: > Retire https://github.com/aslushnikov/tracium / decide our monorepo strategy / do something to make external consumption and internal maintenance of tracium easier

@patrickhulce Yes! If tracium lives in Lighthouse repo (which is optimal imo) and publishes to tracium's npm, than we can just deprecate and archive the aslushnikov/tracium.

Very happy this is happening! 🎉
paulirish: offline we discussed naming this tracehouse instead.
patrickhulce: > Any ideas?

Monorepo where we actually depend on our own `@lighthouzzzzzzz/core` packages :)
brendankenny: > Monorepo where we actually depend on our own @lighthouzzzzzzz/core packages :)

the files would still be named the same without changes to either the lib/ or computed artifact files themselves, though?
patrickhulce: > the files would still be named the same without changes to either the lib/ or computed artifact files themselves, though?

Oh, I see what you were saying now. Why don't we start naming our computed `*-computed`? this has been a problem in metrics for a long time and it's definitely annoying :)
patrickhulce: What's the final naming verdict?

Twitter poll time? 😈
paulirish: 
brendan voiced a few more things:

* not great that we end up with a computed/trace-of-tab that's nearly empty. 
* we got 3 main-thread-tasks.js now. 
* between main-thread-tasks, tracing-processor and trace-of-tab, surely we can kill off one of those.


paulirish: > What's the final naming verdict?

IMO tracehouse naming seems fine.

> Why don't we start naming our computed *-computed? 

agree. i'd love this. (separate PR, natch)

> not great that we end up with a computed/trace-of-tab that's nearly empty.

aye. feels like a codesmell. dunno what we can do about it without adding too much magic.


> we got 3 main-thread-tasks.js now.

audits could be called 'long-tasks' instead, but perhaps too late for that? the -computed fix would then disambiguate between the others..

> between main-thread-tasks, tracing-processor and trace-of-tab, surely we can kill off one of those.

ayup. i think patrick was waiting to do the move first, but that seems rather tempting.
patrickhulce: > not great that we end up with a computed/trace-of-tab that's nearly empty.

I'm not quite as concerned about this thing. One's the meat and the other is the adaptation of our meat to the ultra-LH-specific "computed artifact" concept which as long as we have generic library type things is relatively unavoidable. If `tracehouse` were it's own package on NPM we require'd in I don't think we'd be that worried about having a lightweight `-computed` wrapper.

> audits could be called 'long-tasks' instead, but perhaps too late for that? the -computed fix would then disambiguate between the others..

The obvious step to me here is to parallel `-computed` and rename all our audits `*-audit` without changing their ids :) 

Maybe I'm misunderstanding the primary concern here, but it sounds like these are all problems because the files have the same name?

> i think patrick was waiting to do the move first, but that seems rather tempting.

Indeed I was, my plan for killing one of them is going to be controversial enough to warrant its own PR for discussion 😈 
patrickhulce: @brendankenny what do you need to see from me to get this merged? :)

(if all looks good I will go ahead and re-rebase to have just the two commits)
patrickhulce: > It's a definite architecture smell.

Ooooooooooh if we're just making commentary on the fact that it's not ideal that our repo is organized this way, then you know I wholeheartedly agree. But you already know my thoughts on this so no need for me to get the boxing gloves out 😉 

Patch:
diff --git a/clients/devtools-report-assets.js b/clients/devtools-report-assets.js
index a94320496..58b80f645 100644
--- a/clients/devtools-report-assets.js
+++ b/clients/devtools-report-assets.js
@@ -17,8 +17,8 @@
 const cachedResources = Runtime.cachedResources;
 
 module.exports = {
-  REPORT_CSS: cachedResources['audits2/lighthouse/report.css'],
-  REPORT_JAVASCRIPT: cachedResources['audits2/lighthouse/report.js'],
-  REPORT_TEMPLATE: cachedResources['audits2/lighthouse/template.html'],
-  REPORT_TEMPLATES: cachedResources['audits2/lighthouse/templates.html'],
+  REPORT_CSS: cachedResources['audits/lighthouse/report.css'],
+  REPORT_JAVASCRIPT: cachedResources['audits/lighthouse/report.js'],
+  REPORT_TEMPLATE: cachedResources['audits/lighthouse/template.html'],
+  REPORT_TEMPLATES: cachedResources['audits/lighthouse/templates.html'],
 };
diff --git a/docs/architecture.md b/docs/architecture.md
index 453a8b230..a3ed283df 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -39,7 +39,7 @@ driver.sendCommand('Security.enable');
 
 ## Understanding a Trace
 
-`lighthouse-core/computed/trace-of-tab.js` and `lighthouse-core/lib/traces/tracing-processor.js` provide the core transformation of a trace into more meaningful objects. Each raw trace event has a monotonically increasing timestamp in microseconds, a thread ID, a process ID, a duration in microseconds (potentially), and other applicable metadata properties such as the event type, the task name, the frame, etc. [Learn more about trace events](https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview).
+`lighthouse-core/lib/tracehouse/trace-of-tab.js` and `lighthouse-core/lib/tracehouse/tracing-processor.js` provide the core transformation of a trace into more meaningful objects. Each raw trace event has a monotonically increasing timestamp in microseconds, a thread ID, a process ID, a duration in microseconds (potentially), and other applicable metadata properties such as the event type, the task name, the frame, etc. [Learn more about trace events](https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview).
 
 ### Example Trace Event
 ```js
diff --git a/docs/readme.md b/docs/readme.md
index 4e376a975..0cd05d057 100644
--- a/docs/readme.md
+++ b/docs/readme.md
@@ -115,6 +115,14 @@ instance with an open debugging port.
 1. Navigate to your site and log in.
 1. In a separate terminal tab, run `lighthouse http://mysite.com --port port-number` using the port number from chrome-debug.
 
+## Testing on a site with an untrusted certificate
+
+When testing a site with an untrusted certificate, Chrome will be unable to load the page and so the Lighthouse report will mostly contain errors.
+
+If this certificate **is one you control** and is necessary for development (for instance, `localhost` with a self-signed certificate for local HTTP/2 testing), we recommend you _add the certificate to your locally-trusted certificate store_. In Chrome, see `Settings` > `Privacy and Security` > `Manage certificates` or consult instructions for adding to the certificate store in your operating system.
+
+Alternatively, you can instruct Chrome to ignore the invalid certificate by adding the Lighthouse CLI flag `--chrome-flags="--ignore-certificate-errors"`. However, you must be as careful with this flag as it's equivalent to browsing the web with TLS disabled. Any content loaded by the test page (e.g. third-party scripts or iframed ads) will *also* not be subject to certificate checks, [opening up avenues for MitM attacks](https://www.chromium.org/Home/chromium-security/education/tls#TOC-What-security-properties-does-TLS-give-me-). For these reasons, we recommend the earlier solution of adding the certificate to your local cert store.
+
 ## Testing on a mobile device
 
 Lighthouse can run against a real mobile device. You can follow the [Remote Debugging on Android (Legacy Workflow)](https://developer.chrome.com/devtools/docs/remote-debugging-legacy) up through step 3.3, but the TL;DR is install & run adb, enable USB debugging, then port forward 9222 from the device to the machine with Lighthouse.
diff --git a/docs/releasing.md b/docs/releasing.md
index eabdf233d..e65cc3475 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -44,24 +44,26 @@ bash ./lighthouse-core/scripts/release/test.sh
 # Package everything for publishing
 bash ./lighthouse-core/scripts/release/prepare-package.sh
 
+# Make sure you're in the Lighthouse pristine repo we just tested.
+cd ../lighthouse-pristine
+
+# Publish to NPM
+npm publish
+
+# Publish viewer
+yarn deploy-viewer
+
 # Upload the extension
-node build/build-extension.js package; cd dist/extension-package/
 open https://chrome.google.com/webstore/developer/edit/blipmdconlkpinefehnmjammfjpmpbjk
-echo "Upload the package zip to CWS dev dashboard"
+cd dist/extension-package/
+echo "Upload the package zip to CWS dev dashboard..."
 # Be in lighthouse-extension-owners group
 # Open <https://chrome.google.com/webstore/developer/dashboard>
 # Click _Edit_ on lighthouse
 # _Upload Updated Package_
-# Select `lighthouse-4.X.X.zip`
+# Select `lighthouse-X.X.X.zip`
 # _Publish_ at the bottom
 
-# Make sure you're in the Lighthouse pristine repo we just tested.
-cd ../lighthouse-pristine
-# Publish to NPM
-npm publish
-# Publish viewer
-yarn deploy-viewer
-
 # * Tell the world!!! *
 echo "Complete the _Release publicity_ tasks documented above"
 ```
diff --git a/lighthouse-cli/cli-flags.js b/lighthouse-cli/cli-flags.js
index 9b9ceb989..9e7188f04 100644
--- a/lighthouse-cli/cli-flags.js
+++ b/lighthouse-cli/cli-flags.js
@@ -62,6 +62,17 @@ function getFlags(manualArgv) {
           'lighthouse <url> --only-categories=performance,pwa',
           'Only run specific categories.')
 
+      /**
+       * Also accept a file for all of these flags. Yargs will merge in and override the file-based
+       * flags with the command-line flags.
+       *
+       * i.e. when command-line `--throttling-method=provided` and file `throttlingMethod: "devtools"`,
+       * throttlingMethod will be `provided`.
+       *
+       * @see https://github.com/yargs/yargs/blob/a6e67f15a61558d0ba28bfe53385332f0ce5d431/docs/api.md#config
+       */
+      .config('cli-flags-path')
+
       // List of options
       .group(['verbose', 'quiet'], 'Logging:')
       .describe({
@@ -78,6 +89,7 @@ function getFlags(manualArgv) {
         ],
         'Configuration:')
       .describe({
+        'cli-flags-path': 'The path to a JSON file that contains the desired CLI flags to apply. Flags specified at the command line will still override the file-based ones.',
         // We don't allowlist specific locales. Why? So we can support the user who requests 'es-MX' (unsupported) and we'll fall back to 'es' (supported)
         'locale': 'The locale/language the report should be formatted in',
         'enable-error-reporting':
diff --git a/lighthouse-core/audits/bootup-time.js b/lighthouse-core/audits/bootup-time.js
index a8be895b2..73a7880a2 100644
--- a/lighthouse-core/audits/bootup-time.js
+++ b/lighthouse-core/audits/bootup-time.js
@@ -7,7 +7,7 @@
 
 const Audit = require('./audit.js');
 const NetworkRequest = require('../lib/network-request.js');
-const {taskGroups} = require('../lib/task-groups.js');
+const {taskGroups} = require('../lib/tracehouse/task-groups.js');
 const i18n = require('../lib/i18n/i18n.js');
 const NetworkRecords = require('../computed/network-records.js');
 const MainThreadTasks = require('../computed/main-thread-tasks.js');
diff --git a/lighthouse-core/audits/mainthread-work-breakdown.js b/lighthouse-core/audits/mainthread-work-breakdown.js
index c7a67c643..c16b88585 100644
--- a/lighthouse-core/audits/mainthread-work-breakdown.js
+++ b/lighthouse-core/audits/mainthread-work-breakdown.js
@@ -11,7 +11,7 @@
 'use strict';
 
 const Audit = require('./audit.js');
-const {taskGroups} = require('../lib/task-groups.js');
+const {taskGroups} = require('../lib/tracehouse/task-groups.js');
 const i18n = require('../lib/i18n/i18n.js');
 const MainThreadTasks = require('../computed/main-thread-tasks.js');
 
@@ -29,7 +29,7 @@ const UIStrings = {
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
 
-/** @typedef {import('../lib/task-groups.js').TaskGroupIds} TaskGroupIds */
+/** @typedef {import('../lib/tracehouse/task-groups.js').TaskGroupIds} TaskGroupIds */
 
 class MainThreadWorkBreakdown extends Audit {
   /**
diff --git a/lighthouse-core/audits/metrics.js b/lighthouse-core/audits/metrics.js
index 20a124612..374517c21 100644
--- a/lighthouse-core/audits/metrics.js
+++ b/lighthouse-core/audits/metrics.js
@@ -14,6 +14,7 @@ const FirstCPUIdle = require('../computed/metrics/first-cpu-idle.js');
 const Interactive = require('../computed/metrics/interactive.js');
 const SpeedIndex = require('../computed/metrics/speed-index.js');
 const EstimatedInputLatency = require('../computed/metrics/estimated-input-latency.js');
+const CumulativeLongQueuingDelay = require('../computed/metrics/cumulative-long-queuing-delay.js');
 
 class Metrics extends Audit {
   /**
@@ -59,6 +60,7 @@ class Metrics extends Audit {
     const interactive = await requestOrUndefined(Interactive, metricComputationData);
     const speedIndex = await requestOrUndefined(SpeedIndex, metricComputationData);
     const estimatedInputLatency = await EstimatedInputLatency.request(metricComputationData, context); // eslint-disable-line max-len
+    const cumulativeLongQueuingDelay = await CumulativeLongQueuingDelay.request(metricComputationData, context); // eslint-disable-line max-len
 
     /** @type {UberMetricsItem} */
     const metrics = {
@@ -75,6 +77,7 @@ class Metrics extends Audit {
       speedIndexTs: speedIndex && speedIndex.timestamp,
       estimatedInputLatency: estimatedInputLatency.timing,
       estimatedInputLatencyTs: estimatedInputLatency.timestamp,
+      cumulativeLongQueuingDelay: cumulativeLongQueuingDelay.timing,
 
       // Include all timestamps of interest from trace of tab
       observedNavigationStart: traceOfTab.timings.navigationStart,
@@ -137,6 +140,7 @@ class Metrics extends Audit {
  * @property {number=} speedIndexTs
  * @property {number} estimatedInputLatency
  * @property {number=} estimatedInputLatencyTs
+ * @property {number} cumulativeLongQueuingDelay
  * @property {number} observedNavigationStart
  * @property {number} observedNavigationStartTs
  * @property {number=} observedFirstPaint
diff --git a/lighthouse-core/audits/metrics/cumulative-long-queuing-delay.js b/lighthouse-core/audits/metrics/cumulative-long-queuing-delay.js
new file mode 100644
index 000000000..dd0297ad8
--- /dev/null
+++ b/lighthouse-core/audits/metrics/cumulative-long-queuing-delay.js
@@ -0,0 +1,79 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const Audit = require('../audit.js');
+const CumulativeLQD = require('../../computed/metrics/cumulative-long-queuing-delay.js');
+
+// TODO(deepanjanroy): i18n strings once metric is final.
+const UIStringsNotExported = {
+  title: 'Cumulative Long Queuing Delay',
+  description: '[Experimental metric] Total time period between FCP and Time to Interactive ' +
+      'during which queuing time for any input event would be higher than 50ms.',
+};
+
+class CumulativeLongQueuingDelay extends Audit {
+  /**
+   * @return {LH.Audit.Meta}
+   */
+  static get meta() {
+    return {
+      id: 'cumulative-long-queuing-delay',
+      title: UIStringsNotExported.title,
+      description: UIStringsNotExported.description,
+      scoreDisplayMode: Audit.SCORING_MODES.NUMERIC,
+      requiredArtifacts: ['traces', 'devtoolsLogs'],
+    };
+  }
+
+  /**
+   * @return {LH.Audit.ScoreOptions}
+   */
+  static get defaultOptions() {
+    return {
+      // According to a cluster telemetry run over top 10k sites on mobile, 5th percentile was 0ms,
+      // 25th percentile was 270ms and median was 895ms. These numbers include 404 pages. Picking
+      // thresholds according to our 25/75-th rule will be quite harsh scoring (a single 350ms task)
+      // after FCP will yield a score of .5. The following coefficients are semi-arbitrarily picked
+      // to give 600ms jank a score of .5 and 100ms jank a score of .999. We can tweak these numbers
+      // in the future. See https://www.desmos.com/calculator/a7ib75kq3g
+      scoreMedian: 600,
+      scorePODR: 200,
+    };
+  }
+
+  /**
+   * Audits the page to calculate Cumulative Long Queuing Delay.
+   *
+   * We define Long Queuing Delay Region as any time interval in the loading timeline where queuing
+   * time for an input event would be longer than 50ms. For example, if there is a 110ms main thread
+   * task, the first 60ms of it is Long Queuing Delay Region, because any input event occuring in
+   * that region has to wait more than 50ms. Cumulative Long Queuing Delay is the sum of all Long
+   * Queuing Delay Regions between First Contentful Paint and Interactive Time (TTI).
+   *
+   * @param {LH.Artifacts} artifacts
+   * @param {LH.Audit.Context} context
+   * @return {Promise<LH.Audit.Product>}
+   */
+  static async audit(artifacts, context) {
+    const trace = artifacts.traces[Audit.DEFAULT_PASS];
+    const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
+    const metricComputationData = {trace, devtoolsLog, settings: context.settings};
+    const metricResult = await CumulativeLQD.request(metricComputationData, context);
+
+    return {
+      score: Audit.computeLogNormalScore(
+        metricResult.timing,
+        context.options.scorePODR,
+        context.options.scoreMedian
+      ),
+      numericValue: metricResult.timing,
+      displayValue: 10 * Math.round(metricResult.timing / 10) + '\xa0ms',
+    };
+  }
+}
+
+module.exports = CumulativeLongQueuingDelay;
diff --git a/lighthouse-core/computed/main-thread-tasks.js b/lighthouse-core/computed/main-thread-tasks.js
index f824bb44b..530aa64fc 100644
--- a/lighthouse-core/computed/main-thread-tasks.js
+++ b/lighthouse-core/computed/main-thread-tasks.js
@@ -1,277 +1,23 @@
 /**
- * @license Copyright 2017 Google Inc. All Rights Reserved.
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
  * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
  * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
  */
 'use strict';
 
 const makeComputedArtifact = require('./computed-artifact.js');
-const {taskGroups, taskNameToGroup} = require('../lib/task-groups.js');
+const MainThreadTasks_ = require('../lib/tracehouse/main-thread-tasks.js');
 const TraceOfTab = require('./trace-of-tab.js');
 
-/**
- * @fileoverview
- *
- * This artifact converts the array of raw trace events into an array of hierarchical
- * tasks for easier consumption and bottom-up analysis.
- *
- * Events are easily produced but difficult to consume. They're a mixture of start/end markers, "complete" events, etc.
- * @see https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview
- *
- * LH's TaskNode is an artifact that fills in the gaps a trace event leaves behind.
- * i.e. when did it end? which events are children/parents of this one?
- *
- * Each task will have its group/classification, start time, end time,
- * duration, and self time computed. Each task will potentially have a parent, children, and an
- * attributableURL for the script that was executing/forced this execution.
- */
-
-/** @typedef {import('../lib/task-groups.js').TaskGroup} TaskGroup */
-
-/**
- * @typedef TaskNode
- * @prop {LH.TraceEvent} event
- * @prop {TaskNode[]} children
- * @prop {TaskNode|undefined} parent
- * @prop {number} startTime
- * @prop {number} endTime
- * @prop {number} duration
- * @prop {number} selfTime
- * @prop {string[]} attributableURLs
- * @prop {TaskGroup} group
- */
-
-/** @typedef {{timers: Map<string, TaskNode>}} PriorTaskData */
-
 class MainThreadTasks {
-  /**
-   * @param {LH.TraceEvent} event
-   * @param {TaskNode} [parent]
-   * @return {TaskNode}
-   */
-  static _createNewTaskNode(event, parent) {
-    const newTask = {
-      event,
-      startTime: event.ts,
-      endTime: event.ph === 'X' ? event.ts + Number(event.dur || 0) : NaN,
-      parent: parent,
-      children: [],
-
-      // These properties will be filled in later
-      attributableURLs: [],
-      group: taskGroups.other,
-      duration: NaN,
-      selfTime: NaN,
-    };
-
-    if (parent) {
-      parent.children.push(newTask);
-    }
-
-    return newTask;
-  }
-
-  /**
-   * @param {LH.TraceEvent[]} mainThreadEvents
-   * @param {PriorTaskData} priorTaskData
-   * @param {number} traceEndTs
-   * @return {TaskNode[]}
-   */
-  static _createTasksFromEvents(mainThreadEvents, priorTaskData, traceEndTs) {
-    /** @type {TaskNode[]} */
-    const tasks = [];
-    /** @type {TaskNode|undefined} */
-    let currentTask;
-
-    for (const event of mainThreadEvents) {
-      // Save the timer data, TimerInstall events are instant events `ph === 'I'` so process them first.
-      if (event.name === 'TimerInstall' && currentTask) {
-        /** @type {string} */
-        // @ts-ignore - timerId exists when name is TimerInstall
-        const timerId = event.args.data.timerId;
-        priorTaskData.timers.set(timerId, currentTask);
-      }
-
-      // Only look at X (Complete), B (Begin), and E (End) events as they have most data
-      if (event.ph !== 'X' && event.ph !== 'B' && event.ph !== 'E') continue;
-
-      // Update currentTask based on the elapsed time.
-      // The next event may be after currentTask has ended.
-      while (
-        currentTask &&
-        Number.isFinite(currentTask.endTime) &&
-        currentTask.endTime <= event.ts
-      ) {
-        currentTask = currentTask.parent;
-      }
-
-      // If we don't have a current task, start a new one.
-      if (!currentTask) {
-        // We can't start a task with an end event
-        if (event.ph === 'E') {
-          throw new Error('Fatal trace logic error - unexpected end event');
-        }
-
-        currentTask = MainThreadTasks._createNewTaskNode(event);
-        tasks.push(currentTask);
-
-        continue;
-      }
-
-      if (event.ph === 'X' || event.ph === 'B') {
-        // We're starting a nested event, create it as a child and make it the currentTask
-        const newTask = MainThreadTasks._createNewTaskNode(event, currentTask);
-        tasks.push(newTask);
-        currentTask = newTask;
-      } else {
-        if (currentTask.event.ph !== 'B') {
-          throw new Error(
-            `Fatal trace logic error - expected start event, got ${currentTask.event.ph}`);
-        }
-
-        // We're ending an event, update the end time and the currentTask to its parent
-        currentTask.endTime = event.ts;
-        currentTask = currentTask.parent;
-      }
-    }
-
-    // Starting from the last and bottom-most task, we finish any tasks that didn't end yet.
-    while (currentTask && !Number.isFinite(currentTask.endTime)) {
-      // The last event didn't finish before tracing stopped, use traceEnd timestamp instead.
-      currentTask.endTime = traceEndTs;
-      currentTask = currentTask.parent;
-    }
-
-    // At this point we expect all tasks to have a finite startTime and endTime.
-    return tasks;
-  }
-
-  /**
-   * @param {TaskNode} task
-   * @param {TaskNode|undefined} parent
-   * @return {number}
-   */
-  static _computeRecursiveSelfTime(task, parent) {
-    if (parent && task.endTime > parent.endTime) {
-      throw new Error('Fatal trace logic error - child cannot end after parent');
-    }
-
-    const childTime = task.children
-      .map(child => MainThreadTasks._computeRecursiveSelfTime(child, task))
-      .reduce((sum, child) => sum + child, 0);
-    task.duration = task.endTime - task.startTime;
-    task.selfTime = task.duration - childTime;
-    return task.duration;
-  }
-
-  /**
-   * @param {TaskNode} task
-   * @param {string[]} parentURLs
-   * @param {PriorTaskData} priorTaskData
-   */
-  static _computeRecursiveAttributableURLs(task, parentURLs, priorTaskData) {
-    const argsData = task.event.args.data || {};
-    const stackFrameURLs = (argsData.stackTrace || []).map(entry => entry.url);
-
-    /** @type {Array<string|undefined>} */
-    let taskURLs = [];
-    switch (task.event.name) {
-      /**
-       * Some trace events reference a specific script URL that triggered them.
-       * Use this URL as the higher precedence attributable URL.
-       * @see https://cs.chromium.org/chromium/src/third_party/blink/renderer/devtools/front_end/timeline/TimelineUIUtils.js?type=cs&q=_initEventStyles+-f:out+f:devtools&sq=package:chromium&g=0&l=678-744
-       */
-      case 'v8.compile':
-      case 'EvaluateScript':
-      case 'FunctionCall':
-        taskURLs = [argsData.url].concat(stackFrameURLs);
-        break;
-      case 'v8.compileModule':
-        taskURLs = [task.event.args.fileName].concat(stackFrameURLs);
-        break;
-      case 'TimerFire': {
-        /** @type {string} */
-        // @ts-ignore - timerId exists when name is TimerFire
-        const timerId = task.event.args.data.timerId;
-        const timerInstallerTaskNode = priorTaskData.timers.get(timerId);
-        if (!timerInstallerTaskNode) break;
-        taskURLs = timerInstallerTaskNode.attributableURLs.concat(stackFrameURLs);
-        break;
-      }
-      default:
-        taskURLs = stackFrameURLs;
-        break;
-    }
-
-    /** @type {string[]} */
-    const attributableURLs = Array.from(parentURLs);
-    for (const url of taskURLs) {
-      // Don't add empty URLs
-      if (!url) continue;
-      // Don't add consecutive, duplicate URLs
-      if (attributableURLs[attributableURLs.length - 1] === url) continue;
-      attributableURLs.push(url);
-    }
-
-    task.attributableURLs = attributableURLs;
-    task.children.forEach(child =>
-      MainThreadTasks._computeRecursiveAttributableURLs(child, attributableURLs, priorTaskData));
-  }
-
-  /**
-   * @param {TaskNode} task
-   * @param {TaskGroup} [parentGroup]
-   */
-  static _computeRecursiveTaskGroup(task, parentGroup) {
-    const group = taskNameToGroup[task.event.name];
-    task.group = group || parentGroup || taskGroups.other;
-    task.children.forEach(child => MainThreadTasks._computeRecursiveTaskGroup(child, task.group));
-  }
-
-  /**
-   * @param {LH.TraceEvent[]} traceEvents
-   * @param {number} traceEndTs
-   * @return {TaskNode[]}
-   */
-  static getMainThreadTasks(traceEvents, traceEndTs) {
-    const timers = new Map();
-    const priorTaskData = {timers};
-    const tasks = MainThreadTasks._createTasksFromEvents(traceEvents, priorTaskData, traceEndTs);
-
-    // Compute the recursive properties we couldn't compute earlier, starting at the toplevel tasks
-    for (const task of tasks) {
-      if (task.parent) continue;
-
-      MainThreadTasks._computeRecursiveSelfTime(task, undefined);
-      MainThreadTasks._computeRecursiveAttributableURLs(task, [], priorTaskData);
-      MainThreadTasks._computeRecursiveTaskGroup(task);
-    }
-
-    // Rebase all the times to be relative to start of trace in ms
-    const firstTs = (tasks[0] || {startTime: 0}).startTime;
-    for (const task of tasks) {
-      task.startTime = (task.startTime - firstTs) / 1000;
-      task.endTime = (task.endTime - firstTs) / 1000;
-      task.duration /= 1000;
-      task.selfTime /= 1000;
-
-      // sanity check that we have selfTime which captures all other timing data
-      if (!Number.isFinite(task.selfTime)) {
-        throw new Error('Invalid task timing data');
-      }
-    }
-
-    return tasks;
-  }
-
   /**
    * @param {LH.Trace} trace
    * @param {LH.Audit.Context} context
-   * @return {Promise<Array<TaskNode>>} networkRecords
+   * @return {Promise<Array<LH.Artifacts.TaskNode>>}
    */
   static async compute_(trace, context) {
     const {mainThreadEvents, timestamps} = await TraceOfTab.request(trace, context);
-    return MainThreadTasks.getMainThreadTasks(mainThreadEvents, timestamps.traceEnd);
+    return MainThreadTasks_.getMainThreadTasks(mainThreadEvents, timestamps.traceEnd);
   }
 }
 
diff --git a/lighthouse-core/computed/metrics/cumulative-long-queuing-delay.js b/lighthouse-core/computed/metrics/cumulative-long-queuing-delay.js
new file mode 100644
index 000000000..65f9c7bdc
--- /dev/null
+++ b/lighthouse-core/computed/metrics/cumulative-long-queuing-delay.js
@@ -0,0 +1,124 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const makeComputedArtifact = require('../computed-artifact.js');
+const ComputedMetric = require('./metric.js');
+const LHError = require('../../lib/lh-error.js');
+const TracingProcessor = require('../../lib/traces/tracing-processor.js');
+const LanternCumulativeLongQueuingDelay = require('./lantern-cumulative-long-queuing-delay.js');
+const TimetoInteractive = require('./interactive.js');
+
+/**
+ * @fileoverview This audit determines Cumulative Long Queuing Delay between FCP and TTI.
+
+ * We define Long Queuing Delay Region as any time interval in the loading timeline where queuing
+ * time for an input event would be longer than 50ms. For example, if there is a 110ms main thread
+ * task, the first 60ms of it is Long Queuing Delay Region, because any input event occuring in
+ * that region has to wait more than 50ms. Cumulative Long Queuing Delay is the sum of all Long
+ * Queuing Delay Regions between First Contentful Paint and Interactive Time (TTI).
+ *
+ * This is a new metric designed to accompany Time to Interactive. TTI is strict and does not
+ * reflect incremental improvements to the site performance unless the improvement concerns the last
+ * long task. Cumulative Long Queuing Delay on the other hand is designed to be much more responsive
+ * to smaller improvements to main thread responsiveness.
+ */
+class CumulativeLongQueuingDelay extends ComputedMetric {
+  /**
+   * @return {number}
+   */
+  static get LONG_QUEUING_DELAY_THRESHOLD() {
+    return 50;
+  }
+  /**
+   * @param {Array<{start: number, end: number, duration: number}>} topLevelEvents
+   * @param {number} fcpTimeInMs
+   * @param {number} interactiveTimeMs
+   * @return {number}
+   */
+  static calculateSumOfLongQueuingDelay(topLevelEvents, fcpTimeInMs, interactiveTimeMs) {
+    if (interactiveTimeMs <= fcpTimeInMs) return 0;
+
+    const threshold = CumulativeLongQueuingDelay.LONG_QUEUING_DELAY_THRESHOLD;
+    const longQueuingDelayRegions = [];
+    // First identifying the long queuing delay regions.
+    for (const event of topLevelEvents) {
+      // If the task is less than the delay threshold, it contains no Long Queuing Delay Region.
+      if (event.duration < threshold) continue;
+      // Otherwise, the duration of the task before the delay-threshold-sized interval at the end is
+      // considered Long Queuing Delay Region. Example assuming the threshold is 50ms:
+      //   [              250ms Task                   ]
+      //   |  Long Queuing Delay Region  |   Last 50ms |
+      //               200 ms
+      longQueuingDelayRegions.push({
+        start: event.start,
+        end: event.end - threshold,
+        duration: event.duration - threshold,
+      });
+    }
+
+    let sumLongQueuingDelay = 0;
+    for (const region of longQueuingDelayRegions) {
+      // We only want to add up the Long Queuing Delay regions that fall between FCP and TTI.
+      //
+      // FCP is picked as the lower bound because there is little risk of user input happening
+      // before FCP so Long Queuing Qelay regions do not harm user experience. Developers should be
+      // optimizing to reach FCP as fast as possible without having to worry about task lengths.
+      //
+      // TTI is picked as the upper bound because we want a well defined end point so that the
+      // metric does not rely on how long we trace.
+      if (region.end < fcpTimeInMs) continue;
+      if (region.start > interactiveTimeMs) continue;
+
+      // If a Long Queuing Delay Region spans the edges of our region of interest, we clip it to
+      // only include the part of the region that falls inside.
+      const clippedStart = Math.max(region.start, fcpTimeInMs);
+      const clippedEnd = Math.min(region.end, interactiveTimeMs);
+      const queuingDelayAfterClipping = clippedEnd - clippedStart;
+
+      sumLongQueuingDelay += queuingDelayAfterClipping;
+    }
+
+    return sumLongQueuingDelay;
+  }
+
+  /**
+   * @param {LH.Artifacts.MetricComputationData} data
+   * @param {LH.Audit.Context} context
+   * @return {Promise<LH.Artifacts.LanternMetric>}
+   */
+  static computeSimulatedMetric(data, context) {
+    return LanternCumulativeLongQueuingDelay.request(data, context);
+  }
+
+  /**
+   * @param {LH.Artifacts.MetricComputationData} data
+   * @param {LH.Audit.Context} context
+   * @return {Promise<LH.Artifacts.Metric>}
+   */
+  static async computeObservedMetric(data, context) {
+    const {firstContentfulPaint} = data.traceOfTab.timings;
+    if (!firstContentfulPaint) {
+      throw new LHError(LHError.errors.NO_FCP);
+    }
+
+    const interactiveTimeMs = (await TimetoInteractive.request(data, context)).timing;
+
+    // Not using the start time argument of getMainThreadTopLevelEvents, because
+    // we need to clip the part of the task before the last 50ms properly.
+    const events = TracingProcessor.getMainThreadTopLevelEvents(data.traceOfTab);
+
+    return {
+      timing: CumulativeLongQueuingDelay.calculateSumOfLongQueuingDelay(
+        events,
+        firstContentfulPaint,
+        interactiveTimeMs
+      ),
+    };
+  }
+}
+
+module.exports = makeComputedArtifact(CumulativeLongQueuingDelay);
diff --git a/lighthouse-core/computed/metrics/estimated-input-latency.js b/lighthouse-core/computed/metrics/estimated-input-latency.js
index 66ddd8a82..a027667d9 100644
--- a/lighthouse-core/computed/metrics/estimated-input-latency.js
+++ b/lighthouse-core/computed/metrics/estimated-input-latency.js
@@ -8,7 +8,7 @@
 const makeComputedArtifact = require('../computed-artifact.js');
 const ComputedMetric = require('./metric.js');
 const LHError = require('../../lib/lh-error.js');
-const TracingProcessor = require('../../lib/traces/tracing-processor.js');
+const TracingProcessor = require('../../lib/tracehouse/tracing-processor.js');
 const LanternEstimatedInputLatency = require('./lantern-estimated-input-latency.js');
 
 const ROLLING_WINDOW_SIZE = 5000;
diff --git a/lighthouse-core/computed/metrics/first-cpu-idle.js b/lighthouse-core/computed/metrics/first-cpu-idle.js
index 450dbe103..202821e16 100644
--- a/lighthouse-core/computed/metrics/first-cpu-idle.js
+++ b/lighthouse-core/computed/metrics/first-cpu-idle.js
@@ -7,7 +7,7 @@
 
 const makeComputedArtifact = require('../computed-artifact.js');
 const ComputedMetric = require('./metric.js');
-const TracingProcessor = require('../../lib/traces/tracing-processor.js');
+const TracingProcessor = require('../../lib/tracehouse/tracing-processor.js');
 const LHError = require('../../lib/lh-error.js');
 const LanternFirstCPUIdle = require('./lantern-first-cpu-idle.js');
 
diff --git a/lighthouse-core/computed/metrics/interactive.js b/lighthouse-core/computed/metrics/interactive.js
index 31ad39ae9..cc7fbd05f 100644
--- a/lighthouse-core/computed/metrics/interactive.js
+++ b/lighthouse-core/computed/metrics/interactive.js
@@ -10,7 +10,7 @@ const ComputedMetric = require('./metric.js');
 const LanternInteractive = require('./lantern-interactive.js');
 
 const NetworkRecorder = require('../../lib/network-recorder.js');
-const TracingProcessor = require('../../lib/traces/tracing-processor.js');
+const TracingProcessor = require('../../lib/tracehouse/tracing-processor.js');
 const LHError = require('../../lib/lh-error.js');
 
 const REQUIRED_QUIET_WINDOW = 5000;
diff --git a/lighthouse-core/computed/metrics/lantern-cumulative-long-queuing-delay.js b/lighthouse-core/computed/metrics/lantern-cumulative-long-queuing-delay.js
new file mode 100644
index 000000000..112b3539f
--- /dev/null
+++ b/lighthouse-core/computed/metrics/lantern-cumulative-long-queuing-delay.js
@@ -0,0 +1,121 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const makeComputedArtifact = require('../computed-artifact.js');
+const LanternMetric = require('./lantern-metric.js');
+const BaseNode = require('../../lib/dependency-graph/base-node.js');
+const LanternFirstContentfulPaint = require('./lantern-first-contentful-paint.js');
+const LanternInteractive = require('./lantern-interactive.js');
+
+/** @typedef {BaseNode.Node} Node */
+
+class LanternCumulativeLongQueuingDelay extends LanternMetric {
+  /**
+   * @return {LH.Gatherer.Simulation.MetricCoefficients}
+   */
+  static get COEFFICIENTS() {
+    return {
+      intercept: 0,
+      optimistic: 0.5,
+      pessimistic: 0.5,
+    };
+  }
+
+  /**
+   * @param {Node} dependencyGraph
+   * @return {Node}
+   */
+  static getOptimisticGraph(dependencyGraph) {
+    return dependencyGraph;
+  }
+
+  /**
+   * @param {Node} dependencyGraph
+   * @return {Node}
+   */
+  static getPessimisticGraph(dependencyGraph) {
+    return dependencyGraph;
+  }
+
+  /**
+   * @param {LH.Gatherer.Simulation.Result} simulation
+   * @param {Object} extras
+   * @return {LH.Gatherer.Simulation.Result}
+   */
+  static getEstimateFromSimulation(simulation, extras) {
+    // Intentionally use the opposite FCP estimate. A pessimistic FCP is higher than equal to an
+    // optimistic FCP, which means potentially more tasks are excluded from the
+    // CumulativeLongQueuingDelay computation. So a more pessimistic FCP gives a more optimistic
+    // CumulativeLongQueuingDelay for the same work.
+    const fcpTimeInMs = extras.optimistic
+      ? extras.fcpResult.pessimisticEstimate.timeInMs
+      : extras.fcpResult.optimisticEstimate.timeInMs;
+
+    // Similarly, we always have pessimistic TTI >= optimistic TTI. Therefore, picking optimistic
+    // TTI means our window of interest is smaller and thus potentially more tasks are excluded from
+    // CumulativeLongQueuingDelay computation, yielding a lower (more optimistic)
+    // CumulativeLongQueuingDelay value for the same work.
+    const interactiveTimeMs = extras.optimistic
+      ? extras.interactiveResult.optimisticEstimate.timeInMs
+      : extras.interactiveResult.pessimisticEstimate.timeInMs;
+
+    // Require here to resolve circular dependency.
+    const CumulativeLongQueuingDelay = require('./cumulative-long-queuing-delay.js');
+    const minDurationMs = CumulativeLongQueuingDelay.LONG_QUEUING_DELAY_THRESHOLD;
+
+    const events = LanternCumulativeLongQueuingDelay.getTopLevelEvents(
+      simulation.nodeTimings,
+      minDurationMs
+    );
+
+    return {
+      timeInMs: CumulativeLongQueuingDelay.calculateSumOfLongQueuingDelay(
+        events,
+        fcpTimeInMs,
+        interactiveTimeMs
+      ),
+      nodeTimings: simulation.nodeTimings,
+    };
+  }
+
+  /**
+   * @param {LH.Artifacts.MetricComputationDataInput} data
+   * @param {LH.Audit.Context} context
+   * @return {Promise<LH.Artifacts.LanternMetric>}
+   */
+  static async compute_(data, context) {
+    const fcpResult = await LanternFirstContentfulPaint.request(data, context);
+    const interactiveResult = await LanternInteractive.request(data, context);
+    return this.computeMetricWithGraphs(data, context, {fcpResult, interactiveResult});
+  }
+
+  /**
+   * @param {LH.Gatherer.Simulation.Result['nodeTimings']} nodeTimings
+   * @param {number} minDurationMs
+   */
+  static getTopLevelEvents(nodeTimings, minDurationMs) {
+    /** @type {Array<{start: number, end: number, duration: number}>}
+     */
+    const events = [];
+
+    for (const [node, timing] of nodeTimings.entries()) {
+      if (node.type !== BaseNode.TYPES.CPU) continue;
+      // Filtering out events below minimum duration.
+      if (timing.duration < minDurationMs) continue;
+
+      events.push({
+        start: timing.startTime,
+        end: timing.endTime,
+        duration: timing.duration,
+      });
+    }
+
+    return events;
+  }
+}
+
+module.exports = makeComputedArtifact(LanternCumulativeLongQueuingDelay);
diff --git a/lighthouse-core/computed/metrics/max-potential-fid.js b/lighthouse-core/computed/metrics/max-potential-fid.js
index 0a1ac316f..6fed0414e 100644
--- a/lighthouse-core/computed/metrics/max-potential-fid.js
+++ b/lighthouse-core/computed/metrics/max-potential-fid.js
@@ -9,7 +9,7 @@ const makeComputedArtifact = require('../computed-artifact.js');
 const MetricArtifact = require('./metric.js');
 const LanternMaxPotentialFID = require('./lantern-max-potential-fid.js');
 const LHError = require('../../lib/lh-error.js');
-const TracingProcessor = require('../../lib/traces/tracing-processor.js');
+const TracingProcessor = require('../../lib/tracehouse/tracing-processor.js');
 
 class MaxPotentialFID extends MetricArtifact {
   /**
diff --git a/lighthouse-core/computed/metrics/metric.js b/lighthouse-core/computed/metrics/metric.js
index 6dd4c3102..1b2457ff0 100644
--- a/lighthouse-core/computed/metrics/metric.js
+++ b/lighthouse-core/computed/metrics/metric.js
@@ -5,7 +5,7 @@
  */
 'use strict';
 
-const TracingProcessor = require('../../lib/traces/tracing-processor.js');
+const TracingProcessor = require('../../lib/tracehouse/tracing-processor.js');
 const TraceOfTab = require('../trace-of-tab.js');
 const NetworkRecords = require('../network-records.js');
 
diff --git a/lighthouse-core/computed/page-dependency-graph.js b/lighthouse-core/computed/page-dependency-graph.js
index 292eafbd8..2f728eaf2 100644
--- a/lighthouse-core/computed/page-dependency-graph.js
+++ b/lighthouse-core/computed/page-dependency-graph.js
@@ -9,7 +9,7 @@ const makeComputedArtifact = require('./computed-artifact.js');
 const NetworkNode = require('../lib/dependency-graph/network-node.js');
 const CPUNode = require('../lib/dependency-graph/cpu-node.js');
 const NetworkAnalyzer = require('../lib/dependency-graph/simulator/network-analyzer.js');
-const TracingProcessor = require('../lib/traces/tracing-processor.js');
+const TracingProcessor = require('../lib/tracehouse/tracing-processor.js');
 const NetworkRequest = require('../lib/network-request.js');
 const TraceOfTab = require('./trace-of-tab.js');
 const NetworkRecords = require('./network-records.js');
diff --git a/lighthouse-core/computed/trace-of-tab.js b/lighthouse-core/computed/trace-of-tab.js
index 284c6d9e6..7aa3ee252 100644
--- a/lighthouse-core/computed/trace-of-tab.js
+++ b/lighthouse-core/computed/trace-of-tab.js
@@ -1,70 +1,14 @@
 /**
- * @license Copyright 2017 Google Inc. All Rights Reserved.
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
  * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
  * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
  */
 'use strict';
 
-/**
- * @fileoverview Singluar helper to parse a raw trace and extract the most useful data for
- * various tools. This artifact will take a trace and then:
- *
- * 1. Find the TracingStartedInPage and navigationStart events of our intended tab & frame.
- * 2. Find the firstContentfulPaint and marked firstMeaningfulPaint events
- * 3. Isolate only the trace events from the tab's process (including all threads like compositor)
- *      * Sort those trace events in chronological order (as order isn't guaranteed)
- * 4. Return all those items in one handy bundle.
- */
-
 const makeComputedArtifact = require('./computed-artifact.js');
-const log = require('lighthouse-logger');
-const TracingProcessor = require('../lib/traces/tracing-processor.js');
-const LHError = require('../lib/lh-error.js');
-const Sentry = require('../lib/sentry.js');
-
-const ACCEPTABLE_NAVIGATION_URL_REGEX = /^(chrome|https?):/;
+const TraceOfTab_ = require('../lib/tracehouse/trace-of-tab.js');
 
 class TraceOfTab {
-  /**
-   * Returns true if the event is a navigation start event of a document whose URL seems valid.
-   *
-   * @param {LH.TraceEvent} event
-   */
-  static isNavigationStartOfInterest(event) {
-    return event.name === 'navigationStart' &&
-      (!event.args.data || !event.args.data.documentLoaderURL ||
-        ACCEPTABLE_NAVIGATION_URL_REGEX.test(event.args.data.documentLoaderURL));
-  }
-
-  /**
-   * @param {LH.TraceEvent[]} traceEvents
-   * @param {(e: LH.TraceEvent) => boolean} filter
-   */
-  static filteredStableSort(traceEvents, filter) {
-    // create an array of the indices that we want to keep
-    const indices = [];
-    for (let srcIndex = 0; srcIndex < traceEvents.length; srcIndex++) {
-      if (filter(traceEvents[srcIndex])) {
-        indices.push(srcIndex);
-      }
-    }
-
-    // sort by ts, if there's no ts difference sort by index
-    indices.sort((indexA, indexB) => {
-      const result = traceEvents[indexA].ts - traceEvents[indexB].ts;
-      return result ? result : indexA - indexB;
-    });
-
-    // create a new array using the target indices from previous sort step
-    const sorted = [];
-    for (let i = 0; i < indices.length; i++) {
-      sorted.push(traceEvents[indices[i]]);
-    }
-
-    return sorted;
-  }
-
-
   /**
    * Finds key trace events, identifies main process/thread, and returns timings of trace events
    * in milliseconds since navigation start in addition to the standard microsecond monotonic timestamps.
@@ -72,120 +16,7 @@ class TraceOfTab {
    * @return {Promise<LH.Artifacts.TraceOfTab>}
   */
   static async compute_(trace) {
-    // Parse the trace for our key events and sort them by timestamp. Note: sort
-    // *must* be stable to keep events correctly nested.
-    const keyEvents = TraceOfTab.filteredStableSort(trace.traceEvents, e => {
-      return e.cat.includes('blink.user_timing') ||
-          e.cat.includes('loading') ||
-          e.cat.includes('devtools.timeline') ||
-          e.cat === '__metadata';
-    });
-
-    // Find the inspected frame
-    const mainFrameIds = TracingProcessor.findMainFrameIds(keyEvents);
-
-    // Filter to just events matching the frame ID for sanity
-    const frameEvents = keyEvents.filter(e => e.args.frame === mainFrameIds.frameId);
-
-    // Our navStart will be the last frame navigation in the trace
-    const navigationStart = frameEvents.filter(TraceOfTab.isNavigationStartOfInterest).pop();
-    if (!navigationStart) throw new LHError(LHError.errors.NO_NAVSTART);
-
-    // Find our first paint of this frame
-    const firstPaint = frameEvents.find(e => e.name === 'firstPaint' && e.ts > navigationStart.ts);
-
-    // FCP will follow at/after the FP. Used in so many places we require it.
-    const firstContentfulPaint = frameEvents.find(
-      e => e.name === 'firstContentfulPaint' && e.ts > navigationStart.ts
-    );
-    if (!firstContentfulPaint) throw new LHError(LHError.errors.NO_FCP);
-
-    // fMP will follow at/after the FP
-    let firstMeaningfulPaint = frameEvents.find(
-      e => e.name === 'firstMeaningfulPaint' && e.ts > navigationStart.ts
-    );
-    let fmpFellBack = false;
-
-    // If there was no firstMeaningfulPaint event found in the trace, the network idle detection
-    // may have not been triggered before Lighthouse finished tracing.
-    // In this case, we'll use the last firstMeaningfulPaintCandidate we can find.
-    // However, if no candidates were found (a bogus trace, likely), we fail.
-    if (!firstMeaningfulPaint) {
-      // Track this with Sentry since it's likely a bug we should investigate.
-      Sentry.captureMessage('No firstMeaningfulPaint found, using fallback', {level: 'warning'});
-
-      const fmpCand = 'firstMeaningfulPaintCandidate';
-      fmpFellBack = true;
-      log.verbose('trace-of-tab', `No firstMeaningfulPaint found, falling back to last ${fmpCand}`);
-      const lastCandidate = frameEvents.filter(e => e.name === fmpCand).pop();
-      if (!lastCandidate) {
-        log.verbose('trace-of-tab', 'No `firstMeaningfulPaintCandidate` events found in trace');
-      }
-      firstMeaningfulPaint = lastCandidate;
-    }
-
-    const load = frameEvents.find(e => e.name === 'loadEventEnd' && e.ts > navigationStart.ts);
-    const domContentLoaded = frameEvents.find(
-      e => e.name === 'domContentLoadedEventEnd' && e.ts > navigationStart.ts
-    );
-
-    // subset all trace events to just our tab's process (incl threads other than main)
-    // stable-sort events to keep them correctly nested.
-    const processEvents = TraceOfTab
-      .filteredStableSort(trace.traceEvents, e => e.pid === mainFrameIds.pid);
-
-    const mainThreadEvents = processEvents
-      .filter(e => e.tid === mainFrameIds.tid);
-
-    // traceEnd must exist since at least navigationStart event was verified as existing.
-    const traceEnd = trace.traceEvents.reduce((max, evt) => {
-      return max.ts > evt.ts ? max : evt;
-    });
-    const fakeEndOfTraceEvt = {ts: traceEnd.ts + (traceEnd.dur || 0)};
-
-    /** @param {{ts: number}=} event */
-    const getTimestamp = (event) => event && event.ts;
-    /** @type {LH.Artifacts.TraceTimes} */
-    const timestamps = {
-      navigationStart: navigationStart.ts,
-      firstPaint: getTimestamp(firstPaint),
-      firstContentfulPaint: firstContentfulPaint.ts,
-      firstMeaningfulPaint: getTimestamp(firstMeaningfulPaint),
-      traceEnd: fakeEndOfTraceEvt.ts,
-      load: getTimestamp(load),
-      domContentLoaded: getTimestamp(domContentLoaded),
-    };
-
-
-    /** @param {number} ts */
-    const getTiming = (ts) => (ts - navigationStart.ts) / 1000;
-    /** @param {number=} ts */
-    const maybeGetTiming = (ts) => ts === undefined ? undefined : getTiming(ts);
-    /** @type {LH.Artifacts.TraceTimes} */
-    const timings = {
-      navigationStart: 0,
-      firstPaint: maybeGetTiming(timestamps.firstPaint),
-      firstContentfulPaint: getTiming(timestamps.firstContentfulPaint),
-      firstMeaningfulPaint: maybeGetTiming(timestamps.firstMeaningfulPaint),
-      traceEnd: getTiming(timestamps.traceEnd),
-      load: maybeGetTiming(timestamps.load),
-      domContentLoaded: maybeGetTiming(timestamps.domContentLoaded),
-    };
-
-    return {
-      timings,
-      timestamps,
-      processEvents,
-      mainThreadEvents,
-      mainFrameIds,
-      navigationStartEvt: navigationStart,
-      firstPaintEvt: firstPaint,
-      firstContentfulPaintEvt: firstContentfulPaint,
-      firstMeaningfulPaintEvt: firstMeaningfulPaint,
-      loadEvt: load,
-      domContentLoadedEvt: domContentLoaded,
-      fmpFellBack,
-    };
+    return TraceOfTab_.compute(trace);
   }
 }
 
diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js
index 33ac027e8..15916dda5 100644
--- a/lighthouse-core/config/default-config.js
+++ b/lighthouse-core/config/default-config.js
@@ -170,6 +170,7 @@ const defaultConfig = {
     'screenshot-thumbnails',
     'final-screenshot',
     'metrics/estimated-input-latency',
+    'metrics/cumulative-long-queuing-delay',
     'metrics/max-potential-fid',
     'errors-in-console',
     'time-to-first-byte',
@@ -366,7 +367,7 @@ const defaultConfig = {
         {id: 'first-cpu-idle', weight: 2, group: 'metrics'},
         {id: 'max-potential-fid', weight: 0, group: 'metrics'},
         {id: 'estimated-input-latency', weight: 0}, // intentionally left out of metrics so it won't be displayed
-
+        {id: 'cumulative-long-queuing-delay', weight: 0}, // intentionally left out of metrics so it won't be displayed
         {id: 'render-blocking-resources', weight: 0, group: 'load-opportunities'},
         {id: 'uses-responsive-images', weight: 0, group: 'load-opportunities'},
         {id: 'offscreen-images', weight: 0, group: 'load-opportunities'},
diff --git a/lighthouse-core/gather/driver.js b/lighthouse-core/gather/driver.js
index 93f267a8a..a07c4d6d6 100644
--- a/lighthouse-core/gather/driver.js
+++ b/lighthouse-core/gather/driver.js
@@ -903,53 +903,6 @@ class Driver {
     };
   }
 
-  /**
-   * Return a promise that resolves when an insecure security state is encountered
-   * and a method to cancel internal listeners.
-   * @return {{promise: Promise<string>, cancel: function(): void}}
-   * @private
-   */
-  _monitorForInsecureState() {
-    /** @type {(() => void)} */
-    let cancel = () => {
-      throw new Error('_monitorForInsecureState.cancel() called before it was defined');
-    };
-
-    const promise = new Promise((resolve, reject) => {
-      /**
-       * @param {LH.Crdp.Security.SecurityStateChangedEvent} event
-       */
-      const securityStateChangedListener = ({
-        securityState,
-        explanations,
-        schemeIsCryptographic,
-      }) => {
-        if (securityState === 'insecure' && schemeIsCryptographic) {
-          cancel();
-          const insecureDescriptions = explanations
-            .filter(exp => exp.securityState === 'insecure')
-            .map(exp => exp.description);
-          resolve(insecureDescriptions.join(' '));
-        }
-      };
-      let canceled = false;
-      cancel = () => {
-        if (canceled) return;
-        canceled = true;
-        this.off('Security.securityStateChanged', securityStateChangedListener);
-        // TODO(@patrickhulce): cancel() should really be a promise itself to handle things like this
-        this.sendCommand('Security.disable').catch(() => {});
-      };
-      this.on('Security.securityStateChanged', securityStateChangedListener);
-      this.sendCommand('Security.enable').catch(() => {});
-    });
-
-    return {
-      promise,
-      cancel,
-    };
-  }
-
   /**
    * Returns whether the page appears to be hung.
    * @return {Promise<boolean>}
@@ -1001,13 +954,6 @@ class Driver {
     // CPU listener. Resolves when the CPU has been idle for cpuQuietThresholdMs after network idle.
     let waitForCPUIdle = this._waitForNothing();
 
-    const monitorForInsecureState = this._monitorForInsecureState();
-    const securityCheckPromise = monitorForInsecureState.promise.then(securityMessages => {
-      return function() {
-        throw new LHError(LHError.errors.INSECURE_DOCUMENT_REQUEST, {securityMessages});
-      };
-    });
-
     // Wait for both load promises. Resolves on cleanup function the clears load
     // timeout timer.
     const loadPromise = Promise.all([
@@ -1044,9 +990,8 @@ class Driver {
       };
     });
 
-    // Wait for security issue, load or timeout and run the cleanup function the winner returns.
+    // Wait for load or timeout and run the cleanup function the winner returns.
     const cleanupFn = await Promise.race([
-      securityCheckPromise,
       loadPromise,
       maxTimeoutPromise,
     ]);
@@ -1056,7 +1001,6 @@ class Driver {
     waitForLoadEvent.cancel();
     waitForNetworkIdle.cancel();
     waitForCPUIdle.cancel();
-    monitorForInsecureState.cancel();
 
     await cleanupFn();
   }
diff --git a/lighthouse-core/gather/gatherers/seo/tap-targets.js b/lighthouse-core/gather/gatherers/seo/tap-targets.js
index bd763492a..468236137 100644
--- a/lighthouse-core/gather/gatherers/seo/tap-targets.js
+++ b/lighthouse-core/gather/gatherers/seo/tap-targets.js
@@ -37,38 +37,12 @@ const TARGET_SELECTORS = [
 const tapTargetsSelector = TARGET_SELECTORS.join(',');
 
 /**
- * @param {Element} element
+ * @param {HTMLElement} element
  * @returns {boolean}
  */
 /* istanbul ignore next */
 function elementIsVisible(element) {
-  const {overflowX, overflowY, display, visibility} = getComputedStyle(element);
-
-  if (
-    display === 'none' ||
-    (visibility === 'collapse' && ['TR', 'TBODY', 'COL', 'COLGROUP'].includes(element.tagName))
-  ) {
-    // Element not displayed
-    return false;
-  }
-
-  // only for block and inline-block, since clientWidth/Height are always 0 for inline elements
-  if (display === 'block' || display === 'inline-block') {
-    // if height/width is 0 and no overflow in that direction then
-    // there's no content that the user can see and tap on
-    if ((element.clientWidth === 0 && overflowX === 'hidden') ||
-        (element.clientHeight === 0 && overflowY === 'hidden')) {
-      return false;
-    }
-  }
-
-  const parent = element.parentElement;
-  if (parent && parent.tagName !== 'BODY') {
-    // if a parent is invisible then the current element is also invisible
-    return elementIsVisible(parent);
-  }
-
-  return true;
+  return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
 }
 
 /**
@@ -238,7 +212,7 @@ function gatherTapTargets() {
   // Capture element positions relative to the top of the page
   window.scrollTo(0, 0);
 
-  /** @type {Element[]} */
+  /** @type {HTMLElement[]} */
   // @ts-ignore - getElementsInDocument put into scope via stringification
   const tapTargetElements = getElementsInDocument(tapTargetsSelector);
 
diff --git a/lighthouse-core/lib/manifest-parser.js b/lighthouse-core/lib/manifest-parser.js
index 763910cfd..64d4fd06b 100644
--- a/lighthouse-core/lib/manifest-parser.js
+++ b/lighthouse-core/lib/manifest-parser.js
@@ -16,7 +16,7 @@ const ALLOWED_DISPLAY_VALUES = [
 ];
 /**
  * All display-mode fallbacks, including when unset, lead to default display mode 'browser'.
- * @see https://w3c.github.io/manifest/#dfn-default-display-mode
+ * @see https://www.w3.org/TR/2016/WD-appmanifest-20160825/#dfn-default-display-mode
  */
 const DEFAULT_DISPLAY_MODE = 'browser';
 
@@ -111,7 +111,7 @@ function checkSameOrigin(url1, url2) {
 }
 
 /**
- * https://w3c.github.io/manifest/#start_url-member
+ * https://www.w3.org/TR/2016/WD-appmanifest-20160825/#start_url-member
  * @param {*} jsonInput
  * @param {string} manifestUrl
  * @param {string} documentUrl
@@ -151,7 +151,7 @@ function parseStartUrl(jsonInput, manifestUrl, documentUrl) {
     return {
       raw,
       value: documentUrl,
-      warning: 'ERROR: invalid start_url relative to ${manifestUrl}',
+      warning: `ERROR: invalid start_url relative to ${manifestUrl}`,
     };
   }
 
@@ -218,6 +218,7 @@ function parseOrientation(jsonInput) {
 }
 
 /**
+ * @see https://www.w3.org/TR/2016/WD-appmanifest-20160825/#src-member
  * @param {*} raw
  * @param {string} manifestUrl
  */
@@ -229,8 +230,14 @@ function parseIcon(raw, manifestUrl) {
     src.value = undefined;
   }
   if (src.value) {
-    // 9.4(4) - construct URL with manifest URL as the base
-    src.value = new URL(src.value, manifestUrl).href;
+    try {
+      // 9.4(4) - construct URL with manifest URL as the base
+      src.value = new URL(src.value, manifestUrl).href;
+    } catch (_) {
+      // 9.4 "This algorithm will return a URL or undefined."
+      src.warning = `ERROR: invalid icon url will be ignored: '${raw.src}'`;
+      src.value = undefined;
+    }
   }
 
   const type = parseString(raw.type, true);
@@ -301,20 +308,31 @@ function parseIcons(jsonInput, manifestUrl) {
     };
   }
 
-  // TODO(bckenny): spec says to skip icons missing `src`, so debug messages on
-  // individual icons are lost. Warn instead?
-  const value = raw
+  const parsedIcons = raw
     // 9.6(3)(1)
     .filter(icon => icon.src !== undefined)
     // 9.6(3)(2)(1)
-    .map(icon => parseIcon(icon, manifestUrl))
+    .map(icon => parseIcon(icon, manifestUrl));
+
+  // NOTE: we still lose the specific message on these icons, but it's not possible to surface them
+  // without a massive change to the structure and paradigms of `manifest-parser`.
+  const ignoredIconsWithWarnings = parsedIcons
+    .filter(icon => {
+      const possibleWarnings = [icon.warning, icon.value.type.warning, icon.value.src.warning,
+        icon.value.sizes.warning, icon.value.density.warning].filter(Boolean);
+      const hasSrc = !!icon.value.src.value;
+      return !!possibleWarnings.length && !hasSrc;
+    });
+
+  const value = parsedIcons
     // 9.6(3)(2)(2)
     .filter(parsedIcon => parsedIcon.value.src.value !== undefined);
 
   return {
     raw,
     value,
-    warning: undefined,
+    warning: ignoredIconsWithWarnings.length ?
+      'WARNING: Some icons were ignored due to warnings.' : undefined,
   };
 }
 
@@ -333,7 +351,7 @@ function parseApplication(raw) {
       appUrl.value = new URL(appUrl.value).href;
     } catch (e) {
       appUrl.value = undefined;
-      appUrl.warning = 'ERROR: invalid application URL ${raw.url}';
+      appUrl.warning = `ERROR: invalid application URL ${raw.url}`;
     }
   }
 
@@ -460,10 +478,21 @@ function parse(string, manifestUrl, documentUrl) {
   };
   /* eslint-enable camelcase */
 
+  /** @type {string|undefined} */
+  let manifestUrlWarning;
+  try {
+    const manifestUrlParsed = new URL(manifestUrl);
+    if (!manifestUrlParsed.protocol.startsWith('http')) {
+      manifestUrlWarning = `WARNING: manifest URL not available over a valid network protocol`;
+    }
+  } catch (_) {
+    manifestUrlWarning = `ERROR: invalid manifest URL: '${manifestUrl}'`;
+  }
+
   return {
     raw: string,
     value: manifest,
-    warning: undefined,
+    warning: manifestUrlWarning,
   };
 }
 
diff --git a/lighthouse-core/lib/minify-trace.js b/lighthouse-core/lib/minify-trace.js
index c6610a760..0ff5bd8f3 100644
--- a/lighthouse-core/lib/minify-trace.js
+++ b/lighthouse-core/lib/minify-trace.js
@@ -10,13 +10,13 @@
 /**
  * @fileoverview Minifies a trace by removing unnecessary events, throttling screenshots, etc.
  *  See the following files for necessary events:
- *    - lighthouse-core/computed/trace-of-tab.js
  *    - lighthouse-core/computed/page-dependency-graph.js
  *    - lighthouse-core/lib/dependency-graph/cpu-node.js
- *    - lighthouse-core/lib/traces/tracing-processor.js
+ *    - lighthouse-core/lib/tracehouse/trace-of-tab.js
+ *    - lighthouse-core/lib/tracehouse/tracing-processor.js
  */
 
-const TracingProcessor = require('./traces/tracing-processor.js');
+const TracingProcessor = require('./tracehouse/tracing-processor.js');
 
 const toplevelTaskNames = new Set([
   'RunTask', // m71+
diff --git a/lighthouse-core/lib/page-functions.js b/lighthouse-core/lib/page-functions.js
index 3622f35b7..770c0c855 100644
--- a/lighthouse-core/lib/page-functions.js
+++ b/lighthouse-core/lib/page-functions.js
@@ -80,15 +80,15 @@ function checkTimeSinceLastLongTask() {
 /**
  * @param {string=} selector Optional simple CSS selector to filter nodes on.
  *     Combinators are not supported.
- * @return {Array<Element>}
+ * @return {Array<HTMLElement>}
  */
 /* istanbul ignore next */
 function getElementsInDocument(selector) {
   const realMatchesFn = window.__ElementMatches || window.Element.prototype.matches;
-  /** @type {Array<Element>} */
+  /** @type {Array<HTMLElement>} */
   const results = [];
 
-  /** @param {NodeListOf<Element>} nodes */
+  /** @param {NodeListOf<HTMLElement>} nodes */
   const _findAllElements = nodes => {
     for (let i = 0, el; el = nodes[i]; ++i) {
       if (!selector || realMatchesFn.call(el, selector)) {
diff --git a/lighthouse-core/lib/tracehouse/main-thread-tasks.js b/lighthouse-core/lib/tracehouse/main-thread-tasks.js
new file mode 100644
index 000000000..e1c53205c
--- /dev/null
+++ b/lighthouse-core/lib/tracehouse/main-thread-tasks.js
@@ -0,0 +1,266 @@
+/**
+ * @license Copyright 2017 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const {taskGroups, taskNameToGroup} = require('./task-groups.js');
+
+/**
+ * @fileoverview
+ *
+ * This artifact converts the array of raw trace events into an array of hierarchical
+ * tasks for easier consumption and bottom-up analysis.
+ *
+ * Events are easily produced but difficult to consume. They're a mixture of start/end markers, "complete" events, etc.
+ * @see https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview
+ *
+ * LH's TaskNode is an artifact that fills in the gaps a trace event leaves behind.
+ * i.e. when did it end? which events are children/parents of this one?
+ *
+ * Each task will have its group/classification, start time, end time,
+ * duration, and self time computed. Each task will potentially have a parent, children, and an
+ * attributableURL for the script that was executing/forced this execution.
+ */
+
+/** @typedef {import('./task-groups.js').TaskGroup} TaskGroup */
+
+/**
+ * @typedef TaskNode
+ * @prop {LH.TraceEvent} event
+ * @prop {TaskNode[]} children
+ * @prop {TaskNode|undefined} parent
+ * @prop {number} startTime
+ * @prop {number} endTime
+ * @prop {number} duration
+ * @prop {number} selfTime
+ * @prop {string[]} attributableURLs
+ * @prop {TaskGroup} group
+ */
+
+/** @typedef {{timers: Map<string, TaskNode>}} PriorTaskData */
+
+class MainThreadTasks {
+  /**
+   * @param {LH.TraceEvent} event
+   * @param {TaskNode} [parent]
+   * @return {TaskNode}
+   */
+  static _createNewTaskNode(event, parent) {
+    const newTask = {
+      event,
+      startTime: event.ts,
+      endTime: event.ph === 'X' ? event.ts + Number(event.dur || 0) : NaN,
+      parent: parent,
+      children: [],
+
+      // These properties will be filled in later
+      attributableURLs: [],
+      group: taskGroups.other,
+      duration: NaN,
+      selfTime: NaN,
+    };
+
+    if (parent) {
+      parent.children.push(newTask);
+    }
+
+    return newTask;
+  }
+
+  /**
+   * @param {LH.TraceEvent[]} mainThreadEvents
+   * @param {PriorTaskData} priorTaskData
+   * @param {number} traceEndTs
+   * @return {TaskNode[]}
+   */
+  static _createTasksFromEvents(mainThreadEvents, priorTaskData, traceEndTs) {
+    /** @type {TaskNode[]} */
+    const tasks = [];
+    /** @type {TaskNode|undefined} */
+    let currentTask;
+
+    for (const event of mainThreadEvents) {
+      // Save the timer data, TimerInstall events are instant events `ph === 'I'` so process them first.
+      if (event.name === 'TimerInstall' && currentTask) {
+        /** @type {string} */
+        // @ts-ignore - timerId exists when name is TimerInstall
+        const timerId = event.args.data.timerId;
+        priorTaskData.timers.set(timerId, currentTask);
+      }
+
+      // Only look at X (Complete), B (Begin), and E (End) events as they have most data
+      if (event.ph !== 'X' && event.ph !== 'B' && event.ph !== 'E') continue;
+
+      // Update currentTask based on the elapsed time.
+      // The next event may be after currentTask has ended.
+      while (
+        currentTask &&
+        Number.isFinite(currentTask.endTime) &&
+        currentTask.endTime <= event.ts
+      ) {
+        currentTask = currentTask.parent;
+      }
+
+      // If we don't have a current task, start a new one.
+      if (!currentTask) {
+        // We can't start a task with an end event
+        if (event.ph === 'E') {
+          throw new Error('Fatal trace logic error - unexpected end event');
+        }
+
+        currentTask = MainThreadTasks._createNewTaskNode(event);
+        tasks.push(currentTask);
+
+        continue;
+      }
+
+      if (event.ph === 'X' || event.ph === 'B') {
+        // We're starting a nested event, create it as a child and make it the currentTask
+        const newTask = MainThreadTasks._createNewTaskNode(event, currentTask);
+        tasks.push(newTask);
+        currentTask = newTask;
+      } else {
+        if (currentTask.event.ph !== 'B') {
+          throw new Error(
+            `Fatal trace logic error - expected start event, got ${currentTask.event.ph}`);
+        }
+
+        // We're ending an event, update the end time and the currentTask to its parent
+        currentTask.endTime = event.ts;
+        currentTask = currentTask.parent;
+      }
+    }
+
+    // Starting from the last and bottom-most task, we finish any tasks that didn't end yet.
+    while (currentTask && !Number.isFinite(currentTask.endTime)) {
+      // The last event didn't finish before tracing stopped, use traceEnd timestamp instead.
+      currentTask.endTime = traceEndTs;
+      currentTask = currentTask.parent;
+    }
+
+    // At this point we expect all tasks to have a finite startTime and endTime.
+    return tasks;
+  }
+
+  /**
+   * @param {TaskNode} task
+   * @param {TaskNode|undefined} parent
+   * @return {number}
+   */
+  static _computeRecursiveSelfTime(task, parent) {
+    if (parent && task.endTime > parent.endTime) {
+      throw new Error('Fatal trace logic error - child cannot end after parent');
+    }
+
+    const childTime = task.children
+      .map(child => MainThreadTasks._computeRecursiveSelfTime(child, task))
+      .reduce((sum, child) => sum + child, 0);
+    task.duration = task.endTime - task.startTime;
+    task.selfTime = task.duration - childTime;
+    return task.duration;
+  }
+
+  /**
+   * @param {TaskNode} task
+   * @param {string[]} parentURLs
+   * @param {PriorTaskData} priorTaskData
+   */
+  static _computeRecursiveAttributableURLs(task, parentURLs, priorTaskData) {
+    const argsData = task.event.args.data || {};
+    const stackFrameURLs = (argsData.stackTrace || []).map(entry => entry.url);
+
+    /** @type {Array<string|undefined>} */
+    let taskURLs = [];
+    switch (task.event.name) {
+      /**
+       * Some trace events reference a specific script URL that triggered them.
+       * Use this URL as the higher precedence attributable URL.
+       * @see https://cs.chromium.org/chromium/src/third_party/blink/renderer/devtools/front_end/timeline/TimelineUIUtils.js?type=cs&q=_initEventStyles+-f:out+f:devtools&sq=package:chromium&g=0&l=678-744
+       */
+      case 'v8.compile':
+      case 'EvaluateScript':
+      case 'FunctionCall':
+        taskURLs = [argsData.url].concat(stackFrameURLs);
+        break;
+      case 'v8.compileModule':
+        taskURLs = [task.event.args.fileName].concat(stackFrameURLs);
+        break;
+      case 'TimerFire': {
+        /** @type {string} */
+        // @ts-ignore - timerId exists when name is TimerFire
+        const timerId = task.event.args.data.timerId;
+        const timerInstallerTaskNode = priorTaskData.timers.get(timerId);
+        if (!timerInstallerTaskNode) break;
+        taskURLs = timerInstallerTaskNode.attributableURLs.concat(stackFrameURLs);
+        break;
+      }
+      default:
+        taskURLs = stackFrameURLs;
+        break;
+    }
+
+    /** @type {string[]} */
+    const attributableURLs = Array.from(parentURLs);
+    for (const url of taskURLs) {
+      // Don't add empty URLs
+      if (!url) continue;
+      // Don't add consecutive, duplicate URLs
+      if (attributableURLs[attributableURLs.length - 1] === url) continue;
+      attributableURLs.push(url);
+    }
+
+    task.attributableURLs = attributableURLs;
+    task.children.forEach(child =>
+      MainThreadTasks._computeRecursiveAttributableURLs(child, attributableURLs, priorTaskData));
+  }
+
+  /**
+   * @param {TaskNode} task
+   * @param {TaskGroup} [parentGroup]
+   */
+  static _computeRecursiveTaskGroup(task, parentGroup) {
+    const group = taskNameToGroup[task.event.name];
+    task.group = group || parentGroup || taskGroups.other;
+    task.children.forEach(child => MainThreadTasks._computeRecursiveTaskGroup(child, task.group));
+  }
+
+  /**
+   * @param {LH.TraceEvent[]} traceEvents
+   * @param {number} traceEndTs
+   * @return {TaskNode[]}
+   */
+  static getMainThreadTasks(traceEvents, traceEndTs) {
+    const timers = new Map();
+    const priorTaskData = {timers};
+    const tasks = MainThreadTasks._createTasksFromEvents(traceEvents, priorTaskData, traceEndTs);
+
+    // Compute the recursive properties we couldn't compute earlier, starting at the toplevel tasks
+    for (const task of tasks) {
+      if (task.parent) continue;
+
+      MainThreadTasks._computeRecursiveSelfTime(task, undefined);
+      MainThreadTasks._computeRecursiveAttributableURLs(task, [], priorTaskData);
+      MainThreadTasks._computeRecursiveTaskGroup(task);
+    }
+
+    // Rebase all the times to be relative to start of trace in ms
+    const firstTs = (tasks[0] || {startTime: 0}).startTime;
+    for (const task of tasks) {
+      task.startTime = (task.startTime - firstTs) / 1000;
+      task.endTime = (task.endTime - firstTs) / 1000;
+      task.duration /= 1000;
+      task.selfTime /= 1000;
+
+      // sanity check that we have selfTime which captures all other timing data
+      if (!Number.isFinite(task.selfTime)) {
+        throw new Error('Invalid task timing data');
+      }
+    }
+
+    return tasks;
+  }
+}
+
+module.exports = MainThreadTasks;
diff --git a/lighthouse-core/lib/task-groups.js b/lighthouse-core/lib/tracehouse/task-groups.js
similarity index 100%
rename from lighthouse-core/lib/task-groups.js
rename to lighthouse-core/lib/tracehouse/task-groups.js
diff --git a/lighthouse-core/lib/tracehouse/trace-of-tab.js b/lighthouse-core/lib/tracehouse/trace-of-tab.js
new file mode 100644
index 000000000..65cbc08de
--- /dev/null
+++ b/lighthouse-core/lib/tracehouse/trace-of-tab.js
@@ -0,0 +1,191 @@
+/**
+ * @license Copyright 2017 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+/**
+ * @fileoverview Singluar helper to parse a raw trace and extract the most useful data for
+ * various tools. This artifact will take a trace and then:
+ *
+ * 1. Find the TracingStartedInPage and navigationStart events of our intended tab & frame.
+ * 2. Find the firstContentfulPaint and marked firstMeaningfulPaint events
+ * 3. Isolate only the trace events from the tab's process (including all threads like compositor)
+ *      * Sort those trace events in chronological order (as order isn't guaranteed)
+ * 4. Return all those items in one handy bundle.
+ */
+
+const log = require('lighthouse-logger');
+const TracingProcessor = require('./tracing-processor.js');
+const LHError = require('../lh-error.js');
+const Sentry = require('../sentry.js');
+
+const ACCEPTABLE_NAVIGATION_URL_REGEX = /^(chrome|https?):/;
+
+class TraceOfTab {
+  /**
+   * Returns true if the event is a navigation start event of a document whose URL seems valid.
+   *
+   * @param {LH.TraceEvent} event
+   */
+  static isNavigationStartOfInterest(event) {
+    return event.name === 'navigationStart' &&
+      (!event.args.data || !event.args.data.documentLoaderURL ||
+        ACCEPTABLE_NAVIGATION_URL_REGEX.test(event.args.data.documentLoaderURL));
+  }
+
+  /**
+   * @param {LH.TraceEvent[]} traceEvents
+   * @param {(e: LH.TraceEvent) => boolean} filter
+   */
+  static filteredStableSort(traceEvents, filter) {
+    // create an array of the indices that we want to keep
+    const indices = [];
+    for (let srcIndex = 0; srcIndex < traceEvents.length; srcIndex++) {
+      if (filter(traceEvents[srcIndex])) {
+        indices.push(srcIndex);
+      }
+    }
+
+    // sort by ts, if there's no ts difference sort by index
+    indices.sort((indexA, indexB) => {
+      const result = traceEvents[indexA].ts - traceEvents[indexB].ts;
+      return result ? result : indexA - indexB;
+    });
+
+    // create a new array using the target indices from previous sort step
+    const sorted = [];
+    for (let i = 0; i < indices.length; i++) {
+      sorted.push(traceEvents[indices[i]]);
+    }
+
+    return sorted;
+  }
+
+
+  /**
+   * Finds key trace events, identifies main process/thread, and returns timings of trace events
+   * in milliseconds since navigation start in addition to the standard microsecond monotonic timestamps.
+   * @param {LH.Trace} trace
+   * @return {LH.Artifacts.TraceOfTab}
+  */
+  static compute(trace) {
+    // Parse the trace for our key events and sort them by timestamp. Note: sort
+    // *must* be stable to keep events correctly nested.
+    const keyEvents = TraceOfTab.filteredStableSort(trace.traceEvents, e => {
+      return e.cat.includes('blink.user_timing') ||
+          e.cat.includes('loading') ||
+          e.cat.includes('devtools.timeline') ||
+          e.cat === '__metadata';
+    });
+
+    // Find the inspected frame
+    const mainFrameIds = TracingProcessor.findMainFrameIds(keyEvents);
+
+    // Filter to just events matching the frame ID for sanity
+    const frameEvents = keyEvents.filter(e => e.args.frame === mainFrameIds.frameId);
+
+    // Our navStart will be the last frame navigation in the trace
+    const navigationStart = frameEvents.filter(TraceOfTab.isNavigationStartOfInterest).pop();
+    if (!navigationStart) throw new LHError(LHError.errors.NO_NAVSTART);
+
+    // Find our first paint of this frame
+    const firstPaint = frameEvents.find(e => e.name === 'firstPaint' && e.ts > navigationStart.ts);
+
+    // FCP will follow at/after the FP. Used in so many places we require it.
+    const firstContentfulPaint = frameEvents.find(
+      e => e.name === 'firstContentfulPaint' && e.ts > navigationStart.ts
+    );
+    if (!firstContentfulPaint) throw new LHError(LHError.errors.NO_FCP);
+
+    // fMP will follow at/after the FP
+    let firstMeaningfulPaint = frameEvents.find(
+      e => e.name === 'firstMeaningfulPaint' && e.ts > navigationStart.ts
+    );
+    let fmpFellBack = false;
+
+    // If there was no firstMeaningfulPaint event found in the trace, the network idle detection
+    // may have not been triggered before Lighthouse finished tracing.
+    // In this case, we'll use the last firstMeaningfulPaintCandidate we can find.
+    // However, if no candidates were found (a bogus trace, likely), we fail.
+    if (!firstMeaningfulPaint) {
+      // Track this with Sentry since it's likely a bug we should investigate.
+      Sentry.captureMessage('No firstMeaningfulPaint found, using fallback', {level: 'warning'});
+
+      const fmpCand = 'firstMeaningfulPaintCandidate';
+      fmpFellBack = true;
+      log.verbose('trace-of-tab', `No firstMeaningfulPaint found, falling back to last ${fmpCand}`);
+      const lastCandidate = frameEvents.filter(e => e.name === fmpCand).pop();
+      if (!lastCandidate) {
+        log.verbose('trace-of-tab', 'No `firstMeaningfulPaintCandidate` events found in trace');
+      }
+      firstMeaningfulPaint = lastCandidate;
+    }
+
+    const load = frameEvents.find(e => e.name === 'loadEventEnd' && e.ts > navigationStart.ts);
+    const domContentLoaded = frameEvents.find(
+      e => e.name === 'domContentLoadedEventEnd' && e.ts > navigationStart.ts
+    );
+
+    // subset all trace events to just our tab's process (incl threads other than main)
+    // stable-sort events to keep them correctly nested.
+    const processEvents = TraceOfTab
+      .filteredStableSort(trace.traceEvents, e => e.pid === mainFrameIds.pid);
+
+    const mainThreadEvents = processEvents
+      .filter(e => e.tid === mainFrameIds.tid);
+
+    // traceEnd must exist since at least navigationStart event was verified as existing.
+    const traceEnd = trace.traceEvents.reduce((max, evt) => {
+      return max.ts > evt.ts ? max : evt;
+    });
+    const fakeEndOfTraceEvt = {ts: traceEnd.ts + (traceEnd.dur || 0)};
+
+    /** @param {{ts: number}=} event */
+    const getTimestamp = (event) => event && event.ts;
+    /** @type {LH.Artifacts.TraceTimes} */
+    const timestamps = {
+      navigationStart: navigationStart.ts,
+      firstPaint: getTimestamp(firstPaint),
+      firstContentfulPaint: firstContentfulPaint.ts,
+      firstMeaningfulPaint: getTimestamp(firstMeaningfulPaint),
+      traceEnd: fakeEndOfTraceEvt.ts,
+      load: getTimestamp(load),
+      domContentLoaded: getTimestamp(domContentLoaded),
+    };
+
+
+    /** @param {number} ts */
+    const getTiming = (ts) => (ts - navigationStart.ts) / 1000;
+    /** @param {number=} ts */
+    const maybeGetTiming = (ts) => ts === undefined ? undefined : getTiming(ts);
+    /** @type {LH.Artifacts.TraceTimes} */
+    const timings = {
+      navigationStart: 0,
+      firstPaint: maybeGetTiming(timestamps.firstPaint),
+      firstContentfulPaint: getTiming(timestamps.firstContentfulPaint),
+      firstMeaningfulPaint: maybeGetTiming(timestamps.firstMeaningfulPaint),
+      traceEnd: getTiming(timestamps.traceEnd),
+      load: maybeGetTiming(timestamps.load),
+      domContentLoaded: maybeGetTiming(timestamps.domContentLoaded),
+    };
+
+    return {
+      timings,
+      timestamps,
+      processEvents,
+      mainThreadEvents,
+      mainFrameIds,
+      navigationStartEvt: navigationStart,
+      firstPaintEvt: firstPaint,
+      firstContentfulPaintEvt: firstContentfulPaint,
+      firstMeaningfulPaintEvt: firstMeaningfulPaint,
+      loadEvt: load,
+      domContentLoadedEvt: domContentLoaded,
+      fmpFellBack,
+    };
+  }
+}
+
+module.exports = TraceOfTab;
diff --git a/lighthouse-core/lib/traces/tracing-processor.js b/lighthouse-core/lib/tracehouse/tracing-processor.js
similarity index 100%
rename from lighthouse-core/lib/traces/tracing-processor.js
rename to lighthouse-core/lib/tracehouse/tracing-processor.js
diff --git a/lighthouse-core/report/html/readme.md b/lighthouse-core/report/html/readme.md
index 3f56c76fd..3cc71c1ca 100644
--- a/lighthouse-core/report/html/readme.md
+++ b/lighthouse-core/report/html/readme.md
@@ -32,7 +32,7 @@ The renderer was designed to be portable across various environments.
 
 1. _LH Chrome Extension_: It [creates the HTML as the runner finishes up](https://github.com/GoogleChrome/lighthouse/blob/440155cdda377c458c0efce006bc3a69ce2a351c/lighthouse-core/runner.js#L137-L138) and transforms it [into a blob url in the background page](https://github.com/GoogleChrome/lighthouse/blob/440155cdda377c458c0efce006bc3a69ce2a351c/lighthouse-extension/app/src/lighthouse-ext-background.js#L129-L143).
 1. _LH CLI_: It [creates the HTML as the runner finishes up](https://github.com/GoogleChrome/lighthouse/blob/440155cdda377c458c0efce006bc3a69ce2a351c/lighthouse-core/runner.js#L137-L138) and [saves it to disk](https://github.com/GoogleChrome/lighthouse/blob/440155cdda377c458c0efce006bc3a69ce2a351c/lighthouse-cli/printer.js#L71-L92).
-1. _Chrome DevTools Audits Panel_: The `renderer` files are rolled into the Chromium repo, and they execute within the DevTools context. The audits panel [receives the LHR object from a WebWorker](https://github.com/ChromeDevTools/devtools-frontend/blob/master/front_end/audits2/Audits2ProtocolService.js#L27-L35), through a `postMessage` and then runs [the renderer within DevTools UI](https://github.com/ChromeDevTools/devtools-frontend/blob/fee00605cada877c1f8e3aae758a0f8d05b64476/front_end/audits2/Audits2Panel.js#L519-L542), making a few upgrades ([one](https://github.com/ChromeDevTools/devtools-frontend/blob/fee00605cada877c1f8e3aae758a0f8d05b64476/front_end/audits2/Audits2Panel.js#L570-L583), [two](https://github.com/ChromeDevTools/devtools-frontend/blob/fee00605cada877c1f8e3aae758a0f8d05b64476/front_end/audits2/Audits2Panel.js#L596-L637)) and [simplifications](https://github.com/ChromeDevTools/devtools-frontend/blob/fee00605cada877c1f8e3aae758a0f8d05b64476/front_end/audits2/Audits2Panel.js#L275-L304).
+1. _Chrome DevTools Audits Panel_: The `renderer` files are rolled into the Chromium repo, and they execute within the DevTools context. The audits panel [receives the LHR object from a WebWorker](https://github.com/ChromeDevTools/devtools-frontend/blob/aa1532c2f8bdc37c9886255644ed90ad01c61c77/front_end/audits/AuditsProtocolService.js#L27-L35), through a `postMessage` and then runs [the renderer within DevTools UI](https://github.com/ChromeDevTools/devtools-frontend/blob/aa1532c2f8bdc37c9886255644ed90ad01c61c77/front_end/audits/AuditsPanel.js#L123-L157), making a few [simplifications](https://github.com/ChromeDevTools/devtools-frontend/blob/master/front_end/audits/AuditsReportRenderer.js).
 1. _Hosted [Lighthouse Viewer](https://googlechrome.github.io/lighthouse/viewer/)_: It's a webapp that has the renderer (along with some [additional features](https://github.com/GoogleChrome/lighthouse/blob/master/lighthouse-core/report/html/renderer/report-ui-features.js)) all compiled into a [`viewer.js`](https://googlechrome.github.io/lighthouse/viewer/src/viewer.js) file. Same [basic approach](https://github.com/GoogleChrome/lighthouse/blob/440155cdda377c458c0efce006bc3a69ce2a351c/lighthouse-viewer/app/src/lighthouse-report-viewer.js#L116-L117) there.
 
 ### Polyfills
diff --git a/lighthouse-core/scripts/compile-against-devtools.sh b/lighthouse-core/scripts/compile-against-devtools.sh
index d072579e8..49151e3ef 100644
--- a/lighthouse-core/scripts/compile-against-devtools.sh
+++ b/lighthouse-core/scripts/compile-against-devtools.sh
@@ -13,7 +13,7 @@
 set -x
 
 # This the text here will override the renderer/ files in in the scripts[] array:
-#     https://github.com/ChromeDevTools/devtools-frontend/blob/master/front_end/audits2/module.json#L20
+#     https://github.com/ChromeDevTools/devtools-frontend/blob/master/front_end/audits/module.json#L20
 
 # (Currently this doesnt include logger or report-features)
 files_to_include="\"lighthouse\/renderer\/util.js\", \"lighthouse\/renderer\/dom.js\", \"lighthouse\/renderer\/category-renderer.js\", \"lighthouse\/renderer\/performance-category-renderer.js\", \"lighthouse\/renderer\/crc-details-renderer.js\", \"lighthouse\/renderer\/details-renderer.js\", \"lighthouse\/renderer\/report-renderer.js\","
@@ -54,13 +54,13 @@ cd "$lhroot_path" || exit 1
 yarn devtools "$frontend_path/front_end/"
 
 #
-# monkeypatch the audits2 module.json to include any new files we're added that aren't present
+# monkeypatch the audits module.json to include any new files we're added that aren't present
 #
-audit2_modulejson_path="$frontend_path/front_end/audits2/module.json"
+audit_modulejson_path="$frontend_path/front_end/audits/module.json"
 # remove existing renderer file mentions
-sed -i='' 's/.*\/renderer\/.*//' $audit2_modulejson_path
+sed -i='' 's/.*\/renderer\/.*//' $audit_modulejson_path
 # add in our hardcoded renderer file mentions
-sed -i='' "s/\"Audits2Panel\.js\"/ $files_to_include \"Audits2Panel.js\"/" $audit2_modulejson_path
+sed -i='' "s/\"AuditsPanel\.js\"/ $files_to_include \"AuditsPanel.js\"/" $audit_modulejson_path
 
 # compile, finally
 python "$frontend_path/scripts/compile_frontend.py" --protocol-externs-file "$protocol_path/externs/protocol_externs.js"
diff --git a/lighthouse-core/scripts/lantern/minify-trace.js b/lighthouse-core/scripts/lantern/minify-trace.js
index 4f930158d..2a9d74352 100755
--- a/lighthouse-core/scripts/lantern/minify-trace.js
+++ b/lighthouse-core/scripts/lantern/minify-trace.js
@@ -8,6 +8,7 @@
 
 /* eslint-disable no-console */
 
+
 const fs = require('fs');
 const path = require('path');
 const {minifyTrace} = require('../../lib/minify-trace.js');
diff --git a/lighthouse-core/scripts/release/clean-pristine.sh b/lighthouse-core/scripts/release/clean-pristine.sh
deleted file mode 100755
index f4bf04a26..000000000
--- a/lighthouse-core/scripts/release/clean-pristine.sh
+++ /dev/null
@@ -1,17 +0,0 @@
-#!/usr/bin/env bash
-
-DIRNAME="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
-LH_ROOT="$DIRNAME/../../.."
-cd $LH_ROOT
-
-set -euxo pipefail
-
-# Setup a pristine git environment
-cd ../lighthouse-pristine
-
-if [[ -z "$(git status --porcelain)" ]]; then
-  echo "Pristine repo already clean!"
-  exit 0
-fi
-
-git clean -fx
diff --git a/lighthouse-core/scripts/release/prepare-commit.sh b/lighthouse-core/scripts/release/prepare-commit.sh
index f143149e0..314831f22 100755
--- a/lighthouse-core/scripts/release/prepare-commit.sh
+++ b/lighthouse-core/scripts/release/prepare-commit.sh
@@ -70,7 +70,7 @@ echo "Version bump commit ready on the ${TXT_BOLD}$BRANCH_NAME${TXT_RESET} branc
 
 echo "${TXT_DIM}Press any key to see the git diff, CTRL+C to exit...${TXT_RESET}"
 read -n 1 -r unused_variable
-git --no-pager diff HEAD^
+git diff HEAD^
 echo "${TXT_DIM}Press any key to push to GitHub, CTRL+C to exit...${TXT_RESET}"
 read -n 1 -r unused_variable
 git push -u origin "$BRANCH_NAME"
diff --git a/lighthouse-core/scripts/roll-to-devtools.sh b/lighthouse-core/scripts/roll-to-devtools.sh
index 963cb42c0..5959fd902 100755
--- a/lighthouse-core/scripts/roll-to-devtools.sh
+++ b/lighthouse-core/scripts/roll-to-devtools.sh
@@ -33,10 +33,10 @@ else
 fi
 
 report_dir="lighthouse-core/report/html"
-fe_lh_dir="$frontend_dir/audits2/lighthouse"
+fe_lh_dir="$frontend_dir/audits/lighthouse"
 
 lh_bg_js="dist/lighthouse-dt-bundle.js"
-lh_worker_dir="$frontend_dir/audits2_worker/lighthouse"
+lh_worker_dir="$frontend_dir/audits_worker/lighthouse"
 
 # copy lighthouse-dt-bundle (potentially stale)
 cp -pPR "$lh_bg_js" "$lh_worker_dir/lighthouse-dt-bundle.js"
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index f0f16a4de..5e0e3279f 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -377,6 +377,15 @@
             "scoreDisplayMode": "informative",
             "title": "Minimize Critical Requests Depth"
         },
+        "cumulative-long-queuing-delay": {
+            "description": "[Experimental metric] Total time period between FCP and Time to Interactive during which queuing time for any input event would be higher than 50ms.",
+            "displayValue": "120\u00a0ms",
+            "id": "cumulative-long-queuing-delay",
+            "numericValue": 116.79800000000023,
+            "score": 1.0,
+            "scoreDisplayMode": "numeric",
+            "title": "Cumulative Long Queuing Delay"
+        },
         "custom-controls-labels": {
             "description": "Custom interactive controls have associated labels, provided by aria-label or aria-labelledby. [Learn more](https://developers.google.com/web/fundamentals/accessibility/how-to-review#try_it_with_a_screen_reader).",
             "id": "custom-controls-labels",
@@ -1484,6 +1493,7 @@
             "details": {
                 "items": [
                     {
+                        "cumulativeLongQueuingDelay": 117.0,
                         "estimatedInputLatency": 16.0,
                         "firstCPUIdle": 4927.0,
                         "firstCPUIdleTs": 185608247190.0,
@@ -3489,6 +3499,10 @@
                     "id": "estimated-input-latency",
                     "weight": 0.0
                 },
+                {
+                    "id": "cumulative-long-queuing-delay",
+                    "weight": 0.0
+                },
                 {
                     "group": "load-opportunities",
                     "id": "render-blocking-resources",
@@ -4112,6 +4126,24 @@
                 "name": "lh:computed:EstimatedInputLatency",
                 "startTime": 0.0
             },
+            {
+                "duration": 100.0,
+                "entryType": "measure",
+                "name": "lh:audit:cumulative-long-queuing-delay",
+                "startTime": 0.0
+            },
+            {
+                "duration": 100.0,
+                "entryType": "measure",
+                "name": "lh:computed:CumulativeLongQueuingDelay",
+                "startTime": 0.0
+            },
+            {
+                "duration": 100.0,
+                "entryType": "measure",
+                "name": "lh:computed:Interactive",
+                "startTime": 0.0
+            },
             {
                 "duration": 100.0,
                 "entryType": "measure",
diff --git a/readme.md b/readme.md
index f911924eb..c0ce1e340 100644
--- a/readme.md
+++ b/readme.md
@@ -89,6 +89,8 @@ Output:
 Options:
   --help                        Show help                                                                                                  [boolean]
   --version                     Show version number                                                                                        [boolean]
+  --cli-flags-path              The path to a JSON file that contains the desired CLI flags to apply.
+                                Flags specified at the command line will still override the file-based ones.
   --blocked-url-patterns        Block any network requests to the specified URL patterns                                                     [array]
   --disable-storage-reset       Disable clearing the browser cache and other storage APIs before a run                                     [boolean]
   --throttling-method                  Controls throttling method         [choices: "devtools", "provided", "simulate"]
@@ -304,22 +306,24 @@ This section details services that have integrated Lighthouse data. If you're wo
 ## Related Projects
 Other awesome open source projects that use Lighthouse.
 
-* **[webpack-lighthouse-plugin](https://github.com/addyosmani/webpack-lighthouse-plugin)** - run Lighthouse from a Webpack build.
-* **[lighthouse-mocha-example](https://github.com/justinribeiro/lighthouse-mocha-example)** - gather performance metrics via Lighthouse and tests them in Mocha
-* **[pwmetrics](https://github.com/paulirish/pwmetrics/)** - gather performance metrics
-* **[lighthouse-hue](https://github.com/ebidel/lighthouse-hue)** - set the color of Philips Hue lights based on a Lighthouse score
-   * **[lighthouse-magic-light](https://github.com/manekinekko/lighthouse-magic-light)** set the color of the MagicLight Bluetooth Smart Light Bulb based on Lighthouse score
-* **[lighthouse-batch](https://www.npmjs.com/package/lighthouse-batch)** - run Lighthouse over a number of sites and generate a summary of their metrics/scores.
+* **[Garie](https://github.com/boyney123/garie)** — An open source tool for monitoring performance using Lighthouse,  PageSpeed Insights, [Prometheus](https://prometheus.io/), [Grafana](https://grafana.com/) and [Docker](https://www.docker.com/).
+* **[Gimbal](https://labs.moduscreate.com/gimbal-web-performance-audit-budgeting)** - An [open source (MIT licensed)](https://github.com/ModusCreateOrg/gimbal) tool used to measure, analyze, and budget aspects of a web application. Gimbal also integrates reports with GitHub pull requests.
+* **[lightcrawler](https://github.com/github/lightcrawler)** - Crawl a website and run each page found through Lighthouse.
 * **[lighthouse-badges](https://github.com/emazzotta/lighthouse-badges)** - Generate gh-badges (shields.io) based on Lighthouse performance.
+* **[lighthouse-batch](https://www.npmjs.com/package/lighthouse-batch)** - run Lighthouse over a number of sites and generate a summary of their metrics/scores.
+* **[lighthouse-ci](https://github.com/andreasonny83/lighthouse-ci)** - Run Lighthouse and assert scores satisfy your custom thresholds.
 * **[lighthouse-cron](https://github.com/thearegee/lighthouse-cron)** - Cron multiple batch Lighthouse audits and emit results for sending to remote server.
-* **[lightcrawler](https://github.com/github/lightcrawler)** - Crawl a website and run each page found through Lighthouse.
+* **[lighthouse-gh-reporter](https://github.com/carlesnunez/lighthouse-gh-reporter)** - Run Lighthouse in CI and report back in a comment on your pull requests
+* **[lighthouse-hue](https://github.com/ebidel/lighthouse-hue)** - set the color of Philips Hue lights based on a Lighthouse score
 * **[lighthouse-lambda](https://github.com/joytocode/lighthouse-lambda)** - Run Lighthouse on AWS Lambda with prebuilt stable desktop Headless Chrome.
-* **[Garie](https://github.com/boyney123/garie)** — An open source tool for monitoring performance using Lighthouse,  PageSpeed Insights, [Prometheus](https://prometheus.io/), [Grafana](https://grafana.com/) and [Docker](https://www.docker.com/).
-* **[lighthouse-ci](https://github.com/andreasonny83/lighthouse-ci)** - Run Lighthouse and assert scores satisfy your custom thresholds.
+* **[lighthouse-magic-light](https://github.com/manekinekko/lighthouse-magic-light)** set the color of the MagicLight Bluetooth Smart Light Bulb based on Lighthouse score
+* **[lighthouse-mocha-example](https://github.com/justinribeiro/lighthouse-mocha-example)** - gather performance metrics via Lighthouse and tests them in Mocha
 * **[lighthouse4u](https://github.com/godaddy/lighthouse4u)** - LH4U provides Google Lighthouse as a service, surfaced by both a friendly UI+API, and backed by Elastic Search for easy querying and visualization.
-* **[lighthouse-gh-reporter](https://github.com/carlesnunez/lighthouse-gh-reporter)** - Run Lighthouse in CI and report back in a comment on your pull requests
-* **[react-lighthouse-viewer](https://www.npmjs.com/package/react-lighthouse-viewer)** - Render a Lighthouse JSON report in a React Component.
 * **[performance-budgets](https://performance-budgets.netlify.com/)** - Easily assert Lighthouse budgets with Docker.
+* **[pwmetrics](https://github.com/paulirish/pwmetrics/)** - gather performance metrics
+* **[react-lighthouse-viewer](https://www.npmjs.com/package/react-lighthouse-viewer)** - Render a Lighthouse JSON report in a React Component.
+* **[webpack-lighthouse-plugin](https://github.com/addyosmani/webpack-lighthouse-plugin)** - run Lighthouse from a Webpack build.
+
 ## FAQ
 
 ### How does Lighthouse work?
diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts
index 860a0520f..3129406a9 100644
--- a/types/artifacts.d.ts
+++ b/types/artifacts.d.ts
@@ -9,7 +9,7 @@ import _LanternSimulator = require('../lighthouse-core/lib/dependency-graph/simu
 import _NetworkRequest = require('../lighthouse-core/lib/network-request.js');
 import speedline = require('speedline-core');
 
-type _TaskNode = import('../lighthouse-core/computed/main-thread-tasks.js').TaskNode;
+type _TaskNode = import('../lighthouse-core/lib/tracehouse/main-thread-tasks.js').TaskNode;
 
 type LanternSimulator = InstanceType<typeof _LanternSimulator>;
 


Test patch:
diff --git a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
index d2aa1fbba..3983c1067 100644
--- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
+++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
@@ -42,6 +42,9 @@ Object {
     Object {
       "path": "metrics/estimated-input-latency",
     },
+    Object {
+      "path": "metrics/cumulative-long-queuing-delay",
+    },
     Object {
       "path": "metrics/max-potential-fid",
     },
@@ -710,6 +713,10 @@ Object {
           "id": "estimated-input-latency",
           "weight": 0,
         },
+        Object {
+          "id": "cumulative-long-queuing-delay",
+          "weight": 0,
+        },
         Object {
           "group": "load-opportunities",
           "id": "render-blocking-resources",
diff --git a/lighthouse-cli/test/cli/cli-flags-test.js b/lighthouse-cli/test/cli/cli-flags-test.js
index 431386a0f..ed7d073ac 100644
--- a/lighthouse-cli/test/cli/cli-flags-test.js
+++ b/lighthouse-cli/test/cli/cli-flags-test.js
@@ -29,6 +29,25 @@ describe('CLI bin', function() {
     });
   });
 
+  it('settings are accepted from a file path', () => {
+    const flags = getFlags([
+      'http://www.example.com',
+      `--cli-flags-path="${__dirname}/../fixtures/cli-flags-path.json"`,
+      '--budgets-path=path/to/my/budget-from-command-line.json', // this should override the config value
+    ].join(' '));
+
+    expect(flags).toMatchObject({
+      budgetsPath: 'path/to/my/budget-from-command-line.json',
+      onlyCategories: ['performance', 'seo'],
+      chromeFlags: '--window-size 800,600',
+      throttlingMethod: 'devtools',
+      throttling: {
+        requestLatencyMs: 700,
+        cpuSlowdownMultiplier: 6,
+      },
+    });
+  });
+
   it('array values support csv when appropriate', () => {
     const flags = getFlags([
       'http://www.example.com',
diff --git a/lighthouse-cli/test/fixtures/cli-flags-path.json b/lighthouse-cli/test/fixtures/cli-flags-path.json
new file mode 100644
index 000000000..7d993c9df
--- /dev/null
+++ b/lighthouse-cli/test/fixtures/cli-flags-path.json
@@ -0,0 +1,10 @@
+{
+  "budgetPath": "path/to/my/budget-from-config.json",
+  "onlyCategories": ["performance", "seo"],
+  "chromeFlags": "--window-size 800,600",
+  "throttling-method": "devtools",
+  "throttling": {
+    "requestLatencyMs": 700,
+    "cpuSlowdownMultiplier": 6
+  }
+}
diff --git a/lighthouse-cli/test/fixtures/seo/seo-tap-targets.html b/lighthouse-cli/test/fixtures/seo/seo-tap-targets.html
index 9f76e82d8..25bd22563 100644
--- a/lighthouse-cli/test/fixtures/seo/seo-tap-targets.html
+++ b/lighthouse-cli/test/fixtures/seo/seo-tap-targets.html
@@ -159,6 +159,24 @@
 
     <br/><br/>
 
+    <!-- even if a tap target's size is 0x0px it can still fail if overflowing content is visible -->
+    <div>
+      <a
+        data-gathered-target="zero-width-tap-target-with-overflowing-child-content"
+        style="display: block; width: 0; white-space: nowrap">
+        <!-- TODO: having the span should not be necessary to cause a failure here, but
+             right now we don't try to get the client rects of children that are text nodes -->
+        <span style="display: inline-block; width: 110px; height: 18px">zero width target</span>
+      </a>
+      <a
+        data-gathered-target="passing-tap-target-next-to-zero-width-target"
+        style="display: block; width: 110px; height: 100px;background: #aaa;">
+        passing target
+      </a>
+    </div>
+
+    <br/><br/>
+
     <!-- Should not fail if the two links have the same link target -->
     <div>
       <a
diff --git a/lighthouse-cli/test/smokehouse/error-expectations.js b/lighthouse-cli/test/smokehouse/error-expectations.js
index 04d804e5a..55afc640f 100644
--- a/lighthouse-cli/test/smokehouse/error-expectations.js
+++ b/lighthouse-cli/test/smokehouse/error-expectations.js
@@ -18,10 +18,10 @@ module.exports = [
     },
   },
   {
-    errorCode: 'INSECURE_DOCUMENT_REQUEST',
+    errorCode: undefined,
     lhr: {
       requestedUrl: 'https://expired.badssl.com',
-      finalUrl: 'https://expired.badssl.com',
+      finalUrl: 'https://expired.badssl.com/',
       audits: {},
     },
   },
diff --git a/lighthouse-cli/test/smokehouse/seo/expectations.js b/lighthouse-cli/test/smokehouse/seo/expectations.js
index a913495e7..f5b9a7b18 100644
--- a/lighthouse-cli/test/smokehouse/seo/expectations.js
+++ b/lighthouse-cli/test/smokehouse/seo/expectations.js
@@ -62,6 +62,13 @@ const expectedGatheredTapTargets = [
   {
     snippet: /large-enough-tap-target-next-to-too-small-tap-target/,
   },
+  {
+    snippet: /zero-width-tap-target-with-overflowing-child-content/,
+    shouldFail: true,
+  },
+  {
+    snippet: /passing-tap-target-next-to-zero-width-target/,
+  },
   {
     snippet: /links-with-same-link-target-1/,
   },
@@ -251,6 +258,30 @@ module.exports = [
           })(),
           details: {
             items: [
+              {
+                'tapTarget': {
+                  'type': 'node',
+                  /* eslint-disable max-len */
+                  'snippet': '<a data-gathered-target="zero-width-tap-target-with-overflowing-child-content" style="display: block; width: 0; white-space: nowrap">\n        <!-- TODO: having the span should not be necessary to cause a failure here, but\n             right now we don\'t try to get the client rects of children that …',
+                  'path': '2,HTML,1,BODY,14,DIV,0,A',
+                  'selector': 'body > div > a',
+                  'nodeLabel': 'zero width target',
+                },
+                'overlappingTarget': {
+                  'type': 'node',
+                  /* eslint-disable max-len */
+                  'snippet': '<a data-gathered-target="passing-tap-target-next-to-zero-width-target" style="display: block; width: 110px; height: 100px;background: #aaa;">\n        passing target\n      </a>',
+                  'path': '2,HTML,1,BODY,14,DIV,1,A',
+                  'selector': 'body > div > a',
+                  'nodeLabel': 'passing target',
+                },
+                'tapTargetScore': 864,
+                'overlappingTargetScore': 720,
+                'overlapScoreRatio': 0.8333333333333334,
+                'size': '110x18',
+                'width': 110,
+                'height': 18,
+              },
               {
                 'tapTarget': {
                   'type': 'node',
@@ -276,11 +307,13 @@ module.exports = [
                   'type': 'node',
                   'path': '2,HTML,1,BODY,3,DIV,24,A',
                   'selector': 'body > div > a',
+                  'nodeLabel': 'left',
                 },
                 'overlappingTarget': {
                   'type': 'node',
                   'path': '2,HTML,1,BODY,3,DIV,25,A',
                   'selector': 'body > div > a',
+                  'nodeLabel': 'right',
                 },
                 'tapTargetScore': 1920,
                 'overlappingTargetScore': 560,
diff --git a/lighthouse-core/scripts/release/test.sh b/lighthouse-core/scripts/release/test.sh
index 271a08346..fb568567c 100755
--- a/lighthouse-core/scripts/release/test.sh
+++ b/lighthouse-core/scripts/release/test.sh
@@ -24,7 +24,7 @@ echo "Running the standard test suite..."
 yarn test
 
 echo "Running the smoke tests...."
-yarn smoke
+yarn smoke || yarn smoke || yarn smoke
 
 echo "Testing the CLI..."
 yarn start "https://example.com" --view
diff --git a/lighthouse-core/test/audits/__snapshots__/metrics-test.js.snap b/lighthouse-core/test/audits/__snapshots__/metrics-test.js.snap
index 0807e76b8..685d98388 100644
--- a/lighthouse-core/test/audits/__snapshots__/metrics-test.js.snap
+++ b/lighthouse-core/test/audits/__snapshots__/metrics-test.js.snap
@@ -2,6 +2,7 @@
 
 exports[`Performance: metrics evaluates valid input correctly 1`] = `
 Object {
+  "cumulativeLongQueuingDelay": 748,
   "estimatedInputLatency": 78,
   "estimatedInputLatencyTs": undefined,
   "firstCPUIdle": 3351,
diff --git a/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js b/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js
index 6cf01b6a0..901200997 100644
--- a/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js
@@ -93,7 +93,7 @@ describe('Cache headers audit', () => {
 
   it('detects low value expires headers', () => {
     const expiresIn = seconds => new Date(Date.now() + seconds * 1000).toGMTString();
-    const closeEnough = (actual, exp) => assert.ok(Math.abs(actual - exp) <= 1, 'invalid expires');
+    const closeEnough = (actual, exp) => assert.ok(Math.abs(actual - exp) <= 5, 'invalid expires');
 
     const networkRecords = [
       networkRecord({headers: {expires: expiresIn(86400 * 365)}}), // a year
@@ -132,9 +132,9 @@ describe('Cache headers audit', () => {
     return CacheHeadersAudit.audit(getArtifacts(networkRecords), context).then(result => {
       const items = result.extendedInfo.value.results;
       assert.equal(items.length, 2);
-      assert.ok(Math.abs(items[0].cacheLifetimeMs - 3600 * 1000) <= 1, 'invalid expires parsing');
+      assert.ok(Math.abs(items[0].cacheLifetimeMs - 3600 * 1000) <= 5, 'invalid expires parsing');
       assert.equal(Math.round(items[0].wastedBytes), 8000);
-      assert.ok(Math.abs(items[1].cacheLifetimeMs - 86400 * 1000) <= 1, 'invalid expires parsing');
+      assert.ok(Math.abs(items[1].cacheLifetimeMs - 86400 * 1000) <= 5, 'invalid expires parsing');
       assert.equal(Math.round(items[1].wastedBytes), 4000);
     });
   });
diff --git a/lighthouse-core/test/audits/metrics/cumulative-long-queuing-delay-test.js b/lighthouse-core/test/audits/metrics/cumulative-long-queuing-delay-test.js
new file mode 100644
index 000000000..160322069
--- /dev/null
+++ b/lighthouse-core/test/audits/metrics/cumulative-long-queuing-delay-test.js
@@ -0,0 +1,32 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const cLQDAudit = require('../../../audits/metrics/cumulative-long-queuing-delay.js');
+const options = cLQDAudit.defaultOptions;
+
+const pwaTrace = require('../../fixtures/traces/progressive-app-m60.json');
+
+function generateArtifactsWithTrace(trace) {
+  return {
+    traces: {[cLQDAudit.DEFAULT_PASS]: trace},
+    devtoolsLogs: {[cLQDAudit.DEFAULT_PASS]: []},
+  };
+}
+/* eslint-env jest */
+
+describe('Performance: cumulative-long-queuing-delay audit', () => {
+  it('evaluates cumulative long queuing delay metric properly', async () => {
+    const artifacts = generateArtifactsWithTrace(pwaTrace);
+    const settings = {throttlingMethod: 'provided'};
+    const context = {options, settings, computedCache: new Map()};
+    const output = await cLQDAudit.audit(artifacts, context);
+
+    expect(output.numericValue).toBeCloseTo(48.3, 1);
+    expect(output.score).toBe(1);
+    expect(output.displayValue).toBeDisplayString('50\xa0ms');
+  });
+});
diff --git a/lighthouse-core/test/computed/main-thread-tasks-test.js b/lighthouse-core/test/computed/main-thread-tasks-test.js
index 42b17b5aa..b3298709a 100644
--- a/lighthouse-core/test/computed/main-thread-tasks-test.js
+++ b/lighthouse-core/test/computed/main-thread-tasks-test.js
@@ -1,5 +1,5 @@
 /**
- * @license Copyright 2017 Google Inc. All Rights Reserved.
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
  * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
  * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
  */
@@ -8,232 +8,12 @@
 /* eslint-env jest */
 
 const MainThreadTasks = require('../../computed/main-thread-tasks.js');
-const taskGroups = require('../../lib/task-groups.js').taskGroups;
-const pwaTrace = require('../fixtures/traces/progressive-app.json');
-const TracingProcessor = require('../../lib/traces/tracing-processor.js');
-const assert = require('assert');
-
-describe('MainResource computed artifact', () => {
-  const pid = 1;
-  const tid = 2;
-  const frameId = 'BLAH';
-  const args = {data: {}, frame: frameId};
-  const baseTs = 1241250325;
-  let boilerplateTrace;
-
-  beforeEach(() => {
-    boilerplateTrace = [
-      {ph: 'I', name: 'TracingStartedInPage', pid, tid, ts: baseTs, args: {data: {page: frameId}}},
-      {ph: 'I', name: 'navigationStart', pid, tid, ts: baseTs, args},
-      {ph: 'R', name: 'firstContentfulPaint', pid, tid, ts: baseTs + 1, args},
-    ];
-  });
-
-  it('should get all main thread tasks from a trace', async () => {
-    const context = {computedCache: new Map()};
-    const tasks = await MainThreadTasks.request({traceEvents: pwaTrace}, context);
-    const toplevelTasks = tasks.filter(task => !task.parent);
-    assert.equal(tasks.length, 2305);
-    assert.equal(toplevelTasks.length, 296);
-
-    // Sanity check the reachability of tasks and summation of selfTime
-    const allTasks = [];
-    const queue = toplevelTasks;
-    let totalTime = 0;
-    let totalTopLevelTime = 0;
-    while (queue.length) {
-      const task = queue.shift();
-      totalTime += task.selfTime;
-      totalTopLevelTime += TracingProcessor.isScheduleableTask(task.event) ? task.duration : 0;
-      allTasks.push(task);
-      queue.push(...task.children);
-    }
-
-    assert.equal(allTasks.length, 2305);
-    assert.equal(Math.round(totalTopLevelTime), 386);
-    assert.equal(Math.round(totalTime), 396);
-  });
-
-  it('should compute parent/child correctly', async () => {
-    /*
-    An artistic rendering of the below trace:
-    █████████████████████████████TaskA██████████████████████████████████████████████
-          ████████████████TaskB███████████████████
-               ████TaskC██████
-    */
-    const traceEvents = [
-      ...boilerplateTrace,
-      {ph: 'X', name: 'TaskA', pid, tid, ts: baseTs, dur: 100e3, args},
-      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 5e3, args},
-      {ph: 'X', name: 'TaskC', pid, tid, ts: baseTs + 10e3, dur: 30e3, args},
-      {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 55e3, args},
-    ];
-
-    traceEvents.forEach(evt => Object.assign(evt, {cat: 'devtools.timeline'}));
-
-    const context = {computedCache: new Map()};
-    const tasks = await MainThreadTasks.request({traceEvents}, context);
-    assert.equal(tasks.length, 3);
-
-    const taskA = tasks.find(task => task.event.name === 'TaskA');
-    const taskB = tasks.find(task => task.event.name === 'TaskB');
-    const taskC = tasks.find(task => task.event.name === 'TaskC');
-    assert.deepStrictEqual(taskA, {
-      parent: undefined,
-      attributableURLs: [],
-
-      children: [taskB],
-      event: traceEvents[3],
-      startTime: 0,
-      endTime: 100,
-      duration: 100,
-      selfTime: 50,
-      group: taskGroups.other,
-    });
-
-    assert.deepStrictEqual(taskB, {
-      parent: taskA,
-      attributableURLs: [],
-
-      children: [taskC],
-      event: traceEvents[4],
-      startTime: 5,
-      endTime: 55,
-      duration: 50,
-      selfTime: 20,
-      group: taskGroups.other,
-    });
-  });
-
-  it('should compute attributableURLs correctly', async () => {
-    const baseTs = 1241250325;
-    const url = s => ({args: {data: {url: s}}});
-    const stackFrames = f => ({args: {data: {stackTrace: f.map(url => ({url}))}}});
-
-    /*
-    An artistic rendering of the below trace:
-    █████████████████████████████TaskA██████████████████████████████████████████████
-          ████████████████TaskB███████████████████
-               ████EvaluateScript██████
-                   █D█
-    */
-    const traceEvents = [
-      ...boilerplateTrace,
-      {ph: 'X', name: 'TaskA', pid, tid, ts: baseTs, dur: 100e3, ...url('about:blank')},
-      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 5e3, ...stackFrames(['urlB.1', 'urlB.2'])},
-      {ph: 'X', name: 'EvaluateScript', pid, tid, ts: baseTs + 10e3, dur: 30e3, ...url('urlC')},
-      {ph: 'X', name: 'TaskD', pid, tid, ts: baseTs + 15e3, dur: 5e3, ...stackFrames(['urlD'])},
-      {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 55e3},
-    ];
-
-    traceEvents.forEach(evt => {
-      evt.cat = 'devtools.timeline';
-      evt.args = evt.args || args;
-    });
+const pwaTrace = require('../fixtures/traces/progressive-app-m60.json');
 
+describe('MainThreadTasksComputed', () => {
+  it('computes the artifact', async () => {
     const context = {computedCache: new Map()};
-    const tasks = await MainThreadTasks.request({traceEvents}, context);
-    const taskA = tasks.find(task => task.event.name === 'TaskA');
-    const taskB = tasks.find(task => task.event.name === 'TaskB');
-    const taskC = tasks.find(task => task.event.name === 'EvaluateScript');
-    const taskD = tasks.find(task => task.event.name === 'TaskD');
-
-    assert.deepStrictEqual(taskA.attributableURLs, []);
-    assert.deepStrictEqual(taskB.attributableURLs, ['urlB.1', 'urlB.2']);
-    assert.deepStrictEqual(taskC.attributableURLs, ['urlB.1', 'urlB.2', 'urlC']);
-    assert.deepStrictEqual(taskD.attributableURLs, ['urlB.1', 'urlB.2', 'urlC', 'urlD']);
+    const tasks = await MainThreadTasks.request(pwaTrace, context);
+    expect(tasks.length).toEqual(4784);
   });
-
-  it('should handle the last trace event not ending', async () => {
-    /*
-    An artistic rendering of the below trace:
-    █████████████████████████████TaskA████████████|
-          ████████████████TaskB███████████████████|
-                                            █TaskC|
-                                                  ^ trace abruptly ended
-    */
-    const traceEvents = [
-      ...boilerplateTrace,
-      // These events would normally be accompanied by an 'E' event
-      {ph: 'B', name: 'TaskA', pid, tid, ts: baseTs, args},
-      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 5e3, args},
-      {ph: 'B', name: 'TaskC', pid, tid, ts: baseTs + 100e3, args},
-    ];
-
-    traceEvents.forEach(evt => Object.assign(evt, {cat: 'devtools.timeline'}));
-
-    const context = {computedCache: new Map()};
-    const tasks = await MainThreadTasks.request({traceEvents}, context);
-    expect(tasks).toHaveLength(3);
-
-    const taskA = tasks.find(task => task.event.name === 'TaskA');
-    const taskB = tasks.find(task => task.event.name === 'TaskB');
-    const taskC = tasks.find(task => task.event.name === 'TaskC');
-    expect(taskA).toEqual({
-      parent: undefined,
-      attributableURLs: [],
-
-      children: [taskB],
-      event: traceEvents[3],
-      startTime: 0,
-      endTime: 100,
-      duration: 100,
-      selfTime: 5,
-      group: taskGroups.other,
-    });
-
-    expect(taskB).toEqual({
-      parent: taskA,
-      attributableURLs: [],
-
-      children: [taskC],
-      event: traceEvents[4],
-      startTime: 5,
-      endTime: 100,
-      duration: 95,
-      selfTime: 95,
-      group: taskGroups.other,
-    });
-  });
-
-  const invalidEventSets = [
-    [
-      // TaskA overlaps with TaskB, X first
-      {ph: 'X', name: 'TaskA', pid, tid, ts: baseTs, dur: 100e3, args},
-      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 5e3, args},
-      {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 115e3, args},
-    ],
-    [
-      // TaskA overlaps with TaskB, B first
-      {ph: 'B', name: 'TaskA', pid, tid, ts: baseTs, args},
-      {ph: 'X', name: 'TaskB', pid, tid, ts: baseTs + 5e3, dur: 100e3, args},
-      {ph: 'E', name: 'TaskA', pid, tid, ts: baseTs + 90e3, args},
-    ],
-    [
-      // TaskA is missing a B event
-      {ph: 'E', name: 'TaskA', pid, tid, ts: baseTs, args},
-      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 5e3, args},
-      {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 115e3, args},
-    ],
-    [
-      // TaskB is missing a B event after an X
-      {ph: 'X', name: 'TaskA', pid, tid, ts: baseTs, dur: 100e3, args},
-      {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 10e3, args},
-    ],
-  ];
-
-  for (const invalidEvents of invalidEventSets) {
-    it('should throw on invalid task input', async () => {
-      const traceEvents = [
-        ...boilerplateTrace,
-        ...invalidEvents,
-      ];
-
-      traceEvents.forEach(evt => Object.assign(evt, {cat: 'devtools.timeline'}));
-
-      const context = {computedCache: new Map()};
-      const promise = MainThreadTasks.request({traceEvents}, context);
-      await expect(promise).rejects.toBeTruthy();
-    });
-  }
 });
diff --git a/lighthouse-core/test/computed/metrics/cumulative-long-queuing-delay-test.js b/lighthouse-core/test/computed/metrics/cumulative-long-queuing-delay-test.js
new file mode 100644
index 000000000..8b02ada00
--- /dev/null
+++ b/lighthouse-core/test/computed/metrics/cumulative-long-queuing-delay-test.js
@@ -0,0 +1,115 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const CumulativeLongQueuingDelay =
+    require('../../../computed/metrics/cumulative-long-queuing-delay.js');
+const trace = require('../../fixtures/traces/progressive-app-m60.json');
+const devtoolsLog = require('../../fixtures/traces/progressive-app-m60.devtools.log.json');
+
+/* eslint-env jest */
+
+describe('Metrics: CumulativeLongQueuingDelay', () => {
+  it('should compute a simulated value', async () => {
+    const settings = {throttlingMethod: 'simulate'};
+    const context = {settings, computedCache: new Map()};
+    const result = await CumulativeLongQueuingDelay.request(
+      {trace, devtoolsLog, settings},
+      context
+    );
+
+    expect({
+      timing: Math.round(result.timing),
+      optimistic: Math.round(result.optimisticEstimate.timeInMs),
+      pessimistic: Math.round(result.pessimisticEstimate.timeInMs),
+    }).toMatchInlineSnapshot(`
+Object {
+  "optimistic": 719,
+  "pessimistic": 777,
+  "timing": 748,
+}
+`);
+  });
+
+  it('should compute an observed value', async () => {
+    const settings = {throttlingMethod: 'provided'};
+    const context = {settings, computedCache: new Map()};
+    const result = await CumulativeLongQueuingDelay.request(
+      {trace, devtoolsLog, settings},
+      context
+    );
+    expect(result.timing).toBeCloseTo(48.3, 1);
+  });
+
+  describe('#calculateSumOfLongQueuingDelay', () => {
+    it('reports 0 when no task is longer than 50ms', () => {
+      const events = [
+        {start: 1000, end: 1050, duration: 50},
+        {start: 2000, end: 2010, duration: 10},
+      ];
+
+      const fcpTimeMs = 500;
+      const interactiveTimeMs = 4000;
+
+      expect(CumulativeLongQueuingDelay.calculateSumOfLongQueuingDelay(
+        events,
+        fcpTimeMs,
+        interactiveTimeMs
+      )).toBe(0);
+    });
+
+    it('only looks at tasks within FMP and TTI', () => {
+      const events = [
+        {start: 1000, end: 1060, duration: 60},
+        {start: 2000, end: 2100, duration: 100},
+        {start: 2300, end: 2450, duration: 150},
+        {start: 2600, end: 2800, duration: 200},
+      ];
+
+      const fcpTimeMs = 1500;
+      const interactiveTimeMs = 2500;
+
+      expect(CumulativeLongQueuingDelay.calculateSumOfLongQueuingDelay(
+        events,
+        fcpTimeMs,
+        interactiveTimeMs
+      )).toBe(150);
+    });
+
+    it('clips queuing delay regions properly', () => {
+      const fcpTimeMs = 1050;
+      const interactiveTimeMs = 2050;
+
+      const events = [
+        {start: 1000, end: 1110, duration: 110}, // Contributes 10ms.
+        {start: 2000, end: 2200, duration: 200}, // Contributes 50ms.
+      ];
+
+      expect(CumulativeLongQueuingDelay.calculateSumOfLongQueuingDelay(
+        events,
+        fcpTimeMs,
+        interactiveTimeMs
+      )).toBe(60);
+    });
+
+    // This can happen in the lantern metric case, where we use the optimistic
+    // TTI and pessimistic FCP.
+    it('returns 0 if interactiveTime is earlier than FCP', () => {
+      const fcpTimeMs = 2050;
+      const interactiveTimeMs = 1050;
+
+      const events = [
+        {start: 500, end: 3000, duration: 2500},
+      ];
+
+      expect(CumulativeLongQueuingDelay.calculateSumOfLongQueuingDelay(
+        events,
+        fcpTimeMs,
+        interactiveTimeMs
+      )).toBe(0);
+    });
+  });
+});
diff --git a/lighthouse-core/test/computed/metrics/first-cpu-idle-test.js b/lighthouse-core/test/computed/metrics/first-cpu-idle-test.js
index 665aa4067..cfb21da04 100644
--- a/lighthouse-core/test/computed/metrics/first-cpu-idle-test.js
+++ b/lighthouse-core/test/computed/metrics/first-cpu-idle-test.js
@@ -6,7 +6,7 @@
 'use strict';
 
 const FirstCPUIdle = require('../../../computed/metrics/first-cpu-idle.js');
-const TracingProcessor = require('../../../lib/traces/tracing-processor.js');
+const TracingProcessor = require('../../../lib/tracehouse/tracing-processor.js');
 
 const tooShortTrace = require('../../fixtures/traces/progressive-app.json');
 const acceptableTrace = require('../../fixtures/traces/progressive-app-m60.json');
diff --git a/lighthouse-core/test/computed/trace-of-tab-test.js b/lighthouse-core/test/computed/trace-of-tab-test.js
index 9a2f829f5..4c14d71ca 100644
--- a/lighthouse-core/test/computed/trace-of-tab-test.js
+++ b/lighthouse-core/test/computed/trace-of-tab-test.js
@@ -1,213 +1,125 @@
 /**
- * @license Copyright 2017 Google Inc. All Rights Reserved.
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
  * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
  * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
  */
 'use strict';
 
-const TraceOfTab = require('../../computed/trace-of-tab.js');
-
-const assert = require('assert');
-const fs = require('fs');
-const badNavStartTrace = require('../fixtures/traces/bad-nav-start-ts.json');
-const lateTracingStartedTrace = require('../fixtures/traces/tracingstarted-after-navstart.json');
-const noTracingStartedTrace = require('../fixtures/traces/no-tracingstarted-m74.json');
-const preactTrace = require('../fixtures/traces/preactjs.com_ts_of_undefined.json');
-const noFMPtrace = require('../fixtures/traces/no_fmp_event.json');
-const noFCPtrace = require('../fixtures/traces/airhorner_no_fcp.json');
-const noNavStartTrace = require('../fixtures/traces/no_navstart_event.json');
-const backgroundTabTrace = require('../fixtures/traces/backgrounded-tab-missing-paints.json');
-
 /* eslint-env jest */
-describe('Trace of Tab computed artifact:', () => {
-  it('gathers the events from the tab\'s process', async () => {
-    const trace = await TraceOfTab.compute_(lateTracingStartedTrace);
-
-    const firstEvt = trace.processEvents[0];
-    trace.processEvents.forEach(evt => {
-      assert.equal(evt.pid, firstEvt.pid, 'A traceEvent is found from another process');
-    });
-
-    assert.ok(firstEvt.pid === trace.mainFrameIds.pid);
-    assert.ok(firstEvt.pid === trace.navigationStartEvt.pid);
-    assert.ok(firstEvt.pid === trace.firstContentfulPaintEvt.pid);
-    assert.ok(firstEvt.pid === trace.firstMeaningfulPaintEvt.pid);
-  });
-
-  it('computes timings of each event', async () => {
-    const trace = await TraceOfTab.compute_(lateTracingStartedTrace);
-    assert.equal(Math.round(trace.timings.navigationStart), 0);
-    assert.equal(Math.round(trace.timings.firstPaint), 80);
-    assert.equal(Math.round(trace.timings.firstContentfulPaint), 80);
-    assert.equal(Math.round(trace.timings.firstMeaningfulPaint), 530);
-    assert.equal(Math.round(trace.timings.traceEnd), 649);
-  });
 
-  it('computes timestamps of each event', async () => {
-    const trace = await TraceOfTab.compute_(lateTracingStartedTrace);
-    assert.equal(Math.round(trace.timestamps.navigationStart), 29343540951);
-    assert.equal(Math.round(trace.timestamps.firstPaint), 29343620997);
-    assert.equal(Math.round(trace.timestamps.firstContentfulPaint), 29343621005);
-    assert.equal(Math.round(trace.timestamps.firstMeaningfulPaint), 29344070867);
-    assert.equal(Math.round(trace.timestamps.traceEnd), 29344190223);
-  });
-
-  describe('finds correct FMP', () => {
-    it('if there was a tracingStartedInPage after the frame\'s navStart', async () => {
-      const trace = await TraceOfTab.compute_(lateTracingStartedTrace);
-      assert.equal(trace.mainFrameIds.frameId, '0x163736997740');
-      assert.equal(trace.navigationStartEvt.ts, 29343540951);
-      assert.equal(trace.firstContentfulPaintEvt.ts, 29343621005);
-      assert.equal(trace.firstMeaningfulPaintEvt.ts, 29344070867);
-      assert.ok(!trace.fmpFellBack);
-    });
-
-    it('if there was a tracingStartedInPage after the frame\'s navStart #2', async () => {
-      const trace = await TraceOfTab.compute_(badNavStartTrace);
-      assert.equal(trace.mainFrameIds.frameId, '0x89915541e48');
-      assert.equal(trace.navigationStartEvt.ts, 8885424467);
-      assert.equal(trace.firstContentfulPaintEvt.ts, 8886056886);
-      assert.equal(trace.firstMeaningfulPaintEvt.ts, 8886056891);
-      assert.ok(!trace.fmpFellBack);
-    });
+const TraceOfTab = require('../../computed/trace-of-tab.js');
+const pwaTrace = require('../fixtures/traces/progressive-app-m60.json');
 
-    it('if it appears slightly before the fCP', async () => {
-      const trace = await TraceOfTab.compute_(preactTrace);
-      assert.equal(trace.mainFrameIds.frameId, '0x25edaa521e58');
-      assert.equal(trace.navigationStartEvt.ts, 1805796384607);
-      assert.equal(trace.firstContentfulPaintEvt.ts, 1805797263653);
-      assert.equal(trace.firstMeaningfulPaintEvt.ts, 1805797262960);
-      assert.ok(!trace.fmpFellBack);
-    });
+describe('TraceOfTabComputed', () => {
+  it('computes the artifact', async () => {
+    const context = {computedCache: new Map()};
+    const traceOfTab = await TraceOfTab.request(pwaTrace, context);
 
-    it('from candidates if no defined FMP exists', async () => {
-      const trace = await TraceOfTab.compute_(noFMPtrace);
-      assert.equal(trace.mainFrameIds.frameId, '0x150343381dd0');
-      assert.equal(trace.navigationStartEvt.ts, 2146735807738);
-      assert.equal(trace.firstContentfulPaintEvt.ts, 2146737302468);
-      assert.equal(trace.firstMeaningfulPaintEvt.ts, 2146740268666);
-      assert.ok(trace.fmpFellBack);
-    });
-  });
+    expect(traceOfTab.processEvents.length).toEqual(12865);
+    expect(traceOfTab.mainThreadEvents.length).toEqual(7629);
 
-  it('handles traces missing a paints (captured in background tab)', async () => {
-    const trace = await TraceOfTab.compute_(backgroundTabTrace);
-    assert.equal(trace.mainFrameIds.frameId, '0x53965941e30');
-    assert.notEqual(trace.navigationStartEvt.ts, 1966813346529, 'picked wrong frame');
-    assert.notEqual(trace.navigationStartEvt.ts, 1966813520313, 'picked wrong frame');
-    assert.equal(
-      trace.navigationStartEvt.ts,
-      1966813258737,
-      'didnt select navStart event with same timestamp as usertiming measure'
-    );
-    assert.equal(trace.firstMeaningfulPaintEvt, undefined, 'bad fmp');
-  });
+    delete traceOfTab.processEvents;
+    delete traceOfTab.mainThreadEvents;
 
-  it('handles traces with TracingStartedInBrowser events', async () => {
-    const tracingStartedInBrowserTrace = {
-      'traceEvents': [{
-        'pid': 69850,
-        'tid': 69850,
-        'ts': 2193564729582,
-        'ph': 'I',
-        'cat': 'disabled-by-default-devtools.timeline',
-        'name': 'TracingStartedInBrowser',
-        'args': {'data': {
-          'frameTreeNodeId': 1,
-          'frames': [{
-            'frame': 'B192D1F3355A6F961EC8F0B01623C1FB',
-            'url': 'http://www.example.com/',
-            'name': '',
-            'processId': 69920,
-          }],
-        }},
-        'tts': 1085165,
-        's': 't',
-      }, {
-        'pid': 69920,
-        'tid': 1,
-        'ts': 2193564790059,
-        'ph': 'R',
-        'cat': 'blink.user_timing',
-        'name': 'navigationStart',
-        'args': {
-          'frame': 'B192D1F3355A6F961EC8F0B01623C1FB',
-          'data': {
-            'documentLoaderURL': 'http://www.example.com/',
-            'isLoadingMainFrame': true,
-          },
+    expect(traceOfTab).toEqual({
+      domContentLoadedEvt: {
+        args: {
+          frame: '0x25a638821e30',
         },
-        'tts': 141371,
-      }, {
-        'pid': 69920,
-        'tid': 1,
-        'ts': 2193564790060,
-        'ph': 'R',
-        'cat': 'loading,rail,devtools.timeline',
-        'name': 'firstContentfulPaint',
-        'args': {
-          'frame': 'B192D1F3355A6F961EC8F0B01623C1FB',
+        cat: 'blink.user_timing,rail',
+        name: 'domContentLoadedEventEnd',
+        ph: 'R',
+        pid: 44277,
+        tid: 775,
+        ts: 225414732309,
+        tts: 924831,
+      },
+      firstContentfulPaintEvt: {
+        args: {
+          frame: '0x25a638821e30',
         },
-        'tts': 141372,
-      }, {
-        'pid': 69920,
-        'tid': 1,
-        'ts': 0,
-        'ph': 'M',
-        'cat': '__metadata',
-        'name': 'thread_name',
-        'args': {'name': 'CrRendererMain'},
-      }]};
-    const trace = await TraceOfTab.compute_(tracingStartedInBrowserTrace);
-    assert.equal(trace.mainFrameIds.frameId, 'B192D1F3355A6F961EC8F0B01623C1FB');
-    assert.equal(trace.navigationStartEvt.ts, 2193564790059);
-  });
-
-  it('handles no TracingStarted errors in m74+', async () => {
-    const trace = await TraceOfTab.compute_(noTracingStartedTrace);
-    expect(trace.mainFrameIds.frameId).toEqual('0E0B1AF0B1BA04676037345D18A71577');
-    expect(trace.firstContentfulPaintEvt.ts).toEqual(2610265036367);
-  });
-
-  it('stably sorts events', async () => {
-    const traceJson = fs.readFileSync(__dirname +
-        '/../fixtures/traces/tracingstarted-after-navstart.json', 'utf8');
-    const trace = await TraceOfTab.compute_(JSON.parse(traceJson));
-    const mainPid = trace.mainThreadEvents[0].pid;
-
-    const freshProcessEvents = JSON.parse(traceJson).traceEvents
-        .filter(e => e.pid === mainPid);
-
-    // Group all events with the same timestamp in original trace order.
-    const tsMap = new Map();
-    for (const event of freshProcessEvents) {
-      const tsGroup = tsMap.get(event.ts) || [];
-      tsGroup.push(event);
-      tsMap.set(event.ts, tsGroup);
-    }
-
-    // Assert that groups of same-timestamped events are in the same order in
-    // processed events.
-    for (const [ts, tsGroup] of tsMap) {
-      if (tsGroup.length === 1) {
-        continue;
-      }
-
-      // .filter overhead could be slow, but only a handful of tsGroups.
-      const sortedEvents = trace.processEvents.filter(e => e.ts === ts);
-      assert.deepStrictEqual(sortedEvents, tsGroup);
-    }
-  });
-
-  it('throws on traces missing a navigationStart', () => {
-    return TraceOfTab.compute_(noNavStartTrace)
-      .then(_ => assert(false, 'NO_NAVSTART error not throw'),
-        err => assert.equal(err.message, 'NO_NAVSTART'));
-  });
-
-  it('throws on traces missing an FCP', () => {
-    return TraceOfTab.compute_(noFCPtrace)
-      .then(_ => assert(false, 'NO_FCP error not throw'),
-        err => assert.equal(err.message, 'NO_FCP'));
+        cat: 'loading,rail,devtools.timeline',
+        name: 'firstContentfulPaint',
+        ph: 'I',
+        pid: 44277,
+        s: 'p',
+        tid: 775,
+        ts: 225414670885,
+        tts: 866570,
+      },
+      firstMeaningfulPaintEvt: {
+        args: {
+          frame: '0x25a638821e30',
+        },
+        cat: 'loading,rail,devtools.timeline',
+        name: 'firstMeaningfulPaint',
+        ph: 'R',
+        pid: 44277,
+        tid: 775,
+        ts: 225414955343,
+        tts: 2676979,
+      },
+      firstPaintEvt: {
+        args: {
+          frame: '0x25a638821e30',
+        },
+        cat: 'loading,rail,devtools.timeline',
+        name: 'firstPaint',
+        ph: 'I',
+        pid: 44277,
+        s: 'p',
+        tid: 775,
+        ts: 225414670868,
+        tts: 866553,
+      },
+      fmpFellBack: false,
+      loadEvt: {
+        args: {
+          frame: '0x25a638821e30',
+        },
+        cat: 'blink.user_timing',
+        name: 'loadEventEnd',
+        ph: 'R',
+        pid: 44277,
+        tid: 775,
+        ts: 225416370913,
+        tts: 2369379,
+      },
+      mainFrameIds: {
+        frameId: '0x25a638821e30',
+        pid: 44277,
+        tid: 775,
+      },
+      navigationStartEvt: {
+        args: {
+          frame: '0x25a638821e30',
+        },
+        cat: 'blink.user_timing',
+        name: 'navigationStart',
+        ph: 'R',
+        pid: 44277,
+        tid: 775,
+        ts: 225414172015,
+        tts: 455539,
+      },
+      timestamps: {
+        domContentLoaded: 225414732309,
+        firstContentfulPaint: 225414670885,
+        firstMeaningfulPaint: 225414955343,
+        firstPaint: 225414670868,
+        load: 225416370913,
+        navigationStart: 225414172015,
+        traceEnd: 225426711887,
+      },
+      timings: {
+        domContentLoaded: 560.294,
+        firstContentfulPaint: 498.87,
+        firstMeaningfulPaint: 783.328,
+        firstPaint: 498.853,
+        load: 2198.898,
+        navigationStart: 0,
+        traceEnd: 12539.872,
+      },
+    });
   });
 });
diff --git a/lighthouse-core/test/gather/driver-test.js b/lighthouse-core/test/gather/driver-test.js
index d96ee191b..dfe5ed6ba 100644
--- a/lighthouse-core/test/gather/driver-test.js
+++ b/lighthouse-core/test/gather/driver-test.js
@@ -675,108 +675,6 @@ describe('.gotoURL', () => {
       // Make sure we still cleaned up our listeners
       expect(driver._waitForLoadEvent.getMockCancelFn()).toHaveBeenCalled();
     });
-
-    it('does not reject when page is secure', async () => {
-      const secureSecurityState = {
-        explanations: [],
-        securityState: 'secure',
-      };
-
-      driver.on = driver.once = createMockOnceFn()
-        .mockEvent('Security.securityStateChanged', secureSecurityState);
-
-      const startUrl = 'https://www.example.com';
-      const loadOptions = {
-        waitForLoad: true,
-        passContext: {
-          settings: {
-            maxWaitForLoad: 1,
-          },
-        },
-      };
-
-      const loadPromise = driver.gotoURL(startUrl, loadOptions);
-      await flushAllTimersAndMicrotasks();
-      await loadPromise;
-    });
-
-    it('does not reject when page is insecure but http', async () => {
-      const secureSecurityState = {
-        explanations: [],
-        securityState: 'insecure',
-        schemeIsCryptographic: false,
-      };
-
-      driver.on = driver.once = createMockOnceFn()
-        .mockEvent('Security.securityStateChanged', secureSecurityState);
-
-      const startUrl = 'https://www.example.com';
-      const loadOptions = {
-        waitForLoad: true,
-        passContext: {
-          settings: {
-            maxWaitForLoad: 1,
-          },
-        },
-      };
-
-      const loadPromise = driver.gotoURL(startUrl, loadOptions);
-      await flushAllTimersAndMicrotasks();
-      await loadPromise;
-    });
-
-    it('rejects when page is insecure', async () => {
-      const insecureSecurityState = {
-        explanations: [
-          {
-            description: 'reason 1.',
-            securityState: 'insecure',
-          },
-          {
-            description: 'blah.',
-            securityState: 'info',
-          },
-          {
-            description: 'reason 2.',
-            securityState: 'insecure',
-          },
-        ],
-        securityState: 'insecure',
-        schemeIsCryptographic: true,
-      };
-
-      driver.on = driver.once = createMockOnceFn();
-
-      const startUrl = 'https://www.example.com';
-      const loadOptions = {
-        waitForLoad: true,
-        passContext: {
-          passConfig: {
-            networkQuietThresholdMs: 1,
-          },
-        },
-      };
-
-      // 2 assertions in the catch block and the 1 implicit in `findListener`
-      expect.assertions(3);
-
-      try {
-        const loadPromise = driver.gotoURL(startUrl, loadOptions);
-        await flushAllTimersAndMicrotasks();
-
-        // Use `findListener` instead of `mockEvent` so we can control exactly when the promise resolves
-        const listener = driver.on.findListener('Security.securityStateChanged');
-        listener(insecureSecurityState);
-        await flushAllTimersAndMicrotasks();
-        await loadPromise;
-      } catch (err) {
-        expect(err).toHaveProperty('code', 'INSECURE_DOCUMENT_REQUEST');
-        expect(err.friendlyMessage).toBeDisplayString(
-          'The URL you have provided does not have a valid security certificate. ' +
-          'reason 1. reason 2.'
-        );
-      }
-    });
   });
 });
 
diff --git a/lighthouse-core/test/lib/manifest-parser-test.js b/lighthouse-core/test/lib/manifest-parser-test.js
index 9f4fa38e4..9a93de8a1 100644
--- a/lighthouse-core/test/lib/manifest-parser-test.js
+++ b/lighthouse-core/test/lib/manifest-parser-test.js
@@ -11,6 +11,7 @@ const manifestStub = require('../fixtures/manifest.json');
 
 const EXAMPLE_MANIFEST_URL = 'https://example.com/manifest.json';
 const EXAMPLE_DOC_URL = 'https://example.com/index.html';
+const EXAMPLE_MANIFEST_BLOB_URL = 'blob:https://example.com/manifest.json';
 
 /**
  * Simple manifest parsing helper when the manifest URLs aren't material to the
@@ -27,32 +28,48 @@ function noUrlManifestParser(manifestSrc) {
 describe('Manifest Parser', function() {
   it('should not parse empty string input', function() {
     const parsedManifest = noUrlManifestParser('');
-    assert.ok(parsedManifest.warning);
+    expect(parsedManifest.warning)
+      .toEqual('ERROR: file isn\'t valid JSON: SyntaxError: Unexpected end of JSON input');
   });
 
   it('accepts empty dictionary', function() {
     const parsedManifest = noUrlManifestParser('{}');
-    assert(!parsedManifest.warning);
-    assert.equal(parsedManifest.value.name.value, undefined);
-    assert.equal(parsedManifest.value.short_name.value, undefined);
-    assert.equal(parsedManifest.value.start_url.value, EXAMPLE_DOC_URL);
-    assert.equal(parsedManifest.value.display.value, 'browser');
-    assert.equal(parsedManifest.value.orientation.value, undefined);
-    assert.equal(parsedManifest.value.theme_color.value, undefined);
-    assert.equal(parsedManifest.value.background_color.value, undefined);
-    assert.ok(Array.isArray(parsedManifest.value.icons.value));
-    assert.ok(parsedManifest.value.icons.value.length === 0);
+    expect(parsedManifest.warning).toBeUndefined();
+    expect(parsedManifest.value.name.value).toBe(undefined);
+    expect(parsedManifest.value.short_name.value).toBe(undefined);
+    expect(parsedManifest.value.start_url.value).toBe(EXAMPLE_DOC_URL);
+    expect(parsedManifest.value.display.value).toBe('browser');
+    expect(parsedManifest.value.orientation.value).toBe(undefined);
+    expect(parsedManifest.value.theme_color.value).toBe(undefined);
+    expect(parsedManifest.value.background_color.value).toBe(undefined);
+    expect(parsedManifest.value.icons.value).toHaveLength(0);
     // TODO:
     // related_applications
     // prefer_related_applications
   });
 
+  it('should warn on invalid manifest parser URL', function() {
+    const parsedManifest = manifestParser('{}', 'not a URL', EXAMPLE_DOC_URL);
+    expect(parsedManifest.warning)
+      .toEqual('ERROR: invalid manifest URL: \'not a URL\'');
+  });
+
+  it('should warn on valid but non-(HTTP|HTTPS) manifest parser URL', function() {
+    const parsedManifest = manifestParser('{}', EXAMPLE_MANIFEST_BLOB_URL, EXAMPLE_DOC_URL);
+    expect(parsedManifest.warning)
+      .toEqual('WARNING: manifest URL not available over a valid network protocol');
+  });
+
   describe('icon parsing', function() {
     // 9.7
     it('gives an empty array and an error for erroneous icons entry', () => {
-      const parsedManifest = manifestParser('{"icons": {"16": "img/icons/icon16.png"}}',
-          EXAMPLE_MANIFEST_URL, EXAMPLE_DOC_URL);
-      assert.ok(!parsedManifest.warning);
+      const parsedManifest = manifestParser(
+        '{"icons": {"16": "img/icons/icon16.png"}}',
+        EXAMPLE_MANIFEST_URL,
+        EXAMPLE_DOC_URL
+      );
+
+      expect(parsedManifest.warning).toBeUndefined();
       const icons = parsedManifest.value.icons;
       assert.ok(Array.isArray(icons.value));
       assert.equal(icons.value.length, 0);
@@ -60,9 +77,8 @@ describe('Manifest Parser', function() {
     });
 
     it('gives an empty array and no error for missing icons entry', () => {
-      const parsedManifest = manifestParser('{}',
-          EXAMPLE_MANIFEST_URL, EXAMPLE_DOC_URL);
-      assert.ok(!parsedManifest.warning);
+      const parsedManifest = manifestParser('{}', EXAMPLE_MANIFEST_URL, EXAMPLE_DOC_URL);
+      expect(parsedManifest.warning).toBeUndefined();
       const icons = parsedManifest.value.icons;
       assert.ok(Array.isArray(icons.value));
       assert.equal(icons.value.length, 0);
@@ -70,9 +86,12 @@ describe('Manifest Parser', function() {
     });
 
     it('parses basic string', function() {
-      const parsedManifest = manifestParser('{"icons": [{"src": "192.png", "sizes": "192x192"}]}',
-          EXAMPLE_MANIFEST_URL, EXAMPLE_DOC_URL);
-      assert(!parsedManifest.warning);
+      const parsedManifest = manifestParser(
+        '{"icons": [{"src": "192.png", "sizes": "192x192"}]}',
+        EXAMPLE_MANIFEST_URL,
+        EXAMPLE_DOC_URL
+      );
+      expect(parsedManifest.warning).toBeUndefined();
       const icons = parsedManifest.value.icons;
       assert(!icons.warning);
       const icon192 = icons.value[0];
@@ -81,9 +100,12 @@ describe('Manifest Parser', function() {
     });
 
     it('finds four icons in the stub manifest', function() {
-      const parsedManifest = manifestParser(JSON.stringify(manifestStub), EXAMPLE_MANIFEST_URL,
-          EXAMPLE_DOC_URL);
-      assert(!parsedManifest.warning);
+      const parsedManifest = manifestParser(
+        JSON.stringify(manifestStub),
+        EXAMPLE_MANIFEST_URL,
+        EXAMPLE_DOC_URL
+      );
+      expect(parsedManifest.warning).toBeUndefined();
       assert.equal(parsedManifest.value.icons.value.length, 4);
     });
 
@@ -110,6 +132,19 @@ describe('Manifest Parser', function() {
       assert.equal(icons.value.length, 0);
     });
 
+    it('parses icons and discards any with invalid base URL values', () => {
+      const manifestSrc = JSON.stringify({
+        icons: [{
+          src: '/valid/path',
+        }],
+      });
+      const parsedManifest = manifestParser(manifestSrc, EXAMPLE_MANIFEST_BLOB_URL,
+        EXAMPLE_DOC_URL);
+      const icons = parsedManifest.value.icons;
+      expect(icons.value).toHaveLength(0);
+      expect(icons.warning).toEqual('WARNING: Some icons were ignored due to warnings.');
+    });
+
     it('parses icons and discards any with undefined or empty string src values', () => {
       const manifestSrc = JSON.stringify({
         icons: [{
@@ -139,23 +174,23 @@ describe('Manifest Parser', function() {
   describe('name parsing', function() {
     it('parses basic string', function() {
       const parsedManifest = noUrlManifestParser('{"name":"foo"}');
-      assert(!parsedManifest.warning);
+      expect(parsedManifest.warning).toBeUndefined();
       assert.equal(parsedManifest.value.name.value, 'foo');
     });
 
     it('trims whitespaces', function() {
       const parsedManifest = noUrlManifestParser('{"name":" foo "}');
-      assert(!parsedManifest.warning);
+      expect(parsedManifest.warning).toBeUndefined();
       assert.equal(parsedManifest.value.name.value, 'foo');
     });
 
     it('doesn\'t parse non-string', function() {
       let parsedManifest = noUrlManifestParser('{"name": {} }');
-      assert(!parsedManifest.warning);
+      expect(parsedManifest.warning).toBeUndefined();
       assert.equal(parsedManifest.value.name.value, undefined);
 
       parsedManifest = noUrlManifestParser('{"name": 42 }');
-      assert(!parsedManifest.warning);
+      expect(parsedManifest.warning).toBeUndefined();
       assert.equal(parsedManifest.value.name.value, undefined);
     });
   });
@@ -163,23 +198,23 @@ describe('Manifest Parser', function() {
   describe('short_name parsing', function() {
     it('parses basic string', function() {
       const parsedManifest = noUrlManifestParser('{"short_name":"foo"}');
-      assert(!parsedManifest.warning);
+      expect(parsedManifest.warning).toBeUndefined();
       assert.equal(parsedManifest.value.short_name.value, 'foo');
     });
 
     it('trims whitespaces', function() {
       const parsedManifest = noUrlManifestParser('{"short_name":" foo "}');
-      assert(!parsedManifest.warning);
+      expect(parsedManifest.warning).toBeUndefined();
       assert.equal(parsedManifest.value.short_name.value, 'foo');
     });
 
     it('doesn\'t parse non-string', function() {
       let parsedManifest = noUrlManifestParser('{"short_name": {} }');
-      assert(!parsedManifest.warning);
+      expect(parsedManifest.warning).toBeUndefined();
       assert.equal(parsedManifest.value.short_name.value, undefined);
 
       parsedManifest = noUrlManifestParser('{"short_name": 42 }');
-      assert(!parsedManifest.warning);
+      expect(parsedManifest.warning).toBeUndefined();
       assert.equal(parsedManifest.value.short_name.value, undefined);
     });
   });
diff --git a/lighthouse-core/test/lib/tracehouse/main-thread-tasks-test.js b/lighthouse-core/test/lib/tracehouse/main-thread-tasks-test.js
new file mode 100644
index 000000000..fbfdc5234
--- /dev/null
+++ b/lighthouse-core/test/lib/tracehouse/main-thread-tasks-test.js
@@ -0,0 +1,239 @@
+/**
+ * @license Copyright 2017 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+/* eslint-env jest */
+
+const MainThreadTasks = require('../../../lib/tracehouse/main-thread-tasks.js');
+const TraceOfTab = require('../../../lib/tracehouse/trace-of-tab.js');
+const taskGroups = require('../../../lib/tracehouse/task-groups.js').taskGroups;
+const pwaTrace = require('../../fixtures/traces/progressive-app.json');
+const TracingProcessor = require('../../../lib/tracehouse/tracing-processor.js');
+const assert = require('assert');
+
+describe('Main Thread Tasks', () => {
+  const pid = 1;
+  const tid = 2;
+  const frameId = 'BLAH';
+  const args = {data: {}, frame: frameId};
+  const baseTs = 1241250325;
+  let boilerplateTrace;
+
+  beforeEach(() => {
+    boilerplateTrace = [
+      {ph: 'I', name: 'TracingStartedInPage', pid, tid, ts: baseTs, args: {data: {page: frameId}}},
+      {ph: 'I', name: 'navigationStart', pid, tid, ts: baseTs, args},
+      {ph: 'R', name: 'firstContentfulPaint', pid, tid, ts: baseTs + 1, args},
+    ];
+  });
+
+  function run(trace) {
+    const {mainThreadEvents, timestamps} = TraceOfTab.compute(trace);
+    return MainThreadTasks.getMainThreadTasks(mainThreadEvents, timestamps.traceEnd);
+  }
+
+  it('should get all main thread tasks from a trace', () => {
+    const tasks = run({traceEvents: pwaTrace});
+    const toplevelTasks = tasks.filter(task => !task.parent);
+    assert.equal(tasks.length, 2305);
+    assert.equal(toplevelTasks.length, 296);
+
+    // Sanity check the reachability of tasks and summation of selfTime
+    const allTasks = [];
+    const queue = toplevelTasks;
+    let totalTime = 0;
+    let totalTopLevelTime = 0;
+    while (queue.length) {
+      const task = queue.shift();
+      totalTime += task.selfTime;
+      totalTopLevelTime += TracingProcessor.isScheduleableTask(task.event) ? task.duration : 0;
+      allTasks.push(task);
+      queue.push(...task.children);
+    }
+
+    assert.equal(allTasks.length, 2305);
+    assert.equal(Math.round(totalTopLevelTime), 386);
+    assert.equal(Math.round(totalTime), 396);
+  });
+
+  it('should compute parent/child correctly', () => {
+    /*
+    An artistic rendering of the below trace:
+    █████████████████████████████TaskA██████████████████████████████████████████████
+          ████████████████TaskB███████████████████
+               ████TaskC██████
+    */
+    const traceEvents = [
+      ...boilerplateTrace,
+      {ph: 'X', name: 'TaskA', pid, tid, ts: baseTs, dur: 100e3, args},
+      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 5e3, args},
+      {ph: 'X', name: 'TaskC', pid, tid, ts: baseTs + 10e3, dur: 30e3, args},
+      {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 55e3, args},
+    ];
+
+    traceEvents.forEach(evt => Object.assign(evt, {cat: 'devtools.timeline'}));
+
+    const tasks = run({traceEvents});
+    assert.equal(tasks.length, 3);
+
+    const taskA = tasks.find(task => task.event.name === 'TaskA');
+    const taskB = tasks.find(task => task.event.name === 'TaskB');
+    const taskC = tasks.find(task => task.event.name === 'TaskC');
+    assert.deepStrictEqual(taskA, {
+      parent: undefined,
+      attributableURLs: [],
+
+      children: [taskB],
+      event: traceEvents[3],
+      startTime: 0,
+      endTime: 100,
+      duration: 100,
+      selfTime: 50,
+      group: taskGroups.other,
+    });
+
+    assert.deepStrictEqual(taskB, {
+      parent: taskA,
+      attributableURLs: [],
+
+      children: [taskC],
+      event: traceEvents[4],
+      startTime: 5,
+      endTime: 55,
+      duration: 50,
+      selfTime: 20,
+      group: taskGroups.other,
+    });
+  });
+
+  it('should compute attributableURLs correctly', () => {
+    const baseTs = 1241250325;
+    const url = s => ({args: {data: {url: s}}});
+    const stackFrames = f => ({args: {data: {stackTrace: f.map(url => ({url}))}}});
+
+    /*
+    An artistic rendering of the below trace:
+    █████████████████████████████TaskA██████████████████████████████████████████████
+          ████████████████TaskB███████████████████
+               ████EvaluateScript██████
+                   █D█
+    */
+    const traceEvents = [
+      ...boilerplateTrace,
+      {ph: 'X', name: 'TaskA', pid, tid, ts: baseTs, dur: 100e3, ...url('about:blank')},
+      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 5e3, ...stackFrames(['urlB.1', 'urlB.2'])},
+      {ph: 'X', name: 'EvaluateScript', pid, tid, ts: baseTs + 10e3, dur: 30e3, ...url('urlC')},
+      {ph: 'X', name: 'TaskD', pid, tid, ts: baseTs + 15e3, dur: 5e3, ...stackFrames(['urlD'])},
+      {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 55e3},
+    ];
+
+    traceEvents.forEach(evt => {
+      evt.cat = 'devtools.timeline';
+      evt.args = evt.args || args;
+    });
+
+    const tasks = run({traceEvents});
+    const taskA = tasks.find(task => task.event.name === 'TaskA');
+    const taskB = tasks.find(task => task.event.name === 'TaskB');
+    const taskC = tasks.find(task => task.event.name === 'EvaluateScript');
+    const taskD = tasks.find(task => task.event.name === 'TaskD');
+
+    assert.deepStrictEqual(taskA.attributableURLs, []);
+    assert.deepStrictEqual(taskB.attributableURLs, ['urlB.1', 'urlB.2']);
+    assert.deepStrictEqual(taskC.attributableURLs, ['urlB.1', 'urlB.2', 'urlC']);
+    assert.deepStrictEqual(taskD.attributableURLs, ['urlB.1', 'urlB.2', 'urlC', 'urlD']);
+  });
+
+  it('should handle the last trace event not ending', () => {
+    /*
+    An artistic rendering of the below trace:
+    █████████████████████████████TaskA████████████|
+          ████████████████TaskB███████████████████|
+                                            █TaskC|
+                                                  ^ trace abruptly ended
+    */
+    const traceEvents = [
+      ...boilerplateTrace,
+      // These events would normally be accompanied by an 'E' event
+      {ph: 'B', name: 'TaskA', pid, tid, ts: baseTs, args},
+      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 5e3, args},
+      {ph: 'B', name: 'TaskC', pid, tid, ts: baseTs + 100e3, args},
+    ];
+
+    traceEvents.forEach(evt => Object.assign(evt, {cat: 'devtools.timeline'}));
+
+    const tasks = run({traceEvents});
+    expect(tasks).toHaveLength(3);
+
+    const taskA = tasks.find(task => task.event.name === 'TaskA');
+    const taskB = tasks.find(task => task.event.name === 'TaskB');
+    const taskC = tasks.find(task => task.event.name === 'TaskC');
+    expect(taskA).toEqual({
+      parent: undefined,
+      attributableURLs: [],
+
+      children: [taskB],
+      event: traceEvents[3],
+      startTime: 0,
+      endTime: 100,
+      duration: 100,
+      selfTime: 5,
+      group: taskGroups.other,
+    });
+
+    expect(taskB).toEqual({
+      parent: taskA,
+      attributableURLs: [],
+
+      children: [taskC],
+      event: traceEvents[4],
+      startTime: 5,
+      endTime: 100,
+      duration: 95,
+      selfTime: 95,
+      group: taskGroups.other,
+    });
+  });
+
+  const invalidEventSets = [
+    [
+      // TaskA overlaps with TaskB, X first
+      {ph: 'X', name: 'TaskA', pid, tid, ts: baseTs, dur: 100e3, args},
+      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 5e3, args},
+      {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 115e3, args},
+    ],
+    [
+      // TaskA overlaps with TaskB, B first
+      {ph: 'B', name: 'TaskA', pid, tid, ts: baseTs, args},
+      {ph: 'X', name: 'TaskB', pid, tid, ts: baseTs + 5e3, dur: 100e3, args},
+      {ph: 'E', name: 'TaskA', pid, tid, ts: baseTs + 90e3, args},
+    ],
+    [
+      // TaskA is missing a B event
+      {ph: 'E', name: 'TaskA', pid, tid, ts: baseTs, args},
+      {ph: 'B', name: 'TaskB', pid, tid, ts: baseTs + 5e3, args},
+      {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 115e3, args},
+    ],
+    [
+      // TaskB is missing a B event after an X
+      {ph: 'X', name: 'TaskA', pid, tid, ts: baseTs, dur: 100e3, args},
+      {ph: 'E', name: 'TaskB', pid, tid, ts: baseTs + 10e3, args},
+    ],
+  ];
+
+  for (const invalidEvents of invalidEventSets) {
+    it('should throw on invalid task input', () => {
+      const traceEvents = [
+        ...boilerplateTrace,
+        ...invalidEvents,
+      ];
+
+      traceEvents.forEach(evt => Object.assign(evt, {cat: 'devtools.timeline'}));
+
+      expect(() => run({traceEvents})).toThrow();
+    });
+  }
+});
diff --git a/lighthouse-core/test/lib/tracehouse/trace-of-tab-test.js b/lighthouse-core/test/lib/tracehouse/trace-of-tab-test.js
new file mode 100644
index 000000000..a8fee7340
--- /dev/null
+++ b/lighthouse-core/test/lib/tracehouse/trace-of-tab-test.js
@@ -0,0 +1,209 @@
+/**
+ * @license Copyright 2017 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const TraceOfTab = require('../../../lib/tracehouse/trace-of-tab.js');
+
+const assert = require('assert');
+const fs = require('fs');
+const badNavStartTrace = require('../../fixtures/traces/bad-nav-start-ts.json');
+const lateTracingStartedTrace = require('../../fixtures/traces/tracingstarted-after-navstart.json');
+const noTracingStartedTrace = require('../../fixtures/traces/no-tracingstarted-m74.json');
+const preactTrace = require('../../fixtures/traces/preactjs.com_ts_of_undefined.json');
+const noFMPtrace = require('../../fixtures/traces/no_fmp_event.json');
+const noFCPtrace = require('../../fixtures/traces/airhorner_no_fcp.json');
+const noNavStartTrace = require('../../fixtures/traces/no_navstart_event.json');
+const backgroundTabTrace = require('../../fixtures/traces/backgrounded-tab-missing-paints.json');
+
+/* eslint-env jest */
+describe('Trace of Tab computed artifact:', () => {
+  it('gathers the events from the tab\'s process', () => {
+    const trace = TraceOfTab.compute(lateTracingStartedTrace);
+
+    const firstEvt = trace.processEvents[0];
+    trace.processEvents.forEach(evt => {
+      assert.equal(evt.pid, firstEvt.pid, 'A traceEvent is found from another process');
+    });
+
+    assert.ok(firstEvt.pid === trace.mainFrameIds.pid);
+    assert.ok(firstEvt.pid === trace.navigationStartEvt.pid);
+    assert.ok(firstEvt.pid === trace.firstContentfulPaintEvt.pid);
+    assert.ok(firstEvt.pid === trace.firstMeaningfulPaintEvt.pid);
+  });
+
+  it('computes timings of each event', () => {
+    const trace = TraceOfTab.compute(lateTracingStartedTrace);
+    assert.equal(Math.round(trace.timings.navigationStart), 0);
+    assert.equal(Math.round(trace.timings.firstPaint), 80);
+    assert.equal(Math.round(trace.timings.firstContentfulPaint), 80);
+    assert.equal(Math.round(trace.timings.firstMeaningfulPaint), 530);
+    assert.equal(Math.round(trace.timings.traceEnd), 649);
+  });
+
+  it('computes timestamps of each event', () => {
+    const trace = TraceOfTab.compute(lateTracingStartedTrace);
+    assert.equal(Math.round(trace.timestamps.navigationStart), 29343540951);
+    assert.equal(Math.round(trace.timestamps.firstPaint), 29343620997);
+    assert.equal(Math.round(trace.timestamps.firstContentfulPaint), 29343621005);
+    assert.equal(Math.round(trace.timestamps.firstMeaningfulPaint), 29344070867);
+    assert.equal(Math.round(trace.timestamps.traceEnd), 29344190223);
+  });
+
+  describe('finds correct FMP', () => {
+    it('if there was a tracingStartedInPage after the frame\'s navStart', () => {
+      const trace = TraceOfTab.compute(lateTracingStartedTrace);
+      assert.equal(trace.mainFrameIds.frameId, '0x163736997740');
+      assert.equal(trace.navigationStartEvt.ts, 29343540951);
+      assert.equal(trace.firstContentfulPaintEvt.ts, 29343621005);
+      assert.equal(trace.firstMeaningfulPaintEvt.ts, 29344070867);
+      assert.ok(!trace.fmpFellBack);
+    });
+
+    it('if there was a tracingStartedInPage after the frame\'s navStart #2', () => {
+      const trace = TraceOfTab.compute(badNavStartTrace);
+      assert.equal(trace.mainFrameIds.frameId, '0x89915541e48');
+      assert.equal(trace.navigationStartEvt.ts, 8885424467);
+      assert.equal(trace.firstContentfulPaintEvt.ts, 8886056886);
+      assert.equal(trace.firstMeaningfulPaintEvt.ts, 8886056891);
+      assert.ok(!trace.fmpFellBack);
+    });
+
+    it('if it appears slightly before the fCP', () => {
+      const trace = TraceOfTab.compute(preactTrace);
+      assert.equal(trace.mainFrameIds.frameId, '0x25edaa521e58');
+      assert.equal(trace.navigationStartEvt.ts, 1805796384607);
+      assert.equal(trace.firstContentfulPaintEvt.ts, 1805797263653);
+      assert.equal(trace.firstMeaningfulPaintEvt.ts, 1805797262960);
+      assert.ok(!trace.fmpFellBack);
+    });
+
+    it('from candidates if no defined FMP exists', () => {
+      const trace = TraceOfTab.compute(noFMPtrace);
+      assert.equal(trace.mainFrameIds.frameId, '0x150343381dd0');
+      assert.equal(trace.navigationStartEvt.ts, 2146735807738);
+      assert.equal(trace.firstContentfulPaintEvt.ts, 2146737302468);
+      assert.equal(trace.firstMeaningfulPaintEvt.ts, 2146740268666);
+      assert.ok(trace.fmpFellBack);
+    });
+  });
+
+  it('handles traces missing a paints (captured in background tab)', () => {
+    const trace = TraceOfTab.compute(backgroundTabTrace);
+    assert.equal(trace.mainFrameIds.frameId, '0x53965941e30');
+    assert.notEqual(trace.navigationStartEvt.ts, 1966813346529, 'picked wrong frame');
+    assert.notEqual(trace.navigationStartEvt.ts, 1966813520313, 'picked wrong frame');
+    assert.equal(
+      trace.navigationStartEvt.ts,
+      1966813258737,
+      'didnt select navStart event with same timestamp as usertiming measure'
+    );
+    assert.equal(trace.firstMeaningfulPaintEvt, undefined, 'bad fmp');
+  });
+
+  it('handles traces with TracingStartedInBrowser events', () => {
+    const tracingStartedInBrowserTrace = {
+      'traceEvents': [{
+        'pid': 69850,
+        'tid': 69850,
+        'ts': 2193564729582,
+        'ph': 'I',
+        'cat': 'disabled-by-default-devtools.timeline',
+        'name': 'TracingStartedInBrowser',
+        'args': {'data': {
+          'frameTreeNodeId': 1,
+          'frames': [{
+            'frame': 'B192D1F3355A6F961EC8F0B01623C1FB',
+            'url': 'http://www.example.com/',
+            'name': '',
+            'processId': 69920,
+          }],
+        }},
+        'tts': 1085165,
+        's': 't',
+      }, {
+        'pid': 69920,
+        'tid': 1,
+        'ts': 2193564790059,
+        'ph': 'R',
+        'cat': 'blink.user_timing',
+        'name': 'navigationStart',
+        'args': {
+          'frame': 'B192D1F3355A6F961EC8F0B01623C1FB',
+          'data': {
+            'documentLoaderURL': 'http://www.example.com/',
+            'isLoadingMainFrame': true,
+          },
+        },
+        'tts': 141371,
+      }, {
+        'pid': 69920,
+        'tid': 1,
+        'ts': 2193564790060,
+        'ph': 'R',
+        'cat': 'loading,rail,devtools.timeline',
+        'name': 'firstContentfulPaint',
+        'args': {
+          'frame': 'B192D1F3355A6F961EC8F0B01623C1FB',
+        },
+        'tts': 141372,
+      }, {
+        'pid': 69920,
+        'tid': 1,
+        'ts': 0,
+        'ph': 'M',
+        'cat': '__metadata',
+        'name': 'thread_name',
+        'args': {'name': 'CrRendererMain'},
+      }]};
+    const trace = TraceOfTab.compute(tracingStartedInBrowserTrace);
+    assert.equal(trace.mainFrameIds.frameId, 'B192D1F3355A6F961EC8F0B01623C1FB');
+    assert.equal(trace.navigationStartEvt.ts, 2193564790059);
+  });
+
+  it('handles no TracingStarted errors in m74+', () => {
+    const trace = TraceOfTab.compute(noTracingStartedTrace);
+    expect(trace.mainFrameIds.frameId).toEqual('0E0B1AF0B1BA04676037345D18A71577');
+    expect(trace.firstContentfulPaintEvt.ts).toEqual(2610265036367);
+  });
+
+  it('stably sorts events', () => {
+    const traceJson = fs.readFileSync(__dirname +
+        '/../../fixtures/traces/tracingstarted-after-navstart.json', 'utf8');
+    const trace = TraceOfTab.compute(JSON.parse(traceJson));
+    const mainPid = trace.mainThreadEvents[0].pid;
+
+    const freshProcessEvents = JSON.parse(traceJson).traceEvents
+        .filter(e => e.pid === mainPid);
+
+    // Group all events with the same timestamp in original trace order.
+    const tsMap = new Map();
+    for (const event of freshProcessEvents) {
+      const tsGroup = tsMap.get(event.ts) || [];
+      tsGroup.push(event);
+      tsMap.set(event.ts, tsGroup);
+    }
+
+    // Assert that groups of same-timestamped events are in the same order in
+    // processed events.
+    for (const [ts, tsGroup] of tsMap) {
+      if (tsGroup.length === 1) {
+        continue;
+      }
+
+      // .filter overhead could be slow, but only a handful of tsGroups.
+      const sortedEvents = trace.processEvents.filter(e => e.ts === ts);
+      assert.deepStrictEqual(sortedEvents, tsGroup);
+    }
+  });
+
+  it('throws on traces missing a navigationStart', () => {
+    expect(() =>TraceOfTab.compute(noNavStartTrace)).toThrowError('NO_NAVSTART');
+  });
+
+  it('throws on traces missing an FCP', () => {
+    expect(() =>TraceOfTab.compute(noFCPtrace)).toThrowError('NO_FCP');
+  });
+});
diff --git a/lighthouse-core/test/lib/traces/tracing-processor-test.js b/lighthouse-core/test/lib/tracehouse/tracing-processor-test.js
similarity index 96%
rename from lighthouse-core/test/lib/traces/tracing-processor-test.js
rename to lighthouse-core/test/lib/tracehouse/tracing-processor-test.js
index cff916e36..3241c8298 100644
--- a/lighthouse-core/test/lib/traces/tracing-processor-test.js
+++ b/lighthouse-core/test/lib/tracehouse/tracing-processor-test.js
@@ -8,11 +8,11 @@
 const assert = require('assert');
 
 /* eslint-env jest */
-const TracingProcessor = require('../../../lib/traces/tracing-processor.js');
+const TracingProcessor = require('../../../lib/tracehouse/tracing-processor.js');
 const pwaTrace = require('../../fixtures/traces/progressive-app.json');
 const defaultPercentiles = [0, 0.25, 0.5, 0.75, 0.9, 0.99, 1];
 
-const TraceOfTab = require('../../../computed/trace-of-tab.js');
+const TraceOfTab = require('../../../lib/tracehouse/trace-of-tab.js');
 
 /**
  * Create a riskPercentiles result object by matching the values in percentiles
@@ -147,9 +147,9 @@ describe('TracingProcessor lib', () => {
   });
 
   describe('getMainThreadTopLevelEvents', () => {
-    it('gets durations of top-level tasks', async () => {
+    it('gets durations of top-level tasks', () => {
       const trace = {traceEvents: pwaTrace};
-      const tabTrace = await TraceOfTab.compute_(trace);
+      const tabTrace = TraceOfTab.compute(trace);
       const ret = TracingProcessor.getMainThreadTopLevelEvents(tabTrace);
 
       assert.equal(ret.length, 645);
@@ -190,7 +190,7 @@ describe('TracingProcessor lib', () => {
   describe('getMainThreadTopLevelEventDurations', () => {
     it('gets durations of top-level tasks', async () => {
       const trace = {traceEvents: pwaTrace};
-      const tabTrace = await TraceOfTab.compute_(trace);
+      const tabTrace = TraceOfTab.compute(trace);
       const events = TracingProcessor.getMainThreadTopLevelEvents(tabTrace);
       const ret = TracingProcessor.getMainThreadTopLevelEventDurations(events);
       const durations = ret.durations;
@@ -228,7 +228,7 @@ describe('TracingProcessor lib', () => {
 
     it('compute correct defaults', async () => {
       const trace = {traceEvents: pwaTrace};
-      const tabTrace = await TraceOfTab.compute_(trace);
+      const tabTrace = TraceOfTab.compute(trace);
       const events = TracingProcessor.getMainThreadTopLevelEvents(tabTrace);
       const ret = TracingProcessor.getRiskToResponsiveness(events, 0, tabTrace.timings.traceEnd);
       assert.equal(ret.durations.length, 645);
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 76532a9e9..bfb2a297b 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -191,6 +191,15 @@
       "numericValue": 16,
       "displayValue": "20 ms"
     },
+    "cumulative-long-queuing-delay": {
+      "id": "cumulative-long-queuing-delay",
+      "title": "Cumulative Long Queuing Delay",
+      "description": "[Experimental metric] Total time period between FCP and Time to Interactive during which queuing time for any input event would be higher than 50ms.",
+      "score": 1,
+      "scoreDisplayMode": "numeric",
+      "numericValue": 116.79800000000023,
+      "displayValue": "120 ms"
+    },
     "max-potential-fid": {
       "id": "max-potential-fid",
       "title": "Max Potential First Input Delay",
@@ -1248,6 +1257,7 @@
             "speedIndex": 4417,
             "speedIndexTs": 185607736912,
             "estimatedInputLatency": 16,
+            "cumulativeLongQueuingDelay": 117,
             "observedNavigationStart": 0,
             "observedNavigationStartTs": 185603319912,
             "observedFirstPaint": 3969,
@@ -3360,6 +3370,10 @@
           "id": "estimated-input-latency",
           "weight": 0
         },
+        {
+          "id": "cumulative-long-queuing-delay",
+          "weight": 0
+        },
         {
           "id": "render-blocking-resources",
           "weight": 0,
@@ -4216,6 +4230,24 @@
         "duration": 100,
         "entryType": "measure"
       },
+      {
+        "startTime": 0,
+        "name": "lh:audit:cumulative-long-queuing-delay",
+        "duration": 100,
+        "entryType": "measure"
+      },
+      {
+        "startTime": 0,
+        "name": "lh:computed:CumulativeLongQueuingDelay",
+        "duration": 100,
+        "entryType": "measure"
+      },
+      {
+        "startTime": 0,
+        "name": "lh:computed:Interactive",
+        "duration": 100,
+        "entryType": "measure"
+      },
       {
         "startTime": 0,
         "name": "lh:audit:max-potential-fid",