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

Export metric rating thresholds, add explicit MetricRatingThresholds type #323

Merged
merged 5 commits into from
Mar 9, 2023

Conversation

robatron
Copy link
Contributor

@robatron robatron commented Feb 26, 2023

What?

This PR exports the existing rating thresholds for all metrics, e.g.,

import {CLSThresholds} from 'web-vitals';

console.log(CLSThresholds); // [ 0.1, 0.25 ]

... and defines the existing rating thresholds format with an explicit MetricRatingThresholds type:

/**
 * The thresholds of metric's "good", "needs improvement", and "poor" ratings.
 *
 * | Metric value    | Rating              |
 * | --------------- | ------------------- |
 * | ≦ [0]           | "good"              |
 * | > [0] and ≦ [1] | "needs improvement" |
 * | > [1]           | "poor"              |
 */
export type MetricRatingThresholds = [number, number];

Why?

Exporting typed metric rating thresholds enables referencing threshold values directly, reducing potential duplication issues and improving long-term codebase maintainability for consumers 💪

Details

Exported metric rating thresholds

The thresholds of each metric's "good", "needs improvement", and "poor" ratings are made available as a top-level exports:

import {CLSThresholds, FCPThresholds, FIDThresholds, INPThresholds, LCPThresholds, TTFBThresholds} from 'web-vitals';

console.log(CLSThresholds);  // [ 0.1, 0.25 ]
console.log(FCPThresholds);  // [ 1800, 3000 ]
console.log(FIDThresholds);  // [ 100, 300 ]
console.log(INPThresholds);  // [ 200, 500 ]
console.log(LCPThresholds);  // [ 2500, 4000 ]
console.log(TTFBThresholds); // [ 800, 1800 ]

These exported metric rating thresholds are simply the existing private thresholds hoisted to module scope and exported alongside their on*() functions. E.g.,

// File: src/onCLS.ts

// Existing threshold hoisted to module scope and exported
export const CLSThresholds: MetricRatingThresholds = [0.1, 0.25];

// Existing `on*()` function
export const onCLS = (onReport: CLSReportCallback, opts?: ReportOpts) => { /* ... */}

New MetricRatingThresholds type

This new type simply defines the existing format explicitly (formerly number[]):

export type MetricRatingThresholds = [number, number];

This type represents the thresholds of metric's "good", "needs improvement", and "poor" ratings:

Metric value Rating
≦ [0] "good"
> [0] and ≦ [1] "needs improvement"
> [1] "poor"

I.e.,

  • Metric values up to and including [0] are rated "good"
  • Metric values up to and including [1] are rated "needs improvement"
  • Metric values above [1] are "poor"

@robatron robatron marked this pull request as ready for review February 26, 2023 18:40
@robatron robatron force-pushed the export-thresholds branch 2 times, most recently from f9ad55e to cb26090 Compare February 26, 2023 18:54
@robatron robatron changed the title Export metric thresholds Export metric thresholds, add explicit MetricThresholds type Feb 27, 2023
@tunetheweb
Copy link
Member

Seems a reasonable change, though it does make the built files ever so slightly bigger since we're exporting 6 new values now.

A couple of points:

  1. Can we add something to the README so this isn't a hidden function?
  2. Can we add some tests to ensure this isn't accidentally reverted or broken in future?

For the second, maybe a tests/unit.js file with the following:

import {CLSThresholds, LCPThresholds} from 'web-vitals';
import assert from 'assert';

assert.strictEqual(CLSThresholds[0], 0.1);
assert.strictEqual(CLSThresholds[1], 0.25);
assert.strictEqual(LCPThresholds[0], 2500);
assert.strictEqual(LCPThresholds[1], 4000);

I'm in two minds about putting all 12 test cases in there (two for each metric). On one hand it's a bit repetitive, but on the other it does ensure they are all exported properly so probably for the best despite it being repetitive. Besides I don't expect these thresholds to change often. WDYT?

@robatron robatron force-pushed the export-thresholds branch 3 times, most recently from 71ab89d to 86194e6 Compare February 27, 2023 17:28
@robatron
Copy link
Contributor Author

robatron commented Feb 27, 2023

README updated!

I think it's a good idea to verify all thresholds are being exported correctly even if it's a little repetitive, especially since they're not expected to change often. I added unit tests for this.

BTW, should tests be running in the CI/CD pipeline? Looks like it's currently only running lint checks.

