From 12968363086b5e4dd480a3dfcb4ffa2392192ae9 Mon Sep 17 00:00:00 2001 From: Philip Walton Date: Wed, 20 Jul 2022 16:44:47 -0700 Subject: [PATCH] Add a URL property to LCP attribution --- README.md | 5 +++++ src/attribution/onCLS.ts | 13 +++++++------ src/attribution/onFCP.ts | 8 ++++---- src/attribution/onFID.ts | 8 ++++---- src/attribution/onINP.ts | 8 ++++---- src/attribution/onLCP.ts | 22 ++++++++++++++++------ src/attribution/onTTFB.ts | 8 ++++---- src/types/lcp.ts | 5 +++++ test/e2e/onLCP-test.js | 6 +++++- 9 files changed, 54 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index c7933e1b..6fd0200f 100644 --- a/README.md +++ b/README.md @@ -1014,6 +1014,11 @@ interface LCPAttribution { * The element corresponding to the largest contentful paint for the page. */ element?: string, + /** + * The URL (if applicable) of the LCP image resource. If the LCP element + * is a text node, this value will not be set. + */ + url?: string, /** * The time from when the user initiates loading the page until when the * browser receives the first byte of the response (a.k.a. TTFB). See diff --git a/src/attribution/onCLS.ts b/src/attribution/onCLS.ts index 08af9c72..eed3750d 100644 --- a/src/attribution/onCLS.ts +++ b/src/attribution/onCLS.ts @@ -17,7 +17,7 @@ import {getLoadState} from '../lib/getLoadState.js'; import {getSelector} from '../lib/getSelector.js'; import {onCLS as unattributedOnCLS} from '../onCLS.js'; -import {CLSAttribution, CLSReportCallback, CLSReportCallbackWithAttribution, CLSMetric, CLSMetricWithAttribution, ReportOpts} from '../types.js'; +import {CLSReportCallback, CLSReportCallbackWithAttribution, CLSMetric, CLSMetricWithAttribution, ReportOpts} from '../types.js'; const getLargestLayoutShiftEntry = (entries: LayoutShift[]) => { @@ -28,14 +28,13 @@ const getLargestLayoutShiftSource = (sources: LayoutShiftAttribution[]) => { return sources.find((s) => s.node && s.node.nodeType === 1) || sources[0]; } -const attributeCLS = (metric: CLSMetric): CLSMetricWithAttribution => { - let attribution: CLSAttribution = {}; +const attributeCLS = (metric: CLSMetric): void => { if (metric.entries.length) { const largestEntry = getLargestLayoutShiftEntry(metric.entries); if (largestEntry && largestEntry.sources && largestEntry.sources.length) { const largestSource = getLargestLayoutShiftSource(largestEntry.sources); if (largestSource) { - attribution = { + (metric as CLSMetricWithAttribution).attribution = { largestShiftTarget: getSelector(largestSource.node), largestShiftTime: largestEntry.startTime, largestShiftValue: largestEntry.value, @@ -45,8 +44,9 @@ const attributeCLS = (metric: CLSMetric): CLSMetricWithAttribution => { }; } } + } else { + (metric as CLSMetricWithAttribution).attribution = {}; } - return Object.assign(metric, {attribution}); } /** @@ -72,6 +72,7 @@ const attributeCLS = (metric: CLSMetric): CLSMetricWithAttribution => { */ export const onCLS = (onReport: CLSReportCallbackWithAttribution, opts?: ReportOpts) => { unattributedOnCLS(((metric: CLSMetric) => { - onReport(attributeCLS(metric)); + attributeCLS(metric); + onReport(metric); }) as CLSReportCallback, opts); }; diff --git a/src/attribution/onFCP.ts b/src/attribution/onFCP.ts index d00ad120..d80cf23a 100644 --- a/src/attribution/onFCP.ts +++ b/src/attribution/onFCP.ts @@ -18,10 +18,10 @@ import {getBFCacheRestoreTime} from '../lib/bfcache.js'; import {getLoadState} from '../lib/getLoadState.js'; import {getNavigationEntry} from '../lib/getNavigationEntry.js'; import {onFCP as unattributedOnFCP} from '../onFCP.js'; -import {FCPMetricWithAttribution, FCPReportCallback, FCPReportCallbackWithAttribution, ReportOpts} from '../types.js'; +import {FCPMetric, FCPMetricWithAttribution, FCPReportCallback, FCPReportCallbackWithAttribution, ReportOpts} from '../types.js'; -const attributeFCP = (metric: FCPMetricWithAttribution): void => { +const attributeFCP = (metric: FCPMetric): void => { if (metric.entries.length) { const navigationEntry = getNavigationEntry(); const fcpEntry = metric.entries[metric.entries.length - 1]; @@ -30,7 +30,7 @@ const attributeFCP = (metric: FCPMetricWithAttribution): void => { const activationStart = navigationEntry.activationStart || 0; const ttfb = Math.max(0, navigationEntry.responseStart - activationStart); - metric.attribution = { + (metric as FCPMetricWithAttribution).attribution = { timeToFirstByte: ttfb, firstByteToFCP: metric.value - ttfb, loadState: getLoadState(metric.entries[0].startTime), @@ -40,7 +40,7 @@ const attributeFCP = (metric: FCPMetricWithAttribution): void => { } } else { // There are no entries when restored from bfcache. - metric.attribution = { + (metric as FCPMetricWithAttribution).attribution = { timeToFirstByte: 0, firstByteToFCP: metric.value, loadState: getLoadState(getBFCacheRestoreTime()), diff --git a/src/attribution/onFID.ts b/src/attribution/onFID.ts index 63144706..5f811936 100644 --- a/src/attribution/onFID.ts +++ b/src/attribution/onFID.ts @@ -17,18 +17,18 @@ import {getLoadState} from '../lib/getLoadState.js'; import {getSelector} from '../lib/getSelector.js'; import {onFID as unattributedOnFID} from '../onFID.js'; -import {FIDAttribution, FIDMetricWithAttribution, FIDReportCallback, FIDReportCallbackWithAttribution, ReportOpts} from '../types.js'; +import {FIDMetric, FIDMetricWithAttribution, FIDReportCallback, FIDReportCallbackWithAttribution, ReportOpts} from '../types.js'; -const attributeFID = (metric: FIDMetricWithAttribution): void => { +const attributeFID = (metric: FIDMetric): void => { const fidEntry = metric.entries[0]; - metric.attribution = { + (metric as FIDMetricWithAttribution).attribution = { eventTarget: getSelector(fidEntry.target), eventType: fidEntry.name, eventTime: fidEntry.startTime, eventEntry: fidEntry, loadState: getLoadState(fidEntry.startTime), - } as FIDAttribution; + }; }; /** diff --git a/src/attribution/onINP.ts b/src/attribution/onINP.ts index a47e5abe..2bfb270a 100644 --- a/src/attribution/onINP.ts +++ b/src/attribution/onINP.ts @@ -17,10 +17,10 @@ import {getLoadState} from '../lib/getLoadState.js'; import {getSelector} from '../lib/getSelector.js'; import {onINP as unattributedOnINP} from '../onINP.js'; -import {INPMetricWithAttribution, INPReportCallback, INPReportCallbackWithAttribution, ReportOpts} from '../types.js'; +import {INPMetric, INPMetricWithAttribution, INPReportCallback, INPReportCallbackWithAttribution, ReportOpts} from '../types.js'; -const attributeINP = (metric: INPMetricWithAttribution): void => { +const attributeINP = (metric: INPMetric): void => { if (metric.entries.length) { const longestEntry = metric.entries.sort((a, b) => { // Sort by: 1) duration (DESC), then 2) processing time (DESC) @@ -28,7 +28,7 @@ const attributeINP = (metric: INPMetricWithAttribution): void => { (a.processingEnd - a.processingStart); })[0]; - metric.attribution = { + (metric as INPMetricWithAttribution).attribution = { eventTarget: getSelector(longestEntry.target), eventType: longestEntry.name, eventTime: longestEntry.startTime, @@ -36,7 +36,7 @@ const attributeINP = (metric: INPMetricWithAttribution): void => { loadState: getLoadState(longestEntry.startTime), }; } else { - metric.attribution = {}; + (metric as INPMetricWithAttribution).attribution = {}; } }; diff --git a/src/attribution/onLCP.ts b/src/attribution/onLCP.ts index b697aa79..c129415b 100644 --- a/src/attribution/onLCP.ts +++ b/src/attribution/onLCP.ts @@ -14,20 +14,21 @@ * limitations under the License. */ + import {getNavigationEntry} from '../lib/getNavigationEntry.js'; import {getSelector} from '../lib/getSelector.js'; import {onLCP as unattributedOnLCP} from '../onLCP.js'; -import {LCPMetricWithAttribution, LCPReportCallback, LCPReportCallbackWithAttribution, ReportOpts} from '../types.js'; +import {LCPAttribution, LCPMetric, LCPMetricWithAttribution, LCPReportCallback, LCPReportCallbackWithAttribution, ReportOpts} from '../types.js'; -const attributeLCP = (metric: LCPMetricWithAttribution): void => { +const attributeLCP = (metric: LCPMetric) => { if (metric.entries.length) { const navigationEntry = getNavigationEntry(); if (navigationEntry) { const activationStart = navigationEntry.activationStart || 0; const lcpEntry = metric.entries[metric.entries.length - 1]; - const lcpResourceEntry = performance + const lcpResourceEntry = lcpEntry.url &&performance .getEntriesByType('resource') .filter((e) => e.name === lcpEntry.url)[0]; @@ -48,20 +49,29 @@ const attributeLCP = (metric: LCPMetricWithAttribution): void => { lcpEntry ? lcpEntry.startTime - activationStart : 0 ); - metric.attribution = { + const attribution: LCPAttribution = { element: getSelector(lcpEntry.element), timeToFirstByte: ttfb, resourceLoadDelay: lcpRequestStart - ttfb, resourceLoadTime: lcpResponseEnd - lcpRequestStart, elementRenderDelay: lcpRenderTime - lcpResponseEnd, navigationEntry, - lcpResourceEntry, lcpEntry, }; + + // Only attribution the URL and resource entry if they exist. + if (lcpEntry.url) { + attribution.url = lcpEntry.url; + } + if (lcpResourceEntry) { + attribution.lcpResourceEntry = lcpResourceEntry; + } + + (metric as LCPMetricWithAttribution).attribution = attribution; } } else { // There are no entries when restored from bfcache. - metric.attribution = { + (metric as LCPMetricWithAttribution).attribution = { timeToFirstByte: 0, resourceLoadDelay: 0, resourceLoadTime: 0, diff --git a/src/attribution/onTTFB.ts b/src/attribution/onTTFB.ts index fda25bd6..d114c046 100644 --- a/src/attribution/onTTFB.ts +++ b/src/attribution/onTTFB.ts @@ -15,10 +15,10 @@ */ import {onTTFB as unattributedOnTTFB} from '../onTTFB.js'; -import {TTFBMetricWithAttribution, TTFBReportCallback, TTFBReportCallbackWithAttribution, ReportOpts} from '../types.js'; +import {TTFBMetric, TTFBMetricWithAttribution, TTFBReportCallback, TTFBReportCallbackWithAttribution, ReportOpts} from '../types.js'; -const attributeTTFB = (metric: TTFBMetricWithAttribution): void => { +const attributeTTFB = (metric: TTFBMetric): void => { if (metric.entries.length) { const navigationEntry = metric.entries[0]; const activationStart = navigationEntry.activationStart || 0; @@ -30,7 +30,7 @@ const attributeTTFB = (metric: TTFBMetricWithAttribution): void => { const requestStart = Math.max( navigationEntry.requestStart - activationStart, 0); - metric.attribution = { + (metric as TTFBMetricWithAttribution).attribution = { waitingTime: dnsStart, dnsTime: connectStart - dnsStart, connectionTime: requestStart - connectStart, @@ -38,7 +38,7 @@ const attributeTTFB = (metric: TTFBMetricWithAttribution): void => { navigationEntry: navigationEntry, }; } else { - metric.attribution = { + (metric as TTFBMetricWithAttribution).attribution = { waitingTime: 0, dnsTime: 0, connectionTime: 0, diff --git a/src/types/lcp.ts b/src/types/lcp.ts index 61c666d3..486f735d 100644 --- a/src/types/lcp.ts +++ b/src/types/lcp.ts @@ -36,6 +36,11 @@ export interface LCPAttribution { * The element corresponding to the largest contentful paint for the page. */ element?: string, + /** + * The URL (if applicable) of the LCP image resource. If the LCP element + * is a text node, this value will not be set. + */ + url?: string, /** * The time from when the user initiates loading the page until when the * browser receives the first byte of the response (a.k.a. TTFB). See diff --git a/test/e2e/onLCP-test.js b/test/e2e/onLCP-test.js index e15af840..89e074b6 100644 --- a/test/e2e/onLCP-test.js +++ b/test/e2e/onLCP-test.js @@ -372,6 +372,7 @@ describe('onLCP()', async function() { const [lcp] = await getBeacons(); assertStandardReportsAreCorrect([lcp]); + assert(lcp.attribution.url.endsWith('/test/img/square.png?delay=500')); assert.equal(lcp.attribution.element, 'html>body>main>p>img'); assert.equal(lcp.attribution.timeToFirstByte + lcp.attribution.resourceLoadDelay + @@ -414,6 +415,7 @@ describe('onLCP()', async function() { assertStandardReportsAreCorrect([lcp]); + assert(lcp.attribution.url.endsWith('/test/img/square.png?delay=500')); assert.equal(lcp.attribution.element, 'html>body>main>p>img'); // Specifically check that resourceLoadDelay falls back to `startTime`. @@ -459,7 +461,8 @@ describe('onLCP()', async function() { const [lcp] = await getBeacons(); - assert.strictEqual(lcp.navigationType, 'prerender'); + assert(lcp.attribution.url.endsWith('/test/img/square.png?delay=500')); + assert.equal(lcp.navigationType, 'prerender'); assert.equal(lcp.attribution.element, 'html>body>main>p>img'); // Assert each individual LCP sub-part accounts for `activationStart` @@ -508,6 +511,7 @@ describe('onLCP()', async function() { const [lcp] = await getBeacons(); + assert.equal(lcp.attribution.url, undefined); assert.equal(lcp.attribution.element, 'html>body>main>h1'); assert.equal(lcp.attribution.resourceLoadDelay, 0); assert.equal(lcp.attribution.resourceLoadTime, 0);