treosh / web-vitals-reporter

Report Web Vitals to your API with one POST request per session.
MIT License
239 stars 3 forks source link

Compute overall score #4

Closed staabm closed 4 years ago

staabm commented 4 years ago

Would it be possible for this lib to compute a overall score (like the lighthouse perf score)?

I want to integrate the low level metrics into a cms, but it would help for a top level view if there was a single score at first. The user would then „drill down“ into the separate metrics

staabm commented 4 years ago

Maybe we could calculate a number similar to https://googlechrome.github.io/lighthouse/scorecalc/

alekseykulikov commented 4 years ago

web-vitals-reporter@0.2.0 adds beforeSend option, which you can use to customize final output. Use logic from lighthouse-plugin-field-performance and Lighthouse's computeLogNormal to get the final score.

Working example:

import { getCLS, getFID, getLCP } from 'web-vitals'
import { createApiReporter } from 'web-vitals-reporter'

const report = createApiReporter('/analytics', {
  beforeSend: (result) => {
    const lcpScore = computeLogNormalScore({ p10: 2500, median: 4500 }, result.LCP)
    const fidScore = computeLogNormalScore({ p10: 100, median: 300 }, result.FID || 0)
    const clsScore = computeLogNormalScore({ p10: 0.1, median: 0.25 }, result.CLS)
    const score = Math.min(lcpScore, fidScore, clsScore)
    return { lcpScore, fidScore, clsScore, score }
  },
})

getLCP(report)
getFID(report)
getCLS(report)

// helpers extracted from Lighthouse
// https://github.com/GoogleChrome/lighthouse/blob/b36b694f12b668cc64d232b8fe7c0507011bfdb7/lighthouse-core/audits/audit.js#L79

/** @param {{median: number, p10: number}} controlPoints @param {number} value */
function computeLogNormalScore(controlPoints, value) {
  const percentile = getLogNormalScore(controlPoints, value)
  return clampTo2Decimals(percentile)
}

/** @param {{median: number, p10: number}} parameters @param {number} value */
function getLogNormalScore({ median, p10 }, value) {
  // Required for the log-normal distribution.
  if (median <= 0) throw new Error('median must be greater than zero')
  if (p10 <= 0) throw new Error('p10 must be greater than zero')
  // Not required, but if p10 > median, it flips around and becomes the p90 point.
  if (p10 >= median) throw new Error('p10 must be less than the median')

  // Non-positive values aren't in the distribution, so always 1.
  if (value <= 0) return 1

  // Closest double to `erfc-1(2 * 1/10)`.
  const INVERSE_ERFC_ONE_FIFTH = 0.9061938024368232

  // Shape (σ) is `log(p10/median) / (sqrt(2)*erfc^-1(2 * 1/10))` and
  // standardizedX is `1/2 erfc(log(value/median) / (sqrt(2)*σ))`, so simplify a bit.
  const xLogRatio = Math.log(value / median)
  const p10LogRatio = -Math.log(p10 / median) // negate to keep σ positive.
  const standardizedX = (xLogRatio * INVERSE_ERFC_ONE_FIFTH) / p10LogRatio
  const complementaryPercentile = (1 - erf(standardizedX)) / 2

  // Clamp to [0, 1] to avoid any floating-point out-of-bounds issues.
  return Math.min(1, Math.max(0, complementaryPercentile))
}

/** @param {number} x */
function erf(x) {
  // erf(-x) = -erf(x);
  const sign = Math.sign(x)
  x = Math.abs(x)

  const a1 = 0.254829592
  const a2 = -0.284496736
  const a3 = 1.421413741
  const a4 = -1.453152027
  const a5 = 1.061405429
  const p = 0.3275911
  const t = 1 / (1 + p * x)
  const y = t * (a1 + t * (a2 + t * (a3 + t * (a4 + t * a5))))
  return sign * (1 - y * Math.exp(-x * x))
}

/** @param {number} val */
function clampTo2Decimals(val) {
  return Math.round(val * 100) / 100
}