Repository: GoogleChrome/lighthouse
Pull request #9979 (2019-11-18 16:00:34)

core(lhr): add numericUnit to audit result
**Summary**
Per discussion in https://github.com/GoogleChrome/lighthouse-ci/issues/100#issuecomment-554822193, adds `numericUnit` to our audit result type and enforces setting it when `numericValue` is set.

This also removes `numericValue` for a few audits where it was set to `details.items.length` which is superfluous information. Breaking change, but OK for 6.0 :)

- errors-in-console
- main-thread-tasks
- network-requests

**Related Issues/PRs**
https://github.com/GoogleChrome/lighthouse-ci/issues/100


patrickhulce: > part of the point of numericValue is that you don't have to reach into details.items just to see you don't have any errors on the page, though.

I was never under the impression this was the point of numericValue, that's what the score is for :) 

I can say with authority that the author of `network-requests` and `main-thread-tasks` included `numericValue` superfluously and without good reason because I wrote those audits. At the time I thought it was a more common pattern I was following than it actually was. That just leaves `errors-in-console` which was [authored](https://github.com/GoogleChrome/lighthouse/pull/2836) while the property was still called the ambiguous and undecided purpose `rawValue`, by an external contributor who clearly could not have the project-wide knowledge to make such a distinction even if we had one at the time. 

Patch:
diff --git a/lighthouse-core/audits/audit.js b/lighthouse-core/audits/audit.js
index 2e106feba..78d71291c 100644
--- a/lighthouse-core/audits/audit.js
+++ b/lighthouse-core/audits/audit.js
@@ -263,6 +263,12 @@ class Audit {
       }
     }
 
