Repository: GoogleChrome/lighthouse
Pull request #10324 (2020-02-12 03:52:03)

core(lightwallet): add firstPartyHostnames to budget.json
Adds `options.firstPartyHostnames` to `budget.json`.

**API**:
```json
"options": {
  "firstPartyHostnames": ["my-site.com", "me.my-cdn.com", "*.my-site2.com"]
}
```

A hostname may optionally contain a leading `*` to match multiple subdomains as well as the root domain. For example, `*.example.com` matches both `example.com` and `blog.example.com`.

patrickhulce: This is basically a continuation of what I was previously assigned @connorjclark so I can take this one.
patrickhulce: Thanks for merging them @khempenius! Couple questions before I dive in...

- How would one signify that they own `*.my-cdn.com`? By listing `https://a.my-cdn.com`, `https://b.my-cdn.com`, etc?
- Should it be possible to opt out of the root-level first-party consideration? Say my site is on `foo.wordpress.com` and WordPress injects some stuff at `third-party-assets.wordpress.com`, would manually specifying `foo.wordpress.com` remove the wider net or is this purely additional? I see the property was renamed to `extraFirstPartyOrigins` (maybe based on my comment in the previous PR?) so curious if that policy changed or should change.
- Is it intentional to base this on origins rather than domains? Someone following best practices probably wouldn't be affected, but there is a bit of mismatch with our default root domain check and then others needing to care about http vs. https.
khempenius: @patrickhulce All good points! I'm changing course a bit and proposing what's below instead. I think this design this should answer all of the above questions.

**Proposed API**

`firstPartyHostnames`: `["$HOSTNAME"]`

A hostname may optionally contain a leading `*` to match multiple subdomains as well as the root domain. For example, `*.example.com` matches both `example.com` and `blog.example.com`.

------

Examples of valid patterns for hostnames:
- `"*.example.com"` 
  Applies to root domain and all subdomains.

- `"example.com"`
Only applies to root domain; does not apply to any subdomains

- `"cats.example.com"`
  Applies to a specific subdomain

Edge case example (multiple subdomains):

All of these are valid inputs.
- `"www.notifications.service.gov.uk"`
- `service.gov.uk"`
- `"*.service.gov.uk"`
- `"*.notifications.service.gov.uk"`

-----
**Thoughts**

**Re: Property Name**
Alternatives considered: `firstPartyDomains`, `firstPartyHosts`

- I think the property name `firstPartyDomains` would make it easier to misinterpret `example.com` as applying to “everything, including subdomains, within example.com”.

- It seems reasonable to me to follow the convention set forth in `Url()` for `host`, `hostname`, etc. & I can’t think of a use case where you’d want to get ports mixed up in all of this - hence `hostname`, rather than `host`. That being said `firstPartyHosts` would be more concise.

**Re: Property Value**
- This is more configurable than blanket domain-level granularity with minimal additional user effort (`"*.example.com"` vs. `"example.com"`--> two extra characters.)
- If a user wants to exclude a subdomain, they're stuck writing out all of the other subdomains. That being said, I don't see many sites with that many 1P subdomains. I don’t think trying to support a regex format more complex that `*.<$DOMAIN>` is worth the effort, IMO.
---------
**Appendix**

This API assumes that granularity beyond domain-level is helpful and worth supporting and not an edge case. I see these as the two most likely use cases for this:
- **Tag managers / analytics servers located on a 1P domain**: This seems like a fairly common pattern on larger sites. e.g. Your server-side tag manager is hosted at `tag-manager.my-site.com`; it has a small (but non-zero) client-side footprint. It seems reasonable to me that you might want to consider any overhead associated with tags as 3P.
- **Resources loaded from multi-tenant sites**: e.g. you load resources from `my-site.big-cdn.com`; a third party on your site loads images from `third-party.big-cdn.com`.

patrickhulce: Love it! And essentially the default value for `firstPartyHostnames` if unspecified is `["*.<tld+1>"]`?

I think that addresses everything I can think of! 👍 
khempenius: Updated.

If left unspecified it is `["*.<ROOT_DOMAIN>"]` & the root domain logic comes from one of the existing helper functions that is tld-aware, so it handles things like `.co.uk` properly.

Patch:
diff --git a/lighthouse-core/audits/performance-budget.js b/lighthouse-core/audits/performance-budget.js
index db626de03..d167ebb7b 100644
--- a/lighthouse-core/audits/performance-budget.js
+++ b/lighthouse-core/audits/performance-budget.js
@@ -141,7 +141,8 @@ class ResourceBudget extends Audit {
     ];
 
     return {
-      details: Audit.makeTableDetails(headers, this.tableItems(budget, summary)),
+      details: Audit.makeTableDetails(headers,
+        this.tableItems(budget, summary)),
       score: 1,
     };
   }
