Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a URL property to LCP attribution #244

Merged
merged 1 commit into from Jul 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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