src/onCLS.ts Outdated Show resolved Hide resolved
package.json Outdated Show resolved Hide resolved
@robatron robatron force-pushed the export-thresholds branch 2 times, most recently from 74708ef to 25105d6 Compare February 28, 2023 02:11
@robatron robatron changed the title Export metric thresholds, add explicit MetricThresholds type Export metric rating thresholds, add MetricRatingThresholds type Feb 28, 2023
@robatron robatron force-pushed the export-thresholds branch 7 times, most recently from cf576bf to 94f05d9 Compare March 1, 2023 00:51
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.

This LGTM.

@robatron could you update the initial comment of this PR since it's changed a good bit since you opened this PR. Just in case anyone comes across it later and is confused.

@brendankenny could you give this another review?

@robatron robatron changed the title Export metric rating thresholds, add MetricRatingThresholds type Add public getMetricRatingThresholds() function, add MetricRatingThresholds type Mar 1, 2023
@robatron robatron force-pushed the export-thresholds branch 2 times, most recently from 66c5435 to 3909d10 Compare March 1, 2023 19:15
@robatron
Copy link
Contributor Author

robatron commented Mar 1, 2023

could you update the initial comment of this PR since it's changed a good bit since you opened this PR

Done! Also squashed the commits (→ 3909d10) and should be ready to merge anytime 👍

@tunetheweb
Copy link
Member

could you update the initial comment of this PR since it's changed a good bit since you opened this PR
Done!

Ta!

Also squashed the commits (→ 3909d10)

FYI, for next time, we Squash and Merge, so no need to do this (and actually would prefer not to force push so easier to track changes in the PR!).

and should be ready to merge anytime 👍

Cool, would like @brendankenny 's review before merging.

@robatron
Copy link
Contributor Author

robatron commented Mar 6, 2023

Hey @brendankenny , any other changes you'd like me to make before @tunetheweb merges?

@philipwalton
Copy link
Member

@robatron sorry to jump in here at the last minute, but could you explain to me a bit more about the use case for wanting to know the metric thresholds outside of the report callback function?

I'm concerned that this is adding functionality that 99.9% of users won't need and probably shouldn't use, so I just wanted to make sure I better understand the use case.

Another implementation option, given that this likely isn't an API that most users will need, would be to create a new top-level module export that doesn't contribute to the main bundle, e.g.:

import {onLCP} from 'web-vitals';
import {LCPThreshold} from 'web-vitals/thresholds';

Potentially we could even have a generic web-vitals/meta export that could house information like this that prevents duplication but doesn't contribute to the main bundle size.

WDYT?

@robatron
Copy link
Contributor Author

robatron commented Mar 7, 2023

could you explain to me a bit more about the use case for wanting to know the metric thresholds outside of the report callback function?

Sure! Happy to explain our use cases:

We use Web Vitals thresholds in our analytics and performance monitoring systems to calculate and track degrees of Web Vitals score ratings over time. This allows us to do things like:

  • Monitor the quality of page scores within each rating and proactively address any issues before the rating changes. (E.g., if a page's Web Vitals metric rating is "good", we want to know how "good". If it's nearing "needs improvement" we need to take action before it crosses over.)
  • Set alerts to warn teams when their page's scores are getting too close to an adjacent rating
  • Report improvements or regressions in Web Vitals scores relative to rating thresholds in AB test feature results
  • Visualize Web Vitals thresholds on page health dashboards to give context to metric score trend lines

Additionally, I noticed a number of hard-coded Web Vitals thresholds on GitHub, e.g.,

While some examples could potentially be rewritten to use the Metric['rating'] from the report callback, not all would be easily replaceable. An API for Web Vitals rating thresholds would be beneficial in these cases as well 🙂

Another implementation option, given that this likely isn't an API that most users will need, would be to create a new top-level module export that doesn't contribute to the main bundle, e.g.:

import {onLCP} from 'web-vitals';
import {LCPThreshold} from 'web-vitals/thresholds';

I like it! An API for the official Web Vitals rating thresholds would be highly valuable regardless of origin. I'd be happy to update the pull request if everyone's satisfied with this solution.

@philipwalton
Copy link
Member

Ok, I thought about this a bit more, and I think having a seperate export (e.g. web-vitals/thresholds) might be overkill, and it would also add complexity to the build, so keeping the thresholds in the main bundle seems fine, but I do think we want to make sure they're as small as possible.

Also, I'm not sure the format {good: X, needsImprovement: Y} is any more clear that [X, Y], given that both of these only contain two of the three ratings and neither of them clarify whether the "good" threshold is inclusive or exclusive of X. I think {good: X, poor: Y} is slightly better but still has all of the problems I just mentioned.

