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 new attribution build for debugging issues in the field #237

Merged
merged 1 commit into from Jul 14, 2022

Conversation

philipwalton
Copy link
Member

@philipwalton philipwalton commented Jul 10, 2022

Addresses #203.

This PR adds a new "attribution" build to this library. When used, it augments the Metric object with an attribution property that contains useful information to debug issues that occur in the field.

This is essentially a codification of the recommendations outlined in the post Debug Web Vitals in the field, and it also includes some additional diagnostic information like outlined in the updated Optimize LCP guide.

The API to use this library doesn't change, to get the attribution data you have to important from web-vitals/attribution rather than web-vitals:

- import {onLCP, onFID, onCLS} from 'web-vitals';
+ import {onLCP, onFID, onCLS} from 'web-vitals/attribution';

Then, when the metric callback functions are invokes, the Metric object passed to them contain a new attribution property:

interface MetricWithAttribution extends Metric {
  attribution: {[key: string]: unknown};
}

And as an example of how you might use this in code, where's how you would send data about what element was involved in the largest layout shift contributing to CLS on the page:

import {onCLS} from 'web-vitals/attribution';

onCLS((metric) => {
  navigator.sendBeacon('/analytics', JSON.stringify({
    name: metric.name,
    value: metric.value,
    id: metric.id,

    // This will be a string selector helping identifying the element that shifted.
    // For example: "html>body>main>section>h1.main-heading"
    largestShiftTarget: metric.attribution.largestShiftTarget,
  }));
});

The following type definitions gives an overview of what attribution data could be set on each object, depending on which metric is being observed.

CLS:

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.
   */
  largestShiftTarget?: string;
  /**
   * The time when the single largest layout shift contributing to the page's
   * CLS score occurred.
   */
  largestShiftTime?: DOMHighResTimeStamp;
  /**
   * The layout shift score of the single largest layout shift contributing to
   * the page's CLS score.
   */
  largestShiftValue?: number;
  /**
   * The `LayoutShiftEntry` representing the single largest layout shift
   * contributing to the page's CLS score. (Useful when you need more than just
   * `largestShiftTarget`, `largestShiftTime`, and `largestShiftValue`).
   */
  largestShiftEntry?: LayoutShift;
  /**
   * The first element source (in document order) among the `sources` list
   * of the `largestShiftEntry` object. (Also useful when you need more than
   * just `largestShiftTarget`, `largestShiftTime`, and `largestShiftValue`).
   */
  largestShiftSource?: LayoutShiftAttribution;
  /**
   * The loading state of the document at the time when the largest layout
   * shift contribution to the page's CLS score occurred (see `LoadState`
   * for details).
   */
  loadState?: LoadState;
}

FCP:

interface FCPAttribution {
  /**
   * 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).
   */
  timeToFirstByte: number;
  /**
   * The time between TTFB and the first contentful paint (FCP).
   */
  renderDelay: number;
  /**
   * The loading state of the document at the time when FCP `occurred (see
   * `LoadState` for details). Ideally, documents can paint before they finish
   * loading (e.g. the `loading` or `domInteractive` phases).
   */
  loadState: LoadState,
  /**
   * The `navigation` entry of the current page, which is useful for diagnosing
   * general page load issues.
   */
  navigationEntry?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry;
}

FID:

interface FIDAttribution {
  /**
   * A selector identifying the element that the user interacted with. This
   * element will be the `target` of the `event` dispatched.
   */
  eventTarget: string;
  /**
   * The time when the user interacted. This time will match the `timeStamp`
   * value of the `event` dispatched.
   */
  eventTime: number;
  /**
   * The `type` of the `event` dispatched from the user interaction.
   */
  eventType: string;
  /**
   * The loading state of the document at the time when the first interaction
   * occurred (see `LoadState` for details). If the first interaction occurred
   * while the document was loading and executing script (e.g. usually in the
   * `domInteractive` phase) it can result in long input delays.
   */
  loadState: LoadState;
}

INP:

interface INPAttribution {
  /**
   * A selector identifying the element that the user interacted with. This
   * element will be the `target` of the `event` dispatched.
   */
  eventTarget?: string;
  /**
   * The time when the user interacted. This time will match the `timeStamp`
   * value of the `event` dispatched.
   */
  eventTime?: number;
  /**
   * The `type` of the `event` dispatched from the user interaction.
   */
  eventType?: string;
  /**
   * The loading state of the document at the time when the interaction
   * occurred (see `LoadState` for details). If the interaction occurred
   * while the document was loading and executing script (e.g. usually in the
   * `domInteractive` phase) it can result in long delays.
   */
  loadState?: LoadState;
}

LCP:

