controllers/rum_controller.js

  1. import { Controller } from "@hotwired/stimulus"
  2. import Perfume from "perfume.js"
  3. /**
  4. * @class RUMController
  5. * @classdesc Stimulus controller to track Core Vitals and display results on page.
  6. * @extends Controller
  7. */
  8. export default class extends Controller {
  9. /**
  10. * Will be set on `initialize` with the current Unix timestamp in milliseconds
  11. *
  12. * @type {Number}
  13. * @memberof RUMController
  14. */
  15. rumIdentifier
  16. /**
  17. * @property {Function} footer - targets the normal footer section
  18. * @property {Function} vitalsButton - targets the button to display CWV
  19. * @property {Function} metrics - targets the CWV section
  20. * @property {Function} lcp - targets the text result of LCP
  21. * @property {Function} fid - targets the text result of FID
  22. * @property {Function} cls - targets the text result of CLS
  23. * @memberof RUMController
  24. * @static
  25. */
  26. static targets = [ "footer", "vitalsButton", "metrics", "lcp", "fid", "cls" ]
  27. /**
  28. * @property {Object} lcp - holds LCP data eg {"data":1201.705,"vitalsScore":"good"}
  29. * @property {Object} fid - holds FID data eg {"data":1.305,"vitalsScore":"good"}
  30. * @property {Object} cls - holds CLS data eg {"data":0.0049,"vitalsScore":"good"}
  31. * @memberof RUMController
  32. * @static
  33. */
  34. static values = {
  35. lcp: Object,
  36. fid: Object,
  37. cls: Object
  38. }
  39. /**
  40. * @property {String} success - targets the CSS to use for success highlighting on border and text
  41. * @property {String} warning - targets the CSS to use for warning highlighting on border and text
  42. * @property {String} error - targets the CSS to use for error highlighting on border and text
  43. * @memberof RUMController
  44. * @static
  45. */
  46. static classes = [ "success", "warning", "error" ]
  47. /**
  48. * Initialises perfume.js only on DOMContentLoaded. The CWV's are only available on a full page
  49. * load so this prevents the initialisation on Turbo drive navigations
  50. *
  51. * @instance
  52. * @memberof RUMController
  53. * @returns {void} N/A
  54. * */
  55. initialize() {
  56. this.rumIdentifier = Date.now()
  57. // Test for presence of one Core Web Vital metric and display button if present. This is currently
  58. // a good indicator of Chromium which only support Core Web Vitals metrics
  59. if (window.LayoutShift) this.vitalsButtonTarget.style.display = "block"
  60. window.addEventListener("DOMContentLoaded", (event) => {
  61. try {
  62. new Perfume({
  63. analyticsTracker: (options) => {
  64. const { metricName, data, vitalsScore } = options
  65. switch (metricName) {
  66. case "navigationTiming":
  67. if (data && data.timeToFirstByte) {
  68. this.rumLogger("ttfb", data.timeToFirstByte)
  69. }
  70. break;
  71. case "networkInformation":
  72. if (data && data.effectiveType) {
  73. this.rumLogger("networkInfo", data.effectiveType)
  74. }
  75. break;
  76. case "fcp":
  77. this.rumLogger("fcp", data, vitalsScore)
  78. break;
  79. case "lcp":
  80. this.rumLogger("lcp", data, vitalsScore)
  81. this.lcpValue = { data, vitalsScore }
  82. break;
  83. case "fid":
  84. this.rumLogger("fid", data, vitalsScore)
  85. this.fidValue = { data, vitalsScore }
  86. break;
  87. case "cls":
  88. this.rumLogger("cls", data, vitalsScore)
  89. this.clsValue = { data, vitalsScore }
  90. break;
  91. }
  92. }
  93. })
  94. }
  95. catch(error) {}
  96. })
  97. }
  98. /**
  99. * Displays the normal footer again when the controller disconnects and hides the CWV section
  100. *
  101. * @instance
  102. * @memberof RUMController
  103. * @returns {void} N/A
  104. * */
  105. disconnect() {
  106. this.footerTarget.style.display = "block"
  107. this.metricsTarget.style.display = "none"
  108. }
  109. /**
  110. * Displays the CWV section in the footer and hides the normal footer
  111. *
  112. * @instance
  113. * @memberof RUMController
  114. * @returns {void} N/A
  115. * */
  116. reveal() {
  117. this.footerTarget.style.display = "none"
  118. this.metricsTarget.style.display = "block"
  119. if (window.fathom) fathom.trackGoal("0YWFGYIZ", 0)
  120. }
  121. /**
  122. * Sends the Real User Metric data to a Netlify background function.
  123. *
  124. * Netlify background functions will immediately return a 202 to indicate that the bckground function
  125. * has been triggered but we are not to wait for a result as the function will be queued and could
  126. * take as much as 15 mins to run.
  127. *
  128. * @instance rumLogger
  129. * @property {String} metric - string identifying the RUM metric
  130. * @property {(String|Number)} data - value associated with the metric
  131. * @property {String} [vitalsScore] - CWV reading of good, needs improvement or poor
  132. *
  133. * @memberof RUMController
  134. * @returns {void} N/A
  135. * @see visitorIsBot
  136. * @see postRumLoggerData
  137. *
  138. * @example
  139. * this.rumLogger("cls", 0, "good")
  140. * @example
  141. * this.rumLogger("networkInformation", "4g")
  142. * */
  143. rumLogger(metric, data, vitals_score = null) {
  144. const { data_float, data_string } = this.organiseDataBasedOnMetric(metric, data)
  145. const rumData = {
  146. identifier: this.rumIdentifier,
  147. path: window.location.pathname,
  148. time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone,
  149. time_stamp: new Date().toISOString(),
  150. user_agent: window.navigator.userAgent,
  151. metric,
  152. data_float,
  153. data_string,
  154. vitals_score
  155. }
  156. if (this.visitorIsBot(rumData.user_agent)) return
  157. window.pushr.log({ event: "rum", data: rumData })
  158. }
  159. /**
  160. * The rum metric could be a float or string and I want to store it appropriately in supabase so
  161. * this will populate 2 different fields depending on the metric
  162. *
  163. * @instance organiseDataBasedOnMetric
  164. * @property {String} metric - the userAgent string
  165. * @property {String|Number} data - the userAgent string
  166. *
  167. * @memberof RUMController
  168. * @returns {Object}
  169. * */
  170. organiseDataBasedOnMetric(metric, data) {
  171. if (metric === "networkInfo") return { data_float: null, data_string: data }
  172. return { data_float: data, data_string: null }
  173. }
  174. /**
  175. * Checks if the part of the user_agent matches against one of the given bot names
  176. *
  177. * @instance visitorIsBot
  178. * @property {String} userAgent - the userAgent string
  179. *
  180. * @memberof RUMController
  181. * @returns {Boolean}
  182. * */
  183. visitorIsBot(user_agent) {
  184. const botNames = [
  185. "Googlebot" ,"Bingbot", "Slurp", "DuckDuckBot", "Baiduspider", "YandexBot", "Sogou", "Exabot"
  186. ]
  187. if (botNames.some(name => user_agent.includes(name))) return true
  188. }
  189. /**
  190. * LCP value change callback which calls coreWebVitalResponse
  191. *
  192. * @instance lcpValueChanged
  193. * @memberof RUMController
  194. * @returns {void} N/A
  195. * @see coreWebVitalResponse
  196. * */
  197. lcpValueChanged() {
  198. this.coreWebVitalResponse("lcp", this.lcpValue, this.lcpTarget)
  199. }
  200. /**
  201. * FID value change callback which calls coreWebVitalResponse
  202. *
  203. * @instance fidValueChanged
  204. * @memberof RUMController
  205. * @returns {void} N/A
  206. * @see coreWebVitalResponse
  207. * */
  208. fidValueChanged() {
  209. this.coreWebVitalResponse("fid", this.fidValue, this.fidTarget)
  210. }
  211. /**
  212. * CLS value change callback which calls coreWebVitalResponse
  213. *
  214. * @instance clsValueChanged
  215. * @memberof RUMController
  216. * @returns {void} N/A
  217. * @see coreWebVitalResponse
  218. * */
  219. clsValueChanged() {
  220. this.coreWebVitalResponse("cls", this.clsValue, this.clsTarget)
  221. }
  222. /**
  223. * Returns if no value object present. This will happen on the first runs of the value change callbacks
  224. * on initial page load.
  225. *
  226. * Adds a relevent class for the alert colour.
  227. *
  228. * Replaces the `... waiting` text with the CWV score.
  229. *
  230. * @instance coreWebVitalResponse
  231. * @property {String} cwv - string identifying the CWV
  232. * @property {Object} value - CWV object with score and reading of good, needs improvement or poor
  233. * @property {String} target - string identifying the target in the DOM
  234. *
  235. * @memberof RUMController
  236. * @returns {void} N/A
  237. * @see alertColor
  238. * @see alertSubstring
  239. * @example
  240. * this.coreWebVitalResponse("cls", this.clsValue, this.clsTarget)
  241. * */
  242. coreWebVitalResponse(cwv, value, target) {
  243. if (Object.entries(value).length === 0) return
  244. target.classList.add(this.alertColor(value))
  245. const replacementContent = `${this.alertSubstring(target.innerHTML)}${value.data}`
  246. target.innerHTML = cwv === "cls" ? replacementContent : `${replacementContent}ms`
  247. }
  248. /**
  249. * CLS value change callback which calls coreWebVitalResponse
  250. *
  251. * @instance alertColor
  252. * @property {String} value - CWV rating of good, needsImprovement or poor
  253. *
  254. * @memberof RUMController
  255. * @returns {String} The Stimulus class target
  256. * @example
  257. * this.alertColor("good")
  258. * */
  259. alertColor(value) {
  260. switch (value.vitalsScore) {
  261. case "good":
  262. return this.successClass
  263. case "needsImprovement":
  264. return this.warningClass
  265. case "poor":
  266. return this.errorClass
  267. }
  268. }
  269. /**
  270. * Splits the string at `...` and returns the text on LHS with `...` on the end
  271. *
  272. * @instance alertSubstring
  273. * @property {String} currentContent - The text associated with the CWV
  274. *
  275. * @memberof RUMController
  276. * @returns {String} eg. Largest Contentful Paint ...
  277. * @example
  278. * this.alertSubstring("Largest Contentful Paint ... waiting")
  279. * */
  280. alertSubstring(currentContent) {
  281. const contentStart = currentContent.split("...")[0]
  282. return `${contentStart} ... `
  283. }
  284. }