Repository: GoogleChrome/lighthouse
Pull request #9121 (2019-06-04 23:18:17)

core(gather-runner): convert PAGE_HUNG to non-fatal runtimeError
**Summary**
This should be last step in making all our page-load focused runtimeErrors non-fatal and exiting with an error code. This PR also cleans up some loose ends from #8865 where INSECURE_DOCUMENT_REQUEST was lying around.

**Notable Changes**

- PAGE_HUNG is now in the same boat as NO_FCP, it gets special cased as a `pageLoadError` and is no longer fatal.
- PAGE_HUNG does not exit with its own special exit code, it's just 1 like all the other runtimeErrors. Smoketests are now asserted via `{runtimeError: {code: '...'}}`
- If a pass fails with a `pageLoadError`, the rest of the passes are skipped and the rest of baseArtifacts are not populated. Necessary for recovery from PAGE_HUNG and just made sense, see https://github.com/GoogleChrome/lighthouse/pull/8865#issuecomment-497481383 step 3. 
- `clearDataForOrigin` will not throw PROTOCOL_TIMEOUT if it can't complete, it will just warn and move on. This is necessary for trying to recover from PAGE_HUNG in a non-fatal way, and it was one of the flagged non-responsive methods in our PROTOCOL_TIMEOUT umbrella anyhow.


**Related Issues/PRs**
followup according to https://github.com/GoogleChrome/lighthouse/pull/8865#issuecomment-497481383 (step 2 of the process)


devtools-bot: Howdy chief! Appreciate you filing this bug. :clap:
This error means that the page stopped responding. The solution here is to re-run. If the error repeats, the page or your machine creates infinite loops and needs to be fixed.
:robot: Beep beep boop. 