interface LCPAttribution {
  /**
   * The element corresponding to the largest contentful paint for the page.
   */
  element?: 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
   * [Optimize LCP](https://web.dev/optimize-lcp/) for details.
   */
  timeToFirstByte: number;
  /**
   * The delta between TTFB and when the browser starts loading the LCP
   * resource (if there is one, otherwise 0). See [Optimize
   * LCP](https://web.dev/optimize-lcp/) for details.
   */
  resourceLoadDelay: number;
  /**
   * The total time it takes to load the LCP resource itself (if there is one,
   * otherwise 0). See [Optimize LCP](https://web.dev/optimize-lcp/) for
   * details.
   */
  resourceLoadTime: number;
  /**
   * The delta between when the LCP resource finishes loading until the LCP
   * element is fully rendered. See [Optimize
   * LCP](https://web.dev/optimize-lcp/) for details.
   */
  elementRenderDelay: number;
  /**
   * The `navigation` entry of the current page, which is useful for diagnosing
   * general page load issues.
   */
  navigationEntry?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry;
  /**
   * The `resource` entry for the LCP resource (if applicable), which is useful
   * for diagnosing resource load issues.
   */
  lcpResourceEntry?: PerformanceResourceTiming;
}

One downside of exposing the attribution data via a separate build is it means you either have to get attribution for all metrics or none of them—you can't just get attribution for one, e.g. onCLS(), and then use the unattributed versions of all the other metrics (well, you can, but doing so would import both versions and double the file size).

However, given the full attribution code for all metrics only adds 500 bytes (brotli'd) to the download size, I think that's worth the convenience of not changing the API. Note: in the future, if the pipeline operator comes to JavaScript, it may be worth revisiting this decision.

Another downside is there's no easy way to make the attribution build work with the base+polyfill build without massively increasing complexity (to support ESM, UMD, and IIFE versions of each build, making a base+polyfill+attribution build would double the number of builds).

Based on the analysis done in #238, in this initial PR the attribution build does not make use of the polyfill.


In addition to the above, this PR also updates this package to use type: module as well as package exports, now that they are widely supported. As a result, there are additional changes from require() to import in this PR that are not related to the above changes.

@philipwalton
Copy link
Member Author

Copy link
Member

@tunetheweb tunetheweb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love the idea of adding attribution directly to the library!

It's one of the things I always have to look up, and also means that code is likely stale and not updated for any improvements. Including it in the library means users of the library will automatically benefit from those improvements (assuming you keep the library up to date of course).

Some early comments as not gone through all the actual code changes.

+ import {onLCP, onFID, onCLS} from 'web-vitals/attribution';
```

Usage for each of the imported function is identical to the standard build, but when importing from the attribution build, the [`Metric`](#metric) object will contain an additional [`attribution`](#metricwithattribution) property.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we mention WHY not to use the attribution build all the time? Presumably it's more computation and a larger download so a waste if you won't load the attributions? This may be obvious, but perhaps no harm to be explicit here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good suggestion. Will add.

README.md Show resolved Hide resolved
src/lib/initMetric.ts Outdated Show resolved Hide resolved
@anniesullie
Copy link

Excited to see this change! I think the list of attributes for each metric is good.

Copy link
Member

@mmocny mmocny left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love this! Thanks for the awesome work here.

One overall Nit and a few comment in-line:

  • The attribution values use the term Delay and Time interchangeably. I think all of these represent durations not timestamps, but I assumed some of the "Time" attribution were timestamps because of the name differences.

Looking at the attributions, I think "Time" means "actively working" and "delay" means "waiting around", but it's a bit of a loose fit for things like TTFB.

Perhaps some more of these "Time" names should be "Delay" by those criteria, and perhaps "Time" should be renamed something like "Duration"?

Very soft opinion on that feedback.

src/attribution/onFCP.ts Outdated Show resolved Hide resolved
src/attribution/onCLS.ts Show resolved Hide resolved
src/attribution/onINP.ts Show resolved Hide resolved
src/attribution/onINP.ts Show resolved Hide resolved
@mmocny
Copy link
Member

mmocny commented Jul 13, 2022 via email

@philipwalton
Copy link
Member Author

philipwalton commented Jul 14, 2022

@mmocny @tunetheweb I believe I addressed all your feedback in 7b04ae6. If you have time to take another look, that would be great.

@mmocny
Copy link
Member

mmocny commented Jul 14, 2022

Very much LGTM!

Copy link
Member

@tunetheweb tunetheweb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

A couple of suggestions for < GA4 (since many people use this library for web-vitals-report which doesn't support GA4 yet).

README.md Outdated Show resolved Hide resolved
README.md Show resolved Hide resolved
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants