import { Controller } from "@hotwired/stimulus"
import Perfume from "perfume.js"
/**
* @class RUMController
* @classdesc Stimulus controller to track Core Vitals and display results on page.
* @extends Controller
*/
export default class extends Controller {
/**
* Will be set on `initialize` with the current Unix timestamp in milliseconds
*
* @type {Number}
* @memberof RUMController
*/
rumIdentifier
/**
* @property {Function} footer - targets the normal footer section
* @property {Function} vitalsButton - targets the button to display CWV
* @property {Function} metrics - targets the CWV section
* @property {Function} lcp - targets the text result of LCP
* @property {Function} fid - targets the text result of FID
* @property {Function} cls - targets the text result of CLS
* @memberof RUMController
* @static
*/
static targets = [ "footer", "vitalsButton", "metrics", "lcp", "fid", "cls" ]
/**
* @property {Object} lcp - holds LCP data eg {"data":1201.705,"vitalsScore":"good"}
* @property {Object} fid - holds FID data eg {"data":1.305,"vitalsScore":"good"}
* @property {Object} cls - holds CLS data eg {"data":0.0049,"vitalsScore":"good"}
* @memberof RUMController
* @static
*/
static values = {
lcp: Object,
fid: Object,
cls: Object
}
/**
* @property {String} success - targets the CSS to use for success highlighting on border and text
* @property {String} warning - targets the CSS to use for warning highlighting on border and text
* @property {String} error - targets the CSS to use for error highlighting on border and text
* @memberof RUMController
* @static
*/
static classes = [ "success", "warning", "error" ]
/**
* Initialises perfume.js only on DOMContentLoaded. The CWV's are only available on a full page
* load so this prevents the initialisation on Turbo drive navigations
*
* @instance
* @memberof RUMController
* @returns {void} N/A
* */
initialize() {
this.rumIdentifier = Date.now()
// Test for presence of one Core Web Vital metric and display button if present. This is currently
// a good indicator of Chromium which only support Core Web Vitals metrics
if (window.LayoutShift) this.vitalsButtonTarget.style.display = "block"
window.addEventListener("DOMContentLoaded", (event) => {
try {
new Perfume({
analyticsTracker: (options) => {
const { metricName, data, vitalsScore } = options
switch (metricName) {
case "navigationTiming":
if (data && data.timeToFirstByte) {
this.rumLogger("ttfb", data.timeToFirstByte)
}
break;
case "networkInformation":
if (data && data.effectiveType) {
this.rumLogger("networkInfo", data.effectiveType)
}
break;
case "fcp":
this.rumLogger("fcp", data, vitalsScore)
break;
case "lcp":
this.rumLogger("lcp", data, vitalsScore)
this.lcpValue = { data, vitalsScore }
break;
case "fid":
this.rumLogger("fid", data, vitalsScore)
this.fidValue = { data, vitalsScore }
break;
case "cls":
this.rumLogger("cls", data, vitalsScore)
this.clsValue = { data, vitalsScore }
break;
}
}
})
}
catch(error) {}
})
}
/**
* Displays the normal footer again when the controller disconnects and hides the CWV section
*
* @instance
* @memberof RUMController
* @returns {void} N/A
* */
disconnect() {
this.footerTarget.style.display = "block"
this.metricsTarget.style.display = "none"
}
/**
* Displays the CWV section in the footer and hides the normal footer
*
* @instance
* @memberof RUMController
* @returns {void} N/A
* */
reveal() {
this.footerTarget.style.display = "none"
this.metricsTarget.style.display = "block"
if (window.fathom) fathom.trackGoal("0YWFGYIZ", 0)
}
/**
* Sends the Real User Metric data to a Netlify background function.
*
* Netlify background functions will immediately return a 202 to indicate that the bckground function
* has been triggered but we are not to wait for a result as the function will be queued and could
* take as much as 15 mins to run.
*
* @instance rumLogger
* @property {String} metric - string identifying the RUM metric
* @property {(String|Number)} data - value associated with the metric
* @property {String} [vitalsScore] - CWV reading of good, needs improvement or poor
*
* @memberof RUMController
* @returns {void} N/A
* @see visitorIsBot
* @see postRumLoggerData
*
* @example
* this.rumLogger("cls", 0, "good")
* @example
* this.rumLogger("networkInformation", "4g")
* */
rumLogger(metric, data, vitals_score = null) {
const { data_float, data_string } = this.organiseDataBasedOnMetric(metric, data)
const rumData = {
identifier: this.rumIdentifier,
path: window.location.pathname,
time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone,
time_stamp: new Date().toISOString(),
user_agent: window.navigator.userAgent,
metric,
data_float,
data_string,
vitals_score
}
if (this.visitorIsBot(rumData.user_agent)) return
window.pushr.log({ event: "rum", data: rumData })
}
/**
* The rum metric could be a float or string and I want to store it appropriately in supabase so
* this will populate 2 different fields depending on the metric
*
* @instance organiseDataBasedOnMetric
* @property {String} metric - the userAgent string
* @property {String|Number} data - the userAgent string
*
* @memberof RUMController
* @returns {Object}
* */
organiseDataBasedOnMetric(metric, data) {
if (metric === "networkInfo") return { data_float: null, data_string: data }
return { data_float: data, data_string: null }
}
/**
* Checks if the part of the user_agent matches against one of the given bot names
*
* @instance visitorIsBot
* @property {String} userAgent - the userAgent string
*
* @memberof RUMController
* @returns {Boolean}
* */
visitorIsBot(user_agent) {
const botNames = [
"Googlebot" ,"Bingbot", "Slurp", "DuckDuckBot", "Baiduspider", "YandexBot", "Sogou", "Exabot"
]
if (botNames.some(name => user_agent.includes(name))) return true
}
/**
* LCP value change callback which calls coreWebVitalResponse
*
* @instance lcpValueChanged
* @memberof RUMController
* @returns {void} N/A
* @see coreWebVitalResponse
* */
lcpValueChanged() {
this.coreWebVitalResponse("lcp", this.lcpValue, this.lcpTarget)
}
/**
* FID value change callback which calls coreWebVitalResponse
*
* @instance fidValueChanged
* @memberof RUMController
* @returns {void} N/A
* @see coreWebVitalResponse
* */
fidValueChanged() {
this.coreWebVitalResponse("fid", this.fidValue, this.fidTarget)
}
/**
* CLS value change callback which calls coreWebVitalResponse
*
* @instance clsValueChanged
* @memberof RUMController
* @returns {void} N/A
* @see coreWebVitalResponse
* */
clsValueChanged() {
this.coreWebVitalResponse("cls", this.clsValue, this.clsTarget)
}
/**
* Returns if no value object present. This will happen on the first runs of the value change callbacks
* on initial page load.
*
* Adds a relevent class for the alert colour.
*
* Replaces the `... waiting` text with the CWV score.
*
* @instance coreWebVitalResponse
* @property {String} cwv - string identifying the CWV
* @property {Object} value - CWV object with score and reading of good, needs improvement or poor
* @property {String} target - string identifying the target in the DOM
*
* @memberof RUMController
* @returns {void} N/A
* @see alertColor
* @see alertSubstring
* @example
* this.coreWebVitalResponse("cls", this.clsValue, this.clsTarget)
* */
coreWebVitalResponse(cwv, value, target) {
if (Object.entries(value).length === 0) return
target.classList.add(this.alertColor(value))
const replacementContent = `${this.alertSubstring(target.innerHTML)}${value.data}`
target.innerHTML = cwv === "cls" ? replacementContent : `${replacementContent}ms`
}
/**
* CLS value change callback which calls coreWebVitalResponse
*
* @instance alertColor
* @property {String} value - CWV rating of good, needsImprovement or poor
*
* @memberof RUMController
* @returns {String} The Stimulus class target
* @example
* this.alertColor("good")
* */
alertColor(value) {
switch (value.vitalsScore) {
case "good":
return this.successClass
case "needsImprovement":
return this.warningClass
case "poor":
return this.errorClass
}
}
/**
* Splits the string at `...` and returns the text on LHS with `...` on the end
*
* @instance alertSubstring
* @property {String} currentContent - The text associated with the CWV
*
* @memberof RUMController
* @returns {String} eg. Largest Contentful Paint ...
* @example
* this.alertSubstring("Largest Contentful Paint ... waiting")
* */
alertSubstring(currentContent) {
const contentStart = currentContent.split("...")[0]
return `${contentStart} ... `
}
}