Skip to content

Commit

Permalink
Merge pull request #244 from GoogleChrome/attribution-updates
Browse files Browse the repository at this point in the history
Add a URL property to LCP attribution
  • Loading branch information
philipwalton committed Jul 20, 2022
2 parents 6e88c75 + 1296836 commit ba47392
Show file tree
Hide file tree
Showing 9 changed files with 54 additions and 29 deletions.
5 changes: 5 additions & 0 deletions README.md
Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions src/attribution/onCLS.ts
Expand Up @@ -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[]) => {
Expand All @@ -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,
Expand All @@ -45,8 +44,9 @@ const attributeCLS = (metric: CLSMetric): CLSMetricWithAttribution => {
};
}
}
} else {
(metric as CLSMetricWithAttribution).attribution = {};
}
return Object.assign(metric, {attribution});
}

/**
Expand All @@ -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);
};
8 changes: 4 additions & 4 deletions src/attribution/onFCP.ts
Expand Up @@ -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];
Expand All @@ -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),
Expand All @@ -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()),
Expand Down
8 changes: 4 additions & 4 deletions src/attribution/onFID.ts
Expand Up @@ -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;
};
};

/**
Expand Down
8 changes: 4 additions & 4 deletions src/attribution/onINP.ts
Expand Up @@ -17,26 +17,26 @@
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)
return b.duration - a.duration || (b.processingEnd - b.processingStart) -
(a.processingEnd - a.processingStart);
})[0];

metric.attribution = {
(metric as INPMetricWithAttribution).attribution = {
eventTarget: getSelector(longestEntry.target),
eventType: longestEntry.name,
eventTime: longestEntry.startTime,
eventEntry: longestEntry,
loadState: getLoadState(longestEntry.startTime),
};
} else {
metric.attribution = {};
(metric as INPMetricWithAttribution).attribution = {};
}
};

Expand Down
22 changes: 16 additions & 6 deletions src/attribution/onLCP.ts
Expand Up @@ -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];

Expand All @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions src/attribution/onTTFB.ts
Expand Up @@ -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;
Expand All @@ -30,15 +30,15 @@ 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,
requestTime: metric.value - requestStart,
navigationEntry: navigationEntry,
};
} else {
metric.attribution = {
(metric as TTFBMetricWithAttribution).attribution = {
waitingTime: 0,
dnsTime: 0,
connectionTime: 0,
Expand Down
5 changes: 5 additions & 0 deletions src/types/lcp.ts
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion test/e2e/onLCP-test.js
Expand Up @@ -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 +
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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);
Expand Down

0 comments on commit ba47392

Please sign in to comment.