controllers/rum_controller.js

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} ... `
  }
}