BxAnalyticsVitals.php 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. <?php defined('BX_DOL') or die('hack attempt');
  2. /**
  3. * Copyright (c) UNA, Inc - https://una.io
  4. * MIT License - https://opensource.org/licenses/MIT
  5. *
  6. * @defgroup Analytics Analytics
  7. * @ingroup UnaModules
  8. *
  9. * @{
  10. */
  11. /**
  12. * Web vitals and app timings: which metrics there are, what a client may send, and the 75th
  13. * percentile from a log-scale histogram the database builds with GROUP BY (no window
  14. * functions, so it runs on every MySQL and MariaDB UNA supports).
  15. *
  16. * Timings are milliseconds. CLS is a unitless score; it is stored in thousandths.
  17. */
  18. class BxAnalyticsVitals
  19. {
  20. /**
  21. * Every metric, in the order reports show them. LCP, INP, CLS, FCP and TTFB are web vitals
  22. * of a page load; screen_load is a navigation inside a running app or a soft navigation
  23. * on the web; app_start is a native app's cold start.
  24. */
  25. const METRICS = ['lcp', 'inp', 'cls', 'fcp', 'ttfb', 'screen_load', 'app_start'];
  26. const WEB_METRICS = ['lcp', 'inp', 'cls', 'fcp', 'ttfb', 'screen_load'];
  27. const APP_METRICS = ['screen_load', 'app_start'];
  28. /**
  29. * How the page was reached: the Navigation Timing type, `soft` for a navigation inside a
  30. * single-page app or an htmx swap, `restore` for the back-forward cache.
  31. */
  32. const NAVS = ['navigate', 'reload', 'back_forward', 'prerender', 'soft', 'restore'];
  33. const MAX_MS = 120000;
  34. const MAX_CLS = 10;
  35. /**
  36. * Histogram buckets per e-fold: 20 makes a bucket 5% wide, so the interpolated
  37. * percentile is within a bucket of the exact one, and usually within 2.5%.
  38. */
  39. const BUCKETS = 20;
  40. /**
  41. * A sent value as stored, or null when it is not a number in range: whole milliseconds
  42. * up to 2 minutes, CLS from 0 to 10 in thousandths.
  43. */
  44. public static function getValue($sMetric, $mixedValue)
  45. {
  46. if(!in_array($sMetric, self::METRICS, true) || is_bool($mixedValue) || !is_numeric($mixedValue))
  47. return null;
  48. $fValue = (float)$mixedValue;
  49. if(!is_finite($fValue) || $fValue < 0)
  50. return null;
  51. if($sMetric == 'cls')
  52. return $fValue <= self::MAX_CLS ? (int)round($fValue * 1000) : null;
  53. return $fValue <= self::MAX_MS ? (int)round($fValue) : null;
  54. }
  55. /**
  56. * A stored value in the metric's own unit: CLS back from thousandths.
  57. */
  58. public static function toDisplay($sMetric, $mixedValue)
  59. {
  60. if($mixedValue === null)
  61. return null;
  62. return $sMetric == 'cls' ? round($mixedValue / 1000, 3) : (int)round($mixedValue);
  63. }
  64. /**
  65. * The format a metric's values are shown in: ms or cls.
  66. */
  67. public static function getUnit($sMetric)
  68. {
  69. return $sMetric == 'cls' ? 'cls' : 'ms';
  70. }
  71. /**
  72. * The histogram bucket of a stored value: -1 for exactly 0, else floor(ln(v) * 20). The
  73. * SQL in BxAnalyticsDb::_getVitalsBucketSql is the same expression.
  74. */
  75. public static function getBucket($iValue)
  76. {
  77. return $iValue <= 0 ? -1 : (int)floor(log($iValue) * self::BUCKETS);
  78. }
  79. /**
  80. * A percentile from histogram counts, interpolated geometrically inside the bucket it
  81. * falls in (at the middle of the ranked sample's share of the bucket); null with fewer
  82. * samples than `$iMinSamples`.
  83. *
  84. * A bucket can't tell where its values sit, so with the exact counts on each side of the
  85. * thresholds the estimate is kept on the side the exact percentile is on: a page whose
  86. * values are all 2500 ms has an LCP p75 of 2500, which is good, not 2534.
  87. *
  88. * @param array $aHistogram bucket => count
  89. * @param float $fPercentile 0.75 for p75
  90. * @param array $aSides counts of values up to the good threshold, between the two, and
  91. * above the poor threshold, or [] when unknown
  92. * @param array $aThresholds [good up to, poor above], with `$aSides`
  93. */
  94. public static function getPercentile($aHistogram, $fPercentile = 0.75, $iMinSamples = 20, $aSides = [], $aThresholds = [])
  95. {
  96. $mixedValue = self::_getPercentile($aHistogram, $fPercentile, $iMinSamples);
  97. if($mixedValue === null || count($aSides) != 3 || count($aThresholds) != 2)
  98. return $mixedValue;
  99. $fRank = $fPercentile * array_sum($aSides);
  100. if($aSides[0] >= $fRank)
  101. return min($mixedValue, (float)$aThresholds[0]);
  102. if($aSides[0] + $aSides[1] >= $fRank)
  103. return min(max($mixedValue, (float)$aThresholds[0] + 1), (float)$aThresholds[1]);
  104. return max($mixedValue, (float)$aThresholds[1] + 1);
  105. }
  106. protected static function _getPercentile($aHistogram, $fPercentile, $iMinSamples)
  107. {
  108. $iTotal = 0;
  109. foreach($aHistogram as $iCount)
  110. $iTotal += (int)$iCount;
  111. if(!$iTotal || $iTotal < $iMinSamples)
  112. return null;
  113. ksort($aHistogram, SORT_NUMERIC);
  114. $fRank = $fPercentile * $iTotal;
  115. $iBefore = 0;
  116. foreach($aHistogram as $iBucket => $iCount) {
  117. $iCount = (int)$iCount;
  118. if(!$iCount)
  119. continue;
  120. if($iBefore + $iCount >= $fRank) {
  121. if((int)$iBucket < 0)
  122. return 0.0;
  123. $fShare = min(1, max(0, ($fRank - $iBefore - 0.5) / $iCount));
  124. return exp(((int)$iBucket + $fShare) / self::BUCKETS);
  125. }
  126. $iBefore += $iCount;
  127. }
  128. // rounding left the rank past the last bucket
  129. end($aHistogram);
  130. $iLast = (int)key($aHistogram);
  131. return $iLast < 0 ? 0.0 : exp(($iLast + 1) / self::BUCKETS);
  132. }
  133. /**
  134. * good, needs_improvement or poor for a stored value.
  135. *
  136. * @param array $aThresholds [good up to, poor above]
  137. */
  138. public static function getRating($mixedValue, $aThresholds)
  139. {
  140. if($mixedValue === null)
  141. return '';
  142. if($mixedValue <= $aThresholds[0])
  143. return 'good';
  144. return $mixedValue > $aThresholds[1] ? 'poor' : 'needs_improvement';
  145. }
  146. }
  147. /** @} */