diff --git a/lighthouse-core/computed/origin-summary.js b/lighthouse-core/computed/origin-summary.js
deleted file mode 100644
index 0c63875ca..000000000
--- a/lighthouse-core/computed/origin-summary.js
+++ /dev/null
@@ -1,50 +0,0 @@
-/**
- * @license Copyright 2020 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 NetworkRecords = require('./network-records.js');
-const URL = require('../lib/url-shim.js');
-
-/** @typedef {{count: number, size: number}} ResourceEntry */
-class OriginSummary {
-  /**
-   * @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
-   * @return {Record<string,ResourceEntry>}
-   */
-  static summarize(networkRecords) {
-    /** @type {Record<string,ResourceEntry>} */
-    const resourceSummary = {
-      total: {count: 0, size: 0},
-    };
-
-    for (const record of networkRecords) {
-      const origin = URL.getOrigin(record.url);
-      if (!origin) continue;
-      if (!resourceSummary[origin]) {
-        resourceSummary[origin] = {count: 0, size: 0};
-      }
-      resourceSummary[origin].count++;
-      resourceSummary[origin].size += record.transferSize;
-
-      resourceSummary.total.count++;
-      resourceSummary.total.size += record.transferSize;
-    }
-    return resourceSummary;
-  }
-
-  /**
-   * @param {LH.DevtoolsLog} devtoolsLog
-   * @param {LH.Audit.Context} context
-   * @return {Promise<Record<string,ResourceEntry>>}
-   */
-  static async compute_(devtoolsLog, context) {
-    const networkRecords = await NetworkRecords.request(devtoolsLog, context);
-    return OriginSummary.summarize(networkRecords);
-  }
-}
-
-module.exports = makeComputedArtifact(OriginSummary);
diff --git a/lighthouse-core/computed/resource-summary.js b/lighthouse-core/computed/resource-summary.js
index 4f8bf86a2..257d54da7 100644
--- a/lighthouse-core/computed/resource-summary.js
+++ b/lighthouse-core/computed/resource-summary.js
@@ -7,8 +7,10 @@
 
 const makeComputedArtifact = require('./computed-artifact.js');
 const NetworkRecords = require('./network-records.js');
-const MainResource = require('./main-resource.js');
 const URL = require('../lib/url-shim.js');
+const MainResource = require('./main-resource.js');
+const Budget = require('../config/budget.js');
+const Util = require('../report/html/renderer/util.js');
 
 /** @typedef {{count: number, size: number}} ResourceEntry */
 class ResourceSummary {
@@ -33,10 +35,10 @@ class ResourceSummary {
   /**
    * @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
    * @param {string} mainResourceURL
-   * @return {Record<LH.Budget.ResourceType,ResourceEntry>}
+   * @param {LH.Audit.Context} context
+   * @return {Record<LH.Budget.ResourceType, ResourceEntry>}
    */
-  static summarize(networkRecords, mainResourceURL) {
-    /** @type {Record<LH.Budget.ResourceType,ResourceEntry>} */
+  static summarize(networkRecords, mainResourceURL, context) {
     const resourceSummary = {
       'stylesheet': {count: 0, size: 0},
       'image': {count: 0, size: 0},
@@ -48,27 +50,51 @@ class ResourceSummary {
       'total': {count: 0, size: 0},
       'third-party': {count: 0, size: 0},
     };
+    const budget = Budget.getMatchingBudget(context.settings.budgets, mainResourceURL);
+    let firstPartyHosts = /** @type {Array<string>} */ ([]);
+    if (budget && budget.options && budget.options.firstPartyHostnames) {
+      firstPartyHosts = budget.options.firstPartyHostnames;
+    } else {
+      const rootDomain = Util.getRootDomain(mainResourceURL);
+      firstPartyHosts = [`*.${rootDomain}`];
+    }
 
-    for (const record of networkRecords) {
+    networkRecords.filter(record => {
+      // Ignore favicon.co
+      // Headless Chrome does not request /favicon.ico, so don't consider this request.
+      // Makes resource summary consistent across LR / other channels.
       const type = this.determineResourceType(record);
       if (type === 'other' && record.url.endsWith('/favicon.ico')) {
-        // Headless Chrome does not request /favicon.ico, so don't consider this request.
-        // Makes resource summary consistent across LR / other channels.
-        continue;
+        return false;
       }
-
+      // Ignore non-network protocols
+      const url = new URL(record.url);
+      const protocol = url.protocol.slice(0, -1); // Removes trailing ":" from protocol
+      if (URL.NON_NETWORK_PROTOCOLS.includes(protocol)) {
+        return false;
+      }
+      return true;
+    }).forEach((record) => {
+      const type = this.determineResourceType(record);
       resourceSummary[type].count++;
       resourceSummary[type].size += record.transferSize;
 
       resourceSummary.total.count++;
       resourceSummary.total.size += record.transferSize;
 
-      // Ignores subdomains: i.e. blog.example.com & example.com would match
-      if (!URL.rootDomainsMatch(record.url, mainResourceURL)) {
+      const isFirstParty = firstPartyHosts.some((hostExp) => {
+        const url = new URL(record.url);
+        if (hostExp.startsWith('*.')) {
+          return url.hostname.endsWith(hostExp.slice(2));
+        }
+        return url.hostname === hostExp;
+      });
+
+      if (!isFirstParty) {
         resourceSummary['third-party'].count++;
         resourceSummary['third-party'].size += record.transferSize;
       }
-    }
+    });
     return resourceSummary;
   }
 
@@ -82,8 +108,7 @@ class ResourceSummary {
       NetworkRecords.request(data.devtoolsLog, context),
       MainResource.request(data, context),
     ]);
-
-    return ResourceSummary.summarize(networkRecords, mainResource.url);
+    return ResourceSummary.summarize(networkRecords, mainResource.url, context);
   }
 }
 
diff --git a/lighthouse-core/config/budget.js b/lighthouse-core/config/budget.js
index be478f751..1d2860c5b 100644
--- a/lighthouse-core/config/budget.js
+++ b/lighthouse-core/config/budget.js
@@ -85,7 +85,7 @@ class Budget {
     // Assume resourceType is an allowed string, throw if not.
     if (!validResourceTypes.includes(/** @type {LH.Budget.ResourceType} */ (resourceType))) {
       throw new Error(`Invalid resource type: ${resourceType}. \n` +
-        `Valid resource types are: ${ validResourceTypes.join(', ') }`);
+        `Valid resource types are: ${validResourceTypes.join(', ') }`);
     }
     if (!isNumber(budget)) {
       throw new Error(`Invalid budget: ${budget}`);
@@ -240,6 +240,41 @@ class Budget {
     };
   }
 
+  /**
+   * @param {string} hostname
+   * @return {string}
+   */
+  static validateHostname(hostname) {
+    const errMsg = `${hostname} is not a valid hostname.`;
+    if (hostname.length === 0) {
+      throw new Error(errMsg);
+    }
+    if (hostname.includes('/')) {
+      throw new Error(errMsg);
+    }
+    if (hostname.includes(':')) {
+      throw new Error(errMsg);
+    }
+    if (hostname.includes('*')) {
+      if (!hostname.startsWith('*.') || hostname.lastIndexOf('*') > 0) {
+        throw new Error(errMsg);
+      }
+    }
+    return hostname;
+  }
+
+  /**
+   * @param {unknown} hostnames
+   * @return {undefined|Array<string>}
+   */
+  static validateHostnames(hostnames) {
+    if (Array.isArray(hostnames) && hostnames.every(host => typeof host === 'string')) {
+      return hostnames.map(Budget.validateHostname);
+    } else if (hostnames !== undefined) {
+      throw new Error(`firstPartyHostnames should be defined as an array of strings.`);
+    }
+  }
+
   /**
    * More info on the Budget format:
    * https://github.com/GoogleChrome/lighthouse/issues/6053#issuecomment-428385930
@@ -257,11 +292,20 @@ class Budget {
       /** @type {LH.Budget} */
       const budget = {};
 
-      const {path, resourceSizes, resourceCounts, timings, ...invalidRest} = b;
+      const {path, options, resourceSizes, resourceCounts, timings, ...invalidRest} = b;
       Budget.assertNoExcessProperties(invalidRest, 'Budget');
 
       budget.path = Budget.validatePath(path);
 
+      if (isObjectOfUnknownProperties(options)) {
+        const {firstPartyHostnames, ...invalidRest} = options;
+        Budget.assertNoExcessProperties(invalidRest, 'Options property');
+        budget.options = {};
+        budget.options.firstPartyHostnames = Budget.validateHostnames(firstPartyHostnames);
+      } else if (options !== undefined) {
+        throw new Error(`Invalid options property in budget at index ${index}`);
+      }
+
       if (isArrayOfUnknownObjects(resourceSizes)) {
         budget.resourceSizes = resourceSizes.map(Budget.validateResourceBudget);
         Budget.assertNoDuplicateStrings(budget.resourceSizes.map(r => r.resourceType),
diff --git a/lighthouse-viewer/app/index.html b/lighthouse-viewer/app/index.html
index f8b596618..147f6e264 100644
--- a/lighthouse-viewer/app/index.html
+++ b/lighthouse-viewer/app/index.html
@@ -30,7 +30,9 @@ Unless required by applicable law or agreed to in writing, software distributed
     <div>
       <h1 class="viewer-placeholder__heading">Lighthouse Report Viewer</h1>
       <div class="viewer-placeholder__help">To view a report: Paste its json or a Gist URL.<br>
-      You can also drag 'n drop the file or click here to select it.</div>
+      You can also drag 'n drop the file or click here to select it.<br><br>
+      To get a fresh report, use the Lighthouse browser extension (<a href="https://chrome.google.com/webstore/detail/lighthouse/blipmdconlkpinefehnmjammfjpmpbjk">Chrome</a> / <a href="https://addons.mozilla.org/en-US/firefox/addon/google-lighthouse/">Firefox</a>)<br>
+      or <a href="https://web.dev/measure">web.dev/measure</a>.</div>
       <input type="url" class="viewer-placeholder__url js-gist-url"
              placeholder="Enter or paste Gist URL">
     </div>
diff --git a/lighthouse-viewer/app/src/lighthouse-report-viewer.js b/lighthouse-viewer/app/src/lighthouse-report-viewer.js
index b77aec55c..a75bab819 100644
--- a/lighthouse-viewer/app/src/lighthouse-report-viewer.js
+++ b/lighthouse-viewer/app/src/lighthouse-report-viewer.js
@@ -84,7 +84,7 @@ class LighthouseReportViewer {
     placeholderTarget.addEventListener('click', e => {
       const target = /** @type {?Element} */ (e.target);
 
-      if (target && target.localName !== 'input') {
+      if (target && target.localName !== 'input' && target.localName !== 'a') {
         fileInput.click();
       }
     });
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 9a5f21e4b..271020713 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -2252,9 +2252,9 @@
                         "sizeOverBudget": 72955.0
                     },
                     {
-                        "countOverBudget": "7 requests",
+                        "countOverBudget": "6 requests",
                         "label": "Total",
-                        "requestCount": 17.0,
+                        "requestCount": 16.0,
                         "resourceType": "total",
                         "size": 160517.0,
                         "sizeOverBudget": 58117.0
@@ -2267,9 +2267,8 @@
                         "sizeOverBudget": 7520.0
                     },
                     {
-                        "countOverBudget": "1 request",
                         "label": "Third-party",
-                        "requestCount": 2.0,
+                        "requestCount": 1.0,
                         "resourceType": "third-party",
                         "size": 30174.0,
                         "sizeOverBudget": 4574.0
@@ -2284,7 +2283,7 @@
                     },
                     {
                         "label": "Image",
-                        "requestCount": 2.0,
+                        "requestCount": 1.0,
                         "resourceType": "image",
                         "size": 24741.0
                     },
@@ -2451,7 +2450,7 @@
                 "items": [
                     {
                         "label": "Total",
-                        "requestCount": 17.0,
+                        "requestCount": 16.0,
                         "resourceType": "total",
                         "size": 160517.0
                     },
@@ -2463,7 +2462,7 @@
                     },
                     {
                         "label": "Image",
-                        "requestCount": 2.0,
+                        "requestCount": 1.0,
                         "resourceType": "image",
                         "size": 24741.0
                     },
@@ -2499,14 +2498,14 @@
                     },
                     {
                         "label": "Third-party",
-                        "requestCount": 2.0,
+                        "requestCount": 1.0,
                         "resourceType": "third-party",
                         "size": 30174.0
                     }
                 ],
                 "type": "table"
             },
-            "displayValue": "17 requests \u2022 157 KB",
+            "displayValue": "16 requests \u2022 157 KB",
             "id": "resource-summary",
             "score": null,
             "scoreDisplayMode": "informative",
diff --git a/types/budget.d.ts b/types/budget.d.ts
index 08957fe3f..97ba5dace 100644
--- a/types/budget.d.ts
+++ b/types/budget.d.ts
@@ -11,6 +11,8 @@ declare global {
      * More info: https://github.com/GoogleChrome/budget.json
      */
     export interface Budget {
+      /** Budget options */
+      options?: Budget.Options;
       /**
        * Indicates which pages a budget applies to. Uses the robots.txt format.
        * If it is not supplied, the budget applies to all pages.
@@ -22,7 +24,7 @@ declare global {
       /** Budgets based on resource size. */
       resourceSizes?: Array<Budget.ResourceBudget>;
       /** Budgets based on timing metrics. */
-      timings?: Array<Budget.TimingBudget> ;
+      timings?: Array<Budget.TimingBudget>;
     }
 
     module Budget {
@@ -40,6 +42,16 @@ declare global {
         budget: number;
       }
 
+      export interface Options {
+        /**
+         * List of hostnames used to classify resources as 1st or 3rd party.
+         * Wildcards can optionally be used to match a hostname and all of its subdomains.
+         * For example e.g.: "*.news.gov.uk" matches both "news.gov.uk" and "en.news.gov.uk"
+         * If this property is not set, the root domain and all its subdomains are considered first party.
+         */
+        firstPartyHostnames?: Array<string>;
+      }
+
       /** Supported timing metrics. */
       export type TimingMetric = 'first-contentful-paint' | 'first-cpu-idle' | 'interactive' | 'first-meaningful-paint' | 'max-potential-fid' | 'estimated-input-latency' | 'total-blocking-time' | 'speed-index';
 


Test patch:
diff --git a/lighthouse-core/test/audits/performance-budget-test.js b/lighthouse-core/test/audits/performance-budget-test.js
index 4d2bf4c87..b90a2e7a7 100644
--- a/lighthouse-core/test/audits/performance-budget-test.js
+++ b/lighthouse-core/test/audits/performance-budget-test.js
@@ -17,8 +17,9 @@ describe('Performance: Resource budgets audit', () => {
     artifacts = {
       devtoolsLogs: {
         defaultPass: networkRecordsToDevtoolsLog([
-          {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
+          {url: 'http://example.com', resourceType: 'Document', transferSize: 30},
           {url: 'http://example.com/app.js', resourceType: 'Script', transferSize: 10},
+          {url: 'http://my-cdn.com/styles.css', resourceType: 'Stylesheet', transferSize: 25},
           {url: 'http://third-party.com/script.js', resourceType: 'Script', transferSize: 50},
           {url: 'http://third-party.com/file.jpg', resourceType: 'Image', transferSize: 70},
         ]),
@@ -100,6 +101,53 @@ describe('Performance: Resource budgets audit', () => {
       });
     });
 
+    describe('third-party resource identification', () => {
+      it('guesses root domain if firstPartyHostnames is not provided', async () => {
+        context.settings.budgets = [{
+          path: '/',
+          resourceSizes: [
+            {
+              resourceType: 'third-party',
+              budget: 0,
+            },
+          ],
+          resourceCounts: [
+            {
+              resourceType: 'third-party',
+              budget: 0,
+            },
+          ],
+        }];
+        const result = await ResourceBudgetAudit.audit(artifacts, context);
+        expect(result.details.items[0].size).toBe(145);
+        expect(result.details.items[0].requestCount).toBe(3);
+      });
+
+      it('uses firstPartyHostnames when provided', async () => {
+        context.settings.budgets = [{
+          path: '/',
+          options: {
+            firstPartyHostnames: ['example.com', 'my-cdn.com'],
+          },
+          resourceSizes: [
+            {
+              resourceType: 'third-party',
+              budget: 0,
+            },
+          ],
+          resourceCounts: [
+            {
+              resourceType: 'third-party',
+              budget: 0,
+            },
+          ],
+        }];
+        const result = await ResourceBudgetAudit.audit(artifacts, context);
+        expect(result.details.items[0].size).toBe(120);
+        expect(result.details.items[0].requestCount).toBe(2);
+      });
+    });
+
     it('only includes rows for resource types with budgets', async () => {
       const result = await ResourceBudgetAudit.audit(artifacts, context);
       expect(result.details.items).toHaveLength(2);
@@ -134,6 +182,15 @@ describe('Performance: Resource budgets audit', () => {
   describe('budget selection', () => {
     describe('with a matching budget', () => {
       it('applies the correct budget', async () => {
+        artifacts = {
+          devtoolsLogs: {
+            defaultPass: networkRecordsToDevtoolsLog([
+              {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
+              {url: 'http://third-party.com/script.js', resourceType: 'Script', transferSize: 50},
+            ]),
+          },
+          URL: {requestedUrl: 'http://example.com/file.html', finalUrl: 'http://example.com/file.html'},
+        };
         context.settings.budgets = [{
           path: '/',
           resourceSizes: [
diff --git a/lighthouse-core/test/audits/resource-summary-test.js b/lighthouse-core/test/audits/resource-summary-test.js
index 0b745ff38..63d79c244 100644
--- a/lighthouse-core/test/audits/resource-summary-test.js
+++ b/lighthouse-core/test/audits/resource-summary-test.js
@@ -14,20 +14,21 @@ describe('Performance: Resource summary audit', () => {
   let artifacts;
   let context;
   beforeEach(() => {
-    context = {computedCache: new Map()};
+    context = {computedCache: new Map(), settings: {budgets: null}};
 
     artifacts = {
       devtoolsLogs: {
         defaultPass: networkRecordsToDevtoolsLog([
           {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
           {url: 'http://example.com/app.js', resourceType: 'Script', transferSize: 10},
+          {url: 'http://my-cdn.com/bin.js', resourceType: 'Script', transferSize: 25},
           {url: 'http://third-party.com/script.js', resourceType: 'Script', transferSize: 50},
           {url: 'http://third-party.com/file.jpg', resourceType: 'Image', transferSize: 70},
-        ])},
-      URL: {requestedUrl: 'https://example.com', finalUrl: 'https://example.com'},
+        ]),
+      },
+      URL: {requestedUrl: 'http://example.com', finalUrl: 'http://example.com'},
     };
   });
-
   it('has three table columns', async () => {
     const result = await ResourceSummaryAudit.audit(artifacts, context);
     expect(result.details.headings).toHaveLength(3);
@@ -40,7 +41,7 @@ describe('Performance: Resource summary audit', () => {
 
   it('has the correct display value', async () => {
     const result = await ResourceSummaryAudit.audit(artifacts, context);
-    expect(result.displayValue).toBeDisplayString('4 requests • 0 KB');
+    expect(result.displayValue).toBeDisplayString('5 requests • 0 KB');
   });
 
   it('includes the correct properties for each table item', async () => {
@@ -48,8 +49,8 @@ describe('Performance: Resource summary audit', () => {
     const item = result.details.items[0];
     expect(item.resourceType).toEqual('total');
     expect(item.label).toBeDisplayString('Total');
-    expect(item.requestCount).toBe(4);
-    expect(item.size).toBe(160);
+    expect(item.requestCount).toBe(5);
+    expect(item.size).toBe(185);
   });
 
   it('includes all resource types, regardless of whether page contains them', async () => {
@@ -63,6 +64,29 @@ describe('Performance: Resource summary audit', () => {
     expect(fontItem.requestCount).toBe(0);
     expect(fontItem.size).toBe(0);
   });
+  describe('third-party resource identification', () => {
+    it('is based on root domain if firstPartyHostnames is NOT set', async () => {
+      const result = await ResourceSummaryAudit.audit(artifacts, context);
+      const thirdParty = result.details.items
+        .find(item => item.resourceType === 'third-party');
+      expect(thirdParty.size).toBe(145);
+      expect(thirdParty.requestCount).toBe(3);
+    });
+
+    it('uses firstPartyHostnames if provided', async () => {
+      context.settings.budgets = [{
+        path: '/',
+        options: {
+          firstPartyHostnames: ['example.com', 'my-cdn.com'],
+        },
+      }];
+      const result = await ResourceSummaryAudit.audit(artifacts, context);
+      const thirdParty = result.details.items
+        .find(item => item.resourceType === 'third-party');
+      expect(thirdParty.size).toBe(120);
+      expect(thirdParty.requestCount).toBe(2);
+    });
+  });
 
   describe('table ordering', () => {
     it('except for the last row, it sorts items by size (descending)', async () => {
diff --git a/lighthouse-core/test/computed/origin-summary-test.js b/lighthouse-core/test/computed/origin-summary-test.js
deleted file mode 100644
index 243fcafd5..000000000
--- a/lighthouse-core/test/computed/origin-summary-test.js
+++ /dev/null
@@ -1,87 +0,0 @@
-/**
- * @license Copyright 2020 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 ComputedOriginSummary = require('../../computed/origin-summary.js');
-const assert = require('assert');
-const networkRecordsToDevtoolsLog = require('../network-records-to-devtools-log.js');
-
-/* eslint-env jest */
-
-describe('Origin summary computed', () => {
-  let artifacts;
-  let context;
-  beforeEach(() => {
-    context = {computedCache: new Map()};
-  });
-
-  it('computes statistics for the page overall', async () => {
-    artifacts = networkRecordsToDevtoolsLog([
-      {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
-      {url: 'https://foo.bar.com/script.js', resourceType: 'Script', transferSize: 50},
-      {url: 'http://filez.com/file.jpg', resourceType: 'Image', transferSize: 70},
-    ]);
-    const result = await ComputedOriginSummary.request(artifacts, context);
-    assert.equal(result['total'].count, 3);
-    assert.equal(result['total'].size, 150);
-  });
-
-  it('computes statistics for each origin', async () => {
-    artifacts = networkRecordsToDevtoolsLog([
-      {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
-      {url: 'http://foo.bar.com/script.js', resourceType: 'Script', transferSize: 50},
-      {url: 'http://foo.bar.com/file.jpg', resourceType: 'Image', transferSize: 70},
-      {url: 'https://foo.bar.com/SECURE.jpg', resourceType: 'Image', transferSize: 7},
-    ]);
-    const result = await ComputedOriginSummary.request(artifacts, context);
-    assert.equal(result['http://example.com'].count, 1);
-    assert.equal(result['http://example.com'].size, 30);
-
-    // HTTP & HTTPS version of the same domain are two different origins
-    assert.equal(result['http://foo.bar.com'].count, 2);
-    assert.equal(result['http://foo.bar.com'].size, 120);
-
-    assert.equal(result['https://foo.bar.com'].count, 1);
-    assert.equal(result['https://foo.bar.com'].size, 7);
-  });
-
-  it('computes statistics per origin rather than per domain', async () => {
-    artifacts = networkRecordsToDevtoolsLog([
-      {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
-      {url: 'https://example.com/file1.html', resourceType: 'Document', transferSize: 75},
-      {url: 'http://subdomain.example.com/another-file.html', resourceType: 'Document', transferSize: 50},
-    ]);
-
-    const result = await ComputedOriginSummary.request(artifacts, context);
-    assert.equal(result['http://example.com'].count, 1);
-    assert.equal(result['http://example.com'].size, 30);
-
-    assert.equal(result['https://example.com'].count, 1);
-    assert.equal(result['https://example.com'].size, 75);
-
-    assert.equal(result['http://subdomain.example.com'].count, 1);
-    assert.equal(result['http://subdomain.example.com'].size, 50);
-  });
-
-  /**
-   * Data URLs should be ignored to avoid double counting.
-   * The filesize of a data URL is reflected in the filesize of the resource that contains it.
-   * A data URL does not result in a separate network request.
-   */
-  it('ignores data URLs', async () => {
-    artifacts = networkRecordsToDevtoolsLog([
-      {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
-      {url: 'data:image/png;base64,iVBORw0KGgoAA', resourceType: 'Image', transferSize: 10},
-    ]);
-
-    const result = await ComputedOriginSummary.request(artifacts, context);
-    assert.equal(result['http://example.com'].count, 1);
-    assert.equal(result['http://example.com'].size, 30);
-
-    assert.equal(result['total'].count, 1);
-    assert.equal(result['total'].size, 30);
-  });
-});
diff --git a/lighthouse-core/test/computed/resource-summary-test.js b/lighthouse-core/test/computed/resource-summary-test.js
index d425f3fc9..22a3079c3 100644
--- a/lighthouse-core/test/computed/resource-summary-test.js
+++ b/lighthouse-core/test/computed/resource-summary-test.js
@@ -28,7 +28,7 @@ describe('Resource summary computed', () => {
       {url: 'http://cdn.example.com/script.js', resourceType: 'Script', transferSize: 50},
       {url: 'http://third-party.com/file.jpg', resourceType: 'Image', transferSize: 70},
     ]);
-    context = {computedCache: new Map()};
+    context = {computedCache: new Map(), settings: {budgets: null}};
   });
 
   it('includes all resource types, regardless of whether page contains them', async () => {
@@ -55,7 +55,8 @@ describe('Resource summary computed', () => {
       {url: 'http://third-party.com/another-file.html', resourceType: 'manifest', transferSize: 50},
     ];
 
-    const result = ComputedResourceSummary.summarize(networkRecords, networkRecords[0].url);
+    const result = ComputedResourceSummary.summarize(
+      networkRecords, networkRecords[0].url, context);
     assert.equal(result.other.count, 1);
     assert.equal(result.other.size, 50);
   });
@@ -71,38 +72,147 @@ describe('Resource summary computed', () => {
     assert.equal(result.total.size, 30);
   });
 
-  describe('determining third-party resources', () => {
-    it('with a third-party resource', async () => {
-      artifacts = mockArtifacts([
-        {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
-        {url: 'http://third-party.com/another-file.html', resourceType: 'Document', transferSize: 50},
-      ]);
+  it('ignores records with non-network protocols', async () => {
+    context.settings.budgets = [{
+      path: '/',
+      options: {
+        firstPartyHostnames: ['example.com'],
+      },
+    }];
 
-      const result = await ComputedResourceSummary.request(artifacts, context);
-      assert.equal(result['third-party'].count, 1);
-      assert.equal(result['third-party'].size, 50);
-    });
+    artifacts = mockArtifacts([
+      {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
+      {url: 'data:image/png;base64,iVBORw0KGgoAA', resourceType: 'Image', transferSize: 10},
+      {url: 'blob:http://www.example.com/dflskdfjlkj', resourceType: 'Other', transferSize: 99},
+      {url: 'intent://example.com', resourceType: 'Other', transferSize: 1},
+    ]);
 
-    it('with a first-party resource', async () => {
+    const result = await ComputedResourceSummary.request(artifacts, context);
+    assert.equal(result.total.count, 1);
+    assert.equal(result.total.size, 30);
+  });
+
+  describe('identifying third-party resources', () => {
+    beforeEach(() => {
       artifacts = mockArtifacts([
         {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
-        {url: 'http://example.com/another-file.html', resourceType: 'Document', transferSize: 50},
+        {url: 'http://cdn.example.com/app.js', resourceType: 'Script', transferSize: 10},
+        {url: 'http://my-cdn.com/styles.css', resourceType: 'Stylesheet', transferSize: 25},
+        {url: 'http://third-party.com/script.js', resourceType: 'Script', transferSize: 50},
+        {url: 'http://third-party.com/file.jpg', resourceType: 'Image', transferSize: 70},
       ]);
-
-      const result = await ComputedResourceSummary.request(artifacts, context);
-      assert.equal(result['third-party'].count, 0);
-      assert.equal(result['third-party'].size, 0);
+      context = {computedCache: new Map(), settings: {}};
     });
 
-    it('with a first-party resource loaded from a subdomain', async () => {
-      artifacts = mockArtifacts([
-        {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
-        {url: 'http://blog.example.com/file.html', resourceType: 'Document', transferSize: 50},
-      ]);
+    describe('when firstPartyHostnames is not set', () => {
+      it('the root domain and all subdomains are considered first-party', async () => {
+        context.settings.budgets = null;
+        const result = await ComputedResourceSummary.request(artifacts, context);
+        expect(result['third-party'].size).toBe(25 + 50 + 70);
+        expect(result['third-party'].count).toBe(3);
+      });
+
+      it('correctly identifies root domain when second-level TLDs are used,', async () => {
+        artifacts = mockArtifacts([
+          {url: 'http://shopping-mall.co.uk/file.html', resourceType: 'Document', transferSize: 30},
+          {url: 'http://es.shopping-mall.co.uk/file.html', resourceType: 'Script', transferSize: 7},
+          {url: 'http://co.uk', resourceType: 'Script', transferSize: 10},
+        ]);
+        context.settings.budgets = null;
+        const result = await ComputedResourceSummary.request(artifacts, context);
+        expect(result['third-party'].size).toBe(10);
+        expect(result['third-party'].count).toBe(1);
+      });
+    });
 
-      const result = await ComputedResourceSummary.request(artifacts, context);
-      assert.equal(result['third-party'].count, 0);
-      assert.equal(result['third-party'].size, 0);
+    describe('when firstPartyHostnames is set', () => {
+      const allResourcesSize = 30 + 10 + 25 + 50 + 70;
+      const allResourcesCount = 5;
+      it('handles subdomain hostnames correctly', async () => {
+        context.settings.budgets = [{
+          path: '/',
+          options: {
+            firstPartyHostnames: ['cdn.example.com'],
+          },
+        }];
+        const result = await ComputedResourceSummary.request(artifacts, context);
+        expect(result['third-party'].size).toBe(allResourcesSize - 10);
+        expect(result['third-party'].count).toBe(allResourcesCount - 1);
+      });
+
+      it('handles wildcard expressions correctly', async () => {
+        context.settings.budgets = [{
+          path: '/',
+          options: {
+            // Matches example.com and cdn.example.com
+            firstPartyHostnames: ['*.example.com'],
+          },
+        }];
+        const result = await ComputedResourceSummary.request(artifacts, context);
+        expect(result['third-party'].size).toBe(allResourcesSize - 30 - 10);
+        expect(result['third-party'].count).toBe(allResourcesCount - 2);
+      });
+
+      it('handles root domain hostname correctly', async () => {
+        context.settings.budgets = [{
+          path: '/',
+          options: {
+            // Matches example.com; does not match cdn.example.com
+            firstPartyHostnames: ['example.com'],
+          },
+        }];
+        const result = await ComputedResourceSummary.request(artifacts, context);
+        expect(result['third-party'].size).toBe(allResourcesSize - 30);
+        expect(result['third-party'].count).toBe(allResourcesCount - 1);
+      });
+
+      it('handles multiple hostnames correctly', async () => {
+        context.settings.budgets = [{
+          path: '/',
+          options: {
+            firstPartyHostnames: ['example.com', 'my-cdn.com'],
+          },
+        }];
+        const result = await ComputedResourceSummary.request(artifacts, context);
+        expect(result['third-party'].size).toBe(allResourcesSize - 30 - 25);
+        expect(result['third-party'].count).toBe(allResourcesCount - 2);
+      });
+
+      it('handles duplication of hostnames', async () => {
+        context.settings.budgets = [{
+          path: '/',
+          options: {
+            firstPartyHostnames: ['my-cdn.com', 'my-cdn.com', 'my-cdn.com'],
+          },
+        }];
+        const result = await ComputedResourceSummary.request(artifacts, context);
+        expect(result['third-party'].size).toBe(allResourcesSize - 25);
+        expect(result['third-party'].count).toBe(allResourcesCount - 1);
+      });
+
+      it('handles logical duplication of hostnames', async () => {
+        context.settings.budgets = [{
+          path: '/',
+          options: {
+            firstPartyHostnames: ['example.com', '*.example.com', 'cdn.example.com'],
+          },
+        }];
+        const result = await ComputedResourceSummary.request(artifacts, context);
+        expect(result['third-party'].size).toBe(allResourcesSize - 30 - 10);
+        expect(result['third-party'].count).toBe(allResourcesCount - 2);
+      });
+
+      it('handles using top-level domains as firstPartyHostnames correctly', async () => {
+        context.settings.budgets = [{
+          path: '/',
+          options: {
+            firstPartyHostnames: ['*.com'],
+          },
+        }];
+        const result = await ComputedResourceSummary.request(artifacts, context);
+        expect(result['third-party'].size).toBe(0);
+        expect(result['third-party'].count).toBe(0);
+      });
     });
   });
 });
diff --git a/lighthouse-core/test/config/budget-test.js b/lighthouse-core/test/config/budget-test.js
index c096e0ffb..02b0ae22b 100644
--- a/lighthouse-core/test/config/budget-test.js
+++ b/lighthouse-core/test/config/budget-test.js
@@ -14,6 +14,9 @@ describe('Budget', () => {
   beforeEach(() => {
     budgets = [
       {
+        options: {
+          firstPartyHostnames: ['example.com'],
+        },
         resourceSizes: [
           {
             resourceType: 'script',
@@ -61,6 +64,9 @@ describe('Budget', () => {
     const result = Budget.initializeBudget(budgets);
     assert.equal(result.length, 2);
 
+    // Sets options correctly
+    assert.equal(result[0].options.firstPartyHostnames[0], 'example.com');
+
     // Missing paths are not overwritten
     assert.equal(result[0].path, undefined);
     // Sets path correctly
@@ -131,6 +137,67 @@ describe('Budget', () => {
       assert.throws(_ => Budget.initializeBudget(budgets),
         /^Error: Invalid timings entry in budget at index 1$/);
     });
+
+    it('throws when budget contains an invalid options entry', () => {
+      budgets[0].options = 'Turtles';
+      assert.throws(_ => Budget.initializeBudget(budgets),
+        /^Error: Invalid options property in budget at index 0$/);
+    });
+  });
+
+  describe('firstPartyHostname validation', () => {
+    it('with valid inputs', () => {
+      const validHostnames = [
+        'yolo.com',
+        'so.many.subdomains.org',
+        '127.0.0.1',
+        'localhost',
+        '*.example.gov.uk',
+        '*.example.com',
+      ];
+      budgets[0].options = {firstPartyHostnames: validHostnames};
+
+      const result = Budget.initializeBudget(budgets);
+      expect(result[0].options.firstPartyHostnames).toEqual(validHostnames);
+    });
+
+    it('validates that input does not include a protocol', () => {
+      budgets[0].options = {firstPartyHostnames: ['https://yolo.com']};
+      assert.throws(_ => Budget.initializeBudget(budgets),
+        /https:\/\/yolo.com is not a valid hostname./);
+    });
+
+    it('validates that input does not include ports', () => {
+      budgets[0].options = {firstPartyHostnames: ['yolo.com:8080']};
+      assert.throws(_ => Budget.initializeBudget(budgets),
+        /yolo.com:8080 is not a valid hostname./);
+    });
+
+    it('validates that input does not include path', () => {
+      budgets[0].options = {firstPartyHostnames: ['dogs.com/fido']};
+      assert.throws(_ => Budget.initializeBudget(budgets),
+        /dogs.com\/fido is not a valid hostname./);
+    });
+
+    it('validates that input does not include a trailing slash', () => {
+      budgets[0].options = {firstPartyHostnames: ['dogs.com/']};
+      assert.throws(_ => Budget.initializeBudget(budgets),
+        /dogs.com\/ is not a valid hostname./);
+    });
+
+    describe('wild card validation', () => {
+      it('validates that a wildcard is only used once', () => {
+        budgets[0].options = {firstPartyHostnames: ['*.*.com']};
+        assert.throws(_ => Budget.initializeBudget(budgets),
+          /\*.\*.com is not a valid hostname./);
+      });
+
+      it('validates that a wildcard is only used at the start of the hostname', () => {
+        budgets[0].options = {firstPartyHostnames: ['cats.*.com']};
+        assert.throws(_ => Budget.initializeBudget(budgets),
+          /cats.\*.com is not a valid hostname./);
+      });
+    });
   });
 
   describe('resource budget validation', () => {
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index bbb30518d..6a23c2b1c 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -1400,9 +1400,9 @@
           {
             "resourceType": "total",
             "label": "Total",
-            "requestCount": 17,
+            "requestCount": 16,
             "size": 160517,
-            "countOverBudget": "7 requests",
+            "countOverBudget": "6 requests",
             "sizeOverBudget": 58117
           },
           {
@@ -1415,9 +1415,8 @@
           {
             "resourceType": "third-party",
             "label": "Third-party",
-            "requestCount": 2,
+            "requestCount": 1,
             "size": 30174,
-            "countOverBudget": "1 request",
             "sizeOverBudget": 4574
           },
           {
@@ -1431,7 +1430,7 @@
           {
             "resourceType": "image",
             "label": "Image",
-            "requestCount": 2,
+            "requestCount": 1,
             "size": 24741
           },
           {
@@ -1521,7 +1520,7 @@
       "description": "To set budgets for the quantity and size of page resources, add a budget.json file. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/budgets).",
       "score": null,
       "scoreDisplayMode": "informative",
-      "displayValue": "17 requests • 157 KB",
+      "displayValue": "16 requests • 157 KB",
       "details": {
         "type": "table",
         "headings": [
@@ -1545,7 +1544,7 @@
           {
             "resourceType": "total",
             "label": "Total",
-            "requestCount": 17,
+            "requestCount": 16,
             "size": 160517
           },
           {
@@ -1557,7 +1556,7 @@
           {
             "resourceType": "image",
             "label": "Image",
-            "requestCount": 2,
+            "requestCount": 1,
             "size": 24741
           },
           {
@@ -1593,7 +1592,7 @@
           {
             "resourceType": "third-party",
             "label": "Third-party",
-            "requestCount": 2,
+            "requestCount": 1,
             "size": 30174
           }
         ]
@@ -6003,16 +6002,10 @@
         },
         {
           "values": {
-            "count": 7
+            "count": 6
           },
           "path": "audits[performance-budget].details.items[1].countOverBudget"
         },
-        {
-          "values": {
-            "count": 1
-          },
-          "path": "audits[performance-budget].details.items[3].countOverBudget"
-        },
         {
           "values": {
             "count": 5
@@ -6079,7 +6072,7 @@
       "lighthouse-core/audits/resource-summary.js | displayValue": [
         {
           "values": {
-            "requestCount": 17,
+            "requestCount": 16,
             "byteCount": 160517
           },
           "path": "audits[resource-summary].displayValue"