-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(web-vitals): Vendor in INP from web-vitals library (#9690)
vendored in from https://github.com/GoogleChrome/web-vitals/tree/7f0ed0bfb03c356e348a558a3eda111b498a2a11 Note that this is on web vitals `v3.0.4`. We need to update to latest web vitals version - will do this in a follow up PR.
- Loading branch information
1 parent
28450ad
commit 61e9056
Showing
3 changed files
with
357 additions
and
0 deletions.
There are no files selected for viewing
215 changes: 215 additions & 0 deletions
215
packages/tracing-internal/src/browser/web-vitals/getINP.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,215 @@ | ||
/* | ||
* Copyright 2022 Google LLC | ||
* | ||
* 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 | ||
* | ||
* https://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. | ||
*/ | ||
|
||
import { bindReporter } from './lib/bindReporter'; | ||
import { initMetric } from './lib/initMetric'; | ||
import { observe } from './lib/observe'; | ||
import { onHidden } from './lib/onHidden'; | ||
import { getInteractionCount, initInteractionCountPolyfill } from './lib/polyfills/interactionCountPolyfill'; | ||
import type { ReportCallback, ReportOpts } from './types'; | ||
import type { INPMetric } from './types/inp'; | ||
|
||
interface Interaction { | ||
id: number; | ||
latency: number; | ||
entries: PerformanceEventTiming[]; | ||
} | ||
|
||
/** | ||
* Returns the interaction count since the last bfcache restore (or for the | ||
* full page lifecycle if there were no bfcache restores). | ||
*/ | ||
const getInteractionCountForNavigation = (): number => { | ||
return getInteractionCount(); | ||
}; | ||
|
||
// To prevent unnecessary memory usage on pages with lots of interactions, | ||
// store at most 10 of the longest interactions to consider as INP candidates. | ||
const MAX_INTERACTIONS_TO_CONSIDER = 10; | ||
|
||
// A list of longest interactions on the page (by latency) sorted so the | ||
// longest one is first. The list is as most MAX_INTERACTIONS_TO_CONSIDER long. | ||
const longestInteractionList: Interaction[] = []; | ||
|
||
// A mapping of longest interactions by their interaction ID. | ||
// This is used for faster lookup. | ||
const longestInteractionMap: { [interactionId: string]: Interaction } = {}; | ||
|
||
/** | ||
* Takes a performance entry and adds it to the list of worst interactions | ||
* if its duration is long enough to make it among the worst. If the | ||
* entry is part of an existing interaction, it is merged and the latency | ||
* and entries list is updated as needed. | ||
*/ | ||
const processEntry = (entry: PerformanceEventTiming): void => { | ||
// The least-long of the 10 longest interactions. | ||
const minLongestInteraction = longestInteractionList[longestInteractionList.length - 1]; | ||
|
||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion | ||
const existingInteraction = longestInteractionMap[entry.interactionId!]; | ||
|
||
// Only process the entry if it's possibly one of the ten longest, | ||
// or if it's part of an existing interaction. | ||
if ( | ||
existingInteraction || | ||
longestInteractionList.length < MAX_INTERACTIONS_TO_CONSIDER || | ||
entry.duration > minLongestInteraction.latency | ||
) { | ||
// If the interaction already exists, update it. Otherwise create one. | ||
if (existingInteraction) { | ||
existingInteraction.entries.push(entry); | ||
existingInteraction.latency = Math.max(existingInteraction.latency, entry.duration); | ||
} else { | ||
const interaction = { | ||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion | ||
id: entry.interactionId!, | ||
latency: entry.duration, | ||
entries: [entry], | ||
}; | ||
longestInteractionMap[interaction.id] = interaction; | ||
longestInteractionList.push(interaction); | ||
} | ||
|
||
// Sort the entries by latency (descending) and keep only the top ten. | ||
longestInteractionList.sort((a, b) => b.latency - a.latency); | ||
longestInteractionList.splice(MAX_INTERACTIONS_TO_CONSIDER).forEach(i => { | ||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete | ||
delete longestInteractionMap[i.id]; | ||
}); | ||
} | ||
}; | ||
|
||
/** | ||
* Returns the estimated p98 longest interaction based on the stored | ||
* interaction candidates and the interaction count for the current page. | ||
*/ | ||
const estimateP98LongestInteraction = (): Interaction => { | ||
const candidateInteractionIndex = Math.min( | ||
longestInteractionList.length - 1, | ||
Math.floor(getInteractionCountForNavigation() / 50), | ||
); | ||
|
||
return longestInteractionList[candidateInteractionIndex]; | ||
}; | ||
|
||
/** | ||
* Calculates the [INP](https://web.dev/responsiveness/) value for the current | ||
* page and calls the `callback` function once the value is ready, along with | ||
* the `event` performance entries reported for that interaction. The reported | ||
* value is a `DOMHighResTimeStamp`. | ||
* | ||
* A custom `durationThreshold` configuration option can optionally be passed to | ||
* control what `event-timing` entries are considered for INP reporting. The | ||
* default threshold is `40`, which means INP scores of less than 40 are | ||
* reported as 0. Note that this will not affect your 75th percentile INP value | ||
* unless that value is also less than 40 (well below the recommended | ||
* [good](https://web.dev/inp/#what-is-a-good-inp-score) threshold). | ||
* | ||
* If the `reportAllChanges` configuration option is set to `true`, the | ||
* `callback` function will be called as soon as the value is initially | ||
* determined as well as any time the value changes throughout the page | ||
* lifespan. | ||
* | ||
* _**Important:** INP should be continually monitored for changes throughout | ||
* the entire lifespan of a page—including if the user returns to the page after | ||
* it's been hidden/backgrounded. However, since browsers often [will not fire | ||
* additional callbacks once the user has backgrounded a | ||
* page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden), | ||
* `callback` is always called when the page's visibility state changes to | ||
* hidden. As a result, the `callback` function might be called multiple times | ||
* during the same page load._ | ||
*/ | ||
export const onINP = (onReport: ReportCallback, opts?: ReportOpts): void => { | ||
// Set defaults | ||
// eslint-disable-next-line no-param-reassign | ||
opts = opts || {}; | ||
|
||
// https://web.dev/inp/#what's-a-%22good%22-inp-value | ||
// const thresholds = [200, 500]; | ||
|
||
// TODO(philipwalton): remove once the polyfill is no longer needed. | ||
initInteractionCountPolyfill(); | ||
|
||
const metric = initMetric('INP'); | ||
// eslint-disable-next-line prefer-const | ||
let report: ReturnType<typeof bindReporter>; | ||
|
||
const handleEntries = (entries: INPMetric['entries']): void => { | ||
entries.forEach(entry => { | ||
if (entry.interactionId) { | ||
processEntry(entry); | ||
} | ||
|
||
// Entries of type `first-input` don't currently have an `interactionId`, | ||
// so to consider them in INP we have to first check that an existing | ||
// entry doesn't match the `duration` and `startTime`. | ||
// Note that this logic assumes that `event` entries are dispatched | ||
// before `first-input` entries. This is true in Chrome but it is not | ||
// true in Firefox; however, Firefox doesn't support interactionId, so | ||
// it's not an issue at the moment. | ||
// TODO(philipwalton): remove once crbug.com/1325826 is fixed. | ||
if (entry.entryType === 'first-input') { | ||
const noMatchingEntry = !longestInteractionList.some(interaction => { | ||
return interaction.entries.some(prevEntry => { | ||
return entry.duration === prevEntry.duration && entry.startTime === prevEntry.startTime; | ||
}); | ||
}); | ||
if (noMatchingEntry) { | ||
processEntry(entry); | ||
} | ||
} | ||
}); | ||
|
||
const inp = estimateP98LongestInteraction(); | ||
|
||
if (inp && inp.latency !== metric.value) { | ||
metric.value = inp.latency; | ||
metric.entries = inp.entries; | ||
report(); | ||
} | ||
}; | ||
|
||
const po = observe('event', handleEntries, { | ||
// Event Timing entries have their durations rounded to the nearest 8ms, | ||
// so a duration of 40ms would be any event that spans 2.5 or more frames | ||
// at 60Hz. This threshold is chosen to strike a balance between usefulness | ||
// and performance. Running this callback for any interaction that spans | ||
// just one or two frames is likely not worth the insight that could be | ||
// gained. | ||
durationThreshold: opts.durationThreshold || 40, | ||
} as PerformanceObserverInit); | ||
|
||
report = bindReporter(onReport, metric, opts.reportAllChanges); | ||
|
||
if (po) { | ||
// Also observe entries of type `first-input`. This is useful in cases | ||
// where the first interaction is less than the `durationThreshold`. | ||
po.observe({ type: 'first-input', buffered: true }); | ||
|
||
onHidden(() => { | ||
handleEntries(po.takeRecords() as INPMetric['entries']); | ||
|
||
// If the interaction count shows that there were interactions but | ||
// none were captured by the PerformanceObserver, report a latency of 0. | ||
if (metric.value < 0 && getInteractionCountForNavigation() > 0) { | ||
metric.value = 0; | ||
metric.entries = []; | ||
} | ||
|
||
report(true); | ||
}); | ||
} | ||
}; |
62 changes: 62 additions & 0 deletions
62
packages/tracing-internal/src/browser/web-vitals/lib/polyfills/interactionCountPolyfill.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
/* | ||
* Copyright 2022 Google LLC | ||
* | ||
* 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 | ||
* | ||
* https://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. | ||
*/ | ||
|
||
import type { Metric } from '../../types'; | ||
import { observe } from '../observe'; | ||
|
||
declare global { | ||
interface Performance { | ||
interactionCount: number; | ||
} | ||
} | ||
|
||
let interactionCountEstimate = 0; | ||
let minKnownInteractionId = Infinity; | ||
let maxKnownInteractionId = 0; | ||
|
||
const updateEstimate = (entries: Metric['entries']): void => { | ||
(entries as PerformanceEventTiming[]).forEach(e => { | ||
if (e.interactionId) { | ||
minKnownInteractionId = Math.min(minKnownInteractionId, e.interactionId); | ||
maxKnownInteractionId = Math.max(maxKnownInteractionId, e.interactionId); | ||
|
||
interactionCountEstimate = maxKnownInteractionId ? (maxKnownInteractionId - minKnownInteractionId) / 7 + 1 : 0; | ||
} | ||
}); | ||
}; | ||
|
||
let po: PerformanceObserver | undefined; | ||
|
||
/** | ||
* Returns the `interactionCount` value using the native API (if available) | ||
* or the polyfill estimate in this module. | ||
*/ | ||
export const getInteractionCount = (): number => { | ||
return po ? interactionCountEstimate : performance.interactionCount || 0; | ||
}; | ||
|
||
/** | ||
* Feature detects native support or initializes the polyfill if needed. | ||
*/ | ||
export const initInteractionCountPolyfill = (): void => { | ||
if ('interactionCount' in performance || po) return; | ||
|
||
po = observe('event', updateEstimate, { | ||
type: 'event', | ||
buffered: true, | ||
durationThreshold: 0, | ||
} as PerformanceObserverInit); | ||
}; |
80 changes: 80 additions & 0 deletions
80
packages/tracing-internal/src/browser/web-vitals/types/inp.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
/* | ||
* Copyright 2022 Google LLC | ||
* | ||
* 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 | ||
* | ||
* https://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. | ||
*/ | ||
|
||
import type { LoadState, Metric, ReportCallback } from './base'; | ||
|
||
/** | ||
* An INP-specific version of the Metric object. | ||
*/ | ||
export interface INPMetric extends Metric { | ||
name: 'INP'; | ||
entries: PerformanceEventTiming[]; | ||
} | ||
|
||
/** | ||
* An object containing potentially-helpful debugging information that | ||
* can be sent along with the INP value for the current page visit in order | ||
* to help identify issues happening to real-users in the field. | ||
*/ | ||
export interface INPAttribution { | ||
/** | ||
* A selector identifying the element that the user interacted with for | ||
* the event corresponding to INP. This element will be the `target` of the | ||
* `event` dispatched. | ||
*/ | ||
eventTarget?: string; | ||
/** | ||
* The time when the user interacted for the event corresponding to INP. | ||
* This time will match the `timeStamp` value of the `event` dispatched. | ||
*/ | ||
eventTime?: number; | ||
/** | ||
* The `type` of the `event` dispatched corresponding to INP. | ||
*/ | ||
eventType?: string; | ||
/** | ||
* The `PerformanceEventTiming` entry corresponding to INP. | ||
*/ | ||
eventEntry?: PerformanceEventTiming; | ||
/** | ||
* The loading state of the document at the time when the even corresponding | ||
* to INP occurred (see `LoadState` for details). If the interaction occurred | ||
* while the document was loading and executing script (e.g. usually in the | ||
* `dom-interactive` phase) it can result in long delays. | ||
*/ | ||
loadState?: LoadState; | ||
} | ||
|
||
/** | ||
* An INP-specific version of the Metric object with attribution. | ||
*/ | ||
export interface INPMetricWithAttribution extends INPMetric { | ||
attribution: INPAttribution; | ||
} | ||
|
||
/** | ||
* An INP-specific version of the ReportCallback function. | ||
*/ | ||
export interface INPReportCallback extends ReportCallback { | ||
(metric: INPMetric): void; | ||
} | ||
|
||
/** | ||
* An INP-specific version of the ReportCallback function with attribution. | ||
*/ | ||
export interface INPReportCallbackWithAttribution extends INPReportCallback { | ||
(metric: INPMetricWithAttribution): void; | ||
} |