+    // The Audit.Product type is bifurcated to enforce numericUnit accompanying numericValue;
+    // the existence of `numericUnit` is our discriminant.
+    // Make ts happy and enforce this contract programmatically by only pulling numericValue off of
+    // a `NumericProduct` type.
+    const numericProduct = 'numericUnit' in product ? product : undefined;
+
     return {
       id: audit.meta.id,
       title: auditTitle,
@@ -270,7 +276,8 @@ class Audit {
 
       score,
       scoreDisplayMode,
-      numericValue: product.numericValue,
+      numericValue: numericProduct && numericProduct.numericValue,
+      numericUnit: numericProduct && numericProduct.numericUnit,
 
       displayValue: product.displayValue,
       explanation: product.explanation,
diff --git a/lighthouse-core/audits/bootup-time.js b/lighthouse-core/audits/bootup-time.js
index e488d70d5..a44d29332 100644
--- a/lighthouse-core/audits/bootup-time.js
+++ b/lighthouse-core/audits/bootup-time.js
@@ -189,6 +189,7 @@ class BootupTime extends Audit {
     return {
       score,
       numericValue: totalBootupTime,
+      numericUnit: 'millisecond',
       displayValue: totalBootupTime > 0 ?
         str_(i18n.UIStrings.seconds, {timeInMs: totalBootupTime}) : '',
       details,
diff --git a/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js b/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js
index 03832f1db..883bca533 100644
--- a/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js
+++ b/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js
@@ -211,6 +211,7 @@ class UnusedBytes extends Audit {
       warnings: result.warnings,
       displayValue,
       numericValue: wastedMs,
+      numericUnit: 'millisecond',
       score: UnusedBytes.scoreForWastedMs(wastedMs),
       extendedInfo: {
         value: {
diff --git a/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js b/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js
index abc7a12ae..f8c3cdaff 100644
--- a/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js
+++ b/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js
@@ -226,6 +226,7 @@ class RenderBlockingResources extends Audit {
       displayValue,
       score: ByteEfficiencyAudit.scoreForWastedMs(wastedMs),
       numericValue: wastedMs,
+      numericUnit: 'millisecond',
       details,
     };
   }
diff --git a/lighthouse-core/audits/byte-efficiency/total-byte-weight.js b/lighthouse-core/audits/byte-efficiency/total-byte-weight.js
index aa06e6ec0..29f18e4b0 100644
--- a/lighthouse-core/audits/byte-efficiency/total-byte-weight.js
+++ b/lighthouse-core/audits/byte-efficiency/total-byte-weight.js
@@ -100,6 +100,7 @@ class TotalByteWeight extends ByteEfficiencyAudit {
     return {
       score,
       numericValue: totalBytes,
+      numericUnit: 'byte',
       displayValue: str_(UIStrings.displayValue, {totalBytes}),
       extendedInfo: {
         value: {
diff --git a/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js b/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
index 3d46ac51e..02161ae0d 100644
--- a/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
+++ b/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
@@ -286,6 +286,7 @@ class CacheHeaders extends Audit {
       return {
         score,
         numericValue: totalWastedBytes,
+        numericUnit: 'byte',
         displayValue: str_(UIStrings.displayValue, {itemCount: results.length}),
         extendedInfo: {
           value: {
diff --git a/lighthouse-core/audits/dobetterweb/dom-size.js b/lighthouse-core/audits/dobetterweb/dom-size.js
index 74af579d1..c2d42df28 100644
--- a/lighthouse-core/audits/dobetterweb/dom-size.js
+++ b/lighthouse-core/audits/dobetterweb/dom-size.js
@@ -103,6 +103,7 @@ class DOMSize extends Audit {
       {
         statistic: str_(UIStrings.statisticDOMElements),
         element: '',
+        // TODO: these values should be numbers once `_renderNumeric` in details-renderer can handle them
         value: i18n.formatNumber(stats.totalBodyElements),
       },
       {
@@ -126,6 +127,7 @@ class DOMSize extends Audit {
     return {
       score,
       numericValue: stats.totalBodyElements,
+      numericUnit: 'element',
       displayValue: str_(UIStrings.displayValue, {itemCount: stats.totalBodyElements}),
       extendedInfo: {
         value: items,
diff --git a/lighthouse-core/audits/errors-in-console.js b/lighthouse-core/audits/errors-in-console.js
index 27349f93a..c17d2e8b7 100644
--- a/lighthouse-core/audits/errors-in-console.js
+++ b/lighthouse-core/audits/errors-in-console.js
@@ -124,7 +124,6 @@ class ErrorLogs extends Audit {
 
     return {
       score: Number(numErrors === 0),
-      numericValue: numErrors,
       details,
     };
   }
diff --git a/lighthouse-core/audits/load-fast-enough-for-pwa.js b/lighthouse-core/audits/load-fast-enough-for-pwa.js
index 6ac4dfd7f..4e3af73ee 100644
--- a/lighthouse-core/audits/load-fast-enough-for-pwa.js
+++ b/lighthouse-core/audits/load-fast-enough-for-pwa.js
@@ -98,6 +98,7 @@ class LoadFastEnough4Pwa extends Audit {
       displayValue,
       explanation,
       numericValue: tti.timing,
+      numericUnit: 'millisecond',
     };
   }
 }
diff --git a/lighthouse-core/audits/main-thread-tasks.js b/lighthouse-core/audits/main-thread-tasks.js
index e3265f8bb..df7b2cb31 100644
--- a/lighthouse-core/audits/main-thread-tasks.js
+++ b/lighthouse-core/audits/main-thread-tasks.js
@@ -51,7 +51,6 @@ class MainThreadTasks extends Audit {
 
     return {
       score: 1,
-      numericValue: results.length,
       details: tableDetails,
     };
   }
diff --git a/lighthouse-core/audits/mainthread-work-breakdown.js b/lighthouse-core/audits/mainthread-work-breakdown.js
index 8bbaac8c3..6a57a4136 100644
--- a/lighthouse-core/audits/mainthread-work-breakdown.js
+++ b/lighthouse-core/audits/mainthread-work-breakdown.js
@@ -124,6 +124,7 @@ class MainThreadWorkBreakdown extends Audit {
     return {
       score,
       numericValue: totalExecutionTime,
+      numericUnit: 'millisecond',
       displayValue: str_(i18n.UIStrings.seconds, {timeInMs: totalExecutionTime}),
       details: tableDetails,
     };
diff --git a/lighthouse-core/audits/metrics.js b/lighthouse-core/audits/metrics.js
index 8179ed86d..3b5110c4e 100644
--- a/lighthouse-core/audits/metrics.js
+++ b/lighthouse-core/audits/metrics.js
@@ -52,6 +52,7 @@ class Metrics extends Audit {
     return {
       score: 1,
       numericValue: metrics.interactive || 0,
+      numericUnit: 'millisecond',
       details,
     };
   }
diff --git a/lighthouse-core/audits/metrics/estimated-input-latency.js b/lighthouse-core/audits/metrics/estimated-input-latency.js
index f775c4333..148cf71a5 100644
--- a/lighthouse-core/audits/metrics/estimated-input-latency.js
+++ b/lighthouse-core/audits/metrics/estimated-input-latency.js
@@ -65,6 +65,7 @@ class EstimatedInputLatency extends Audit {
         context.options.scoreMedian
       ),
       numericValue: metricResult.timing,
+      numericUnit: 'millisecond',
       displayValue: str_(i18n.UIStrings.ms, {timeInMs: metricResult.timing}),
     };
   }
diff --git a/lighthouse-core/audits/metrics/first-contentful-paint-3g.js b/lighthouse-core/audits/metrics/first-contentful-paint-3g.js
index 8742c2752..fbe86e0ae 100644
--- a/lighthouse-core/audits/metrics/first-contentful-paint-3g.js
+++ b/lighthouse-core/audits/metrics/first-contentful-paint-3g.js
@@ -56,6 +56,7 @@ class FirstContentfulPaint3G extends Audit {
         context.options.scoreMedian
       ),
       numericValue: metricResult.timing,
+      numericUnit: 'millisecond',
       displayValue: `${metricResult.timing}\xa0ms`,
     };
   }
diff --git a/lighthouse-core/audits/metrics/first-contentful-paint.js b/lighthouse-core/audits/metrics/first-contentful-paint.js
index 75653041e..02d7b01bd 100644
--- a/lighthouse-core/audits/metrics/first-contentful-paint.js
+++ b/lighthouse-core/audits/metrics/first-contentful-paint.js
@@ -71,6 +71,7 @@ class FirstContentfulPaint extends Audit {
         scoreOptions.scoreMedian
       ),
       numericValue: metricResult.timing,
+      numericUnit: 'millisecond',
       displayValue: str_(i18n.UIStrings.seconds, {timeInMs: metricResult.timing}),
     };
   }
diff --git a/lighthouse-core/audits/metrics/first-cpu-idle.js b/lighthouse-core/audits/metrics/first-cpu-idle.js
index d2a2c9582..dc96cb53c 100644
--- a/lighthouse-core/audits/metrics/first-cpu-idle.js
+++ b/lighthouse-core/audits/metrics/first-cpu-idle.js
@@ -74,6 +74,7 @@ class FirstCPUIdle extends Audit {
         scoreOptions.scoreMedian
       ),
       numericValue: metricResult.timing,
+      numericUnit: 'millisecond',
       displayValue: str_(i18n.UIStrings.seconds, {timeInMs: metricResult.timing}),
     };
   }
diff --git a/lighthouse-core/audits/metrics/first-meaningful-paint.js b/lighthouse-core/audits/metrics/first-meaningful-paint.js
index 0bfacc2a1..a8a1af5e4 100644
--- a/lighthouse-core/audits/metrics/first-meaningful-paint.js
+++ b/lighthouse-core/audits/metrics/first-meaningful-paint.js
@@ -74,6 +74,7 @@ class FirstMeaningfulPaint extends Audit {
         scoreOptions.scoreMedian
       ),
       numericValue: metricResult.timing,
+      numericUnit: 'millisecond',
       displayValue: str_(i18n.UIStrings.seconds, {timeInMs: metricResult.timing}),
     };
   }
diff --git a/lighthouse-core/audits/metrics/interactive.js b/lighthouse-core/audits/metrics/interactive.js
index c4a162257..ac9cadf16 100644
--- a/lighthouse-core/audits/metrics/interactive.js
+++ b/lighthouse-core/audits/metrics/interactive.js
@@ -86,6 +86,7 @@ class InteractiveMetric extends Audit {
         scoreOptions.scoreMedian
       ),
       numericValue: timeInMs,
+      numericUnit: 'millisecond',
       displayValue: str_(i18n.UIStrings.seconds, {timeInMs}),
       extendedInfo: {
         value: extendedInfo,
diff --git a/lighthouse-core/audits/metrics/largest-contentful-paint.js b/lighthouse-core/audits/metrics/largest-contentful-paint.js
index 61ad7f4d0..99cadb7ec 100644
--- a/lighthouse-core/audits/metrics/largest-contentful-paint.js
+++ b/lighthouse-core/audits/metrics/largest-contentful-paint.js
@@ -14,7 +14,7 @@ const UIStrings = {
   title: 'Largest Contentful Paint',
   /** Description of the Largest Contentful Paint (LCP) metric, which marks the time at which the largest text or image is painted by the browser. This is displayed within a tooltip when the user hovers on the metric name to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'Largest Contentful Paint marks the time at which the largest text or image is ' +
-      `painted. [Learn More](https://web.dev/largest-contentful-paint)`, // TODO: waiting on LH specific doc.
+      `painted. [Learn More](https://web.dev/lighthouse-largest-contentful-paint)`,
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
@@ -62,6 +62,7 @@ class LargestContentfulPaint extends Audit {
         context.options.scoreMedian
       ),
       numericValue: metricResult.timing,
+      numericUnit: 'millisecond',
       displayValue: str_(i18n.UIStrings.seconds, {timeInMs: metricResult.timing}),
     };
   }
diff --git a/lighthouse-core/audits/metrics/max-potential-fid.js b/lighthouse-core/audits/metrics/max-potential-fid.js
index 1e0d879d2..0d68c564c 100644
--- a/lighthouse-core/audits/metrics/max-potential-fid.js
+++ b/lighthouse-core/audits/metrics/max-potential-fid.js
@@ -65,6 +65,7 @@ class MaxPotentialFID extends Audit {
         context.options.scoreMedian
       ),
       numericValue: metricResult.timing,
+      numericUnit: 'millisecond',
       displayValue: str_(i18n.UIStrings.ms, {timeInMs: metricResult.timing}),
     };
   }
diff --git a/lighthouse-core/audits/metrics/speed-index.js b/lighthouse-core/audits/metrics/speed-index.js
index 9780c069e..2a6f5d6ff 100644
--- a/lighthouse-core/audits/metrics/speed-index.js
+++ b/lighthouse-core/audits/metrics/speed-index.js
@@ -73,6 +73,7 @@ class SpeedIndex extends Audit {
         scoreOptions.scoreMedian
       ),
       numericValue: metricResult.timing,
+      numericUnit: 'millisecond',
       displayValue: str_(i18n.UIStrings.seconds, {timeInMs: metricResult.timing}),
     };
   }
diff --git a/lighthouse-core/audits/metrics/total-blocking-time.js b/lighthouse-core/audits/metrics/total-blocking-time.js
index fd8634870..1832cea7c 100644
--- a/lighthouse-core/audits/metrics/total-blocking-time.js
+++ b/lighthouse-core/audits/metrics/total-blocking-time.js
@@ -72,6 +72,7 @@ class TotalBlockingTime extends Audit {
         context.options.scoreMedian
       ),
       numericValue: metricResult.timing,
+      numericUnit: 'millisecond',
       displayValue: str_(i18n.UIStrings.ms, {timeInMs: metricResult.timing}),
     };
   }
diff --git a/lighthouse-core/audits/network-requests.js b/lighthouse-core/audits/network-requests.js
index 53acc72bb..6aee8692c 100644
--- a/lighthouse-core/audits/network-requests.js
+++ b/lighthouse-core/audits/network-requests.js
@@ -90,7 +90,6 @@ class NetworkRequests extends Audit {
 
       return {
         score: 1,
-        numericValue: results.length,
         details: tableDetails,
       };
     });
diff --git a/lighthouse-core/audits/network-rtt.js b/lighthouse-core/audits/network-rtt.js
index 13f074fe2..46be3445a 100644
--- a/lighthouse-core/audits/network-rtt.js
+++ b/lighthouse-core/audits/network-rtt.js
@@ -70,6 +70,7 @@ class NetworkRTT extends Audit {
     return {
       score: 1,
       numericValue: maxRtt,
+      numericUnit: 'millisecond',
       displayValue: str_(i18n.UIStrings.ms, {timeInMs: maxRtt}),
       details: tableDetails,
     };
diff --git a/lighthouse-core/audits/network-server-latency.js b/lighthouse-core/audits/network-server-latency.js
index bd65c5037..101e27947 100644
--- a/lighthouse-core/audits/network-server-latency.js
+++ b/lighthouse-core/audits/network-server-latency.js
@@ -69,6 +69,7 @@ class NetworkServerLatency extends Audit {
     return {
       score: Math.max(1 - (maxLatency / 500), 0),
       numericValue: maxLatency,
+      numericUnit: 'millisecond',
       displayValue: str_(i18n.UIStrings.ms, {timeInMs: maxLatency}),
       details: tableDetails,
     };
diff --git a/lighthouse-core/audits/predictive-perf.js b/lighthouse-core/audits/predictive-perf.js
index 74ca4444e..e54774be0 100644
--- a/lighthouse-core/audits/predictive-perf.js
+++ b/lighthouse-core/audits/predictive-perf.js
@@ -97,6 +97,7 @@ class PredictivePerf extends Audit {
     return {
       score,
       numericValue: values.roughEstimateOfTTI,
+      numericUnit: 'millisecond',
       displayValue: i18n.formatMilliseconds(values.roughEstimateOfTTI),
       details: {
         type: 'debugdata',
diff --git a/lighthouse-core/audits/redirects.js b/lighthouse-core/audits/redirects.js
index 0a9ada34f..7194e0a4a 100644
--- a/lighthouse-core/audits/redirects.js
+++ b/lighthouse-core/audits/redirects.js
@@ -109,6 +109,7 @@ class Redirects extends Audit {
       // We award a passing grade if you only have 1 redirect
       score: redirectRequests.length <= 2 ? 1 : UnusedBytes.scoreForWastedMs(totalWastedMs),
       numericValue: totalWastedMs,
+      numericUnit: 'millisecond',
       displayValue: totalWastedMs ?
         str_(i18n.UIStrings.displayValueMsSavings, {wastedMs: totalWastedMs}) :
         '',
diff --git a/lighthouse-core/audits/time-to-first-byte.js b/lighthouse-core/audits/time-to-first-byte.js
index 93feee2b7..15a6f3ca1 100644
--- a/lighthouse-core/audits/time-to-first-byte.js
+++ b/lighthouse-core/audits/time-to-first-byte.js
@@ -70,6 +70,7 @@ class TTFBMetric extends Audit {
 
     return {
       numericValue: ttfb,
+      numericUnit: 'millisecond',
       score: Number(passed),
       displayValue,
       details,
diff --git a/lighthouse-core/audits/uses-rel-preconnect.js b/lighthouse-core/audits/uses-rel-preconnect.js
index 8b652ebcf..d90d39f4f 100644
--- a/lighthouse-core/audits/uses-rel-preconnect.js
+++ b/lighthouse-core/audits/uses-rel-preconnect.js
@@ -192,6 +192,7 @@ class UsesRelPreconnectAudit extends Audit {
     return {
       score: UnusedBytes.scoreForWastedMs(maxWasted),
       numericValue: maxWasted,
+      numericUnit: 'millisecond',
       displayValue: maxWasted ?
         str_(i18n.UIStrings.displayValueMsSavings, {wastedMs: maxWasted}) :
         '',
diff --git a/lighthouse-core/audits/uses-rel-preload.js b/lighthouse-core/audits/uses-rel-preload.js
index e70bcccfa..538121b63 100644
--- a/lighthouse-core/audits/uses-rel-preload.js
+++ b/lighthouse-core/audits/uses-rel-preload.js
@@ -226,6 +226,7 @@ class UsesRelPreloadAudit extends Audit {
     return {
       score: UnusedBytes.scoreForWastedMs(wastedMs),
       numericValue: wastedMs,
+      numericUnit: 'millisecond',
       displayValue: wastedMs ?
         str_(i18n.UIStrings.displayValueMsSavings, {wastedMs}) :
         '',
diff --git a/lighthouse-core/lib/i18n/locales/en-US.json b/lighthouse-core/lib/i18n/locales/en-US.json
index 02d004d02..bbf3bd85e 100644
--- a/lighthouse-core/lib/i18n/locales/en-US.json
+++ b/lighthouse-core/lib/i18n/locales/en-US.json
@@ -855,7 +855,7 @@
     "message": "Time to interactive is the amount of time it takes for the page to become fully interactive. [Learn more](https://web.dev/interactive)."
   },
   "lighthouse-core/audits/metrics/largest-contentful-paint.js | description": {
-    "message": "Largest Contentful Paint marks the time at which the largest text or image is painted. [Learn More](https://web.dev/largest-contentful-paint)"
+    "message": "Largest Contentful Paint marks the time at which the largest text or image is painted. [Learn More](https://web.dev/lighthouse-largest-contentful-paint)"
   },
   "lighthouse-core/audits/metrics/largest-contentful-paint.js | title": {
     "message": "Largest Contentful Paint"
diff --git a/lighthouse-core/lib/i18n/locales/en-XL.json b/lighthouse-core/lib/i18n/locales/en-XL.json
index 2c72b9db3..a9f2ec023 100644
--- a/lighthouse-core/lib/i18n/locales/en-XL.json
+++ b/lighthouse-core/lib/i18n/locales/en-XL.json
@@ -855,7 +855,7 @@
     "message": "T̂ím̂é t̂ó îńt̂ér̂áĉt́îv́ê íŝ t́ĥé âḿôún̂t́ ôf́ t̂ím̂é ît́ t̂ák̂éŝ f́ôŕ t̂h́ê ṕâǵê t́ô b́êćôḿê f́ûĺl̂ý îńt̂ér̂áĉt́îv́ê. [Ĺêár̂ń m̂ór̂é](https://web.dev/interactive)."
   },
   "lighthouse-core/audits/metrics/largest-contentful-paint.js | description": {
-    "message": "L̂ár̂ǵêśt̂ Ćôńt̂én̂t́f̂úl̂ Ṕâín̂t́ m̂ár̂ḱŝ t́ĥé t̂ím̂é ât́ ŵh́îćĥ t́ĥé l̂ár̂ǵêśt̂ t́êx́t̂ ór̂ ím̂áĝé îś p̂áîńt̂éd̂. [Ĺêár̂ń M̂ór̂é](https://web.dev/largest-contentful-paint)"
+    "message": "L̂ár̂ǵêśt̂ Ćôńt̂én̂t́f̂úl̂ Ṕâín̂t́ m̂ár̂ḱŝ t́ĥé t̂ím̂é ât́ ŵh́îćĥ t́ĥé l̂ár̂ǵêśt̂ t́êx́t̂ ór̂ ím̂áĝé îś p̂áîńt̂éd̂. [Ĺêár̂ń M̂ór̂é](https://web.dev/lighthouse-largest-contentful-paint)"
   },
   "lighthouse-core/audits/metrics/largest-contentful-paint.js | title": {
     "message": "L̂ár̂ǵêśt̂ Ćôńt̂én̂t́f̂úl̂ Ṕâín̂t́"
diff --git a/lighthouse-core/report/html/renderer/details-renderer.js b/lighthouse-core/report/html/renderer/details-renderer.js
index 54edee4af..3f9a1d37a 100644
--- a/lighthouse-core/report/html/renderer/details-renderer.js
+++ b/lighthouse-core/report/html/renderer/details-renderer.js
@@ -168,6 +168,8 @@ class DetailsRenderer {
    * @return {Element}
    */
   _renderNumeric(text) {
+    // TODO: this should probably accept a number and call `formatNumber` instead of being identical
+    // to _renderText.
     const element = this._dom.createElement('div', 'lh-numeric');
     element.textContent = text;
     return element;
diff --git a/proto/lighthouse-result.proto b/proto/lighthouse-result.proto
index 52ff4f369..84fe42e90 100644
--- a/proto/lighthouse-result.proto
+++ b/proto/lighthouse-result.proto
@@ -295,6 +295,9 @@ message AuditResult {
   // of nodes in the DOM or the timestamp of a specific load event. More
   // information can be found in the audit details, if present.
   google.protobuf.DoubleValue numeric_value = 11;
+
+  // The unit of the numeric_value field. Used to format the numeric value for display.
+  string numeric_unit = 12;
 }
 
 // Message containing the i18n data for the LHR - Version 1
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index f652a58a7..089ce3a4e 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -188,6 +188,7 @@
             },
             "displayValue": "1.2\u00a0s",
             "id": "bootup-time",
+            "numericUnit": "millisecond",
             "numericValue": 1150.573,
             "score": 0.92,
             "scoreDisplayMode": "numeric",
@@ -597,6 +598,7 @@
             },
             "displayValue": "31 elements",
             "id": "dom-size",
+            "numericUnit": "element",
             "numericValue": 31.0,
             "score": 1.0,
             "scoreDisplayMode": "numeric",
@@ -636,6 +638,7 @@
                 "type": "opportunity"
             },
             "id": "efficient-animated-content",
+            "numericUnit": "millisecond",
             "numericValue": 0.0,
             "score": 1.0,
             "scoreDisplayMode": "numeric",
@@ -686,7 +689,6 @@
                 "type": "table"
             },
             "id": "errors-in-console",
-            "numericValue": 5.0,
             "score": 0.0,
             "scoreDisplayMode": "binary",
             "title": "Browser errors were logged to the console"
@@ -695,6 +697,7 @@
             "description": "Estimated Input Latency is an estimate of how long your app takes to respond to user input, in milliseconds, during the busiest 5s window of page load. If your latency is higher than 50 ms, users may perceive your app as laggy. [Learn more](https://web.dev/estimated-input-latency).",
             "displayValue": "20\u00a0ms",
             "id": "estimated-input-latency",
+            "numericUnit": "millisecond",
             "numericValue": 16.0,
             "score": 1.0,
             "scoreDisplayMode": "numeric",
@@ -765,6 +768,7 @@
             "description": "First Contentful Paint marks the time at which the first text or image is painted. [Learn more](https://web.dev/first-contentful-paint).",
             "displayValue": "4.0\u00a0s",
             "id": "first-contentful-paint",
+            "numericUnit": "millisecond",
             "numericValue": 3969.135,
             "score": 0.51,
             "scoreDisplayMode": "numeric",
@@ -774,6 +778,7 @@
             "description": "First CPU Idle marks the first time at which the page's main thread is quiet enough to handle input.  [Learn more](https://web.dev/first-cpu-idle).",
             "displayValue": "4.9\u00a0s",
             "id": "first-cpu-idle",
+            "numericUnit": "millisecond",
             "numericValue": 4927.278,
             "score": 0.72,
             "scoreDisplayMode": "numeric",
@@ -783,6 +788,7 @@
             "description": "First Meaningful Paint measures when the primary content of a page is visible. [Learn more](https://web.dev/first-meaningful-paint).",
             "displayValue": "4.0\u00a0s",
             "id": "first-meaningful-paint",
+            "numericUnit": "millisecond",
             "numericValue": 3969.136,
             "score": 0.51,
             "scoreDisplayMode": "numeric",
@@ -1126,6 +1132,7 @@
             "description": "Time to interactive is the amount of time it takes for the page to become fully interactive. [Learn more](https://web.dev/interactive).",
             "displayValue": "4.9\u00a0s",
             "id": "interactive",
+            "numericUnit": "millisecond",
             "numericValue": 4927.278,
             "score": 0.78,
             "scoreDisplayMode": "numeric",
@@ -1283,9 +1290,10 @@
             "title": "Form elements do not have associated labels"
         },
         "largest-contentful-paint": {
-            "description": "Largest Contentful Paint marks the time at which the largest text or image is painted. [Learn More](https://web.dev/largest-contentful-paint)",
+            "description": "Largest Contentful Paint marks the time at which the largest text or image is painted. [Learn More](https://web.dev/lighthouse-largest-contentful-paint)",
             "displayValue": "4.9\u00a0s",
             "id": "largest-contentful-paint",
+            "numericUnit": "millisecond",
             "numericValue": 4927.278,
             "score": 0.31,
             "scoreDisplayMode": "numeric",
@@ -1379,6 +1387,7 @@
         "load-fast-enough-for-pwa": {
             "description": "A fast page load over a cellular network ensures a good mobile user experience. [Learn more](https://web.dev/load-fast-enough-for-pwa).",
             "id": "load-fast-enough-for-pwa",
+            "numericUnit": "millisecond",
             "numericValue": 4927.278,
             "score": 1.0,
             "scoreDisplayMode": "binary",
@@ -1477,7 +1486,6 @@
                 "type": "table"
             },
             "id": "main-thread-tasks",
-            "numericValue": 16.0,
             "score": null,
             "scoreDisplayMode": "informative",
             "title": "Tasks"
@@ -1539,6 +1547,7 @@
             },
             "displayValue": "1.5\u00a0s",
             "id": "mainthread-work-breakdown",
+            "numericUnit": "millisecond",
             "numericValue": 1548.5690000000002,
             "score": 0.96,
             "scoreDisplayMode": "numeric",
@@ -1555,6 +1564,7 @@
             "description": "The maximum potential First Input Delay that your users could experience is the duration of the longest task. [Learn more](https://web.dev/lighthouse-max-potential-fid).",
             "displayValue": "120\u00a0ms",
             "id": "max-potential-fid",
+            "numericUnit": "millisecond",
             "numericValue": 122.537,
             "score": 0.92,
             "scoreDisplayMode": "numeric",
@@ -1636,6 +1646,7 @@
                 "type": "debugdata"
             },
             "id": "metrics",
+            "numericUnit": "millisecond",
             "numericValue": 4927.0,
             "score": null,
             "scoreDisplayMode": "informative",
@@ -1877,7 +1888,6 @@
                 "type": "table"
             },
             "id": "network-requests",
-            "numericValue": 18.0,
             "score": null,
             "scoreDisplayMode": "informative",
             "title": "Network Requests"
@@ -1912,6 +1922,7 @@
             },
             "displayValue": "0\u00a0ms",
             "id": "network-rtt",
+            "numericUnit": "millisecond",
             "numericValue": 1.7249999999999943,
             "score": null,
             "scoreDisplayMode": "informative",
@@ -1947,6 +1958,7 @@
             },
             "displayValue": "570\u00a0ms",
             "id": "network-server-latency",
+            "numericUnit": "millisecond",
             "numericValue": 572.829,
             "score": null,
             "scoreDisplayMode": "informative",
@@ -2132,6 +2144,7 @@
                 "type": "opportunity"
             },
             "id": "offscreen-images",
+            "numericUnit": "millisecond",
             "numericValue": 0.0,
             "score": 1.0,
             "scoreDisplayMode": "numeric",
@@ -2313,6 +2326,7 @@
                 "type": "opportunity"
             },
             "id": "redirects",
