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 support for generating custom targets in the attribution build #585

Merged
merged 15 commits into from
Jan 14, 2025
Merged
81 changes: 60 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ Measuring the Web Vitals scores for your real users is a great first step toward

The "attribution" build helps you do that by including additional diagnostic information with each metric to help you identify the root cause of poor performance as well as prioritize the most important things to fix.

The "attribution" build is slightly larger than the "standard" build (by about 600 bytes, brotli'd), so while the code size is still small, it's only recommended if you're actually using these features.
The "attribution" build is slightly larger than the "standard" build (by about 1.5K, brotli'd), so while the code size is still small, it's only recommended if you're actually using these features.
philipwalton marked this conversation as resolved.
Show resolved Hide resolved

To load the "attribution" build, change any `import` statements that reference `web-vitals` to `web-vitals/attribution`:

Expand Down Expand Up @@ -631,6 +631,35 @@ _See also [Rating Thresholds](#rating-thresholds)._
```ts
interface ReportOpts {
reportAllChanges?: boolean;
}
```

Metric-specific subclasses:

##### `INPReportOpts`

```ts
interface INPReportOpts extends ReportOpts {
durationThreshold?: number;
}
```

#### `AttributionReportOpts`

A subclass of `ReportOpts` used for each metric function exported in the [attribution build](#attribution).

```ts
interface AttributionReportOpts extends ReportOpts {
generateTarget?: (el: Node | null) => unknown;
}
```

Metric-specific subclasses:

##### `INPAttributionReportOpts`

```ts
interface INPAttributionReportOpts extends AttributionReportOpts {
durationThreshold?: number;
}
```
Expand Down Expand Up @@ -688,7 +717,10 @@ Calculates the [FCP](https://web.dev/articles/fcp) value for the current page an
#### `onINP()`

```ts
function onINP(callback: (metric: INPMetric) => void, opts?: ReportOpts): void;
function onINP(
callback: (metric: INPMetric) => void,
opts?: INPReportOpts,
): void;
```

Calculates the [INP](https://web.dev/articles/inp) 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`](https://developer.mozilla.org/docs/Web/API/DOMHighResTimeStamp).
Expand Down Expand Up @@ -758,20 +790,29 @@ console.log(LCPThresholds); // [ 2500, 4000 ]

### Attribution:

The following objects contain potentially-helpful debugging information that can be sent along with the metric values for the current page visit in order to help identify issues happening to real-users in the field.
In the [attribution build](#attribution-build) each of the metric functions has two primary differences from their standard build counterparts:

When using the attribution build, these objects are found as an `attribution` property on each metric.
1. They accept an `AttributionReportOpts` objects instead of a `ReportOpts` object. The `AttributionReportOpts` object supports an additional `generateTarget()` function that lets developers customize how DOM elements are stringified for reporting purposes. When passed, the return value `generateTarget()` function will be used for any "target" properties in the following attribution objects: [`CLSAttribution`](#CLSAttribution), [`INPAttribution`](#INPAttribution), and [`LCPAttribution`](#LCPAttribution).
philipwalton marked this conversation as resolved.
Show resolved Hide resolved

See the [attribution build](#attribution-build) section for details on how to use this feature.
```ts
interface AttributionReportOpts extends ReportOpts {
generateTarget?: (el: Node | null) => string;
}
```

2. Their callback is invoked with a `MetricWithAttribution` objects instead of a `Metric` object. Each `MetricWithAttribution` extends the `Metric` object and adds an additional `attribution` object, which contains potentially-helpful debugging information that can be sent along with the metric values for the current page visit in order to help identify issues happening to real-users in the field.

The next sections document the shape of the `attribution` object for each of the metrics:

#### `CLSAttribution`

```ts
interface CLSAttribution {
/**
* A selector identifying the first element (in document order) that
* shifted when the single largest layout shift contributing to the page's
* CLS score occurred.
* By default, a selector identifying the first element (in document order)
* that shifted when the single largest layout shift that contributed to the
* page's CLS score occurred. If the `generateTarget` configuration option
* was passed, then this will instead be the return value of that function.
*/
largestShiftTarget?: string;
/**
Expand Down Expand Up @@ -842,19 +883,14 @@ interface FCPAttribution {
```ts
interface INPAttribution {
/**
* A selector identifying the element that the user first interacted with
* as part of the frame where the INP candidate interaction occurred.
* If this value is an empty string, that generally means the element was
* removed from the DOM after the interaction.
* By default, a selector identifying the element that the user first
* interacted with as part of the frame where the INP candidate interaction
* occurred. If this value is an empty string, that generally means the
* element was removed from the DOM after the interaction. If the
* `generateTarget` configuration option was passed, then this will instead
* be the return value of that function.
*/
interactionTarget: string;
/**
* A reference to the HTML element identified by `interactionTarget`.
* NOTE: for attribution purpose, a selector identifying the element is
* typically more useful than the element itself. However, the element is
* also made available in case additional context is needed.
*/
interactionTargetElement: Node | undefined;
/**
* The time when the user first interacted during the frame where the INP
* candidate interaction occurred (if more than one interaction occurred
Expand Down Expand Up @@ -927,9 +963,12 @@ interface INPAttribution {
```ts
interface LCPAttribution {
/**
* The element corresponding to the largest contentful paint for the page.
* By default, a selector identifying the element corresponding to the
* largest contentful paint for the page. If the `generateTarget`
* configuration option was passed, then this will instead be the return
* value of that function.
*/
element?: string;
target?: string;
/**
* The URL (if applicable) of the LCP image resource. If the LCP element
* is a text node, this value will not be set.
Expand Down
87 changes: 56 additions & 31 deletions src/attribution/onCLS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@
* limitations under the License.
*/

import {LayoutShiftManager} from '../lib/LayoutShiftManager.js';
import {getLoadState} from '../lib/getLoadState.js';
import {getSelector} from '../lib/getSelector.js';
import {initUnique} from '../lib/initUnique.js';
import {onCLS as unattributedOnCLS} from '../onCLS.js';
import {
CLSAttribution,
CLSMetric,
CLSMetricWithAttribution,
ReportOpts,
AttributionReportOpts,
} from '../types.js';

const getLargestLayoutShiftEntry = (entries: LayoutShift[]) => {
Expand All @@ -32,35 +34,6 @@ const getLargestLayoutShiftSource = (sources: LayoutShiftAttribution[]) => {
return sources.find((s) => s.node?.nodeType === 1) || sources[0];
};

const attributeCLS = (metric: CLSMetric): CLSMetricWithAttribution => {
// Use an empty object if no other attribution has been set.
let attribution: CLSAttribution = {};

if (metric.entries.length) {
const largestEntry = getLargestLayoutShiftEntry(metric.entries);
if (largestEntry?.sources?.length) {
const largestSource = getLargestLayoutShiftSource(largestEntry.sources);
if (largestSource) {
attribution = {
largestShiftTarget: getSelector(largestSource.node),
largestShiftTime: largestEntry.startTime,
largestShiftValue: largestEntry.value,
largestShiftSource: largestSource,
largestShiftEntry: largestEntry,
loadState: getLoadState(largestEntry.startTime),
};
}
}
}

// Use `Object.assign()` to ensure the original metric object is returned.
const metricWithAttribution: CLSMetricWithAttribution = Object.assign(
metric,
{attribution},
);
return metricWithAttribution;
};

/**
* Calculates the [CLS](https://web.dev/articles/cls) value for the current page and
* calls the `callback` function once the value is ready to be reported, along
Expand All @@ -84,8 +57,60 @@ const attributeCLS = (metric: CLSMetric): CLSMetricWithAttribution => {
*/
export const onCLS = (
onReport: (metric: CLSMetricWithAttribution) => void,
opts: ReportOpts = {},
opts: AttributionReportOpts = {},
) => {
// Clone the opts object to ensure it's unique, so we can initialize a
// single instance of the `LayoutShiftManager` class that's shared only with
// this function invocation and the `unattributedOnCLS()` invocation below
// (which is passed the same `opts` object).
opts = Object.assign({}, opts);

const layoutShiftManager = initUnique(opts, LayoutShiftManager);
const layoutShiftTargetMap: WeakMap<LayoutShiftAttribution, string> =
new WeakMap();

layoutShiftManager._onAfterProcessingUnexpectedShift = (
entry: LayoutShift,
) => {
if (entry.sources.length) {
const largestSource = getLargestLayoutShiftSource(entry.sources);
if (largestSource) {
const generateTargetFn = opts.generateTarget ?? getSelector;
const customTarget = generateTargetFn(largestSource.node);
layoutShiftTargetMap.set(largestSource, customTarget);
}
}
};

const attributeCLS = (metric: CLSMetric): CLSMetricWithAttribution => {
// Use an empty object if no other attribution has been set.
let attribution: CLSAttribution = {};

if (metric.entries.length) {
const largestEntry = getLargestLayoutShiftEntry(metric.entries);
if (largestEntry?.sources.length) {
const largestSource = getLargestLayoutShiftSource(largestEntry.sources);
if (largestSource) {
attribution = {
largestShiftTarget: layoutShiftTargetMap.get(largestSource),
largestShiftTime: largestEntry.startTime,
largestShiftValue: largestEntry.value,
largestShiftSource: largestSource,
largestShiftEntry: largestEntry,
loadState: getLoadState(largestEntry.startTime),
};
}
}
}

// Use `Object.assign()` to ensure the original metric object is returned.
const metricWithAttribution: CLSMetricWithAttribution = Object.assign(
metric,
{attribution},
);
return metricWithAttribution;
};

unattributedOnCLS((metric: CLSMetric) => {
const metricWithAttribution = attributeCLS(metric);
onReport(metricWithAttribution);
Expand Down
4 changes: 2 additions & 2 deletions src/attribution/onFCP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
FCPAttribution,
FCPMetric,
FCPMetricWithAttribution,
ReportOpts,
AttributionReportOpts,
} from '../types.js';

const attributeFCP = (metric: FCPMetric): FCPMetricWithAttribution => {
Expand Down Expand Up @@ -67,7 +67,7 @@ const attributeFCP = (metric: FCPMetric): FCPMetricWithAttribution => {
*/
export const onFCP = (
onReport: (metric: FCPMetricWithAttribution) => void,
opts: ReportOpts = {},
opts: AttributionReportOpts = {},
) => {
unattributedOnFCP((metric: FCPMetric) => {
const metricWithAttribution = attributeFCP(metric);
Expand Down
57 changes: 18 additions & 39 deletions src/attribution/onINP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@
import {getLoadState} from '../lib/getLoadState.js';
import {getSelector} from '../lib/getSelector.js';
import {initUnique} from '../lib/initUnique.js';
import {InteractionManager} from '../lib/InteractionManager.js';
import {InteractionManager, Interaction} from '../lib/InteractionManager.js';
import {observe} from '../lib/observe.js';
import {whenIdleOrHidden} from '../lib/whenIdleOrHidden.js';
import {onINP as unattributedOnINP} from '../onINP.js';
import {
INPAttribution,
INPMetric,
INPMetricWithAttribution,
ReportOpts,
INPAttributionReportOpts,
} from '../types.js';

interface pendingEntriesGroup {
Expand Down Expand Up @@ -76,7 +76,7 @@ const MAX_PREVIOUS_FRAMES = 50;
*/
export const onINP = (
onReport: (metric: INPMetricWithAttribution) => void,
opts: ReportOpts = {},
opts: INPAttributionReportOpts = {},
) => {
// Clone the opts object to ensure it's unique, so we can initialize a
// single instance of the `InteractionManager` class that's shared only with
Expand Down Expand Up @@ -108,7 +108,7 @@ export const onINP = (
> = new WeakMap();

// A mapping of interactionIds to the target Node.
const interactionTargetMap: Map<number, Node> = new Map();
const interactionTargetMap: WeakMap<Interaction, string> = new WeakMap();

// A boolean flag indicating whether or not a cleanup task has been queued.
let cleanupPending = false;
Expand All @@ -123,15 +123,11 @@ export const onINP = (
queueCleanup();
};

// Get a reference to the interaction target element in case it's removed
// from the DOM later.
const saveInteractionTarget = (entry: PerformanceEventTiming) => {
if (
entry.interactionId &&
entry.target &&
!interactionTargetMap.has(entry.interactionId)
) {
interactionTargetMap.set(entry.interactionId, entry.target);
const saveInteractionTarget = (interaction: Interaction) => {
if (!interactionTargetMap.get(interaction)) {
const generateTargetFn = opts.generateTarget ?? getSelector;
const customTarget = generateTargetFn(interaction.entries[0].target);
interactionTargetMap.set(interaction, customTarget);
}
};

Expand Down Expand Up @@ -203,16 +199,6 @@ export const onINP = (
};

const cleanupEntries = () => {
// Delete any stored interaction target elements if they're not part of one
// of the 10 longest interactions.
if (interactionTargetMap.size > 10) {
for (const [key] of interactionTargetMap) {
if (!interactionManager._longestInteractionMap.has(key)) {
interactionTargetMap.delete(key);
}
}
}

// Keep all render times that are part of a pending INP candidate or
// that occurred within the 50 most recently-dispatched groups of events.
const longestInteractionGroups =
Expand Down Expand Up @@ -251,10 +237,8 @@ export const onINP = (
cleanupPending = false;
};

interactionManager._entryPreProcessingCallbacks.push(
saveInteractionTarget,
groupEntriesByRenderTime,
);
interactionManager._onBeforeProcessingEntry = groupEntriesByRenderTime;
interactionManager._onAfterProcessingInteraction = saveInteractionTarget;

const getIntersectingLoAFs = (
start: DOMHighResTimeStamp,
Expand Down Expand Up @@ -305,20 +289,15 @@ export const onINP = (
const longAnimationFrameEntries: PerformanceLongAnimationFrameTiming[] =
getIntersectingLoAFs(firstEntry.startTime, processingEnd);

// The first interaction entry may not have a target defined, so use the
// first one found in the entry list.
// TODO: when the following bug is fixed just use `firstInteractionEntry`.
// https://bugs.chromium.org/p/chromium/issues/detail?id=1367329
// As a fallback, also check the interactionTargetMap (to account for
// cases where the element is removed from the DOM before reporting happens).
const firstEntryWithTarget = metric.entries.find((entry) => entry.target);
const interactionTargetElement =
firstEntryWithTarget?.target ??
interactionTargetMap.get(firstEntry.interactionId);
const interaction = interactionManager._longestInteractionMap.get(
firstEntry.interactionId,
);

const attribution: INPAttribution = {
interactionTarget: getSelector(interactionTargetElement),
interactionTargetElement: interactionTargetElement,
// TS flags the next line because `interactionTargetMap.get()` might
// return `undefined`, but we ignore this assuming the user knows what
// they are doing.
interactionTarget: interactionTargetMap.get(interaction!)!,
interactionType: firstEntry.name.startsWith('key')
? 'keyboard'
: 'pointer',
Expand Down
Loading
Loading