Skip to content

Commit

Permalink
Add support for generating custom targets in the attribution build (#585
Browse files Browse the repository at this point in the history
)

* Move the interactions logic into a reusable class

* Rename the interactions.ts file to match symbol

* Add basic test for INP

* Add tests for LCP

* Add tests for CLS

* Add tests for FCP

* Add tests for TTFB

* Add stricter comparison tests

* Add support for generating custom targets

* Fix tests

* Update src/attribution/onCLS.ts

Co-authored-by: Barry Pollard <[email protected]>

* Switch from $ to _

* Update the README docs and types

* Update README.md

Co-authored-by: Barry Pollard <[email protected]>

---------

Co-authored-by: Barry Pollard <[email protected]>
  • Loading branch information
philipwalton and tunetheweb authored Jan 14, 2025
1 parent 06df5a7 commit dc31a21
Show file tree
Hide file tree
Showing 25 changed files with 581 additions and 261 deletions.
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.

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, optional, `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).

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

0 comments on commit dc31a21

Please sign in to comment.