+            "numericUnit": "millisecond",
             "numericValue": 0.0,
             "score": 1.0,
             "scoreDisplayMode": "numeric",
@@ -2377,6 +2391,7 @@
             },
             "displayValue": "Potential savings of 1,130\u00a0ms",
             "id": "render-blocking-resources",
+            "numericUnit": "millisecond",
             "numericValue": 1129.0,
             "score": 0.46,
             "scoreDisplayMode": "numeric",
@@ -2547,6 +2562,7 @@
             "description": "Speed Index shows how quickly the contents of a page are visibly populated. [Learn more](https://web.dev/speed-index).",
             "displayValue": "4.4\u00a0s",
             "id": "speed-index",
+            "numericUnit": "millisecond",
             "numericValue": 4417.0,
             "score": 0.74,
             "scoreDisplayMode": "numeric",
@@ -2730,6 +2746,7 @@
             },
             "displayValue": "Root document took 570\u00a0ms",
             "id": "time-to-first-byte",
+            "numericUnit": "millisecond",
             "numericValue": 570.5630000000001,
             "score": 1.0,
             "scoreDisplayMode": "binary",
@@ -2739,6 +2756,7 @@
             "description": "Sum of all time periods between FCP and Time to Interactive, when task length exceeded 50ms, expressed in milliseconds. [Learn more](https://web.dev/lighthouse-total-blocking-time).",
             "displayValue": "120\u00a0ms",
             "id": "total-blocking-time",