Patch:
diff --git a/clients/extension/scripts/popup.js b/clients/extension/scripts/popup.js
index bf33fe7c2..382e2d635 100644
--- a/clients/extension/scripts/popup.js
+++ b/clients/extension/scripts/popup.js
@@ -13,14 +13,7 @@
  * Lighthouse itself, mapped to more useful strings to report to the user.
  */
 const NON_BUG_ERROR_MESSAGES = {
-  // The user tries to review an error page or has network issues
-  'ERRORED_DOCUMENT_REQUEST': 'Unable to load the page. Please verify the url you ' +
-      'are trying to review.',
-  'FAILED_DOCUMENT_REQUEST': 'Unable to load the page. Please verify the url you ' +
-      'are trying to review.',
   'DNS_FAILURE': 'DNS servers could not resolve the provided domain.',
-  'INSECURE_DOCUMENT_REQUEST': 'The URL you have provided does not have a valid' +
-      ' SSL certificate.',
   'INVALID_URL': 'Lighthouse can only audit URLs that start' +
       ' with http:// or https://.',
 
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/lighthouse-cli/run.js b/lighthouse-cli/run.js
index 88c6a4ba8..cc8c717f1 100644
--- a/lighthouse-cli/run.js
+++ b/lighthouse-cli/run.js
@@ -24,8 +24,6 @@ const opn = require('opn');
 
 const _RUNTIME_ERROR_CODE = 1;
 const _PROTOCOL_TIMEOUT_EXIT_CODE = 67;
-const _PAGE_HUNG_EXIT_CODE = 68;
-const _INSECURE_DOCUMENT_REQUEST_EXIT_CODE = 69;
 
 /**
  * exported for testing
@@ -76,18 +74,6 @@ function printProtocolTimeoutErrorAndExit() {
   return process.exit(_PROTOCOL_TIMEOUT_EXIT_CODE);
 }
 
-/** @param {LighthouseError} err @return {never} */
-function printPageHungErrorAndExit(err) {
-  console.error('Page hung:', err.friendlyMessage);
-  return process.exit(_PAGE_HUNG_EXIT_CODE);
-}
-
-/** @param {LighthouseError} err @return {never} */
-function printInsecureDocumentRequestErrorAndExit(err) {
-  console.error('Insecure document request:', err.friendlyMessage);
-  return process.exit(_INSECURE_DOCUMENT_REQUEST_EXIT_CODE);
-}
-
 /**
  * @param {LighthouseError} err
  * @return {never}
@@ -109,10 +95,6 @@ function printErrorAndExit(err) {
     return printConnectionErrorAndExit();
   } else if (err.code === 'CRI_TIMEOUT') {
     return printProtocolTimeoutErrorAndExit();
-  } else if (err.code === 'PAGE_HUNG') {
-    return printPageHungErrorAndExit(err);
-  } else if (err.code === 'INSECURE_DOCUMENT_REQUEST') {
-    return printInsecureDocumentRequestErrorAndExit(err);
   } else {
     return printRuntimeErrorAndExit(err);
   }
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..da1689072
--- /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/tracehouse/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 a07c4d6d6..a5a8ee8a6 100644
--- a/lighthouse-core/gather/driver.js
+++ b/lighthouse-core/gather/driver.js
@@ -1452,7 +1452,7 @@ class Driver {
    * @param {string} url
    * @return {Promise<void>}
    */
-  clearDataForOrigin(url) {
+  async clearDataForOrigin(url) {
     const origin = new URL(url).origin;
 
     // Clear all types of storage except cookies, so the user isn't logged out.
@@ -1469,10 +1469,22 @@ class Driver {
       'cache_storage',
     ].join(',');
 
-    return this.sendCommand('Storage.clearDataForOrigin', {
-      origin: origin,
-      storageTypes: typesToClear,
-    });
+    // `Storage.clearDataForOrigin` is one of our PROTOCOL_TIMEOUT culprits and this command is also
+    // run in the context of PAGE_HUNG to cleanup. We'll keep the timeout low and just warn if it fails.
+    this.setNextProtocolTimeout(5000);
+
+    try {
+      await this.sendCommand('Storage.clearDataForOrigin', {
+        origin: origin,
+        storageTypes: typesToClear,
+      });
+    } catch (err) {
+      if (/** @type {LH.LighthouseError} */(err).code === 'PROTOCOL_TIMEOUT') {
+        log.warn('Driver', 'clearDataForOrigin timed out');
+      } else {
+        throw err;
+      }
+    }
   }
 
   /**
diff --git a/lighthouse-core/gather/gather-runner.js b/lighthouse-core/gather/gather-runner.js
index 24f3baafb..99fc7b6ff 100644
--- a/lighthouse-core/gather/gather-runner.js
+++ b/lighthouse-core/gather/gather-runner.js
@@ -71,7 +71,7 @@ class GatherRunner {
       passContext.url = finalUrl;
     } catch (err) {
       // If it's one of our loading-based LHErrors, we'll treat it as a page load error.
-      if (err.code === 'NO_FCP') {
+      if (err.code === 'NO_FCP' || err.code === 'PAGE_HUNG') {
         return {navigationError: err};
       }
 
@@ -452,7 +452,7 @@ class GatherRunner {
       traces: {},
       devtoolsLogs: {},
       settings: options.settings,
-      URL: {requestedUrl: options.requestedUrl, finalUrl: ''},
+      URL: {requestedUrl: options.requestedUrl, finalUrl: options.requestedUrl},
       Timing: [],
     };
   }
@@ -552,6 +552,9 @@ class GatherRunner {
         const passResults = await GatherRunner.runPass(passContext);
         Object.assign(artifacts, passResults.artifacts);
 
+        // If we encountered a pageLoadError, don't try to keep loading the page in future passes.
+        if (passResults.pageLoadError) break;
+
         if (isFirstPass) {
           await GatherRunner.populateBaseArtifacts(passContext);
           isFirstPass = false;
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/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/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/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/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>;
 
diff --git a/types/smokehouse.d.ts b/types/smokehouse.d.ts
index 5dc28921a..246e9cbc2 100644
--- a/types/smokehouse.d.ts
+++ b/types/smokehouse.d.ts
@@ -22,7 +22,6 @@
   export type ExpectedLHR = Pick<LH.Result, 'audits' | 'finalUrl' | 'requestedUrl'>
 
   export type ExpectedRunnerResult = {
-    errorCode?: string;
     lhr: ExpectedLHR,
     artifacts?: Partial<LH.Artifacts>
   }


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/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 55afc640f..c7d436833 100644
--- a/lighthouse-cli/test/smokehouse/error-expectations.js
+++ b/lighthouse-cli/test/smokehouse/error-expectations.js
@@ -10,19 +10,19 @@
  */
 module.exports = [
   {
-    errorCode: 'PAGE_HUNG',
     lhr: {
       requestedUrl: 'http://localhost:10200/infinite-loop.html',
       finalUrl: 'http://localhost:10200/infinite-loop.html',
       audits: {},
+      runtimeError: {code: 'PAGE_HUNG'},
     },
   },
   {
-    errorCode: undefined,
     lhr: {
       requestedUrl: 'https://expired.badssl.com',
       finalUrl: 'https://expired.badssl.com/',
       audits: {},
+      runtimeError: {code: 'FAILED_DOCUMENT_REQUEST'},
     },
   },
 ];
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-cli/test/smokehouse/smokehouse-report.js b/lighthouse-cli/test/smokehouse/smokehouse-report.js
index 656561f32..f3f4f04b0 100644
--- a/lighthouse-cli/test/smokehouse/smokehouse-report.js
+++ b/lighthouse-cli/test/smokehouse/smokehouse-report.js
@@ -155,12 +155,6 @@ function collateResults(actual, expected) {
   });
 
   return [
-    {
-      name: 'error code',
-      actual: actual.errorCode,
-      expected: expected.errorCode,
-      equal: actual.errorCode === expected.errorCode,
-    },
     {
       name: 'final url',
       actual: actual.lhr.finalUrl,
diff --git a/lighthouse-cli/test/smokehouse/smokehouse.js b/lighthouse-cli/test/smokehouse/smokehouse.js
index 680de8884..0d3f5ff3f 100755
--- a/lighthouse-cli/test/smokehouse/smokehouse.js
+++ b/lighthouse-cli/test/smokehouse/smokehouse.js
@@ -16,8 +16,6 @@ const log = require('lighthouse-logger');
 const {collateResults, report} = require('./smokehouse-report.js');
 
 const PROTOCOL_TIMEOUT_EXIT_CODE = 67;
-const PAGE_HUNG_EXIT_CODE = 68;
-const INSECURE_DOCUMENT_REQUEST_EXIT_CODE = 69;
 const RETRIES = 3;
 
 /**
@@ -38,21 +36,6 @@ function resolveLocalOrCwd(payloadPath) {
   return resolved;
 }
 
-/**
- * Determines if the Lighthouse run ended in an unexpected fatal result.
- * @param {number} exitCode
- * @param {string} outputPath
- */
-function isUnexpectedFatalResult(exitCode, outputPath) {
-  return exitCode !== 0
-    // These runtime errors are currently fatal but "expected" runtime errors we are asserting against.
-    && exitCode !== PAGE_HUNG_EXIT_CODE
-    && exitCode !== INSECURE_DOCUMENT_REQUEST_EXIT_CODE
-    // On runtime errors we exit with a error status code, but still output a report.
-    // If the report exists, it wasn't a fatal LH error we need to abort on, it's one we're asserting :)
-    && !fs.existsSync(outputPath);
-}
-
 /**
  * Launch Chrome and do a full Lighthouse run.
  * @param {string} url
@@ -108,25 +91,17 @@ function runLighthouse(url, configPath, isDebug) {
   if (runResults.status === PROTOCOL_TIMEOUT_EXIT_CODE) {
     console.error(`Lighthouse debugger connection timed out ${RETRIES} times. Giving up.`);
     process.exit(1);
-  } else if (isUnexpectedFatalResult(runResults.status, outputPath)) {
-    console.error(`Lighthouse run failed with exit code ${runResults.status}. stderr to follow:`);
+  } else if (!fs.existsSync(outputPath)) {
+    console.error(`Lighthouse run failed to produce a report and exited with ${runResults.status}. stderr to follow:`); // eslint-disable-line max-len
     console.error(runResults.stderr);
     process.exit(runResults.status);
   }
 
-  let errorCode;
-  let lhr = {requestedUrl: url, finalUrl: url, audits: {}};
-  if (runResults.status === PAGE_HUNG_EXIT_CODE) {
-    errorCode = 'PAGE_HUNG';
-  } else if (runResults.status === INSECURE_DOCUMENT_REQUEST_EXIT_CODE) {
-    errorCode = 'INSECURE_DOCUMENT_REQUEST';
+  const lhr = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
+  if (isDebug) {
+    console.log('LHR output available at: ', outputPath);
   } else {
-    lhr = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
-    if (isDebug) {
-      console.log('LHR output available at: ', outputPath);
-    } else if (fs.existsSync(outputPath)) {
-      fs.unlinkSync(outputPath);
-    }
+    fs.unlinkSync(outputPath);
   }
 
   // Artifacts are undefined if they weren't written to disk (e.g. if there was an error).
@@ -136,7 +111,6 @@ function runLighthouse(url, configPath, isDebug) {
   } catch (e) {}
 
   return {
-    errorCode,
     lhr,
     artifacts,
   };
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/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",