From 157b90e14b7e44d3c520a64d3617d8bbe83ed036 Mon Sep 17 00:00:00 2001 From: Barry Pollard Date: Thu, 7 Nov 2024 16:02:36 +0000 Subject: [PATCH 01/12] Add Early Hints support --- README.md | 7 +++++++ src/attribution/onTTFB.ts | 7 +++++++ src/onTTFB.ts | 13 +++++++++---- src/types.ts | 5 ++++- src/types/ttfb.ts | 7 +++++++ test/e2e/onTTFB-test.js | 11 +++++++++++ test/views/ttfb.njk | 28 +++++++++++++++++++++++++++- 7 files changed, 72 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 55c033c1..f7064110 100644 --- a/README.md +++ b/README.md @@ -1009,6 +1009,13 @@ export interface TTFBAttribution { * processing time. */ requestDuration: number; + /** + * The total time from the first byte of the response was received. Until the + * first byte of the document was received. This will only be non-zero for + * servers using Early Hints and where browsers support sending this additional + * timing. This time is after the TTFB metric.value. + */ + documentDuration: number; /** * The `navigation` entry of the current page, which is useful for diagnosing * general page load issues. This can be used to access `serverTiming` for diff --git a/src/attribution/onTTFB.ts b/src/attribution/onTTFB.ts index 5e96b408..e9dc4477 100644 --- a/src/attribution/onTTFB.ts +++ b/src/attribution/onTTFB.ts @@ -30,6 +30,7 @@ const attributeTTFB = (metric: TTFBMetric): TTFBMetricWithAttribution => { dnsDuration: 0, connectionDuration: 0, requestDuration: 0, + documentDuration: 0, }; if (metric.entries.length) { @@ -57,6 +58,11 @@ const attributeTTFB = (metric: TTFBMetric): TTFBMetricWithAttribution => { 0, ); + // Fallback to responseStart for finalResponseHeadersStart + const finalResponseHeadersStart = + (navigationEntry.finalResponseHeadersStart ?? + navigationEntry.responseStart) - activationStart; + attribution = { waitingDuration: waitEnd, cacheDuration: dnsStart - waitEnd, @@ -69,6 +75,7 @@ const attributeTTFB = (metric: TTFBMetric): TTFBMetricWithAttribution => { // service worker controlled requests were connectStart and connectEnd // are the same. requestDuration: metric.value - connectEnd, + documentDuration: finalResponseHeadersStart - metric.value, navigationEntry: navigationEntry, }; } diff --git a/src/onTTFB.ts b/src/onTTFB.ts index 721dca0e..3e29f6b0 100644 --- a/src/onTTFB.ts +++ b/src/onTTFB.ts @@ -71,14 +71,19 @@ export const onTTFB = ( const navigationEntry = getNavigationEntry(); if (navigationEntry) { + // Form Chrome 115 until Chrome 132 (with flags), Chrome reported + // responseStart as the document bytes, rather than Early Hint bytes. + // Prefer the Early Hint bytes (firstInterimResponseStart) for + // consistency with other browers, if non-zero + const responseStart = + navigationEntry.firstInterimResponseStart || + navigationEntry.responseStart; + // The activationStart reference is used because TTFB should be // relative to page activation rather than navigation start if the // page was prerendered. But in cases where `activationStart` occurs // after the first byte is received, this time should be clamped at 0. - metric.value = Math.max( - navigationEntry.responseStart - getActivationStart(), - 0, - ); + metric.value = Math.max(responseStart - getActivationStart(), 0); metric.entries = [navigationEntry]; report(true); diff --git a/src/types.ts b/src/types.ts index 2f1e2214..fb00b6cf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,9 +53,12 @@ declare global { durationThreshold?: number; } - // https://wicg.github.io/nav-speculation/prerendering.html#performance-navigation-timing-extension interface PerformanceNavigationTiming { + // https://wicg.github.io/nav-speculation/prerendering.html#performance-navigation-timing-extension activationStart?: number; + // Early Hints support + firstInterimResponseStart?: number; + finalResponseHeadersStart?: number; } // https://wicg.github.io/event-timing/#sec-performance-event-timing diff --git a/src/types/ttfb.ts b/src/types/ttfb.ts index 3559084d..36cc8c16 100644 --- a/src/types/ttfb.ts +++ b/src/types/ttfb.ts @@ -62,6 +62,13 @@ export interface TTFBAttribution { * processing time. */ requestDuration: number; + /** + * The total time from the first byte of the response was received. Until the + * first byte of the document was received. This will only be non-zero for + * servers using Early Hints and where browsers support sending this additional + * timing. This time is after the TTFB metric.value. + */ + documentDuration: number; /** * The `navigation` entry of the current page, which is useful for diagnosing * general page load issues. This can be used to access `serverTiming` for diff --git a/test/e2e/onTTFB-test.js b/test/e2e/onTTFB-test.js index 25453043..60ac7060 100644 --- a/test/e2e/onTTFB-test.js +++ b/test/e2e/onTTFB-test.js @@ -368,6 +368,17 @@ describe('onTTFB()', async function () { assert.strictEqual(ttfb.attribution.requestDuration, 0); assert.strictEqual(ttfb.attribution.navigationEntry, undefined); }); + + it('reports the correct value for Early Hints', async function () { + await navigateTo( + '/test/ttfb?responseStart=10&earlyHintsDelay=50&attribution=1', + ); + + const ttfb = await getTTFBBeacon(); + + assert.strictEqual(ttfb.value, 10); + assert.strictEqual(ttfb.attribution.documentDuration, 50); + }); }); }); diff --git a/test/views/ttfb.njk b/test/views/ttfb.njk index e4d7c935..c77a7150 100644 --- a/test/views/ttfb.njk +++ b/test/views/ttfb.njk @@ -27,13 +27,39 @@ From 388e65ddd52e2b0b147eeaada3685011f1e10f75 Mon Sep 17 00:00:00 2001 From: Barry Pollard Date: Tue, 10 Dec 2024 12:34:06 +0000 Subject: [PATCH 09/12] Review feedback --- CHANGELOG.md | 5 ++++- src/attribution/onLCP.ts | 9 +++++---- src/onTTFB.ts | 9 +++++---- src/types.ts | 2 +- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 722cf1b7..f66bc473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,14 @@ # Changelog +### v5.0.0-rc.1 (???) + +- **[BREAKING]** Add interoperable TTFB to measure Early Hints consistently ([#566](https://github.com/GoogleChrome/web-vitals/pull/566)) + ### v5.0.0-rc.0 (2024-10-03) - **[BREAKING]** Remove the deprecated `onFID()` function ([#519](https://github.com/GoogleChrome/web-vitals/pull/519)) - **[BREAKING]** Change browser support policy to Baseline Widely Available ([#525](https://github.com/GoogleChrome/web-vitals/pull/525)) - **[BREAKING]** Sort the classes that appear in attribution selectors to reduce cardinality ([#518](https://github.com/GoogleChrome/web-vitals/pull/518)) -- **[BREAKING]** Add interoperable TTFB to measure Early Hints consistently ([#566](https://github.com/GoogleChrome/web-vitals/pull/566)) - Cap INP breakdowns to INP duration ([#528](https://github.com/GoogleChrome/web-vitals/pull/528)) - Cap LCP load duration to LCP time ([#527](https://github.com/GoogleChrome/web-vitals/pull/527)) diff --git a/src/attribution/onLCP.ts b/src/attribution/onLCP.ts index 1b3ad32f..43f03a29 100644 --- a/src/attribution/onLCP.ts +++ b/src/attribution/onLCP.ts @@ -47,10 +47,11 @@ const attributeLCP = (metric: LCPMetric): LCPMetricWithAttribution => { const ttfb = Math.max( 0, - // From Chrome 115 until, Chrome reported responseStart as the document - // bytes, rather than Early Hint bytes. Prefer the Early Hint bytes - // (firstInterimResponseStart) for consistency with other browers, if - // non-zero + // From Chrome 115 until 133, Chrome reported responseStart as the + // document bytes, rather than Early Hint bytes. Prefer the Early Hint + // bytes (firstInterimResponseStart) for consistency with other + // browers, but only if non-zero (so use || rather than ??) as zero + // indicates no early hints. navigationEntry.firstInterimResponseStart || navigationEntry.responseStart - activationStart, ); diff --git a/src/onTTFB.ts b/src/onTTFB.ts index 47e6801b..9a87febf 100644 --- a/src/onTTFB.ts +++ b/src/onTTFB.ts @@ -71,10 +71,11 @@ export const onTTFB = ( const navigationEntry = getNavigationEntry(); if (navigationEntry) { - // From Chrome 115 until, Chrome reported responseStart as the document - // bytes, rather than Early Hint bytes. Prefer the Early Hint bytes - // (firstInterimResponseStart) for consistency with other browers, if - // non-zero + // From Chrome 115 until 133, Chrome reported responseStart as the + // document bytes, rather than Early Hint bytes. Prefer the Early Hint + // bytes (firstInterimResponseStart) for consistency with other + // browers, but only if non-zero (so use || rather than ??) as zero + // indicates no early hints. const responseStart = navigationEntry.firstInterimResponseStart || navigationEntry.responseStart; diff --git a/src/types.ts b/src/types.ts index fb00b6cf..2e21636d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -56,7 +56,7 @@ declare global { interface PerformanceNavigationTiming { // https://wicg.github.io/nav-speculation/prerendering.html#performance-navigation-timing-extension activationStart?: number; - // Early Hints support + // https://w3c.github.io/resource-timing/#dom-performanceresourcetiming-firstinterimresponsestart firstInterimResponseStart?: number; finalResponseHeadersStart?: number; } From 3a068299779545b4479224d165acfe2a72701a8e Mon Sep 17 00:00:00 2001 From: Barry Pollard Date: Tue, 10 Dec 2024 12:42:06 +0000 Subject: [PATCH 10/12] Early hints testing improvements --- test/server.js | 13 +++++++++++++ test/views/ttfb.njk | 32 -------------------------------- 2 files changed, 13 insertions(+), 32 deletions(-) diff --git a/test/server.js b/test/server.js index ff666576..5d13d4b0 100644 --- a/test/server.js +++ b/test/server.js @@ -39,6 +39,19 @@ app.use((req, res, next) => { } }); +// Allow the use of a `earlyHintsDelay` query param to delay any response +// after sending an early hints +app.use((req, res, next) => { + if (req.query && req.query.earlyHintsDelay) { + res.writeEarlyHints({ + 'link': '; rel=preload; as=style', + }); + setTimeout(next, req.query.earlyHintsDelay); + } else { + next(); + } +}); + // Add a "collect" endpoint to simulate analytics beacons. app.post('/collect', bodyParser.text(), (req, res) => { // Uncomment to log the metric when manually testing. diff --git a/test/views/ttfb.njk b/test/views/ttfb.njk index 008d9f1b..f7f34651 100644 --- a/test/views/ttfb.njk +++ b/test/views/ttfb.njk @@ -43,38 +43,6 @@ // Block... } } - - if (params.has('earlyHintsDelay')) { - const earlyHintsDelay = Number(params.get('earlyHintsDelay')) - // Block for delay time—to avoid the library seeing future timestamps, - // and so not reporting a TTFB at all as it's invalid. - block(Number(params.get('earlyHintsDelay'))); - // Chrome >= 133 has both finalResponseHeadersStart and firstInterimResponseStart - if ('finalResponseHeadersStart' in PerformanceNavigationTiming.prototype) { - Object.defineProperties(navEntry, { - 'firstInterimResponseStart': { - value: Number(navEntry.responseStart), - enumerable: true, - }, - 'finalResponseHeadersStart': { - value: Number(navEntry.responseStart + earlyHintsDelay), - enumerable: true, - } - }); - } else if ('firstInterimResponseStart' in PerformanceNavigationTiming.prototype) { - // TODO: Can remove these after Chrome 133 lands and above is used. - Object.defineProperties(navEntry, { - 'firstInterimResponseStart': { - value: Number(navEntry.responseStart), - enumerable: true, - }, - 'responseStart': { - value: Number(navEntry.responseStart + earlyHintsDelay), - enumerable: true, - }, - }) - }; - }