+            "numericUnit": "millisecond",
             "numericValue": 116.79800000000023,
             "score": 1.0,
             "scoreDisplayMode": "numeric",
@@ -2805,6 +2823,7 @@
             },
             "displayValue": "Total size was 157\u00a0KB",
             "id": "total-byte-weight",
+            "numericUnit": "byte",
             "numericValue": 160738.0,
             "score": 1.0,
             "scoreDisplayMode": "numeric",
@@ -2820,6 +2839,7 @@
                 "type": "opportunity"
             },
             "id": "unminified-css",
+            "numericUnit": "millisecond",
             "numericValue": 0.0,
             "score": 1.0,
             "scoreDisplayMode": "numeric",
@@ -2859,6 +2879,7 @@
             },
             "displayValue": "Potential savings of 30\u00a0KB",
             "id": "unminified-javascript",
+            "numericUnit": "millisecond",
             "numericValue": 150.0,
             "score": 0.88,
             "scoreDisplayMode": "numeric",
@@ -2875,6 +2896,7 @@
                 "type": "opportunity"
             },
             "id": "unused-css-rules",
+            "numericUnit": "millisecond",
             "numericValue": 0.0,
             "score": 1.0,
             "scoreDisplayMode": "numeric",
@@ -2890,6 +2912,7 @@
                 "type": "opportunity"
             },
             "id": "unused-javascript",
+            "numericUnit": "millisecond",
             "numericValue": 0.0,
             "score": 1.0,
             "scoreDisplayMode": "numeric",
@@ -3101,6 +3124,7 @@
             },
             "displayValue": "10 resources found",
             "id": "uses-long-cache-ttl",
+            "numericUnit": "byte",
             "numericValue": 103455.0,
             "score": 0.58,
             "scoreDisplayMode": "numeric",
@@ -3116,6 +3140,7 @@
                 "type": "opportunity"
             },
             "id": "uses-optimized-images",
+            "numericUnit": "millisecond",
             "numericValue": 0.0,
             "score": 1.0,
             "scoreDisplayMode": "numeric",
@@ -3167,6 +3192,7 @@
                 "type": "opportunity"
             },
             "id": "uses-rel-preconnect",
+            "numericUnit": "millisecond",
             "numericValue": 0.0,
             "score": 1.0,
             "scoreDisplayMode": "numeric",
@@ -3182,6 +3208,7 @@
                 "type": "opportunity"
             },
             "id": "uses-rel-preload",
+            "numericUnit": "millisecond",
             "numericValue": 0.0,
             "score": 1.0,
             "scoreDisplayMode": "numeric",
@@ -3197,6 +3224,7 @@
                 "type": "opportunity"
             },
             "id": "uses-responsive-images",
+            "numericUnit": "millisecond",
             "numericValue": 0.0,
             "score": 1.0,
             "scoreDisplayMode": "numeric",
@@ -3241,6 +3269,7 @@
             },
             "displayValue": "Potential savings of 63\u00a0KB",
             "id": "uses-text-compression",
+            "numericUnit": "millisecond",
             "numericValue": 300.0,
             "score": 0.75,
             "scoreDisplayMode": "numeric",
@@ -3285,6 +3314,7 @@
             },
             "displayValue": "Potential savings of 9\u00a0KB",
             "id": "uses-webp-images",
+            "numericUnit": "millisecond",
             "numericValue": 0.0,
             "score": 1.0,
             "scoreDisplayMode": "numeric",
diff --git a/types/audit.d.ts b/types/audit.d.ts
index 05ecf3d85..0b33886b6 100644
--- a/types/audit.d.ts
+++ b/types/audit.d.ts
@@ -66,10 +66,11 @@ declare global {
       wastedPercent?: number;
     }
 
-    /** Type returned by Audit.audit(). Only score is required.  */
-    export interface Product {
+    /** The shared properties of an Audit.Product whether it has a numericValue or not. We want to enforce `numericUnit` accompanying `numericValue` whenever it is set, so the final Audit.Product type is a discriminated union on `'numericValue' in audit`*/
+    interface ProductBase {
       /** The scored value of the audit, provided in the range `0-1`, or null if `scoreDisplayMode` indicates not scored. */
       score: number | null;
+      /** The i18n'd string value that the audit wishes to display for its results. This value is not necessarily the string version of the `numericValue`. */
       displayValue?: string;
       /** An explanation of why the audit failed on the test page. */
       explanation?: string;
@@ -80,12 +81,26 @@ declare global {
       extendedInfo?: {[p: string]: any};
       /** Overrides scoreDisplayMode with notApplicable if set to true */
       notApplicable?: boolean;
-      /** A numeric value that has a meaning specific to the audit, e.g. the number of nodes in the DOM or the timestamp of a specific load event. More information can be found in the audit details, if present. */
-      numericValue?: number;
       /** Extra information about the page provided by some types of audits, in one of several possible forms that can be rendered in the HTML report. */
       details?: Audit.Details;
     }
 
+    /** The Audit.Product type for audits that do not return a `numericValue`. */
+    interface NonNumericProduct extends ProductBase {
+      numericValue?: never;
+    }
+
+    /** The Audit.Product type for audits that do return a `numericValue`. */
+    interface NumericProduct extends ProductBase {
+      /** A numeric value that has a meaning specific to the audit, e.g. the number of nodes in the DOM or the timestamp of a specific load event. More information can be found in the audit details, if present. */
+      numericValue: number;
+      /** The unit of `numericValue`, used when the consumer wishes to convert numericValue to a display string. A superset of https://tc39.es/proposal-unified-intl-numberformat/section6/locales-currencies-tz_proposed_out.html#sec-issanctionedsimpleunitidentifier */
+      numericUnit: 'byte'|'millisecond'|'element';
+    }
+
+    /** Type returned by Audit.audit(). Only score is required.  */
+    export type Product = NonNumericProduct | NumericProduct;
+
     /* Audit result returned in Lighthouse report. All audits offer a description and score of 0-1. */
     export interface Result {
       displayValue?: string;
@@ -114,6 +129,8 @@ declare global {
       description: string;
       /** A numeric value that has a meaning specific to the audit, e.g. the number of nodes in the DOM or the timestamp of a specific load event. More information can be found in the audit details, if present. */
       numericValue?: number;
+      /** The unit of `numericValue`, used when the consumer wishes to convert numericValue to a display string. */
+      numericUnit?: string;
       /** Extra information about the page provided by some types of audits, in one of several possible forms that can be rendered in the HTML report. */
       details?: Audit.Details;
     }


Test patch:
diff --git a/lighthouse-core/test/audits/errors-in-console-test.js b/lighthouse-core/test/audits/errors-in-console-test.js
index c9480cb1d..3d58c3913 100644
--- a/lighthouse-core/test/audits/errors-in-console-test.js
+++ b/lighthouse-core/test/audits/errors-in-console-test.js
@@ -16,7 +16,6 @@ describe('Console error logs audit', () => {
       ConsoleMessages: [],
       RuntimeExceptions: [],
     }, {options: {}});
-    assert.equal(auditResult.numericValue, 0);
     assert.equal(auditResult.score, 1);
     assert.ok(!auditResult.displayValue, 0);
     assert.equal(auditResult.details.items.length, 0);
@@ -35,7 +34,6 @@ describe('Console error logs audit', () => {
       ],
       RuntimeExceptions: [],
     }, {options: {}});
-    assert.equal(auditResult.numericValue, 0);
     assert.equal(auditResult.score, 1);
     assert.equal(auditResult.details.items.length, 0);
   });