Given this, and the fact that [X, Y] will product the smallest bundle size and least amount of code changes, I think I'm leaning toward that. What do others think?

And if we go this route, I think it makes the most sense for the metric modules to export the thresholds, so that folks only using one or two metrics don't pay the cost of all of the thresholds.

So I'm thinking something like:

// File: ./onCLS.js

// The thresholds are a top-level module export for each metric.
export const LCPThresholds = [2500, 4000];

// The metric function exported as it is today.
export const onLCP = () => { ... };

Also, I understand @brendankenny's concern about people modifying the thresholds, but honestly I'm not too worried about that. If we do want to prevent that from happening, we could always export Object.freeze([2500, 4000]), which would compress better than a function that returns an object. But having it as module export already prevents reassignment, and an array is exactly as mutable as the {good: X, needsImprovement: Y} object in the current changes, so my preference would be to just export the array as is.

@tunetheweb
Copy link
Member

I do think being able to export the thresholds is a good thing. And agree the Array without labels is probably sufficient.

So I'm thinking something like:

But that would be 6 exports (with quite long names), whereas a function to get the exports (based in a metric parameter) is only one export. So surely - good bit smaller?

so that folks only using one or two metrics don't pay the cost of all of the thresholds.

That cost seems pretty small to me isn’t it?

@philipwalton
Copy link
Member

But that would be 6 exports (with quite long names), whereas a function to get the exports (based in a metric parameter) is only one export. So surely - good bit smaller?

All modern bundlers will remove those exports when building application code, and assign them to a local variable (which could be minified, so in that sense they're basically free).

I just tried a build that exported all of the symbols I outlined above and it increased the brotli'd size of the final bundle by 38 bytes, so that would be the maximum size increase if pulling the build from a CDN, but if bundling it locally then it shouldn't increase it at all (I'm pretty sure).

@tunetheweb
Copy link
Member

Compression is amazing! Funny, I was just making that point (to compare compressed size of a bundle, rather than uncompressed size) today and then forgot my own advice!

OK then so back to original implementation basically @robatron . Apologies for all the iterations here and ending up back to the beginning basically!

- Replace exported Web Vitals metric thresholds with a new public
function`getMetricRatingThresholds()`
- Refactor `MetricRatingThresholds` type to be more self-explanatory
@robatron robatron changed the title Add public getMetricRatingThresholds() function, add MetricRatingThresholds type Export metric rating threshold, add explicit MetricRatingThresholds type Mar 8, 2023
@robatron
Copy link
Contributor Author

robatron commented Mar 8, 2023

Lol. Ok, back to the original implementation then! (Another reason I shouldn't have pre-squashed my commits 😆)

I unsquashed my commits, reverted the relevant code, and updated the PR title and description accordingly.

WDYT?

@brendankenny
Copy link
Member

brendankenny commented Mar 8, 2023

Also, I understand @brendankenny's concern about people modifying the thresholds, but honestly I'm not too worried about that. If we do want to prevent that from happening, we could always export Object.freeze([2500, 4000]), which would compress better than a function that returns an object. But having it as module export already prevents reassignment, and an array is exactly as mutable as the {good: X, needsImprovement: Y} object in the current changes, so my preference would be to just export the array as is.

Hi @philipwalton :)

FWIW I was thinking more like

const thresholds = [0.1, 0.25];

export const onCLS = () => {
  // use `thresholds` as before.
}

export const CLSThresholds = [...thresholds];

at which point

export const CLSThresholds = {good: thresholds[0], poor: thresholds[1]};

doesn't matter much, size-wise. This would prevent you having to consult the docs on which number is which, at least, though I do take the point that we don't really have names we use for the thresholds, and those names conflate thresholds with buckets.

@robatron
Copy link
Contributor Author

robatron commented Mar 9, 2023

FWIW I was thinking more like

Yeah, I had something similar in the previous implementation to prevent modification:

export const getMetricRatingThresholds = () => {
  try {
    // Return a copy to prevent changes
    const {good, needsImprovement} = metricRatingThresholds[metricName];
    return {good, needsImprovement};
  } catch (e) { /* ... */ }
};

@robatron robatron changed the title Export metric rating threshold, add explicit MetricRatingThresholds type Export metric rating thresholds, add explicit MetricRatingThresholds type Mar 9, 2023
Copy link
Member

@philipwalton philipwalton left a comment

Choose a reason for hiding this comment

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

LGTM. Thanks for hanging in there with us on this!

README.md Outdated Show resolved Hide resolved
@tunetheweb
Copy link
Member

OK let's merge this. Will do a release then.

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