@@ -81,7 +79,6 @@ describe('Console error logs audit', () => {
       }],
     }, {options: {}});
 
-    assert.equal(auditResult.numericValue, 3);
     assert.equal(auditResult.score, 0);
     assert.equal(auditResult.details.items.length, 3);
     assert.equal(auditResult.details.items[0].url, 'http://www.example.com/favicon.ico');
@@ -107,7 +104,6 @@ describe('Console error logs audit', () => {
       ],
       RuntimeExceptions: [],
     }, {options: {}});
-    assert.equal(auditResult.numericValue, 1);
     assert.equal(auditResult.score, 0);
     assert.equal(auditResult.details.items.length, 1);
     // url is undefined
@@ -138,7 +134,6 @@ describe('Console error logs audit', () => {
         },
       }],
     }, {options: {}});
-    assert.equal(auditResult.numericValue, 1);
     assert.equal(auditResult.score, 0);
     assert.equal(auditResult.details.items.length, 1);
     assert.strictEqual(auditResult.details.items[0].url, 'http://example.com/fancybox.js');
diff --git a/lighthouse-core/test/audits/network-requests-test.js b/lighthouse-core/test/audits/network-requests-test.js
index 205c63cad..99dc94dfa 100644
--- a/lighthouse-core/test/audits/network-requests-test.js
+++ b/lighthouse-core/test/audits/network-requests-test.js
@@ -22,7 +22,6 @@ describe('Network requests audit', () => {
 
     return NetworkRequests.audit(artifacts, {computedCache: new Map()}).then(output => {
       assert.equal(output.score, 1);
-      assert.equal(output.numericValue, 66);
       assert.equal(output.details.items.length, 66);
       assert.equal(output.details.items[0].url, 'https://pwa.rocks/');
       assert.equal(output.details.items[0].startTime, 0);
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 63dfd7dd8..cf2620eab 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -78,6 +78,7 @@
       "score": 0.51,
       "scoreDisplayMode": "numeric",
       "numericValue": 3969.135,
+      "numericUnit": "millisecond",
       "displayValue": "4.0 s"
     },
     "first-meaningful-paint": {
@@ -87,15 +88,17 @@
       "score": 0.51,
       "scoreDisplayMode": "numeric",
       "numericValue": 3969.136,
+      "numericUnit": "millisecond",
       "displayValue": "4.0 s"
     },
     "largest-contentful-paint": {
       "id": "largest-contentful-paint",
       "title": "Largest Contentful Paint",
-      "description": "Largest Contentful Paint marks the time at which the largest text or image is painted. [Learn More](https://web.dev/largest-contentful-paint)",
+      "description": "Largest Contentful Paint marks the time at which the largest text or image is painted. [Learn More](https://web.dev/lighthouse-largest-contentful-paint)",
       "score": 0.31,
       "scoreDisplayMode": "numeric",
       "numericValue": 4927.278,
+      "numericUnit": "millisecond",
       "displayValue": "4.9 s"
     },
     "load-fast-enough-for-pwa": {
@@ -104,7 +107,8 @@
       "description": "A fast page load over a cellular network ensures a good mobile user experience. [Learn more](https://web.dev/load-fast-enough-for-pwa).",
       "score": 1,
       "scoreDisplayMode": "binary",
-      "numericValue": 4927.278
+      "numericValue": 4927.278,
+      "numericUnit": "millisecond"
     },
     "speed-index": {
       "id": "speed-index",
@@ -113,6 +117,7 @@
       "score": 0.74,
       "scoreDisplayMode": "numeric",
       "numericValue": 4417,
+      "numericUnit": "millisecond",
       "displayValue": "4.4 s"
     },
     "screenshot-thumbnails": {
@@ -198,6 +203,7 @@
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 16,
+      "numericUnit": "millisecond",
       "displayValue": "20 ms"
     },
     "total-blocking-time": {
@@ -207,6 +213,7 @@
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 116.79800000000023,
+      "numericUnit": "millisecond",
       "displayValue": "120 ms"
     },
     "max-potential-fid": {
@@ -216,6 +223,7 @@
       "score": 0.92,
       "scoreDisplayMode": "numeric",
       "numericValue": 122.537,
+      "numericUnit": "millisecond",
       "displayValue": "120 ms"
     },
     "errors-in-console": {
@@ -224,7 +232,6 @@
       "description": "Errors logged to the console indicate unresolved problems. They can come from network request failures and other browser concerns. [Learn more](https://web.dev/errors-in-console)",
       "score": 0,
       "scoreDisplayMode": "binary",
-      "numericValue": 5,
       "details": {
         "type": "table",
         "headings": [
@@ -275,6 +282,7 @@
       "score": 1,
       "scoreDisplayMode": "binary",
       "numericValue": 570.5630000000001,
+      "numericUnit": "millisecond",
       "displayValue": "Root document took 570 ms",
       "details": {
         "type": "opportunity",
@@ -290,6 +298,7 @@
       "score": 0.72,
       "scoreDisplayMode": "numeric",
       "numericValue": 4927.278,
+      "numericUnit": "millisecond",
       "displayValue": "4.9 s"
     },
     "interactive": {
@@ -299,6 +308,7 @@
       "score": 0.78,
       "scoreDisplayMode": "numeric",
       "numericValue": 4927.278,
+      "numericUnit": "millisecond",
       "displayValue": "4.9 s"
     },
     "user-timings": {
@@ -466,6 +476,7 @@
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,
+      "numericUnit": "millisecond",
       "displayValue": "",
       "details": {
         "type": "opportunity",
@@ -648,6 +659,7 @@
       "score": 0.96,
       "scoreDisplayMode": "numeric",
       "numericValue": 1548.5690000000002,
+      "numericUnit": "millisecond",
       "displayValue": "1.5 s",
       "details": {
         "type": "table",
@@ -710,6 +722,7 @@
       "score": 0.92,
       "scoreDisplayMode": "numeric",
       "numericValue": 1150.573,
+      "numericUnit": "millisecond",
       "displayValue": "1.2 s",
       "details": {
         "type": "table",
@@ -776,6 +789,7 @@
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,
+      "numericUnit": "millisecond",
       "displayValue": "",
       "details": {
         "type": "opportunity",
@@ -791,6 +805,7 @@
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,
+      "numericUnit": "millisecond",
       "displayValue": "",
       "warnings": [],
       "details": {
@@ -850,7 +865,6 @@
       "description": "Lists the network requests that were made during page load.",
       "score": null,
       "scoreDisplayMode": "informative",
-      "numericValue": 18,
       "details": {
         "type": "table",
         "headings": [
@@ -1092,6 +1106,7 @@
       "score": null,
       "scoreDisplayMode": "informative",
       "numericValue": 1.7249999999999943,
+      "numericUnit": "millisecond",
       "displayValue": "0 ms",
       "details": {
         "type": "table",
@@ -1127,6 +1142,7 @@
       "score": null,
       "scoreDisplayMode": "informative",
       "numericValue": 572.829,
+      "numericUnit": "millisecond",
       "displayValue": "570 ms",
       "details": {
         "type": "table",
@@ -1161,7 +1177,6 @@
       "description": "Lists the toplevel main thread tasks that executed during page load.",
       "score": null,
       "scoreDisplayMode": "informative",
-      "numericValue": 16,
       "details": {
         "type": "table",
         "headings": [
@@ -1253,6 +1268,7 @@
       "score": null,
       "scoreDisplayMode": "informative",
       "numericValue": 4927,
+      "numericUnit": "millisecond",
       "details": {
         "type": "debugdata",
         "items": [
@@ -2271,6 +2287,7 @@
       "score": 0.58,
       "scoreDisplayMode": "numeric",
       "numericValue": 103455,
+      "numericUnit": "byte",
       "displayValue": "10 resources found",
       "details": {
         "type": "table",
@@ -2378,6 +2395,7 @@
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 160738,
+      "numericUnit": "byte",
       "displayValue": "Total size was 157 KB",
       "details": {
         "type": "table",
@@ -2444,6 +2462,7 @@
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,
+      "numericUnit": "millisecond",
       "displayValue": "",
       "warnings": [],
       "details": {
@@ -2461,6 +2480,7 @@
       "score": 0.46,
       "scoreDisplayMode": "numeric",
       "numericValue": 1129,
+      "numericUnit": "millisecond",
       "displayValue": "Potential savings of 1,130 ms",
       "details": {
         "type": "opportunity",
@@ -2518,6 +2538,7 @@
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,
+      "numericUnit": "millisecond",
       "displayValue": "",
       "details": {
         "type": "opportunity",
@@ -2534,6 +2555,7 @@
       "score": 0.88,
       "scoreDisplayMode": "numeric",
       "numericValue": 150,
+      "numericUnit": "millisecond",
       "displayValue": "Potential savings of 30 KB",
       "warnings": [],
       "details": {
@@ -2574,6 +2596,7 @@
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,
+      "numericUnit": "millisecond",
       "displayValue": "",
       "details": {
         "type": "opportunity",
@@ -2590,6 +2613,7 @@
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,
+      "numericUnit": "millisecond",
       "displayValue": "",
       "details": {
         "type": "opportunity",
@@ -2606,6 +2630,7 @@
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,
+      "numericUnit": "millisecond",
       "displayValue": "Potential savings of 9 KB",
       "warnings": [],
       "details": {
@@ -2652,6 +2677,7 @@
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,
+      "numericUnit": "millisecond",
       "displayValue": "",
       "warnings": [],
       "details": {
@@ -2669,6 +2695,7 @@
       "score": 0.75,
       "scoreDisplayMode": "numeric",
       "numericValue": 300,
+      "numericUnit": "millisecond",
       "displayValue": "Potential savings of 63 KB",
       "details": {
         "type": "opportunity",
@@ -2712,6 +2739,7 @@
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,
+      "numericUnit": "millisecond",
       "displayValue": "",
       "warnings": [],
       "details": {
@@ -2729,6 +2757,7 @@
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 0,
+      "numericUnit": "millisecond",
       "displayValue": "",
       "details": {
         "type": "opportunity",
@@ -2760,6 +2789,7 @@
       "score": 1,
       "scoreDisplayMode": "numeric",
       "numericValue": 31,
+      "numericUnit": "element",
       "displayValue": "31 elements",
       "details": {
         "type": "table",